diff --git a/docs/source/spec/core/selectors.rst b/docs/source/spec/core/selectors.rst index 32c2c9279bd..d0ca2ed1c0d 100644 --- a/docs/source/spec/core/selectors.rst +++ b/docs/source/spec/core/selectors.rst @@ -70,34 +70,43 @@ Attribute selectors =================== *Attribute selectors* are used to match shapes based on -:ref:`shape IDs `, :ref:`traits `, and other properties. +:ref:`shape IDs `, :ref:`traits `, and other +:ref:`attributes `. -.. important:: - - Implementations MUST NOT fail when unknown attribute keys are - encountered; implementations SHOULD emit a warning and match no results - when an unknown attribute is encountered. +.. _attribute-existence: Attribute existence ------------------- -Checks for the existence of an attribute without any kind of -comparison. +An attribute existence check tests for the existence of an attribute without +any kind of comparison. -The following selector checks if a shape has the :ref:`deprecated-trait`: +The following selector matches shapes that are marked with the +:ref:`deprecated-trait`: .. code-block:: none [trait|deprecated] +Projected values from the :ref:`values-projection` and :ref:`keys-projection` +are only considered present if they yield one or more results. + +The following example matches all shapes that have an ``enum`` trait, +the trait contains at least one ``enum`` entry, and one or more entries +contains a non-empty ``tags`` list. + +.. code-block:: none + + [trait|enum|(values)|tags|(values)] + Attribute comparison -------------------- -An attribute selector with a comparator checks for the existence of an -attribute and compares the resolved attribute values to a comma separated -list of values. +An attribute selector with a :token:`comparator ` +checks for the existence of an attribute and compares the resolved +attribute values to a comma separated list of values. The following selector matches shapes that have the :ref:`documentation-trait` with a value set to an empty string: @@ -119,18 +128,19 @@ which one or more tags matches either "foo" or "baz". Attribute comparisons can be made case-insensitive by preceding the closing bracket with ``i``. -The following selector matches shapes that have a documentation string -that case-insensitively contains the word "FIXME": +The following selector matches shapes that have a :ref:`httpQuery-trait` +that case-insensitively contains the word "token": .. code-block:: none - [trait|documentation*=FIXME i] + [trait|httpQuery*=token i] Attribute comparators --------------------- -Attribute selectors support the following comparators: +Attribute selectors support the following +:token:`comparators `: .. list-table:: :header-rows: 1 @@ -160,6 +170,17 @@ Attribute selectors support the following comparators: * - ``<=`` - Matches if the attribute value is less than or equal to the comparison value. + * - ``?=`` + - Matches if the attribute value on the left hand side of the comparator + *exists* and matches the existence assertion on the right hand side. + This comparator uses the same rules defined in :ref:`attribute-existence`. + The comparator matches if the value exists and the right hand side of + the comparator is ``true``, or if the value does not exist and the + right hand side of the comparator is set to ``false``. + + +Relative comparators +~~~~~~~~~~~~~~~~~~~~ The ``<``, ``<=``, ``>``, ``>=`` comparators only match if both the attribute value and comparison value contain valid :token:`number` productions. If @@ -186,10 +207,27 @@ is not a valid number: [trait|httpError >= "not a number!"] +.. _selector-attributes: + +Attributes +========== + +Selector attributes return objects that MAY have nested properties. Objects +returned from selectors MAY be available to cast to a string. + +.. important:: + + Implementations MUST NOT fail when unknown attribute keys are + encountered; implementations SHOULD emit a warning and match no results + when an unknown attribute is encountered. + + ``id`` attribute ---------------- -Gets the full shape ID of a shape. +The ``id`` attribute returns an object that can be evaluated as a string. +When used as a string, ``id`` contains the full :ref:`shape ID ` +of a shape. The following example matches only the ``foo.baz#Structure`` shape: @@ -205,102 +243,160 @@ is enclosed in single or double quotes: [id='foo.baz#Structure$foo'] -``id|namespace`` attribute --------------------------- +``id`` properties +~~~~~~~~~~~~~~~~~ -Gets the namespace part of a shape ID. +The ``id`` attribute can be used as an object and has the +following properties. -The following example matches all shapes in the ``foo.baz`` namespace: +``namespace`` (``string``) + Gets the :token:`namespace` part of a shape ID. -.. code-block:: none + The following example matches all shapes in the ``foo.baz`` namespace: + + .. code-block:: none + + [id|namespace='foo.baz'] +``name`` (``string``) + Gets the name part of a shape ID. + + The following example matches all shapes in the model that have a shape + name of ``MyShape``. + + .. code-block:: none + + [id|name=MyShape] +``member`` (``string``) + Gets the member part of a shape ID (if available). + + The following example matches all members in the model that have a member + name of ``foo``. + + .. code-block:: none + + [id|member=foo] +``(length)`` + The :ref:`(length) attribute function ` + returns the length of the :token:`absolute shape ID ` + as a string. + + The following example matches all shapes with an absolute shape ID that + is longer than 100 characters: + + .. code-block:: none - [id|namespace=foo.baz] + [id|(length) > 100] -``id|name`` attribute +``service`` attribute --------------------- -Gets the name part of a shape ID. +The ``service`` attribute is an object that is available for service shapes. +The following selector matches all service shapes: -The following example matches all shapes in the model that have a shape -name of ``MyShape``. +.. code-block:: none + + [service] + +The intent of the above selector is more clearly stated using the following +selector: .. code-block:: none - [id|name=MyShape] + service +When compared to a string value, the ``service`` attribute returns an +empty string. -``id|member`` attribute ------------------------ -Gets the member part of a shape ID (if available). +``service`` properties +~~~~~~~~~~~~~~~~~~~~~~ -The following example matches all members in the model that have a member -name of ``foo``. +The ``service`` attribute contains the following properties: -.. code-block:: none +``version`` (``string``) + Gets the version property of a service shape if the shape is + a service. - [id|member=foo] + The following example matches all service shapes that have a version + property that starts with ``2018-``: + .. code-block:: none -``service|version`` attribute ------------------------------ + [service|version^='2018-'] +``(length)`` + Returns ``1``, the number of attribute supported by the service + property. -Gets the version property of a service shape if the shape is -a service. -The following example matches all service shapes that have a version -property that starts with ``2018-``: +``trait`` attribute +------------------- + +The ``trait`` attribute returns an object that contains every trait applied +to a shape. Each key of the ``trait`` object is the absolute shape ID of a +trait applied to the shape, and each value is the value of the applied trait. + +The following example matches all shapes that have the +:ref:`deprecated-trait`: .. code-block:: none - [service|version^='2018-'] + [trait|smithy.api#deprecated] +Traits in the ``smithy.api`` namespace MAY be retrieved from the ``trait`` +object without a namespace. -``trait|*`` attribute ---------------------- +.. code-block:: none -Gets the value of a trait applied to a shape, where "*" is the ID -of a trait. The ``smithy.api`` namespace MAY be omitted from shape IDs -provided to the ``trait`` attribute. Traits are converted to their -serialized :token:`node ` form when matching against their values. -Only string, Boolean, and numeric values can be compared with an expected -value. Boolean values are converted to "true" or "false". Numeric values -are converted to their string representation. + [trait|deprecated] + +Traits are converted to their serialized :token:`node ` form +when matching against their values. Only string, Boolean, and numeric +values can be compared with an expected value. Boolean values are converted +to "true" or "false". Numeric values are converted to their string +representation. -The following selector finds all structure shapes with the :ref:`error-trait` -trait, and the ``error`` trait can be set to any value: +The following selector matches all shapes with the :ref:`error-trait` set to +``client``: .. code-block:: none - structure[trait|error] + [trait|error=client] -The following selector finds all structure shapes with the :ref:`error-trait` -set to ``client``: +The following selector matches all shapes with the :ref:`error-trait`, but +the trait is not set to ``client``: .. code-block:: none - structure[trait|error=client] + [trait|error!=client] -The following selector finds all structure shapes with the :ref:`error-trait`, -but the trait is not set to ``client``: +The following selector matches all shapes with the :ref:`documentation-trait` +that have a value that contains "TODO" or "FIXME": .. code-block:: none - structure[trait|error!=client] + [trait|documentation *= TODO, FIXME] -Fully-qualified trait names are supported: +.. note:: + + When converted to a string, the ``trait`` attribute returns an + empty string. + +The ``(length)`` attribute function returns the number of traits applied to a +shape. + +The following example matches all shapes with more than 10 traits applied to it: .. code-block:: none - string[trait|smithy.example#customTrait=foo] + [trait|(length) > 10] -Nested trait properties -~~~~~~~~~~~~~~~~~~~~~~~~ +Nested attribute properties +--------------------------- -Nested properties of a trait can be selected using subsequent pipe (``|``) -delimited property names. +Nested properties of an object attribute can be selected using subsequent +pipe (``|``) delimited property names. The following example matches all shapes that have a :ref:`range-trait` with a ``min`` property set to ``1``: @@ -309,9 +405,79 @@ with a ``min`` property set to ``1``: [trait|range|min=1] -Values of a :token:`list ` can be selected using the special -``(values)`` syntax. Each element from the value currently being evaluated -is used as a new value to check subsequent properties against. + +.. _attribute-function-properties: + +Attribute function properties +----------------------------- + +:token:`Attribute function properties ` are used +to create :ref:`projections ` and apply other +functions on attributes. Attributes support the following functions: + +``(keys)`` + Creates a :ref:`keys-projection` on objects. +``(values)`` + Creates a :ref:`values-projection` on arrays and objects. +``(length)`` + Returns the number of elements in an array, the number of entries in an + object, or the number of characters in a string. + + The following example matches shapes where the name of the shape is + longer than 20 characters: + + .. code-block:: none + + [id|name|(length) > 20] + + The following example matches shapes where the :ref:`externalDocumentation-trait` + has more than 10 entries: + + .. code-block:: none + + [trait|externalDocumentation|(length) > 10] + + The following example checks if any ``enum`` trait definition contains + more than 100 tags: + + .. code-block:: none + + [trait|enum|(values)|tags|(length) > 100] + + The following example checks if any ``enum`` trait definition contains + a tag that is longer than 20 characters: + + .. code-block:: none + + [trait|enum|(values)|tags|(values)|(length) > 20] + +.. note:: + + Attribute functions are not actual properties of an attribute. They are + never yielded as part of the result of a ``(values)`` or ``(keys)`` + projection. + + +.. _attribute-projections: + +Attribute projections +--------------------- + +*Attribute projections* are values that perform set intersections with other +values. A projection is formed using either the ``(values)`` or ``(keys)`` +:token:`function-property `. + + +.. _values-projection: + +``(values)`` projection +~~~~~~~~~~~~~~~~~~~~~~~ + +The ``(values)`` property creates a *projection* of all values contained +in a :token:`list ` or :token:`object `. Each +element from the value currently being evaluated is used as a new value +to check subsequent properties against. A ``(values)`` projection on any +value other than an array or object yields no result. The following example matches all shapes that have an :ref:`enum-trait` that contains an enum definition with a ``tags`` property that is set to @@ -321,30 +487,31 @@ that contains an enum definition with a ``tags`` property that is set to [trait|enum|(values)|tags|(values)=internal] -An empty list is not considered present when checking for existence. - -The following example matches all shapes that have an ``enum`` trait, -the trait contains at least one ``enum`` entry, and one or more entries -contains a non-empty ``tags`` list. +The following example matches all shapes that have an :ref:`externalDocumentation-trait` +that has a value set to ``https://example.com``: .. code-block:: none - [trait|enum|(values)|tags|(values)] + [trait|externalDocumentation|(values)='https://example.com'] -Values of an :token:`object ` can also be selected using the -special ``(values)`` syntax. Each value from object currently being evaluated -is used as a new value to check subsequent properties against. - -The following example matches all shapes that have an :ref:`externalDocumentation-trait` -that has a value set to ``https://example.com``: +The following selector matches every trait applied to a shape that is a string +that contains a '$' character: .. code-block:: none - [trait|externalDocumentation|(values)='https://example.com'] + [trait|(values)*='$'] + + +.. _keys-projection: + +``(keys)`` projection +~~~~~~~~~~~~~~~~~~~~~ -Keys of an object can be selected using the special ``(keys)`` syntax. Each -key currently being evaluated is used as a new value to check subsequent -properties against. +The ``(keys)`` property creates a *projection* of all keys of an +:token:`object `. Each key of the object currently being +evaluated is used as a new value to check subsequent properties against. +A ``(keys)`` projection on any value other than an object yields no +result. The following example matches all shapes that have an ``externalDocumentation`` trait that has an entry named ``Homepage``: @@ -353,18 +520,29 @@ trait that has an entry named ``Homepage``: [trait|externalDocumentation|(keys)=Homepage] -Like the ``(values)`` property, the ``(keys)`` property also treats empty -objects as not present. - -The following example matches all shapes that have a trait named -``myMapTrait`` that has at least one entry: +The following selector matches shapes that apply any traits in the +``smithy.example`` namespace: .. code-block:: none - [trait|smithy.example#myMapTrait|(keys)] + [trait|(keys)^='smithy.example#'] + + +Projection comparisons +~~~~~~~~~~~~~~~~~~~~~~ + +When a projection is compared against a scalar value, the comparison matches +if any value in the projection satisfies the comparator assertion against the +scalar value. When a projection is compared against another projection, the +comparison matches if any value in the left projection satisfies the +comparator when compared against any value in the right projection. + + +Path traversal error handling +----------------------------- Implementations MUST tolerate expressions that do not perform a valid -traversal of a trait. The following example attempts to descend into +traversal of an attribute. The following example attempts to descend into non-existent properties of the :ref:`documentation-trait`. This example MUST not cause an error and MUST match no shapes: @@ -373,6 +551,104 @@ MUST not cause an error and MUST match no shapes: [trait|documentation|invalid|child=Hi] +Scoped attribute selectors +========================== + +A :token:`scoped attribute selector ` is similar to an +attribute selector, but it allows multiple complex comparisons to be made +against a scoped attribute. + + +Context values +-------------- + +The first part of a scoped attribute selector is the attribute that is scoped +for the expression, followed by ``:``. The scoped attribute is accessed using +a :token:`context value ` in the form of +``@{`` :token:`identifier` ``}``. + +In the following example, the ``trait|range`` attribute is used as the scoped +attribute of the expression, and the selector matches all shapes marked with +the :ref:`range-trait` where the ``min`` value is greater than the ``max`` +value: + +.. code-block:: none + + [@trait|range: @{min} > @{max}] + +The ``(values)`` and ``(keys)`` projections MAY be used as the scoped +attribute context value. When the scoped attribute context value is a +projection, each flattened value of the projection is individually tested +against each assertion. If any value from the projection matches the +assertions, then the selector matches the shape. + +The following selector matches shapes that have an :ref:`enum-trait` where one +or more of the enum definitions is both marked as ``deprecated`` and contains +an entry in its ``tags`` property named ``deprecated``. + +.. code-block:: none + + [@trait|enum|(values): + @{deprecated}=true && + @{tags|(values)}="deprecated"] + + +And-logic +--------- + +Selector assertions can be combined together using *and* statements with ``&&``. + +The following selector matches all shapes with the :ref:`idRef-trait` that +set ``failWhenMissing`` to true and omit an ``errorMessage``: + +.. code-block:: none + + [@trait|idRef: @{failWhenMissing}=true && @{errorMessage}?=false] + + +Matching multiple values +------------------------ + +Like non-scoped selectors, multiple values can be provided using a comma +separated list. One or more resolved attribute values MUST match one or more +provided values. + +The following selector matches all shapes with the :ref:`httpApiKeyAuth-trait` +where the ``in`` property is ``header`` and the ``name`` property is neither +``x-api-token`` or ``authorization``: + +.. code-block:: none + + [@trait|httpApiKeyAuth: + @{name}=header && + @{in}!='x-api-token', 'authorization'] + + +Case insensitive comparisons +---------------------------- + +The ``i`` token used before ``&&`` or the closing ``]`` makes a comparison +case-insensitive. + +The following selector matches on the ``httpApiKeyAuth`` trait using +case-insensitive comparisons: + +.. code-block:: none + + [@trait|httpApiKeyAuth: + @{name}=header i && + @{in}!='x-api-token', 'authorization' i] + +The following selector matches on the ``httpApiKeyAuth`` trait but only +uses a case-insensitive comparison on ``in``: + +.. code-block:: none + + [@trait|httpApiKeyAuth: + @{name}=header && + @{in}!='x-api-token', 'authorization' i] + + Neighbors ========= @@ -776,6 +1052,7 @@ Selectors are defined by the following ABNF_ grammar. selector :`selector_expression` *(`selector_expression`) selector_expression :`selector_shape_types` :/ `selector_attr` + :/ `selector_scoped_attr` :/ `selector_function_expression` :/ `selector_neighbor` selector_shape_types :"*" / `identifier` @@ -787,13 +1064,20 @@ Selectors are defined by the following ABNF_ grammar. selector_recursive_neighbor :"~>" selector_rel_type :`identifier` selector_attr :"[" `selector_key` *(`selector_comparator` `selector_values` ["i"]) "]" - selector_key :`identifier` *("|" `selector_key_path`) - selector_key_path :`selector_pseudo_key` / `selector_value` - selector_values :`selector_value` *("," `selector_value`) + selector_key :`identifier` ["|" `selector_path`] + selector_path :`selector_path_segment` *("|" `selector_path_segment`) + selector_path_segment :`selector_value` / `selector_function_property` selector_value :`selector_text` / `number` / `root_shape_id` + selector_function_property :"(" `identifier` ")" + selector_values :`selector_value` *("," `selector_value`) + selector_comparator :"^=" / "$=" / "*=" / "!=" / ">=" / ">" / "<=" / "<" / "?=" / "=" selector_absolute_root_shape_id :`namespace` "#" `identifier` - selector_pseudo_key :"(" `identifier` ")" - selector_comparator :"^=" / "$=" / "*=" / "!=" / ">=" / ">" / "<=" / "<" / "=" + selector_scoped_attr :"[@" `selector_key` ":" `selector_scoped_assertions` "]" + selector_scoped_assertions :`selector_scoped_assertion` *("&&" `selector_scoped_assertion`) + selector_scoped_assertion :`selector_scoped_value` `selector_comparator` `selector_scoped_values` ["i"] + selector_scoped_value :`selector_value` / `selector_context_value` + selector_context_value :"@{" `selector_path` "}" + selector_scoped_values :`selector_scoped_value` *("," `selector_scoped_value`) selector_function_expression :":" `selector_function` "(" `selector` *("," `selector`) ")" selector_function :`identifier` selector_text :`selector_single_quoted_text` / `selector_double_quoted_text` diff --git a/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/emit-each-selector-validator.errors b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/emit-each-selector-validator.errors index f8272884761..a03af84e154 100644 --- a/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/emit-each-selector-validator.errors +++ b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/emit-each-selector-validator.errors @@ -96,33 +96,32 @@ [DANGER] other.ns#String: Selector capture matched selector: [id|name='String'] | shapeName [DANGER] other.ns#String: Selector capture matched selector: simpleType | simpleType [NOTE] other.ns#String: The string shape is not connected to from any service shape. | UnreferencedShape -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "": Unable to deserialize Node using fromNode method: Syntax error at character 0 of 0, near ``: Unexpected selector EOF; expression `` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "": Unable to deserialize Node using fromNode method: Syntax error at character 0 of 0, near ``: Unexpected selector EOF; expression `` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "!": Unable to deserialize Node using fromNode method: Syntax error at character 0 of 1, near `!`: Unexpected selector character: !; expression `!` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "'foo'": Unable to deserialize Node using fromNode method: Syntax error at character 0 of 5, near `'foo'`: Unexpected selector character: '; expression `'foo'` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "\"foo\"": Unable to deserialize Node using fromNode method: Syntax error at character 0 of 5, near `"foo"`: Unexpected selector character: "; expression `"foo"` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "invalid": Unable to deserialize Node using fromNode method: Syntax error at character 7 of 7, near ``: Unknown shape type: invalid; expression `invalid` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[]": Unable to deserialize Node using fromNode method: Syntax error at character 1 of 2, near `]`: Invalid attribute start character `]`; expression `[]` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[foo|]": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 6, near `]`: Invalid attribute start character `]`; expression `[foo|]` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[|]": Unable to deserialize Node using fromNode method: Syntax error at character 1 of 3, near `|]`: Invalid attribute start character `|`; expression `[|]` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[a=]": Unable to deserialize Node using fromNode method: Syntax error at character 3 of 4, near `]`: Invalid attribute start character `]`; expression `[a=]` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[a=b": Unable to deserialize Node using fromNode method: Syntax error at character 4 of 4, near ``: Expected one of the following tokens: ']'; expression `[a=b` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "string=b": Unable to deserialize Node using fromNode method: Syntax error at character 6 of 8, near `=b`: Unexpected selector character: =; expression `string=b` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[foo=']": Unable to deserialize Node using fromNode method: Syntax error at character 6 of 7, near `]`: Expected ' to close ]; expression `[foo=']` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[foo=\"]": Unable to deserialize Node using fromNode method: Syntax error at character 6 of 7, near `]`: Expected " to close ]; expression `[foo="]` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[foo==value]": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 12, near `=value]`: Invalid attribute start character `=`; expression `[foo==value]` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[foo^foo]": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 9, near `foo]`: Expected one of the following tokens: '='; expression `[foo^foo]` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(:not(string) > list": Unable to deserialize Node using fromNode method: Syntax error at character 23 of 23, near ``: Expected one of the following tokens: ')' ','; expression `:is(:not(string) > list` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "foo -[]->": Unable to deserialize Node using fromNode method: Syntax error at character 3 of 9, near ` -[]->`: Unknown shape type: foo; expression `foo -[]->` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "foo -[input]->": Unable to deserialize Node using fromNode method: Syntax error at character 3 of 14, near ` -[input]->`: Unknown shape type: foo; expression `foo -[input]->` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":not": Unable to deserialize Node using fromNode method: Syntax error at character 4 of 4, near ``: Expected one of the following tokens: '('; expression `:not` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":not(": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 5, near ``: Unexpected selector EOF; expression `:not(` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":not()": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 6, near `)`: Unexpected selector character: ); expression `:not()` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":not(string": Unable to deserialize Node using fromNode method: Syntax error at character 11 of 11, near ``: Expected one of the following tokens: ')' ','; expression `:not(string` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is": Unable to deserialize Node using fromNode method: Syntax error at character 3 of 3, near ``: Expected one of the following tokens: '('; expression `:is` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(": Unable to deserialize Node using fromNode method: Syntax error at character 4 of 4, near ``: Unexpected selector EOF; expression `:is(` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":nay()": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 6, near `)`: Unexpected selector character: ); expression `:nay()` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(string": Unable to deserialize Node using fromNode method: Syntax error at character 10 of 10, near ``: Expected one of the following tokens: ')' ','; expression `:is(string` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(string, ": Unable to deserialize Node using fromNode method: Syntax error at character 12 of 12, near ``: Unexpected selector EOF; expression `:is(string, ` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(string, )": Unable to deserialize Node using fromNode method: Syntax error at character 12 of 13, near `)`: Unexpected selector character: ); expression `:is(string, )` | Model -[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(string, :not())": Unable to deserialize Node using fromNode method: Syntax error at character 17 of 19, near `))`: Unexpected selector character: ); expression `:is(string, :not())` | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "": Unable to deserialize Node using fromNode method: Syntax error at character 0 of 0, near ``: Unexpected selector EOF; expression: | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "!": Unable to deserialize Node using fromNode method: Syntax error at character 0 of 1, near `!`: Unexpected selector character: !; expression: ! | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "'foo'": Unable to deserialize Node using fromNode method: Syntax error at character 0 of 5, near `'foo'`: Unexpected selector character: '; expression: 'foo' | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "\"foo\"": Unable to deserialize Node using fromNode method: Syntax error at character 0 of 5, near `"foo"`: Unexpected selector character: "; expression: "foo" | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "invalid": Unable to deserialize Node using fromNode method: Syntax error at character 7 of 7, near ``: Unknown shape type: invalid; expression: invalid | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[]": Unable to deserialize Node using fromNode method: Syntax error at character 1 of 2, near `]`: Invalid attribute start character `]`; expression: [] | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[foo|]": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 6, near `]`: Invalid attribute start character `]`; expression: [foo|] | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[|]": Unable to deserialize Node using fromNode method: Syntax error at character 1 of 3, near `|]`: Invalid attribute start character `|`; expression: [|] | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[a=]": Unable to deserialize Node using fromNode method: Syntax error at character 3 of 4, near `]`: Invalid attribute start character `]`; expression: [a=] | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[a=b": Unable to deserialize Node using fromNode method: Syntax error at character 4 of 4, near ``: Expected one of the following tokens: ']'; expression: [a=b | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "string=b": Unable to deserialize Node using fromNode method: Syntax error at character 6 of 8, near `=b`: Unexpected selector character: =; expression: string=b | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[foo=']": Unable to deserialize Node using fromNode method: Syntax error at character 6 of 7, near `]`: Expected ' to close ]; expression: [foo='] | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[foo=\"]": Unable to deserialize Node using fromNode method: Syntax error at character 6 of 7, near `]`: Expected " to close ]; expression: [foo="] | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[foo==value]": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 12, near `=value]`: Invalid attribute start character `=`; expression: [foo==value] | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "[foo^foo]": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 9, near `foo]`: Expected one of the following tokens: '='; expression: [foo^foo] | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(:not(string) > list": Unable to deserialize Node using fromNode method: Syntax error at character 23 of 23, near ``: Expected one of the following tokens: ')' ','; expression: :is(:not(string) > list | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "foo -[]->": Unable to deserialize Node using fromNode method: Syntax error at character 3 of 9, near ` -[]->`: Unknown shape type: foo; expression: foo -[]-> | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from "foo -[input]->": Unable to deserialize Node using fromNode method: Syntax error at character 3 of 14, near ` -[input]->`: Unknown shape type: foo; expression: foo -[input]-> | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":not": Unable to deserialize Node using fromNode method: Syntax error at character 4 of 4, near ``: Expected one of the following tokens: '('; expression: :not | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":not(": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 5, near ``: Unexpected selector EOF; expression: :not( | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":not()": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 6, near `)`: Unexpected selector character: ); expression: :not() | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":not(string": Unable to deserialize Node using fromNode method: Syntax error at character 11 of 11, near ``: Expected one of the following tokens: ')' ','; expression: :not(string | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is": Unable to deserialize Node using fromNode method: Syntax error at character 3 of 3, near ``: Expected one of the following tokens: '('; expression: :is | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(": Unable to deserialize Node using fromNode method: Syntax error at character 4 of 4, near ``: Unexpected selector EOF; expression: :is( | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":nay()": Unable to deserialize Node using fromNode method: Syntax error at character 5 of 6, near `)`: Unexpected selector character: ); expression: :nay() | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(string": Unable to deserialize Node using fromNode method: Syntax error at character 10 of 10, near ``: Expected one of the following tokens: ')' ','; expression: :is(string | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(string, ": Unable to deserialize Node using fromNode method: Syntax error at character 12 of 12, near ``: Unexpected selector EOF; expression: :is(string, | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(string, )": Unable to deserialize Node using fromNode method: Syntax error at character 12 of 13, near `)`: Unexpected selector character: ); expression: :is(string, ) | Model +[ERROR] -: Error creating `EmitEachSelector` validator: Deserialization error at (/selector): unable to create software.amazon.smithy.model.selector.Selector from ":is(string, :not())": Unable to deserialize Node using fromNode method: Syntax error at character 17 of 19, near `))`: Unexpected selector character: ); expression: :is(string, :not()) | Model diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/selector/AttributeComparator.java b/smithy-model/src/main/java/software/amazon/smithy/model/selector/AttributeComparator.java new file mode 100644 index 00000000000..66add40a948 --- /dev/null +++ b/smithy-model/src/main/java/software/amazon/smithy/model/selector/AttributeComparator.java @@ -0,0 +1,128 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.model.selector; + +import java.math.BigDecimal; +import java.util.Locale; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * Compares two selector attribute values. + */ +@FunctionalInterface +interface AttributeComparator { + + AttributeComparator EQUALS = stringComparator(String::equals); + AttributeComparator NOT_EQUALS = stringComparator((a, b) -> !a.equals(b)); + AttributeComparator STARTS_WITH = stringComparator(String::startsWith); + AttributeComparator ENDS_WITH = stringComparator(String::endsWith); + AttributeComparator CONTAINS = stringComparator(String::contains); + AttributeComparator GT = stringComparator((a, b) -> numericComparison(a, b, result -> result == 1)); + AttributeComparator GTE = stringComparator((a, b) -> numericComparison(a, b, result -> result >= 0)); + AttributeComparator LT = stringComparator((a, b) -> numericComparison(a, b, result -> result <= -1)); + AttributeComparator LTE = stringComparator((a, b) -> numericComparison(a, b, result -> result <= 0)); + AttributeComparator EXISTS = AttributeComparator::existsCheck; + + /** + * Compares the left hand side value against the right using a comparator. + * + * @param lhs Left value of the comparison. + * @param rhs Right value of the comparison. + * @param caseInsensitive Whether or not the comparison is case-insensitive. + * @return Returns true if the values match the comparator. + */ + boolean compare(AttributeValue lhs, AttributeValue rhs, boolean caseInsensitive); + + /** + * Compares the given attribute values by flattening each side of the + * comparison, and comparing each value. + * + *

