mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 23:59:08 +03:00
feat(sql-to-rest): better error handling
This commit is contained in:
1 parent
3e27a265ab
commit
2c85cd2896
6 files changed
+284
-138
No files matched your search
@@ -0,0 +1,30 @@
|
||||
export class ParsingError extends Error {
|
||||
name = 'ParsingError'
|
||||
|
||||
constructor(message: string) {
|
||||
super(sentenceCase(message))
|
||||
}
|
||||
}
|
||||
|
||||
export class UnimplementedError extends Error {
|
||||
name = 'UnimplementedError'
|
||||
}
|
||||
|
||||
export class UnsupportedError extends Error {
|
||||
name = 'UnsupportedError'
|
||||
}
|
||||
|
||||
export class RenderError extends Error {
|
||||
name = 'RenderError'
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public renderer: 'http' | 'supabase-js'
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export function sentenceCase(value: string) {
|
||||
return value[0].toUpperCase() + value.slice(1)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './errors'
|
||||
export * from './processor'
|
||||
export * from './renderers/http'
|
||||
export * from './renderers/supabase-js'
|
||||
@@ -1,5 +1,6 @@
|
||||
import { oneLine } from 'common-tags'
|
||||
import { parseQuery } from 'libpg-query'
|
||||
import { ParsingError, UnimplementedError, UnsupportedError } from './errors'
|
||||
import {
|
||||
A_Expr,
|
||||
ColumnRef,
|
||||
@@ -153,19 +154,29 @@ export type Sort = {
|
||||
* can be rendered to various targets (HTTP, supabase-js, etc).
|
||||
*/
|
||||
export async function processSql(sql: string) {
|
||||
const result: ParsedQuery = await parseQuery(sql)
|
||||
try {
|
||||
const result: ParsedQuery = await parseQuery(sql)
|
||||
|
||||
if (result.stmts.length === 0) {
|
||||
throw new Error('Expected a statement, but received none')
|
||||
if (result.stmts.length === 0) {
|
||||
throw new UnsupportedError('Expected a statement, but received none')
|
||||
}
|
||||
|
||||
if (result.stmts.length > 1) {
|
||||
throw new UnsupportedError('Expected a single statement, but received multiple')
|
||||
}
|
||||
|
||||
const [statement] = result.stmts.map((stmt) => processStmt(stmt))
|
||||
|
||||
return statement
|
||||
} catch (err) {
|
||||
if (err instanceof Error && 'cursorPosition' in err) {
|
||||
const parsingError = new ParsingError(err.message)
|
||||
Object.assign(parsingError, err)
|
||||
throw parsingError
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,30 +186,30 @@ function processStmt({ stmt }: Stmt): Statement {
|
||||
if ('SelectStmt' in stmt) {
|
||||
return processSelectStmt(stmt)
|
||||
} else if ('InsertStmt' in stmt) {
|
||||
throw new Error(`Insert statements are not yet supported by the translator`)
|
||||
throw new UnimplementedError(`Insert statements are not yet supported by the translator`)
|
||||
} else if ('UpdateStmt' in stmt) {
|
||||
throw new Error(`Update statements are not yet supported by the translator`)
|
||||
throw new UnimplementedError(`Update statements are not yet supported by the translator`)
|
||||
} else if ('DeleteStmt' in stmt) {
|
||||
throw new Error(`Delete statements are not yet supported by the translator`)
|
||||
throw new UnimplementedError(`Delete statements are not yet supported by the translator`)
|
||||
} else if ('ExplainStmt' in stmt) {
|
||||
throw new Error(`Explain statements are not yet supported by the translator`)
|
||||
throw new UnimplementedError(`Explain statements are not yet supported by the translator`)
|
||||
} else {
|
||||
const [stmtType] = Object.keys(stmt)
|
||||
throw new Error(`Unsupported stmt type '${stmtType}'`)
|
||||
throw new UnsupportedError(`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')
|
||||
throw new UnsupportedError('The query must have a from clause')
|
||||
}
|
||||
|
||||
if (stmt.SelectStmt.fromClause.length > 1) {
|
||||
throw new Error('Only one FROM source is supported')
|
||||
throw new UnsupportedError('Only one FROM source is supported')
|
||||
}
|
||||
|
||||
if (stmt.SelectStmt.withClause) {
|
||||
throw new Error('WITH clauses are not supported')
|
||||
throw new UnsupportedError('WITH clauses are not supported')
|
||||
}
|
||||
|
||||
const [fromClause] = stmt.SelectStmt.fromClause
|
||||
@@ -250,7 +261,7 @@ function processFromClause(fromClause: FromExpression): {
|
||||
]
|
||||
|
||||
if (!('A_Expr' in fromClause.JoinExpr.quals)) {
|
||||
throw new Error(`Expected join qualifier to be an expression comparing columns`)
|
||||
throw new UnsupportedError(`Expected join qualifier to be an expression comparing columns`)
|
||||
}
|
||||
|
||||
let leftQualifierRelation
|
||||
@@ -259,7 +270,7 @@ function processFromClause(fromClause: FromExpression): {
|
||||
const joinQualifierExpression = fromClause.JoinExpr.quals.A_Expr
|
||||
|
||||
if (!('ColumnRef' in joinQualifierExpression.lexpr)) {
|
||||
throw new Error(`Expected left side of join qualifier to be a column reference`)
|
||||
throw new UnsupportedError(`Expected left side of join qualifier to be a column reference`)
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -267,7 +278,9 @@ function processFromClause(fromClause: FromExpression): {
|
||||
(field): field is PgString => 'String' in field
|
||||
)
|
||||
) {
|
||||
throw new Error(`Expected left column reference of join qualifier to contain String fields`)
|
||||
throw new UnsupportedError(
|
||||
`Expected left column reference of join qualifier to contain String fields`
|
||||
)
|
||||
}
|
||||
|
||||
const leftColumnFields = joinQualifierExpression.lexpr.ColumnRef.fields.map(
|
||||
@@ -285,13 +298,13 @@ function processFromClause(fromClause: FromExpression): {
|
||||
} else if (leftRelationName === joinedRelation) {
|
||||
leftQualifierRelation = joinedRelation
|
||||
} else {
|
||||
throw new Error(
|
||||
throw new UnsupportedError(
|
||||
`Left side of join qualifier references a different relation (${leftRelationName}) than the join (${existingRelations.join(', ')})`
|
||||
)
|
||||
}
|
||||
|
||||
if (!('ColumnRef' in joinQualifierExpression.rexpr)) {
|
||||
throw new Error(`Expected right side of join qualifier to be a column reference`)
|
||||
throw new UnsupportedError(`Expected right side of join qualifier to be a column reference`)
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -299,7 +312,9 @@ function processFromClause(fromClause: FromExpression): {
|
||||
(field): field is PgString => 'String' in field
|
||||
)
|
||||
) {
|
||||
throw new Error(`Expected right column reference of join qualifier to contain String fields`)
|
||||
throw new UnsupportedError(
|
||||
`Expected right column reference of join qualifier to contain String fields`
|
||||
)
|
||||
}
|
||||
|
||||
const rightColumnFields = joinQualifierExpression.rexpr.ColumnRef.fields.map(
|
||||
@@ -317,24 +332,24 @@ function processFromClause(fromClause: FromExpression): {
|
||||
} else if (rightRelationName === joinedRelation) {
|
||||
rightQualifierRelation = joinedRelation
|
||||
} else {
|
||||
throw new Error(
|
||||
throw new UnsupportedError(
|
||||
`Right side of join qualifier references a different relation (${rightRelationName}) than the join (${existingRelations.join(', ')})`
|
||||
)
|
||||
}
|
||||
|
||||
if (rightQualifierRelation === leftQualifierRelation) {
|
||||
// TODO: support for recursive relationships
|
||||
throw new Error(`Join qualifier cannot compare columns from same relation`)
|
||||
throw new UnsupportedError(`Join qualifier cannot compare columns from same relation`)
|
||||
}
|
||||
|
||||
if (rightQualifierRelation !== joinedRelation && leftQualifierRelation !== joinedRelation) {
|
||||
throw new Error(`Join qualifier must reference a column from the joined table`)
|
||||
throw new UnsupportedError(`Join qualifier must reference a column from the joined table`)
|
||||
}
|
||||
|
||||
const [qualifierOperatorString] = joinQualifierExpression.name
|
||||
|
||||
if (qualifierOperatorString.String.sval !== '=') {
|
||||
throw new Error(`Expected join qualifier operator to be '='`)
|
||||
throw new UnsupportedError(`Expected join qualifier operator to be '='`)
|
||||
}
|
||||
|
||||
let left: JoinedColumn
|
||||
@@ -381,7 +396,7 @@ function processFromClause(fromClause: FromExpression): {
|
||||
}
|
||||
} else {
|
||||
const [fieldType] = Object.keys(fromClause)
|
||||
throw new Error(`Unsupported FROM clause type '${fieldType}'`)
|
||||
throw new UnsupportedError(`Unsupported FROM clause type '${fieldType}'`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,10 +429,10 @@ function processTargetList(
|
||||
cast,
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Expressions not supported in select target`)
|
||||
throw new UnsupportedError(`Expressions not supported in select target`)
|
||||
}
|
||||
} else {
|
||||
throw new Error('Only columns allowed in select targets')
|
||||
throw new UnsupportedError('Only columns allowed in select targets')
|
||||
}
|
||||
|
||||
const { fields } = columnRef.ColumnRef
|
||||
@@ -452,7 +467,7 @@ function processTargetList(
|
||||
)
|
||||
|
||||
if (!embeddedTarget) {
|
||||
throw new Error(
|
||||
throw new UnsupportedError(
|
||||
oneLine`
|
||||
Found foreign column '${target.column}' in target list without a join to that relation.
|
||||
Did you forget to join that relation or alias it to something else?
|
||||
@@ -484,7 +499,7 @@ function processTargetList(
|
||||
)
|
||||
|
||||
if (!parent) {
|
||||
throw new Error(
|
||||
throw new UnsupportedError(
|
||||
`Something went wrong, could not find parent embedded target for nested embedded target '${embeddedTarget.relation}'`
|
||||
)
|
||||
}
|
||||
@@ -501,7 +516,7 @@ function processTargetList(
|
||||
function processWhereClause(expression: WhereExpression): Filter {
|
||||
if ('A_Expr' in expression) {
|
||||
if ('TypeCast' in expression.A_Expr.lexpr) {
|
||||
throw new Error('Casting is not supported in the WHERE clause')
|
||||
throw new UnsupportedError('Casting is not supported in the WHERE clause')
|
||||
}
|
||||
|
||||
let column: string
|
||||
@@ -510,7 +525,7 @@ function processWhereClause(expression: WhereExpression): Filter {
|
||||
try {
|
||||
column = renderJsonExpression(expression.A_Expr.lexpr)
|
||||
} catch (err) {
|
||||
throw new Error(`Non column expressions not supported in WHERE clause`)
|
||||
throw new UnsupportedError(`Non column expressions not supported in WHERE clause`)
|
||||
}
|
||||
} else if ('ColumnRef' in expression.A_Expr.lexpr) {
|
||||
const { fields } = expression.A_Expr.lexpr.ColumnRef
|
||||
@@ -519,17 +534,19 @@ function processWhereClause(expression: WhereExpression): Filter {
|
||||
|
||||
if (!('String' in field)) {
|
||||
const [fieldType] = Object.keys(field)
|
||||
throw new Error(`WHERE clause fields must be String type, received '${fieldType}'`)
|
||||
throw new UnsupportedError(
|
||||
`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')
|
||||
throw new UnsupportedError('Only one operator name supported per expression')
|
||||
}
|
||||
|
||||
const stringFields = fields.filter((field): field is PgString => 'String' in field)
|
||||
column = stringFields.map((field) => field.String.sval).join('.')
|
||||
} else {
|
||||
throw new Error(`Non column values not supported in WHERE clause`)
|
||||
throw new UnsupportedError(`Non column values not supported in WHERE clause`)
|
||||
}
|
||||
|
||||
const [name] = expression.A_Expr.name
|
||||
@@ -537,7 +554,7 @@ function processWhereClause(expression: WhereExpression): Filter {
|
||||
const operator = mapOperatorSymbol(operatorSymbol)
|
||||
|
||||
if (!('A_Const' in expression.A_Expr.rexpr)) {
|
||||
throw new Error(`Expected right side of WHERE clause expression to be a constant`)
|
||||
throw new UnsupportedError(`Expected right side of WHERE clause expression to be a constant`)
|
||||
}
|
||||
|
||||
let value: any
|
||||
@@ -549,7 +566,7 @@ function processWhereClause(expression: WhereExpression): Filter {
|
||||
} else if ('fval' in expression.A_Expr.rexpr.A_Const) {
|
||||
value = parseFloat(expression.A_Expr.rexpr.A_Const.fval.fval)
|
||||
} else {
|
||||
throw new Error(
|
||||
throw new UnsupportedError(
|
||||
`WHERE clause values must be a string (sval), integer (ival), or float (fval)`
|
||||
)
|
||||
}
|
||||
@@ -568,7 +585,7 @@ function processWhereClause(expression: WhereExpression): Filter {
|
||||
|
||||
if (!('String' in field)) {
|
||||
const [fieldType] = Object.keys(field)
|
||||
throw new Error(`WHERE clause fields must be String type, received '${fieldType}'`)
|
||||
throw new UnsupportedError(`WHERE clause fields must be String type, received '${fieldType}'`)
|
||||
}
|
||||
|
||||
const stringFields = fields.filter((field): field is PgString => 'String' in field)
|
||||
@@ -595,7 +612,7 @@ function processWhereClause(expression: WhereExpression): Filter {
|
||||
} else if (expression.BoolExpr.boolop === 'NOT_EXPR') {
|
||||
operator = 'not'
|
||||
} else {
|
||||
throw new Error(`Unknown boolop '${expression.BoolExpr.boolop}'`)
|
||||
throw new UnsupportedError(`Unknown boolop '${expression.BoolExpr.boolop}'`)
|
||||
}
|
||||
|
||||
const values = expression.BoolExpr.args.map((arg) => processWhereClause(arg))
|
||||
@@ -604,7 +621,7 @@ function processWhereClause(expression: WhereExpression): Filter {
|
||||
// we just return the child directly and set negate=true on it.
|
||||
if (operator === 'not') {
|
||||
if (values.length > 1) {
|
||||
throw new Error(
|
||||
throw new UnsupportedError(
|
||||
`NOT expressions expected to have only 1 child. Received ${values.length} children`
|
||||
)
|
||||
}
|
||||
@@ -621,18 +638,18 @@ function processWhereClause(expression: WhereExpression): Filter {
|
||||
}
|
||||
} else {
|
||||
const [expressionType] = Object.keys(expression)
|
||||
throw new Error(`Unknown WHERE clause expression '${expressionType}'`)
|
||||
throw new UnsupportedError(`Unknown WHERE clause expression '${expressionType}'`)
|
||||
}
|
||||
}
|
||||
|
||||
function processSortClause(sorts: SortBy[]): Sort[] {
|
||||
return sorts.map((sortBy) => {
|
||||
if ('TypeCast' in sortBy.SortBy.node) {
|
||||
throw new Error('Casting is not supported in the ORDER BY clause')
|
||||
throw new UnsupportedError('Casting is not supported in the ORDER BY clause')
|
||||
}
|
||||
|
||||
if (!('ColumnRef' in sortBy.SortBy.node)) {
|
||||
throw new Error('ORDER BY clause only accepts columns')
|
||||
throw new UnsupportedError('ORDER BY clause only accepts columns')
|
||||
}
|
||||
|
||||
const { fields } = sortBy.SortBy.node.ColumnRef
|
||||
@@ -641,7 +658,9 @@ function processSortClause(sorts: SortBy[]): Sort[] {
|
||||
|
||||
if (!('String' in field)) {
|
||||
const [fieldType] = Object.keys(field)
|
||||
throw new Error(`ORDER BY clause fields must be String type, received '${fieldType}'`)
|
||||
throw new UnsupportedError(
|
||||
`ORDER BY clause fields must be String type, received '${fieldType}'`
|
||||
)
|
||||
}
|
||||
|
||||
const stringFields = fields.filter((field): field is PgString => 'String' in field)
|
||||
@@ -666,7 +685,7 @@ function mapSortByDirection(direction: string) {
|
||||
case 'SORTBY_DEFAULT':
|
||||
return undefined
|
||||
default:
|
||||
throw new Error(`Unknown sort by direction '${direction}'`)
|
||||
throw new UnsupportedError(`Unknown sort by direction '${direction}'`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,7 +698,7 @@ function mapSortByNulls(nulls: string) {
|
||||
case 'SORTBY_NULLS_DEFAULT':
|
||||
return undefined
|
||||
default:
|
||||
throw new Error(`Unknown sort by nulls '${nulls}'`)
|
||||
throw new UnsupportedError(`Unknown sort by nulls '${nulls}'`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,7 +708,7 @@ function processLimit(selectStmt: SelectStmt): Limit | undefined {
|
||||
|
||||
if (selectStmt.SelectStmt.limitCount) {
|
||||
if (!('ival' in selectStmt.SelectStmt.limitCount.A_Const)) {
|
||||
throw new Error(`Limit count expected to be an integer`)
|
||||
throw new UnsupportedError(`Limit count expected to be an integer`)
|
||||
}
|
||||
|
||||
count = selectStmt.SelectStmt.limitCount.A_Const.ival.ival
|
||||
@@ -697,7 +716,7 @@ function processLimit(selectStmt: SelectStmt): Limit | undefined {
|
||||
|
||||
if (selectStmt.SelectStmt.limitOffset) {
|
||||
if (!('ival' in selectStmt.SelectStmt.limitOffset.A_Const)) {
|
||||
throw new Error(`Limit offset expected to be an integer`)
|
||||
throw new UnsupportedError(`Limit offset expected to be an integer`)
|
||||
}
|
||||
|
||||
offset = selectStmt.SelectStmt.limitOffset.A_Const.ival.ival
|
||||
@@ -720,7 +739,7 @@ function mapJoinType(joinType: string) {
|
||||
case 'JOIN_LEFT':
|
||||
return 'left'
|
||||
default:
|
||||
throw new Error(`Unsupported join type '${joinType}'`)
|
||||
throw new UnsupportedError(`Unsupported join type '${joinType}'`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,7 +762,7 @@ function mapOperatorSymbol(operatorSymbol: string) {
|
||||
case '~~*':
|
||||
return 'ilike'
|
||||
default:
|
||||
throw new Error(`Unsupported operator symbol '${operatorSymbol}'`)
|
||||
throw new UnsupportedError(`Unsupported operator symbol '${operatorSymbol}'`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,7 +775,7 @@ function renderFields(fields: Field[]) {
|
||||
return '*'
|
||||
} else {
|
||||
const [fieldType] = Object.keys(field)
|
||||
throw new Error(`Unsupported ColumnRef field type '${fieldType}'`)
|
||||
throw new UnsupportedError(`Unsupported ColumnRef field type '${fieldType}'`)
|
||||
}
|
||||
})
|
||||
.join('.')
|
||||
@@ -764,14 +783,14 @@ function renderFields(fields: Field[]) {
|
||||
|
||||
function renderJsonExpression(expression: A_Expr): string {
|
||||
if (expression.A_Expr.name.length > 1) {
|
||||
throw new Error('Only one operator name supported per expression')
|
||||
throw new UnsupportedError('Only one operator name supported per expression')
|
||||
}
|
||||
|
||||
const [name] = expression.A_Expr.name
|
||||
const operator = name.String.sval
|
||||
|
||||
if (!['->', '->>'].includes(operator)) {
|
||||
throw new Error(`Invalid JSON operator`)
|
||||
throw new UnsupportedError(`Invalid JSON operator`)
|
||||
}
|
||||
|
||||
let left: string
|
||||
@@ -781,24 +800,24 @@ function renderJsonExpression(expression: A_Expr): string {
|
||||
if ('sval' in expression.A_Expr.lexpr.A_Const) {
|
||||
left = expression.A_Expr.lexpr.A_Const.sval.sval
|
||||
} else {
|
||||
throw new Error('Invalid JSON path')
|
||||
throw new UnsupportedError('Invalid JSON path')
|
||||
}
|
||||
} else if ('A_Expr' in expression.A_Expr.lexpr) {
|
||||
left = renderJsonExpression(expression.A_Expr.lexpr)
|
||||
} else if ('ColumnRef' in expression.A_Expr.lexpr) {
|
||||
left = renderFields(expression.A_Expr.lexpr.ColumnRef.fields)
|
||||
} else {
|
||||
throw new Error('Invalid JSON path')
|
||||
throw new UnsupportedError('Invalid JSON path')
|
||||
}
|
||||
|
||||
if ('A_Const' in expression.A_Expr.rexpr) {
|
||||
if ('sval' in expression.A_Expr.rexpr.A_Const) {
|
||||
right = expression.A_Expr.rexpr.A_Const.sval.sval
|
||||
} else {
|
||||
throw new Error('Invalid JSON path')
|
||||
throw new UnsupportedError('Invalid JSON path')
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid JSON path')
|
||||
throw new UnsupportedError('Invalid JSON path')
|
||||
}
|
||||
|
||||
return `${left}${operator}${right}`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { stripIndent } from 'common-tags'
|
||||
import { RenderError } from '../errors'
|
||||
import { Filter, Select, Statement } from '../processor'
|
||||
import { renderTargets, uriEncode, uriEncodeParams } from './util'
|
||||
|
||||
@@ -17,7 +18,7 @@ export async function renderHttp(processed: Statement): Promise<HttpRequest> {
|
||||
case 'select':
|
||||
return formatSelect(processed)
|
||||
default:
|
||||
throw new Error(`Unsupported statement type '${processed.type}'`)
|
||||
throw new RenderError(`Unsupported statement type '${processed.type}'`, 'http')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +119,7 @@ function formatSelectFilterRoot(params: URLSearchParams, filter: Filter) {
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown filter type '${type}'`)
|
||||
throw new RenderError(`Unknown filter type '${type}'`, 'http')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +139,7 @@ function formatSelectFilter(filter: Filter): string {
|
||||
} else if (type === 'logical') {
|
||||
return `${maybeNot}${filter.operator}(${filter.values.map((subFilter) => formatSelectFilter(subFilter)).join(',')})`
|
||||
} else {
|
||||
throw new Error(`Unknown filter type '${type}'`)
|
||||
throw new RenderError(`Unknown filter type '${type}'`, 'http')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as babel from 'prettier/plugins/babel'
|
||||
import * as estree from 'prettier/plugins/estree'
|
||||
import * as prettier from 'prettier/standalone'
|
||||
import { RenderError } from '../errors'
|
||||
import { Filter, Select, Statement } from '../processor'
|
||||
import { renderTargets } from './util'
|
||||
|
||||
@@ -16,7 +17,7 @@ export async function renderSupabaseJs(processed: Statement): Promise<SupabaseJs
|
||||
case 'select':
|
||||
return formatSelect(processed)
|
||||
default:
|
||||
throw new Error(`Unsupported statement type '${processed.type}'`)
|
||||
throw new RenderError(`Unsupported statement type '${processed.type}'`, 'supabase-js')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ async function formatSelect(select: Select): Promise<SupabaseJsQuery> {
|
||||
if (limit.count !== undefined && limit.offset === undefined) {
|
||||
lines.push(`.limit(${limit.count})`)
|
||||
} else if (limit.count === undefined && limit.offset !== undefined) {
|
||||
throw new Error(`supabase-js doesn't support an offset without a limit`)
|
||||
throw new RenderError(`supabase-js doesn't support an offset without a limit`, 'supabase-js')
|
||||
} else if (limit.count !== undefined && limit.offset !== undefined) {
|
||||
lines.push(`.range(${limit.offset}, ${limit.offset + limit.count})`)
|
||||
}
|
||||
@@ -92,7 +93,6 @@ function formatSelectFilterRoot(lines: string[], filter: Filter) {
|
||||
|
||||
if (filter.negate) {
|
||||
if (filter.type === 'column') {
|
||||
const value = typeof filter.value === 'string' ? `'${filter.value}'` : filter.value
|
||||
lines.push(
|
||||
`.not(${JSON.stringify(filter.column)}, ${JSON.stringify(filter.operator)}, ${JSON.stringify(filter.value)})`
|
||||
)
|
||||
@@ -108,7 +108,6 @@ function formatSelectFilterRoot(lines: string[], filter: Filter) {
|
||||
|
||||
// Column filter, eg. .eq('title', 'Cheese')
|
||||
if (type === 'column') {
|
||||
const value = typeof filter.value === 'string' ? `'${filter.value}'` : filter.value
|
||||
lines.push(
|
||||
`.${filter.operator}(${JSON.stringify(filter.column)}, ${JSON.stringify(filter.value)})`
|
||||
)
|
||||
@@ -130,7 +129,7 @@ function formatSelectFilterRoot(lines: string[], filter: Filter) {
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown filter type '${type}'`)
|
||||
throw new RenderError(`Unknown filter type '${type}'`, 'supabase-js')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +142,6 @@ function formatSelectFilter(filter: Filter): string {
|
||||
} else if (type === 'logical') {
|
||||
return `${maybeNot}${filter.operator}(${filter.values.map((subFilter) => formatSelectFilter(subFilter)).join(', ')})`
|
||||
} else {
|
||||
throw new Error(`Unknown filter type '${type}'`)
|
||||
throw new RenderError(`Unknown filter type '${type}'`, 'supabase-js')
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Editor, { useMonaco } from '@monaco-editor/react'
|
||||
import { stripIndent } from 'common-tags'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import { ChevronUp, GitPullRequest } from 'lucide-react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { useTheme } from 'next-themes'
|
||||
import {
|
||||
@@ -14,8 +14,12 @@ import {
|
||||
import Markdown from 'react-markdown'
|
||||
import {
|
||||
HttpRequest,
|
||||
ParsingError,
|
||||
RenderError,
|
||||
Statement,
|
||||
SupabaseJsQuery,
|
||||
UnimplementedError,
|
||||
UnsupportedError,
|
||||
formatCurl,
|
||||
formatHttp,
|
||||
processSql,
|
||||
@@ -64,10 +68,16 @@ export default function SqlToRest({ baseUrl = 'http://localhost:54321/rest/v1' }
|
||||
|
||||
const [sql, setSql] = useState(defaultValue)
|
||||
const [statement, setStatement] = useState<Statement>()
|
||||
const [currentLanguage, setCurrentLanguage] = useState('http')
|
||||
|
||||
const [httpRequest, setHttpRequest] = useState<HttpRequest>()
|
||||
const [jsQuery, setJsQuery] = useState<SupabaseJsQuery>()
|
||||
const [errorMessage, setErrorMessage] = useState<string>()
|
||||
const [currentLanguage, setCurrentLanguage] = useState('http')
|
||||
|
||||
const [parsingError, setParsingError] = useState<ParsingError>()
|
||||
const [unimplementedError, setUnimplementedError] = useState<UnimplementedError>()
|
||||
const [unsupportedError, setUnsupportedError] = useState<UnsupportedError>()
|
||||
const [httpRenderError, setHttpRenderError] = useState<RenderError>()
|
||||
const [supabaseJsRenderError, setSupabaseJsRenderError] = useState<RenderError>()
|
||||
|
||||
const rawHttp = useMemo(() => {
|
||||
if (!httpRequest) {
|
||||
@@ -179,17 +189,39 @@ export default function SqlToRest({ baseUrl = 'http://localhost:54321/rest/v1' }
|
||||
const httpRequest = await renderHttp(statement)
|
||||
const jsQuery = await renderSupabaseJs(statement)
|
||||
|
||||
setErrorMessage(undefined)
|
||||
setParsingError(undefined)
|
||||
setUnimplementedError(undefined)
|
||||
setUnsupportedError(undefined)
|
||||
setHttpRenderError(undefined)
|
||||
setSupabaseJsRenderError(undefined)
|
||||
|
||||
setStatement(statement)
|
||||
setHttpRequest(httpRequest)
|
||||
setJsQuery(jsQuery)
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
setParsingError(undefined)
|
||||
setUnimplementedError(undefined)
|
||||
setUnsupportedError(undefined)
|
||||
setHttpRenderError(undefined)
|
||||
setSupabaseJsRenderError(undefined)
|
||||
|
||||
setErrorMessage(sentenceCase(error.message))
|
||||
if (error instanceof ParsingError) {
|
||||
setParsingError(error)
|
||||
} else if (error instanceof UnimplementedError) {
|
||||
setUnimplementedError(error)
|
||||
} else if (error instanceof UnsupportedError) {
|
||||
setUnsupportedError(error)
|
||||
} else if (error instanceof RenderError) {
|
||||
if (error.renderer === 'http') {
|
||||
setHttpRenderError(error)
|
||||
} else if (error.renderer === 'supabase-js') {
|
||||
setSupabaseJsRenderError(error)
|
||||
} else {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -225,11 +257,44 @@ export default function SqlToRest({ baseUrl = 'http://localhost:54321/rest/v1' }
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage && <Alert className="text-red-900">{errorMessage}</Alert>}
|
||||
{parsingError && <Alert className="text-red-900">{parsingError.message}</Alert>}
|
||||
{unsupportedError && (
|
||||
<Alert className="text-red-900">
|
||||
<div>{unsupportedError.message}.</div>
|
||||
<div className="prose text-sm mt-2">
|
||||
PostgREST doesn't support this query. If you're unable to modify the query, wrap it in
|
||||
a stored procedure and call it using the{' '}
|
||||
<a href="https://postgrest.org/en/v12/references/api/stored_procedures.html#stored-procedures">
|
||||
RPC
|
||||
</a>{' '}
|
||||
endpoint.
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
{unimplementedError && (
|
||||
<Alert className="text-orange-1000">
|
||||
{unimplementedError.message}.
|
||||
<div className="mt-2 text-white flex gap-1 leading-6">
|
||||
<GitPullRequest className="inline-block" width={16} />
|
||||
<a
|
||||
href="https://github.com/supabase/supabase/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Create a PR
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn('flex flex-col gap-4', errorMessage ? 'opacity-25 pointer-events-none' : '')}
|
||||
className={cn(
|
||||
'flex flex-col gap-4',
|
||||
parsingError || unsupportedError || unimplementedError
|
||||
? 'opacity-25 pointer-events-none'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">Choose language to translate to</div>
|
||||
<Tabs
|
||||
@@ -238,72 +303,107 @@ export default function SqlToRest({ baseUrl = 'http://localhost:54321/rest/v1' }
|
||||
queryGroup="language"
|
||||
>
|
||||
<Tabs.Panel id="curl" label="cURL" className="flex flex-col gap-4">
|
||||
<CodeBlock language="curl" hideLineNumbers className="self-stretch">
|
||||
{httpRenderError && <Alert className="text-red-900">{httpRenderError.message}</Alert>}
|
||||
<CodeBlock
|
||||
language="curl"
|
||||
hideLineNumbers
|
||||
className={cn(
|
||||
'self-stretch',
|
||||
httpRenderError ? 'opacity-25 pointer-events-none' : ''
|
||||
)}
|
||||
>
|
||||
{curlCommand}
|
||||
</CodeBlock>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel id="http" label="HTTP" className="flex flex-col gap-4">
|
||||
<CodeBlock language="http" hideLineNumbers className="self-stretch">
|
||||
{httpRenderError && <Alert className="text-red-900">{httpRenderError.message}</Alert>}
|
||||
<CodeBlock
|
||||
language="http"
|
||||
hideLineNumbers
|
||||
className={cn(
|
||||
'self-stretch',
|
||||
httpRenderError ? 'opacity-25 pointer-events-none' : ''
|
||||
)}
|
||||
>
|
||||
{rawHttp}
|
||||
</CodeBlock>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel id="js" label="JavaScript">
|
||||
<CodeBlock language="js" hideLineNumbers className="self-stretch">
|
||||
<Tabs.Panel id="js" label="JavaScript" className="flex flex-col gap-4">
|
||||
{supabaseJsRenderError && (
|
||||
<Alert className="text-red-900">{supabaseJsRenderError.message}</Alert>
|
||||
)}
|
||||
<CodeBlock
|
||||
language="js"
|
||||
hideLineNumbers
|
||||
className={cn(
|
||||
'self-stretch',
|
||||
supabaseJsRenderError ? 'opacity-25 pointer-events-none' : ''
|
||||
)}
|
||||
>
|
||||
{jsCommand}
|
||||
</CodeBlock>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4',
|
||||
((currentLanguage === 'http' || currentLanguage === 'curl') && httpRenderError) ||
|
||||
(currentLanguage === 'js' && supabaseJsRenderError)
|
||||
? 'opacity-25 pointer-events-none'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{relevantAssumptions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="my-1 text-base text-inherit">Assumptions</h3>
|
||||
<ol className="my-0 text-foreground">
|
||||
{relevantAssumptions.map((assumption) => (
|
||||
<li>
|
||||
<Markdown className="text-sm">{assumption}</Markdown>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{relevantAssumptions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="my-1 text-base text-inherit">Assumptions</h3>
|
||||
<ol className="my-0 text-foreground">
|
||||
{relevantAssumptions.map((assumption) => (
|
||||
<li>
|
||||
<Markdown className="text-sm">{assumption}</Markdown>
|
||||
</li>
|
||||
{relevantFaqs.length > 0 && (
|
||||
<>
|
||||
<h3 className="my-1 text-base text-inherit">FAQs</h3>
|
||||
{relevantFaqs.map((faq) => (
|
||||
<Collapsible
|
||||
key={faq.id}
|
||||
className="flex flex-col items-stretch justify-start bg-surface-100 rounded border border-default px-4"
|
||||
>
|
||||
<Collapsible.Trigger asChild>
|
||||
<button type="button" className="flex justify-between items-center p-3">
|
||||
<Markdown
|
||||
className="text-sm text-left"
|
||||
components={{
|
||||
p: ({ children }: PropsWithChildren) => <p className="m-0">{children}</p>,
|
||||
}}
|
||||
>
|
||||
{faq.question}
|
||||
</Markdown>
|
||||
<ChevronUp className="transition data-open-parent:rotate-0 data-closed-parent:rotate-180" />
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div className="text-foreground flex flex-col justify-start items-center px-3 pb-4">
|
||||
<Markdown
|
||||
className="text-sm"
|
||||
components={{
|
||||
code: (props: any) => <CodeBlock hideLineNumbers {...props} />,
|
||||
}}
|
||||
>
|
||||
{faq.answer}
|
||||
</Markdown>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{relevantFaqs.length > 0 && (
|
||||
<>
|
||||
<h3 className="my-1 text-base text-inherit">FAQs</h3>
|
||||
{relevantFaqs.map((faq) => (
|
||||
<Collapsible
|
||||
key={faq.id}
|
||||
className="flex flex-col items-stretch justify-start bg-surface-100 rounded border border-default px-4"
|
||||
>
|
||||
<Collapsible.Trigger asChild>
|
||||
<button type="button" className="flex justify-between items-center p-3">
|
||||
<Markdown
|
||||
className="text-sm text-left"
|
||||
components={{
|
||||
p: ({ children }: PropsWithChildren) => <p className="m-0">{children}</p>,
|
||||
}}
|
||||
>
|
||||
{faq.question}
|
||||
</Markdown>
|
||||
<ChevronUp className="transition data-open-parent:rotate-0 data-closed-parent:rotate-180" />
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div className="text-foreground flex flex-col justify-start items-center px-3 pb-4">
|
||||
<Markdown
|
||||
className="text-sm"
|
||||
components={{
|
||||
code: (props: any) => <CodeBlock hideLineNumbers {...props} />,
|
||||
}}
|
||||
>
|
||||
{faq.answer}
|
||||
</Markdown>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -327,7 +427,3 @@ function getTheme(isDarkMode: boolean): editor.IStandaloneThemeData {
|
||||
colors: { 'editor.background': isDarkMode ? '#1f1f1f' : '#f0f0f0' },
|
||||
}
|
||||
}
|
||||
|
||||
function sentenceCase(value: string) {
|
||||
return value[0].toUpperCase() + value.slice(1)
|
||||
}
|
||||
Reference in new issue
Block a user