From 1f13381bf9b20106ed36f4f46098abaa3aeac0cd Mon Sep 17 00:00:00 2001 From: Ralf Kistner Date: Thu, 17 Oct 2024 09:51:28 +0200 Subject: [PATCH 1/3] Add json_keys function. --- packages/sync-rules/src/sql_functions.ts | 27 +++++++++++++++++++ .../sync-rules/test/src/sql_functions.test.ts | 24 +++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/sync-rules/src/sql_functions.ts b/packages/sync-rules/src/sql_functions.ts index 715f1494f..a6832ae5d 100644 --- a/packages/sync-rules/src/sql_functions.ts +++ b/packages/sync-rules/src/sql_functions.ts @@ -223,6 +223,32 @@ const json_valid: DocumentedSqlFunction = { documentation: 'Returns 1 if valid, 0 if invalid' }; +const json_keys: DocumentedSqlFunction = { + debugName: 'json_keys', + call(json: SqliteValue) { + const jsonString = castAsText(json); + if (jsonString == null) { + return null; + } + + const jsonParsed = JSONBig.parse(jsonString); + if (typeof jsonParsed != 'object') { + throw new Error(`Cannot call json_keys on a scalar`); + } else if (Array.isArray(jsonParsed)) { + throw new Error(`Cannot call json_keys on an array`); + } + const keys = Object.keys(jsonParsed as {}); + // Keys are always strings, safe to use plain JSON. + return JSON.stringify(keys); + }, + parameters: [{ name: 'json', type: ExpressionType.ANY, optional: false }], + getReturnType(args) { + // TODO: proper nullable types + return ExpressionType.TEXT; + }, + detail: 'Returns the keys of a JSON object as a JSON array' +}; + const unixepoch: DocumentedSqlFunction = { debugName: 'unixepoch', call(value?: SqliteValue, specifier?: SqliteValue, specifier2?: SqliteValue) { @@ -397,6 +423,7 @@ export const SQL_FUNCTIONS_NAMED = { json_extract, json_array_length, json_valid, + json_keys, unixepoch, datetime, st_asgeojson, diff --git a/packages/sync-rules/test/src/sql_functions.test.ts b/packages/sync-rules/test/src/sql_functions.test.ts index 525133c3b..9c11b9352 100644 --- a/packages/sync-rules/test/src/sql_functions.test.ts +++ b/packages/sync-rules/test/src/sql_functions.test.ts @@ -44,6 +44,30 @@ describe('SQL functions', () => { expect(fn.json_array_length(`{"a":[1,2,3,4]}`)).toEqual(0n); }); + test('json_keys', () => { + expect(fn.json_keys(`{"a": 1, "b": "2", "0": "test", "c": {"d": "e"}}`)).toEqual(`["0","a","b","c"]`); + expect(fn.json_keys(`{}`)).toEqual(`[]`); + expect(fn.json_keys(null)).toEqual(null); + expect(fn.json_keys()).toEqual(null); + expect(() => fn.json_keys(`{"a": 1, "a": 2}`)).toThrow(); + expect(() => fn.json_keys(`[1,2,3]`)).toThrow(); + expect(() => fn.json_keys(3)).toThrow(); + }); + + test('json_valid', () => { + expect(fn.json_valid(`{"a": 1, "b": "2", "0": "test", "c": {"d": "e"}}`)).toEqual(1n); + expect(fn.json_valid(`{}`)).toEqual(1n); + expect(fn.json_valid(null)).toEqual(0n); + expect(fn.json_valid()).toEqual(0n); + expect(fn.json_valid(`{"a": 1, "a": 2}`)).toEqual(0n); + expect(fn.json_valid(`[1,2,3]`)).toEqual(1n); + expect(fn.json_valid(3)).toEqual(1n); + expect(fn.json_valid('test')).toEqual(0n); + expect(fn.json_valid('"test"')).toEqual(1n); + expect(fn.json_valid('true')).toEqual(1n); + expect(fn.json_valid('TRUE')).toEqual(0n); + }); + test('typeof', () => { expect(fn.typeof(null)).toEqual('null'); expect(fn.typeof('test')).toEqual('text'); From 093e2b29808b3e30fdc718dab72f4873dfac7ca5 Mon Sep 17 00:00:00 2001 From: Ralf Kistner Date: Thu, 17 Oct 2024 10:15:23 +0200 Subject: [PATCH 2/3] Add substring function. --- packages/sync-rules/src/sql_functions.ts | 56 +++++++++++++++++++ .../sync-rules/test/src/sql_functions.test.ts | 16 ++++++ 2 files changed, 72 insertions(+) diff --git a/packages/sync-rules/src/sql_functions.ts b/packages/sync-rules/src/sql_functions.ts index a6832ae5d..de74b0e5f 100644 --- a/packages/sync-rules/src/sql_functions.ts +++ b/packages/sync-rules/src/sql_functions.ts @@ -70,6 +70,61 @@ const lower: DocumentedSqlFunction = { detail: 'Convert text to lower case' }; +const substring: DocumentedSqlFunction = { + debugName: 'substring', + call(value: SqliteValue, start: SqliteValue, length?: SqliteValue) { + const text = castAsText(value); + if (text == null) { + return null; + } + const startIndex = cast(start, 'integer') as bigint | null; + if (startIndex == null) { + return null; + } + if (length === null) { + // Different from undefined in this case, to match SQLite behavior + return null; + } + const castLength = cast(length ?? null, 'integer') as bigint | null; + let realLength: number; + if (castLength == null) { + // undefined (not specified) + realLength = text.length + 1; // +1 to account for the start = 0 special case + } else { + realLength = Number(castLength); + } + + let realStart = 0; + if (startIndex < 0n) { + realStart = Math.max(0, text.length + Number(startIndex)); + } else if (startIndex == 0n) { + // Weird special case + realStart = 0; + realLength -= 1; + } else { + realStart = Number(startIndex) - 1; + } + + if (realLength < 0) { + // Negative length means we return that many characters _before_ + // the start index. + return text.substring(realStart + realLength, realStart); + } + + return text.substring(realStart, realStart + realLength); + }, + parameters: [ + { name: 'value', type: ExpressionType.TEXT, optional: false }, + { name: 'start', type: ExpressionType.INTEGER, optional: false }, + { name: 'length', type: ExpressionType.INTEGER, optional: true } + ], + getReturnType(args) { + return ExpressionType.TEXT; + }, + detail: 'Compute a substring', + documentation: 'The start index starts at 1. If no length is specified, the remainder of the string is returned.' +}; + const hex: DocumentedSqlFunction = { debugName: 'hex', call(value: SqliteValue) { @@ -415,6 +470,7 @@ const st_y: DocumentedSqlFunction = { export const SQL_FUNCTIONS_NAMED = { upper, lower, + substring, hex, length, base64, diff --git a/packages/sync-rules/test/src/sql_functions.test.ts b/packages/sync-rules/test/src/sql_functions.test.ts index 9c11b9352..1a155d6af 100644 --- a/packages/sync-rules/test/src/sql_functions.test.ts +++ b/packages/sync-rules/test/src/sql_functions.test.ts @@ -125,6 +125,22 @@ describe('SQL functions', () => { expect(fn.lower(Uint8Array.of(0x61, 0x62, 0x43))).toEqual('abc'); }); + test('substring', () => { + expect(fn.substring(null)).toEqual(null); + expect(fn.substring('abc')).toEqual(null); + expect(fn.substring('abcde', 2, 3)).toEqual('bcd'); + expect(fn.substring('abcde', 2)).toEqual('bcde'); + expect(fn.substring('abcde', 2, null)).toEqual(null); + expect(fn.substring('abcde', 0, 1)).toEqual(''); + expect(fn.substring('abcde', 0, 2)).toEqual('a'); + expect(fn.substring('abcde', 1, 2)).toEqual('ab'); + expect(fn.substring('abcde', -2)).toEqual('de'); + expect(fn.substring('abcde', -2, 1)).toEqual('d'); + expect(fn.substring('abcde', 6, -5)).toEqual('abcde'); + expect(fn.substring('abcde', 5, -2)).toEqual('cd'); + expect(fn.substring('2023-06-28 14:12:00.999Z', 1, 10)).toEqual('2023-06-28'); + }); + test('cast', () => { expect(cast(null, 'text')).toEqual(null); expect(cast(null, 'integer')).toEqual(null); From 0f90b028b57c50dbcfe7e19e122043a5be969a00 Mon Sep 17 00:00:00 2001 From: Ralf Kistner Date: Thu, 17 Oct 2024 10:30:46 +0200 Subject: [PATCH 3/3] Add changeset. --- .changeset/thirty-llamas-carry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thirty-llamas-carry.md diff --git a/.changeset/thirty-llamas-carry.md b/.changeset/thirty-llamas-carry.md new file mode 100644 index 000000000..c789a7f26 --- /dev/null +++ b/.changeset/thirty-llamas-carry.md @@ -0,0 +1,5 @@ +--- +'@powersync/service-sync-rules': minor +--- + +Support substring and json_keys functions in sync rules