Skip to content

Commit a8fdb60

Browse files
committed
Add set stdlib function for immutable data path updates (94)
Runtime helpers: - src/runtime.ts: Added kSet for JavaScript and k_set for Ruby - docker/init.sql: Added elo_set PostgreSQL function Bindings: - src/bindings/javascript.ts: Registered set with helperCall('kSet') - src/bindings/ruby.ts: Registered set using k_set helper - src/bindings/sql.ts: Registered set using elo_set function Tests: - test/fixtures/datapath-set.elo: 12 acceptance tests covering: - Basic value setting - Nested path creation (.user.name) - Mixed array/object creation (.foo.0.bar) - Array index extension with nulls - Error cases (setting on wrong types) Documentation: - README.md: Added set to data navigation features - web/src/pages/stdlib.astro: Added set to Tuple section Usage: set({}, .name, 'Alice') // => {name: 'Alice'} set({}, .user.name, 'Bob') // => {user: {name: 'Bob'}} set({}, .foo.0.bar, 12) // => {foo: [{bar: 12}]} set({items: [1, 2, 3]}, .items.1, 99) // => {items: [1, 99, 3]} 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent be14300 commit a8fdb60

12 files changed

Lines changed: 204 additions & 1 deletion

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
## Problem to solve
2+
3+
We can easily extract a value from a data structure with `fetch`.
4+
It should be as easy to set/replace a value somewhere in that datastructure
5+
with a datapath and a replacement value.
6+
7+
## Idea
8+
9+
- Find a name for that function (`set` or find something better)
10+
- Provide an implementation/compilation such that whatever the path, the
11+
value is set, creating necessary tuples/list in the structure (and only
12+
failing if a conflict occurs)
13+
14+
## Example
15+
16+
```elo
17+
assert({} |> set(.foo.0.bar, 12) == { foo: [{ bar: 12 }] })
18+
```
19+
20+
```elo
21+
assertFails([] |> set(.foo.0.bar, 12))
22+
```
23+
24+
## Todo
25+
26+
- Find a name
27+
- Implement
28+
- Complete Learn (?) / Reference (?) / Stdlib (!)
29+
- If you modify examples on website, maintain tracking unit/acceptance tests
30+
accordingly
31+
- If you modify the table of contents on the website, make sure burger menu is
32+
kept in sync

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ See also the Related work section below.
4848
- Objects: `{name: 'Alice', age: 30}`
4949
- Arrays: `[1, 2, 3]`, `['a', 'b']`, `[1, 'mixed', true, null]`
5050
- DataPaths: `.x.y.z`, `.items.0.name` (for navigating data structures)
51-
- **Data navigation**: `fetch(data, .path)` for safe access with null handling
51+
- **Data navigation**: `fetch(data, .path)` for safe access with null handling, `set(data, .path, value)` for immutable updates
5252
- **Parentheses** for grouping
5353
- **Multi-target compilation**:
5454
- Ruby (using `**` for power, `&&`/`||`/`!` for boolean logic, `Date.parse()`, `DateTime.parse()`, `ActiveSupport::Duration.parse()`)

