Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions broker/migrations/034_add_item_cql_indexes.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_item_call_number_pr_id;
DROP INDEX IF EXISTS idx_item_item_id_pr_id;
DROP INDEX IF EXISTS idx_item_barcode_pr_id;
10 changes: 10 additions & 0 deletions broker/migrations/034_add_item_cql_indexes.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE INDEX idx_item_barcode_pr_id
ON item (barcode, pr_id);

CREATE INDEX idx_item_item_id_pr_id
ON item (item_id, pr_id)
WHERE item_id IS NOT NULL;

CREATE INDEX idx_item_call_number_pr_id
ON item (call_number, pr_id)
Comment on lines +2 to +9
WHERE call_number IS NOT NULL;
3 changes: 2 additions & 1 deletion broker/oapi/open-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1359,7 +1359,8 @@ paths:
Query parameter cql can be used to filter the results.
With cql you can use these fields state, side, requester_symbol, supplier_symbol, needs_attention,
has_notification, has_cost, has_unread_notification, service_type, service_level, created_at, needed_at,
requester_req_id, title, author, patron, terminal_state, updated_at, cql.serverChoice.
requester_req_id, title, author, isbn, issn, item_id, barcode, call_number, patron, terminal_state,
updated_at, cql.serverChoice.
tags:
- patron-requests-api
parameters:
Expand Down
91 changes: 91 additions & 0 deletions broker/patron_request/db/field_exists_string.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package pr_db

import (
"fmt"

"github.com/indexdata/cql-go/cql"
"github.com/indexdata/cql-go/pgcql"
)

type FieldExistsString struct {
column string
table string
alias string
correlation string
valueExpression string
sortExpression string
field *pgcql.FieldString
}

func NewFieldExistsString(table, alias, correlation, valueExpression string) *FieldExistsString {
field := pgcql.NewFieldString().WithLikeOps()
field.SetColumn(valueExpression)
return &FieldExistsString{
table: table,
alias: alias,
correlation: correlation,
valueExpression: valueExpression,
field: field,
}
}

func (f *FieldExistsString) GetColumn() string {
return f.column
}

func (f *FieldExistsString) SetColumn(column string) {
f.column = column
}

func (f *FieldExistsString) Sort() string {
return f.sortExpression
}

func (f *FieldExistsString) WithSortExpression(sortExpression string) *FieldExistsString {
f.sortExpression = sortExpression
return f
}

func (f *FieldExistsString) WithField(field *pgcql.FieldString) *FieldExistsString {
f.field = field
f.field.SetColumn(f.valueExpression)
return f
}

func (f *FieldExistsString) Generate(sc cql.SearchClause, queryArgumentIndex int) (string, []any, error) {
switch {
case sc.Term == "" && isPositiveStringRelation(sc.Relation):
return "NOT " + f.existsSql(f.nonEmptyValuePredicate()), nil, nil
case sc.Term == "" && sc.Relation == cql.NE:
return f.existsSql(f.nonEmptyValuePredicate()), nil, nil
case sc.Relation == cql.NE:
sc.Relation = cql.EQ
predicate, args, err := f.field.Generate(sc, queryArgumentIndex)
if err != nil {
return "", nil, err
}
return "NOT " + f.existsSql(predicate), args, nil
default:
predicate, args, err := f.field.Generate(sc, queryArgumentIndex)
if err != nil {
return "", nil, err
}
return f.existsSql(predicate), args, nil
}
}

func isPositiveStringRelation(relation cql.Relation) bool {
return relation == cql.EQ || relation == cql.EXACT || relation == "=="
}

func (f *FieldExistsString) existsSql(predicate string) string {
source := f.table
if f.alias != "" {
source += " " + f.alias
}
return fmt.Sprintf("EXISTS (SELECT 1 FROM %s WHERE %s AND %s)", source, f.correlation, predicate)
}

func (f *FieldExistsString) nonEmptyValuePredicate() string {
return fmt.Sprintf("COALESCE(%s, '') <> ''", f.valueExpression)
}
5 changes: 5 additions & 0 deletions broker/patron_request/db/prcql.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ func handlePatronRequestsQuery(cqlString string, noBaseArgs int) (pgcql.Query, e
def.AddField("isbn", NewFieldTextArrayContains("bibliographic_item_identifiers(ill_request, 'ISBN')").WithFunction("norm_isxn"))
def.AddField("issn", NewFieldTextArrayContains("bibliographic_item_identifiers(ill_request, 'ISSN')").WithFunction("norm_isxn"))

itemCorrelation := "cql_item.pr_id = patron_request_search_view.id"
def.AddField("item_id", NewFieldExistsString("item", "cql_item", itemCorrelation, "cql_item.item_id"))
def.AddField("barcode", NewFieldExistsString("item", "cql_item", itemCorrelation, "cql_item.barcode"))
def.AddField("call_number", NewFieldExistsString("item", "cql_item", itemCorrelation, "cql_item.call_number"))

f = pgcql.NewFieldString().WithLikeOps()
def.AddField("patron", f)

Expand Down
115 changes: 115 additions & 0 deletions broker/patron_request/db/prcql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,121 @@ func TestHandlePatronRequestsQueryIsbnUsesNormIsxn(t *testing.T) {
}
}

