diff --git a/CCL_REFERENCE.md b/CCL_REFERENCE.md index ab632799..62034cd4 100644 --- a/CCL_REFERENCE.md +++ b/CCL_REFERENCE.md @@ -455,6 +455,34 @@ name["last week"] = "Jeff" score[1700000000] >= 90 ``` +#### Temporal range (leaf keys) + +A leaf key can bind to a half-open time interval `[start, end)` rather +than a single instant, using the `...` range separator. The interval is +start-inclusive and end-exclusive, matching the `BETWEEN` convention. +Either endpoint may be omitted for an open-ended range: + +``` +lock[1718380800000000...1718384400000000] = "claimed" -- closed +status[...1700000000] = "active" -- open start +lock[1700000000...] = "claimed" -- open end +``` + +A range must pin at least one endpoint (`foo[...]` is rejected), carries +exactly one `...` separator (`foo[t1...t2...t3]` is rejected), and a +closed range may not start after it ends (`foo[t2...t1]` with `t2 > t1` +is rejected); `t1 == t2` is an empty interval and is accepted. A key +still carries at most one bracket annotation, so a range cannot follow +another bracket (`foo[t1][t2...t3]` is rejected). + +The canonical serialization renders both endpoints as microseconds and +omits any keyword, for example `lock[1700000000...]`. + +Range bindings are currently supported only on flat leaf keys. Range +bindings on navigation stops, scope prefixes, and transitive keys are +not yet supported. Evaluating what a range read returns is a +server-side concern and is outside the grammar. + #### Per-stop navigation Navigation keys carry one bracket per stop. Each annotation binds only diff --git a/grammar/grammar.jjt b/grammar/grammar.jjt index ee23a9e1..a3a08ec7 100644 --- a/grammar/grammar.jjt +++ b/grammar/grammar.jjt @@ -42,7 +42,6 @@ import com.cinchapi.ccl.grammar.NavigationKeySymbol; import com.cinchapi.ccl.grammar.KeyTokenSymbol; import com.cinchapi.ccl.grammar.OperatorSymbol; import com.cinchapi.ccl.grammar.OrderComponentSymbol; -import com.cinchapi.ccl.grammar.TemporalKeySymbol; import com.cinchapi.ccl.grammar.TimestampSymbol; import com.cinchapi.ccl.grammar.ValueSymbol; import com.cinchapi.ccl.grammar.PageSymbol; @@ -545,7 +544,7 @@ KeyTokenSymbol Key() : FunctionKeySymbol function; Token key; KeyTokenSymbol result; - TimestampSymbol ts = null; + String parameter = null; } { ( @@ -560,16 +559,12 @@ KeyTokenSymbol Key() : (key= | key=) { result = new NavigationKeySymbol(key.image); } ) - ( LOOKAHEAD(2) ts=BracketedTimestamp() )? + ( LOOKAHEAD(2) parameter=KeyBracketParameter() )? { - if(ts == null) { + if(parameter == null) { return result; } - if(result instanceof NavigationKeySymbol) { - return NavigationKeySymbol.withTimestampOnLastStop( - (NavigationKeySymbol) result, ts); - } - return new TemporalKeySymbol(result, ts); + return Parsing.applyKeyBracket(result, parameter); } } @@ -765,18 +760,20 @@ TimestampSymbol Timestamp() : { return new TimestampSymbol(NaturalLanguage.parseMicros(timestamp)); } } -// Bracket-form timestamp: the brackets themselves are the timestamp pivot, -// so the leading at/on/during keyword is optional. The keyword form is -// accepted for backward compatibility. -TimestampSymbol BracketedTimestamp() : +// Bracket-form key parameter: the raw content between a leaf key's +// brackets. The leading at/on/during keyword is optional (the brackets +// themselves are the pivot) and is consumed without being returned. +// Interpreting the content -- single instant vs. temporal range -- is +// deferred to Parsing.applyKeyBracket so the meaning lives in one place. +String KeyBracketParameter() : { Token word; - String timestamp = ""; + String parameter = ""; } { ()? - (LOOKAHEAD(2) (word= | word= | word= | word= | word= | word= | word=) { timestamp += (timestamp.equals("")) ? word.image : " " + word.image; })+ - { return new TimestampSymbol(NaturalLanguage.parseMicros(timestamp)); } + (LOOKAHEAD(2) (word= | word= | word= | word= | word= | word= | word=) { parameter += (parameter.equals("")) ? word.image : " " + word.image; })+ + { return parameter; } } TimestampSymbol TimestampReadCommand() : diff --git a/src/main/java/com/cinchapi/ccl/Parsing.java b/src/main/java/com/cinchapi/ccl/Parsing.java index 12b464ec..87e74c21 100644 --- a/src/main/java/com/cinchapi/ccl/Parsing.java +++ b/src/main/java/com/cinchapi/ccl/Parsing.java @@ -28,14 +28,18 @@ import com.cinchapi.ccl.grammar.ConjunctionSymbol; import com.cinchapi.ccl.grammar.ExpressionSymbol; import com.cinchapi.ccl.grammar.KeyTokenSymbol; +import com.cinchapi.ccl.grammar.NavigationKeySymbol; import com.cinchapi.ccl.grammar.OperatorSymbol; import com.cinchapi.ccl.grammar.ParenthesisSymbol; import com.cinchapi.ccl.grammar.PostfixNotationSymbol; import com.cinchapi.ccl.grammar.ScopeEndSymbol; import com.cinchapi.ccl.grammar.ScopeSymbol; +import com.cinchapi.ccl.grammar.TemporalKeySymbol; +import com.cinchapi.ccl.grammar.TemporalRangeKeySymbol; import com.cinchapi.ccl.grammar.TimestampSymbol; import com.cinchapi.ccl.grammar.Symbol; import com.cinchapi.ccl.grammar.ValueTokenSymbol; +import com.cinchapi.ccl.util.NaturalLanguage; import com.cinchapi.common.base.AnyStrings; import com.cinchapi.common.base.Array; import com.google.common.base.Preconditions; @@ -50,6 +54,102 @@ */ public final class Parsing { + /** + * The literal token that separates the two endpoints of a temporal + * range bracket binding (for example {@code foo[t1...t2]}). + */ + private static final String RANGE_SEPARATOR = "..."; + + /** + * Interpret the raw {@code content} of a leaf key's bracket parameter + * and pair it with {@code base} as the appropriate parameterized key. + * + *

Content containing the range separator {@code ...} produces a + * {@link TemporalRangeKeySymbol} over the half-open interval its + * endpoints describe; either endpoint may be omitted for an + * open-ended range. All other content is a single instant and + * produces a {@link TemporalKeySymbol}, or, when {@code base} is a + * {@link NavigationKeySymbol}, folds the timestamp onto the path's + * last stop. + * + * @param base the key the bracket parameter binds to + * @param content the raw bracket content; the leading {@code at} / + * {@code on} / {@code during} keyword, if any, has already + * been consumed by the grammar + * @return the parameterized {@link KeyTokenSymbol} + * @throws IllegalArgumentException if a range binding is applied to a + * navigation key, if both endpoints are omitted, if more + * than one range separator is present, or if an endpoint + * cannot be parsed as a timestamp + */ + public static KeyTokenSymbol applyKeyBracket(KeyTokenSymbol base, + String content) { + int separator = indexOfRangeSeparator(content, 0); + if(separator < 0) { + TimestampSymbol timestamp = new TimestampSymbol( + NaturalLanguage.parseMicros(content.trim())); + if(base instanceof NavigationKeySymbol) { + return NavigationKeySymbol.withTimestampOnLastStop( + (NavigationKeySymbol) base, timestamp); + } + else { + return new TemporalKeySymbol(base, timestamp); + } + } + else { + Preconditions.checkArgument( + indexOfRangeSeparator(content, + separator + RANGE_SEPARATOR.length()) < 0, + "a temporal range has exactly two endpoints separated by " + + "a single '%s'; '%s' has more than one", + RANGE_SEPARATOR, content); + String startText = content.substring(0, separator).trim(); + String endText = content + .substring(separator + RANGE_SEPARATOR.length()).trim(); + TimestampSymbol start = startText.isEmpty() ? null + : new TimestampSymbol( + NaturalLanguage.parseMicros(startText)); + TimestampSymbol end = endText.isEmpty() ? null + : new TimestampSymbol( + NaturalLanguage.parseMicros(endText)); + return new TemporalRangeKeySymbol(base, start, end); + } + } + + /** + * Return the index of the next {@link #RANGE_SEPARATOR} in + * {@code content} at or after {@code from} that lies outside + * any quoted endpoint, or {@code -1} if there is none. + * + *

A quoted timestamp phrase may itself contain {@code ...} (for + * example {@code foo["a...b"]}); such an occurrence is part of the + * endpoint, not a range separator, so it is skipped. This keeps the + * single-vs-range decision and the endpoint split from being fooled + * by punctuation inside a quoted phrase. + * + * @param content the raw bracket content + * @param from the index to begin scanning from + * @return the index of the next unquoted separator, or {@code -1} + */ + private static int indexOfRangeSeparator(String content, int from) { + char quote = 0; + for (int i = from; i < content.length(); ++i) { + char c = content.charAt(i); + if(quote != 0) { + if(c == quote) { + quote = 0; + } + } + else if(c == '"' || c == '\'' || c == '`') { + quote = c; + } + else if(content.startsWith(RANGE_SEPARATOR, i)) { + return i; + } + } + return -1; + } + /** * Go through a list of symbols and group the expressions together in a * {@link ExpressionSymbol} object. diff --git a/src/main/java/com/cinchapi/ccl/generated/Grammar.java b/src/main/java/com/cinchapi/ccl/generated/Grammar.java index 3b3aa1b2..fa2d98b8 100644 --- a/src/main/java/com/cinchapi/ccl/generated/Grammar.java +++ b/src/main/java/com/cinchapi/ccl/generated/Grammar.java @@ -19,7 +19,6 @@ import com.cinchapi.ccl.grammar.KeyTokenSymbol; import com.cinchapi.ccl.grammar.OperatorSymbol; import com.cinchapi.ccl.grammar.OrderComponentSymbol; -import com.cinchapi.ccl.grammar.TemporalKeySymbol; import com.cinchapi.ccl.grammar.TimestampSymbol; import com.cinchapi.ccl.grammar.ValueSymbol; import com.cinchapi.ccl.grammar.PageSymbol; @@ -1009,7 +1008,7 @@ final public void RelationalExpressionNoTimestamp() throws ParseException {/*@bg final public KeyTokenSymbol Key() throws ParseException {FunctionKeySymbol function; Token key; KeyTokenSymbol result; - TimestampSymbol ts = null; + String parameter = null; if (jj_2_10(2)) { function = KeyFunction(); result = function; @@ -1076,19 +1075,15 @@ final public void RelationalExpressionNoTimestamp() throws ParseException {/*@bg } if (jj_2_11(2)) { jj_consume_token(OPEN_BRACKET); - ts = BracketedTimestamp(); + parameter = KeyBracketParameter(); jj_consume_token(CLOSE_BRACKET); } else { ; } -if(ts == null) { +if(parameter == null) { {if ("" != null) return result;} } - if(result instanceof NavigationKeySymbol) { - {if ("" != null) return NavigationKeySymbol.withTimestampOnLastStop( - (NavigationKeySymbol) result, ts);} - } - {if ("" != null) return new TemporalKeySymbol(result, ts);} + {if ("" != null) return Parsing.applyKeyBracket(result, parameter);} throw new Error("Missing return statement in function"); } @@ -1623,11 +1618,13 @@ final public void RelationalExpressionNoTimestamp() throws ParseException {/*@bg throw new Error("Missing return statement in function"); } -// Bracket-form timestamp: the brackets themselves are the timestamp pivot, -// so the leading at/on/during keyword is optional. The keyword form is -// accepted for backward compatibility. - final public TimestampSymbol BracketedTimestamp() throws ParseException {Token word; - String timestamp = ""; +// Bracket-form key parameter: the raw content between a leaf key's +// brackets. The leading at/on/during keyword is optional (the brackets +// themselves are the pivot) and is consumed without being returned. +// Interpreting the content -- single instant vs. temporal range -- is +// deferred to Parsing.applyKeyBracket so the meaning lives in one place. + final public String KeyBracketParameter() throws ParseException {Token word; + String parameter = ""; switch ((jj_ntk==-1)?jj_ntk_f():jj_ntk) { case TIMESTAMP:{ jj_consume_token(TIMESTAMP); @@ -1673,14 +1670,14 @@ final public void RelationalExpressionNoTimestamp() throws ParseException {/*@bg jj_consume_token(-1); throw new ParseException(); } -timestamp += (timestamp.equals("")) ? word.image : " " + word.image; +parameter += (parameter.equals("")) ? word.image : " " + word.image; if (jj_2_20(2)) { ; } else { break label_10; } } -{if ("" != null) return new TimestampSymbol(NaturalLanguage.parseMicros(timestamp));} +{if ("" != null) return parameter;} throw new Error("Missing return statement in function"); } @@ -5618,6 +5615,24 @@ private boolean jj_2_71(int xla) finally { jj_save(70, xla); } } + private boolean jj_3_29() + { + Token xsp; + xsp = jj_scanpos; + if (jj_scan_token(53)) { + jj_scanpos = xsp; + if (jj_3R_40()) return true; + } + return false; + } + + private boolean jj_3_54() + { + if (jj_3R_55()) return true; + if (jj_scan_token(QUOTED_STRING)) return true; + return false; + } + private boolean jj_3R_22() { if (jj_3R_67()) return true; @@ -5629,6 +5644,18 @@ private boolean jj_3R_22() return false; } + private boolean jj_3R_27() + { + if (jj_scan_token(TIMESTAMP)) return true; + Token xsp; + if (jj_3_19()) return true; + while (true) { + xsp = jj_scanpos; + if (jj_3_19()) { jj_scanpos = xsp; break; } + } + return false; + } + private boolean jj_3_32() { if (jj_3R_42()) return true; @@ -5644,6 +5671,12 @@ private boolean jj_3_32() return false; } + private boolean jj_3R_23() + { + if (jj_3R_69()) return true; + return false; + } + private boolean jj_3R_29() { Token xsp; @@ -5655,12 +5688,6 @@ private boolean jj_3R_29() return false; } - private boolean jj_3R_23() - { - if (jj_3R_69()) return true; - return false; - } - private boolean jj_3R_39() { if (jj_scan_token(NUMERIC)) return true; @@ -5675,12 +5702,6 @@ private boolean jj_3_31() return false; } - private boolean jj_3R_108() - { - if (jj_scan_token(BINARY_OPERATOR)) return true; - return false; - } - private boolean jj_3_71() { Token xsp; @@ -5696,6 +5717,12 @@ private boolean jj_3_71() return false; } + private boolean jj_3R_108() + { + if (jj_scan_token(BINARY_OPERATOR)) return true; + return false; + } + private boolean jj_3_70() { Token xsp; @@ -5715,6 +5742,13 @@ private boolean jj_3_70() return false; } + private boolean jj_3R_65() + { + if (jj_scan_token(NUMERIC)) return true; + if (jj_scan_token(COMMA)) return true; + return false; + } + private boolean jj_3R_106() { Token xsp; @@ -5729,13 +5763,6 @@ private boolean jj_3R_106() return false; } - private boolean jj_3R_65() - { - if (jj_scan_token(NUMERIC)) return true; - if (jj_scan_token(COMMA)) return true; - return false; - } - private boolean jj_3_28() { Token xsp; @@ -5747,13 +5774,6 @@ private boolean jj_3_28() return false; } - private boolean jj_3R_38() - { - if (jj_scan_token(NUMERIC)) return true; - if (jj_scan_token(COMMA)) return true; - return false; - } - private boolean jj_3_2() { Token xsp; @@ -5767,6 +5787,13 @@ private boolean jj_3_2() return false; } + private boolean jj_3R_38() + { + if (jj_scan_token(NUMERIC)) return true; + if (jj_scan_token(COMMA)) return true; + return false; + } + private boolean jj_3R_104() { Token xsp; @@ -5822,6 +5849,20 @@ private boolean jj_3_53() return false; } + private boolean jj_3_1() + { + Token xsp; + xsp = jj_scanpos; + if (jj_scan_token(56)) jj_scanpos = xsp; + if (jj_3R_22()) return true; + xsp = jj_scanpos; + if (jj_3R_23()) jj_scanpos = xsp; + xsp = jj_scanpos; + if (jj_3R_24()) jj_scanpos = xsp; + if (jj_scan_token(102)) return true; + return false; + } + private boolean jj_3R_37() { if (jj_scan_token(NUMERIC)) return true; @@ -5840,20 +5881,6 @@ private boolean jj_3_27() return false; } - private boolean jj_3_1() - { - Token xsp; - xsp = jj_scanpos; - if (jj_scan_token(56)) jj_scanpos = xsp; - if (jj_3R_22()) return true; - xsp = jj_scanpos; - if (jj_3R_23()) jj_scanpos = xsp; - xsp = jj_scanpos; - if (jj_3R_24()) jj_scanpos = xsp; - if (jj_scan_token(102)) return true; - return false; - } - private boolean jj_3R_116() { if (jj_scan_token(QUOTED_STRING)) return true; @@ -5896,6 +5923,17 @@ private boolean jj_3_68() return false; } + private boolean jj_3_26() + { + Token xsp; + xsp = jj_scanpos; + if (jj_scan_token(53)) { + jj_scanpos = xsp; + if (jj_3R_37()) return true; + } + return false; + } + private boolean jj_3R_115() { Token xsp; @@ -5922,17 +5960,6 @@ private boolean jj_3R_115() return false; } - private boolean jj_3_26() - { - Token xsp; - xsp = jj_scanpos; - if (jj_scan_token(53)) { - jj_scanpos = xsp; - if (jj_3R_37()) return true; - } - return false; - } - private boolean jj_3R_109() { Token xsp; @@ -5999,6 +6026,24 @@ private boolean jj_3_51() return false; } + private boolean jj_3_45() + { + Token xsp; + xsp = jj_scanpos; + if (jj_scan_token(53)) { + jj_scanpos = xsp; + if (jj_3R_52()) return true; + } + return false; + } + + private boolean jj_3R_62() + { + if (jj_scan_token(NUMERIC)) return true; + if (jj_scan_token(COMMA)) return true; + return false; + } + private boolean jj_3_16() { Token xsp; @@ -6022,24 +6067,6 @@ private boolean jj_3_16() return false; } - private boolean jj_3_45() - { - Token xsp; - xsp = jj_scanpos; - if (jj_scan_token(53)) { - jj_scanpos = xsp; - if (jj_3R_52()) return true; - } - return false; - } - - private boolean jj_3R_62() - { - if (jj_scan_token(NUMERIC)) return true; - if (jj_scan_token(COMMA)) return true; - return false; - } - private boolean jj_3R_100() { Token xsp; @@ -6062,41 +6089,41 @@ private boolean jj_3_66() return false; } - private boolean jj_3R_94() + private boolean jj_3_50() { + if (jj_3R_42()) return true; Token xsp; xsp = jj_scanpos; - if (jj_3_17()) { + if (jj_scan_token(40)) { jj_scanpos = xsp; - if (jj_3R_100()) { + if (jj_scan_token(37)) { jj_scanpos = xsp; - if (jj_3R_101()) return true; + if (jj_scan_token(39)) return true; } } return false; } - private boolean jj_3_17() - { - if (jj_3R_32()) return true; - return false; - } - - private boolean jj_3_50() + private boolean jj_3R_94() { - if (jj_3R_42()) return true; Token xsp; xsp = jj_scanpos; - if (jj_scan_token(40)) { + if (jj_3_17()) { jj_scanpos = xsp; - if (jj_scan_token(37)) { + if (jj_3R_100()) { jj_scanpos = xsp; - if (jj_scan_token(39)) return true; + if (jj_3R_101()) return true; } } return false; } + private boolean jj_3_17() + { + if (jj_3R_32()) return true; + return false; + } + private boolean jj_3_49() { if (jj_3R_43()) return true; @@ -6255,12 +6282,6 @@ private boolean jj_3R_105() return false; } - private boolean jj_3R_103() - { - if (jj_scan_token(NUMERIC)) return true; - return false; - } - private boolean jj_3R_61() { if (jj_scan_token(NUMERIC)) return true; @@ -6279,6 +6300,12 @@ private boolean jj_3_39() return false; } + private boolean jj_3R_103() + { + if (jj_scan_token(NUMERIC)) return true; + return false; + } + private boolean jj_3R_36() { if (jj_scan_token(OPEN_BRACKET)) return true; @@ -6371,32 +6398,6 @@ private boolean jj_3_64() return false; } - private boolean jj_3_12() - { - Token xsp; - xsp = jj_scanpos; - if (jj_scan_token(90)) { - jj_scanpos = xsp; - if (jj_scan_token(91)) { - jj_scanpos = xsp; - if (jj_scan_token(88)) { - jj_scanpos = xsp; - if (jj_scan_token(92)) { - jj_scanpos = xsp; - if (jj_scan_token(97)) { - jj_scanpos = xsp; - if (jj_scan_token(93)) { - jj_scanpos = xsp; - if (jj_scan_token(96)) return true; - } - } - } - } - } - } - return false; - } - private boolean jj_3_43() { if (jj_3R_43()) return true; @@ -6434,18 +6435,7 @@ private boolean jj_3_40() return false; } - private boolean jj_3R_113() - { - Token xsp; - if (jj_3_12()) return true; - while (true) { - xsp = jj_scanpos; - if (jj_3_12()) { jj_scanpos = xsp; break; } - } - return false; - } - - private boolean jj_3_24() + private boolean jj_3_12() { Token xsp; xsp = jj_scanpos; @@ -6453,9 +6443,46 @@ private boolean jj_3_24() jj_scanpos = xsp; if (jj_scan_token(91)) { jj_scanpos = xsp; + if (jj_scan_token(88)) { + jj_scanpos = xsp; if (jj_scan_token(92)) { jj_scanpos = xsp; - if (jj_scan_token(93)) { + if (jj_scan_token(97)) { + jj_scanpos = xsp; + if (jj_scan_token(93)) { + jj_scanpos = xsp; + if (jj_scan_token(96)) return true; + } + } + } + } + } + } + return false; + } + + private boolean jj_3R_113() + { + Token xsp; + if (jj_3_12()) return true; + while (true) { + xsp = jj_scanpos; + if (jj_3_12()) { jj_scanpos = xsp; break; } + } + return false; + } + + private boolean jj_3_24() + { + Token xsp; + xsp = jj_scanpos; + if (jj_scan_token(90)) { + jj_scanpos = xsp; + if (jj_scan_token(91)) { + jj_scanpos = xsp; + if (jj_scan_token(92)) { + jj_scanpos = xsp; + if (jj_scan_token(93)) { jj_scanpos = xsp; if (jj_scan_token(96)) return true; } @@ -6570,6 +6597,14 @@ private boolean jj_3_42() return false; } + private boolean jj_3_11() + { + if (jj_scan_token(OPEN_BRACKET)) return true; + if (jj_3R_31()) return true; + if (jj_scan_token(CLOSE_BRACKET)) return true; + return false; + } + private boolean jj_3_63() { if (jj_3R_43()) return true; @@ -6582,14 +6617,6 @@ private boolean jj_3_63() return false; } - private boolean jj_3_11() - { - if (jj_scan_token(OPEN_BRACKET)) return true; - if (jj_3R_31()) return true; - if (jj_scan_token(CLOSE_BRACKET)) return true; - return false; - } - private boolean jj_3_38() { Token xsp; @@ -6620,13 +6647,6 @@ private boolean jj_3R_76() return false; } - private boolean jj_3_41() - { - if (jj_scan_token(OPEN_BRACKET)) return true; - if (jj_scan_token(NUMERIC)) return true; - return false; - } - private boolean jj_3R_75() { Token xsp; @@ -6647,9 +6667,10 @@ private boolean jj_3R_75() return false; } - private boolean jj_3_23() + private boolean jj_3_41() { - if (jj_3R_30()) return true; + if (jj_scan_token(OPEN_BRACKET)) return true; + if (jj_scan_token(NUMERIC)) return true; return false; } @@ -6659,14 +6680,9 @@ private boolean jj_3_10() return false; } - private boolean jj_3_62() + private boolean jj_3_23() { - Token xsp; - xsp = jj_scanpos; - if (jj_scan_token(53)) { - jj_scanpos = xsp; - if (jj_3R_60()) return true; - } + if (jj_3R_30()) return true; return false; } @@ -6686,6 +6702,17 @@ private boolean jj_3R_43() return false; } + private boolean jj_3_62() + { + Token xsp; + xsp = jj_scanpos; + if (jj_scan_token(53)) { + jj_scanpos = xsp; + if (jj_3R_60()) return true; + } + return false; + } + private boolean jj_3R_59() { if (jj_3R_43()) return true; @@ -6759,6 +6786,13 @@ private boolean jj_3_6() return false; } + private boolean jj_3_9() + { + if (jj_3R_28()) return true; + if (jj_3R_29()) return true; + return false; + } + private boolean jj_3_60() { Token xsp; @@ -6770,13 +6804,6 @@ private boolean jj_3_60() return false; } - private boolean jj_3_9() - { - if (jj_3R_28()) return true; - if (jj_3R_29()) return true; - return false; - } - private boolean jj_3R_45() { if (jj_scan_token(NUMERIC)) return true; @@ -6796,26 +6823,26 @@ private boolean jj_3_4() return false; } - private boolean jj_3_36() + private boolean jj_3_3() { - Token xsp; - xsp = jj_scanpos; - if (jj_scan_token(53)) { - jj_scanpos = xsp; - if (jj_3R_45()) return true; - } + if (jj_3R_27()) return true; return false; } - private boolean jj_3_3() + private boolean jj_3_5() { if (jj_3R_27()) return true; return false; } - private boolean jj_3_5() + private boolean jj_3_36() { - if (jj_3R_27()) return true; + Token xsp; + xsp = jj_scanpos; + if (jj_scan_token(53)) { + jj_scanpos = xsp; + if (jj_3R_45()) return true; + } return false; } @@ -6904,6 +6931,14 @@ private boolean jj_3R_81() return false; } + private boolean jj_3R_98() + { + if (jj_3R_108()) return true; + if (jj_3R_109()) return true; + if (jj_3R_109()) return true; + return false; + } + private boolean jj_3R_80() { if (jj_3R_89()) return true; @@ -6921,11 +6956,13 @@ private boolean jj_3_57() return false; } - private boolean jj_3R_98() + private boolean jj_3R_97() { - if (jj_3R_108()) return true; - if (jj_3R_109()) return true; - if (jj_3R_109()) return true; + if (jj_3R_106()) return true; + if (jj_3R_107()) return true; + Token xsp; + xsp = jj_scanpos; + if (jj_3_5()) jj_scanpos = xsp; return false; } @@ -6940,16 +6977,6 @@ private boolean jj_3R_70() return false; } - private boolean jj_3R_97() - { - if (jj_3R_106()) return true; - if (jj_3R_107()) return true; - Token xsp; - xsp = jj_scanpos; - if (jj_3_5()) jj_scanpos = xsp; - return false; - } - private boolean jj_3R_96() { if (jj_3R_104()) return true; @@ -6991,6 +7018,14 @@ private boolean jj_3R_93() return false; } + private boolean jj_3_8() + { + if (jj_3R_28()) return true; + if (jj_3R_29()) return true; + if (jj_3R_94()) return true; + return false; + } + private boolean jj_3_21() { Token xsp; @@ -7017,14 +7052,6 @@ private boolean jj_3_21() return false; } - private boolean jj_3_8() - { - if (jj_3R_28()) return true; - if (jj_3R_29()) return true; - if (jj_3R_94()) return true; - return false; - } - private boolean jj_3R_55() { Token xsp; @@ -7146,17 +7173,6 @@ private boolean jj_3R_41() return false; } - private boolean jj_3R_42() - { - Token xsp; - xsp = jj_scanpos; - if (jj_3R_73()) { - jj_scanpos = xsp; - if (jj_3R_74()) return true; - } - return false; - } - private boolean jj_3R_79() { if (jj_scan_token(CONJUNCTION)) return true; @@ -7169,10 +7185,14 @@ private boolean jj_3R_68() return false; } - private boolean jj_3R_66() + private boolean jj_3R_42() { - if (jj_scan_token(NUMERIC)) return true; - if (jj_scan_token(COMMA)) return true; + Token xsp; + xsp = jj_scanpos; + if (jj_3R_73()) { + jj_scanpos = xsp; + if (jj_3R_74()) return true; + } return false; } @@ -7183,6 +7203,13 @@ private boolean jj_3R_91() return false; } + private boolean jj_3R_66() + { + if (jj_scan_token(NUMERIC)) return true; + if (jj_scan_token(COMMA)) return true; + return false; + } + private boolean jj_3_56() { if (jj_scan_token(NUMERIC)) return true; @@ -7206,16 +7233,22 @@ private boolean jj_3R_85() return false; } - private boolean jj_3R_40() + private boolean jj_3R_26() { - if (jj_scan_token(NUMERIC)) return true; - if (jj_scan_token(COMMA)) return true; + if (jj_3R_70()) return true; return false; } - private boolean jj_3R_26() + private boolean jj_3R_88() { - if (jj_3R_70()) return true; + if (jj_3R_92()) return true; + return false; + } + + private boolean jj_3R_40() + { + if (jj_scan_token(NUMERIC)) return true; + if (jj_scan_token(COMMA)) return true; return false; } @@ -7226,13 +7259,14 @@ private boolean jj_3_34() return false; } - private boolean jj_3R_88() + private boolean jj_3R_87() { - if (jj_3R_92()) return true; + if (jj_scan_token(OPEN_PARENTHESES)) return true; + if (jj_3R_22()) return true; return false; } - private boolean jj_3_19() + private boolean jj_3_20() { Token xsp; xsp = jj_scanpos; @@ -7258,7 +7292,33 @@ private boolean jj_3_19() return false; } - private boolean jj_3_20() + private boolean jj_3R_84() + { + if (jj_scan_token(OPEN_BRACKET)) return true; + return false; + } + + private boolean jj_3R_78() + { + Token xsp; + xsp = jj_scanpos; + if (jj_3R_86()) { + jj_scanpos = xsp; + if (jj_3R_87()) { + jj_scanpos = xsp; + if (jj_3R_88()) return true; + } + } + return false; + } + + private boolean jj_3R_86() + { + if (jj_3R_91()) return true; + return false; + } + + private boolean jj_3_19() { Token xsp; xsp = jj_scanpos; @@ -7284,19 +7344,6 @@ private boolean jj_3_20() return false; } - private boolean jj_3R_84() - { - if (jj_scan_token(OPEN_BRACKET)) return true; - return false; - } - - private boolean jj_3R_87() - { - if (jj_scan_token(OPEN_PARENTHESES)) return true; - if (jj_3R_22()) return true; - return false; - } - private boolean jj_3R_31() { Token xsp; @@ -7321,26 +7368,6 @@ private boolean jj_3_30() return false; } - private boolean jj_3R_78() - { - Token xsp; - xsp = jj_scanpos; - if (jj_3R_86()) { - jj_scanpos = xsp; - if (jj_3R_87()) { - jj_scanpos = xsp; - if (jj_3R_88()) return true; - } - } - return false; - } - - private boolean jj_3R_86() - { - if (jj_3R_91()) return true; - return false; - } - private boolean jj_3R_77() { Token xsp; @@ -7358,21 +7385,6 @@ private boolean jj_3R_25() return false; } - private boolean jj_3_33() - { - if (jj_3R_43()) return true; - Token xsp; - xsp = jj_scanpos; - if (jj_scan_token(40)) { - jj_scanpos = xsp; - if (jj_scan_token(37)) { - jj_scanpos = xsp; - if (jj_scan_token(39)) return true; - } - } - return false; - } - private boolean jj_3R_67() { if (jj_3R_78()) return true; @@ -7384,39 +7396,24 @@ private boolean jj_3R_67() return false; } - private boolean jj_3R_24() - { - if (jj_3R_70()) return true; - return false; - } - - private boolean jj_3R_27() - { - if (jj_scan_token(TIMESTAMP)) return true; - Token xsp; - if (jj_3_19()) return true; - while (true) { - xsp = jj_scanpos; - if (jj_3_19()) { jj_scanpos = xsp; break; } - } - return false; - } - - private boolean jj_3_29() + private boolean jj_3_33() { + if (jj_3R_43()) return true; Token xsp; xsp = jj_scanpos; - if (jj_scan_token(53)) { + if (jj_scan_token(40)) { jj_scanpos = xsp; - if (jj_3R_40()) return true; + if (jj_scan_token(37)) { + jj_scanpos = xsp; + if (jj_scan_token(39)) return true; + } } return false; } - private boolean jj_3_54() + private boolean jj_3R_24() { - if (jj_3R_55()) return true; - if (jj_scan_token(QUOTED_STRING)) return true; + if (jj_3R_70()) return true; return false; } diff --git a/src/main/java/com/cinchapi/ccl/generated/GrammarTokenManager.java b/src/main/java/com/cinchapi/ccl/generated/GrammarTokenManager.java index bf9206fc..acd44540 100644 --- a/src/main/java/com/cinchapi/ccl/generated/GrammarTokenManager.java +++ b/src/main/java/com/cinchapi/ccl/generated/GrammarTokenManager.java @@ -18,7 +18,6 @@ import com.cinchapi.ccl.grammar.KeyTokenSymbol; import com.cinchapi.ccl.grammar.OperatorSymbol; import com.cinchapi.ccl.grammar.OrderComponentSymbol; -import com.cinchapi.ccl.grammar.TemporalKeySymbol; import com.cinchapi.ccl.grammar.TimestampSymbol; import com.cinchapi.ccl.grammar.ValueSymbol; import com.cinchapi.ccl.grammar.PageSymbol; diff --git a/src/main/java/com/cinchapi/ccl/grammar/TemporalRangeKeySymbol.java b/src/main/java/com/cinchapi/ccl/grammar/TemporalRangeKeySymbol.java new file mode 100644 index 00000000..13949c45 --- /dev/null +++ b/src/main/java/com/cinchapi/ccl/grammar/TemporalRangeKeySymbol.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2013-2026 Cinchapi Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.cinchapi.ccl.grammar; + +import java.util.Objects; + +import javax.annotation.Nullable; + +import com.google.common.base.Preconditions; + +/** + * A {@link TemporalRangeKeySymbol} pairs a {@link KeyTokenSymbol} with a + * half-open temporal interval {@code [start, end)} rather than the single + * instant a {@link TemporalKeySymbol} pins. The interval is + * start-inclusive and end-exclusive, matching the {@code BETWEEN} + * convention used elsewhere for time windows. + * + *

