Skip to content

Commit c127ad2

Browse files
committed
Update JSON functions
1 parent 98c6bef commit c127ad2

17 files changed

+395
-209
lines changed

packages/sync-rules/src/SqlBucketDescriptor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export class SqlBucketDescriptor implements BucketSource {
6565
if (this.bucketParameters == null) {
6666
throw new Error('Bucket parameters must be defined');
6767
}
68-
const dataRows = SqlDataQuery.fromSql(this.name, this.bucketParameters, sql, options);
68+
const dataRows = SqlDataQuery.fromSql(this.name, this.bucketParameters, sql, options, this.compatibility);
6969

7070
this.dataQueries.push(dataRows);
7171

packages/sync-rules/src/SqlDataQuery.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,26 @@ import { SqlRuleError } from './errors.js';
55
import { ExpressionType } from './ExpressionType.js';
66
import { SourceTableInterface } from './SourceTableInterface.js';
77
import { SqlTools } from './sql_filters.js';
8-
import { castAsText } from './sql_functions.js';
98
import { checkUnsupportedFeatures, isClauseError } from './sql_support.js';
109
import { SyncRulesOptions } from './SqlSyncRules.js';
1110
import { TablePattern } from './TablePattern.js';
1211
import { TableQuerySchema } from './TableQuerySchema.js';
1312
import { BucketIdTransformer, EvaluationResult, ParameterMatchClause, QuerySchema, SqliteRow } from './types.js';
1413
import { getBucketId, isSelectStatement } from './utils.js';
14+
import { CompatibilityContext } from './compatibility.js';
1515

1616
export interface SqlDataQueryOptions extends BaseSqlDataQueryOptions {
1717
filter: ParameterMatchClause;
1818
}
1919

2020
export class SqlDataQuery extends BaseSqlDataQuery {
21-
static fromSql(descriptorName: string, bucketParameters: string[], sql: string, options: SyncRulesOptions) {
21+
static fromSql(
22+
descriptorName: string,
23+
bucketParameters: string[],
24+
sql: string,
25+
options: SyncRulesOptions,
26+
compatibility: CompatibilityContext
27+
) {
2228
const parsed = parse(sql, { locationTracking: true });
2329
const schema = options.schema;
2430

@@ -67,6 +73,7 @@ export class SqlDataQuery extends BaseSqlDataQuery {
6773
table: alias,
6874
parameterTables: ['bucket'],
6975
valueTables: [alias],
76+
compatibilityContext: compatibility,
7077
sql,
7178
schema: querySchema
7279
});

packages/sync-rules/src/SqlParameterQuery.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export class SqlParameterQuery {
123123
sql,
124124
supportsExpandingParameters: true,
125125
supportsParameterExpressions: true,
126+
compatibilityContext: options.compatibility,
126127
schema: querySchema
127128
});
128129
tools.checkSpecificNameCase(tableRef);

packages/sync-rules/src/StaticSqlParameterQuery.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export class StaticSqlParameterQuery {
4545
table: undefined,
4646
parameterTables: ['token_parameters', 'user_parameters'],
4747
supportsParameterExpressions: true,
48+
compatibilityContext: options.compatibility,
4849
sql
4950
});
5051
const where = q.where;

packages/sync-rules/src/TableValuedFunctionSqlParameterQuery.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { FromCall, SelectedColumn, SelectFromStatement } from 'pgsql-ast-parser';
1+
import { FromCall, SelectFromStatement } from 'pgsql-ast-parser';
22
import { SqlRuleError } from './errors.js';
33
import { SqlTools } from './sql_filters.js';
44
import { checkUnsupportedFeatures, isClauseError, isParameterValueClause, sqliteBool } from './sql_support.js';
5-
import { TABLE_VALUED_FUNCTIONS, TableValuedFunction } from './TableValuedFunctions.js';
5+
import { generateTableValuedFunctions, TableValuedFunction } from './TableValuedFunctions.js';
66
import {
77
BucketIdTransformer,
88
ParameterValueClause,
@@ -49,11 +49,13 @@ export class TableValuedFunctionSqlParameterQuery {
4949
options: QueryParseOptions,
5050
queryId: string
5151
): TableValuedFunctionSqlParameterQuery {
52+
const compatibility = options.compatibility;
5253
let errors: SqlRuleError[] = [];
5354

5455
errors.push(...checkUnsupportedFeatures(sql, q));
5556

56-
if (!(call.function.name in TABLE_VALUED_FUNCTIONS)) {
57+
const tableValuedFunctions = generateTableValuedFunctions(compatibility);
58+
if (!(call.function.name in tableValuedFunctions)) {
5759
throw new SqlRuleError(`Table-valued function ${call.function.name} is not defined.`, sql, call);
5860
}
5961

@@ -64,6 +66,7 @@ export class TableValuedFunctionSqlParameterQuery {
6466
table: callTable,
6567
parameterTables: ['token_parameters', 'user_parameters', callTable],
6668
supportsParameterExpressions: true,
69+
compatibilityContext: compatibility,
6770
sql
6871
});
6972
const where = q.where;
@@ -73,7 +76,7 @@ export class TableValuedFunctionSqlParameterQuery {
7376
const columns = q.columns ?? [];
7477
const bucketParameters = columns.map((column) => tools.getOutputName(column));
7578

76-
const functionImpl = TABLE_VALUED_FUNCTIONS[call.function.name]!;
79+
const functionImpl = tableValuedFunctions[call.function.name]!;
7780
let priority = options.priority;
7881
let parameterExtractors: Record<string, ParameterValueClause> = {};
7982

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CompatibilityContext, CompatibilityOption } from './compatibility.js';
12
import { SqliteJsonValue, SqliteRow, SqliteValue } from './types.js';
23
import { jsonValueToSqlite } from './utils.js';
34

@@ -8,38 +9,40 @@ export interface TableValuedFunction {
89
documentation: string;
910
}
1011

11-
export const JSON_EACH: TableValuedFunction = {
12-
name: 'json_each',
13-
call(args: SqliteValue[]) {
14-
if (args.length != 1) {
15-
throw new Error(`json_each expects 1 argument, got ${args.length}`);
16-
}
17-
const valueString = args[0];
18-
if (valueString === null) {
19-
return [];
20-
} else if (typeof valueString !== 'string') {
21-
throw new Error(`Expected json_each to be called with a string, got ${valueString}`);
22-
}
23-
let values: SqliteJsonValue[] = [];
24-
try {
25-
values = JSON.parse(valueString);
26-
} catch (e) {
27-
throw new Error('Expected JSON string');
28-
}
29-
if (!Array.isArray(values)) {
30-
throw new Error(`Expected an array, got ${valueString}`);
31-
}
12+
function jsonEachImplementation(fixedJsonBehavior: boolean): TableValuedFunction {
13+
return {
14+
name: 'json_each',
15+
call(args: SqliteValue[]) {
16+
if (args.length != 1) {
17+
throw new Error(`json_each expects 1 argument, got ${args.length}`);
18+
}
19+
const valueString = args[0];
20+
if (valueString === null) {
21+
return [];
22+
} else if (typeof valueString !== 'string') {
23+
throw new Error(`Expected json_each to be called with a string, got ${valueString}`);
24+
}
25+
let values: SqliteJsonValue[] = [];
26+
try {
27+
values = JSON.parse(valueString);
28+
} catch (e) {
29+
throw new Error('Expected JSON string');
30+
}
31+
if (!Array.isArray(values)) {
32+
throw new Error(`Expected an array, got ${valueString}`);
33+
}
3234

33-
return values.map((v) => {
34-
return {
35-
value: jsonValueToSqlite(v)
36-
};
37-
});
38-
},
39-
detail: 'Each element of a JSON array',
40-
documentation: 'Returns each element of a JSON array as a separate row.'
41-
};
35+
return values.map((v) => {
36+
return {
37+
value: jsonValueToSqlite(fixedJsonBehavior, v)
38+
};
39+
});
40+
},
41+
detail: 'Each element of a JSON array',
42+
documentation: 'Returns each element of a JSON array as a separate row.'
43+
};
44+
}
4245

43-
export const TABLE_VALUED_FUNCTIONS: Record<string, TableValuedFunction> = {
44-
json_each: JSON_EACH
45-
};
46+
export function generateTableValuedFunctions(compatibility: CompatibilityContext): Record<string, TableValuedFunction> {
47+
return { json_each: jsonEachImplementation(compatibility.isEnabled(CompatibilityOption.fixedJsonExtract)) };
48+
}

packages/sync-rules/src/compatibility.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,16 @@ export class CompatibilityOption {
2828
CompatibilityEdition.SYNC_STREAMS
2929
);
3030

31+
static fixedJsonExtract = new CompatibilityOption(
32+
'fixed_json_extract',
33+
"Old versions of the sync service used to treat `->> 'foo.bar'` as a two-element JSON path. With this compatibility option enabled, it follows modern SQLite and treats it as a single key. The `$.` prefix would be required to split on `.`.",
34+
CompatibilityEdition.SYNC_STREAMS
35+
);
36+
3137
static byName: Record<string, CompatibilityOption> = Object.freeze({
3238
timestamps_iso8601: this.timestampsIso8601,
33-
versioned_bucket_ids: this.versionedBucketIds
39+
versioned_bucket_ids: this.versionedBucketIds,
40+
fixed_json_extract: this.fixedJsonExtract
3441
});
3542
}
3643

packages/sync-rules/src/events/SqlEventDescriptor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export class SqlEventDescriptor {
2323
}
2424

2525
addSourceQuery(sql: string, options: SyncRulesOptions): QueryParseResult {
26-
const source = SqlEventSourceQuery.fromSql(this.name, sql, options);
26+
const source = SqlEventSourceQuery.fromSql(this.name, sql, options, this.compatibility);
2727

2828
// Each source query should be for a unique table
2929
const existingSourceQuery = this.sourceQueries.find((q) => q.table == source.table);

packages/sync-rules/src/events/SqlEventSourceQuery.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { TablePattern } from '../TablePattern.js';
1010
import { TableQuerySchema } from '../TableQuerySchema.js';
1111
import { EvaluationError, QuerySchema, SqliteJsonRow, SqliteRow } from '../types.js';
1212
import { isSelectStatement } from '../utils.js';
13+
import { CompatibilityContext } from '../compatibility.js';
1314

1415
export type EvaluatedEventSourceRow = {
1516
data: SqliteJsonRow;
@@ -24,7 +25,7 @@ export type EvaluatedEventRowWithErrors = {
2425
* Defines how a Replicated Row is mapped to source parameters for events.
2526
*/
2627
export class SqlEventSourceQuery extends BaseSqlDataQuery {
27-
static fromSql(descriptor_name: string, sql: string, options: SyncRulesOptions) {
28+
static fromSql(descriptor_name: string, sql: string, options: SyncRulesOptions, compatibility: CompatibilityContext) {
2829
const parsed = parse(sql, { locationTracking: true });
2930
const schema = options.schema;
3031

@@ -73,7 +74,8 @@ export class SqlEventSourceQuery extends BaseSqlDataQuery {
7374
parameterTables: [],
7475
valueTables: [alias],
7576
sql,
76-
schema: querySchema
77+
schema: querySchema,
78+
compatibilityContext: compatibility
7779
});
7880

7981
let extractors: RowValueExtractor[] = [];

packages/sync-rules/src/request_functions.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ExpressionType } from './ExpressionType.js';
2-
import { jsonExtractFromRecord } from './sql_functions.js';
2+
import { CompatibilityContext, CompatibilityEdition, CompatibilityOption } from './compatibility.js';
3+
import { generateSqlFunctions } from './sql_functions.js';
34
import { ParameterValueSet, SqliteValue } from './types.js';
45

56
export interface SqlParameterFunction {
@@ -15,6 +16,9 @@ export interface SqlParameterFunction {
1516
documentation: string;
1617
}
1718

19+
const jsonExtractFromRecord = generateSqlFunctions(
20+
new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS)
21+
).jsonExtractFromRecord;
1822
/**
1923
* Defines a `parameters` function and a `parameter` function.
2024
*
@@ -50,12 +54,11 @@ export function parameterFunctions(options: {
5054
parameterCount: 1,
5155
call(parameters: ParameterValueSet, path) {
5256
const parsed = options.extractJsonParsed(parameters);
57+
// jsonExtractFromRecord uses the correct behavior of only splitting the path if it starts with $.
58+
// This particular JSON extract function always had that behavior, so we don't need to take backwards
59+
// compatibility into account.
5360
if (typeof path == 'string') {
54-
if (path.startsWith('$.')) {
55-
return jsonExtractFromRecord(parsed, path, '->>');
56-
} else {
57-
return parsed[path];
58-
}
61+
return jsonExtractFromRecord(parsed, path, '->>');
5962
}
6063

6164
return null;

0 commit comments

Comments
 (0)