docker/init.sql

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,65 @@ EXCEPTION WHEN OTHERS THEN
112112
RAISE EXCEPTION '.: expected Data, got %', pg_typeof(v);
113113
END;
114114
$$ LANGUAGE plpgsql IMMUTABLE;
115+
116+
-- Set a value at a path in JSONB, creating intermediate structures as needed
117+
CREATE OR REPLACE FUNCTION elo_set(data JSONB, path TEXT[], value JSONB) RETURNS JSONB AS $$
118+
DECLARE
119+
seg TEXT;
120+
rest TEXT[];
121+
next_default JSONB;
122+
existing JSONB;
123+
arr JSONB;
124+
obj JSONB;
125+
i INTEGER;
126+
arr_len INTEGER;
127+
BEGIN
128+
IF array_length(path, 1) IS NULL OR array_length(path, 1) = 0 THEN
129+
RETURN value;
130+
END IF;
131+
132+
seg := path[1];
133+
rest := path[2:];
134+
135+
-- Determine what default structure to create for next level
136+
IF array_length(rest, 1) IS NULL OR array_length(rest, 1) = 0 THEN
137+
next_default := 'null'::jsonb;
138+
ELSIF rest[1] ~ '^\d+$' THEN
139+
next_default := '[]'::jsonb;
140+
ELSE
141+
next_default := '{}'::jsonb;
142+
END IF;
143+
144+
-- Is this an array index?
145+
IF seg ~ '^\d+$' THEN
146+
i := seg::INTEGER;
147+
IF data IS NOT NULL AND jsonb_typeof(data) != 'null' AND jsonb_typeof(data) != 'array' THEN
148+
RAISE EXCEPTION 'cannot set array index on non-array';
149+
END IF;
150+
arr := COALESCE(NULLIF(data, 'null'::jsonb), '[]'::jsonb);
151+
arr_len := jsonb_array_length(arr);
152+
-- Extend array with nulls if needed
153+
WHILE arr_len <= i LOOP
154+
arr := arr || 'null'::jsonb;
155+
arr_len := arr_len + 1;
156+
END LOOP;
157+
existing := arr->i;
158+
IF existing IS NULL OR jsonb_typeof(existing) = 'null' THEN
159+
existing := next_default;
160+
END IF;
161+
arr := jsonb_set(arr, ARRAY[i::TEXT], elo_set(existing, rest, value));
162+
RETURN arr;
163+
ELSE
164+
-- Object key
165+
IF data IS NOT NULL AND jsonb_typeof(data) != 'null' AND jsonb_typeof(data) = 'array' THEN
166+
RAISE EXCEPTION 'cannot set object key on array';
167+
END IF;
168+
obj := COALESCE(NULLIF(data, 'null'::jsonb), '{}'::jsonb);
169+
existing := obj->seg;
170+
IF existing IS NULL OR jsonb_typeof(existing) = 'null' THEN
171+
existing := next_default;
172+
END IF;
173+
RETURN jsonb_set(obj, ARRAY[seg], elo_set(existing, rest, value));
174+
END IF;
175+
END;
176+
$$ LANGUAGE plpgsql IMMUTABLE;

