diff --git a/bin/repl b/bin/repl index 29d130d..758d9bb 100755 Binary files a/bin/repl and b/bin/repl differ diff --git a/dialect_compliance_test.go b/dialect_compliance_test.go index 3809640..d27c5ee 100644 --- a/dialect_compliance_test.go +++ b/dialect_compliance_test.go @@ -233,7 +233,7 @@ func TestDialectSpecificInArrayField(t *testing.T) { expected map[Dialect]string } - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "test.tags", Type: FieldTypeArray}, {Name: "test.scores", Type: FieldTypeArray}, }) @@ -289,6 +289,96 @@ func TestDialectSpecificInArrayField(t *testing.T) { } } +// TestDialectSpecificIdentifierQuoting tests that numeric-leading path segments +// are quoted with the correct dialect-specific character. +func TestDialectSpecificIdentifierQuoting(t *testing.T) { + type testCase struct { + name string + input string + expected map[Dialect]string + } + + tests := []testCase{ + { + name: "numeric-leading segment in comparison", + input: `{">=": [{"var": "data.user_transaction_history.24h.tx.sum"}, 50000]}`, + expected: map[Dialect]string{ + DialectBigQuery: "WHERE data.user_transaction_history.`24h`.tx.sum >= 50000", + DialectSpanner: "WHERE data.user_transaction_history.`24h`.tx.sum >= 50000", + DialectPostgreSQL: `WHERE data.user_transaction_history."24h".tx.sum >= 50000`, + DialectDuckDB: `WHERE data.user_transaction_history."24h".tx.sum >= 50000`, + DialectClickHouse: "WHERE data.user_transaction_history.`24h`.tx.sum >= 50000", + }, + }, + { + name: "normal segments remain unquoted", + input: `{">": [{"var": "user.amount"}, 100]}`, + expected: map[Dialect]string{ + DialectBigQuery: "WHERE user.amount > 100", + DialectSpanner: "WHERE user.amount > 100", + DialectPostgreSQL: "WHERE user.amount > 100", + DialectDuckDB: "WHERE user.amount > 100", + DialectClickHouse: "WHERE user.amount > 100", + }, + }, + { + name: "multiple numeric-leading segments", + input: `{"==": [{"var": "stats.7d.10m.count"}, 0]}`, + expected: map[Dialect]string{ + DialectBigQuery: "WHERE stats.`7d`.`10m`.count = 0", + DialectSpanner: "WHERE stats.`7d`.`10m`.count = 0", + DialectPostgreSQL: `WHERE stats."7d"."10m".count = 0`, + DialectDuckDB: `WHERE stats."7d"."10m".count = 0`, + DialectClickHouse: "WHERE stats.`7d`.`10m`.count = 0", + }, + }, + { + name: "missing operator with numeric-leading segment", + input: `{"missing": "data.history.24h.tx.count"}`, + expected: map[Dialect]string{ + DialectBigQuery: "WHERE data.history.`24h`.tx.count IS NULL", + DialectSpanner: "WHERE data.history.`24h`.tx.count IS NULL", + DialectPostgreSQL: `WHERE data.history."24h".tx.count IS NULL`, + DialectDuckDB: `WHERE data.history."24h".tx.count IS NULL`, + DialectClickHouse: "WHERE data.history.`24h`.tx.count IS NULL", + }, + }, + { + name: "var with default and numeric-leading segment", + input: `{"==": [{"var": ["metrics.30d.total", 0]}, 0]}`, + expected: map[Dialect]string{ + DialectBigQuery: "WHERE COALESCE(metrics.`30d`.total, 0) = 0", + DialectSpanner: "WHERE COALESCE(metrics.`30d`.total, 0) = 0", + DialectPostgreSQL: `WHERE COALESCE(metrics."30d".total, 0) = 0`, + DialectDuckDB: `WHERE COALESCE(metrics."30d".total, 0) = 0`, + DialectClickHouse: "WHERE COALESCE(metrics.`30d`.total, 0) = 0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for d, expected := range tt.expected { + t.Run(d.String(), func(t *testing.T) { + tr, err := NewTranspiler(d) + if err != nil { + t.Fatalf("Failed to create transpiler for %s: %v", d.String(), err) + } + + result, err := tr.Transpile(tt.input) + if err != nil { + t.Errorf("[%s] Transpile() error = %v", d.String(), err) + return + } + if result != expected { + t.Errorf("[%s] Transpile() = %q, want %q", d.String(), result, expected) + } + }) + } + }) + } +} + // TestDialectSpecificStringFunctions tests string position functions across dialects. func TestDialectSpecificStringFunctions(t *testing.T) { type testCase struct { diff --git a/docs/api-reference.md b/docs/api-reference.md index 46dc18f..a201e78 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -293,10 +293,10 @@ Check if error has specific code. ### NewSchema ```go -func NewSchema(fields []FieldSchema) *Schema +func NewSchema(fields []FieldSchema) (*Schema, error) ``` -Create a new schema from field definitions. +Create a new schema from field definitions. Returns an error if any field name contains quote characters (backtick, double quote, or single quote) — field names must be raw, unquoted identifiers. ### NewSchemaFromJSON diff --git a/docs/dialects.md b/docs/dialects.md index 518e672..e28c5b0 100644 --- a/docs/dialects.md +++ b/docs/dialects.md @@ -35,6 +35,20 @@ All JSON Logic operators are supported across all dialects. The library generate | **Array** | `in`, `map`, `filter`, `reduce`, `all`, `some`, `none`, `merge` | ✓ | ✓ | ✓ | ✓ | ✓ | | **String** | `in`, `cat`, `substr` | ✓ | ✓ | ✓ | ✓ | ✓ | +## Identifier Quoting + +Path segments that are not valid unquoted SQL identifiers (e.g. start with a digit) are automatically quoted using the dialect-appropriate character: + +| Dialect | Quote Character | Example | +|---------|----------------|---------| +| BigQuery | Backtick (`` ` ``) | `` data.history.`24h`.tx.sum `` | +| Spanner | Backtick (`` ` ``) | `` data.history.`24h`.tx.sum `` | +| PostgreSQL | Double quote (`"`) | `data.history."24h".tx.sum` | +| DuckDB | Double quote (`"`) | `data.history."24h".tx.sum` | +| ClickHouse | Backtick (`` ` ``) | `` data.history.`24h`.tx.sum `` | + +Segments that only contain letters, digits, and underscores (and don't start with a digit) remain unquoted. + ## Dialect-Specific SQL Generation Some operators generate different SQL based on the target dialect: diff --git a/docs/error-handling.md b/docs/error-handling.md index 166fe05..c0c0321 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -140,7 +140,7 @@ _, err := transpiler.Transpile(`{"unknownOp": [1, 2]}`) ### Field Not in Schema ```go -schema := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ +schema, _ := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ {Name: "known_field", Type: jsonlogic2sql.FieldTypeString}, }) transpiler.SetSchema(schema) @@ -152,7 +152,7 @@ _, err := transpiler.Transpile(`{"==": [{"var": "unknown_field"}, "test"]}`) ### Invalid Enum Value ```go -schema := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ +schema, _ := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ {Name: "status", Type: jsonlogic2sql.FieldTypeEnum, AllowedValues: []string{"active", "pending"}}, }) transpiler.SetSchema(schema) @@ -164,7 +164,7 @@ _, err := transpiler.Transpile(`{"==": [{"var": "status"}, "invalid"]}`) ### Type Mismatch ```go -schema := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ +schema, _ := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ {Name: "name", Type: jsonlogic2sql.FieldTypeString}, }) transpiler.SetSchema(schema) diff --git a/docs/getting-started.md b/docs/getting-started.md index 744d5a6..64422b3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -114,13 +114,14 @@ transpiler, _ := jsonlogic2sql.NewTranspiler(jsonlogic2sql.DialectClickHouse) ## Variable Naming -The transpiler preserves JSON Logic variable names as-is in the SQL output: +The transpiler preserves JSON Logic variable names in the SQL output, with automatic quoting for segments that are not valid unquoted SQL identifiers: - Dot notation is preserved: `transaction.amount` → `transaction.amount` - Nested variables: `user.account.age` → `user.account.age` - Simple variables remain unchanged: `amount` → `amount` - -This allows for proper JSON column access in databases that support it (like PostgreSQL with JSONB columns). +- Segments starting with a digit are quoted automatically: + - BigQuery/Spanner/ClickHouse: `data.history.24h.tx.sum` → `` data.history.`24h`.tx.sum `` + - PostgreSQL/DuckDB: `data.history.24h.tx.sum` → `data.history."24h".tx.sum` ## Next Steps diff --git a/docs/operators.md b/docs/operators.md index 72926bf..25c99be 100644 --- a/docs/operators.md +++ b/docs/operators.md @@ -37,6 +37,8 @@ WHERE data[1] WHERE COALESCE(status, 'pending') ``` +> **Note:** Path segments that start with a digit (e.g. `24h`, `7d`) are automatically quoted using the dialect-appropriate character. See [Identifier Quoting](dialects.md#identifier-quoting) for details. + ### Missing Field Check (Single) ```json diff --git a/docs/schema-validation.md b/docs/schema-validation.md index 7f71156..416b957 100644 --- a/docs/schema-validation.md +++ b/docs/schema-validation.md @@ -14,7 +14,7 @@ import ( func main() { // Create a schema with field definitions - schema := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ + schema, _ := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ {Name: "order.amount", Type: jsonlogic2sql.FieldTypeInteger}, {Name: "order.status", Type: jsonlogic2sql.FieldTypeString}, {Name: "user.verified", Type: jsonlogic2sql.FieldTypeBoolean}, @@ -39,6 +39,8 @@ func main() { } ``` +**Note:** Field names must be raw, unquoted identifiers. `NewSchema` returns an error if any field name contains quote characters (backtick, double quote, or single quote). The transpiler handles identifier quoting automatically based on the target dialect. + ## Loading Schema from JSON ```go @@ -90,7 +92,7 @@ When a schema is provided, operators perform strict type validation: ### Example ```go -schema := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ +schema, _ := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ {Name: "amount", Type: jsonlogic2sql.FieldTypeInteger}, {Name: "tags", Type: jsonlogic2sql.FieldTypeArray}, {Name: "name", Type: jsonlogic2sql.FieldTypeString}, @@ -157,7 +159,7 @@ Enum fields allow you to define a fixed set of allowed values: ```go // Define schema with enum field -schema := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ +schema, _ := jsonlogic2sql.NewSchema([]jsonlogic2sql.FieldSchema{ {Name: "status", Type: jsonlogic2sql.FieldTypeEnum, AllowedValues: []string{"active", "pending", "cancelled"}}, {Name: "priority", Type: jsonlogic2sql.FieldTypeEnum, AllowedValues: []string{"low", "medium", "high"}}, }) @@ -191,7 +193,7 @@ _, err = transpiler.Transpile(`{"==": [{"var": "status"}, "invalid"]}`) ```go // Schema creation -schema := jsonlogic2sql.NewSchema(fields []FieldSchema) +schema, err := jsonlogic2sql.NewSchema(fields []FieldSchema) schema, err := jsonlogic2sql.NewSchemaFromJSON(data []byte) schema, err := jsonlogic2sql.NewSchemaFromFile(filepath string) diff --git a/internal/dialect/dialect.go b/internal/dialect/dialect.go index 3c7ae53..a43bd73 100644 --- a/internal/dialect/dialect.go +++ b/internal/dialect/dialect.go @@ -1,7 +1,11 @@ // Package dialect provides SQL dialect definitions for the transpiler. package dialect -import "fmt" +import ( + "fmt" + "strings" + "unicode" +) // Dialect represents a SQL dialect that the transpiler can target. type Dialect int @@ -62,3 +66,45 @@ func (d Dialect) Validate() error { } return nil } + +// NeedsQuoting returns true if an identifier segment requires quoting. +// A segment needs quoting if it starts with a digit or contains characters +// other than letters, digits, and underscores. +func NeedsQuoting(segment string) bool { + if segment == "" { + return false + } + first := rune(segment[0]) + if unicode.IsDigit(first) { + return true + } + for _, r := range segment { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' { + return true + } + } + return false +} + +// ContainsQuoteCharacters returns true if the segment contains backticks, double +// quotes, or single quotes. These characters are used for identifier quoting and +// must not appear in raw variable names — the transpiler handles quoting automatically. +func ContainsQuoteCharacters(segment string) bool { + return strings.ContainsAny(segment, "`\"'") +} + +// QuoteIdentifierSegment wraps a single identifier segment with dialect-appropriate +// quote characters. It also escapes any embedded quote characters within the segment. +// - BigQuery / Spanner / ClickHouse: backtick (`) +// - PostgreSQL / DuckDB: double quote (") +func QuoteIdentifierSegment(segment string, d Dialect) string { + //nolint:exhaustive // default uses backtick (safe for GoogleSQL family) + switch d { + case DialectPostgreSQL, DialectDuckDB: + escaped := strings.ReplaceAll(segment, `"`, `""`) + return `"` + escaped + `"` + default: + escaped := strings.ReplaceAll(segment, "`", "``") + return "`" + escaped + "`" + } +} diff --git a/internal/dialect/dialect_test.go b/internal/dialect/dialect_test.go index bf49dcd..9a2839e 100644 --- a/internal/dialect/dialect_test.go +++ b/internal/dialect/dialect_test.go @@ -78,3 +78,82 @@ func TestDialect_Validate(t *testing.T) { }) } } + +func TestNeedsQuoting(t *testing.T) { + tests := []struct { + segment string + expected bool + }{ + {"name", false}, + {"user_name", false}, + {"_private", false}, + {"tx", false}, + {"24h", true}, + {"7d", true}, + {"10m", true}, + {"120d", true}, + {"col-name", true}, + {"has space", true}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.segment, func(t *testing.T) { + if got := NeedsQuoting(tt.segment); got != tt.expected { + t.Errorf("NeedsQuoting(%q) = %v, want %v", tt.segment, got, tt.expected) + } + }) + } +} + +func TestQuoteIdentifierSegment(t *testing.T) { + tests := []struct { + name string + segment string + dialect Dialect + expected string + }{ + {"BigQuery backtick", "24h", DialectBigQuery, "`24h`"}, + {"Spanner backtick", "24h", DialectSpanner, "`24h`"}, + {"ClickHouse backtick", "7d", DialectClickHouse, "`7d`"}, + {"PostgreSQL double quote", "24h", DialectPostgreSQL, `"24h"`}, + {"DuckDB double quote", "10m", DialectDuckDB, `"10m"`}, + {"BigQuery escapes embedded backtick", "ab`cd", DialectBigQuery, "`ab``cd`"}, + {"PostgreSQL escapes embedded double quote", `ab"cd`, DialectPostgreSQL, `"ab""cd"`}, + {"Unspecified uses backtick", "24h", DialectUnspecified, "`24h`"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := QuoteIdentifierSegment(tt.segment, tt.dialect); got != tt.expected { + t.Errorf("QuoteIdentifierSegment(%q, %v) = %q, want %q", tt.segment, tt.dialect, got, tt.expected) + } + }) + } +} + +func TestContainsQuoteCharacters(t *testing.T) { + tests := []struct { + segment string + expected bool + }{ + {"normal", false}, + {"24h", false}, + {"_field", false}, + {"", false}, + {"`quoted`", true}, + {`"quoted"`, true}, + {"has`tick", true}, + {`has"quote`, true}, + {"'quoted'", true}, + {"has'single", true}, + } + + for _, tt := range tests { + t.Run(tt.segment, func(t *testing.T) { + if got := ContainsQuoteCharacters(tt.segment); got != tt.expected { + t.Errorf("ContainsQuoteCharacters(%q) = %v, want %v", tt.segment, got, tt.expected) + } + }) + } +} diff --git a/internal/operators/data.go b/internal/operators/data.go index f0fb27d..fc50ed7 100644 --- a/internal/operators/data.go +++ b/internal/operators/data.go @@ -3,6 +3,8 @@ package operators import ( "fmt" "strings" + + "github.com/h22rana/jsonlogic2sql/internal/dialect" ) // DataOperator handles data access operators (var, missing, missing_some). @@ -58,7 +60,10 @@ func (d *DataOperator) handleVar(args []interface{}) (string, error) { return "", err } } - columnName := d.convertVarName(varName) + columnName, err := d.convertVarName(varName) + if err != nil { + return "", err + } return columnName, nil } @@ -79,7 +84,10 @@ func (d *DataOperator) handleVar(args []interface{}) (string, error) { return "", err } } - columnName := d.convertVarName(varName) + columnName, err := d.convertVarName(varName) + if err != nil { + return "", err + } // If there's a default value, use COALESCE if len(arr) > 1 { @@ -116,7 +124,10 @@ func (d *DataOperator) handleMissing(args []interface{}) (string, error) { return "", err } } - columnName := d.convertVarName(varName) + columnName, err := d.convertVarName(varName) + if err != nil { + return "", err + } return fmt.Sprintf("%s IS NULL", columnName), nil } @@ -138,7 +149,10 @@ func (d *DataOperator) handleMissing(args []interface{}) (string, error) { return "", err } } - columnName := d.convertVarName(name) + columnName, err := d.convertVarName(name) + if err != nil { + return "", err + } nullConditions = append(nullConditions, fmt.Sprintf("%s IS NULL", columnName)) } @@ -185,7 +199,10 @@ func (d *DataOperator) handleMissingSome(args []interface{}) (string, error) { return "", err } } - columnName := d.convertVarName(name) + columnName, err := d.convertVarName(name) + if err != nil { + return "", err + } nullConditions = append(nullConditions, fmt.Sprintf("%s IS NULL", columnName)) } return fmt.Sprintf("(%s)", strings.Join(nullConditions, " OR ")), nil @@ -205,7 +222,10 @@ func (d *DataOperator) handleMissingSome(args []interface{}) (string, error) { return "", err } } - columnName := d.convertVarName(name) + columnName, err := d.convertVarName(name) + if err != nil { + return "", err + } caseStatements = append(caseStatements, fmt.Sprintf("CASE WHEN %s IS NULL THEN 1 ELSE 0 END", columnName)) } @@ -214,12 +234,28 @@ func (d *DataOperator) handleMissingSome(args []interface{}) (string, error) { return fmt.Sprintf("(%s) >= %d", nullCount, int(minCount)), nil } -// convertVarName converts a JSON Logic variable name to SQL column name -// Preserves dot notation for nested properties: "user.verified" -> "user.verified". -func (d *DataOperator) convertVarName(varName string) string { - // Keep the original dot notation as-is for nested properties - // This allows for proper JSON column access in databases that support it - return varName +// convertVarName converts a JSON Logic variable name to a SQL column reference. +// It splits the name on dots, quotes any segment that is not a valid unquoted SQL +// identifier (e.g. starts with a digit like "24h"), and rejoins with dots. +// Returns an error if any segment contains quote characters (backtick, double +// quote, or single quote), since the transpiler handles quoting automatically. +func (d *DataOperator) convertVarName(varName string) (string, error) { + dl := dialect.DialectUnspecified + if d.config != nil { + dl = d.config.GetDialect() + } + + segments := strings.Split(varName, ".") + for i, seg := range segments { + if dialect.ContainsQuoteCharacters(seg) { + return "", fmt.Errorf("variable name %q contains quote characters; "+ + "use raw identifiers — the transpiler handles quoting automatically", varName) + } + if dialect.NeedsQuoting(seg) { + segments[i] = dialect.QuoteIdentifierSegment(seg, dl) + } + } + return strings.Join(segments, "."), nil } // getNumber extracts a number from an interface{} and returns it as float64. diff --git a/internal/operators/data_test.go b/internal/operators/data_test.go index f5e606f..33d3741 100644 --- a/internal/operators/data_test.go +++ b/internal/operators/data_test.go @@ -242,7 +242,10 @@ func TestDataOperator_convertVarName(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - result := op.convertVarName(tt.input) + result, err := op.convertVarName(tt.input) + if err != nil { + t.Fatalf("convertVarName(%s) unexpected error: %v", tt.input, err) + } if result != tt.expected { t.Errorf("convertVarName(%s) = %s, expected %s", tt.input, result, tt.expected) } @@ -250,6 +253,31 @@ func TestDataOperator_convertVarName(t *testing.T) { } } +func TestDataOperator_convertVarName_rejectsPreQuoted(t *testing.T) { + op := NewDataOperator(nil) + + tests := []struct { + name string + input string + }{ + {"backtick quoted segment", "data.`24h`.tx"}, + {"double-quote quoted segment", `data."24h".tx`}, + {"embedded backtick", "col`name"}, + {"embedded double quote", `col"name`}, + {"single-quote quoted segment", "data.'24h'.tx"}, + {"embedded single quote", "col'name"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := op.convertVarName(tt.input) + if err == nil { + t.Errorf("convertVarName(%q) expected error for pre-quoted input, got nil", tt.input) + } + }) + } +} + func TestDataOperator_valueToSQL(t *testing.T) { op := NewDataOperator(nil) diff --git a/schema.go b/schema.go index cd5ed8a..4fb97df 100644 --- a/schema.go +++ b/schema.go @@ -4,6 +4,9 @@ import ( "encoding/json" "fmt" "os" + "strings" + + "github.com/h22rana/jsonlogic2sql/internal/dialect" ) // FieldType represents the type of a field in the schema. @@ -33,14 +36,24 @@ type Schema struct { } // NewSchema creates a new schema from a slice of field schemas. -func NewSchema(fields []FieldSchema) *Schema { +// Returns an error if any field name contains quote characters (backtick, +// double quote, or single quote). Schema field names must be raw, unquoted +// identifiers — the transpiler handles quoting automatically. +func NewSchema(fields []FieldSchema) (*Schema, error) { s := &Schema{ fields: make(map[string]FieldSchema), } for _, field := range fields { + for _, seg := range strings.Split(field.Name, ".") { + if dialect.ContainsQuoteCharacters(seg) { + return nil, fmt.Errorf( + "schema field %q contains quote characters; "+ + "use raw identifiers — the transpiler handles quoting automatically", field.Name) + } + } s.fields[field.Name] = field } - return s + return s, nil } // NewSchemaFromJSON creates a new schema from a JSON byte slice. @@ -49,7 +62,7 @@ func NewSchemaFromJSON(data []byte) (*Schema, error) { if err := json.Unmarshal(data, &fields); err != nil { return nil, fmt.Errorf("invalid schema JSON: %w", err) } - return NewSchema(fields), nil + return NewSchema(fields) } // NewSchemaFromFile loads a schema from a JSON file. diff --git a/schema_comprehensive_test.go b/schema_comprehensive_test.go index ddef13c..0789342 100644 --- a/schema_comprehensive_test.go +++ b/schema_comprehensive_test.go @@ -178,7 +178,7 @@ func TestSchemaValidationComprehensive(t *testing.T) { // TestSchemaTypeAwareBehavior tests the type-aware "in" operator behavior. func TestSchemaTypeAwareBehavior(t *testing.T) { - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "tags", Type: FieldTypeArray}, {Name: "description", Type: FieldTypeString}, {Name: "status", Type: FieldTypeString}, @@ -501,7 +501,7 @@ func TestSchemaFromFileExample(t *testing.T) { // TestInOperatorWithSchemaIntegration tests the IN operator behavior with schema-based type detection. func TestInOperatorWithSchemaIntegration(t *testing.T) { - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "user.roles", Type: FieldTypeArray}, {Name: "user.bio", Type: FieldTypeString}, {Name: "status", Type: FieldTypeString}, @@ -547,7 +547,7 @@ func TestInOperatorWithSchemaIntegration(t *testing.T) { // TestEdgeCasesWithSchema tests various edge cases. func TestEdgeCasesWithSchema(t *testing.T) { - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "amount", Type: FieldTypeInteger}, {Name: "name", Type: FieldTypeString}, {Name: "active", Type: FieldTypeBoolean}, @@ -598,7 +598,7 @@ func TestEdgeCasesWithSchema(t *testing.T) { // TestTypeAwareOperators tests type validation across all operators. func TestTypeAwareOperators(t *testing.T) { // Create a schema with various field types - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "amount", Type: FieldTypeInteger}, {Name: "price", Type: FieldTypeNumber}, {Name: "name", Type: FieldTypeString}, @@ -873,7 +873,7 @@ func TestTypeValidationWithoutSchema(t *testing.T) { // TestTypeValidationWithFieldNotInSchema verifies that fields not in schema pass validation. func TestTypeValidationWithFieldNotInSchema(t *testing.T) { - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "known_field", Type: FieldTypeInteger}, }) @@ -892,7 +892,7 @@ func TestTypeValidationWithFieldNotInSchema(t *testing.T) { // TestEnumTypeSupport tests enum type validation and SQL generation. func TestEnumTypeSupport(t *testing.T) { - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "status", Type: FieldTypeEnum, AllowedValues: []string{"active", "pending", "canceled"}}, {Name: "priority", Type: FieldTypeEnum, AllowedValues: []string{"low", "medium", "high"}}, {Name: "name", Type: FieldTypeString}, @@ -1040,7 +1040,7 @@ func TestEnumSchemaFromJSON(t *testing.T) { // TestEnumWithComplexExpressions tests enum validation in complex nested expressions. func TestEnumWithComplexExpressions(t *testing.T) { - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "status", Type: FieldTypeEnum, AllowedValues: []string{"active", "pending", "canceled"}}, {Name: "amount", Type: FieldTypeInteger}, }) @@ -1099,7 +1099,7 @@ func TestEnumWithComplexExpressions(t *testing.T) { // based on the field being compared. This ensures proper SQL output like "field >= 50000" // instead of "field >= '50000'" when comparing an integer field with a string value. func TestTypeCoercionForComparisons(t *testing.T) { - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "amount", Type: FieldTypeInteger}, {Name: "price", Type: FieldTypeNumber}, {Name: "status", Type: FieldTypeString}, diff --git a/schema_test.go b/schema_test.go index b63c698..95bf76e 100644 --- a/schema_test.go +++ b/schema_test.go @@ -8,7 +8,7 @@ import ( func TestSchemaValidation(t *testing.T) { // Create a schema with some fields - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "order.items.count", Type: FieldTypeInteger}, {Name: "order.total.amount", Type: FieldTypeInteger}, {Name: "user.name", Type: FieldTypeString}, @@ -77,7 +77,7 @@ func TestSchemaFromJSON(t *testing.T) { func TestSchemaWithTranspiler(t *testing.T) { // Create schema - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "amount", Type: FieldTypeInteger}, {Name: "status", Type: FieldTypeString}, }) @@ -108,7 +108,7 @@ func TestSchemaWithTranspiler(t *testing.T) { func TestSchemaInOperator(t *testing.T) { // Create schema with array and string fields - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "tags", Type: FieldTypeArray}, {Name: "description", Type: FieldTypeString}, }) @@ -222,7 +222,7 @@ func TestSchemaFromFile_NotFound(t *testing.T) { } func TestSchemaGetFields(t *testing.T) { - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "field1", Type: FieldTypeString}, {Name: "field2", Type: FieldTypeInteger}, {Name: "field3", Type: FieldTypeBoolean}, @@ -252,7 +252,7 @@ func TestSchemaGetFields(t *testing.T) { } func TestSchemaIsBooleanType(t *testing.T) { - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "is_active", Type: FieldTypeBoolean}, {Name: "name", Type: FieldTypeString}, {Name: "count", Type: FieldTypeInteger}, @@ -278,7 +278,7 @@ func TestSchemaIsBooleanType(t *testing.T) { } func TestSchemaGetAllowedValues(t *testing.T) { - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "status", Type: FieldTypeEnum, AllowedValues: []string{"active", "pending", "closed"}}, {Name: "name", Type: FieldTypeString}, }) @@ -301,3 +301,50 @@ func TestSchemaGetAllowedValues(t *testing.T) { t.Errorf("GetAllowedValues(nonexistent) should return nil for non-existent field") } } + +func TestNewSchema_RejectsQuotedFieldNames(t *testing.T) { + tests := []struct { + name string + fields []FieldSchema + }{ + { + "backtick in field name", + []FieldSchema{{Name: "data.`24h`.tx", Type: FieldTypeInteger}}, + }, + { + "double quote in field name", + []FieldSchema{{Name: `data."24h".tx`, Type: FieldTypeInteger}}, + }, + { + "backtick-wrapped segment", + []FieldSchema{{Name: "history.`7d`.count", Type: FieldTypeNumber}}, + }, + { + "single quote in field name", + []FieldSchema{{Name: "data.'24h'.tx.sum", Type: FieldTypeInteger}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewSchema(tt.fields) + if err == nil { + t.Errorf("NewSchema() expected error for quoted field name %q, got nil", tt.fields[0].Name) + } + }) + } +} + +func TestNewSchema_AcceptsRawFieldNames(t *testing.T) { + schema, err := NewSchema([]FieldSchema{ + {Name: "data.history.24h.tx.sum", Type: FieldTypeInteger}, + {Name: "user.name", Type: FieldTypeString}, + {Name: "tags", Type: FieldTypeArray}, + }) + if err != nil { + t.Fatalf("NewSchema() unexpected error for raw field names: %v", err) + } + if !schema.HasField("data.history.24h.tx.sum") { + t.Error("schema should have field data.history.24h.tx.sum") + } +} diff --git a/transpiler_test.go b/transpiler_test.go index f6548bc..1838af0 100644 --- a/transpiler_test.go +++ b/transpiler_test.go @@ -1536,7 +1536,7 @@ func TestNewTranspilerWithConfig(t *testing.T) { } func TestNewTranspilerWithConfig_WithSchema(t *testing.T) { - schema := NewSchema([]FieldSchema{ + schema, _ := NewSchema([]FieldSchema{ {Name: "amount", Type: FieldTypeInteger}, {Name: "status", Type: FieldTypeString}, })