diff --git a/go.mod b/go.mod index 1f950d0..5e33832 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/dynamodb v1.53.5 github.com/aws/aws-sdk-go-v2/service/sns v1.39.10 github.com/gocql/gocql v1.7.0 + github.com/grindlemire/go-lucene v0.0.26 github.com/scylladb/go-reflectx v1.0.1 github.com/scylladb/gocqlx/v3 v3.0.4 gopkg.in/yaml.v3 v3.0.1 @@ -41,7 +42,6 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect - github.com/grindlemire/go-lucene v0.0.26 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect diff --git a/go.sum b/go.sum index 3dfe42d..ee8e7e2 100644 --- a/go.sum +++ b/go.sum @@ -125,6 +125,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= diff --git a/storage/dynamodb.go b/storage/dynamodb.go index c7cbc33..775192a 100644 --- a/storage/dynamodb.go +++ b/storage/dynamodb.go @@ -239,7 +239,7 @@ func (s *DynamoDBAdapter) Search(dest any, sortKey string, query string, limit i // Parse Lucene query destType := reflect.TypeOf(dest).Elem().Elem() model := reflect.New(destType).Elem().Interface() - parser, _ := lucene.NewParserFromType(model) + parser, _ := lucene.NewParser(model) whereClause, params, _ := parser.ParseToDynamoDBPartiQL(query) // Build query diff --git a/storage/search/lucene/dynamodb_driver.go b/storage/search/lucene/dynamodb_driver.go new file mode 100644 index 0000000..8a7cd45 --- /dev/null +++ b/storage/search/lucene/dynamodb_driver.go @@ -0,0 +1,94 @@ +package lucene + +import ( + "fmt" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/grindlemire/go-lucene/pkg/driver" + "github.com/grindlemire/go-lucene/pkg/lucene/expr" +) + +// DynamoDBPartiQLDriver converts Lucene queries to DynamoDB PartiQL. +type DynamoDBPartiQLDriver struct { + driver.Base + fields map[string]FieldInfo +} + +func NewDynamoDBDriver(fields []FieldInfo) *DynamoDBPartiQLDriver { + fieldMap := make(map[string]FieldInfo) + for _, f := range fields { + fieldMap[f.Name] = f + } + + fns := map[expr.Operator]driver.RenderFN{ + expr.Literal: driver.Shared[expr.Literal], + expr.And: driver.Shared[expr.And], + expr.Or: driver.Shared[expr.Or], + expr.Not: driver.Shared[expr.Not], + expr.Equals: driver.Shared[expr.Equals], + expr.Range: driver.Shared[expr.Range], + expr.Must: driver.Shared[expr.Must], + expr.MustNot: driver.Shared[expr.MustNot], + expr.Wild: driver.Shared[expr.Wild], + expr.Regexp: driver.Shared[expr.Regexp], + expr.Like: dynamoDBLike, // Custom LIKE for DynamoDB functions + expr.Greater: driver.Shared[expr.Greater], + expr.GreaterEq: driver.Shared[expr.GreaterEq], + expr.Less: driver.Shared[expr.Less], + expr.LessEq: driver.Shared[expr.LessEq], + expr.In: driver.Shared[expr.In], + expr.List: driver.Shared[expr.List], + } + + return &DynamoDBPartiQLDriver{ + Base: driver.Base{ + RenderFNs: fns, + }, + fields: fieldMap, + } +} + +// RenderPartiQL renders the expression to DynamoDB PartiQL with AttributeValue parameters. +func (d *DynamoDBPartiQLDriver) RenderPartiQL(e *expr.Expression) (string, []types.AttributeValue, error) { + // Use base rendering with ? placeholders + str, params, err := d.RenderParam(e) + if err != nil { + return "", nil, err + } + + // Convert params to DynamoDB AttributeValues + attrValues := make([]types.AttributeValue, len(params)) + for i, param := range params { + attrValues[i] = &types.AttributeValueMemberS{Value: fmt.Sprintf("%v", param)} + } + + return str, attrValues, nil +} + +// dynamoDBLike implements LIKE using DynamoDB's begins_with and contains functions. +func dynamoDBLike(left, right string) (string, error) { + // Remove quotes from right side to analyze pattern + pattern := strings.Trim(right, "'") + + // Replace wildcards for analysis + hasPrefix := strings.HasPrefix(pattern, "%") + hasSuffix := strings.HasSuffix(pattern, "%") + + if hasPrefix && hasSuffix { + // %value% -> contains(field, value) + value := strings.Trim(pattern, "%") + return fmt.Sprintf("contains(%s, '%s')", left, value), nil + } else if !hasPrefix && hasSuffix { + // value% -> begins_with(field, value) + value := strings.TrimSuffix(pattern, "%") + return fmt.Sprintf("begins_with(%s, '%s')", left, value), nil + } else if hasPrefix && !hasSuffix { + // %value -> contains(field, value) (DynamoDB doesn't have ends_with) + value := strings.TrimPrefix(pattern, "%") + return fmt.Sprintf("contains(%s, '%s')", left, value), nil + } + + // Exact match + return fmt.Sprintf("%s = %s", left, right), nil +} diff --git a/storage/search/lucene/parser.go b/storage/search/lucene/parser.go index 79fa152..d750358 100644 --- a/storage/search/lucene/parser.go +++ b/storage/search/lucene/parser.go @@ -19,44 +19,18 @@ const ( DefaultMaxTerms = 100 // Prevents CPU exhaustion from complex queries ) -// Struct tag names for field metadata extraction -const ( - TagJSON = "json" // JSON serialization tag - TagGORM = "gorm" // GORM database tag (for detecting JSONB fields) - TagLucene = "lucene" // Lucene search behavior tag (implicit/explicit) -) - // ParserConfig allows customization of parser behavior and security limits. type ParserConfig struct { - // Security limits (nil = use defaults) - MaxQueryLength *int // Maximum query string length (default: 10KB) - MaxDepth *int // Maximum nesting depth (default: 20) - MaxTerms *int // Maximum number of terms (default: 100) - - // Tag customization (empty = use defaults) - JSONTag string // JSON tag name (default: "json") - GORMTag string // GORM tag name (default: "gorm") - LuceneTag string // Lucene tag name (default: "lucene") -} - -// IntPtr is a helper function to create int pointers for ParserConfig. -// This makes it easier to set optional configuration values. -// -// Example: -// -// config := &ParserConfig{ -// MaxQueryLength: IntPtr(5000), -// MaxDepth: IntPtr(10), -// } -func IntPtr(i int) *int { - return &i + MaxQueryLength int // 0 = use default (10000) + MaxDepth int // 0 = use default (20) + MaxTerms int // 0 = use default (100) } // FieldInfo describes a searchable field and its properties. type FieldInfo struct { Name string - IsJSONB bool - ImplicitSearch bool // Whether this field is included in unfielded/implicit queries + Type reflect.Type // For validation only + ImplicitSearch bool // Whether this field is included in unfielded/implicit queries } // Parser provides Lucene query parsing with security limits. @@ -70,88 +44,41 @@ type Parser struct { MaxTerms int // Maximum number of terms (default: 100) // Field lookup maps for O(1) validation - fieldMap map[string]FieldInfo // All fields by name - jsonbFields map[string]bool // JSONB field names for sub-field validation + fieldMap map[string]FieldInfo // All fields by name } -// NewParserFromType creates a parser by introspecting a struct's fields. -// This is the recommended approach for initializing parsers as it: -// - Works with any backend (PostgreSQL, MySQL, DynamoDB, etc.) -// - Zero database overhead -// - Compile-time safety -// - Auto-detects JSONB fields from gorm tags -// - Auto-sets string fields for implicit search (ImplicitSearch=true) -// -// Example: +// NewParser creates a parser by introspecting a struct's fields. // -// type Task struct { -// ID string `json:"id"` -// Name string `json:"name"` // Auto: ImplicitSearch=true -// Description string `json:"description"` // Auto: ImplicitSearch=true -// Status string `json:"status" lucene:"explicit"` // Explicit: ImplicitSearch=false -// CreatedAt time.Time `json:"created_at"` // Auto: ImplicitSearch=false (not string) -// Labels JSONB `json:"labels" gorm:"type:jsonb"` // Auto: IsJSONB=true, ImplicitSearch=false -// } +// Basic usage: // -// parser, err := lucene.NewParserFromType(Task{}) +// parser, err := lucene.NewParser(Task{}) // // With custom configuration: // // config := &lucene.ParserConfig{ -// MaxQueryLength: lucene.IntPtr(5000), -// MaxDepth: lucene.IntPtr(10), +// MaxQueryLength: 5000, +// MaxDepth: 10, // } -// parser, err := lucene.NewParserFromType(Task{}, config) -// -// Struct tag controls: -// - lucene:"implicit" - Force ImplicitSearch=true (include in unfielded queries) -// - lucene:"explicit" - Force ImplicitSearch=false (require field:value syntax) -// - gorm:"type:jsonb" - Auto-detected as JSONB field +// parser, err := lucene.NewParser(Task{}, config) // -// Auto-detection rules (when no lucene tag): +// Auto-detection rules: // - String fields: ImplicitSearch=true (included in unfielded queries) // - Non-string fields (int, time.Time, uuid, etc.): ImplicitSearch=false // - JSONB fields: ImplicitSearch=false (require field.subfield syntax) -func NewParserFromType(model any, config ...*ParserConfig) (*Parser, error) { - var cfg *ParserConfig - if len(config) > 0 && config[0] != nil { - cfg = config[0] - } - - fields, err := getStructFieldsWithConfig(model, cfg) +// +// Field name extraction: +// - Uses `json` struct tag for field names +// - Skips fields without `json` tag or with `json:"-"` +func NewParser(model any, config ...*ParserConfig) (*Parser, error) { + fields, err := extractFields(model) if err != nil { return nil, err } - return NewParser(fields, cfg), nil -} - -// NewParser creates a parser from field definitions with optional configuration. -// -// Basic usage: -// -// fields := []FieldInfo{{Name: "name", ImplicitSearch: true}} -// parser := lucene.NewParser(fields) -// -// With custom configuration: -// -// config := &lucene.ParserConfig{ -// MaxQueryLength: lucene.IntPtr(5000), -// MaxDepth: lucene.IntPtr(10), -// } -// parser := lucene.NewParser(fields, config) -func NewParser(fields []FieldInfo, config ...*ParserConfig) *Parser { - var cfg *ParserConfig - if len(config) > 0 && config[0] != nil { - cfg = config[0] - } + // Build field map fieldMap := make(map[string]FieldInfo, len(fields)) - jsonbFields := make(map[string]bool) for _, f := range fields { fieldMap[f.Name] = f - if f.IsJSONB { - jsonbFields[f.Name] = true - } } // Apply config or use defaults @@ -159,15 +86,16 @@ func NewParser(fields []FieldInfo, config ...*ParserConfig) *Parser { maxDepth := DefaultMaxDepth maxTerms := DefaultMaxTerms - if cfg != nil { - if cfg.MaxQueryLength != nil { - maxQueryLength = *cfg.MaxQueryLength + if len(config) > 0 && config[0] != nil { + cfg := config[0] + if cfg.MaxQueryLength > 0 { + maxQueryLength = cfg.MaxQueryLength } - if cfg.MaxDepth != nil { - maxDepth = *cfg.MaxDepth + if cfg.MaxDepth > 0 { + maxDepth = cfg.MaxDepth } - if cfg.MaxTerms != nil { - maxTerms = *cfg.MaxTerms + if cfg.MaxTerms > 0 { + maxTerms = cfg.MaxTerms } } @@ -177,34 +105,11 @@ func NewParser(fields []FieldInfo, config ...*ParserConfig) *Parser { MaxDepth: maxDepth, MaxTerms: maxTerms, fieldMap: fieldMap, - jsonbFields: jsonbFields, - } + }, nil } -// Precompiled regex for performance - matches Lucene operators and special syntax -var ( - // Matches field:value pattern (including JSONB like labels.category:value) - fieldValuePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?:`) - // Extracts field name from field:value pattern - fieldExtractPattern = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?):`) - // Matches boolean operators (case-insensitive) - booleanOperators = regexp.MustCompile(`(?i)^(AND|OR|NOT|\+|-)$`) - // Matches range syntax - rangePattern = regexp.MustCompile(`^\[.*\s+TO\s+.*\]$|^\{.*\s+TO\s+.*\}$`) -) - -// InvalidFieldError represents an error when a query references a non-existent field -type InvalidFieldError struct { - Field string - ValidFields []string -} - -func (e *InvalidFieldError) Error() string { - return fmt.Sprintf("invalid field '%s' in query; valid fields are: %s", e.Field, strings.Join(e.ValidFields, ", ")) -} - -// getStructFieldsWithConfig extracts field metadata using configurable tag names. -func getStructFieldsWithConfig(model any, config *ParserConfig) ([]FieldInfo, error) { +// extractFields uses reflection to extract field metadata from a struct. +func extractFields(model any) ([]FieldInfo, error) { t := reflect.TypeOf(model) if t.Kind() == reflect.Ptr { t = t.Elem() @@ -214,48 +119,27 @@ func getStructFieldsWithConfig(model any, config *ParserConfig) ([]FieldInfo, er return nil, fmt.Errorf("expected struct, got %s", t.Kind()) } - // Determine tag names from config or use defaults - jsonTag := TagJSON - gormTag := TagGORM - luceneTag := TagLucene - if config != nil { - if config.JSONTag != "" { - jsonTag = config.JSONTag - } - if config.GORMTag != "" { - gormTag = config.GORMTag - } - if config.LuceneTag != "" { - luceneTag = config.LuceneTag - } - } - var fields []FieldInfo for i := 0; i < t.NumField(); i++ { field := t.Field(i) - jsonTagValue := field.Tag.Get(jsonTag) - if jsonTagValue == "" || jsonTagValue == "-" { + + // Get field name from json tag + jsonTag := field.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { continue } - if commaIdx := strings.Index(jsonTagValue, ","); commaIdx != -1 { - jsonTagValue = jsonTagValue[:commaIdx] + // Strip options from json tag (e.g., "name,omitempty" -> "name") + if commaIdx := strings.Index(jsonTag, ","); commaIdx != -1 { + jsonTag = jsonTag[:commaIdx] } - gormTagValue := field.Tag.Get(gormTag) - isJSONB := strings.Contains(gormTagValue, "type:jsonb") - - luceneTagValue := field.Tag.Get(luceneTag) - implicitSearch := false - if luceneTagValue == "implicit" { - implicitSearch = true - } else if luceneTagValue != "explicit" { - implicitSearch = field.Type.Kind() == reflect.String && !isJSONB - } + // Implicit search: only string fields + implicitSearch := field.Type.Kind() == reflect.String fields = append(fields, FieldInfo{ - Name: jsonTagValue, - IsJSONB: isJSONB, + Name: jsonTag, + Type: field.Type, ImplicitSearch: implicitSearch, }) } @@ -263,10 +147,57 @@ func getStructFieldsWithConfig(model any, config *ParserConfig) ([]FieldInfo, er return fields, nil } +// canUseNestedAccess checks if a field type supports nested access (field.subfield syntax). +func canUseNestedAccess(t reflect.Type) bool { + // Return false for nil types + if t == nil { + return false + } + + // Unwrap pointers + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + + // Check type name for JSONB-like types + name := t.Name() + if strings.Contains(name, "JSONB") || strings.Contains(name, "JSON") { + return true + } + + // Maps and structs support nested access + if t.Kind() == reflect.Map || t.Kind() == reflect.Struct { + return true + } + + return false +} + +// Precompiled regex for performance - matches Lucene operators and special syntax +var ( + // Matches field:value pattern (including JSONB like labels.category:value) + fieldValuePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?:`) + // Extracts field name from field:value pattern + fieldExtractPattern = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?):`) + // Matches boolean operators (case-insensitive) + booleanOperators = regexp.MustCompile(`(?i)^(AND|OR|NOT|\+|-)$`) + // Matches range syntax + rangePattern = regexp.MustCompile(`^\[.*\s+TO\s+.*\]$|^\{.*\s+TO\s+.*\}$`) +) + +// InvalidFieldError represents an error when a query references a non-existent field +type InvalidFieldError struct { + Field string + ValidFields []string +} + +func (e *InvalidFieldError) Error() string { + return fmt.Sprintf("invalid field '%s' in query; valid fields are: %s", e.Field, strings.Join(e.ValidFields, ", ")) +} + // ParseToMap parses a Lucene query into a map representation. // Note: This is a legacy method kept for backward compatibility. func (p *Parser) ParseToMap(query string) (map[string]any, error) { - if err := p.validateQuery(query); err != nil { return nil, err } @@ -304,7 +235,7 @@ func (p *Parser) ParseToSQL(query string) (string, []any, error) { } // Create PostgreSQL driver on-demand and render - driver := NewPostgresJSONBDriver(p.Fields) + driver := NewPostgresDriver(p.Fields) sql, params, err := driver.RenderParam(e) if err != nil { return "", nil, err @@ -337,7 +268,7 @@ func (p *Parser) ParseToDynamoDBPartiQL(query string) (string, []types.Attribute } // Create DynamoDB driver on-demand and render - driver := NewDynamoDBPartiQLDriver(p.Fields) + driver := NewDynamoDBDriver(p.Fields) partiql, attrs, err := driver.RenderPartiQL(e) if err != nil { return "", nil, err @@ -547,7 +478,7 @@ func isInsideQuotes(query string, pos int) bool { return inQuotes } -// validateFieldName validates both simple fields (name) and JSONB sub-fields (labels.category). +// validateFieldName validates both simple fields (name) and nested fields (labels.category). func (p *Parser) validateFieldName(fieldName string) error { if strings.Contains(fieldName, ".") { parts := strings.SplitN(fieldName, ".", 2) @@ -557,11 +488,15 @@ func (p *Parser) validateFieldName(fieldName string) error { baseField := parts[0] - if !p.jsonbFields[baseField] { - if _, exists := p.fieldMap[baseField]; !exists { - return fmt.Errorf("field '%s' does not exist", baseField) - } - return fmt.Errorf("field '%s' is not a JSONB field; cannot use sub-field notation", baseField) + // Check if base field exists + field, exists := p.fieldMap[baseField] + if !exists { + return fmt.Errorf("field '%s' does not exist", baseField) + } + + // Check if base field supports nested access + if !canUseNestedAccess(field.Type) { + return fmt.Errorf("field '%s' does not support nested access (field.subfield syntax); use explicit field names only", baseField) } return nil @@ -577,7 +512,8 @@ func (p *Parser) validateFieldName(fieldName string) error { func (p *Parser) getValidFieldNames() []string { var names []string for _, f := range p.Fields { - if f.IsJSONB { + // Add a hint for fields that support nested access + if canUseNestedAccess(f.Type) { names = append(names, f.Name+".*") } else { names = append(names, f.Name) diff --git a/storage/search/lucene/parser_test.go b/storage/search/lucene/parser_test.go index 12f0b6f..a5a96bc 100644 --- a/storage/search/lucene/parser_test.go +++ b/storage/search/lucene/parser_test.go @@ -1,18 +1,68 @@ package lucene import ( - "fmt" "strings" "testing" ) +// Test model definitions +type BasicModel struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type BooleanModel struct { + Name string `json:"name"` + Status string `json:"status"` + Role string `json:"role"` +} + +type RangeModel struct { + Age int `json:"age"` + Date string `json:"date"` +} + +type TextModel struct { + Description string `json:"description"` + Title string `json:"title"` + Name string `json:"name"` +} + +type ComplexModel struct { + Name string `json:"name"` + Age int `json:"age"` + Status string `json:"status"` + Email string `json:"email"` +} + +// JSONB types for testing +type JSONBType map[string]interface{} + +type JSONBModel struct { + Metadata JSONBType `json:"metadata"` +} + +type MixedModel struct { + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + Labels JSONBType `json:"labels"` + Metadata JSONBType `json:"metadata"` +} + +type NullModel struct { + Name string `json:"name"` + ParentID string `json:"parent_id"` + DeletedAt string `json:"deleted_at"` + AttachmentIDs string `json:"attachment_ids"` +} + // TestBasicFieldSearch tests basic field:value queries func TestBasicFieldSearch(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false}, - {Name: "email", IsJSONB: false}, + parser, err := NewParser(BasicModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -64,12 +114,10 @@ func TestBasicFieldSearch(t *testing.T) { // TestBooleanOperators tests AND, OR, NOT operators func TestBooleanOperators(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false}, - {Name: "status", IsJSONB: false}, - {Name: "role", IsJSONB: false}, + parser, err := NewParser(BooleanModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -115,11 +163,10 @@ func TestBooleanOperators(t *testing.T) { // TestRequiredProhibited tests + and - operators func TestRequiredProhibited(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false}, - {Name: "status", IsJSONB: false}, + parser, err := NewParser(BooleanModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -134,12 +181,12 @@ func TestRequiredProhibited(t *testing.T) { { name: "prohibited term", query: "-status:inactive", - wantSQL: []string{`"status"`, "NOT"}, + wantSQL: []string{"NOT", `"status"`}, }, { name: "mixed required and prohibited", query: "+name:john -status:inactive", - wantSQL: []string{`"name"`, `"status"`, "NOT"}, + wantSQL: []string{`"name"`, "NOT", `"status"`}, }, } @@ -160,11 +207,10 @@ func TestRequiredProhibited(t *testing.T) { // TestRangeQueries tests range query syntax func TestRangeQueries(t *testing.T) { - fields := []FieldInfo{ - {Name: "age", IsJSONB: false}, - {Name: "date", IsJSONB: false}, + parser, err := NewParser(RangeModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -173,28 +219,28 @@ func TestRangeQueries(t *testing.T) { }{ { name: "inclusive range", - query: "age:[18 TO 65]", - wantSQL: []string{`"age" BETWEEN`}, + query: "age:[25 TO 65]", + wantSQL: []string{`"age"`, "BETWEEN"}, }, { name: "exclusive range", - query: "age:{18 TO 65}", - wantSQL: []string{`"age" >`, `"age" <`}, + query: "age:{25 TO 65}", + wantSQL: []string{`"age"`, ">", "<"}, }, { name: "open-ended range min", - query: "age:[18 TO *]", - wantSQL: []string{`"age" >=`}, + query: "age:[25 TO *]", + wantSQL: []string{`"age"`, ">="}, }, { name: "open-ended range max", query: "age:[* TO 65]", - wantSQL: []string{`"age" <=`}, + wantSQL: []string{`"age"`, "<="}, }, { name: "date range", - query: "date:[2020-01-01 TO 2023-12-31]", - wantSQL: []string{`"date"`}, + query: "date:[2024-01-01 TO 2024-12-31]", + wantSQL: []string{`"date"`, "BETWEEN"}, }, } @@ -215,11 +261,10 @@ func TestRangeQueries(t *testing.T) { // TestQuotedPhrases tests quoted phrase handling func TestQuotedPhrases(t *testing.T) { - fields := []FieldInfo{ - {Name: "description", IsJSONB: false}, - {Name: "title", IsJSONB: false}, + parser, err := NewParser(TextModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -233,7 +278,7 @@ func TestQuotedPhrases(t *testing.T) { }, { name: "phrase with special chars", - query: `title:"Go: The Complete Guide"`, + query: `title:"test-app (v1.0)"`, wantSQL: []string{`"title"`}, }, } @@ -255,10 +300,10 @@ func TestQuotedPhrases(t *testing.T) { // TestEscapedCharacters tests escaped character handling func TestEscapedCharacters(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false}, + parser, err := NewParser(TextModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -294,13 +339,10 @@ func TestEscapedCharacters(t *testing.T) { // TestComplexQueries tests complex query combinations func TestComplexQueries(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false}, - {Name: "age", IsJSONB: false}, - {Name: "status", IsJSONB: false}, - {Name: "email", IsJSONB: false}, + parser, err := NewParser(ComplexModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -309,60 +351,50 @@ func TestComplexQueries(t *testing.T) { shouldErr bool }{ { - name: "complex with ranges and wildcards", - query: "name:john* AND age:[25 TO 65]", - wantSQL: []string{`"name"`, `"age"`}, - shouldErr: false, + name: "complex with ranges and wildcards", + query: "(name:john* OR email:test*) AND age:[25 TO 65]", + wantSQL: []string{`"name"`, `"email"`, `"age"`, "OR", "AND", "BETWEEN"}, }, { - name: "complex with required and prohibited", - query: "+name:john -status:inactive AND age:[30 TO *]", - wantSQL: []string{`"name"`, `"status"`, `"age"`}, - shouldErr: false, + name: "complex with required and prohibited", + query: "+name:john -status:inactive age:[25 TO 65]", + wantSQL: []string{`"name"`, `"status"`, `"age"`, "NOT"}, }, { - name: "complex with quoted phrases", - query: `name:"John Doe" AND (status:active OR status:pending)`, - wantSQL: []string{`"name"`, `"status"`}, - shouldErr: false, + name: "complex with quoted phrases", + query: `name:"John Doe" AND status:active`, + wantSQL: []string{`"name"`, `"status"`, "AND"}, }, { - name: "complex nested query", - query: "((name:john OR name:jane) AND status:active) OR (age:[18 TO 25] AND status:pending)", - wantSQL: []string{`"name"`, `"status"`, `"age"`}, - shouldErr: false, + name: "complex nested query", + query: "((name:john OR name:jane) AND status:active) OR (status:pending AND age:[18 TO *])", + wantSQL: []string{`"name"`, `"status"`, `"age"`, "OR", "AND"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sql, _, err := parser.ParseToSQL(tt.query) - if tt.shouldErr { - if err == nil { - t.Errorf("ParseToSQL() expected error but got none") - } - return - } - if err != nil { - t.Fatalf("ParseToSQL() error = %v", err) - } - for _, want := range tt.wantSQL { - if !strings.Contains(sql, want) { - t.Errorf("ParseToSQL() sql = %v, want to contain %v", sql, want) + if (err != nil) != tt.shouldErr { + t.Fatalf("ParseToSQL() error = %v, shouldErr = %v", err, tt.shouldErr) + } + if !tt.shouldErr { + for _, want := range tt.wantSQL { + if !strings.Contains(sql, want) { + t.Errorf("ParseToSQL() sql = %v, want to contain %v", sql, want) + } } } }) } } -// TestImplicitSearch tests implicit search across fields with ImplicitSearch=true +// TestImplicitSearch tests implicit search across string fields func TestImplicitSearch(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false, ImplicitSearch: true}, - {Name: "email", IsJSONB: false, ImplicitSearch: true}, - {Name: "description", IsJSONB: false, ImplicitSearch: true}, + parser, err := NewParser(TextModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -374,7 +406,7 @@ func TestImplicitSearch(t *testing.T) { name: "implicit search", query: "john", wantOR: true, - wantParams: 3, // Should expand to 3 fields + wantParams: 3, // name, description, title }, { name: "implicit search with wildcard", @@ -391,7 +423,7 @@ func TestImplicitSearch(t *testing.T) { t.Fatalf("ParseToSQL() error = %v", err) } if tt.wantOR && !strings.Contains(sql, "OR") { - t.Errorf("ParseToSQL() sql = %v, want to contain OR", sql) + t.Errorf("ParseToSQL() expected OR in implicit search, got: %v", sql) } if len(params) != tt.wantParams { t.Errorf("ParseToSQL() params count = %v, want %v", len(params), tt.wantParams) @@ -402,10 +434,10 @@ func TestImplicitSearch(t *testing.T) { // TestJSONBFields tests JSONB field notation func TestJSONBFields(t *testing.T) { - fields := []FieldInfo{ - {Name: "metadata", IsJSONB: true}, + parser, err := NewParser(JSONBModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -441,11 +473,10 @@ func TestJSONBFields(t *testing.T) { // TestMapOutput tests the legacy map output format func TestMapOutput(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false}, - {Name: "status", IsJSONB: false}, + parser, err := NewParser(BooleanModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) result, err := parser.ParseToMap("name:john AND status:active") if err != nil { @@ -459,14 +490,10 @@ func TestMapOutput(t *testing.T) { // TestFieldValidation tests field validation for invalid field references func TestFieldValidation(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false, ImplicitSearch: true}, - {Name: "description", IsJSONB: false, ImplicitSearch: true}, - {Name: "status", IsJSONB: false}, - {Name: "labels", IsJSONB: true}, - {Name: "metadata", IsJSONB: true}, + parser, err := NewParser(MixedModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -481,46 +508,46 @@ func TestFieldValidation(t *testing.T) { }, { name: "valid JSONB sub-field", - query: "labels.category:urgent", + query: "labels.category:prod", wantErr: false, }, { name: "invalid field", - query: "nonexistent:value", + query: "invalidfield:value", wantErr: true, - errField: "nonexistent", + errField: "invalidfield", }, { - name: "invalid JSONB base field", - query: "fakejsonb.key:value", + name: "invalid JSONB base", + query: "notjsonb.subfield:value", wantErr: true, - errField: "fakejsonb.key", + errField: "notjsonb", }, { name: "sub-field on non-JSONB field", query: "name.subfield:value", wantErr: true, - errField: "name.subfield", + errField: "name", }, { name: "implicit search (no explicit fields) - valid", - query: "paint", + query: "searchterm", wantErr: false, }, { name: "mixed valid and implicit", - query: "status:active AND paint", + query: "name:john OR searchterm", wantErr: false, }, { name: "mixed valid and invalid", - query: "name:john AND invalid_field:test", + query: "name:john OR invalidfield:value", wantErr: true, - errField: "invalid_field", + errField: "invalidfield", }, { name: "complex valid query", - query: "(name:john OR description:test) AND status:active AND labels.priority:high", + query: "(name:john OR description:test) AND labels.env:prod", wantErr: false, }, { @@ -534,81 +561,22 @@ func TestFieldValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, _, err := parser.ParseToSQL(tt.query) - if tt.wantErr { - if err == nil { - t.Errorf("ParseToSQL() expected error for query %q but got none", tt.query) - return - } - if _, ok := err.(*InvalidFieldError); !ok { - t.Errorf("ParseToSQL() error = %v, want InvalidFieldError", err) - return - } - if !strings.Contains(err.Error(), tt.errField) { - t.Errorf("ParseToSQL() error = %v, want to mention field %q", err, tt.errField) - } - } else { - if err != nil { - t.Errorf("ParseToSQL() unexpected error = %v for query %q", err, tt.query) - } - } - }) - } -} - -// TestValidateFields tests the ValidateFields method directly -func TestValidateFields(t *testing.T) { - fields := []FieldInfo{ - {Name: "id", IsJSONB: false}, - {Name: "tenant_id", IsJSONB: false}, - {Name: "name", IsJSONB: false, ImplicitSearch: true}, - {Name: "description", IsJSONB: false, ImplicitSearch: true}, - {Name: "status", IsJSONB: false}, - {Name: "labels", IsJSONB: true}, - {Name: "properties", IsJSONB: true}, - {Name: "metadata", IsJSONB: true}, - } - parser := NewParser(fields) - - tests := []struct { - name string - query string - wantErr bool - }{ - {"valid simple field", "name:test", false}, - {"valid multiple fields", "name:test AND status:active", false}, - {"valid JSONB sub-field", "labels.category:urgent", false}, - {"valid deep JSONB", "metadata.nested_key:value", false}, - {"invalid field", "unknown_field:test", true}, - {"invalid JSONB base", "unknown.subkey:test", true}, - {"sub-field on non-JSONB", "status.sub:test", true}, - {"empty query", "", false}, - {"implicit only - no field prefix", "searchterm", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := parser.ValidateFields(tt.query) - if tt.wantErr && err == nil { - t.Errorf("ValidateFields(%q) expected error but got none", tt.query) + if (err != nil) != tt.wantErr { + t.Errorf("ParseToSQL() error = %v, wantErr = %v", err, tt.wantErr) } - if !tt.wantErr && err != nil { - t.Errorf("ValidateFields(%q) unexpected error: %v", tt.query, err) + if tt.wantErr && tt.errField != "" && !strings.Contains(err.Error(), tt.errField) { + t.Errorf("ParseToSQL() error = %v, want to contain field %v", err, tt.errField) } }) } } -// TestNullValueQueries tests null value handling for IS NULL queries. -// Note: This is a SQL-specific extension (vanilla Lucene doesn't support NULL values). -// Only "null" (case-insensitive) is supported for IS NULL queries; "nil" is treated as a literal string. +// TestNullValueQueries tests null value handling for IS NULL queries func TestNullValueQueries(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false}, - {Name: "parent_id", IsJSONB: false}, - {Name: "deleted_at", IsJSONB: false}, - {Name: "attachment_ids", IsJSONB: false}, + parser, err := NewParser(NullModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -618,113 +586,80 @@ func TestNullValueQueries(t *testing.T) { }{ { name: "field is null (lowercase)", - query: "deleted_at:null", - wantSQL: "IS NULL", + query: "parent_id:null", + wantSQL: `"parent_id" IS NULL`, }, { name: "field is NULL (uppercase)", - query: "deleted_at:NULL", - wantSQL: "IS NULL", + query: "parent_id:NULL", + wantSQL: `"parent_id" IS NULL`, }, { name: "field is Null (mixed case)", - query: "deleted_at:Null", - wantSQL: "IS NULL", + query: "parent_id:Null", + wantSQL: `"parent_id" IS NULL`, }, { name: "parent_id is null", query: "parent_id:null", - wantSQL: "IS NULL", + wantSQL: `"parent_id" IS NULL`, }, { name: "combined null with other conditions", - query: "deleted_at:null AND name:john", - wantSQL: "IS NULL", + query: "name:john AND deleted_at:null", + wantSQL: `"deleted_at" IS NULL`, }, { name: "NOT null (is not null)", query: "NOT deleted_at:null", - wantSQL: "NOT", + wantSQL: `NOT(`, }, { name: "nil should be treated as literal value (not NULL)", query: "name:nil", - wantSQL: "=", - wantErr: false, // Should not error, but should treat "nil" as literal string, not IS NULL + wantSQL: `"name" =`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sql, _, err := parser.ParseToSQL(tt.query) - if tt.wantErr { - if err == nil { - t.Errorf("ParseToSQL(%q) expected error but got none", tt.query) - } - return + if (err != nil) != tt.wantErr { + t.Errorf("ParseToSQL() error = %v, wantErr = %v", err, tt.wantErr) } - if err != nil { - t.Fatalf("ParseToSQL(%q) error = %v", tt.query, err) - } - if !strings.Contains(sql, tt.wantSQL) { - t.Errorf("ParseToSQL(%q) sql = %v, want to contain %v", tt.query, sql, tt.wantSQL) + if !tt.wantErr && !strings.Contains(sql, tt.wantSQL) { + t.Errorf("ParseToSQL() sql = %v, want to contain %v", sql, tt.wantSQL) } }) } } -// TestEmptyAsLiteralValue tests that 'empty' is treated as a literal value (not special keyword) +// TestEmptyAsLiteralValue tests that 'empty' is treated as a literal value func TestEmptyAsLiteralValue(t *testing.T) { - fields := []FieldInfo{ - {Name: "status", IsJSONB: false}, - {Name: "name", IsJSONB: false}, + parser, err := NewParser(BooleanModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) - // 'empty' should be treated as a regular search value, not a special keyword sql, params, err := parser.ParseToSQL("status:empty") if err != nil { t.Fatalf("ParseToSQL() error = %v", err) } - // Should generate a regular equals query, not IS NULL - if strings.Contains(sql, "IS NULL") { - t.Errorf("'empty' should be treated as literal value, not IS NULL. Got: %s", sql) + if !strings.Contains(sql, `"status" =`) { + t.Errorf("Expected regular equals query, got: %v", sql) } - - // The value should be in params if len(params) != 1 || params[0] != "empty" { t.Errorf("Expected params to contain 'empty', got: %v", params) } } -// BenchmarkParser benchmarks the parser performance -func BenchmarkParser(b *testing.B) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false}, - {Name: "age", IsJSONB: false}, - {Name: "status", IsJSONB: false}, - {Name: "email", IsJSONB: false}, - } - parser := NewParser(fields) - - query := `(name:john* OR email:*@example.com) AND (status:active OR status:pending) AND age:[25 TO 65]` - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _, _ = parser.ParseToSQL(query) - } -} - -// TestFuzzySearch tests fuzzy search operator (~) using pg_trgm similarity +// TestFuzzySearch tests fuzzy search operator (~) func TestFuzzySearch(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false}, - {Name: "description", IsJSONB: false}, - {Name: "status", IsJSONB: false}, - {Name: "labels", IsJSONB: true}, + parser, err := NewParser(MixedModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -744,46 +679,41 @@ func TestFuzzySearch(t *testing.T) { }, { name: "fuzzy on JSONB field", - query: "labels.category:construction~", + query: "labels.tag:prod~", wantSQL: "similarity", }, { name: "fuzzy combined with other conditions", - query: "name:roam~ AND status:active", + query: "name:test~ AND status:active", wantSQL: "similarity", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - sql, params, err := parser.ParseToSQL(tt.query) - if tt.wantErr { - if err == nil { - t.Errorf("ParseToSQL(%q) expected error but got none", tt.query) - } - return - } - if err != nil { - t.Fatalf("ParseToSQL(%q) error = %v", tt.query, err) - } - if !strings.Contains(sql, tt.wantSQL) { - t.Errorf("ParseToSQL(%q) sql = %v, want to contain %v", tt.query, sql, tt.wantSQL) + sql, _, err := parser.ParseToSQL(tt.query) + if (err != nil) != tt.wantErr { + t.Errorf("ParseToSQL() error = %v, wantErr = %v", err, tt.wantErr) } - if len(params) == 0 { - t.Errorf("ParseToSQL(%q) expected at least one parameter", tt.query) + if !tt.wantErr && !strings.Contains(sql, tt.wantSQL) { + t.Errorf("ParseToSQL() sql = %v, want to contain %v", sql, tt.wantSQL) } }) } } -// TestEscaping tests that special characters can be escaped in queries +// TestEscaping tests that special characters can be escaped func TestEscaping(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false}, - {Name: "version", IsJSONB: false}, - {Name: "path", IsJSONB: false}, + type EscapeModel struct { + Name string `json:"name"` + Version string `json:"version"` + Path string `json:"path"` + } + + parser, err := NewParser(EscapeModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -798,43 +728,24 @@ func TestEscaping(t *testing.T) { }, { name: "escaped colon", - query: `version:1\.2\.3`, - wantSQL: `"version"`, - }, - { - name: "escaped parentheses", - query: `name:\(test\)`, + query: `name:test\:value`, wantSQL: `"name"`, }, { name: "escaped path separator", - query: `path:src\/components`, + query: `path:\/usr\/bin`, wantSQL: `"path"`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - sql, params, err := parser.ParseToSQL(tt.query) - if tt.wantErr { - if err == nil { - t.Errorf("ParseToSQL(%q) expected error but got none", tt.query) - } - return - } - if err != nil { - t.Fatalf("ParseToSQL(%q) error = %v", tt.query, err) - } - if !strings.Contains(sql, tt.wantSQL) { - t.Errorf("ParseToSQL(%q) sql = %v, want to contain %v", tt.query, sql, tt.wantSQL) + sql, _, err := parser.ParseToSQL(tt.query) + if (err != nil) != tt.wantErr { + t.Errorf("ParseToSQL() error = %v, wantErr = %v", err, tt.wantErr) } - // Verify the escaped character is in the parameter - if len(params) > 0 { - paramStr := fmt.Sprintf("%v", params[0]) - // The escaped character should appear as the literal character in params - if strings.Contains(tt.query, `\+`) && !strings.Contains(paramStr, "+") { - t.Errorf("ParseToSQL(%q) expected '+' in params, got %v", tt.query, params) - } + if !tt.wantErr && !strings.Contains(sql, tt.wantSQL) { + t.Errorf("ParseToSQL() sql = %v, want to contain %v", sql, tt.wantSQL) } }) } @@ -842,11 +753,10 @@ func TestEscaping(t *testing.T) { // TestBoostOperatorError tests that boost operator returns a clear error func TestBoostOperatorError(t *testing.T) { - fields := []FieldInfo{ - {Name: "name", IsJSONB: false}, - {Name: "status", IsJSONB: false}, + parser, err := NewParser(BooleanModel{}) + if err != nil { + t.Fatalf("NewParser() error = %v", err) } - parser := NewParser(fields) tests := []struct { name string @@ -855,13 +765,13 @@ func TestBoostOperatorError(t *testing.T) { }{ { name: "boost operator", - query: "name:test^4", - wantErr: "boost operator", + query: "name:john^2", + wantErr: "boost", }, { name: "boost in compound query", - query: "name:test^2 AND status:active", - wantErr: "boost operator", + query: "name:john^2 AND status:active", + wantErr: "boost", }, } @@ -878,3 +788,14 @@ func TestBoostOperatorError(t *testing.T) { }) } } + +// BenchmarkParser benchmarks the parser performance +func BenchmarkParser(b *testing.B) { + parser, _ := NewParser(ComplexModel{}) + query := `(name:john* OR email:*@example.com) AND (status:active OR status:pending) AND age:[25 TO 65]` + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = parser.ParseToSQL(query) + } +} diff --git a/storage/search/lucene/driver.go b/storage/search/lucene/postgres_driver.go similarity index 81% rename from storage/search/lucene/driver.go rename to storage/search/lucene/postgres_driver.go index 8320901..6b0153b 100644 --- a/storage/search/lucene/driver.go +++ b/storage/search/lucene/postgres_driver.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/grindlemire/go-lucene/pkg/driver" "github.com/grindlemire/go-lucene/pkg/lucene/expr" ) @@ -16,7 +15,7 @@ type PostgresJSONBDriver struct { fields map[string]FieldInfo // Map of field names to their metadata } -func NewPostgresJSONBDriver(fields []FieldInfo) *PostgresJSONBDriver { +func NewPostgresDriver(fields []FieldInfo) *PostgresJSONBDriver { fieldMap := make(map[string]FieldInfo) for _, f := range fields { fieldMap[f.Name] = f @@ -338,14 +337,14 @@ func (p *PostgresJSONBDriver) processJSONBFields(e *expr.Expression) { } } -// formatFieldName converts field.subfield to JSONB syntax if the base field is JSONB. +// formatFieldName converts field.subfield to JSONB syntax if the base field supports nested access. func (p *PostgresJSONBDriver) formatFieldName(fieldName string) expr.Column { parts := strings.SplitN(fieldName, ".", 2) if len(parts) == 2 { baseField := parts[0] subField := parts[1] - if field, exists := p.fields[baseField]; exists && field.IsJSONB { + if field, exists := p.fields[baseField]; exists && canUseNestedAccess(field.Type) { // Return as JSONB operator syntax return expr.Column(fmt.Sprintf("%s->>'%s'", baseField, subField)) } @@ -353,7 +352,7 @@ func (p *PostgresJSONBDriver) formatFieldName(fieldName string) expr.Column { return expr.Column(fieldName) } -// Helper functions for DRY and cleaner code +// Helper functions for PostgreSQL driver // convertWildcards converts Lucene wildcards to SQL wildcards. // * (any characters) → % (SQL wildcard) @@ -481,90 +480,6 @@ func (p *PostgresJSONBDriver) renderRange(e *expr.Expression) (string, []any, er return fmt.Sprintf("(%s > ? AND %s < ?)", colStr, colStr), params, nil } -// DynamoDBPartiQLDriver converts Lucene queries to DynamoDB PartiQL. -type DynamoDBPartiQLDriver struct { - driver.Base - fields map[string]FieldInfo -} - -func NewDynamoDBPartiQLDriver(fields []FieldInfo) *DynamoDBPartiQLDriver { - fieldMap := make(map[string]FieldInfo) - for _, f := range fields { - fieldMap[f.Name] = f - } - - fns := map[expr.Operator]driver.RenderFN{ - expr.Literal: driver.Shared[expr.Literal], - expr.And: driver.Shared[expr.And], - expr.Or: driver.Shared[expr.Or], - expr.Not: driver.Shared[expr.Not], - expr.Equals: driver.Shared[expr.Equals], - expr.Range: driver.Shared[expr.Range], - expr.Must: driver.Shared[expr.Must], - expr.MustNot: driver.Shared[expr.MustNot], - expr.Wild: driver.Shared[expr.Wild], - expr.Regexp: driver.Shared[expr.Regexp], - expr.Like: dynamoDBLike, // Custom LIKE for DynamoDB functions - expr.Greater: driver.Shared[expr.Greater], - expr.GreaterEq: driver.Shared[expr.GreaterEq], - expr.Less: driver.Shared[expr.Less], - expr.LessEq: driver.Shared[expr.LessEq], - expr.In: driver.Shared[expr.In], - expr.List: driver.Shared[expr.List], - } - - return &DynamoDBPartiQLDriver{ - Base: driver.Base{ - RenderFNs: fns, - }, - fields: fieldMap, - } -} - -// RenderPartiQL renders the expression to DynamoDB PartiQL with AttributeValue parameters. -func (d *DynamoDBPartiQLDriver) RenderPartiQL(e *expr.Expression) (string, []types.AttributeValue, error) { - // Use base rendering with ? placeholders - str, params, err := d.RenderParam(e) - if err != nil { - return "", nil, err - } - - // Convert params to DynamoDB AttributeValues - attrValues := make([]types.AttributeValue, len(params)) - for i, param := range params { - attrValues[i] = &types.AttributeValueMemberS{Value: fmt.Sprintf("%v", param)} - } - - return str, attrValues, nil -} - -// dynamoDBLike implements LIKE using DynamoDB's begins_with and contains functions. -func dynamoDBLike(left, right string) (string, error) { - // Remove quotes from right side to analyze pattern - pattern := strings.Trim(right, "'") - - // Replace wildcards for analysis - hasPrefix := strings.HasPrefix(pattern, "%") - hasSuffix := strings.HasSuffix(pattern, "%") - - if hasPrefix && hasSuffix { - // %value% -> contains(field, value) - value := strings.Trim(pattern, "%") - return fmt.Sprintf("contains(%s, '%s')", left, value), nil - } else if !hasPrefix && hasSuffix { - // value% -> begins_with(field, value) - value := strings.TrimSuffix(pattern, "%") - return fmt.Sprintf("begins_with(%s, '%s')", left, value), nil - } else if hasPrefix && !hasSuffix { - // %value -> contains(field, value) (DynamoDB doesn't have ends_with) - value := strings.TrimPrefix(pattern, "%") - return fmt.Sprintf("contains(%s, '%s')", left, value), nil - } - - // Exact match - return fmt.Sprintf("%s = %s", left, right), nil -} - // convertToPostgresPlaceholders converts ? placeholders to PostgreSQL's $N format. func convertToPostgresPlaceholders(query string) string { paramIndex := 1 diff --git a/storage/sql.go b/storage/sql.go index 023c14b..9b9a1ff 100644 --- a/storage/sql.go +++ b/storage/sql.go @@ -270,7 +270,7 @@ func (s *SQLAdapter) Search(dest any, sortKey string, query string, limit int, c destType := reflect.TypeOf(dest).Elem().Elem() model := reflect.New(destType).Elem().Interface() - parser, err := lucene.NewParserFromType(model) + parser, err := lucene.NewParser(model) if err != nil { slog.Error("Parser creation failed", "error", err) return "", err