Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wet-gorillas-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/service-sync-rules': minor
---

Expand supported combinations of the IN operator
2 changes: 1 addition & 1 deletion packages/sync-rules/src/StaticSqlParameterQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export class StaticSqlParameterQuery {

get usesUnauthenticatedRequestParameters(): boolean {
// select where request.parameters() ->> 'include_comments'
const unauthenticatedFilter = this.filter!.usesUnauthenticatedRequestParameters;
const unauthenticatedFilter = this.filter?.usesUnauthenticatedRequestParameters;

// select request.parameters() ->> 'project_id'
const unauthenticatedExtractor =
Expand Down
105 changes: 68 additions & 37 deletions packages/sync-rules/src/sql_filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ExpressionType, TYPE_NONE } from './ExpressionType.js';
import { SqlRuleError } from './errors.js';
import {
BASIC_OPERATORS,
OPERATOR_IN,
OPERATOR_IS_NOT_NULL,
OPERATOR_IS_NULL,
OPERATOR_JSON_EXTRACT_JSON,
Expand Down Expand Up @@ -302,13 +303,18 @@ export class SqlTools {
throw new Error('Unexpected');
}
} else if (op == 'IN') {
// Options:
// static IN static
// parameterValue IN static

if (isRowValueClause(leftFilter) && isRowValueClause(rightFilter)) {
// static1 IN static2
return compileStaticOperator(op, leftFilter, rightFilter);
// Special cases:
// parameterValue IN rowValue
// rowValue IN parameterValue
// All others are handled by standard function composition

const composeType = this.getComposeType(OPERATOR_IN, [leftFilter, rightFilter], [left, right]);
if (composeType.errorClause != null) {
return composeType.errorClause;
} else if (composeType.argsType != null) {
// This is a standard supported configuration, takes precedence over
// the special cases below.
return this.composeFunction(OPERATOR_IN, [leftFilter, rightFilter], [left, right]);
} else if (isParameterValueClause(leftFilter) && isRowValueClause(rightFilter)) {
// token_parameters.value IN table.some_array
// bucket.param IN table.some_array
Expand Down Expand Up @@ -371,7 +377,8 @@ export class SqlTools {
usesUnauthenticatedRequestParameters: rightFilter.usesUnauthenticatedRequestParameters
} satisfies ParameterMatchClause;
} else {
return this.error(`Unsupported usage of IN operator`, expr);
// Not supported, return the error previously computed
return this.error(composeType.error!, composeType.errorExpr);
}
} else if (BASIC_OPERATORS.has(op)) {
const fnImpl = getOperatorFunction(op);
Expand Down Expand Up @@ -634,36 +641,19 @@ export class SqlTools {
* @returns a compiled function clause
*/
composeFunction(fnImpl: SqlFunction, argClauses: CompiledClause[], debugArgExpressions: Expr[]): CompiledClause {
let argsType: 'static' | 'row' | 'param' = 'static';
for (let i = 0; i < argClauses.length; i++) {
const debugArg = debugArgExpressions[i];
const clause = argClauses[i];
if (isClauseError(clause)) {
// Return immediately on error
return clause;
} else if (isStaticValueClause(clause)) {
// argsType unchanged
} else if (isParameterValueClause(clause)) {
if (!this.supports_parameter_expressions) {
return this.error(`Cannot use bucket parameters in expressions`, debugArg);
}
if (argsType == 'static' || argsType == 'param') {
argsType = 'param';
} else {
return this.error(`Cannot use table values and parameters in the same clauses`, debugArg);
}
} else if (isRowValueClause(clause)) {
if (argsType == 'static' || argsType == 'row') {
argsType = 'row';
} else {
return this.error(`Cannot use table values and parameters in the same clauses`, debugArg);
}
} else {
return this.error(`Parameter match clauses cannot be used here`, debugArg);
}
const result = this.getComposeType(fnImpl, argClauses, debugArgExpressions);
if (result.errorClause != null) {
return result.errorClause;
} else if (result.error != null) {
return this.error(result.error, result.errorExpr);
}
const argsType = result.argsType!;

if (argsType == 'row' || argsType == 'static') {
if (argsType == 'static') {
const args = argClauses.map((e) => (e as StaticValueClause).value);
const evaluated = fnImpl.call(...args);
return staticValueClause(evaluated);
} else if (argsType == 'row') {
return {
evaluate: (tables) => {
const args = argClauses.map((e) => (e as RowValueClause).evaluate(tables));
Expand Down Expand Up @@ -705,7 +695,48 @@ export class SqlTools {
}
}

parameterFunction() {}
getComposeType(
fnImpl: SqlFunction,
argClauses: CompiledClause[],
debugArgExpressions: Expr[]
): { argsType?: string; error?: string; errorExpr?: Expr; errorClause?: ClauseError } {
let argsType: 'static' | 'row' | 'param' = 'static';
for (let i = 0; i < argClauses.length; i++) {
const debugArg = debugArgExpressions[i];
const clause = argClauses[i];
if (isClauseError(clause)) {
// Return immediately on error
return { errorClause: clause };
} else if (isStaticValueClause(clause)) {
// argsType unchanged
} else if (isParameterValueClause(clause)) {
if (!this.supports_parameter_expressions) {
if (fnImpl.debugName == 'operatorIN') {
// Special-case error message to be more descriptive
return { error: `Cannot use bucket parameters on the right side of IN operators`, errorExpr: debugArg };
}
return { error: `Cannot use bucket parameters in expressions`, errorExpr: debugArg };
}
if (argsType == 'static' || argsType == 'param') {
argsType = 'param';
} else {
return { error: `Cannot use table values and parameters in the same clauses`, errorExpr: debugArg };
}
} else if (isRowValueClause(clause)) {
if (argsType == 'static' || argsType == 'row') {
argsType = 'row';
} else {
return { error: `Cannot use table values and parameters in the same clauses`, errorExpr: debugArg };
}
} else {
return { error: `Parameter match clauses cannot be used here`, errorExpr: debugArg };
}
}

return {
argsType
};
}
}

function isStatic(expr: Expr) {
Expand Down
4 changes: 3 additions & 1 deletion packages/sync-rules/src/sql_functions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { JSONBig } from '@powersync/service-jsonbig';
import { SQLITE_FALSE, SQLITE_TRUE, sqliteBool, sqliteNot } from './sql_support.js';
import { getOperatorFunction, SQLITE_FALSE, SQLITE_TRUE, sqliteBool, sqliteNot } from './sql_support.js';
import { SqliteValue } from './types.js';
import { jsonValueToSqlite } from './utils.js';
// Declares @syncpoint/wkx module
Expand Down Expand Up @@ -787,6 +787,8 @@ export const OPERATOR_NOT: SqlFunction = {
}
};

export const OPERATOR_IN = getOperatorFunction('IN');

export function castOperator(castTo: string | undefined): SqlFunction | null {
if (castTo == null || !CAST_TYPES.has(castTo)) {
return null;
Expand Down
36 changes: 35 additions & 1 deletion packages/sync-rules/test/src/data_queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,38 @@ describe('data queries', () => {
expect(query.evaluateRow(ASSETS, { id: 'asset1', org_id: null })).toEqual([]);
});

test('static IN data query', function () {
const sql = `SELECT * FROM assets WHERE 'green' IN assets.categories`;
const query = SqlDataQuery.fromSql('mybucket', [], sql);
expect(query.errors).toEqual([]);

expect(query.evaluateRow(ASSETS, { id: 'asset1', categories: JSON.stringify(['red', 'green']) })).toMatchObject([
{
bucket: 'mybucket[]',
table: 'assets',
id: 'asset1'
}
]);

expect(query.evaluateRow(ASSETS, { id: 'asset1', categories: JSON.stringify(['red', 'blue']) })).toEqual([]);
});

test('data IN static query', function () {
const sql = `SELECT * FROM assets WHERE assets.condition IN '["good","great"]'`;
const query = SqlDataQuery.fromSql('mybucket', [], sql);
expect(query.errors).toEqual([]);

expect(query.evaluateRow(ASSETS, { id: 'asset1', condition: 'good' })).toMatchObject([
{
bucket: 'mybucket[]',
table: 'assets',
id: 'asset1'
}
]);

expect(query.evaluateRow(ASSETS, { id: 'asset1', condition: 'bad' })).toEqual([]);
});

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);
Expand Down Expand Up @@ -158,7 +190,9 @@ describe('data queries', () => {
test('invalid query - invalid IN', function () {
const sql = 'SELECT * FROM assets WHERE assets.category IN bucket.categories';
const query = SqlDataQuery.fromSql('mybucket', ['categories'], sql);
expect(query.errors).toMatchObject([{ type: 'fatal', message: 'Unsupported usage of IN operator' }]);
expect(query.errors).toMatchObject([
{ type: 'fatal', message: 'Cannot use bucket parameters on the right side of IN operators' }
]);
});

test('invalid query - not all parameters used', function () {
Expand Down
64 changes: 63 additions & 1 deletion packages/sync-rules/test/src/static_parameter_queries.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { SqlParameterQuery } from '../../src/index.js';
import { RequestParameters, SqlParameterQuery } from '../../src/index.js';
import { StaticSqlParameterQuery } from '../../src/StaticSqlParameterQuery.js';
import { normalizeTokenParameters } from './util.js';

Expand Down Expand Up @@ -82,6 +82,68 @@ describe('static parameter queries', () => {
expect(query.getStaticBucketIds(normalizeTokenParameters({ user_id: 'user1' }))).toEqual(['mybucket["user1"]']);
});

test('static value', function () {
const sql = `SELECT WHERE 1`;
const query = SqlParameterQuery.fromSql('mybucket', sql) as StaticSqlParameterQuery;
expect(query.errors).toEqual([]);
expect(query.getStaticBucketIds(new RequestParameters({ sub: '' }, {}))).toEqual(['mybucket[]']);
});

test('static expression (1)', function () {
const sql = `SELECT WHERE 1 = 1`;
const query = SqlParameterQuery.fromSql('mybucket', sql) as StaticSqlParameterQuery;
expect(query.errors).toEqual([]);
expect(query.getStaticBucketIds(new RequestParameters({ sub: '' }, {}))).toEqual(['mybucket[]']);
});

test('static expression (2)', function () {
const sql = `SELECT WHERE 1 != 1`;
const query = SqlParameterQuery.fromSql('mybucket', sql) as StaticSqlParameterQuery;
expect(query.errors).toEqual([]);
expect(query.getStaticBucketIds(new RequestParameters({ sub: '' }, {}))).toEqual([]);
});

test('static IN expression', function () {
const sql = `SELECT WHERE 'admin' IN '["admin", "superuser"]'`;
const query = SqlParameterQuery.fromSql('mybucket', sql, undefined, {}) as StaticSqlParameterQuery;
expect(query.errors).toEqual([]);
expect(query.getStaticBucketIds(new RequestParameters({ sub: '' }, {}))).toEqual(['mybucket[]']);
});

test('IN for permissions in request.jwt() (1)', function () {
// Can use -> or ->> here
const sql = `SELECT 'read:users' IN (request.jwt() ->> 'permissions') as access_granted`;
const query = SqlParameterQuery.fromSql('mybucket', sql) as StaticSqlParameterQuery;
expect(query.errors).toEqual([]);
expect(
query.getStaticBucketIds(new RequestParameters({ sub: '', permissions: ['write', 'read:users'] }, {}))
).toEqual(['mybucket[1]']);
expect(
query.getStaticBucketIds(new RequestParameters({ sub: '', permissions: ['write', 'write:users'] }, {}))
).toEqual(['mybucket[0]']);
});

test('IN for permissions in request.jwt() (2)', function () {
// Can use -> or ->> here
const sql = `SELECT WHERE 'read:users' IN (request.jwt() ->> 'permissions')`;
const query = SqlParameterQuery.fromSql('mybucket', sql, undefined, {}) as StaticSqlParameterQuery;
expect(query.errors).toEqual([]);
expect(
query.getStaticBucketIds(new RequestParameters({ sub: '', permissions: ['write', 'read:users'] }, {}))
).toEqual(['mybucket[]']);
expect(
query.getStaticBucketIds(new RequestParameters({ sub: '', permissions: ['write', 'write:users'] }, {}))
).toEqual([]);
});

test('IN for permissions in request.jwt() (3)', function () {
const sql = `SELECT WHERE request.jwt() ->> 'role' IN '["admin", "superuser"]'`;
const query = SqlParameterQuery.fromSql('mybucket', sql, undefined, {}) as StaticSqlParameterQuery;
expect(query.errors).toEqual([]);
expect(query.getStaticBucketIds(new RequestParameters({ sub: '', role: 'superuser' }, {}))).toEqual(['mybucket[]']);
expect(query.getStaticBucketIds(new RequestParameters({ sub: '', role: 'superadmin' }, {}))).toEqual([]);
});

test('case-sensitive queries (1)', () => {
const sql = 'SELECT request.user_id() as USER_ID';
const query = SqlParameterQuery.fromSql('mybucket', sql) as SqlParameterQuery;
Expand Down
Loading