Skip to content

Commit

Permalink
Merge pull request #93 from powersync-ja/in-operator-composition
Browse files Browse the repository at this point in the history
Use standard function composition for IN operator
  • Loading branch information
rkistner authored Sep 30, 2024
2 parents d528917 + 0e16938 commit 27efd52
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 41 deletions.
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

0 comments on commit 27efd52

Please sign in to comment.