Skip to content

Commit ddb18b2

Browse files
Merge pull request #131 from OP-TED/TEDEFO-1854-improve-uniqueness-testing
TEDEFO-1854 Align toolkit to improved uniqueness testing
2 parents 69000c5 + a06cc97 commit ddb18b2

7 files changed

Lines changed: 240 additions & 17 deletions

File tree

src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,9 +414,10 @@ public boolean isFieldRepeatableFromContext(final String fieldId, final String c
414414
}
415415

416416
private boolean isFieldRepeatableFromContext(final SdkField sdkField, final SdkField context) {
417-
// If the field itself is repeatable, it returns multiple values
417+
// If the field itself is repeatable, it returns multiple values UNLESS it IS the context
418+
// (e.g., inside a predicate on this field: BT-Repeatable[BT-Repeatable != ''])
418419
if (sdkField.isRepeatable()) {
419-
return true;
420+
return !sdkField.equals(context);
420421
}
421422

422423
// Use cached ancestry from node

src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ public class TypeMismatchException extends ParseCancellationException {
3030
public enum ErrorCode {
3131
CANNOT_CONVERT,
3232
CANNOT_COMPARE,
33-
EXPECTED_SEQUENCE,
33+
EXPECTED_SCALAR,
3434
EXPECTED_FIELD_CONTEXT
3535
}
3636

3737
private static final String CANNOT_CONVERT = "Type mismatch. Expected %s instead of %s.";
3838
private static final String CANNOT_COMPARE = "Type mismatch. Cannot compare values of different types: %s and %s";
39-
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.";
39+
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.";
4040
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.";
4141

4242
private final ErrorCode errorCode;
@@ -71,7 +71,7 @@ public static TypeMismatchException cannotCompare(Expression left, Expression ri
7171
}
7272

7373
public static TypeMismatchException fieldMayRepeat(String fieldId, String contextSymbol) {
74-
return new TypeMismatchException(ErrorCode.EXPECTED_SEQUENCE, String.format(EXPECTED_SEQUENCE, fieldId,
74+
return new TypeMismatchException(ErrorCode.EXPECTED_SCALAR, String.format(EXPECTED_SCALAR, fieldId,
7575
contextSymbol != null ? contextSymbol : "root"));
7676
}
7777

src/main/java/eu/europa/ted/efx/interfaces/ScriptGenerator.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,13 @@
2727
import eu.europa.ted.efx.model.expressions.scalar.ScalarExpression;
2828
import eu.europa.ted.efx.model.expressions.scalar.StringExpression;
2929
import eu.europa.ted.efx.model.expressions.scalar.TimeExpression;
30+
import eu.europa.ted.efx.model.expressions.sequence.BooleanSequenceExpression;
31+
import eu.europa.ted.efx.model.expressions.sequence.DateSequenceExpression;
32+
import eu.europa.ted.efx.model.expressions.sequence.DurationSequenceExpression;
3033
import eu.europa.ted.efx.model.expressions.sequence.NumericSequenceExpression;
3134
import eu.europa.ted.efx.model.expressions.sequence.SequenceExpression;
3235
import eu.europa.ted.efx.model.expressions.sequence.StringSequenceExpression;
36+
import eu.europa.ted.efx.model.expressions.sequence.TimeSequenceExpression;
3337

3438
/**
3539
* A ScriptGenerator is used by the EFX expression translator to translate specific computations to
@@ -412,9 +416,42 @@ public StringExpression composeSubstringExtraction(StringExpression text, Numeri
412416

413417
public BooleanExpression composeExistsCondition(PathExpression reference);
414418

419+
/**
420+
* Uniqueness check for EFX 1 syntax.
421+
* <p>
422+
* This method supports the limited uniqueness syntax available in EFX 1.
423+
* It is used exclusively by the EFX 1 translator and is kept for backward
424+
* compatibility with EFX 1.
425+
* <p>
426+
* <b>EFX 2 does not use this method.</b> EFX 2's stricter type checking enables
427+
* more powerful uniqueness syntax, supported by the typed overloads below.
428+
*
429+
* @param needle The value to check for uniqueness
430+
* @param haystack The collection to search within
431+
* @return A boolean expression evaluating to true if needle appears exactly once in haystack
432+
*/
415433
public BooleanExpression composeUniqueValueCondition(PathExpression needle,
416434
PathExpression haystack);
417435

436+
// Typed uniqueness conditions (EFX 2)
437+
public BooleanExpression composeUniqueValueCondition(StringExpression needle,
438+
StringSequenceExpression haystack);
439+
440+
public BooleanExpression composeUniqueValueCondition(NumericExpression needle,
441+
NumericSequenceExpression haystack);
442+
443+
public BooleanExpression composeUniqueValueCondition(BooleanExpression needle,
444+
BooleanSequenceExpression haystack);
445+
446+
public BooleanExpression composeUniqueValueCondition(DateExpression needle,
447+
DateSequenceExpression haystack);
448+
449+
public BooleanExpression composeUniqueValueCondition(TimeExpression needle,
450+
TimeSequenceExpression haystack);
451+
452+
public BooleanExpression composeUniqueValueCondition(DurationExpression needle,
453+
DurationSequenceExpression haystack);
454+
418455
public BooleanExpression composeSequenceEqualFunction(SequenceExpression one,
419456
SequenceExpression two);
420457

src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,9 +430,74 @@ public void exitPresenceCondition(EfxParser.PresenceConditionContext ctx) {
430430
}
431431

432432
@Override
433-
public void exitUniqueValueCondition(EfxParser.UniqueValueConditionContext ctx) {
434-
PathExpression haystack = this.stack.pop(PathExpression.class);
435-
PathExpression needle = this.stack.pop(haystack.getClass());
433+
public void exitStringUniqueValueCondition(EfxParser.StringUniqueValueConditionContext ctx) {
434+
StringSequenceExpression haystack = this.stack.pop(StringSequenceExpression.class);
435+
StringExpression needle = this.stack.pop(StringExpression.class);
436+
437+
if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) {
438+
this.stack.push(
439+
this.script.composeLogicalNot(this.script.composeUniqueValueCondition(needle, haystack)));
440+
} else {
441+
this.stack.push(this.script.composeUniqueValueCondition(needle, haystack));
442+
}
443+
}
444+
445+
@Override
446+
public void exitNumericUniqueValueCondition(EfxParser.NumericUniqueValueConditionContext ctx) {
447+
NumericSequenceExpression haystack = this.stack.pop(NumericSequenceExpression.class);
448+
NumericExpression needle = this.stack.pop(NumericExpression.class);
449+
450+
if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) {
451+
this.stack.push(
452+
this.script.composeLogicalNot(this.script.composeUniqueValueCondition(needle, haystack)));
453+
} else {
454+
this.stack.push(this.script.composeUniqueValueCondition(needle, haystack));
455+
}
456+
}
457+
458+
@Override
459+
public void exitBooleanUniqueValueCondition(EfxParser.BooleanUniqueValueConditionContext ctx) {
460+
BooleanSequenceExpression haystack = this.stack.pop(BooleanSequenceExpression.class);
461+
BooleanExpression needle = this.stack.pop(BooleanExpression.class);
462+
463+
if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) {
464+
this.stack.push(
465+
this.script.composeLogicalNot(this.script.composeUniqueValueCondition(needle, haystack)));
466+
} else {
467+
this.stack.push(this.script.composeUniqueValueCondition(needle, haystack));
468+
}
469+
}
470+
471+
@Override
472+
public void exitDateUniqueValueCondition(EfxParser.DateUniqueValueConditionContext ctx) {
473+
DateSequenceExpression haystack = this.stack.pop(DateSequenceExpression.class);
474+
DateExpression needle = this.stack.pop(DateExpression.class);
475+
476+
if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) {
477+
this.stack.push(
478+
this.script.composeLogicalNot(this.script.composeUniqueValueCondition(needle, haystack)));
479+
} else {
480+
this.stack.push(this.script.composeUniqueValueCondition(needle, haystack));
481+
}
482+
}
483+
484+
@Override
485+
public void exitTimeUniqueValueCondition(EfxParser.TimeUniqueValueConditionContext ctx) {
486+
TimeSequenceExpression haystack = this.stack.pop(TimeSequenceExpression.class);
487+
TimeExpression needle = this.stack.pop(TimeExpression.class);
488+
489+
if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) {
490+
this.stack.push(
491+
this.script.composeLogicalNot(this.script.composeUniqueValueCondition(needle, haystack)));
492+
} else {
493+
this.stack.push(this.script.composeUniqueValueCondition(needle, haystack));
494+
}
495+
}
496+
497+
@Override
498+
public void exitDurationUniqueValueCondition(EfxParser.DurationUniqueValueConditionContext ctx) {
499+
DurationSequenceExpression haystack = this.stack.pop(DurationSequenceExpression.class);
500+
DurationExpression needle = this.stack.pop(DurationExpression.class);
436501

437502
if (ctx.modifier != null && ctx.modifier.getText().equals(NOT_MODIFIER)) {
438503
this.stack.push(

src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,13 @@
5050
import eu.europa.ted.efx.model.expressions.scalar.StringLiteral;
5151
import eu.europa.ted.efx.model.expressions.scalar.TimeExpression;
5252
import eu.europa.ted.efx.model.expressions.scalar.TimeLiteral;
53+
import eu.europa.ted.efx.model.expressions.sequence.BooleanSequenceExpression;
54+
import eu.europa.ted.efx.model.expressions.sequence.DateSequenceExpression;
55+
import eu.europa.ted.efx.model.expressions.sequence.DurationSequenceExpression;
5356
import eu.europa.ted.efx.model.expressions.sequence.NumericSequenceExpression;
5457
import eu.europa.ted.efx.model.expressions.sequence.SequenceExpression;
5558
import eu.europa.ted.efx.model.expressions.sequence.StringSequenceExpression;
59+
import eu.europa.ted.efx.model.expressions.sequence.TimeSequenceExpression;
5660
import eu.europa.ted.efx.model.types.EfxDataType;
5761

5862
@SdkComponent(versions = {"2"},
@@ -343,13 +347,58 @@ public BooleanExpression composeExistsCondition(PathExpression reference) {
343347
return new BooleanExpression(reference.getScript());
344348
}
345349

350+
/**
351+
* EFX 1 uniqueness check - kept for backward compatibility.
352+
* EFX 2 uses the typed overloads below.
353+
*/
346354
@Override
347355
public BooleanExpression composeUniqueValueCondition(PathExpression needle,
348356
PathExpression haystack) {
349357
return new BooleanExpression("count(for $x in " + needle.getScript() + ", $y in " + haystack.getScript()
350358
+ "[. = $x] return $y) = 1");
351359
}
352360

361+
@Override
362+
public BooleanExpression composeUniqueValueCondition(StringExpression needle,
363+
StringSequenceExpression haystack) {
364+
return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript());
365+
}
366+
367+
@Override
368+
public BooleanExpression composeUniqueValueCondition(NumericExpression needle,
369+
NumericSequenceExpression haystack) {
370+
return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript());
371+
}
372+
373+
@Override
374+
public BooleanExpression composeUniqueValueCondition(BooleanExpression needle,
375+
BooleanSequenceExpression haystack) {
376+
return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript());
377+
}
378+
379+
@Override
380+
public BooleanExpression composeUniqueValueCondition(DateExpression needle,
381+
DateSequenceExpression haystack) {
382+
return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript());
383+
}
384+
385+
@Override
386+
public BooleanExpression composeUniqueValueCondition(TimeExpression needle,
387+
TimeSequenceExpression haystack) {
388+
return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript());
389+
}
390+
391+
@Override
392+
public BooleanExpression composeUniqueValueCondition(DurationExpression needle,
393+
DurationSequenceExpression haystack) {
394+
return composeTypedUniqueValueCondition(needle.getScript(), haystack.getScript());
395+
}
396+
397+
private BooleanExpression composeTypedUniqueValueCondition(String needle, String haystack) {
398+
return new BooleanExpression(
399+
"count(for $n in " + needle + ", $x in " + haystack + "[. = $n] return $x) = 1");
400+
}
401+
353402
//#endregion Boolean Expressions ------------------------------------------
354403

