diff --git a/.changeset/olive-games-destroy.md b/.changeset/olive-games-destroy.md new file mode 100644 index 000000000..19439f5b0 --- /dev/null +++ b/.changeset/olive-games-destroy.md @@ -0,0 +1,6 @@ +--- +'@powersync/service-sync-rules': minor +'@powersync/service-image': minor +--- + +Add the `fixed_json_extract` compatibility option. When enabled, JSON-extracting operators are updated to match SQLite more closely. diff --git a/packages/sync-rules/src/SqlBucketDescriptor.ts b/packages/sync-rules/src/SqlBucketDescriptor.ts index 46d9670f7..eb9bbed26 100644 --- a/packages/sync-rules/src/SqlBucketDescriptor.ts +++ b/packages/sync-rules/src/SqlBucketDescriptor.ts @@ -65,7 +65,7 @@ export class SqlBucketDescriptor implements BucketSource { if (this.bucketParameters == null) { throw new Error('Bucket parameters must be defined'); } - const dataRows = SqlDataQuery.fromSql(this.name, this.bucketParameters, sql, options); + const dataRows = SqlDataQuery.fromSql(this.name, this.bucketParameters, sql, options, this.compatibility); this.dataQueries.push(dataRows); diff --git a/packages/sync-rules/src/SqlDataQuery.ts b/packages/sync-rules/src/SqlDataQuery.ts index 1c575d6e5..ac4843789 100644 --- a/packages/sync-rules/src/SqlDataQuery.ts +++ b/packages/sync-rules/src/SqlDataQuery.ts @@ -5,20 +5,26 @@ import { SqlRuleError } from './errors.js'; import { ExpressionType } from './ExpressionType.js'; import { SourceTableInterface } from './SourceTableInterface.js'; import { SqlTools } from './sql_filters.js'; -import { castAsText } from './sql_functions.js'; import { checkUnsupportedFeatures, isClauseError } from './sql_support.js'; import { SyncRulesOptions } from './SqlSyncRules.js'; import { TablePattern } from './TablePattern.js'; import { TableQuerySchema } from './TableQuerySchema.js'; import { BucketIdTransformer, EvaluationResult, ParameterMatchClause, QuerySchema, SqliteRow } from './types.js'; import { getBucketId, isSelectStatement } from './utils.js'; +import { CompatibilityContext } from './compatibility.js'; export interface SqlDataQueryOptions extends BaseSqlDataQueryOptions { filter: ParameterMatchClause; } export class SqlDataQuery extends BaseSqlDataQuery { - static fromSql(descriptorName: string, bucketParameters: string[], sql: string, options: SyncRulesOptions) { + static fromSql( + descriptorName: string, + bucketParameters: string[], + sql: string, + options: SyncRulesOptions, + compatibility: CompatibilityContext + ) { const parsed = parse(sql, { locationTracking: true }); const schema = options.schema; @@ -67,6 +73,7 @@ export class SqlDataQuery extends BaseSqlDataQuery { table: alias, parameterTables: ['bucket'], valueTables: [alias], + compatibilityContext: compatibility, sql, schema: querySchema }); diff --git a/packages/sync-rules/src/SqlParameterQuery.ts b/packages/sync-rules/src/SqlParameterQuery.ts index 44fca5504..b8102b7bf 100644 --- a/packages/sync-rules/src/SqlParameterQuery.ts +++ b/packages/sync-rules/src/SqlParameterQuery.ts @@ -123,6 +123,7 @@ export class SqlParameterQuery { sql, supportsExpandingParameters: true, supportsParameterExpressions: true, + compatibilityContext: options.compatibility, schema: querySchema }); tools.checkSpecificNameCase(tableRef); diff --git a/packages/sync-rules/src/StaticSqlParameterQuery.ts b/packages/sync-rules/src/StaticSqlParameterQuery.ts index 57bbc6dfd..33c711f16 100644 --- a/packages/sync-rules/src/StaticSqlParameterQuery.ts +++ b/packages/sync-rules/src/StaticSqlParameterQuery.ts @@ -45,6 +45,7 @@ export class StaticSqlParameterQuery { table: undefined, parameterTables: ['token_parameters', 'user_parameters'], supportsParameterExpressions: true, + compatibilityContext: options.compatibility, sql }); const where = q.where; diff --git a/packages/sync-rules/src/TableValuedFunctionSqlParameterQuery.ts b/packages/sync-rules/src/TableValuedFunctionSqlParameterQuery.ts index 25ca791f1..49597d814 100644 --- a/packages/sync-rules/src/TableValuedFunctionSqlParameterQuery.ts +++ b/packages/sync-rules/src/TableValuedFunctionSqlParameterQuery.ts @@ -1,8 +1,8 @@ -import { FromCall, SelectedColumn, SelectFromStatement } from 'pgsql-ast-parser'; +import { FromCall, SelectFromStatement } from 'pgsql-ast-parser'; import { SqlRuleError } from './errors.js'; import { SqlTools } from './sql_filters.js'; import { checkUnsupportedFeatures, isClauseError, isParameterValueClause, sqliteBool } from './sql_support.js'; -import { TABLE_VALUED_FUNCTIONS, TableValuedFunction } from './TableValuedFunctions.js'; +import { generateTableValuedFunctions, TableValuedFunction } from './TableValuedFunctions.js'; import { BucketIdTransformer, ParameterValueClause, @@ -49,11 +49,13 @@ export class TableValuedFunctionSqlParameterQuery { options: QueryParseOptions, queryId: string ): TableValuedFunctionSqlParameterQuery { + const compatibility = options.compatibility; let errors: SqlRuleError[] = []; errors.push(...checkUnsupportedFeatures(sql, q)); - if (!(call.function.name in TABLE_VALUED_FUNCTIONS)) { + const tableValuedFunctions = generateTableValuedFunctions(compatibility); + if (!(call.function.name in tableValuedFunctions)) { throw new SqlRuleError(`Table-valued function ${call.function.name} is not defined.`, sql, call); } @@ -64,6 +66,7 @@ export class TableValuedFunctionSqlParameterQuery { table: callTable, parameterTables: ['token_parameters', 'user_parameters', callTable], supportsParameterExpressions: true, + compatibilityContext: compatibility, sql }); const where = q.where; @@ -73,7 +76,7 @@ export class TableValuedFunctionSqlParameterQuery { const columns = q.columns ?? []; const bucketParameters = columns.map((column) => tools.getOutputName(column)); - const functionImpl = TABLE_VALUED_FUNCTIONS[call.function.name]!; + const functionImpl = tableValuedFunctions[call.function.name]!; let priority = options.priority; let parameterExtractors: Record = {}; diff --git a/packages/sync-rules/src/TableValuedFunctions.ts b/packages/sync-rules/src/TableValuedFunctions.ts index 5da2ae19b..5031a1db7 100644 --- a/packages/sync-rules/src/TableValuedFunctions.ts +++ b/packages/sync-rules/src/TableValuedFunctions.ts @@ -1,3 +1,4 @@ +import { CompatibilityContext, CompatibilityOption } from './compatibility.js'; import { SqliteJsonValue, SqliteRow, SqliteValue } from './types.js'; import { jsonValueToSqlite } from './utils.js'; @@ -8,38 +9,40 @@ export interface TableValuedFunction { documentation: string; } -export const JSON_EACH: TableValuedFunction = { - name: 'json_each', - call(args: SqliteValue[]) { - if (args.length != 1) { - throw new Error(`json_each expects 1 argument, got ${args.length}`); - } - const valueString = args[0]; - if (valueString === null) { - return []; - } else if (typeof valueString !== 'string') { - throw new Error(`Expected json_each to be called with a string, got ${valueString}`); - } - let values: SqliteJsonValue[] = []; - try { - values = JSON.parse(valueString); - } catch (e) { - throw new Error('Expected JSON string'); - } - if (!Array.isArray(values)) { - throw new Error(`Expected an array, got ${valueString}`); - } +function jsonEachImplementation(fixedJsonBehavior: boolean): TableValuedFunction { + return { + name: 'json_each', + call(args: SqliteValue[]) { + if (args.length != 1) { + throw new Error(`json_each expects 1 argument, got ${args.length}`); + } + const valueString = args[0]; + if (valueString === null) { + return []; + } else if (typeof valueString !== 'string') { + throw new Error(`Expected json_each to be called with a string, got ${valueString}`); + } + let values: SqliteJsonValue[] = []; + try { + values = JSON.parse(valueString); + } catch (e) { + throw new Error('Expected JSON string'); + } + if (!Array.isArray(values)) { + throw new Error(`Expected an array, got ${valueString}`); + } - return values.map((v) => { - return { - value: jsonValueToSqlite(v) - }; - }); - }, - detail: 'Each element of a JSON array', - documentation: 'Returns each element of a JSON array as a separate row.' -}; + return values.map((v) => { + return { + value: jsonValueToSqlite(fixedJsonBehavior, v) + }; + }); + }, + detail: 'Each element of a JSON array', + documentation: 'Returns each element of a JSON array as a separate row.' + }; +} -export const TABLE_VALUED_FUNCTIONS: Record = { - json_each: JSON_EACH -}; +export function generateTableValuedFunctions(compatibility: CompatibilityContext): Record { + return { json_each: jsonEachImplementation(compatibility.isEnabled(CompatibilityOption.fixedJsonExtract)) }; +} diff --git a/packages/sync-rules/src/compatibility.ts b/packages/sync-rules/src/compatibility.ts index e50aa06fc..091365b55 100644 --- a/packages/sync-rules/src/compatibility.ts +++ b/packages/sync-rules/src/compatibility.ts @@ -28,9 +28,16 @@ export class CompatibilityOption { CompatibilityEdition.SYNC_STREAMS ); + static fixedJsonExtract = new CompatibilityOption( + 'fixed_json_extract', + "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 `.`.", + CompatibilityEdition.SYNC_STREAMS + ); + static byName: Record = Object.freeze({ timestamps_iso8601: this.timestampsIso8601, - versioned_bucket_ids: this.versionedBucketIds + versioned_bucket_ids: this.versionedBucketIds, + fixed_json_extract: this.fixedJsonExtract }); } diff --git a/packages/sync-rules/src/events/SqlEventDescriptor.ts b/packages/sync-rules/src/events/SqlEventDescriptor.ts index 827c92d66..6b894a5d6 100644 --- a/packages/sync-rules/src/events/SqlEventDescriptor.ts +++ b/packages/sync-rules/src/events/SqlEventDescriptor.ts @@ -23,7 +23,7 @@ export class SqlEventDescriptor { } addSourceQuery(sql: string, options: SyncRulesOptions): QueryParseResult { - const source = SqlEventSourceQuery.fromSql(this.name, sql, options); + const source = SqlEventSourceQuery.fromSql(this.name, sql, options, this.compatibility); // Each source query should be for a unique table const existingSourceQuery = this.sourceQueries.find((q) => q.table == source.table); diff --git a/packages/sync-rules/src/events/SqlEventSourceQuery.ts b/packages/sync-rules/src/events/SqlEventSourceQuery.ts index 18f828eb7..629067f22 100644 --- a/packages/sync-rules/src/events/SqlEventSourceQuery.ts +++ b/packages/sync-rules/src/events/SqlEventSourceQuery.ts @@ -10,6 +10,7 @@ import { TablePattern } from '../TablePattern.js'; import { TableQuerySchema } from '../TableQuerySchema.js'; import { EvaluationError, QuerySchema, SqliteJsonRow, SqliteRow } from '../types.js'; import { isSelectStatement } from '../utils.js'; +import { CompatibilityContext } from '../compatibility.js'; export type EvaluatedEventSourceRow = { data: SqliteJsonRow; @@ -24,7 +25,7 @@ export type EvaluatedEventRowWithErrors = { * Defines how a Replicated Row is mapped to source parameters for events. */ export class SqlEventSourceQuery extends BaseSqlDataQuery { - static fromSql(descriptor_name: string, sql: string, options: SyncRulesOptions) { + static fromSql(descriptor_name: string, sql: string, options: SyncRulesOptions, compatibility: CompatibilityContext) { const parsed = parse(sql, { locationTracking: true }); const schema = options.schema; @@ -73,7 +74,8 @@ export class SqlEventSourceQuery extends BaseSqlDataQuery { parameterTables: [], valueTables: [alias], sql, - schema: querySchema + schema: querySchema, + compatibilityContext: compatibility }); let extractors: RowValueExtractor[] = []; diff --git a/packages/sync-rules/src/request_functions.ts b/packages/sync-rules/src/request_functions.ts index d1b904625..e9f23cb47 100644 --- a/packages/sync-rules/src/request_functions.ts +++ b/packages/sync-rules/src/request_functions.ts @@ -1,5 +1,6 @@ import { ExpressionType } from './ExpressionType.js'; -import { jsonExtractFromRecord } from './sql_functions.js'; +import { CompatibilityContext, CompatibilityEdition, CompatibilityOption } from './compatibility.js'; +import { generateSqlFunctions } from './sql_functions.js'; import { ParameterValueSet, SqliteValue } from './types.js'; export interface SqlParameterFunction { @@ -15,6 +16,9 @@ export interface SqlParameterFunction { documentation: string; } +const jsonExtractFromRecord = generateSqlFunctions( + new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS) +).jsonExtractFromRecord; /** * Defines a `parameters` function and a `parameter` function. * @@ -50,12 +54,11 @@ export function parameterFunctions(options: { parameterCount: 1, call(parameters: ParameterValueSet, path) { const parsed = options.extractJsonParsed(parameters); + // jsonExtractFromRecord uses the correct behavior of only splitting the path if it starts with $. + // This particular JSON extract function always had that behavior, so we don't need to take backwards + // compatibility into account. if (typeof path == 'string') { - if (path.startsWith('$.')) { - return jsonExtractFromRecord(parsed, path, '->>'); - } else { - return parsed[path]; - } + return jsonExtractFromRecord(parsed, path, '->>'); } return null; diff --git a/packages/sync-rules/src/sql_filters.ts b/packages/sync-rules/src/sql_filters.ts index 2120ff3f8..b9e8b9d90 100644 --- a/packages/sync-rules/src/sql_filters.ts +++ b/packages/sync-rules/src/sql_filters.ts @@ -10,13 +10,11 @@ import { OPERATOR_IN, OPERATOR_IS_NOT_NULL, OPERATOR_IS_NULL, - OPERATOR_JSON_EXTRACT_JSON, - OPERATOR_JSON_EXTRACT_SQL, OPERATOR_NOT, OPERATOR_OVERLAP, - SQL_FUNCTIONS, SqlFunction, castOperator, + generateSqlFunctions, getOperatorFunction, sqliteTypeOf } from './sql_functions.js'; @@ -47,7 +45,7 @@ import { TrueIfParametersMatch } from './types.js'; import { isJsonValue } from './utils.js'; -import { STREAM_FUNCTIONS } from './streams/functions.js'; +import { CompatibilityContext } from './compatibility.js'; export const MATCH_CONST_FALSE: TrueIfParametersMatch = []; export const MATCH_CONST_TRUE: TrueIfParametersMatch = [{}]; @@ -105,6 +103,11 @@ export interface SqlToolsOptions { * Schema for validations. */ schema?: QuerySchema; + + /** + * Context controling how functions should behave if we've made backwards-incompatible change to them. + */ + compatibilityContext: CompatibilityContext; } export class SqlTools { @@ -121,6 +124,8 @@ export class SqlTools { readonly supportsExpandingParameters: boolean; readonly supportsParameterExpressions: boolean; readonly parameterFunctions: Record>; + readonly compatibilityContext: CompatibilityContext; + readonly functions: ReturnType; schema?: QuerySchema; @@ -140,6 +145,9 @@ export class SqlTools { this.supportsExpandingParameters = options.supportsExpandingParameters ?? false; this.supportsParameterExpressions = options.supportsParameterExpressions ?? false; this.parameterFunctions = options.parameterFunctions ?? { request: REQUEST_FUNCTIONS }; + this.compatibilityContext = options.compatibilityContext; + + this.functions = generateSqlFunctions(this.compatibilityContext); } error(message: string, expr: NodeLocation | Expr | undefined): ClauseError { @@ -315,7 +323,7 @@ export class SqlTools { if (schema == null) { // Just fn() - const fnImpl = SQL_FUNCTIONS[fn]; + const fnImpl = this.functions.named[fn]; if (fnImpl == null) { return this.error(`Function '${fn}' is not defined`, expr); } @@ -377,9 +385,9 @@ export class SqlTools { const debugArgs: Expr[] = [expr.operand, expr]; const args: CompiledClause[] = [operand, staticValueClause(expr.member)]; if (expr.op == '->') { - return this.composeFunction(OPERATOR_JSON_EXTRACT_JSON, args, debugArgs); + return this.composeFunction(this.functions.operatorJsonExtractJson, args, debugArgs); } else { - return this.composeFunction(OPERATOR_JSON_EXTRACT_SQL, args, debugArgs); + return this.composeFunction(this.functions.operatorJsonExtractSql, args, debugArgs); } } else if (expr.type == 'cast') { const operand = this.compileClause(expr.operand); diff --git a/packages/sync-rules/src/sql_functions.ts b/packages/sync-rules/src/sql_functions.ts index 34bef6e0f..afebbf646 100644 --- a/packages/sync-rules/src/sql_functions.ts +++ b/packages/sync-rules/src/sql_functions.ts @@ -9,6 +9,7 @@ import wkx from '@syncpoint/wkx'; import { ExpressionType, SqliteType, SqliteValueType, TYPE_INTEGER } from './ExpressionType.js'; import * as uuid from 'uuid'; import { CustomSqliteValue } from './types/custom_sqlite_value.js'; +import { CompatibilityContext, CompatibilityOption } from './compatibility.js'; export const BASIC_OPERATORS = new Set([ '=', @@ -269,48 +270,6 @@ const iif: DocumentedSqlFunction = { detail: 'If x is true then returns y else returns z' }; -const json_extract: DocumentedSqlFunction = { - debugName: 'json_extract', - call(json: SqliteValue, path: SqliteValue) { - return jsonExtract(json, path, 'json_extract'); - }, - parameters: [ - { name: 'json', type: ExpressionType.ANY, optional: false }, - { name: 'path', type: ExpressionType.ANY, optional: false } - ], - getReturnType(args) { - return ExpressionType.ANY_JSON; - }, - detail: 'Extract a JSON property' -}; - -const json_array_length: DocumentedSqlFunction = { - debugName: 'json_array_length', - call(json: SqliteValue, path?: SqliteValue) { - if (path != null) { - json = json_extract.call(json, path); - } - const jsonString = castAsText(json); - if (jsonString == null) { - return null; - } - - const jsonParsed = JSONBig.parse(jsonString); - if (!Array.isArray(jsonParsed)) { - return 0n; - } - return BigInt(jsonParsed.length); - }, - parameters: [ - { name: 'json', type: ExpressionType.ANY, optional: false }, - { name: 'path', type: ExpressionType.ANY, optional: true } - ], - getReturnType(args) { - return ExpressionType.INTEGER; - }, - detail: 'Returns the length of a JSON array' -}; - const json_valid: DocumentedSqlFunction = { debugName: 'json_valid', call(json: SqliteValue) { @@ -522,36 +481,50 @@ const st_y: DocumentedSqlFunction = { detail: 'Get the Y value of a PostGIS point' }; -export const SQL_FUNCTIONS_NAMED = { - upper, - lower, - substring, - hex, - length, - base64, - uuid_blob, - typeof: fn_typeof, - ifnull, - iif, - json_extract, - json_array_length, - json_valid, - json_keys, - unixepoch, - datetime, - st_asgeojson, - st_astext, - st_x, - st_y -}; +export function generateSqlFunctions(compatibility: CompatibilityContext) { + const json = jsonFunctions(compatibility.isEnabled(CompatibilityOption.fixedJsonExtract)); + + const named = { + upper, + lower, + substring, + hex, + length, + base64, + uuid_blob, + typeof: fn_typeof, + ifnull, + iif, + json_extract: json.json_extract, + json_array_length: json.json_array_length, + json_valid, + json_keys, + unixepoch, + datetime, + st_asgeojson, + st_astext, + st_x, + st_y + }; + + type FunctionName = keyof typeof named; -type FunctionName = keyof typeof SQL_FUNCTIONS_NAMED; + const callable = Object.fromEntries(Object.entries(named).map(([name, fn]) => [name, fn.call])) as Record< + FunctionName, + SqlFunction['call'] + >; -export const SQL_FUNCTIONS_CALL = Object.fromEntries( - Object.entries(SQL_FUNCTIONS_NAMED).map(([name, fn]) => [name, fn.call]) -) as Record; + const namedRecord: Record = named; -export const SQL_FUNCTIONS: Record = SQL_FUNCTIONS_NAMED; + return { + named: namedRecord, + callable, + jsonExtract: json.jsonExtract, + jsonExtractFromRecord: json.jsonExtractFromRecord, + operatorJsonExtractJson: json.OPERATOR_JSON_EXTRACT_JSON, + operatorJsonExtractSql: json.OPERATOR_JSON_EXTRACT_SQL + }; +} export const CAST_TYPES = new Set(['text', 'numeric', 'integer', 'real', 'blob']); @@ -871,66 +844,144 @@ function concat(a: SqliteValue, b: SqliteValue): string | null { return aText + bText; } -export function jsonExtract(sourceValue: SqliteValue, path: SqliteValue, operator: string) { - const valueText = castAsText(sourceValue); - if (valueText == null || path == null) { - return null; +/** + * Generates implementations for JSON-extracting functions. + * + * @param fixedJsonExtractBehavior Controls whether we opt-in to the new behavior where a path like `foo.bar` extracts + * a key named `foo.bar` (instead of `foo` and then `bar`, which requires `$.foo.bar` in new versions). + */ +function jsonFunctions(fixedJsonExtractBehavior: boolean) { + function jsonExtract(sourceValue: SqliteValue, path: SqliteValue, operator: string) { + const valueText = castAsText(sourceValue); + if (valueText == null || path == null) { + return null; + } + + let value = JSONBig.parse(valueText) as any; + return jsonExtractFromRecord(value, path, operator); } - let value = JSONBig.parse(valueText) as any; - return jsonExtractFromRecord(value, path, operator); -} + function jsonExtractFromRecord(value: any, path: SqliteValue, operator: string) { + const pathText = castAsText(path); + if (value == null || pathText == null) { + return null; + } -export function jsonExtractFromRecord(value: any, path: SqliteValue, operator: string) { - const pathText = castAsText(path); - if (value == null || pathText == null) { - return null; - } + const isProperJsonPath = pathText.startsWith('$'); + if (operator == 'json_extract' && !isProperJsonPath) { + throw new Error(`JSON path must start with $.`); + } - const components = pathText.split('.'); - if (components[0] == '$') { - components.shift(); - } else if (operator == 'json_extract') { - throw new Error(`JSON path must start with $.`); - } + let components: string[] = []; + if (fixedJsonExtractBehavior) { + if (isProperJsonPath) { + components = pathText.split('.'); + components.shift(); + } else { + components = [pathText]; + } + } else { + components = pathText.split('.'); + if (isProperJsonPath) { + components.shift(); + } + } - for (let c of components) { - if (value == null) { - break; + for (let c of components) { + if (value == null) { + break; + } + value = value[c]; } - value = value[c]; - } - if (operator == '->') { - // -> must always stringify, except when it's null - if (value == null) { - return null; + if (operator == '->') { + if (fixedJsonExtractBehavior) { + // The ?? null is to turn undefined (i.e., key not found) into a null return value. In all other cases, we + // return JSON strings. + return JSONBig.stringify(value) ?? null; + } + + // -> must always stringify, except when it's null + if (value == null) { + return null; + } + return JSONBig.stringify(value); + } else { + // Plain scalar value - simple conversion. + return jsonValueToSqlite(fixedJsonExtractBehavior, value as string | number | bigint | boolean | null); } - return JSONBig.stringify(value); - } else { - // Plain scalar value - simple conversion. - return jsonValueToSqlite(value as string | number | bigint | boolean | null); } -} -export const OPERATOR_JSON_EXTRACT_JSON: SqlFunction = { - debugName: 'operator->', - call(json: SqliteValue, path: SqliteValue) { - return jsonExtract(json, path, '->'); - }, - getReturnType(args) { - return ExpressionType.ANY_JSON; - } -}; + const OPERATOR_JSON_EXTRACT_JSON: SqlFunction = { + debugName: 'operator->', + call(json: SqliteValue, path: SqliteValue) { + return jsonExtract(json, path, '->'); + }, + getReturnType(args) { + return ExpressionType.ANY_JSON; + } + }; -export const OPERATOR_JSON_EXTRACT_SQL: SqlFunction = { - debugName: 'operator->>', - call(json: SqliteValue, path: SqliteValue) { - return jsonExtract(json, path, '->>'); - }, - getReturnType(_args) { - return ExpressionType.ANY_JSON; - } -}; + const OPERATOR_JSON_EXTRACT_SQL: SqlFunction = { + debugName: 'operator->>', + call(json: SqliteValue, path: SqliteValue) { + return jsonExtract(json, path, '->>'); + }, + getReturnType(_args) { + return ExpressionType.ANY_JSON; + } + }; + + const json_extract: DocumentedSqlFunction = { + debugName: 'json_extract', + call(json: SqliteValue, path: SqliteValue) { + return jsonExtract(json, path, 'json_extract'); + }, + parameters: [ + { name: 'json', type: ExpressionType.ANY, optional: false }, + { name: 'path', type: ExpressionType.ANY, optional: false } + ], + getReturnType(args) { + return ExpressionType.ANY_JSON; + }, + detail: 'Extract a JSON property' + }; + + const json_array_length: DocumentedSqlFunction = { + debugName: 'json_array_length', + call(json: SqliteValue, path?: SqliteValue) { + if (path != null) { + json = json_extract.call(json, path); + } + const jsonString = castAsText(json); + if (jsonString == null) { + return null; + } + + const jsonParsed = JSONBig.parse(jsonString); + if (!Array.isArray(jsonParsed)) { + return 0n; + } + return BigInt(jsonParsed.length); + }, + parameters: [ + { name: 'json', type: ExpressionType.ANY, optional: false }, + { name: 'path', type: ExpressionType.ANY, optional: true } + ], + getReturnType(args) { + return ExpressionType.INTEGER; + }, + detail: 'Returns the length of a JSON array' + }; + + return { + OPERATOR_JSON_EXTRACT_JSON, + OPERATOR_JSON_EXTRACT_SQL, + jsonExtract, + jsonExtractFromRecord, + json_extract, + json_array_length + }; +} export const OPERATOR_IS_NULL: SqlFunction = { debugName: 'operator_is_null', diff --git a/packages/sync-rules/src/streams/from_sql.ts b/packages/sync-rules/src/streams/from_sql.ts index e8494a8a4..97a302a12 100644 --- a/packages/sync-rules/src/streams/from_sql.ts +++ b/packages/sync-rules/src/streams/from_sql.ts @@ -41,7 +41,7 @@ import { Statement } from 'pgsql-ast-parser'; import { STREAM_FUNCTIONS } from './functions.js'; -import { CompatibilityContext, CompatibilityEdition } from '../compatibility.js'; +import { CompatibilityEdition } from '../compatibility.js'; export function syncStreamFromSql( descriptorName: string, @@ -90,6 +90,7 @@ class SyncStreamCompiler { sql: this.sql, schema: querySchema, parameterFunctions: STREAM_FUNCTIONS, + compatibilityContext: this.options.compatibility, supportsParameterExpressions: true, supportsExpandingParameters: true // needed for table.column IN (subscription.parameters() -> ...) }); @@ -382,6 +383,7 @@ class SyncStreamCompiler { sql: this.sql, schema: querySchema, supportsParameterExpressions: true, + compatibilityContext: this.options.compatibility, parameterFunctions: STREAM_FUNCTIONS }); tools.checkSpecificNameCase(tableRef); diff --git a/packages/sync-rules/src/utils.ts b/packages/sync-rules/src/utils.ts index 234874858..f509da9c6 100644 --- a/packages/sync-rules/src/utils.ts +++ b/packages/sync-rules/src/utils.ts @@ -56,10 +56,20 @@ export function filterJsonRow(data: SqliteRow): SqliteJsonRow { * * Types specifically not supported in output are `boolean` and `undefined`. */ -export function jsonValueToSqlite(value: null | undefined | string | number | bigint | boolean | any): SqliteValue { +export function jsonValueToSqlite( + fixedJsonBehavior: boolean, + value: null | undefined | string | number | bigint | boolean | any +): SqliteValue { + let isObject = typeof value == 'object'; + if (fixedJsonBehavior) { + // With the fixed json behavior, make json_extract() not represent a null value as 'null' but instead use a SQL NULL + // value. + isObject = isObject && value != null; + } + if (typeof value == 'boolean') { return value ? SQLITE_TRUE : SQLITE_FALSE; - } else if (typeof value == 'object' || Array.isArray(value)) { + } else if (isObject || Array.isArray(value)) { // Objects and arrays must be stringified return JSONBig.stringify(value); } else { diff --git a/packages/sync-rules/test/src/compatibility.test.ts b/packages/sync-rules/test/src/compatibility.test.ts index 0b07b47dc..6390dc65a 100644 --- a/packages/sync-rules/test/src/compatibility.test.ts +++ b/packages/sync-rules/test/src/compatibility.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { SqlSyncRules, DateTimeValue, toSyncRulesValue } from '../../src/index.js'; -import { ASSETS, normalizeQuerierOptions, PARSE_OPTIONS } from './util.js'; +import { ASSETS, identityBucketTransformer, normalizeQuerierOptions, PARSE_OPTIONS } from './util.js'; describe('compatibility options', () => { describe('timestamps', () => { @@ -198,6 +198,58 @@ config: ]); }); + describe('json handling', () => { + const description = JSON.stringify({ foo: { bar: 'baz' } }); + + test('old behavior', () => { + const rules = SqlSyncRules.fromYaml( + ` +bucket_definitions: + a: + data: + - SELECT id, description ->> 'foo.bar' AS "desc" FROM assets + `, + PARSE_OPTIONS + ); + + expect( + rules.evaluateRow({ + sourceTable: ASSETS, + record: { + id: 'id', + description: description + }, + bucketIdTransformer: identityBucketTransformer + }) + ).toStrictEqual([{ bucket: 'a[]', data: { desc: 'baz', id: 'id' }, id: 'id', table: 'assets' }]); + }); + + test('new behavior', () => { + const rules = SqlSyncRules.fromYaml( + ` +bucket_definitions: + a: + data: + - SELECT id, description ->> 'foo.bar' AS "desc" FROM assets +config: + fixed_json_extract: true + `, + PARSE_OPTIONS + ); + + expect( + rules.evaluateRow({ + sourceTable: ASSETS, + record: { + id: 'id', + description: description + }, + bucketIdTransformer: identityBucketTransformer + }) + ).toStrictEqual([{ bucket: 'a[]', data: { desc: null, id: 'id' }, id: 'id', table: 'assets' }]); + }); + }); + test('warning for unknown option', () => { expect(() => { SqlSyncRules.fromYaml( diff --git a/packages/sync-rules/test/src/data_queries.test.ts b/packages/sync-rules/test/src/data_queries.test.ts index 219cbce04..34fba0db3 100644 --- a/packages/sync-rules/test/src/data_queries.test.ts +++ b/packages/sync-rules/test/src/data_queries.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from 'vitest'; -import { ExpressionType, SqlDataQuery, SqlSyncRules } from '../../src/index.js'; +import { CompatibilityContext, ExpressionType, SqlDataQuery, SqlSyncRules } from '../../src/index.js'; import { ASSETS, BASIC_SCHEMA, identityBucketTransformer, PARSE_OPTIONS } from './util.js'; describe('data queries', () => { test('uses bucket id transformer', function () { const sql = 'SELECT * FROM assets WHERE assets.org_id = bucket.org_id'; - const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toEqual([]); expect( @@ -22,7 +22,7 @@ describe('data queries', () => { test('bucket parameters = query', function () { const sql = 'SELECT * FROM assets WHERE assets.org_id = bucket.org_id'; - const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toEqual([]); expect(query.evaluateRow(ASSETS, { id: 'asset1', org_id: 'org1' }, identityBucketTransformer)).toEqual([ @@ -39,7 +39,7 @@ describe('data queries', () => { test('bucket parameters IN query', function () { const sql = 'SELECT * FROM assets WHERE bucket.category IN assets.categories'; - const query = SqlDataQuery.fromSql('mybucket', ['category'], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', ['category'], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toEqual([]); expect( @@ -66,7 +66,7 @@ describe('data queries', () => { test('static IN data query', function () { const sql = `SELECT * FROM assets WHERE 'green' IN assets.categories`; - const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toEqual([]); expect( @@ -94,7 +94,7 @@ describe('data queries', () => { test('data IN static query', function () { const sql = `SELECT * FROM assets WHERE assets.condition IN '["good","great"]'`; - const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toEqual([]); expect(query.evaluateRow(ASSETS, { id: 'asset1', condition: 'good' }, identityBucketTransformer)).toMatchObject([ @@ -110,7 +110,7 @@ describe('data queries', () => { test('table alias', function () { const sql = 'SELECT * FROM assets as others WHERE others.org_id = bucket.org_id'; - const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toEqual([]); expect(query.evaluateRow(ASSETS, { id: 'asset1', org_id: 'org1' }, identityBucketTransformer)).toEqual([ @@ -130,7 +130,8 @@ describe('data queries', () => { 'q1', ['user_id'], `SELECT * FROM assets WHERE owner_id = bucket.user_id`, - PARSE_OPTIONS + PARSE_OPTIONS, + compatibility ); expect(q1.getColumnOutputs(schema)).toEqual([ { @@ -157,7 +158,8 @@ describe('data queries', () => { name ->> '$.attr' as json_value, ifnull(name, 2.0) as maybe_name FROM assets WHERE owner_id = bucket.user_id`, - PARSE_OPTIONS + PARSE_OPTIONS, + compatibility ); expect(q2.getColumnOutputs(schema)).toEqual([ { @@ -182,7 +184,8 @@ describe('data queries', () => { 'q1', ['user_id'], 'SELECT id, name, count FROM assets WHERE owner_id = bucket.user_id', - { ...PARSE_OPTIONS, schema } + { ...PARSE_OPTIONS, schema }, + compatibility ); expect(q1.errors).toEqual([]); @@ -190,7 +193,8 @@ describe('data queries', () => { 'q2', ['user_id'], 'SELECT id, upper(description) as d FROM assets WHERE other_id = bucket.user_id', - { ...PARSE_OPTIONS, schema } + { ...PARSE_OPTIONS, schema }, + compatibility ); expect(q2.errors).toMatchObject([ { @@ -207,7 +211,8 @@ describe('data queries', () => { 'q3', ['user_id'], 'SELECT id, description, * FROM nope WHERE other_id = bucket.user_id', - { ...PARSE_OPTIONS, schema } + { ...PARSE_OPTIONS, schema }, + compatibility ); expect(q3.errors).toMatchObject([ { @@ -216,7 +221,7 @@ describe('data queries', () => { } ]); - const q4 = SqlDataQuery.fromSql('q4', [], 'SELECT * FROM other', { ...PARSE_OPTIONS, schema }); + const q4 = SqlDataQuery.fromSql('q4', [], 'SELECT * FROM other', { ...PARSE_OPTIONS, schema }, compatibility); expect(q4.errors).toMatchObject([ { message: `Query must return an "id" column`, @@ -224,13 +229,19 @@ describe('data queries', () => { } ]); - const q5 = SqlDataQuery.fromSql('q5', [], 'SELECT other_id as id, * FROM other', { ...PARSE_OPTIONS, schema }); + const q5 = SqlDataQuery.fromSql( + 'q5', + [], + 'SELECT other_id as id, * FROM other', + { ...PARSE_OPTIONS, schema }, + compatibility + ); expect(q5.errors).toMatchObject([]); }); test('invalid query - invalid IN', function () { const sql = 'SELECT * FROM assets WHERE assets.category IN bucket.categories'; - const query = SqlDataQuery.fromSql('mybucket', ['categories'], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', ['categories'], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([ { type: 'fatal', message: 'Cannot use bucket parameters on the right side of IN operators' } ]); @@ -238,7 +249,7 @@ describe('data queries', () => { test('invalid query - not all parameters used', function () { const sql = 'SELECT * FROM assets WHERE 1'; - const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([ { type: 'fatal', message: 'Query must cover all bucket parameters. Expected: ["bucket.org_id"] Got: []' } ]); @@ -246,7 +257,7 @@ describe('data queries', () => { test('invalid query - parameter not defined', function () { const sql = 'SELECT * FROM assets WHERE assets.org_id = bucket.org_id'; - const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([ { type: 'fatal', message: 'Query must cover all bucket parameters. Expected: [] Got: ["bucket.org_id"]' } ]); @@ -254,25 +265,25 @@ describe('data queries', () => { test('invalid query - function on parameter (1)', function () { const sql = 'SELECT * FROM assets WHERE assets.org_id = upper(bucket.org_id)'; - const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([{ type: 'fatal', message: 'Cannot use bucket parameters in expressions' }]); }); test('invalid query - function on parameter (2)', function () { const sql = 'SELECT * FROM assets WHERE assets.org_id = upper(bucket.org_id)'; - const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([{ type: 'fatal', message: 'Cannot use bucket parameters in expressions' }]); }); test('invalid query - match clause in select', () => { const sql = 'SELECT id, (bucket.org_id = assets.org_id) as org_matches FROM assets where org_id = bucket.org_id'; - const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', ['org_id'], sql, PARSE_OPTIONS, compatibility); expect(query.errors[0].message).toMatch(/Parameter match expression is not allowed here/); }); test('case-sensitive queries (1)', () => { const sql = 'SELECT * FROM Assets'; - const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([ { message: `Unquoted identifiers are converted to lower-case. Use "Assets" instead.` } ]); @@ -280,7 +291,7 @@ describe('data queries', () => { test('case-sensitive queries (2)', () => { const sql = 'SELECT *, Name FROM assets'; - const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([ { message: `Unquoted identifiers are converted to lower-case. Use "Name" instead.` } ]); @@ -288,7 +299,7 @@ describe('data queries', () => { test('case-sensitive queries (3)', () => { const sql = 'SELECT * FROM assets WHERE Archived = False'; - const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([ { message: `Unquoted identifiers are converted to lower-case. Use "Archived" instead.` } ]); @@ -297,7 +308,7 @@ describe('data queries', () => { test.skip('case-sensitive queries (4)', () => { // Cannot validate table alias yet const sql = 'SELECT * FROM assets as myAssets'; - const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([ { message: `Unquoted identifiers are converted to lower-case. Use "myAssets" instead.` } ]); @@ -306,7 +317,7 @@ describe('data queries', () => { test.skip('case-sensitive queries (5)', () => { // Cannot validate table alias yet const sql = 'SELECT * FROM assets myAssets'; - const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([ { message: `Unquoted identifiers are converted to lower-case. Use "myAssets" instead.` } ]); @@ -315,7 +326,7 @@ describe('data queries', () => { test.skip('case-sensitive queries (6)', () => { // Cannot validate anything with a schema yet const sql = 'SELECT * FROM public.ASSETS'; - const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([ { message: `Unquoted identifiers are converted to lower-case. Use "ASSETS" instead.` } ]); @@ -324,9 +335,11 @@ describe('data queries', () => { test.skip('case-sensitive queries (7)', () => { // Cannot validate schema yet const sql = 'SELECT * FROM PUBLIC.assets'; - const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS); + const query = SqlDataQuery.fromSql('mybucket', [], sql, PARSE_OPTIONS, compatibility); expect(query.errors).toMatchObject([ { message: `Unquoted identifiers are converted to lower-case. Use "PUBLIC" instead.` } ]); }); }); + +const compatibility = CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY; diff --git a/packages/sync-rules/test/src/sql_functions.test.ts b/packages/sync-rules/test/src/sql_functions.test.ts index 6096eec55..9691e4195 100644 --- a/packages/sync-rules/test/src/sql_functions.test.ts +++ b/packages/sync-rules/test/src/sql_functions.test.ts @@ -1,7 +1,15 @@ -import { SQL_FUNCTIONS_CALL, cast, jsonExtract } from '../../src/index.js'; +import { + cast, + CompatibilityContext, + generateSqlFunctions, + CompatibilityOption, + CompatibilityEdition +} from '../../src/index.js'; import { describe, expect, test } from 'vitest'; -const fn = SQL_FUNCTIONS_CALL; +const compatibilityFunctions = generateSqlFunctions(CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY); +const fn = compatibilityFunctions.callable; + describe('SQL functions', () => { test('json extract', () => { expect(fn.json_extract(JSON.stringify({ foo: 'bar' }), '$.foo')).toEqual('bar'); @@ -19,6 +27,8 @@ describe('SQL functions', () => { }); test('->>', () => { + const jsonExtract = compatibilityFunctions.jsonExtract; + expect(jsonExtract(JSON.stringify({ foo: 'bar' }), '$.foo', '->>')).toEqual('bar'); expect(jsonExtract(JSON.stringify({ foo: 42 }), '$.foo', '->>')).toEqual(42n); expect(jsonExtract(`{"foo": 42.0}`, '$.foo', '->>')).toEqual(42.0); @@ -34,11 +44,14 @@ describe('SQL functions', () => { }); test('->', () => { + const jsonExtract = compatibilityFunctions.jsonExtract; + expect(jsonExtract(JSON.stringify({ foo: 'bar' }), '$.foo', '->')).toEqual('"bar"'); expect(jsonExtract(JSON.stringify({ foo: 42 }), '$.foo', '->')).toEqual('42'); expect(jsonExtract(`{"foo": 42.0}`, '$.foo', '->')).toEqual('42.0'); expect(jsonExtract(JSON.stringify({ foo: 'bar' }), 'foo', '->')).toEqual('"bar"'); expect(jsonExtract(JSON.stringify({ foo: 42 }), 'foo', '->')).toEqual('42'); + expect(jsonExtract(JSON.stringify({ foo: 42 }), 'bar', '->')).toBeNull(); expect(jsonExtract(`{"foo": 42.0}`, 'foo', '->')).toEqual('42.0'); expect(jsonExtract(`{"foo": 42.0}`, '$', '->')).toEqual(`{"foo":42.0}`); expect(jsonExtract(`{"foo": true}`, '$.foo', '->')).toEqual('true'); @@ -48,6 +61,16 @@ describe('SQL functions', () => { expect(jsonExtract(`{}`, '$.foo', '->')).toBeNull(); }); + test('fixed json extract', () => { + const { jsonExtract, callable } = generateSqlFunctions(new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS)); + + expect(callable.json_extract(`{"foo": null}`, '$.foo')).toBeNull(); + expect(jsonExtract(`{"foo": null}`, '$.foo', '->>')).toBeNull(); + + expect(jsonExtract(`{"foo": null}`, '$.foo', '->')).toStrictEqual('null'); + expect(jsonExtract(`{"foo": null}`, '$.bar', '->')).toBeNull(); + }); + test('json_array_length', () => { expect(fn.json_array_length(`[1,2,3,4]`)).toEqual(4n); expect(fn.json_array_length(`[1,2,3,4]`, '$')).toEqual(4n); diff --git a/packages/sync-rules/test/src/table_valued_function_queries.test.ts b/packages/sync-rules/test/src/table_valued_function_queries.test.ts index 4edd00e3c..997270381 100644 --- a/packages/sync-rules/test/src/table_valued_function_queries.test.ts +++ b/packages/sync-rules/test/src/table_valued_function_queries.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'vitest'; -import { RequestParameters, SqlParameterQuery } from '../../src/index.js'; +import { + CompatibilityContext, + CompatibilityEdition, + CompatibilityOption, + RequestParameters, + SqlParameterQuery +} from '../../src/index.js'; import { StaticSqlParameterQuery } from '../../src/StaticSqlParameterQuery.js'; import { identityBucketTransformer, PARSE_OPTIONS } from './util.js'; @@ -20,13 +26,45 @@ describe('table-valued function queries', () => { expect( query.getStaticBucketDescriptions( - new RequestParameters({ sub: '' }, { array: [1, 2, 3] }), + new RequestParameters({ sub: '' }, { array: [1, 2, 3, null] }), identityBucketTransformer ) ).toEqual([ { bucket: 'mybucket[1]', priority: 3 }, { bucket: 'mybucket[2]', priority: 3 }, - { bucket: 'mybucket[3]', priority: 3 } + { bucket: 'mybucket[3]', priority: 3 }, + { bucket: 'mybucket["null"]', priority: 3 } + ]); + }); + + test('json_each(array param), fixed json', function () { + const sql = "SELECT json_each.value as v FROM json_each(request.parameters() -> 'array')"; + const query = SqlParameterQuery.fromSql( + 'mybucket', + sql, + { + ...PARSE_OPTIONS, + accept_potentially_dangerous_queries: true, + compatibility: new CompatibilityContext( + CompatibilityEdition.LEGACY, + new Map([[CompatibilityOption.fixedJsonExtract, true]]) + ) + }, + '1' + ) as StaticSqlParameterQuery; + expect(query.errors).toEqual([]); + expect(query.bucketParameters).toEqual(['v']); + + expect( + query.getStaticBucketDescriptions( + new RequestParameters({ sub: '' }, { array: [1, 2, 3, null] }), + identityBucketTransformer + ) + ).toEqual([ + { bucket: 'mybucket[1]', priority: 3 }, + { bucket: 'mybucket[2]', priority: 3 }, + { bucket: 'mybucket[3]', priority: 3 }, + { bucket: 'mybucket[null]', priority: 3 } ]); });