diff --git a/.github/workflows/javadoc.yml b/.github/workflows/javadoc.yml new file mode 100644 index 00000000..5c308ac5 --- /dev/null +++ b/.github/workflows/javadoc.yml @@ -0,0 +1,21 @@ +name: Check the javadoc + +on: + push: + + # Allows to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + javadoc: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'adopt' + - name: Check javadoc + run: mvn --batch-mode clean test-compile javadoc:javadoc javadoc:test-javadoc diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 83d2e671..bc153f51 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,10 +4,9 @@ on: push: branches: - 'develop' - - 'main' release: - types: [created] + types: [published] # Allows to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 490f72e5..53b7be4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# EFX Toolkit 2.0.0-alpha.7 Release Notes +# EFX Toolkit 2.0.0-alpha.8 Release Notes _The EFX Toolkit for Java developers is a library that enables the transpilation of [EFX](https://docs.ted.europa.eu/eforms/latest/efx) expressions and templates to different target languages. It also includes an implementation of an EFX-to-XPath transpiler._ @@ -6,14 +6,16 @@ _The EFX Toolkit for Java developers is a library that enables the transpilation ## In this release -This is an incremental update over 2.0.0-alpha.6. It refines SDK data-type resolution and updates the eForms Core dependency, while retaining full support for the EFX-2 grammar released with SDK 2.0.0-alpha.2. +This is an incremental update over 2.0.0-alpha.7. -### Changes since 2.0.0-alpha.6 +### Changes since 2.0.0-alpha.7 -- Field data types are now taken directly from the SDK field definitions, removing a temporary `SdkSymbolResolver` heuristic that inferred the `duration` type from `measure` fields using the `duration-unit` codelist. Added regression tests covering measure and duration fields. -- Upgraded the eForms Core Java dependency to 1.7.0. +- **Selectors**: an EFX expression can now yield the path of the XML elements a reference points to, instead of the values held in them. Write `&{reference}` in EFX-1, and `&{reference}` or `WITH context SELECT reference` in EFX-2. The first use of this new feature is in the `privacy.undisclosedFieldSelector` property in `fields.json` of SDK 1.16.0-beta.2 and SDK 2.0.0-alpha.3. The new property identifies the elements that must be masked and effectively withheld from publication. +- **Preferred language selection**: fixed an issue with the implicit invocation in EFX-1 of the `preferred-language-text` function when multilingual text fields are referenced. The function is now called correctly only when transpiling view templates, which is the only context where this functionality is applicable. EFX-2 requires explicit invocation of the function and is therefore unaffected by this fix. +- **Context overrides**: fixed multiple issues that caused the transpiler to produce valid but inaccurate XPaths when using a context override (`context::field`) to modify the path through which the value of a field is reached. Transpilation now preserves navigation steps and predicates accurately, and expressions using a context override will now produce a longer XPath, which however honours all predicates and enforces the presence of the context to select the designated value(s). This feature has not been used so far in any published SDK, so this change does not have an impact on the interpretation of existing rules and templates published in any SDK version. +- The eForms Core Java dependency is now 1.9.0. -The following sections describe the features of the 2.0.0 line, unchanged since 2.0.0-alpha.6. +The following sections describe the features of the 2.0.0 line, unchanged since 2.0.0-alpha.7. ### EFX-2 language support @@ -59,7 +61,7 @@ NOTE: Transpilation of EFX-1 to XPath and XSL in this version of the EFX Toolkit ## Breaking changes -No new breaking changes are introduced in 2.0.0-alpha.7. For the breaking changes introduced earlier in the 2.0.0 line, see the [2.0.0-alpha.6 release notes](https://github.com/OP-TED/efx-toolkit-java/releases/tag/2.0.0-alpha.6). +`XPathContextualizer.join` now takes a third argument, which says how far the joined path may be shortened; the two-argument form has been removed. For the breaking changes introduced earlier in the 2.0.0 line, see the [2.0.0-alpha.6 release notes](https://github.com/OP-TED/efx-toolkit-java/releases/tag/2.0.0-alpha.6). ## Future development @@ -76,7 +78,7 @@ Documentation for the EFX Toolkit is available at: eu.europa.ted.eforms efx-toolkit-java - 2.0.0-alpha.7 + 2.0.0-alpha.8 jar EFX Toolkit for Java @@ -46,9 +46,9 @@ ${project.build.directory}/eforms-sdk/antlr4 - 1.13.0 - 2.0.0-alpha.2 - 1.7.0 + 1.16.0-beta.2 + 2.0.0-alpha.3 + 1.9.0 4.13.1 diff --git a/src/main/java/eu/europa/ted/efx/interfaces/ScriptGenerator.java b/src/main/java/eu/europa/ted/efx/interfaces/ScriptGenerator.java index 8b290794..50d8d7d2 100644 --- a/src/main/java/eu/europa/ted/efx/interfaces/ScriptGenerator.java +++ b/src/main/java/eu/europa/ted/efx/interfaces/ScriptGenerator.java @@ -328,7 +328,12 @@ default PathExpression composeFieldInExternalReference(final PathExpression exte /** * Joins two given path expressions into one by placing the second after the first and using the * proper delimiter. - * + * + *