func TestFieldExistsStringGenerate(t *testing.T) {
f := NewFieldExistsString("item", "i", "i.pr_id = pr.id", "i.barcode")

t.Run("eq uses exists with string field predicate", func(t *testing.T) {
sc := searchClauseForTest("abc", "=")
gotSQL, gotArgs, err := f.Generate(sc, 3)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
wantSQL := "EXISTS (SELECT 1 FROM item i WHERE i.pr_id = pr.id AND i.barcode = $3)"
if gotSQL != wantSQL {
t.Fatalf("sql = %q, want %q", gotSQL, wantSQL)
}
if len(gotArgs) != 1 || gotArgs[0] != "abc" {
t.Fatalf("args = %#v, want one raw term arg", gotArgs)
}
})

t.Run("wildcard eq uses exists with like predicate", func(t *testing.T) {
sc := searchClauseForTest("abc*", "=")
gotSQL, gotArgs, err := f.Generate(sc, 4)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
wantSQL := "EXISTS (SELECT 1 FROM item i WHERE i.pr_id = pr.id AND i.barcode LIKE $4)"
if gotSQL != wantSQL {
t.Fatalf("sql = %q, want %q", gotSQL, wantSQL)
}
if len(gotArgs) != 1 || gotArgs[0] != "abc%" {
t.Fatalf("args = %#v, want wildcard-converted term arg", gotArgs)
}
})

t.Run("ne uses not exists with positive string field predicate", func(t *testing.T) {
sc := searchClauseForTest("abc*", "<>")
gotSQL, gotArgs, err := f.Generate(sc, 5)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
wantSQL := "NOT EXISTS (SELECT 1 FROM item i WHERE i.pr_id = pr.id AND i.barcode LIKE $5)"
if gotSQL != wantSQL {
t.Fatalf("sql = %q, want %q", gotSQL, wantSQL)
}
if len(gotArgs) != 1 || gotArgs[0] != "abc%" {
t.Fatalf("args = %#v, want wildcard-converted term arg", gotArgs)
}
})

t.Run("empty eq checks no non-empty related value", func(t *testing.T) {
sc := searchClauseForTest("", "=")
gotSQL, gotArgs, err := f.Generate(sc, 6)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
wantSQL := "NOT EXISTS (SELECT 1 FROM item i WHERE i.pr_id = pr.id AND COALESCE(i.barcode, '') <> '')"
if gotSQL != wantSQL {
t.Fatalf("sql = %q, want %q", gotSQL, wantSQL)
}
if len(gotArgs) != 0 {
t.Fatalf("args = %#v, want empty args", gotArgs)
}
})

t.Run("empty ne checks at least one non-empty related value", func(t *testing.T) {
sc := searchClauseForTest("", "<>")
gotSQL, gotArgs, err := f.Generate(sc, 7)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
wantSQL := "EXISTS (SELECT 1 FROM item i WHERE i.pr_id = pr.id AND COALESCE(i.barcode, '') <> '')"
if gotSQL != wantSQL {
t.Fatalf("sql = %q, want %q", gotSQL, wantSQL)
}
if len(gotArgs) != 0 {
t.Fatalf("args = %#v, want empty args", gotArgs)
}
})
}

func TestHandlePatronRequestsQueryItemFieldsUseExists(t *testing.T) {
tests := []struct {
name string
cql string
wantWhere string
}{
{
name: "item_id exact",
cql: `item_id = "item-123"`,
wantWhere: "EXISTS (SELECT 1 FROM item cql_item WHERE cql_item.pr_id = patron_request_search_view.id AND cql_item.item_id = $3)",
},
{
name: "barcode wildcard",
cql: `barcode = "abc*"`,
wantWhere: "EXISTS (SELECT 1 FROM item cql_item WHERE cql_item.pr_id = patron_request_search_view.id AND cql_item.barcode LIKE $3)",
},
{
name: "call_number empty",
cql: `call_number = ""`,
wantWhere: "NOT EXISTS (SELECT 1 FROM item cql_item WHERE cql_item.pr_id = patron_request_search_view.id AND COALESCE(cql_item.call_number, '') <> '')",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
query, err := handlePatronRequestsQuery(tc.cql, 2)
if err != nil {
t.Fatalf("handlePatronRequestsQuery() error = %v", err)
}
if got := query.GetWhereClause(); got != tc.wantWhere {
t.Fatalf("where clause = %q, want %q", got, tc.wantWhere)
}
})
}
}

func searchClauseForTest(term, relation string) cql.SearchClause {
return cql.SearchClause{Term: term, Relation: cql.Relation(relation)}
}
11 changes: 11 additions & 0 deletions broker/sqlc/pr_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ CREATE TABLE item
created_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE INDEX idx_item_barcode_pr_id
ON item (barcode, pr_id);

CREATE INDEX idx_item_item_id_pr_id
ON item (item_id, pr_id)
WHERE item_id IS NOT NULL;

CREATE INDEX idx_item_call_number_pr_id
ON item (call_number, pr_id)
Comment on lines +44 to +51
WHERE call_number IS NOT NULL;

CREATE TABLE notification
(
id VARCHAR PRIMARY KEY,
Expand Down
Loading
Loading