This method is necessary in order to support matching on projections. + * + * @param lhs The left hand side of the comparison. + * @param rhs The right hand side of the comparison. + * @param insensitive Whether or not to use a case-insensitive comparison. + * @return Returns true if the attributes match the comparator. + */ + default boolean flattenedCompare(AttributeValue lhs, AttributeValue rhs, boolean insensitive) { + for (AttributeValue l : lhs.getFlattenedValues()) { + for (AttributeValue r : rhs.getFlattenedValues()) { + if (compare(l, r, insensitive)) { + return true; + } + } + } + + return false; + } + + // String comparators simplify how comparisons are made on attribute + // values that MUST resolve to strings. + static AttributeComparator stringComparator(BiFunction compare) { + return (lhs, rhs, caseInsensitive) -> { + // Both values MUST be present to compare. + if (!lhs.isPresent() || !rhs.isPresent()) { + return false; + } + + String lhsString = lhs.toString(); + String rhsString = rhs.toString(); + + // Convert both sides of the comparison to lowercase when case insensitive. + if (caseInsensitive) { + lhsString = lhsString.toLowerCase(Locale.ENGLISH); + rhsString = rhsString.toLowerCase(Locale.ENGLISH); + } + + return compare.apply(lhsString, rhsString); + }; + } + + // Try to parse both numbers, ignore numeric failures since that's acceptable, + // then pass the result of calling compareTo on the numbers to the given + // evaluator. The evaluator then determines if the comparison is what was expected. + static boolean numericComparison(String lhs, String rhs, Function evaluator) { + BigDecimal lhsNumber = parseNumber(lhs); + if (lhsNumber == null) { + return false; + } + + BigDecimal rhsNumber = parseNumber(rhs); + if (rhsNumber == null) { + return false; + } + + return evaluator.apply(lhsNumber.compareTo(rhsNumber)); + } + + // Invalid numbers do not fail the parser or evaluation of a selector. + static BigDecimal parseNumber(String token) { + try { + return new BigDecimal(token); + } catch (NumberFormatException e) { + return null; + } + } + + // Checks if a value "exists" and if the expected boolean string matches + // the resolved existence boolean. + static boolean existsCheck(AttributeValue a, AttributeValue b, boolean caseInsensitive) { + String bString = b.toString(); + return (a.isPresent() && bString.equals("true")) + || (!a.isPresent() && b.toString().equals("false")); + } +} diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/selector/AttributeSelector.java b/smithy-model/src/main/java/software/amazon/smithy/model/selector/AttributeSelector.java index b97278c0b25..c7deca98a2a 100644 --- a/smithy-model/src/main/java/software/amazon/smithy/model/selector/AttributeSelector.java +++ b/smithy-model/src/main/java/software/amazon/smithy/model/selector/AttributeSelector.java @@ -15,138 +15,70 @@ package software.amazon.smithy.model.selector; -import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Locale; import java.util.Set; -import java.util.function.BiFunction; -import java.util.function.Function; import java.util.stream.Collectors; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.neighbor.NeighborProvider; -import software.amazon.smithy.model.shapes.ServiceShape; import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.utils.ListUtils; /** - * Matches shapes with a specific attribute. + * Matches shapes with a specific attribute or that matches an attribute comparator. */ final class AttributeSelector implements Selector { - static final Comparator EQUALS = String::equals; - static final Comparator NOT_EQUALS = (a, b) -> !a.equals(b); - static final Comparator STARTS_WITH = String::startsWith; - static final Comparator ENDS_WITH = String::endsWith; - static final Comparator CONTAINS = String::contains; - - static final Comparator GT = (a, b) -> numericComparison(a, b, i -> i == 1); - static final Comparator GTE = (a, b) -> numericComparison(a, b, i -> i >= 0); - static final Comparator LT = (a, b) -> numericComparison(a, b, i -> i <= -1); - static final Comparator LTE = (a, b) -> numericComparison(a, b, i -> i <= 0); - - static final KeyGetter KEY_ID = (shape) -> ListUtils.of(shape.getId().toString()); - static final KeyGetter KEY_ID_NAMESPACE = (shape) -> ListUtils.of(shape.getId().getNamespace()); - static final KeyGetter KEY_ID_NAME = (shape) -> ListUtils.of(shape.getId().getName()); - static final KeyGetter KEY_ID_MEMBER = (shape) -> shape.getId().getMember() - .map(Collections::singletonList) - .orElseGet(Collections::emptyList); - static final KeyGetter KEY_SERVICE_VERSION = (shape) -> shape.asServiceShape() - .map(ServiceShape::getVersion) - .map(Collections::singletonList) - .orElseGet(Collections::emptyList); - - private final KeyGetter key; - private final List expected; - private final Comparator comparator; + private final AttributeValue.Factory key; + private final List expected; + private final AttributeComparator comparator; private final boolean caseInsensitive; - interface KeyGetter extends Function> {} - - interface Comparator extends BiFunction {} - - AttributeSelector(KeyGetter key) { - this.key = key; - expected = null; - comparator = null; - caseInsensitive = false; - } - AttributeSelector( - KeyGetter key, - Comparator comparator, + AttributeValue.Factory key, List expected, + AttributeComparator comparator, boolean caseInsensitive ) { - this.expected = expected; this.key = key; this.caseInsensitive = caseInsensitive; this.comparator = comparator; - // Case insensitive comparisons are made by converting both - // side of the comparison to lowercase. - if (caseInsensitive) { - for (int i = 0; i < expected.size(); i++) { - expected.set(i, expected.get(i).toLowerCase(Locale.ENGLISH)); + // Create the valid values of the expected selector. + if (expected == null) { + this.expected = Collections.emptyList(); + } else { + this.expected = new ArrayList<>(expected.size()); + for (String validValue : expected) { + this.expected.add(new AttributeValue.Literal(validValue)); } } } + static AttributeSelector existence(AttributeValue.Factory key) { + return new AttributeSelector(key, null, null, false); + } + @Override public Set select(Model model, NeighborProvider neighborProvider, Set shapes) { return shapes.stream() - .filter(shape -> matchesAttribute(key.apply(shape))) + .filter(this::matchesAttribute) .collect(Collectors.toSet()); } - private boolean matchesAttribute(List result) { - if (comparator == null) { - return !result.isEmpty(); - } - - for (String attribute : result) { - // The returned attribute value might be null if - // the value exists, but isn't comparable. - if (attribute == null) { - continue; - } + private boolean matchesAttribute(Shape shape) { + AttributeValue lhs = key.create(shape); - if (caseInsensitive) { - attribute = attribute.toLowerCase(Locale.ENGLISH); - } + if (expected.isEmpty()) { + return lhs.isPresent(); + } - for (String value : expected) { - if (comparator.apply(attribute, value)) { - return true; - } + for (AttributeValue rhs : expected) { + if (comparator.flattenedCompare(lhs, rhs, caseInsensitive)) { + return true; } } return false; } - - // Try to parse both numbers, ignore numeric failures since that's acceptable, - // then pass the result of calling compareTo on the numbers to the given - // evaluator. The evaluator then determines if the comparison is what was expected. - private static boolean numericComparison(String lhs, String rhs, Function evaluator) { - BigDecimal lhsNumber = parseNumber(lhs); - if (lhsNumber == null) { - return false; - } - - BigDecimal rhsNumber = parseNumber(rhs); - if (rhsNumber == null) { - return false; - } - - return evaluator.apply(lhsNumber.compareTo(rhsNumber)); - } - - private static BigDecimal parseNumber(String token) { - try { - return new BigDecimal(token); - } catch (NumberFormatException e) { - return null; - } - } } diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/selector/AttributeValue.java b/smithy-model/src/main/java/software/amazon/smithy/model/selector/AttributeValue.java new file mode 100644 index 00000000000..fe46175cfc3 --- /dev/null +++ b/smithy-model/src/main/java/software/amazon/smithy/model/selector/AttributeValue.java @@ -0,0 +1,476 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.model.selector; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.BooleanNode; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NodeVisitor; +import software.amazon.smithy.model.node.NullNode; +import software.amazon.smithy.model.node.NumberNode; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.Trait; + +/** + * Selector attribute values are the data model of selectors. + */ +abstract class AttributeValue { + + /** Value used when a property or attribute value does not exist. **/ + static final AttributeValue NULL = new AttributeValue() { + @Override + boolean isPresent() { + return false; + } + + @Override + AttributeValue getProperty(String key) { + return NULL; + } + }; + + /** Value created and used when a property does not exist. */ + static final Factory NULL_FACTORY = shape -> NULL; + + private static final Logger LOGGER = Logger.getLogger(AttributeValue.class.getName()); + private static final String KEYS = "(keys)"; + private static final String VALUES = "(values)"; + private static final String LENGTH = "(length)"; + + @FunctionalInterface + interface Factory { + AttributeValue create(Shape shape); + } + + @Override + public String toString() { + return ""; + } + + /** + * Gets a property from the attribute value. + * + *

