Skip to content

Commit ee7f873

Browse files
michaelmcneesclaude
andcommitted
fix: address CodeRabbit review — null handling, date formats, docs, tests (#14)
- default(): fall through to rhs when lhs is null (types.NullValue) - parseTimestamp(): try RFC3339Nano, bare ISO date, US date, and named-month formats before erroring - expressions.md: remove undeclared `now` variable from two examples - data-transformations.md: add missing exists_one row to list-macros table - data-transform-api-to-db.yaml: simplify to single-user fetch+insert (fixes broken batch param shape) - functions_test.go: add null/fallback tests for default(), edge-case tests for flatten(), and new format tests for parseTimestamp() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7085f68 commit ee7f873

5 files changed

Lines changed: 52 additions & 15 deletions

File tree

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
name: data-transform-api-to-db
22
description: >
3-
Fetches user data from an API, transforms each record using CEL
4-
expressions to match a database schema, and inserts the normalized
5-
records into Postgres. Demonstrates map(), obj(), toLower(), and
6-
type coercion without requiring an AI model.
3+
Fetches a user from an API, transforms the record using CEL expressions
4+
to match a database schema, and inserts the normalized data into Postgres.
5+
Demonstrates obj(), toLower(), and string functions without requiring
6+
an AI model.
77
88
steps:
9-
- name: fetch-users
9+
- name: fetch-user
1010
action: http/request
1111
timeout: "15s"
1212
params:
1313
method: GET
14-
url: "https://jsonplaceholder.typicode.com/users"
14+
url: "https://jsonplaceholder.typicode.com/users/1"
1515
headers:
1616
Accept: "application/json"
1717

18-
- name: store-users
18+
- name: store-user
1919
action: postgres/query
2020
credential: app-db
2121
params:
2222
query: "INSERT INTO users (username, email, city) VALUES ($1, $2, $3)"
23-
params: "{{ steps['fetch-users'].output.json.map(u, [u.username.toLower(), u.email.toLower(), u.address.city]) }}"
23+
args:
24+
- "{{ steps['fetch-user'].output.json.username.toLower() }}"
25+
- "{{ steps['fetch-user'].output.json.email.toLower() }}"
26+
- "{{ steps['fetch-user'].output.json.address.city }}"

internal/cel/functions.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ func (l *collectionLib) CompileOptions() []cel.EnvOption {
159159
[]*cel.Type{cel.DynType, cel.DynType},
160160
cel.DynType,
161161
cel.BinaryBinding(func(lhs, rhs ref.Val) ref.Val {
162-
if types.IsError(lhs) || types.IsUnknown(lhs) {
162+
if types.IsError(lhs) || types.IsUnknown(lhs) || lhs == types.NullValue {
163163
return rhs
164164
}
165165
return lhs
@@ -304,11 +304,20 @@ func (l *timeLib) CompileOptions() []cel.EnvOption {
304304
cel.TimestampType,
305305
cel.UnaryBinding(func(val ref.Val) ref.Val {
306306
s := string(val.(types.String))
307-
t, err := time.Parse(time.RFC3339, s)
308-
if err != nil {
309-
return types.NewErr("parseTimestamp: %v", err)
307+
layouts := []string{
308+
time.RFC3339,
309+
time.RFC3339Nano,
310+
"2006-01-02T15:04:05",
311+
"2006-01-02",
312+
"01/02/2006",
313+
"Jan 2, 2006",
314+
}
315+
for _, layout := range layouts {
316+
if t, err := time.Parse(layout, s); err == nil {
317+
return types.Timestamp{Time: t}
318+
}
310319
}
311-
return types.Timestamp{Time: t}
320+
return types.NewErr("parseTimestamp: unable to parse %q (tried RFC3339, ISO 8601 date, and common formats)", s)
312321
}),
313322
),
314323
),

internal/cel/functions_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ func TestFunc_Default(t *testing.T) {
253253
}{
254254
{"value exists returns value", `default("hello", "fallback")`, "hello"},
255255
{"empty string returns empty string", `default("", "fallback")`, ""},
256+
{"null returns fallback", `default(null, "fallback")`, "fallback"},
257+
{"non-null int unchanged", `default(42, 0)`, int64(42)},
256258
}
257259
for _, tt := range tests {
258260
t.Run(tt.name, func(t *testing.T) {
@@ -276,6 +278,11 @@ func TestFunc_Flatten(t *testing.T) {
276278
expr: `flatten([[1, 2], [3, 4], [5]])`,
277279
want: []any{int64(1), int64(2), int64(3), int64(4), int64(5)},
278280
},
281+
{
282+
name: "mixed nested and non-nested",
283+
expr: `flatten([[1], [3, 4]])`,
284+
want: []any{int64(1), int64(3), int64(4)},
285+
},
279286
}
280287
for _, tt := range tests {
281288
t.Run(tt.name, func(t *testing.T) {
@@ -284,6 +291,20 @@ func TestFunc_Flatten(t *testing.T) {
284291
assert.Equal(t, tt.want, result)
285292
})
286293
}
294+
295+
// Empty list: flatten([]) — result may be nil or empty slice.
296+
t.Run("empty list", func(t *testing.T) {
297+
ctx := newTestContext()
298+
ctx.Inputs["empty"] = []any{}
299+
result, err := eval.Eval(`flatten(inputs.empty)`, ctx)
300+
require.NoError(t, err)
301+
// CEL may return nil or an empty slice for an empty list; both are acceptable.
302+
if result != nil {
303+
list, ok := result.([]any)
304+
require.True(t, ok, "expected []any, got %T", result)
305+
assert.Empty(t, list)
306+
}
307+
})
287308
}
288309

289310
// Task 7: jsonEncode and jsonDecode
@@ -364,6 +385,9 @@ func TestFunc_Timestamp(t *testing.T) {
364385
{"iso8601", `parseTimestamp("2024-01-15T00:00:00Z")`, false},
365386
{"with_offset", `parseTimestamp("2024-06-01T12:30:00+05:30")`, false},
366387
{"invalid", `parseTimestamp("not-a-date")`, true},
388+
{"date_only", `parseTimestamp("2026-03-24")`, false},
389+
{"us_date", `parseTimestamp("03/24/2026")`, false},
390+
{"rfc3339nano", `parseTimestamp("2026-03-24T19:00:00.123456789Z")`, false},
367391
}
368392
for _, tt := range tests {
369393
t.Run(tt.name, func(t *testing.T) {

site/src/content/docs/concepts/expressions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ Parses an ISO 8601 / RFC 3339 string to a CEL timestamp value. Named `parseTimes
596596
steps:
597597
- name: check-expiry
598598
action: http/request
599-
if: "parseTimestamp(steps.fetch.output.json.expires_at) < now"
599+
if: "parseTimestamp(steps.fetch.output.json.expires_at) < parseTimestamp(\"2026-12-31T00:00:00Z\")"
600600
params:
601601
method: POST
602602
url: "https://api.example.com/renew"
@@ -619,7 +619,7 @@ steps:
619619
# Format as "2006-01-02" (Go layout for YYYY-MM-DD)
620620
report_date: "{{ formatTimestamp(parseTimestamp(steps.fetch.output.json.created_at), '2006-01-02') }}"
621621
# Format with time for a human-readable label
622-
label: "Report for {{ formatTimestamp(now, 'Jan 2, 2006') }}"
622+
label: "Report for {{ formatTimestamp(parseTimestamp(steps.fetch.output.json.created_at), 'Jan 2, 2006') }}"
623623
```
624624

625625
## Limitations

site/src/content/docs/getting-started/data-transformations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ These are the Mantle CEL extensions available in workflow expressions. For full
260260
| `.map(var, expr)` | Transform each element, returning a new list |
261261
| `.filter(var, expr)` | Return elements where `expr` evaluates to true |
262262
| `.exists(var, expr)` | True if any element satisfies `expr` |
263+
| `.exists_one(var, expr)` | True if exactly one element satisfies `expr` |
263264
| `.all(var, expr)` | True if every element satisfies `expr` |
264265

265266
**String**

0 commit comments

Comments
 (0)