355404
//#region Boolean functions -----------------------------------------------

src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,66 @@ void testPresenceCondition_WithNot() {
7878
@Test
7979
void testUniqueValueCondition() {
8080
testExpressionTranslationWithContext(
81-
"count(for $x in PathNode/TextField, $y in /*/PathNode/TextField[. = $x] return $y) = 1",
81+
"count(for $n in PathNode/TextField/normalize-space(text()), $x in /*/PathNode/TextField/normalize-space(text())[. = $n] return $x) = 1",
8282
"ND-Root", "BT-00-Text is unique in /BT-00-Text");
8383
}
8484

8585
@Test
8686
void testUniqueValueCondition_WithNot() {
8787
testExpressionTranslationWithContext(
88-
"not(count(for $x in PathNode/TextField, $y in /*/PathNode/TextField[. = $x] return $y) = 1)",
88+
"not(count(for $n in PathNode/TextField/normalize-space(text()), $x in /*/PathNode/TextField/normalize-space(text())[. = $n] return $x) = 1)",
8989
"ND-Root", "BT-00-Text is not unique in /BT-00-Text");
9090
}
9191

92+
@Test
93+
void testStringUniqueValueCondition_WithLiteralSequence() {
94+
testExpressionTranslationWithContext(
95+
"count(for $n in 'b', $x in ('a','b','c','b')[. = $n] return $x) = 1",
96+
"BT-00-Text", "'b' is unique in ('a', 'b', 'c', 'b')");
97+
}
98+
99+
@Test
100+
void testNumericUniqueValueCondition_WithLiteralSequence() {
101+
testExpressionTranslationWithContext(
102+
"count(for $n in 2, $x in (1,2,3,2)[. = $n] return $x) = 1",
103+
"BT-00-Number", "2 is unique in (1, 2, 3, 2)");
104+
}
105+
106+
@Test
107+
void testStringUniqueValueCondition_WithRepeatableField() {
108+
testExpressionTranslationWithContext(
109+
"count(for $n in PathNode/TextField/normalize-space(text()), $x in /*/PathNode/RepeatableTextField/normalize-space(text())[. = $n] return $x) = 1",
110+
"ND-Root", "BT-00-Text is unique in /BT-00-Repeatable-Text");
111+
}
112+
113+
@Test
114+
void testStringUniqueValueCondition_WithNot() {
115+
testExpressionTranslationWithContext(
116+
"not(count(for $n in 'x', $x in ('a','b','c')[. = $n] return $x) = 1)",
117+
"BT-00-Text", "'x' is not unique in ('a', 'b', 'c')");
118+
}
119+
120+
@Test
121+
void testStringUniqueValueCondition_WithRelativeFieldReference() {
122+
testExpressionTranslationWithContext(
123+
"count(for $n in PathNode/TextField/normalize-space(text()), $x in PathNode/RepeatableTextField/normalize-space(text())[. = $n] return $x) = 1",
124+
"ND-Root", "BT-00-Text is unique in BT-00-Repeatable-Text");
125+
}
126+
127+
@Test
128+
void testStringUniqueValueCondition_WithFieldReferencePredicate() {
129+
testExpressionTranslationWithContext(
130+
"count(for $n in PathNode/TextField/normalize-space(text()), $x in /*/PathNode/RepeatableTextField[./normalize-space(text()) != '']/normalize-space(text())[. = $n] return $x) = 1",
131+
"ND-Root", "BT-00-Text is unique in /BT-00-Repeatable-Text[BT-00-Repeatable-Text != '']");
132+
}
133+
134+
@Test
135+
void testStringUniqueValueCondition_WithFieldInRepeatableNodePredicate() {
136+
testExpressionTranslationWithContext(
137+
"count(for $n in PathNode/TextField/normalize-space(text()), $x in /*/RepeatableNode/TextField[./normalize-space(text()) != '']/normalize-space(text())[. = $n] return $x) = 1",
138+
"ND-Root", "BT-00-Text is unique in /BT-00-Text-In-Repeatable-Node[BT-00-Text-In-Repeatable-Node != '']");
139+
}
140+
92141

93142
@Test
94143
void testLikePatternCondition() {
@@ -1791,15 +1840,15 @@ void testScalarFromRepeatableField_ThrowsError() {
17911840
// A repeatable field used as scalar should throw TypeMismatchException.fieldMayRepeat()
17921841
TypeMismatchException ex = assertThrows(TypeMismatchException.class,
17931842
() -> translateExpressionWithContext("ND-Root", "BT-00-Repeatable-Text == 'test'"));
1794-
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode());
1843+
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode());
17951844
}
17961845

