diff --git a/packages/sql-to-rest/src/processor.ts b/packages/sql-to-rest/src/processor.ts index a8129bde1bb..275bdcf45dd 100644 --- a/packages/sql-to-rest/src/processor.ts +++ b/packages/sql-to-rest/src/processor.ts @@ -1,6 +1,7 @@ import { parseQuery } from 'libpg-query' import { ParsingError, UnimplementedError, UnsupportedError } from './errors' import { + A_Const, A_Expr, ColumnRef, Field, @@ -55,22 +56,22 @@ export type NeqColumnFilter = BaseColumnFilter & { export type GtColumnFilter = BaseColumnFilter & { operator: 'gt' - value: number + value: string | number } export type GteColumnFilter = BaseColumnFilter & { operator: 'gte' - value: number + value: string | number } export type LtColumnFilter = BaseColumnFilter & { operator: 'lt' - value: number + value: string | number } export type LteColumnFilter = BaseColumnFilter & { operator: 'lte' - value: number + value: string | number } export type LikeColumnFilter = BaseColumnFilter & { @@ -98,6 +99,11 @@ export type IsColumnFilter = BaseColumnFilter & { value: null } +export type InColumnFilter = BaseColumnFilter & { + operator: 'in' + value: (string | number)[] +} + export type ColumnFilter = | EqColumnFilter | NeqColumnFilter @@ -110,6 +116,7 @@ export type ColumnFilter = | MatchColumnFilter | ImatchColumnFilter | IsColumnFilter + | InColumnFilter export type LogicalFilter = BaseFilter & { type: 'logical' @@ -655,34 +662,75 @@ function processWhereClause(expression: WhereExpression): Filter { throw new UnsupportedError(`Non column values not supported in WHERE clause`) } + const kind = expression.A_Expr.kind const [name] = expression.A_Expr.name const operatorSymbol = name.String.sval - const operator = mapOperatorSymbol(operatorSymbol) + const operator = mapOperatorSymbol(kind, operatorSymbol) - if (!('A_Const' in expression.A_Expr.rexpr)) { - throw new UnsupportedError(`Expected right side of WHERE clause expression to be a constant`) - } + if ( + operator === 'eq' || + operator === 'neq' || + operator === 'gt' || + operator === 'gte' || + operator === 'lt' || + operator === 'lte' + ) { + if (!('A_Const' in expression.A_Expr.rexpr)) { + throw new UnsupportedError( + `Expected right side of WHERE clause '${operatorSymbol}' expression to be a constant` + ) + } - let value: any + const value = parseConstant(expression.A_Expr.rexpr) + return { + type: 'column', + column, + operator, + negate: false, + value, + } + } else if ( + operator === 'like' || + operator === 'ilike' || + operator === 'match' || + operator === 'imatch' + ) { + if (!('A_Const' in expression.A_Expr.rexpr) || !('sval' in expression.A_Expr.rexpr.A_Const)) { + throw new UnsupportedError( + `Expected right side of WHERE clause '${operator}' expression to be a string constant` + ) + } - if ('sval' in expression.A_Expr.rexpr.A_Const) { - value = expression.A_Expr.rexpr.A_Const.sval.sval - } else if ('ival' in expression.A_Expr.rexpr.A_Const) { - value = expression.A_Expr.rexpr.A_Const.ival.ival - } else if ('fval' in expression.A_Expr.rexpr.A_Const) { - value = parseFloat(expression.A_Expr.rexpr.A_Const.fval.fval) + const value = expression.A_Expr.rexpr.A_Const.sval.sval + + return { + type: 'column', + column, + operator, + negate: false, + value, + } + } else if (operator === 'in') { + if ( + !('List' in expression.A_Expr.rexpr) || + !expression.A_Expr.rexpr.List.items.every((item) => 'A_Const' in item) + ) { + throw new UnsupportedError( + `Expected right side of WHERE clause '${operator}' expression to be a list of constants` + ) + } + + const value = expression.A_Expr.rexpr.List.items.map((item) => parseConstant(item)) + + return { + type: 'column', + column, + operator, + negate: false, + value, + } } else { - throw new UnsupportedError( - `WHERE clause values must be a string (sval), integer (ival), or float (fval)` - ) - } - - return { - type: 'column', - column, - operator, - negate: false, - value, + throw new UnsupportedError(`Unsupported operator '${operator}'`) } } else if ('NullTest' in expression) { const { fields } = expression.NullTest.arg.ColumnRef @@ -849,30 +897,54 @@ function mapJoinType(joinType: string) { } } -function mapOperatorSymbol(operatorSymbol: string) { - switch (operatorSymbol) { - case '=': - return 'eq' - case '<>': - return 'neq' - case '>': - return 'gt' - case '>=': - return 'gte' - case '<': - return 'lt' - case '<=': - return 'lte' - case '~~': - return 'like' - case '~~*': - return 'ilike' - case '~': - return 'match' - case '~*': - return 'imatch' - default: - throw new UnsupportedError(`Unsupported operator symbol '${operatorSymbol}'`) +function mapOperatorSymbol(kind: A_Expr['A_Expr']['kind'], operatorSymbol: string) { + switch (kind) { + case 'AEXPR_OP': { + switch (operatorSymbol) { + case '=': + return 'eq' + case '<>': + return 'neq' + case '>': + return 'gt' + case '>=': + return 'gte' + case '<': + return 'lt' + case '<=': + return 'lte' + case '~': + return 'match' + case '~*': + return 'imatch' + default: + throw new UnsupportedError(`Unsupported operator '${operatorSymbol}'`) + } + } + case 'AEXPR_LIKE': { + switch (operatorSymbol) { + case '~~': + return 'like' + default: + throw new UnsupportedError(`Unsupported operator '${operatorSymbol}'`) + } + } + case 'AEXPR_ILIKE': { + switch (operatorSymbol) { + case '~~*': + return 'ilike' + default: + throw new UnsupportedError(`Unsupported operator '${operatorSymbol}'`) + } + } + case 'AEXPR_IN': { + switch (operatorSymbol) { + case '=': + return 'in' + default: + throw new UnsupportedError(`Unsupported operator '${operatorSymbol}'`) + } + } } } @@ -1117,3 +1189,14 @@ function validateGroupClause(groupClause: ColumnRef[], targets: Target[], from: ) } } +function parseConstant(constant: A_Const) { + if ('sval' in constant.A_Const) { + return constant.A_Const.sval.sval + } else if ('ival' in constant.A_Const) { + return constant.A_Const.ival.ival + } else if ('fval' in constant.A_Const) { + return parseFloat(constant.A_Const.fval.fval) + } else { + throw new UnsupportedError(`Constant values must be a string, integer, or float`) + } +} diff --git a/packages/sql-to-rest/src/renderers/http.test.ts b/packages/sql-to-rest/src/renderers/http.test.ts index 6536880dc39..3fbfafc67a3 100644 --- a/packages/sql-to-rest/src/renderers/http.test.ts +++ b/packages/sql-to-rest/src/renderers/http.test.ts @@ -309,6 +309,40 @@ describe('select', () => { expect(fullPath).toBe('/books?description=imatch.%5E[a-z]%2B') }) + test('in operator', async () => { + const sql = stripIndents` + select + * + from + books + where + category in ('fiction', 'sci-fi') + ` + + const statement = await processSql(sql) + const { method, fullPath } = await renderHttp(statement) + + expect(method).toBe('GET') + expect(fullPath).toBe('/books?category=in.(fiction,sci-fi)') + }) + + test('in operator with comma', async () => { + const sql = stripIndents` + select + * + from + books + where + category in ('a,b,c', 'd,e,f') + ` + + const statement = await processSql(sql) + const { method, fullPath } = await renderHttp(statement) + + expect(method).toBe('GET') + expect(fullPath).toBe('/books?category=in.(%22a,b,c%22,%22d,e,f%22)') + }) + test('"and" expression', async () => { const sql = stripIndents` select diff --git a/packages/sql-to-rest/src/renderers/http.ts b/packages/sql-to-rest/src/renderers/http.ts index c3e234f883f..3c67c4da0a3 100644 --- a/packages/sql-to-rest/src/renderers/http.ts +++ b/packages/sql-to-rest/src/renderers/http.ts @@ -94,13 +94,24 @@ function formatSelectFilterRoot(params: URLSearchParams, filter: Filter) { // Column filter, eg. "title=eq.Cheese" if (type === 'column') { - let value = filter.value // Convert '%' to URL-safe '*' if (filter.operator === 'like' || filter.operator === 'ilike') { - value = filter.value.replaceAll('%', '*') + const value = filter.value.replaceAll('%', '*') + params.append(filter.column, `${maybeNot}${filter.operator}.${value}`) + } else if (filter.operator === 'in') { + const value = filter.value + .map((value) => { + // If an 'in' value contains a comma, wrap in double quotes + if (value.toString().includes(',')) { + return `"${value}"` + } + return value + }) + .join(',') + params.append(filter.column, `${maybeNot}${filter.operator}.(${value})`) + } else { + params.append(filter.column, `${maybeNot}${filter.operator}.${filter.value}`) } - - params.append(filter.column, `${maybeNot}${filter.operator}.${value}`) } // Logical operator filter, eg. "or=(title.eq.Cheese,title.eq.Salsa)"" else if (type === 'logical') { diff --git a/packages/sql-to-rest/src/renderers/supabase-js.test.ts b/packages/sql-to-rest/src/renderers/supabase-js.test.ts index bf7fa8df279..9baec355a9b 100644 --- a/packages/sql-to-rest/src/renderers/supabase-js.test.ts +++ b/packages/sql-to-rest/src/renderers/supabase-js.test.ts @@ -322,7 +322,7 @@ describe('select', () => { from books where - description ~ '^[a-zA-Z]+' + description ~ '^[a-zA-Z]+' ` const statement = await processSql(sql) @@ -343,7 +343,7 @@ describe('select', () => { from books where - description ~* '^[a-z]+' + description ~* '^[a-z]+' ` const statement = await processSql(sql) @@ -357,6 +357,27 @@ describe('select', () => { `) }) + test('in operator', async () => { + const sql = stripIndents` + select + * + from + books + where + category in ('fiction', 'sci-fi') + ` + + const statement = await processSql(sql) + const { code } = await renderSupabaseJs(statement) + + expect(code).toBe(stripIndent` + const { data, error } = await supabase + .from('books') + .select() + .in('category', ['fiction', 'sci-fi']) + `) + }) + test('"and" expression', async () => { const sql = stripIndents` select diff --git a/packages/sql-to-rest/src/types/libpg-query.ts b/packages/sql-to-rest/src/types/libpg-query.ts index 089feb7832b..b9729e76de7 100644 --- a/packages/sql-to-rest/src/types/libpg-query.ts +++ b/packages/sql-to-rest/src/types/libpg-query.ts @@ -58,7 +58,7 @@ export type A_Expr = { | 'AEXPR_NOT_BETWEEN_SYM' name: PgString[] lexpr: A_Const | A_Expr | ColumnRef | TypeCast - rexpr: A_Const | A_Expr | ColumnRef | TypeCast + rexpr: A_Const | A_Expr | ColumnRef | TypeCast | List location: number } } @@ -105,6 +105,12 @@ export type TypeCast = { } } +export type List = { + List: { + items: A_Const[] + } +} + export type FuncCall = { FuncCall: { funcname: PgString[]