diff --git a/apollo-federation/src/sources/connect/expand/mod.rs b/apollo-federation/src/sources/connect/expand/mod.rs index 80331bb7708..681086a2327 100644 --- a/apollo-federation/src/sources/connect/expand/mod.rs +++ b/apollo-federation/src/sources/connect/expand/mod.rs @@ -206,8 +206,7 @@ mod helpers { use crate::schema::position::TypeDefinitionPosition; use crate::schema::FederationSchema; use crate::schema::ValidFederationSchema; - use crate::sources::connect::json_selection::ExtractParameters; - use crate::sources::connect::json_selection::StaticParameter; + use crate::sources::connect::json_selection::KnownVariable; use crate::sources::connect::url_template::Parameter; use crate::sources::connect::ConnectSpecDefinition; use crate::sources::connect::Connector; @@ -514,6 +513,10 @@ mod helpers { } } + Parameter::Config { item: _, paths: _ } => { + // TODO Implement $config handling + } + // All sibling fields marked by $this in a transport must be carried over to the output type // regardless of its use in the output selection. Parameter::Sibling { field, paths } => { @@ -799,36 +802,69 @@ mod helpers { fn extract_params_from_body( connector: &Connector, ) -> Result, FederationError> { - let body_parameters = connector - .transport - .body - .as_ref() - .and_then(JSONSelection::extract_parameters) - .unwrap_or_default(); - - body_parameters - .iter() - .map(|StaticParameter { name, paths }| { - let mut parts = paths.iter(); - let field = parts.next().ok_or(FederationError::internal( - "expected parameter in JSONSelection to contain a field", - ))?; - - match *name { - "$args" => Ok(Parameter::Argument { - argument: field, - paths: parts.copied().collect(), - }), - "$this" => Ok(Parameter::Sibling { - field, - paths: parts.copied().collect(), - }), - other => Err(FederationError::internal(format!( - "got unsupported parameter: {other}" - ))), + let Some(body) = &connector.transport.body else { + return Ok(HashSet::default()); + }; + + use crate::sources::connect::json_selection::ExternalVarPaths; + let var_paths = body.external_var_paths(); + + let mut results = HashSet::with_capacity_and_hasher(var_paths.len(), Default::default()); + + for var_path in var_paths { + match var_path.var_name_and_nested_keys() { + Some((KnownVariable::Args, keys)) => { + let mut keys_iter = keys.into_iter(); + let first_key = keys_iter.next().ok_or(FederationError::internal( + "expected at least one key in $args", + ))?; + results.insert(Parameter::Argument { + argument: first_key, + paths: keys_iter.collect(), + }); + } + Some((KnownVariable::This, keys)) => { + let mut keys_iter = keys.into_iter(); + let first_key = keys_iter.next().ok_or(FederationError::internal( + "expected at least one key in $this", + ))?; + results.insert(Parameter::Sibling { + field: first_key, + paths: keys_iter.collect(), + }); } - }) - .collect::, _>>() + Some((KnownVariable::Config, keys)) => { + let mut keys_iter = keys.into_iter(); + let first_key = keys_iter.next().ok_or(FederationError::internal( + "expected at least one key in $config", + ))?; + results.insert(Parameter::Config { + item: first_key, + paths: keys_iter.collect(), + }); + } + // To get the safety benefits of the KnownVariable enum, we need + // to enumerate all the cases explicitly, without wildcard + // matches. However, body.external_var_paths() only returns free + // (externally-provided) variables like $this, $args, and + // $config. The $ and @ variables, by contrast, are always bound + // to something within the input data. + Some((kv @ KnownVariable::Dollar, _)) | Some((kv @ KnownVariable::AtSign, _)) => { + return Err(FederationError::internal(format!( + "got unexpected non-external variable: {:?}", + kv, + ))); + } + None => { + return Err(FederationError::internal(format!( + "could not extract variable from path: {:?}", + var_path, + ))); + } + }; + } + + Ok(results) } } diff --git a/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__nested_inputs__it_expands_supergraph-2.snap b/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__nested_inputs__it_expands_supergraph-2.snap index d7e7ec01192..62df5fe7a86 100644 --- a/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__nested_inputs__it_expands_supergraph-2.snap +++ b/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__nested_inputs__it_expands_supergraph-2.snap @@ -78,10 +78,12 @@ expression: connectors.by_service_name body: None, }, selection: Path( - Var( - "$", - Empty, - ), + PathSelection { + path: Var( + $, + Empty, + ), + }, ), config: None, max_requests: None, diff --git a/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__realistic__it_expands_supergraph-2.snap b/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__realistic__it_expands_supergraph-2.snap index 7c7c332ec50..ef0380e2fb2 100644 --- a/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__realistic__it_expands_supergraph-2.snap +++ b/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__realistic__it_expands_supergraph-2.snap @@ -225,15 +225,17 @@ expression: connectors.by_service_name Alias { name: "emailDomain", }, - Var( - "$args", - Key( - Field( - "email", + PathSelection { + path: Var( + $args, + Key( + Field( + "email", + ), + Empty, ), - Empty, ), - ), + }, ), ], star: None, diff --git a/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__simple__it_expands_supergraph-2.snap b/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__simple__it_expands_supergraph-2.snap index f9e658e089a..d297ef82eba 100644 --- a/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__simple__it_expands_supergraph-2.snap +++ b/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__simple__it_expands_supergraph-2.snap @@ -209,15 +209,17 @@ expression: connectors.by_service_name Alias { name: "with_b", }, - Var( - "$this", - Key( - Field( - "b", + PathSelection { + path: Var( + $this, + Key( + Field( + "b", + ), + Empty, ), - Empty, ), - ), + }, ), ], star: None, @@ -226,10 +228,12 @@ expression: connectors.by_service_name ), }, selection: Path( - Var( - "$", - Empty, - ), + PathSelection { + path: Var( + $, + Empty, + ), + }, ), config: None, max_requests: None, diff --git a/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__steelthread__it_expands_supergraph-2.snap b/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__steelthread__it_expands_supergraph-2.snap index 84f4e833ac7..aa220577787 100644 --- a/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__steelthread__it_expands_supergraph-2.snap +++ b/apollo-federation/src/sources/connect/expand/tests/snapshots/apollo_federation__sources__connect__expand__tests__steelthread__it_expands_supergraph-2.snap @@ -219,15 +219,17 @@ expression: connectors.by_service_name body: None, }, selection: Path( - Var( - "$", - Key( - Field( - "phone", + PathSelection { + path: Var( + $, + Key( + Field( + "phone", + ), + Empty, ), - Empty, ), - ), + }, ), config: None, max_requests: None, diff --git a/apollo-federation/src/sources/connect/json_selection/README.md b/apollo-federation/src/sources/connect/json_selection/README.md index f194782395a..8941b58a4b1 100644 --- a/apollo-federation/src/sources/connect/json_selection/README.md +++ b/apollo-federation/src/sources/connect/json_selection/README.md @@ -55,7 +55,16 @@ improvements, we should adhere to the following principles: avoided because it limits the developer's ability to subselect fields of the opaque `JSON` value in GraphQL operations. -3. Backwards compatibility should be maintained as we release new versions of +3. `JSONSelection` syntax may be _subsetted_ arbitrarily, either by generating a + reduced `JSONSelection` that serves the needs of a particular GraphQL + operation, or by skipping unneeded selections during `ApplyTo` execution. + When this subsetting happens, it would be highly undesirable for the behavior + of the remaining selections to change unexpectedly. Equivalently, but in the + other direction, `JSONSelection` syntax should always be _composable_, in the + sense that two `NamedSelection` items should continue to work as before when + used together in the same `SubSelection`. + +4. Backwards compatibility should be maintained as we release new versions of the `JSONSelection` syntax along with new versions of the (forthcoming) `@link(url: "https://specs.apollo.dev/connect/vX.Y")` specification. Wherever possible, we should only add new functionality, not remove or change existing @@ -78,28 +87,28 @@ SubSelection ::= "{" NakedSubSelection "}" NamedSelection ::= NamedPathSelection | NamedFieldSelection | NamedQuotedSelection | NamedGroupSelection NamedPathSelection ::= Alias PathSelection NamedFieldSelection ::= Alias? Identifier SubSelection? -NamedQuotedSelection ::= Alias StringLiteral SubSelection? +NamedQuotedSelection ::= Alias LitString SubSelection? NamedGroupSelection ::= Alias SubSelection Alias ::= Identifier ":" -PathSelection ::= (VarPath | KeyPath) SubSelection? +PathSelection ::= (VarPath | KeyPath | AtPath) SubSelection? VarPath ::= "$" (NO_SPACE Identifier)? PathStep* KeyPath ::= Key PathStep+ +AtPath ::= "@" PathStep* PathStep ::= "." Key | "->" Identifier MethodArgs? -Key ::= Identifier | StringLiteral +Key ::= Identifier | LitString Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* -StringLiteral ::= "'" ("\\'" | [^'])* "'" | '"' ('\\"' | [^"])* '"' -MethodArgs ::= "(" (JSLiteral ("," JSLiteral)*)? ")" -JSLiteral ::= JSPrimitive | JSObject | JSArray | PathSelection -JSPrimitive ::= StringLiteral | JSNumber | "true" | "false" | "null" -JSNumber ::= "-"? (UnsignedInt ("." [0-9]*)? | "." [0-9]+) -UnsignedInt ::= "0" | [1-9] NO_SPACE [0-9]* -JSObject ::= "{" (JSProperty ("," JSProperty)*)? "}" -JSProperty ::= Key ":" JSLiteral -JSArray ::= "[" (JSLiteral ("," JSLiteral)*)? "]" +MethodArgs ::= "(" (LitExpr ("," LitExpr)* ","?)? ")" +LitExpr ::= LitPrimitive | LitObject | LitArray | PathSelection +LitPrimitive ::= LitString | LitNumber | "true" | "false" | "null" +LitString ::= "'" ("\\'" | [^'])* "'" | '"' ('\\"' | [^"])* '"' +LitNumber ::= "-"? ([0-9]+ ("." [0-9]*)? | "." [0-9]+) +LitObject ::= "{" (LitProperty ("," LitProperty)* ","?)? "}" +LitProperty ::= Key ":" LitExpr +LitArray ::= "[" (LitExpr ("," LitExpr)* ","?)? "]" StarSelection ::= Alias? "*" SubSelection? NO_SPACE ::= !SpacesOrComments SpacesOrComments ::= (Spaces | Comment)+ -Spaces ::= (" " | "\t" | "\r" | "\n")+ +Spaces ::= ("⎵" | "\t" | "\r" | "\n")+ Comment ::= "#" [^\n]* ``` @@ -153,13 +162,12 @@ in a few key places: ```ebnf VarPath ::= "$" (NO_SPACE Identifier)? PathStep* Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* -UnsignedInt ::= "0" | [1-9] NO_SPACE [0-9]* ``` These rules mean the `$` of a `$variable` cannot be separated from the identifier part (so `$ var` is invalid), and the first character of a -multi-character `Identifier` or `UnsignedInt` must not be separated from the -remaining characters. +multi-character `Identifier` must not be separated from the remaining +characters. Make sure you use `spaces_or_comments` generously when modifying or adding to the grammar implementation, or parsing may fail in cryptic ways when the input @@ -170,7 +178,7 @@ contains seemingly harmless whitespace or comment characters. Since the `JSONSelection` syntax is meant to be embedded within GraphQL string literals, and GraphQL shares the same `'...'` and `"..."` string literal syntax as `JSONSelection`, it can be visually confusing to embed a `JSONSelection` -string literal (denoted by the `StringLiteral` non-terminal) within a GraphQL +string literal (denoted by the `LitString` non-terminal) within a GraphQL string. Fortunately, GraphQL also supports multi-line string literals, delimited by @@ -385,7 +393,7 @@ A `PathSelection` is a `VarPath` or `KeyPath` followed by an optional anonymous value from the input JSON, without preserving the nested structure of the keys along the path. -Since properties along the path may be either `Identifier` or `StringLiteral` +Since properties along the path may be either `Identifier` or `LitString` values, you are not limited to selecting only properties that are valid GraphQL field names, e.g. `myID: people."Ben Newman".id`. This is a slight departure from JavaScript syntax, which would use `people["Ben Newman"].id` to achieve the @@ -472,8 +480,7 @@ type User @key(fields: "id") { ``` In addition to variables like `$this` and `$args`, a special `$` variable is -always bound to the current value being processed, which allows you to transform -input data that looks like this +always bound to the value received by the closest enclosing `SubSelection`, which allows you to transform input data that looks like this ```json { @@ -560,6 +567,52 @@ character (so `.data { id name }`, or even `.data.id` or `.data.name`) to mean the same thing as `$.`, but this is no longer recommended, since `.data` is easy to mistype and misread, compared to `$.data`. +### `AtPath ::=` + +![AtPath](./grammar/AtPath.svg) + +Similar to the special `$` variable, the `@` character always represents the +current value being processed, which is often equal to `$`, but may differ from +the `$` variable when `@` is used within the arguments of `->` methods. + +For example, when you want to compute the logical conjunction of several +properties of the current object, you can keep using `$` with different property +selections: + +```graphql +all: $.first->and($.second)->and($.third) +``` + +If the `$` variable were rebound to the input value received by the `->and` +method, this style of method chaining would not work, because the `$.second` +expression would attempt to select a `second` property from the value of +`$.first`. Instead, the `$` remains bound to the same value received by the +closest enclosing `{...}` selection set, or the root value when used at the top +level of a `JSONSelection`. + +The `@` character becomes useful when you need to refer to the input value +received by a `->` method, as when using the `->echo` method to wrap a given +input value: + +```graphql +wrapped: field->echo({ fieldValue: @ }) +children: parent->echo([@.child1, @.child2, @.child3]) +``` + +The `->map` method has the special ability to apply its argument to each element +of its input array, so `@` will take on the value of each of those elements, +rather than referring to the array itself: + +```graphql +doubled: numbers->map({ value: @->mul(2) }) +types: values->map(@->typeof) +``` + +This special behavior of `@` within `->map` is available to any method +implementation, since method arguments are not evaluated before calling the +method, but are passed in as expressions that the method may choose to evaluate +(or even repeatedly reevaluate) however it chooses. + ### `PathStep ::=` ![PathStep](./grammar/PathStep.svg) @@ -567,32 +620,93 @@ to mistype and misread, compared to `$.data`. A `PathStep` is a single step along a `VarPath` or `KeyPath`, which can either select a nested key using `.` or invoke a method using `->`. -Keys selected using `.` can be either `Identifier` or `StringLiteral` names, but +Keys selected using `.` can be either `Identifier` or `LitString` names, but method names invoked using `->` must be `Identifier` names, and must be registered in the `JSONSelection` parser in order to be recognized. For the time being, only a fixed set of known methods are supported, though this list may grow and/or become user-configurable in the future: -> Full disclosure: even this list is still aspirational, but suggestive of the -> kinds of methods that are likely to be supported in the next version of the -> `JSONSelection` parser. - ```graphql -list->first { id name } -list->last.name -list->slice($args.start, $args.end) -list->reverse -some.value->times(2) -some.value->plus($addend) -some.value->minus(100) -some.value->div($divisor) -isDog: kind->eq("dog") -isNotCat: kind->neq("cat") -__typename: kind->match({ "dog": "Dog", "cat": "Cat" }) -decoded: utf8Bytes->decode("utf-8") -utf8Bytes: string->encode("utf-8") -encoded: bytes->encode("base64") +# The ->echo method returns its first input argument as-is, ignoring +# the input data. Useful for embedding literal values, as in +# $->echo("give me this string"), or wrapping the input value. +__typename: $->echo("Book") +wrapped: field->echo({ fieldValue: @ }) + +# Returns the type of the data as a string, e.g. "object", "array", +# "string", "number", "boolean", or "null". Note that `typeof null` is +# "object" in JavaScript but "null" for our purposes. +typeOfValue: value->typeof + +# When invoked against an array, ->map evaluates its first argument +# against each element of the array, binding the element values to `@`, +# and returns an array of the results. When invoked against a non-array, +# ->map evaluates its first argument against that value and returns the +# result without wrapping it in an array. +doubled: numbers->map(@->mul(2)) +types: values->map(@->typeof) + +# Returns true if the data is deeply equal to the first argument, false +# otherwise. Equality is solely value-based (all JSON), no references. +isObject: value->typeof->eq("object") + +# Takes any number of pairs [candidate, value], and returns value for +# the first candidate that equals the input data. If none of the +# pairs match, a runtime error is reported, but a single-element +# [] array as the final argument guarantees a default value. +__typename: kind->match( + ["dog", "Canine"], + ["cat", "Feline"], + ["Exotic"] +) + +# Like ->match, but expects the first element of each pair to evaluate +# to a boolean, returning the second element of the first pair whose +# first element is true. This makes providing a final catch-all case +# easy, since the last pair can be [true, ]. +__typename: kind->matchIf( + [@->eq("dog"), "Canine"], + [@->eq("cat"), "Feline"], + [true, "Exotic"] +) + +# Arithmetic methods, supporting both integers and floating point values, +# similar to JavaScript. +sum: $.a->add($.b)->add($.c) +difference: $.a->sub($.b)->sub($.c) +product: $.a->mul($.b, $.c) +quotient: $.a->div($.b) +remainder: $.a->mod($.b) + +# Array/string methods +first: list->first +last: list->last +index3: list->get(3) +secondToLast: list->get(-2) +slice: list->slice(0, 5) +substring: string->slice(2, 5) +arraySize: array->size +stringLength: string->size + +# Object methods +aValue: $->echo({ a: 123 })->get("a") +hasKey: object->has("key") +hasAB: object->has("a")->and(object->has("b")) +numberOfProperties: object->size +keys: object->keys +values: object->values +entries: object->entries +keysFromEntries: object->entries.key +valuesFromEntries: object->entries.value + +# Logical methods +negation: $.condition->not +bangBang: $.condition->not->not +disjunction: $.a->or($.b)->or($.c) +conjunction: $.a->and($.b, $.c) +aImpliesB: $.a->not->or($.b) +excludedMiddle: $.toBe->or($.toBe->not)->eq(true) ``` ### `MethodArgs ::=` @@ -600,8 +714,8 @@ encoded: bytes->encode("base64") ![MethodArgs](./grammar/MethodArgs.svg) When a `PathStep` invokes an `->operator` method, the method invocation may -optionally take a sequence of comma-separated `JSLiteral` arguments in -parentheses, as in `list->slice(0, 5)` or `kilometers: miles->times(1.60934)`. +optionally take a sequence of comma-separated `LitExpr` arguments in +parentheses, as in `list->slice(0, 5)` or `kilometers: miles->mul(1.60934)`. Methods do not have to take arguments, as in `list->first` or `list->last`, which is why `MethodArgs` is optional in `PathStep`. @@ -611,7 +725,7 @@ which is why `MethodArgs` is optional in `PathStep`. ![Key](./grammar/Key.svg) A property name occurring along a dotted `PathSelection`, either an `Identifier` -or a `StringLiteral`. +or a `LitString`. ### `Identifier ::=` @@ -624,9 +738,33 @@ In some languages, identifiers can include `$` characters, but `JSONSelection` syntax aims to match GraphQL grammar, which does not allow `$` in field names. Instead, the `$` is reserved for denoting variables in `VarPath` selections. -### `StringLiteral ::=` +### `LitExpr ::=` + +![LitExpr](./grammar/LitExpr.svg) -![StringLiteral](./grammar/StringLiteral.svg) +A `LitExpr` (short for _literal expression_) represents a JSON-like value that +can be passed inline as part of `MethodArgs`. + +The `LitExpr` mini-language diverges from JSON by allowing symbolic +`PathSelection` values (which may refer to variables or fields) in addition to +the usual JSON primitives. This allows `->` methods to be parameterized in +powerful ways, e.g. `page: list->slice(0, $limit)`. + +Also, as a minor syntactic convenience, `LitObject` literals can have +`Identifier` or `LitString` keys, whereas JSON objects can have only +double-quoted string literal keys. + +### `LitPrimitive ::=` + +![LitPrimitive](./grammar/LitPrimitive.svg) + +Analogous to a JSON primitive value, with the only differences being that +`LitNumber` does not currently support the exponential syntax, and `LitString` +values can be single-quoted as well as double-quoted. + +### `LitString ::=` + +![LitString](./grammar/LitString.svg) A string literal that can be single-quoted or double-quoted, and may contain any characters except the quote character that delimits the string. The backslash @@ -642,71 +780,38 @@ You can avoid most of the headaches of escaping by choosing your outer quote characters wisely. If your string contains many double quotes, use single quotes to delimit the string, and vice versa, as in JavaScript. -### `JSLiteral ::=` - -![JSLiteral](./grammar/JSLiteral.svg) +### `LitNumber ::=` -A `JSLiteral` represents a JSON-like value that can be passed inline as part of -`MethodArgs`. - -The `JSLiteral` mini-language diverges from JSON by allowing symbolic -`PathSelection` values (which may refer to variables or fields) in addition to -the usual JSON primitives. This allows `->` methods to be parameterized in -powerful ways, e.g. `page: list->slice(0, $limit)`. - -Also, as a minor syntactic convenience, `JSObject` literals can have -`Identifier` or `StringLiteral` keys, whereas JSON objects can have only -double-quoted string literal keys. - -### `JSPrimitive ::=` - -![JSPrimitive](./grammar/JSPrimitive.svg) - -Analogous to a JSON primitive value, with the only differences being that -`JSNumber` does not currently support the exponential syntax, and -`StringLiteral` values can be single-quoted as well as double-quoted. - -### `JSNumber ::=` - -![JSNumber](./grammar/JSNumber.svg) +![LitNumber](./grammar/LitNumber.svg) A numeric literal that is possibly negative and may contain a fractional component. The integer component is required unless a fractional component is present, and the fractional component can have zero digits when the integer component is present (as in `-123.`), but the fractional component must have at least one digit when there is no integer component, since `.` is not a valid -numeric literal by itself. Leading and trailing zeroes are essential for the -fractional component, but leading zeroes are disallowed for the integer -component, except when the integer component is exactly zero. - -### `UnsignedInt ::=` - -![UnsignedInt](./grammar/UnsignedInt.svg) - -The integer component of a `JSNumber`, which must be either `0` or an integer -without any leading zeroes. +numeric literal by itself. -### `JSObject ::=` +### `LitObject ::=` -![JSObject](./grammar/JSObject.svg) +![LitObject](./grammar/LitObject.svg) -A sequence of `JSProperty` items within curly braces, as in JavaScript. +A sequence of `LitProperty` items within curly braces, as in JavaScript. Trailing commas are not currently allowed, but could be supported in the future. -### `JSProperty ::=` +### `LitProperty ::=` -![JSProperty](./grammar/JSProperty.svg) +![LitProperty](./grammar/LitProperty.svg) -A key-value pair within a `JSObject`. Note that the `Key` may be either an -`Identifier` or a `StringLiteral`, as in JavaScript. This is a little different +A key-value pair within a `LitObject`. Note that the `Key` may be either an +`Identifier` or a `LitString`, as in JavaScript. This is a little different from JSON, which allows double-quoted strings only. -### `JSArray ::=` +### `LitArray ::=` -![JSArray](./grammar/JSArray.svg) +![LitArray](./grammar/LitArray.svg) -A list of `JSLiteral` items within square brackets, as in JavaScript. +A list of `LitExpr` items within square brackets, as in JavaScript. Trailing commas are not currently allowed, but could be supported in the future. diff --git a/apollo-federation/src/sources/connect/json_selection/apply_to.rs b/apollo-federation/src/sources/connect/json_selection/apply_to.rs index 0efe2b11ecf..a6d971050c1 100644 --- a/apollo-federation/src/sources/connect/json_selection/apply_to.rs +++ b/apollo-federation/src/sources/connect/json_selection/apply_to.rs @@ -7,41 +7,69 @@ use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use itertools::Itertools; use serde_json_bytes::json; -use serde_json_bytes::Map; +use serde_json_bytes::Map as JSONMap; use serde_json_bytes::Value as JSON; use super::helpers::json_type_name; +use super::immutable::InputPath; +use super::known_var::KnownVariable; +use super::lit_expr::LitExpr; +use super::methods::lookup_arrow_method; use super::parser::*; -pub trait ApplyTo { +pub(super) type VarsWithPathsMap<'a> = IndexMap)>; + +impl JSONSelection { // Applying a selection to a JSON value produces a new JSON value, along // with any/all errors encountered in the process. The value is represented // as an Option to allow for undefined/missing values (which JSON does not // explicitly support), which are distinct from null values (which it does // support). - fn apply_to(&self, data: &JSON) -> (Option, Vec) { + pub fn apply_to(&self, data: &JSON) -> (Option, Vec) { self.apply_with_vars(data, &IndexMap::default()) } - fn apply_with_vars( + pub fn apply_with_vars( &self, data: &JSON, vars: &IndexMap, ) -> (Option, Vec) { - let mut input_path = vec![]; // Using IndexSet over HashSet to preserve the order of the errors. let mut errors = IndexSet::default(); - let value = self.apply_to_path(data, vars, &mut input_path, &mut errors); + + let mut vars_with_paths: VarsWithPathsMap = IndexMap::default(); + for (var_name, var_data) in vars { + if let Some(known_var) = KnownVariable::from_str(var_name.as_str()) { + vars_with_paths.insert( + known_var, + (var_data, InputPath::empty().append(json!(var_name))), + ); + } else { + errors.insert(ApplyToError::new( + format!("Unknown variable {}", var_name), + vec![json!(var_name)], + )); + } + } + // The $ variable initially refers to the root data value, but is + // rebound by nested selection sets to refer to the root value the + // selection set was applied to. + vars_with_paths.insert(KnownVariable::Dollar, (data, InputPath::empty())); + + let value = self.apply_to_path(data, &vars_with_paths, &InputPath::empty(), &mut errors); + (value, errors.into_iter().collect()) } +} +pub(super) trait ApplyToInternal { // This is the trait method that should be implemented and called // recursively by the various JSONSelection types. fn apply_to_path( &self, data: &JSON, - vars: &IndexMap, - input_path: &mut Vec, + vars: &VarsWithPathsMap, + input_path: &InputPath, errors: &mut IndexSet, ) -> Option; @@ -50,20 +78,19 @@ pub trait ApplyTo { fn apply_to_array( &self, data_array: &[JSON], - vars: &IndexMap, - input_path: &mut Vec, + vars: &VarsWithPathsMap, + input_path: &InputPath, errors: &mut IndexSet, ) -> Option { let mut output = Vec::with_capacity(data_array.len()); for (i, element) in data_array.iter().enumerate() { - input_path.push(JSON::Number(i.into())); - let value = self.apply_to_path(element, vars, input_path, errors); - input_path.pop(); + let input_path_with_index = input_path.append(json!(i)); + let applied = self.apply_to_path(element, vars, &input_path_with_index, errors); // When building an Object, we can simply omit missing properties // and report an error, but when building an Array, we need to // insert null values to preserve the original array indices/length. - output.push(value.unwrap_or(JSON::Null)); + output.push(applied.unwrap_or(JSON::Null)); } Some(JSON::Array(output)) @@ -85,15 +112,15 @@ impl Hash for ApplyToError { } impl ApplyToError { - fn new(message: &str, path: &[JSON]) -> Self { + pub(crate) fn new(message: String, path: Vec) -> Self { Self(json!({ "message": message, - "path": JSON::Array(path.to_vec()), + "path": JSON::Array(path), })) } #[cfg(test)] - fn from_json(json: &JSON) -> Self { + pub(crate) fn from_json(json: &JSON) -> Self { if let JSON::Object(error) = json { if let Some(JSON::String(message)) = error.get("message") { if let Some(JSON::Array(path)) = error.get("path") { @@ -132,18 +159,14 @@ impl ApplyToError { } } -impl ApplyTo for JSONSelection { +impl ApplyToInternal for JSONSelection { fn apply_to_path( &self, data: &JSON, - vars: &IndexMap, - input_path: &mut Vec, + vars: &VarsWithPathsMap, + input_path: &InputPath, errors: &mut IndexSet, ) -> Option { - if let JSON::Array(array) = data { - return self.apply_to_array(array, vars, input_path, errors); - } - match self { // Because we represent a JSONSelection::Named as a SubSelection, we // can fully delegate apply_to_path to SubSelection::apply_to_path. @@ -161,38 +184,37 @@ impl ApplyTo for JSONSelection { } } -impl ApplyTo for NamedSelection { +impl ApplyToInternal for NamedSelection { fn apply_to_path( &self, data: &JSON, - vars: &IndexMap, - input_path: &mut Vec, + vars: &VarsWithPathsMap, + input_path: &InputPath, errors: &mut IndexSet, ) -> Option { if let JSON::Array(array) = data { return self.apply_to_array(array, vars, input_path, errors); } - let mut output = Map::new(); + let mut output = JSONMap::new(); #[rustfmt::skip] // cargo fmt butchers this closure's formatting let mut field_quoted_helper = | alias: Option<&Alias>, key: Key, selection: &Option, - input_path: &mut Vec, | { - input_path.push(key.to_json()); - let name = key.as_string(); - if let Some(child) = data.get(name.clone()) { - let output_name = alias.map_or(&name, |alias| &alias.name); + let input_path_with_key = input_path.append(key.to_json()); + let name = key.as_str(); + if let Some(child) = data.get(name) { + let output_name = alias.map_or(name, |alias| alias.name.as_str()); if let Some(selection) = selection { - let value = selection.apply_to_path(child, vars, input_path, errors); + let value = selection.apply_to_path(child, vars, &input_path_with_key, errors); if let Some(value) = value { - output.insert(output_name.clone(), value); + output.insert(output_name, value); } } else { - output.insert(output_name.clone(), child.clone()); + output.insert(output_name, child.clone()); } } else { errors.insert(ApplyToError::new( @@ -200,29 +222,18 @@ impl ApplyTo for NamedSelection { "Property {} not found in {}", key.dotted(), json_type_name(data), - ).as_str(), - input_path, + ), + input_path_with_key.to_vec(), )); } - input_path.pop(); }; match self { Self::Field(alias, name, selection) => { - field_quoted_helper( - alias.as_ref(), - Key::Field(name.clone()), - selection, - input_path, - ); + field_quoted_helper(alias.as_ref(), Key::Field(name.clone()), selection); } Self::Quoted(alias, name, selection) => { - field_quoted_helper( - Some(alias), - Key::Quoted(name.clone()), - selection, - input_path, - ); + field_quoted_helper(Some(alias), Key::Quoted(name.clone()), selection); } Self::Path(alias, path_selection) => { let value = path_selection.apply_to_path(data, vars, input_path, errors); @@ -242,53 +253,81 @@ impl ApplyTo for NamedSelection { } } -// $typenames is a special variable for referring to literal typenames. See -// note in selection_set.rs for more detail. -pub(super) const TYPENAMES: &str = "$typenames"; - -impl ApplyTo for PathSelection { +impl ApplyToInternal for PathSelection { fn apply_to_path( &self, data: &JSON, - vars: &IndexMap, - input_path: &mut Vec, + vars: &VarsWithPathsMap, + input_path: &InputPath, errors: &mut IndexSet, ) -> Option { - if let JSON::Array(array) = data { - return self.apply_to_array(array, vars, input_path, errors); - } - - match self { - Self::Var(var_name, tail) => { - if var_name == "$" { - // Because $ refers to the current value, we keep using - // input_path instead of creating a new var_path here. - tail.apply_to_path(data, vars, input_path, errors) - } else if var_name == TYPENAMES { - if let PathSelection::Key(Key::Field(ref name), _) = **tail { - let var_data = json!({ name: name }); - let mut var_path = vec![json!(name)]; - tail.apply_to_path(&var_data, vars, &mut var_path, errors) + match &self.path { + // If this is a KeyPath, instead of using data as given, we need to + // evaluate the path starting from the current value of $. To + // evaluate the KeyPath against data, prefix it with @. This logic + // supports method chaining like obj->has('a')->and(obj->has('b')), + // where both obj references are interpreted as $.obj. + PathList::Key(key, tail) => { + if let Some((dollar_data, dollar_path)) = vars.get(&KnownVariable::Dollar) { + let input_path_with_key = dollar_path.append(key.to_json()); + if let Some(child) = dollar_data.get(key.as_str()) { + tail.apply_to_path(child, vars, &input_path_with_key, errors) } else { errors.insert(ApplyToError::new( - format!("Invalid {} usage", TYPENAMES).as_str(), - &[json!(var_name), json!(tail)], + format!( + "Property {} not found in {}", + key.dotted(), + json_type_name(dollar_data), + ), + input_path_with_key.to_vec(), )); None } - } else if let Some(var_data) = vars.get(var_name) { - let mut var_path = vec![json!(var_name)]; - tail.apply_to_path(var_data, vars, &mut var_path, errors) + } else { + // If $ is undefined for some reason, fall back to using data. + self.path.apply_to_path(data, vars, input_path, errors) + } + } + path => path.apply_to_path(data, vars, input_path, errors), + } + } +} + +impl ApplyToInternal for PathList { + fn apply_to_path( + &self, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + errors: &mut IndexSet, + ) -> Option { + match self { + Self::Var(var_name, tail) => { + if var_name == &KnownVariable::AtSign { + // We represent @ as a variable name in PathList::Var, but + // it is never stored in the vars map, because it is always + // shorthand for the current data value. + tail.apply_to_path(data, vars, input_path, errors) + } else if let Some((var_data, var_path)) = vars.get(var_name) { + // Variables are associated with a path, which is always + // just the variable name for named $variables other than $. + // For the special variable $, the path represents the + // sequence of keys from the root input data to the $ data. + tail.apply_to_path(var_data, vars, var_path, errors) } else { errors.insert(ApplyToError::new( - format!("Variable {} not found", var_name).as_str(), - &[json!(var_name)], + format!("Variable {} not found", var_name.as_str()), + input_path.to_vec(), )); None } } Self::Key(key, tail) => { - input_path.push(key.to_json()); + if let JSON::Array(array) = data { + return self.apply_to_array(array, vars, input_path, errors); + } + + let input_path_with_key = input_path.append(key.to_json()); if !matches!(data, JSON::Object(_)) { errors.insert(ApplyToError::new( @@ -296,42 +335,46 @@ impl ApplyTo for PathSelection { "Property {} not found in {}", key.dotted(), json_type_name(data), - ) - .as_str(), - input_path, + ), + input_path_with_key.to_vec(), )); - input_path.pop(); return None; } - let result = if let Some(child) = match key { - Key::Field(name) => data.get(name), - Key::Quoted(name) => data.get(name), - Key::Index(index) => data.get(index), - } { - tail.apply_to_path(child, vars, input_path, errors) + if let Some(child) = data.get(key.as_str()) { + tail.apply_to_path(child, vars, &input_path_with_key, errors) } else { errors.insert(ApplyToError::new( format!( "Property {} not found in {}", key.dotted(), json_type_name(data), - ) - .as_str(), - input_path, + ), + input_path_with_key.to_vec(), )); None - }; - - input_path.pop(); - - result + } } - Self::Selection(selection) => { - // If data is not an object here, this recursive apply_to_path - // call will handle the error. - selection.apply_to_path(data, vars, input_path, errors) + Self::Method(method_name, method_args, tail) => { + if let Some(method) = lookup_arrow_method(method_name) { + method( + method_name.as_str(), + method_args, + data, + vars, + input_path, + tail.as_ref(), + errors, + ) + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} not found", method_name), + input_path.to_vec(), + )); + None + } } + Self::Selection(selection) => selection.apply_to_path(data, vars, input_path, errors), Self::Empty => { // If data is not an object here, we want to preserve its value // without an error. @@ -341,28 +384,72 @@ impl ApplyTo for PathSelection { } } -impl ApplyTo for SubSelection { +impl ApplyToInternal for LitExpr { fn apply_to_path( &self, data: &JSON, - vars: &IndexMap, - input_path: &mut Vec, + vars: &VarsWithPathsMap, + input_path: &InputPath, + errors: &mut IndexSet, + ) -> Option { + match self { + Self::String(s) => Some(JSON::String(s.clone().into())), + Self::Number(n) => Some(JSON::Number(n.clone())), + Self::Bool(b) => Some(JSON::Bool(*b)), + Self::Null => Some(JSON::Null), + Self::Object(map) => { + let mut output = JSONMap::with_capacity(map.len()); + for (key, value) in map { + if let Some(value_json) = value.apply_to_path(data, vars, input_path, errors) { + output.insert(key.clone(), value_json); + } + } + Some(JSON::Object(output)) + } + Self::Array(vec) => { + let mut output = Vec::with_capacity(vec.len()); + for value in vec { + output.push( + value + .apply_to_path(data, vars, input_path, errors) + .unwrap_or(JSON::Null), + ); + } + Some(JSON::Array(output)) + } + Self::Path(path) => path.apply_to_path(data, vars, input_path, errors), + } + } +} + +impl ApplyToInternal for SubSelection { + fn apply_to_path( + &self, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, errors: &mut IndexSet, ) -> Option { if let JSON::Array(array) = data { return self.apply_to_array(array, vars, input_path, errors); } + let vars: VarsWithPathsMap = { + let mut vars = vars.clone(); + vars.insert(KnownVariable::Dollar, (data, input_path.clone())); + vars + }; + let (data_map, data_really_primitive) = match data { JSON::Object(data_map) => (data_map.clone(), false), - _primitive => (Map::new(), true), + _primitive => (JSONMap::new(), true), }; - let mut output = Map::new(); + let mut output = JSONMap::new(); let mut input_names = IndexSet::default(); for named_selection in &self.selections { - let value = named_selection.apply_to_path(data, vars, input_path, errors); + let value = named_selection.apply_to_path(data, &vars, input_path, errors); // If value is an object, extend output with its keys and their values. if let Some(JSON::Object(key_and_value)) = value { @@ -381,23 +468,8 @@ impl ApplyTo for SubSelection { input_names.insert(name.as_str()); } NamedSelection::Path(_, path_selection) => { - if let PathSelection::Key(key, _) = path_selection { - match key { - Key::Field(name) | Key::Quoted(name) => { - input_names.insert(name.as_str()); - } - // While Property::Index may be used to - // represent the input_path during apply_to_path - // when arrays are encountered, it will never be - // used to represent the parsed structure of any - // actual selection string, becase arrays are - // processed automatically/implicitly and their - // indices are never explicitly selected. This - // means the numeric Property::Index case cannot - // affect the keys selected by * selections, so - // input_names does not need updating here. - Key::Index(_) => {} - }; + if let PathList::Key(key, _) = &path_selection.path { + input_names.insert(key.as_str()); } } // The contents of groups do not affect the keys matched by @@ -410,7 +482,7 @@ impl ApplyTo for SubSelection { match &self.star { // Aliased but not subselected, e.g. "a b c rest: *" Some(StarSelection(Some(alias), None)) => { - let mut star_output = Map::new(); + let mut star_output = JSONMap::new(); for (key, value) in &data_map { if !input_names.contains(key.as_str()) { star_output.insert(key.clone(), value.clone()); @@ -420,11 +492,11 @@ impl ApplyTo for SubSelection { } // Aliased and subselected, e.g. "alias: * { hello }" Some(StarSelection(Some(alias), Some(selection))) => { - let mut star_output = Map::new(); + let mut star_output = JSONMap::new(); for (key, value) in &data_map { if !input_names.contains(key.as_str()) { if let Some(selected) = - selection.apply_to_path(value, vars, input_path, errors) + selection.apply_to_path(value, &vars, input_path, errors) { star_output.insert(key.clone(), selected); } @@ -437,7 +509,7 @@ impl ApplyTo for SubSelection { for (key, value) in &data_map { if !input_names.contains(key.as_str()) { if let Some(selected) = - selection.apply_to_path(value, vars, input_path, errors) + selection.apply_to_path(value, &vars, input_path, errors) { output.insert(key.clone(), selected); } @@ -1213,14 +1285,18 @@ mod tests { ), ); assert_eq!( - selection!("id: $args.id name").apply_to(&data), + selection!("nested.path { id: $args.id name }").apply_to(&json!({ + "nested": { + "path": data.clone(), + }, + })), ( Some(json!({ "name": "Ben" })), vec![ApplyToError::from_json(&json!({ "message": "Variable $args not found", - "path": ["$args"], + "path": ["nested", "path"], }))], ), ); @@ -1238,12 +1314,18 @@ mod tests { }))], ), ); + + // A single variable path should not be mapped over an input array. + assert_eq!( + selection!("$args.id").apply_with_vars(&json!([1, 2, 3]), &vars), + (Some(json!("id from args")), vec![]), + ); } #[test] fn test_apply_to_variable_expressions_typename() { let typename_object = - selection!("__typename: $typenames.Product reviews { __typename: $typenames.Review }") + selection!("__typename: $->echo('Product') reviews { __typename: $->echo('Review') }") .apply_to(&json!({"reviews": [{}]})); assert_eq!( typename_object, diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Alias.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Alias.svg index 5c2a8db39b5..285de6b7ee4 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/Alias.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/Alias.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/AtPath.svg b/apollo-federation/src/sources/connect/json_selection/grammar/AtPath.svg new file mode 100644 index 00000000000..f9e37d05d92 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/AtPath.svg @@ -0,0 +1,53 @@ + + + + + + + + + + @ + + + + PathStep + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg index a28134cc17e..ff67c916470 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Identifier.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Identifier.svg index 03a7bb0abf4..2e096eb115d 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/Identifier.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/Identifier.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSArray.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSArray.svg deleted file mode 100644 index cd7d51ed753..00000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSArray.svg +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - [ - - - - JSLiteral - - - - , - - - ] - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSNumber.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSNumber.svg deleted file mode 100644 index 413b1fe8357..00000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSNumber.svg +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - UnsignedInt - - - - . - - - [0-9] - - - . - - - [0-9] - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg index c828cdaf358..9efc7c8fad1 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg @@ -1,32 +1,32 @@ - + @@ -34,8 +34,8 @@ - - + + NakedSubSelection PathSelection - - + d="m17 17 h2 m20 0 h10 m142 0 h10 m-182 0 h20 m162 0 h20 m-202 0 q10 0 10 10 m182 0 q0 -10 10 -10 m-192 10 v24 m182 0 v-24 m-182 24 q0 10 10 10 m162 0 q10 0 10 -10 m-172 10 h10 m106 0 h10 m0 0 h36 m23 -44 h-3"/> + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSObject.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSObject.svg deleted file mode 100644 index d305abdacd9..00000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSObject.svg +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - { - - - - JSProperty - - - - , - - - } - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSPrimitive.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSPrimitive.svg deleted file mode 100644 index 1b3d4527394..00000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSPrimitive.svg +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - StringLiteral - - - - - JSNumber - - - - true - - - false - - - null - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Key.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Key.svg index a41054011a9..bd9802b2243 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/Key.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/Key.svg @@ -1,32 +1,32 @@ - + @@ -39,14 +39,14 @@ Identifier - - - StringLiteral + xlink:href="#LitString" + xlink:title="LitString"> + + + LitString - - + d="m17 17 h2 m20 0 h10 m78 0 h10 m-118 0 h20 m98 0 h20 m-138 0 q10 0 10 10 m118 0 q0 -10 10 -10 m-128 10 v24 m118 0 v-24 m-118 24 q0 10 10 10 m98 0 q10 0 10 -10 m-108 10 h10 m72 0 h10 m0 0 h6 m23 -44 h-3"/> + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg b/apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg index 191de27c461..df434950532 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/LitArray.svg b/apollo-federation/src/sources/connect/json_selection/grammar/LitArray.svg new file mode 100644 index 00000000000..391e2907ff2 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/LitArray.svg @@ -0,0 +1,77 @@ + + + + + + + + + + [ + + + + LitExpr + + + + , + + + , + + + ] + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSLiteral.svg b/apollo-federation/src/sources/connect/json_selection/grammar/LitExpr.svg similarity index 54% rename from apollo-federation/src/sources/connect/json_selection/grammar/JSLiteral.svg rename to apollo-federation/src/sources/connect/json_selection/grammar/LitExpr.svg index 74a592e7e70..200f1d9bb60 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSLiteral.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/LitExpr.svg @@ -3,54 +3,54 @@ - - - JSPrimitive + xlink:href="#LitPrimitive" + xlink:title="LitPrimitive"> + + + LitPrimitive - - - JSObject + xlink:href="#LitObject" + xlink:title="LitObject"> + + + LitObject - - - JSArray + xlink:href="#LitArray" + xlink:title="LitArray"> + + + LitArray PathSelection + d="m17 17 h2 m20 0 h10 m90 0 h10 m0 0 h16 m-146 0 h20 m126 0 h20 m-166 0 q10 0 10 10 m146 0 q0 -10 10 -10 m-156 10 v24 m146 0 v-24 m-146 24 q0 10 10 10 m126 0 q10 0 10 -10 m-136 10 h10 m76 0 h10 m0 0 h30 m-136 -10 v20 m146 0 v-20 m-146 20 v24 m146 0 v-24 m-146 24 q0 10 10 10 m126 0 q10 0 10 -10 m-136 10 h10 m68 0 h10 m0 0 h38 m-136 -10 v20 m146 0 v-20 m-146 20 v24 m146 0 v-24 m-146 24 q0 10 10 10 m126 0 q10 0 10 -10 m-136 10 h10 m106 0 h10 m23 -132 h-3"/> diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/LitNumber.svg b/apollo-federation/src/sources/connect/json_selection/grammar/LitNumber.svg new file mode 100644 index 00000000000..4eb2cc458c8 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/LitNumber.svg @@ -0,0 +1,71 @@ + + + + + + + + + + - + + + [0-9] + + + . + + + [0-9] + + + . + + + [0-9] + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/LitObject.svg b/apollo-federation/src/sources/connect/json_selection/grammar/LitObject.svg new file mode 100644 index 00000000000..67f5cc44fbf --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/LitObject.svg @@ -0,0 +1,77 @@ + + + + + + + + + + { + + + + LitProperty + + + + , + + + , + + + } + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/LitPrimitive.svg b/apollo-federation/src/sources/connect/json_selection/grammar/LitPrimitive.svg new file mode 100644 index 00000000000..c7ac6846e1c --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/LitPrimitive.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + LitString + + + + + LitNumber + + + + true + + + false + + + null + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSProperty.svg b/apollo-federation/src/sources/connect/json_selection/grammar/LitProperty.svg similarity index 59% rename from apollo-federation/src/sources/connect/json_selection/grammar/JSProperty.svg rename to apollo-federation/src/sources/connect/json_selection/grammar/LitProperty.svg index 320035e1e40..f46525478ba 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSProperty.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/LitProperty.svg @@ -1,32 +1,32 @@ - + @@ -47,14 +47,14 @@ rx="10"/> : - - - JSLiteral + xlink:href="#LitExpr" + xlink:title="LitExpr"> + + + LitExpr - - + d="m17 17 h2 m0 0 h10 m42 0 h10 m0 0 h10 m24 0 h10 m0 0 h10 m64 0 h10 m3 0 h-3"/> + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/StringLiteral.svg b/apollo-federation/src/sources/connect/json_selection/grammar/LitString.svg similarity index 86% rename from apollo-federation/src/sources/connect/json_selection/grammar/StringLiteral.svg rename to apollo-federation/src/sources/connect/json_selection/grammar/LitString.svg index 229fe4e594e..ebdf1b31238 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/StringLiteral.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/LitString.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg b/apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg index c000e67e4a9..de10e75c16a 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg @@ -1,32 +1,32 @@ - + @@ -40,11 +40,11 @@ rx="10"/> ( - - - JSLiteral + xlink:href="#LitExpr" + xlink:title="LitExpr"> + + + LitExpr , - - + + , + + - ) + ) - - + d="m17 61 h2 m0 0 h10 m26 0 h10 m40 0 h10 m64 0 h10 m-104 0 l20 0 m-1 0 q-9 0 -9 -10 l0 -24 q0 -10 10 -10 m84 44 l20 0 m-20 0 q10 0 10 -10 l0 -24 q0 -10 -10 -10 m-84 0 h10 m24 0 h10 m0 0 h40 m40 44 h10 m0 0 h34 m-64 0 h20 m44 0 h20 m-84 0 q10 0 10 10 m64 0 q0 -10 10 -10 m-74 10 v12 m64 0 v-12 m-64 12 q0 10 10 10 m44 0 q10 0 10 -10 m-54 10 h10 m24 0 h10 m-208 -32 h20 m208 0 h20 m-248 0 q10 0 10 10 m228 0 q0 -10 10 -10 m-238 10 v46 m228 0 v-46 m-228 46 q0 10 10 10 m208 0 q10 0 10 -10 m-218 10 h10 m0 0 h198 m20 -66 h10 m26 0 h10 m3 0 h-3"/> + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NakedSubSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NakedSubSelection.svg index c7ec2b04a53..ffcfa089f1f 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NakedSubSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NakedSubSelection.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg index d2934e6c781..553d2b88a5c 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg @@ -1,32 +1,32 @@ - + @@ -48,12 +48,12 @@ - - + + SubSelection - - + d="m17 17 h2 m20 0 h10 m0 0 h60 m-90 0 h20 m70 0 h20 m-110 0 q10 0 10 10 m90 0 q0 -10 10 -10 m-100 10 v12 m90 0 v-12 m-90 12 q0 10 10 10 m70 0 q10 0 10 -10 m-80 10 h10 m50 0 h10 m20 -32 h10 m78 0 h10 m20 0 h10 m0 0 h112 m-142 0 h20 m122 0 h20 m-162 0 q10 0 10 10 m142 0 q0 -10 10 -10 m-152 10 v12 m142 0 v-12 m-142 12 q0 10 10 10 m122 0 q10 0 10 -10 m-132 10 h10 m102 0 h10 m23 -32 h-3"/> + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg index 743cbaf26d5..c4305cb5361 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg @@ -1,32 +1,32 @@ - + @@ -41,12 +41,12 @@ - - + + SubSelection - - + d="m17 17 h2 m0 0 h10 m50 0 h10 m0 0 h10 m102 0 h10 m3 0 h-3"/> + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg index c00795281d5..402d3eacc51 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg index 0a28dac9b3d..41f63141ed7 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg @@ -1,32 +1,32 @@ - + @@ -39,21 +39,21 @@ Alias - - - StringLiteral + xlink:href="#LitString" + xlink:title="LitString"> + + + LitString - - - SubSelection + + + SubSelection - - + d="m17 17 h2 m0 0 h10 m50 0 h10 m0 0 h10 m72 0 h10 m20 0 h10 m0 0 h112 m-142 0 h20 m122 0 h20 m-162 0 q10 0 10 10 m142 0 q0 -10 10 -10 m-152 10 v12 m142 0 v-12 m-142 12 q0 10 10 10 m122 0 q10 0 10 -10 m-132 10 h10 m102 0 h10 m23 -32 h-3"/> + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg index 5ee79391fbf..6fb4914e74f 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg @@ -3,54 +3,54 @@ + xlink:href="#NamedPathSelection" + xlink:title="NamedPathSelection"> - NamedFieldSelection + NamedPathSelection - - - NamedQuotedSelection + xlink:href="#NamedFieldSelection" + xlink:title="NamedFieldSelection"> + + + NamedFieldSelection - - - NamedPathSelection + xlink:href="#NamedQuotedSelection" + xlink:title="NamedQuotedSelection"> + + + NamedQuotedSelection NamedGroupSelection + d="m17 17 h2 m20 0 h10 m152 0 h10 m0 0 h16 m-208 0 h20 m188 0 h20 m-228 0 q10 0 10 10 m208 0 q0 -10 10 -10 m-218 10 v24 m208 0 v-24 m-208 24 q0 10 10 10 m188 0 q10 0 10 -10 m-198 10 h10 m152 0 h10 m0 0 h16 m-198 -10 v20 m208 0 v-20 m-208 20 v24 m208 0 v-24 m-208 24 q0 10 10 10 m188 0 q10 0 10 -10 m-198 10 h10 m168 0 h10 m-198 -10 v20 m208 0 v-20 m-208 20 v24 m208 0 v-24 m-208 24 q0 10 10 10 m188 0 q10 0 10 -10 m-198 10 h10 m160 0 h10 m0 0 h8 m23 -132 h-3"/> diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg index 575d9840a53..dc2676eced2 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg @@ -1,32 +1,32 @@ - + @@ -45,15 +45,22 @@ KeyPath + + + + AtPath + - - + + SubSelection - - + d="m17 17 h2 m20 0 h10 m70 0 h10 m0 0 h2 m-112 0 h20 m92 0 h20 m-132 0 q10 0 10 10 m112 0 q0 -10 10 -10 m-122 10 v24 m112 0 v-24 m-112 24 q0 10 10 10 m92 0 q10 0 10 -10 m-102 10 h10 m72 0 h10 m-102 -10 v20 m112 0 v-20 m-112 20 v24 m112 0 v-24 m-112 24 q0 10 10 10 m92 0 q10 0 10 -10 m-102 10 h10 m62 0 h10 m0 0 h10 m40 -88 h10 m0 0 h112 m-142 0 h20 m122 0 h20 m-162 0 q10 0 10 10 m142 0 q0 -10 10 -10 m-152 10 v12 m142 0 v-12 m-142 12 q0 10 10 10 m122 0 q10 0 10 -10 m-132 10 h10 m102 0 h10 m23 -32 h-3"/> + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/PathStep.svg b/apollo-federation/src/sources/connect/json_selection/grammar/PathStep.svg index 299a06d8cc6..313830841d4 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/PathStep.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/PathStep.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Spaces.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Spaces.svg index a08f8268705..3dc54e66f59 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/Spaces.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/Spaces.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/SpacesOrComments.svg b/apollo-federation/src/sources/connect/json_selection/grammar/SpacesOrComments.svg index 2e9c815c1d4..74299b7733d 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/SpacesOrComments.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/SpacesOrComments.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg index 2b4615d2e9a..4e09fae7df7 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg @@ -1,32 +1,32 @@ - + @@ -49,12 +49,12 @@ - - + + SubSelection - - + d="m17 17 h2 m20 0 h10 m0 0 h60 m-90 0 h20 m70 0 h20 m-110 0 q10 0 10 10 m90 0 q0 -10 10 -10 m-100 10 v12 m90 0 v-12 m-90 12 q0 10 10 10 m70 0 q10 0 10 -10 m-80 10 h10 m50 0 h10 m20 -32 h10 m28 0 h10 m20 0 h10 m0 0 h112 m-142 0 h20 m122 0 h20 m-162 0 q10 0 10 10 m142 0 q0 -10 10 -10 m-152 10 v12 m142 0 v-12 m-142 12 q0 10 10 10 m122 0 q10 0 10 -10 m-132 10 h10 m102 0 h10 m23 -32 h-3"/> + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/SubSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/SubSelection.svg index 12284c811cf..913b60aa0ce 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/SubSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/SubSelection.svg @@ -1,32 +1,32 @@ - + @@ -42,20 +42,20 @@ - - + + NakedSubSelection - - + - } + } - - + d="m17 17 h2 m0 0 h10 m28 0 h10 m0 0 h10 m142 0 h10 m0 0 h10 m28 0 h10 m3 0 h-3"/> + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/UnsignedInt.svg b/apollo-federation/src/sources/connect/json_selection/grammar/UnsignedInt.svg deleted file mode 100644 index 3d8fdde47e3..00000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/UnsignedInt.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - [1-9] - - - - NO_SPACE - - - - [0-9] - - - 0 - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg b/apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg index 0017afb15f3..16daef9d217 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/graphql.rs b/apollo-federation/src/sources/connect/json_selection/graphql.rs index 6cb22179469..609059ca242 100644 --- a/apollo-federation/src/sources/connect/json_selection/graphql.rs +++ b/apollo-federation/src/sources/connect/json_selection/graphql.rs @@ -21,6 +21,7 @@ use super::parser::NamedSelection; use super::parser::PathSelection; use super::parser::StarSelection; use super::parser::SubSelection; +use super::PathList; #[derive(Default)] struct GraphQLSelections(Vec>); @@ -92,19 +93,22 @@ impl From for Vec { impl From for Vec { fn from(val: PathSelection) -> Vec { + val.path.into() + } +} + +impl From for Vec { + fn from(val: PathList) -> Vec { match val { - PathSelection::Var(_, _) => { - // Variable references do not correspond to GraphQL fields. - vec![] - } - PathSelection::Key(_, tail) => { - let tail = *tail; - tail.into() - } - PathSelection::Selection(selection) => { - GraphQLSelections::from(selection).valid_selections() - } - PathSelection::Empty => vec![], + // Variable references do not correspond to GraphQL fields. + PathList::Var(_, _) => vec![], + PathList::Key(_, tail) => (*tail).into(), + // TODO If we decide to allow MethodArgs to accept PathSelection + // values that refer to fields (rather than $variables), then we + // will need to convert those field selection paths to GraphQL. + PathList::Method(_, _, tail) => (*tail).into(), + PathList::Selection(selection) => GraphQLSelections::from(selection).valid_selections(), + PathList::Empty => vec![], } } } diff --git a/apollo-federation/src/sources/connect/json_selection/immutable.rs b/apollo-federation/src/sources/connect/json_selection/immutable.rs new file mode 100644 index 00000000000..e74419fa69a --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/immutable.rs @@ -0,0 +1,56 @@ +use std::clone::Clone; +use std::rc::Rc; + +#[derive(Debug, Clone)] +pub struct InputPath { + path: Path, +} + +type Path = Option>>; + +#[derive(Debug, Clone)] +struct AppendPath { + prefix: Path, + last: T, +} + +impl InputPath { + pub fn empty() -> InputPath { + InputPath { path: None } + } + + pub fn append(&self, last: T) -> Self { + Self { + path: Some(Rc::new(AppendPath { + prefix: self.path.clone(), + last, + })), + } + } + + pub fn to_vec(&self) -> Vec { + // This method needs to be iterative rather than recursive, to be + // consistent with the paranoia of the drop method. + let mut vec = Vec::new(); + let mut path = self.path.as_deref(); + while let Some(p) = path { + vec.push(p.last.clone()); + path = p.prefix.as_deref(); + } + vec.reverse(); + vec + } +} + +impl Drop for InputPath { + fn drop(&mut self) { + let mut path = self.path.take(); + while let Some(rc) = path { + if let Ok(mut p) = Rc::try_unwrap(rc) { + path = p.prefix.take(); + } else { + break; + } + } + } +} diff --git a/apollo-federation/src/sources/connect/json_selection/known_var.rs b/apollo-federation/src/sources/connect/json_selection/known_var.rs new file mode 100644 index 00000000000..b526cbedf90 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/known_var.rs @@ -0,0 +1,43 @@ +#[derive(PartialEq, Eq, Clone, Hash)] +pub(crate) enum KnownVariable { + This, + Args, + Config, + Dollar, + AtSign, +} + +impl KnownVariable { + pub(crate) fn from_str(var_name: &str) -> Option { + match var_name { + "$this" => Some(Self::This), + "$args" => Some(Self::Args), + "$config" => Some(Self::Config), + "$" => Some(Self::Dollar), + "@" => Some(Self::AtSign), + _ => None, + } + } + + pub(crate) fn as_str(&self) -> &'static str { + match self { + Self::This => "$this", + Self::Args => "$args", + Self::Config => "$config", + Self::Dollar => "$", + Self::AtSign => "@", + } + } +} + +impl std::fmt::Debug for KnownVariable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::fmt::Display for KnownVariable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} diff --git a/apollo-federation/src/sources/connect/json_selection/lit_expr.rs b/apollo-federation/src/sources/connect/json_selection/lit_expr.rs new file mode 100644 index 00000000000..4bd7065dfd0 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/lit_expr.rs @@ -0,0 +1,496 @@ +//! A LitExpr (short for LiteralExpression) is similar to a JSON value (or +//! serde_json::Value), with the addition of PathSelection as a possible leaf +//! value, so literal expressions passed to -> methods (via MethodArgs) can +//! incorporate dynamic $variable values in addition to the usual input data and +//! argument values. + +use apollo_compiler::collections::IndexMap; +use nom::branch::alt; +use nom::bytes::complete::tag; +use nom::character::complete::char; +use nom::character::complete::one_of; +use nom::combinator::map; +use nom::combinator::opt; +use nom::combinator::recognize; +use nom::multi::many0; +use nom::multi::many1; +use nom::sequence::delimited; +use nom::sequence::pair; +use nom::sequence::preceded; +use nom::sequence::tuple; +use nom::IResult; + +use super::helpers::spaces_or_comments; +use super::parser::parse_string_literal; +use super::parser::Key; +use super::parser::PathSelection; +use super::ExternalVarPaths; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum LitExpr { + String(String), + Number(serde_json::Number), + Bool(bool), + Null, + Object(IndexMap), + Array(Vec), + Path(PathSelection), +} + +impl LitExpr { + // LitExpr ::= LitPrimitive | LitObject | LitArray | PathSelection + // LitPrimitive ::= LitString | LitNumber | "true" | "false" | "null" + pub fn parse(input: &str) -> IResult<&str, Self> { + tuple(( + spaces_or_comments, + alt(( + map(parse_string_literal, Self::String), + Self::parse_number, + map(tag("true"), |_| Self::Bool(true)), + map(tag("false"), |_| Self::Bool(false)), + map(tag("null"), |_| Self::Null), + Self::parse_object, + Self::parse_array, + map(PathSelection::parse, Self::Path), + )), + spaces_or_comments, + ))(input) + .map(|(input, (_, value, _))| (input, value)) + } + + // LitNumber ::= "-"? ([0-9]+ ("." [0-9]*)? | "." [0-9]+) + fn parse_number(input: &str) -> IResult<&str, Self> { + let (suffix, (neg, _spaces, num)) = delimited( + spaces_or_comments, + tuple(( + opt(char('-')), + spaces_or_comments, + alt(( + recognize(pair( + many1(one_of("0123456789")), + opt(preceded(char('.'), many0(one_of("0123456789")))), + )), + recognize(pair(tag("."), many1(one_of("0123456789")))), + )), + )), + spaces_or_comments, + )(input)?; + + let mut number = String::new(); + if let Some('-') = neg { + number.push('-'); + } + if num.starts_with('.') { + // The serde_json::Number::parse method requires a leading digit + // before the decimal point. + number.push('0'); + } + number.push_str(num); + if num.ends_with('.') { + // The serde_json::Number::parse method requires a trailing digit + // after the decimal point. + number.push('0'); + } + + if let Ok(lit_number) = number.parse().map(Self::Number) { + Ok((suffix, lit_number)) + } else { + Err(nom::Err::Failure(nom::error::Error::new( + input, + nom::error::ErrorKind::IsNot, + ))) + } + } + + // LitObject ::= "{" (LitProperty ("," LitProperty)* ","?)? "}" + fn parse_object(input: &str) -> IResult<&str, Self> { + delimited( + tuple((spaces_or_comments, char('{'), spaces_or_comments)), + map( + opt(tuple(( + Self::parse_property, + many0(preceded(char(','), Self::parse_property)), + opt(char(',')), + ))), + |properties| { + let mut output = IndexMap::default(); + if let Some(((first_key, first_value), rest, _trailing_comma)) = properties { + output.insert(first_key.to_string(), first_value); + for (key, value) in rest { + output.insert(key.to_string(), value); + } + } + Self::Object(output) + }, + ), + tuple((spaces_or_comments, char('}'), spaces_or_comments)), + )(input) + } + + // LitProperty ::= Key ":" LitExpr + fn parse_property(input: &str) -> IResult<&str, (String, Self)> { + tuple((Key::parse, char(':'), Self::parse))(input) + .map(|(input, (key, _, value))| (input, (key.as_string(), value))) + } + + // LitArray ::= "[" (LitExpr ("," LitExpr)* ","?)? "]" + fn parse_array(input: &str) -> IResult<&str, Self> { + delimited( + tuple((spaces_or_comments, char('['), spaces_or_comments)), + map( + opt(tuple(( + Self::parse, + many0(preceded(char(','), Self::parse)), + opt(char(',')), + ))), + |elements| { + let mut output = vec![]; + if let Some((first, rest, _trailing_comma)) = elements { + output.push(first); + output.extend(rest); + } + Self::Array(output) + }, + ), + tuple((spaces_or_comments, char(']'), spaces_or_comments)), + )(input) + } + + pub(super) fn as_i64(&self) -> Option { + match self { + Self::Number(n) => n.as_i64(), + _ => None, + } + } +} + +impl ExternalVarPaths for LitExpr { + fn external_var_paths(&self) -> Vec<&PathSelection> { + let mut paths = vec![]; + match self { + Self::String(_) | Self::Number(_) | Self::Bool(_) | Self::Null => {} + Self::Object(map) => { + for value in map.values() { + paths.extend(value.external_var_paths()); + } + } + Self::Array(vec) => { + for value in vec { + paths.extend(value.external_var_paths()); + } + } + Self::Path(path) => { + paths.extend(path.external_var_paths()); + } + } + paths + } +} + +#[cfg(test)] +mod tests { + use super::super::known_var::KnownVariable; + use super::*; + use crate::sources::connect::json_selection::PathList; + + #[test] + fn test_lit_expr_parse_primitives() { + assert_eq!( + LitExpr::parse("'hello'"), + Ok(("", LitExpr::String("hello".to_string()))), + ); + assert_eq!( + LitExpr::parse("\"hello\""), + Ok(("", LitExpr::String("hello".to_string()))), + ); + + assert_eq!( + LitExpr::parse("123"), + Ok(("", LitExpr::Number(serde_json::Number::from(123)))), + ); + assert_eq!( + LitExpr::parse("-123"), + Ok(("", LitExpr::Number(serde_json::Number::from(-123)))), + ); + assert_eq!( + LitExpr::parse(" - 123 "), + Ok(("", LitExpr::Number(serde_json::Number::from(-123)))), + ); + assert_eq!( + LitExpr::parse("123.456"), + Ok(( + "", + LitExpr::Number(serde_json::Number::from_f64(123.456).unwrap()) + )), + ); + assert_eq!( + LitExpr::parse(".456"), + Ok(( + "", + LitExpr::Number(serde_json::Number::from_f64(0.456).unwrap()) + )), + ); + assert_eq!( + LitExpr::parse("-.456"), + Ok(( + "", + LitExpr::Number(serde_json::Number::from_f64(-0.456).unwrap()) + )), + ); + assert_eq!( + LitExpr::parse("123."), + Ok(( + "", + LitExpr::Number(serde_json::Number::from_f64(123.0).unwrap()) + )), + ); + assert_eq!( + LitExpr::parse("-123."), + Ok(( + "", + LitExpr::Number(serde_json::Number::from_f64(-123.0).unwrap()) + )), + ); + + assert_eq!(LitExpr::parse("true"), Ok(("", LitExpr::Bool(true)))); + assert_eq!(LitExpr::parse(" true "), Ok(("", LitExpr::Bool(true)))); + assert_eq!(LitExpr::parse("false"), Ok(("", LitExpr::Bool(false)))); + assert_eq!(LitExpr::parse(" false "), Ok(("", LitExpr::Bool(false)))); + assert_eq!(LitExpr::parse("null"), Ok(("", LitExpr::Null))); + assert_eq!(LitExpr::parse(" null "), Ok(("", LitExpr::Null))); + } + + #[test] + fn test_lit_expr_parse_objects() { + assert_eq!( + LitExpr::parse("{'a': 1}"), + Ok(( + "", + LitExpr::Object({ + let mut map = IndexMap::default(); + map.insert( + "a".to_string(), + LitExpr::Number(serde_json::Number::from(1)), + ); + map + }) + )) + ); + + { + let expected = LitExpr::Object({ + let mut map = IndexMap::default(); + map.insert( + "a".to_string(), + LitExpr::Number(serde_json::Number::from(1)), + ); + map.insert( + "b".to_string(), + LitExpr::Number(serde_json::Number::from(2)), + ); + map + }); + assert_eq!( + LitExpr::parse("{'a': 1, 'b': 2}"), + Ok(("", expected.clone())) + ); + assert_eq!( + LitExpr::parse("{ a : 1, 'b': 2}"), + Ok(("", expected.clone())) + ); + assert_eq!(LitExpr::parse("{ a : 1, b: 2}"), Ok(("", expected.clone()))); + assert_eq!( + LitExpr::parse("{ \"a\" : 1, \"b\": 2 }"), + Ok(("", expected.clone())) + ); + assert_eq!( + LitExpr::parse("{ \"a\" : 1, b: 2 }"), + Ok(("", expected.clone())) + ); + assert_eq!( + LitExpr::parse("{ a : 1, \"b\": 2 }"), + Ok(("", expected.clone())) + ); + } + } + + #[test] + fn test_lit_expr_parse_arrays() { + assert_eq!( + LitExpr::parse("[1, 2]"), + Ok(( + "", + LitExpr::Array(vec![ + LitExpr::Number(serde_json::Number::from(1)), + LitExpr::Number(serde_json::Number::from(2)), + ]) + )) + ); + + assert_eq!( + LitExpr::parse("[1, true, 'three']"), + Ok(( + "", + LitExpr::Array(vec![ + LitExpr::Number(serde_json::Number::from(1)), + LitExpr::Bool(true), + LitExpr::String("three".to_string()), + ]) + )) + ); + } + + #[test] + fn test_lit_expr_parse_paths() { + { + let expected = LitExpr::Path(PathSelection { + path: PathList::Key( + Key::Field("a".to_string()), + Box::new(PathList::Key( + Key::Field("b".to_string()), + Box::new(PathList::Key( + Key::Field("c".to_string()), + Box::new(PathList::Empty), + )), + )), + ), + }); + assert_eq!(LitExpr::parse("a.b.c"), Ok(("", expected.clone()))); + assert_eq!(LitExpr::parse(" a . b . c "), Ok(("", expected.clone()))); + } + + { + let expected = LitExpr::Path(PathSelection { + path: PathList::Key(Key::Field("data".to_string()), Box::new(PathList::Empty)), + }); + assert_eq!(LitExpr::parse(".data"), Ok(("", expected.clone()))); + assert_eq!(LitExpr::parse(" . data "), Ok(("", expected.clone()))); + } + + { + let expected = LitExpr::Array(vec![ + LitExpr::Path(PathSelection { + path: PathList::Key(Key::Field("a".to_string()), Box::new(PathList::Empty)), + }), + LitExpr::Path(PathSelection { + path: PathList::Key( + Key::Field("b".to_string()), + Box::new(PathList::Key( + Key::Field("c".to_string()), + Box::new(PathList::Empty), + )), + ), + }), + LitExpr::Path(PathSelection { + path: PathList::Key( + Key::Field("d".to_string()), + Box::new(PathList::Key( + Key::Field("e".to_string()), + Box::new(PathList::Key( + Key::Field("f".to_string()), + Box::new(PathList::Empty), + )), + )), + ), + }), + ]); + assert_eq!( + LitExpr::parse("[.a, b.c, .d.e.f]"), + Ok(("", expected.clone())) + ); + assert_eq!( + LitExpr::parse("[.a, b.c, .d.e.f,]"), + Ok(("", expected.clone())) + ); + assert_eq!( + LitExpr::parse("[ . a , b . c , . d . e . f ]"), + Ok(("", expected.clone())) + ); + assert_eq!( + LitExpr::parse("[ . a , b . c , . d . e . f , ]"), + Ok(("", expected.clone())) + ); + assert_eq!( + LitExpr::parse( + r#"[ + .a, + b.c, + .d.e.f, + ]"# + ), + Ok(("", expected.clone())) + ); + assert_eq!( + LitExpr::parse( + r#"[ + . a , + . b . c , + d . e . f , + ]"# + ), + Ok(("", expected.clone())) + ); + } + + { + let expected = LitExpr::Object({ + let mut map = IndexMap::default(); + map.insert( + "a".to_string(), + LitExpr::Path(PathSelection { + path: PathList::Var( + KnownVariable::Args, + Box::new(PathList::Key( + Key::Field("a".to_string()), + Box::new(PathList::Empty), + )), + ), + }), + ); + map.insert( + "b".to_string(), + LitExpr::Path(PathSelection { + path: PathList::Var( + KnownVariable::This, + Box::new(PathList::Key( + Key::Field("b".to_string()), + Box::new(PathList::Empty), + )), + ), + }), + ); + map + }); + + assert_eq!( + LitExpr::parse( + r#"{ + a: $args.a, + b: $this.b, + }"# + ), + Ok(("", expected.clone())), + ); + + assert_eq!( + LitExpr::parse( + r#"{ + b: $this.b, + a: $args.a, + }"# + ), + Ok(("", expected.clone())), + ); + + assert_eq!( + LitExpr::parse( + r#" { + a : $args . a , + b : $this . b + ,} "# + ), + Ok(("", expected.clone())), + ); + } + } +} diff --git a/apollo-federation/src/sources/connect/json_selection/methods.rs b/apollo-federation/src/sources/connect/json_selection/methods.rs new file mode 100644 index 00000000000..fabcd36a234 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/methods.rs @@ -0,0 +1,155 @@ +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use lazy_static::lazy_static; +use serde_json_bytes::Value as JSON; + +use super::immutable::InputPath; +use super::ApplyToError; +use super::MethodArgs; +use super::PathList; +use super::VarsWithPathsMap; + +// Two kinds of methods: public ones and not-yet-public ones. The future ones +// have proposed implementations and tests, and some are even used within the +// tests of other methods, but are not yet exposed for use in connector schemas. +// Graduating to public status requires updated documentation, careful review, +// and team discussion to make sure the method is one we want to support +// long-term. Once we have a better story for checking method type signatures +// and versioning any behavioral changes, we should be able to expand/improve +// the list of public::* methods more quickly/confidently. +mod future; +mod public; + +#[cfg(test)] +mod tests; + +type ArrowMethod = fn( + // Method name + method_name: &str, + // Arguments passed to this method + method_args: &Option, + // The JSON input value (data) + data: &JSON, + // The variables + vars: &VarsWithPathsMap, + // The input_path (may contain integers) + input_path: &InputPath, + // The rest of the PathList + tail: &PathList, + // Errors + errors: &mut IndexSet, +) -> Option; + +lazy_static! { + // This set controls which ->methods are exposed for use in connector + // schemas. Non-public methods are still implemented and tested, but will + // not be returned from lookup_arrow_method outside of tests. + static ref PUBLIC_ARROW_METHODS: IndexSet<&'static str> = { + let mut public_methods = IndexSet::default(); + + // Before enabling a method here, move it from the future:: namespace to + // the top level of the methods.rs file. + public_methods.insert("echo"); + // public_methods.insert("typeof"); + public_methods.insert("map"); + // public_methods.insert("eq"); + public_methods.insert("match"); + // public_methods.insert("matchIf"); + // public_methods.insert("match_if"); + // public_methods.insert("add"); + // public_methods.insert("sub"); + // public_methods.insert("mul"); + // public_methods.insert("div"); + // public_methods.insert("mod"); + public_methods.insert("first"); + public_methods.insert("last"); + public_methods.insert("slice"); + public_methods.insert("size"); + // public_methods.insert("has"); + // public_methods.insert("get"); + // public_methods.insert("keys"); + // public_methods.insert("values"); + public_methods.insert("entries"); + // public_methods.insert("not"); + // public_methods.insert("or"); + // public_methods.insert("and"); + + public_methods + }; + + // This map registers all the built-in ->methods that are currently + // implemented, even the non-public ones that are not included in the + // PUBLIC_ARROW_METHODS set. + static ref ARROW_METHODS: IndexMap = { + let mut methods = IndexMap::::default(); + + // This built-in method returns its first input argument as-is, ignoring + // the input data. Useful for embedding literal values, as in + // $->echo("give me this string"). + methods.insert("echo".to_string(), public::echo_method); + + // Returns the type of the data as a string, e.g. "object", "array", + // "string", "number", "boolean", or "null". Note that `typeof null` is + // "object" in JavaScript but "null" for our purposes. + methods.insert("typeof".to_string(), future::typeof_method); + + // When invoked against an array, ->map evaluates its first argument + // against each element of the array and returns an array of the + // results. When invoked against a non-array, ->map evaluates its first + // argument against the data and returns the result. + methods.insert("map".to_string(), public::map_method); + + // Returns true if the data is deeply equal to the first argument, false + // otherwise. Equality is solely value-based (all JSON), no references. + methods.insert("eq".to_string(), future::eq_method); + + // Takes any number of pairs [candidate, value], and returns value for + // the first candidate that equals the input data $. If none of the + // pairs match, a runtime error is reported, but a single-element + // [] array as the final argument guarantees a default value. + methods.insert("match".to_string(), public::match_method); + + // Like ->match, but expects the first element of each pair to evaluate + // to a boolean, returning the second element of the first pair whose + // first element is true. This makes providing a final catch-all case + // easy, since the last pair can be [true, ]. + methods.insert("matchIf".to_string(), future::match_if_method); + methods.insert("match_if".to_string(), future::match_if_method); + + // Arithmetic methods + methods.insert("add".to_string(), future::add_method); + methods.insert("sub".to_string(), future::sub_method); + methods.insert("mul".to_string(), future::mul_method); + methods.insert("div".to_string(), future::div_method); + methods.insert("mod".to_string(), future::mod_method); + + // Array/string methods (note that ->has and ->get also work for array + // and string indexes) + methods.insert("first".to_string(), public::first_method); + methods.insert("last".to_string(), public::last_method); + methods.insert("slice".to_string(), public::slice_method); + methods.insert("size".to_string(), public::size_method); + + // Object methods (note that ->size also works for objects) + methods.insert("has".to_string(), future::has_method); + methods.insert("get".to_string(), future::get_method); + methods.insert("keys".to_string(), future::keys_method); + methods.insert("values".to_string(), future::values_method); + methods.insert("entries".to_string(), public::entries_method); + + // Logical methods + methods.insert("not".to_string(), future::not_method); + methods.insert("or".to_string(), future::or_method); + methods.insert("and".to_string(), future::and_method); + + methods + }; +} + +pub(super) fn lookup_arrow_method(method_name: &str) -> Option<&ArrowMethod> { + if cfg!(test) || PUBLIC_ARROW_METHODS.contains(method_name) { + ARROW_METHODS.get(method_name) + } else { + None + } +} diff --git a/apollo-federation/src/sources/connect/json_selection/methods/future.rs b/apollo-federation/src/sources/connect/json_selection/methods/future.rs new file mode 100644 index 00000000000..d38b7ac054a --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/methods/future.rs @@ -0,0 +1,581 @@ +// The future.rs module contains methods that are not yet exposed for use in +// JSONSelection strings in connector schemas, but have proposed implementations +// and tests. After careful review, they may one day move to public.rs. + +use apollo_compiler::collections::IndexSet; +use serde_json::Number; +use serde_json_bytes::Value as JSON; + +use crate::sources::connect::json_selection::helpers::json_type_name; +use crate::sources::connect::json_selection::immutable::InputPath; +use crate::sources::connect::json_selection::lit_expr::LitExpr; +use crate::sources::connect::json_selection::ApplyToError; +use crate::sources::connect::json_selection::ApplyToInternal; +use crate::sources::connect::json_selection::MethodArgs; +use crate::sources::connect::json_selection::PathList; +use crate::sources::connect::json_selection::VarsWithPathsMap; + +pub(super) fn typeof_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(_)) = method_args { + errors.insert(ApplyToError::new( + format!("Method ->{} does not take any arguments", method_name), + input_path.to_vec(), + )); + None + } else { + let typeof_string = JSON::String(json_type_name(data).to_string().into()); + tail.apply_to_path(&typeof_string, vars, input_path, errors) + } +} + +pub(super) fn eq_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(args)) = method_args { + if args.len() == 1 { + let matches = if let Some(value) = args[0].apply_to_path(data, vars, input_path, errors) + { + data == &value + } else { + false + }; + return tail.apply_to_path(&JSON::Bool(matches), vars, input_path, errors); + } + } + errors.insert(ApplyToError::new( + format!("Method ->{} requires exactly one argument", method_name), + input_path.to_vec(), + )); + None +} + +// Like ->match, but expects the first element of each pair +// to evaluate to a boolean, returning the second element of +// the first pair whose first element is true. This makes +// providing a final catch-all case easy, since the last +// pair can be [true, ]. +pub(super) fn match_if_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(args)) = method_args { + for pair in args { + if let LitExpr::Array(pair) = pair { + if pair.len() == 2 { + if let Some(JSON::Bool(true)) = + pair[0].apply_to_path(data, vars, input_path, errors) + { + return pair[1] + .apply_to_path(data, vars, input_path, errors) + .and_then(|value| { + tail.apply_to_path(&value, vars, input_path, errors) + }); + }; + } + } + } + } + errors.insert(ApplyToError::new( + format!( + "Method ->{} did not match any [condition, value] pair", + method_name + ), + input_path.to_vec(), + )); + None +} + +pub(super) fn arithmetic_method( + method_name: &str, + method_args: &Option, + op: impl Fn(&Number, &Number) -> Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(args)) = method_args { + if let JSON::Number(result) = data { + let mut result = result.clone(); + for arg in args { + let value_opt = arg.apply_to_path(data, vars, input_path, errors); + if let Some(JSON::Number(n)) = value_opt { + if let Some(new_result) = op(&result, &n) { + result = new_result; + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} failed on argument {}", method_name, n), + input_path.to_vec(), + )); + return None; + } + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} requires numeric arguments", method_name), + input_path.to_vec(), + )); + return None; + } + } + Some(JSON::Number(result)) + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} requires numeric arguments", method_name), + input_path.to_vec(), + )); + None + } + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} requires at least one argument", method_name), + input_path.to_vec(), + )); + None + } +} + +macro_rules! infix_math_op { + ($name:ident, $op:tt) => { + fn $name(a: &Number, b: &Number) -> Option { + if a.is_f64() || b.is_f64() { + Number::from_f64(a.as_f64().unwrap() $op b.as_f64().unwrap()) + } else if let (Some(a_i64), Some(b_i64)) = (a.as_i64(), b.as_i64()) { + Some(Number::from(a_i64 $op b_i64)) + } else { + None + } + } + }; +} +infix_math_op!(add_op, +); +infix_math_op!(sub_op, -); +infix_math_op!(mul_op, *); +infix_math_op!(div_op, /); +infix_math_op!(rem_op, %); + +macro_rules! infix_math_method { + ($name:ident, $op:ident) => { + pub(super) fn $name( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, + ) -> Option { + if let Some(result) = arithmetic_method( + method_name, + method_args, + &$op, + data, + vars, + input_path, + errors, + ) { + tail.apply_to_path(&result, vars, input_path, errors) + } else { + None + } + } + }; +} +infix_math_method!(add_method, add_op); +infix_math_method!(sub_method, sub_op); +infix_math_method!(mul_method, mul_op); +infix_math_method!(div_method, div_op); +infix_math_method!(mod_method, rem_op); + +pub(super) fn has_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(args)) = method_args { + match args.first() { + Some(arg) => match &arg.apply_to_path(data, vars, input_path, errors) { + Some(json_index @ JSON::Number(n)) => match (data, n.as_i64()) { + (JSON::Array(array), Some(index)) => { + let ilen = array.len() as i64; + // Negative indices count from the end of the array + let index = if index < 0 { ilen + index } else { index }; + tail.apply_to_path( + &JSON::Bool(index >= 0 && index < ilen), + vars, + &input_path.append(json_index.clone()), + errors, + ) + } + (json_key @ JSON::String(s), Some(index)) => { + let ilen = s.as_str().len() as i64; + // Negative indices count from the end of the array + let index = if index < 0 { ilen + index } else { index }; + tail.apply_to_path( + &JSON::Bool(index >= 0 && index < ilen), + vars, + &input_path.append(json_key.clone()), + errors, + ) + } + _ => tail.apply_to_path( + &JSON::Bool(false), + vars, + &input_path.append(json_index.clone()), + errors, + ), + }, + Some(json_key @ JSON::String(s)) => match data { + JSON::Object(map) => tail.apply_to_path( + &JSON::Bool(map.contains_key(s.as_str())), + vars, + &input_path.append(json_key.clone()), + errors, + ), + _ => tail.apply_to_path( + &JSON::Bool(false), + vars, + &input_path.append(json_key.clone()), + errors, + ), + }, + Some(value) => tail.apply_to_path( + &JSON::Bool(false), + vars, + &input_path.append(value.clone()), + errors, + ), + None => tail.apply_to_path(&JSON::Bool(false), vars, input_path, errors), + }, + None => { + errors.insert(ApplyToError::new( + format!("Method ->{} requires an argument", method_name), + input_path.to_vec(), + )); + None + } + } + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} requires an argument", method_name), + input_path.to_vec(), + )); + None + } +} + +// Returns the array or string element at the given index, as Option. If +// the index is out of bounds, returns None and reports an error. +pub(super) fn get_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(args)) = method_args { + if let Some(index_literal) = args.first() { + match &index_literal.apply_to_path(data, vars, input_path, errors) { + Some(JSON::Number(n)) => match (data, n.as_i64()) { + (JSON::Array(array), Some(i)) => { + // Negative indices count from the end of the array + if let Some(element) = array.get(if i < 0 { + (array.len() as i64 + i) as usize + } else { + i as usize + }) { + tail.apply_to_path(element, vars, input_path, errors) + } else { + errors.insert(ApplyToError::new( + format!( + "Method ->{}({}) array index out of bounds", + method_name, i, + ), + input_path.to_vec(), + )); + None + } + } + (JSON::String(s), Some(i)) => { + let s_str = s.as_str(); + let ilen = s_str.len() as i64; + // Negative indices count from the end of the array + let index = if i < 0 { ilen + i } else { i }; + if index >= 0 && index < ilen { + let uindex = index as usize; + let single_char_string = s_str[uindex..uindex + 1].to_string(); + tail.apply_to_path( + &JSON::String(single_char_string.into()), + vars, + input_path, + errors, + ) + } else { + errors.insert(ApplyToError::new( + format!( + "Method ->{}({}) string index out of bounds", + method_name, i, + ), + input_path.to_vec(), + )); + None + } + } + (_, None) => { + errors.insert(ApplyToError::new( + format!("Method ->{} requires an integer index", method_name), + input_path.to_vec(), + )); + None + } + _ => { + errors.insert(ApplyToError::new( + format!( + "Method ->{} requires an array or string input, not {}", + method_name, + json_type_name(data), + ), + input_path.to_vec(), + )); + None + } + }, + Some(key @ JSON::String(s)) => match data { + JSON::Object(map) => { + if let Some(value) = map.get(s.as_str()) { + tail.apply_to_path(value, vars, input_path, errors) + } else { + errors.insert(ApplyToError::new( + format!("Method ->{}({}) object key not found", method_name, key), + input_path.to_vec(), + )); + None + } + } + _ => { + errors.insert(ApplyToError::new( + format!("Method ->{}({}) requires an object input", method_name, key), + input_path.to_vec(), + )); + None + } + }, + Some(value) => { + errors.insert(ApplyToError::new( + format!( + "Method ->{}({}) requires an integer or string argument", + method_name, value, + ), + input_path.to_vec(), + )); + None + } + None => { + errors.insert(ApplyToError::new( + format!("Method ->{} received undefined argument", method_name), + input_path.to_vec(), + )); + None + } + } + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} requires an argument", method_name), + input_path.to_vec(), + )); + None + } + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} requires an argument", method_name), + input_path.to_vec(), + )); + None + } +} + +pub(super) fn keys_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(_)) = method_args { + errors.insert(ApplyToError::new( + format!("Method ->{} does not take any arguments", method_name), + input_path.to_vec(), + )); + return None; + } + + match data { + JSON::Object(map) => { + let keys = map.keys().map(|key| JSON::String(key.clone())).collect(); + tail.apply_to_path(&JSON::Array(keys), vars, input_path, errors) + } + _ => { + errors.insert(ApplyToError::new( + format!( + "Method ->{} requires an object input, not {}", + method_name, + json_type_name(data), + ), + input_path.to_vec(), + )); + None + } + } +} + +pub(super) fn values_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(_)) = method_args { + errors.insert(ApplyToError::new( + format!("Method ->{} does not take any arguments", method_name), + input_path.to_vec(), + )); + return None; + } + + match data { + JSON::Object(map) => { + let values = map.values().cloned().collect(); + tail.apply_to_path(&JSON::Array(values), vars, input_path, errors) + } + _ => { + errors.insert(ApplyToError::new( + format!( + "Method ->{} requires an object input, not {}", + method_name, + json_type_name(data), + ), + input_path.to_vec(), + )); + None + } + } +} + +pub(super) fn not_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if method_args.is_some() { + errors.insert(ApplyToError::new( + format!("Method ->{} does not take any arguments", method_name), + input_path.to_vec(), + )); + None + } else { + tail.apply_to_path(&JSON::Bool(!is_truthy(data)), vars, input_path, errors) + } +} + +fn is_truthy(data: &JSON) -> bool { + match data { + JSON::Bool(b) => *b, + JSON::Number(n) => n.as_f64().map_or(false, |n| n != 0.0), + JSON::Null => false, + JSON::String(s) => !s.as_str().is_empty(), + JSON::Object(_) | JSON::Array(_) => true, + } +} + +pub(super) fn or_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(args)) = method_args { + let mut result = is_truthy(data); + for arg in args { + if result { + break; + } + result = arg + .apply_to_path(data, vars, input_path, errors) + .map(|value| is_truthy(&value)) + .unwrap_or(false); + } + tail.apply_to_path(&JSON::Bool(result), vars, input_path, errors) + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} requires arguments", method_name), + input_path.to_vec(), + )); + None + } +} + +pub(super) fn and_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(args)) = method_args { + let mut result = is_truthy(data); + for arg in args { + if !result { + break; + } + result = arg + .apply_to_path(data, vars, input_path, errors) + .map(|value| is_truthy(&value)) + .unwrap_or(false); + } + tail.apply_to_path(&JSON::Bool(result), vars, input_path, errors) + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} requires arguments", method_name), + input_path.to_vec(), + )); + None + } +} diff --git a/apollo-federation/src/sources/connect/json_selection/methods/public.rs b/apollo-federation/src/sources/connect/json_selection/methods/public.rs new file mode 100644 index 00000000000..5e6ec57c008 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/methods/public.rs @@ -0,0 +1,352 @@ +use apollo_compiler::collections::IndexSet; +use serde_json_bytes::ByteString; +use serde_json_bytes::Map as JSONMap; +use serde_json_bytes::Value as JSON; + +use crate::sources::connect::json_selection::helpers::json_type_name; +use crate::sources::connect::json_selection::immutable::InputPath; +use crate::sources::connect::json_selection::lit_expr::LitExpr; +use crate::sources::connect::json_selection::ApplyToError; +use crate::sources::connect::json_selection::ApplyToInternal; +use crate::sources::connect::json_selection::MethodArgs; +use crate::sources::connect::json_selection::PathList; +use crate::sources::connect::json_selection::VarsWithPathsMap; + +pub(super) fn echo_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(args)) = method_args { + if let Some(arg) = args.first() { + return arg + .apply_to_path(data, vars, input_path, errors) + .and_then(|value| tail.apply_to_path(&value, vars, input_path, errors)); + } + } + errors.insert(ApplyToError::new( + format!("Method ->{} requires one argument", method_name), + input_path.to_vec(), + )); + None +} + +pub(super) fn map_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(args)) = method_args { + if let Some(first_arg) = args.first() { + if let JSON::Array(array) = data { + let mut output = Vec::with_capacity(array.len()); + + for (i, element) in array.iter().enumerate() { + let input_path = input_path.append(JSON::Number(i.into())); + if let Some(applied) = + first_arg.apply_to_path(element, vars, &input_path, errors) + { + if let Some(value) = tail.apply_to_path(&applied, vars, &input_path, errors) + { + output.push(value); + continue; + } + } + output.push(JSON::Null); + } + + Some(JSON::Array(output)) + } else { + first_arg.apply_to_path(data, vars, input_path, errors) + } + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} requires one argument", method_name), + input_path.to_vec(), + )); + None + } + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} requires one argument", method_name), + input_path.to_vec(), + )); + None + } +} + +pub(super) fn match_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + // Takes any number of pairs [key, value], and returns value for the first + // key that equals the data. If none of the pairs match, returns None. + // Typically, the final pair will use @ as its key to ensure some default + // value is returned. + if let Some(MethodArgs(args)) = method_args { + for pair in args { + if let LitExpr::Array(pair) = pair { + if pair.len() == 2 { + if let Some(candidate) = pair[0].apply_to_path(data, vars, input_path, errors) { + if candidate == *data { + return pair[1] + .apply_to_path(data, vars, input_path, errors) + .and_then(|value| { + tail.apply_to_path(&value, vars, input_path, errors) + }); + } + }; + } + } + } + } + errors.insert(ApplyToError::new( + format!( + "Method ->{} did not match any [candidate, value] pair", + method_name + ), + input_path.to_vec(), + )); + None +} + +pub(super) fn first_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(_)) = method_args { + errors.insert(ApplyToError::new( + format!("Method ->{} does not take any arguments", method_name), + input_path.to_vec(), + )); + return None; + } + + match data { + JSON::Array(array) => array + .first() + .and_then(|first| tail.apply_to_path(first, vars, input_path, errors)), + JSON::String(s) => s.as_str().chars().next().and_then(|first| { + tail.apply_to_path( + &JSON::String(first.to_string().into()), + vars, + input_path, + errors, + ) + }), + _ => tail.apply_to_path(data, vars, input_path, errors), + } +} + +pub(super) fn last_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(_)) = method_args { + errors.insert(ApplyToError::new( + format!("Method ->{} does not take any arguments", method_name), + input_path.to_vec(), + )); + return None; + } + + match data { + JSON::Array(array) => array + .last() + .and_then(|last| tail.apply_to_path(last, vars, input_path, errors)), + JSON::String(s) => s.as_str().chars().last().and_then(|last| { + tail.apply_to_path( + &JSON::String(last.to_string().into()), + vars, + input_path, + errors, + ) + }), + _ => tail.apply_to_path(data, vars, input_path, errors), + } +} + +pub(super) fn slice_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + let length = if let JSON::Array(array) = data { + array.len() as i64 + } else if let JSON::String(s) = data { + s.as_str().len() as i64 + } else { + errors.insert(ApplyToError::new( + format!("Method ->{} requires an array or string input", method_name), + input_path.to_vec(), + )); + return None; + }; + + if let Some(MethodArgs(args)) = method_args { + let start = args + .first() + .and_then(|arg| arg.apply_to_path(data, vars, input_path, errors)) + .and_then(|n| n.as_i64()) + .unwrap_or(0) + .max(0) + .min(length) as usize; + let end = args + .get(1) + .and_then(|arg| arg.apply_to_path(data, vars, input_path, errors)) + .and_then(|n| n.as_i64()) + .unwrap_or(length) + .max(0) + .min(length) as usize; + + let array = match data { + JSON::Array(array) => { + if end - start > 0 { + JSON::Array( + array + .iter() + .skip(start) + .take(end - start) + .cloned() + .collect(), + ) + } else { + JSON::Array(vec![]) + } + } + JSON::String(s) => { + if end - start > 0 { + JSON::String(s.as_str()[start..end].to_string().into()) + } else { + JSON::String("".to_string().into()) + } + } + _ => unreachable!(), + }; + + tail.apply_to_path(&array, vars, input_path, errors) + } else { + Some(data.clone()) + } +} + +pub(super) fn size_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(_)) = method_args { + errors.insert(ApplyToError::new( + format!("Method ->{} does not take any arguments", method_name), + input_path.to_vec(), + )); + return None; + } + + match data { + JSON::Array(array) => { + let size = array.len() as i64; + tail.apply_to_path(&JSON::Number(size.into()), vars, input_path, errors) + } + JSON::String(s) => { + let size = s.as_str().len() as i64; + tail.apply_to_path(&JSON::Number(size.into()), vars, input_path, errors) + } + // Though we can't ask for ->first or ->last or ->at(n) on an object, we + // can safely return how many properties the object has for ->size. + JSON::Object(map) => { + let size = map.len() as i64; + tail.apply_to_path(&JSON::Number(size.into()), vars, input_path, errors) + } + _ => { + errors.insert(ApplyToError::new( + format!( + "Method ->{} requires an array, string, or object input, not {}", + method_name, + json_type_name(data), + ), + input_path.to_vec(), + )); + None + } + } +} + +// Returns a list of [{ key, value }, ...] objects for each key-value pair in +// the object. Returning a list of [[ key, value ], ...] pairs might also seem +// like an option, but GraphQL doesn't handle heterogeneous lists (or tuples) as +// well as it handles objects with named properties like { key, value }. +pub(super) fn entries_method( + method_name: &str, + method_args: &Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + tail: &PathList, + errors: &mut IndexSet, +) -> Option { + if let Some(MethodArgs(_)) = method_args { + errors.insert(ApplyToError::new( + format!("Method ->{} does not take any arguments", method_name), + input_path.to_vec(), + )); + return None; + } + + match data { + JSON::Object(map) => { + let entries = map + .iter() + .map(|(key, value)| { + let mut key_value_pair = JSONMap::new(); + key_value_pair.insert(ByteString::from("key"), JSON::String(key.clone())); + key_value_pair.insert(ByteString::from("value"), value.clone()); + JSON::Object(key_value_pair) + }) + .collect(); + tail.apply_to_path(&JSON::Array(entries), vars, input_path, errors) + } + _ => { + errors.insert(ApplyToError::new( + format!( + "Method ->{} requires an object input, not {}", + method_name, + json_type_name(data), + ), + input_path.to_vec(), + )); + None + } + } +} diff --git a/apollo-federation/src/sources/connect/json_selection/methods/tests.rs b/apollo-federation/src/sources/connect/json_selection/methods/tests.rs new file mode 100644 index 00000000000..ebb70aeb997 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/methods/tests.rs @@ -0,0 +1,1098 @@ +use serde_json_bytes::json; + +use super::*; +use crate::selection; + +#[test] +fn test_echo_method() { + assert_eq!( + selection!("$->echo('oyez')").apply_to(&json!(null)), + (Some(json!("oyez")), vec![]), + ); + + assert_eq!( + selection!("$->echo('oyez')").apply_to(&json!([1, 2, 3])), + (Some(json!("oyez")), vec![]), + ); + + assert_eq!( + selection!("$->echo([1, 2, 3]) { id: $ }").apply_to(&json!(null)), + (Some(json!([{ "id": 1 }, { "id": 2 }, { "id": 3 }])), vec![]), + ); + + assert_eq!( + selection!("$->echo([1, 2, 3])->last { id: $ }").apply_to(&json!(null)), + (Some(json!({ "id": 3 })), vec![]), + ); + + assert_eq!( + selection!("$->echo([1.1, 0.2, -3.3]) { id: $ }").apply_to(&json!(null)), + ( + Some(json!([{ "id": 1.1 }, { "id": 0.2 }, { "id": -3.3 }])), + vec![] + ), + ); + + assert_eq!( + selection!("$.nested.value->echo(['before', @, 'after'])").apply_to(&json!({ + "nested": { + "value": 123, + }, + })), + (Some(json!(["before", 123, "after"])), vec![]), + ); + + assert_eq!( + selection!("$.nested.value->echo(['before', $, 'after'])").apply_to(&json!({ + "nested": { + "value": 123, + }, + })), + ( + Some(json!(["before", { + "nested": { + "value": 123, + }, + }, "after"])), + vec![] + ), + ); + + assert_eq!( + selection!("data->echo(@.results->last)").apply_to(&json!({ + "data": { + "results": [1, 2, 3], + }, + })), + (Some(json!(3)), vec![]), + ); + + assert_eq!( + selection!("results->echo(@->first)").apply_to(&json!({ + "results": [ + [1, 2, 3], + "ignored", + ], + })), + (Some(json!([1, 2, 3])), vec![]), + ); + + assert_eq!( + selection!("results->echo(@->first)->last").apply_to(&json!({ + "results": [ + [1, 2, 3], + "ignored", + ], + })), + (Some(json!(3)), vec![]), + ); + + { + let nested_value_data = json!({ + "nested": { + "value": 123, + }, + }); + + let expected = (Some(json!({ "wrapped": 123 })), vec![]); + + let check = |selection: &str| { + assert_eq!(selection!(selection).apply_to(&nested_value_data), expected,); + }; + + check("nested.value->echo({ wrapped: @ })"); + check("nested.value->echo({ wrapped: @,})"); + check("nested.value->echo({ wrapped: @,},)"); + check("nested.value->echo({ wrapped: @},)"); + check("nested.value->echo({ wrapped: @ , } , )"); + } + + // Turn a list of { name, hobby } objects into a single { names: [...], + // hobbies: [...] } object. + assert_eq!( + selection!( + r#" + people->echo({ + names: @.name, + hobbies: @.hobby, + }) + "# + ) + .apply_to(&json!({ + "people": [ + { "name": "Alice", "hobby": "reading" }, + { "name": "Bob", "hobby": "fishing" }, + { "hobby": "painting", "name": "Charlie" }, + ], + })), + ( + Some(json!({ + "names": ["Alice", "Bob", "Charlie"], + "hobbies": ["reading", "fishing", "painting"], + })), + vec![], + ), + ); +} + +#[test] +fn test_typeof_method() { + fn check(selection: &str, data: &JSON, expected_type: &str) { + assert_eq!( + selection!(selection).apply_to(data), + (Some(json!(expected_type)), vec![]), + ); + } + + check("$->typeof", &json!(null), "null"); + check("$->typeof", &json!(true), "boolean"); + check("@->typeof", &json!(false), "boolean"); + check("$->typeof", &json!(123), "number"); + check("$->typeof", &json!(123.45), "number"); + check("$->typeof", &json!("hello"), "string"); + check("$->typeof", &json!([1, 2, 3]), "array"); + check("$->typeof", &json!({ "key": "value" }), "object"); +} + +#[test] +fn test_map_method() { + assert_eq!( + selection!("$->map(@->add(10))").apply_to(&json!([1, 2, 3])), + (Some(json!(vec![11, 12, 13])), vec![]), + ); + + assert_eq!( + selection!("messages->map(@.role)").apply_to(&json!({ + "messages": [ + { "role": "admin" }, + { "role": "user" }, + { "role": "guest" }, + ], + })), + (Some(json!(["admin", "user", "guest"])), vec![]), + ); + + assert_eq!( + selection!("messages->map(@.roles)").apply_to(&json!({ + "messages": [ + { "roles": ["admin"] }, + { "roles": ["user", "guest"] }, + ], + })), + (Some(json!([["admin"], ["user", "guest"]])), vec![]), + ); + + assert_eq!( + selection!("values->map(@->typeof)").apply_to(&json!({ + "values": [1, 2.5, "hello", true, null, [], {}], + })), + ( + Some(json!([ + "number", "number", "string", "boolean", "null", "array", "object" + ])), + vec![], + ), + ); + + assert_eq!( + selection!("singleValue->map(@->mul(10))").apply_to(&json!({ + "singleValue": 123, + })), + (Some(json!(1230)), vec![]), + ); +} + +#[test] +fn test_missing_method() { + assert_eq!( + selection!("nested.path->bogus").apply_to(&json!({ + "nested": { + "path": 123, + }, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->bogus not found", + "path": ["nested", "path"], + }))], + ), + ); +} + +#[test] +fn test_match_methods() { + assert_eq!( + selection!( + r#" + name + __typename: kind->match( + ['dog', 'Canine'], + ['cat', 'Feline'] + ) + "# + ) + .apply_to(&json!({ + "kind": "cat", + "name": "Whiskers", + })), + ( + Some(json!({ + "__typename": "Feline", + "name": "Whiskers", + })), + vec![], + ), + ); + + assert_eq!( + selection!( + r#" + name + __typename: kind->match( + ['dog', 'Canine'], + ['cat', 'Feline'], + [@, 'Exotic'], + ) + "# + ) + .apply_to(&json!({ + "kind": "axlotl", + "name": "Gulpy", + })), + ( + Some(json!({ + "__typename": "Exotic", + "name": "Gulpy", + })), + vec![], + ), + ); + + assert_eq!( + selection!( + r#" + name + __typename: kind->match( + ['dog', 'Canine'], + ['cat', 'Feline'], + [@, 'Exotic'], + ) + "# + ) + .apply_to(&json!({ + "kind": "dog", + "name": "Laika", + })), + ( + Some(json!({ + "__typename": "Canine", + "name": "Laika", + })), + vec![], + ), + ); + + assert_eq!( + selection!( + r#" + num: value->matchIf( + [@->typeof->eq('number'), @], + [true, 'not a number'] + ) + "# + ) + .apply_to(&json!({ "value": 123 })), + ( + Some(json!({ + "num": 123, + })), + vec![], + ), + ); + + assert_eq!( + selection!( + r#" + num: value->matchIf( + [@->typeof->eq('number'), @], + [true, 'not a number'] + ) + "# + ) + .apply_to(&json!({ "value": true })), + ( + Some(json!({ + "num": "not a number", + })), + vec![], + ), + ); + + assert_eq!( + selection!( + r#" + result->matchIf( + [@->typeof->eq('boolean'), @], + [true, 'not boolean'] + ) + "# + ) + .apply_to(&json!({ + "result": true, + })), + (Some(json!(true)), vec![]), + ); + + assert_eq!( + selection!( + r#" + result->match_if( + [@->typeof->eq('boolean'), @], + [true, 'not boolean'] + ) + "# + ) + .apply_to(&json!({ + "result": 321, + })), + (Some(json!("not boolean")), vec![]), + ); +} + +fn test_arithmetic_methods() { + assert_eq!( + selection!("$->add(1)").apply_to(&json!(2)), + (Some(json!(3)), vec![]), + ); + assert_eq!( + selection!("$->add(1.5)").apply_to(&json!(2)), + (Some(json!(3.5)), vec![]), + ); + assert_eq!( + selection!("$->add(1)").apply_to(&json!(2.5)), + (Some(json!(3.5)), vec![]), + ); + assert_eq!( + selection!("$->add(1, 2, 3, 5, 8)").apply_to(&json!(1)), + (Some(json!(20)), vec![]), + ); + + assert_eq!( + selection!("$->sub(1)").apply_to(&json!(2)), + (Some(json!(1)), vec![]), + ); + assert_eq!( + selection!("$->sub(1.5)").apply_to(&json!(2)), + (Some(json!(0.5)), vec![]), + ); + assert_eq!( + selection!("$->sub(10)").apply_to(&json!(2.5)), + (Some(json!(-7.5)), vec![]), + ); + assert_eq!( + selection!("$->sub(10, 2.5)").apply_to(&json!(2.5)), + (Some(json!(-10.0)), vec![]), + ); + + assert_eq!( + selection!("$->mul(2)").apply_to(&json!(3)), + (Some(json!(6)), vec![]), + ); + assert_eq!( + selection!("$->mul(2.5)").apply_to(&json!(3)), + (Some(json!(7.5)), vec![]), + ); + assert_eq!( + selection!("$->mul(2)").apply_to(&json!(3.5)), + (Some(json!(7.0)), vec![]), + ); + assert_eq!( + selection!("$->mul(-2.5)").apply_to(&json!(3.5)), + (Some(json!(-8.75)), vec![]), + ); + assert_eq!( + selection!("$->mul(2, 3, 5, 7)").apply_to(&json!(10)), + (Some(json!(2100)), vec![]), + ); + + assert_eq!( + selection!("$->div(2)").apply_to(&json!(6)), + (Some(json!(3)), vec![]), + ); + assert_eq!( + selection!("$->div(2.5)").apply_to(&json!(7.5)), + (Some(json!(3.0)), vec![]), + ); + assert_eq!( + selection!("$->div(2)").apply_to(&json!(7)), + (Some(json!(3)), vec![]), + ); + assert_eq!( + selection!("$->div(2.5)").apply_to(&json!(7)), + (Some(json!(2.8)), vec![]), + ); + assert_eq!( + selection!("$->div(2, 3, 5, 7)").apply_to(&json!(2100)), + (Some(json!(10)), vec![]), + ); + + assert_eq!( + selection!("$->mod(2)").apply_to(&json!(6)), + (Some(json!(0)), vec![]), + ); + assert_eq!( + selection!("$->mod(2.5)").apply_to(&json!(7.5)), + (Some(json!(0.0)), vec![]), + ); + assert_eq!( + selection!("$->mod(2)").apply_to(&json!(7)), + (Some(json!(1)), vec![]), + ); + assert_eq!( + selection!("$->mod(4)").apply_to(&json!(7)), + (Some(json!(3)), vec![]), + ); + assert_eq!( + selection!("$->mod(2.5)").apply_to(&json!(7)), + (Some(json!(2.0)), vec![]), + ); + assert_eq!( + selection!("$->mod(2, 3, 5, 7)").apply_to(&json!(2100)), + (Some(json!(0)), vec![]), + ); +} + +#[test] +fn test_array_methods() { + assert_eq!( + selection!("$->first").apply_to(&json!([1, 2, 3])), + (Some(json!(1)), vec![]), + ); + + assert_eq!(selection!("$->first").apply_to(&json!([])), (None, vec![]),); + + assert_eq!( + selection!("$->last").apply_to(&json!([1, 2, 3])), + (Some(json!(3)), vec![]), + ); + + assert_eq!(selection!("$->last").apply_to(&json!([])), (None, vec![]),); + + assert_eq!( + selection!("$->get(1)").apply_to(&json!([1, 2, 3])), + (Some(json!(2)), vec![]), + ); + + assert_eq!( + selection!("$->get(-1)").apply_to(&json!([1, 2, 3])), + (Some(json!(3)), vec![]), + ); + + assert_eq!( + selection!("numbers->map(@->get(-2))").apply_to(&json!({ + "numbers": [ + [1, 2, 3], + [5, 6], + ], + })), + (Some(json!([2, 5])), vec![]), + ); + + assert_eq!( + selection!("$->get(3)").apply_to(&json!([1, 2, 3])), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get(3) array index out of bounds", + "path": [], + }))] + ), + ); + + assert_eq!( + selection!("$->get(-4)").apply_to(&json!([1, 2, 3])), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get(-4) array index out of bounds", + "path": [], + }))] + ), + ); + + assert_eq!( + selection!("$->get").apply_to(&json!([1, 2, 3])), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get requires an argument", + "path": [], + }))] + ), + ); + + assert_eq!( + selection!("$->get('bogus')").apply_to(&json!([1, 2, 3])), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get(\"bogus\") requires an object input", + "path": [], + }))] + ), + ); + + assert_eq!( + selection!("$->has(1)").apply_to(&json!([1, 2, 3])), + (Some(json!(true)), vec![]), + ); + + assert_eq!( + selection!("$->has(5)").apply_to(&json!([1, 2, 3])), + (Some(json!(false)), vec![]), + ); + + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!([1, 2, 3, 4, 5])), + (Some(json!([2, 3])), vec![]), + ); + + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!([1, 2])), + (Some(json!([2])), vec![]), + ); + + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!([1])), + (Some(json!([])), vec![]), + ); + + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!([])), + (Some(json!([])), vec![]), + ); + + assert_eq!( + selection!("$->size").apply_to(&json!([])), + (Some(json!(0)), vec![]), + ); + + assert_eq!( + selection!("$->size").apply_to(&json!([1, 2, 3])), + (Some(json!(3)), vec![]), + ); +} + +#[test] +fn test_size_method_errors() { + assert_eq!( + selection!("$->size").apply_to(&json!(null)), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->size requires an array, string, or object input, not null", + "path": [], + }))] + ), + ); + + assert_eq!( + selection!("$->size").apply_to(&json!(true)), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->size requires an array, string, or object input, not boolean", + "path": [], + }))] + ), + ); + + assert_eq!( + selection!("count->size").apply_to(&json!({ + "count": 123, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->size requires an array, string, or object input, not number", + "path": ["count"], + }))] + ), + ); +} + +#[test] +fn test_string_methods() { + assert_eq!( + selection!("$->has(2)").apply_to(&json!("oyez")), + (Some(json!(true)), vec![]), + ); + + assert_eq!( + selection!("$->has(-2)").apply_to(&json!("oyez")), + (Some(json!(true)), vec![]), + ); + + assert_eq!( + selection!("$->has(10)").apply_to(&json!("oyez")), + (Some(json!(false)), vec![]), + ); + + assert_eq!( + selection!("$->has(-10)").apply_to(&json!("oyez")), + (Some(json!(false)), vec![]), + ); + + assert_eq!( + selection!("$->first").apply_to(&json!("hello")), + (Some(json!("h")), vec![]), + ); + + assert_eq!( + selection!("$->last").apply_to(&json!("hello")), + (Some(json!("o")), vec![]), + ); + + assert_eq!( + selection!("$->get(2)").apply_to(&json!("oyez")), + (Some(json!("e")), vec![]), + ); + + assert_eq!( + selection!("$->get(-1)").apply_to(&json!("oyez")), + (Some(json!("z")), vec![]), + ); + + assert_eq!( + selection!("$->get(3)").apply_to(&json!("oyez")), + (Some(json!("z")), vec![]), + ); + + assert_eq!( + selection!("$->get(4)").apply_to(&json!("oyez")), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get(4) string index out of bounds", + "path": [], + }))] + ), + ); + + assert_eq!( + selection!("$->get($->echo(-5)->mul(2))").apply_to(&json!("oyez")), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get(-10) string index out of bounds", + "path": [], + }))] + ), + ); + + assert_eq!( + selection!("$->get(true)").apply_to(&json!("input")), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get(true) requires an integer or string argument", + "path": [], + }))] + ), + ); + + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!("")), + (Some(json!("")), vec![]), + ); + + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!("hello")), + (Some(json!("el")), vec![]), + ); + + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!("he")), + (Some(json!("e")), vec![]), + ); + + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!("h")), + (Some(json!("")), vec![]), + ); + + assert_eq!( + selection!("$->size").apply_to(&json!("hello")), + (Some(json!(5)), vec![]), + ); + + assert_eq!( + selection!("$->size").apply_to(&json!("")), + (Some(json!(0)), vec![]), + ); +} + +#[test] +fn test_object_methods() { + assert_eq!( + selection!("object->has('a')").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!(true)), vec![]), + ); + + assert_eq!( + selection!("object->has('c')").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!(false)), vec![]), + ); + + assert_eq!( + selection!("object->has(true)").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!(false)), vec![]), + ); + + assert_eq!( + selection!("object->has(null)").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!(false)), vec![]), + ); + + assert_eq!( + selection!("object->has('a')->and(object->has('b'))").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!(true)), vec![]), + ); + + assert_eq!( + selection!("object->has('b')->and(object->has('c'))").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!(false)), vec![]), + ); + + assert_eq!( + selection!("object->has('xxx')->typeof").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!("boolean")), vec![]), + ); + + assert_eq!( + selection!("$->size").apply_to(&json!({ "a": 1, "b": 2, "c": 3 })), + (Some(json!(3)), vec![]), + ); + + assert_eq!( + selection!("$->size").apply_to(&json!({})), + (Some(json!(0)), vec![]), + ); + + assert_eq!( + selection!("$->get('a')").apply_to(&json!({ + "a": 1, + "b": 2, + "c": 3, + })), + (Some(json!(1)), vec![]), + ); + + assert_eq!( + selection!("$->get('b')").apply_to(&json!({ + "a": 1, + "b": 2, + "c": 3, + })), + (Some(json!(2)), vec![]), + ); + + assert_eq!( + selection!("$->get('c')").apply_to(&json!({ + "a": 1, + "b": 2, + "c": 3, + })), + (Some(json!(3)), vec![]), + ); + + assert_eq!( + selection!("$->get('d')").apply_to(&json!({ + "a": 1, + "b": 2, + "c": 3, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get(\"d\") object key not found", + "path": [], + }))] + ), + ); + + assert_eq!( + selection!("$->get('a')->add(10)").apply_to(&json!({ + "a": 1, + "b": 2, + "c": 3, + })), + (Some(json!(11)), vec![]), + ); + + assert_eq!( + selection!("$->get('b')->add(10)").apply_to(&json!({ + "a": 1, + "b": 2, + "c": 3, + })), + (Some(json!(12)), vec![]), + ); + + assert_eq!( + selection!("$->keys").apply_to(&json!({ + "a": 1, + "b": 2, + "c": 3, + })), + (Some(json!(["a", "b", "c"])), vec![]), + ); + + assert_eq!( + selection!("$->keys").apply_to(&json!({})), + (Some(json!([])), vec![]), + ); + + assert_eq!( + selection!("notAnObject->keys").apply_to(&json!({ + "notAnObject": 123, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->keys requires an object input, not number", + "path": ["notAnObject"], + }))] + ), + ); + + assert_eq!( + selection!("$->values").apply_to(&json!({ + "a": 1, + "b": "two", + "c": false, + })), + (Some(json!([1, "two", false])), vec![]), + ); + + assert_eq!( + selection!("$->values").apply_to(&json!({})), + (Some(json!([])), vec![]), + ); + + assert_eq!( + selection!("notAnObject->values").apply_to(&json!({ + "notAnObject": null, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->values requires an object input, not null", + "path": ["notAnObject"], + }))] + ), + ); + + assert_eq!( + selection!("$->entries").apply_to(&json!({ + "a": 1, + "b": "two", + "c": false, + })), + ( + Some(json!([ + { "key": "a", "value": 1 }, + { "key": "b", "value": "two" }, + { "key": "c", "value": false }, + ])), + vec![], + ), + ); + + assert_eq!( + // This is just like $->keys, given the automatic array mapping of + // .key, though you probably want to use ->keys directly because it + // avoids cloning all the values unnecessarily. + selection!("$->entries.key").apply_to(&json!({ + "one": 1, + "two": 2, + "three": 3, + })), + (Some(json!(["one", "two", "three"])), vec![]), + ); + + assert_eq!( + // This is just like $->values, given the automatic array mapping of + // .value, though you probably want to use ->values directly because + // it avoids cloning all the keys unnecessarily. + selection!("$->entries.value").apply_to(&json!({ + "one": 1, + "two": 2, + "three": 3, + })), + (Some(json!([1, 2, 3])), vec![]), + ); + + assert_eq!( + selection!("$->entries").apply_to(&json!({})), + (Some(json!([])), vec![]), + ); + + assert_eq!( + selection!("notAnObject->entries").apply_to(&json!({ + "notAnObject": true, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->entries requires an object input, not boolean", + "path": ["notAnObject"], + }))] + ), + ); +} + +#[test] +fn test_logical_methods() { + assert_eq!( + selection!("$->map(@->not)").apply_to(&json!([ + true, + false, + 0, + 1, + -123, + null, + "hello", + {}, + [], + ])), + ( + Some(json!([ + false, true, true, false, false, true, false, false, false, + ])), + vec![], + ), + ); + + assert_eq!( + selection!("$->map(@->not->not)").apply_to(&json!([ + true, + false, + 0, + 1, + -123, + null, + "hello", + {}, + [], + ])), + ( + Some(json!([ + true, false, false, true, true, false, true, true, true, + ])), + vec![], + ), + ); + + assert_eq!( + selection!("$.a->and($.b, $.c)").apply_to(&json!({ + "a": true, + "b": null, + "c": true, + })), + (Some(json!(false)), vec![]), + ); + assert_eq!( + selection!("$.b->and($.c, $.a)").apply_to(&json!({ + "a": "hello", + "b": true, + "c": 123, + })), + (Some(json!(true)), vec![]), + ); + assert_eq!( + selection!("$.both->and($.and)").apply_to(&json!({ + "both": true, + "and": true, + })), + (Some(json!(true)), vec![]), + ); + assert_eq!( + selection!("data.x->and($.data.y)").apply_to(&json!({ + "data": { + "x": true, + "y": false, + }, + })), + (Some(json!(false)), vec![]), + ); + + assert_eq!( + selection!("$.a->or($.b, $.c)").apply_to(&json!({ + "a": true, + "b": null, + "c": true, + })), + (Some(json!(true)), vec![]), + ); + assert_eq!( + selection!("$.b->or($.a, $.c)").apply_to(&json!({ + "a": false, + "b": null, + "c": 0, + })), + (Some(json!(false)), vec![]), + ); + assert_eq!( + selection!("$.both->or($.and)").apply_to(&json!({ + "both": true, + "and": false, + })), + (Some(json!(true)), vec![]), + ); + assert_eq!( + selection!("data.x->or($.data.y)").apply_to(&json!({ + "data": { + "x": false, + "y": false, + }, + })), + (Some(json!(false)), vec![]), + ); +} diff --git a/apollo-federation/src/sources/connect/json_selection/mod.rs b/apollo-federation/src/sources/connect/json_selection/mod.rs index 61a94591ea7..37119b630f3 100644 --- a/apollo-federation/src/sources/connect/json_selection/mod.rs +++ b/apollo-federation/src/sources/connect/json_selection/mod.rs @@ -1,16 +1,19 @@ mod apply_to; mod graphql; mod helpers; -mod parameter_extraction; +mod immutable; +mod known_var; +mod lit_expr; +mod methods; mod parser; mod pretty; mod selection_set; pub use apply_to::*; -pub use parameter_extraction::*; -pub use parser::*; // Pretty code is currently only used in tests, so this cfg is to suppress the // unused lint warning. If pretty code is needed in not test code, feel free to // remove the `#[cfg(test)]`. +pub(crate) use known_var::*; +pub use parser::*; #[cfg(test)] pub use pretty::*; diff --git a/apollo-federation/src/sources/connect/json_selection/parameter_extraction.rs b/apollo-federation/src/sources/connect/json_selection/parameter_extraction.rs deleted file mode 100644 index 0a037ba176b..00000000000 --- a/apollo-federation/src/sources/connect/json_selection/parameter_extraction.rs +++ /dev/null @@ -1,113 +0,0 @@ -use std::collections::HashSet; - -use super::JSONSelection; -use super::NamedSelection; -use super::PathSelection; -use super::SubSelection; - -/// A representation of a static parameter. -/// -/// Each parameter can include path components for drilling down into specific -/// members of a parameter. -/// -/// Note: This is somewhat related to [crate::sources::connect::Parameter] -/// but is less restrictive as it does not do any formal validation of parameters. -/// -/// e.g. A parameter like below -/// ```json_selection -/// $this.a.b.c -/// ``` -/// -/// would have the following representation: -/// ```rust -/// # use apollo_federation::sources::connect::StaticParameter; -/// StaticParameter { -/// name: "this", -/// paths: vec!["a", "b", "c"], -/// } -/// # ; -/// ``` -#[derive(Debug, Hash, PartialEq, Eq)] -pub struct StaticParameter<'a> { - /// The name of the parameter, after the $ - /// TODO: This might be nice to have as an enum, but it requires making - /// extraction fallible. Another option would be to have JSONSelection aware - /// of which variables it knows about, but that might not make sense to have - /// as a responsibility of JSONSelection. - pub name: &'a str, - - /// Any paths after the name - pub paths: Vec<&'a str>, -} - -pub trait ExtractParameters { - /// Extract parameters for static analysis - fn extract_parameters(&self) -> Option>; -} - -impl ExtractParameters for JSONSelection { - fn extract_parameters(&self) -> Option> { - match &self { - JSONSelection::Named(named) => named.extract_parameters(), - JSONSelection::Path(path) => path.extract_parameters(), - } - } -} - -impl ExtractParameters for PathSelection { - fn extract_parameters(&self) -> Option> { - let param = match &self { - PathSelection::Var(name, rest) => Some(StaticParameter { - name: name.as_str(), - paths: rest - .collect_paths() - .iter() - // We don't run `to_string` here since path implements display and prepends - // a '.' to the path components - .map(|k| match k { - super::Key::Field(val) | super::Key::Quoted(val) => val.as_str(), - super::Key::Index(_) => "[]", // TODO: Remove when JSONSelection removes it - }) - .collect(), - }), - PathSelection::Key(_, _) | PathSelection::Selection(_) | PathSelection::Empty => None, - }; - - param.map(|p| { - let mut set = HashSet::with_hasher(Default::default()); - set.insert(p); - - set - }) - } -} - -impl ExtractParameters for SubSelection { - fn extract_parameters(&self) -> Option> { - let params: HashSet<_> = self - .selections - .iter() - .filter_map(NamedSelection::extract_parameters) - .flatten() - .collect(); - - if params.is_empty() { - None - } else { - Some(params) - } - } -} - -impl ExtractParameters for NamedSelection { - fn extract_parameters(&self) -> Option> { - match &self { - NamedSelection::Field(_, _, Some(sub)) - | NamedSelection::Quoted(_, _, Some(sub)) - | NamedSelection::Group(_, sub) => sub.extract_parameters(), - - NamedSelection::Path(_, path) => path.extract_parameters(), - _ => None, - } - } -} diff --git a/apollo-federation/src/sources/connect/json_selection/parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs index df2bf09337a..134c7ccef5e 100644 --- a/apollo-federation/src/sources/connect/json_selection/parser.rs +++ b/apollo-federation/src/sources/connect/json_selection/parser.rs @@ -1,6 +1,7 @@ use std::fmt::Display; use nom::branch::alt; +use nom::bytes::complete::tag; use nom::character::complete::char; use nom::character::complete::one_of; use nom::combinator::all_consuming; @@ -13,15 +14,20 @@ use nom::sequence::pair; use nom::sequence::preceded; use nom::sequence::tuple; use nom::IResult; -use serde::Serialize; use serde_json_bytes::Value as JSON; use super::helpers::spaces_or_comments; +use super::known_var::KnownVariable; +use super::lit_expr::LitExpr; + +pub(crate) trait ExternalVarPaths { + fn external_var_paths(&self) -> Vec<&PathSelection>; +} // JSONSelection ::= NakedSubSelection | PathSelection // NakedSubSelection ::= NamedSelection* StarSelection? -#[derive(Debug, PartialEq, Clone, Serialize)] +#[derive(Debug, PartialEq, Clone)] pub enum JSONSelection { // Although we reuse the SubSelection type for the JSONSelection::Named // case, we parse it as a sequence of NamedSelection items without the @@ -43,24 +49,13 @@ impl JSONSelection { JSONSelection::Named(subselect) => { subselect.selections.is_empty() && subselect.star.is_none() } - JSONSelection::Path(path) => path == &PathSelection::Empty, + JSONSelection::Path(path) => path.path == PathList::Empty, } } pub fn parse(input: &str) -> IResult<&str, Self> { alt(( - all_consuming(map( - tuple(( - many0(NamedSelection::parse), - // When a * selection is used, it must be the last selection - // in the sequence, since it is not a NamedSelection. - opt(StarSelection::parse), - // In case there were no named selections and no * selection, we - // still want to consume any space before the end of the input. - spaces_or_comments, - )), - |(selections, star, _)| Self::Named(SubSelection { selections, star }), - )), + all_consuming(map(SubSelection::parse_naked, Self::Named)), all_consuming(map(PathSelection::parse, Self::Path)), ))(input) } @@ -80,13 +75,22 @@ impl JSONSelection { } } +impl ExternalVarPaths for JSONSelection { + fn external_var_paths(&self) -> Vec<&PathSelection> { + match self { + JSONSelection::Named(subselect) => subselect.external_var_paths(), + JSONSelection::Path(path) => path.external_var_paths(), + } + } +} + // NamedSelection ::= NamedPathSelection | NamedFieldSelection | NamedQuotedSelection | NamedGroupSelection // NamedPathSelection ::= Alias PathSelection // NamedFieldSelection ::= Alias? Identifier SubSelection? -// NamedQuotedSelection ::= Alias StringLiteral SubSelection? +// NamedQuotedSelection ::= Alias LitString SubSelection? // NamedGroupSelection ::= Alias SubSelection -#[derive(Debug, PartialEq, Clone, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone)] pub enum NamedSelection { Field(Option, String, Option), Quoted(Alias, String, Option), @@ -188,23 +192,130 @@ impl NamedSelection { } } -// PathSelection ::= (VarPath | KeyPath) SubSelection? +impl ExternalVarPaths for NamedSelection { + fn external_var_paths(&self) -> Vec<&PathSelection> { + match self { + NamedSelection::Field(_, _, Some(sub)) + | NamedSelection::Quoted(_, _, Some(sub)) + | NamedSelection::Group(_, sub) => sub.external_var_paths(), + NamedSelection::Path(_, path) => path.external_var_paths(), + _ => vec![], + } + } +} + +// PathSelection ::= (VarPath | KeyPath | AtPath) SubSelection? // VarPath ::= "$" (NO_SPACE Identifier)? PathStep* // KeyPath ::= Key PathStep+ +// AtPath ::= "@" PathStep* // PathStep ::= "." Key | "->" Identifier MethodArgs? -#[derive(Debug, PartialEq, Clone, Serialize)] -pub enum PathSelection { - // We use a recursive structure here instead of a Vec to make applying - // the selection to a JSON value easier. - Var(String, Box), - Key(Key, Box), +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct PathSelection { + pub(super) path: PathList, +} + +impl PathSelection { + pub fn parse(input: &str) -> IResult<&str, Self> { + let (input, path) = PathList::parse(input)?; + Ok((input, Self { path })) + } + + pub(crate) fn var_name_and_nested_keys(&self) -> Option<(&KnownVariable, Vec<&str>)> { + match &self.path { + PathList::Var(var_name, tail) => Some((var_name, tail.prefix_of_keys())), + _ => None, + } + } + + pub(super) fn is_single_key(&self) -> bool { + self.path.is_single_key() + } + + pub(super) fn from_slice(keys: &[Key], selection: Option) -> Self { + Self { + path: PathList::from_slice(keys, selection), + } + } + + pub(super) fn next_subselection(&self) -> Option<&SubSelection> { + self.path.next_subselection() + } + + pub(super) fn next_mut_subselection(&mut self) -> Option<&mut SubSelection> { + self.path.next_mut_subselection() + } +} + +impl ExternalVarPaths for PathSelection { + fn external_var_paths(&self) -> Vec<&PathSelection> { + let mut paths = vec![]; + match &self.path { + PathList::Var(var_name, tail) => { + // The $ and @ variables refer to parts of the current JSON + // data, so they do not need to be surfaced as external variable + // references. + if var_name != &KnownVariable::Dollar && var_name != &KnownVariable::AtSign { + paths.push(self); + } + paths.extend(tail.external_var_paths()); + } + PathList::Key(_, tail) => { + paths.extend(tail.external_var_paths()); + } + PathList::Method(_, opt_args, tail) => { + if let Some(args) = opt_args { + for lit_arg in &args.0 { + paths.extend(lit_arg.external_var_paths()); + } + } + paths.extend(tail.external_var_paths()); + } + PathList::Selection(sub) => paths.extend(sub.external_var_paths()), + PathList::Empty => {} + }; + paths + } +} + +impl From for PathSelection { + fn from(path: PathList) -> Self { + Self { path } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub(super) enum PathList { + // A VarPath must start with a variable (either $identifier, $, or @), + // followed by any number of PathStep items (the Box). Because we + // represent the @ quasi-variable using PathList::Var, this variant handles + // both VarPath and AtPath from the grammar. The String variable name must + // always contain the $ character. The PathList::Var variant may only appear + // at the beginning of a PathSelection's PathList, not in the middle. + Var(KnownVariable, Box), + + // A PathSelection that starts with a PathList::Key is a KeyPath, but a + // PathList::Key also counts as PathStep item, so it may also appear in the + // middle/tail of a PathList. + Key(Key, Box), + + // A PathList::Method is a PathStep item that may appear only in the + // middle/tail (not the beginning) of a PathSelection. Methods are + // distinguished from .keys by their ->method invocation syntax. + Method(String, Option, Box), + + // Optionally, a PathList may end with a SubSelection, which applies a set + // of named selections to the final value of the path. PathList::Selection + // by itself is not a valid PathList. Selection(SubSelection), + + // Every PathList must be terminated by either PathList::Selection or + // PathList::Empty. PathList::Empty by itself is not a valid PathList. Empty, } -impl PathSelection { - pub(crate) fn parse(input: &str) -> IResult<&str, Self> { +impl PathList { + pub fn parse(input: &str) -> IResult<&str, Self> { match Self::parse_with_depth(input, 0) { Ok((remainder, Self::Empty)) => Err(nom::Err::Error(nom::error::Error::new( remainder, @@ -217,19 +328,41 @@ impl PathSelection { fn parse_with_depth(input: &str, depth: usize) -> IResult<&str, Self> { let (input, _spaces) = spaces_or_comments(input)?; - // Variable references and key references without a leading . are - // accepted only at depth 0, or at the beginning of the PathSelection. + // Variable references (including @ references) and key references + // without a leading . are accepted only at depth 0, or at the beginning + // of the PathSelection. if depth == 0 { if let Ok((suffix, opt_var)) = delimited( tuple((spaces_or_comments, char('$'))), - opt(parse_identifier), + opt(parse_identifier_no_space), spaces_or_comments, )(input) { let (input, rest) = Self::parse_with_depth(suffix, depth + 1)?; // Note the $ prefix is included in the variable name. - let dollar_var = format!("${}", opt_var.unwrap_or("".to_string())); - return Ok((input, Self::Var(dollar_var, Box::new(rest)))); + let var_name = format!("${}", opt_var.unwrap_or("".to_string())); + return if let Some(known_var) = KnownVariable::from_str(&var_name) { + Ok((input, Self::Var(known_var, Box::new(rest)))) + } else { + // Reject unknown variables at parse time. + // TODO Improve these parse error messages. + Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::IsNot, + ))) + }; + } + + if let Ok((suffix, _)) = + tuple((spaces_or_comments, char('@'), spaces_or_comments))(input) + { + let (input, rest) = Self::parse_with_depth(suffix, depth + 1)?; + // Because we include the $ in the variable name for ordinary + // variables, we have the freedom to store other symbols as + // special variables, such as @ for the current value. In fact, + // as long as we can parse the token(s) as a PathList::Var, the + // name of a variable could technically be any string we like. + return Ok((input, Self::Var(KnownVariable::AtSign, Box::new(rest)))); } if let Ok((suffix, key)) = Key::parse(input) { @@ -265,8 +398,19 @@ impl PathSelection { ))); } - // If the PathSelection has a SubSelection, it must appear at the end of - // a non-empty path. + // PathSelection can never start with a naked ->method (instead, use + // $->method if you want to operate on the current value). + if let Ok((suffix, (method, args))) = preceded( + tuple((spaces_or_comments, tag("->"), spaces_or_comments)), + tuple((parse_identifier, opt(MethodArgs::parse))), + )(input) + { + let (input, rest) = Self::parse_with_depth(suffix, depth + 1)?; + return Ok((input, Self::Method(method, args, Box::new(rest)))); + } + + // Likewise, if the PathSelection has a SubSelection, it must appear at + // the end of a non-empty path. if let Ok((suffix, selection)) = SubSelection::parse(input) { return Ok((suffix, Self::Selection(selection))); } @@ -276,7 +420,25 @@ impl PathSelection { Ok((input, Self::Empty)) } - pub(crate) fn from_slice(properties: &[Key], selection: Option) -> Self { + pub(super) fn is_single_key(&self) -> bool { + match self { + Self::Key(_, rest) => matches!(rest.as_ref(), Self::Selection(_) | Self::Empty), + _ => false, + } + } + + fn prefix_of_keys(&self) -> Vec<&str> { + match self { + Self::Key(key, rest) => { + let mut keys = vec![key.as_str()]; + keys.extend(rest.prefix_of_keys()); + keys + } + _ => vec![], + } + } + + pub(super) fn from_slice(properties: &[Key], selection: Option) -> Self { match properties { [] => selection.map_or(Self::Empty, Self::Selection), [head, tail @ ..] => { @@ -285,48 +447,60 @@ impl PathSelection { } } - /// Collect all nested paths - /// - /// This method attempts to collect as many paths as possible, shorting out once - /// a non path selection is encountered. - pub(crate) fn collect_paths(&self) -> Vec<&Key> { - let mut results = Vec::new(); - - // Collect as many as possible - let mut current = self; - while let Self::Key(key, rest) = current { - results.push(key); - - current = rest; + /// Find the next subselection, traversing nested chains if needed + pub(super) fn next_subselection(&self) -> Option<&SubSelection> { + match self { + Self::Var(_, tail) => tail.next_subselection(), + Self::Key(_, tail) => tail.next_subselection(), + Self::Method(_, _, tail) => tail.next_subselection(), + Self::Selection(sub) => Some(sub), + Self::Empty => None, } - - results } - /// Find the next subselection, traversing nested chains if needed - pub(crate) fn next_subselection(&self) -> Option<&SubSelection> { + /// Find the next subselection, traversing nested chains if needed. Returns a mutable reference + pub(super) fn next_mut_subselection(&mut self) -> Option<&mut SubSelection> { match self { - PathSelection::Var(_, path) => path.next_subselection(), - PathSelection::Key(_, path) => path.next_subselection(), - PathSelection::Selection(sub) => Some(sub), - PathSelection::Empty => None, + Self::Var(_, tail) => tail.next_mut_subselection(), + Self::Key(_, tail) => tail.next_mut_subselection(), + Self::Method(_, _, tail) => tail.next_mut_subselection(), + Self::Selection(sub) => Some(sub), + Self::Empty => None, } } +} - /// Find the next subselection, traversing nested chains if needed. Returns a mutable reference - pub(crate) fn next_mut_subselection(&mut self) -> Option<&mut SubSelection> { +impl ExternalVarPaths for PathList { + fn external_var_paths(&self) -> Vec<&PathSelection> { + let mut paths = vec![]; match self { - PathSelection::Var(_, path) => path.next_mut_subselection(), - PathSelection::Key(_, path) => path.next_mut_subselection(), - PathSelection::Selection(sub) => Some(sub), - PathSelection::Empty => None, + // PathSelection::collect_var_paths is responsible for adding all + // variable &PathSelection items to the set, since this + // PathList::Var case cannot be sure it's looking at the beginning + // of the path. However, we call rest.collect_var_paths() + // recursively because the tail of the list could contain other full + // PathSelection variable references. + PathList::Var(_, rest) | PathList::Key(_, rest) => { + paths.extend(rest.external_var_paths()); + } + PathList::Method(_, opt_args, rest) => { + if let Some(args) = opt_args { + for lit_arg in &args.0 { + paths.extend(lit_arg.external_var_paths()); + } + } + paths.extend(rest.external_var_paths()); + } + PathList::Selection(sub) => paths.extend(sub.external_var_paths()), + PathList::Empty => {} } + paths } } // SubSelection ::= "{" NakedSubSelection "}" -#[derive(Debug, PartialEq, Clone, Serialize, Default)] +#[derive(Debug, PartialEq, Eq, Clone, Default)] pub struct SubSelection { pub(super) selections: Vec, pub(super) star: Option, @@ -334,9 +508,16 @@ pub struct SubSelection { impl SubSelection { pub(crate) fn parse(input: &str) -> IResult<&str, Self> { + delimited( + tuple((spaces_or_comments, char('{'))), + Self::parse_naked, + tuple((char('}'), spaces_or_comments)), + )(input) + } + + fn parse_naked(input: &str) -> IResult<&str, Self> { tuple(( spaces_or_comments, - char('{'), many0(NamedSelection::parse), // Note that when a * selection is used, it must be the last // selection in the SubSelection, since it does not count as a @@ -344,10 +525,8 @@ impl SubSelection { // selections vector. opt(StarSelection::parse), spaces_or_comments, - char('}'), - spaces_or_comments, ))(input) - .map(|(input, (_, _, selections, star, _, _, _))| (input, Self { selections, star })) + .map(|(input, (_, selections, star, _))| (input, Self { selections, star })) } pub fn selections_iter(&self) -> impl Iterator { @@ -401,9 +580,19 @@ pub struct NamedSelectionIndex { pos: usize, } +impl ExternalVarPaths for SubSelection { + fn external_var_paths(&self) -> Vec<&PathSelection> { + let mut paths = vec![]; + for selection in &self.selections { + paths.extend(selection.external_var_paths()); + } + paths + } +} + // StarSelection ::= Alias? "*" SubSelection? -#[derive(Debug, PartialEq, Clone, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone)] pub struct StarSelection( pub(super) Option, pub(super) Option>, @@ -433,7 +622,7 @@ impl StarSelection { // Alias ::= Identifier ":" -#[derive(Debug, PartialEq, Clone, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone)] pub struct Alias { pub(super) name: String, } @@ -461,17 +650,16 @@ impl Alias { } } -// Key ::= Identifier | StringLiteral +// Key ::= Identifier | LitString -#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone)] pub enum Key { Field(String), Quoted(String), - Index(usize), } impl Key { - fn parse(input: &str) -> IResult<&str, Self> { + pub fn parse(input: &str) -> IResult<&str, Self> { alt(( map(parse_identifier, Self::Field), map(parse_string_literal, Self::Quoted), @@ -482,7 +670,6 @@ impl Key { match self { Key::Field(name) => JSON::String(name.clone().into()), Key::Quoted(name) => JSON::String(name.clone().into()), - Key::Index(index) => JSON::Number((*index).into()), } } @@ -493,7 +680,14 @@ impl Key { match self { Key::Field(name) => name.clone(), Key::Quoted(name) => name.clone(), - Key::Index(n) => n.to_string(), + } + } + // Like as_string, but without cloning a new String, for times when the Key + // itself lives longer than the &str. + pub fn as_str(&self) -> &str { + match self { + Key::Field(name) => name.as_str(), + Key::Quoted(name) => name.as_str(), } } @@ -511,7 +705,6 @@ impl Key { let quoted = serde_json_bytes::Value::String(field.clone().into()).to_string(); format!(".{quoted}") } - Key::Index(index) => format!(".{index}"), } } } @@ -528,22 +721,27 @@ impl Display for Key { fn parse_identifier(input: &str) -> IResult<&str, String> { delimited( spaces_or_comments, - recognize(pair( - one_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"), - many0(one_of( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789", - )), - )), + parse_identifier_no_space, spaces_or_comments, )(input) .map(|(input, name)| (input, name.to_string())) } -// StringLiteral ::= +fn parse_identifier_no_space(input: &str) -> IResult<&str, String> { + recognize(pair( + one_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"), + many0(one_of( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789", + )), + ))(input) + .map(|(input, name)| (input, name.to_string())) +} + +// LitString ::= // | "'" ("\\'" | [^'])* "'" // | '"' ('\\"' | [^"])* '"' -fn parse_string_literal(input: &str) -> IResult<&str, String> { +pub fn parse_string_literal(input: &str) -> IResult<&str, String> { let input = spaces_or_comments(input).map(|(input, _)| input)?; let mut input_char_indices = input.char_indices(); @@ -590,6 +788,35 @@ fn parse_string_literal(input: &str) -> IResult<&str, String> { } } +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct MethodArgs(pub(super) Vec); + +// Comma-separated positional arguments for a method, surrounded by parentheses. +// When an arrow method is used without arguments, the Option for +// the PathSelection::Method will be None, so we can safely define MethodArgs +// using a Vec in all cases (possibly empty but never missing). +impl MethodArgs { + fn parse(input: &str) -> IResult<&str, Self> { + delimited( + tuple((spaces_or_comments, char('('), spaces_or_comments)), + opt(map( + tuple(( + LitExpr::parse, + many0(preceded(char(','), LitExpr::parse)), + opt(char(',')), + )), + |(first, rest, _trailing_comma)| { + let mut output = vec![first]; + output.extend(rest); + output + }, + )), + tuple((spaces_or_comments, char(')'), spaces_or_comments)), + )(input) + .map(|(input, args)| (input, Self(args.unwrap_or_default()))) + } +} + #[cfg(test)] mod tests { use super::*; @@ -597,7 +824,7 @@ mod tests { #[test] fn test_identifier() { - assert_eq!(parse_identifier("hello"), Ok(("", "hello".to_string())),); + assert_eq!(parse_identifier("hello"), Ok(("", "hello".to_string()))); assert_eq!( parse_identifier("hello_world"), @@ -609,7 +836,25 @@ mod tests { Ok(("", "hello_world_123".to_string())), ); - assert_eq!(parse_identifier(" hello "), Ok(("", "hello".to_string())),); + assert_eq!(parse_identifier(" hello "), Ok(("", "hello".to_string()))); + + assert_eq!( + parse_identifier_no_space("oyez"), + Ok(("", "oyez".to_string())), + ); + + assert_eq!( + parse_identifier_no_space("oyez "), + Ok((" ", "oyez".to_string())), + ); + + assert_eq!( + parse_identifier_no_space(" oyez "), + Err(nom::Err::Error(nom::error::Error::new( + " oyez ", + nom::error::ErrorKind::OneOf + ))), + ); } #[test] @@ -1182,96 +1427,102 @@ mod tests { #[test] fn test_path_selection_vars() { check_path_selection( - "$var", - PathSelection::Var("$var".to_string(), Box::new(PathSelection::Empty)), + "$this", + PathList::Var(KnownVariable::This, Box::new(PathList::Empty)).into(), ); check_path_selection( "$", - PathSelection::Var("$".to_string(), Box::new(PathSelection::Empty)), + PathList::Var(KnownVariable::Dollar, Box::new(PathList::Empty)).into(), ); check_path_selection( - "$var { hello }", - PathSelection::Var( - "$var".to_string(), - Box::new(PathSelection::Selection(SubSelection { + "$this { hello }", + PathList::Var( + KnownVariable::This, + Box::new(PathList::Selection(SubSelection { selections: vec![NamedSelection::Field(None, "hello".to_string(), None)], star: None, })), - ), + ) + .into(), ); check_path_selection( "$ { hello }", - PathSelection::Var( - "$".to_string(), - Box::new(PathSelection::Selection(SubSelection { + PathList::Var( + KnownVariable::Dollar, + Box::new(PathList::Selection(SubSelection { selections: vec![NamedSelection::Field(None, "hello".to_string(), None)], star: None, })), - ), + ) + .into(), ); check_path_selection( - "$var { before alias: $args.arg after }", - PathSelection::Var( - "$var".to_string(), - Box::new(PathSelection::Selection(SubSelection { + "$this { before alias: $args.arg after }", + PathList::Var( + KnownVariable::This, + Box::new(PathList::Selection(SubSelection { selections: vec![ NamedSelection::Field(None, "before".to_string(), None), NamedSelection::Path( Alias { name: "alias".to_string(), }, - PathSelection::Var( - "$args".to_string(), - Box::new(PathSelection::Key( + PathList::Var( + KnownVariable::Args, + Box::new(PathList::Key( Key::Field("arg".to_string()), - Box::new(PathSelection::Empty), + Box::new(PathList::Empty), )), - ), + ) + .into(), ), NamedSelection::Field(None, "after".to_string(), None), ], star: None, })), - ), + ) + .into(), ); check_path_selection( "$.nested { key injected: $args.arg }", - PathSelection::Var( - "$".to_string(), - Box::new(PathSelection::Key( + PathList::Var( + KnownVariable::Dollar, + Box::new(PathList::Key( Key::Field("nested".to_string()), - Box::new(PathSelection::Selection(SubSelection { + Box::new(PathList::Selection(SubSelection { selections: vec![ NamedSelection::Field(None, "key".to_string(), None), NamedSelection::Path( Alias { name: "injected".to_string(), }, - PathSelection::Var( - "$args".to_string(), - Box::new(PathSelection::Key( + PathList::Var( + KnownVariable::Args, + Box::new(PathList::Key( Key::Field("arg".to_string()), - Box::new(PathSelection::Empty), + Box::new(PathList::Empty), )), - ), + ) + .into(), ), ], star: None, })), )), - ), + ) + .into(), ); check_path_selection( - "$root.a.b.c", - PathSelection::Var( - "$root".to_string(), - Box::new(PathSelection::from_slice( + "$args.a.b.c", + PathList::Var( + KnownVariable::Args, + Box::new(PathList::from_slice( &[ Key::Field("a".to_string()), Key::Field("b".to_string()), @@ -1279,7 +1530,8 @@ mod tests { ], None, )), - ), + ) + .into(), ); check_path_selection( @@ -1310,30 +1562,32 @@ mod tests { check_path_selection( "$.data", - PathSelection::Var( - "$".to_string(), - Box::new(PathSelection::Key( + PathList::Var( + KnownVariable::Dollar, + Box::new(PathList::Key( Key::Field("data".to_string()), - Box::new(PathSelection::Empty), + Box::new(PathList::Empty), )), - ), + ) + .into(), ); check_path_selection( "$.data.'quoted property'.nested", - PathSelection::Var( - "$".to_string(), - Box::new(PathSelection::Key( + PathList::Var( + KnownVariable::Dollar, + Box::new(PathList::Key( Key::Field("data".to_string()), - Box::new(PathSelection::Key( + Box::new(PathList::Key( Key::Quoted("quoted property".to_string()), - Box::new(PathSelection::Key( + Box::new(PathList::Key( Key::Field("nested".to_string()), - Box::new(PathSelection::Empty), + Box::new(PathList::Empty), )), )), )), - ), + ) + .into(), ); assert_eq!( @@ -1362,18 +1616,257 @@ mod tests { assert_eq!( selection!("$"), - JSONSelection::Path(PathSelection::Var( - "$".to_string(), - Box::new(PathSelection::Empty), - )), + JSONSelection::Path( + PathList::Var(KnownVariable::Dollar, Box::new(PathList::Empty)).into() + ), ); assert_eq!( selection!("$this"), - JSONSelection::Path(PathSelection::Var( - "$this".to_string(), - Box::new(PathSelection::Empty), - )), + JSONSelection::Path( + PathList::Var(KnownVariable::This, Box::new(PathList::Empty)).into() + ), + ); + + assert_eq!( + selection!("value: $ a { b c }"), + JSONSelection::Named(SubSelection { + selections: vec![ + NamedSelection::Path( + Alias::new("value"), + PathSelection { + path: PathList::Var(KnownVariable::Dollar, Box::new(PathList::Empty)), + }, + ), + NamedSelection::Field( + None, + "a".to_string(), + Some(SubSelection { + selections: vec![ + NamedSelection::Field(None, "b".to_string(), None), + NamedSelection::Field(None, "c".to_string(), None), + ], + star: None, + }), + ), + ], + star: None, + }), + ); + assert_eq!( + selection!("value: $this { b c }"), + JSONSelection::Named(SubSelection { + selections: vec![NamedSelection::Path( + Alias::new("value"), + PathSelection { + path: PathList::Var( + KnownVariable::This, + Box::new(PathList::Selection(SubSelection { + selections: vec![ + NamedSelection::Field(None, "b".to_string(), None), + NamedSelection::Field(None, "c".to_string(), None), + ], + star: None, + })), + ), + }, + ),], + star: None, + }), + ); + } + + #[test] + fn test_path_selection_at() { + check_path_selection( + "@", + PathSelection { + path: PathList::Var(KnownVariable::AtSign, Box::new(PathList::Empty)), + }, + ); + + check_path_selection( + "@.a.b.c", + PathSelection { + path: PathList::Var( + KnownVariable::AtSign, + Box::new(PathList::from_slice( + &[ + Key::Field("a".to_string()), + Key::Field("b".to_string()), + Key::Field("c".to_string()), + ], + None, + )), + ), + }, + ); + + check_path_selection( + "@.items->first", + PathSelection { + path: PathList::Var( + KnownVariable::AtSign, + Box::new(PathList::Key( + Key::Field("items".to_string()), + Box::new(PathList::Method( + "first".to_string(), + None, + Box::new(PathList::Empty), + )), + )), + ), + }, + ); + } + + #[test] + fn test_path_methods() { + check_path_selection( + "data.x->or(data.y)", + PathSelection { + path: PathList::Key( + Key::Field("data".to_string()), + Box::new(PathList::Key( + Key::Field("x".to_string()), + Box::new(PathList::Method( + "or".to_string(), + Some(MethodArgs(vec![LitExpr::Path(PathSelection::from_slice( + &[Key::Field("data".to_string()), Key::Field("y".to_string())], + None, + ))])), + Box::new(PathList::Empty), + )), + )), + ), + }, + ); + + { + let expected = PathSelection { + path: PathList::Key( + Key::Field("data".to_string()), + Box::new(PathList::Method( + "query".to_string(), + Some(MethodArgs(vec![ + LitExpr::Path(PathSelection::from_slice( + &[Key::Field("a".to_string())], + None, + )), + LitExpr::Path(PathSelection::from_slice( + &[Key::Field("b".to_string())], + None, + )), + LitExpr::Path(PathSelection::from_slice( + &[Key::Field("c".to_string())], + None, + )), + ])), + Box::new(PathList::Empty), + )), + ), + }; + check_path_selection("data->query(.a, .b, .c)", expected.clone()); + check_path_selection("data->query(.a, .b, .c )", expected.clone()); + check_path_selection("data->query(.a, .b, .c,)", expected.clone()); + check_path_selection("data->query(.a, .b, .c ,)", expected.clone()); + check_path_selection("data->query(.a, .b, .c , )", expected.clone()); + } + + { + let expected = PathSelection { + path: PathList::Key( + Key::Field("data".to_string()), + Box::new(PathList::Key( + Key::Field("x".to_string()), + Box::new(PathList::Method( + "concat".to_string(), + Some(MethodArgs(vec![LitExpr::Array(vec![ + LitExpr::Path(PathSelection::from_slice( + &[Key::Field("data".to_string()), Key::Field("y".to_string())], + None, + )), + LitExpr::Path(PathSelection::from_slice( + &[Key::Field("data".to_string()), Key::Field("z".to_string())], + None, + )), + ])])), + Box::new(PathList::Empty), + )), + )), + ), + }; + check_path_selection("data.x->concat([data.y, data.z])", expected.clone()); + check_path_selection("data.x->concat([ data.y, data.z ])", expected.clone()); + check_path_selection("data.x->concat([data.y, data.z,])", expected.clone()); + check_path_selection("data.x->concat([data.y, data.z , ])", expected.clone()); + check_path_selection("data.x->concat([data.y, data.z,],)", expected.clone()); + check_path_selection("data.x->concat([data.y, data.z , ] , )", expected.clone()); + } + + check_path_selection( + "data->method([$ { x2: x->times(2) }, $ { y2: y->times(2) }])", + PathSelection { + path: PathList::Key( + Key::Field("data".to_string()), + Box::new(PathList::Method( + "method".to_string(), + Some(MethodArgs(vec![LitExpr::Array(vec![ + LitExpr::Path(PathSelection { + path: PathList::Var( + KnownVariable::Dollar, + Box::new(PathList::Selection(SubSelection { + selections: vec![NamedSelection::Path( + Alias::new("x2"), + PathSelection { + path: PathList::Key( + Key::Field("x".to_string()), + Box::new(PathList::Method( + "times".to_string(), + Some(MethodArgs(vec![LitExpr::Number( + "2".parse().expect( + "serde_json::Number parse error", + ), + )])), + Box::new(PathList::Empty), + )), + ), + }, + )], + star: None, + })), + ), + }), + LitExpr::Path(PathSelection { + path: PathList::Var( + KnownVariable::Dollar, + Box::new(PathList::Selection(SubSelection { + selections: vec![NamedSelection::Path( + Alias::new("y2"), + PathSelection { + path: PathList::Key( + Key::Field("y".to_string()), + Box::new(PathList::Method( + "times".to_string(), + Some(MethodArgs(vec![LitExpr::Number( + "2".parse().expect( + "serde_json::Number parse error", + ), + )])), + Box::new(PathList::Empty), + )), + ), + }, + )], + star: None, + })), + ), + }), + ])])), + Box::new(PathList::Empty), + )), + ), + }, ); } @@ -1596,4 +2089,61 @@ mod tests { }), ); } + + #[test] + fn test_collect_var_paths() { + { + let sel = selection!( + r#" + $->echo([$args.arg1, $args.arg2, @.items->first]) + "# + ); + let args_arg1_path = PathSelection::parse("$args.arg1").unwrap().1; + let args_arg2_path = PathSelection::parse("$args.arg2").unwrap().1; + assert_eq!( + sel.external_var_paths(), + vec![&args_arg1_path, &args_arg2_path,] + ); + } + { + let sel = selection!( + r#" + $this.kind->match( + ["A", $this.a], + ["B", $this.b], + ["C", $this.c], + [@, @->to_lower_case], + ) + "# + ); + let this_kind_path = match &sel { + JSONSelection::Path(path) => path, + _ => panic!("Expected PathSelection"), + }; + let this_a_path = PathSelection::parse("$this.a").unwrap().1; + let this_b_path = PathSelection::parse("$this.b").unwrap().1; + let this_c_path = PathSelection::parse("$this.c").unwrap().1; + assert_eq!( + sel.external_var_paths(), + vec![this_kind_path, &this_a_path, &this_b_path, &this_c_path,] + ); + } + { + let sel = selection!( + r#" + data.results->slice($args.start, $args.end) { + id + __typename: $args.type + } + "# + ); + let start_path = PathSelection::parse("$args.start").unwrap().1; + let end_path = PathSelection::parse("$args.end").unwrap().1; + let args_type_path = PathSelection::parse("$args.type").unwrap().1; + assert_eq!( + sel.external_var_paths(), + vec![&start_path, &end_path, &args_type_path] + ); + } + } } diff --git a/apollo-federation/src/sources/connect/json_selection/pretty.rs b/apollo-federation/src/sources/connect/json_selection/pretty.rs index b607780eeb5..edb1d98e6d8 100644 --- a/apollo-federation/src/sources/connect/json_selection/pretty.rs +++ b/apollo-federation/src/sources/connect/json_selection/pretty.rs @@ -5,8 +5,11 @@ //! pretty printing trait which is then implemented on the various sub types //! of the JSONSelection tree. +use super::lit_expr::LitExpr; use crate::sources::connect::json_selection::JSONSelection; +use crate::sources::connect::json_selection::MethodArgs; use crate::sources::connect::json_selection::NamedSelection; +use crate::sources::connect::json_selection::PathList; use crate::sources::connect::json_selection::PathSelection; use crate::sources::connect::json_selection::StarSelection; use crate::sources::connect::json_selection::SubSelection; @@ -89,6 +92,27 @@ impl PrettyPrintable for SubSelection { } impl PrettyPrintable for PathSelection { + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { + let inner = self.path.pretty_print_with_indentation(inline, indentation); + // Because we can't tell where PathList::Key elements appear in the path + // once we're inside PathList::pretty_print_with_indentation, we print + // all PathList::Key elements with a leading '.' character, but we + // remove the initial '.' if the path has more than one element, because + // then the leading '.' is not necessary to disambiguate the key from a + // field. To complicate matters further, inner may begin with spaces due + // to indentation. + let leading_space_count = inner.chars().take_while(|c| *c == ' ').count(); + let suffix = inner[leading_space_count..].to_string(); + if suffix.starts_with('.') && !self.path.is_single_key() { + // Strip the '.' but keep any leading spaces. + format!("{}{}", " ".repeat(leading_space_count), &suffix[1..]) + } else { + inner + } + } +} + +impl PrettyPrintable for PathList { fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { let mut result = String::new(); @@ -97,22 +121,125 @@ impl PrettyPrintable for PathSelection { } match self { - PathSelection::Var(var, path) => { - let rest = path.pretty_print_with_indentation(true, indentation); + Self::Var(var, tail) => { + let rest = tail.pretty_print_with_indentation(true, indentation); result.push_str(var.as_str()); result.push_str(rest.as_str()); } - PathSelection::Key(key, path) => { - let rest = path.pretty_print_with_indentation(true, indentation); + Self::Key(key, tail) => { + let rest = tail.pretty_print_with_indentation(true, indentation); result.push_str(key.dotted().as_str()); result.push_str(rest.as_str()); } - PathSelection::Selection(sub) => { + Self::Method(method, args, tail) => { + result.push_str("->"); + result.push_str(method.as_str()); + if let Some(args) = args { + result.push_str( + args.pretty_print_with_indentation(true, indentation) + .as_str(), + ); + } + result.push_str( + tail.pretty_print_with_indentation(true, indentation) + .as_str(), + ); + } + Self::Selection(sub) => { let sub = sub.pretty_print_with_indentation(true, indentation); result.push(' '); result.push_str(sub.as_str()); } - PathSelection::Empty => {} + Self::Empty => {} + } + + result + } +} + +impl PrettyPrintable for MethodArgs { + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { + let mut result = String::new(); + + if !inline { + result.push_str(indent_chars(indentation).as_str()); + } + + result.push('('); + + // TODO Break long argument lists across multiple lines, with indentation? + for (i, arg) in self.0.iter().enumerate() { + if i > 0 { + result.push_str(", "); + } + result.push_str( + arg.pretty_print_with_indentation(true, indentation) + .as_str(), + ); + } + + result.push(')'); + + result + } +} + +impl PrettyPrintable for LitExpr { + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { + let mut result = String::new(); + if !inline { + result.push_str(indent_chars(indentation).as_str()); + } + + match self { + LitExpr::String(s) => { + let safely_quoted = serde_json_bytes::Value::String(s.clone().into()).to_string(); + result.push_str(safely_quoted.as_str()); + } + LitExpr::Number(n) => result.push_str(n.to_string().as_str()), + LitExpr::Bool(b) => result.push_str(b.to_string().as_str()), + LitExpr::Null => result.push_str("null"), + LitExpr::Object(map) => { + result.push('{'); + let mut is_first = true; + for (key, value) in map { + if is_first { + is_first = false; + } else { + result.push_str(", "); + } + let key = serde_json_bytes::Value::String(key.clone().into()).to_string(); + result.push_str(key.as_str()); + result.push_str(": "); + result.push_str( + value + .pretty_print_with_indentation(true, indentation) + .as_str(), + ); + } + result.push('}'); + } + LitExpr::Array(vec) => { + result.push('['); + let mut is_first = true; + for value in vec { + if is_first { + is_first = false; + } else { + result.push_str(", "); + } + result.push_str( + value + .pretty_print_with_indentation(true, indentation) + .as_str(), + ); + } + result.push(']'); + } + LitExpr::Path(path) => { + let path = path.pretty_print_with_indentation(inline, indentation); + result.push_str(path.as_str()); + } } result @@ -264,7 +391,7 @@ mod tests { "cool: beans", "cool: beans {\n whoa\n}", // Path - "cool: .one.two.three", + "cool: one.two.three", // Quoted r#"cool: "b e a n s""#, "cool: \"b e a n s\" {\n a\n b\n}", @@ -288,11 +415,12 @@ mod tests { // Var "$.one.two.three", "$this.a.b", - "$id.first {\n username\n}", + "$this.id.first {\n username\n}", // Key ".first", - ".a.b.c.d.e", - ".one.two.three {\n a\n b\n}", + "a.b.c.d.e", + "one.two.three {\n a\n b\n}", + ".single {\n x\n}", ]; for path in paths { let (unmatched, path_selection) = PathSelection::parse(path).unwrap(); diff --git a/apollo-federation/src/sources/connect/json_selection/selection_set.rs b/apollo-federation/src/sources/connect/json_selection/selection_set.rs index 1b22630d242..a246b8b8ba1 100644 --- a/apollo-federation/src/sources/connect/json_selection/selection_set.rs +++ b/apollo-federation/src/sources/connect/json_selection/selection_set.rs @@ -18,14 +18,17 @@ use std::collections::HashSet; +use apollo_compiler::collections::IndexMap; use apollo_compiler::executable::Field; use apollo_compiler::executable::Selection; use apollo_compiler::executable::SelectionSet; use apollo_compiler::Node; -use indexmap::IndexMap; use multimap::MultiMap; -use super::TYPENAMES; +use super::known_var::KnownVariable; +use super::lit_expr::LitExpr; +use super::parser::MethodArgs; +use super::parser::PathList; use crate::sources::connect::json_selection::Alias; use crate::sources::connect::json_selection::NamedSelection; use crate::sources::connect::JSONSelection; @@ -47,7 +50,7 @@ impl SubSelection { /// Apply a selection set to create a new [`SubSelection`] pub fn apply_selection_set(&self, selection_set: &SelectionSet) -> Self { let mut new_selections = Vec::new(); - let mut dropped_fields = IndexMap::new(); + let mut dropped_fields = IndexMap::default(); let mut referenced_fields = HashSet::new(); let field_map = map_fields_by_name(selection_set); @@ -64,13 +67,18 @@ impl SubSelection { Alias { name: "__typename".to_string(), }, - PathSelection::Var( - TYPENAMES.to_string(), - Box::new(PathSelection::Key( - Key::Field(selection_set.ty.to_string()), - Box::new(PathSelection::Empty), - )), - ), + PathSelection { + path: PathList::Var( + KnownVariable::Dollar, + Box::new(PathList::Method( + "echo".to_string(), + Some(MethodArgs(vec![LitExpr::String( + selection_set.ty.to_string(), + )])), + Box::new(PathList::Empty), + )), + ), + }, )); } @@ -183,16 +191,29 @@ impl SubSelection { impl PathSelection { /// Apply a selection set to create a new [`PathSelection`] + pub fn apply_selection_set(&self, selection_set: &SelectionSet) -> Self { + Self { + path: self.path.apply_selection_set(selection_set), + } + } +} + +impl PathList { pub fn apply_selection_set(&self, selection_set: &SelectionSet) -> Self { match self { - Self::Var(str, path) => Self::Var( - str.clone(), + Self::Var(name, path) => Self::Var( + name.clone(), Box::new(path.apply_selection_set(selection_set)), ), Self::Key(key, path) => Self::Key( key.clone(), Box::new(path.apply_selection_set(selection_set)), ), + Self::Method(method_name, args, path) => Self::Method( + method_name.clone(), + args.clone(), + Box::new(path.apply_selection_set(selection_set)), + ), Self::Selection(sub) => Self::Selection(sub.apply_selection_set(selection_set)), Self::Empty => Self::Empty, } @@ -201,9 +222,9 @@ impl PathSelection { #[inline] fn key_name(path_selection: &PathSelection) -> Option<&str> { - match path_selection { - PathSelection::Key(Key::Field(name), _) => Some(name), - PathSelection::Key(Key::Quoted(name), _) => Some(name), + match &path_selection.path { + PathList::Key(Key::Field(name), _) => Some(name), + PathList::Key(Key::Quoted(name), _) => Some(name), _ => None, } } @@ -237,8 +258,6 @@ mod tests { use apollo_compiler::Schema; use pretty_assertions::assert_eq; - use crate::sources::connect::ApplyTo; - fn selection_set(schema: &Valid, s: &str) -> SelectionSet { apollo_compiler::ExecutableDocument::parse_and_validate(schema, s, "./") .unwrap() @@ -306,7 +325,7 @@ mod tests { r###".result { z: a y: c - x: .e.f + x: e.f w: "i-j" v: { u: l @@ -393,7 +412,7 @@ mod tests { __unused__i: i rest: * } - path_to_f: .c.f + path_to_f: c.f rest: * }"### ); @@ -558,10 +577,10 @@ mod tests { assert_eq!( transformed.to_string(), r###".result { - __typename: $typenames.T + __typename: $->echo("T") id author: { - __typename: $typenames.A + __typename: $->echo("A") id: authorId } }"### diff --git a/apollo-federation/src/sources/connect/mod.rs b/apollo-federation/src/sources/connect/mod.rs index 2226447bc4a..db54b2d5b93 100644 --- a/apollo-federation/src/sources/connect/mod.rs +++ b/apollo-federation/src/sources/connect/mod.rs @@ -13,12 +13,10 @@ mod url_template; pub mod validation; use apollo_compiler::name; -pub use json_selection::ApplyTo; pub use json_selection::ApplyToError; pub use json_selection::JSONSelection; pub use json_selection::Key; pub use json_selection::PathSelection; -pub use json_selection::StaticParameter; pub use json_selection::SubSelection; pub use models::CustomConfiguration; pub(crate) use spec::ConnectSpecDefinition; diff --git a/apollo-federation/src/sources/connect/url_template.rs b/apollo-federation/src/sources/connect/url_template.rs index 9ef5dd7904c..3b90836d158 100644 --- a/apollo-federation/src/sources/connect/url_template.rs +++ b/apollo-federation/src/sources/connect/url_template.rs @@ -503,6 +503,14 @@ pub enum Parameter<'a> { /// Any optional nexted selection on the field paths: Vec<&'a str>, }, + + Config { + /// The sub-property of $config to use + item: &'a str, + + /// Any additional nested selections under $config.item + paths: Vec<&'a str>, + }, } impl Display for ParameterValue { diff --git a/apollo-router/src/plugins/connectors/handle_responses.rs b/apollo-router/src/plugins/connectors/handle_responses.rs index b5c741de8bb..506b05ef628 100644 --- a/apollo-router/src/plugins/connectors/handle_responses.rs +++ b/apollo-router/src/plugins/connectors/handle_responses.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use apollo_compiler::validation::Valid; use apollo_compiler::Schema; -use apollo_federation::sources::connect::ApplyTo; use apollo_federation::sources::connect::Connector; use http_body::Body as HttpBody; use parking_lot::Mutex; diff --git a/apollo-router/src/plugins/connectors/http_json_transport.rs b/apollo-router/src/plugins/connectors/http_json_transport.rs index 7e451ad9cb8..e60a77ffbeb 100644 --- a/apollo-router/src/plugins/connectors/http_json_transport.rs +++ b/apollo-router/src/plugins/connectors/http_json_transport.rs @@ -2,7 +2,6 @@ use std::collections::HashSet; use std::sync::Arc; use apollo_compiler::collections::IndexMap; -use apollo_federation::sources::connect::ApplyTo; use apollo_federation::sources::connect::HeaderSource; use apollo_federation::sources::connect::HttpJsonTransport; use apollo_federation::sources::connect::URLTemplate;