src/bindings/javascript.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ export function createJavaScriptBinding(): StdLib<string> {
354354

355355
// Data path navigation
356356
jsLib.register('fetch', [Types.any, Types.fn], helperCall('kFetch'));
357+
jsLib.register('set', [Types.any, Types.fn, Types.any], helperCall('kSet'));
357358

358359
// Error handling
359360
jsLib.register('fail', [Types.string], (args, ctx) => {

src/bindings/ruby.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,13 @@ export function createRubyBinding(): StdLib<string> {
285285
const path = ctx.emit(args[1]);
286286
return `(->(d, p) { p.reduce(d) { |cur, seg| break nil if cur.nil?; seg.is_a?(Integer) ? (cur.is_a?(Array) ? cur[seg] : nil) : (cur.is_a?(Hash) ? cur[seg] : nil) } }).call(${data}, ${path})`;
287287
});
288+
rubyLib.register('set', [Types.any, Types.fn, Types.any], (args, ctx) => {
289+
ctx.requireHelper?.('k_set');
290+
const data = ctx.emit(args[0]);
291+
const path = ctx.emit(args[1]);
292+
const value = ctx.emit(args[2]);
293+
return `k_set(${data}, ${path}, ${value})`;
294+
});
288295

289296
// Error handling
290297
rubyLib.register('fail', [Types.string], (args, ctx) => {

src/bindings/sql.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,13 @@ export function createSQLBinding(): StdLib<string> {
278278
// Convert path array to text array and use jsonb #> operator
279279
return `(${data})::jsonb #> (${path})::text[]`;
280280
});
281+
sqlLib.register('set', [Types.any, Types.fn, Types.any], (args, ctx) => {
282+
const data = ctx.emit(args[0]);
283+
const path = ctx.emit(args[1]);
284+
const value = ctx.emit(args[2]);
285+
// Use the elo_set function which handles path creation
286+
return `elo_set((${data})::jsonb, (${path})::text[], to_jsonb(${value}))`;
287+
});
281288

282289
// Error handling - uses elo_fail() PL/pgSQL function that raises an exception
283290
sqlLib.register('fail', [Types.string], (args, ctx) => `elo_fail(${ctx.emit(args[0])})`);

src/runtime.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,26 @@ export const JS_HELPERS: Record<string, string> = {
101101
}
102102
}
103103
return current === undefined ? null : current;
104+
}`,
105+
kSet: `function kSet(data, path, value) {
106+
if (path.length === 0) return value;
107+
const seg = path[0];
108+
const rest = path.slice(1);
109+
const nextDefault = rest.length === 0 ? null : typeof rest[0] === 'number' ? [] : {};
110+
if (typeof seg === 'number') {
111+
if (data !== null && data !== undefined && !Array.isArray(data)) throw new Error('cannot set array index on non-array');
112+
const arr = Array.isArray(data) ? [...data] : [];
113+
while (arr.length <= seg) arr.push(null);
114+
const existing = arr[seg];
115+
arr[seg] = kSet(existing === null || existing === undefined ? nextDefault : existing, rest, value);
116+
return arr;
117+
} else {
118+
if (Array.isArray(data)) throw new Error('cannot set object key on array');
119+
const obj = data !== null && data !== undefined && typeof data === 'object' ? {...data} : {};
120+
const existing = obj[seg];
121+
obj[seg] = kSet(existing === null || existing === undefined ? nextDefault : existing, rest, value);
122+
return obj;
123+
}
104124
}`,
105125
// Type selectors
106126
kInt: `function kInt(v) {
@@ -213,6 +233,26 @@ export const RUBY_HELPER_DEPS: Record<string, string[]> = {
213233
};
214234

215235
export const RUBY_HELPERS: Record<string, string> = {
236+
k_set: `def k_set(d, p, v)
237+
return v if p.empty?
238+
seg = p[0]
239+
rest = p[1..]
240+
next_default = rest.empty? ? nil : (rest[0].is_a?(Integer) ? [] : {})
241+
if seg.is_a?(Integer)
242+
raise 'cannot set array index on non-array' if !d.nil? && !d.is_a?(Array)
243+
arr = d.is_a?(Array) ? d.dup : []
244+
arr[seg] = nil while arr.length <= seg
245+
existing = arr[seg]
246+
arr[seg] = k_set(existing.nil? ? next_default : existing, rest, v)
247+
arr
248+
else
249+
raise 'cannot set object key on array' if d.is_a?(Array)
250+
obj = d.is_a?(Hash) ? d.dup : {}
251+
existing = obj[seg]
252+
obj[seg] = k_set(existing.nil? ? next_default : existing, rest, v)
253+
obj
254+
end
255+
end`,
216256
p_ok: `def p_ok(v, p) { success: true, path: p, message: '', value: v, cause: [] } end`,
217257
p_fail: `def p_fail(p, m = nil, c = nil) { success: false, path: p, message: m || '', value: nil, cause: c || [] } end`,
218258
p_any: `def p_any(v, p) p_ok(v, p) end`,

test/fixtures/datapath-set.elo

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
assert(set({}, .name, 'Alice') == {name: 'Alice'})
2+
assert(set({name: 'Alice'}, .name, 'Bob') == {name: 'Bob'})
3+
assert(set({name: 'Alice'}, .age, 30) == {name: 'Alice', age: 30})
4+
assert(set({}, .user.name, 'Bob') == {user: {name: 'Bob'}})
5+
assert(set({}, .foo.0.bar, 12) == {foo: [{bar: 12}]})
6+
assert(set({items: [1, 2, 3]}, .items.1, 99) == {items: [1, 99, 3]})
7+
assert(set({items: [1]}, .items.3, 99) == {items: [1, null, null, 99]})
8+
assert(set(null, .x, 1) == {x: 1})
9+
assert(set({}, .a.b.c, 1) == {a: {b: {c: 1}}})
10+
assertFails(set([], .foo, 1))
11+
assertFails(set([1, 2, 3], .foo, 1))
12+
assertFails(set({foo: []}, .foo.bar, 1))

0 commit comments

Comments
 (0)