diff --git a/README.md b/README.md index cc50f14..ca03327 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ When modifying the grammar file you will need to re-generate the ANTLR sources b `mvn clean generate-sources compile` +## CDT ranges + +Map and list path segments support closed-boundaries and half-open ranges. +Semantics follow Aerospike CDT conventions (inclusive lower / exclusive upper for keys and values where applicable). +See [Guide: Working with Lists and Maps — CDT ranges](docs/guides/02-working-with-lists-and-maps.md#cdt-ranges). + ## Usage examples ### Parsing AEL expression diff --git a/docs/api-reference.md b/docs/api-reference.md index 77d3658..bd145f9 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -78,6 +78,16 @@ Represents an available secondary index for optimization. * Must be non-negative (`>= 0`). * Providing a realistic, non-negative cardinality value is recommended for better automatic index selection (indexes with higher `binValuesRatio` are preferred). +## CDT path segments and ranges + +Expressions may use **closed-boundaries** and **half-open CDT ranges** on maps and lists. Details in the +[Guide: Working with Lists and Maps — CDT ranges](guides/02-working-with-lists-and-maps.md#cdt-ranges). + +The **`parseCTX`** API accepts the same surface syntax as paths embedded in expressions, +but **only simple selectors** produce `CTX[]` (single key, index, value, rank, etc.). +**Range segments** (index/value/key intervals) are not converted to context entries and will fail with an error +indicating that context is not supported for that part type. + ## Path Functions Path functions are suffixed to a bin or CDT path with dot notation. diff --git a/docs/guides/02-working-with-lists-and-maps.md b/docs/guides/02-working-with-lists-and-maps.md index 74d88bb..0f1578b 100644 --- a/docs/guides/02-working-with-lists-and-maps.md +++ b/docs/guides/02-working-with-lists-and-maps.md @@ -83,6 +83,16 @@ Or use the full form of such AEL string if needed: "$.user.{0}.get(type: STRING, return: VALUE) == 'Alice'" ``` +#### CDT ranges + +Beyond a single index, you can express **intervals** on CDT keys, values, indexes, and ranks using closed-boundaries +or half-open ranges: + +- **Map key range** — `{a-c}` selects keys from `a` up to but not including `c`; `{a-}` is open on the upper side; `{-c}` is open on the lower side (all keys strictly below `c`). +- **Map index / rank range** — `{s:e}` selects index/rank span `[s, e)`; `{s:}` is open on the upper side; `{:e}` is open on the lower side (same span as `{0:e}`). +- **Map value range** — `{=v:w}`, `{=v:}`, `{=:w}` follow inclusive/exclusive value endpoints per `getByValueRange`. +- **Lists** — the same shapes use square brackets (`[…]`) instead of braces. + ### Filtering by Map Value **AEL String:** diff --git a/src/main/antlr4/com/aerospike/ael/Condition.g4 b/src/main/antlr4/com/aerospike/ael/Condition.g4 index 19daa15..8e7d335 100644 --- a/src/main/antlr4/com/aerospike/ael/Condition.g4 +++ b/src/main/antlr4/com/aerospike/ael/Condition.g4 @@ -274,8 +274,8 @@ mapPart | mapKey | mapValue | mapRank - | mapIndex | mapKeyRange + | mapIndex | mapKeyList | mapIndexRange | mapValueList @@ -319,6 +319,7 @@ invertedMapKeyRange keyRangeIdentifier : mapKey '-' mapKey | mapKey '-' + | '-' mapKey ; mapKeyList @@ -354,6 +355,7 @@ invertedMapIndexRange indexRangeIdentifier : start ':' end | start ':' + | ':' end ; signedInt: '-'? INT; @@ -390,6 +392,7 @@ invertedMapValueRange valueRangeIdentifier : valueIdentifier ':' valueIdentifier | valueIdentifier ':' + | ':' valueIdentifier ; mapRankRange @@ -408,6 +411,7 @@ invertedMapRankRange rankRangeIdentifier : start ':' end | start ':' + | ':' end ; mapRankRangeRelative @@ -425,6 +429,7 @@ invertedMapRankRangeRelative rankRangeRelativeIdentifier : start ':' relativeRankEnd + | ':' relativeRankEnd ; relativeRankEnd @@ -451,6 +456,7 @@ invertedMapIndexRangeRelative indexRangeRelativeIdentifier : start ':' relativeKeyEnd + | ':' relativeKeyEnd ; relativeKeyEnd diff --git a/src/main/java/com/aerospike/ael/parts/cdt/list/ListIndexRange.java b/src/main/java/com/aerospike/ael/parts/cdt/list/ListIndexRange.java index 3de9d5e..fafe530 100644 --- a/src/main/java/com/aerospike/ael/parts/cdt/list/ListIndexRange.java +++ b/src/main/java/com/aerospike/ael/parts/cdt/list/ListIndexRange.java @@ -8,8 +8,8 @@ import com.aerospike.ael.client.exp.ListExp; import com.aerospike.ael.parts.path.BasePath; +import static com.aerospike.ael.util.ParsingUtils.halfOpenRangeCount; import static com.aerospike.ael.util.ParsingUtils.parseSignedInt; -import static com.aerospike.ael.util.ParsingUtils.subtractNullable; public class ListIndexRange extends ListPart { private final boolean isInverted; @@ -20,7 +20,7 @@ public ListIndexRange(boolean isInverted, Integer start, Integer end) { super(ListPartType.INDEX_RANGE); this.isInverted = isInverted; this.start = start; - this.count = subtractNullable(end, start); + this.count = halfOpenRangeCount(start, end); } public static ListIndexRange from(ConditionParser.ListIndexRangeContext ctx) { @@ -32,11 +32,8 @@ public static ListIndexRange from(ConditionParser.ListIndexRangeContext ctx) { indexRange != null ? indexRange.indexRangeIdentifier() : invertedIndexRange.indexRangeIdentifier(); boolean isInverted = indexRange == null; - Integer start = parseSignedInt(range.start().signedInt()); - Integer end = null; - if (range.end() != null) { - end = parseSignedInt(range.end().signedInt()); - } + Integer start = range.start() != null ? parseSignedInt(range.start().signedInt()) : null; + Integer end = range.end() != null ? parseSignedInt(range.end().signedInt()) : null; return new ListIndexRange(isInverted, start, end); } @@ -49,7 +46,8 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType cdtReturnType = cdtReturnType | ListReturnType.INVERTED; } - Exp startExp = Exp.val(start); + int startIndex = start != null ? start : 0; + Exp startExp = Exp.val(startIndex); if (count == null) { return ListExp.getByIndexRange(cdtReturnType, startExp, Exp.bin(basePath.getBinPart().getBinName(), basePath.getBinType()), context); diff --git a/src/main/java/com/aerospike/ael/parts/cdt/list/ListRankRange.java b/src/main/java/com/aerospike/ael/parts/cdt/list/ListRankRange.java index 56d7d98..e193fa9 100644 --- a/src/main/java/com/aerospike/ael/parts/cdt/list/ListRankRange.java +++ b/src/main/java/com/aerospike/ael/parts/cdt/list/ListRankRange.java @@ -8,8 +8,8 @@ import com.aerospike.ael.client.exp.ListExp; import com.aerospike.ael.parts.path.BasePath; +import static com.aerospike.ael.util.ParsingUtils.halfOpenRangeCount; import static com.aerospike.ael.util.ParsingUtils.parseSignedInt; -import static com.aerospike.ael.util.ParsingUtils.subtractNullable; public class ListRankRange extends ListPart { private final boolean isInverted; @@ -20,7 +20,7 @@ public ListRankRange(boolean isInverted, Integer start, Integer end) { super(ListPartType.RANK_RANGE); this.isInverted = isInverted; this.start = start; - this.count = subtractNullable(end, start); + this.count = halfOpenRangeCount(start, end); } public static ListRankRange from(ConditionParser.ListRankRangeContext ctx) { @@ -32,11 +32,8 @@ public static ListRankRange from(ConditionParser.ListRankRangeContext ctx) { rankRange != null ? rankRange.rankRangeIdentifier() : invertedRankRange.rankRangeIdentifier(); boolean isInverted = rankRange == null; - Integer start = parseSignedInt(range.start().signedInt()); - Integer end = null; - if (range.end() != null) { - end = parseSignedInt(range.end().signedInt()); - } + Integer start = range.start() != null ? parseSignedInt(range.start().signedInt()) : null; + Integer end = range.end() != null ? parseSignedInt(range.end().signedInt()) : null; return new ListRankRange(isInverted, start, end); } @@ -49,7 +46,8 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType cdtReturnType = cdtReturnType | ListReturnType.INVERTED; } - Exp startExp = Exp.val(start); + int startRank = start != null ? start : 0; + Exp startExp = Exp.val(startRank); if (count == null) { return ListExp.getByRankRange(cdtReturnType, startExp, Exp.bin(basePath.getBinPart().getBinName(), basePath.getBinType()), context); diff --git a/src/main/java/com/aerospike/ael/parts/cdt/list/ListRankRangeRelative.java b/src/main/java/com/aerospike/ael/parts/cdt/list/ListRankRangeRelative.java index e850137..d98f2c5 100644 --- a/src/main/java/com/aerospike/ael/parts/cdt/list/ListRankRangeRelative.java +++ b/src/main/java/com/aerospike/ael/parts/cdt/list/ListRankRangeRelative.java @@ -9,9 +9,9 @@ import com.aerospike.ael.parts.path.BasePath; import com.aerospike.ael.util.ParsingUtils; +import static com.aerospike.ael.util.ParsingUtils.halfOpenRangeCount; import static com.aerospike.ael.util.ParsingUtils.objectToExp; import static com.aerospike.ael.util.ParsingUtils.parseSignedInt; -import static com.aerospike.ael.util.ParsingUtils.subtractNullable; public class ListRankRangeRelative extends ListPart { private final boolean isInverted; @@ -23,7 +23,7 @@ public ListRankRangeRelative(boolean isInverted, Integer start, Integer end, Obj super(ListPartType.RANK_RANGE_RELATIVE); this.isInverted = isInverted; this.start = start; - this.count = subtractNullable(end, start); + this.count = halfOpenRangeCount(start, end); this.relative = relative; } @@ -37,7 +37,7 @@ public static ListRankRangeRelative from(ConditionParser.ListRankRangeRelativeCo : invertedRankRangeRelative.rankRangeRelativeIdentifier(); boolean isInverted = rankRangeRelative == null; - Integer start = parseSignedInt(range.start().signedInt()); + Integer start = range.start() != null ? parseSignedInt(range.start().signedInt()) : null; Integer end = null; if (range.relativeRankEnd().end() != null) { end = parseSignedInt(range.relativeRankEnd().end().signedInt()); @@ -62,14 +62,14 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType Exp relativeExp = objectToExp(relative); - Exp startExp = Exp.val(start); + Exp valueExp = Exp.val(start != null ? start : 0); if (count == null) { - return ListExp.getByValueRelativeRankRange(cdtReturnType, startExp, relativeExp, + return ListExp.getByValueRelativeRankRange(cdtReturnType, valueExp, relativeExp, Exp.bin(basePath.getBinPart().getBinName(), basePath.getBinType()), context); } - return ListExp.getByValueRelativeRankRange(cdtReturnType, startExp, relativeExp, Exp.val(count), + return ListExp.getByValueRelativeRankRange(cdtReturnType, valueExp, relativeExp, Exp.val(count), Exp.bin(basePath.getBinPart().getBinName(), basePath.getBinType()), context); } diff --git a/src/main/java/com/aerospike/ael/parts/cdt/list/ListValueRange.java b/src/main/java/com/aerospike/ael/parts/cdt/list/ListValueRange.java index 461536c..298b7d1 100644 --- a/src/main/java/com/aerospike/ael/parts/cdt/list/ListValueRange.java +++ b/src/main/java/com/aerospike/ael/parts/cdt/list/ListValueRange.java @@ -8,15 +8,27 @@ import com.aerospike.ael.client.exp.ListExp; import com.aerospike.ael.parts.path.BasePath; -import static com.aerospike.ael.util.ParsingUtils.requireIntValueIdentifier; +import com.aerospike.ael.util.ParsingUtils; + +import static com.aerospike.ael.util.ParsingUtils.objectToExp; +import static com.aerospike.ael.util.ParsingUtils.requireSupportedExpValue; public class ListValueRange extends ListPart { private final boolean isInverted; - private final Integer start; - private final Integer end; + private final Object start; + private final Object end; - public ListValueRange(boolean isInverted, Integer start, Integer end) { + public ListValueRange(boolean isInverted, Object start, Object end) { super(ListPartType.VALUE_RANGE); + if (start != null) { + requireSupportedExpValue(start, "ListValueRange start"); + } + if (end != null) { + requireSupportedExpValue(end, "ListValueRange end"); + } + if (start == null && end == null) { + throw new AelParseException("ListValueRange requires at least one bound"); + } this.isInverted = isInverted; this.start = start; this.end = end; @@ -31,14 +43,8 @@ public static ListValueRange from(ConditionParser.ListValueRangeContext ctx) { valueRange != null ? valueRange.valueRangeIdentifier() : invertedValueRange.valueRangeIdentifier(); boolean isInverted = valueRange == null; - Integer startValue = requireIntValueIdentifier(range.valueIdentifier(0)); - - Integer endValue = null; - if (range.valueIdentifier(1) != null) { - endValue = requireIntValueIdentifier(range.valueIdentifier(1)); - } - - return new ListValueRange(isInverted, startValue, endValue); + ParsingUtils.NullableEndpoints bounds = ParsingUtils.parseValueRangeEndpoints(range); + return new ListValueRange(isInverted, bounds.start(), bounds.end()); } throw new AelParseException("Could not translate ListValueRange from ctx: %s".formatted(ctx)); } @@ -49,8 +55,8 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType cdtReturnType = cdtReturnType | ListReturnType.INVERTED; } - Exp startExp = Exp.val(start); - Exp endExp = end != null ? Exp.val(end) : null; + Exp startExp = start != null ? objectToExp(start) : null; + Exp endExp = end != null ? objectToExp(end) : null; return ListExp.getByValueRange(cdtReturnType, startExp, endExp, Exp.bin(basePath.getBinPart().getBinName(), basePath.getBinType()), context); diff --git a/src/main/java/com/aerospike/ael/parts/cdt/map/MapIndexRange.java b/src/main/java/com/aerospike/ael/parts/cdt/map/MapIndexRange.java index b56ce54..4f540ca 100644 --- a/src/main/java/com/aerospike/ael/parts/cdt/map/MapIndexRange.java +++ b/src/main/java/com/aerospike/ael/parts/cdt/map/MapIndexRange.java @@ -8,8 +8,8 @@ import com.aerospike.ael.client.exp.MapExp; import com.aerospike.ael.parts.path.BasePath; +import static com.aerospike.ael.util.ParsingUtils.halfOpenRangeCount; import static com.aerospike.ael.util.ParsingUtils.parseSignedInt; -import static com.aerospike.ael.util.ParsingUtils.subtractNullable; public class MapIndexRange extends MapPart { private final boolean isInverted; @@ -20,7 +20,7 @@ public MapIndexRange(boolean isInverted, Integer start, Integer end) { super(MapPartType.INDEX_RANGE); this.isInverted = isInverted; this.start = start; - this.count = subtractNullable(end, start); + this.count = halfOpenRangeCount(start, end); } public static MapIndexRange from(ConditionParser.MapIndexRangeContext ctx) { @@ -32,11 +32,8 @@ public static MapIndexRange from(ConditionParser.MapIndexRangeContext ctx) { indexRange != null ? indexRange.indexRangeIdentifier() : invertedIndexRange.indexRangeIdentifier(); boolean isInverted = indexRange == null; - Integer start = parseSignedInt(range.start().signedInt()); - Integer end = null; - if (range.end() != null) { - end = parseSignedInt(range.end().signedInt()); - } + Integer start = range.start() != null ? parseSignedInt(range.start().signedInt()) : null; + Integer end = range.end() != null ? parseSignedInt(range.end().signedInt()) : null; return new MapIndexRange(isInverted, start, end); } @@ -49,7 +46,8 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType cdtReturnType = cdtReturnType | MapReturnType.INVERTED; } - Exp startExp = Exp.val(start); + int startIndex = start != null ? start : 0; + Exp startExp = Exp.val(startIndex); if (count == null) { return MapExp.getByIndexRange(cdtReturnType, startExp, Exp.bin(basePath.getBinPart().getBinName(), basePath.getBinType()), context); diff --git a/src/main/java/com/aerospike/ael/parts/cdt/map/MapIndexRangeRelative.java b/src/main/java/com/aerospike/ael/parts/cdt/map/MapIndexRangeRelative.java index c47c631..7c5525e 100644 --- a/src/main/java/com/aerospike/ael/parts/cdt/map/MapIndexRangeRelative.java +++ b/src/main/java/com/aerospike/ael/parts/cdt/map/MapIndexRangeRelative.java @@ -10,8 +10,8 @@ import com.aerospike.ael.util.ParsingUtils; +import static com.aerospike.ael.util.ParsingUtils.halfOpenRangeCount; import static com.aerospike.ael.util.ParsingUtils.parseSignedInt; -import static com.aerospike.ael.util.ParsingUtils.subtractNullable; public class MapIndexRangeRelative extends MapPart { private final boolean isInverted; @@ -26,7 +26,7 @@ public MapIndexRangeRelative(boolean isInverted, Integer start, Integer end, Obj } this.isInverted = isInverted; this.start = start; - this.count = subtractNullable(end, start); + this.count = halfOpenRangeCount(start, end); this.relative = relative; } @@ -40,7 +40,7 @@ public static MapIndexRangeRelative from(ConditionParser.MapIndexRangeRelativeCo : invertedIndexRangeRelative.indexRangeRelativeIdentifier(); boolean isInverted = indexRangeRelative == null; - Integer start = parseSignedInt(range.start().signedInt()); + Integer start = range.start() != null ? parseSignedInt(range.start().signedInt()) : null; Integer end = null; if (range.relativeKeyEnd().end() != null) { end = parseSignedInt(range.relativeKeyEnd().end().signedInt()); @@ -62,7 +62,8 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType } Exp keyExp = ParsingUtils.objectToExp(relative); - Exp startExp = Exp.val(start); + int startIndex = start != null ? start : 0; + Exp startExp = Exp.val(startIndex); if (count == null) { return MapExp.getByKeyRelativeIndexRange(cdtReturnType, keyExp, startExp, Exp.bin(basePath.getBinPart().getBinName(), diff --git a/src/main/java/com/aerospike/ael/parts/cdt/map/MapKeyRange.java b/src/main/java/com/aerospike/ael/parts/cdt/map/MapKeyRange.java index 8702448..decd14a 100644 --- a/src/main/java/com/aerospike/ael/parts/cdt/map/MapKeyRange.java +++ b/src/main/java/com/aerospike/ael/parts/cdt/map/MapKeyRange.java @@ -10,8 +10,6 @@ import com.aerospike.ael.util.ParsingUtils; -import java.util.Optional; - public class MapKeyRange extends MapPart { private final boolean isInverted; private final Object start; @@ -19,10 +17,15 @@ public class MapKeyRange extends MapPart { public MapKeyRange(boolean isInverted, Object start, Object end) { super(MapPartType.KEY_RANGE); - requireSupportedKeyType(start, "MapKeyRange start"); + if (start != null) { + requireSupportedKeyType(start, "MapKeyRange start"); + } if (end != null) { requireSupportedKeyType(end, "MapKeyRange end"); } + if (start == null && end == null) { + throw new AelParseException("MapKeyRange requires at least one bound"); + } this.isInverted = isInverted; this.start = start; this.end = end; @@ -37,13 +40,8 @@ public static MapKeyRange from(ConditionParser.MapKeyRangeContext ctx) { keyRange != null ? keyRange.keyRangeIdentifier() : invertedKeyRange.keyRangeIdentifier(); boolean isInverted = keyRange == null; - Object startKey = ParsingUtils.parseMapKey(range.mapKey(0)); - - Object endKey = Optional.ofNullable(range.mapKey(1)) - .map(ParsingUtils::parseMapKey) - .orElse(null); - - return new MapKeyRange(isInverted, startKey, endKey); + ParsingUtils.NullableEndpoints bounds = ParsingUtils.parseMapKeyRangeEndpoints(range); + return new MapKeyRange(isInverted, bounds.start(), bounds.end()); } throw new AelParseException("Could not translate MapKeyRange from ctx: %s".formatted(ctx)); } @@ -54,7 +52,7 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType cdtReturnType = cdtReturnType | MapReturnType.INVERTED; } - Exp startExp = ParsingUtils.objectToExp(start); + Exp startExp = start != null ? ParsingUtils.objectToExp(start) : null; Exp endExp = end != null ? ParsingUtils.objectToExp(end) : null; return MapExp.getByKeyRange(cdtReturnType, startExp, endExp, Exp.bin(basePath.getBinPart().getBinName(), diff --git a/src/main/java/com/aerospike/ael/parts/cdt/map/MapRankRange.java b/src/main/java/com/aerospike/ael/parts/cdt/map/MapRankRange.java index df27f4c..69a94ec 100644 --- a/src/main/java/com/aerospike/ael/parts/cdt/map/MapRankRange.java +++ b/src/main/java/com/aerospike/ael/parts/cdt/map/MapRankRange.java @@ -8,8 +8,8 @@ import com.aerospike.ael.client.exp.MapExp; import com.aerospike.ael.parts.path.BasePath; +import static com.aerospike.ael.util.ParsingUtils.halfOpenRangeCount; import static com.aerospike.ael.util.ParsingUtils.parseSignedInt; -import static com.aerospike.ael.util.ParsingUtils.subtractNullable; public class MapRankRange extends MapPart { private final boolean isInverted; @@ -20,7 +20,7 @@ public MapRankRange(boolean isInverted, Integer start, Integer end) { super(MapPartType.RANK_RANGE); this.isInverted = isInverted; this.start = start; - this.count = subtractNullable(end, start); + this.count = halfOpenRangeCount(start, end); } public static MapRankRange from(ConditionParser.MapRankRangeContext ctx) { @@ -32,11 +32,8 @@ public static MapRankRange from(ConditionParser.MapRankRangeContext ctx) { rankRange != null ? rankRange.rankRangeIdentifier() : invertedRankRange.rankRangeIdentifier(); boolean isInverted = rankRange == null; - Integer start = parseSignedInt(range.start().signedInt()); - Integer end = null; - if (range.end() != null) { - end = parseSignedInt(range.end().signedInt()); - } + Integer start = range.start() != null ? parseSignedInt(range.start().signedInt()) : null; + Integer end = range.end() != null ? parseSignedInt(range.end().signedInt()) : null; return new MapRankRange(isInverted, start, end); } @@ -49,7 +46,8 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType cdtReturnType = cdtReturnType | MapReturnType.INVERTED; } - Exp startExp = Exp.val(start); + int startRank = start != null ? start : 0; + Exp startExp = Exp.val(startRank); if (count == null) { return MapExp.getByRankRange(cdtReturnType, startExp, Exp.bin(basePath.getBinPart().getBinName(), basePath.getBinType()), context); diff --git a/src/main/java/com/aerospike/ael/parts/cdt/map/MapRankRangeRelative.java b/src/main/java/com/aerospike/ael/parts/cdt/map/MapRankRangeRelative.java index 36db2b6..f7ad9f5 100644 --- a/src/main/java/com/aerospike/ael/parts/cdt/map/MapRankRangeRelative.java +++ b/src/main/java/com/aerospike/ael/parts/cdt/map/MapRankRangeRelative.java @@ -9,9 +9,9 @@ import com.aerospike.ael.parts.path.BasePath; import com.aerospike.ael.util.ParsingUtils; +import static com.aerospike.ael.util.ParsingUtils.halfOpenRangeCount; import static com.aerospike.ael.util.ParsingUtils.objectToExp; import static com.aerospike.ael.util.ParsingUtils.parseSignedInt; -import static com.aerospike.ael.util.ParsingUtils.subtractNullable; public class MapRankRangeRelative extends MapPart { private final boolean isInverted; @@ -23,7 +23,7 @@ public MapRankRangeRelative(boolean isInverted, Integer start, Integer end, Obje super(MapPartType.RANK_RANGE_RELATIVE); this.isInverted = isInverted; this.start = start; - this.count = subtractNullable(end, start); + this.count = halfOpenRangeCount(start, end); this.relative = relative; } @@ -37,7 +37,7 @@ public static MapRankRangeRelative from(ConditionParser.MapRankRangeRelativeCont : invertedRankRangeRelative.rankRangeRelativeIdentifier(); boolean isInverted = rankRangeRelative == null; - Integer start = parseSignedInt(range.start().signedInt()); + Integer start = range.start() != null ? parseSignedInt(range.start().signedInt()) : null; Integer end = null; if (range.relativeRankEnd().end() != null) { end = parseSignedInt(range.relativeRankEnd().end().signedInt()); @@ -62,13 +62,13 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType Exp relativeExp = objectToExp(relative); - Exp startExp = Exp.val(start); + Exp valueExp = Exp.val(start != null ? start : 0); if (count == null) { - return MapExp.getByValueRelativeRankRange(cdtReturnType, startExp, relativeExp, + return MapExp.getByValueRelativeRankRange(cdtReturnType, valueExp, relativeExp, Exp.bin(basePath.getBinPart().getBinName(), basePath.getBinType()), context); } - return MapExp.getByValueRelativeRankRange(cdtReturnType, startExp, relativeExp, Exp.val(count), + return MapExp.getByValueRelativeRankRange(cdtReturnType, valueExp, relativeExp, Exp.val(count), Exp.bin(basePath.getBinPart().getBinName(), basePath.getBinType()), context); } } diff --git a/src/main/java/com/aerospike/ael/parts/cdt/map/MapValueRange.java b/src/main/java/com/aerospike/ael/parts/cdt/map/MapValueRange.java index b623023..0eeed13 100644 --- a/src/main/java/com/aerospike/ael/parts/cdt/map/MapValueRange.java +++ b/src/main/java/com/aerospike/ael/parts/cdt/map/MapValueRange.java @@ -8,15 +8,27 @@ import com.aerospike.ael.client.exp.MapExp; import com.aerospike.ael.parts.path.BasePath; -import static com.aerospike.ael.util.ParsingUtils.requireIntValueIdentifier; +import com.aerospike.ael.util.ParsingUtils; + +import static com.aerospike.ael.util.ParsingUtils.objectToExp; +import static com.aerospike.ael.util.ParsingUtils.requireSupportedExpValue; public class MapValueRange extends MapPart { private final boolean isInverted; - private final Integer start; - private final Integer end; + private final Object start; + private final Object end; - public MapValueRange(boolean isInverted, Integer start, Integer end) { + public MapValueRange(boolean isInverted, Object start, Object end) { super(MapPartType.VALUE_RANGE); + if (start != null) { + requireSupportedExpValue(start, "MapValueRange start"); + } + if (end != null) { + requireSupportedExpValue(end, "MapValueRange end"); + } + if (start == null && end == null) { + throw new AelParseException("MapValueRange requires at least one bound"); + } this.isInverted = isInverted; this.start = start; this.end = end; @@ -31,14 +43,8 @@ public static MapValueRange from(ConditionParser.MapValueRangeContext ctx) { valueRange != null ? valueRange.valueRangeIdentifier() : invertedValueRange.valueRangeIdentifier(); boolean isInverted = valueRange == null; - Integer startValue = requireIntValueIdentifier(range.valueIdentifier(0)); - - Integer endValue = null; - if (range.valueIdentifier(1) != null) { - endValue = requireIntValueIdentifier(range.valueIdentifier(1)); - } - - return new MapValueRange(isInverted, startValue, endValue); + ParsingUtils.NullableEndpoints bounds = ParsingUtils.parseValueRangeEndpoints(range); + return new MapValueRange(isInverted, bounds.start(), bounds.end()); } throw new AelParseException("Could not translate MapValueRange from ctx: %s".formatted(ctx)); } @@ -49,8 +55,8 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType cdtReturnType = cdtReturnType | MapReturnType.INVERTED; } - Exp startExp = Exp.val(start); - Exp endExp = end != null ? Exp.val(end) : null; + Exp startExp = start != null ? objectToExp(start) : null; + Exp endExp = end != null ? objectToExp(end) : null; return MapExp.getByValueRange(cdtReturnType, startExp, endExp, Exp.bin(basePath.getBinPart().getBinName(), basePath.getBinType()), context); diff --git a/src/main/java/com/aerospike/ael/util/ParsingUtils.java b/src/main/java/com/aerospike/ael/util/ParsingUtils.java index 8f9a649..37fc2ed 100644 --- a/src/main/java/com/aerospike/ael/util/ParsingUtils.java +++ b/src/main/java/com/aerospike/ael/util/ParsingUtils.java @@ -3,7 +3,6 @@ import com.aerospike.ael.ConditionParser; import com.aerospike.ael.AelParseException; import com.aerospike.ael.client.exp.Exp; -import lombok.NonNull; import lombok.experimental.UtilityClass; import org.antlr.v4.runtime.ParserRuleContext; import org.antlr.v4.runtime.tree.TerminalNode; @@ -16,6 +15,8 @@ public class ParsingUtils { private static final BigInteger LONG_MIN_ABS = BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE); private static final BigInteger INT_MIN_VALUE = BigInteger.valueOf(Integer.MIN_VALUE); private static final BigInteger INT_MAX_VALUE = BigInteger.valueOf(Integer.MAX_VALUE); + private static final BigInteger LONG_MIN_VALUE = BigInteger.valueOf(Long.MIN_VALUE); + private static final BigInteger LONG_MAX_VALUE = BigInteger.valueOf(Long.MAX_VALUE); /** * Extracts a signed integer value from a {@code signedInt} parser rule context. @@ -37,6 +38,25 @@ public static int parseSignedInt(ConditionParser.SignedIntContext ctx) { return signedValue.intValue(); } + /** + * Extracts a signed long value from a {@code signedInt} parser rule context. + * Same grammar rule as {@link #parseSignedInt} ({@code signedInt: '-'? INT;}), + * but validates against the full 64-bit signed range. + * + * @param ctx The signedInt context from the parser + * @return The parsed long value, negated if a '-' prefix is present + */ + public static long parseSignedLong(ConditionParser.SignedIntContext ctx) { + String intText = ctx.INT().getText(); + boolean isNegative = ctx.getText().startsWith("-"); + BigInteger value = parseUnsignedIntegerLiteral(intText); + BigInteger signedValue = isNegative ? value.negate() : value; + if (signedValue.compareTo(LONG_MIN_VALUE) < 0 || signedValue.compareTo(LONG_MAX_VALUE) > 0) { + throw new AelParseException("Signed integer literal out of range for LONG: " + ctx.getText()); + } + return signedValue.longValue(); + } + /** * Parses an unsigned INT token (decimal, hex or binary) into a long value. * The value range is [0, 2^63], where 2^63 is represented as {@link Long#MIN_VALUE}. @@ -149,10 +169,11 @@ public static long parseLongMapKey(String text) { /** * Extracts a typed value from a {@code valueIdentifier} parser rule context. - * Handles NAME_IDENTIFIER, QUOTED_STRING, IN keyword (as literal text), and signedInt. + * Handles NAME_IDENTIFIER, QUOTED_STRING, IN keyword (as literal text), signedInt, + * BLOB_LITERAL, and B64_LITERAL. * * @param ctx The valueIdentifier context from the parser - * @return The parsed value as String or Integer + * @return The parsed value as String, Long, or byte[] */ public static Object parseValueIdentifier(ConditionParser.ValueIdentifierContext ctx) { String result = resolveStringToken(ctx); @@ -160,7 +181,7 @@ public static Object parseValueIdentifier(ConditionParser.ValueIdentifierContext return result; } if (ctx.signedInt() != null) { - return parseSignedInt(ctx.signedInt()); + return parseSignedLong(ctx.signedInt()); } TerminalNode blobLiteral = ctx.getToken(ConditionParser.BLOB_LITERAL, 0); if (blobLiteral != null) { @@ -199,17 +220,20 @@ public static byte[] parseB64ToBytes(String text) { } /** - * Parses a {@code valueIdentifier} context and requires the result to be an {@link Integer}. - * Used by value-range elements where only integer operands are valid. + * Parses a {@code valueIdentifier} context and requires the result to be an integer ({@link Long}). + *

