diff --git a/broker/pgcql/pg_definition.go b/broker/pgcql/pg_definition.go new file mode 100644 index 00000000..3c276fb7 --- /dev/null +++ b/broker/pgcql/pg_definition.go @@ -0,0 +1,28 @@ +package pgcql + +import "github.com/indexdata/cql-go/cql" + +type PgDefinition struct { + fields map[string]Field +} + +func (pg *PgDefinition) AddField(name string, field Field) Definition { + if pg.fields == nil { + pg.fields = make(map[string]Field) + } + pg.fields[name] = field + return pg +} + +func (pg *PgDefinition) GetFieldType(name string) Field { + if field, ok := pg.fields[name]; ok { + return field + } + return nil +} + +func (pg *PgDefinition) Parse(q cql.Query, queryArgumentIndex int) (Query, error) { + query := &PgQuery{} + err := query.parse(q, queryArgumentIndex, pg) + return query, err +} diff --git a/broker/pgcql/pg_field_string.go b/broker/pgcql/pg_field_string.go new file mode 100644 index 00000000..c47570b1 --- /dev/null +++ b/broker/pgcql/pg_field_string.go @@ -0,0 +1,33 @@ +package pgcql + +import ( + "fmt" + + "github.com/indexdata/cql-go/cql" +) + +type FieldString struct { + column string +} + +func (f *FieldString) GetColumn() string { + return f.column +} + +func (f *FieldString) WithColumn(column string) Field { + f.column = column + return f +} + +func (f *FieldString) Generate(sc cql.SearchClause, queryArgumentIndex int) (string, []interface{}, error) { + var operator string + switch sc.Relation { + case cql.EQ: + operator = "=" + case cql.NE: + operator = "!=" + default: + return "", nil, &PgError{message: "unsupported operator"} + } + return f.column + " " + operator + fmt.Sprintf(" $%d", queryArgumentIndex), []interface{}{sc.Term}, nil +} diff --git a/broker/pgcql/pg_query.go b/broker/pgcql/pg_query.go new file mode 100644 index 00000000..3b5b1388 --- /dev/null +++ b/broker/pgcql/pg_query.go @@ -0,0 +1,86 @@ +package pgcql + +import ( + "fmt" + + "github.com/indexdata/cql-go/cql" +) + +type PgQuery struct { + def *PgDefinition + queryArgumentIndex int + arguments []interface{} + sql string +} + +func (p *PgQuery) parse(q cql.Query, queryArgumentIndex int, def *PgDefinition) error { + p.def = def + p.arguments = make([]interface{}, 0) + p.queryArgumentIndex = queryArgumentIndex + if q.SortSpec != nil { + return &PgError{message: "sorting not supported"} + } + return p.parseClause(q.Clause, 0) +} + +func (p *PgQuery) parseClause(sc cql.Clause, level int) error { + if sc.SearchClause != nil { + index := sc.SearchClause.Index + fieldType := p.def.GetFieldType(index) + if fieldType == nil { + return &PgError{message: fmt.Sprintf("unknown field %s", index)} + } + sql, args, err := fieldType.Generate(*sc.SearchClause, p.queryArgumentIndex) + if err != nil { + return err + } + p.sql += sql + if args != nil { + p.queryArgumentIndex += len(args) + p.arguments = append(p.arguments, args...) + } + return nil + } else if sc.BoolClause != nil { + if level > 0 { + p.sql += "(" + } + err := p.parseClause(sc.BoolClause.Left, level+1) + if err != nil { + return err + } + if sc.BoolClause.Operator == cql.AND { + p.sql += " AND " + } else if sc.BoolClause.Operator == cql.OR { + p.sql += " OR " + } else if sc.BoolClause.Operator == cql.NOT { + p.sql += " AND NOT " + } else { + return &PgError{message: fmt.Sprintf("unsupported operator %s", sc.BoolClause.Operator)} + } + err = p.parseClause(sc.BoolClause.Right, level+1) + if err != nil { + return err + } + if level > 0 { + p.sql += ")" + } + return nil + } + return &PgError{message: "unsupported clause type"} +} + +func (p *PgQuery) GetWhereClause() string { + return p.sql +} + +func (p *PgQuery) GetQueryArguments() []interface{} { + return p.arguments +} + +func (p *PgQuery) GetOrderByClause() string { + return "" +} + +func (p *PgQuery) GetOrderByFields() string { + return "" +} diff --git a/broker/pgcql/pgcql.go b/broker/pgcql/pgcql.go new file mode 100644 index 00000000..a33bd064 --- /dev/null +++ b/broker/pgcql/pgcql.go @@ -0,0 +1,32 @@ +package pgcql + +import ( + "github.com/indexdata/cql-go/cql" +) + +type PgError struct { + message string +} + +func (e *PgError) Error() string { + return e.message +} + +type Field interface { + GetColumn() string + WithColumn(column string) Field + Generate(sc cql.SearchClause, queryArgumentIndex int) (string, []interface{}, error) +} + +type Definition interface { + AddField(name string, field Field) Definition + GetFieldType(name string) Field + Parse(q cql.Query, queryArgumentIndex int) (Query, error) +} + +type Query interface { + GetWhereClause() string + GetQueryArguments() []interface{} + GetOrderByClause() string + GetOrderByFields() string +} diff --git a/broker/pgcql/pgcql_test.go b/broker/pgcql/pgcql_test.go new file mode 100644 index 00000000..6e45a5a8 --- /dev/null +++ b/broker/pgcql/pgcql_test.go @@ -0,0 +1,95 @@ +package pgcql + +import ( + "reflect" + "strings" + "testing" + + "github.com/indexdata/cql-go/cql" + "github.com/stretchr/testify/assert" +) + +func TestBadSearchClause(t *testing.T) { + def := &PgDefinition{} + + assert.Nil(t, def.GetFieldType("foo")) + + q := cql.Query{} + _, err := def.Parse(q, 1) + assert.Error(t, err, "Expected error for empty query") + assert.Equal(t, "unsupported clause type", err.Error()) +} + +func TestParsing(t *testing.T) { + def := &PgDefinition{} + title := &FieldString{} + title.WithColumn("Title") + assert.Equal(t, title.GetColumn(), "Title", "GetColumn() should return the column name") + + author := &FieldString{} + author.WithColumn("Author") + + serverChoice := &FieldString{} + serverChoice.WithColumn("T") + + def.AddField("title", title).AddField("author", author).AddField("cql.serverChoice", serverChoice) + + for _, testcase := range []struct { + query string + expected string + expectedArgs []interface{} + }{ + {"abc", "T = $1", []interface{}{"abc"}}, + {"au=2", "-unknown field au", nil}, + {"title>2", "-unsupported operator", nil}, + {"title=2", "Title = $1", []interface{}{"2"}}, + {"title<>2", "Title != $1", []interface{}{"2"}}, + {"a or b and c", "(T = $1 OR T = $2) AND T = $3", []interface{}{"a", "b", "c"}}, + {"title = abc", "Title = $1", []interface{}{"abc"}}, + {"author = \"test\"", "Author = $1", []interface{}{"test"}}, + {"title = a AND author = b c", "Title = $1 AND Author = $2", []interface{}{"a", "b c"}}, + {"title = 'a' OR author = 'b'", "Title = $1 OR Author = $2", []interface{}{"'a'", "'b'"}}, + {"title = a NOT author = b", "Title = $1 AND NOT Author = $2", []interface{}{"a", "b"}}, + {"a prox b", "-unsupported operator prox", []interface{}{}}, + {"a sortby title", "-sorting not supported", []interface{}{}}, + {"au=2 or a", "-unknown field au", nil}, + {"a or au=2", "-unknown field au", nil}, + } { + var parser cql.Parser + q, err := parser.Parse(testcase.query) + if err != nil { + t.Errorf("%s: CQL parse error: %v", testcase.query, err) + continue + } + pgQuery, err := def.Parse(q, 1) + + expectedError := strings.HasPrefix(testcase.expected, "-") + + if err != nil { + if expectedError { + if strings.TrimPrefix(testcase.expected, "-") != err.Error() { + t.Errorf("%s: Expected error %s, got %s", testcase.query, strings.TrimPrefix(testcase.expected, "-"), err) + } + } else { + t.Errorf("%s: Failed to parse: %v", testcase.query, err) + } + continue + } + if expectedError { + t.Errorf("%s: Expected error, but got OK", testcase.query) + continue + } + if pgQuery.GetWhereClause() != testcase.expected { + t.Errorf("%s: Expected %s, got %s", testcase.query, testcase.expected, pgQuery.GetWhereClause()) + } + if !reflect.DeepEqual(pgQuery.GetQueryArguments(), testcase.expectedArgs) { + t.Errorf("%s: Expected arguments %v, got %v", testcase.query, testcase.expectedArgs, pgQuery.GetQueryArguments()) + } + if pgQuery.GetOrderByClause() != "" { + t.Errorf("%s: Expected empty order by clause, got %s", testcase.query, pgQuery.GetOrderByClause()) + } + if pgQuery.GetOrderByFields() != "" { + t.Errorf("%s: Expected empty order by fields, got %s", testcase.query, pgQuery.GetOrderByFields()) + } + } +}