diff --git a/bql/grammar/grammar.go b/bql/grammar/grammar.go index ec85d8d7..fa71d5d4 100644 --- a/bql/grammar/grammar.go +++ b/bql/grammar/grammar.go @@ -327,6 +327,7 @@ func moreClauses() []*Clause { Elements: []Element{ NewTokenType(lexer.ItemDot), NewSymbol("CLAUSES"), + NewSymbol("FILTER_CLAUSES"), }, }, {}, @@ -365,6 +366,33 @@ func clauses() []*Clause { } } +func moreFilterClauses() []*Clause { + return []*Clause{ + { + Elements: []Element{ + NewTokenType(lexer.ItemDot), + NewSymbol("FILTER_CLAUSES"), + }, + }, + {}, + } +} +func filterClauses() []*Clause { + return []*Clause{ + { + Elements: []Element{ + NewTokenType(lexer.ItemFilter), + NewTokenType(lexer.ItemFilterFunction), + NewTokenType(lexer.ItemLPar), + NewTokenType(lexer.ItemBinding), + NewTokenType(lexer.ItemRPar), + NewSymbol("MORE_FILTER_CLAUSES"), + }, + }, + {}, + } +} + func optionalClauses() []*Clause { return []*Clause{ { @@ -1266,6 +1294,8 @@ func BQL() *Grammar { "MORE_CLAUSES": moreClauses(), "CLAUSES": clauses(), "OPTIONAL_CLAUSE": optionalClauses(), + "FILTER_CLAUSES": filterClauses(), + "MORE_FILTER_CLAUSES": moreFilterClauses(), "SUBJECT_EXTRACT": subjectExtractClauses(), "SUBJECT_TYPE": subjectTypeClauses(), "SUBJECT_ID": subjectIDClauses(), @@ -1400,6 +1430,12 @@ func SemanticBQL() *Grammar { } setElementHook(semanticBQL, objSymbols, semantic.WhereObjectClauseHook(), nil) + // Filter clause hook. + filterSymbols := []semantic.Symbol{ + "FILTER_CLAUSES", + } + setElementHook(semanticBQL, filterSymbols, semantic.WhereFilterClauseHook(), nil) + // Collect binding variables variables. varSymbols := []semantic.Symbol{ "VARS", "VARS_AS", "MORE_VARS", "COUNT_DISTINCT", diff --git a/bql/grammar/grammar_test.go b/bql/grammar/grammar_test.go index 53f761c6..614bfc67 100644 --- a/bql/grammar/grammar_test.go +++ b/bql/grammar/grammar_test.go @@ -194,6 +194,20 @@ func TestAcceptByParse(t *testing.T) { ?n "_object"@[] ?o};`, // Show the graphs. `show graphs;`, + // Test FILTER clause inside WHERE. + `select ?a + from ?b + where { + ?s ?p ?o . + FILTER latest(?p) + };`, + `select ?a + from ?b + where { + ?s ?p ?o . + FILTER latest(?p) . + FILTER latest(?o) + };`, // Test optional trailing dot after the last clause inside WHERE. `select ?a from ?b @@ -229,6 +243,19 @@ func TestAcceptByParse(t *testing.T) { ?s ?p ?o . /u ?p ?o };`, + `select ?a + from ?b + where { + ?s ?p ?o . + FILTER latest(?p) . + };`, + `select ?a + from ?b + where { + ?s ?p ?o . + FILTER latest(?p) . + FILTER latest(?o) . + };`, } p, err := NewParser(BQL()) if err != nil { @@ -386,6 +413,46 @@ func TestRejectByParse(t *testing.T) { where {?n "_subject"@[] ?s. ?n "_predicate"@[] ?p. ?n "_object"@[] ?o};`, + // Test invalid FILTER clause inside WHERE. + `select ?a + from ?b + where { + ?s ?p ?o . + FILTER latest ?p + };`, + `select ?a + from ?b + where { + ?s ?p ?o . + FILTER latest (?p) + };`, + `select ?a + from ?b + where { + FILTER latest(?p) . + ?s ?p ?o + };`, + `select ?a + from ?b + where { + ?s ?p ?o . + FILTER latest(?p) . + /u ?p ?o + };`, + `select ?a + from ?b + where { + ?s ?p ?o . + ?s ?p ?o . + FILTER latest(?p) . + /u ?p ?o + };`, + `select ?a + from ?b + where { + ?s ?p ?o . + FILTER late^st(?p) + };`, // Test invalid trailing dot use inside WHERE. `select ?a from ?b @@ -411,6 +478,19 @@ func TestRejectByParse(t *testing.T) { ?s ?p ?o /u ?p ?o };`, + `select ?a + from ?b + where { + ?s ?p ?o + FILTER latest(?p) + };`, + `select ?a + from ?b + where { + ?s ?p ?o . + FILTER latest(?p) + FILTER latest(?o) + };`, } p, err := NewParser(BQL()) if err != nil { @@ -565,6 +645,13 @@ func TestAcceptQueryBySemanticParse(t *testing.T) { `select ?s from ?g where{/_ as ?s ?p "id"@[?foo, ?bar] as ?o} order by ?s;`, `select ?s as ?a, ?o as ?b, ?o as ?c from ?g where{?s ?p ?o} order by ?a ASC, ?b DESC;`, `select ?s as ?a, ?o as ?b, ?o as ?c from ?g where{?s ?p ?o} order by ?a ASC, ?b DESC, ?a ASC, ?b DESC, ?c;`, + // Test valid FILTER clause for grammar with hooks. + `select ?p + from ?b + where { + ?s ?p ?o . + FILTER latest(?p) + };`, } p, err := NewParser(SemanticBQL()) if err != nil { @@ -596,6 +683,13 @@ func TestRejectByParseAndSemantic(t *testing.T) { `select ?s as ?a, ?o as ?b, ?o as ?c from ?g where{?s ?p ?o} order by ?a ASC, ?a DESC;`, // Wrong limit literal. `select ?s as ?a, ?o as ?b, ?o as ?c from ?g where{?s ?p ?o} LIMIT "true"^^type:bool;`, + // Reject not supported FILTER function. + `select ?p, ?o + from ?test + where { + /u ?p ?o . + FILTER notSupportedFilterFunction(?p) + };`, } p, err := NewParser(SemanticBQL()) if err != nil { diff --git a/bql/lexer/lexer.go b/bql/lexer/lexer.go index 0c45a69b..1cbd5570 100644 --- a/bql/lexer/lexer.go +++ b/bql/lexer/lexer.go @@ -139,6 +139,10 @@ const ( ItemGraphs // ItemOptional identifies optional graph pattern clauses. ItemOptional + // ItemFilter represents the filter keyword in BQL. + ItemFilter + // ItemFilterFunction represents a filter function in BQL. + ItemFilterFunction ) func (tt TokenType) String() string { @@ -253,6 +257,10 @@ func (tt TokenType) String() string { return "GRAPHS" case ItemOptional: return "OPTIONAL" + case ItemFilter: + return "FILTER" + case ItemFilterFunction: + return "FILTER_FUNCTION" default: return "UNKNOWN" } @@ -294,6 +302,7 @@ const ( from = "from" where = "where" optional = "optional" + filter = "filter" as = "as" before = "before" after = "after" @@ -402,6 +411,9 @@ func lexToken(l *lexer) stateFn { return lexPredicateOrLiteral } if unicode.IsLetter(r) { + if l.lastTokenType == ItemFilter { + return lexFilterFunction + } return lexKeyword } } @@ -617,6 +629,10 @@ func lexKeyword(l *lexer) stateFn { consumeKeyword(l, ItemOptional) return lexSpace } + if strings.EqualFold(input, filter) { + consumeKeyword(l, ItemFilter) + return lexSpace + } if strings.EqualFold(input, typeKeyword) { consumeKeyword(l, ItemType) return lexSpace @@ -648,6 +664,25 @@ func lexKeyword(l *lexer) stateFn { return nil } +// lexFilterFunction lexes a filter function out of the input (used in FILTER clauses). +func lexFilterFunction(l *lexer) stateFn { + l.next() + var nr rune + for { + nr = l.next() + if nr == leftPar { + l.backup() + break + } + if !unicode.IsLetter(nr) { + l.emitError(`invalid rune in filter function: "` + string(nr) + `"; filter functions should be formed only by letters`) + return nil + } + } + l.emit(ItemFilterFunction) + return lexSpace +} + func lexNode(l *lexer) stateFn { ltID := false for done := false; !done; { diff --git a/bql/lexer/lexer_test.go b/bql/lexer/lexer_test.go index 55a5c0e1..d30fd594 100644 --- a/bql/lexer/lexer_test.go +++ b/bql/lexer/lexer_test.go @@ -76,6 +76,8 @@ func TestTokenTypeString(t *testing.T) { {ItemShow, "SHOW"}, {ItemGraphs, "GRAPHS"}, {ItemOptional, "OPTIONAL"}, + {ItemFilter, "FILTER"}, + {ItemFilterFunction, "FILTER_FUNCTION"}, {TokenType(-1), "UNKNOWN"}, } @@ -388,6 +390,29 @@ func TestIndividualTokens(t *testing.T) { {Type: ItemEOF}, }, }, + { + `FILTER latest(?p)`, + []Token{ + {Type: ItemFilter, Text: "FILTER"}, + {Type: ItemFilterFunction, Text: "latest"}, + {Type: ItemLPar, Text: "("}, + {Type: ItemBinding, Text: "?p"}, + {Type: ItemRPar, Text: ")"}, + {Type: ItemEOF}, + }, + }, + { + `FILTER latest(?p) .`, + []Token{ + {Type: ItemFilter, Text: "FILTER"}, + {Type: ItemFilterFunction, Text: "latest"}, + {Type: ItemLPar, Text: "("}, + {Type: ItemBinding, Text: "?p"}, + {Type: ItemRPar, Text: ")"}, + {Type: ItemDot, Text: "."}, + {Type: ItemEOF}, + }, + }, } for _, test := range table { @@ -578,6 +603,36 @@ func TestValidTokenQuery(t *testing.T) { ItemHaving, ItemBinding, ItemEQ, ItemTime, ItemSemicolon, ItemEOF, }, }, + { + `select ?s ?p ?o + from ?foo + where { + ?s ?p ?o . + FILTER latest(?p) + };`, + []TokenType{ + ItemQuery, ItemBinding, ItemBinding, ItemBinding, ItemFrom, ItemBinding, + ItemWhere, ItemLBracket, ItemBinding, ItemBinding, ItemBinding, ItemDot, + ItemFilter, ItemFilterFunction, ItemLPar, ItemBinding, ItemRPar, + ItemRBracket, ItemSemicolon, ItemEOF, + }, + }, + { + `select ?s ?p ?o + from ?foo + where { + ?s ?p ?o . + FILTER latest(?p) . + FILTER latest(?o) + };`, + []TokenType{ + ItemQuery, ItemBinding, ItemBinding, ItemBinding, ItemFrom, ItemBinding, + ItemWhere, ItemLBracket, ItemBinding, ItemBinding, ItemBinding, ItemDot, + ItemFilter, ItemFilterFunction, ItemLPar, ItemBinding, ItemRPar, ItemDot, + ItemFilter, ItemFilterFunction, ItemLPar, ItemBinding, ItemRPar, + ItemRBracket, ItemSemicolon, ItemEOF, + }, + }, } for _, test := range table { diff --git a/bql/semantic/convert.go b/bql/semantic/convert.go index af62fc54..8060d986 100644 --- a/bql/semantic/convert.go +++ b/bql/semantic/convert.go @@ -69,6 +69,11 @@ func (c ConsumedElement) Token() *lexer.Token { return c.token } +// String returns a string representation of the ConsumedElement. +func (c ConsumedElement) String() string { + return fmt.Sprintf("{isSymbol=%v, symbol=%s, token=%s}", c.isSymbol, c.symbol, c.token) +} + // ToNode converts the node found by the lexer and converts it into a BadWolf // node. func ToNode(ce ConsumedElement) (*node.Node, error) { diff --git a/bql/semantic/hooks.go b/bql/semantic/hooks.go index 3ff5a2e5..76b5b7dc 100644 --- a/bql/semantic/hooks.go +++ b/bql/semantic/hooks.go @@ -92,6 +92,12 @@ func WhereObjectClauseHook() ElementHook { return whereObjectClause() } +// WhereFilterClauseHook returns the singleton for the working filter clause hook that +// populates the filters list. +func WhereFilterClauseHook() ElementHook { + return whereFilterClause() +} + // VarAccumulatorHook returns the singleton for accumulating variable // projections. func VarAccumulatorHook() ElementHook { @@ -336,6 +342,7 @@ func whereInitWorkingClause() ClauseHook { var hook ClauseHook hook = func(s *Statement, _ Symbol) (ClauseHook, error) { s.ResetWorkingGraphClause() + s.ResetWorkingFilterClause() return hook, nil } return hook @@ -664,6 +671,59 @@ func whereObjectClause() ElementHook { return hook } +// whereFilterClause returns an element hook that updates the working filter clause and, +// if the filter clause is complete, populates the filters list of the statement. +func whereFilterClause() ElementHook { + var hook ElementHook + supportedFilterFunctions := map[string]bool{ + "latest": true, + } + + hook = func(st *Statement, ce ConsumedElement) (ElementHook, error) { + if ce.IsSymbol() { + return hook, nil + } + + tkn := ce.Token() + currFilter := st.WorkingFilter() + switch tkn.Type { + case lexer.ItemFilterFunction: + if currFilter == nil { + return nil, fmt.Errorf("could not add filter function %q to nil filter clause", tkn.Text) + } + if currFilter.Operation != "" { + return nil, fmt.Errorf("invalid filter function %q on filter clause since already set to %q", tkn.Text, currFilter.Operation) + } + if !supportedFilterFunctions[tkn.Text] { + return nil, fmt.Errorf("filter function %q on filter clause is not supported", tkn.Text) + } + currFilter.Operation = tkn.Text + return hook, nil + case lexer.ItemBinding: + if currFilter == nil { + return nil, fmt.Errorf("could not add binding %q to nil filter clause", tkn.Text) + } + if currFilter.Operation == "" { + return nil, fmt.Errorf("could not add binding %q to a filter clause that does not have a filter function previously set", tkn.Text) + } + if currFilter.Binding != "" { + return nil, fmt.Errorf("invalid binding %q on filter clause since already set to %q", tkn.Text, currFilter.Binding) + } + currFilter.Binding = tkn.Text + return hook, nil + case lexer.ItemRPar: + if currFilter == nil || currFilter.Operation == "" || currFilter.Binding == "" { + return nil, fmt.Errorf("could not add invalid working filter %q to the statement filters list", currFilter) + } + st.AddWorkingFilterClause() + } + + return hook, nil + } + + return hook +} + // varAccumulator returns an element hook that updates the object // modifiers on the working graph clause. func varAccumulator() ElementHook { diff --git a/bql/semantic/hooks_test.go b/bql/semantic/hooks_test.go index 4494449d..b9dd18fe 100644 --- a/bql/semantic/hooks_test.go +++ b/bql/semantic/hooks_test.go @@ -179,9 +179,229 @@ func TestWhereInitClauseHook(t *testing.T) { f := whereInitWorkingClause() st := &Statement{} f(st, Symbol("FOO")) - if st.WorkingClause() == nil { - t.Errorf("semantic.WhereInitWorkingClause should have returned a valid working clause for statement %v", st) + if wc := st.WorkingClause(); wc == nil || !wc.IsEmpty() { + t.Errorf(`semantic.Statement.WorkingClause() = %q for statement "%v" after call to semantic.WhereInitWorkingClause; want empty GraphClause`, wc, st) } + if wf := st.WorkingFilter(); wf == nil || !wf.IsEmpty() { + t.Errorf(`semantic.Statement.WorkingFilter() = %q for statement "%v" after call to semantic.WhereInitWorkingClause; want empty FilterClause`, wf, st) + } +} + +func TestWhereFilterClauseHook(t *testing.T) { + st := &Statement{} + f := whereFilterClause() + st.ResetWorkingFilterClause() + + testTable := []struct { + id string + ces []ConsumedElement + want *FilterClause + }{ + { + id: "FILTER latest(?p)", + ces: []ConsumedElement{ + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemFilter, + Text: "FILTER", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemFilterFunction, + Text: "latest", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemLPar, + Text: "(", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemBinding, + Text: "?p", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemRPar, + Text: ")", + }), + NewConsumedSymbol("FOO"), + }, + want: &FilterClause{ + Operation: "latest", + Binding: "?p", + }, + }, + { + id: "FILTER latest(?o)", + ces: []ConsumedElement{ + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemFilter, + Text: "FILTER", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemFilterFunction, + Text: "latest", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemLPar, + Text: "(", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemBinding, + Text: "?o", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemRPar, + Text: ")", + }), + NewConsumedSymbol("FOO"), + }, + want: &FilterClause{ + Operation: "latest", + Binding: "?o", + }, + }, + } + for i, entry := range testTable { + t.Run(entry.id, func(t *testing.T) { + for _, ce := range entry.ces { + if _, err := f(st, ce); err != nil { + t.Errorf("%q: semantic.WhereFilterClauseHook(%s) = _, %v; want _, nil", entry.id, ce, err) + } + } + if got, want := st.FilterClauses()[i], entry.want; !reflect.DeepEqual(got, want) { + t.Errorf("%q: semantic.Statement.FilterClauses()[%d] = %s; want %s", entry.id, i, got, want) + } + }) + } + + t.Run(fmt.Sprintf("final length filters list expects %d", len(testTable)), func(t *testing.T) { + if got, want := len(st.FilterClauses()), len(testTable); got != want { + t.Errorf("len(semantic.Statement.FilterClauses()) = %d after consuming %d valid FILTER clauses; want %d", got, want, want) + } + }) +} + +func TestWhereFilterClauseHookError(t *testing.T) { + st := &Statement{} + f := whereFilterClause() + st.ResetWorkingFilterClause() + + testTable := []struct { + id string + ces []ConsumedElement + ceIndexError int + }{ + { + id: "FILTER notSupportedFilterFunction(?p)", + ces: []ConsumedElement{ + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemFilter, + Text: "FILTER", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemFilterFunction, + Text: "notSupportedFilterFunction", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemLPar, + Text: "(", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemBinding, + Text: "?p", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemRPar, + Text: ")", + }), + }, + ceIndexError: 1, + }, + { + id: "FILTER latest latest(?p)", + ces: []ConsumedElement{ + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemFilter, + Text: "FILTER", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemFilterFunction, + Text: "latest", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemFilterFunction, + Text: "latest", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemLPar, + Text: "(", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemBinding, + Text: "?p", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemRPar, + Text: ")", + }), + }, + ceIndexError: 2, + }, + { + id: "FILTER latest(?p, ?o)", + ces: []ConsumedElement{ + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemFilter, + Text: "FILTER", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemFilterFunction, + Text: "latest", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemLPar, + Text: "(", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemBinding, + Text: "?p", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemComma, + Text: ",", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemBinding, + Text: "?o", + }), + NewConsumedToken(&lexer.Token{ + Type: lexer.ItemRPar, + Text: ")", + }), + }, + ceIndexError: 5, + }, + } + for _, entry := range testTable { + t.Run(entry.id, func(t *testing.T) { + var err error + for i, ce := range entry.ces { + if _, err = f(st, ce); err != nil { + if i != entry.ceIndexError { + t.Errorf("%q: semantic.WhereFilterClauseHook(%v) = _, %v; want _, nil since the expected error should be when consuming %v", entry.id, ce, err, entry.ces[entry.ceIndexError]) + } + break + } + } + if err == nil { + t.Errorf("%q: semantic.WhereFilterClauseHook(%v) = _, nil; want _, error", entry.id, entry.ces[entry.ceIndexError]) + } + st.ResetWorkingFilterClause() + }) + } + + t.Run("final length filters list expects 0", func(t *testing.T) { + if got, want := len(st.FilterClauses()), 0; got != want { + t.Errorf("len(semantic.Statement.FilterClauses()) = %d; want %d", got, want) + } + }) } func TestWhereWorkingClauseHook(t *testing.T) { diff --git a/bql/semantic/semantic.go b/bql/semantic/semantic.go index 4f6175aa..fe556d0a 100644 --- a/bql/semantic/semantic.go +++ b/bql/semantic/semantic.go @@ -103,6 +103,8 @@ type Statement struct { limitSet bool limit int64 lookupOptions storage.LookupOptions + filters []*FilterClause + workingFilter *FilterClause } // GraphClause represents a clause of a graph pattern in a where clause. @@ -143,6 +145,16 @@ type GraphClause struct { OTemporal bool } +// FilterClause represents a FILTER clause inside WHERE. +// Operation below refers to the filter function being applied (eg: "latest"), Binding refers to the binding it +// will be applied to and Value, when specified, contains the second argument of the filter function (not applicable for all +// Operations - some like "latest" do not use it while others like "greaterThan" do, see Issue 129). +type FilterClause struct { + Operation string + Binding string + Value string +} + // ConstructClause represents a singular clause within a construct statement. type ConstructClause struct { S *node.Node @@ -399,6 +411,16 @@ func (c *GraphClause) IsEmpty() bool { return reflect.DeepEqual(c, &GraphClause{}) } +// IsEmpty will return true if there are no set values in the filter clause. +func (f *FilterClause) IsEmpty() bool { + return reflect.DeepEqual(f, &FilterClause{}) +} + +// String returns a string representation of the filter clause. +func (f *FilterClause) String() string { + return fmt.Sprintf("%+v", *f) +} + // String returns a readable representation of a construct clause. func (c *ConstructClause) String() string { b := bytes.NewBufferString("{ ") @@ -584,16 +606,31 @@ func (s *Statement) GraphPatternClauses() []*GraphClause { return s.pattern } +// FilterClauses returns the list of FILTER clauses. +func (s *Statement) FilterClauses() []*FilterClause { + return s.filters +} + // ResetWorkingGraphClause resets the current working graph clause. func (s *Statement) ResetWorkingGraphClause() { s.workingClause = &GraphClause{} } +// ResetWorkingFilterClause resets the current working filter clause. +func (s *Statement) ResetWorkingFilterClause() { + s.workingFilter = &FilterClause{} +} + // WorkingClause returns the current working clause. func (s *Statement) WorkingClause() *GraphClause { return s.workingClause } +// WorkingFilter returns the current working filter. +func (s *Statement) WorkingFilter() *FilterClause { + return s.workingFilter +} + // AddWorkingGraphClause adds the current working graph clause to the set of // clauses that form the graph pattern. func (s *Statement) AddWorkingGraphClause() { @@ -603,6 +640,12 @@ func (s *Statement) AddWorkingGraphClause() { s.ResetWorkingGraphClause() } +// AddWorkingFilterClause adds the current working filter clause to the filters list. +func (s *Statement) AddWorkingFilterClause() { + s.filters = append(s.filters, s.workingFilter) + s.ResetWorkingFilterClause() +} + // Projection returns the available projections in the statement. func (s *Statement) Projection() []*Projection { return s.projection diff --git a/bql/semantic/semantic_test.go b/bql/semantic/semantic_test.go index 6528276b..5bcd16c4 100644 --- a/bql/semantic/semantic_test.go +++ b/bql/semantic/semantic_test.go @@ -259,6 +259,32 @@ func TestGraphClauseManipulation(t *testing.T) { } } +func TestFilterClauseManipulation(t *testing.T) { + st := &Statement{} + + t.Run("workingFilter initial states ok", func(t *testing.T) { + if wf := st.WorkingFilter(); wf != nil { + t.Fatalf(`semantic.Statement.WorkingFilter() = %q for statement "%v" without initialization; want nil`, wf, st) + } + st.ResetWorkingFilterClause() + if wf := st.WorkingFilter(); wf == nil || !wf.IsEmpty() { + t.Fatalf(`semantic.Statement.WorkingFilter() = %q for statement "%v" after call to ResetWorkingFilterClause; want empty FilterClause`, wf, st) + } + }) + + t.Run("add workingFilter success", func(t *testing.T) { + wf := st.WorkingFilter() + *wf = FilterClause{Operation: "latest", Binding: "?p"} + st.AddWorkingFilterClause() + if got, want := len(st.FilterClauses()), 1; got != want { + t.Fatalf(`len(semantic.Statement.FilterClauses()) = %d for statement "%v"; want %d`, got, st, want) + } + if wf = st.WorkingFilter(); wf == nil || !wf.IsEmpty() { + t.Fatalf(`semantic.Statement.WorkingFilter() = %q for statement "%v"; want empty FilterClause`, wf, st) + } + }) +} + func TestBindingListing(t *testing.T) { stm := Statement{} stm.ResetWorkingGraphClause() @@ -324,6 +350,27 @@ func TestIsEmptyClause(t *testing.T) { } +func TestIsEmptyFilterClause(t *testing.T) { + testTable := []struct { + in *FilterClause + want bool + }{ + { + in: &FilterClause{}, + want: true, + }, + { + in: &FilterClause{Operation: "latest", Binding: "?p"}, + want: false, + }, + } + for _, entry := range testTable { + if got := entry.in.IsEmpty(); got != entry.want { + t.Errorf("FilterClause.IsEmpty(%q) = %v; want %v", entry.in, got, entry.want) + } + } +} + func TestSortedGraphPatternClauses(t *testing.T) { s := &Statement{ pattern: []*GraphClause{