diff --git a/src/main/java/app/quickcase/sdk/spring/condition/ConditionParser.java b/src/main/java/app/quickcase/sdk/spring/condition/ConditionParser.java index 40a78e9..0cdbee6 100644 --- a/src/main/java/app/quickcase/sdk/spring/condition/ConditionParser.java +++ b/src/main/java/app/quickcase/sdk/spring/condition/ConditionParser.java @@ -9,7 +9,7 @@ public class ConditionParser { private final TokensParser parser = new TokensParser(); private final ConditionNormaliser normaliser = new ConditionNormaliser(); - public Condition parse(String conditionString) { + public Condition parse(String conditionString) throws ConditionSyntaxException { return new Condition(normaliser.normalise(parser.parse(extractor.extract(conditionString)))); } } diff --git a/src/main/java/app/quickcase/sdk/spring/condition/ConditionSyntaxException.java b/src/main/java/app/quickcase/sdk/spring/condition/ConditionSyntaxException.java new file mode 100644 index 0000000..09c79cc --- /dev/null +++ b/src/main/java/app/quickcase/sdk/spring/condition/ConditionSyntaxException.java @@ -0,0 +1,7 @@ +package app.quickcase.sdk.spring.condition; + +public class ConditionSyntaxException extends Exception { + public ConditionSyntaxException(String message) { + super(message); + } +} diff --git a/src/main/java/app/quickcase/sdk/spring/condition/tokens/extract/TokenCharacter.java b/src/main/java/app/quickcase/sdk/spring/condition/tokens/extract/TokenCharacter.java index 8de23f7..8f23d24 100644 --- a/src/main/java/app/quickcase/sdk/spring/condition/tokens/extract/TokenCharacter.java +++ b/src/main/java/app/quickcase/sdk/spring/condition/tokens/extract/TokenCharacter.java @@ -16,8 +16,13 @@ public enum TokenCharacter { DOUBLE_QUOTE('"'), PARENTHESIS_OPEN('('), PARENTHESIS_CLOSE(')'), + COLON(':'), DOT('.'), EQUAL('='), + GREATER_THAN('>'), + LESS_THAN('<'), + SQUARE_BRACKET_OPEN('['), + SQUARE_BRACKET_CLOSE(']'), UNDERSCORE('_'); // ASCII code @@ -29,6 +34,8 @@ public enum TokenCharacter { public static final TokenCharacter[] OPERATOR_SYMBOLS = new TokenCharacter[]{ EQUAL, + GREATER_THAN, + LESS_THAN, }; public static final TokenCharacter[] GROUP_DELIMITERS = new TokenCharacter[]{ @@ -49,8 +56,8 @@ public static Boolean isText(int code) { // a-z return true; } - if (code == DOT.code || code == UNDERSCORE.code) { - // ._ + if (code == COLON.code || code == DOT.code || code == UNDERSCORE.code || code == SQUARE_BRACKET_OPEN.code || code == SQUARE_BRACKET_CLOSE.code) { + // :._[] return true; } diff --git a/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/TokensParser.java b/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/TokensParser.java index 2ad4929..e7d0d39 100644 --- a/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/TokensParser.java +++ b/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/TokensParser.java @@ -6,7 +6,7 @@ import app.quickcase.sdk.spring.condition.ConditionNode; import app.quickcase.sdk.spring.condition.tokens.Token; -import app.quickcase.sdk.spring.condition.tokens.parse.error.SyntaxException; +import app.quickcase.sdk.spring.condition.ConditionSyntaxException; import app.quickcase.sdk.spring.condition.tokens.parse.state.ParsingState; /** @@ -18,7 +18,7 @@ */ public class TokensParser { - public ConditionNode[] parse(Token[] tokens) { + public ConditionNode[] parse(Token[] tokens) throws ConditionSyntaxException { ParsingState state = ParsingState.START; ParsingContext context = new ParsingContext(); @@ -28,7 +28,7 @@ public ConditionNode[] parse(Token[] tokens) { .filter((posState) -> posState.accept(token)) .findFirst(); if (nextState.isEmpty()) { - throw new SyntaxException(String.format( + throw new ConditionSyntaxException(String.format( "Unexpected token %s, expected one of: %s", token, formatStates(nextPossibleStates) @@ -42,7 +42,7 @@ public ConditionNode[] parse(Token[] tokens) { // Validate final state final ParsingState[] endStates = state.nextStates(context); if (!Arrays.asList(endStates).contains(ParsingState.END)) { - throw new SyntaxException("Unexpected end of condition, expected one of: " + formatStates(endStates)); + throw new ConditionSyntaxException("Unexpected end of condition, expected one of: " + formatStates(endStates)); } return context.rootNodes(); diff --git a/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/error/SyntaxException.java b/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/error/SyntaxException.java deleted file mode 100644 index 95d7cb4..0000000 --- a/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/error/SyntaxException.java +++ /dev/null @@ -1,7 +0,0 @@ -package app.quickcase.sdk.spring.condition.tokens.parse.error; - -public class SyntaxException extends RuntimeException { - public SyntaxException(String message) { - super(message); - } -} diff --git a/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/FieldPathStateHandler.java b/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/FieldPathStateHandler.java index a5b779c..61d577f 100644 --- a/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/FieldPathStateHandler.java +++ b/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/FieldPathStateHandler.java @@ -5,13 +5,12 @@ import app.quickcase.sdk.spring.condition.tokens.TextToken; import app.quickcase.sdk.spring.condition.tokens.Token; import app.quickcase.sdk.spring.condition.tokens.parse.ParsingContext; +import app.quickcase.sdk.spring.path.FieldPath; class FieldPathStateHandler implements ParsingStateHandler { - private static final Pattern REGEX = Pattern.compile("^[a-zA-Z0-9._]+$"); - @Override public Boolean accept(Token token) { - return token instanceof TextToken && REGEX.matcher(token.value()).matches(); + return token instanceof TextToken && FieldPath.accepts(token.value()); } @Override diff --git a/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/OperatorStateHandler.java b/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/OperatorStateHandler.java index 7cb466b..0691f95 100644 --- a/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/OperatorStateHandler.java +++ b/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/OperatorStateHandler.java @@ -8,10 +8,16 @@ import app.quickcase.sdk.spring.condition.tokens.parse.ParsingContext; class OperatorStateHandler implements ParsingStateHandler { + private final String targetToken; private final String[] acceptTokens; private final ParsingState[] nextStates; public OperatorStateHandler(String[] acceptTokens, ParsingState[] nextStates) { + this(null, acceptTokens, nextStates); + } + + public OperatorStateHandler(String targetToken, String[] acceptTokens, ParsingState[] nextStates) { + this.targetToken = targetToken; this.acceptTokens = acceptTokens; this.nextStates = nextStates; } @@ -29,6 +35,6 @@ public ParsingState[] nextStates(ParsingContext context) { @Override public void apply(ParsingContext context, Token token) { - context.getCriteriaBuilder().operator(token.value()); + context.getCriteriaBuilder().operator(targetToken != null ? targetToken : token.value()); } } diff --git a/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/ParsingState.java b/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/ParsingState.java index 960daaa..cb4d7ff 100644 --- a/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/ParsingState.java +++ b/src/main/java/app/quickcase/sdk/spring/condition/tokens/parse/state/ParsingState.java @@ -28,10 +28,30 @@ public enum ParsingState { new String[] {"ENDS_WITH_IC"}, new ParsingState[]{VALUE_STRING} )), + COMP_GREATER_THAN(new OperatorStateHandler( + "GREATER_THAN", + new String[] {"GREATER_THAN", ">"}, + new ParsingState[]{VALUE_NUMBER} + )), + COMP_GREATER_OR_EQUALS(new OperatorStateHandler( + "GREATER_OR_EQUALS", + new String[] {"GREATER_OR_EQUALS", ">="}, + new ParsingState[]{VALUE_NUMBER} + )), COMP_HAS_LENGTH(new OperatorStateHandler( new String[]{"HAS_LENGTH"}, new ParsingState[]{VALUE_NUMBER} )), + COMP_LESS_THAN(new OperatorStateHandler( + "LESS_THAN", + new String[] {"LESS_THAN", "<"}, + new ParsingState[]{VALUE_NUMBER} + )), + COMP_LESS_OR_EQUALS(new OperatorStateHandler( + "LESS_OR_EQUALS", + new String[] {"LESS_OR_EQUALS", "<="}, + new ParsingState[]{VALUE_NUMBER} + )), COMP_MATCHES(new OperatorStateHandler( new String[]{"MATCHES"}, new ParsingState[]{VALUE_STRING} @@ -53,7 +73,11 @@ public enum ParsingState { COMP_CONTAINS, COMP_EQUALS, COMP_ENDS_WITH, + COMP_GREATER_THAN, + COMP_GREATER_OR_EQUALS, COMP_HAS_LENGTH, + COMP_LESS_THAN, + COMP_LESS_OR_EQUALS, COMP_MATCHES, COMP_STARTS_WITH, }; diff --git a/src/main/java/app/quickcase/sdk/spring/metadata/Metadata.java b/src/main/java/app/quickcase/sdk/spring/metadata/Metadata.java index a5670b1..8bac301 100644 --- a/src/main/java/app/quickcase/sdk/spring/metadata/Metadata.java +++ b/src/main/java/app/quickcase/sdk/spring/metadata/Metadata.java @@ -29,20 +29,14 @@ public static Metadata fromPath(@NonNull String path) { var name = path.substring(1, path.length() - 1).toLowerCase(); return switch (name) { - case "workspace", "organisation", "jurisdiction" -> - WORKSPACE; - case "type", "case_type" -> - TYPE; - case "id", "reference", "case_reference" -> - ID; + case "workspace", "organisation", "jurisdiction" -> WORKSPACE; + case "type", "case_type" -> TYPE; + case "id", "reference", "case_reference" -> ID; case "title" -> TITLE; case "state" -> STATE; - case "classification", "security_classification" -> - CLASSIFICATION; - case "createdat", "created", "created_date" -> - CREATED_AT; - case "lastmodifiedat", "modified", "last_modified", "last_modified_date" -> - LAST_MODIFIED_AT; + case "classification", "security_classification" -> CLASSIFICATION; + case "createdat", "created", "created_date" -> CREATED_AT; + case "lastmodifiedat", "modified", "last_modified", "last_modified_date" -> LAST_MODIFIED_AT; default -> throw new IllegalArgumentException("Invalid metadata path: " + path); }; } diff --git a/src/main/java/app/quickcase/sdk/spring/path/ComputedFieldPath.java b/src/main/java/app/quickcase/sdk/spring/path/ComputedFieldPath.java new file mode 100644 index 0000000..d30c17e --- /dev/null +++ b/src/main/java/app/quickcase/sdk/spring/path/ComputedFieldPath.java @@ -0,0 +1,25 @@ +package app.quickcase.sdk.spring.path; + +import java.util.regex.Pattern; + +import lombok.NonNull; + +public class ComputedFieldPath extends FieldPath { + public static final Pattern PATTERN = Pattern.compile("^:[a-zA-Z0-9_]+$"); + + protected ComputedFieldPath(@NonNull String path) { + super(path); + + if (!accepts(path)) { + throw new IllegalArgumentException("Invalid computed field path: " + path); + } + } + + public static boolean accepts(String path) { + return PATTERN.matcher(path).matches(); + } + + public String getIdentifier() { + return path.substring(1); + } +} diff --git a/src/main/java/app/quickcase/sdk/spring/path/DataFieldPath.java b/src/main/java/app/quickcase/sdk/spring/path/DataFieldPath.java index 127de5f..d853959 100644 --- a/src/main/java/app/quickcase/sdk/spring/path/DataFieldPath.java +++ b/src/main/java/app/quickcase/sdk/spring/path/DataFieldPath.java @@ -8,11 +8,15 @@ import lombok.NonNull; public final class DataFieldPath extends FieldPath { - public static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9_]+(?:\\[(?:(?:id:[a-zA-Z0-9_]+)|(?:[0-9]+))?])?(?:\\.[a-zA-Z0-9_]+(?:\\[(?:(?:id:[a-zA-Z0-9_]+)|(?:[0-9]+))?])?)*$"); - public static final Pattern COLLECTION_ITEM_PATTERN = Pattern.compile("^(?[a-zA-Z0-9_]+)\\[(?:(?:id:(?[a-zA-Z0-9_]+))|(?[0-9]+))?]$"); + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9_]+(?:\\[(?:(?:id:[a-zA-Z0-9_]+)|(?:[0-9]+))?])?(?:\\.[a-zA-Z0-9_]+(?:\\[(?:(?:id:[a-zA-Z0-9_]+)|(?:[0-9]+))?])?)*$"); + private static final Pattern COLLECTION_ITEM_PATTERN = Pattern.compile("^(?[a-zA-Z0-9_]+)\\[(?:(?:id:(?[a-zA-Z0-9_]+))|(?[0-9]+))?]$"); DataFieldPath(@NonNull String path) { super(path); + + if (!accepts(path)) { + throw new IllegalArgumentException("Invalid data field path: " + path); + } } public List elements() { @@ -21,6 +25,10 @@ public List elements() { .toList(); } + public static boolean accepts(String path) { + return PATTERN.matcher(path).matches(); + } + @Getter public static class Element { private final String identifier; diff --git a/src/main/java/app/quickcase/sdk/spring/path/FieldPath.java b/src/main/java/app/quickcase/sdk/spring/path/FieldPath.java index ca37cb6..98e4005 100644 --- a/src/main/java/app/quickcase/sdk/spring/path/FieldPath.java +++ b/src/main/java/app/quickcase/sdk/spring/path/FieldPath.java @@ -11,11 +11,19 @@ protected FieldPath(@NonNull String path) { this.path = path; } + public static boolean accepts(@NonNull String path) { + return MetadataFieldPath.accepts(path) || ComputedFieldPath.accepts(path) || DataFieldPath.accepts(path); + } + public static FieldPath of(@NonNull String path) { - if (MetadataFieldPath.PATTERN.matcher(path).matches()) { + if (MetadataFieldPath.accepts(path)) { return ofMetadata(path); } + if (ComputedFieldPath.accepts(path)) { + return ofComputed(path); + } + return ofData(path); } @@ -24,13 +32,13 @@ public static MetadataFieldPath ofMetadata(@NonNull String path) { } public static DataFieldPath ofData(@NonNull String path) { - if (!DataFieldPath.PATTERN.matcher(path).matches()) { - throw new IllegalArgumentException("Invalid data field path: " + path); - } - return new DataFieldPath(path); } + public static ComputedFieldPath ofComputed(@NonNull String path) { + return new ComputedFieldPath(path); + } + @Override public String toString() { return path; diff --git a/src/main/java/app/quickcase/sdk/spring/path/MetadataFieldPath.java b/src/main/java/app/quickcase/sdk/spring/path/MetadataFieldPath.java index 75dbcc2..a7dc1ae 100644 --- a/src/main/java/app/quickcase/sdk/spring/path/MetadataFieldPath.java +++ b/src/main/java/app/quickcase/sdk/spring/path/MetadataFieldPath.java @@ -1,6 +1,6 @@ package app.quickcase.sdk.spring.path; -import java.util.regex.Pattern; +import java.util.Arrays; import app.quickcase.sdk.spring.metadata.Metadata; import lombok.Getter; @@ -8,8 +8,6 @@ @Getter public final class MetadataFieldPath extends FieldPath { - public static final Pattern PATTERN = Pattern.compile("^\\[[a-zA-Z_]+]$"); - final private Metadata metadata; MetadataFieldPath(@NonNull String path) { @@ -17,4 +15,7 @@ public final class MetadataFieldPath extends FieldPath { metadata = Metadata.fromPath(path); } + public static boolean accepts(@NonNull String path) { + return Arrays.stream(Metadata.values()).anyMatch(meta -> meta.getPath().equalsIgnoreCase(path)); + } } diff --git a/src/test/java/app/quickcase/sdk/spring/condition/ConditionParserTest.java b/src/test/java/app/quickcase/sdk/spring/condition/ConditionParserTest.java index d70d604..16673e7 100644 --- a/src/test/java/app/quickcase/sdk/spring/condition/ConditionParserTest.java +++ b/src/test/java/app/quickcase/sdk/spring/condition/ConditionParserTest.java @@ -154,6 +154,10 @@ void nLevelGrouping() { private void assertCondition(String conditionString, Condition expected) { final ConditionParser parser = new ConditionParser(); - assertThat(parser.parse(conditionString), equalTo(expected)); + try { + assertThat(parser.parse(conditionString), equalTo(expected)); + } catch (ConditionSyntaxException e) { + throw new RuntimeException(e); + } } } diff --git a/src/test/java/app/quickcase/sdk/spring/condition/tokens/extract/TokensExtractorTest.java b/src/test/java/app/quickcase/sdk/spring/condition/tokens/extract/TokensExtractorTest.java index bc9beee..32bbe1d 100644 --- a/src/test/java/app/quickcase/sdk/spring/condition/tokens/extract/TokensExtractorTest.java +++ b/src/test/java/app/quickcase/sdk/spring/condition/tokens/extract/TokensExtractorTest.java @@ -84,6 +84,40 @@ private static Stream provideHappyTestCases() { text("OR"), text("NOT"), groupDelimiter("("), text("c"), operator("==="), number("3"), groupDelimiter(")") ) + ), + args( + "Support for all field path syntaxes", + "[state] == \"active\" " + + "AND :computedField == 2" + + "AND complexField.member1 == \"test\" " + + "AND collectionField[0].value == \"itemValue\" " + + "AND collectionField[id:item1] == \"itemValue\"", + array( + text("[state]"), operator("=="), quotedString("active"), + text("AND"), text(":computedField"), operator("=="), number("2"), + text("AND"), text("complexField.member1"), operator("=="), quotedString("test"), + text("AND"), text("collectionField[0].value"), operator("=="), quotedString("itemValue"), + text("AND"), text("collectionField[id:item1]"), operator("=="), quotedString("itemValue") + ) + ), + args( + "Support for all operator symbols", + "field1 = 0 " + + "AND field2 == 0 " + + "AND field3 === 0 " + + "AND field4 > 0 " + + "AND field5 >= 0 " + + "AND field6 < 0 " + + "AND field7 <= 0", + array( + text("field1"), operator("="), number("0"), + text("AND"), text("field2"), operator("=="), number("0"), + text("AND"), text("field3"), operator("==="), number("0"), + text("AND"), text("field4"), operator(">"), number("0"), + text("AND"), text("field5"), operator(">="), number("0"), + text("AND"), text("field6"), operator("<"), number("0"), + text("AND"), text("field7"), operator("<="), number("0") + ) ) ); } diff --git a/src/test/java/app/quickcase/sdk/spring/condition/tokens/parse/TokensParserTest.java b/src/test/java/app/quickcase/sdk/spring/condition/tokens/parse/TokensParserTest.java index ad3f4ac..2c51be4 100644 --- a/src/test/java/app/quickcase/sdk/spring/condition/tokens/parse/TokensParserTest.java +++ b/src/test/java/app/quickcase/sdk/spring/condition/tokens/parse/TokensParserTest.java @@ -6,7 +6,7 @@ import app.quickcase.sdk.spring.condition.Criteria; import app.quickcase.sdk.spring.condition.Group; import app.quickcase.sdk.spring.condition.tokens.Token; -import app.quickcase.sdk.spring.condition.tokens.parse.error.SyntaxException; +import app.quickcase.sdk.spring.condition.ConditionSyntaxException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -38,7 +38,7 @@ private static Group negatedGroup(ConditionNode...members) { @Test @DisplayName("should parse simple conjunction condition without grouping") - void shouldParseSimpleConjunctionNoGrouping() { + void shouldParseSimpleConjunctionNoGrouping() throws ConditionSyntaxException { var tokens = condition( text("field1"), operator("==="), quotedString("value1"), text("AND"), @@ -55,7 +55,7 @@ void shouldParseSimpleConjunctionNoGrouping() { @Test @DisplayName("should parse simple condition with redundant grouping") - void shouldParseSimpleConditionWithRedundantGrouping() { + void shouldParseSimpleConditionWithRedundantGrouping() throws ConditionSyntaxException { var tokens = condition( groupDelimiter("("), text("field1"), operator("==="), quotedString("value1"), @@ -72,7 +72,7 @@ void shouldParseSimpleConditionWithRedundantGrouping() { @Test @DisplayName("should parse composed condition with one level of grouping") - void shouldParseComposedConditionWithSingleLevelGrouping() { + void shouldParseComposedConditionWithSingleLevelGrouping() throws ConditionSyntaxException { var tokens = condition( groupDelimiter("("), text("a"), operator("==="), quotedString("1"), text("AND"), text("b"), operator("==="), quotedString("2"), @@ -101,7 +101,7 @@ void shouldParseComposedConditionWithSingleLevelGrouping() { @Test @DisplayName("should parse conditions with nested groups") - void shouldParseConditionWithNestedGrouping() { + void shouldParseConditionWithNestedGrouping() throws ConditionSyntaxException { var tokens = condition( groupDelimiter("("), groupDelimiter("("), text("a"), operator("==="), quotedString("1"), groupDelimiter(")"), @@ -134,7 +134,7 @@ void shouldRejectGroupEndOutsideOfGroup() { var tokens = condition(text("field1"), operator("==="), quotedString("value1"), groupDelimiter(")")); final TokensParser parser = new TokensParser(); - final SyntaxException exception = Assertions.assertThrows(SyntaxException.class, + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, () -> parser.parse(tokens)); assertThat( exception.getMessage(), @@ -148,7 +148,7 @@ void shouldRejectConditionMissingValue() { var tokens = condition(text("field1"), operator("===")); final TokensParser parser = new TokensParser(); - final SyntaxException exception = Assertions.assertThrows(SyntaxException.class, + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, () -> parser.parse(tokens)); assertThat( exception.getMessage(), @@ -162,7 +162,7 @@ void shouldRejectConditionGroupNotClosed() { var tokens = condition(groupDelimiter("("), text("field1"), operator("==="), quotedString("value1")); final TokensParser parser = new TokensParser(); - final SyntaxException exception = Assertions.assertThrows(SyntaxException.class, + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, () -> parser.parse(tokens)); assertThat( exception.getMessage(), @@ -172,7 +172,7 @@ void shouldRejectConditionGroupNotClosed() { @Test @DisplayName("should parse negated condition") - void shouldParseNegatedCondition() { + void shouldParseNegatedCondition() throws ConditionSyntaxException { var tokens = condition( groupDelimiter("("), text("a"), operator("==="), quotedString("1"), text("AND"), text("NOT"), text("b"), operator("==="), quotedString("2"), @@ -203,12 +203,37 @@ void shouldParseNegatedCondition() { })); } + @Test + @DisplayName("should parse all valid field path syntaxes") + void shouldParseAllValidFieldPathSyntaxes() throws ConditionSyntaxException { + var tokens = condition( + text("[state]"), operator("==="), quotedString("active"), + text("AND"), text(":computedField"), operator("==="), number("2"), + text("AND"), text("complexField.member1"), operator("==="), quotedString("test"), + text("AND"), text("collectionField[0].value"), operator("==="), quotedString("itemValue"), + text("AND"), text("collectionField[id:item1]"), operator("==="), quotedString("itemValue") + ); + + final TokensParser parser = new TokensParser(); + assertThat(parser.parse(tokens), equalTo(new ConditionNode[]{ + criteria("[state]", "EQUALS", "active").build(), + AND, + criteria(":computedField", "EQUALS", 2).build(), + AND, + criteria("complexField.member1", "EQUALS", "test").build(), + AND, + criteria("collectionField[0].value", "EQUALS", "itemValue").build(), + AND, + criteria("collectionField[id:item1]", "EQUALS", "itemValue").build(), + })); + } + @Nested @DisplayName("EQUALS") class EqualsOperator { @Test @DisplayName("should parse all forms of EQUALS operator") - void shouldParseAllEqualsOperator() { + void shouldParseAllEqualsOperator() throws ConditionSyntaxException { var tokens = condition( // Case insensitive text("field1"), operator("="), quotedString("a"), text("AND"), @@ -235,7 +260,7 @@ void shouldParseAllEqualsOperator() { @Test @DisplayName("should accept numeric criteria value") - void shouldAcceptNumericCriteriaValue() { + void shouldAcceptNumericCriteriaValue() throws ConditionSyntaxException { var tokens = condition(text("field1"), text("EQUALS"), number("1")); final TokensParser parser = new TokensParser(); @@ -252,7 +277,7 @@ void shouldRejectOtherValues() { ); final TokensParser parser = new TokensParser(); - final SyntaxException exception = Assertions.assertThrows(SyntaxException.class, + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, () -> parser.parse(tokens)); assertThat( exception.getMessage(), @@ -266,7 +291,7 @@ void shouldRejectOtherValues() { class StartsWithOperator { @Test @DisplayName("should parse all forms of STARTS_WITH operator") - void shouldParseAllStartsWithOperator() { + void shouldParseAllStartsWithOperator() throws ConditionSyntaxException { var tokens = condition( // Case insensitive text("field1"), text("STARTS_WITH_IC"), quotedString("a"), text("AND"), @@ -290,7 +315,7 @@ void shouldRejectNumericValues() { ); final TokensParser parser = new TokensParser(); - final SyntaxException exception = Assertions.assertThrows(SyntaxException.class, + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, () -> parser.parse(tokens)); assertThat( exception.getMessage(), @@ -304,7 +329,7 @@ void shouldRejectNumericValues() { class EndsWithOperator { @Test @DisplayName("should parse all forms of ENDS_WITH operator") - void shouldParseAllEndsWithOperator() { + void shouldParseAllEndsWithOperator() throws ConditionSyntaxException { var tokens = condition( // Case insensitive text("field1"), text("ENDS_WITH_IC"), quotedString("a"), text("AND"), @@ -328,7 +353,7 @@ void shouldRejectNumericValues() { ); final TokensParser parser = new TokensParser(); - final SyntaxException exception = Assertions.assertThrows(SyntaxException.class, + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, () -> parser.parse(tokens)); assertThat( exception.getMessage(), @@ -342,7 +367,7 @@ void shouldRejectNumericValues() { class ContainsOperator { @Test @DisplayName("should parse all forms of CONTAINS operator") - void shouldParseAllContainsOperator() { + void shouldParseAllContainsOperator() throws ConditionSyntaxException { var tokens = condition( // Case insensitive text("field1"), text("CONTAINS_IC"), quotedString("a"), text("AND"), @@ -360,7 +385,7 @@ void shouldParseAllContainsOperator() { @Test @DisplayName("should accept numeric criteria value") - void shouldAcceptNumericCriteriaValue() { + void shouldAcceptNumericCriteriaValue() throws ConditionSyntaxException { var tokens = condition(text("field1"), text("CONTAINS"), number("1")); final TokensParser parser = new TokensParser(); @@ -375,7 +400,7 @@ void shouldAcceptNumericCriteriaValue() { class MatchesOperator { @Test @DisplayName("should parse MATCHES operator") - void shouldParseMatchesOperator() { + void shouldParseMatchesOperator() throws ConditionSyntaxException { var tokens = condition( text("field1"), text("MATCHES"), quotedString("^[a-z]{3}$") ); @@ -394,7 +419,7 @@ void shouldRejectNumericValues() { ); final TokensParser parser = new TokensParser(); - final SyntaxException exception = Assertions.assertThrows(SyntaxException.class, + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, () -> parser.parse(tokens)); assertThat( exception.getMessage(), @@ -408,7 +433,7 @@ void shouldRejectNumericValues() { class HasLengthOperator { @Test @DisplayName("should parse HAS_LENGTH operator") - void shouldParseAllContainsOperator() { + void shouldParseAllContainsOperator() throws ConditionSyntaxException { var tokens = condition(text("field1"), text("HAS_LENGTH"), number("3")); final TokensParser parser = new TokensParser(); @@ -423,7 +448,7 @@ void shouldRejectQuotedStringValues() { var tokens = condition(text("field1"), text("HAS_LENGTH"), quotedString("abc")); final TokensParser parser = new TokensParser(); - final SyntaxException exception = Assertions.assertThrows(SyntaxException.class, + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, () -> parser.parse(tokens)); assertThat( exception.getMessage(), @@ -431,4 +456,212 @@ void shouldRejectQuotedStringValues() { ); } } + + @Nested + @DisplayName("GREATER_THAN") + class GreaterThanOperator { + @Test + @DisplayName("should parse all forms of GREATER_THAN operator") + void shouldParseAllEqualsOperator() throws ConditionSyntaxException { + var tokens = condition( + text("field1"), operator(">"), number("2"), text("AND"), + text("field2"), text("GREATER_THAN"), number("2") + ); + + final TokensParser parser = new TokensParser(); + assertThat(parser.parse(tokens), equalTo(new ConditionNode[]{ + criteria("field1", "GREATER_THAN", 2).build(), + AND, + criteria("field2", "GREATER_THAN", 2).build(), + })); + } + + @Test + @DisplayName("should reject text values") + void shouldRejectTextValues() { + var tokens = condition( + text("field1"), operator(">"), text("abc") // Non-quoted string + ); + + final TokensParser parser = new TokensParser(); + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, + () -> parser.parse(tokens)); + assertThat( + exception.getMessage(), + equalTo("Unexpected token TextToken[value=abc], expected one of: VALUE_NUMBER") + ); + } + + @Test + @DisplayName("should reject quoted string values") + void shouldRejectQuotedStringValues() { + var tokens = condition( + text("field2"), operator(">"), quotedString("abc") // Quoted string + ); + + final TokensParser parser = new TokensParser(); + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, + () -> parser.parse(tokens)); + assertThat( + exception.getMessage(), + equalTo("Unexpected token QuotedStringToken[value=abc], expected one of: VALUE_NUMBER") + ); + } + } + + @Nested + @DisplayName("GREATER_OR_EQUALS") + class GreaterOrEqualsOperator { + @Test + @DisplayName("should parse all forms of GREATER_OR_EQUALS operator") + void shouldParseAllEqualsOperator() throws ConditionSyntaxException { + var tokens = condition( + text("field1"), operator(">="), number("2"), text("AND"), + text("field2"), text("GREATER_OR_EQUALS"), number("2") + ); + + final TokensParser parser = new TokensParser(); + assertThat(parser.parse(tokens), equalTo(new ConditionNode[]{ + criteria("field1", "GREATER_OR_EQUALS", 2).build(), + AND, + criteria("field2", "GREATER_OR_EQUALS", 2).build(), + })); + } + + @Test + @DisplayName("should reject text values") + void shouldRejectTextValues() { + var tokens = condition( + text("field1"), operator(">="), text("abc") // Non-quoted string + ); + + final TokensParser parser = new TokensParser(); + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, + () -> parser.parse(tokens)); + assertThat( + exception.getMessage(), + equalTo("Unexpected token TextToken[value=abc], expected one of: VALUE_NUMBER") + ); + } + + @Test + @DisplayName("should reject quoted string values") + void shouldRejectQuotedStringValues() { + var tokens = condition( + text("field2"), operator(">="), quotedString("abc") // Quoted string + ); + + final TokensParser parser = new TokensParser(); + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, + () -> parser.parse(tokens)); + assertThat( + exception.getMessage(), + equalTo("Unexpected token QuotedStringToken[value=abc], expected one of: VALUE_NUMBER") + ); + } + } + + @Nested + @DisplayName("LESS_THAN") + class LessThanOperator { + @Test + @DisplayName("should parse all forms of LESS_THAN operator") + void shouldParseAllEqualsOperator() throws ConditionSyntaxException { + var tokens = condition( + text("field1"), operator("<"), number("2"), text("AND"), + text("field2"), text("LESS_THAN"), number("2") + ); + + final TokensParser parser = new TokensParser(); + assertThat(parser.parse(tokens), equalTo(new ConditionNode[]{ + criteria("field1", "LESS_THAN", 2).build(), + AND, + criteria("field2", "LESS_THAN", 2).build(), + })); + } + + @Test + @DisplayName("should reject text values") + void shouldRejectTextValues() { + var tokens = condition( + text("field1"), operator("<"), text("abc") // Non-quoted string + ); + + final TokensParser parser = new TokensParser(); + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, + () -> parser.parse(tokens)); + assertThat( + exception.getMessage(), + equalTo("Unexpected token TextToken[value=abc], expected one of: VALUE_NUMBER") + ); + } + + @Test + @DisplayName("should reject quoted string values") + void shouldRejectQuotedStringValues() { + var tokens = condition( + text("field2"), operator("<"), quotedString("abc") // Quoted string + ); + + final TokensParser parser = new TokensParser(); + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, + () -> parser.parse(tokens)); + assertThat( + exception.getMessage(), + equalTo("Unexpected token QuotedStringToken[value=abc], expected one of: VALUE_NUMBER") + ); + } + } + + @Nested + @DisplayName("LESS_OR_EQUALS") + class LessOrEqualsOperator { + @Test + @DisplayName("should parse all forms of LESS_OR_EQUALS operator") + void shouldParseAllEqualsOperator() throws ConditionSyntaxException { + var tokens = condition( + text("field1"), operator("<="), number("2"), text("AND"), + text("field2"), text("LESS_OR_EQUALS"), number("2") + ); + + final TokensParser parser = new TokensParser(); + assertThat(parser.parse(tokens), equalTo(new ConditionNode[]{ + criteria("field1", "LESS_OR_EQUALS", 2).build(), + AND, + criteria("field2", "LESS_OR_EQUALS", 2).build(), + })); + } + + @Test + @DisplayName("should reject text values") + void shouldRejectTextValues() { + var tokens = condition( + text("field1"), operator("<="), text("abc") // Non-quoted string + ); + + final TokensParser parser = new TokensParser(); + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, + () -> parser.parse(tokens)); + assertThat( + exception.getMessage(), + equalTo("Unexpected token TextToken[value=abc], expected one of: VALUE_NUMBER") + ); + } + + @Test + @DisplayName("should reject quoted string values") + void shouldRejectQuotedStringValues() { + var tokens = condition( + text("field2"), operator("<="), quotedString("abc") // Quoted string + ); + + final TokensParser parser = new TokensParser(); + final ConditionSyntaxException exception = Assertions.assertThrows(ConditionSyntaxException.class, + () -> parser.parse(tokens)); + assertThat( + exception.getMessage(), + equalTo("Unexpected token QuotedStringToken[value=abc], expected one of: VALUE_NUMBER") + ); + } + } } diff --git a/src/test/java/app/quickcase/sdk/spring/path/FieldPathTest.java b/src/test/java/app/quickcase/sdk/spring/path/FieldPathTest.java index b4ce3ed..9f654ad 100644 --- a/src/test/java/app/quickcase/sdk/spring/path/FieldPathTest.java +++ b/src/test/java/app/quickcase/sdk/spring/path/FieldPathTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; @@ -12,6 +14,55 @@ class FieldPathTest { + @Nested + class Accepts { + @ParameterizedTest + @ValueSource(strings = { + // metadata + "[type]", + "[state]", + // data field + "field1", + "another_field", + "complex1.member1", + "collection1[].value.member1", + "collection1[0].value.member1", + "collection1[id:item_1].value.member1", + // computed field + ":backlinksCount", + ":another_computed_field", + }) + @DisplayName("should accept valid field path") + void shouldAcceptValidFieldPath(String path) { + assertThat(FieldPath.accepts(path), is(true)); + } + + @ParameterizedTest + @ValueSource(strings = { + // metadata + "[invalidMeta]", + "[state", + "state]", + // data field + "fie:ld1", + "another field", + ".field2", + "complex1..member1", + "collection1[abc].value.member1", + "[0]collection1.value.member1", + "collection1[id:item_1]value.member1", + // computed field + ":", + ":computed:", + ":com:puted", + ":computed[0]", + }) + @DisplayName("should reject invalid field path") + void shouldRejectInvalidFieldPath(String path) { + assertThat(FieldPath.accepts(path), is(false)); + } + } + @Nested class OfMetadata { @Test @@ -49,6 +100,25 @@ void shouldReturnDataFieldPath() { } } + @Nested + class OfComputed { + @Test + @DisplayName("should throw error when path is not for computed field") + void shouldThrowErrorWhenNotMetadata() { + var error = assertThrows(IllegalArgumentException.class, () -> FieldPath.ofComputed("field1")); + assertThat(error.getMessage(), equalTo("Invalid computed field path: field1")); + } + + @Test + @DisplayName("should return computed field path") + void shouldReturnComputedFieldPath() { + var path = FieldPath.ofComputed(":linkedRecordsCount"); + + assertThat(path.toString(), equalTo(":linkedRecordsCount")); + assertThat(path.getIdentifier(), equalTo("linkedRecordsCount")); + } + } + @Nested class Of { @Test @@ -62,6 +132,17 @@ void shouldReturnMetadataFieldPath() { ); } + @Test + @DisplayName("should return computed field path") + void shouldReturnComputedFieldPath() { + var path = FieldPath.of(":linkedRecordsCount"); + + assertAll( + () -> assertThat(path.toString(), equalTo(":linkedRecordsCount")), + () -> assertThat(path, is(instanceOf(ComputedFieldPath.class))) + ); + } + @Test @DisplayName("should return data field path") void shouldReturnDataFieldPath() {