Skip to content

Commit 6bc4c02

Browse files
feat: Add multi-dimensional array support (fixes #49) (#90)
Implemented comprehensive support for PostgreSQL multi-dimensional arrays with automatic dimension detection and backward compatibility. Changes: - Added Dimensions field to pg.FieldSchema to store array dimensionality - Implemented detectArrayDimensions() to parse PostgreSQL type strings * Handles bracket notation: integer[], integer[][], integer[][][] * Handles underscore notation: _int4, _int4[], _int4[][] * Correctly combines both notations (e.g., _int4[] = 2D array) - Updated LoadTableSchema() to detect dimensions from udt_name column - Modified ARRAY_LENGTH SQL generation to use detected dimensions - Updated getArrayDimension() to use CEL typeMap for accurate type lookup Testing: - Added comprehensive unit tests for dimension detection (22 test cases) - Added integration tests for 1D, 2D, 3D, and 4D arrays - Added backward compatibility tests - All existing tests pass (72.2% coverage) Backward Compatibility: - Arrays without schema information default to dimension 1 - Explicit Dimensions=0 defaults to dimension 1 - Existing code continues to work without changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <[email protected]>
1 parent cdd65df commit 6bc4c02

File tree

5 files changed

+4108
-15
lines changed

5 files changed

+4108
-15
lines changed

cel2sql.go

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,57 @@ func (con *converter) getFieldElementType(tableName, fieldName string) string {
486486
return ""
487487
}
488488

489+
// getArrayDimension returns the number of array dimensions for a field expression.
490+
// Returns 1 if no schema information is available (backward compatible default).
491+
// For multi-dimensional arrays, returns the detected dimension count (2 for int[][], 3 for int[][][], etc.)
492+
func (con *converter) getArrayDimension(expr *exprpb.Expr) int {
493+
// Default to 1D arrays if we can't determine from schema
494+
if con.schemas == nil {
495+
return 1
496+
}
497+
498+
// Try to extract field name from the select expression
499+
selectExpr := expr.GetSelectExpr()
500+
if selectExpr == nil {
501+
return 1
502+
}
503+
504+
fieldName := selectExpr.GetField()
505+
operand := selectExpr.GetOperand()
506+
507+
// Get the type of the operand from the type map
508+
operandType := con.typeMap[operand.GetId()]
509+
if operandType == nil {
510+
return 1
511+
}
512+
513+
// Extract the type name (e.g., "TestTable" from the object type)
514+
typeName := operandType.GetMessageType()
515+
if typeName == "" {
516+
return 1
517+
}
518+
519+
// Look up the schema by type name
520+
schema, ok := con.schemas[typeName]
521+
if !ok {
522+
return 1
523+
}
524+
525+
// Find the field in the schema
526+
field, found := schema.FindField(fieldName)
527+
if !found || !field.Repeated {
528+
return 1
529+
}
530+
531+
// If dimensions is explicitly set and > 0, use it
532+
if field.Dimensions > 0 {
533+
return field.Dimensions
534+
}
535+
536+
// Otherwise default to 1
537+
return 1
538+
}
539+
489540
func (con *converter) visitCall(expr *exprpb.Expr) error {
490541
// Check for context cancellation before processing function calls
491542
if err := con.checkContext(); err != nil {
@@ -1770,15 +1821,18 @@ func (con *converter) visitCallFunc(expr *exprpb.Expr) error {
17701821
con.str.WriteString("), 0)")
17711822
return nil
17721823
}
1773-
// For PostgreSQL, we need to specify the array dimension (1 for 1D arrays)
1824+
// For PostgreSQL, we need to specify the array dimension
1825+
// Detect the dimension from schema if available, otherwise default to 1
1826+
dimension := con.getArrayDimension(argExpr)
1827+
17741828
// Wrap in COALESCE to handle NULL arrays (ARRAY_LENGTH returns NULL for NULL input)
17751829
con.str.WriteString("COALESCE(ARRAY_LENGTH(")
17761830
nested := isBinaryOrTernaryOperator(argExpr)
17771831
err := con.visitMaybeNested(argExpr, nested)
17781832
if err != nil {
17791833
return err
17801834
}
1781-
con.str.WriteString(", 1), 0)")
1835+
con.str.WriteString(fmt.Sprintf(", %d), 0)", dimension))
17821836
return nil
17831837
default:
17841838
return newConversionErrorf(errMsgUnsupportedType, "size() argument type: %s", argType.String())

multidim_array_test.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package cel2sql
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/cel-go/cel"
7+
"github.com/spandigital/cel2sql/v3/pg"
8+
)
9+
10+
// TestMultiDimensionalArrays tests size() function with multi-dimensional arrays
11+
func TestMultiDimensionalArrays(t *testing.T) {
12+
// Create schema with arrays of different dimensions
13+
schema := pg.NewSchema([]pg.FieldSchema{
14+
{Name: "tags", Type: "text", Repeated: true, Dimensions: 1}, // 1D array
15+
{Name: "matrix", Type: "integer", Repeated: true, Dimensions: 2}, // 2D array
16+
{Name: "cube", Type: "integer", Repeated: true, Dimensions: 3}, // 3D array
17+
{Name: "hypercube", Type: "integer", Repeated: true, Dimensions: 4}, // 4D array
18+
{Name: "simple_array", Type: "text", Repeated: true, Dimensions: 0}, // Dimension not set (defaults to 1)
19+
{Name: "id", Type: "integer", Repeated: false, Dimensions: 0}, // Not an array
20+
})
21+
22+
provider := pg.NewTypeProvider(map[string]pg.Schema{"TestTable": schema})
23+
24+
env, err := cel.NewEnv(
25+
cel.CustomTypeProvider(provider),
26+
cel.Variable("data", cel.ObjectType("TestTable")),
27+
)
28+
if err != nil {
29+
t.Fatalf("failed to create CEL environment: %v", err)
30+
}
31+
32+
tests := []struct {
33+
name string
34+
cel string
35+
expectedSQL string
36+
expectedDim int
37+
description string
38+
}{
39+
{
40+
name: "1D array size",
41+
cel: "size(data.tags) > 0",
42+
expectedSQL: "COALESCE(ARRAY_LENGTH(data.tags, 1), 0) > 0",
43+
expectedDim: 1,
44+
description: "1D array should use dimension 1",
45+
},
46+
{
47+
name: "2D array size",
48+
cel: "size(data.matrix) > 0",
49+
expectedSQL: "COALESCE(ARRAY_LENGTH(data.matrix, 2), 0) > 0",
50+
expectedDim: 2,
51+
description: "2D array should use dimension 2",
52+
},
53+
{
54+
name: "3D array size",
55+
cel: "size(data.cube) == 5",
56+
expectedSQL: "COALESCE(ARRAY_LENGTH(data.cube, 3), 0) = 5",
57+
expectedDim: 3,
58+
description: "3D array should use dimension 3",
59+
},
60+
{
61+
name: "4D array size",
62+
cel: "size(data.hypercube) < 10",
63+
expectedSQL: "COALESCE(ARRAY_LENGTH(data.hypercube, 4), 0) < 10",
64+
expectedDim: 4,
65+
description: "4D array should use dimension 4",
66+
},
67+
{
68+
name: "array with no dimension set defaults to 1",
69+
cel: "size(data.simple_array) > 0",
70+
expectedSQL: "COALESCE(ARRAY_LENGTH(data.simple_array, 1), 0) > 0",
71+
expectedDim: 1,
72+
description: "Array with Dimensions=0 should default to dimension 1",
73+
},
74+
{
75+
name: "complex expression with 2D array",
76+
cel: "size(data.matrix) >= 3 && size(data.matrix) <= 10",
77+
expectedSQL: "COALESCE(ARRAY_LENGTH(data.matrix, 2), 0) >= 3 AND COALESCE(ARRAY_LENGTH(data.matrix, 2), 0) <= 10",
78+
expectedDim: 2,
79+
description: "Complex expressions should maintain correct dimension",
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
ast, issues := env.Compile(tt.cel)
86+
if issues != nil && issues.Err() != nil {
87+
t.Fatalf("failed to compile CEL: %v", issues.Err())
88+
}
89+
90+
sql, err := Convert(ast, WithSchemas(map[string]pg.Schema{"TestTable": schema}))
91+
if err != nil {
92+
t.Fatalf("failed to convert to SQL: %v", err)
93+
}
94+
95+
if sql != tt.expectedSQL {
96+
t.Errorf("unexpected SQL for %s:\n got: %s\n want: %s", tt.description, sql, tt.expectedSQL)
97+
}
98+
99+
t.Logf("✓ %s: ARRAY_LENGTH(..., %d)", tt.description, tt.expectedDim)
100+
})
101+
}
102+
}
103+
104+
// TestMultiDimensionalArrayBackwardCompatibility ensures that existing 1D array behavior is preserved
105+
func TestMultiDimensionalArrayBackwardCompatibility(t *testing.T) {
106+
tests := []struct {
107+
name string
108+
cel string
109+
expectedSQL string
110+
}{
111+
{
112+
name: "size without schema - defaults to 1D",
113+
cel: "size(string_list) > 0",
114+
expectedSQL: "COALESCE(ARRAY_LENGTH(string_list, 1), 0) > 0",
115+
},
116+
{
117+
name: "size in complex expression - defaults to 1D",
118+
cel: "size(items) >= 2 && size(items) <= 5",
119+
expectedSQL: "COALESCE(ARRAY_LENGTH(items, 1), 0) >= 2 AND COALESCE(ARRAY_LENGTH(items, 1), 0) <= 5",
120+
},
121+
}
122+
123+
for _, tt := range tests {
124+
t.Run(tt.name, func(t *testing.T) {
125+
// Create environment with variables declared but no schema
126+
env, err := cel.NewEnv(
127+
cel.Variable("string_list", cel.ListType(cel.StringType)),
128+
cel.Variable("items", cel.ListType(cel.IntType)),
129+
)
130+
if err != nil {
131+
t.Fatalf("failed to create CEL environment: %v", err)
132+
}
133+
134+
ast, issues := env.Compile(tt.cel)
135+
if issues != nil && issues.Err() != nil {
136+
t.Fatalf("failed to compile CEL: %v", issues.Err())
137+
}
138+
139+
// Convert WITHOUT providing schema - should default to dimension 1
140+
sql, err := Convert(ast)
141+
if err != nil {
142+
t.Fatalf("failed to convert to SQL: %v", err)
143+
}
144+
145+
if sql != tt.expectedSQL {
146+
t.Errorf("backward compatibility failed:\n got: %s\n want: %s", sql, tt.expectedSQL)
147+
}
148+
149+
t.Logf("✓ Backward compatible: defaults to dimension 1 when no schema")
150+
})
151+
}
152+
}
153+
154+
// TestExplicitDimensionOverridesDefault tests that explicitly setting Dimensions to 1 works correctly
155+
func TestExplicitDimensionOverridesDefault(t *testing.T) {
156+
// Schema with explicitly set Dimensions=1
157+
schemaExplicit := pg.NewSchema([]pg.FieldSchema{
158+
{Name: "tags", Type: "text", Repeated: true, Dimensions: 1},
159+
})
160+
161+
// Schema with Dimensions=0 (not set, should use 1 as default)
162+
schemaImplicit := pg.NewSchema([]pg.FieldSchema{
163+
{Name: "tags", Type: "text", Repeated: true, Dimensions: 0},
164+
})
165+
166+
provider1 := pg.NewTypeProvider(map[string]pg.Schema{"TestTable": schemaExplicit})
167+
provider2 := pg.NewTypeProvider(map[string]pg.Schema{"TestTable": schemaImplicit})
168+
169+
env1, _ := cel.NewEnv(
170+
cel.CustomTypeProvider(provider1),
171+
cel.Variable("data", cel.ObjectType("TestTable")),
172+
)
173+
174+
env2, _ := cel.NewEnv(
175+
cel.CustomTypeProvider(provider2),
176+
cel.Variable("data", cel.ObjectType("TestTable")),
177+
)
178+
179+
celExpr := "size(data.tags) > 0"
180+
expectedSQL := "COALESCE(ARRAY_LENGTH(data.tags, 1), 0) > 0"
181+
182+
// Test with explicit Dimensions=1
183+
ast1, _ := env1.Compile(celExpr)
184+
sql1, err := Convert(ast1, WithSchemas(map[string]pg.Schema{"TestTable": schemaExplicit}))
185+
if err != nil {
186+
t.Fatalf("failed with explicit dimension: %v", err)
187+
}
188+
if sql1 != expectedSQL {
189+
t.Errorf("explicit Dimensions=1 failed:\n got: %s\n want: %s", sql1, expectedSQL)
190+
}
191+
192+
// Test with implicit Dimensions=0 (should default to 1)
193+
ast2, _ := env2.Compile(celExpr)
194+
sql2, err := Convert(ast2, WithSchemas(map[string]pg.Schema{"TestTable": schemaImplicit}))
195+
if err != nil {
196+
t.Fatalf("failed with implicit dimension: %v", err)
197+
}
198+
if sql2 != expectedSQL {
199+
t.Errorf("implicit Dimensions=0 (default) failed:\n got: %s\n want: %s", sql2, expectedSQL)
200+
}
201+
202+
t.Log("✓ Both explicit Dimensions=1 and implicit Dimensions=0 default to dimension 1")
203+
}

0 commit comments

Comments
 (0)