17971846
@Test
17981847
void testScalarFromFieldInRepeatableNode_ThrowsErrorFromRootContext() {
17991848
// Field in ND-RepeatableNode (repeatable) used as scalar from ND-Root should throw
18001849
TypeMismatchException ex = assertThrows(TypeMismatchException.class,
18011850
() -> translateExpressionWithContext("ND-Root", "BT-00-Text-In-Repeatable-Node == 'test'"));
1802-
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode());
1851+
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode());
18031852
}
18041853

18051854
@Test
@@ -1814,15 +1863,15 @@ void testScalarFromFieldInNestedRepeatableNode_ThrowsErrorFromRootContext() {
18141863
// Field in ND-RepeatableSubSubNode (inside ND-NonRepeatableSubNode inside ND-RepeatableNode) used from root should throw
18151864
TypeMismatchException ex = assertThrows(TypeMismatchException.class,
18161865
() -> translateExpressionWithContext("ND-Root", "BT-00-Text-In-RepeatableSubSubNode == 'test'"));
1817-
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode());
1866+
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode());
18181867
}
18191868

18201869
@Test
18211870
void testScalarFromFieldInNestedRepeatableNode_ThrowsErrorFromRepeatableNodeContext() {
18221871
// Field in ND-RepeatableSubSubNode used from ND-RepeatableNode should still throw (ND-RepeatableSubSubNode is also repeatable)
18231872
TypeMismatchException ex = assertThrows(TypeMismatchException.class,
18241873
() -> translateExpressionWithContext("ND-RepeatableNode", "BT-00-Text-In-RepeatableSubSubNode == 'test'"));
1825-
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode());
1874+
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode());
18261875
}
18271876