+ * This is how the path of a context override is built: the second path is relative to the first, + * which is the context the override names. What that implies for the joined path is for the + * implementation to decide, in the terms of the language it targets. + * * @param first The part of the path that goes before the delimiter. * @param second The part of the path that goes after the delimiter. * @return The joined path expression. @@ -799,7 +804,9 @@ public StringExpression composeReplaceRegexFunction(StringExpression text, * Given a reference to a multilingual field, this function should generate the target language script * that returns the text value of the field in the preferred language. * - * Calling the function in EFX 2 + * In EFX-2 the template author calls this explicitly, through the + * {@code preferred-language-text} function. EFX-1 has no such function, so template + * translation applies it implicitly to every multilingual field it renders. * * @since SDK 2.0.0 * @see #getPreferredLanguage(PathExpression) diff --git a/src/main/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1.java b/src/main/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1.java index 5f1ff96a..fdac2042 100644 --- a/src/main/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1.java +++ b/src/main/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1.java @@ -1095,6 +1095,23 @@ public void exitSequenceFromFieldReference(SequenceFromFieldReferenceContext ctx } } + /** + * A selector-block yields the reference itself rather than its value: the value step that every + * other reference position applies is deliberately not composed here. The path is otherwise + * resolved exactly as it would be in an expression-block, relative to the declared context + * unless the author wrote it as an absolute reference. + */ + @Override + public void exitSelection(final SelectionContext ctx) { + if (ctx.attributeReference() != null) { + // attributeReference has no exit handler of its own, so the attribute step is composed here + // rather than globally, which would double-compose it for the scalar and sequence positions. + this.stack.push(this.script.composeFieldAttributeReference( + this.stack.pop(PathExpression.class), + ctx.attributeReference().Identifier().getText(), StringPath.class)); + } + } + @Override public void exitScalarFromAttributeReference(ScalarFromAttributeReferenceContext ctx) { this.stack.push(this.script.composeFieldAttributeReference(this.stack.pop(PathExpression.class), @@ -1117,12 +1134,11 @@ public void exitSequenceFromAttributeReference(SequenceFromAttributeReferenceCon */ @Override public void exitContextFieldSpecifier(ContextFieldSpecifierContext ctx) { - this.stack.pop(PathExpression.class); // Discard the PathExpression placed in the stack for - // the context field. + final PathExpression contextFieldPath = this.stack.pop(PathExpression.class); final String contextFieldId = getFieldId(ctx.fieldContext()); this.efxContext .push(new FieldContext(contextFieldId, this.symbols.getAbsolutePathOfField(contextFieldId), - this.symbols.getRelativePathOfField(contextFieldId, this.efxContext.symbol()))); + contextFieldPath)); } @@ -1146,12 +1162,11 @@ public void exitFieldReferenceWithFieldContextOverride( */ @Override public void exitContextNodeSpecifier(ContextNodeSpecifierContext ctx) { - this.stack.pop(PathExpression.class); // Discard the PathExpression placed in the stack for - // the context node. + final PathExpression contextNodePath = this.stack.pop(PathExpression.class); final String contextNodeId = getNodeId(ctx.node); this.efxContext .push(new NodeContext(contextNodeId, this.symbols.getAbsolutePathOfNode(contextNodeId), - this.symbols.getRelativePathOfNode(contextNodeId, this.efxContext.symbol()))); + contextNodePath)); } /** diff --git a/src/main/java/eu/europa/ted/efx/sdk1/EfxTemplateTranslatorV1.java b/src/main/java/eu/europa/ted/efx/sdk1/EfxTemplateTranslatorV1.java index 286a695e..50bce68f 100644 --- a/src/main/java/eu/europa/ted/efx/sdk1/EfxTemplateTranslatorV1.java +++ b/src/main/java/eu/europa/ted/efx/sdk1/EfxTemplateTranslatorV1.java @@ -359,7 +359,7 @@ private void shorthandIndirectLabelReference(ParserRuleContext ctx, final String ? this.script.composeFieldAttributeReference( this.script.contextualizePath(this.symbols.getAbsolutePathOfFieldWithoutTheAttribute(fieldId), currentContext.absolutePath()), this.symbols.getAttributeNameFromAttributeField(fieldId), StringPath.class) - : this.script.composeFieldValueReference( + : this.composeFieldValueReference( this.symbols.getRelativePathOfField(fieldId, currentContext.symbol())); Variable loopVariable = new Variable("item", this.script.composeVariableDeclaration("item", StringExpression.class), StringExpression.empty(), @@ -532,12 +532,60 @@ public void exitShorthandFieldValueReferenceFromContextField( if (!this.efxContext.isFieldContext()) { throw InvalidUsageException.shorthandRequiresFieldContext(ctx, "$value"); } - this.stack.push(this.script.composeFieldValueReference( + this.stack.push(this.composeFieldValueReference( this.symbols.getRelativePathOfField(this.efxContext.symbol(), this.efxContext.symbol()))); } // #endregion Expression Blocks ${...} -------------------------------------- - + + // #region Value References ------------------------------------------------- + + /*** + * Multilingual fields are handled by this class, so the value reference is composed here instead + * of directly by the script generator. Anything else is left to the inherited behaviour. + * + * @see #composeFieldValueReference(PathExpression) + */ + @Override + public void exitScalarFromFieldReference(final ScalarFromFieldReferenceContext ctx) { + if (!this.stack.peekType().is(EfxDataType.MultilingualString.class)) { + super.exitScalarFromFieldReference(ctx); + return; + } + this.stack.push(this.composeFieldValueReference(this.stack.pop(PathExpression.class))); + } + + /*** + * @see #exitScalarFromFieldReference(ScalarFromFieldReferenceContext) + */ + @Override + public void exitSequenceFromFieldReference(final SequenceFromFieldReferenceContext ctx) { + if (!this.stack.peekType().is(EfxDataType.MultilingualString.class)) { + super.exitSequenceFromFieldReference(ctx); + return; + } + this.stack.push(this.composeFieldValueReference(this.stack.pop(PathExpression.class))); + } + + /*** + * In a view template the value of a multilingual field must be rendered in the language preferred + * by the reader, which EFX-1 gives the template author no syntax to ask for. Template translation + * therefore selects the preferred language implicitly, for every multilingual field it renders. + * + * Outside of view templates no such selection is possible: the function that performs it is + * provided by the XSL of the notice viewer and exists nowhere else. There the value of a + * multilingual field is retrieved like that of any other text field. + */ + private PathExpression composeFieldValueReference(final PathExpression fieldReference) { + if (fieldReference.is(EfxDataType.MultilingualString.class)) { + return Expression.from(this.script.getTextInPreferredLanguage(fieldReference), + fieldReference.getClass()); + } + return this.script.composeFieldValueReference(fieldReference); + } + + // #endregion Value References ---------------------------------------------- + // #region Context Declaration Blocks {...} --------------------------------- /** diff --git a/src/main/java/eu/europa/ted/efx/sdk1/xpath/XPathScriptGeneratorV1.java b/src/main/java/eu/europa/ted/efx/sdk1/xpath/XPathScriptGeneratorV1.java index 38391384..82d92d58 100644 --- a/src/main/java/eu/europa/ted/efx/sdk1/xpath/XPathScriptGeneratorV1.java +++ b/src/main/java/eu/europa/ted/efx/sdk1/xpath/XPathScriptGeneratorV1.java @@ -25,7 +25,6 @@ import eu.europa.ted.efx.model.expressions.scalar.NumericExpression; import eu.europa.ted.efx.model.expressions.scalar.StringExpression; import eu.europa.ted.efx.model.expressions.scalar.StringLiteral; -import eu.europa.ted.efx.model.types.EfxDataType; import eu.europa.ted.efx.xpath.XPathScriptGenerator; @SdkComponent(versions = {"1"}, componentType = SdkComponentType.SCRIPT_GENERATOR) @@ -48,29 +47,6 @@ public StringExpression composeToStringConversion(NumericExpression number) { return new StringExpression("format-number(" + number.getScript() + ", '" + formatString + "')"); } - /*** - * This method is overridden to workaround a limitation of EFX 1. - * - * When a multilingual text field is referenced, then a special XPath expression - * is generated to retrieve the value in the "preferred" language. - * Preferred language is the first language among the languages listed in the - * translator options for which a text value is available in the field. - * - * The logic of the workaround is as follows: - * if the fieldReference is a multilingual text field and it does not - * already come with a predicate that filters by @languageID, then we add a - * predicate which, using a for loop, will find the first language for which a - * value is available in the field. - * - * In EFX 1 therefore the selection of the appropriate (preferred) language is - * done implicitly, whereas in EFX 2 it is done explicitly by calling a special - * function designed to perform this task. - * - * Both EFX-1 and EFX-2 implementations of the feature rely on the existence of a - * $PREFERRED_LANGUAGES variable in the XSLT. - * This function returns the list of languages used in the visualisation in the - * order of preference (visualisation language followed by notice language(s)). - */ /** * Preserved V1 behavior: pass EFX string literal through as-is without converting * escape sequences to XPath format. @@ -91,13 +67,31 @@ public BooleanExpression composePatternMatchCondition(StringExpression expressio String.format("fn:matches(normalize-space(%s), %s)", expression.getScript(), pattern)); } + /*** + * Retrieves the value of a multilingual text field in the "preferred" language. + * Preferred language is the first language among the languages listed in the + * translator options for which a text value is available in the field. + * + * This is a workaround for a limitation of EFX 1: the language cannot be selected + * explicitly by the template author, so template translation applies this + * implicitly to every multilingual field it renders. In EFX 2 the selection is + * done explicitly, by calling a function designed to perform this task. + * + * If the reference already comes with a predicate that filters by @languageID, + * then the template author has already pinned a language and the value is + * retrieved as-is. + */ @Override - public PathExpression composeFieldValueReference(PathExpression fieldReference) { - XPathInfo xpathInfo = XPathProcessor.parse(fieldReference.getScript()); - if (fieldReference.is(EfxDataType.MultilingualString.class) && !xpathInfo.hasPredicate("@languageID")) { - return Expression.instantiate("efx:preferred-language-text(" + fieldReference.getScript() + ")", fieldReference.getClass()); + public StringExpression getTextInPreferredLanguage(final PathExpression fieldReference) { + final XPathInfo xpathInfo = XPathProcessor.parse(fieldReference.getScript()); + if (xpathInfo.hasPredicate("@languageID")) { + // The value reference is a PathExpression, which is not a StringExpression and cannot + // be returned as such. Only the generated script matters here: the caller re-creates + // the expression using the type of the field reference it started from. + return Expression.from(super.composeFieldValueReference(fieldReference), + StringExpression.class); } - return super.composeFieldValueReference(fieldReference); + return super.getTextInPreferredLanguage(fieldReference); } @Override diff --git a/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java b/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java index 1b002876..e4907b09 100644 --- a/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java +++ b/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java @@ -1919,6 +1919,25 @@ public void exitSequenceFromFieldReference(SequenceFromFieldReferenceContext ctx this.resolveAndPushFieldReference(ctx, result, fieldId); } + /** + * A selector yields the reference itself rather than its value: the value step that every other + * reference position applies is deliberately not composed here. The path is otherwise resolved + * exactly as it would be in an expression, relative to the declared context unless the author + * wrote it as an absolute reference. + * + *

The reference tier has already left the path on the stack. Only an attribute reference + * needs work, because {@code attributeReference} has no exit handler of its own; composing the + * attribute step globally would double-compose it for the scalar and sequence positions. + */ + @Override + public void exitSelection(final SelectionContext ctx) { + if (ctx.attributeReference() != null) { + this.stack.push(this.script.composeFieldAttributeReference( + this.stack.pop(PathExpression.class), + ctx.attributeReference().attributeName.getText(), StringPath.class)); + } + } + @Override public void exitScalarFromAttributeReference(ScalarFromAttributeReferenceContext ctx) { PathExpression result = this.script.composeFieldAttributeReference(this.stack.pop(PathExpression.class), @@ -2055,12 +2074,11 @@ public void exitFieldReferenceWithFieldContextOverride( */ @Override public void exitContextNodeSpecifier(ContextNodeSpecifierContext ctx) { - this.stack.pop(PathExpression.class); // Discard the PathExpression placed in the stack for - // the context node. + final PathExpression contextNodePath = this.stack.pop(PathExpression.class); final String contextNodeId = getNodeId(ctx.node); this.efxContext .push(new NodeContext(contextNodeId, this.symbols.getAbsolutePathOfNode(contextNodeId), - this.symbols.getRelativePathOfNode(contextNodeId, this.efxContext.symbol()))); + contextNodePath)); } /** diff --git a/src/main/java/eu/europa/ted/efx/xpath/XPathContextualizer.java b/src/main/java/eu/europa/ted/efx/xpath/XPathContextualizer.java index cfd0eb5e..4d5da9cd 100644 --- a/src/main/java/eu/europa/ted/efx/xpath/XPathContextualizer.java +++ b/src/main/java/eu/europa/ted/efx/xpath/XPathContextualizer.java @@ -13,6 +13,7 @@ */ package eu.europa.ted.efx.xpath; +import eu.europa.ted.eforms.xpath.XPathProcessor.Simplification; import eu.europa.ted.eforms.xpath.XPathProcessor; import eu.europa.ted.efx.model.expressions.Expression; import eu.europa.ted.efx.model.expressions.PathExpression; @@ -39,9 +40,15 @@ public static PathExpression contextualize(final PathExpression contextXpath, return Expression.instantiate(result, xpath.getClass()); } - public static PathExpression join(final PathExpression first, final PathExpression second) { + /** + * Joins the path of a context to a path that is relative to it, shortening the result as far as + * the caller asks for. + */ + public static PathExpression join(final PathExpression first, final PathExpression second, + final Simplification simplification) { - String joinedXPath = XPathProcessor.join(first.getScript(), second.getScript()); + String joinedXPath = + XPathProcessor.join(first.getScript(), second.getScript(), simplification); return Expression.instantiate(joinedXPath, second.getClass()); } diff --git a/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java b/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java index 83c90a18..f2219137 100644 --- a/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java +++ b/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java @@ -27,6 +27,7 @@ import eu.europa.ted.eforms.sdk.component.SdkComponent; import eu.europa.ted.eforms.sdk.component.SdkComponentType; +import eu.europa.ted.eforms.xpath.XPathProcessor.Simplification; import eu.europa.ted.efx.interfaces.ScriptGenerator; import eu.europa.ted.efx.interfaces.TranslatorOptions; import eu.europa.ted.efx.model.expressions.Expression; @@ -298,7 +299,12 @@ public T composeParenthesizedExpression(T expression, Cla @Override public PathExpression joinPaths(final PathExpression first, final PathExpression second) { - return XPathContextualizer.join(first, second); + // Every step of both paths is kept. A context override means the value of the field by a path + // that starts at the context, so where the context is not present in the notice there is no + // starting point and nothing is selected. Shortening the path would remove the walk to the + // context and with it that condition, leaving an override indistinguishable from a plain + // reference. + return XPathContextualizer.join(first, second, Simplification.NONE); } @Override @@ -784,11 +790,27 @@ public StringLiteral getStringLiteralFromUnquotedString(String value) { return new StringLiteral("'" + value + "'"); } + /** + * Emits a call to {@code efx:preferred-language}, a function of the notice viewer's XSLT runtime + * library. It returns the identifier of the first language, among those the visualisation + * prefers, for which the field holds a value. + * + * @see #getTextInPreferredLanguage(PathExpression) + */ @Override public StringExpression getPreferredLanguage(PathExpression fieldReference) { return new StringExpression("efx:preferred-language(" + fieldReference.getScript() + ")"); } + /** + * Emits a call to {@code efx:preferred-language-text}, a function of the notice viewer's XSLT + * runtime library. + * + * Both EFX-1 and EFX-2 reach this method, and the function they call relies on a + * {@code $PREFERRED_LANGUAGES} variable defined by the XSLT. That variable holds the languages + * used in the visualisation, in order of preference: the visualisation language followed by the + * notice languages. + */ @Override public StringExpression getTextInPreferredLanguage(PathExpression fieldReference) { return new StringExpression("efx:preferred-language-text(" + fieldReference.getScript() + ")"); diff --git a/src/test/java/eu/europa/ted/efx/EfxTestsBase.java b/src/test/java/eu/europa/ted/efx/EfxTestsBase.java index b477e9cf..90a2d06e 100644 --- a/src/test/java/eu/europa/ted/efx/EfxTestsBase.java +++ b/src/test/java/eu/europa/ted/efx/EfxTestsBase.java @@ -5,15 +5,10 @@ import eu.europa.ted.efx.interfaces.TranslatorOptions; import eu.europa.ted.efx.mock.DependencyFactoryMock; import eu.europa.ted.efx.model.DecimalFormat; -import net.sf.saxon.s9api.ExtensionFunction; -import net.sf.saxon.s9api.ItemType; -import net.sf.saxon.s9api.OccurrenceIndicator; import net.sf.saxon.s9api.Processor; import net.sf.saxon.s9api.QName; import net.sf.saxon.s9api.SaxonApiException; -import net.sf.saxon.s9api.SequenceType; import net.sf.saxon.s9api.XPathCompiler; -import net.sf.saxon.s9api.XdmValue; public abstract class EfxTestsBase { @@ -26,51 +21,17 @@ public abstract class EfxTestsBase { static { Processor processor = new Processor(false); - // Register custom EFX extension functions so Saxon can validate XPath syntax. - // These are only needed for V1 expression tests, where multilingual field references - // are implicitly wrapped in efx:preferred-language-text() by XPathScriptGeneratorV1. - // V2 bans these functions in expression context (template-only). - processor.registerExtensionFunction(efxFunction("preferred-language")); - processor.registerExtensionFunction(efxFunction("preferred-language-text")); - XPATH_COMPILER = processor.newXPathCompiler(); XPATH_COMPILER.setLanguageVersion("3.1"); XPATH_COMPILER.declareNamespace("fn", "http://www.w3.org/2005/xpath-functions"); XPATH_COMPILER.declareNamespace("xs", "http://www.w3.org/2001/XMLSchema"); + // The functions of the EFX namespace are provided by the XSL of the notice viewer and are + // available to view templates only. None of them is registered here, so an expression that + // calls one fails to compile: that is what keeps them out of validation rules. XPATH_COMPILER.declareNamespace("efx", EFX_NAMESPACE); XPATH_COMPILER.declareVariable(new QName("urlPrefix")); } - /** - * Creates a dummy extension function stub for XPath syntax validation. - * Accepts one argument (node) and returns a string. - */ - private static ExtensionFunction efxFunction(String localName) { - return new ExtensionFunction() { - @Override - public QName getName() { - return new QName(EFX_NAMESPACE, localName); - } - - @Override - public SequenceType getResultType() { - return SequenceType.makeSequenceType(ItemType.STRING, OccurrenceIndicator.ONE); - } - - @Override - public SequenceType[] getArgumentTypes() { - return new SequenceType[] { - SequenceType.makeSequenceType(ItemType.ANY_ITEM, OccurrenceIndicator.ONE_OR_MORE) - }; - } - - @Override - public XdmValue call(XdmValue[] arguments) { - throw new UnsupportedOperationException("Stub for XPath validation only"); - } - }; - } - protected abstract String getSdkVersion(); protected void testExpressionTranslationWithContext(final String expectedTranslation, diff --git a/src/test/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1Test.java b/src/test/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1Test.java index 918c6b2c..8d1206b7 100644 --- a/src/test/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1Test.java @@ -90,7 +90,7 @@ void testLikePatternCondition_WithNot() { @Test void testFieldValueComparison_UsingTextFields() { testExpressionTranslationWithContext( - "PathNode/TextField/normalize-space(text()) = efx:preferred-language-text(PathNode/TextMultilingualField)", + "PathNode/TextField/normalize-space(text()) = PathNode/TextMultilingualField/normalize-space(text())", "ND-Root", "BT-00-Text == BT-00-Text-Multilingual"); } @@ -1089,13 +1089,21 @@ void testFieldReferenceInOtherNotice() { @Test void testFieldReferenceWithFieldContextOverride() { - testExpressionTranslationWithContext("../TextField/normalize-space(text())", "BT-00-Code", + testExpressionTranslationWithContext("../ChildNode/SubLevelTextField/../../TextField/normalize-space(text())", "BT-00-Code", "BT-01-SubLevel-Text::BT-00-Text"); } + @Test + void testFieldReferenceWithFieldContextOverride_WithPredicate() { + testExpressionTranslationWithContext( + "../ChildNode/SubLevelTextField['a' = 'a']/../../TextField/normalize-space(text())", + "BT-00-Code", + "BT-01-SubLevel-Text['a' == 'a']::BT-00-Text"); + } + @Test void testFieldReferenceWithFieldContextOverride_WithIntegerField() { - testExpressionTranslationWithContext("../IntegerField/number()", "BT-00-Code", + testExpressionTranslationWithContext("../ChildNode/SubLevelTextField/../../IntegerField/number()", "BT-00-Code", "BT-01-SubLevel-Text::BT-00-Integer"); } @@ -1107,7 +1115,7 @@ void testFieldReferenceWithNodeContextOverride() { @Test void testFieldReferenceWithNodeContextOverride_WithPredicate() { - testExpressionTranslationWithContext("../../PathNode/IntegerField/number()", "BT-00-Text", + testExpressionTranslationWithContext("../..[PathNode/IndicatorField = true()]/PathNode/IntegerField/number()", "BT-00-Text", "ND-Root[BT-00-Indicator == TRUE]::BT-00-Integer"); } @@ -1135,12 +1143,24 @@ void testFieldReference_WithAxis() { "ND-Root::preceding::BT-00-Integer"); } + /** + * Outside of view templates there is no preferred language to select: efx:preferred-language-text() + * is defined by the notice viewer's XSL and is unavailable anywhere else, so a multilingual field + * value is retrieved like any other text value. + */ @Test void testMultilingualTextFieldReference() { - testExpressionTranslationWithContext("efx:preferred-language-text(PathNode/TextMultilingualField)", + testExpressionTranslationWithContext("PathNode/TextMultilingualField/normalize-space(text())", "ND-Root", "BT-00-Text-Multilingual"); } + @Test + void testMultilingualTextFieldReference_AsSequence() { + testExpressionTranslationWithContext( + "for $t in PathNode/TextMultilingualField/normalize-space(text()) return $t", "ND-Root", + "for text:$t in BT-00-Text-Multilingual return $t"); + } + @Test void testMultilingualTextFieldReference_WithLanguagePredicate() { testExpressionTranslationWithContext("PathNode/TextMultilingualField[./@languageID = 'eng']/normalize-space(text())", @@ -1641,4 +1661,131 @@ void testParameterizedExpression_WithDurationParameter() { // #endregion: Compare sequences // #endregion Sequence Functions + + // #region: Selectors ------------------------------------------------------- + + /** + * A selector yields the same path an expression would use, without the value step that an + * expression appends. It is relative or absolute according to how the reference was written, + * exactly as in any other position in the language. + */ + @Test + void testSelector_YieldsTheSamePathAsTheEquivalentExpression() { + assertEquals(translateExpression("{ND-Root} ${BT-00-Text}"), + translateExpression("{ND-Root} &{BT-00-Text}") + "/normalize-space(text())"); + assertEquals(translateExpression("{ND-SubNode} ${BT-00-Text}"), + translateExpression("{ND-SubNode} &{BT-00-Text}") + "/normalize-space(text())"); + assertEquals(translateExpression("{ND-Root} ${/BT-00-Text}"), + translateExpression("{ND-Root} &{/BT-00-Text}") + "/normalize-space(text())"); + assertEquals(translateExpression("{ND-Root} ${BT-00-Integer}"), + translateExpression("{ND-Root} &{BT-00-Integer}") + "/number()"); + } + + @Test + void testSelector_FieldReference() { + testExpressionTranslation("PathNode/TextField", "{ND-Root} &{BT-00-Text}"); + } + + @Test + void testSelector_AbsoluteFieldReference() { + testExpressionTranslation("/*/PathNode/TextField", "{ND-Root} &{/BT-00-Text}"); + } + + @Test + void testSelector_WithPredicate() { + testExpressionTranslation( + "/*/PathNode/TextField[../CodeField/normalize-space(text()) = 'x']", + "{ND-Root} &{/BT-00-Text[BT-00-Code == 'x']}"); + } + + @Test + void testSelector_NumericFieldHasNoValueStep() { + testExpressionTranslation("PathNode/IntegerField", "{ND-Root} &{BT-00-Integer}"); + } + + @Test + void testSelector_DurationFieldHasNoValueStep() { + testExpressionTranslation("PathNode/DurationField", "{ND-Root} &{BT-00-Duration}"); + } + + @Test + void testSelector_IndicatorField() { + testExpressionTranslation("PathNode/IndicatorField", "{ND-Root} &{BT-00-Indicator}"); + } + + @Test + void testSelector_MultilingualField() { + testExpressionTranslation("PathNode/TextMultilingualField", + "{ND-Root} &{BT-00-Text-Multilingual}"); + } + + @Test + void testSelector_AttributeField() { + testExpressionTranslation("PathNode/TextField/@Attribute", "{ND-Root} &{BT-00-Attribute}"); + } + + @Test + void testSelector_ExplicitAttributeReference() { + testExpressionTranslation("/*/PathNode/TextField/@Attribute", + "{ND-Root} &{/BT-00-Text/@Attribute}"); + } + + @Test + void testSelector_NodeReference() { + testExpressionTranslation("SubNode", "{ND-Root} &{ND-SubNode}"); + } + + @Test + void testSelector_AbsoluteNodeReference() { + testExpressionTranslation("/*/SubNode", "{ND-Root} &{/ND-SubNode}"); + } + + @Test + void testSelector_FieldUnderAnotherNode() { + testExpressionTranslation("SubNode/SubTextField", "{ND-Root} &{BT-01-SubNode-Text}"); + } + + @Test + void testSelector_ContextIsTheReferencedFieldItself() { + testExpressionTranslation(".", "{BT-00-Text} &{BT-00-Text}"); + } + + @Test + void testSelector_ContextIsAnotherNode() { + testExpressionTranslation("SubTextField", "{ND-SubNode} &{BT-01-SubNode-Text}"); + testExpressionTranslation("../PathNode/TextField", "{ND-SubNode} &{BT-00-Text}"); + } + + @Test + void testSelector_ContextIsAField() { + testExpressionTranslation("../CodeField", "{BT-00-Text} &{BT-00-Code}"); + testExpressionTranslation("../../SubNode/SubTextField", "{BT-00-Text} &{BT-01-SubNode-Text}"); + } + + @Test + void testSelector_ContextIsAFieldWithPredicate() { + testExpressionTranslation(".[../CodeField/normalize-space(text()) = 'x']", + "{BT-00-Text} &{BT-00-Text[BT-00-Code == 'x']}"); + testExpressionTranslation( + "SubTextField[../../PathNode/CodeField/normalize-space(text()) = 'x']", + "{ND-SubNode} &{BT-01-SubNode-Text[BT-00-Code == 'x']}"); + } + + /** + * An absolute selector is obtained the same way as an absolute reference anywhere else: by + * writing the leading slash. Consumers that evaluate against a whole document need that. + */ + @Test + void testSelector_IsAbsoluteOnlyWhenWrittenAsAbsolute() { + testExpressionTranslation("/*/PathNode/TextField", "{ND-SubNode} &{/BT-00-Text}"); + testExpressionTranslation("/*/PathNode/CodeField", "{BT-00-Text} &{/BT-00-Code}"); + } + + @Test + void testSelector_RejectsValueExpression() { + assertThrows(ParseCancellationException.class, + () -> translateExpression("{ND-Root} &{BT-00-Text == 'x'}")); + } + + // #endregion: Selectors ---------------------------------------------------- } diff --git a/src/test/java/eu/europa/ted/efx/sdk1/EfxTemplateTranslatorV1Test.java b/src/test/java/eu/europa/ted/efx/sdk1/EfxTemplateTranslatorV1Test.java index cf92627e..b178b811 100644 --- a/src/test/java/eu/europa/ted/efx/sdk1/EfxTemplateTranslatorV1Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk1/EfxTemplateTranslatorV1Test.java @@ -377,4 +377,51 @@ void testImplicitFormatting_Dates() { void testImplicitFormatting_Times() { assertEquals("TEMPLATES:\nlet block01() -> { eval(for $item in PathNode/StartTimeField/xs:time(text()) return format-time($item, '[H01]:[m01] [Z]')) }\nMAIN:\nfor-each(/*).call(block01())", translateTemplate("{ND-Root} ${BT-00-StartTime}")); } + + /*** Multilingual fields ***/ + + /** + * In a view template the value of a multilingual field must be retrieved in the preferred + * language, so the reference is wrapped in a call to efx:preferred-language-text(). + * This is the behaviour that template translation must keep. + */ + @Test + void testMultilingualTextField_IsRetrievedInPreferredLanguage() { + assertEquals( + "TEMPLATES:\nlet block01() -> { eval(efx:preferred-language-text(PathNode/TextMultilingualField)) }\nMAIN:\nfor-each(/*).call(block01())", + translateTemplate("{ND-Root} ${BT-00-Text-Multilingual}")); + } + + /** + * When the reference already pins a language, the value is retrieved as-is: adding the preferred + * language call on top would override the language the template author asked for. + */ + @Test + void testMultilingualTextField_WithLanguagePredicate_IsRetrievedAsIs() { + assertEquals( + "TEMPLATES:\nlet block01() -> { eval(PathNode/TextMultilingualField[./@languageID = 'eng']/normalize-space(text())) }\nMAIN:\nfor-each(/*).call(block01())", + translateTemplate( + "{ND-Root} ${BT-00-Text-Multilingual[BT-00-Text-Multilingual/@languageID == 'eng']}")); + } + + /** + * A multilingual field used as a sequence goes through its own code path, so it needs its own + * coverage. + */ + @Test + void testMultilingualTextField_AsSequence_IsRetrievedInPreferredLanguage() { + assertEquals( + "TEMPLATES:\nlet block01() -> { eval(for $t in efx:preferred-language-text(PathNode/TextMultilingualField) return $t) }\nMAIN:\nfor-each(/*).call(block01())", + translateTemplate("{ND-Root} ${for text:$t in BT-00-Text-Multilingual return $t}")); + } + + /** + * The $value shorthand goes through a template-only code path, so it needs its own coverage. + */ + @Test + void testMultilingualTextField_ShorthandValueReference_IsRetrievedInPreferredLanguage() { + assertEquals( + "TEMPLATES:\nlet block01() -> { eval(efx:preferred-language-text(.)) }\nMAIN:\nfor-each(/*/PathNode/TextMultilingualField).call(block01())", + translateTemplate("{BT-00-Text-Multilingual} $value")); + } } diff --git a/src/test/java/eu/europa/ted/efx/sdk2/EfxComputeDependencyExtractorTest.java b/src/test/java/eu/europa/ted/efx/sdk2/EfxComputeDependencyExtractorTest.java index 89545dc6..37b51b18 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/EfxComputeDependencyExtractorTest.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxComputeDependencyExtractorTest.java @@ -132,4 +132,41 @@ void testDuplicateFieldReferences() { } // #endregion: Deduplication + + // #region: Selectors ------------------------------------------------------- + + /** + * The extractor parses the same top-level rule as the translators, so it sees the selector + * alternative added to the grammar although it has no handler of its own for it. It nevertheless + * derives the correct dependencies, because it listens to the reference exits rather than to the + * top-level block. These tests record that, so that the behaviour is not lost inadvertently. + */ + @Test + void testSelector_YieldsTheSameDependenciesAsTheEquivalentExpression() { + assertEquals(extract("WITH ND-Root COMPUTE BT-00-Text"), + extract("WITH ND-Root SELECT BT-00-Text")); + } + + @Test + void testSelector_IncludesReferencesFromThePredicate() { + Set deps = extract("WITH ND-Root SELECT /BT-00-Text[BT-00-Code == 'x']"); + assertTrue(deps.contains("BT-00-Text")); + assertTrue(deps.contains("BT-00-Code")); + assertTrue(deps.contains("ND-Root")); + } + + @Test + void testSelector_IncludesANodeReference() { + Set deps = extract("WITH ND-Root SELECT ND-SubNode"); + assertTrue(deps.contains("ND-SubNode")); + assertTrue(deps.contains("ND-Root")); + } + + @Test + void testSelector_BraceSpellingBehavesTheSame() { + assertEquals(extract("WITH ND-Root SELECT /BT-00-Text"), + extract("{ND-Root} &{/BT-00-Text}")); + } + + // #endregion: Selectors ---------------------------------------------------- } diff --git a/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java b/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java index 7b8b103f..67683825 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java @@ -1265,13 +1265,13 @@ void testFieldReferenceWithPredicate_WithFieldReferenceInPredicate() { @Test void testFieldReferenceWithFieldContextOverride() { - testExpressionTranslationWithContext("../TextField/normalize-space(text())", "BT-00-Code", + testExpressionTranslationWithContext("../ChildNode/SubLevelTextField/../../TextField/normalize-space(text())", "BT-00-Code", "BT-01-SubLevel-Text::BT-00-Text"); } @Test void testFieldReferenceWithFieldContextOverride_WithIntegerField() { - testExpressionTranslationWithContext("../IntegerField/number()", "BT-00-Code", + testExpressionTranslationWithContext("../ChildNode/SubLevelTextField/../../IntegerField/number()", "BT-00-Code", "BT-01-SubLevel-Text::integerField"); } @@ -1281,9 +1281,32 @@ void testFieldReferenceWithNodeContextOverride() { "ND-Root::integerField"); } + @Test + void testFieldReferenceWithNodeContextOverride_AbsoluteAnchor() { + testExpressionTranslationWithContext("/*/PathNode/TextField", "BT-00-Text", + "/ND-Root::BT-00-Text is present"); + } + + @Test + void testFieldReferenceWithFieldContextOverride_AbsoluteAnchor() { + testExpressionTranslationWithContext( + "/*/PathNode/ChildNode/SubLevelTextField/../../TextField", "BT-00-Code", + "/BT-01-SubLevel-Text::BT-00-Text is present"); + } + + @Test + void testFieldReferenceWithNodeContextOverride_WithPredicateFromTheSdk() { + // The anchor's path carries a predicate that comes from the SDK rather than from the + // expression. It has to survive for the same reason a written one does. + testExpressionTranslationWithContext( + "../FieldsPrivacy[FieldIdentifierCode/text()='rep-text-priv']/ReasonCode", + "BT-00-Text", + "ND-PrivacyForRepeatableText::BT-197(BT-00)-Repeatable-Text is present"); + } + @Test void testFieldReferenceWithNodeContextOverride_WithPredicate() { - testExpressionTranslationWithContext("../../PathNode/IntegerField/number()", "BT-00-Text", + testExpressionTranslationWithContext("../..[PathNode/IndicatorField = true()]/PathNode/IntegerField/number()", "BT-00-Text", "ND-Root[BT-00-Indicator == TRUE]::integerField"); } @@ -3820,19 +3843,21 @@ void testLinkedProperty_InSequenceContext() { @Test void testFieldContextOverride_WithPredicateOnContext() { - // joinPaths simplifies: ../ChildNode/SubLevelTextField['a'='a']/../../TextField → ../TextField - // because navigating UP from a predicated node reaches the parent regardless + // The walk to the context is kept: the override says the value is reached by a path that + // starts at the context, so where no SubLevelTextField satisfies the predicate there is + // nothing to start from and nothing is selected. testExpressionTranslationWithContext( - "../TextField/normalize-space(text())", + "../ChildNode/SubLevelTextField['a' = 'a']/../../TextField/normalize-space(text())", "BT-00-Code", "BT-01-SubLevel-Text['a' == 'a']::BT-00-Text"); } @Test void testFieldContextOverride_WithIndexerOnContext() { - // joinPaths simplifies the indexed context path the same way as predicates + // An indexed context is kept for the same reason as a predicated one: without a first + // SubLevelTextField there is no starting point. testExpressionTranslationWithContext( - "../TextField/normalize-space(text())", + "../ChildNode/SubLevelTextField[1]/../../TextField/normalize-space(text())", "BT-00-Code", "BT-01-SubLevel-Text[1]::BT-00-Text"); } @@ -3840,7 +3865,7 @@ void testFieldContextOverride_WithIndexerOnContext() { @Test void testFieldContextOverride_InSequenceContext() { testExpressionTranslationWithContext( - "'test' = ../TextField/normalize-space(text())", + "'test' = ../ChildNode/SubLevelTextField/../../TextField/normalize-space(text())", "BT-00-Code", "'test' in BT-01-SubLevel-Text::BT-00-Text"); } @@ -3855,9 +3880,10 @@ void testNodeContextOverride_InSequenceContext() { @Test void testStackedOverride_NodeAndField() { - // joinPaths simplifies: .../ChildNode/SubLevelTextField/../../TextField → .../TextField + // The walk to the context is kept even with nothing written on it, so the override + // selects nothing where the context is absent. testExpressionTranslationWithContext( - "../../PathNode/TextField/normalize-space(text())", + "../../PathNode/ChildNode/SubLevelTextField/../../TextField/normalize-space(text())", "BT-00-Text", "ND-Root::BT-01-SubLevel-Text::BT-00-Text"); } @@ -3866,7 +3892,7 @@ void testStackedOverride_NodeAndField() { @Test void testFieldContextOverride_InPresenceCondition() { testExpressionTranslationWithContext( - "../TextField", + "../ChildNode/SubLevelTextField/../../TextField", "BT-00-Code", "BT-01-SubLevel-Text::BT-00-Text is present"); } @@ -3875,7 +3901,7 @@ void testFieldContextOverride_InPresenceCondition() { @Test void testFieldContextOverride_InAttributeContext() { testExpressionTranslationWithContext( - "../CodeField/@listName", + "../ChildNode/SubLevelTextField/../../CodeField/@listName", "BT-00-Code", "BT-01-SubLevel-Text::BT-00-Code/@listName"); } @@ -3884,7 +3910,7 @@ void testFieldContextOverride_InAttributeContext() { @Test void testFieldContextOverride_WithPredicate_InSequenceContext() { testExpressionTranslationWithContext( - "'test' = ../TextField/normalize-space(text())", + "'test' = ../ChildNode/SubLevelTextField['a' = 'a']/../../TextField/normalize-space(text())", "BT-00-Code", "'test' in BT-01-SubLevel-Text['a' == 'a']::BT-00-Text"); } @@ -3893,7 +3919,7 @@ void testFieldContextOverride_WithPredicate_InSequenceContext() { @Test void testFieldContextOverride_WithPredicate_InPresenceCondition() { testExpressionTranslationWithContext( - "../TextField", + "../ChildNode/SubLevelTextField['a' = 'a']/../../TextField", "BT-00-Code", "BT-01-SubLevel-Text['a' == 'a']::BT-00-Text is present"); } @@ -3902,7 +3928,7 @@ void testFieldContextOverride_WithPredicate_InPresenceCondition() { @Test void testFieldContextOverride_WithPredicate_InAttributeContext() { testExpressionTranslationWithContext( - "../CodeField/@listName", + "../ChildNode/SubLevelTextField['a' = 'a']/../../CodeField/@listName", "BT-00-Code", "BT-01-SubLevel-Text['a' == 'a']::BT-00-Code/@listName"); } @@ -3911,7 +3937,7 @@ void testFieldContextOverride_WithPredicate_InAttributeContext() { @Test void testFieldContextOverride_WithIndexer_InSequenceContext() { testExpressionTranslationWithContext( - "'test' = ../TextField/normalize-space(text())", + "'test' = ../ChildNode/SubLevelTextField[1]/../../TextField/normalize-space(text())", "BT-00-Code", "'test' in BT-01-SubLevel-Text[1]::BT-00-Text"); } @@ -3920,7 +3946,7 @@ void testFieldContextOverride_WithIndexer_InSequenceContext() { @Test void testFieldContextOverride_WithIndexer_InPresenceCondition() { testExpressionTranslationWithContext( - "../TextField", + "../ChildNode/SubLevelTextField[1]/../../TextField", "BT-00-Code", "BT-01-SubLevel-Text[1]::BT-00-Text is present"); } @@ -3929,7 +3955,7 @@ void testFieldContextOverride_WithIndexer_InPresenceCondition() { @Test void testFieldContextOverride_WithIndexer_InAttributeContext() { testExpressionTranslationWithContext( - "../CodeField/@listName", + "../ChildNode/SubLevelTextField[1]/../../CodeField/@listName", "BT-00-Code", "BT-01-SubLevel-Text[1]::BT-00-Code/@listName"); } @@ -3956,7 +3982,7 @@ void testNodeContextOverride_InAttributeContext() { @Test void testNodeContextOverride_WithPredicate_InSequenceContext() { testExpressionTranslationWithContext( - "'test' = ../../PathNode/TextField/normalize-space(text())", + "'test' = ../..[PathNode/IndicatorField = true()]/PathNode/TextField/normalize-space(text())", "BT-00-Text", "'test' in ND-Root[BT-00-Indicator == TRUE]::BT-00-Text"); } @@ -3965,7 +3991,7 @@ void testNodeContextOverride_WithPredicate_InSequenceContext() { @Test void testNodeContextOverride_WithPredicate_InPresenceCondition() { testExpressionTranslationWithContext( - "../../PathNode/TextField", + "../..[PathNode/IndicatorField = true()]/PathNode/TextField", "BT-00-Text", "ND-Root[BT-00-Indicator == TRUE]::BT-00-Text is present"); } @@ -3974,7 +4000,7 @@ void testNodeContextOverride_WithPredicate_InPresenceCondition() { @Test void testNodeContextOverride_WithPredicate_InAttributeContext() { testExpressionTranslationWithContext( - "../../PathNode/CodeField/@listName", + "../..[PathNode/IndicatorField = true()]/PathNode/CodeField/@listName", "BT-00-Text", "ND-Root[BT-00-Indicator == TRUE]::BT-00-Code/@listName"); } @@ -4010,7 +4036,7 @@ void testVariableContextOverride_InAttributeContext() { @Test void testStackedOverride_NodeAndField_InSequenceContext() { testExpressionTranslationWithContext( - "'test' = ../../PathNode/TextField/normalize-space(text())", + "'test' = ../../PathNode/ChildNode/SubLevelTextField/../../TextField/normalize-space(text())", "BT-00-Text", "'test' in ND-Root::BT-01-SubLevel-Text::BT-00-Text"); } @@ -4019,7 +4045,7 @@ void testStackedOverride_NodeAndField_InSequenceContext() { @Test void testStackedOverride_NodeAndField_InPresenceCondition() { testExpressionTranslationWithContext( - "../../PathNode/TextField", + "../../PathNode/ChildNode/SubLevelTextField/../../TextField", "BT-00-Text", "ND-Root::BT-01-SubLevel-Text::BT-00-Text is present"); } @@ -4028,7 +4054,7 @@ void testStackedOverride_NodeAndField_InPresenceCondition() { @Test void testStackedOverride_NodeAndField_InAttributeContext() { testExpressionTranslationWithContext( - "../../PathNode/CodeField/@listName", + "../../PathNode/ChildNode/SubLevelTextField/../../CodeField/@listName", "BT-00-Text", "ND-Root::BT-01-SubLevel-Text::BT-00-Code/@listName"); } @@ -4674,4 +4700,113 @@ void testCompute_CaseInsensitive() { } // #endregion: EFX-2 COMPUTE syntax + + // #region: Selectors ------------------------------------------------------- + + /** + * A selector yields the same path an expression would use, without the value step that an + * expression appends. It is relative or absolute according to how the reference was written. + */ + @Test + void testSelector_YieldsTheSamePathAsTheEquivalentExpression() { + assertEquals(translateExpression("WITH ND-Root COMPUTE BT-00-Text"), + translateExpression("WITH ND-Root SELECT BT-00-Text") + "/normalize-space(text())"); + assertEquals(translateExpression("WITH ND-Root COMPUTE BT-00-Integer"), + translateExpression("WITH ND-Root SELECT BT-00-Integer") + "/number()"); + assertEquals(translateExpression("WITH ND-SubNode COMPUTE BT-00-Text"), + translateExpression("WITH ND-SubNode SELECT BT-00-Text") + "/normalize-space(text())"); + } + + /** Both spellings of the selector must produce the same result. */ + @Test + void testSelector_BothSpellingsAgree() { + assertEquals(translateExpression("WITH ND-Root SELECT /BT-00-Text"), + translateExpression("{ND-Root} &{/BT-00-Text}")); + assertEquals(translateExpression("WITH ND-Root SELECT /BT-00-Text[BT-00-Code == 'x']"), + translateExpression("{ND-Root} &{/BT-00-Text[BT-00-Code == 'x']}")); + } + + @Test + void testSelector_FieldReference() { + testExpressionTranslation("PathNode/TextField", "WITH ND-Root SELECT BT-00-Text"); + } + + @Test + void testSelector_AbsoluteFieldReference() { + testExpressionTranslation("/*/PathNode/TextField", "WITH ND-Root SELECT /BT-00-Text"); + } + + @Test + void testSelector_WithPredicate() { + testExpressionTranslation("/*/PathNode/TextField[../CodeField/normalize-space(text()) = 'x']", + "WITH ND-Root SELECT /BT-00-Text[BT-00-Code == 'x']"); + } + + @Test + void testSelector_NumericFieldHasNoValueStep() { + testExpressionTranslation("PathNode/IntegerField", "WITH ND-Root SELECT BT-00-Integer"); + } + + @Test + void testSelector_DurationFieldHasNoValueStep() { + testExpressionTranslation("PathNode/DurationField", "WITH ND-Root SELECT BT-00-Duration"); + } + + @Test + void testSelector_NodeReference() { + testExpressionTranslation("SubNode", "WITH ND-Root SELECT ND-SubNode"); + testExpressionTranslation("/*/SubNode", "WITH ND-Root SELECT /ND-SubNode"); + } + + @Test + void testSelector_AttributeReference() { + testExpressionTranslation("/*/PathNode/TextField/@Attribute", + "WITH ND-Root SELECT /BT-00-Text/@Attribute"); + } + + @Test + void testSelector_IsRelativeToTheDeclaredContext() { + testExpressionTranslation("../PathNode/TextField", "WITH ND-SubNode SELECT BT-00-Text"); + testExpressionTranslation("/*/PathNode/TextField", "WITH ND-SubNode SELECT /BT-00-Text"); + } + + @Test + void testSelector_KeywordIsCaseInsensitive() { + assertEquals(translateExpression("WITH ND-Root SELECT BT-00-Text"), + translateExpression("with ND-Root select BT-00-Text")); + } + + @Test + void testSelector_RejectsValueExpression() { + assertThrows(ParseCancellationException.class, + () -> translateExpression("WITH ND-Root SELECT BT-00-Text == 'x'")); + } + + + /** + * A selector's indexer is a node-level indexer, like every other {@code fieldContext} in the + * language: it selects the nth occurrence within each parent, not the nth item of the sequence + * overall. This is why an indexed selector differs from an indexed expression, where the index + * applies to the sequence of values - the one case where a selector is not simply the expression + * without its value step. + * + *

Do not "correct" this into a parenthesised form. Indexing at node level is deliberate and is + * shared with {@code :rawValue}, context iterators and context overrides; changing it here alone + * would make the selector the odd one out, and changing it everywhere would redefine the language. + */ + @Test + void testSelector_IndexerAppliesAtNodeLevel() { + testExpressionTranslation("PathNode/TextField[1]", "WITH ND-Root SELECT BT-00-Text[1]"); + testExpressionTranslation("(PathNode/TextField/normalize-space(text()))[1]", + "WITH ND-Root COMPUTE BT-00-Text[1]"); + } + + @Test + void testSelector_IndexerAfterPredicate() { + testExpressionTranslation( + "/*/PathNode/TextField[../CodeField/normalize-space(text()) = 'x'][1]", + "WITH ND-Root SELECT /BT-00-Text[BT-00-Code == 'x'][1]"); + } + + // #endregion: Selectors ---------------------------------------------------- }