Skip to content

Commit 977ec7c

Browse files
Merge pull request #36 from aptlogica/josnb-support
Josnb support
2 parents f114013 + a874cf0 commit 977ec7c

5 files changed

Lines changed: 763 additions & 9 deletions

File tree

pkg/database/postgres/repo.go

Lines changed: 209 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,74 @@ func ValidateColumnName(name string) error {
145145
return nil
146146
}
147147

148+
// ValidateJSONBPath validates JSONB path expressions
149+
// Supports expressions like: column->'key'->>'value', column->0->>'nested', etc.
150+
func ValidateJSONBPath(path string) error {
151+
path = strings.TrimSpace(path)
152+
153+
if len(path) == 0 {
154+
return fmt.Errorf("JSONB path cannot be empty")
155+
}
156+
157+
if len(path) > 512 { // Allow longer paths for JSONB
158+
return fmt.Errorf("JSONB path exceeds maximum length (512 chars): %d", len(path))
159+
}
160+
161+
// Check for SQL injection patterns (allow single quotes for JSONB keys, but block double quotes and dangerous SQL)
162+
dangerousPatterns := []string{";", "--", "/*", "*/", "\""}
163+
for _, pattern := range dangerousPatterns {
164+
if strings.Contains(path, pattern) {
165+
return fmt.Errorf("JSONB path contains potentially dangerous characters: %s", path)
166+
}
167+
}
168+
169+
// Validate structure: must start with valid column name, followed by JSONB operators
170+
// Find the base column name (everything before the first ->)
171+
operatorIndex := strings.Index(path, "->")
172+
if operatorIndex == -1 {
173+
return fmt.Errorf("JSONB path must contain -> or ->> operator: %s", path)
174+
}
175+
176+
baseName := path[:operatorIndex]
177+
if !validColumnRegex.MatchString(baseName) {
178+
return fmt.Errorf("invalid base column in JSONB path: %s", baseName)
179+
}
180+
181+
// Rest of the path after base column should contain balanced single quotes and valid JSONB operators
182+
// Check for balanced quotes
183+
singleQuoteCount := strings.Count(path, "'")
184+
if singleQuoteCount%2 != 0 {
185+
return fmt.Errorf("unbalanced quotes in JSONB path: %s", path)
186+
}
187+
188+
return nil
189+
}
190+
191+
// IsJSONBPath checks if a column reference uses JSONB operators
192+
func IsJSONBPath(name string) bool {
193+
return strings.Contains(name, "->") || strings.Contains(name, "->>")
194+
}
195+
196+
// ValidateAndFormatColumn validates a column name or JSONB path and formats it for SQL
197+
// For simple columns, it quotes the identifier; for JSONB paths, it returns the path as-is (already safe)
198+
func ValidateAndFormatColumn(name string) (string, error) {
199+
name = strings.TrimSpace(name)
200+
201+
// Check if it's a JSONB path expression
202+
if IsJSONBPath(name) {
203+
if err := ValidateJSONBPath(name); err != nil {
204+
return "", err
205+
}
206+
return name, nil // Return JSONB path unquoted
207+
}
208+
209+
// Otherwise validate as regular column name
210+
if err := ValidateColumnName(name); err != nil {
211+
return "", err
212+
}
213+
return pq.QuoteIdentifier(name), nil // Quote regular column names
214+
}
215+
148216
// SplitQualifiedName splits a qualified table name on dots while respecting quoted identifiers
149217
func SplitQualifiedName(qualifiedName string) ([]string, error) {
150218
parts := make([]string, 0, 2)
@@ -234,6 +302,12 @@ var allowedOperators = map[string]bool{
234302
"lt": true, "<": true, "lte": true, "<=": true,
235303
"like": true, "ilike": true, "in": true, "not_in": true,
236304
"is_null": true, "is_not_null": true, "any": true,
305+
// JSONB operators
306+
"jsonb_contains": true, // @>
307+
"jsonb_contained": true, // <@
308+
"jsonb_has_key": true, // ?
309+
"jsonb_has_any_key": true, // ?|
310+
"jsonb_has_all_keys": true, // ?&
237311
}
238312

239313
// ValidateOperator ensures operator is in the whitelist and safe to use
@@ -721,7 +795,11 @@ func (postgresDbService *PostgresDbService) ToInterfaceSlice(v interface{}) ([]i
721795

722796
// BuildSimpleCondition builds conditions for simple operators (=, !=, <, >, etc.)
723797
func (postgresDbService *PostgresDbService) BuildSimpleCondition(filter models.QueryFilter, operator string, argCounter int) (string, []interface{}, int) {
724-
condition := fmt.Sprintf("%s %s $%d", pq.QuoteIdentifier(filter.Column), operator, argCounter)
798+
formattedColumn, err := ValidateAndFormatColumn(filter.Column)
799+
if err != nil {
800+
return "", nil, argCounter
801+
}
802+
condition := fmt.Sprintf("%s %s $%d", formattedColumn, operator, argCounter)
725803
args := []interface{}{filter.Value}
726804
return condition, args, argCounter + 1
727805
}
@@ -733,6 +811,11 @@ func (postgresDbService *PostgresDbService) BuildInCondition(filter models.Query
733811
return "", nil, argCounter
734812
}
735813

814+
formattedColumn, err := ValidateAndFormatColumn(filter.Column)
815+
if err != nil {
816+
return "", nil, argCounter
817+
}
818+
736819
placeholders := make([]string, len(values))
737820
args := make([]interface{}, 0, len(values))
738821
for i, val := range values {
@@ -745,36 +828,151 @@ func (postgresDbService *PostgresDbService) BuildInCondition(filter models.Query
745828
if useNot {
746829
operator = "NOT IN"
747830
}
748-
condition := fmt.Sprintf("%s %s (%s)", pq.QuoteIdentifier(filter.Column), operator, strings.Join(placeholders, ", "))
831+
condition := fmt.Sprintf("%s %s (%s)", formattedColumn, operator, strings.Join(placeholders, ", "))
749832
return condition, args, argCounter
750833
}
751834

752835
// BuildNullCondition builds conditions for IS NULL/IS NOT NULL operators
753836
func (postgresDbService *PostgresDbService) BuildNullCondition(filter models.QueryFilter, useNot bool, argCounter int) (string, []interface{}, int) {
837+
formattedColumn, err := ValidateAndFormatColumn(filter.Column)
838+
if err != nil {
839+
return "", nil, argCounter
840+
}
754841
operator := "IS NULL"
755842
if useNot {
756843
operator = "IS NOT NULL"
757844
}
758-
condition := fmt.Sprintf("%s %s", pq.QuoteIdentifier(filter.Column), operator)
845+
condition := fmt.Sprintf("%s %s", formattedColumn, operator)
759846
return condition, nil, argCounter
760847
}
761848

762849
// BuildAnyCondition builds conditions for ANY operator
763850
func (postgresDbService *PostgresDbService) BuildAnyCondition(filter models.QueryFilter, argCounter int) (string, []interface{}, int) {
764-
condition := fmt.Sprintf("$%d = ANY(%s)", argCounter, pq.QuoteIdentifier(filter.Column))
851+
formattedColumn, err := ValidateAndFormatColumn(filter.Column)
852+
if err != nil {
853+
return "", nil, argCounter
854+
}
855+
condition := fmt.Sprintf("$%d = ANY(%s)", argCounter, formattedColumn)
765856
args := []interface{}{filter.Value}
766857
return condition, args, argCounter + 1
767858
}
768859

860+
// BuildJSONBCondition builds conditions for JSONB path queries
861+
// Example: column=["raw_statement"], json_path=["result", "success"], operator="eq", value="true"
862+
// Produces: raw_statement->'result'->>'success' = $1
863+
func (postgresDbService *PostgresDbService) BuildJSONBCondition(filter models.QueryFilter, argCounter int) (string, []interface{}, int) {
864+
// Validate column name
865+
if err := ValidateColumnName(filter.Column); err != nil {
866+
return "", nil, argCounter
867+
}
868+
869+
if len(filter.JSONPath) == 0 {
870+
return "", nil, argCounter
871+
}
872+
873+
// Build JSONB path expression: column->'key1'->'key2'->>'final_key'
874+
quotedCol := pq.QuoteIdentifier(filter.Column)
875+
pathExpr := quotedCol
876+
877+
// Navigate through the path, using ->> for the last key to extract text
878+
for i, key := range filter.JSONPath {
879+
quotedKey := fmt.Sprintf("'%s'", strings.ReplaceAll(key, "'", "''")) // SQL escape single quotes
880+
if i == len(filter.JSONPath)-1 {
881+
// Last key - use ->> to extract as text for comparison
882+
pathExpr += fmt.Sprintf(" ->> %s", quotedKey)
883+
} else {
884+
// Intermediate keys - use -> to navigate as JSONB
885+
pathExpr += fmt.Sprintf(" -> %s", quotedKey)
886+
}
887+
}
888+
889+
// Now build the condition using the path expression
890+
operator := strings.ToLower(filter.Operator)
891+
var condition string
892+
var args []interface{}
893+
894+
switch operator {
895+
case "eq", "=":
896+
condition = fmt.Sprintf("%s = $%d", pathExpr, argCounter)
897+
args = []interface{}{filter.Value}
898+
argCounter++
899+
case "neq", "!=", "<>":
900+
condition = fmt.Sprintf("%s != $%d", pathExpr, argCounter)
901+
args = []interface{}{filter.Value}
902+
argCounter++
903+
case "gt", ">":
904+
condition = fmt.Sprintf("%s > $%d", pathExpr, argCounter)
905+
args = []interface{}{filter.Value}
906+
argCounter++
907+
case "gte", ">=":
908+
condition = fmt.Sprintf("%s >= $%d", pathExpr, argCounter)
909+
args = []interface{}{filter.Value}
910+
argCounter++
911+
case "lt", "<":
912+
condition = fmt.Sprintf("%s < $%d", pathExpr, argCounter)
913+
args = []interface{}{filter.Value}
914+
argCounter++
915+
case "lte", "<=":
916+
condition = fmt.Sprintf("%s <= $%d", pathExpr, argCounter)
917+
args = []interface{}{filter.Value}
918+
argCounter++
919+
case "like":
920+
condition = fmt.Sprintf("%s LIKE $%d", pathExpr, argCounter)
921+
args = []interface{}{filter.Value}
922+
argCounter++
923+
case "ilike":
924+
condition = fmt.Sprintf("%s ILIKE $%d", pathExpr, argCounter)
925+
args = []interface{}{filter.Value}
926+
argCounter++
927+
case "in":
928+
values, ok := postgresDbService.ToInterfaceSlice(filter.Value)
929+
if !ok || len(values) == 0 {
930+
return "", nil, argCounter
931+
}
932+
placeholders := make([]string, len(values))
933+
for i, val := range values {
934+
placeholders[i] = fmt.Sprintf("$%d", argCounter)
935+
args = append(args, val)
936+
argCounter++
937+
}
938+
condition = fmt.Sprintf("%s IN (%s)", pathExpr, strings.Join(placeholders, ", "))
939+
case "not_in":
940+
values, ok := postgresDbService.ToInterfaceSlice(filter.Value)
941+
if !ok || len(values) == 0 {
942+
return "", nil, argCounter
943+
}
944+
placeholders := make([]string, len(values))
945+
for i, val := range values {
946+
placeholders[i] = fmt.Sprintf("$%d", argCounter)
947+
args = append(args, val)
948+
argCounter++
949+
}
950+
condition = fmt.Sprintf("%s NOT IN (%s)", pathExpr, strings.Join(placeholders, ", "))
951+
case "is_null":
952+
condition = fmt.Sprintf("%s IS NULL", pathExpr)
953+
case "is_not_null":
954+
condition = fmt.Sprintf("%s IS NOT NULL", pathExpr)
955+
default:
956+
// Unknown operator
957+
return "", nil, argCounter
958+
}
959+
960+
return condition, args, argCounter
961+
}
962+
769963
func (postgresDbService *PostgresDbService) BuildFilterCondition(filter models.QueryFilter, argCounter int) (string, []interface{}, int) {
770964
// VALIDATE OPERATOR FIRST - before any SQL string building
771965
if err := ValidateOperator(filter.Operator); err != nil {
772-
// Return empty condition on invalid operator - caller should handle this
773-
// or we could return error as fourth return value (future improvement)
966+
// Return empty condition on invalid operator
774967
return "", nil, argCounter
775968
}
776969

777-
// VALIDATE COLUMN NAME - ensure column is safe from SQL injection
970+
// Check if this is a JSONB path query
971+
if len(filter.JSONPath) > 0 {
972+
return postgresDbService.BuildJSONBCondition(filter, argCounter)
973+
}
974+
975+
// Regular column validation and processing
778976
if err := ValidateColumnName(filter.Column); err != nil {
779977
// Return empty condition on invalid column
780978
return "", nil, argCounter
@@ -2740,10 +2938,12 @@ func (r *PostgresDbService) RemoveManyToManyRelations(relationship *models.Relat
27402938
// - int: Updated argCounter after consuming parameters
27412939
//
27422940
// Example output for one-to-many:
2743-
// SELECT orders.* FROM orders WHERE orders.user_id = $1
2941+
//
2942+
// SELECT orders.* FROM orders WHERE orders.user_id = $1
27442943
//
27452944
// Example output for many-to-many:
2746-
// SELECT t.* FROM products t INNER JOIN order_items j ON t.id = j.product_id WHERE j.order_id = $1
2945+
//
2946+
// SELECT t.* FROM products t INNER JOIN order_items j ON t.id = j.product_id WHERE j.order_id = $1
27472947
func (r *PostgresDbService) buildRelationshipBaseQuery(relationship *models.RelationshipDefinition, params models.QueryParams, argCounter int) (string, int) {
27482948
var query strings.Builder
27492949

0 commit comments

Comments
 (0)