This method never returns null. It should instead return + * {@link #NULL} when the property does not exist. + * + * @param key Property to get. + * @return Returns the nested property. + */ + abstract AttributeValue getProperty(String key); + + /** + * Checks if the attribute value is considered present. + * + *

Attribute value are considered present if they are not null. If the + * attribute value is a projection, then it is considered present if it is + * not empty. + * + * @return Returns true if present. + */ + boolean isPresent() { + return true; + } + + /** + * Gets all of the attribute values contained in the attribute value. + * + *

This will yield a single result for normal attributes, or a list + * of multiple values for projections. + * + * @return Returns the flattened attribute values contained in the attribute value. + */ + Collection getFlattenedValues() { + return Collections.singleton(this); + } + + /** + * Creates the most efficient kind of selector value for the given path. + * + * @param current Value to path into. + * @param path The parsed path to select from the value. + * @return Returns the created selector value. + */ + static AttributeValue createPathSelector(AttributeValue current, List path) { + if (path.isEmpty()) { + return current; + } + + for (String segment : path) { + current = current.getProperty(segment); + if (!current.isPresent()) { + break; + } + } + + return current; + } + + /** + * An attribute that contains a static, scalar String value. + */ + static final class Literal extends AttributeValue { + final Object value; + + Literal(Object value) { + this.value = Objects.requireNonNull(value); + } + + @Override + public String toString() { + return value.toString(); + } + + @Override + public AttributeValue getProperty(String key) { + if (key.equals(LENGTH)) { + return new Literal(toString().length()); + } else { + return NULL; + } + } + } + + /** + * An attribute that contains a {@link Node}. + * + *

This kind of attribute is typically used when traversing into trait values. + * Properties of object nodes can be selected by name. {@link NullNode} are + * not considered present. The (values) pseudo-property can be used on object + * nodes and array nodes. The (keys) pseudo-property can be used on object nodes. + */ + static final class NodeValue extends AttributeValue { + static final NodeVisitor TO_STRING = new NodeToString(); + final Node value; + + NodeValue(Node value) { + this.value = value; + } + + @Override + public boolean isPresent() { + return !value.isNullNode(); + } + + @Override + public String toString() { + return value.accept(TO_STRING); + } + + @Override + AttributeValue getProperty(String key) { + switch (value.getType()) { + case OBJECT: + ObjectNode objectNode = value.expectObjectNode(); + switch (key) { + case KEYS: + return project(objectNode.getMembers().keySet()); + case VALUES: + return project(objectNode.getMembers().values()); + case LENGTH: + return new Literal(objectNode.getMembers().size()); + default: + return value.expectObjectNode() + .getMember(key) + .map(NodeValue::new) + .orElse(NULL); + } + case ARRAY: + ArrayNode arrayNode = value.expectArrayNode(); + switch (key) { + case VALUES: + return project(arrayNode.getElements()); + case LENGTH: + return new Literal(arrayNode.size()); + default: + return NULL; + } + case STRING: + if (key.equals(LENGTH)) { + return new Literal(value.expectStringNode().getValue().length()); + } + // fall through + default: + return NULL; + } + } + + private AttributeValue project(Collection nodes) { + return new Projection(nodes.stream().map(NodeValue::new).collect(Collectors.toList())); + } + + // Only string, numbers, and booleans are converted to strings. + private static final class NodeToString extends NodeVisitor.Default { + @Override + protected String getDefault(software.amazon.smithy.model.node.Node node) { + return ""; + } + + @Override + public String stringNode(StringNode node) { + return node.getValue(); + } + + @Override + public String numberNode(NumberNode node) { + return node.getValue().toString(); + } + + @Override + public String booleanNode(BooleanNode node) { + return Boolean.toString(node.getValue()); + } + } + } + + /** + * A projected attribute value that matches N values contained within it. + * + *

Projections wrap other values and match if any contained value matches, + * are only considered present if there are 1 or more values, and return + * new projections based on properties contained within the projection. + */ + static final class Projection extends AttributeValue { + final Collection values; + Collection flattened; + + Projection(Collection values) { + this.values = values; + } + + @Override + AttributeValue getProperty(String key) { + // All of the values that are yielded from the projected values + // come together to create a new projection. + List result = new ArrayList<>(); + for (AttributeValue value : values) { + AttributeValue next = value.getProperty(key); + if (next.isPresent()) { + result.add(next); + } + } + + return new Projection(result); + } + + /** + * Computes the flattened values of the projection. + * + * @return Returns the flattened values of the projection. + */ + Collection getFlattenedValues() { + if (flattened == null) { + List result = new ArrayList<>(values.size()); + for (AttributeValue value : values) { + // Projections need to be flattened! + if (value instanceof Projection) { + result.addAll(((Projection) value).getFlattenedValues()); + } else { + result.add(value); + } + } + flattened = result; + } + + return flattened; + } + + @Override + boolean isPresent() { + // An empty projection is not considered present. + return !values.isEmpty(); + } + } + + /** + * Attribute that contains service shape properties. + * + *

Using this attribute as a string yields an empty string. This + * attribute has the following properties: + * + *

    + *
  • version: The service version as a string.
  • + *
+ */ + static final class Service extends AttributeValue { + final ServiceShape service; + + Service(ServiceShape service) { + this.service = service; + } + + static AttributeValue.Factory createFactory(List path) { + // Optimization to help with debugging selectors. + if (path.size() > 1) { + LOGGER.warning("Too many path segments for `service` attribute: " + path); + } + + return shape -> shape.asServiceShape() + .map(Service::new) + .map(value -> AttributeValue.createPathSelector(value, path)) + .orElse(NULL); + } + + @Override + AttributeValue getProperty(String key) { + switch (key) { + case "version": + return new Literal(service.getVersion()); + case KEYS: + return new Projection(Collections.singleton(new Literal("version"))); + case VALUES: + return new Projection(Collections.singleton(new Literal(service.getVersion()))); + case LENGTH: + // Returns the number of elements in the object. It's only "version" + // for services. + return new Literal(1); + default: + return NULL; + } + } + } + + /** + * Grabs values out of a {@link ShapeId} or uses the shape ID directly + * as a string, and when cast to a string, returns the absolute shape ID. + * + *

This attribute has the following properties: + * + *

    + *
  • namespace: The shape ID namespace.
  • + *
  • name: The shape ID name.
  • + *
  • member: The optionally present shape ID member.
  • + *
+ */ + static final class Id extends AttributeValue { + final ShapeId id; + + Id(ShapeId id) { + this.id = id; + } + + static AttributeValue.Factory createFactory(List path) { + if (path.size() > 1) { + // Make debugging selectors easier. + LOGGER.warning("Too many selector path segments provided when selecting into `id`: " + path); + } + + return shape -> createPathSelector(new Id(shape.getId()), path); + } + + @Override + public String toString() { + return id.toString(); + } + + @Override + AttributeValue getProperty(String property) { + switch (property) { + case "name": + return new Literal(id.getName()); + case "namespace": + return new Literal(id.getNamespace()); + case "member": + return id.getMember() + .map(Literal::new) + .orElse(NULL); + case KEYS: + List keys = new ArrayList<>(3); + keys.add(new Literal("namespace")); + keys.add(new Literal("name")); + id.getMember().ifPresent(member -> keys.add(new Literal("member"))); + return new Projection(keys); + case VALUES: + List values = new ArrayList<>(3); + values.add(new Literal(id.getNamespace())); + values.add(new Literal(id.getName())); + id.getMember().ifPresent(member -> values.add(new Literal(member))); + return new Projection(values); + case LENGTH: + // Length returns the length of the shape ID. + return new Literal(id.toString().length()); + default: + return NULL; + } + } + } + + /** + * Grabs values out of the traits of a shape. + * + *

This attribute value can be indexed using absolute shape IDs or + * relative shape IDs. When a relative shape ID is provided, it is + * resolved to the 'smithy.api' namespace. + * + *

Calling (values) on this attribute will return a projection that + * contains all of the traits applied to the shape as Node values. + * + *

Calling (keys) on this attribute will return a projection that + * contains all of the shape IDs of each trait applied to a shape. + */ + static final class Traits extends AttributeValue { + final Shape shape; + + Traits(Shape shape) { + this.shape = Objects.requireNonNull(shape); + } + + static AttributeValue.Factory createFactory(List path) { + return shape -> createPathSelector(new Traits(shape), path); + } + + @Override + AttributeValue getProperty(String property) { + switch (property) { + case KEYS: + // This allows the projected keys to be used like shape IDs: + // [trait|(keys)|namespace='com.foo'] + List keyValues = new ArrayList<>(); + for (ShapeId id : shape.getAllTraits().keySet()) { + keyValues.add(new Id(id)); + } + return new Projection(keyValues); + case VALUES: + // This allows the projected values to be used as nodes. This + // selector finds all traits that have 'foo' property. + // [trait|(values)|foo] + List values = new ArrayList<>(); + for (Trait trait : shape.getAllTraits().values()) { + values.add(new NodeValue(trait.toNode())); + } + return new Projection(values); + case LENGTH: + return new Literal(shape.getAllTraits().size()); + default: + // A normal property getter. This allows relative trait shape IDs + // and absolute IDs. Relative IDs resolve to 'smithy.api'. + return shape.findTrait(ShapeId.from(Trait.makeAbsoluteName(property))) + .map(trait -> new NodeValue(trait.toNode())) + .orElse(NULL); + } + } + } +} diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/selector/Parser.java b/smithy-model/src/main/java/software/amazon/smithy/model/selector/Parser.java index 246b97f16d5..0754a727b80 100644 --- a/smithy-model/src/main/java/software/amazon/smithy/model/selector/Parser.java +++ b/smithy-model/src/main/java/software/amazon/smithy/model/selector/Parser.java @@ -46,10 +46,12 @@ final class Parser { } private final String expression; + private final int length; private int position = 0; private Parser(String selector) { - this.expression = selector; + expression = selector; + length = expression.length(); } static Selector parse(String selector) { @@ -70,7 +72,7 @@ private List recursiveParse() { ws(); // Parse until a break token: ",", "]", and ")". - while (position != expression.length() && !BREAK_TOKENS.contains(expression.charAt(position))) { + while (position != length && !BREAK_TOKENS.contains(expression.charAt(position))) { selectors.add(createSelector()); // Always skip ws after calling createSelector. ws(); @@ -89,7 +91,12 @@ private Selector createSelector() { return parseFunction(); case '[': // attribute position++; - return parseAttribute(); + if (charPeek() == '@') { + position++; + return parseScopedAttribute(); + } else { + return parseAttribute(); + } case '>': // undirected neighbor position++; return new NeighborSelector(ListUtils.of()); @@ -131,17 +138,16 @@ private Selector createSelector() { } private void ws() { - while (position < expression.length() && isWhitespace(expression.charAt(position))) { - position++; + for (; position < length; position++) { + char c = expression.charAt(position); + if (c != ' ' && c != '\t' && c != '\r' && c != '\n') { + break; + } } } - private boolean isWhitespace(char c) { - return c == ' ' || c == '\t' || c == '\r' || c == '\n'; - } - private char charPeek() { - return position == expression.length() ? Character.MIN_VALUE : expression.charAt(position); + return position == length ? Character.MIN_VALUE : expression.charAt(position); } private char expect(char... tokens) { @@ -232,47 +238,71 @@ private List parseVariadic() { private Selector parseAttribute() { ws(); - AttributeSelector.KeyGetter attributeKey = parseAttributeKey(); + AttributeValue.Factory keyFactory = parseAttributePath(); ws(); - char next = expect(']', '=', '!', '^', '$', '*', '>', '<'); - AttributeSelector.Comparator comparator; + char next = expect(']', '=', '!', '^', '$', '*', '?', '>', '<'); + + if (next == ']') { + return AttributeSelector.existence(keyFactory); + } + + AttributeComparator comparator = parseComparator(next); + List values = parseAttributeValues(); + boolean insensitive = parseCaseInsensitiveToken(); + expect(']'); + return new AttributeSelector(keyFactory, values, comparator, insensitive); + } + + private boolean parseCaseInsensitiveToken() { + ws(); + boolean insensitive = charPeek() == 'i'; + if (insensitive) { + position++; + ws(); + } + return insensitive; + } + private AttributeComparator parseComparator(char next) { + AttributeComparator comparator; switch (next) { - case ']': - return new AttributeSelector(attributeKey); case '=': - comparator = AttributeSelector.EQUALS; + comparator = AttributeComparator.EQUALS; break; case '!': expect('='); - comparator = AttributeSelector.NOT_EQUALS; + comparator = AttributeComparator.NOT_EQUALS; break; case '^': expect('='); - comparator = AttributeSelector.STARTS_WITH; + comparator = AttributeComparator.STARTS_WITH; break; case '$': expect('='); - comparator = AttributeSelector.ENDS_WITH; + comparator = AttributeComparator.ENDS_WITH; break; case '*': expect('='); - comparator = AttributeSelector.CONTAINS; + comparator = AttributeComparator.CONTAINS; + break; + case '?': + expect('='); + comparator = AttributeComparator.EXISTS; break; case '>': if (charPeek() == '=') { position++; - comparator = AttributeSelector.GTE; + comparator = AttributeComparator.GTE; } else { - comparator = AttributeSelector.GT; + comparator = AttributeComparator.GT; } break; case '<': if (charPeek() == '=') { position++; - comparator = AttributeSelector.LTE; + comparator = AttributeComparator.LTE; } else { - comparator = AttributeSelector.LT; + comparator = AttributeComparator.LT; } break; default: @@ -280,75 +310,98 @@ private Selector parseAttribute() { throw syntax("Unknown attribute comparator token '" + next + "'"); } - List values = parseAttributeValues(); ws(); + return comparator; + } - boolean insensitive = charPeek() == 'i'; - if (insensitive) { - position++; + // "[@" selector_key ":" selector_scoped_comparisons "]" + private Selector parseScopedAttribute() { + ws(); + AttributeValue.Factory keyScope = parseAttributePath(); + ws(); + expect(':'); + ws(); + return new ScopedAttributeSelector(keyScope, parseScopedAssertions()); + } + + // selector_scoped_comparison *("&&" selector_scoped_comparison) + private List parseScopedAssertions() { + List assertions = new ArrayList<>(); + assertions.add(parseScopedAssertion()); + ws(); + + while (charPeek() == '&') { + expect('&'); + expect('&'); ws(); + assertions.add(parseScopedAssertion()); } expect(']'); - return new AttributeSelector(attributeKey, comparator, values, insensitive); + return assertions; + } + + private ScopedAttributeSelector.Assertion parseScopedAssertion() { + ScopedAttributeSelector.ScopedFactory lhs = parseScopedValue(); + char next = charPeek(); + position++; + AttributeComparator comparator = parseComparator(next); + + List rhs = new ArrayList<>(); + rhs.add(parseScopedValue()); + + while (charPeek() == ',') { + position++; + rhs.add(parseScopedValue()); + } + + boolean insensitive = parseCaseInsensitiveToken(); + return new ScopedAttributeSelector.Assertion(lhs, comparator, rhs, insensitive); + } + + private ScopedAttributeSelector.ScopedFactory parseScopedValue() { + ws(); + if (charPeek() == '@') { + position++; + expect('{'); + // parse at least one path segment, followed by any number of + // comma separated segments. + List path = new ArrayList<>(); + path.add(parseSelectorPathSegment()); + path.addAll(parseSelectorPath()); + expect('}'); + ws(); + return value -> AttributeValue.createPathSelector(value, path); + } else { + String parsedValue = parseAttributeValue(); + ws(); + return value -> new AttributeValue.Literal(parsedValue); + } } - private AttributeSelector.KeyGetter parseAttributeKey() { + private AttributeValue.Factory parseAttributePath() { // Parse the top-level namespace key. String namespace = parseIdentifier(); // It is optionally followed by "|" delimited path keys. - List path = parsePipeDelimitedTraitAttributes(); + List path = parseSelectorPath(); switch (namespace) { case "id": - if (path.isEmpty()) { - return AttributeSelector.KEY_ID; - } else if (path.size() == 1) { - switch (path.get(0)) { - case "namespace": - return AttributeSelector.KEY_ID_NAMESPACE; - case "name": - return AttributeSelector.KEY_ID_NAME; - case "member": - return AttributeSelector.KEY_ID_MEMBER; - default: - // Unknown attributes always return no result. - LOGGER.warning("Unknown selector attribute `id` path " + path.get(0) + ": " + expression); - return s -> Collections.emptyList(); - } - } else { - // Unknown attributes always return no result. - LOGGER.warning("Too many selector attribute `id` paths " + path + ": " + expression); - return s -> Collections.emptyList(); - } + return AttributeValue.Id.createFactory(path); case "service": - if (path.size() != 1) { - throw syntax("service attributes require exactly one path item"); - } else if (path.get(0).equals("version")) { - return AttributeSelector.KEY_SERVICE_VERSION; - } else { - // Unknown attributes always return no result. - LOGGER.warning("Unknown selector service attribute path " + path + ": " + expression); - return s -> Collections.emptyList(); - } + return AttributeValue.Service.createFactory(path); case "trait": - if (path.isEmpty()) { - throw syntax("Trait attributes require a trait shape ID"); - } else if (path.size() == 1) { - return new TraitAttributeKey(path.get(0), Collections.emptyList()); - } else { - return new TraitAttributeKey(path.get(0), path.subList(1, path.size())); - } + return AttributeValue.Traits.createFactory(path); default: // Unknown attributes always return no result. - LOGGER.warning("Unknown selector attribute `" + namespace + "` " + expression); - return s -> Collections.emptyList(); + LOGGER.warning("Unknown selector attribute `" + namespace + "`: " + expression); + return AttributeValue.NULL_FACTORY; } } - // Can be a shape_id, quoted string, number, or pseudo_key. - private List parsePipeDelimitedTraitAttributes() { + // Can be a shape_id, quoted string, number, or function key. + private List parseSelectorPath() { ws(); if (charPeek() != '|') { @@ -358,21 +411,25 @@ private List parsePipeDelimitedTraitAttributes() { List result = new ArrayList<>(); do { position++; // skip '|' - ws(); - // Handle pseudo-keys enclosed in "(" identifier ")". - if (charPeek() == '(') { - position++; - String propertyName = parseIdentifier(); - expect(')'); - result.add("(" + propertyName + ")"); - } else { - result.add(parseAttributeValue()); - } + result.add(parseSelectorPathSegment()); } while (charPeek() == '|'); return result; } + private String parseSelectorPathSegment() { + ws(); + // Handle function properties enclosed in "(" identifier ")". + if (charPeek() == '(') { + position++; + String propertyName = parseIdentifier(); + expect(')'); + return "(" + propertyName + ")"; + } else { + return parseAttributeValue(); + } + } + private List parseAttributeValues() { List result = new ArrayList<>(); result.add(parseAttributeValue()); @@ -416,7 +473,7 @@ private String parseAttributeValue() { private String consumeInside(char c) { int i = ++position; - while (i < expression.length()) { + while (i < length) { if (expression.charAt(i) == c) { String result = expression.substring(position, i); position = i + 1; diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/selector/ScopedAttributeSelector.java b/smithy-model/src/main/java/software/amazon/smithy/model/selector/ScopedAttributeSelector.java new file mode 100644 index 00000000000..6dda2e4e921 --- /dev/null +++ b/smithy-model/src/main/java/software/amazon/smithy/model/selector/ScopedAttributeSelector.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.model.selector; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.neighbor.NeighborProvider; +import software.amazon.smithy.model.shapes.Shape; + +/** + * Matches a scoped attribute or projection against a set of assertions that + * can path into the scoped attribute. + */ +final class ScopedAttributeSelector implements Selector { + + static final class Assertion { + private final ScopedFactory lhs; + private final AttributeComparator comparator; + private final List rhs; + private final boolean caseInsensitive; + + Assertion( + ScopedFactory lhs, + AttributeComparator comparator, + List rhs, + boolean caseInsensitive + ) { + this.lhs = lhs; + this.comparator = comparator; + this.rhs = rhs; + this.caseInsensitive = caseInsensitive; + } + } + + /** + * Creates an AttributeValue from the given scope value. + * + *

This is useful for pathing into scopes. + */ + @FunctionalInterface + interface ScopedFactory { + AttributeValue create(AttributeValue value); + } + + private final AttributeValue.Factory keyScope; + private final List assertions; + + ScopedAttributeSelector(AttributeValue.Factory keyScope, List assertions) { + this.keyScope = keyScope; + this.assertions = assertions; + } + + @Override + public Set select(Model model, NeighborProvider neighborProvider, Set shapes) { + return shapes.stream() + .filter(this::matchesAssertions) + .collect(Collectors.toSet()); + } + + private boolean matchesAssertions(Shape shape) { + // First resolve the scope of the assertions. + AttributeValue scope = keyScope.create(shape); + + // If it's not present, then nothing could ever match. + if (!scope.isPresent()) { + return false; + } + + // When dealing with a projection, each flattened projection value is + // used as a scope and then passed to the assertions one at a time. + for (AttributeValue value : scope.getFlattenedValues()) { + if (compareWithScope(value)) { + return true; + } + } + + return false; + } + + private boolean compareWithScope(AttributeValue scope) { + // Ensure that each assertion matches, and provide them the scope. + for (Assertion assertion : assertions) { + AttributeValue lhs = assertion.lhs.create(scope); + for (ScopedFactory factory : assertion.rhs) { + AttributeValue rhs = factory.create(scope); + if (!assertion.comparator.flattenedCompare(lhs, rhs, assertion.caseInsensitive)) { + return false; + } + } + } + + return true; + } +} diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/selector/SelectorSyntaxException.java b/smithy-model/src/main/java/software/amazon/smithy/model/selector/SelectorSyntaxException.java index 440c6f2d042..d174cb7352c 100644 --- a/smithy-model/src/main/java/software/amazon/smithy/model/selector/SelectorSyntaxException.java +++ b/smithy-model/src/main/java/software/amazon/smithy/model/selector/SelectorSyntaxException.java @@ -28,6 +28,6 @@ private static String createMessage(String message, String expression, int pos) if (pos <= expression.length()) { result += ", near `" + expression.substring(pos) + "`"; } - return result + ": " + message + "; expression `" + expression + "`"; + return result + ": " + message + "; expression: " + expression; } } diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/selector/TraitAttributeKey.java b/smithy-model/src/main/java/software/amazon/smithy/model/selector/TraitAttributeKey.java deleted file mode 100644 index a25a58a4773..00000000000 --- a/smithy-model/src/main/java/software/amazon/smithy/model/selector/TraitAttributeKey.java +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.model.selector; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import software.amazon.smithy.model.node.BooleanNode; -import software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.node.NodeVisitor; -import software.amazon.smithy.model.node.NumberNode; -import software.amazon.smithy.model.node.ObjectNode; -import software.amazon.smithy.model.node.StringNode; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.traits.Trait; - -final class TraitAttributeKey implements AttributeSelector.KeyGetter { - - private static final NodeToString NODE_TO_STRING = new NodeToString(); - private final String traitName; - private final List traitPath; - - TraitAttributeKey(String traitName, List traitPath) { - this.traitName = traitName; - this.traitPath = traitPath; - } - - TraitAttributeKey(String traitName) { - this(traitName, Collections.emptyList()); - } - - @Override - public List apply(Shape shape) { - Trait trait = shape.findTrait(traitName).orElse(null); - - if (trait == null) { - // An empty list means the trait does not exist. - return Collections.emptyList(); - } else if (traitPath.isEmpty()) { - // A list with a value of null means it exists but isn't comparable. - // A list with a non-null values means it exists and is comparable. - return Collections.singletonList(trait.toNode().accept(NODE_TO_STRING)); - } else { - // Path into the trait to see if a value exists / is comparable. - List result = new ArrayList<>(); - evaluateNode(trait.toNode(), 0, result); - return result; - } - } - - private void evaluateNode(Node node, int pathPosition, List result) { - // Terminal state attempts to take the current value and - // add it to the result. This is executes when pathing into - // object node values. - if (pathPosition >= traitPath.size()) { - result.add(node.accept(NODE_TO_STRING)); - return; - } - - String path = traitPath.get(pathPosition); - if (node.isObjectNode()) { - ObjectNode value = node.expectObjectNode(); - if (path.equals("(keys)")) { - projectedEvaluate(value.getMembers().keySet(), pathPosition + 1, result); - } else if (path.equals("(values)")) { - projectedEvaluate(value.getMembers().values(), pathPosition + 1, result); - } else if (value.getMember(path).isPresent()) { - evaluateNode(value.expectMember(path), pathPosition + 1, result); - } - } else if (node.isArrayNode()) { - // The only valid path after an array is (values). - if (path.equals("(values)")) { - projectedEvaluate(node.expectArrayNode().getElements(), pathPosition + 1, result); - } - } - } - - private void projectedEvaluate(Collection nodes, int pathPosition, List result) { - // If projecting on the last path item (i.e., an expression that ends - // with (values)), then populate the result set with the evaluated values. - if (pathPosition == traitPath.size()) { - // Note that empty lists do not appear in the result set. Do not - // project lists if you need to match on empty lists. - for (Node element : nodes) { - result.add(element.accept(NODE_TO_STRING)); - } - } else { - // Continue projecting and evaluating values inside of the trait. - for (Node element : nodes) { - evaluateNode(element, pathPosition, result); - } - } - } - - // Only strings, booleans, and numbers are converted to - // comparable strings. All other values become null, meaning - // that the value is present, but not actually comparable. - private static final class NodeToString extends NodeVisitor.Default { - @Override - protected String getDefault(Node node) { - return null; - } - - @Override - public String stringNode(StringNode node) { - return node.getValue(); - } - - @Override - public String numberNode(NumberNode node) { - return node.getValue().toString(); - } - - @Override - public String booleanNode(BooleanNode node) { - return Boolean.toString(node.getValue()); - } - } -} diff --git a/smithy-model/src/test/java/software/amazon/smithy/model/selector/AndSelectorTest.java b/smithy-model/src/test/java/software/amazon/smithy/model/selector/AndSelectorTest.java index 97602643722..3e4cc4c1f99 100644 --- a/smithy-model/src/test/java/software/amazon/smithy/model/selector/AndSelectorTest.java +++ b/smithy-model/src/test/java/software/amazon/smithy/model/selector/AndSelectorTest.java @@ -28,13 +28,14 @@ import software.amazon.smithy.model.shapes.ShapeType; import software.amazon.smithy.model.shapes.StringShape; import software.amazon.smithy.model.traits.SensitiveTrait; +import software.amazon.smithy.utils.ListUtils; public class AndSelectorTest { @Test public void matchesAllPredicates() { Selector selector = AndSelector.of(Arrays.asList( new ShapeTypeSelector(ShapeType.STRING), - new AttributeSelector(new TraitAttributeKey("sensitive")))); + AttributeSelector.existence(AttributeValue.Traits.createFactory(ListUtils.of("sensitive"))))); Shape a = IntegerShape.builder().id("foo.baz#Bar").build(); Shape b = StringShape.builder().id("foo.baz#Bam").addTrait(new SensitiveTrait()).build(); Model model = Model.builder().addShapes(a, b).build(); @@ -47,7 +48,7 @@ public void matchesAllPredicates() { public void shortCircuits() { Selector selector = AndSelector.of(Arrays.asList( new ShapeTypeSelector(ShapeType.BIG_INTEGER), - new AttributeSelector(new TraitAttributeKey("sensitive")))); + AttributeSelector.existence(AttributeValue.Traits.createFactory(ListUtils.of("sensitive"))))); Shape a = IntegerShape.builder().id("foo.baz#Bar").build(); Shape b = StringShape.builder().id("foo.baz#Bam").addTrait(new SensitiveTrait()).build(); Model model = Model.builder().addShapes(a, b).build(); diff --git a/smithy-model/src/test/java/software/amazon/smithy/model/selector/OfSelectorTest.java b/smithy-model/src/test/java/software/amazon/smithy/model/selector/OfSelectorTest.java index 3a301bdc8df..97d507c250d 100644 --- a/smithy-model/src/test/java/software/amazon/smithy/model/selector/OfSelectorTest.java +++ b/smithy-model/src/test/java/software/amazon/smithy/model/selector/OfSelectorTest.java @@ -33,6 +33,7 @@ import software.amazon.smithy.model.shapes.StringShape; import software.amazon.smithy.model.shapes.StructureShape; import software.amazon.smithy.model.traits.SensitiveTrait; +import software.amazon.smithy.utils.ListUtils; public class OfSelectorTest { @Test @@ -40,7 +41,7 @@ public void matchesMembersThatAreContainedWithinSelector() { Model model = createModel(); // Containing shape must have the sensitive trait. Selector selector = new OfSelector(Collections.singletonList( - new AttributeSelector(new TraitAttributeKey("sensitive")))); + AttributeSelector.existence(AttributeValue.Traits.createFactory(ListUtils.of("sensitive"))))); Set result = selector.select(model); assertThat(result, hasSize(1)); diff --git a/smithy-model/src/test/java/software/amazon/smithy/model/selector/SelectorTest.java b/smithy-model/src/test/java/software/amazon/smithy/model/selector/SelectorTest.java index f97c5d753e2..a3e32518f69 100644 --- a/smithy-model/src/test/java/software/amazon/smithy/model/selector/SelectorTest.java +++ b/smithy-model/src/test/java/software/amazon/smithy/model/selector/SelectorTest.java @@ -22,7 +22,6 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; -import static org.hamcrest.Matchers.hasItems; import static org.hamcrest.Matchers.not; import java.util.List; @@ -69,18 +68,27 @@ public void supportsDeprecatedEachFunction() { assertThat(result1, equalTo(result2)); } - private List ids(Model model, String expression) { + private Set ids(Model model, String expression) { return Selector.parse(expression) .select(model) .stream() .map(Shape::getId) .map(ShapeId::toString) - .collect(Collectors.toList()); + .collect(Collectors.toSet()); + } + + @Test + public void detectsUnexpectedEof() { + SelectorSyntaxException e = Assertions.assertThrows(SelectorSyntaxException.class, () -> { + Selector.parse(":is(string,"); + }); + + assertThat(e.getMessage(), containsString("Unexpected selector EOF")); } @Test public void selectsUsingNestedTraitValues() { - List result = ids(traitModel, "[trait|range|min=1]"); + Set result = ids(traitModel, "[trait|range|min=1]"); assertThat(result, hasItem("smithy.example#RangeInt1")); assertThat(result, not(hasItem("smithy.example#RangeInt2"))); @@ -89,7 +97,7 @@ public void selectsUsingNestedTraitValues() { @Test public void selectsUsingNestedTraitValuesUsingNegation() { - List result = ids(traitModel, "[trait|range|min!=1]"); + Set result = ids(traitModel, "[trait|range|min!=1]"); assertThat(result, hasItem("smithy.example#RangeInt2")); assertThat(result, not(hasItem("smithy.example#RangeInt1"))); @@ -98,7 +106,7 @@ public void selectsUsingNestedTraitValuesUsingNegation() { @Test public void selectsUsingNestedTraitValuesThroughProjection() { - List result = ids(traitModel, "[trait|enum|(values)|deprecated=true]"); + Set result = ids(traitModel, "[trait|enum|(values)|deprecated=true]"); assertThat(result, hasItem("smithy.example#EnumString")); assertThat(result, not(hasItem("smithy.example#DocumentedString"))); @@ -106,7 +114,7 @@ public void selectsUsingNestedTraitValuesThroughProjection() { @Test public void canSelectOnTraitObjectKeys() { - List result = ids(traitModel, "[trait|externalDocumentation|(keys)=Homepage]"); + Set result = ids(traitModel, "[trait|externalDocumentation|(keys)=Homepage]"); assertThat(result, hasItem("smithy.example#DocumentedString1")); assertThat(result, not(hasItem("smithy.example#DocumentedString2"))); @@ -114,7 +122,7 @@ public void canSelectOnTraitObjectKeys() { @Test public void canSelectOnTraitObjectValues() { - List result = ids(traitModel, "[trait|externalDocumentation|(values)='https://www.anotherexample.com/']"); + Set result = ids(traitModel, "[trait|externalDocumentation|(values)='https://www.anotherexample.com/']"); assertThat(result, hasItem("smithy.example#DocumentedString2")); assertThat(result, not(hasItem("smithy.example#DocumentedString1"))); @@ -122,29 +130,29 @@ public void canSelectOnTraitObjectValues() { @Test public void pathThroughTerminalValueReturnsNoResults() { - List result = ids(traitModel, "[trait|documentation|foo|baz='nope']"); + Set result = ids(traitModel, "[trait|documentation|foo|baz='nope']"); assertThat(result, empty()); } @Test public void pathThroughArrayWithInvalidItemReturnsNoResults() { - List result = ids(traitModel, "[trait|tags|foo|baz='nope']"); + Set result = ids(traitModel, "[trait|tags|foo|baz='nope']"); assertThat(result, empty()); } @Test public void supportsNotEqualsAttribute() { - List result = ids(modelJson, "[id|member!=member]"); + Set result = ids(modelJson, "[id|member!=member]"); assertThat(result, containsInAnyOrder("ns.foo#Map$key", "ns.foo#Map$value")); } @Test public void supportsMatchingDeeplyOnTraitValues() { - List result1 = ids(traitModel, "[trait|smithy.example#nestedTrait|foo|foo|bar='hi']"); - List result2 = ids(traitModel, "[trait|smithy.example#nestedTrait|foo|foo|bar='bye']"); + Set result1 = ids(traitModel, "[trait|smithy.example#nestedTrait|foo|foo|bar='hi']"); + Set result2 = ids(traitModel, "[trait|smithy.example#nestedTrait|foo|foo|bar='bye']"); assertThat(result1, hasItem("smithy.example#DocumentedString1")); assertThat(result1, not(hasItem("smithy.example#DocumentedString2"))); @@ -154,7 +162,7 @@ public void supportsMatchingDeeplyOnTraitValues() { @Test public void emptyListDoesNotAppearWhenProjecting() { - List result = ids(traitModel, "[trait|enum|(values)|tags|(values)]"); + Set result = ids(traitModel, "[trait|enum|(values)|tags|(values)]"); assertThat(result, hasItem("smithy.example#EnumString")); assertThat(result, hasItem("smithy.example#DocumentedString1")); @@ -390,14 +398,14 @@ public void parsesValidQuotedAttributes() { } @Test - public void detectsInvalidKnownAttributePaths() { + public void toleratesInvalidKnownAttributePaths() { List exprs = ListUtils.of( "[service]", "[service|version|foo]", "[trait]"); for (String expr : exprs) { - Assertions.assertThrows(SelectorSyntaxException.class, () -> Selector.parse(expr)); + Selector.parse(expr); } } @@ -449,7 +457,7 @@ public void canMatchOnExistence() { @Test public void canMatchUsingCaseInsensitiveComparison() { - List matches = ids(traitModel, "[trait|error = 'CLIENT' i]"); + Set matches = ids(traitModel, "[trait|error = 'CLIENT' i]"); assertThat(matches, containsInAnyOrder("smithy.example#ErrorStruct2")); } @@ -461,16 +469,16 @@ public void cannotMatchOnNonComparableAttributes() { @Test public void canMatchUsingCommaSeparatedAttributeValues() { - List matches1 = ids(traitModel, "[trait|enum|(values)|value='m256.mega', 'nope']"); - List matches2 = ids(traitModel, "[trait|enum|(values)|value = 'm256.mega' ,'nope' ]"); - List matches3 = ids(traitModel, "[trait|enum|(values)|value = 'm256.mega' , nope ]"); + Set matches1 = ids(traitModel, "[trait|enum|(values)|value='m256.mega', 'nope']"); + Set matches2 = ids(traitModel, "[trait|enum|(values)|value = 'm256.mega' ,'nope' ]"); + Set matches3 = ids(traitModel, "[trait|enum|(values)|value = 'm256.mega' , nope ]"); - assertThat(matches1, equalTo(matches2)); - assertThat(matches1, equalTo(matches3)); assertThat(matches1, containsInAnyOrder( "smithy.example#DocumentedString1", "smithy.example#DocumentedString2", "smithy.example#EnumString")); + assertThat(matches1, equalTo(matches2)); + assertThat(matches1, equalTo(matches3)); } @Test @@ -503,13 +511,13 @@ public void parsedRelativeComparators() { @Test public void canMatchUsingRelativeSelectors() { - List matches1 = ids(traitModel, "[trait|httpError >= 500]"); - List matches2 = ids(traitModel, "[trait|httpError > 499]"); - List matches3 = ids(traitModel, "[trait|httpError >= 400]"); - List matches4 = ids(traitModel, "[trait|httpError > 399]"); - List matches5 = ids(traitModel, "[trait|httpError <= 500]"); - List matches6 = ids(traitModel, "[trait|httpError < 500]"); - List matches7 = ids(traitModel, "[trait|httpError >= 500e0]"); + Set matches1 = ids(traitModel, "[trait|httpError >= 500]"); + Set matches2 = ids(traitModel, "[trait|httpError > 499]"); + Set matches3 = ids(traitModel, "[trait|httpError >= 400]"); + Set matches4 = ids(traitModel, "[trait|httpError > 399]"); + Set matches5 = ids(traitModel, "[trait|httpError <= 500]"); + Set matches6 = ids(traitModel, "[trait|httpError < 500]"); + Set matches7 = ids(traitModel, "[trait|httpError >= 500e0]"); assertThat(matches1, containsInAnyOrder("smithy.example#ErrorStruct1")); assertThat(matches2, containsInAnyOrder("smithy.example#ErrorStruct1")); @@ -525,4 +533,283 @@ public void invalidNumbersFailsGracefully() { assertThat(ids(traitModel, "[trait|httpError >= 'nope']"), empty()); assertThat(ids(traitModel, "[trait|error >= 500]"), empty()); } + + @Test + public void toleratesGettingNullPropertyFromString() { + assertThat(ids(traitModel, "[id|no|no=100]"), empty()); + } + + @Test + public void checksIfValueIsPresent() { + Set hasTags = ids(traitModel, "[id|namespace='smithy.example'][trait|tags?=true]"); + Set noTags = ids(traitModel, "[id|namespace='smithy.example'][trait|tags?=false]"); + + assertThat(hasTags, containsInAnyOrder("smithy.example#EnumString")); + assertThat(noTags, not(hasItem("smithy.example#EnumString"))); + assertThat(noTags, not(empty())); + } + + @Test + public void matchesOnShapeIdName() { + Set enumString = ids(traitModel, "[id|name=EnumString]"); + + assertThat(enumString, contains("smithy.example#EnumString")); + } + + @Test + public void projectsKeysOfIdAttribute() { + Set allIds1 = ids(traitModel, "[id|namespace='smithy.example'][id|(keys)=namespace]"); + Set allIds2 = ids(traitModel, "[id|namespace='smithy.example'][id|(keys)=name]"); + Set someIds = ids(traitModel, "[id|namespace='smithy.example'][id|(keys)=member]"); + + assertThat(allIds1, not(empty())); + assertThat(allIds1, equalTo(allIds2)); + assertThat(someIds, not(empty())); + + for (String id : someIds) { + assertThat(id, containsString("$")); + } + } + + @Test + public void projectsValuesOfIdAttribute() { + // Ids should match exactly the same in both selectors. + Set allIds1 = ids(traitModel, "[id|namespace='smithy.example']"); + Set allIds2 = ids( + traitModel, "[id|namespace='smithy.example'][id|(values)='smithy.example']"); + + assertThat(allIds1, not(empty())); + assertThat(allIds1, equalTo(allIds2)); + + // Member should have matched exactly the same members in both selectors. + Set someIds1 = ids(traitModel, "[id|(values)='member']"); + Set someIds2 = ids(traitModel, "member[id|member=member]"); + + assertThat(someIds1, not(empty())); + assertThat(someIds1, equalTo(someIds2)); + } + + @Test + public void selectsServiceExistence() { + Set services1 = ids(traitModel, "[service]"); + Set services2 = ids(traitModel, "service"); + + assertThat(services1, not(empty())); + assertThat(services1, equalTo(services2)); + } + + @Test + public void selectsServiceVersions() { + Set services1 = ids(traitModel, "[service|version='2020-04-21']"); + Set services2 = ids(traitModel, "[id|name=MyService]"); + + assertThat(services1, not(empty())); + assertThat(services1, equalTo(services2)); + } + + @Test + public void projectsServiceKeysAndValues() { + Set services1 = ids(traitModel, "service"); + Set services2 = ids(traitModel, "[service|(keys)=version]"); + Set services3 = ids(traitModel, "[service|(values)='2020-04-21']"); + + assertThat(services1, not(empty())); + assertThat(services1, equalTo(services2)); + assertThat(services1, equalTo(services3)); + } + + @Test + public void toleratesUnknownServicePaths() { + Set services1 = ids(traitModel, "[service|foo|baz='bam']"); + Set services2 = ids(traitModel, "[service|foo|baz]"); + + assertThat(services1, empty()); + assertThat(services2, empty()); + } + + @Test + public void projectsTraitKeysAsShapeIds() { + // All traits with a shape ID name of 'tags'. + Set shapes1 = ids(traitModel, "[id|namespace='smithy.example'][trait|(keys)|name='tags']"); + Set shapes2 = ids(traitModel, "[id|namespace='smithy.example'][trait|tags]"); + + assertThat(shapes1, contains("smithy.example#EnumString")); + assertThat(shapes2, equalTo(shapes1)); + } + + @Test + public void projectsTraitValuesAsNodes() { + // All traits that have a property named "min". + Set shapes1 = ids(traitModel, "[id|namespace='smithy.example'][trait|(values)|min]"); + Set shapes2 = ids(traitModel, "[id|namespace='smithy.example'][trait|range]"); + + assertThat(shapes1, containsInAnyOrder("smithy.example#RangeInt1", "smithy.example#RangeInt2")); + assertThat(shapes2, equalTo(shapes1)); + } + + @Test + public void parsesValidScopedAttributes() { + List exprs = ListUtils.of( + "[@trait: 10=10]", + "[@trait: @{foo}=10]", + "[@trait: @{foo}=@{foo}]", + "[@trait: @{foo}=@{foo} && bar=bar]", + "[@trait: @{foo}=@{foo} && bar=10]", + "[@trait: @{foo}=@{foo} && bar=@{baz}]", + "[@trait: @{foo}=@{foo} && bar=@{baz} && bam='abc']", + "[@trait: @{foo}=@{foo} i && bar=@{baz} i && bam='abc' i ]", + "[@ trait : @{foo}=@{foo} i &&bar=@{baz} i&&bam='abc'i\n]", + "[@\r\n\t trait\r\n\t : @{foo}=@{foo}]\r\n\t ", + "[@trait: @{foo|baz|bam|(boo)}=@{foo|bar|(boo)|baz}]", + "[@trait: @{foo|baz|bam|(boo)}=@{foo|bar|(boo)|baz}, @{foo|bam}]", + // Comma separated values are or'd together. + "[@trait: @{foo|baz|bam|(boo)}=@{foo|bar|(boo)|baz}, @{foo|bam} i && 10=10 i]"); + + for (String expr : exprs) { + Selector.parse(expr); + } + } + + @Test + public void detectsInvalidScopedAttributes() { + List exprs = ListUtils.of( + "[@", + "[@foo", + "[@foo:", + "[@foo: bar", + "[@foo: bar=", + "[@foo: bar=bam", + "[@foo: bar=bam i", + "[@foo: bar+bam]", // Invalid comparator + "[@foo: @", + "[@foo: @{", + "[@foo: @{abc", + "[@foo: @{abc}", + "[@foo: @{abc}]", + "[@foo: @{abc}=", + "[@foo: @{abc}=10", + "[@foo: @{abc}=10 &&", + "[@foo: @{abc}=10 && abc", + "[@foo: @{abc}=10 && abc=", + "[@foo: @{abc}=10 && abc=def", + "[@foo: @{abc}=10 && abc=def i", + "[@foo: @{abc{}}=10]", + "[@foo: @{abc|}=10]", + "[@foo: @{abc|def(baz)}=10]", // not a valid segment + "[@foo: @{abc|()}=10]", // missing contents of () + "[@foo: @{abc|(.)}=10]"); // invalid contents of ()); + + for (String expr : exprs) { + Assertions.assertThrows(SelectorSyntaxException.class, () -> Selector.parse(expr)); + } + } + + @Test + public void evaluatesScopedAttributes() { + Set shapes1 = ids(traitModel, "[@trait|range: @{min}=1 && @{max}=10]"); + // Can scope to `trait` and then do assertions on all traits. + // Not very useful, but technically supported. + Set shapes2 = ids(traitModel, "[@trait: @{range|min}=1 && @{range|max}=10]"); + + assertThat(shapes1, contains("smithy.example#RangeInt1")); + assertThat(shapes2, equalTo(shapes1)); + } + + @Test + public void evaluatesScopedAttributesWithProjections() { + // Note that the projection can be on either side. + Set shapes1 = ids(traitModel, "[@trait|enum|(values): @{name}=@{value} && @{tags|(values)}=hi]"); + Set shapes2 = ids(traitModel, "[@trait|enum|(values): @{name}=@{value} && hi=@{tags|(values)}]"); + + assertThat(shapes1, contains("smithy.example#DocumentedString1")); + assertThat(shapes2, equalTo(shapes1)); + } + + @Test + public void projectionsCanMatchThemselvesThroughIntersection() { + // Any enum with tags should match it's own tags. + Set shapes1 = ids(traitModel, "[@trait|enum|(values): @{tags|(values)}=@{tags|(values)}]"); + Set shapes2 = ids(traitModel, "[@trait|enum|(values): @{tags}?=true]"); + + assertThat(shapes1, not(empty())); + assertThat(shapes2, equalTo(shapes1)); + } + + @Test + public void nestedProjectionsAreFlattened() { + Set shapes1 = ids(traitModel, "[@trait|smithy.example#listyTrait|(values)|(values)|(values): @{foo}=a]"); + Set shapes2 = ids(traitModel, "[@trait|smithy.example#listyTrait|(values)|(values)|(values): @{foo}=b]"); + Set shapes3 = ids(traitModel, "[@trait|smithy.example#listyTrait|(values)|(values)|(values): @{foo}=c]"); + + assertThat(shapes1, contains("smithy.example#MyService")); + assertThat(shapes2, equalTo(shapes1)); + assertThat(shapes3, equalTo(shapes1)); + } + + @Test + public void getsTheLengthOfShapeIds() { + Set shapes1 = ids(traitModel, "[id=smithy.api#String][id|(length) = 17]"); + Set shapes2 = ids(traitModel, "[id=smithy.api#String][id|name|(length) = 6]"); + Set shapes3 = ids(traitModel, "[id=smithy.api#String][id|namespace|(length) = 10]"); + + assertThat(shapes1, contains("smithy.api#String")); + assertThat(shapes2, equalTo(shapes1)); + assertThat(shapes3, equalTo(shapes1)); + } + + @Test + public void getsTheLengthOfTraits() { + Set shapes = ids(traitModel, "[id=smithy.example#MyService][trait|(length) = 1]"); + + assertThat(shapes, contains("smithy.example#MyService")); + } + + @Test + public void getsTheLengthOfService() { + Set shapes = ids(traitModel, "[id=smithy.example#MyService][service|(length) = 1]"); + + assertThat(shapes, contains("smithy.example#MyService")); + } + + @Test + public void nullLengthIsNull() { + assertThat(ids(traitModel, "[id|name|(foo)|(length) = 1]"), empty()); + } + + @Test + public void getsTheLengthOfTraitString() { + // "client" + Set shapes = ids(traitModel, "[id=smithy.example#ErrorStruct1][trait|error|(length) = 6]"); + + assertThat(shapes, contains("smithy.example#ErrorStruct1")); + } + + @Test + public void getsTheLengthOfTraitArray() { + Set shapes = ids( + traitModel, + "[id=smithy.example#MyService][trait|smithy.example#listyTrait|(length) = 2]"); + + assertThat(shapes, contains("smithy.example#MyService")); + } + + @Test + public void getsTheLengthOfTraitObject() { + Set shapes = ids( + traitModel, + "[id=smithy.example#DocumentedString2][trait|externalDocumentation|(length) = 1]"); + + assertThat(shapes, contains("smithy.example#DocumentedString2")); + } + + @Test + public void projectionLengthUsesSetLogic() { + // Find shapes with the enum trait where there are more than 1 tags on any + // enum definition. + Set shapes = ids( + traitModel, + "[id|namespace='smithy.example'][trait|enum|(values)|tags|(length) > 1]"); + + assertThat(shapes, contains("smithy.example#DocumentedString1")); + } } diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/selector/nested-traits.smithy b/smithy-model/src/test/resources/software/amazon/smithy/model/selector/nested-traits.smithy index 7635b2ebcf7..0c9e395d3d9 100644 --- a/smithy-model/src/test/resources/software/amazon/smithy/model/selector/nested-traits.smithy +++ b/smithy-model/src/test/resources/software/amazon/smithy/model/selector/nested-traits.smithy @@ -25,7 +25,15 @@ namespace smithy.example value: "m256.mega", name: "M256_MEGA", deprecated: true - } + }, + { + value: "hi", + name: "bye" + }, + { + value: "bye", + name: "hi" + }, ]) @tags(["foo", "baz"]) string EnumString @@ -45,7 +53,12 @@ integer RangeInt2 value: "m256.mega", name: "M256_MEGA", tags: ["notEbs"] - } + }, + { + value: "hi", + name: "hi", + tags: ["hi", "there"] + }, ]) @nestedTrait(foo: {foo: {bar: "hi"}}) string DocumentedString1 @@ -76,3 +89,25 @@ structure ErrorStruct1 {} @error("client") @httpError(400) structure ErrorStruct2 {} + +@listyTrait([[[{foo: "a"}, {foo: "b"}]], [[{foo: "c"}]]]) +service MyService { + version: "2020-04-21" +} + +@trait +list listyTrait { + member: ListyTraitMember1, +} + +list ListyTraitMember1 { + member: ListyTraitMember2, +} + +list ListyTraitMember2 { + member: ListyTraitStruct, +} + +structure ListyTraitStruct { + foo: String +}