diff --git a/package-lock.json b/package-lock.json index 7c1abe8e5e9..7e10aef83eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41983,6 +41983,10 @@ "integrity": "sha512-38NNWamo0PKv80Zn8r1iXgatkWEB04CPhun8nEQhEJlYBMSIV4OOt4g3diynjVHJSunNSQqAjKcwmhM0EeLCtg==", "dev": true }, + "node_modules/sql-to-rest": { + "resolved": "packages/sql-to-rest", + "link": true + }, "node_modules/sqlstring": { "version": "2.3.3", "license": "MIT", @@ -47673,6 +47677,47 @@ "version": "0.0.0", "license": "MIT" }, + "packages/sql-to-rest": { + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@gregnr/libpg-query": "^13.4.0-dev.12" + }, + "devDependencies": { + "@babel/core": "^7.23.6", + "@babel/preset-env": "^7.23.6", + "@jest/globals": "^29.7.0", + "@types/common-tags": "^1.8.4", + "babel-jest": "^29.7.0", + "babel-plugin-transform-import-meta": "^2.2.1", + "common-tags": "^1.8.2", + "jest": "^29.7.0", + "sql-formatter": "^15.0.2", + "ts-jest": "^29.1.1", + "tsconfig": "*", + "typescript": "^5.4.3" + } + }, + "packages/sql-to-rest/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "packages/sql-to-rest/node_modules/sql-formatter": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.3.1.tgz", + "integrity": "sha512-L/dqan+Hrt0PpPdCbHcI9bdfOvqaQZR7v5c5SWMJ3bUGQSezK09Mm9q2I3B4iObjaq7FyoldIM+fDSmfzGRXCA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1", + "get-stdin": "=8.0.0", + "nearley": "^2.20.1" + }, + "bin": { + "sql-formatter": "bin/sql-formatter-cli.cjs" + } + }, "packages/tsconfig": { "version": "0.0.0" }, diff --git a/packages/ai-commands/src/__snapshots__/sql.test.ts.snap b/packages/ai-commands/src/__snapshots__/sql.test.ts.snap index ed775186dac..a43165c1f28 100644 --- a/packages/ai-commands/src/__snapshots__/sql.test.ts.snap +++ b/packages/ai-commands/src/__snapshots__/sql.test.ts.snap @@ -42,7 +42,15 @@ exports[`rls chat select policy using table definition 1`] = ` { "checkExpression": undefined, "command": "select", + "roles": [ + "authenticated", + ], "table": "todos", - "usingExpression": "user_id = auth.uid ()", + "usingExpression": " + user_id = ( + select + auth.uid () + ) +", } `; diff --git a/packages/ai-commands/src/sql.edge.ts b/packages/ai-commands/src/sql.edge.ts index fb5164e9bc2..bec0e704559 100644 --- a/packages/ai-commands/src/sql.edge.ts +++ b/packages/ai-commands/src/sql.edge.ts @@ -26,7 +26,7 @@ export async function chatRlsPolicy( { role: 'system', content: stripIndent` - You're an Postgres expert in writing row level security policies. Your purpose is to + You're a Supabase Postgres expert in writing row level security policies. Your purpose is to generate a policy with the constraints given by the user. You will be provided a schema on which the policy should be applied. @@ -48,6 +48,99 @@ export async function chatRlsPolicy( \`\`\`sql CREATE POLICY "My descriptive policy." ON users FOR INSERT USING (user_name = current_user) WITH (true); \`\`\` + + Here are some performance recommendations: + + ### Add indexes + + Make sure you've added [indexes](/docs/guides/database/postgres/indexes) on any columns used within the Policies which are not already indexed (or primary keys). For a Policy like this: + + \`\`\`sql + create policy "rls_test_select" on test_table + to authenticated + using ( (select auth.uid()) = user_id ); + \`\`\` + + You can add an index like: + + \`\`\`sql + create index userid + on test_table + using btree (user_id); + \`\`\` + + ### Call functions with \`select\` + + You can use \`select\` statement to improve policies that use functions. For example, instead of this: + + \`\`\`sql + create policy "rls_test_select" on test_table + to authenticated + using ( auth.uid() = user_id ); + \`\`\` + + You can do: + + \`\`\`sql + create policy "rls_test_select" on test_table + to authenticated + using ( (select auth.uid()) = user_id ); + \`\`\` + + This method works well for JWT functions like \`auth.uid()\` and \`auth.jwt()\` as well as \`security definer\` Functions. Wrapping the function causes an \`initPlan\` to be run by the Postgres optimizer, which allows it to "cache" the results per-statement, rather than calling the function on each row. + + > Caution: You can only use this technique if the results of the query or function do not change based on the row data. + + ### Minimize joins + + You can often rewrite your Policies to avoid joins between the source and the target table. Instead, try to organize your policy to fetch all the relevant data from the target table into an array or set, then you can use an \`IN\` or \`ANY\` operation in your filter. + + For example, this is an example of a slow policy which joins the source \`test_table\` to the target \`team_user\`: + + \`\`\`sql + create policy "rls_test_select" on test_table + to authenticated + using ( + (select auth.uid()) in ( + select user_id + from team_user + where team_user.team_id = team_id -- joins to the source "test_table.team_id" + ) + ); + \`\`\` + + We can rewrite this to avoid this join, and instead select the filter criteria into a set: + + \`\`\`sql + create policy "rls_test_select" on test_table + to authenticated + using ( + team_id in ( + select team_id + from team_user + where user_id = (select auth.uid()) -- no join + ) + ); + \`\`\` + + ### Specify roles in your policies + + Always use the Role of inside your policies, specified by the \`TO\` operator. For example, instead of this query: + + \`\`\`sql + create policy "rls_test_select" on rls_test + using ( auth.uid() = user_id ); + \`\`\` + + Use: + + \`\`\`sql + create policy "rls_test_select" on rls_test + to authenticated + using ( (select auth.uid()) = user_id ); + \`\`\` + + This prevents the policy \`( (select auth.uid()) = user_id )\` from running for any \`anon\` users, since the execution stops at the \`to authenticated\` step. `, }, ] diff --git a/packages/ai-commands/src/sql.test.ts b/packages/ai-commands/src/sql.test.ts index a6f612229aa..f136c789ad8 100644 --- a/packages/ai-commands/src/sql.test.ts +++ b/packages/ai-commands/src/sql.test.ts @@ -127,7 +127,7 @@ describe('rls chat', () => { expect(otherInfo).toMatchSnapshot() await expect(name).toMatchCriteria( - 'policy says that users can select their own todos (not insert, update, delete, etc)' + 'policy says that users can select their own todos (vs. insert, update, delete, etc)' ) }) }) diff --git a/packages/ai-commands/test-plugin.js b/packages/ai-commands/test-plugin.js new file mode 100644 index 00000000000..f4da33c7957 --- /dev/null +++ b/packages/ai-commands/test-plugin.js @@ -0,0 +1,39 @@ +module.exports = function ({ types: t }) { + console.log('got here 2') + return { + visitor: { + FunctionDeclaration(path) { + // Check if there's a comment with "@static-build" directly above the function + if ( + path.node.leadingComments && + path.node.leadingComments.some((comment) => comment.value.includes('@static-build')) + ) { + console.log('in plugin 2', path.toString()) + try { + // Assuming the function is synchronous for simplicity + const result = eval(`(${path.toString()})()`) + + // Replace the original function with a function returning the cached result + path.replaceWith( + t.functionDeclaration( + path.node.id, + [], + t.blockStatement([t.returnStatement(t.valueToNode(result))]), + path.node.generator, + path.node.async + ) + ) + + // Filter out the '@static-build' comment to prevent re-processing + path.node.leadingComments = path.node.leadingComments.filter( + (comment) => !comment.value.includes('@static-build') + ) + } catch (error) { + console.error('Error executing static function:', error) + } + } + }, + }, + } +} +console.log('got here') diff --git a/packages/ai-commands/test/util.ts b/packages/ai-commands/test/util.ts index d6193bacf55..eacb0502adc 100644 --- a/packages/ai-commands/test/util.ts +++ b/packages/ai-commands/test/util.ts @@ -12,6 +12,7 @@ declare global { export type PolicyInfo = { name: string table: string + roles: string[] command?: string usingExpression?: string checkExpression?: string @@ -75,6 +76,11 @@ export async function getPolicyInfo(sql: string) { const createPolicyStatement = stmt.CreatePolicyStmt + const roles = createPolicyStatement.roles + .map(({ RoleSpec }: any) => RoleSpec) + .filter(({ roletype }: any) => roletype === 'ROLESPEC_CSTRING') + .map(({ rolename }: any) => rolename) + if (!createPolicyStatement) { throw new Error('Expected a create policy statement') } @@ -96,6 +102,7 @@ export async function getPolicyInfo(sql: string) { const policyInfo: PolicyInfo = { name: createPolicyStatement.policy_name, table: createPolicyStatement.table.relname, + roles, command: createPolicyStatement.cmd_name, usingExpression, checkExpression, diff --git a/packages/sql-to-rest/babel.config.js b/packages/sql-to-rest/babel.config.js new file mode 100644 index 00000000000..7a52c7fe867 --- /dev/null +++ b/packages/sql-to-rest/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + presets: [['@babel/preset-env', { targets: { node: 'current' } }]], + plugins: ['babel-plugin-transform-import-meta'], +} diff --git a/packages/sql-to-rest/jest.config.js b/packages/sql-to-rest/jest.config.js new file mode 100644 index 00000000000..82b50725670 --- /dev/null +++ b/packages/sql-to-rest/jest.config.js @@ -0,0 +1,9 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.ts?$': 'ts-jest', + '^.+\\.(js|jsx)$': 'babel-jest', + }, +} diff --git a/packages/sql-to-rest/package.json b/packages/sql-to-rest/package.json new file mode 100644 index 00000000000..437a113e4fd --- /dev/null +++ b/packages/sql-to-rest/package.json @@ -0,0 +1,28 @@ +{ + "name": "sql-to-rest", + "version": "0.0.0", + "main": "./src/index.ts", + "types": "./src/index.ts", + "license": "MIT", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "dependencies": { + "@gregnr/libpg-query": "^13.4.0-dev.12" + }, + "devDependencies": { + "@babel/core": "^7.23.6", + "@babel/preset-env": "^7.23.6", + "@jest/globals": "^29.7.0", + "@types/common-tags": "^1.8.4", + "babel-jest": "^29.7.0", + "babel-plugin-transform-import-meta": "^2.2.1", + "common-tags": "^1.8.2", + "jest": "^29.7.0", + "sql-formatter": "^15.0.2", + "ts-jest": "^29.1.1", + "tsconfig": "*", + "typescript": "^5.4.3" + } +} diff --git a/packages/sql-to-rest/src/index.ts b/packages/sql-to-rest/src/index.ts new file mode 100644 index 00000000000..4fa95c6caf3 --- /dev/null +++ b/packages/sql-to-rest/src/index.ts @@ -0,0 +1,2 @@ +export * from './processor' +export * from './renderers/http' diff --git a/packages/sql-to-rest/src/processor.ts b/packages/sql-to-rest/src/processor.ts new file mode 100644 index 00000000000..7d6ff998d56 --- /dev/null +++ b/packages/sql-to-rest/src/processor.ts @@ -0,0 +1,289 @@ +import { parseQuery } from '@gregnr/libpg-query' +import { + ParsedQuery, + SelectResTarget, + SelectStmt, + Stmt, + WhereClauseExpression, +} from './types/pg-query' + +export type Statement = Select + +export type Select = { + type: 'select' + from: string + targets: Target[] + filter?: Filter +} + +export type Operator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'is' +export type LogicalOperator = 'and' | 'or' + +export type BaseFilter = { + negate: boolean +} + +export type ColumnFilter = BaseFilter & { + type: 'column' + column: string + operator: Operator + value: string +} + +export type LogicalFilter = BaseFilter & { + type: 'logical' + operator: LogicalOperator + values: Filter[] +} + +export type Filter = ColumnFilter | LogicalFilter + +export type Target = { + type: 'target' + column: string + alias?: string +} + +/** + * Coverts SQL into a PostgREST-compatible `Statement`. + * + * Expects SQL to contain only one statement. + * + * @returns An intermediate `Statement` object that + * can be rendered to various targets (HTTP, supabase-js, etc). + */ +export async function processSql(sql: string) { + const result: ParsedQuery = await parseQuery(sql) + + console.dir(result, { depth: null }) + + if (result.stmts.length === 0) { + throw new Error('Expected a statement, but received none') + } + + if (result.stmts.length > 1) { + throw new Error('Expected a single statement, but received multiple') + } + + const [statement] = result.stmts.map((stmt) => processStmt(stmt)) + + return statement +} + +/** + * Converts a pg-query `Stmt` into a PostgREST-compatible `Statement`. + */ +function processStmt({ stmt }: Stmt): Statement { + const keys = Object.keys(stmt) as unknown as (keyof typeof stmt)[] + + if (keys.length > 1) { + throw new Error(`stmt contains multiple statements: ${JSON.stringify(keys)}`) + } + + const [stmtType] = keys + + switch (stmtType) { + case 'SelectStmt': + return processSelectStmt(stmt) + default: + throw new Error(`Unsupported stmt type '${stmtType}'`) + } +} + +function processSelectStmt(stmt: SelectStmt): Select { + if (!stmt.SelectStmt.fromClause) { + throw new Error('A relation must be included in a from clause') + } + + if (stmt.SelectStmt.fromClause.length > 1) { + throw new Error('Only one relation supported in from clause') + } + + const [rangeVar] = stmt.SelectStmt.fromClause + + const from = rangeVar.RangeVar.relname + + const targets = processTargetList(stmt.SelectStmt.targetList) + + const filter = stmt.SelectStmt.whereClause + ? processWhereClause(stmt.SelectStmt.whereClause) + : undefined + + return { + type: 'select', + from, + targets, + filter, + } +} + +function processTargetList(targetList: SelectResTarget[]): Target[] { + return targetList.map((resTarget) => { + if (!('ColumnRef' in resTarget.ResTarget.val)) { + throw new Error('Only columns allowed in select targets') + } + + const { fields } = resTarget.ResTarget.val.ColumnRef + + if (fields.length > 1) { + throw new Error('Only one field supported per column') + } + + const [field] = fields + + let column: string + + if ('A_Star' in field) { + column = '*' + } else if ('String' in field) { + column = field.String.str + } else { + const [fieldType] = Object.keys(field) + throw new Error(`Unsupported ColumnRef field type '${fieldType}'`) + } + + const alias = resTarget.ResTarget.name + + return { + type: 'target', + column, + alias, + } + }) +} + +function processWhereClause(expression: WhereClauseExpression): Filter { + if ('A_Expr' in expression) { + const { fields } = expression.A_Expr.lexpr.ColumnRef + + if (fields.length > 1) { + throw new Error('Only one field supported per column') + } + + const [field] = fields + + if (!('String' in field)) { + const [fieldType] = Object.keys(field) + throw new Error(`WHERE clause fields must be String type, received '${fieldType}'`) + } + + if (expression.A_Expr.name.length > 1) { + throw new Error('Only one operator name supported per expression') + } + + const [name] = expression.A_Expr.name + const operatorSymbol = name.String.str + const operator = mapOperatorSymbol(operatorSymbol) + + const column = field.String.str + let value: string + + if ('String' in expression.A_Expr.rexpr.A_Const.val) { + value = expression.A_Expr.rexpr.A_Const.val.String.str + } else if ('Integer' in expression.A_Expr.rexpr.A_Const.val) { + value = expression.A_Expr.rexpr.A_Const.val.Integer.ival.toString() + } else { + const [valType] = Object.keys(expression.A_Expr.rexpr.A_Const.val) + throw new Error(`WHERE clause values must be String or Integer types, received '${valType}'`) + } + + if (operator === 'like' || operator === 'ilike') { + value = value.replaceAll('%', '*') + } + + return { + type: 'column', + column, + operator, + negate: false, + value, + } + } else if ('NullTest' in expression) { + const { fields } = expression.NullTest.arg.ColumnRef + + if (fields.length > 1) { + throw new Error('Only one field supported per column') + } + + const [field] = fields + + if (!('String' in field)) { + const [fieldType] = Object.keys(field) + throw new Error(`WHERE clause fields must be String type, received '${fieldType}'`) + } + + const column = field.String.str + + const negate = expression.NullTest.nulltesttype === 'IS_NOT_NULL' + const operator = 'is' + const value = 'null' + + return { + type: 'column', + column, + operator, + negate, + value, + } + } else if ('BoolExpr' in expression) { + let operator: 'and' | 'or' | 'not' + + if (expression.BoolExpr.boolop === 'AND_EXPR') { + operator = 'and' + } else if (expression.BoolExpr.boolop === 'OR_EXPR') { + operator = 'or' + } else if (expression.BoolExpr.boolop === 'NOT_EXPR') { + operator = 'not' + } else { + throw new Error(`Unknown boolop '${expression.BoolExpr.boolop}'`) + } + + const values = expression.BoolExpr.args.map((arg) => processWhereClause(arg)) + + // The 'not' operator is special - instead of wrapping its child, + // we just return the child directly and set negate=true on it. + if (operator === 'not') { + if (values.length > 1) { + throw new Error( + `NOT expressions expected to have only 1 child. Received ${values.length} children` + ) + } + const [filter] = values + filter.negate = true + return filter + } + + return { + type: 'logical', + operator, + negate: false, + values, + } + } else { + const [expressionType] = Object.keys(expression) + throw new Error(`Unknown WHERE clause expression '${expressionType}'`) + } +} + +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' + default: + throw new Error(`Unsupported operator symbol '${operatorSymbol}'`) + } +} diff --git a/packages/sql-to-rest/src/renderers/http.test.ts b/packages/sql-to-rest/src/renderers/http.test.ts new file mode 100644 index 00000000000..8f369626d09 --- /dev/null +++ b/packages/sql-to-rest/src/renderers/http.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, test } from '@jest/globals' +import { stripIndents } from 'common-tags' +import { processSql } from '../processor' +import { renderHttp } from './http' + +describe('select', () => { + test('specified columns', async () => { + const sql = stripIndents` + select + title, + description + from + books + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?select=title,description') + }) + + test('inline target expression fails', async () => { + const sql = stripIndents` + select + 1 + 1 + from + books + ` + + await expect(processSql(sql)).rejects.toThrowError() + }) + + test('missing table fails', async () => { + const sql = stripIndents` + select 'Test' + ` + + await expect(processSql(sql)).rejects.toThrowError() + }) + + test('aliased column', async () => { + const sql = stripIndents` + select + title as my_title + from + books + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?select=my_title:title') + }) + + test('equal', async () => { + const sql = stripIndents` + select + * + from + books + where title = 'Cheese' + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?title=eq.Cheese') + }) + + test('not equal', async () => { + const sql = stripIndents` + select + * + from + books + where + title != 'Cheese' + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?title=neq.Cheese') + }) + + test('not wrapped equal', async () => { + const sql = stripIndents` + select + * + from + books + where + not ( + title = 'Cheese' + ) + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?title=not.eq.Cheese') + }) + + test('null', async () => { + const sql = stripIndents` + select + * + from + books + where + title is null + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?title=is.null') + }) + + test('not null', async () => { + const sql = stripIndents` + select + * + from + books + where + title is not null + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?title=not.is.null') + }) + + test('greater than', async () => { + const sql = stripIndents` + select + * + from + books + where + pages > 10 + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?pages=gt.10') + }) + + test('greater than or equal', async () => { + const sql = stripIndents` + select + * + from + books + where + pages >= 10 + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?pages=gte.10') + }) + + test('less than', async () => { + const sql = stripIndents` + select + * + from + books + where + pages < 10 + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?pages=lt.10') + }) + + test('less than or equal', async () => { + const sql = stripIndents` + select + * + from + books + where + pages <= 10 + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?pages=lte.10') + }) + + test('"and" expression', async () => { + const sql = stripIndents` + select + * + from + books + where + title = 'Cheese' and + description ilike '%salsa%' + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?title=eq.Cheese&description=ilike.*salsa*') + }) + + test('"or" expression', async () => { + const sql = stripIndents` + select + * + from + books + where + title = 'Cheese' or + title = 'Salsa' + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?or=(title.eq.Cheese,title.eq.Salsa)') + }) + + test('negated "and" expression', async () => { + const sql = stripIndents` + select + * + from + books + where + not ( + title = 'Cheese' and + description ilike '%salsa%' + ) + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?not.and=(title.eq.Cheese,description.ilike.*salsa*)') + }) + + test('negated "or" expression', async () => { + const sql = stripIndents` + select + * + from + books + where + not ( + title = 'Cheese' or + title = 'Salsa' + ) + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe('/books?not.or=(title.eq.Cheese,title.eq.Salsa)') + }) + + test('"and" expression with nested "or"', async () => { + const sql = stripIndents` + select + * + from + books + where + title like 'T%' and + ( + description ilike '%tacos%' or + description ilike '%salsa%' + ) + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe( + '/books?title=like.T*&or=(description.ilike.*tacos*,description.ilike.*salsa*)' + ) + }) + + test('negated "and" expression with nested "or"', async () => { + const sql = stripIndents` + select + * + from + books + where + not ( + title like 'T%' and + ( + description ilike '%tacos%' or + description ilike '%salsa%' + ) + ) + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe( + '/books?not.and=(title.like.T*,or(description.ilike.*tacos*,description.ilike.*salsa*))' + ) + }) + + test('negated "and" expression with negated nested "or"', async () => { + const sql = stripIndents` + select + * + from + books + where + not ( + title like 'T%' and + not ( + description ilike '%tacos%' or + description ilike '%salsa%' + ) + ) + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe( + '/books?not.and=(title.like.T*,not.or(description.ilike.*tacos*,description.ilike.*salsa*))' + ) + }) + + test('order of operations', async () => { + const sql = stripIndents` + select + * + from + books + where + title like 'T%' and + description ilike '%tacos%' or + description ilike '%salsa%' + ` + + const statement = await processSql(sql) + const { method, path } = renderHttp(statement) + + expect(method).toBe('GET') + expect(path).toBe( + '/books?or=(and(title.like.T*,description.ilike.*tacos*),description.ilike.*salsa*)' + ) + }) +}) diff --git a/packages/sql-to-rest/src/renderers/http.ts b/packages/sql-to-rest/src/renderers/http.ts new file mode 100644 index 00000000000..7452fe149e5 --- /dev/null +++ b/packages/sql-to-rest/src/renderers/http.ts @@ -0,0 +1,115 @@ +import { Filter, Select, Statement } from '../processor' + +export type HttpRequest = { + method: 'GET' + path: string +} + +/** + * Renders a `Statement` as an HTTP request. + */ +export function renderHttp(processed: Statement): HttpRequest { + switch (processed.type) { + case 'select': + return formatSelect(processed) + default: + throw new Error(`Unsupported statement type '${processed.type}'`) + } +} + +function formatSelect(select: Select): HttpRequest { + const { from, targets, filter } = select + const params = new URLSearchParams() + + if (targets.length > 0) { + const [firstTarget] = targets + + // Exclude "select=*" if it's the only target + if (firstTarget.column !== '*' || targets.length !== 1) { + params.set( + 'select', + targets + .map(({ column, alias }) => { + if (alias) { + return `${alias}:${column}` + } + return column + }) + .join(',') + ) + } + } + + if (filter) { + formatSelectFilterRoot(params, filter) + } + + let path = `/${from}` + + if (params.size > 0) { + path += `?${encodeParams(params, ['*', '(', ')', ',', ':'])}` + } + + return { + method: 'GET', + path, + } +} + +/** + * URL encodes query parameters with an optional character whitelist + * that should not be encoded. + */ +function encodeParams(params: URLSearchParams, characterWhitelist: string[] = []) { + let urlEncodedParams = params.toString() + + // Convert whitelisted characters back from their hex representation (eg. '%2A' -> '*') + for (const char of characterWhitelist) { + const hexCode = char.charCodeAt(0).toString(16).toUpperCase() + urlEncodedParams = urlEncodedParams.replaceAll(`%${hexCode}`, char) + } + + return urlEncodedParams +} + +function formatSelectFilterRoot(params: URLSearchParams, filter: Filter) { + const { type } = filter + const maybeNot = filter.negate ? 'not.' : '' + + // Column filter, eg. "title=eq.Cheese" + if (type === 'column') { + params.set(filter.column, `${maybeNot}${filter.operator}.${filter.value}`) + } + // Logical operator filter, eg. "or=(title.eq.Cheese,title.eq.Salsa)"" + else if (type === 'logical') { + // The `and` operator is a a special case where we can format each nested + // filter as a separate query param as long as the `and` is not negated + if (filter.operator === 'and' && !filter.negate) { + for (const subFilter of filter.values) { + formatSelectFilterRoot(params, subFilter) + } + } + // Otherwise use the =(...) syntax + else { + params.set( + `${maybeNot}${filter.operator}`, + `(${filter.values.map((subFilter) => formatSelectFilter(subFilter)).join(',')})` + ) + } + } else { + throw new Error(`Unknown filter type '${type}'`) + } +} + +function formatSelectFilter(filter: Filter): string { + const { type } = filter + const maybeNot = filter.negate ? 'not.' : '' + + if (type === 'column') { + return `${maybeNot}${filter.column}.${filter.operator}.${filter.value}` + } else if (type === 'logical') { + return `${maybeNot}${filter.operator}(${filter.values.map((subFilter) => formatSelectFilter(subFilter)).join(',')})` + } else { + throw new Error(`Unknown filter type '${type}'`) + } +} diff --git a/packages/sql-to-rest/src/types/pg-query.ts b/packages/sql-to-rest/src/types/pg-query.ts new file mode 100644 index 00000000000..56414b7562d --- /dev/null +++ b/packages/sql-to-rest/src/types/pg-query.ts @@ -0,0 +1,159 @@ +export type String = { + String: { + str: string + } +} + +export type Integer = { + Integer: { + ival: number + } +} + +export type A_Star = { + A_Star: {} +} + +export type A_Const = { + A_Const: { + val: String | Integer + location: number + } +} + +/** + * Infix, prefix, and postfix expressions + * + * https://github.com/postgres/postgres/blob/d12b4ba1bd3eedd862064cf1dad5ff107c5cba90/src/include/nodes/parsenodes.h#L308-L327 + */ +export type A_Expr = { + A_Expr: { + kind: + | 'AEXPR_OP' + | 'AEXPR_OP_ANY' + | 'AEXPR_OP_ALL' + | 'AEXPR_DISTINCT' + | 'AEXPR_NOT_DISTINCT' + | 'AEXPR_NULLIF' + | 'AEXPR_IN' + | 'AEXPR_LIKE' + | 'AEXPR_ILIKE' + | 'AEXPR_SIMILAR' + | 'AEXPR_BETWEEN' + | 'AEXPR_NOT_BETWEEN' + | 'AEXPR_BETWEEN_SYM' + | 'AEXPR_NOT_BETWEEN_SYM' + name: String[] + lexpr: ColumnRef + rexpr: A_Const + location: number + } +} + +export type BoolExpr = { + BoolExpr: { + boolop: 'AND_EXPR' | 'OR_EXPR' | 'NOT_EXPR' + args: (A_Expr | BoolExpr)[] + location: number + } +} + +/** + * The operation of testing a value for NULLness. + * + * https://github.com/postgres/postgres/blob/d12b4ba1bd3eedd862064cf1dad5ff107c5cba90/src/include/nodes/primnodes.h#L1903-L1935 + */ +export type NullTest = { + NullTest: { + arg: ColumnRef + nulltesttype: 'IS_NULL' | 'IS_NOT_NULL' + location: number + } +} + +export type ColumnRef = { + ColumnRef: { + fields: (String | A_Star)[] + location: number + } +} + +/** + * Result target, used in target list of pre-transformed parse trees (select, insert, update). + * + * https://github.com/postgres/postgres/blob/d12b4ba1bd3eedd862064cf1dad5ff107c5cba90/src/include/nodes/parsenodes.h#L496-L521 + */ +export type SelectResTarget = { + ResTarget: { + val: ColumnRef | A_Expr + name?: string + location: number + } +} + +/** + * Range variable, used in FROM clauses. + * + * https://github.com/postgres/postgres/blob/d12b4ba1bd3eedd862064cf1dad5ff107c5cba90/src/include/nodes/primnodes.h#L63-L95 + */ +export type RangeVar = { + RangeVar: { + /** + * the schema name + */ + schemaname?: string + + /** + * the relation/sequence name + */ + relname: string + + /** + * expand rel by inheritance? recursively act on children? + */ + inh: boolean + + relpersistence: string + + /** + * token location + */ + location: number + + /** + * table alias & optional column aliases + */ + alias?: string + } +} + +export type WhereClauseExpression = A_Expr | BoolExpr | NullTest + +/** + * Select statement. + * + * https://github.com/postgres/postgres/blob/d12b4ba1bd3eedd862064cf1dad5ff107c5cba90/src/include/nodes/parsenodes.h#L2121-L2168 + */ +export type SelectStmt = { + SelectStmt: { + targetList: SelectResTarget[] + fromClause?: RangeVar[] + distinctClause?: any[] + intoClause?: any[] + whereClause?: WhereClauseExpression + groupClause?: any[] + groupDistinct?: boolean + havingClause?: any[] + limitOption: string + op: string + } +} + +export type Stmt = { + stmt: SelectStmt +} + +export type ParsedQuery = { + version: number + stmts: Stmt[] +} diff --git a/packages/sql-to-rest/tsconfig.json b/packages/sql-to-rest/tsconfig.json new file mode 100644 index 00000000000..e8b5504d293 --- /dev/null +++ b/packages/sql-to-rest/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "tsconfig/react-library.json", + "include": [ + "." + ], + "exclude": [ + "dist", + "build", + "node_modules" + ], + "compilerOptions": { + "types": [ + "./test/extensions.d.ts" + ] + } +} \ No newline at end of file