diff --git a/pkg/database/postgres/repo.go b/pkg/database/postgres/repo.go index 8629200..0d7fda5 100644 --- a/pkg/database/postgres/repo.go +++ b/pkg/database/postgres/repo.go @@ -145,6 +145,74 @@ func ValidateColumnName(name string) error { return nil } +// ValidateJSONBPath validates JSONB path expressions +// Supports expressions like: column->'key'->>'value', column->0->>'nested', etc. +func ValidateJSONBPath(path string) error { + path = strings.TrimSpace(path) + + if len(path) == 0 { + return fmt.Errorf("JSONB path cannot be empty") + } + + if len(path) > 512 { // Allow longer paths for JSONB + return fmt.Errorf("JSONB path exceeds maximum length (512 chars): %d", len(path)) + } + + // Check for SQL injection patterns (allow single quotes for JSONB keys, but block double quotes and dangerous SQL) + dangerousPatterns := []string{";", "--", "/*", "*/", "\""} + for _, pattern := range dangerousPatterns { + if strings.Contains(path, pattern) { + return fmt.Errorf("JSONB path contains potentially dangerous characters: %s", path) + } + } + + // Validate structure: must start with valid column name, followed by JSONB operators + // Find the base column name (everything before the first ->) + operatorIndex := strings.Index(path, "->") + if operatorIndex == -1 { + return fmt.Errorf("JSONB path must contain -> or ->> operator: %s", path) + } + + baseName := path[:operatorIndex] + if !validColumnRegex.MatchString(baseName) { + return fmt.Errorf("invalid base column in JSONB path: %s", baseName) + } + + // Rest of the path after base column should contain balanced single quotes and valid JSONB operators + // Check for balanced quotes + singleQuoteCount := strings.Count(path, "'") + if singleQuoteCount%2 != 0 { + return fmt.Errorf("unbalanced quotes in JSONB path: %s", path) + } + + return nil +} + +// IsJSONBPath checks if a column reference uses JSONB operators +func IsJSONBPath(name string) bool { + return strings.Contains(name, "->") || strings.Contains(name, "->>") +} + +// ValidateAndFormatColumn validates a column name or JSONB path and formats it for SQL +// For simple columns, it quotes the identifier; for JSONB paths, it returns the path as-is (already safe) +func ValidateAndFormatColumn(name string) (string, error) { + name = strings.TrimSpace(name) + + // Check if it's a JSONB path expression + if IsJSONBPath(name) { + if err := ValidateJSONBPath(name); err != nil { + return "", err + } + return name, nil // Return JSONB path unquoted + } + + // Otherwise validate as regular column name + if err := ValidateColumnName(name); err != nil { + return "", err + } + return pq.QuoteIdentifier(name), nil // Quote regular column names +} + // SplitQualifiedName splits a qualified table name on dots while respecting quoted identifiers func SplitQualifiedName(qualifiedName string) ([]string, error) { parts := make([]string, 0, 2) @@ -234,6 +302,12 @@ var allowedOperators = map[string]bool{ "lt": true, "<": true, "lte": true, "<=": true, "like": true, "ilike": true, "in": true, "not_in": true, "is_null": true, "is_not_null": true, "any": true, + // JSONB operators + "jsonb_contains": true, // @> + "jsonb_contained": true, // <@ + "jsonb_has_key": true, // ? + "jsonb_has_any_key": true, // ?| + "jsonb_has_all_keys": true, // ?& } // ValidateOperator ensures operator is in the whitelist and safe to use @@ -721,7 +795,11 @@ func (postgresDbService *PostgresDbService) ToInterfaceSlice(v interface{}) ([]i // BuildSimpleCondition builds conditions for simple operators (=, !=, <, >, etc.) func (postgresDbService *PostgresDbService) BuildSimpleCondition(filter models.QueryFilter, operator string, argCounter int) (string, []interface{}, int) { - condition := fmt.Sprintf("%s %s $%d", pq.QuoteIdentifier(filter.Column), operator, argCounter) + formattedColumn, err := ValidateAndFormatColumn(filter.Column) + if err != nil { + return "", nil, argCounter + } + condition := fmt.Sprintf("%s %s $%d", formattedColumn, operator, argCounter) args := []interface{}{filter.Value} return condition, args, argCounter + 1 } @@ -733,6 +811,11 @@ func (postgresDbService *PostgresDbService) BuildInCondition(filter models.Query return "", nil, argCounter } + formattedColumn, err := ValidateAndFormatColumn(filter.Column) + if err != nil { + return "", nil, argCounter + } + placeholders := make([]string, len(values)) args := make([]interface{}, 0, len(values)) for i, val := range values { @@ -745,36 +828,151 @@ func (postgresDbService *PostgresDbService) BuildInCondition(filter models.Query if useNot { operator = "NOT IN" } - condition := fmt.Sprintf("%s %s (%s)", pq.QuoteIdentifier(filter.Column), operator, strings.Join(placeholders, ", ")) + condition := fmt.Sprintf("%s %s (%s)", formattedColumn, operator, strings.Join(placeholders, ", ")) return condition, args, argCounter } // BuildNullCondition builds conditions for IS NULL/IS NOT NULL operators func (postgresDbService *PostgresDbService) BuildNullCondition(filter models.QueryFilter, useNot bool, argCounter int) (string, []interface{}, int) { + formattedColumn, err := ValidateAndFormatColumn(filter.Column) + if err != nil { + return "", nil, argCounter + } operator := "IS NULL" if useNot { operator = "IS NOT NULL" } - condition := fmt.Sprintf("%s %s", pq.QuoteIdentifier(filter.Column), operator) + condition := fmt.Sprintf("%s %s", formattedColumn, operator) return condition, nil, argCounter } // BuildAnyCondition builds conditions for ANY operator func (postgresDbService *PostgresDbService) BuildAnyCondition(filter models.QueryFilter, argCounter int) (string, []interface{}, int) { - condition := fmt.Sprintf("$%d = ANY(%s)", argCounter, pq.QuoteIdentifier(filter.Column)) + formattedColumn, err := ValidateAndFormatColumn(filter.Column) + if err != nil { + return "", nil, argCounter + } + condition := fmt.Sprintf("$%d = ANY(%s)", argCounter, formattedColumn) args := []interface{}{filter.Value} return condition, args, argCounter + 1 } +// BuildJSONBCondition builds conditions for JSONB path queries +// Example: column=["raw_statement"], json_path=["result", "success"], operator="eq", value="true" +// Produces: raw_statement->'result'->>'success' = $1 +func (postgresDbService *PostgresDbService) BuildJSONBCondition(filter models.QueryFilter, argCounter int) (string, []interface{}, int) { + // Validate column name + if err := ValidateColumnName(filter.Column); err != nil { + return "", nil, argCounter + } + + if len(filter.JSONPath) == 0 { + return "", nil, argCounter + } + + // Build JSONB path expression: column->'key1'->'key2'->>'final_key' + quotedCol := pq.QuoteIdentifier(filter.Column) + pathExpr := quotedCol + + // Navigate through the path, using ->> for the last key to extract text + for i, key := range filter.JSONPath { + quotedKey := fmt.Sprintf("'%s'", strings.ReplaceAll(key, "'", "''")) // SQL escape single quotes + if i == len(filter.JSONPath)-1 { + // Last key - use ->> to extract as text for comparison + pathExpr += fmt.Sprintf(" ->> %s", quotedKey) + } else { + // Intermediate keys - use -> to navigate as JSONB + pathExpr += fmt.Sprintf(" -> %s", quotedKey) + } + } + + // Now build the condition using the path expression + operator := strings.ToLower(filter.Operator) + var condition string + var args []interface{} + + switch operator { + case "eq", "=": + condition = fmt.Sprintf("%s = $%d", pathExpr, argCounter) + args = []interface{}{filter.Value} + argCounter++ + case "neq", "!=", "<>": + condition = fmt.Sprintf("%s != $%d", pathExpr, argCounter) + args = []interface{}{filter.Value} + argCounter++ + case "gt", ">": + condition = fmt.Sprintf("%s > $%d", pathExpr, argCounter) + args = []interface{}{filter.Value} + argCounter++ + case "gte", ">=": + condition = fmt.Sprintf("%s >= $%d", pathExpr, argCounter) + args = []interface{}{filter.Value} + argCounter++ + case "lt", "<": + condition = fmt.Sprintf("%s < $%d", pathExpr, argCounter) + args = []interface{}{filter.Value} + argCounter++ + case "lte", "<=": + condition = fmt.Sprintf("%s <= $%d", pathExpr, argCounter) + args = []interface{}{filter.Value} + argCounter++ + case "like": + condition = fmt.Sprintf("%s LIKE $%d", pathExpr, argCounter) + args = []interface{}{filter.Value} + argCounter++ + case "ilike": + condition = fmt.Sprintf("%s ILIKE $%d", pathExpr, argCounter) + args = []interface{}{filter.Value} + argCounter++ + case "in": + values, ok := postgresDbService.ToInterfaceSlice(filter.Value) + if !ok || len(values) == 0 { + return "", nil, argCounter + } + placeholders := make([]string, len(values)) + for i, val := range values { + placeholders[i] = fmt.Sprintf("$%d", argCounter) + args = append(args, val) + argCounter++ + } + condition = fmt.Sprintf("%s IN (%s)", pathExpr, strings.Join(placeholders, ", ")) + case "not_in": + values, ok := postgresDbService.ToInterfaceSlice(filter.Value) + if !ok || len(values) == 0 { + return "", nil, argCounter + } + placeholders := make([]string, len(values)) + for i, val := range values { + placeholders[i] = fmt.Sprintf("$%d", argCounter) + args = append(args, val) + argCounter++ + } + condition = fmt.Sprintf("%s NOT IN (%s)", pathExpr, strings.Join(placeholders, ", ")) + case "is_null": + condition = fmt.Sprintf("%s IS NULL", pathExpr) + case "is_not_null": + condition = fmt.Sprintf("%s IS NOT NULL", pathExpr) + default: + // Unknown operator + return "", nil, argCounter + } + + return condition, args, argCounter +} + func (postgresDbService *PostgresDbService) BuildFilterCondition(filter models.QueryFilter, argCounter int) (string, []interface{}, int) { // VALIDATE OPERATOR FIRST - before any SQL string building if err := ValidateOperator(filter.Operator); err != nil { - // Return empty condition on invalid operator - caller should handle this - // or we could return error as fourth return value (future improvement) + // Return empty condition on invalid operator return "", nil, argCounter } - // VALIDATE COLUMN NAME - ensure column is safe from SQL injection + // Check if this is a JSONB path query + if len(filter.JSONPath) > 0 { + return postgresDbService.BuildJSONBCondition(filter, argCounter) + } + + // Regular column validation and processing if err := ValidateColumnName(filter.Column); err != nil { // Return empty condition on invalid column return "", nil, argCounter @@ -2740,10 +2938,12 @@ func (r *PostgresDbService) RemoveManyToManyRelations(relationship *models.Relat // - int: Updated argCounter after consuming parameters // // Example output for one-to-many: -// SELECT orders.* FROM orders WHERE orders.user_id = $1 +// +// SELECT orders.* FROM orders WHERE orders.user_id = $1 // // Example output for many-to-many: -// SELECT t.* FROM products t INNER JOIN order_items j ON t.id = j.product_id WHERE j.order_id = $1 +// +// SELECT t.* FROM products t INNER JOIN order_items j ON t.id = j.product_id WHERE j.order_id = $1 func (r *PostgresDbService) buildRelationshipBaseQuery(relationship *models.RelationshipDefinition, params models.QueryParams, argCounter int) (string, int) { var query strings.Builder diff --git a/pkg/database/postgres/repo_filter_test.go b/pkg/database/postgres/repo_filter_test.go index 494d23e..d83df30 100644 --- a/pkg/database/postgres/repo_filter_test.go +++ b/pkg/database/postgres/repo_filter_test.go @@ -6,6 +6,7 @@ package postgres_test import ( + "strings" "testing" "github.com/aptlogica/go-postgres-rest/pkg/database/postgres" @@ -170,3 +171,179 @@ func TestBuildSelectColumnParts(t *testing.T) { t.Fatalf("expected [\"*\"], got %v", parts) } } + +// JSONB Path Support Tests +func TestValidateJSONBPath(t *testing.T) { + testCases := []struct { + name string + path string + shouldErr bool + }{ + // Valid JSONB paths + {name: "simple JSONB arrow", path: "data->'key'", shouldErr: false}, + {name: "JSONB double arrow", path: "data->>'value'", shouldErr: false}, + {name: "nested JSONB path", path: "raw_statement->'result'->>'success'", shouldErr: false}, + {name: "JSONB with array index", path: "data->[0]", shouldErr: false}, + {name: "complex JSONB path", path: "raw_statement->'verb'->>'id'", shouldErr: false}, + + // Invalid JSONB paths + {name: "empty path", path: "", shouldErr: true}, + {name: "path with semicolon", path: "data->'key';DROP TABLE x", shouldErr: true}, + {name: "path with SQL comment", path: "data->'key'--comment", shouldErr: true}, + {name: "path with double quotes", path: "data->\"key\"", shouldErr: true}, + {name: "path with unbalanced quotes", path: "data->'key", shouldErr: true}, + {name: "path too long", path: "a" + string(make([]byte, 513)), shouldErr: true}, + {name: "no JSONB operator", path: "data", shouldErr: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := postgres.ValidateJSONBPath(tc.path) + if (err != nil) != tc.shouldErr { + t.Fatalf("ValidateJSONBPath(%q): got error=%v, want error=%v. Error: %v", tc.path, err != nil, tc.shouldErr, err) + } + }) + } +} + +func TestIsJSONBPath(t *testing.T) { + testCases := []struct { + name string + column string + isJSONB bool + }{ + {name: "regular column", column: "age", isJSONB: false}, + {name: "quoted column", column: "\"complex-name\"", isJSONB: false}, + {name: "JSONB with arrow", column: "data->'key'", isJSONB: true}, + {name: "JSONB with double arrow", column: "data->>'value'", isJSONB: true}, + {name: "nested JSONB", column: "data->'a'->>'b'", isJSONB: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := postgres.IsJSONBPath(tc.column) + if result != tc.isJSONB { + t.Fatalf("IsJSONBPath(%q): got %v, want %v", tc.column, result, tc.isJSONB) + } + }) + } +} + +func TestValidateAndFormatColumn(t *testing.T) { + testCases := []struct { + name string + column string + shouldErr bool + expectedOut string + }{ + // Regular columns (should be quoted) + {name: "simple column", column: "age", shouldErr: false, expectedOut: "\"age\""}, + {name: "column with underscore", column: "user_id", shouldErr: false, expectedOut: "\"user_id\""}, + + // JSONB paths (should NOT be quoted) + {name: "JSONB arrow", column: "data->'key'", shouldErr: false, expectedOut: "data->'key'"}, + {name: "JSONB double arrow", column: "data->>'value'", shouldErr: false, expectedOut: "data->>'value'"}, + {name: "nested JSONB", column: "raw_statement->'result'->>'success'", shouldErr: false, expectedOut: "raw_statement->'result'->>'success'"}, + {name: "JSONB verb path", column: "raw_statement->'verb'->>'id'", shouldErr: false, expectedOut: "raw_statement->'verb'->>'id'"}, + + // Invalid columns + {name: "column with dash (not JSONB)", column: "bad-col", shouldErr: true, expectedOut: ""}, + {name: "invalid JSONB", column: "data->'key';DROP", shouldErr: true, expectedOut: ""}, + {name: "empty column", column: "", shouldErr: true, expectedOut: ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := postgres.ValidateAndFormatColumn(tc.column) + if (err != nil) != tc.shouldErr { + t.Fatalf("ValidateAndFormatColumn(%q): got error=%v, want error=%v", tc.column, err != nil, tc.shouldErr) + } + if !tc.shouldErr && result != tc.expectedOut { + t.Fatalf("ValidateAndFormatColumn(%q): got %q, want %q", tc.column, result, tc.expectedOut) + } + }) + } +} + +func TestBuildSimpleConditionWithJSONB(t *testing.T) { + svc := &postgres.PostgresDbService{} + + // Regular column + cond, args, next := svc.BuildSimpleCondition(models.QueryFilter{Column: "age", Operator: "eq", Value: 25}, "=", 1) + expectedCond := "\"age\" = $1" + if cond != expectedCond || len(args) != 1 || args[0] != 25 || next != 2 { + t.Fatalf("regular column: expected %s, got %s", expectedCond, cond) + } + + // JSONB path + cond, args, next = svc.BuildSimpleCondition(models.QueryFilter{Column: "raw_statement->'result'->>'success'", Operator: "eq", Value: "true"}, "=", 1) + expectedCond = "raw_statement->'result'->>'success' = $1" + if cond != expectedCond || len(args) != 1 || args[0] != "true" || next != 2 { + t.Fatalf("JSONB path: expected %s, got %s", expectedCond, cond) + } + + // JSONB path with greater than operator + cond, args, next = svc.BuildSimpleCondition(models.QueryFilter{Column: "data->'count'->>'value'", Operator: "gt", Value: 100}, ">", 5) + expectedCond = "data->'count'->>'value' > $5" + if cond != expectedCond || len(args) != 1 || args[0] != 100 || next != 6 { + t.Fatalf("JSONB greater than: expected %s, got %s", expectedCond, cond) + } +} + +func TestBuildComplexFilterWithJSONB(t *testing.T) { + svc := &postgres.PostgresDbService{} + + // Test complex filter with JSONB paths using json_path arrays + filter := models.ComplexFilter{ + Logic: "AND", + Filters: []models.QueryFilter{ + {Column: "timestamp", Operator: "gte", Value: "2026-04-01T00:00:00Z"}, + {Column: "raw_statement", JSONPath: []string{"result", "success"}, Operator: "eq", Value: "true"}, + }, + Groups: []models.ComplexFilter{ + { + Logic: "OR", + Filters: []models.QueryFilter{ + {Column: "raw_statement", JSONPath: []string{"verb", "id"}, Operator: "eq", Value: "http://adlnet.gov/expapi/verbs/completed"}, + {Column: "raw_statement", JSONPath: []string{"verb", "id"}, Operator: "eq", Value: "http://adlnet.gov/expapi/verbs/passed"}, + }, + }, + }, + } + + cond, args, next := svc.BuildComplexFilter(filter, 1) + + if cond == "" || len(args) == 0 || next <= 1 { + t.Fatalf("BuildComplexFilter with JSONB failed: cond=%q, args=%v, next=%d", cond, args, next) + } + + // Verify the JSONB paths are in the condition + if !strings.Contains(cond, "raw_statement") { + t.Fatalf("BuildComplexFilter output missing raw_statement: %s", cond) + } + + // Verify regular columns are quoted + if !strings.Contains(cond, "\"timestamp\"") { + t.Fatalf("BuildComplexFilter output should have quoted timestamp: %s", cond) + } + + // Verify AND logic is present + if !strings.Contains(cond, "AND") { + t.Fatalf("BuildComplexFilter output missing AND logic: %s", cond) + } + + // Verify OR logic is present + if !strings.Contains(cond, "OR") { + t.Fatalf("BuildComplexFilter output missing OR logic: %s", cond) + } + + // Verify arguments are in order + if len(args) < 4 { + t.Fatalf("expected at least 4 arguments, got %d", len(args)) + } +} + +// Helper function for string contains check +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/pkg/database/postgres/repo_jsonb_buildquery_test.go b/pkg/database/postgres/repo_jsonb_buildquery_test.go new file mode 100644 index 0000000..656795f --- /dev/null +++ b/pkg/database/postgres/repo_jsonb_buildquery_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Aptlogica Technologies Private Limited +// SPDX-License-Identifier: MIT +// Websites: https://www.aptlogica.com | https://www.serenibase.com +// Support: support@aptlogica.com | support@serenibase.com + +package postgres_test + +import ( + "strings" + "testing" + + postgres "github.com/aptlogica/go-postgres-rest/pkg/database/postgres" + "github.com/aptlogica/go-postgres-rest/pkg/models" +) + +// Test that BuildAdvancedQuery correctly incorporates JSONB paths from complex filters +func TestBuildAdvancedQueryWithJSONB(t *testing.T) { + svc := &postgres.PostgresDbService{} + + limit := 50 + params := models.QueryParams{ + Select: []string{"id", "raw_statement", "timestamp", "stored"}, + Complex: &models.ComplexFilter{ + Logic: "AND", + Filters: []models.QueryFilter{ + {Column: "timestamp", Operator: "gte", Value: "2026-04-01T00:00:00Z"}, + {Column: "raw_statement", JSONPath: []string{"result", "success"}, Operator: "eq", Value: "true"}, + }, + Groups: []models.ComplexFilter{ + { + Logic: "OR", + Filters: []models.QueryFilter{ + {Column: "raw_statement", JSONPath: []string{"verb", "id"}, Operator: "eq", Value: "http://adlnet.gov/expapi/verbs/completed"}, + {Column: "raw_statement", JSONPath: []string{"verb", "id"}, Operator: "eq", Value: "http://adlnet.gov/expapi/verbs/passed"}, + }, + }, + }, + }, + OrderBy: []string{"timestamp desc"}, + Limit: &limit, + } + + query, args := svc.BuildAdvancedQuery("statements", params) + + if !strings.Contains(query, "FROM statements") { + t.Fatalf("expected FROM clause, got query: %s", query) + } + + // Verify JSONB paths are properly built in the query + if !strings.Contains(query, "raw_statement") { + t.Fatalf("expected raw_statement in WHERE, got query: %s", query) + } + + if !strings.Contains(query, "result") || !strings.Contains(query, "success") { + t.Fatalf("expected JSONB path components in query, got: %s", query) + } + + if !strings.Contains(query, "verb") || !strings.Contains(query, "id") { + t.Fatalf("expected JSONB verb/id path in query, got: %s", query) + } + + if !strings.Contains(strings.ToUpper(query), "ORDER BY") { + t.Fatalf("expected ORDER BY in query: %s", query) + } + + if !strings.Contains(query, "LIMIT") { + t.Fatalf("expected LIMIT in query: %s", query) + } + + // Limit should be the last argument appended + if len(args) == 0 || args[len(args)-1] != 50 { + t.Fatalf("expected last arg to be limit 50, got args: %v", args) + } +} diff --git a/pkg/database/postgres/repo_jsonb_test.go b/pkg/database/postgres/repo_jsonb_test.go new file mode 100644 index 0000000..cad09d8 --- /dev/null +++ b/pkg/database/postgres/repo_jsonb_test.go @@ -0,0 +1,302 @@ +// Copyright (c) 2026 Aptlogica Technologies Private Limited +// SPDX-License-Identifier: MIT +// Websites: https://www.aptlogica.com | https://www.serenibase.com +// Support: support@aptlogica.com | support@serenibase.com + +package postgres_test + +import ( + "strings" + "testing" + + "github.com/aptlogica/go-postgres-rest/pkg/database/postgres" + "github.com/aptlogica/go-postgres-rest/pkg/models" +) + +// TestBuildJSONBConditionBasic tests basic JSONB path queries +func TestBuildJSONBConditionBasic(t *testing.T) { + svc := &postgres.PostgresDbService{} + + // Test: column='raw_statement', json_path=['result', 'success'], operator='eq', value='true' + cond, args, next := svc.BuildJSONBCondition(models.QueryFilter{ + Column: "raw_statement", + JSONPath: []string{"result", "success"}, + Operator: "eq", + Value: "true", + }, 1) + + // Should produce: "raw_statement" -> 'result' ->> 'success' = $1 + if !strings.Contains(cond, "raw_statement") || !strings.Contains(cond, "'result'") || !strings.Contains(cond, "'success'") { + t.Fatalf("unexpected condition: %s", cond) + } + if !strings.Contains(cond, "=") || len(args) != 1 || args[0] != "true" || next != 2 { + t.Fatalf("expected condition with = operator, args=['true'], next=2, got: cond=%s args=%v next=%d", cond, args, next) + } +} + +// TestBuildJSONBConditionDeepPath tests deeply nested JSONB paths +func TestBuildJSONBConditionDeepPath(t *testing.T) { + svc := &postgres.PostgresDbService{} + + // Test deeper nesting: column='data', json_path=['user', 'profile', 'email'] + cond, args, _ := svc.BuildJSONBCondition(models.QueryFilter{ + Column: "data", + JSONPath: []string{"user", "profile", "email"}, + Operator: "eq", + Value: "user@example.com", + }, 1) + + // Should have multiple -> operators and final ->> + if strings.Count(cond, "->") < 2 { + t.Fatalf("expected multiple -> operators for nested path, got: %s", cond) + } + if len(args) != 1 || args[0] != "user@example.com" { + t.Fatalf("unexpected args: %v", args) + } +} + +// TestBuildJSONBConditionOperators tests different operators with JSONB paths +func TestBuildJSONBConditionOperators(t *testing.T) { + svc := &postgres.PostgresDbService{} + + tests := []struct { + name string + operator string + value interface{} + expectedOp string + expectedArgs int + }{ + {"eq", "eq", "value", "=", 1}, + {"neq", "!=", "value", "!=", 1}, + {"gt", ">", 10, ">", 1}, + {"gte", ">=", 10, ">=", 1}, + {"lt", "<", 10, "<", 1}, + {"lte", "<=", 10, "<=", 1}, + {"like", "like", "%pattern%", "LIKE", 1}, + {"ilike", "ilike", "%Pattern%", "ILIKE", 1}, + {"in", "in", []string{"a", "b", "c"}, "IN", 3}, + {"not_in", "not_in", []int{1, 2, 3}, "NOT IN", 3}, + {"is_null", "is_null", nil, "IS NULL", 0}, + {"is_not_null", "is_not_null", nil, "IS NOT NULL", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cond, args, _ := svc.BuildJSONBCondition(models.QueryFilter{ + Column: "payload", + JSONPath: []string{"status", "code"}, + Operator: tt.operator, + Value: tt.value, + }, 1) + + if !strings.Contains(cond, tt.expectedOp) { + t.Fatalf("expected operator %s, got: %s", tt.expectedOp, cond) + } + if len(args) != tt.expectedArgs { + t.Fatalf("expected %d args, got %d: %v", tt.expectedArgs, len(args), args) + } + }) + } +} + +// TestBuildJSONBConditionSQLEscaping tests SQL injection prevention +func TestBuildJSONBConditionSQLEscaping(t *testing.T) { + svc := &postgres.PostgresDbService{} + + // Test that single quotes in JSON keys are properly escaped + cond, _, _ := svc.BuildJSONBCondition(models.QueryFilter{ + Column: "metadata", + JSONPath: []string{"user's", "name"}, + Operator: "eq", + Value: "test", + }, 1) + + // Single quotes in the JSON key should be escaped (doubled) + if !strings.Contains(cond, "''") { + t.Fatalf("expected single quote escaping in JSON keys, got: %s", cond) + } +} + +// TestBuildComplexFilterWithJSONBFilters tests JSONB path queries in complex filters +func TestBuildComplexFilterWithJSONBFilters(t *testing.T) { + svc := &postgres.PostgresDbService{} + + // Test combining JSONB filters with regular filters in a complex filter + complex := models.ComplexFilter{ + Logic: "AND", + Filters: []models.QueryFilter{ + { + Column: "raw_statement", + JSONPath: []string{"result", "success"}, + Operator: "eq", + Value: "true", + }, + { + Column: "raw_statement", + JSONPath: []string{"verb", "id"}, + Operator: "eq", + Value: "http://adlnet.gov/expapi/verbs/completed", + }, + }, + } + + cond, args, _ := svc.BuildComplexFilter(complex, 1) + if cond == "" || len(args) != 2 { + t.Fatalf("unexpected complex filter with JSONB: cond=%s args=%v", cond, args) + } + if !strings.Contains(cond, "AND") { + t.Fatalf("expected AND logic in complex filter, got: %s", cond) + } +} + +// TestBuildComplexFilterWithJSONBGroups tests JSONB paths in complex filter groups +func TestBuildComplexFilterWithJSONBGroups(t *testing.T) { + svc := &postgres.PostgresDbService{} + + // Test: (raw_statement->'verb'->>'id' = 'completed' OR raw_statement->'verb'->>'id' = 'passed') AND timestamp >= '2026-04-01' + complex := models.ComplexFilter{ + Logic: "AND", + Filters: []models.QueryFilter{ + { + Column: "timestamp", + Operator: "gte", + Value: "2026-04-01T00:00:00Z", + }, + }, + Groups: []models.ComplexFilter{ + { + Logic: "OR", + Filters: []models.QueryFilter{ + { + Column: "raw_statement", + JSONPath: []string{"verb", "id"}, + Operator: "eq", + Value: "http://adlnet.gov/expapi/verbs/completed", + }, + { + Column: "raw_statement", + JSONPath: []string{"verb", "id"}, + Operator: "eq", + Value: "http://adlnet.gov/expapi/verbs/passed", + }, + }, + }, + }, + } + + cond, args, _ := svc.BuildComplexFilter(complex, 1) + if cond == "" { + t.Fatalf("expected complex filter with JSONB groups, got empty") + } + + // Should have both AND and OR logic + if !strings.Contains(cond, "OR") || !strings.Contains(cond, "AND") { + t.Fatalf("expected both OR and AND logic, got: %s", cond) + } + + // Should have correct number of args (1 timestamp + 2 verb ids) + if len(args) != 3 { + t.Fatalf("expected 3 args, got %d: %v", len(args), args) + } +} + +// TestBuildAdvancedQueryWithJSONBPath tests JSONB in full advanced queries +func TestBuildAdvancedQueryWithJSONBPath(t *testing.T) { + svc := &postgres.PostgresDbService{} + + limit := 50 + params := models.QueryParams{ + Select: []string{"id", "raw_statement", "timestamp"}, + Complex: &models.ComplexFilter{ + Logic: "AND", + Filters: []models.QueryFilter{ + { + Column: "timestamp", + Operator: "gte", + Value: "2026-04-01T00:00:00Z", + }, + { + Column: "raw_statement", + JSONPath: []string{"result", "success"}, + Operator: "eq", + Value: "true", + }, + }, + Groups: []models.ComplexFilter{ + { + Logic: "OR", + Filters: []models.QueryFilter{ + { + Column: "raw_statement", + JSONPath: []string{"verb", "id"}, + Operator: "eq", + Value: "http://adlnet.gov/expapi/verbs/completed", + }, + { + Column: "raw_statement", + JSONPath: []string{"verb", "id"}, + Operator: "eq", + Value: "http://adlnet.gov/expapi/verbs/passed", + }, + }, + }, + }, + }, + OrderBy: []string{"timestamp DESC"}, + Limit: &limit, + } + + query, args := svc.BuildAdvancedQuery("statements", params) + + // Basic structure checks + if !strings.Contains(query, "SELECT") || !strings.Contains(query, "FROM statements") { + t.Fatalf("expected basic SELECT structure, got: %s", query) + } + + // Should contain WHERE clause + if !strings.Contains(query, "WHERE") { + t.Fatalf("expected WHERE clause in query, got: %s", query) + } + + // Should have ORDER BY and LIMIT + if !strings.Contains(query, "ORDER BY") || !strings.Contains(query, "LIMIT") { + t.Fatalf("expected ORDER BY and LIMIT, got: %s", query) + } + + // Should have correct number of args (1 timestamp + 3 jsonb values + 1 limit) + if len(args) < 4 { + t.Fatalf("expected at least 4 args, got %d: %v", len(args), args) + } +} + +// TestBuildJSONBConditionEmptyPath tests edge cases with empty JSONB paths +func TestBuildJSONBConditionEmptyPath(t *testing.T) { + svc := &postgres.PostgresDbService{} + + cond, args, counter := svc.BuildJSONBCondition(models.QueryFilter{ + Column: "data", + JSONPath: []string{}, // Empty path + Operator: "eq", + Value: "test", + }, 1) + + if cond != "" || len(args) != 0 || counter != 1 { + t.Fatalf("expected empty condition for empty JSONPath, got cond=%s args=%v counter=%d", cond, args, counter) + } +} + +// TestBuildJSONBConditionInvalidColumn tests handling of invalid column names +func TestBuildJSONBConditionInvalidColumn(t *testing.T) { + svc := &postgres.PostgresDbService{} + + cond, args, counter := svc.BuildJSONBCondition(models.QueryFilter{ + Column: "1invalid", // Invalid column name + JSONPath: []string{"key"}, + Operator: "eq", + Value: "test", + }, 1) + + if cond != "" || len(args) != 0 || counter != 1 { + t.Fatalf("expected empty condition for invalid column, got cond=%s", cond) + } +} diff --git a/pkg/models/schema.go b/pkg/models/schema.go index c63d6d2..f2d0a8f 100644 --- a/pkg/models/schema.go +++ b/pkg/models/schema.go @@ -41,6 +41,7 @@ type ForeignKey struct { // Enhanced query models type QueryFilter struct { Column string `json:"column"` + JSONPath []string `json:"json_path,omitempty"` // For JSONB path queries: ["result", "success"] Operator string `json:"operator"` Value interface{} `json:"value"` Logic string `json:"logic,omitempty"` // "AND" or "OR"