18281877
@Test
@@ -1837,7 +1886,7 @@ void testScalarFromFieldInNonRepeatableNestedInRepeatable_ThrowsErrorFromRootCon
18371886
// Field in ND-NonRepeatableSubNode (non-repeatable) inside ND-RepeatableNode (repeatable) used from root should throw
18381887
TypeMismatchException ex = assertThrows(TypeMismatchException.class,
18391888
() -> translateExpressionWithContext("ND-Root", "BT-00-Text-In-NonRepeatableSubNode == 'test'"));
1840-
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode());
1889+
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode());
18411890
}
18421891

18431892
@Test
@@ -1847,6 +1896,14 @@ void testScalarFromFieldInNonRepeatableNestedInRepeatable_OkFromRepeatableNodeCo
18471896
"ND-RepeatableNode", "BT-00-Text-In-NonRepeatableSubNode == 'test'");
18481897
}
18491898

1899+
@Test
1900+
void testRepeatableFieldInUniqueCondition_ThrowsError() {
1901+
// A repeatable field used as needle (left side) in uniqueness condition should throw
1902+
TypeMismatchException ex = assertThrows(TypeMismatchException.class,
1903+
() -> translateExpressionWithContext("ND-Root", "BT-00-Repeatable-Text is unique in /BT-00-Text"));
1904+
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode());
1905+
}
1906+
18501907
// #endregion: Scalar/Sequence Validation
18511908

18521909
// #region: InvalidIdentifierException Tests --------------------------------
@@ -1906,7 +1963,7 @@ void testScalarFromFieldContextVariable_Repeatable_ThrowsFieldMayRepeat() {
19061963
// A repeatable field context variable used as scalar should throw
19071964
TypeMismatchException ex = assertThrows(TypeMismatchException.class,
19081965
() -> translateExpressionWithContext("ND-Root", "for context:$f in BT-00-Repeatable-Text return $f == 'test'"));
1909-
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode());
1966+
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode());
19101967
}
19111968

19121969
// #endregion: TypeMismatchException - fieldMayRepeat (Context Variables)
@@ -1963,7 +2020,7 @@ void testPredicateComparison_RepeatableFieldAsScalar_ThrowsError() {
19632020
// Pattern: FIELD[REPEATABLE_FIELD == $var] - the repeatable field is used as scalar
19642021
TypeMismatchException ex = assertThrows(TypeMismatchException.class,
19652022
() -> translateExpressionWithContext("ND-Root", "BT-00-Text[BT-00-Repeatable-Text == 'test']"));
1966-
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SEQUENCE, ex.getErrorCode());
2023+
assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode());
19672024
}
19682025

19692026
@Test

0 commit comments

Comments
 (0)