Skip to content

Commit 223ada3

Browse files
fix: Standardize error message format throughout codebase (fixes #38)
This commit addresses issue #38 by standardizing error handling across the cel2sql codebase with the following improvements: ## Changes ### Added Sentinel Errors - Introduced exported sentinel errors in errors.go to enable `errors.Is()` checking: - `ErrUnsupportedExpression`, `ErrInvalidFieldName`, `ErrInvalidSchema` - `ErrInvalidRegexPattern`, `ErrMaxDepthExceeded`, `ErrMaxOutputLengthExceeded` - `ErrInvalidComprehension`, `ErrMaxComprehensionDepthExceeded` - `ErrInvalidArguments`, `ErrInvalidTimestampOperation`, `ErrInvalidDuration` - `ErrInvalidJSONPath`, `ErrInvalidOperator`, `ErrUnsupportedType` - `ErrContextCanceled`, `ErrInvalidByteArrayLength` - Added `pg.ErrInvalidSchema` for PostgreSQL provider package ### Improved Error Wrapping - All errors now use `fmt.Errorf()` with `%w` for proper error wrapping - Sentinel errors provide context while maintaining error chains - Enables better error handling with `errors.Is()` and `errors.As()` ### Enhanced Error Messages - Added operation context to error messages (e.g., "failed to identify comprehension") - Improved specificity for debugging (e.g., "depth 4 exceeds limit of 3") - Maintained security-conscious error handling in pg/provider.go (no credential exposure) ### Updated Tests - Modified tests to check for new error message formats - Changed from exact string matching to `Contains()` for flexibility - Updated error assertions to work with wrapped sentinel errors ## Benefits - Better debugging with improved error context - Enables programmatic error handling with `errors.Is()` - More maintainable error handling patterns - Consistent error format across the codebase 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4e367be commit 223ada3

13 files changed

+147
-90
lines changed

cel2sql.go

Lines changed: 31 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ package cel2sql
44
import (
55
"context"
66
"encoding/hex"
7-
"errors"
87
"fmt"
98
"log/slog"
109
"math"
@@ -330,7 +329,7 @@ func (con *converter) checkContext() error {
330329
return nil
331330
}
332331
if err := con.ctx.Err(); err != nil {
333-
return fmt.Errorf("conversion cancelled: %w", err)
332+
return fmt.Errorf("%w: %w", ErrContextCanceled, err)
334333
}
335334
return nil
336335
}
@@ -343,7 +342,7 @@ func (con *converter) visit(expr *exprpb.Expr) error {
343342
// Check depth limit before context check (fail fast)
344343
// Allow depths up to and including maxDepth
345344
if con.depth > con.maxDepth {
346-
return fmt.Errorf("expression exceeds maximum recursion depth of %d", con.maxDepth)
345+
return fmt.Errorf("%w: depth %d exceeds limit of %d", ErrMaxDepthExceeded, con.depth, con.maxDepth)
347346
}
348347

349348
// Check for context cancellation at the main recursion entry point
@@ -353,7 +352,7 @@ func (con *converter) visit(expr *exprpb.Expr) error {
353352

354353
// Check SQL output length limit to prevent resource exhaustion (CWE-400)
355354
if con.str.Len() > con.maxOutputLen {
356-
return fmt.Errorf("generated SQL exceeds maximum output length of %d", con.maxOutputLen)
355+
return fmt.Errorf("%w: %d bytes exceeds limit of %d", ErrMaxOutputLengthExceeded, con.str.Len(), con.maxOutputLen)
357356
}
358357

359358
switch expr.ExprKind.(type) {
@@ -706,7 +705,7 @@ func (con *converter) callStartsWith(target *exprpb.Expr, args []*exprpb.Expr) e
706705
// or for more robust handling: LEFT(string, LENGTH(prefix)) = prefix
707706

708707
if target == nil || len(args) == 0 {
709-
return errors.New("startsWith function requires both string and prefix arguments")
708+
return fmt.Errorf("%w: startsWith function requires both string and prefix arguments", ErrInvalidArguments)
710709
}
711710

712711
// Visit the string expression
@@ -723,7 +722,7 @@ func (con *converter) callStartsWith(target *exprpb.Expr, args []*exprpb.Expr) e
723722
prefix := constExpr.GetStringValue()
724723
// Reject patterns containing null bytes
725724
if strings.Contains(prefix, "\x00") {
726-
return errors.New("LIKE patterns cannot contain null bytes")
725+
return fmt.Errorf("%w: LIKE patterns cannot contain null bytes", ErrInvalidArguments)
727726
}
728727
// Escape special LIKE characters: %, _, \
729728
escaped := escapeLikePattern(prefix)
@@ -747,7 +746,7 @@ func (con *converter) callEndsWith(target *exprpb.Expr, args []*exprpb.Expr) err
747746
// or for more robust handling: RIGHT(string, LENGTH(suffix)) = suffix
748747

749748
if target == nil || len(args) == 0 {
750-
return errors.New("endsWith function requires both string and suffix arguments")
749+
return fmt.Errorf("%w: endsWith function requires both string and suffix arguments", ErrInvalidArguments)
751750
}
752751

753752
// Visit the string expression
@@ -764,7 +763,7 @@ func (con *converter) callEndsWith(target *exprpb.Expr, args []*exprpb.Expr) err
764763
suffix := constExpr.GetStringValue()
765764
// Reject patterns containing null bytes
766765
if strings.Contains(suffix, "\x00") {
767-
return errors.New("LIKE patterns cannot contain null bytes")
766+
return fmt.Errorf("%w: LIKE patterns cannot contain null bytes", ErrInvalidArguments)
768767
}
769768
// Escape special LIKE characters: %, _, \
770769
escaped := escapeLikePattern(suffix)
@@ -837,7 +836,7 @@ func (con *converter) callMatches(target *exprpb.Expr, args []*exprpb.Expr) erro
837836
}
838837

839838
if stringExpr == nil || patternExpr == nil {
840-
return errors.New("matches function requires both string and pattern arguments")
839+
return fmt.Errorf("%w: matches function requires both string and pattern arguments", ErrInvalidArguments)
841840
}
842841

843842
// Visit the string expression
@@ -851,13 +850,13 @@ func (con *converter) callMatches(target *exprpb.Expr, args []*exprpb.Expr) erro
851850
re2Pattern := constExpr.GetStringValue()
852851
// Reject patterns containing null bytes
853852
if strings.Contains(re2Pattern, "\x00") {
854-
return errors.New("regex patterns cannot contain null bytes")
853+
return fmt.Errorf("%w: regex patterns cannot contain null bytes", ErrInvalidRegexPattern)
855854
}
856855

857856
// Convert RE2 to POSIX with security validation
858857
posixPattern, caseInsensitive, err := convertRE2ToPOSIX(re2Pattern)
859858
if err != nil {
860-
return fmt.Errorf("invalid regex pattern: %w", err)
859+
return fmt.Errorf("%w: %w", ErrInvalidRegexPattern, err)
861860
}
862861

863862
con.logger.LogAttrs(context.Background(), slog.LevelDebug,
@@ -1041,10 +1040,10 @@ func (con *converter) visitCallListIndex(expr *exprpb.Expr) error {
10411040
if constExpr := index.GetConstExpr(); constExpr != nil {
10421041
idx := constExpr.GetInt64Value()
10431042
if idx == math.MaxInt64 {
1044-
return errors.New("array index overflow: cannot convert math.MaxInt64 to 1-based indexing")
1043+
return fmt.Errorf("%w: array index overflow, cannot convert math.MaxInt64 to 1-based indexing", ErrInvalidArguments)
10451044
}
10461045
if idx < 0 {
1047-
return fmt.Errorf("invalid negative array index: %d", idx)
1046+
return fmt.Errorf("%w: negative array index %d is not supported", ErrInvalidArguments, idx)
10481047
}
10491048
con.str.WriteString(strconv.FormatInt(idx+1, 10))
10501049
} else {
@@ -1081,8 +1080,8 @@ func (con *converter) visitComprehension(expr *exprpb.Expr) error {
10811080

10821081
// Check comprehension depth limit before context check (fail fast)
10831082
if con.comprehensionDepth > maxComprehensionDepth {
1084-
return fmt.Errorf("comprehension nesting depth %d exceeds maximum of %d",
1085-
con.comprehensionDepth, maxComprehensionDepth)
1083+
return fmt.Errorf("%w: depth %d exceeds limit of %d",
1084+
ErrMaxComprehensionDepthExceeded, con.comprehensionDepth, maxComprehensionDepth)
10861085
}
10871086

10881087
// Check for context cancellation before processing comprehensions (potentially expensive)
@@ -1092,7 +1091,7 @@ func (con *converter) visitComprehension(expr *exprpb.Expr) error {
10921091

10931092
info, err := con.identifyComprehension(expr)
10941093
if err != nil {
1095-
return fmt.Errorf("failed to identify comprehension: %w", err)
1094+
return fmt.Errorf("%w: failed to identify comprehension: %w", ErrInvalidComprehension, err)
10961095
}
10971096

10981097
switch info.Type {
@@ -1468,14 +1467,14 @@ func (con *converter) visitTransformMapComprehension(_ *exprpb.Expr, _ *Comprehe
14681467
// Generate SQL for TRANSFORM_MAP comprehension: work with map entries
14691468
// This is complex for PostgreSQL - maps are typically represented as JSON or composite types
14701469
// For now, return an error indicating this needs special handling
1471-
return errors.New("TRANSFORM_MAP comprehension requires map/JSON support: not yet implemented")
1470+
return fmt.Errorf("%w: TRANSFORM_MAP comprehension requires map/JSON support (not yet implemented)", ErrInvalidComprehension)
14721471
}
14731472

14741473
func (con *converter) visitTransformMapEntryComprehension(_ *exprpb.Expr, _ *ComprehensionInfo) error {
14751474
// Generate SQL for TRANSFORM_MAP_ENTRY comprehension: work with map key-value pairs
14761475
// This is complex for PostgreSQL - maps are typically represented as JSON or composite types
14771476
// For now, return an error indicating this needs special handling
1478-
return errors.New("TRANSFORM_MAP_ENTRY comprehension requires map/JSON support: not yet implemented")
1477+
return fmt.Errorf("%w: TRANSFORM_MAP_ENTRY comprehension requires map/JSON support (not yet implemented)", ErrInvalidComprehension)
14791478
}
14801479

14811480
func (con *converter) visitConst(expr *exprpb.Expr) error {
@@ -1522,7 +1521,7 @@ func (con *converter) visitConst(expr *exprpb.Expr) error {
15221521
str := c.GetStringValue()
15231522
// Reject strings containing null bytes
15241523
if strings.Contains(str, "\x00") {
1525-
return errors.New("string literals cannot contain null bytes")
1524+
return fmt.Errorf("%w: string literals cannot contain null bytes", ErrInvalidArguments)
15261525
}
15271526

15281527
if con.parameterize {
@@ -1547,7 +1546,7 @@ func (con *converter) visitConst(expr *exprpb.Expr) error {
15471546
} else {
15481547
// Validate byte array length to prevent resource exhaustion (CWE-400)
15491548
if len(b) > maxByteArrayLength {
1550-
return fmt.Errorf("byte array exceeds maximum length of %d bytes (got %d bytes)", maxByteArrayLength, len(b))
1549+
return fmt.Errorf("%w: %d bytes exceeds limit of %d bytes", ErrInvalidByteArrayLength, len(b), maxByteArrayLength)
15511550
}
15521551
con.str.WriteString("'\\x")
15531552
con.str.WriteString(hex.EncodeToString(b))
@@ -1564,7 +1563,7 @@ func (con *converter) visitIdent(expr *exprpb.Expr) error {
15641563

15651564
// Validate identifier name for security (prevent SQL injection)
15661565
if err := validateFieldName(identName); err != nil {
1567-
return fmt.Errorf("invalid identifier name: %w", err)
1566+
return fmt.Errorf("%w: %w", ErrInvalidFieldName, err)
15681567
}
15691568

15701569
// Check if this identifier needs numeric casting for JSON comprehensions
@@ -1601,7 +1600,7 @@ func (con *converter) visitSelect(expr *exprpb.Expr) error {
16011600
// Validate field name for security (prevent SQL injection)
16021601
fieldName := sel.GetField()
16031602
if err := validateFieldName(fieldName); err != nil {
1604-
return fmt.Errorf("invalid field name in select expression: %w", err)
1603+
return fmt.Errorf("%w: %w", ErrInvalidFieldName, err)
16051604
}
16061605

16071606
// Handle the case when the select expression was generated by the has() macro.
@@ -1966,7 +1965,7 @@ func isBinaryOrTernaryOperator(expr *exprpb.Expr) bool {
19661965
func convertRE2ToPOSIX(re2Pattern string) (string, bool, error) {
19671966
// 1. Check pattern length to prevent processing extremely long patterns
19681967
if len(re2Pattern) > maxRegexPatternLength {
1969-
return "", false, fmt.Errorf("regex pattern exceeds maximum length of %d characters", maxRegexPatternLength)
1968+
return "", false, fmt.Errorf("%w: pattern length %d exceeds limit of %d characters", ErrInvalidRegexPattern, len(re2Pattern), maxRegexPatternLength)
19701969
}
19711970

19721971
// 2. Extract case-insensitive flag if present
@@ -1979,27 +1978,27 @@ func convertRE2ToPOSIX(re2Pattern string) (string, bool, error) {
19791978
// 3. Detect unsupported RE2 features and return errors
19801979
// Lookahead assertions
19811980
if strings.Contains(re2Pattern, "(?=") || strings.Contains(re2Pattern, "(?!") {
1982-
return "", false, errors.New("lookahead assertions (?=...), (?!...) are not supported in PostgreSQL POSIX regex")
1981+
return "", false, fmt.Errorf("%w: lookahead assertions (?=...), (?!...) are not supported in PostgreSQL POSIX regex", ErrInvalidRegexPattern)
19831982
}
19841983
// Lookbehind assertions
19851984
if strings.Contains(re2Pattern, "(?<=") || strings.Contains(re2Pattern, "(?<!") {
1986-
return "", false, errors.New("lookbehind assertions (?<=...), (?<!...) are not supported in PostgreSQL POSIX regex")
1985+
return "", false, fmt.Errorf("%w: lookbehind assertions (?<=...), (?<!...) are not supported in PostgreSQL POSIX regex", ErrInvalidRegexPattern)
19871986
}
19881987
// Named capture groups
19891988
if strings.Contains(re2Pattern, "(?P<") {
1990-
return "", false, errors.New("named capture groups (?P<name>...) are not supported in PostgreSQL POSIX regex")
1989+
return "", false, fmt.Errorf("%w: named capture groups (?P<name>...) are not supported in PostgreSQL POSIX regex", ErrInvalidRegexPattern)
19911990
}
19921991
// Other inline flags (after we've already handled (?i))
19931992
if strings.Contains(re2Pattern, "(?m") || strings.Contains(re2Pattern, "(?s") || strings.Contains(re2Pattern, "(?-") {
1994-
return "", false, errors.New("inline flags other than (?i) are not supported in PostgreSQL POSIX regex")
1993+
return "", false, fmt.Errorf("%w: inline flags other than (?i) are not supported in PostgreSQL POSIX regex", ErrInvalidRegexPattern)
19951994
}
19961995

19971996
// 4. Detect catastrophic nested quantifiers that cause exponential backtracking
19981997
// Patterns like (a+)+, (a*)*, (x+x+)+, ((a)+b)+, etc. are extremely dangerous
19991998

20001999
// Check for doubled quantifiers
20012000
if matched, _ := regexp.MatchString(`[*+][*+]`, re2Pattern); matched {
2002-
return "", false, errors.New("regex contains catastrophic nested quantifiers that could cause ReDoS")
2001+
return "", false, fmt.Errorf("%w: regex contains catastrophic nested quantifiers that could cause ReDoS", ErrInvalidRegexPattern)
20032002
}
20042003

20052004
// Check for groups that contain quantifiers and are themselves quantified
@@ -2030,7 +2029,7 @@ func convertRE2ToPOSIX(re2Pattern string) (string, bool, error) {
20302029
if nextChar == '*' || nextChar == '+' || nextChar == '?' || nextChar == '{' {
20312030
// This group is quantified. Check if it contains quantifiers
20322031
if len(groupHasQuantifier) > 0 && groupHasQuantifier[len(groupHasQuantifier)-1] {
2033-
return "", false, errors.New("regex contains catastrophic nested quantifiers that could cause ReDoS")
2032+
return "", false, fmt.Errorf("%w: regex contains catastrophic nested quantifiers that could cause ReDoS", ErrInvalidRegexPattern)
20342033
}
20352034
}
20362035
}
@@ -2061,15 +2060,15 @@ func convertRE2ToPOSIX(re2Pattern string) (string, bool, error) {
20612060
// 5. Count and limit capture groups to prevent memory exhaustion
20622061
groupCount := strings.Count(re2Pattern, "(") - strings.Count(re2Pattern, `\(`)
20632062
if groupCount > maxRegexGroups {
2064-
return "", false, fmt.Errorf("regex contains %d capture groups, exceeds maximum of %d", groupCount, maxRegexGroups)
2063+
return "", false, fmt.Errorf("%w: regex contains %d capture groups, exceeds limit of %d", ErrInvalidRegexPattern, groupCount, maxRegexGroups)
20652064
}
20662065

20672066
// 6. Detect exponential alternation patterns like (a|a)*b or (a|ab)*
20682067
alternationPattern := regexp.MustCompile(`\([^)]*\|[^)]*\)[*+]`)
20692068
if alternationPattern.MatchString(re2Pattern) {
20702069
// Check if alternation has overlapping branches (more dangerous)
20712070
// This is a simple heuristic - full analysis would be more complex
2072-
return "", false, errors.New("regex contains quantified alternation that could cause ReDoS")
2071+
return "", false, fmt.Errorf("%w: regex contains quantified alternation that could cause ReDoS", ErrInvalidRegexPattern)
20732072
}
20742073

20752074
// 7. Check nesting depth to prevent deeply nested patterns
@@ -2086,7 +2085,7 @@ func convertRE2ToPOSIX(re2Pattern string) (string, bool, error) {
20862085
}
20872086
}
20882087
if maxDepth > maxRegexNestingDepth {
2089-
return "", false, fmt.Errorf("regex nesting depth %d exceeds maximum of %d", maxDepth, maxRegexNestingDepth)
2088+
return "", false, fmt.Errorf("%w: nesting depth %d exceeds limit of %d", ErrInvalidRegexPattern, maxDepth, maxRegexNestingDepth)
20902089
}
20912090

20922091
// Passed all security checks - proceed with conversion

comprehension_depth_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ func TestNestedComprehensionDepthLimit(t *testing.T) {
9696

9797
if tt.shouldErr {
9898
require.Error(t, err, tt.description)
99-
require.Contains(t, err.Error(), "comprehension nesting depth",
99+
require.Contains(t, err.Error(), "comprehension depth",
100100
"Error should mention comprehension depth")
101-
require.Contains(t, err.Error(), "exceeds maximum",
101+
require.Contains(t, err.Error(), "exceeds limit",
102102
"Error should mention exceeding maximum")
103103
} else {
104104
require.NoError(t, err, tt.description)
@@ -158,8 +158,8 @@ func TestComprehensionDepthWithSchemas(t *testing.T) {
158158

159159
if tt.shouldErr {
160160
require.Error(t, err, tt.description)
161-
require.Contains(t, err.Error(), "comprehension nesting depth")
162-
require.Contains(t, err.Error(), "exceeds maximum")
161+
require.Contains(t, err.Error(), "comprehension depth")
162+
require.Contains(t, err.Error(), "exceeds limit")
163163
} else {
164164
require.NoError(t, err, tt.description)
165165
}
@@ -203,9 +203,9 @@ func TestComprehensionDepthErrorMessage(t *testing.T) {
203203

204204
_, err = cel2sql.Convert(ast)
205205
require.Error(t, err)
206-
require.Contains(t, err.Error(), "comprehension nesting depth",
206+
require.Contains(t, err.Error(), "comprehension depth",
207207
"Error message should mention comprehension depth")
208-
require.Contains(t, err.Error(), "exceeds maximum",
208+
require.Contains(t, err.Error(), "exceeds limit",
209209
"Error message should mention exceeding maximum")
210210
}
211211

@@ -261,8 +261,8 @@ func TestComprehensionDepthWithMixedExpressions(t *testing.T) {
261261

262262
if tt.shouldErr {
263263
require.Error(t, err, tt.description)
264-
require.Contains(t, err.Error(), "comprehension nesting depth")
265-
require.Contains(t, err.Error(), "exceeds maximum")
264+
require.Contains(t, err.Error(), "comprehension depth")
265+
require.Contains(t, err.Error(), "exceeds limit")
266266
} else {
267267
require.NoError(t, err, tt.description)
268268
}
@@ -321,8 +321,8 @@ func TestComprehensionDepthBoundaryConditions(t *testing.T) {
321321

322322
if tt.shouldErr {
323323
require.Error(t, err)
324-
require.Contains(t, err.Error(), "comprehension nesting depth")
325-
require.Contains(t, err.Error(), "exceeds maximum")
324+
require.Contains(t, err.Error(), "comprehension depth")
325+
require.Contains(t, err.Error(), "exceeds limit")
326326
} else {
327327
require.NoError(t, err)
328328
}

comprehensions.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package cel2sql
22

33
import (
44
"context"
5-
"errors"
5+
"fmt"
66
"log/slog"
77

88
"github.com/google/cel-go/common/operators"
@@ -67,7 +67,7 @@ type ComprehensionInfo struct {
6767
func (con *converter) identifyComprehension(expr *exprpb.Expr) (*ComprehensionInfo, error) {
6868
comprehension := expr.GetComprehensionExpr()
6969
if comprehension == nil {
70-
return nil, errors.New("expression is not a comprehension")
70+
return nil, fmt.Errorf("%w: expression is not a comprehension", ErrInvalidComprehension)
7171
}
7272

7373
// Analyze the comprehension structure to determine its type

context_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func TestConvertWithContext_CancelledContext(t *testing.T) {
6161
require.Error(t, err)
6262
assert.Empty(t, sql)
6363
assert.True(t, errors.Is(err, context.Canceled), "error should wrap context.Canceled")
64-
assert.Contains(t, err.Error(), "conversion cancelled")
64+
assert.Contains(t, err.Error(), "operation cancelled")
6565
}
6666

6767
// TestConvertWithContext_Timeout tests that a timeout context stops conversion
@@ -85,7 +85,7 @@ func TestConvertWithContext_Timeout(t *testing.T) {
8585
require.Error(t, err)
8686
assert.Empty(t, sql)
8787
assert.True(t, errors.Is(err, context.DeadlineExceeded), "error should wrap context.DeadlineExceeded")
88-
assert.Contains(t, err.Error(), "conversion cancelled")
88+
assert.Contains(t, err.Error(), "operation cancelled")
8989
}
9090

9191
// TestConvertWithContext_ComplexExpression tests context cancellation with a complex expression
@@ -122,7 +122,7 @@ func TestConvertWithContext_ComplexExpression(t *testing.T) {
122122
sql, err = cel2sql.Convert(ast, cel2sql.WithContext(cancelledCtx), cel2sql.WithSchemas(schemas))
123123
require.Error(t, err)
124124
assert.Empty(t, sql)
125-
assert.Contains(t, err.Error(), "conversion cancelled")
125+
assert.Contains(t, err.Error(), "operation cancelled")
126126
}
127127

128128
// TestConvertWithContext_MultipleOptions tests combining context with other options
@@ -180,5 +180,5 @@ func TestConvertWithContext_LongRunningConversion(t *testing.T) {
180180
// Should fail due to timeout
181181
_, err = cel2sql.Convert(ast, cel2sql.WithContext(ctx))
182182
require.Error(t, err)
183-
assert.Contains(t, err.Error(), "conversion cancelled")
183+
assert.Contains(t, err.Error(), "operation cancelled")
184184
}

0 commit comments

Comments
 (0)