Either endpoint may be absent to express an open-ended interval: + * an absent {@code start} is the open-start form ({@code key[...end]}), + * and an absent {@code end} is the open-end form ({@code key[start...]}). + * At least one endpoint is always present. + * + *

This symbol records the endpoints only; it does not decide what a + * range read returns. That evaluation lives server-side and is out of + * scope for the grammar layer. + * + *

As with {@link TemporalKeySymbol}, a {@link NavigationKeySymbol} is + * never wrapped and an already-parameterized key is rejected, enforcing + * the "at most one bracket-timestamp parameter per key" invariant the + * rest of the AST relies on. + * + * @author Jeff Nelson + */ +public final class TemporalRangeKeySymbol + extends KeyTokenSymbol> { + + /** + * The inclusive start of the interval, or {@code null} for the + * open-start form. + */ + @Nullable + private final TimestampSymbol start; + + /** + * The exclusive end of the interval, or {@code null} for the open-end + * form. + */ + @Nullable + private final TimestampSymbol end; + + /** + * Construct a new {@link TemporalRangeKeySymbol}. + * + * @param key the wrapped {@link KeyTokenSymbol}; must not be a + * {@link NavigationKeySymbol} (navigation timestamps live + * on the path's stops) and must not already be + * parameterized (a key carries at most one + * bracket-timestamp parameter) + * @param start the inclusive start of the interval, or {@code null} + * for the open-start form + * @param end the exclusive end of the interval, or {@code null} for + * the open-end form + * @throws IllegalArgumentException if {@code key} is a + * {@link NavigationKeySymbol} or already parameterized, + * if both endpoints are absent, or if {@code start} is + * strictly greater than {@code end} + */ + public TemporalRangeKeySymbol(KeyTokenSymbol key, + @Nullable TimestampSymbol start, @Nullable TimestampSymbol end) { + super(Preconditions.checkNotNull(key)); + Preconditions.checkArgument(!(key instanceof NavigationKeySymbol), + "TemporalRangeKeySymbol cannot wrap a NavigationKeySymbol; " + + "temporal-range bindings on navigation keys are " + + "not yet supported"); + Preconditions.checkArgument(!key.isParameterized(), + "TemporalRangeKeySymbol cannot wrap an already-parameterized " + + "key; a key carries at most one bracket-timestamp " + + "parameter"); + Preconditions.checkArgument(start != null || end != null, + "a temporal range must pin at least one endpoint"); + Preconditions.checkArgument( + start == null || end == null + || start.timestamp() <= end.timestamp(), + "a temporal range cannot start after it ends"); + this.start = start; + this.end = end; + } + + /** + * Return the inclusive start of the interval, or {@code null} for the + * open-start form. + * + * @return the start {@link TimestampSymbol} or {@code null} + */ + @Nullable + public TimestampSymbol start() { + return start; + } + + /** + * Return the exclusive end of the interval, or {@code null} for the + * open-end form. + * + * @return the end {@link TimestampSymbol} or {@code null} + */ + @Nullable + public TimestampSymbol end() { + return end; + } + + @Override + public boolean equals(Object obj) { + if(this == obj) { + return true; + } + else if(!(obj instanceof TemporalRangeKeySymbol)) { + return false; + } + TemporalRangeKeySymbol other = (TemporalRangeKeySymbol) obj; + return key.equals(other.key) && Objects.equals(start, other.start) + && Objects.equals(end, other.end); + } + + @Override + public int hashCode() { + return Objects.hash(key, start, end); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(key.toString()).append('['); + if(start != null) { + sb.append(start.timestamp()); + } + sb.append("..."); + if(end != null) { + sb.append(end.timestamp()); + } + return sb.append(']').toString(); + } + + @Override + public String baseKey() { + return key.baseKey(); + } + + @Override + public boolean isParameterized() { + return true; + } + + @Override + public KeyTokenSymbol stripParameters() { + return key.stripParameters(); + } + +} diff --git a/src/test/java/com/cinchapi/ccl/BracketTimestampCommandTest.java b/src/test/java/com/cinchapi/ccl/BracketTimestampCommandTest.java index 51156776..bb706fcb 100644 --- a/src/test/java/com/cinchapi/ccl/BracketTimestampCommandTest.java +++ b/src/test/java/com/cinchapi/ccl/BracketTimestampCommandTest.java @@ -29,6 +29,7 @@ import com.cinchapi.ccl.grammar.OrderComponentSymbol; import com.cinchapi.ccl.grammar.OrderSymbol; import com.cinchapi.ccl.grammar.TemporalKeySymbol; +import com.cinchapi.ccl.grammar.TemporalRangeKeySymbol; import com.cinchapi.ccl.grammar.command.BrowseSymbol; import com.cinchapi.ccl.grammar.command.CalculateSymbol; import com.cinchapi.ccl.grammar.command.GetSymbol; @@ -174,6 +175,49 @@ public void testFindOrInsertAcceptsBracketInCriterion() { Assert.assertTrue(tree instanceof CommandTree); } + @Test + public void testSelectRangeKeyBracket() { + SelectSymbol cmd = parseCommand( + String.format("select name[%d...%d] from 1", T1, T2), + SelectSymbol.class); + assertTemporalRangeKey(cmd.keys().iterator().next(), "name", T1, T2); + } + + @Test + public void testGetRangeKeyBracket() { + GetSymbol cmd = parseCommand( + String.format("get name[%d...%d] from 1", T1, T2), + GetSymbol.class); + assertTemporalRangeKey(cmd.keys().iterator().next(), "name", T1, T2); + } + + @Test + public void testFindRangeKeyBracket() { + AbstractSyntaxTree tree = compiler().parse( + String.format("find name[%d...%d] = \"jeff\"", T1, T2)); + Assert.assertTrue(tree instanceof CommandTree); + com.cinchapi.ccl.syntax.ConditionTree cond = + ((CommandTree) tree).conditionTree(); + com.cinchapi.ccl.grammar.ExpressionSymbol expr = + (com.cinchapi.ccl.grammar.ExpressionSymbol) + ((com.cinchapi.ccl.syntax.ExpressionTree) cond).root(); + assertTemporalRangeKey(expr.key(), "name", T1, T2); + } + + @Test + public void testAddRejectsRangeKey() { + assertCommandRejected( + String.format("add name[%d...%d] as \"jeff\" in 1", T1, T2), + "add command"); + } + + @Test + public void testAuditRejectsRangeKey() { + assertCommandRejected( + String.format("audit name[%d...%d] in 1", T1, T2), + "audit command"); + } + @Test public void testAddRejectsBracketKey() { assertCommandRejected( @@ -478,6 +522,19 @@ private void assertTemporalKey(KeyTokenSymbol key, String expectedKey, Assert.assertEquals(expectedTs, temporal.timestamp().timestamp()); } + private void assertTemporalRangeKey(KeyTokenSymbol key, + String expectedKey, long expectedStart, long expectedEnd) { + Assert.assertTrue( + "expected TemporalRangeKeySymbol but got " + + key.getClass().getSimpleName(), + key instanceof TemporalRangeKeySymbol); + TemporalRangeKeySymbol range = (TemporalRangeKeySymbol) key; + Assert.assertTrue(range.key() instanceof KeySymbol); + Assert.assertEquals(expectedKey, ((KeySymbol) range.key()).key()); + Assert.assertEquals(expectedStart, range.start().timestamp()); + Assert.assertEquals(expectedEnd, range.end().timestamp()); + } + private static final Function VALUE_FN = Convert::stringToJava; private static final Function OP_FN = Convert::stringToOperator; diff --git a/src/test/java/com/cinchapi/ccl/BracketTimestampMatrixTest.java b/src/test/java/com/cinchapi/ccl/BracketTimestampMatrixTest.java index 892e7c35..0fa83b30 100644 --- a/src/test/java/com/cinchapi/ccl/BracketTimestampMatrixTest.java +++ b/src/test/java/com/cinchapi/ccl/BracketTimestampMatrixTest.java @@ -30,6 +30,7 @@ import com.cinchapi.ccl.grammar.NavigationKeySymbol; import com.cinchapi.ccl.grammar.Symbol; import com.cinchapi.ccl.grammar.TemporalKeySymbol; +import com.cinchapi.ccl.grammar.TemporalRangeKeySymbol; import com.cinchapi.ccl.grammar.TimestampSymbol; import com.cinchapi.ccl.syntax.AbstractSyntaxTree; import com.cinchapi.ccl.syntax.AndTree; @@ -159,6 +160,193 @@ public void testF7_DoubleBracketOnLeafRejected() { } } + /** + * Goal: Verify that a closed temporal range on a + * flat leaf ({@code foo[t1...t2] = X}) parses to a + * {@link TemporalRangeKeySymbol} carrying both endpoints. + */ + @Test + public void testR1_ClosedRange() { + String ccl = String.format("foo[%d...%d] = \"X\"", T1, T2); + assertLeafKeyTemporalRange(parseExpression(ccl), "foo", T1, T2); + } + + /** + * Goal: Verify that an open-start temporal range + * ({@code foo[...t2] = X}) parses to a + * {@link TemporalRangeKeySymbol} with a null start and the given + * end. + */ + @Test + public void testR2_OpenStartRange() { + String ccl = String.format("foo[...%d] = \"X\"", T2); + ExpressionSymbol expr = parseExpression(ccl); + TemporalRangeKeySymbol range = assertLeafKeyRange(expr, "foo"); + Assert.assertNull(range.start()); + Assert.assertEquals(T2, range.end().timestamp()); + } + + /** + * Goal: Verify that an open-end temporal range + * ({@code foo[t1...] = X}) parses to a + * {@link TemporalRangeKeySymbol} with the given start and a null + * end. + */ + @Test + public void testR3_OpenEndRange() { + String ccl = String.format("foo[%d...] = \"X\"", T1); + ExpressionSymbol expr = parseExpression(ccl); + TemporalRangeKeySymbol range = assertLeafKeyRange(expr, "foo"); + Assert.assertEquals(T1, range.start().timestamp()); + Assert.assertNull(range.end()); + } + + /** + * Goal: Verify that a range pinning neither + * endpoint ({@code foo[...] = X}) is rejected at parse time. + */ + @Test + public void testR4_FullyOpenRangeRejected() { + String ccl = "foo[...] = \"X\""; + try { + compiler().parse(ccl); + Assert.fail("expected SyntaxException for fully-open range " + + "in: " + ccl); + } + catch (SyntaxException e) { + Assert.assertTrue( + "expected message to mention 'at least one endpoint' " + + "but was: " + e.getMessage(), + e.getMessage().contains("at least one endpoint")); + } + } + + /** + * Goal: Verify that a backwards range whose start + * follows its end ({@code foo[t2...t1] = X}) is rejected at parse + * time. + */ + @Test + public void testR5_BackwardsRangeRejected() { + String ccl = String.format("foo[%d...%d] = \"X\"", T2, T1); + try { + compiler().parse(ccl); + Assert.fail("expected SyntaxException for backwards range " + + "in: " + ccl); + } + catch (SyntaxException e) { + Assert.assertTrue( + "expected message to mention 'start after it ends' " + + "but was: " + e.getMessage(), + e.getMessage().contains("start after it ends")); + } + } + + /** + * Goal: Verify that a single bracket followed by a + * range bracket on one leaf ({@code foo[t1][t2...t3] = X}) is + * rejected, since {@code Key()} accepts at most one trailing + * bracket. + */ + @Test + public void testR6_RangeAfterSingleBracketRejected() { + String ccl = String.format("foo[%d][%d...%d] = \"X\"", T1, T2, T3); + try { + compiler().parse(ccl); + Assert.fail("expected SyntaxException for double bracket with " + + "range in: " + ccl); + } + catch (SyntaxException e) { + // Pass — Key()'s optional bracket cannot fire twice. + } + } + + /** + * Goal: Verify lossless round-trip of the three + * range shapes (closed, open-start, open-end) through + * {@code tokenize} then re-parse. + */ + @Test + public void testRoundTripRange() { + assertRoundTrip(String.format("foo[%d...%d] = \"X\"", T1, T2)); + assertRoundTrip(String.format("foo[...%d] = \"X\"", T2)); + assertRoundTrip(String.format("foo[%d...] = \"X\"", T1)); + } + + /** + * Goal: Verify that a range whose endpoints are + * quoted date strings ({@code score["2024-01-01"..."2024-02-01"]}) + * parses to a {@link TemporalRangeKeySymbol} carrying both resolved + * endpoints. This exercises the {@code QUOTED_STRING} token path, + * which the all-numeric range tests do not. Endpoints are compared + * at day precision because date strings resolve relative to the + * current instant (see {@code testL1_LeafNaturalLanguageBracket}). + */ + @Test + public void testR7_QuotedDateEndpoints() { + TimestampSymbol expectedStart = new TimestampSymbol( + NaturalLanguage.parseMicros("2024-01-01"), TimeUnit.DAYS); + TimestampSymbol expectedEnd = new TimestampSymbol( + NaturalLanguage.parseMicros("2024-02-01"), TimeUnit.DAYS); + ExpressionSymbol expr = parseExpression( + "score[\"2024-01-01\"...\"2024-02-01\"] = \"X\""); + TemporalRangeKeySymbol range = assertLeafKeyRange(expr, "score"); + Assert.assertEquals(expectedStart, range.start()); + Assert.assertEquals(expectedEnd, range.end()); + } + + /** + * Goal: Verify that an open-end range whose start is + * a natural-language phrase ({@code foo[last week...]}) parses to a + * {@link TemporalRangeKeySymbol} routed through + * {@link NaturalLanguage#parseMicros}, with a null end. Compared at + * day precision because the phrase is anchored to the current + * instant. + */ + @Test + public void testR8_NaturalLanguageOpenEnd() { + TimestampSymbol expectedStart = new TimestampSymbol( + NaturalLanguage.parseMicros("last week"), TimeUnit.DAYS); + ExpressionSymbol expr = parseExpression("foo[last week...] = \"X\""); + TemporalRangeKeySymbol range = assertLeafKeyRange(expr, "foo"); + Assert.assertEquals(expectedStart, range.start()); + Assert.assertNull(range.end()); + } + + /** + * Goal: Verify that content carrying more than one + * range separator ({@code foo[t1...t2...t3]}) is rejected at parse + * time rather than silently splitting on the first {@code ...} and + * mis-parsing the remainder as one endpoint. + */ + @Test + public void testR9_MultipleSeparatorsRejected() { + String ccl = String.format("foo[%d...%d...%d] = \"X\"", T1, T2, T3); + try { + compiler().parse(ccl); + Assert.fail("expected SyntaxException for multiple range " + + "separators in: " + ccl); + } + catch (SyntaxException e) { + Assert.assertTrue( + "expected message to mention 'more than one' but was: " + + e.getMessage(), + e.getMessage().contains("more than one")); + } + } + + /** + * Goal: Verify lossless round-trip of quoted and + * natural-language range endpoints. The canonical {@code toString()} + * renders both endpoints as resolved microseconds, so re-parsing the + * re-emitted form must yield an equal AST. + */ + @Test + public void testRoundTripRangeResolvedEndpoints() { + assertRoundTrip("score[\"2024-01-01\"...\"2024-02-01\"] = \"X\""); + assertRoundTrip("foo[last week...] = \"X\""); + } + /** * Goal: Verify that an unbracketed navigation key * ({@code a.foo = X}) parses to a {@link NavigationKeySymbol} @@ -870,6 +1058,27 @@ private void assertLeafKeyTemporal(AbstractSyntaxTree leaf, temporal.timestamp().timestamp()); } + private TemporalRangeKeySymbol assertLeafKeyRange(ExpressionSymbol expr, + String expectedKey) { + KeyTokenSymbol key = expr.key(); + Assert.assertTrue( + "expected TemporalRangeKeySymbol but was " + + key.getClass().getSimpleName(), + key instanceof TemporalRangeKeySymbol); + TemporalRangeKeySymbol range = (TemporalRangeKeySymbol) key; + Assert.assertTrue(range.key() instanceof KeySymbol); + Assert.assertEquals(expectedKey, + ((KeySymbol) range.key()).key().toString()); + return range; + } + + private void assertLeafKeyTemporalRange(ExpressionSymbol expr, + String expectedKey, long expectedStart, long expectedEnd) { + TemporalRangeKeySymbol range = assertLeafKeyRange(expr, expectedKey); + Assert.assertEquals(expectedStart, range.start().timestamp()); + Assert.assertEquals(expectedEnd, range.end().timestamp()); + } + private void assertLeafKeyUnstamped(AbstractSyntaxTree leaf) { ExpressionTree exprTree = (ExpressionTree) leaf; ExpressionSymbol expr = (ExpressionSymbol) exprTree.root(); diff --git a/src/test/java/com/cinchapi/ccl/grammar/TemporalRangeKeySymbolTest.java b/src/test/java/com/cinchapi/ccl/grammar/TemporalRangeKeySymbolTest.java new file mode 100644 index 00000000..72651ed6 --- /dev/null +++ b/src/test/java/com/cinchapi/ccl/grammar/TemporalRangeKeySymbolTest.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2013-2026 Cinchapi Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.cinchapi.ccl.grammar; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Unit tests for {@link TemporalRangeKeySymbol}. + * + * @author Jeff Nelson + */ +public class TemporalRangeKeySymbolTest { + + @Test(expected = NullPointerException.class) + public void testConstructRejectsNullKey() { + new TemporalRangeKeySymbol(null, new TimestampSymbol(1L), + new TimestampSymbol(2L)); + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructRejectsBothEndpointsAbsent() { + new TemporalRangeKeySymbol(new KeySymbol("foo"), null, null); + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructRejectsBackwardsRange() { + new TemporalRangeKeySymbol(new KeySymbol("foo"), + new TimestampSymbol(2L), new TimestampSymbol(1L)); + } + + @Test + public void testConstructAcceptsEmptyRangeWhenStartEqualsEnd() { + TemporalRangeKeySymbol symbol = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(1L)); + Assert.assertEquals(1L, symbol.start().timestamp()); + Assert.assertEquals(1L, symbol.end().timestamp()); + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructRejectsNavigationKeySymbolWrapping() { + new TemporalRangeKeySymbol(new NavigationKeySymbol("a.b"), + new TimestampSymbol(1L), new TimestampSymbol(2L)); + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructRejectsParameterizedKeyWrapping() { + TemporalKeySymbol inner = new TemporalKeySymbol(new KeySymbol("foo"), + new TimestampSymbol(1L)); + new TemporalRangeKeySymbol(inner, new TimestampSymbol(2L), + new TimestampSymbol(3L)); + } + + @Test + public void testEndpointAccessors() { + TimestampSymbol start = new TimestampSymbol(1L); + TimestampSymbol end = new TimestampSymbol(2L); + TemporalRangeKeySymbol symbol = new TemporalRangeKeySymbol( + new KeySymbol("foo"), start, end); + Assert.assertSame(start, symbol.start()); + Assert.assertSame(end, symbol.end()); + } + + @Test + public void testOpenStartHasNullStart() { + TemporalRangeKeySymbol symbol = new TemporalRangeKeySymbol( + new KeySymbol("foo"), null, new TimestampSymbol(2L)); + Assert.assertNull(symbol.start()); + Assert.assertEquals(2L, symbol.end().timestamp()); + } + + @Test + public void testOpenEndHasNullEnd() { + TemporalRangeKeySymbol symbol = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), null); + Assert.assertEquals(1L, symbol.start().timestamp()); + Assert.assertNull(symbol.end()); + } + + @Test + public void testToStringClosedRange() { + TemporalRangeKeySymbol symbol = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(2L)); + Assert.assertEquals("foo[1...2]", symbol.toString()); + } + + @Test + public void testToStringOpenStartRange() { + TemporalRangeKeySymbol symbol = new TemporalRangeKeySymbol( + new KeySymbol("foo"), null, new TimestampSymbol(2L)); + Assert.assertEquals("foo[...2]", symbol.toString()); + } + + @Test + public void testToStringOpenEndRange() { + TemporalRangeKeySymbol symbol = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), null); + Assert.assertEquals("foo[1...]", symbol.toString()); + } + + @Test + public void testEqualsWhenKeyAndEndpointsMatch() { + TemporalRangeKeySymbol a = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(2L)); + TemporalRangeKeySymbol b = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(2L)); + Assert.assertEquals(a, b); + Assert.assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void testNotEqualsWhenStartDiffers() { + TemporalRangeKeySymbol a = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(3L)); + TemporalRangeKeySymbol b = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(2L), + new TimestampSymbol(3L)); + Assert.assertNotEquals(a, b); + } + + @Test + public void testNotEqualsWhenEndDiffers() { + TemporalRangeKeySymbol a = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(2L)); + TemporalRangeKeySymbol b = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(3L)); + Assert.assertNotEquals(a, b); + } + + @Test + public void testNotEqualsWhenOpennessDiffers() { + TemporalRangeKeySymbol closed = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(2L)); + TemporalRangeKeySymbol openStart = new TemporalRangeKeySymbol( + new KeySymbol("foo"), null, new TimestampSymbol(2L)); + Assert.assertNotEquals(closed, openStart); + } + + @Test + public void testNotEqualsWhenKeyDiffers() { + TemporalRangeKeySymbol a = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(2L)); + TemporalRangeKeySymbol b = new TemporalRangeKeySymbol( + new KeySymbol("bar"), new TimestampSymbol(1L), + new TimestampSymbol(2L)); + Assert.assertNotEquals(a, b); + } + + @Test + public void testNotEqualsToTemporalKeySymbol() { + TemporalRangeKeySymbol range = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(2L)); + Assert.assertNotEquals(range, + new TemporalKeySymbol(new KeySymbol("foo"), + new TimestampSymbol(1L))); + } + + @Test + public void testBaseKeyDelegatesToWrappedKey() { + TemporalRangeKeySymbol symbol = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(2L)); + Assert.assertEquals("foo", symbol.baseKey()); + } + + @Test + public void testIsParameterized() { + TemporalRangeKeySymbol symbol = new TemporalRangeKeySymbol( + new KeySymbol("foo"), new TimestampSymbol(1L), + new TimestampSymbol(2L)); + Assert.assertTrue(symbol.isParameterized()); + } + + @Test + public void testStripParametersReturnsWrappedKey() { + KeySymbol inner = new KeySymbol("foo"); + TemporalRangeKeySymbol symbol = new TemporalRangeKeySymbol(inner, + new TimestampSymbol(1L), new TimestampSymbol(2L)); + Assert.assertEquals(inner, symbol.stripParameters()); + } + +}