From 0d62981743da13b175f5c2e344415c450bf76c75 Mon Sep 17 00:00:00 2001 From: Ioannis Rosuochatzakis Date: Wed, 4 Feb 2026 04:36:11 +0100 Subject: [PATCH 1/5] TEDEFO-1854 Align toolkit to improved uniqueness testing - Implement typed uniqueness conditions (any value in any collection) - Fix: repeatable fields used as own context treated as scalar - Rename error code EXPECTED_SEQUENCE to EXPECTED_SCALAR --- .../ted/eforms/sdk/SdkSymbolResolver.java | 5 +- .../efx/exceptions/TypeMismatchException.java | 6 +- .../ted/efx/interfaces/ScriptGenerator.java | 37 +++++++++ .../efx/sdk2/EfxExpressionTranslatorV2.java | 71 +++++++++++++++++- .../ted/efx/xpath/XPathScriptGenerator.java | 44 +++++++++++ .../sdk2/EfxExpressionTranslatorV2Test.java | 75 ++++++++++++++++--- .../ted/efx/sdk2/SdkSymbolResolverTest.java | 14 ++++ 7 files changed, 235 insertions(+), 17 deletions(-) diff --git a/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java b/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java index faefcaf..08df970 100644 --- a/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java +++ b/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java @@ -414,9 +414,10 @@ public boolean isFieldRepeatableFromContext(final String fieldId, final String c } private boolean isFieldRepeatableFromContext(final SdkField sdkField, final SdkField context) { - // If the field itself is repeatable, it returns multiple values + // If the field itself is repeatable, it returns multiple values UNLESS it IS the context + // (e.g., inside a predicate on this field: BT-Repeatable[BT-Repeatable != '']) if (sdkField.isRepeatable()) { - return true; + return !sdkField.equals(context); } // Use cached ancestry from node diff --git a/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java b/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java index 1eeb6e1..381a0b9 100644 --- a/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java +++ b/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java @@ -30,13 +30,13 @@ public class TypeMismatchException extends ParseCancellationException { public enum ErrorCode { CANNOT_CONVERT, CANNOT_COMPARE, - EXPECTED_SEQUENCE, + EXPECTED_SCALAR, EXPECTED_FIELD_CONTEXT } private static final String CANNOT_CONVERT = "Type mismatch. Expected %s instead of %s."; private static final String CANNOT_COMPARE = "Type mismatch. Cannot compare values of different types: %s and %s"; - private static final String EXPECTED_SEQUENCE = "Type mismatch. Field '%s' may return multiple values from context '%s', but is used as a scalar. Use a sequence expression or change the context."; + private static final String EXPECTED_SCALAR = "Type mismatch. Field '%s' may return multiple values from context '%s', but is used as a scalar. Use a sequence expression or change the context."; private static final String EXPECTED_FIELD_CONTEXT = "Type mismatch. Context variable '$%s' refers to node '%s', but is used as a value. Only field context variables can be used in value expressions."; private final ErrorCode errorCode; @@ -71,7 +71,7 @@ public static TypeMismatchException cannotCompare(Expression left, Expression ri } public static TypeMismatchException fieldMayRepeat(String fieldId, String contextSymbol) { - return new TypeMismatchException(ErrorCode.EXPECTED_SEQUENCE, String.format(EXPECTED_SEQUENCE, fieldId, + return new TypeMismatchException(ErrorCode.EXPECTED_SCALAR, String.format(EXPECTED_SCALAR, fieldId, contextSymbol != null ? contextSymbol : "root")); } 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 3b0c29c..8e67e89 100644 --- a/src/main/java/eu/europa/ted/efx/interfaces/ScriptGenerator.java +++ b/src/main/java/eu/europa/ted/efx/interfaces/ScriptGenerator.java @@ -27,9 +27,13 @@ import eu.europa.ted.efx.model.expressions.scalar.ScalarExpression; import eu.europa.ted.efx.model.expressions.scalar.StringExpression; import eu.europa.ted.efx.model.expressions.scalar.TimeExpression; +import eu.europa.ted.efx.model.expressions.sequence.BooleanSequenceExpression; +import eu.europa.ted.efx.model.expressions.sequence.DateSequenceExpression; +import eu.europa.ted.efx.model.expressions.sequence.DurationSequenceExpression; import eu.europa.ted.efx.model.expressions.sequence.NumericSequenceExpression; import eu.europa.ted.efx.model.expressions.sequence.SequenceExpression; import eu.europa.ted.efx.model.expressions.sequence.StringSequenceExpression; +import eu.europa.ted.efx.model.expressions.sequence.TimeSequenceExpression; /** * A ScriptGenerator is used by the EFX expression translator to translate specific computations to @@ -412,9 +416,42 @@ public StringExpression composeSubstringExtraction(StringExpression text, Numeri public BooleanExpression composeExistsCondition(PathExpression reference); + /** + * Uniqueness check for EFX 1 syntax. + *

+ * This method supports the limited uniqueness syntax available in EFX 1. + * It is used exclusively by the EFX 1 translator and is kept for backward + * compatibility with EFX 1. + *

+ * EFX 2 does not use this method. EFX 2's stricter type checking enables + * more powerful uniqueness syntax, supported by the typed overloads below. + * + * @param needle The value to check for uniqueness + * @param haystack The collection to search within + * @return A boolean expression evaluating to true if needle appears exactly once in haystack + */ public BooleanExpression composeUniqueValueCondition(PathExpression needle, PathExpression haystack); + // Typed uniqueness conditions (EFX 2) + public BooleanExpression composeUniqueValueCondition(StringExpression needle, + StringSequenceExpression haystack); + + public BooleanExpression composeUniqueValueCondition(NumericExpression needle, + NumericSequenceExpression haystack); + + public BooleanExpression composeUniqueValueCondition(BooleanExpression needle, + BooleanSequenceExpression haystack); + + public BooleanExpression composeUniqueValueCondition(DateExpression needle, + DateSequenceExpression haystack); + + public BooleanExpression composeUniqueValueCondition(TimeExpression needle, + TimeSequenceExpression haystack); + + public BooleanExpression composeUniqueValueCondition(DurationExpression needle, + DurationSequenceExpression haystack); + public BooleanExpression composeSequenceEqualFunction(SequenceExpression one, SequenceExpression two); 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 e439e85..c2e7fc4 100644 --- a/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java +++ b/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java @@ -430,9 +430,74 @@ public void exitPresenceCondition(EfxParser.PresenceConditionContext ctx) { } @Override - public void exitUniqueValueCondition(EfxParser.UniqueValueConditionContext ctx) { - PathExpression haystack = this.stack.pop(PathExpression.class); - PathExpression needle = this.stack.pop(haystack.getClass()); + public void exitStringUniqueValueCondition(EfxParser.StringUniqueValueConditionContext ctx) { + StringSequenceExpression haystack = this.stack.pop(StringSequenceExpression.class); + StringExpression needle = this.stack.pop(StringExpression.class); + + if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { + this.stack.push( + this.script.composeLogicalNot(this.script.composeUniqueValueCondition(needle, haystack))); + } else { + this.stack.push(this.script.composeUniqueValueCondition(needle, haystack)); + } + } + + @Override + public void exitNumericUniqueValueCondition(EfxParser.NumericUniqueValueConditionContext ctx) { + NumericSequenceExpression haystack = this.stack.pop(NumericSequenceExpression.class); + NumericExpression needle = this.stack.pop(NumericExpression.class); + + if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { + this.stack.push( + this.script.composeLogicalNot(this.script.composeUniqueValueCondition(needle, haystack))); + } else { + this.stack.push(this.script.composeUniqueValueCondition(needle, haystack)); + } + } + + @Override + public void exitBooleanUniqueValueCondition(EfxParser.BooleanUniqueValueConditionContext ctx) { + BooleanSequenceExpression haystack = this.stack.pop(BooleanSequenceExpression.class); + BooleanExpression needle = this.stack.pop(BooleanExpression.class); + + if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { + this.stack.push( + this.script.composeLogicalNot(this.script.composeUniqueValueCondition(needle, haystack))); + } else { + this.stack.push(this.script.composeUniqueValueCondition(needle, haystack)); + } + } + + @Override + public void exitDateUniqueValueCondition(EfxParser.DateUniqueValueConditionContext ctx) { + DateSequenceExpression haystack = this.stack.pop(DateSequenceExpression.class); + DateExpression needle = this.stack.pop(DateExpression.class); + + if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { + this.stack.push( + this.script.composeLogicalNot(this.script.composeUniqueValueCondition(needle, haystack))); + } else { + this.stack.push(this.script.composeUniqueValueCondition(needle, haystack)); + } + } + + @Override + public void exitTimeUniqueValueCondition(EfxParser.TimeUniqueValueConditionContext ctx) { + TimeSequenceExpression haystack = this.stack.pop(TimeSequenceExpression.class); + TimeExpression needle = this.stack.pop(TimeExpression.class); + + if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { + this.stack.push( + this.script.composeLogicalNot(this.script.composeUniqueValueCondition(needle, haystack))); + } else { + this.stack.push(this.script.composeUniqueValueCondition(needle, haystack)); + } + } + + @Override + public void exitDurationUniqueValueCondition(EfxParser.DurationUniqueValueConditionContext ctx) { + DurationSequenceExpression haystack = this.stack.pop(DurationSequenceExpression.class); + DurationExpression needle = this.stack.pop(DurationExpression.class); if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { this.stack.push( 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 3bd4a92..a3da967 100644 --- a/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java +++ b/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java @@ -50,9 +50,13 @@ import eu.europa.ted.efx.model.expressions.scalar.StringLiteral; import eu.europa.ted.efx.model.expressions.scalar.TimeExpression; import eu.europa.ted.efx.model.expressions.scalar.TimeLiteral; +import eu.europa.ted.efx.model.expressions.sequence.BooleanSequenceExpression; +import eu.europa.ted.efx.model.expressions.sequence.DateSequenceExpression; +import eu.europa.ted.efx.model.expressions.sequence.DurationSequenceExpression; import eu.europa.ted.efx.model.expressions.sequence.NumericSequenceExpression; import eu.europa.ted.efx.model.expressions.sequence.SequenceExpression; import eu.europa.ted.efx.model.expressions.sequence.StringSequenceExpression; +import eu.europa.ted.efx.model.expressions.sequence.TimeSequenceExpression; import eu.europa.ted.efx.model.types.EfxDataType; @SdkComponent(versions = {"2"}, @@ -343,6 +347,10 @@ public BooleanExpression composeExistsCondition(PathExpression reference) { return new BooleanExpression(reference.getScript()); } + /** + * EFX 1 uniqueness check - kept for backward compatibility. + * EFX 2 uses the typed overloads below. + */ @Override public BooleanExpression composeUniqueValueCondition(PathExpression needle, PathExpression haystack) { @@ -350,6 +358,42 @@ public BooleanExpression composeUniqueValueCondition(PathExpression needle, + "[. = $x] return $y) = 1"); } + @Override + public BooleanExpression composeUniqueValueCondition(StringExpression needle, + StringSequenceExpression haystack) { + return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + } + + @Override + public BooleanExpression composeUniqueValueCondition(NumericExpression needle, + NumericSequenceExpression haystack) { + return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + } + + @Override + public BooleanExpression composeUniqueValueCondition(BooleanExpression needle, + BooleanSequenceExpression haystack) { + return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + } + + @Override + public BooleanExpression composeUniqueValueCondition(DateExpression needle, + DateSequenceExpression haystack) { + return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + } + + @Override + public BooleanExpression composeUniqueValueCondition(TimeExpression needle, + TimeSequenceExpression haystack) { + return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + } + + @Override + public BooleanExpression composeUniqueValueCondition(DurationExpression needle, + DurationSequenceExpression haystack) { + return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + } + //#endregion Boolean Expressions ------------------------------------------ //#region Boolean functions ----------------------------------------------- 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 3f1f83b..532f022 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java @@ -78,17 +78,66 @@ void testPresenceCondition_WithNot() { @Test void testUniqueValueCondition() { testExpressionTranslationWithContext( - "count(for $x in PathNode/TextField, $y in /*/PathNode/TextField[. = $x] return $y) = 1", + "count(/*/PathNode/TextField/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1", "ND-Root", "BT-00-Text is unique in /BT-00-Text"); } @Test void testUniqueValueCondition_WithNot() { testExpressionTranslationWithContext( - "not(count(for $x in PathNode/TextField, $y in /*/PathNode/TextField[. = $x] return $y) = 1)", + "not(count(/*/PathNode/TextField/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1)", "ND-Root", "BT-00-Text is not unique in /BT-00-Text"); } + @Test + void testStringUniqueValueCondition_WithLiteralSequence() { + testExpressionTranslationWithContext( + "count(('a','b','c','b')[. = 'b']) = 1", + "BT-00-Text", "'b' is unique in ('a', 'b', 'c', 'b')"); + } + + @Test + void testNumericUniqueValueCondition_WithLiteralSequence() { + testExpressionTranslationWithContext( + "count((1,2,3,2)[. = 2]) = 1", + "BT-00-Number", "2 is unique in (1, 2, 3, 2)"); + } + + @Test + void testStringUniqueValueCondition_WithRepeatableField() { + testExpressionTranslationWithContext( + "count(/*/PathNode/RepeatableTextField/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1", + "ND-Root", "BT-00-Text is unique in /BT-00-Repeatable-Text"); + } + + @Test + void testStringUniqueValueCondition_WithNot() { + testExpressionTranslationWithContext( + "not(count(('a','b','c')[. = 'x']) = 1)", + "BT-00-Text", "'x' is not unique in ('a', 'b', 'c')"); + } + + @Test + void testStringUniqueValueCondition_WithRelativeFieldReference() { + testExpressionTranslationWithContext( + "count(PathNode/RepeatableTextField/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1", + "ND-Root", "BT-00-Text is unique in BT-00-Repeatable-Text"); + } + + @Test + void testStringUniqueValueCondition_WithFieldReferencePredicate() { + testExpressionTranslationWithContext( + "count(/*/PathNode/RepeatableTextField[./normalize-space(text()) != '']/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1", + "ND-Root", "BT-00-Text is unique in /BT-00-Repeatable-Text[BT-00-Repeatable-Text != '']"); + } + + @Test + void testStringUniqueValueCondition_WithFieldInRepeatableNodePredicate() { + testExpressionTranslationWithContext( + "count(/*/RepeatableNode/TextField[./normalize-space(text()) != '']/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1", + "ND-Root", "BT-00-Text is unique in /BT-00-Text-In-Repeatable-Node[BT-00-Text-In-Repeatable-Node != '']"); + } + @Test void testLikePatternCondition() { @@ -1791,7 +1840,7 @@ void testScalarFromRepeatableField_ThrowsError() { // A repeatable field used as scalar should throw TypeMismatchException.fieldMayRepeat() TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "BT-00-Repeatable-Text == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); } @Test @@ -1799,7 +1848,7 @@ void testScalarFromFieldInRepeatableNode_ThrowsErrorFromRootContext() { // Field in ND-RepeatableNode (repeatable) used as scalar from ND-Root should throw TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "BT-00-Text-In-Repeatable-Node == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); } @Test @@ -1814,7 +1863,7 @@ void testScalarFromFieldInNestedRepeatableNode_ThrowsErrorFromRootContext() { // Field in ND-RepeatableSubSubNode (inside ND-NonRepeatableSubNode inside ND-RepeatableNode) used from root should throw TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "BT-00-Text-In-RepeatableSubSubNode == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); } @Test @@ -1822,7 +1871,7 @@ void testScalarFromFieldInNestedRepeatableNode_ThrowsErrorFromRepeatableNodeCont // Field in ND-RepeatableSubSubNode used from ND-RepeatableNode should still throw (ND-RepeatableSubSubNode is also repeatable) TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-RepeatableNode", "BT-00-Text-In-RepeatableSubSubNode == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); } @Test @@ -1837,7 +1886,7 @@ void testScalarFromFieldInNonRepeatableNestedInRepeatable_ThrowsErrorFromRootCon // Field in ND-NonRepeatableSubNode (non-repeatable) inside ND-RepeatableNode (repeatable) used from root should throw TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "BT-00-Text-In-NonRepeatableSubNode == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); } @Test @@ -1847,6 +1896,14 @@ void testScalarFromFieldInNonRepeatableNestedInRepeatable_OkFromRepeatableNodeCo "ND-RepeatableNode", "BT-00-Text-In-NonRepeatableSubNode == 'test'"); } + @Test + void testRepeatableFieldInUniqueCondition_ThrowsError() { + // A repeatable field used as needle (left side) in uniqueness condition should throw + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateExpressionWithContext("ND-Root", "BT-00-Repeatable-Text is unique in /BT-00-Text")); + assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); + } + // #endregion: Scalar/Sequence Validation // #region: InvalidIdentifierException Tests -------------------------------- @@ -1906,7 +1963,7 @@ void testScalarFromFieldContextVariable_Repeatable_ThrowsFieldMayRepeat() { // A repeatable field context variable used as scalar should throw TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "for context:$f in BT-00-Repeatable-Text return $f == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); } // #endregion: TypeMismatchException - fieldMayRepeat (Context Variables) @@ -1963,7 +2020,7 @@ void testPredicateComparison_RepeatableFieldAsScalar_ThrowsError() { // Pattern: FIELD[REPEATABLE_FIELD == $var] - the repeatable field is used as scalar TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "BT-00-Text[BT-00-Repeatable-Text == 'test']")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); } @Test diff --git a/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java b/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java index 70cbbb1..5698e35 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java @@ -183,6 +183,20 @@ void isFieldRepeatableFromContext_attributeInRepNode_fromRepNode_returnsFalse() assertFalse(resolver.isFieldRepeatableFromContext("BT-00-Attribute-In-Repeatable-Node", "ND-RepeatableNode"), "Attribute field should not be repeatable when context is its parent repeatable node"); } + + @Test + @DisplayName("Repeatable field from root returns true") + void isFieldRepeatableFromContext_repeatableField_fromRoot_returnsTrue() { + assertTrue(resolver.isFieldRepeatableFromContext("BT-00-Repeatable-Text", null), + "Repeatable field should return true from root context"); + } + + @Test + @DisplayName("Repeatable node from root returns true") + void isNodeRepeatableFromContext_repeatableNode_fromRoot_returnsTrue() { + assertTrue(resolver.isNodeRepeatableFromContext("ND-RepeatableNode", null), + "Repeatable node should return true from root context"); + } } // ========================================================================= From 0d210cb444699b69027dce0b793567ff399fb7f2 Mon Sep 17 00:00:00 2001 From: Ioannis Rosuochatzakis Date: Sun, 8 Feb 2026 01:54:51 +0100 Subject: [PATCH 2/5] TEDEFO-4319 Add privacy settings support and fix SymbolResolver inconsistencies --- .../ted/eforms/sdk/SdkSymbolResolver.java | 75 +++++++++- .../exceptions/ConsistencyCheckException.java | 13 +- .../efx/exceptions/InvalidUsageException.java | 10 +- .../exceptions/SdkInconsistencyException.java | 65 +++++++++ .../ted/efx/interfaces/ScriptGenerator.java | 7 + .../ted/efx/interfaces/SymbolResolver.java | 38 ++++- .../europa/ted/efx/model/PrivacySetting.java | 28 ++++ .../efx/sdk2/EfxExpressionTranslatorV2.java | 136 +++++++++++++++++- .../ted/efx/xpath/XPathScriptGenerator.java | 5 + .../efx/mock/sdk2/SymbolResolverMockV2.java | 3 + .../sdk2/EfxExpressionTranslatorV2Test.java | 117 +++++++++++++++ .../ted/efx/sdk2/SdkSymbolResolverTest.java | 33 +++-- src/test/resources/json/README.md | 27 +++- src/test/resources/json/sdk2-fields.json | 45 +++++- 14 files changed, 558 insertions(+), 44 deletions(-) create mode 100644 src/main/java/eu/europa/ted/efx/exceptions/SdkInconsistencyException.java create mode 100644 src/main/java/eu/europa/ted/efx/model/PrivacySetting.java diff --git a/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java b/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java index 08df970..2304001 100644 --- a/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java +++ b/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java @@ -22,6 +22,8 @@ import java.util.function.Function; import java.util.stream.Collectors; +import eu.europa.ted.efx.exceptions.ConsistencyCheckException; +import eu.europa.ted.efx.exceptions.SdkInconsistencyException; import eu.europa.ted.efx.exceptions.SymbolResolutionException; import eu.europa.ted.eforms.sdk.component.SdkComponent; @@ -29,14 +31,17 @@ import eu.europa.ted.eforms.sdk.entity.SdkCodelist; import eu.europa.ted.eforms.sdk.entity.SdkField; import eu.europa.ted.eforms.sdk.entity.SdkNode; +import eu.europa.ted.eforms.sdk.entity.SdkDataType; import eu.europa.ted.eforms.sdk.entity.SdkNoticeSubtype; import eu.europa.ted.eforms.sdk.repository.SdkCodelistRepository; +import eu.europa.ted.eforms.sdk.repository.SdkDataTypeRepository; import eu.europa.ted.eforms.sdk.repository.SdkFieldRepository; import eu.europa.ted.eforms.sdk.repository.SdkNodeRepository; import eu.europa.ted.eforms.sdk.repository.SdkNoticeTypeRepository; import eu.europa.ted.eforms.sdk.resource.SdkResourceLoader; import eu.europa.ted.eforms.xpath.XPathProcessor; import eu.europa.ted.efx.interfaces.SymbolResolver; +import eu.europa.ted.efx.model.PrivacySetting; import eu.europa.ted.efx.model.expressions.PathExpression; import eu.europa.ted.efx.model.expressions.scalar.NodePath; import eu.europa.ted.efx.model.expressions.scalar.ScalarPath; @@ -61,6 +66,8 @@ public class SdkSymbolResolver implements SymbolResolver { protected Map noticeTypesById; + protected SdkDataTypeRepository dataTypeById; + private SdkNode cachedRootNode; @Override @@ -110,6 +117,7 @@ protected void loadMapData(final String sdkVersion, final Path sdkRootPath) this.codelistById = new SdkCodelistRepository(sdkVersion, codelistsPath); this.noticeTypesById = new SdkNoticeTypeRepository(sdkVersion, noticeTypesPath); + this.dataTypeById = SdkDataTypeRepository.createDefault(); } @Override @@ -159,7 +167,7 @@ public PathExpression getAbsolutePathOfNode(final String nodeId) { } private PathExpression getAbsolutePathOfNode(final SdkNode sdkNode) { - if (this.isNodeRepeatableFromContext(sdkNode, null)) { + if (this.isNodeRepeatableFromContext(sdkNode, this.getRootNode())) { return new NodeSequencePath(sdkNode.getXpathAbsolute()); } else { return new NodePath(sdkNode.getXpathAbsolute()); @@ -343,13 +351,16 @@ public PathExpression getAbsolutePathOfFieldWithoutTheAttribute(final String fie throw SymbolResolutionException.unknownSymbol(fieldId); } - String pathToElement = sdkField.getXpathInfo().getPathToLastElement(); - SdkNode parentNode = sdkField.getParentNode(); + if (!sdkField.getXpathInfo().isAttribute()) { + return this.getAbsolutePathOfField(sdkField); + } - if (parentNode != null && this.isNodeRepeatableFromContext(parentNode, null)) { - return new NodeSequencePath(pathToElement); + String pathToElement = sdkField.getXpathInfo().getPathToLastElement(); + FieldTypes fieldType = FieldTypes.fromString(sdkField.getType()); + if (this.isFieldRepeatableFromContext(sdkField, this.getRootNode())) { + return SequencePath.instantiate(pathToElement, fieldType); } else { - return new NodePath(pathToElement); + return ScalarPath.instantiate(pathToElement, fieldType); } } @@ -500,7 +511,7 @@ public boolean isNodeRepeatableFromContext(final String nodeId, final String con final SdkNode contextNode = contextNodeId != null ? this.resolveNode(contextNodeId) - : null; + : this.getRootNode(); if (contextNodeId != null && contextNode == null) { throw SymbolResolutionException.unknownSymbol(contextNodeId); } @@ -619,4 +630,54 @@ private SdkNode resolveNode(String nodeId) { // #endregion Identifier Resolution ------------------------------------------------ + @Override + public String getPrivacyCodeOfField(final String fieldId) { + final SdkField sdkField = this.resolveField(fieldId); + if (sdkField == null) { + throw SymbolResolutionException.unknownSymbol(fieldId); + } + + return sdkField.getPrivacyCode(); + } + + @Override + public String getPrivacySettingOfField(final String fieldId, final PrivacySetting privacyField) { + final SdkField sdkField = this.resolveField(fieldId); + if (sdkField == null) { + throw SymbolResolutionException.unknownSymbol(fieldId); + } + + final SdkField.PrivacySettings privacy = sdkField.getPrivacySettings(); + if (privacy == null) { + return null; + } + + switch (privacyField) { + case PRIVACY_CODE_FIELD: + return privacy.getPrivacyCodeFieldId(); + case PUBLICATION_DATE_FIELD: + return privacy.getPublicationDateFieldId(); + case JUSTIFICATION_CODE_FIELD: + return privacy.getJustificationCodeFieldId(); + case JUSTIFICATION_DESCRIPTION_FIELD: + return privacy.getJustificationDescriptionFieldId(); + default: + throw ConsistencyCheckException.unhandledPrivacySetting(privacyField); + } + } + + @Override + public String getPrivacyMask(final String fieldId) { + final SdkField sdkField = this.resolveField(fieldId); + if (sdkField == null) { + throw SymbolResolutionException.unknownSymbol(fieldId); + } + + final SdkDataType dataType = this.dataTypeById.get(sdkField.getType()); + if (dataType == null) { + throw SdkInconsistencyException.unknownDataType(sdkField.getType()); + } + return dataType.getPrivacyMask(); + } + } diff --git a/src/main/java/eu/europa/ted/efx/exceptions/ConsistencyCheckException.java b/src/main/java/eu/europa/ted/efx/exceptions/ConsistencyCheckException.java index e156614..e03cdf8 100644 --- a/src/main/java/eu/europa/ted/efx/exceptions/ConsistencyCheckException.java +++ b/src/main/java/eu/europa/ted/efx/exceptions/ConsistencyCheckException.java @@ -26,7 +26,8 @@ public enum ErrorCode { MISSING_TYPE_MAPPING, MISSING_TYPE_ANNOTATION, UNKNOWN_EXPRESSION_TYPE, - INVALID_VARIABLE_CONTEXT + INVALID_VARIABLE_CONTEXT, + UNHANDLED_PRIVACY_SETTING } private static final String TYPE_NOT_REGISTERED = @@ -61,6 +62,11 @@ public enum ErrorCode { "This indicates a bug in the translator. " + "Ensure all variable contexts are properly classified as FieldContext or NodeContext."; + private static final String UNHANDLED_PRIVACY_SETTING = + "Privacy setting '%s' is not handled. " + + "This indicates a bug in the translator. " + + "Add the missing case to the switch in getPrivacySettingOfField()."; + private final ErrorCode errorCode; private ConsistencyCheckException(ErrorCode errorCode, String message) { @@ -100,4 +106,9 @@ public static ConsistencyCheckException unknownExpressionType(Class type) { public static ConsistencyCheckException invalidVariableContext() { return new ConsistencyCheckException(ErrorCode.INVALID_VARIABLE_CONTEXT, INVALID_VARIABLE_CONTEXT); } + + public static ConsistencyCheckException unhandledPrivacySetting(Object setting) { + return new ConsistencyCheckException(ErrorCode.UNHANDLED_PRIVACY_SETTING, + String.format(UNHANDLED_PRIVACY_SETTING, setting)); + } } diff --git a/src/main/java/eu/europa/ted/efx/exceptions/InvalidUsageException.java b/src/main/java/eu/europa/ted/efx/exceptions/InvalidUsageException.java index 98018d3..ca5fd59 100644 --- a/src/main/java/eu/europa/ted/efx/exceptions/InvalidUsageException.java +++ b/src/main/java/eu/europa/ted/efx/exceptions/InvalidUsageException.java @@ -25,13 +25,15 @@ public enum ErrorCode { SHORTHAND_REQUIRES_CODE_OR_INDICATOR, SHORTHAND_REQUIRES_FIELD_CONTEXT, INVALID_NOTICE_SUBTYPE_RANGE_ORDER, - INVALID_NOTICE_SUBTYPE_TOKEN + INVALID_NOTICE_SUBTYPE_TOKEN, + FIELD_NOT_WITHHOLDABLE } private static final String SHORTHAND_REQUIRES_CODE_OR_INDICATOR = "Indirect label reference shorthand #{%1$s}, requires a field of type 'code' or 'indicator'. Field %1$s is of type %2$s."; private static final String SHORTHAND_REQUIRES_FIELD_CONTEXT = "The %s shorthand syntax can only be used when a field is declared as context."; private static final String INVALID_NOTICE_SUBTYPE_RANGE_ORDER = "Notice subtype range '%s-%s' is not in ascending order."; private static final String INVALID_NOTICE_SUBTYPE_TOKEN = "Invalid notice subtype token '%s'. Expected format: 'X' or 'X-Y'."; + private static final String FIELD_NOT_WITHHOLDABLE = "Field '%s' is always published and cannot be withheld from publication."; private final ErrorCode errorCode; @@ -59,4 +61,8 @@ public static InvalidUsageException invalidNoticeSubtypeRangeOrder(String start, public static InvalidUsageException invalidNoticeSubtypeToken(String token) { return new InvalidUsageException(ErrorCode.INVALID_NOTICE_SUBTYPE_TOKEN, String.format(INVALID_NOTICE_SUBTYPE_TOKEN, token)); } -} \ No newline at end of file + + public static InvalidUsageException fieldNotWithholdable(String fieldId) { + return new InvalidUsageException(ErrorCode.FIELD_NOT_WITHHOLDABLE, String.format(FIELD_NOT_WITHHOLDABLE, fieldId)); + } +} diff --git a/src/main/java/eu/europa/ted/efx/exceptions/SdkInconsistencyException.java b/src/main/java/eu/europa/ted/efx/exceptions/SdkInconsistencyException.java new file mode 100644 index 0000000..fb1badb --- /dev/null +++ b/src/main/java/eu/europa/ted/efx/exceptions/SdkInconsistencyException.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026 European Union + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European + * Commission – subsequent versions of the EUPL (the "Licence"); You may not use this work except in + * compliance with the Licence. You may obtain a copy of the Licence at: + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence + * is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the Licence for the specific language governing permissions and limitations under + * the Licence. + */ +package eu.europa.ted.efx.exceptions; + +/** + * Exception thrown when the toolkit encounters inconsistent data in the SDK. + * This indicates a problem with the SDK data, not a user error or a toolkit bug. + */ +public class SdkInconsistencyException extends IllegalStateException { + + public enum ErrorCode { + MISSING_PRIVACY_CODE_FIELD, + MISSING_PUBLICATION_DATE_FIELD, + UNKNOWN_DATA_TYPE + } + + private static final String MISSING_PRIVACY_CODE_FIELD = + "Field '%s' has a privacy code but no privacy code field ID. " + + "This indicates inconsistent privacy settings in the SDK data."; + + private static final String MISSING_PUBLICATION_DATE_FIELD = + "Field '%s' has a privacy code but no publication date field ID. " + + "This indicates inconsistent privacy settings in the SDK data."; + + private static final String UNKNOWN_DATA_TYPE = + "Unknown data type '%s'. " + + "This indicates a field type that is not defined in the SDK."; + + private final ErrorCode errorCode; + + private SdkInconsistencyException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } + + public static SdkInconsistencyException missingPrivacyCodeField(String fieldId) { + return new SdkInconsistencyException(ErrorCode.MISSING_PRIVACY_CODE_FIELD, + String.format(MISSING_PRIVACY_CODE_FIELD, fieldId)); + } + + public static SdkInconsistencyException missingPublicationDateField(String fieldId) { + return new SdkInconsistencyException(ErrorCode.MISSING_PUBLICATION_DATE_FIELD, + String.format(MISSING_PUBLICATION_DATE_FIELD, fieldId)); + } + + public static SdkInconsistencyException unknownDataType(String fieldType) { + return new SdkInconsistencyException(ErrorCode.UNKNOWN_DATA_TYPE, + String.format(UNKNOWN_DATA_TYPE, fieldType)); + } +} 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 8e67e89..5ab0e44 100644 --- a/src/main/java/eu/europa/ted/efx/interfaces/ScriptGenerator.java +++ b/src/main/java/eu/europa/ted/efx/interfaces/ScriptGenerator.java @@ -467,6 +467,13 @@ public DateExpression composeAddition(final DateExpression date, public DateExpression composeSubtraction(final DateExpression date, final DurationExpression duration); + /** + * Returns the current date as a date expression in the target language. + * + * @return A date expression representing today's date. + */ + public DateExpression getCurrentDate(); + //#endregion Date Functions ------------------------------------------------- // #region Time Functions --------------------------------------------------- diff --git a/src/main/java/eu/europa/ted/efx/interfaces/SymbolResolver.java b/src/main/java/eu/europa/ted/efx/interfaces/SymbolResolver.java index 9cbe03a..2f5a360 100644 --- a/src/main/java/eu/europa/ted/efx/interfaces/SymbolResolver.java +++ b/src/main/java/eu/europa/ted/efx/interfaces/SymbolResolver.java @@ -15,6 +15,7 @@ import java.util.List; +import eu.europa.ted.efx.model.PrivacySetting; import eu.europa.ted.efx.model.expressions.PathExpression; /** @@ -63,8 +64,7 @@ public PathExpression getRelativePathOfField(final String fieldId, * given context (node or field). * * @param fieldId The identifier of the field to look for. - * @param contextId The identifier of the context node or field. If a field ID is provided, - * its parent node is used as the context. + * @param contextId The identifier of the context node or field. * @return The path to the given field relative to the given context. */ public PathExpression getRelativePathOfField(final String fieldId, final String contextId); @@ -89,8 +89,7 @@ public PathExpression getRelativePathOfNode(final String nodeId, * given context (node or field). * * @param nodeId The identifier of the node to look for. - * @param contextId The identifier of the context node or field. If a field ID is provided, - * its parent node is used as the context. + * @param contextId The identifier of the context node or field. * @return The path to the given node relative to the given context. */ public PathExpression getRelativePathOfNode(final String nodeId, final String contextId); @@ -261,4 +260,35 @@ public PathExpression getRelativePathOfNode(final String nodeId, * @return The absolute path of the root node as a PathExpression. */ public PathExpression getRootPath(); + + /** + * Gets the privacy code of the given field. The privacy code is a value from a codelist that + * groups fields into withholding categories. Its presence indicates that the field is + * withholdable. + * + * @param fieldId The identifier of the field to look for. + * @return The privacy code (e.g., "cod-bus"), or null if the field is not withholdable. + */ + public String getPrivacyCodeOfField(final String fieldId); + + /** + * Gets the identifier of a companion privacy field associated with the given field. + * Companion fields carry the runtime privacy state (withholding flag, publication date, + * justification) for a withholdable field. + * + * @param fieldId The identifier of the withholdable field. + * @param privacyField The type of companion field to look up. + * @return The identifier of the companion field, or null if the field has no privacy settings. + */ + public String getPrivacySettingOfField(final String fieldId, final PrivacySetting privacyField); + + /** + * Gets the privacy masking value for the given field. + * The masking value depends on the field's type (e.g. "unpublished" for text fields, + * "1970-01-01Z" for date fields, "-1" for numeric fields). + * + * @param fieldId The identifier of the field to look for. + * @return The masking value as a string. + */ + public String getPrivacyMask(final String fieldId); } diff --git a/src/main/java/eu/europa/ted/efx/model/PrivacySetting.java b/src/main/java/eu/europa/ted/efx/model/PrivacySetting.java new file mode 100644 index 0000000..5954c78 --- /dev/null +++ b/src/main/java/eu/europa/ted/efx/model/PrivacySetting.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 European Union + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European + * Commission – subsequent versions of the EUPL (the "Licence"); You may not use this work except in + * compliance with the Licence. You may obtain a copy of the Licence at: + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence + * is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the Licence for the specific language governing permissions and limitations under + * the Licence. + */ +package eu.europa.ted.efx.model; + +import eu.europa.ted.efx.interfaces.SymbolResolver; + +/** + * Identifies the companion fields associated with a withholdable field's privacy settings. + * Used with {@link SymbolResolver#getPrivacySettingOfField(String, PrivacySetting)} to look up + * the field ID of a specific privacy companion field. + */ +public enum PrivacySetting { + PRIVACY_CODE_FIELD, + PUBLICATION_DATE_FIELD, + JUSTIFICATION_CODE_FIELD, + JUSTIFICATION_DESCRIPTION_FIELD +} 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 c2e7fc4..e5f131e 100644 --- a/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java +++ b/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java @@ -38,6 +38,8 @@ import eu.europa.ted.eforms.sdk.component.SdkComponentType; import eu.europa.ted.efx.exceptions.InvalidArgumentException; import eu.europa.ted.efx.exceptions.InvalidIdentifierException; +import eu.europa.ted.efx.exceptions.InvalidUsageException; +import eu.europa.ted.efx.exceptions.SdkInconsistencyException; import eu.europa.ted.efx.exceptions.SymbolResolutionException; import eu.europa.ted.efx.exceptions.TypeMismatchException; import eu.europa.ted.efx.exceptions.ConsistencyCheckException; @@ -49,6 +51,7 @@ import eu.europa.ted.efx.model.Context.FieldContext; import eu.europa.ted.efx.model.Context.NodeContext; import eu.europa.ted.efx.model.ContextStack; +import eu.europa.ted.efx.model.PrivacySetting; import eu.europa.ted.efx.model.expressions.Expression; import eu.europa.ted.efx.model.expressions.PathExpression; import eu.europa.ted.efx.model.expressions.TypedExpression; @@ -1624,7 +1627,116 @@ public void exitEndsWithFunction(EndsWithFunctionContext ctx) { this.stack.push(this.script.composeEndsWithCondition(text, endsWith)); } - // sequence-equal typed handlers + // #region Privacy settings ------------------------------------------------ + + @Override + public void exitFieldIsWithholdableCondition(FieldIsWithholdableConditionContext ctx) { + final String fieldId = ctx.fieldMention().getText(); + + final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); + final boolean negated = ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER); + final boolean isWithholdable = privacyCode != null && !privacyCode.isEmpty(); + this.stack.push(this.script.getBooleanEquivalent(negated != isWithholdable)); + } + + @Override + public void exitFieldWasWithheldCondition(FieldWasWithheldConditionContext ctx) { + final String fieldId = ctx.fieldMention().getText(); + + final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); + if (privacyCode == null || privacyCode.isEmpty()) { + throw InvalidUsageException.fieldNotWithholdable(fieldId); + } + + BooleanExpression result = this.composeWasWithheldCondition(fieldId, privacyCode); + if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { + result = this.script.composeLogicalNot(result); + } + this.stack.push(result); + } + + @Override + public void exitFieldIsWithheldCondition(FieldIsWithheldConditionContext ctx) { + final String fieldId = ctx.fieldMention().getText(); + + final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); + if (privacyCode == null || privacyCode.isEmpty()) { + throw InvalidUsageException.fieldNotWithholdable(fieldId); + } + + // "is withheld" = "was withheld" AND "still withheld" + BooleanExpression result = this.script.composeLogicalAnd( + this.composeWasWithheldCondition(fieldId, privacyCode), + this.composeStillWithheldCondition(fieldId)); + if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { + result = this.script.composeLogicalNot(result); + } + this.stack.push(result); + } + + @Override + public void exitFieldIsDisclosedCondition(FieldIsDisclosedConditionContext ctx) { + final String fieldId = ctx.fieldMention().getText(); + + final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); + if (privacyCode == null || privacyCode.isEmpty()) { + throw InvalidUsageException.fieldNotWithholdable(fieldId); + } + + // "is disclosed" = "was withheld" AND NOT "still withheld" AND NOT "masked" + BooleanExpression result = this.script.composeLogicalAnd( + this.script.composeLogicalAnd( + this.composeWasWithheldCondition(fieldId, privacyCode), + this.script.composeLogicalNot(this.composeStillWithheldCondition(fieldId))), + this.composeNotMaskedCondition(fieldId)); + if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { + result = this.script.composeLogicalNot(result); + } + this.stack.push(result); + } + + private BooleanExpression composeWasWithheldCondition(String fieldId, String privacyCode) { + final String privacyCodeFieldId = this.symbols.getPrivacySettingOfField(fieldId, PrivacySetting.PRIVACY_CODE_FIELD); + if (privacyCodeFieldId == null) { + throw SdkInconsistencyException.missingPrivacyCodeField(fieldId); + } + + return this.script.composeComparisonOperation( + new StringExpression(this.script.composeFieldValueReference( + this.symbols.getRelativePathOfField(privacyCodeFieldId, this.efxContext.symbol())).getScript()), + "==", + this.script.getStringLiteralFromUnquotedString(privacyCode)); + } + + private BooleanExpression composeStillWithheldCondition(String fieldId) { + final String publicationDateFieldId = this.symbols.getPrivacySettingOfField(fieldId, PrivacySetting.PUBLICATION_DATE_FIELD); + if (publicationDateFieldId == null) { + throw SdkInconsistencyException.missingPublicationDateField(fieldId); + } + + final PathExpression pubDateFieldPath = this.symbols.getRelativePathOfField(publicationDateFieldId, + this.efxContext.symbol()); + + return this.script.composeLogicalOr( + this.script.composeLogicalNot(this.script.composeExistsCondition(pubDateFieldPath)), + this.script.composeComparisonOperation( + new DateExpression(this.script.composeFieldValueReference(pubDateFieldPath).getScript()), ">", + this.script.getCurrentDate())); + } + + private BooleanExpression composeNotMaskedCondition(String fieldId) { + final String maskingValue = this.symbols.getPrivacyMask(fieldId); + final PathExpression fieldPath = this.symbols.getRelativePathOfField(fieldId, this.efxContext.symbol()); + return this.script.composeComparisonOperation( + new StringExpression(this.script.composeFieldValueReference(fieldPath).getScript()), + "!=", + this.script.getStringLiteralFromUnquotedString(maskingValue)); + } + + // #endregion Privacy settings --------------------------------------------- + + // #region Sequence-equal ---------------------------------------------------- + @Override public void exitStringSequenceEqualFunction(StringSequenceEqualFunctionContext ctx) { exitSequenceEqualFunction(StringSequenceExpression.class); @@ -1661,6 +1773,8 @@ private void exitSequenceEqualFunction(Class s this.stack.push(this.script.composeSequenceEqualFunction(one, two)); } + // #endregion Sequence-equal ------------------------------------------------- + // #endregion Boolean functions --------------------------------------------- // #region Numeric functions ------------------------------------------------ @@ -1848,7 +1962,8 @@ public void exitYearMonthDurationFromStringFunction( // #region Sequence Functions ----------------------------------------------- - // distinct-values typed handlers + // #region Distinct-values --------------------------------------------------- + @Override public void exitStringDistinctValuesFunction(StringDistinctValuesFunctionContext ctx) { exitDistinctValuesFunction(StringSequenceExpression.class); @@ -1884,7 +1999,10 @@ private void exitDistinctValuesFunction(Class this.stack.push(this.script.composeDistinctValuesFunction(list, listType)); } - // union typed handlers + // #endregion Distinct-values ------------------------------------------------ + + // #region Union ------------------------------------------------------------ + @Override public void exitStringUnionFunction(StringUnionFunctionContext ctx) { exitUnionFunction(StringSequenceExpression.class); @@ -1921,7 +2039,10 @@ private void exitUnionFunction(Class listType) this.stack.push(this.script.composeUnionFunction(one, two, listType)); } - // intersect typed handlers + // #endregion Union --------------------------------------------------------- + + // #region Intersect ------------------------------------------------------- + @Override public void exitStringIntersectFunction(StringIntersectFunctionContext ctx) { exitIntersectFunction(StringSequenceExpression.class); @@ -1958,7 +2079,10 @@ private void exitIntersectFunction(Class listT this.stack.push(this.script.composeIntersectFunction(one, two, listType)); } - // except typed handlers + // #endregion Intersect ------------------------------------------------------ + + // #region Except ---------------------------------------------------------- + @Override public void exitStringExceptFunction(StringExceptFunctionContext ctx) { exitExceptFunction(StringSequenceExpression.class); @@ -1995,6 +2119,8 @@ private void exitExceptFunction(Class listType this.stack.push(this.script.composeExceptFunction(one, two, listType)); } + // #endregion Except --------------------------------------------------------- + // #endregion Sequence Functions -------------------------------------------- // #region Helpers ---------------------------------------------------------- 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 a3da967..b11ef0c 100644 --- a/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java +++ b/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java @@ -553,6 +553,11 @@ public DateExpression composeSubtraction(DateExpression date, DurationExpression return new DateExpression("(" + date.getScript() + " - " + duration.getScript() + ")"); } + @Override + public DateExpression getCurrentDate() { + return new DateExpression("current-date()"); + } + //#endregion Date functions ------------------------------------------------- //#region Time functions ---------------------------------------------------- diff --git a/src/test/java/eu/europa/ted/efx/mock/sdk2/SymbolResolverMockV2.java b/src/test/java/eu/europa/ted/efx/mock/sdk2/SymbolResolverMockV2.java index 73a24d0..6fbcbb2 100644 --- a/src/test/java/eu/europa/ted/efx/mock/sdk2/SymbolResolverMockV2.java +++ b/src/test/java/eu/europa/ted/efx/mock/sdk2/SymbolResolverMockV2.java @@ -28,6 +28,7 @@ import eu.europa.ted.eforms.sdk.component.SdkComponentType; import eu.europa.ted.eforms.sdk.entity.SdkCodelist; import eu.europa.ted.eforms.sdk.entity.v2.SdkCodelistV2; +import eu.europa.ted.eforms.sdk.repository.SdkDataTypeRepository; import eu.europa.ted.eforms.sdk.repository.SdkFieldRepository; import eu.europa.ted.eforms.sdk.repository.SdkNodeRepository; @@ -55,6 +56,8 @@ private void loadTestData() throws InstantiationException { // Mock notice types - not needed, we override getAllNoticeSubtypeIds() this.noticeTypesById = new HashMap<>(); + + this.dataTypeById = SdkDataTypeRepository.createDefault(); } private static Entry buildCodelistMock(final String codelistId, 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 532f022..c537b9e 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java @@ -1280,6 +1280,123 @@ void testEndsWithFunction() { "ends-with(BT-00-Text, 'abc')"); } + @Test + void testFieldIsWithholdable_nonWithholdable() { + testExpressionTranslationWithContext( + "false()", "BT-00-Text", + "BT-00-Text is withholdable"); + } + + @Test + void testFieldIsWithholdable_withholdable() { + testExpressionTranslationWithContext( + "true()", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node is withholdable"); + } + + @Test + void testFieldWasWithheld() { + testExpressionTranslationWithContext( + "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/FieldIdentifierCode/normalize-space(text()) = 'test-priv'", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node was withheld"); + } + + @Test + void testFieldIsWithheld() { + final String privacyPath = "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + + " and " + + "not(" + privacyPath + "/PublicationDate)" + + " or " + + privacyPath + "/PublicationDate/xs:date(text()) > current-date()", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node is withheld"); + } + + @Test + void testFieldIsDisclosed() { + final String privacyPath = "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + // wasWithheld + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + + " and " + // NOT stillWithheld + + "not(" + + "not(" + privacyPath + "/PublicationDate)" + + " or " + + privacyPath + "/PublicationDate/xs:date(text()) > current-date()" + + ")" + + " and " + // NOT masked + + "./normalize-space(text()) != 'unpublished'", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node is disclosed"); + } + + @Test + void testFieldIsNotWithholdable_nonWithholdable() { + testExpressionTranslationWithContext( + "true()", "BT-00-Text", + "BT-00-Text is not withholdable"); + } + + @Test + void testFieldIsNotWithholdable_withholdable() { + testExpressionTranslationWithContext( + "false()", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node is not withholdable"); + } + + @Test + void testFieldWasNotWithheld() { + final String fieldPrefix = "RepeatableNode/TextField/"; + final String privacyPath = fieldPrefix + "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + "not(" + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv')", + "ND-Root", + "BT-00-Text-In-Repeatable-Node was not withheld"); + } + + @Test + void testFieldIsNotWithheld() { + final String fieldPrefix = "RepeatableNode/TextField/"; + final String privacyPath = fieldPrefix + "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + "not(" + + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + + " and " + + "not(" + privacyPath + "/PublicationDate)" + + " or " + + privacyPath + "/PublicationDate/xs:date(text()) > current-date()" + + ")", + "ND-Root", + "BT-00-Text-In-Repeatable-Node is not withheld"); + } + + @Test + void testFieldIsNotDisclosed() { + final String fieldPrefix = "RepeatableNode/TextField/"; + final String privacyPath = fieldPrefix + "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + "not(" + + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + + " and " + + "not(" + + "not(" + privacyPath + "/PublicationDate)" + + " or " + + privacyPath + "/PublicationDate/xs:date(text()) > current-date()" + + ")" + + " and " + + "RepeatableNode/TextField/normalize-space(text()) != 'unpublished'" + + ")", + "ND-Root", + "BT-00-Text-In-Repeatable-Node is not disclosed"); + } + // #endregion: Boolean functions // #region: Numeric functions ----------------------------------------------- diff --git a/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java b/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java index 5698e35..07b3ba0 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java @@ -41,7 +41,6 @@ import eu.europa.ted.efx.model.expressions.scalar.ScalarPath; import eu.europa.ted.efx.model.expressions.scalar.StringPath; import eu.europa.ted.efx.model.expressions.scalar.TimePath; -import eu.europa.ted.efx.model.expressions.sequence.NodeSequencePath; import eu.europa.ted.efx.model.expressions.sequence.SequencePath; /** @@ -309,27 +308,27 @@ void repeatableSubSubNode_shouldReturnSequencePath() { } @Test - @DisplayName("Non-repeatable attribute field without attribute returns NodePath") - void nonRepeatableAttributeField_withoutAttribute_shouldReturnNodePath() { + @DisplayName("Non-repeatable attribute field without attribute returns ScalarPath") + void nonRepeatableAttributeField_withoutAttribute_shouldReturnScalarPath() { assertFalse(resolver.isFieldRepeatableFromContext("BT-00-Attribute", null), "Precondition: attribute field should NOT be repeatable from root"); PathExpression path = resolver.getAbsolutePathOfFieldWithoutTheAttribute("BT-00-Attribute"); - assertTrue(path instanceof NodePath, - "Non-repeatable attribute field without @attribute should return NodePath, got: " + path.getClass().getSimpleName()); + assertTrue(path instanceof ScalarPath, + "Non-repeatable attribute field without @attribute should return ScalarPath, got: " + path.getClass().getSimpleName()); } @Test - @DisplayName("Attribute field in repeatable node without attribute returns NodeSequencePath") - void attributeInRepeatableNode_withoutAttribute_shouldReturnNodeSequencePath() { + @DisplayName("Attribute field in repeatable node without attribute returns SequencePath") + void attributeInRepeatableNode_withoutAttribute_shouldReturnSequencePath() { assertTrue(resolver.isFieldRepeatableFromContext("BT-00-Attribute-In-Repeatable-Node", null), "Precondition: attribute field in repeatable node should be repeatable from root"); PathExpression path = resolver.getAbsolutePathOfFieldWithoutTheAttribute("BT-00-Attribute-In-Repeatable-Node"); - assertTrue(path instanceof NodeSequencePath, - "Attribute field in repeatable node without @attribute should return NodeSequencePath, got: " + path.getClass().getSimpleName()); + assertTrue(path instanceof SequencePath, + "Attribute field in repeatable node without @attribute should return SequencePath, got: " + path.getClass().getSimpleName()); } } @@ -572,21 +571,21 @@ void getRootCodelistOfField_codeField_returnsRootCodelist() { } @Test - @DisplayName("Attribute field without attribute returns NodePath") - void getAbsolutePathOfFieldWithoutTheAttribute_returnsNodePath() { + @DisplayName("Attribute field without attribute returns StringPath") + void getAbsolutePathOfFieldWithoutTheAttribute_returnsStringPath() { PathExpression path = resolver.getAbsolutePathOfFieldWithoutTheAttribute("BT-00-Attribute"); - assertEquals(NodePath.class, path.getClass(), - "Attribute field without attribute should return NodePath"); + assertEquals(StringPath.class, path.getClass(), + "Attribute field without attribute should return StringPath"); } @Test - @DisplayName("Code attribute field without attribute returns NodePath") - void getAbsolutePathOfFieldWithoutTheAttribute_codeAttribute_returnsNodePath() { + @DisplayName("Code attribute field without attribute returns StringPath") + void getAbsolutePathOfFieldWithoutTheAttribute_codeAttribute_returnsStringPath() { PathExpression path = resolver.getAbsolutePathOfFieldWithoutTheAttribute("BT-00-CodeAttribute"); - assertEquals(NodePath.class, path.getClass(), - "Code attribute field without attribute should return NodePath"); + assertEquals(StringPath.class, path.getClass(), + "Code attribute field without attribute should return StringPath"); } } diff --git a/src/test/resources/json/README.md b/src/test/resources/json/README.md index 53727b5..ceb45f2 100644 --- a/src/test/resources/json/README.md +++ b/src/test/resources/json/README.md @@ -14,11 +14,12 @@ ND-Root (non-rep) | +-- ND-RepeatableInSubNode2 (REP) <- rep in non-rep (sibling) | +-- ND-RepeatableNode (REP) - +-- ND-NonRepeatableSubNode (non-rep) <- non-rep in rep - | +-- ND-RepeatableSubSubNode (REP) <- rep in non-rep in rep - +-- ND-NonRepeatableSubNode2 (non-rep) <- non-rep in rep (sibling) - +-- ND-RepeatableInRepeatableNode (REP) <- rep in rep - +-- ND-RepeatableInRepeatableNode2 (REP) <- rep in rep (sibling) +| +-- ND-NonRepeatableSubNode (non-rep) <- non-rep in rep +| | +-- ND-RepeatableSubSubNode (REP) <- rep in non-rep in rep +| +-- ND-NonRepeatableSubNode2 (non-rep) <- non-rep in rep (sibling) +| +-- ND-RepeatableInRepeatableNode (REP) <- rep in rep +| +-- ND-RepeatableInRepeatableNode2 (REP) <- rep in rep (sibling) +| +-- ND-PrivacyInRepeatableNode (non-rep) <- privacy metadata node ``` ## Repeatability Coverage @@ -54,10 +55,21 @@ Fields use the pattern `BT-XY-Type` where: | BT-24-* | ND-RepeatableInRepeatableNode2 | SEQUENCE (self + ancestor) | | BT-25-* | ND-RepeatableSubSubNode | SEQUENCE (self + ancestor) | +## Privacy Test Data + +Field `BT-00-Text-In-Repeatable-Node` has privacy settings configured with code `test-priv`. +Its privacy metadata is stored under node `ND-PrivacyInRepeatableNode` with these fields: + +| Field ID | Purpose | Type | +|----------|---------|------| +| BT-195(BT-00)-Text-In-Repeatable-Node | Unpublished field identifier | code | +| BT-196(BT-00)-Text-In-Repeatable-Node | Reason description | text-multilingual | +| BT-197(BT-00)-Text-In-Repeatable-Node | Reason code | code | +| BT-198(BT-00)-Text-In-Repeatable-Node | Publication date | date | + ## Files -- `fields-sdk2.json` - Field definitions with XPath expressions -- `nodes-sdk2.json` - Node definitions with hierarchy and repeatability +- `sdk2-fields.json` - Field and node definitions with XPath expressions ## Test Scenarios Enabled @@ -65,3 +77,4 @@ Fields use the pattern `BT-XY-Type` where: 2. **Ancestor repeatability** - Fields under repeatable ancestors 3. **Cross-branch navigation** - Paths that backtrack through parent nodes 4. **Multiple data types** - Each node has fields of 7 different types +5. **Privacy settings** - Fields with privacy metadata (withheld/disclosed conditions) diff --git a/src/test/resources/json/sdk2-fields.json b/src/test/resources/json/sdk2-fields.json index 2dd5990..a4def02 100644 --- a/src/test/resources/json/sdk2-fields.json +++ b/src/test/resources/json/sdk2-fields.json @@ -95,6 +95,13 @@ "xpathRelative": "RepeatableInRepeatableNode2", "repeatable": true, "alias": "RepeatableInRepeatableNode2" + }, + { + "id": "ND-PrivacyInRepeatableNode", + "parentId": "ND-RepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']", + "xpathRelative": "TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']", + "repeatable": false } ], "fields": [ @@ -344,7 +351,14 @@ "type": "text", "parentNodeId": "ND-RepeatableNode", "xpathAbsolute": "/*/RepeatableNode/TextField", - "xpathRelative": "TextField" + "xpathRelative": "TextField", + "privacy": { + "code": "test-priv", + "unpublishedFieldId": "BT-195(BT-00)-Text-In-Repeatable-Node", + "reasonCodeFieldId": "BT-197(BT-00)-Text-In-Repeatable-Node", + "reasonDescriptionFieldId": "BT-196(BT-00)-Text-In-Repeatable-Node", + "publicationDateFieldId": "BT-198(BT-00)-Text-In-Repeatable-Node" + } }, { "id": "BT-00-Attribute-In-Repeatable-Node", @@ -849,6 +863,35 @@ "parentNodeId": "ND-RepeatableSubSubNode", "xpathAbsolute": "/*/RepeatableNode/NonRepeatableSubNode/RepeatableSubSubNode/Measure", "xpathRelative": "Measure" + }, + { + "id": "BT-195(BT-00)-Text-In-Repeatable-Node", + "type": "code", + "parentNodeId": "ND-PrivacyInRepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/FieldIdentifierCode", + "xpathRelative": "FieldIdentifierCode", + "presetValue": "test-priv" + }, + { + "id": "BT-196(BT-00)-Text-In-Repeatable-Node", + "type": "text-multilingual", + "parentNodeId": "ND-PrivacyInRepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/ReasonDescription", + "xpathRelative": "ReasonDescription" + }, + { + "id": "BT-197(BT-00)-Text-In-Repeatable-Node", + "type": "code", + "parentNodeId": "ND-PrivacyInRepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/ReasonCode", + "xpathRelative": "ReasonCode" + }, + { + "id": "BT-198(BT-00)-Text-In-Repeatable-Node", + "type": "date", + "parentNodeId": "ND-PrivacyInRepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/PublicationDate", + "xpathRelative": "PublicationDate" } ] } From a06cc97721ab8deb0b78391fd01e3e37317a6164 Mon Sep 17 00:00:00 2001 From: Ioannis Rosuochatzakis Date: Sun, 8 Feb 2026 02:40:49 +0100 Subject: [PATCH 3/5] TEDEFO-1854 Fix invalid XPath in typed uniqueness conditions --- .../ted/efx/xpath/XPathScriptGenerator.java | 17 +++++++++++------ .../sdk2/EfxExpressionTranslatorV2Test.java | 18 +++++++++--------- 2 files changed, 20 insertions(+), 15 deletions(-) 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 a3da967..c00ffd1 100644 --- a/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java +++ b/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java @@ -361,37 +361,42 @@ public BooleanExpression composeUniqueValueCondition(PathExpression needle, @Override public BooleanExpression composeUniqueValueCondition(StringExpression needle, StringSequenceExpression haystack) { - return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript()); } @Override public BooleanExpression composeUniqueValueCondition(NumericExpression needle, NumericSequenceExpression haystack) { - return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript()); } @Override public BooleanExpression composeUniqueValueCondition(BooleanExpression needle, BooleanSequenceExpression haystack) { - return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript()); } @Override public BooleanExpression composeUniqueValueCondition(DateExpression needle, DateSequenceExpression haystack) { - return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript()); } @Override public BooleanExpression composeUniqueValueCondition(TimeExpression needle, TimeSequenceExpression haystack) { - return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript()); } @Override public BooleanExpression composeUniqueValueCondition(DurationExpression needle, DurationSequenceExpression haystack) { - return new BooleanExpression("count(" + haystack.getScript() + "[. = " + needle.getScript() + "]) = 1"); + return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript()); + } + + private BooleanExpression composeTypedUniqueValueCondition(String needle, String haystack) { + return new BooleanExpression( + "count(for $n in " + needle + ", $x in " + haystack + "[. = $n] return $x) = 1"); } //#endregion Boolean Expressions ------------------------------------------ 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 532f022..ba61fc9 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java @@ -78,63 +78,63 @@ void testPresenceCondition_WithNot() { @Test void testUniqueValueCondition() { testExpressionTranslationWithContext( - "count(/*/PathNode/TextField/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1", + "count(for $n in PathNode/TextField/normalize-space(text()), $x in /*/PathNode/TextField/normalize-space(text())[. = $n] return $x) = 1", "ND-Root", "BT-00-Text is unique in /BT-00-Text"); } @Test void testUniqueValueCondition_WithNot() { testExpressionTranslationWithContext( - "not(count(/*/PathNode/TextField/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1)", + "not(count(for $n in PathNode/TextField/normalize-space(text()), $x in /*/PathNode/TextField/normalize-space(text())[. = $n] return $x) = 1)", "ND-Root", "BT-00-Text is not unique in /BT-00-Text"); } @Test void testStringUniqueValueCondition_WithLiteralSequence() { testExpressionTranslationWithContext( - "count(('a','b','c','b')[. = 'b']) = 1", + "count(for $n in 'b', $x in ('a','b','c','b')[. = $n] return $x) = 1", "BT-00-Text", "'b' is unique in ('a', 'b', 'c', 'b')"); } @Test void testNumericUniqueValueCondition_WithLiteralSequence() { testExpressionTranslationWithContext( - "count((1,2,3,2)[. = 2]) = 1", + "count(for $n in 2, $x in (1,2,3,2)[. = $n] return $x) = 1", "BT-00-Number", "2 is unique in (1, 2, 3, 2)"); } @Test void testStringUniqueValueCondition_WithRepeatableField() { testExpressionTranslationWithContext( - "count(/*/PathNode/RepeatableTextField/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1", + "count(for $n in PathNode/TextField/normalize-space(text()), $x in /*/PathNode/RepeatableTextField/normalize-space(text())[. = $n] return $x) = 1", "ND-Root", "BT-00-Text is unique in /BT-00-Repeatable-Text"); } @Test void testStringUniqueValueCondition_WithNot() { testExpressionTranslationWithContext( - "not(count(('a','b','c')[. = 'x']) = 1)", + "not(count(for $n in 'x', $x in ('a','b','c')[. = $n] return $x) = 1)", "BT-00-Text", "'x' is not unique in ('a', 'b', 'c')"); } @Test void testStringUniqueValueCondition_WithRelativeFieldReference() { testExpressionTranslationWithContext( - "count(PathNode/RepeatableTextField/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1", + "count(for $n in PathNode/TextField/normalize-space(text()), $x in PathNode/RepeatableTextField/normalize-space(text())[. = $n] return $x) = 1", "ND-Root", "BT-00-Text is unique in BT-00-Repeatable-Text"); } @Test void testStringUniqueValueCondition_WithFieldReferencePredicate() { testExpressionTranslationWithContext( - "count(/*/PathNode/RepeatableTextField[./normalize-space(text()) != '']/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1", + "count(for $n in PathNode/TextField/normalize-space(text()), $x in /*/PathNode/RepeatableTextField[./normalize-space(text()) != '']/normalize-space(text())[. = $n] return $x) = 1", "ND-Root", "BT-00-Text is unique in /BT-00-Repeatable-Text[BT-00-Repeatable-Text != '']"); } @Test void testStringUniqueValueCondition_WithFieldInRepeatableNodePredicate() { testExpressionTranslationWithContext( - "count(/*/RepeatableNode/TextField[./normalize-space(text()) != '']/normalize-space(text())[. = PathNode/TextField/normalize-space(text())]) = 1", + "count(for $n in PathNode/TextField/normalize-space(text()), $x in /*/RepeatableNode/TextField[./normalize-space(text()) != '']/normalize-space(text())[. = $n] return $x) = 1", "ND-Root", "BT-00-Text is unique in /BT-00-Text-In-Repeatable-Node[BT-00-Text-In-Repeatable-Node != '']"); } From eb470cf9a63adf86f2298345aa99129707c634d3 Mon Sep 17 00:00:00 2001 From: Ioannis Rosuochatzakis Date: Sun, 8 Feb 2026 11:05:14 +0100 Subject: [PATCH 4/5] TEDEFO-4319 Fix operator precedence in privacy conditions --- .../ted/efx/sdk2/EfxExpressionTranslatorV2.java | 12 +++++++----- .../efx/sdk2/EfxExpressionTranslatorV2Test.java | 16 ++++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) 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 e5f131e..c35057c 100644 --- a/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java +++ b/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java @@ -1717,11 +1717,13 @@ private BooleanExpression composeStillWithheldCondition(String fieldId) { final PathExpression pubDateFieldPath = this.symbols.getRelativePathOfField(publicationDateFieldId, this.efxContext.symbol()); - return this.script.composeLogicalOr( - this.script.composeLogicalNot(this.script.composeExistsCondition(pubDateFieldPath)), - this.script.composeComparisonOperation( - new DateExpression(this.script.composeFieldValueReference(pubDateFieldPath).getScript()), ">", - this.script.getCurrentDate())); + return this.script.composeParenthesizedExpression( + this.script.composeLogicalOr( + this.script.composeLogicalNot(this.script.composeExistsCondition(pubDateFieldPath)), + this.script.composeComparisonOperation( + new DateExpression(this.script.composeFieldValueReference(pubDateFieldPath).getScript()), ">", + this.script.getCurrentDate())), + BooleanExpression.class); } private BooleanExpression composeNotMaskedCondition(String fieldId) { 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 e04426b..45e8e85 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java @@ -1309,9 +1309,9 @@ void testFieldIsWithheld() { testExpressionTranslationWithContext( privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + " and " - + "not(" + privacyPath + "/PublicationDate)" + + "(not(" + privacyPath + "/PublicationDate)" + " or " - + privacyPath + "/PublicationDate/xs:date(text()) > current-date()", + + privacyPath + "/PublicationDate/xs:date(text()) > current-date())", "BT-00-Text-In-Repeatable-Node", "BT-00-Text-In-Repeatable-Node is withheld"); } @@ -1325,9 +1325,9 @@ void testFieldIsDisclosed() { + " and " // NOT stillWithheld + "not(" - + "not(" + privacyPath + "/PublicationDate)" + + "(not(" + privacyPath + "/PublicationDate)" + " or " - + privacyPath + "/PublicationDate/xs:date(text()) > current-date()" + + privacyPath + "/PublicationDate/xs:date(text()) > current-date())" + ")" + " and " // NOT masked @@ -1369,9 +1369,9 @@ void testFieldIsNotWithheld() { "not(" + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + " and " - + "not(" + privacyPath + "/PublicationDate)" + + "(not(" + privacyPath + "/PublicationDate)" + " or " - + privacyPath + "/PublicationDate/xs:date(text()) > current-date()" + + privacyPath + "/PublicationDate/xs:date(text()) > current-date())" + ")", "ND-Root", "BT-00-Text-In-Repeatable-Node is not withheld"); @@ -1386,9 +1386,9 @@ void testFieldIsNotDisclosed() { + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + " and " + "not(" - + "not(" + privacyPath + "/PublicationDate)" + + "(not(" + privacyPath + "/PublicationDate)" + " or " - + privacyPath + "/PublicationDate/xs:date(text()) > current-date()" + + privacyPath + "/PublicationDate/xs:date(text()) > current-date())" + ")" + " and " + "RepeatableNode/TextField/normalize-space(text()) != 'unpublished'" From 7e83d031b5933b6835208176e3b6aea7308336b5 Mon Sep 17 00:00:00 2001 From: Ioannis Rosuochatzakis Date: Mon, 9 Feb 2026 02:22:02 +0100 Subject: [PATCH 5/5] TEDEFO-4319 Implement linked field properties and computed privacy properties in translator --- .../exceptions/ConsistencyCheckException.java | 13 +- .../efx/sdk2/EfxExpressionTranslatorV2.java | 190 +++++++++++----- .../sdk2/EfxExpressionTranslatorV2Test.java | 206 +++++++++++++----- src/test/resources/json/README.md | 39 +++- src/test/resources/json/sdk2-fields.json | 107 ++++++++- 5 files changed, 443 insertions(+), 112 deletions(-) diff --git a/src/main/java/eu/europa/ted/efx/exceptions/ConsistencyCheckException.java b/src/main/java/eu/europa/ted/efx/exceptions/ConsistencyCheckException.java index e03cdf8..b2a8cc1 100644 --- a/src/main/java/eu/europa/ted/efx/exceptions/ConsistencyCheckException.java +++ b/src/main/java/eu/europa/ted/efx/exceptions/ConsistencyCheckException.java @@ -27,7 +27,8 @@ public enum ErrorCode { MISSING_TYPE_ANNOTATION, UNKNOWN_EXPRESSION_TYPE, INVALID_VARIABLE_CONTEXT, - UNHANDLED_PRIVACY_SETTING + UNHANDLED_PRIVACY_SETTING, + UNHANDLED_LINKED_FIELD_PROPERTY } private static final String TYPE_NOT_REGISTERED = @@ -67,6 +68,11 @@ public enum ErrorCode { "This indicates a bug in the translator. " + "Add the missing case to the switch in getPrivacySettingOfField()."; + private static final String UNHANDLED_LINKED_FIELD_PROPERTY = + "Linked field property '%s' is not handled. " + + "This indicates a bug in the translator. " + + "Add the missing case to getLinkedFieldId()."; + private final ErrorCode errorCode; private ConsistencyCheckException(ErrorCode errorCode, String message) { @@ -111,4 +117,9 @@ public static ConsistencyCheckException unhandledPrivacySetting(Object setting) return new ConsistencyCheckException(ErrorCode.UNHANDLED_PRIVACY_SETTING, String.format(UNHANDLED_PRIVACY_SETTING, setting)); } + + public static ConsistencyCheckException unhandledLinkedFieldProperty(String property) { + return new ConsistencyCheckException(ErrorCode.UNHANDLED_LINKED_FIELD_PROPERTY, + String.format(UNHANDLED_LINKED_FIELD_PROPERTY, property)); + } } 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 c35057c..6c23495 100644 --- a/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java +++ b/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java @@ -215,58 +215,91 @@ private String getTranslatedScript() { return sb.toString().trim(); } - protected static String getFieldId(FieldReferenceContext ctx) { + private String getLinkedFieldId(String baseFieldId, LinkedFieldPropertyContext ctx) { + if (ctx.PublicationDate() != null) + return this.symbols.getPrivacySettingOfField(baseFieldId, PrivacySetting.PUBLICATION_DATE_FIELD); + else if (ctx.JustificationCode() != null) + return this.symbols.getPrivacySettingOfField(baseFieldId, PrivacySetting.JUSTIFICATION_CODE_FIELD); + else if (ctx.JustificationDescription() != null) + return this.symbols.getPrivacySettingOfField(baseFieldId, PrivacySetting.JUSTIFICATION_DESCRIPTION_FIELD); + else + throw ConsistencyCheckException.unhandledLinkedFieldProperty(ctx.getText()); + } + + protected String getFieldId(LinkedFieldReferenceContext ctx) { + if (ctx == null) { + return null; + } + String baseFieldId = ctx.simpleFieldReference().fieldId.getText(); + if (ctx.linkedFieldProperty() == null) { + return baseFieldId; + } + return this.getLinkedFieldId(baseFieldId, ctx.linkedFieldProperty()); + } + + protected String getFieldId(EfxParser.FieldMentionContext ctx) { + if (ctx == null) { + return null; + } + String baseFieldId = ctx.fieldId.getText(); + if (ctx.linkedFieldProperty() == null) { + return baseFieldId; + } + return this.getLinkedFieldId(baseFieldId, ctx.linkedFieldProperty()); + } + + protected String getFieldId(FieldReferenceContext ctx) { if (ctx == null) { return null; } if (ctx.absoluteFieldReference() != null) { - return getFieldId(ctx.absoluteFieldReference()); + return this.getFieldId(ctx.absoluteFieldReference()); } if (ctx.fieldReferenceInOtherNotice() != null) { - return getFieldId(ctx.fieldReferenceInOtherNotice()); + return this.getFieldId(ctx.fieldReferenceInOtherNotice()); } assert false : "Unexpected context type for field reference: " + ctx.getClass().getSimpleName(); return null; } - protected static String getFieldId(AbsoluteFieldReferenceContext ctx) { + protected String getFieldId(AbsoluteFieldReferenceContext ctx) { if (ctx == null) { return null; } - return ctx.reference.reference.simpleFieldReference().fieldId.getText(); + return this.getFieldId(ctx.reference.reference.linkedFieldReference()); } - protected static String getFieldId(FieldReferenceInOtherNoticeContext ctx) { + protected String getFieldId(FieldReferenceInOtherNoticeContext ctx) { if (ctx == null) { return null; } - return ctx.reference.reference.reference.reference.reference.simpleFieldReference().fieldId.getText(); + return this.getFieldId(ctx.reference.reference.reference.reference.reference.linkedFieldReference()); } - protected static String getFieldId(FieldContextContext ctx) { + protected String getFieldId(FieldContextContext ctx) { if (ctx == null) { return null; } if (ctx.absoluteFieldReference() != null) { - return getFieldId(ctx.absoluteFieldReference()); + return this.getFieldId(ctx.absoluteFieldReference()); } if (ctx.fieldReferenceWithPredicate() != null) { - return getFieldId(ctx.fieldReferenceWithPredicate()); + return this.getFieldId(ctx.fieldReferenceWithPredicate()); } assert false : "Unexpected context type for field reference: " + ctx.getClass().getSimpleName(); return null; } - protected static String getFieldId(FieldReferenceWithPredicateContext ctx) { + protected String getFieldId(FieldReferenceWithPredicateContext ctx) { if (ctx == null) { return null; } - return ctx.fieldReferenceWithAxis().simpleFieldReference().fieldId.getText(); + return this.getFieldId(ctx.fieldReferenceWithAxis().linkedFieldReference()); } protected static String getNodeId(NodeReferenceContext ctx) { @@ -1102,6 +1135,16 @@ public void exitSimpleFieldReference(EfxParser.SimpleFieldReferenceContext ctx) symbols.getRelativePathOfField(ctx.fieldId.getText(), this.efxContext.symbol())); } + @Override + public void exitLinkedFieldReference(LinkedFieldReferenceContext ctx) { + if (ctx.linkedFieldProperty() != null) { + this.stack.pop(PathExpression.class); // discard base field path + String companionFieldId = getFieldId(ctx); + this.stack.push( + symbols.getRelativePathOfField(companionFieldId, this.efxContext.symbol())); + } + } + @Override public void enterAbsoluteFieldReference(AbsoluteFieldReferenceContext ctx) { if (ctx.Slash() != null) { @@ -1630,69 +1673,98 @@ public void exitEndsWithFunction(EndsWithFunctionContext ctx) { // #region Privacy settings ------------------------------------------------ @Override - public void exitFieldIsWithholdableCondition(FieldIsWithholdableConditionContext ctx) { - final String fieldId = ctx.fieldMention().getText(); + public void exitFieldWasWithheldProperty(FieldWasWithheldPropertyContext ctx) { + final String fieldId = getFieldId(ctx.fieldMention()); + if (this.isFieldRepeatableFromContext(fieldId, this.efxContext.peek())) { + throw TypeMismatchException.fieldMayRepeat(fieldId, this.efxContext.symbol()); + } final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); - final boolean negated = ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER); - final boolean isWithholdable = privacyCode != null && !privacyCode.isEmpty(); - this.stack.push(this.script.getBooleanEquivalent(negated != isWithholdable)); + if (privacyCode == null || privacyCode.isEmpty()) { + throw InvalidUsageException.fieldNotWithholdable(fieldId); + } + + this.stack.push(this.composeWasWithheldCondition(fieldId, privacyCode)); } @Override - public void exitFieldWasWithheldCondition(FieldWasWithheldConditionContext ctx) { - final String fieldId = ctx.fieldMention().getText(); + public void exitFieldIsWithheldProperty(FieldIsWithheldPropertyContext ctx) { + final String fieldId = getFieldId(ctx.fieldMention()); + if (this.isFieldRepeatableFromContext(fieldId, this.efxContext.peek())) { + throw TypeMismatchException.fieldMayRepeat(fieldId, this.efxContext.symbol()); + } final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); if (privacyCode == null || privacyCode.isEmpty()) { throw InvalidUsageException.fieldNotWithholdable(fieldId); } - BooleanExpression result = this.composeWasWithheldCondition(fieldId, privacyCode); - if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { - result = this.script.composeLogicalNot(result); - } - this.stack.push(result); + this.stack.push(this.script.composeLogicalAnd( + this.composeWasWithheldCondition(fieldId, privacyCode), + this.composeStillWithheldCondition(fieldId))); + } + + @Override + public void exitFieldIsWithholdableProperty(FieldIsWithholdablePropertyContext ctx) { + final String fieldId = getFieldId(ctx.fieldMention()); + final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); + final boolean isWithholdable = privacyCode != null && !privacyCode.isEmpty(); + this.stack.push(this.script.getBooleanEquivalent(isWithholdable)); } @Override - public void exitFieldIsWithheldCondition(FieldIsWithheldConditionContext ctx) { - final String fieldId = ctx.fieldMention().getText(); + public void exitFieldIsDisclosedProperty(FieldIsDisclosedPropertyContext ctx) { + final String fieldId = getFieldId(ctx.fieldMention()); + if (this.isFieldRepeatableFromContext(fieldId, this.efxContext.peek())) { + throw TypeMismatchException.fieldMayRepeat(fieldId, this.efxContext.symbol()); + } final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); if (privacyCode == null || privacyCode.isEmpty()) { throw InvalidUsageException.fieldNotWithholdable(fieldId); } - // "is withheld" = "was withheld" AND "still withheld" - BooleanExpression result = this.script.composeLogicalAnd( - this.composeWasWithheldCondition(fieldId, privacyCode), - this.composeStillWithheldCondition(fieldId)); - if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { - result = this.script.composeLogicalNot(result); - } - this.stack.push(result); + // "isDisclosed" = "was withheld" AND NOT "still withheld" AND NOT "masked" + this.stack.push(this.script.composeLogicalAnd( + this.script.composeLogicalAnd( + this.composeWasWithheldCondition(fieldId, privacyCode), + this.script.composeLogicalNot(this.composeStillWithheldCondition(fieldId))), + this.script.composeLogicalNot(this.composeIsMaskedCondition(fieldId)))); } @Override - public void exitFieldIsDisclosedCondition(FieldIsDisclosedConditionContext ctx) { - final String fieldId = ctx.fieldMention().getText(); + public void exitFieldIsMaskedProperty(FieldIsMaskedPropertyContext ctx) { + final String fieldId = getFieldId(ctx.fieldMention()); + if (this.isFieldRepeatableFromContext(fieldId, this.efxContext.peek())) { + throw TypeMismatchException.fieldMayRepeat(fieldId, this.efxContext.symbol()); + } final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); if (privacyCode == null || privacyCode.isEmpty()) { throw InvalidUsageException.fieldNotWithholdable(fieldId); } - // "is disclosed" = "was withheld" AND NOT "still withheld" AND NOT "masked" - BooleanExpression result = this.script.composeLogicalAnd( - this.script.composeLogicalAnd( - this.composeWasWithheldCondition(fieldId, privacyCode), - this.script.composeLogicalNot(this.composeStillWithheldCondition(fieldId))), - this.composeNotMaskedCondition(fieldId)); - if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) { - result = this.script.composeLogicalNot(result); + // "isMasked" = was withheld AND field value equals the privacy mask + this.stack.push(this.script.composeLogicalAnd( + this.composeWasWithheldCondition(fieldId, privacyCode), + this.composeIsMaskedCondition(fieldId))); + } + + @Override + public void exitFieldPrivacyCodeProperty(FieldPrivacyCodePropertyContext ctx) { + final String fieldId = getFieldId(ctx.fieldMention()); + final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); + if (privacyCode == null || privacyCode.isEmpty()) { + throw InvalidUsageException.fieldNotWithholdable(fieldId); } - this.stack.push(result); + this.stack.push(this.script.getStringLiteralFromUnquotedString(privacyCode)); + } + + private boolean isFieldRepeatableFromContext(String fieldId, Context context) { + String contextNodeId = context.isFieldContext() + ? this.symbols.getParentNodeOfField(context.symbol()) + : context.symbol(); + return this.symbols.isFieldRepeatableFromContext(fieldId, contextNodeId); } private BooleanExpression composeWasWithheldCondition(String fieldId, String privacyCode) { @@ -1726,13 +1798,31 @@ private BooleanExpression composeStillWithheldCondition(String fieldId) { BooleanExpression.class); } - private BooleanExpression composeNotMaskedCondition(String fieldId) { + private BooleanExpression composeIsMaskedCondition(String fieldId) { final String maskingValue = this.symbols.getPrivacyMask(fieldId); - final PathExpression fieldPath = this.symbols.getRelativePathOfField(fieldId, this.efxContext.symbol()); + final PathExpression fieldValue = this.script.composeFieldValueReference(this.symbols.getRelativePathOfField(fieldId, this.efxContext.symbol())); + + if (!(fieldValue instanceof ScalarExpression)) { + throw TypeMismatchException.fieldMayRepeat(fieldId, this.efxContext.symbol()); + } + return this.script.composeComparisonOperation( - new StringExpression(this.script.composeFieldValueReference(fieldPath).getScript()), - "!=", - this.script.getStringLiteralFromUnquotedString(maskingValue)); + TypedExpression.from(fieldValue, ScalarExpression.class), + "==", + this.getTypedLiteralFromUnquotedString(maskingValue, fieldValue.getDataType())); + } + + private ScalarExpression getTypedLiteralFromUnquotedString(String value, Class type) { + if (EfxDataType.Number.class.isAssignableFrom(type)) { + return this.script.getNumericLiteralEquivalent(value); + } + if (EfxDataType.Date.class.isAssignableFrom(type)) { + return this.script.getDateLiteralEquivalent(value); + } + if (EfxDataType.Time.class.isAssignableFrom(type)) { + return this.script.getTimeLiteralEquivalent(value); + } + return this.script.getStringLiteralFromUnquotedString(value); } // #endregion Privacy settings --------------------------------------------- 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 45e8e85..329a6de 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java @@ -1280,32 +1280,71 @@ void testEndsWithFunction() { "ends-with(BT-00-Text, 'abc')"); } + // Linked field property tests + @Test - void testFieldIsWithholdable_nonWithholdable() { + void testLinkedFieldProperty_publicationDate() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; testExpressionTranslationWithContext( - "false()", "BT-00-Text", - "BT-00-Text is withholdable"); + privacyPath + "/PublicationDate", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node:publicationDate is present"); } @Test - void testFieldIsWithholdable_withholdable() { + void testLinkedFieldProperty_justificationCode() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; testExpressionTranslationWithContext( - "true()", + privacyPath + "/ReasonCode", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node:justificationCode is present"); + } + + @Test + void testLinkedFieldProperty_justificationDescription() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/ReasonDescription", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node:justificationDescription is present"); + } + + @Test + void testLinkedFieldProperty_onNonWithholdableField() { + assertThrows(ParseCancellationException.class, + () -> translateExpressionWithContext("BT-00-Text", + "BT-00-Text:publicationDate is present")); + } + + @Test + void testLinkedFieldProperty_inComparison() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/PublicationDate/xs:date(text()) > xs:date('2025-01-01Z')", "BT-00-Text-In-Repeatable-Node", - "BT-00-Text-In-Repeatable-Node is withholdable"); + "BT-00-Text-In-Repeatable-Node:publicationDate > 2025-01-01Z"); } + // Computed property tests + @Test - void testFieldWasWithheld() { + void testComputedProperty_wasWithheld() { testExpressionTranslationWithContext( - "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/FieldIdentifierCode/normalize-space(text()) = 'test-priv'", + "../FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/FieldIdentifierCode/normalize-space(text()) = 'test-priv'", "BT-00-Text-In-Repeatable-Node", - "BT-00-Text-In-Repeatable-Node was withheld"); + "BT-00-Text-In-Repeatable-Node:wasWithheld"); + } + + @Test + void testComputedProperty_wasWithheld_onNonWithholdableField() { + assertThrows(ParseCancellationException.class, + () -> translateExpressionWithContext("BT-00-Text", + "BT-00-Text:wasWithheld")); } @Test - void testFieldIsWithheld() { - final String privacyPath = "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + void testComputedProperty_isWithheld() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; testExpressionTranslationWithContext( privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + " and " @@ -1313,77 +1352,124 @@ void testFieldIsWithheld() { + " or " + privacyPath + "/PublicationDate/xs:date(text()) > current-date())", "BT-00-Text-In-Repeatable-Node", - "BT-00-Text-In-Repeatable-Node is withheld"); + "BT-00-Text-In-Repeatable-Node:isWithheld"); + } + + @Test + void testComputedProperty_isWithheld_onNonWithholdableField() { + assertThrows(ParseCancellationException.class, + () -> translateExpressionWithContext("BT-00-Text", + "BT-00-Text:isWithheld")); + } + + @Test + void testComputedProperty_isWithholdable_true() { + testExpressionTranslationWithContext( + "true()", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node:isWithholdable"); + } + + @Test + void testComputedProperty_isWithholdable_false() { + testExpressionTranslationWithContext( + "false()", "BT-00-Text", + "BT-00-Text:isWithholdable"); } @Test - void testFieldIsDisclosed() { - final String privacyPath = "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + void testComputedProperty_isDisclosed() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; testExpressionTranslationWithContext( - // wasWithheld privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + " and " - // NOT stillWithheld + "not(" + "(not(" + privacyPath + "/PublicationDate)" + " or " + privacyPath + "/PublicationDate/xs:date(text()) > current-date())" + ")" + " and " - // NOT masked - + "./normalize-space(text()) != 'unpublished'", + + "not(./normalize-space(text()) = 'unpublished')", "BT-00-Text-In-Repeatable-Node", - "BT-00-Text-In-Repeatable-Node is disclosed"); + "BT-00-Text-In-Repeatable-Node:isDisclosed"); } @Test - void testFieldIsNotWithholdable_nonWithholdable() { - testExpressionTranslationWithContext( - "true()", "BT-00-Text", - "BT-00-Text is not withholdable"); + void testComputedProperty_isDisclosed_onNonWithholdableField() { + assertThrows(ParseCancellationException.class, + () -> translateExpressionWithContext("BT-00-Text", + "BT-00-Text:isDisclosed")); } @Test - void testFieldIsNotWithholdable_withholdable() { + void testComputedProperty_isMasked() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; testExpressionTranslationWithContext( - "false()", + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + + " and " + + "./normalize-space(text()) = 'unpublished'", "BT-00-Text-In-Repeatable-Node", - "BT-00-Text-In-Repeatable-Node is not withholdable"); + "BT-00-Text-In-Repeatable-Node:isMasked"); + } + + @Test + void testComputedProperty_isMasked_onNonWithholdableField() { + assertThrows(ParseCancellationException.class, + () -> translateExpressionWithContext("BT-00-Text", + "BT-00-Text:isMasked")); } @Test - void testFieldWasNotWithheld() { - final String fieldPrefix = "RepeatableNode/TextField/"; - final String privacyPath = fieldPrefix + "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + void testComputedProperty_isMasked_numericField() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='num-priv']"; testExpressionTranslationWithContext( - "not(" + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv')", - "ND-Root", - "BT-00-Text-In-Repeatable-Node was not withheld"); + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'num-priv'" + + " and " + + "./number() = -1", + "BT-00-Number-In-Repeatable-Node", + "BT-00-Number-In-Repeatable-Node:isMasked"); + } + + @Test + void testComputedProperty_isMasked_repeatingFieldFromContext() { + assertThrows(ParseCancellationException.class, + () -> translateExpressionWithContext("ND-Root", + "BT-00-Text-In-Repeatable-Node:isMasked")); } @Test - void testFieldIsNotWithheld() { - final String fieldPrefix = "RepeatableNode/TextField/"; - final String privacyPath = fieldPrefix + "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + void testComputedProperty_isDisclosed_numericField() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='num-priv']"; testExpressionTranslationWithContext( - "not(" - + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'num-priv'" + " and " - + "(not(" + privacyPath + "/PublicationDate)" - + " or " - + privacyPath + "/PublicationDate/xs:date(text()) > current-date())" - + ")", - "ND-Root", - "BT-00-Text-In-Repeatable-Node is not withheld"); + + "not(" + + "(not(" + privacyPath + "/PublicationDate)" + + " or " + + privacyPath + "/PublicationDate/xs:date(text()) > current-date())" + + ")" + + " and " + + "not(./number() = -1)", + "BT-00-Number-In-Repeatable-Node", + "BT-00-Number-In-Repeatable-Node:isDisclosed"); + } + + @Test + void testComputedProperty_isMasked_dateField() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + + " and " + + "./xs:date(text()) = xs:date('1970-01-01Z')", + "BT-00-Date-In-Repeatable-Node", + "BT-00-Date-In-Repeatable-Node:isMasked"); } @Test - void testFieldIsNotDisclosed() { - final String fieldPrefix = "RepeatableNode/TextField/"; - final String privacyPath = fieldPrefix + "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + void testComputedProperty_isDisclosed_dateField() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; testExpressionTranslationWithContext( - "not(" - + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + " and " + "not(" + "(not(" + privacyPath + "/PublicationDate)" @@ -1391,10 +1477,26 @@ void testFieldIsNotDisclosed() { + privacyPath + "/PublicationDate/xs:date(text()) > current-date())" + ")" + " and " - + "RepeatableNode/TextField/normalize-space(text()) != 'unpublished'" - + ")", - "ND-Root", - "BT-00-Text-In-Repeatable-Node is not disclosed"); + + "not(./xs:date(text()) = xs:date('1970-01-01Z'))", + "BT-00-Date-In-Repeatable-Node", + "BT-00-Date-In-Repeatable-Node:isDisclosed"); + } + + // Metadata property tests + + @Test + void testMetadataProperty_privacyCode() { + testExpressionTranslationWithContext( + "'test-priv'", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node:privacyCode"); + } + + @Test + void testMetadataProperty_privacyCode_onNonWithholdableField() { + assertThrows(ParseCancellationException.class, + () -> translateExpressionWithContext("BT-00-Text", + "BT-00-Text:privacyCode")); } // #endregion: Boolean functions diff --git a/src/test/resources/json/README.md b/src/test/resources/json/README.md index ceb45f2..9826b30 100644 --- a/src/test/resources/json/README.md +++ b/src/test/resources/json/README.md @@ -19,7 +19,8 @@ ND-Root (non-rep) | +-- ND-NonRepeatableSubNode2 (non-rep) <- non-rep in rep (sibling) | +-- ND-RepeatableInRepeatableNode (REP) <- rep in rep | +-- ND-RepeatableInRepeatableNode2 (REP) <- rep in rep (sibling) -| +-- ND-PrivacyInRepeatableNode (non-rep) <- privacy metadata node +| +-- ND-PrivacyInRepeatableNode (non-rep) <- privacy code 'test-priv' (text + date fields) +| +-- ND-PrivacyInRepeatableNode2 (non-rep) <- privacy code 'num-priv' (number field) ``` ## Repeatability Coverage @@ -57,8 +58,20 @@ Fields use the pattern `BT-XY-Type` where: ## Privacy Test Data -Field `BT-00-Text-In-Repeatable-Node` has privacy settings configured with code `test-priv`. -Its privacy metadata is stored under node `ND-PrivacyInRepeatableNode` with these fields: +Privacy metadata is stored in `FieldsPrivacy` elements which are **siblings** of the field +elements they protect (not children — fields are leaf XML elements). Each `FieldsPrivacy` element +is identified by a `FieldIdentifierCode` predicate matching the privacy code. + +### Text and date fields sharing privacy code `test-priv` + +Both `BT-00-Text-In-Repeatable-Node` (text) and `BT-00-Date-In-Repeatable-Node` (date) share the +same privacy code and thus the same `FieldsPrivacy` node, but each has its own companion fields. +This tests that multiple fields can share a privacy node and that type-specific masking works +(string mask `'unpublished'` vs date mask `'1970-01-01Z'`). + +Privacy node: `ND-PrivacyInRepeatableNode` (parent: `ND-RepeatableNode`) + +Text field companions: | Field ID | Purpose | Type | |----------|---------|------| @@ -67,6 +80,26 @@ Its privacy metadata is stored under node `ND-PrivacyInRepeatableNode` with thes | BT-197(BT-00)-Text-In-Repeatable-Node | Reason code | code | | BT-198(BT-00)-Text-In-Repeatable-Node | Publication date | date | +Date field companions: + +| Field ID | Purpose | Type | +|----------|---------|------| +| BT-195(BT-00)-Date-In-Repeatable-Node | Unpublished field identifier | code | +| BT-196(BT-00)-Date-In-Repeatable-Node | Reason description | text-multilingual | +| BT-197(BT-00)-Date-In-Repeatable-Node | Reason code | code | +| BT-198(BT-00)-Date-In-Repeatable-Node | Publication date | date | + +### Numeric field: `BT-00-Number-In-Repeatable-Node` (code: `num-priv`) + +Privacy node: `ND-PrivacyInRepeatableNode2` (parent: `ND-RepeatableNode`) + +| Field ID | Purpose | Type | +|----------|---------|------| +| BT-195(BT-00)-Number-In-Repeatable-Node | Unpublished field identifier | code | +| BT-196(BT-00)-Number-In-Repeatable-Node | Reason description | text-multilingual | +| BT-197(BT-00)-Number-In-Repeatable-Node | Reason code | code | +| BT-198(BT-00)-Number-In-Repeatable-Node | Publication date | date | + ## Files - `sdk2-fields.json` - Field and node definitions with XPath expressions diff --git a/src/test/resources/json/sdk2-fields.json b/src/test/resources/json/sdk2-fields.json index a4def02..526647c 100644 --- a/src/test/resources/json/sdk2-fields.json +++ b/src/test/resources/json/sdk2-fields.json @@ -99,8 +99,15 @@ { "id": "ND-PrivacyInRepeatableNode", "parentId": "ND-RepeatableNode", - "xpathAbsolute": "/*/RepeatableNode/TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']", - "xpathRelative": "TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']", + "xpathRelative": "FieldsPrivacy[FieldIdentifierCode/text()='test-priv']", + "repeatable": false + }, + { + "id": "ND-PrivacyInRepeatableNode2", + "parentId": "ND-RepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='num-priv']", + "xpathRelative": "FieldsPrivacy[FieldIdentifierCode/text()='num-priv']", "repeatable": false } ], @@ -368,6 +375,21 @@ "xpathAbsolute": "/*/RepeatableNode/TextField/@attribute", "xpathRelative": "TextField/@attribute" }, + { + "id": "BT-00-Date-In-Repeatable-Node", + "alias": "dateInRepeatableNode", + "type": "date", + "parentNodeId": "ND-RepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/DateField", + "xpathRelative": "DateField", + "privacy": { + "code": "test-priv", + "unpublishedFieldId": "BT-195(BT-00)-Date-In-Repeatable-Node", + "reasonCodeFieldId": "BT-197(BT-00)-Date-In-Repeatable-Node", + "reasonDescriptionFieldId": "BT-196(BT-00)-Date-In-Repeatable-Node", + "publicationDateFieldId": "BT-198(BT-00)-Date-In-Repeatable-Node" + } + }, { "id": "BT-00-Text-In-NonRepeatableSubNode", "alias": "textInNonRepeatableSubNode", @@ -864,11 +886,55 @@ "xpathAbsolute": "/*/RepeatableNode/NonRepeatableSubNode/RepeatableSubSubNode/Measure", "xpathRelative": "Measure" }, + { + "id": "BT-00-Number-In-Repeatable-Node", + "alias": "numberInRepeatableNode", + "type": "number", + "parentNodeId": "ND-RepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/NumberField", + "xpathRelative": "NumberField", + "privacy": { + "code": "num-priv", + "unpublishedFieldId": "BT-195(BT-00)-Number-In-Repeatable-Node", + "reasonCodeFieldId": "BT-197(BT-00)-Number-In-Repeatable-Node", + "reasonDescriptionFieldId": "BT-196(BT-00)-Number-In-Repeatable-Node", + "publicationDateFieldId": "BT-198(BT-00)-Number-In-Repeatable-Node" + } + }, + { + "id": "BT-195(BT-00)-Number-In-Repeatable-Node", + "type": "code", + "parentNodeId": "ND-PrivacyInRepeatableNode2", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='num-priv']/FieldIdentifierCode", + "xpathRelative": "FieldIdentifierCode", + "presetValue": "num-priv" + }, + { + "id": "BT-196(BT-00)-Number-In-Repeatable-Node", + "type": "text-multilingual", + "parentNodeId": "ND-PrivacyInRepeatableNode2", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='num-priv']/ReasonDescription", + "xpathRelative": "ReasonDescription" + }, + { + "id": "BT-197(BT-00)-Number-In-Repeatable-Node", + "type": "code", + "parentNodeId": "ND-PrivacyInRepeatableNode2", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='num-priv']/ReasonCode", + "xpathRelative": "ReasonCode" + }, + { + "id": "BT-198(BT-00)-Number-In-Repeatable-Node", + "type": "date", + "parentNodeId": "ND-PrivacyInRepeatableNode2", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='num-priv']/PublicationDate", + "xpathRelative": "PublicationDate" + }, { "id": "BT-195(BT-00)-Text-In-Repeatable-Node", "type": "code", "parentNodeId": "ND-PrivacyInRepeatableNode", - "xpathAbsolute": "/*/RepeatableNode/TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/FieldIdentifierCode", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/FieldIdentifierCode", "xpathRelative": "FieldIdentifierCode", "presetValue": "test-priv" }, @@ -876,21 +942,50 @@ "id": "BT-196(BT-00)-Text-In-Repeatable-Node", "type": "text-multilingual", "parentNodeId": "ND-PrivacyInRepeatableNode", - "xpathAbsolute": "/*/RepeatableNode/TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/ReasonDescription", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/ReasonDescription", "xpathRelative": "ReasonDescription" }, { "id": "BT-197(BT-00)-Text-In-Repeatable-Node", "type": "code", "parentNodeId": "ND-PrivacyInRepeatableNode", - "xpathAbsolute": "/*/RepeatableNode/TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/ReasonCode", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/ReasonCode", "xpathRelative": "ReasonCode" }, { "id": "BT-198(BT-00)-Text-In-Repeatable-Node", "type": "date", "parentNodeId": "ND-PrivacyInRepeatableNode", - "xpathAbsolute": "/*/RepeatableNode/TextField/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/PublicationDate", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/PublicationDate", + "xpathRelative": "PublicationDate" + }, + { + "id": "BT-195(BT-00)-Date-In-Repeatable-Node", + "type": "code", + "parentNodeId": "ND-PrivacyInRepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/FieldIdentifierCode", + "xpathRelative": "FieldIdentifierCode", + "presetValue": "test-priv" + }, + { + "id": "BT-196(BT-00)-Date-In-Repeatable-Node", + "type": "text-multilingual", + "parentNodeId": "ND-PrivacyInRepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/ReasonDescription", + "xpathRelative": "ReasonDescription" + }, + { + "id": "BT-197(BT-00)-Date-In-Repeatable-Node", + "type": "code", + "parentNodeId": "ND-PrivacyInRepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/ReasonCode", + "xpathRelative": "ReasonCode" + }, + { + "id": "BT-198(BT-00)-Date-In-Repeatable-Node", + "type": "date", + "parentNodeId": "ND-PrivacyInRepeatableNode", + "xpathAbsolute": "/*/RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/PublicationDate", "xpathRelative": "PublicationDate" } ]