feat: sql-to-rest poc

This commit is contained in:
Greg Richardson committed 2024-05-01 13:49:41 -06:00
1 parent cc09ae54a5
commit 01d9b15096
15 files changed
+1197 -3

No files matched your search

+45
View File
@@ -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"
},
@@ -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 ()
)
",
}
`;
+94 -1
View File
@@ -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.
`,
},
]
+1 -1
View File
@@ -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)'
)
})
})
+39
View File
@@ -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')
+7
View File
@@ -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,
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
plugins: ['babel-plugin-transform-import-meta'],
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.ts?$': 'ts-jest',
'^.+\\.(js|jsx)$': 'babel-jest',
},
}
+28
View File
@@ -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"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './processor'
export * from './renderers/http'
+289
View File
@@ -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}'`)
}
}
@@ -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*)'
)
})
})
+115
View File
@@ -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 <operator>=(...) 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}'`)
}
}
+159
View File
@@ -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[]
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "tsconfig/react-library.json",
"include": [
"."
],
"exclude": [
"dist",
"build",
"node_modules"
],
"compilerOptions": {
"types": [
"./test/extensions.d.ts"
]
}
}