+ * Note: currently unused — value-range elements now accept any type supported by + * {@link #objectToExp}. Retained for potential future use. * * @param ctx The valueIdentifier context from the parser - * @return The parsed integer value + * @return The parsed long value * @throws AelParseException if the parsed value is not an integer */ - public static Integer requireIntValueIdentifier(ConditionParser.ValueIdentifierContext ctx) { + @SuppressWarnings("unused") + public static long requireIntValueIdentifier(ConditionParser.ValueIdentifierContext ctx) { Object result = parseValueIdentifier(ctx); - if (result instanceof Integer intValue) { - return intValue; + if (result instanceof Long longValue) { + return longValue; } throw new AelParseException( "Value range requires integer operands, got: %s".formatted(ctx.getText())); @@ -221,9 +245,13 @@ public static Integer requireIntValueIdentifier(ConditionParser.ValueIdentifierC * * @param value The parsed value object * @return The corresponding {@link Exp} value expression - * @throws AelParseException if the value type is not supported + * @throws AelParseException if the value is null or its type is not supported + * @see #isSupportedExpType(Object) */ public static Exp objectToExp(Object value) { + if (value == null) { + throw new AelParseException("Cannot convert null to Exp"); + } if (value instanceof String s) return Exp.val(s); if (value instanceof Long l) return Exp.val(l); if (value instanceof Integer i) return Exp.val(i); @@ -232,6 +260,37 @@ public static Exp objectToExp(Object value) { "Unsupported value type for Exp conversion: " + value.getClass().getSimpleName()); } + /** + * Checks whether a value is one of the types supported by {@link #objectToExp}: + * {@link String}, {@link Integer}, {@link Long}, or {@code byte[]}. + */ + private static boolean isSupportedExpType(Object value) { + return value instanceof String || value instanceof Integer + || value instanceof Long || value instanceof byte[]; + } + + /** + * Validates that a value is a supported type for {@link #objectToExp}. + *

+ * Accepts {@link String}, {@link Integer}, {@link Long}, and {@code byte[]}. + * Both {@link #parseValueIdentifier} and {@link #parseMapKey} return {@link Long} for + * numeric values; {@link Integer} is kept for robustness when values are constructed directly. + * + * @param value The value to check + * @param context Description for the error message (e.g. "MapValueRange start") + * @throws AelParseException if value is null or not a supported type + * @see #isSupportedExpType(Object) + */ + public static void requireSupportedExpValue(Object value, String context) { + if (value == null) { + throw new AelParseException("Null value in %s".formatted(context)); + } + if (!isSupportedExpType(value)) { + throw new AelParseException( + "Unsupported value type in %s: %s".formatted(context, value.getClass().getSimpleName())); + } + } + /** * Get the string inside the quotes. * @@ -247,12 +306,69 @@ public static String unquote(String str) { } /** - * @param a Integer, can be null - * @param b Integer, non-null - * @return a - b if a != null, otherwise null + * Length of half-open interval {@code [start, end)} for CDT index/rank {@code count} arguments, + * consistent with closed {@code {s:e}} parsing ({@code end} exclusive). + * + * @param start inclusive lower bound; {@code null} means absolute index/rank {@code 0} for span purposes + * @param end exclusive upper bound; {@code null} means upper-unbounded (tail selection) + * @return {@code end - start} when both finite; {@code end} when {@code start} is null; {@code null} when {@code end} is null + * @implNote When both {@code start} and {@code end} are non-null but {@code start > end}, the result is negative. + * The grammar does not validate ordering; callers {@link com.aerospike.ael.parts.cdt.map.MapIndexRange} forward this + * to Aerospike CDT APIs as with closed ranges. + */ + public static Integer halfOpenRangeCount(Integer start, Integer end) { + if (end == null) { + return null; + } + if (start == null) { + return end; + } + return end - start; + } + + /** + * Parses {@link ConditionParser.KeyRangeIdentifierContext}: two keys, open upper ({@code key-}), or open lower ({@code -key}). + */ + public static NullableEndpoints parseMapKeyRangeEndpoints(ConditionParser.KeyRangeIdentifierContext range) { + int keyCount = range.mapKey().size(); + if (keyCount == 2) { + return new NullableEndpoints<>(parseMapKey(range.mapKey(0)), parseMapKey(range.mapKey(1))); + } + if (keyCount == 1) { + if (range.getChild(0) instanceof ConditionParser.MapKeyContext) { + return new NullableEndpoints<>(parseMapKey(range.mapKey(0)), null); + } + return new NullableEndpoints<>(null, parseMapKey(range.mapKey(0))); + } + throw new AelParseException("Could not parse map key range identifier: %s".formatted(range.getText())); + } + + /** + * Parses {@link ConditionParser.ValueRangeIdentifierContext}: two values, open upper ({@code v:}), or open lower ({@code :v}). + */ + public static NullableEndpoints parseValueRangeEndpoints(ConditionParser.ValueRangeIdentifierContext range) { + int n = range.valueIdentifier().size(); + if (n == 2) { + return new NullableEndpoints<>( + parseValueIdentifier(range.valueIdentifier(0)), + parseValueIdentifier(range.valueIdentifier(1))); + } + if (n == 1) { + if (range.getChild(0) instanceof ConditionParser.ValueIdentifierContext) { + return new NullableEndpoints<>(parseValueIdentifier(range.valueIdentifier(0)), null); + } + return new NullableEndpoints<>(null, parseValueIdentifier(range.valueIdentifier(0))); + } + throw new AelParseException("Could not parse value range identifier: %s".formatted(range.getText())); + } + + /** + * Nullable inclusive/exclusive endpoints for map key or value interval parsing. + * + * @param start inclusive lower bound for keys/values (CDT wire: null begin allowed) + * @param end exclusive upper bound (null end allowed) */ - public static Integer subtractNullable(Integer a, @NonNull Integer b) { - return a == null ? null : a - b; + public record NullableEndpoints(T start, T end) { } /** diff --git a/src/test/java/com/aerospike/ael/ctx/CtxTests.java b/src/test/java/com/aerospike/ael/ctx/CtxTests.java index 89a20a2..0c30f00 100644 --- a/src/test/java/com/aerospike/ael/ctx/CtxTests.java +++ b/src/test/java/com/aerospike/ael/ctx/CtxTests.java @@ -122,6 +122,22 @@ void mapExpression_oneLevel() { new CTX[]{CTX.mapValue(Value.get(100))}); } + @Test + void mapExpression_halfOpenIndexRangeUnsupportedForCtx() { + assertThatThrownBy(() -> parseCtx("$.mapBin1.{:3}")) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("Could not parse the given AEL path input") + .hasStackTraceContaining("Context is not supported"); + } + + @Test + void listExpression_halfOpenIndexRangeUnsupportedForCtx() { + assertThatThrownBy(() -> parseCtx("$.listBin1.[:3]")) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("Could not parse the given AEL path input") + .hasStackTraceContaining("Context is not supported"); + } + @Test void existsPathFunctionRejected() { assertThatThrownBy(() -> parseCtx("$.mapBin1.a.exists()")) diff --git a/src/test/java/com/aerospike/ael/expression/ListExpressionsTests.java b/src/test/java/com/aerospike/ael/expression/ListExpressionsTests.java index caeceb7..3cbbbc6 100644 --- a/src/test/java/com/aerospike/ael/expression/ListExpressionsTests.java +++ b/src/test/java/com/aerospike/ael/expression/ListExpressionsTests.java @@ -401,6 +401,22 @@ void listIndexRange() { Exp.val(1), Exp.listBin("listBin1")); TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[1:]"), expected); + + // Lower-open [0, n) same as [0:n] + expected = ListExp.getByIndexRange( + ListReturnType.VALUE, + Exp.val(0), + Exp.val(3), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[:3]"), expected); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[0:3]"), expected); + + expected = ListExp.getByIndexRange( + ListReturnType.VALUE | ListReturnType.INVERTED, + Exp.val(0), + Exp.val(3), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[!:3]"), expected); } @Test @@ -453,6 +469,138 @@ void listValueRange() { null, Exp.listBin("listBin1")); TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[=111:]"), expected); + + expected = ListExp.getByValueRange( + ListReturnType.VALUE, + null, + Exp.val(334), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[=:334]"), expected); + + expected = ListExp.getByValueRange( + ListReturnType.VALUE | ListReturnType.INVERTED, + null, + Exp.val(334), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[!=:334]"), expected); + } + + @Test + void listValueRangeStrUnquoted() { + Exp expected = ListExp.getByValueRange( + ListReturnType.VALUE, + Exp.val("aaa"), + Exp.val("zzz"), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[=aaa:zzz]"), expected); + } + + @Test + void listValueRangeStrSingleQuoted() { + Exp expected = ListExp.getByValueRange( + ListReturnType.VALUE, + Exp.val("hello"), + Exp.val("world"), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[='hello':'world']"), expected); + } + + @Test + void listValueRangeStrDoubleQuoted() { + Exp expected = ListExp.getByValueRange( + ListReturnType.VALUE, + Exp.val("hello"), + Exp.val("world"), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[=\"hello\":\"world\"]"), expected); + } + + @Test + void listValueRangeStrOpenEnded() { + Exp expected = ListExp.getByValueRange( + ListReturnType.VALUE, + Exp.val("hello"), + null, + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[='hello':]"), expected); + } + + @Test + void listValueRangeStrInverted() { + Exp expected = ListExp.getByValueRange( + ListReturnType.VALUE | ListReturnType.INVERTED, + Exp.val("hello"), + Exp.val("world"), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[!='hello':'world']"), expected); + } + + @Test + void listValueRangeMixedTypes() { + Exp expected = ListExp.getByValueRange( + ListReturnType.VALUE, + Exp.val("abc"), + Exp.val(999), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[=abc:999]"), expected); + } + + @Test + void listValueRangeBlob() { + Exp expected = ListExp.getByValueRange( + ListReturnType.VALUE, + Exp.val(new byte[]{0x0A}), + Exp.val(new byte[]{(byte) 0xFF}), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[=X'0A':X'FF']"), expected); + } + + @Test + void listValueRangeBase64Blob() { + byte[] start = java.util.Base64.getDecoder().decode("AAAA"); + byte[] end = java.util.Base64.getDecoder().decode("////"); + Exp expected = ListExp.getByValueRange( + ListReturnType.VALUE, + Exp.val(start), + Exp.val(end), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[=b64'AAAA':b64'////']"), expected); + } + + @Test + void listValueRangeInKeyword() { + Exp expected = ListExp.getByValueRange( + ListReturnType.VALUE, + Exp.val("in"), + Exp.val("out"), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[=in:out]"), expected); + } + + @Test + void negListValueRangeFloat() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.listBin1.[=1.5:2.5]"))) + .isInstanceOf(AelParseException.class); + } + + @Test + void negListValueRangeBoolean() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.listBin1.[=true:false]"))) + .isInstanceOf(AelParseException.class); + } + + @Test + void negListValueRangeOddHexBlob() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.listBin1.[=X'f':X'ff']"))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("even number of hex characters"); + } + + @Test + void negListValueRangeInvalidB64() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.listBin1.[=b64'A':b64'AQID']"))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("Base64 BLOB literal contains invalid Base64 content"); } @Test @@ -464,6 +612,20 @@ void listRankRange() { Exp.listBin("listBin1")); TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[#0:3]"), expected); + expected = ListExp.getByRankRange( + ListReturnType.VALUE, + Exp.val(0), + Exp.val(3), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[#:3]"), expected); + + expected = ListExp.getByRankRange( + ListReturnType.VALUE | ListReturnType.INVERTED, + Exp.val(0), + Exp.val(3), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[!#:3]"), expected); + // Inverted expected = ListExp.getByRankRange( ListReturnType.VALUE | ListReturnType.INVERTED, @@ -498,6 +660,15 @@ void listRankRangeRelative() { Exp.listBin("listBin1")); TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[#-3:-1~b]"), expected); + expected = ListExp.getByValueRelativeRankRange( + ListReturnType.VALUE, + Exp.val(0), + Exp.val("b"), + Exp.val(2), + Exp.listBin("listBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[#:2~b]"), expected); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.listBin1.[#0:2~b]"), expected); + // Inverted expected = ListExp.getByValueRelativeRankRange( ListReturnType.VALUE | ListReturnType.INVERTED, diff --git a/src/test/java/com/aerospike/ael/expression/MapExpressionsTests.java b/src/test/java/com/aerospike/ael/expression/MapExpressionsTests.java index 66194ca..c0387f2 100644 --- a/src/test/java/com/aerospike/ael/expression/MapExpressionsTests.java +++ b/src/test/java/com/aerospike/ael/expression/MapExpressionsTests.java @@ -320,8 +320,9 @@ void mapIndexHexAndBinary() { TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{0xff} == 100"), Exp.eq(MapExp.getByIndex(MapReturnType.VALUE, Exp.Type.INT, Exp.val(255), Exp.mapBin("mapBin1")), Exp.val(100))); + // {-0b10}: open upper bound at key 0b10 (=2); mapKeyRange is tried before mapIndex (see keyRangeIdentifier). TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{-0b10} == 100"), - Exp.eq(MapExp.getByIndex(MapReturnType.VALUE, Exp.Type.INT, Exp.val(-2), + Exp.eq(MapExp.getByKeyRange(MapReturnType.VALUE, null, Exp.val(2L), Exp.mapBin("mapBin1")), Exp.val(100))); } @@ -460,6 +461,27 @@ void mapKeyRange() { Exp.mapBin("mapBin1")); TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{a-}"), expected); TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{\"a\"-}"), expected); + + // Open lower bound (exclusive upper key) + expected = MapExp.getByKeyRange( + MapReturnType.VALUE, + null, + Exp.val("z"), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{-z}"), expected); + expected = MapExp.getByKeyRange( + MapReturnType.VALUE, + null, + Exp.val(55L), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{-55}"), expected); + + expected = MapExp.getByKeyRange( + MapReturnType.VALUE | MapReturnType.INVERTED, + null, + Exp.val("z"), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{!-z}"), expected); } @Test @@ -511,6 +533,23 @@ void mapIndexRange() { Exp.val(1), Exp.mapBin("mapBin1")); TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{1:}"), expected); + + // Lower-open [0, n) same as {0:n} + expected = MapExp.getByIndexRange( + MapReturnType.VALUE, + Exp.val(0), + Exp.val(3), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{:3}"), expected); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{0:3}"), expected); + + // Inverted lower-open + expected = MapExp.getByIndexRange( + MapReturnType.VALUE | MapReturnType.INVERTED, + Exp.val(0), + Exp.val(3), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{!:3}"), expected); } @Test @@ -562,6 +601,139 @@ void mapValueRange() { null, Exp.mapBin("mapBin1")); TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{=111:}"), expected); + + // Open lower bound (values strictly below end) + expected = MapExp.getByValueRange( + MapReturnType.VALUE, + null, + Exp.val(334), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{=:334}"), expected); + + expected = MapExp.getByValueRange( + MapReturnType.VALUE | MapReturnType.INVERTED, + null, + Exp.val(334), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{!=:334}"), expected); + } + + @Test + void mapValueRangeStrUnquoted() { + Exp expected = MapExp.getByValueRange( + MapReturnType.VALUE, + Exp.val("aaa"), + Exp.val("zzz"), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{=aaa:zzz}"), expected); + } + + @Test + void mapValueRangeStrSingleQuoted() { + Exp expected = MapExp.getByValueRange( + MapReturnType.VALUE, + Exp.val("hello"), + Exp.val("world"), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{='hello':'world'}"), expected); + } + + @Test + void mapValueRangeStrDoubleQuoted() { + Exp expected = MapExp.getByValueRange( + MapReturnType.VALUE, + Exp.val("hello"), + Exp.val("world"), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{=\"hello\":\"world\"}"), expected); + } + + @Test + void mapValueRangeStrOpenEnded() { + Exp expected = MapExp.getByValueRange( + MapReturnType.VALUE, + Exp.val("hello"), + null, + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{='hello':}"), expected); + } + + @Test + void mapValueRangeStrInverted() { + Exp expected = MapExp.getByValueRange( + MapReturnType.VALUE | MapReturnType.INVERTED, + Exp.val("hello"), + Exp.val("world"), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{!='hello':'world'}"), expected); + } + + @Test + void mapValueRangeMixedTypes() { + Exp expected = MapExp.getByValueRange( + MapReturnType.VALUE, + Exp.val("abc"), + Exp.val(999), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{=abc:999}"), expected); + } + + @Test + void mapValueRangeBlob() { + Exp expected = MapExp.getByValueRange( + MapReturnType.VALUE, + Exp.val(new byte[]{0x0A}), + Exp.val(new byte[]{(byte) 0xFF}), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{=X'0A':X'FF'}"), expected); + } + + @Test + void mapValueRangeBase64Blob() { + byte[] start = java.util.Base64.getDecoder().decode("AAAA"); + byte[] end = java.util.Base64.getDecoder().decode("////"); + Exp expected = MapExp.getByValueRange( + MapReturnType.VALUE, + Exp.val(start), + Exp.val(end), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{=b64'AAAA':b64'////'}"), expected); + } + + @Test + void mapValueRangeInKeyword() { + Exp expected = MapExp.getByValueRange( + MapReturnType.VALUE, + Exp.val("in"), + Exp.val("out"), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{=in:out}"), expected); + } + + @Test + void negMapValueRangeFloat() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.mapBin1.{=1.5:2.5}"))) + .isInstanceOf(AelParseException.class); + } + + @Test + void negMapValueRangeBoolean() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.mapBin1.{=true:false}"))) + .isInstanceOf(AelParseException.class); + } + + @Test + void negMapValueRangeOddHexBlob() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.mapBin1.{=X'f':X'ff'}"))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("even number of hex characters"); + } + + @Test + void negMapValueRangeInvalidB64() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.mapBin1.{=b64'A':b64'AQID'}"))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("Base64 BLOB literal contains invalid Base64 content"); } @Test @@ -573,6 +745,21 @@ void mapRankRange() { Exp.mapBin("mapBin1")); TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{#0:3}"), expected); + // Lower-open same as {#0:n} + expected = MapExp.getByRankRange( + MapReturnType.VALUE, + Exp.val(0), + Exp.val(3), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{#:3}"), expected); + + expected = MapExp.getByRankRange( + MapReturnType.VALUE | MapReturnType.INVERTED, + Exp.val(0), + Exp.val(3), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{!#:3}"), expected); + // Inverted expected = MapExp.getByRankRange( MapReturnType.VALUE | MapReturnType.INVERTED, @@ -607,6 +794,16 @@ void mapRankRangeRelative() { Exp.mapBin("mapBin1")); TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{#-1:1~10}"), expected); + // Lower-open same as {#0:1~10} + expected = MapExp.getByValueRelativeRankRange( + MapReturnType.VALUE, + Exp.val(0), + Exp.val(10), + Exp.val(1), + Exp.mapBin("mapBin1")); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{#:1~10}"), expected); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{#0:1~10}"), expected); + // Inverted expected = MapExp.getByValueRelativeRankRange( MapReturnType.VALUE | MapReturnType.INVERTED, @@ -634,6 +831,7 @@ void mapIndexRangeRelative() { Exp.val(1), Exp.mapBin("mapBin1")); TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{0:1~a}"), expected); + TestUtils.parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin1.{:1~a}"), expected); // Inverted expected = MapExp.getByKeyRelativeIndexRange( diff --git a/src/test/java/com/aerospike/ael/expression/MapKeyTypingTests.java b/src/test/java/com/aerospike/ael/expression/MapKeyTypingTests.java index 9a1e585..5703f02 100644 --- a/src/test/java/com/aerospike/ael/expression/MapKeyTypingTests.java +++ b/src/test/java/com/aerospike/ael/expression/MapKeyTypingTests.java @@ -397,6 +397,13 @@ void binaryIntInKeyRange() { parseFilterExpressionAndCompare(ExpressionContext.of("$.bin.{0b1010-z}"), expected); } + @Test + void openLowerIntKeyExclusiveUpper() { + Exp expected = MapExp.getByKeyRange(MapReturnType.VALUE, + null, Exp.val(55L), Exp.mapBin("bin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.bin.{-55}"), expected); + } + @Test void intKeyInRelativeIndex() { Exp expected = MapExp.getByKeyRelativeIndexRange(MapReturnType.VALUE, diff --git a/src/test/java/com/aerospike/ael/util/ParsingUtilsTests.java b/src/test/java/com/aerospike/ael/util/ParsingUtilsTests.java new file mode 100644 index 0000000..eb0726e --- /dev/null +++ b/src/test/java/com/aerospike/ael/util/ParsingUtilsTests.java @@ -0,0 +1,149 @@ +package com.aerospike.ael.util; + +import com.aerospike.ael.AelParseException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import com.aerospike.ael.parts.cdt.list.ListValueRange; +import com.aerospike.ael.parts.cdt.map.MapKeyRange; +import com.aerospike.ael.parts.cdt.map.MapValueRange; + +import static com.aerospike.ael.util.ParsingUtils.halfOpenRangeCount; +import static com.aerospike.ael.util.ParsingUtils.objectToExp; +import static com.aerospike.ael.util.ParsingUtils.requireSupportedExpValue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ParsingUtilsTests { + + @Nested + class RequireSupportedExpValue { + + @Test + void acceptsString() { + assertThatNoException() + .isThrownBy(() -> requireSupportedExpValue("hello", "test")); + } + + @Test + void acceptsInteger() { + assertThatNoException() + .isThrownBy(() -> requireSupportedExpValue(42, "test")); + } + + @Test + void acceptsLong() { + assertThatNoException() + .isThrownBy(() -> requireSupportedExpValue(42L, "test")); + } + + @Test + void acceptsByteArray() { + assertThatNoException() + .isThrownBy(() -> requireSupportedExpValue(new byte[]{1, 2}, "test")); + } + + @Test + @SuppressWarnings("DataFlowIssue") + void rejectsNull() { + assertThatThrownBy(() -> requireSupportedExpValue(null, "ctx")) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("Null value in ctx"); + } + + @Test + void rejectsDouble() { + assertThatThrownBy(() -> requireSupportedExpValue(3.14, "ctx")) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("Unsupported value type in ctx") + .hasMessageContaining("Double"); + } + + @Test + void rejectsBoolean() { + assertThatThrownBy(() -> requireSupportedExpValue(true, "ctx")) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("Unsupported value type in ctx") + .hasMessageContaining("Boolean"); + } + + @Test + void rejectsList() { + assertThatThrownBy(() -> requireSupportedExpValue(List.of(1), "ctx")) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("Unsupported value type in ctx"); + } + } + + @Nested + class ObjectToExp { + + @Test + @SuppressWarnings("DataFlowIssue") + void rejectsNull() { + assertThatThrownBy(() -> objectToExp(null)) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("Cannot convert null to Exp"); + } + + @Test + void rejectsUnsupportedType() { + assertThatThrownBy(() -> objectToExp(3.14)) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("Unsupported value type for Exp conversion") + .hasMessageContaining("Double"); + } + } + + @Nested + class HalfOpenRangeCount { + + @Test + void endNullYieldsNullForTailSemantics() { + assertThat(halfOpenRangeCount(1, null)).isNull(); + } + + @Test + void startNullYieldsEndForOpenLower() { + assertThat(halfOpenRangeCount(null, 3)).isEqualTo(3); + } + + @Test + void bothSetYieldsDifference() { + assertThat(halfOpenRangeCount(1, 3)).isEqualTo(2); + } + + @Test + void invertedSpanIsNegative() { + assertThat(halfOpenRangeCount(5, 2)).isEqualTo(-3); + } + } + + @Nested + class OpenRangePartConstructors { + + @Test + void mapKeyRange_rejectsBothBoundsNull() { + assertThatThrownBy(() -> new MapKeyRange(false, null, null)) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("MapKeyRange requires at least one bound"); + } + + @Test + void mapValueRange_rejectsBothBoundsNull() { + assertThatThrownBy(() -> new MapValueRange(false, null, null)) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("MapValueRange requires at least one bound"); + } + + @Test + void listValueRange_rejectsBothBoundsNull() { + assertThatThrownBy(() -> new ListValueRange(false, null, null)) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("ListValueRange requires at least one bound"); + } + } +}