mirror of
https://github.com/supabase/supabase.git
synced 2026-10-19 08:09:11 +03:00
feat(sql-to-rest): in operator
This commit is contained in:
1 parent
560006a9cd
commit
ee005f4a7a
5 files changed
+212
-57
No files matched your search
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in new issue
Block a user