Skip to content

Commit b5fd3f0

Browse files
committed
Implement new parameter syntax
1 parent 9c68c49 commit b5fd3f0

File tree

7 files changed

+223
-109
lines changed

7 files changed

+223
-109
lines changed

packages/sync-rules/src/request_functions.ts

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { ExpressionType } from './ExpressionType.js';
2+
import { jsonExtract, jsonExtractFromRecord } from './sql_functions.js';
23
import { ParameterValueSet, SqliteValue } from './types.js';
34

45
export interface SqlParameterFunction {
56
readonly debugName: string;
6-
call: (parameters: ParameterValueSet) => SqliteValue;
7+
call: (parameters: ParameterValueSet, ...args: SqliteValue[]) => SqliteValue;
78
getReturnType(): ExpressionType;
9+
parameterCount: number;
810
/** request.user_id(), request.jwt(), token_parameters.* */
911
usesAuthenticatedRequestParameters: boolean;
1012
/** request.parameters(), user_parameters.* */
@@ -13,23 +15,83 @@ export interface SqlParameterFunction {
1315
documentation: string;
1416
}
1517

16-
const request_parameters: SqlParameterFunction = {
17-
debugName: 'request.parameters',
18-
call(parameters: ParameterValueSet) {
19-
return parameters.rawUserParameters;
20-
},
21-
getReturnType() {
22-
return ExpressionType.TEXT;
23-
},
24-
detail: 'Unauthenticated request parameters as JSON',
25-
documentation:
26-
'Returns parameters passed by the client as a JSON string. These parameters are not authenticated - any value can be passed in by the client.',
27-
usesAuthenticatedRequestParameters: false,
28-
usesUnauthenticatedRequestParameters: true
29-
};
18+
/**
19+
* Defines a `parameters` function and a `parameter` function.
20+
*
21+
* The `parameters` function extracts a JSON object from the {@link ParameterValueSet} while the `parameter` function
22+
* takes a second argument (a JSON path or a single key) to extract.
23+
*/
24+
export function parameterFunctions(options: {
25+
schema: string;
26+
extractJsonString: (v: ParameterValueSet) => string;
27+
extractJsonParsed: (v: ParameterValueSet) => any;
28+
sourceDescription: string;
29+
sourceDocumentation: string;
30+
usesAuthenticatedRequestParameters: boolean;
31+
usesUnauthenticatedRequestParameters: boolean;
32+
}) {
33+
const allParameters: SqlParameterFunction = {
34+
debugName: `${options.schema}.parameters`,
35+
parameterCount: 0,
36+
call(parameters: ParameterValueSet) {
37+
return options.extractJsonString(parameters);
38+
},
39+
getReturnType() {
40+
return ExpressionType.TEXT;
41+
},
42+
detail: options.sourceDescription,
43+
documentation: `Returns ${options.sourceDocumentation}`,
44+
usesAuthenticatedRequestParameters: options.usesAuthenticatedRequestParameters,
45+
usesUnauthenticatedRequestParameters: options.usesUnauthenticatedRequestParameters
46+
};
47+
48+
const extractParameter: SqlParameterFunction = {
49+
debugName: `${options.schema}.parameter`,
50+
parameterCount: 1,
51+
call(parameters: ParameterValueSet, path) {
52+
const parsed = options.extractJsonParsed(parameters);
53+
if (typeof path == 'string') {
54+
if (path.startsWith('$.')) {
55+
return jsonExtractFromRecord(parsed, path, '->>');
56+
} else {
57+
return parsed[path];
58+
}
59+
}
60+
61+
return null;
62+
},
63+
getReturnType() {
64+
return ExpressionType.ANY;
65+
},
66+
detail: `Extract value from ${options.sourceDescription}`,
67+
documentation: `Returns an extracted value (via the key as the second argument) from ${options.sourceDocumentation}`,
68+
usesAuthenticatedRequestParameters: options.usesAuthenticatedRequestParameters,
69+
usesUnauthenticatedRequestParameters: options.usesUnauthenticatedRequestParameters
70+
};
71+
72+
return { parameters: allParameters, parameter: extractParameter };
73+
}
74+
75+
export function globalRequestParameterFunctions(schema: string) {
76+
return parameterFunctions({
77+
schema,
78+
extractJsonString: function (v: ParameterValueSet): string {
79+
return v.rawUserParameters;
80+
},
81+
extractJsonParsed: function (v: ParameterValueSet) {
82+
return v.userParameters;
83+
},
84+
sourceDescription: 'Unauthenticated request parameters as JSON',
85+
sourceDocumentation:
86+
'parameters passed by the client as a JSON string. These parameters are not authenticated - any value can be passed in by the client.',
87+
usesAuthenticatedRequestParameters: false,
88+
usesUnauthenticatedRequestParameters: true
89+
});
90+
}
3091

3192
export const request_jwt: SqlParameterFunction = {
3293
debugName: 'request.jwt',
94+
parameterCount: 0,
3395
call(parameters: ParameterValueSet) {
3496
return parameters.rawTokenPayload;
3597
},
@@ -44,6 +106,7 @@ export const request_jwt: SqlParameterFunction = {
44106

45107
export const request_user_id: SqlParameterFunction = {
46108
debugName: 'request.user_id',
109+
parameterCount: 0,
47110
call(parameters: ParameterValueSet) {
48111
return parameters.userId;
49112
},
@@ -56,8 +119,8 @@ export const request_user_id: SqlParameterFunction = {
56119
usesUnauthenticatedRequestParameters: false
57120
};
58121

59-
export const REQUEST_FUNCTIONS_NAMED = {
60-
parameters: request_parameters,
122+
const REQUEST_FUNCTIONS_NAMED = {
123+
...globalRequestParameterFunctions('request'),
61124
jwt: request_jwt,
62125
user_id: request_user_id
63126
};

packages/sync-rules/src/sql_filters.ts

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { nil } from 'pgsql-ast-parser/src/utils.js';
44
import { BucketPriority, isValidPriority } from './BucketDescription.js';
55
import { ExpressionType } from './ExpressionType.js';
66
import { SqlRuleError } from './errors.js';
7-
import { REQUEST_FUNCTIONS } from './request_functions.js';
7+
import { REQUEST_FUNCTIONS, SqlParameterFunction } from './request_functions.js';
88
import {
99
BASIC_OPERATORS,
1010
OPERATOR_IN,
@@ -96,7 +96,10 @@ export interface SqlToolsOptions {
9696
*/
9797
supportsParameterExpressions?: boolean;
9898

99-
isStream?: boolean;
99+
/**
100+
* For each schema, all available parameter functions.
101+
*/
102+
parameterFunctions?: Record<string, Record<string, SqlParameterFunction>>;
100103

101104
/**
102105
* Schema for validations.
@@ -117,7 +120,7 @@ export class SqlTools {
117120

118121
readonly supportsExpandingParameters: boolean;
119122
readonly supportsParameterExpressions: boolean;
120-
readonly isSyncStream: boolean;
123+
readonly parameterFunctions: Record<string, Record<string, SqlParameterFunction>>;
121124

122125
schema?: QuerySchema;
123126

@@ -136,7 +139,7 @@ export class SqlTools {
136139
this.sql = options.sql;
137140
this.supportsExpandingParameters = options.supportsExpandingParameters ?? false;
138141
this.supportsParameterExpressions = options.supportsParameterExpressions ?? false;
139-
this.isSyncStream = options.isStream ?? false;
142+
this.parameterFunctions = options.parameterFunctions ?? { request: REQUEST_FUNCTIONS };
140143
}
141144

142145
error(message: string, expr: NodeLocation | Expr | undefined): ClauseError {
@@ -309,6 +312,7 @@ export class SqlTools {
309312
} else if (expr.type == 'call' && expr.function?.name != null) {
310313
const schema = expr.function.schema; // schema.function()
311314
const fn = expr.function.name;
315+
312316
if (schema == null) {
313317
// Just fn()
314318
const fnImpl = SQL_FUNCTIONS[fn];
@@ -319,36 +323,41 @@ export class SqlTools {
319323
const argClauses = expr.args.map((arg) => this.compileClause(arg));
320324
const composed = this.composeFunction(fnImpl, argClauses, expr.args);
321325
return composed;
322-
} else if (schema == 'request' && !this.isSyncStream) {
323-
// Special function
326+
} else if (schema in this.parameterFunctions) {
324327
if (!this.supportsParameterExpressions) {
325328
return this.error(`${schema} schema is not available in data queries`, expr);
326329
}
327330

328-
if (expr.args.length > 0) {
329-
return this.error(`Function '${schema}.${fn}' does not take arguments`, expr);
330-
}
331+
const impl = this.parameterFunctions[schema][fn];
331332

332-
if (fn in REQUEST_FUNCTIONS) {
333-
const fnImpl = REQUEST_FUNCTIONS[fn];
334-
return {
335-
key: 'request.parameters()',
336-
lookupParameterValue(parameters) {
337-
return fnImpl.call(parameters);
338-
},
339-
usesAuthenticatedRequestParameters: fnImpl.usesAuthenticatedRequestParameters,
340-
usesUnauthenticatedRequestParameters: fnImpl.usesUnauthenticatedRequestParameters
341-
} satisfies ParameterValueClause;
342-
} else {
343-
return this.error(`Function '${schema}.${fn}' is not defined`, expr);
344-
}
345-
} else if (this.isSyncStream && schema in STREAM_FUNCTIONS) {
346-
const impl = STREAM_FUNCTIONS[schema][fn];
347333
if (impl) {
334+
if (expr.args.length != impl.parameterCount) {
335+
return this.error(`Function '${schema}.${fn}' takes ${impl.parameterCount} arguments.`, expr);
336+
}
337+
338+
const compiledArguments = expr.args.map(this.compileClause);
339+
let hasInvalidArgument = false;
340+
for (let i = 0; i < expr.args.length; i++) {
341+
const argument = compiledArguments[i];
342+
343+
if (!isParameterValueClause(argument)) {
344+
hasInvalidArgument = true;
345+
if (!isClauseError(argument)) {
346+
this.error('Must only depend on data derived from request.', expr.args[i]);
347+
}
348+
}
349+
}
350+
351+
if (hasInvalidArgument) {
352+
return { error: true };
353+
}
354+
355+
const parameterArguments = compiledArguments as ParameterValueClause[];
348356
return {
349-
key: `${schema}.${fn}()`,
357+
key: `${schema}.${fn}(${parameterArguments.map((p) => p.key).join(',')})`,
350358
lookupParameterValue(parameters) {
351-
return impl.call(parameters);
359+
const evaluatedArgs = parameterArguments.map((p) => p.lookupParameterValue(parameters));
360+
return impl.call(parameters, ...evaluatedArgs);
352361
},
353362
usesAuthenticatedRequestParameters: impl.usesAuthenticatedRequestParameters,
354363
usesUnauthenticatedRequestParameters: impl.usesUnauthenticatedRequestParameters

packages/sync-rules/src/sql_functions.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { JSONBig } from '@powersync/service-jsonbig';
22
import { SQLITE_FALSE, SQLITE_TRUE, sqliteBool, sqliteNot } from './sql_support.js';
3-
import { SqliteValue } from './types.js';
3+
import { SqliteRow, SqliteValue } from './types.js';
44
import { jsonValueToSqlite } from './utils.js';
55
// Declares @syncpoint/wkx module
66
// This allows for consumers of this lib to resolve types correctly
@@ -870,8 +870,17 @@ function concat(a: SqliteValue, b: SqliteValue): string | null {
870870

871871
export function jsonExtract(sourceValue: SqliteValue, path: SqliteValue, operator: string) {
872872
const valueText = castAsText(sourceValue);
873+
if (valueText == null || path == null) {
874+
return null;
875+
}
876+
877+
let value = JSONBig.parse(valueText) as any;
878+
return jsonExtractFromRecord(value, path, operator);
879+
}
880+
881+
export function jsonExtractFromRecord(value: any, path: SqliteValue, operator: string) {
873882
const pathText = castAsText(path);
874-
if (valueText == null || pathText == null) {
883+
if (value == null || pathText == null) {
875884
return null;
876885
}
877886

@@ -882,7 +891,6 @@ export function jsonExtract(sourceValue: SqliteValue, path: SqliteValue, operato
882891
throw new Error(`JSON path must start with $.`);
883892
}
884893

885-
let value = JSONBig.parse(valueText) as any;
886894
for (let c of components) {
887895
if (value == null) {
888896
break;

packages/sync-rules/src/streams/from_sql.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
SelectStatement,
4141
Statement
4242
} from 'pgsql-ast-parser';
43+
import { STREAM_FUNCTIONS } from './functions.js';
4344

4445
export function syncStreamFromSql(
4546
descriptorName: string,
@@ -80,9 +81,9 @@ class SyncStreamCompiler {
8081
valueTables: [alias],
8182
sql: this.sql,
8283
schema: querySchema,
84+
parameterFunctions: STREAM_FUNCTIONS,
8385
supportsParameterExpressions: true,
84-
supportsExpandingParameters: true, // needed for table.column IN (subscription.parameters() -> ...)
85-
isStream: true
86+
supportsExpandingParameters: true // needed for table.column IN (subscription.parameters() -> ...)
8687
});
8788
tools.checkSpecificNameCase(tableRef);
8889
let filter = this.whereClauseToFilters(tools, query.where);
@@ -372,7 +373,7 @@ class SyncStreamCompiler {
372373
sql: this.sql,
373374
schema: querySchema,
374375
supportsParameterExpressions: true,
375-
isStream: true
376+
parameterFunctions: STREAM_FUNCTIONS
376377
});
377378
tools.checkSpecificNameCase(tableRef);
378379

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,45 @@
1-
import { ExpressionType } from '../ExpressionType.js';
2-
import { request_jwt, request_user_id, SqlParameterFunction } from '../request_functions.js';
1+
import {
2+
globalRequestParameterFunctions,
3+
parameterFunctions,
4+
request_user_id,
5+
SqlParameterFunction
6+
} from '../request_functions.js';
37
import { ParameterValueSet } from '../types.js';
48

5-
const subscription_parameters: SqlParameterFunction = {
6-
debugName: 'subscription.parameters',
7-
call(parameters: ParameterValueSet) {
8-
return parameters.rawStreamParameters;
9-
},
10-
getReturnType() {
11-
return ExpressionType.TEXT;
12-
},
13-
detail: 'Unauthenticated subscription parameters as JSON',
14-
documentation:
15-
'Returns parameters passed by the client for this stream as a JSON string. These parameters are not authenticated - any value can be passed in by the client.',
16-
usesAuthenticatedRequestParameters: false,
17-
usesUnauthenticatedRequestParameters: true
18-
};
19-
20-
const connection_parameters: SqlParameterFunction = {
21-
debugName: 'connection.parameters',
22-
call(parameters: ParameterValueSet) {
23-
return parameters.rawUserParameters;
24-
},
25-
getReturnType() {
26-
return ExpressionType.TEXT;
27-
},
28-
detail: 'Unauthenticated connection parameters as JSON',
29-
documentation:
30-
'Returns parameters passed by the client as a JSON string. These parameters are not authenticated - any value can be passed in by the client.',
31-
usesAuthenticatedRequestParameters: false,
32-
usesUnauthenticatedRequestParameters: true
33-
};
34-
359
export const STREAM_FUNCTIONS: Record<string, Record<string, SqlParameterFunction>> = {
3610
subscription: {
37-
parameters: subscription_parameters
11+
...parameterFunctions({
12+
schema: 'subscription',
13+
extractJsonString: function (v: ParameterValueSet): string {
14+
return v.rawStreamParameters ?? '{}';
15+
},
16+
extractJsonParsed: function (v: ParameterValueSet) {
17+
return v.streamParameters ?? {};
18+
},
19+
sourceDescription: 'Unauthenticated subscription parameters as JSON',
20+
sourceDocumentation:
21+
'parameters passed by the client for this stream as a JSON string. These parameters are not authenticated - any value can be passed in by the client.',
22+
usesAuthenticatedRequestParameters: false,
23+
usesUnauthenticatedRequestParameters: true
24+
})
3825
},
3926
connection: {
40-
parameters: connection_parameters
27+
...globalRequestParameterFunctions('connection')
4128
},
42-
token: {
29+
auth: {
4330
user_id: request_user_id,
44-
jwt: request_jwt
31+
...parameterFunctions({
32+
schema: 'auth',
33+
extractJsonString: function (v: ParameterValueSet): string {
34+
return v.rawTokenPayload;
35+
},
36+
extractJsonParsed: function (v: ParameterValueSet) {
37+
return v.tokenParameters;
38+
},
39+
sourceDescription: 'JWT payload as JSON',
40+
sourceDocumentation: 'JWT payload as a JSON string. This is always validated against trusted keys',
41+
usesAuthenticatedRequestParameters: true,
42+
usesUnauthenticatedRequestParameters: false
43+
})
4544
}
4645
};

0 commit comments

Comments
 (0)