Skip to content

Commit e26e434

Browse files
authored
Fix -> operator on missing values to return null (#193)
* Fix -> on missing value to return null. * Improve error message if value is not a json array. * Add changeset. * Tweak test. * Fix comments in tests. * Add test for json_each + json_keys. * Fix typo.
1 parent f4898a3 commit e26e434

File tree

5 files changed

+67
-10
lines changed

5 files changed

+67
-10
lines changed

.changeset/fast-monkeys-approve.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/service-sync-rules': patch
3+
---
4+
5+
Fix -> operator on missing values to return null.

packages/sync-rules/src/TableValuedFunctions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const JSON_EACH: TableValuedFunction = {
2727
throw new Error('Expected JSON string');
2828
}
2929
if (!Array.isArray(values)) {
30-
throw new Error('Expected an array');
30+
throw new Error(`Expected an array, got ${valueString}`);
3131
}
3232

3333
return values.map((v) => {

packages/sync-rules/src/sql_functions.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ const iif: DocumentedSqlFunction = {
260260
parameters: [
261261
{ name: 'x', type: ExpressionType.ANY, optional: false },
262262
{ name: 'y', type: ExpressionType.ANY, optional: false },
263-
{ name: 'z', type: ExpressionType.ANY, optional: false },
263+
{ name: 'z', type: ExpressionType.ANY, optional: false }
264264
],
265265
getReturnType() {
266266
return ExpressionType.ANY;
@@ -865,7 +865,10 @@ export function jsonExtract(sourceValue: SqliteValue, path: SqliteValue, operato
865865
value = value[c];
866866
}
867867
if (operator == '->') {
868-
// -> must always stringify
868+
// -> must always stringify, except when it's null
869+
if (value == null) {
870+
return null;
871+
}
869872
return JSONBig.stringify(value);
870873
} else {
871874
// Plain scalar value - simple conversion.

packages/sync-rules/test/src/sql_functions.test.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ describe('SQL functions', () => {
1212
expect(fn.json_extract(JSON.stringify({ foo: 42 }), '$')).toEqual('{"foo":42}');
1313
expect(fn.json_extract(`{"foo": 42.0}`, '$')).toEqual('{"foo":42.0}');
1414
expect(fn.json_extract(`{"foo": true}`, '$')).toEqual('{"foo":true}');
15+
// SQLite gives null instead of 'null'. We should match that, but it's a breaking change.
16+
expect(fn.json_extract(`{"foo": null}`, '$.foo')).toEqual('null');
17+
// Matches SQLite
18+
expect(fn.json_extract(`{}`, '$.foo')).toBeNull();
1519
});
1620

1721
test('->>', () => {
@@ -23,6 +27,10 @@ describe('SQL functions', () => {
2327
expect(jsonExtract(`{"foo": 42.0}`, 'foo', '->>')).toEqual(42.0);
2428
expect(jsonExtract(`{"foo": 42.0}`, '$', '->>')).toEqual(`{"foo":42.0}`);
2529
expect(jsonExtract(`{"foo": true}`, '$.foo', '->>')).toEqual(1n);
30+
// SQLite gives null instead of 'null'. We should match that, but it's a breaking change.
31+
expect(jsonExtract(`{"foo": null}`, '$.foo', '->>')).toEqual('null');
32+
// Matches SQLite
33+
expect(jsonExtract(`{}`, '$.foo', '->>')).toBeNull();
2634
});
2735

2836
test('->', () => {
@@ -34,6 +42,10 @@ describe('SQL functions', () => {
3442
expect(jsonExtract(`{"foo": 42.0}`, 'foo', '->')).toEqual('42.0');
3543
expect(jsonExtract(`{"foo": 42.0}`, '$', '->')).toEqual(`{"foo":42.0}`);
3644
expect(jsonExtract(`{"foo": true}`, '$.foo', '->')).toEqual('true');
45+
// SQLite gives 'null' instead of null. We should match that, but it's a breaking change.
46+
expect(jsonExtract(`{"foo": null}`, '$.foo', '->')).toBeNull();
47+
// Matches SQLite
48+
expect(jsonExtract(`{}`, '$.foo', '->')).toBeNull();
3749
});
3850

3951
test('json_array_length', () => {
@@ -127,13 +139,13 @@ describe('SQL functions', () => {
127139
test('iif', () => {
128140
expect(fn.iif(null, 1, 2)).toEqual(2);
129141
expect(fn.iif(0, 1, 2)).toEqual(2);
130-
expect(fn.iif(1, "first", "second")).toEqual("first");
131-
expect(fn.iif(0.1, "is_true", "is_false")).toEqual("is_true");
132-
expect(fn.iif("a", "is_true", "is_false")).toEqual("is_false");
133-
expect(fn.iif(0n, "is_true", "is_false")).toEqual("is_false");
134-
expect(fn.iif(2n, "is_true", "is_false")).toEqual("is_true");
135-
expect(fn.iif(new Uint8Array([]), "is_true", "is_false")).toEqual("is_false");
136-
expect(fn.iif(Uint8Array.of(0x61, 0x62, 0x43), "is_true", "is_false")).toEqual("is_false");
142+
expect(fn.iif(1, 'first', 'second')).toEqual('first');
143+
expect(fn.iif(0.1, 'is_true', 'is_false')).toEqual('is_true');
144+
expect(fn.iif('a', 'is_true', 'is_false')).toEqual('is_false');
145+
expect(fn.iif(0n, 'is_true', 'is_false')).toEqual('is_false');
146+
expect(fn.iif(2n, 'is_true', 'is_false')).toEqual('is_true');
147+
expect(fn.iif(new Uint8Array([]), 'is_true', 'is_false')).toEqual('is_false');
148+
expect(fn.iif(Uint8Array.of(0x61, 0x62, 0x43), 'is_true', 'is_false')).toEqual('is_false');
137149
});
138150

139151
test('upper', () => {

packages/sync-rules/test/src/table_valued_function_queries.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,43 @@ describe('table-valued function queries', () => {
4242
expect(query.getStaticBucketIds(new RequestParameters({ sub: '' }, {}))).toEqual([]);
4343
});
4444

45+
test('json_each(array param not present)', function () {
46+
const sql = "SELECT json_each.value as v FROM json_each(request.parameters() -> 'array_not_present')";
47+
const query = SqlParameterQuery.fromSql('mybucket', sql, {
48+
...PARSE_OPTIONS,
49+
accept_potentially_dangerous_queries: true
50+
}) as StaticSqlParameterQuery;
51+
expect(query.errors).toEqual([]);
52+
expect(query.bucket_parameters).toEqual(['v']);
53+
54+
expect(query.getStaticBucketIds(new RequestParameters({ sub: '' }, {}))).toEqual([]);
55+
});
56+
57+
test('json_each(array param not present, ifnull)', function () {
58+
const sql = "SELECT json_each.value as v FROM json_each(ifnull(request.parameters() -> 'array_not_present', '[]'))";
59+
const query = SqlParameterQuery.fromSql('mybucket', sql, {
60+
...PARSE_OPTIONS,
61+
accept_potentially_dangerous_queries: true
62+
}) as StaticSqlParameterQuery;
63+
expect(query.errors).toEqual([]);
64+
expect(query.bucket_parameters).toEqual(['v']);
65+
66+
expect(query.getStaticBucketIds(new RequestParameters({ sub: '' }, {}))).toEqual([]);
67+
});
68+
69+
test('json_each on json_keys', function () {
70+
const sql = `SELECT value FROM json_each(json_keys('{"a": [], "b": 2, "c": null}'))`;
71+
const query = SqlParameterQuery.fromSql('mybucket', sql, PARSE_OPTIONS) as StaticSqlParameterQuery;
72+
expect(query.errors).toEqual([]);
73+
expect(query.bucket_parameters).toEqual(['value']);
74+
75+
expect(query.getStaticBucketIds(new RequestParameters({ sub: '' }, {}))).toEqual([
76+
'mybucket["a"]',
77+
'mybucket["b"]',
78+
'mybucket["c"]'
79+
]);
80+
});
81+
4582
test('json_each with fn alias', function () {
4683
const sql = "SELECT e.value FROM json_each(request.parameters() -> 'array') e";
4784
const query = SqlParameterQuery.fromSql('mybucket', sql, {

0 commit comments

Comments
 (0)