├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── assets ├── diagram-dark.png └── diagram-light.png ├── package-lock.json ├── package.json ├── src ├── errors.ts ├── index.ts ├── processor │ ├── aggregate.ts │ ├── filter.ts │ ├── index.ts │ ├── limit.ts │ ├── select.ts │ ├── sort.ts │ ├── types.ts │ └── util.ts └── renderers │ ├── http.test.ts │ ├── http.ts │ ├── supabase-js.test.ts │ ├── supabase-js.ts │ └── util.ts ├── tsconfig.json └── tsup.config.ts /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [18.x, 20.x, 22.x, 24.x] 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | 29 | - name: Install dependencies 30 | run: npm ci 31 | 32 | - name: Run tests 33 | run: npm test 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "endOfLine": "lf" 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Supabase 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SQL to PostgREST translator 2 | 3 | [![Tests](https://github.com/supabase-community/sql-to-rest/actions/workflows/tests.yml/badge.svg)](https://github.com/supabase-community/sql-to-rest/actions?query=branch%3Amain) 4 | [![Package](https://img.shields.io/npm/v/@supabase/sql-to-rest)](https://www.npmjs.com/package/@supabase/sql-to-rest) 5 | [![License: MIT](https://img.shields.io/npm/l/@supabase/sql-to-rest)](#license) 6 | 7 | TypeScript library that translates SQL queries to the equivalent [PostgREST](https://github.com/PostgREST/postgrest)-compitable HTTP requests and client library code. Works on both browser and server. 8 | 9 | ### What is PostgREST? 10 | 11 | [PostgREST](https://postgrest.org/) is a REST API that auto-generates endpoints based on relations in your database (`public` schema only by default). It uses JWTs and RLS policies to handle authorization. 12 | 13 | ### How can SQL be converted to REST? 14 | 15 | The PostgREST API supports a lot of SQL-like features including: 16 | 17 | - Vertical filtering (select only the columns you care about) 18 | - Horizontal filtering (filter rows by comparing data in columns: `=`, `>`, `<`, `in`, `like`, etc) 19 | - Sorting 20 | - Limit and offset 21 | - Resource embeddings (joins to other relations using foreign keys) 22 | - Aggregate operations (`count()`, `sum()`, `avg()`, `min()`, `max()`) 23 | - Nested `AND`/`OR` expressions 24 | - Aliasing and casting 25 | - JSON columns (selecting, filtering, and sorting) 26 | 27 | This library takes the SQL input and translates it to 1-to-1 to the equivalent PostgREST syntax. Any unsupported SQL will [throw an error](#sql-is-a-very-open-language---how-can-it-all-translate-to-rest). 28 | 29 | ### Example 30 | 31 | The following SQL: 32 | 33 | ```sql 34 | select 35 | title, 36 | description 37 | from 38 | books 39 | where 40 | description ilike '%cheese%' 41 | order by 42 | title desc 43 | limit 44 | 5 45 | offset 46 | 10 47 | ``` 48 | 49 | Will get translated to: 50 | 51 | _cURL_ 52 | 53 | ```shell 54 | curl -G http://localhost:54321/rest/v1/books \ 55 | -d "select=title,description" \ 56 | -d "description=ilike.*cheese*" \ 57 | -d "order=title.desc" \ 58 | -d "limit=5" \ 59 | -d "offset=10" 60 | ``` 61 | 62 | _Raw HTTP_ 63 | 64 | ```http 65 | GET /rest/v1/books?select=title,description&description=ilike.*cheese*&order=title.desc&limit=5&offset=10 HTTP/1.1 66 | Host: localhost:54321 67 | ``` 68 | 69 | _supabase-js_ 70 | 71 | ```js 72 | const { data, error } = await supabase 73 | .from('books') 74 | .select( 75 | ` 76 | title, 77 | description 78 | ` 79 | ) 80 | .ilike('description', '%cheese%') 81 | .order('title', { ascending: false }) 82 | .range(10, 15) 83 | ``` 84 | 85 | ## Install 86 | 87 | ```shell 88 | npm i @supabase/sql-to-rest 89 | ``` 90 | 91 | ```shell 92 | yarn add @supabase/sql-to-rest 93 | ``` 94 | 95 | ## Usage 96 | 97 | _Note: This library is pre-1.0, so expect slight API changes over time._ 98 | 99 | ```js 100 | import { processSql, renderHttp, formatCurl } from '@supabase/sql-to-rest' 101 | 102 | // Process SQL into intermediate PostgREST AST 103 | const statement = await processSql(` 104 | select 105 | * 106 | from 107 | books 108 | `) 109 | 110 | // Render the AST into an HTTP request 111 | const httpRequest = await renderHttp(statement) 112 | 113 | // Format the HTTP request as a cURL command (requires base URL) 114 | const curlCommand = formatCurl('http://localhost:54321/rest/v1', httpRequest) 115 | 116 | console.log(curlCommand) 117 | // curl http://localhost:54321/rest/v1/books 118 | 119 | // Or use it directly 120 | const response = await fetch(`http://localhost:54321/rest/v1${httpRequest.fullPath}`, { 121 | method: httpRequest.method, 122 | }) 123 | ``` 124 | 125 | ### `processSql()` 126 | 127 | Takes a SQL string and converts it into a PostgREST abstract syntax tree (AST) called a `Statement`. This is an intermediate object that can later be rendered to your language/protocol of choice. 128 | 129 | ```js 130 | import { processSql } from '@supabase/sql-to-rest' 131 | 132 | const statement = await processSql(` 133 | select 134 | * 135 | from 136 | books 137 | `) 138 | ``` 139 | 140 | Outputs a `Promise`: 141 | 142 | ```js 143 | { 144 | type: 'select', 145 | from: 'books', 146 | targets: [ 147 | { 148 | type: 'column-target', 149 | column: '*', 150 | alias: undefined, 151 | }, 152 | ], 153 | filter: undefined, 154 | sorts: [], 155 | limit: undefined 156 | } 157 | ``` 158 | 159 | ### `renderHttp()` 160 | 161 | Takes the intermediate `Statement` and renders it as an HTTP request. 162 | 163 | ```js 164 | import { processSql, renderHttp } from '@supabase/sql-to-rest' 165 | 166 | const statement = await processSql(` 167 | select 168 | * 169 | from 170 | books 171 | `) 172 | 173 | const httpRequest = await renderHttp(statement) 174 | ``` 175 | 176 | Outputs a `Promise`: 177 | 178 | ```js 179 | { 180 | method: 'GET', 181 | path: '/books', 182 | params: URLSearchParams {}, 183 | fullPath: [Getter] // combines path with the query params 184 | } 185 | ``` 186 | 187 | An `HttpRequest` can also be formatted as a `cURL` command or as raw HTTP. 188 | 189 | #### cURL command 190 | 191 | ```js 192 | import { 193 | // ... 194 | formatCurl, 195 | } from '@supabase/sql-to-rest' 196 | 197 | // ... 198 | 199 | const curlCommand = formatCurl('http://localhost:54321/rest/v1', httpRequest) 200 | ``` 201 | 202 | Outputs: 203 | 204 | ```shell 205 | curl http://localhost:54321/rest/v1/books 206 | ``` 207 | 208 | #### Raw HTTP 209 | 210 | ```js 211 | import { 212 | // ... 213 | formatHttp, 214 | } from '@supabase/sql-to-rest' 215 | 216 | // ... 217 | 218 | const rawHttp = formatHttp('http://localhost:54321/rest/v1', httpRequest) 219 | ``` 220 | 221 | Outputs: 222 | 223 | ```http 224 | GET /rest/v1/books HTTP/1.1 225 | Host: localhost:54321 226 | ``` 227 | 228 | ### `renderSupabaseJs()` 229 | 230 | Takes the intermediate `Statement` and renders it as [`supabase-js`](https://github.com/supabase/supabase-js) client code. 231 | 232 | ```js 233 | import { processSql, renderSupabaseJs } from '@supabase/sql-to-rest' 234 | 235 | const statement = await processSql(` 236 | select 237 | * 238 | from 239 | books 240 | `) 241 | 242 | const { code } = await renderSupabaseJs(statement) 243 | ``` 244 | 245 | Outputs a `Promise`, where `code` contains: 246 | 247 | ```js 248 | const { data, error } = await supabase.from('books').select() 249 | ``` 250 | 251 | The rendered JS code is automatically formatted using `prettier`. 252 | 253 | ## How does it work? 254 | 255 | 256 | 257 | SQL to REST diagram 258 | 259 | 260 | 1. The SQL string is parsed into a PostgreSQL abstract syntax tree (AST) using [`pg-parser`](https://github.com/supabase-community/pg-parser), a JavaScript SQL parser that uses C code from the official PostgreSQL codebase (compiled to WASM). Supports Postgres 17 syntax. 261 | 2. The PostgreSQL AST is translated into a much smaller and simpler PostgREST AST. Since PostgREST supports a subset of SQL syntax, any unsupported SQL operation will throw an `UnsupportedError` with a description of exactly what wasn't supported. 262 | 3. The intermediate PostgREST AST can be rendered to your language/protocol of choice. Currently supports HTTP (with `cURL` and raw HTTP formatters), and [`supabase-js`](https://github.com/supabase/supabase-js) code (which wraps PostgREST). Other languages are on the roadmap (PR's welcome!) 263 | 264 | ## Roadmap 265 | 266 | ### SQL features 267 | 268 | #### Statements 269 | 270 | - [x] `select` statements ([`GET` requests](https://postgrest.org/en/latest/references/api/tables_views.html#read)) 271 | - [ ] `insert` statements ([`POST` requests](https://postgrest.org/en/latest/references/api/tables_views.html#insert)) 272 | - [ ] `on conflict update` ([upsert](https://postgrest.org/en/latest/references/api/tables_views.html#upsert)) 273 | - [ ] `update` statements ([`PATCH` requests](https://postgrest.org/en/latest/references/api/tables_views.html#update)) 274 | - [ ] `delete` statements ([`DELETE` requests](https://postgrest.org/en/latest/references/api/tables_views.html#delete)) 275 | - [ ] `explain` statements ([Execution plan](https://postgrest.org/en/latest/references/observability.html#execution-plan)) 276 | 277 | #### [Filters](https://postgrest.org/en/latest/references/api/tables_views.html#horizontal-filtering) 278 | 279 | ##### Column operators 280 | 281 | - [x] `=` (`eq`) 282 | - [x] `>` (`gt`) 283 | - [x] `>=` (`gte`) 284 | - [x] `<` (`lt`) 285 | - [x] `<=` (`lte`) 286 | - [x] `<>` or `!=` (`neq`) 287 | - [x] `like` (`like`) 288 | - [x] `ilike` (`ilike`) 289 | - [x] `~` (`match`) 290 | - [x] `~*` (`imatch`) 291 | - [x] `in` (`in`) 292 | - [ ] `is` (`is`): _partial support, only `is null` for now_ 293 | - [ ] `is distinct from` (`isdistinct`) 294 | - [x] `@@` (`fts`, `plfts`, `phfts`, `wfts`) 295 | - [ ] `@>` (`cs`) 296 | - [ ] `<@` (`cd`) 297 | - [ ] `&&` (`ov`) 298 | - [ ] `<<` (`sl`) 299 | - [ ] `>>` (`sr`) 300 | - [ ] `&<` (`nxr`) 301 | - [ ] `&>` (`nxl`) 302 | - [ ] `-|-` (`adj`) 303 | 304 | ##### Logical operators 305 | 306 | - [x] `not` (`not`) 307 | - [x] `or` (`or`) 308 | - [x] `and` (`and`) 309 | - [ ] `all` (`all`) 310 | - [ ] `any` (`any`) 311 | 312 | #### [Ordering](https://postgrest.org/en/latest/references/api/tables_views.html#ordering) 313 | 314 | - [x] `asc` (`asc`) 315 | - [x] `desc` (`desc`) 316 | - [x] `nulls first` (`nullsfirst`) 317 | - [x] `nulls last` (`nullslast`) 318 | 319 | #### [Pagination](https://postgrest.org/en/latest/references/api/pagination_count.html) 320 | 321 | - [x] `limit` (`limit`) 322 | - [x] `offset` (`offset`) 323 | - [ ] HTTP range headers 324 | 325 | #### [Aggregates](https://postgrest.org/en/latest/references/api/aggregate_functions.html) 326 | 327 | ##### Functions 328 | 329 | - [x] `count()` 330 | - [x] `sum()` 331 | - [x] `avg()` 332 | - [x] `max()` 333 | - [x] `min()` 334 | 335 | ##### Features 336 | 337 | - [x] aggregate over entire table 338 | - [x] aggregate on joined table column 339 | - [x] aggregate with `group by` 340 | - [x] aggregate with `group by` on joined table column 341 | 342 | #### [Joins](https://postgrest.org/en/latest/references/api/resource_embedding.html) (Resource Embedding) 343 | 344 | SQL joins are supported using PostgREST resource embeddings with the [spread `...` syntax](https://postgrest.org/en/latest/references/api/resource_embedding.html#spread-embedded-resource) (flattens joined table into primary table). 345 | 346 | #### [Aliases](https://postgrest.org/en/latest/references/api/tables_views.html#renaming-columns) 347 | 348 | - [x] column aliases 349 | - [x] table aliases 350 | 351 | #### [Casts](https://postgrest.org/en/latest/references/api/tables_views.html#casting-columns) 352 | 353 | - [x] column casts (in select target only) 354 | - [x] aggregate function casts (in select target only) 355 | 356 | #### [JSON columns](https://postgrest.org/en/latest/references/api/tables_views.html#json-columns) 357 | 358 | JSON columns (eg. `select metadata->'person'->>'name'`) are supported in the following places: 359 | 360 | - [x] select targets 361 | - [x] filters 362 | - [x] sorts 363 | 364 | ### Renderers 365 | 366 | - [x] HTTP 367 | - [x] cURL formatter 368 | - [x] Raw HTTP formatter 369 | - [x] [`supabase-js`](https://github.com/supabase/supabase-js) 370 | - [ ] [`supabase-flutter`](https://github.com/supabase/supabase-flutter) 371 | - [ ] [`supabase-swift`](https://github.com/supabase/supabase-swift) 372 | - [ ] [`supabase-py`](https://github.com/supabase-community/supabase-py) 373 | - [ ] [`supabase-csharp`](https://github.com/supabase-community/postgrest-csharp) 374 | - [ ] [`supabase-kt`](https://github.com/supabase-community/supabase-kt) 375 | 376 | ## FAQs 377 | 378 | ### Are you parsing SQL from scratch? 379 | 380 | Thankfully no. We use [`pg-parser`](https://github.com/supabase-community/pg-parser) which compiles source code from the real PostgreSQL parser to WASM and wraps it in JavaScript bindings. 381 | 382 | This means we never have to worry about the SQL itself getting parsed incorrectly - it uses the exact same code as the actual PostgreSQL database. This library uses code from PostgreSQL 17. 383 | 384 | ### SQL is a very open language - how can it all translate to REST? 385 | 386 | It can't. PostgREST only supports a subset of SQL-like features (by design), so this library only translates features that can be mapped 1-to-1. 387 | 388 | When it detects SQL that doesn't translate (eg. sub-queries), it will throw an `UnsupportedError` with a description of exactly what couldn't be translated. 389 | 390 | ### How can I be confident that my SQL is translating correctly? 391 | 392 | We've built [unit tests](./src/renderers/http.test.ts) for every feature supported. The vast majority of PostgREST features have been implemented, but it doesn't cover 100% yet (see [Roadmap](#roadmap)). If you discover an error in the translation, please [submit an issue](https://github.com/supabase-community/sql-to-rest/issues/new/choose). 393 | 394 | ## License 395 | 396 | MIT 397 | -------------------------------------------------------------------------------- /assets/diagram-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/sql-to-rest/3f1c517ca1aad34d36cc5aec24b14c9230c007fd/assets/diagram-dark.png -------------------------------------------------------------------------------- /assets/diagram-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/sql-to-rest/3f1c517ca1aad34d36cc5aec24b14c9230c007fd/assets/diagram-light.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@supabase/sql-to-rest", 3 | "version": "0.1.8", 4 | "main": "./dist/index.cjs", 5 | "types": "./dist/index.d.ts", 6 | "license": "MIT", 7 | "type": "module", 8 | "homepage": "https://github.com/supabase-community/sql-to-rest#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/supabase-community/sql-to-rest.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/supabase-community/sql-to-rest/issues/new/choose" 15 | }, 16 | "keywords": [ 17 | "SQL", 18 | "REST", 19 | "PostgREST", 20 | "PostgreSQL", 21 | "translator" 22 | ], 23 | "files": [ 24 | "dist/**/*" 25 | ], 26 | "exports": { 27 | ".": { 28 | "import": "./dist/index.js", 29 | "types": "./dist/index.d.ts", 30 | "default": "./dist/index.cjs" 31 | } 32 | }, 33 | "sideEffects": false, 34 | "scripts": { 35 | "build": "tsup --clean", 36 | "typecheck": "tsc --noEmit", 37 | "test": "vitest", 38 | "prepublishOnly": "npm run build" 39 | }, 40 | "dependencies": { 41 | "@babel/parser": "^7.24.5", 42 | "@supabase/pg-parser": "^0.1.1", 43 | "prettier": "^3.2.5" 44 | }, 45 | "devDependencies": { 46 | "@total-typescript/tsconfig": "^1.0.4", 47 | "@types/common-tags": "^1.8.4", 48 | "common-tags": "^1.8.2", 49 | "sql-formatter": "^15.0.2", 50 | "tsup": "^8.0.2", 51 | "typescript": "^5.4.3", 52 | "vitest": "^3.2.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export class ParsingError extends Error { 2 | override name = 'ParsingError' 3 | 4 | constructor( 5 | message: string, 6 | public hint?: string 7 | ) { 8 | super(sentenceCase(message)) 9 | } 10 | } 11 | 12 | export class UnimplementedError extends Error { 13 | override name = 'UnimplementedError' 14 | } 15 | 16 | export class UnsupportedError extends Error { 17 | override name = 'UnsupportedError' 18 | 19 | constructor( 20 | message: string, 21 | public hint?: string 22 | ) { 23 | super(message) 24 | } 25 | } 26 | 27 | export class RenderError extends Error { 28 | override name = 'RenderError' 29 | 30 | constructor( 31 | message: string, 32 | public renderer: 'http' | 'supabase-js' 33 | ) { 34 | super(message) 35 | } 36 | } 37 | 38 | export function sentenceCase(value: string) { 39 | if (typeof value !== 'string') { 40 | throw new TypeError('Expected a string') 41 | } 42 | 43 | if (value.length === 0) { 44 | return value 45 | } 46 | 47 | return value[0]!.toUpperCase() + value.slice(1) 48 | } 49 | 50 | /** 51 | * Returns hints for common parsing errors. 52 | */ 53 | export function getParsingErrorHint(message: string) { 54 | switch (message) { 55 | case 'syntax error at or near "from"': 56 | return 'Did you leave a trailing comma in the select target list?' 57 | case 'syntax error at or near "where"': 58 | return 'Do you have an incomplete join in the FROM clause?' 59 | default: 60 | undefined 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './errors.js' 2 | export * from './processor/index.js' 3 | export * from './renderers/http.js' 4 | export * from './renderers/supabase-js.js' 5 | -------------------------------------------------------------------------------- /src/processor/aggregate.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnRef } from '@supabase/pg-parser/17/types' 2 | import { UnsupportedError } from '../errors.js' 3 | import type { Relations, Target } from './types.js' 4 | import { everyTarget, renderFields, someTarget } from './util.js' 5 | 6 | export function validateGroupClause( 7 | groupClause: ColumnRef[], 8 | targets: Target[], 9 | relations: Relations 10 | ) { 11 | const groupByColumns = groupClause.map((columnRef) => { 12 | if (!columnRef.fields) { 13 | throw new UnsupportedError('Group by clause must contain at least one column') 14 | } 15 | return renderFields(columnRef.fields, relations) ?? [] 16 | }) 17 | 18 | if ( 19 | !groupByColumns.every((column) => 20 | someTarget(targets, (target, parent) => { 21 | // The `count()` special case aggregate has no column attached 22 | if (!('column' in target)) { 23 | return false 24 | } 25 | 26 | const path = parent 27 | ? // joined columns have to be prefixed with their relation 28 | [parent.alias && !parent.flatten ? parent.alias : parent.relation, target.column] 29 | : // top-level columns will have no prefix 30 | [target.column] 31 | 32 | const qualifiedName = path.join('.') 33 | return qualifiedName === column 34 | }) 35 | ) 36 | ) { 37 | throw new UnsupportedError(`Every group by column must also exist as a select target`) 38 | } 39 | 40 | if ( 41 | someTarget(targets, (target) => target.type === 'aggregate-target') && 42 | !everyTarget(targets, (target, parent) => { 43 | if (target.type === 'aggregate-target') { 44 | return true 45 | } 46 | 47 | const path = parent 48 | ? // joined columns have to be prefixed with their relation 49 | [parent.alias && !parent.flatten ? parent.alias : parent.relation, target.column] 50 | : // top-level columns will have no prefix 51 | [target.column] 52 | 53 | const qualifiedName = path.join('.') 54 | 55 | return groupByColumns.some((column) => qualifiedName === column) 56 | }) 57 | ) { 58 | throw new UnsupportedError( 59 | `Every non-aggregate select target must also exist in a group by clause` 60 | ) 61 | } 62 | 63 | if ( 64 | groupByColumns.length > 0 && 65 | !someTarget(targets, (target) => target.type === 'aggregate-target') 66 | ) { 67 | throw new UnsupportedError( 68 | `There must be at least one aggregate function in the select target list when using group by` 69 | ) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/processor/filter.ts: -------------------------------------------------------------------------------- 1 | import type { A_Expr_Kind, Node } from '@supabase/pg-parser/17/types' 2 | import { UnsupportedError } from '../errors.js' 3 | import type { ColumnFilter, Filter, Relations } from './types.js' 4 | import { parseConstant, processJsonTarget, renderFields } from './util.js' 5 | 6 | export function processWhereClause(expression: Node, relations: Relations): Filter { 7 | if ('A_Expr' in expression) { 8 | let column: string 9 | 10 | if (!expression.A_Expr.name || expression.A_Expr.name.length > 1) { 11 | throw new UnsupportedError('Only one operator name supported per expression') 12 | } 13 | 14 | const kind = expression.A_Expr.kind 15 | 16 | if (!kind) { 17 | throw new UnsupportedError('WHERE clause must have an operator kind') 18 | } 19 | 20 | const [name] = expression.A_Expr.name 21 | 22 | if (!name) { 23 | throw new UnsupportedError('WHERE clause must have an operator name') 24 | } 25 | 26 | if (!('String' in name)) { 27 | throw new UnsupportedError('WHERE clause operator name must be a string') 28 | } 29 | 30 | if (!name.String.sval) { 31 | throw new UnsupportedError('WHERE clause operator name cannot be empty') 32 | } 33 | 34 | const operatorSymbol = name.String.sval.toLowerCase() 35 | const operator = mapOperatorSymbol(kind, operatorSymbol) 36 | 37 | if (!expression.A_Expr.lexpr) { 38 | throw new UnsupportedError('Left side of WHERE clause must be a column or expression') 39 | } 40 | 41 | if ('A_Expr' in expression.A_Expr.lexpr) { 42 | try { 43 | const target = processJsonTarget(expression.A_Expr.lexpr.A_Expr, relations) 44 | column = target.column 45 | } catch (err) { 46 | throw new UnsupportedError(`Left side of WHERE clause must be a column`) 47 | } 48 | } else if ('ColumnRef' in expression.A_Expr.lexpr) { 49 | const { fields } = expression.A_Expr.lexpr.ColumnRef 50 | if (!fields || fields.length === 0) { 51 | throw new UnsupportedError(`Left side of WHERE clause must reference a column`) 52 | } 53 | column = renderFields(fields, relations) 54 | } else if ('TypeCast' in expression.A_Expr.lexpr) { 55 | throw new UnsupportedError('Casting is not supported in the WHERE clause') 56 | } else if ('FuncCall' in expression.A_Expr.lexpr) { 57 | if (!expression.A_Expr.lexpr.FuncCall.funcname) { 58 | throw new UnsupportedError(`Left side of WHERE clause must reference a column`) 59 | } 60 | const functionName = renderFields(expression.A_Expr.lexpr.FuncCall.funcname, relations) 61 | 62 | // Only 'to_tsvector' function is supported on left side of WHERE clause (when using FTS `@@` operator)) 63 | if (operator === 'fts') { 64 | if (functionName === 'to_tsvector') { 65 | if ( 66 | !expression.A_Expr.lexpr.FuncCall.args || 67 | expression.A_Expr.lexpr.FuncCall.args.length !== 1 68 | ) { 69 | throw new UnsupportedError(`${functionName} requires 1 column argument`) 70 | } 71 | 72 | // We grab the column passed to `to_tsvector` and discard the `to_tsvector` function 73 | // We can do this because Postgres will implicitly wrap text columns in `to_tsvector` at query time 74 | const [arg] = expression.A_Expr.lexpr.FuncCall.args 75 | 76 | if (!arg) { 77 | throw new UnsupportedError(`${functionName} requires a column argument`) 78 | } 79 | 80 | if ('A_Expr' in arg) { 81 | try { 82 | const target = processJsonTarget(arg.A_Expr, relations) 83 | column = target.column 84 | } catch (err) { 85 | throw new UnsupportedError(`${functionName} requires a column argument`) 86 | } 87 | } else if ('ColumnRef' in arg) { 88 | const { fields } = arg.ColumnRef 89 | if (!fields) { 90 | throw new UnsupportedError(`${functionName} requires a column argument`) 91 | } 92 | column = renderFields(fields, relations) 93 | } else if ('TypeCast' in arg) { 94 | throw new UnsupportedError('Casting is not supported in the WHERE clause') 95 | } else { 96 | throw new UnsupportedError(`${functionName} requires a column argument`) 97 | } 98 | } else { 99 | throw new UnsupportedError( 100 | `Only 'to_tsvector' function allowed on left side of text search operator` 101 | ) 102 | } 103 | } else { 104 | throw new UnsupportedError(`Left side of WHERE clause must be a column`) 105 | } 106 | } else { 107 | throw new UnsupportedError(`Left side of WHERE clause must be a column`) 108 | } 109 | 110 | if ( 111 | operator === 'eq' || 112 | operator === 'neq' || 113 | operator === 'gt' || 114 | operator === 'gte' || 115 | operator === 'lt' || 116 | operator === 'lte' 117 | ) { 118 | if (!expression.A_Expr.rexpr) { 119 | throw new UnsupportedError( 120 | `Right side of WHERE clause '${operatorSymbol}' expression must be present` 121 | ) 122 | } 123 | 124 | if (!('A_Const' in expression.A_Expr.rexpr)) { 125 | throw new UnsupportedError( 126 | `Right side of WHERE clause '${operatorSymbol}' expression must be a constant`, 127 | `Did you forget to wrap your value in single quotes?` 128 | ) 129 | } 130 | 131 | const value = parseConstant(expression.A_Expr.rexpr.A_Const) 132 | return { 133 | type: 'column', 134 | column, 135 | operator, 136 | negate: false, 137 | value, 138 | } 139 | } 140 | // Between is not supported by PostgREST, but we can generate the equivalent using '>=' and '<=' 141 | else if ( 142 | operator === 'between' || 143 | operator === 'between symmetric' || 144 | operator === 'not between' || 145 | operator === 'not between symmetric' 146 | ) { 147 | if (!expression.A_Expr.rexpr) { 148 | throw new UnsupportedError( 149 | `Right side of WHERE clause '${operatorSymbol}' expression must be present` 150 | ) 151 | } 152 | 153 | if ( 154 | !('List' in expression.A_Expr.rexpr) || 155 | expression.A_Expr.rexpr.List.items?.length !== 2 156 | ) { 157 | throw new UnsupportedError( 158 | `Right side of WHERE clause '${operatorSymbol}' expression must contain two constants` 159 | ) 160 | } 161 | 162 | let [leftValue, rightValue] = expression.A_Expr.rexpr.List.items.map((item) => { 163 | if (!('A_Const' in item)) { 164 | throw new UnsupportedError( 165 | `Right side of WHERE clause '${operatorSymbol}' expression must contain two constants` 166 | ) 167 | } 168 | return parseConstant(item.A_Const) 169 | }) 170 | 171 | // 'between symmetric' doesn't care which argument comes first order-wise, 172 | // ie. it auto swaps the arguments if the left value is greater than the right value 173 | if (operator.includes('symmetric')) { 174 | // We can only implement the symmetric logic if the values are numbers 175 | // If they're strings, they could be dates, text columns, etc which we can't sort here 176 | if (typeof leftValue !== 'number' || typeof rightValue !== 'number') { 177 | throw new UnsupportedError(`BETWEEN SYMMETRIC is only supported with number values`) 178 | } 179 | 180 | // If the left value is greater than the right, swap them 181 | if (leftValue > rightValue) { 182 | const temp = rightValue 183 | rightValue = leftValue 184 | leftValue = temp 185 | } 186 | } 187 | 188 | if (!leftValue) { 189 | throw new UnsupportedError( 190 | `Left side of WHERE clause '${operatorSymbol}' expression must be a constant` 191 | ) 192 | } 193 | 194 | const leftFilter: ColumnFilter = { 195 | type: 'column', 196 | column, 197 | operator: 'gte', 198 | negate: false, 199 | value: leftValue, 200 | } 201 | 202 | if (!rightValue) { 203 | throw new UnsupportedError( 204 | `Right side of WHERE clause '${operatorSymbol}' expression must be a constant` 205 | ) 206 | } 207 | 208 | const rightFilter: ColumnFilter = { 209 | type: 'column', 210 | column, 211 | operator: 'lte', 212 | negate: false, 213 | value: rightValue, 214 | } 215 | 216 | return { 217 | type: 'logical', 218 | operator: 'and', 219 | negate: operator.includes('not'), 220 | values: [leftFilter, rightFilter], 221 | } 222 | } else if ( 223 | operator === 'like' || 224 | operator === 'ilike' || 225 | operator === 'match' || 226 | operator === 'imatch' 227 | ) { 228 | if (!expression.A_Expr.rexpr) { 229 | throw new UnsupportedError( 230 | `Right side of WHERE clause '${operatorSymbol}' expression must be present` 231 | ) 232 | } 233 | 234 | if ( 235 | !('A_Const' in expression.A_Expr.rexpr) || 236 | !('sval' in expression.A_Expr.rexpr.A_Const) || 237 | !expression.A_Expr.rexpr.A_Const.sval?.sval 238 | ) { 239 | throw new UnsupportedError( 240 | `Right side of WHERE clause '${operator}' expression must be a string constant` 241 | ) 242 | } 243 | 244 | const value = expression.A_Expr.rexpr.A_Const.sval.sval 245 | 246 | return { 247 | type: 'column', 248 | column, 249 | operator, 250 | negate: false, 251 | value, 252 | } 253 | } else if (operator === 'in') { 254 | if (!expression.A_Expr.rexpr) { 255 | throw new UnsupportedError( 256 | `Right side of WHERE clause '${operatorSymbol}' expression must be present` 257 | ) 258 | } 259 | 260 | if ( 261 | !('List' in expression.A_Expr.rexpr) || 262 | !expression.A_Expr.rexpr.List.items?.every((item) => 'A_Const' in item) 263 | ) { 264 | throw new UnsupportedError( 265 | `Right side of WHERE clause '${operator}' expression must be a list of constants` 266 | ) 267 | } 268 | 269 | const value = expression.A_Expr.rexpr.List.items.map((item) => parseConstant(item.A_Const)) 270 | 271 | return { 272 | type: 'column', 273 | column, 274 | operator, 275 | negate: false, 276 | value, 277 | } 278 | } else if (operator === 'fts') { 279 | const supportedTextSearchFunctions = [ 280 | 'to_tsquery', 281 | 'plainto_tsquery', 282 | 'phraseto_tsquery', 283 | 'websearch_to_tsquery', 284 | ] 285 | 286 | if (!expression.A_Expr.rexpr) { 287 | throw new UnsupportedError( 288 | `Right side of WHERE clause '${operatorSymbol}' expression must be present` 289 | ) 290 | } 291 | 292 | if (!('FuncCall' in expression.A_Expr.rexpr) || !expression.A_Expr.rexpr.FuncCall.funcname) { 293 | throw new UnsupportedError( 294 | `Right side of WHERE clause '${operatorSymbol}' expression must be one of these functions: ${supportedTextSearchFunctions.join(', ')}` 295 | ) 296 | } 297 | 298 | const functionName = renderFields(expression.A_Expr.rexpr.FuncCall.funcname, relations) 299 | 300 | if (!supportedTextSearchFunctions.includes(functionName)) { 301 | throw new UnsupportedError( 302 | `Right side of WHERE clause '${operatorSymbol}' expression must be one of these functions: ${supportedTextSearchFunctions.join(', ')}` 303 | ) 304 | } 305 | 306 | if ( 307 | !expression.A_Expr.rexpr.FuncCall.args || 308 | expression.A_Expr.rexpr.FuncCall.args.length === 0 || 309 | expression.A_Expr.rexpr.FuncCall.args.length > 2 310 | ) { 311 | throw new UnsupportedError(`${functionName} requires 1 or 2 arguments`) 312 | } 313 | 314 | const args = expression.A_Expr.rexpr.FuncCall.args.map((arg) => { 315 | if (!('A_Const' in arg) || !arg.A_Const.sval?.sval) { 316 | throw new UnsupportedError(`${functionName} only accepts text arguments`) 317 | } 318 | 319 | return arg.A_Const.sval.sval 320 | }) 321 | 322 | // config (eg. 'english') is the first argument if passed 323 | const [config] = args.slice(-2, -1) 324 | 325 | // query is always the last argument 326 | const [query] = args.slice(-1) 327 | 328 | if (!query) { 329 | throw new UnsupportedError(`${functionName} requires a query argument`) 330 | } 331 | 332 | // Adjust operator based on FTS function 333 | const operator = mapTextSearchFunction(functionName) 334 | 335 | return { 336 | type: 'column', 337 | column, 338 | operator, 339 | config, 340 | value: query, 341 | negate: false, 342 | } 343 | } else { 344 | throw new UnsupportedError(`Unsupported operator '${operatorSymbol}'`) 345 | } 346 | } else if ('NullTest' in expression) { 347 | if (!expression.NullTest.arg || !('ColumnRef' in expression.NullTest.arg)) { 348 | throw new UnsupportedError(`NullTest expression must have an argument of type ColumnRef`) 349 | } 350 | 351 | const { fields } = expression.NullTest.arg.ColumnRef 352 | 353 | if (!fields) { 354 | throw new UnsupportedError(`NullTest expression must reference a column`) 355 | } 356 | 357 | const column = renderFields(fields, relations) 358 | const negate = expression.NullTest.nulltesttype === 'IS_NOT_NULL' 359 | const operator = 'is' 360 | const value = null 361 | 362 | return { 363 | type: 'column', 364 | column, 365 | operator, 366 | negate, 367 | value, 368 | } 369 | } else if ('BoolExpr' in expression) { 370 | let operator: 'and' | 'or' | 'not' 371 | 372 | if (expression.BoolExpr.boolop === 'AND_EXPR') { 373 | operator = 'and' 374 | } else if (expression.BoolExpr.boolop === 'OR_EXPR') { 375 | operator = 'or' 376 | } else if (expression.BoolExpr.boolop === 'NOT_EXPR') { 377 | operator = 'not' 378 | } else { 379 | throw new UnsupportedError(`Unknown boolop '${expression.BoolExpr.boolop}'`) 380 | } 381 | 382 | if (!expression.BoolExpr.args) { 383 | throw new UnsupportedError(`BoolExpr must have arguments`) 384 | } 385 | 386 | const values = expression.BoolExpr.args.map((arg) => processWhereClause(arg, relations)) 387 | 388 | // The 'not' operator is special - instead of wrapping its child, 389 | // we just return the child directly and set negate=true on it. 390 | if (operator === 'not') { 391 | if (values.length > 1) { 392 | throw new UnsupportedError( 393 | `NOT expressions must have only 1 child, but received ${values.length} children` 394 | ) 395 | } 396 | 397 | const [filter] = values 398 | if (!filter) { 399 | throw new UnsupportedError(`NOT expression must have a child filter`) 400 | } 401 | 402 | filter.negate = true 403 | return filter 404 | } 405 | 406 | return { 407 | type: 'logical', 408 | operator, 409 | negate: false, 410 | values, 411 | } 412 | } else { 413 | throw new UnsupportedError(`The WHERE clause must contain an expression`) 414 | } 415 | } 416 | 417 | function mapOperatorSymbol(kind: A_Expr_Kind, operatorSymbol: string) { 418 | switch (kind) { 419 | case 'AEXPR_OP': { 420 | switch (operatorSymbol) { 421 | case '=': 422 | return 'eq' 423 | case '<>': 424 | return 'neq' 425 | case '>': 426 | return 'gt' 427 | case '>=': 428 | return 'gte' 429 | case '<': 430 | return 'lt' 431 | case '<=': 432 | return 'lte' 433 | case '~': 434 | return 'match' 435 | case '~*': 436 | return 'imatch' 437 | case '@@': 438 | // 'fts' isn't necessarily the final operator (there is also plfts, phfts, wfts) 439 | // we adjust this downstream based on the tsquery function used 440 | return 'fts' 441 | default: 442 | throw new UnsupportedError(`Unsupported operator '${operatorSymbol}'`) 443 | } 444 | } 445 | case 'AEXPR_BETWEEN': 446 | case 'AEXPR_BETWEEN_SYM': 447 | case 'AEXPR_NOT_BETWEEN': 448 | case 'AEXPR_NOT_BETWEEN_SYM': { 449 | switch (operatorSymbol) { 450 | case 'between': 451 | return 'between' 452 | case 'between symmetric': 453 | return 'between symmetric' 454 | case 'not between': 455 | return 'not between' 456 | case 'not between symmetric': 457 | return 'not between symmetric' 458 | default: 459 | throw new UnsupportedError(`Unsupported operator '${operatorSymbol}'`) 460 | } 461 | } 462 | case 'AEXPR_LIKE': { 463 | switch (operatorSymbol) { 464 | case '~~': 465 | return 'like' 466 | default: 467 | throw new UnsupportedError(`Unsupported operator '${operatorSymbol}'`) 468 | } 469 | } 470 | case 'AEXPR_ILIKE': { 471 | switch (operatorSymbol) { 472 | case '~~*': 473 | return 'ilike' 474 | default: 475 | throw new UnsupportedError(`Unsupported operator '${operatorSymbol}'`) 476 | } 477 | } 478 | case 'AEXPR_IN': { 479 | switch (operatorSymbol) { 480 | case '=': 481 | return 'in' 482 | default: 483 | throw new UnsupportedError(`Unsupported operator '${operatorSymbol}'`) 484 | } 485 | } 486 | } 487 | } 488 | 489 | /** 490 | * Maps text search query functions to the respective PostgREST operator. 491 | */ 492 | function mapTextSearchFunction(functionName: string) { 493 | switch (functionName) { 494 | case 'to_tsquery': 495 | return 'fts' 496 | case 'plainto_tsquery': 497 | return 'plfts' 498 | case 'phraseto_tsquery': 499 | return 'phfts' 500 | case 'websearch_to_tsquery': 501 | return 'wfts' 502 | default: 503 | throw new UnsupportedError(`Function '${functionName}' not supported for full-text search`) 504 | } 505 | } 506 | -------------------------------------------------------------------------------- /src/processor/index.ts: -------------------------------------------------------------------------------- 1 | import { PgParser, unwrapParseResult } from '@supabase/pg-parser' 2 | import type { RawStmt } from '@supabase/pg-parser/17/types' 3 | import { 4 | ParsingError, 5 | UnimplementedError, 6 | UnsupportedError, 7 | getParsingErrorHint, 8 | } from '../errors.js' 9 | import { processSelectStatement } from './select.js' 10 | import type { Statement } from './types.js' 11 | 12 | export { supportedAggregateFunctions } from './select.js' 13 | export * from './types.js' 14 | export { everyTarget, flattenTargets, someFilter, someTarget } from './util.js' 15 | 16 | const parser = new PgParser() 17 | 18 | /** 19 | * Coverts SQL into a PostgREST-compatible `Statement`. 20 | * 21 | * Expects SQL to contain only one statement. 22 | * 23 | * @returns An intermediate `Statement` object that 24 | * can be rendered to various targets (HTTP, supabase-js, etc). 25 | */ 26 | export async function processSql(sql: string): Promise { 27 | try { 28 | const result = await unwrapParseResult(parser.parse(sql)) 29 | 30 | if (!result.stmts || result.stmts.length === 0) { 31 | throw new UnsupportedError('Expected a statement, but received none') 32 | } 33 | 34 | if (result.stmts.length > 1) { 35 | throw new UnsupportedError('Expected a single statement, but received multiple') 36 | } 37 | 38 | const [statement] = result.stmts.map((stmt) => { 39 | if (!stmt) { 40 | throw new UnsupportedError('Expected a statement, but received an empty one') 41 | } 42 | 43 | return processStatement(stmt) 44 | }) 45 | 46 | return statement! 47 | } catch (err) { 48 | if (err instanceof Error && 'cursorPosition' in err) { 49 | const hint = getParsingErrorHint(err.message) 50 | const parsingError = new ParsingError(err.message, hint) 51 | 52 | Object.assign(parsingError, err) 53 | throw parsingError 54 | } else { 55 | throw err 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * Converts a pg-query `Stmt` into a PostgREST-compatible `Statement`. 62 | */ 63 | function processStatement({ stmt }: RawStmt): Statement { 64 | if (!stmt) { 65 | throw new UnsupportedError('Expected a statement, but received an empty one') 66 | } 67 | 68 | if ('SelectStmt' in stmt) { 69 | return processSelectStatement(stmt.SelectStmt) 70 | } else if ('InsertStmt' in stmt) { 71 | throw new UnimplementedError(`Insert statements are not yet implemented by the translator`) 72 | } else if ('UpdateStmt' in stmt) { 73 | throw new UnimplementedError(`Update statements are not yet implemented by the translator`) 74 | } else if ('DeleteStmt' in stmt) { 75 | throw new UnimplementedError(`Delete statements are not yet implemented by the translator`) 76 | } else if ('ExplainStmt' in stmt) { 77 | throw new UnimplementedError(`Explain statements are not yet implemented by the translator`) 78 | } else { 79 | const [stmtType] = Object.keys(stmt) 80 | if (!stmtType) { 81 | throw new UnsupportedError('Expected a statement, but received an empty one') 82 | } 83 | const statementType = stmtType.replace(/Stmt$/, '') 84 | throw new UnsupportedError(`${statementType} statements are not supported`) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/processor/limit.ts: -------------------------------------------------------------------------------- 1 | import type { SelectStmt } from '@supabase/pg-parser/17/types' 2 | import { UnsupportedError } from '../errors.js' 3 | import type { Limit } from './types.js' 4 | 5 | export function processLimit(selectStmt: SelectStmt): Limit | undefined { 6 | let count: number | undefined = undefined 7 | let offset: number | undefined = undefined 8 | 9 | if (selectStmt.limitCount) { 10 | if (!('A_Const' in selectStmt.limitCount)) { 11 | throw new UnsupportedError(`Limit count must be an A_Const`) 12 | } 13 | 14 | if (!('ival' in selectStmt.limitCount.A_Const)) { 15 | throw new UnsupportedError(`Limit count must be an integer`) 16 | } 17 | 18 | if (!selectStmt.limitCount.A_Const.ival) { 19 | throw new UnsupportedError(`Limit count must have an integer value`) 20 | } 21 | 22 | count = selectStmt.limitCount.A_Const.ival.ival 23 | } 24 | 25 | if (selectStmt.limitOffset) { 26 | if (!('A_Const' in selectStmt.limitOffset)) { 27 | throw new UnsupportedError(`Limit offset must be an A_Const`) 28 | } 29 | 30 | if (!('ival' in selectStmt.limitOffset.A_Const)) { 31 | throw new UnsupportedError(`Limit offset must be an integer`) 32 | } 33 | 34 | if (!selectStmt.limitOffset.A_Const.ival) { 35 | throw new UnsupportedError(`Limit offset must have an integer value`) 36 | } 37 | 38 | offset = selectStmt.limitOffset.A_Const.ival.ival 39 | } 40 | 41 | if (count === undefined && offset === undefined) { 42 | return undefined 43 | } 44 | 45 | return { 46 | count, 47 | offset, 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/processor/select.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | A_Expr, 3 | ColumnRef, 4 | FuncCall, 5 | Node, 6 | ResTarget, 7 | SelectStmt, 8 | String, 9 | TypeCast, 10 | } from '@supabase/pg-parser/17/types' 11 | import { UnsupportedError } from '../errors.js' 12 | import { validateGroupClause } from './aggregate.js' 13 | import { processWhereClause } from './filter.js' 14 | import { processLimit } from './limit.js' 15 | import { processSortClause } from './sort.js' 16 | import type { 17 | AggregateTarget, 18 | ColumnTarget, 19 | EmbeddedTarget, 20 | JoinedColumn, 21 | Relations, 22 | Select, 23 | Target, 24 | } from './types.js' 25 | import { processJsonTarget, renderDataType, renderFields } from './util.js' 26 | 27 | export const supportedAggregateFunctions = ['avg', 'count', 'max', 'min', 'sum'] 28 | 29 | export function processSelectStatement(stmt: SelectStmt): Select { 30 | if (!stmt) { 31 | throw new UnsupportedError('Expected a statement, but received an empty one') 32 | } 33 | 34 | if (!stmt.fromClause) { 35 | throw new UnsupportedError('The query must have a from clause') 36 | } 37 | 38 | if (!stmt.targetList) { 39 | throw new UnsupportedError('The query must have a target list') 40 | } 41 | 42 | if (stmt.fromClause.length > 1) { 43 | throw new UnsupportedError('Only one FROM source is supported') 44 | } 45 | 46 | if (stmt.withClause) { 47 | throw new UnsupportedError('CTEs are not supported') 48 | } 49 | 50 | if (stmt.distinctClause) { 51 | throw new UnsupportedError('SELECT DISTINCT is not supported') 52 | } 53 | 54 | if (stmt.havingClause) { 55 | throw new UnsupportedError('The HAVING clause is not supported') 56 | } 57 | 58 | const [fromClause] = stmt.fromClause 59 | 60 | if (!fromClause) { 61 | throw new UnsupportedError('The FROM clause must have a relation') 62 | } 63 | 64 | const relations = processFromClause(fromClause) 65 | 66 | const from = relations.primary.name 67 | 68 | const targetList = stmt.targetList.map((node) => { 69 | if (!('ResTarget' in node)) { 70 | throw new UnsupportedError('Target list must contain ResTarget nodes') 71 | } 72 | return node.ResTarget 73 | }) 74 | 75 | const targets = processTargetList(targetList, relations) 76 | 77 | const groupByColumns = 78 | stmt.groupClause?.map((node) => { 79 | if (!('ColumnRef' in node)) { 80 | throw new UnsupportedError('Group by clause must contain column references') 81 | } 82 | return node.ColumnRef 83 | }) ?? [] 84 | 85 | validateGroupClause(groupByColumns, targets, relations) 86 | 87 | const filter = stmt.whereClause ? processWhereClause(stmt.whereClause, relations) : undefined 88 | 89 | const sortByColumns = 90 | stmt.sortClause?.map((sortBy) => { 91 | if (!('SortBy' in sortBy)) { 92 | throw new UnsupportedError('Sort clause must contain SortBy nodes') 93 | } 94 | return sortBy.SortBy 95 | }) ?? [] 96 | 97 | const sorts = processSortClause(sortByColumns, relations) 98 | 99 | const limit = processLimit(stmt) 100 | 101 | return { 102 | type: 'select', 103 | from, 104 | targets, 105 | filter, 106 | sorts, 107 | limit, 108 | } 109 | } 110 | 111 | function processFromClause(fromClause: Node): Relations { 112 | if ('RangeVar' in fromClause) { 113 | if (!fromClause.RangeVar.relname) { 114 | throw new UnsupportedError('The FROM clause must have a relation name') 115 | } 116 | 117 | return { 118 | primary: { 119 | name: fromClause.RangeVar.relname, 120 | alias: fromClause.RangeVar.alias?.aliasname, 121 | get reference() { 122 | return this.alias ?? this.name 123 | }, 124 | }, 125 | joined: [], 126 | } 127 | } else if ('JoinExpr' in fromClause) { 128 | if (!fromClause.JoinExpr.jointype) { 129 | throw new UnsupportedError('Join expression must have a join type') 130 | } 131 | 132 | if (!fromClause.JoinExpr.larg || !fromClause.JoinExpr.rarg) { 133 | throw new UnsupportedError('Join expression must have both left and right relations') 134 | } 135 | const joinType = mapJoinType(fromClause.JoinExpr.jointype) 136 | const { primary, joined } = processFromClause(fromClause.JoinExpr.larg) 137 | 138 | if (!('RangeVar' in fromClause.JoinExpr.rarg)) { 139 | throw new UnsupportedError('Join expression must have a right relation of type RangeVar') 140 | } 141 | 142 | const joinedRelationAlias = fromClause.JoinExpr.rarg.RangeVar.alias?.aliasname 143 | const joinedRelation = joinedRelationAlias ?? fromClause.JoinExpr.rarg.RangeVar.relname 144 | 145 | const existingRelations = [ 146 | primary.reference, 147 | ...joined.map((t) => t.alias ?? t.relation), 148 | joinedRelation, 149 | ] 150 | 151 | if (!fromClause.JoinExpr.quals || !('A_Expr' in fromClause.JoinExpr.quals)) { 152 | throw new UnsupportedError(`Join qualifier must be an expression comparing columns`) 153 | } 154 | 155 | let leftQualifierRelation 156 | let rightQualifierRelation 157 | 158 | const joinQualifierExpression = fromClause.JoinExpr.quals.A_Expr 159 | 160 | if (!joinQualifierExpression.lexpr || !('ColumnRef' in joinQualifierExpression.lexpr)) { 161 | throw new UnsupportedError(`Left side of join qualifier must be a column`) 162 | } 163 | 164 | if ( 165 | !joinQualifierExpression.lexpr.ColumnRef.fields || 166 | !joinQualifierExpression.lexpr.ColumnRef.fields.every( 167 | (field): field is { String: String } => 'String' in field 168 | ) 169 | ) { 170 | throw new UnsupportedError(`Left side column of join qualifier must contain String fields`) 171 | } 172 | 173 | const leftColumnFields = joinQualifierExpression.lexpr.ColumnRef.fields.map( 174 | (field) => field.String.sval 175 | ) 176 | 177 | // Relation and column names are last two parts of the qualified name 178 | const [leftRelationName] = leftColumnFields.slice(-2, -1) 179 | const [leftColumnName] = leftColumnFields.slice(-1) 180 | 181 | if (!leftColumnName) { 182 | throw new UnsupportedError(`Left side of join qualifier must have a column name`) 183 | } 184 | 185 | if (!leftRelationName) { 186 | leftQualifierRelation = primary.reference 187 | } else if (existingRelations.includes(leftRelationName)) { 188 | leftQualifierRelation = leftRelationName 189 | } else if (leftRelationName === joinedRelation) { 190 | leftQualifierRelation = joinedRelation 191 | } else { 192 | throw new UnsupportedError( 193 | `Left side of join qualifier references a different relation (${leftRelationName}) than the join (${existingRelations.join(', ')})` 194 | ) 195 | } 196 | 197 | if (!joinQualifierExpression.rexpr) { 198 | throw new UnsupportedError(`Join qualifier must have a right side expression`) 199 | } 200 | 201 | if (!('ColumnRef' in joinQualifierExpression.rexpr)) { 202 | throw new UnsupportedError(`Right side of join qualifier must be a column`) 203 | } 204 | 205 | if ( 206 | !joinQualifierExpression.rexpr.ColumnRef.fields?.every( 207 | (field): field is { String: String } => 'String' in field 208 | ) 209 | ) { 210 | throw new UnsupportedError(`Right side column of join qualifier must contain String fields`) 211 | } 212 | 213 | const rightColumnFields = joinQualifierExpression.rexpr.ColumnRef.fields.map( 214 | (field) => field.String.sval 215 | ) 216 | 217 | // Relation and column names are last two parts of the qualified name 218 | const [rightRelationName] = rightColumnFields.slice(-2, -1) 219 | const [rightColumnName] = rightColumnFields.slice(-1) 220 | 221 | if (!rightColumnName) { 222 | throw new UnsupportedError(`Right side of join qualifier must have a column name`) 223 | } 224 | 225 | if (!rightRelationName) { 226 | rightQualifierRelation = primary.reference 227 | } else if (existingRelations.includes(rightRelationName)) { 228 | rightQualifierRelation = rightRelationName 229 | } else if (rightRelationName === joinedRelation) { 230 | rightQualifierRelation = joinedRelation 231 | } else { 232 | throw new UnsupportedError( 233 | `Right side of join qualifier references a different relation (${rightRelationName}) than the join (${existingRelations.join(', ')})` 234 | ) 235 | } 236 | 237 | if (rightQualifierRelation === leftQualifierRelation) { 238 | // TODO: support for recursive relationships 239 | throw new UnsupportedError(`Join qualifier cannot compare columns from same relation`) 240 | } 241 | 242 | if (rightQualifierRelation !== joinedRelation && leftQualifierRelation !== joinedRelation) { 243 | throw new UnsupportedError(`Join qualifier must reference a column from the joined table`) 244 | } 245 | 246 | if (!joinQualifierExpression.name) { 247 | throw new UnsupportedError(`Join qualifier must have an operator`) 248 | } 249 | 250 | const [qualifierOperatorString] = joinQualifierExpression.name 251 | 252 | if (!qualifierOperatorString || !('String' in qualifierOperatorString)) { 253 | throw new UnsupportedError(`Join qualifier operator must be a string`) 254 | } 255 | 256 | if (qualifierOperatorString.String.sval !== '=') { 257 | throw new UnsupportedError(`Join qualifier operator must be '='`) 258 | } 259 | 260 | let left: JoinedColumn 261 | let right: JoinedColumn 262 | 263 | // If left qualifier referenced the joined relation, swap left and right 264 | if (rightQualifierRelation === joinedRelation) { 265 | left = { 266 | relation: leftQualifierRelation, 267 | column: leftColumnName, 268 | } 269 | right = { 270 | relation: rightQualifierRelation, 271 | column: rightColumnName, 272 | } 273 | } else { 274 | right = { 275 | relation: leftQualifierRelation, 276 | column: leftColumnName, 277 | } 278 | left = { 279 | relation: rightQualifierRelation, 280 | column: rightColumnName, 281 | } 282 | } 283 | 284 | if (!fromClause.JoinExpr.rarg.RangeVar.relname) { 285 | throw new UnsupportedError('Join expression must have a right relation name') 286 | } 287 | 288 | const embeddedTarget: EmbeddedTarget = { 289 | type: 'embedded-target', 290 | relation: fromClause.JoinExpr.rarg.RangeVar.relname, 291 | alias: fromClause.JoinExpr.rarg.RangeVar.alias?.aliasname, 292 | joinType, 293 | targets: [], // these will be filled in later when processing the select target list 294 | flatten: true, 295 | joinedColumns: { 296 | left, 297 | right, 298 | }, 299 | } 300 | 301 | return { 302 | primary, 303 | joined: [...joined, embeddedTarget], 304 | } 305 | } else { 306 | const [fieldType] = Object.keys(fromClause) 307 | throw new UnsupportedError(`Unsupported FROM clause type '${fieldType}'`) 308 | } 309 | } 310 | 311 | function processTargetList(targetList: ResTarget[], relations: Relations): Target[] { 312 | // First pass: map each SQL target column to a PostgREST target 1-to-1 313 | const flattenedColumnTargets: (ColumnTarget | AggregateTarget)[] = targetList.map((resTarget) => { 314 | if (!resTarget.val) { 315 | throw new UnsupportedError(`Target list item must have a value`) 316 | } 317 | 318 | const target = processTarget(resTarget.val, relations) 319 | target.alias = resTarget.name 320 | 321 | return target 322 | }) 323 | 324 | // Second pass: transfer joined columns to `embeddedTargets` 325 | const columnTargets = flattenedColumnTargets.filter((target) => { 326 | // Account for the special case when the aggregate doesn't have a column attached 327 | // ie. `count()`: should always be applied to the top level relation 328 | if (target.type === 'aggregate-target' && !('column' in target)) { 329 | return true 330 | } 331 | 332 | const qualifiedName = target.column.split('.') 333 | 334 | // Relation and column names are last two parts of the qualified name 335 | const [relationName] = qualifiedName.slice(-2, -1) 336 | const [columnName] = qualifiedName.slice(-1) 337 | 338 | // If there is no prefix, this column belongs to the primary relation at the top level 339 | if (!relationName) { 340 | return true 341 | } 342 | 343 | if (!columnName) { 344 | throw new UnsupportedError(`Column name cannot be empty in target list`) 345 | } 346 | 347 | // If this column is part of a joined relation 348 | if (relationName) { 349 | const embeddedTarget = relations.joined.find( 350 | (t) => (t.alias && !t.flatten ? t.alias : t.relation) === relationName 351 | ) 352 | 353 | if (!embeddedTarget) { 354 | throw new UnsupportedError( 355 | `Found foreign column '${target.column}' in target list without a join to that relation`, 356 | 'Did you forget to join that relation or alias it to something else?' 357 | ) 358 | } 359 | 360 | // Strip relation from column name 361 | target.column = columnName 362 | 363 | // Nest the column in the embedded target 364 | embeddedTarget.targets.push(target) 365 | 366 | // Remove this column from the top level 367 | return false 368 | } 369 | 370 | return true 371 | }) 372 | 373 | // Third pass: nest embedded targets within each other based on the relations in their join qualifiers 374 | const nestedEmbeddedTargets = relations.joined.reduce( 375 | (output, embeddedTarget) => { 376 | // If the embedded target was joined with the primary relation, return it 377 | if (embeddedTarget.joinedColumns.left.relation === relations.primary.reference) { 378 | return [...output, embeddedTarget] 379 | } 380 | 381 | // Otherwise identify the correct parent and nest it within its targets 382 | const parent = relations.joined.find( 383 | (t) => (t.alias ?? t.relation) === embeddedTarget.joinedColumns.left.relation 384 | ) 385 | 386 | if (!parent) { 387 | throw new UnsupportedError( 388 | `Something went wrong, could not find parent embedded target for nested embedded target '${embeddedTarget.relation}'` 389 | ) 390 | } 391 | 392 | parent.targets.push(embeddedTarget) 393 | return output 394 | }, 395 | [] 396 | ) 397 | 398 | return [...columnTargets, ...nestedEmbeddedTargets] 399 | } 400 | 401 | function processTarget(target: Node, relations: Relations): ColumnTarget | AggregateTarget { 402 | if ('TypeCast' in target) { 403 | return processCast(target.TypeCast, relations) 404 | } else if ('ColumnRef' in target) { 405 | return processColumn(target.ColumnRef, relations) 406 | } else if ('A_Expr' in target) { 407 | return processExpression(target.A_Expr, relations) 408 | } else if ('FuncCall' in target) { 409 | return processFunctionCall(target.FuncCall, relations) 410 | } else { 411 | throw new UnsupportedError( 412 | 'Only columns, JSON fields, and aggregates are supported as query targets' 413 | ) 414 | } 415 | } 416 | 417 | function mapJoinType(joinType: string) { 418 | switch (joinType) { 419 | case 'JOIN_INNER': 420 | return 'inner' 421 | case 'JOIN_LEFT': 422 | return 'left' 423 | default: 424 | throw new UnsupportedError(`Unsupported join type '${joinType}'`) 425 | } 426 | } 427 | 428 | function processCast(target: TypeCast, relations: Relations) { 429 | if (!target.typeName?.names) { 430 | throw new UnsupportedError('Type cast must have a type name') 431 | } 432 | 433 | const names = target.typeName.names.map((name) => { 434 | if (!('String' in name)) { 435 | throw new UnsupportedError('Type cast name must be a string') 436 | } 437 | return name.String 438 | }) 439 | 440 | const cast = renderDataType(names) 441 | 442 | if (!target.arg) { 443 | throw new UnsupportedError('Type cast must have an argument') 444 | } 445 | 446 | if ('A_Const' in target.arg) { 447 | throw new UnsupportedError( 448 | 'Only columns, JSON fields, and aggregates are supported as query targets' 449 | ) 450 | } 451 | 452 | const nestedTarget = processTarget(target.arg, relations) 453 | 454 | const { type } = nestedTarget 455 | 456 | if (type === 'aggregate-target') { 457 | return { 458 | ...nestedTarget, 459 | outputCast: cast, 460 | } 461 | } else if (type === 'column-target') { 462 | return { 463 | ...nestedTarget, 464 | cast, 465 | } 466 | } else { 467 | throw new UnsupportedError(`Cannot process target with type '${type}'`) 468 | } 469 | } 470 | 471 | function processColumn(target: ColumnRef, relations: Relations): ColumnTarget { 472 | if (!target.fields) { 473 | throw new UnsupportedError('Column reference must have fields') 474 | } 475 | 476 | return { 477 | type: 'column-target', 478 | column: renderFields(target.fields, relations), 479 | } 480 | } 481 | 482 | function processExpression(target: A_Expr, relations: Relations): ColumnTarget { 483 | try { 484 | return processJsonTarget(target, relations) 485 | } catch (err) { 486 | const maybeJsonHint = 487 | err instanceof Error && err.message === 'Invalid JSON path' 488 | ? 'Did you forget to quote a JSON path?' 489 | : undefined 490 | throw new UnsupportedError(`Expressions not supported as targets`, maybeJsonHint) 491 | } 492 | } 493 | 494 | function processFunctionCall(target: FuncCall, relations: Relations): AggregateTarget { 495 | if (!target.funcname) { 496 | throw new UnsupportedError('Aggregate function must have a name') 497 | } 498 | 499 | const functionName = renderFields(target.funcname, relations) 500 | 501 | if (!supportedAggregateFunctions.includes(functionName)) { 502 | throw new UnsupportedError( 503 | `Only the following aggregate functions are supported: ${JSON.stringify(supportedAggregateFunctions)}` 504 | ) 505 | } 506 | 507 | // The `count(*)` special case that has no columns attached 508 | if (functionName === 'count' && !target.args && target.agg_star) { 509 | return { 510 | type: 'aggregate-target', 511 | functionName, 512 | } 513 | } 514 | 515 | if (!target.args) { 516 | throw new UnsupportedError(`Aggregate function '${functionName}' requires a column argument`) 517 | } 518 | 519 | if (target.args && target.args.length > 1) { 520 | throw new UnsupportedError(`Aggregate functions only accept one argument`) 521 | } 522 | 523 | const [arg] = target.args 524 | 525 | if (!arg) { 526 | throw new UnsupportedError(`Aggregate function '${functionName}' requires a column argument`) 527 | } 528 | 529 | const nestedTarget = processTarget(arg, relations) 530 | 531 | if (nestedTarget.type === 'aggregate-target') { 532 | throw new UnsupportedError(`Aggregate functions cannot contain another function`) 533 | } 534 | 535 | const { cast, ...columnTarget } = nestedTarget 536 | 537 | return { 538 | ...columnTarget, 539 | type: 'aggregate-target', 540 | functionName, 541 | inputCast: cast, 542 | } 543 | } 544 | -------------------------------------------------------------------------------- /src/processor/sort.ts: -------------------------------------------------------------------------------- 1 | import type { SortBy } from '@supabase/pg-parser/17/types' 2 | import { UnsupportedError } from '../errors.js' 3 | import type { Relations, Sort } from './types.js' 4 | import { processJsonTarget, renderFields } from './util.js' 5 | 6 | export function processSortClause(sorts: SortBy[], relations: Relations): Sort[] { 7 | return sorts.map((sortBy) => { 8 | let column: string 9 | 10 | if (!sortBy.node) { 11 | throw new UnsupportedError(`ORDER BY clause must reference a column`) 12 | } 13 | 14 | if ('A_Expr' in sortBy.node) { 15 | try { 16 | const target = processJsonTarget(sortBy.node.A_Expr, relations) 17 | column = target.column 18 | } catch (err) { 19 | throw new UnsupportedError(`ORDER BY clause must reference a column`) 20 | } 21 | } else if ('ColumnRef' in sortBy.node) { 22 | const { fields } = sortBy.node.ColumnRef 23 | if (!fields) { 24 | throw new UnsupportedError(`ORDER BY clause must reference a column`) 25 | } 26 | column = renderFields(fields, relations, 'parenthesis') 27 | } else if ('TypeCast' in sortBy.node) { 28 | throw new UnsupportedError('Casting is not supported in the ORDER BY clause') 29 | } else { 30 | throw new UnsupportedError(`ORDER BY clause must reference a column`) 31 | } 32 | 33 | if (!sortBy.sortby_dir) { 34 | throw new UnsupportedError(`ORDER BY clause must specify a direction`) 35 | } 36 | 37 | const direction = mapSortByDirection(sortBy.sortby_dir) 38 | 39 | if (!sortBy.sortby_nulls) { 40 | throw new UnsupportedError(`ORDER BY clause must specify nulls handling`) 41 | } 42 | 43 | const nulls = mapSortByNulls(sortBy.sortby_nulls) 44 | 45 | return { 46 | column, 47 | direction, 48 | nulls, 49 | } 50 | }) 51 | } 52 | 53 | function mapSortByDirection(direction: string) { 54 | switch (direction) { 55 | case 'SORTBY_ASC': 56 | return 'asc' 57 | case 'SORTBY_DESC': 58 | return 'desc' 59 | case 'SORTBY_DEFAULT': 60 | return undefined 61 | default: 62 | throw new UnsupportedError(`Unknown sort by direction '${direction}'`) 63 | } 64 | } 65 | 66 | function mapSortByNulls(nulls: string) { 67 | switch (nulls) { 68 | case 'SORTBY_NULLS_FIRST': 69 | return 'first' 70 | case 'SORTBY_NULLS_LAST': 71 | return 'last' 72 | case 'SORTBY_NULLS_DEFAULT': 73 | return undefined 74 | default: 75 | throw new UnsupportedError(`Unknown sort by nulls '${nulls}'`) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/processor/types.ts: -------------------------------------------------------------------------------- 1 | export type Statement = Select 2 | 3 | export type Select = { 4 | type: 'select' 5 | from: string 6 | targets: Target[] 7 | filter?: Filter 8 | sorts?: Sort[] 9 | limit?: Limit 10 | } 11 | 12 | export type Limit = { 13 | count?: number 14 | offset?: number 15 | } 16 | 17 | export type LogicalOperator = 'and' | 'or' 18 | 19 | export type BaseFilter = { 20 | negate: boolean 21 | } 22 | 23 | export type BaseColumnFilter = BaseFilter & { 24 | type: 'column' 25 | column: string 26 | } 27 | 28 | export type EqColumnFilter = BaseColumnFilter & { 29 | operator: 'eq' 30 | value: string | number 31 | } 32 | 33 | export type NeqColumnFilter = BaseColumnFilter & { 34 | operator: 'neq' 35 | value: string | number 36 | } 37 | 38 | export type GtColumnFilter = BaseColumnFilter & { 39 | operator: 'gt' 40 | value: string | number 41 | } 42 | 43 | export type GteColumnFilter = BaseColumnFilter & { 44 | operator: 'gte' 45 | value: string | number 46 | } 47 | 48 | export type LtColumnFilter = BaseColumnFilter & { 49 | operator: 'lt' 50 | value: string | number 51 | } 52 | 53 | export type LteColumnFilter = BaseColumnFilter & { 54 | operator: 'lte' 55 | value: string | number 56 | } 57 | 58 | export type LikeColumnFilter = BaseColumnFilter & { 59 | operator: 'like' 60 | value: string 61 | } 62 | 63 | export type IlikeColumnFilter = BaseColumnFilter & { 64 | operator: 'ilike' 65 | value: string 66 | } 67 | 68 | export type MatchColumnFilter = BaseColumnFilter & { 69 | operator: 'match' 70 | value: string 71 | } 72 | 73 | export type ImatchColumnFilter = BaseColumnFilter & { 74 | operator: 'imatch' 75 | value: string 76 | } 77 | 78 | export type IsColumnFilter = BaseColumnFilter & { 79 | operator: 'is' 80 | value: null 81 | } 82 | 83 | export type InColumnFilter = BaseColumnFilter & { 84 | operator: 'in' 85 | value: (string | number)[] 86 | } 87 | 88 | export type FtsColumnFilter = BaseColumnFilter & { 89 | operator: 'fts' 90 | config?: string 91 | value: string 92 | } 93 | 94 | export type PlainFtsColumnFilter = BaseColumnFilter & { 95 | operator: 'plfts' 96 | config?: string 97 | value: string 98 | } 99 | 100 | export type PhraseFtsColumnFilter = BaseColumnFilter & { 101 | operator: 'phfts' 102 | config?: string 103 | value: string 104 | } 105 | 106 | export type WebSearchFtsColumnFilter = BaseColumnFilter & { 107 | operator: 'wfts' 108 | config?: string 109 | value: string 110 | } 111 | 112 | export type ColumnFilter = 113 | | EqColumnFilter 114 | | NeqColumnFilter 115 | | GtColumnFilter 116 | | GteColumnFilter 117 | | LtColumnFilter 118 | | LteColumnFilter 119 | | LikeColumnFilter 120 | | IlikeColumnFilter 121 | | MatchColumnFilter 122 | | ImatchColumnFilter 123 | | IsColumnFilter 124 | | InColumnFilter 125 | | FtsColumnFilter 126 | | PlainFtsColumnFilter 127 | | PhraseFtsColumnFilter 128 | | WebSearchFtsColumnFilter 129 | 130 | export type LogicalFilter = BaseFilter & { 131 | type: 'logical' 132 | operator: LogicalOperator 133 | values: Filter[] 134 | } 135 | 136 | export type Filter = ColumnFilter | LogicalFilter 137 | 138 | /** 139 | * Represents a direct column target in the select. 140 | */ 141 | export type ColumnTarget = { 142 | type: 'column-target' 143 | column: string 144 | alias?: string 145 | cast?: string 146 | } 147 | 148 | export type JoinedColumn = { 149 | relation: string 150 | column: string 151 | } 152 | 153 | /** 154 | * Represents a resource embedding (joined) target in the select. 155 | */ 156 | export type EmbeddedTarget = { 157 | type: 'embedded-target' 158 | relation: string 159 | targets: Target[] 160 | joinType: 'left' | 'inner' 161 | joinedColumns: { 162 | left: JoinedColumn 163 | right: JoinedColumn 164 | } 165 | alias?: string 166 | flatten?: boolean 167 | } 168 | 169 | export type BaseAggregateTarget = { 170 | type: 'aggregate-target' 171 | alias?: string 172 | outputCast?: string 173 | } 174 | 175 | export type ColumnAggregateTarget = BaseAggregateTarget & { 176 | functionName: string 177 | column: string 178 | inputCast?: string 179 | } 180 | 181 | /** 182 | * Special case `count()` aggregate target that works 183 | * with no column attached. 184 | */ 185 | export type CountAggregateTarget = BaseAggregateTarget & { 186 | type: 'aggregate-target' 187 | functionName: 'count' 188 | } 189 | 190 | /** 191 | * Represents a aggregate target in the select. 192 | */ 193 | export type AggregateTarget = CountAggregateTarget | ColumnAggregateTarget 194 | 195 | export type Target = ColumnTarget | AggregateTarget | EmbeddedTarget 196 | 197 | export type Sort = { 198 | column: string 199 | direction?: 'asc' | 'desc' 200 | nulls?: 'first' | 'last' 201 | } 202 | 203 | export type Relations = { 204 | primary: { 205 | name: string 206 | alias?: string 207 | get reference(): string 208 | } 209 | joined: EmbeddedTarget[] 210 | } 211 | -------------------------------------------------------------------------------- /src/processor/util.ts: -------------------------------------------------------------------------------- 1 | import type { A_Const, A_Expr, Node, String } from '@supabase/pg-parser/17/types' 2 | import { UnsupportedError } from '../errors.js' 3 | import type { 4 | AggregateTarget, 5 | ColumnFilter, 6 | ColumnTarget, 7 | EmbeddedTarget, 8 | Filter, 9 | Relations, 10 | Target, 11 | } from './types.js' 12 | 13 | export function processJsonTarget(expression: A_Expr, relations: Relations): ColumnTarget { 14 | if (!expression.name || expression.name.length === 0) { 15 | throw new UnsupportedError('JSON operator must have a name') 16 | } 17 | 18 | if (expression.name.length > 1) { 19 | throw new UnsupportedError('Only one operator name supported per expression') 20 | } 21 | 22 | const [name] = expression.name 23 | 24 | if (!('String' in name!)) { 25 | throw new UnsupportedError('JSON operator name must be a string') 26 | } 27 | 28 | const operator = name.String.sval 29 | 30 | if (!operator) { 31 | throw new UnsupportedError('JSON operator name cannot be empty') 32 | } 33 | 34 | if (!['->', '->>'].includes(operator)) { 35 | throw new UnsupportedError(`Invalid JSON operator`) 36 | } 37 | 38 | let cast: string | undefined = undefined 39 | let left: string | number 40 | let right: string | number 41 | 42 | if (!expression.lexpr) { 43 | throw new UnsupportedError('JSON path must have a left expression') 44 | } 45 | 46 | if ('A_Const' in expression.lexpr) { 47 | // JSON path cannot contain a float 48 | if ('fval' in expression.lexpr.A_Const) { 49 | throw new UnsupportedError('Invalid JSON path') 50 | } 51 | left = parseConstant(expression.lexpr.A_Const) 52 | } else if ('A_Expr' in expression.lexpr) { 53 | const { column } = processJsonTarget(expression.lexpr.A_Expr, relations) 54 | left = column 55 | } else if ('ColumnRef' in expression.lexpr) { 56 | if (!expression.lexpr.ColumnRef.fields) { 57 | throw new UnsupportedError('JSON path must have a column reference') 58 | } 59 | left = renderFields(expression.lexpr.ColumnRef.fields, relations) 60 | } else { 61 | throw new UnsupportedError('Invalid JSON path') 62 | } 63 | 64 | if (!expression.rexpr || !expression.rexpr) { 65 | throw new UnsupportedError('JSON path must have a right expression') 66 | } 67 | 68 | if ('A_Const' in expression.rexpr) { 69 | // JSON path cannot contain a float 70 | if ('fval' in expression.rexpr.A_Const) { 71 | throw new UnsupportedError('Invalid JSON path') 72 | } 73 | right = parseConstant(expression.rexpr.A_Const) 74 | } else if ('TypeCast' in expression.rexpr) { 75 | if (!expression.rexpr.TypeCast.typeName?.names) { 76 | throw new UnsupportedError('Type cast must have a name') 77 | } 78 | cast = renderDataType( 79 | expression.rexpr.TypeCast.typeName.names.map((n) => { 80 | if (!('String' in n)) { 81 | throw new UnsupportedError('Type cast name must be a string') 82 | } 83 | return n.String 84 | }) 85 | ) 86 | 87 | if (!expression.rexpr.TypeCast.arg) { 88 | throw new UnsupportedError('Type cast must have an argument') 89 | } 90 | 91 | if ('A_Const' in expression.rexpr.TypeCast.arg) { 92 | if ('sval' in expression.rexpr.TypeCast.arg.A_Const) { 93 | if (!expression.rexpr.TypeCast.arg.A_Const.sval?.sval) { 94 | throw new UnsupportedError('Type cast argument cannot be empty') 95 | } 96 | right = expression.rexpr.TypeCast.arg.A_Const.sval.sval 97 | } else { 98 | throw new UnsupportedError('Invalid JSON path') 99 | } 100 | } else { 101 | throw new UnsupportedError('Invalid JSON path') 102 | } 103 | } else { 104 | throw new UnsupportedError('Invalid JSON path') 105 | } 106 | 107 | return { 108 | type: 'column-target', 109 | column: `${left}${operator}${right}`, 110 | cast, 111 | } 112 | } 113 | 114 | export function renderFields( 115 | fields: Node[], 116 | relations: Relations, 117 | syntax: 'dot' | 'parenthesis' = 'dot' 118 | ): string { 119 | // Get qualified column name segments, eg. `author.name` -> ['author', 'name'] 120 | const nameSegments = fields.map((field) => { 121 | if ('String' in field) { 122 | return field.String.sval 123 | } else if ('A_Star' in field) { 124 | return '*' 125 | } else { 126 | const [internalType] = Object.keys(field) 127 | throw new UnsupportedError(`Unsupported internal type '${internalType}' for data type names`) 128 | } 129 | }) 130 | 131 | // Relation and column names are last two parts of the qualified name 132 | const [relationOrAliasName] = nameSegments.slice(-2, -1) 133 | const [columnName] = nameSegments.slice(-1) 134 | 135 | const joinedRelation = relations.joined.find( 136 | (t) => (t.alias ?? t.relation) === relationOrAliasName 137 | ) 138 | 139 | // If the column is prefixed with the primary relation, strip the prefix 140 | if (!relationOrAliasName || relationOrAliasName === relations.primary.reference) { 141 | if (!columnName) { 142 | throw new UnsupportedError('Column name cannot be empty') 143 | } 144 | return columnName 145 | } 146 | // If it's prefixed with a joined relation in the FROM clause, keep the relation prefix 147 | else if (joinedRelation) { 148 | // Joined relations that are spread don't support aliases, so we will 149 | // convert the alias back to the original relation name in this case 150 | const joinedRelationName = joinedRelation.flatten 151 | ? joinedRelation.relation 152 | : relationOrAliasName 153 | 154 | if (syntax === 'dot') { 155 | return [joinedRelationName, columnName].join('.') 156 | } else if (syntax === 'parenthesis') { 157 | return `${joinedRelationName}(${columnName})` 158 | } else { 159 | throw new Error(`Unknown render syntax '${syntax}'`) 160 | } 161 | } 162 | // If it's prefixed with an unknown relation, throw an error 163 | else { 164 | const qualifiedName = [relationOrAliasName, columnName].join('.') 165 | 166 | throw new UnsupportedError( 167 | `Found foreign column '${qualifiedName}' without a join to that relation`, 168 | 'Did you forget to join that relation or alias it to something else?' 169 | ) 170 | } 171 | } 172 | 173 | export function renderDataType(names: String[]) { 174 | const [first, ...rest] = names 175 | 176 | if (!first) { 177 | throw new UnsupportedError('Data type must have a name') 178 | } 179 | 180 | if (first.sval === 'pg_catalog' && rest.length === 1) { 181 | const [name] = rest 182 | 183 | if (!name) { 184 | throw new UnsupportedError('Data type must have a name') 185 | } 186 | 187 | // The PG parser converts some data types, eg. int -> pg_catalog.int4 188 | // so we'll map those back 189 | switch (name.sval) { 190 | case 'int2': 191 | return 'smallint' 192 | case 'int4': 193 | return 'int' 194 | case 'int8': 195 | return 'bigint' 196 | case 'float8': 197 | return 'float' 198 | default: 199 | return name.sval 200 | } 201 | } else if (rest.length > 0) { 202 | throw new UnsupportedError( 203 | `Casts can only reference data types by their unqualified name (not schema-qualified)` 204 | ) 205 | } else { 206 | return first.sval 207 | } 208 | } 209 | 210 | export function parseConstant(constant: A_Const) { 211 | if ('sval' in constant) { 212 | if (constant.sval?.sval === undefined) { 213 | throw new UnsupportedError('Constant value cannot be empty') 214 | } 215 | return constant.sval.sval 216 | } else if ('ival' in constant) { 217 | if (constant.ival === undefined) { 218 | throw new UnsupportedError('Constant value cannot be undefined') 219 | } 220 | // The PG parser turns 0 into undefined, so convert it back here 221 | return constant.ival.ival ?? 0 222 | } else if ('fval' in constant) { 223 | if (constant.fval?.fval === undefined) { 224 | throw new UnsupportedError('Constant value cannot be undefined') 225 | } 226 | return parseFloat(constant.fval.fval) 227 | } else { 228 | throw new UnsupportedError(`Constant values must be a string, integer, or float`) 229 | } 230 | } 231 | 232 | /** 233 | * Recursively flattens PostgREST embedded targets. 234 | */ 235 | export function flattenTargets(targets: Target[]): Target[] { 236 | return targets.flatMap((target) => { 237 | const { type } = target 238 | if (type === 'column-target' || type === 'aggregate-target') { 239 | return target 240 | } else if (type === 'embedded-target') { 241 | return [target, ...flattenTargets(target.targets)] 242 | } else { 243 | throw new UnsupportedError(`Unknown target type '${type}'`) 244 | } 245 | }) 246 | } 247 | 248 | /** 249 | * Recursively iterates through PostgREST filters and checks if the predicate 250 | * matches any of them (ie. `some()`). 251 | */ 252 | export function someFilter(filter: Filter, predicate: (filter: ColumnFilter) => boolean): boolean { 253 | const { type } = filter 254 | 255 | if (type === 'column') { 256 | return predicate(filter) 257 | } else if (type === 'logical') { 258 | return filter.values.some((f) => someFilter(f, predicate)) 259 | } else { 260 | throw new UnsupportedError(`Unknown filter type '${type}'`) 261 | } 262 | } 263 | 264 | /** 265 | * Recursively iterates through a PostgREST target list and checks if the predicate 266 | * matches every one of them (ie. `some()`). 267 | */ 268 | export function everyTarget( 269 | targets: Target[], 270 | predicate: (target: ColumnTarget | AggregateTarget, parent?: EmbeddedTarget) => boolean, 271 | parent?: EmbeddedTarget 272 | ): boolean { 273 | return targets.every((target) => { 274 | const { type } = target 275 | 276 | if (type === 'column-target' || type === 'aggregate-target') { 277 | return predicate(target, parent) 278 | } else if (type === 'embedded-target') { 279 | return everyTarget(target.targets, predicate, target) 280 | } else { 281 | throw new UnsupportedError(`Unknown target type '${type}'`) 282 | } 283 | }) 284 | } 285 | 286 | /** 287 | * Recursively iterates through a PostgREST target list and checks if the predicate 288 | * matches any of them (ie. `some()`). 289 | */ 290 | export function someTarget( 291 | targets: Target[], 292 | predicate: (target: ColumnTarget | AggregateTarget, parent?: EmbeddedTarget) => boolean, 293 | parent?: EmbeddedTarget 294 | ): boolean { 295 | return targets.some((target) => { 296 | const { type } = target 297 | 298 | if (type === 'column-target' || type === 'aggregate-target') { 299 | return predicate(target, parent) 300 | } else if (type === 'embedded-target') { 301 | return someTarget(target.targets, predicate, target) 302 | } else { 303 | throw new UnsupportedError(`Unknown target type '${type}'`) 304 | } 305 | }) 306 | } 307 | -------------------------------------------------------------------------------- /src/renderers/http.test.ts: -------------------------------------------------------------------------------- 1 | import { stripIndents } from 'common-tags' 2 | import { describe, expect, test } from 'vitest' 3 | import { processSql } from '../processor/index.js' 4 | import { renderHttp } from './http.js' 5 | 6 | describe('select', () => { 7 | test('select all columns', async () => { 8 | const sql = stripIndents` 9 | select 10 | * 11 | from 12 | books 13 | ` 14 | 15 | const statement = await processSql(sql) 16 | const { method, fullPath } = await renderHttp(statement) 17 | 18 | expect(method).toBe('GET') 19 | expect(fullPath).toBe('/books') 20 | }) 21 | 22 | test('select specified columns', async () => { 23 | const sql = stripIndents` 24 | select 25 | title, 26 | description 27 | from 28 | books 29 | ` 30 | 31 | const statement = await processSql(sql) 32 | const { method, fullPath } = await renderHttp(statement) 33 | 34 | expect(method).toBe('GET') 35 | expect(fullPath).toBe('/books?select=title,description') 36 | }) 37 | 38 | test('select distinct fails', async () => { 39 | const sql = stripIndents` 40 | select 41 | distinct category 42 | from 43 | books 44 | ` 45 | 46 | await expect(processSql(sql)).rejects.toThrowError() 47 | }) 48 | 49 | test('inline target expression fails', async () => { 50 | const sql = stripIndents` 51 | select 52 | 1 + 1 53 | from 54 | books 55 | ` 56 | 57 | await expect(processSql(sql)).rejects.toThrowError() 58 | }) 59 | 60 | test('missing table fails', async () => { 61 | const sql = stripIndents` 62 | select 'Test' 63 | ` 64 | 65 | await expect(processSql(sql)).rejects.toThrowError() 66 | }) 67 | 68 | test('aliased column', async () => { 69 | const sql = stripIndents` 70 | select 71 | title as my_title 72 | from 73 | books 74 | ` 75 | 76 | const statement = await processSql(sql) 77 | const { method, fullPath } = await renderHttp(statement) 78 | 79 | expect(method).toBe('GET') 80 | expect(fullPath).toBe('/books?select=my_title:title') 81 | }) 82 | 83 | test('remove alias when it matches column name', async () => { 84 | const sql = stripIndents` 85 | select 86 | title as title 87 | from 88 | books 89 | ` 90 | 91 | const statement = await processSql(sql) 92 | const { method, fullPath } = await renderHttp(statement) 93 | 94 | expect(method).toBe('GET') 95 | expect(fullPath).toBe('/books?select=title') 96 | }) 97 | 98 | test('equal', async () => { 99 | const sql = stripIndents` 100 | select 101 | * 102 | from 103 | books 104 | where 105 | title = 'Cheese' 106 | ` 107 | 108 | const statement = await processSql(sql) 109 | const { method, fullPath } = await renderHttp(statement) 110 | 111 | expect(method).toBe('GET') 112 | expect(fullPath).toBe('/books?title=eq.Cheese') 113 | }) 114 | 115 | test('not equal', async () => { 116 | const sql = stripIndents` 117 | select 118 | * 119 | from 120 | books 121 | where 122 | title != 'Cheese' 123 | ` 124 | 125 | const statement = await processSql(sql) 126 | const { method, fullPath } = await renderHttp(statement) 127 | 128 | expect(method).toBe('GET') 129 | expect(fullPath).toBe('/books?title=neq.Cheese') 130 | }) 131 | 132 | test('not wrapped equal', async () => { 133 | const sql = stripIndents` 134 | select 135 | * 136 | from 137 | books 138 | where 139 | not ( 140 | title = 'Cheese' 141 | ) 142 | ` 143 | 144 | const statement = await processSql(sql) 145 | const { method, fullPath } = await renderHttp(statement) 146 | 147 | expect(method).toBe('GET') 148 | expect(fullPath).toBe('/books?title=not.eq.Cheese') 149 | }) 150 | 151 | test('null', async () => { 152 | const sql = stripIndents` 153 | select 154 | * 155 | from 156 | books 157 | where 158 | title is null 159 | ` 160 | 161 | const statement = await processSql(sql) 162 | const { method, fullPath } = await renderHttp(statement) 163 | 164 | expect(method).toBe('GET') 165 | expect(fullPath).toBe('/books?title=is.null') 166 | }) 167 | 168 | test('not null', async () => { 169 | const sql = stripIndents` 170 | select 171 | * 172 | from 173 | books 174 | where 175 | title is not null 176 | ` 177 | 178 | const statement = await processSql(sql) 179 | const { method, fullPath } = await renderHttp(statement) 180 | 181 | expect(method).toBe('GET') 182 | expect(fullPath).toBe('/books?title=not.is.null') 183 | }) 184 | 185 | test('float type', async () => { 186 | const sql = stripIndents` 187 | select 188 | * 189 | from 190 | books 191 | where 192 | pages > 10.1 193 | ` 194 | 195 | const statement = await processSql(sql) 196 | const { method, fullPath } = await renderHttp(statement) 197 | 198 | expect(method).toBe('GET') 199 | expect(fullPath).toBe('/books?pages=gt.10.1') 200 | }) 201 | 202 | test('greater than', async () => { 203 | const sql = stripIndents` 204 | select 205 | * 206 | from 207 | books 208 | where 209 | pages > 10 210 | ` 211 | 212 | const statement = await processSql(sql) 213 | const { method, fullPath } = await renderHttp(statement) 214 | 215 | expect(method).toBe('GET') 216 | expect(fullPath).toBe('/books?pages=gt.10') 217 | }) 218 | 219 | test('greater than or equal', async () => { 220 | const sql = stripIndents` 221 | select 222 | * 223 | from 224 | books 225 | where 226 | pages >= 10 227 | ` 228 | 229 | const statement = await processSql(sql) 230 | const { method, fullPath } = await renderHttp(statement) 231 | 232 | expect(method).toBe('GET') 233 | expect(fullPath).toBe('/books?pages=gte.10') 234 | }) 235 | 236 | test('less than', async () => { 237 | const sql = stripIndents` 238 | select 239 | * 240 | from 241 | books 242 | where 243 | pages < 10 244 | ` 245 | 246 | const statement = await processSql(sql) 247 | const { method, fullPath } = await renderHttp(statement) 248 | 249 | expect(method).toBe('GET') 250 | expect(fullPath).toBe('/books?pages=lt.10') 251 | }) 252 | 253 | test('less than or equal', async () => { 254 | const sql = stripIndents` 255 | select 256 | * 257 | from 258 | books 259 | where 260 | pages <= 10 261 | ` 262 | 263 | const statement = await processSql(sql) 264 | const { method, fullPath } = await renderHttp(statement) 265 | 266 | expect(method).toBe('GET') 267 | expect(fullPath).toBe('/books?pages=lte.10') 268 | }) 269 | 270 | test('like', async () => { 271 | const sql = stripIndents` 272 | select 273 | * 274 | from 275 | books 276 | where 277 | description like 'Cheese%' 278 | ` 279 | 280 | const statement = await processSql(sql) 281 | const { method, fullPath } = await renderHttp(statement) 282 | 283 | expect(method).toBe('GET') 284 | expect(fullPath).toBe('/books?description=like.Cheese*') 285 | }) 286 | 287 | test('ilike', async () => { 288 | const sql = stripIndents` 289 | select 290 | * 291 | from 292 | books 293 | where 294 | description ilike '%cheese%' 295 | ` 296 | 297 | const statement = await processSql(sql) 298 | const { method, fullPath } = await renderHttp(statement) 299 | 300 | expect(method).toBe('GET') 301 | expect(fullPath).toBe('/books?description=ilike.*cheese*') 302 | }) 303 | 304 | test('match', async () => { 305 | const sql = stripIndents` 306 | select 307 | * 308 | from 309 | books 310 | where 311 | description ~ '^[a-zA-Z]+' 312 | ` 313 | 314 | const statement = await processSql(sql) 315 | const { method, fullPath } = await renderHttp(statement) 316 | 317 | expect(method).toBe('GET') 318 | expect(fullPath).toBe('/books?description=match.%5E[a-zA-Z]%2B') 319 | }) 320 | 321 | test('imatch', async () => { 322 | const sql = stripIndents` 323 | select 324 | * 325 | from 326 | books 327 | where 328 | description ~* '^[a-z]+' 329 | ` 330 | 331 | const statement = await processSql(sql) 332 | const { method, fullPath } = await renderHttp(statement) 333 | 334 | expect(method).toBe('GET') 335 | expect(fullPath).toBe('/books?description=imatch.%5E[a-z]%2B') 336 | }) 337 | 338 | test('in operator', async () => { 339 | const sql = stripIndents` 340 | select 341 | * 342 | from 343 | books 344 | where 345 | category in ('fiction', 'sci-fi') 346 | ` 347 | 348 | const statement = await processSql(sql) 349 | const { method, fullPath } = await renderHttp(statement) 350 | 351 | expect(method).toBe('GET') 352 | expect(fullPath).toBe('/books?category=in.(fiction,sci-fi)') 353 | }) 354 | 355 | test('in operator with comma', async () => { 356 | const sql = stripIndents` 357 | select 358 | * 359 | from 360 | books 361 | where 362 | category in ('a,b,c', 'd,e,f') 363 | ` 364 | 365 | const statement = await processSql(sql) 366 | const { method, fullPath } = await renderHttp(statement) 367 | 368 | expect(method).toBe('GET') 369 | expect(fullPath).toBe('/books?category=in.(%22a,b,c%22,%22d,e,f%22)') 370 | }) 371 | 372 | test('between operator', async () => { 373 | const sql = stripIndents` 374 | select 375 | * 376 | from 377 | books 378 | where 379 | pages between 10 and 20 380 | ` 381 | 382 | const statement = await processSql(sql) 383 | const { method, fullPath } = await renderHttp(statement) 384 | 385 | expect(method).toBe('GET') 386 | expect(fullPath).toBe('/books?pages=gte.10&pages=lte.20') 387 | }) 388 | 389 | test('between symmetric operator', async () => { 390 | const sql = stripIndents` 391 | select 392 | * 393 | from 394 | books 395 | where 396 | pages between symmetric 20 and 10 397 | ` 398 | 399 | const statement = await processSql(sql) 400 | const { method, fullPath } = await renderHttp(statement) 401 | 402 | expect(method).toBe('GET') 403 | expect(fullPath).toBe('/books?pages=gte.10&pages=lte.20') 404 | }) 405 | 406 | test('between symmetric fails if arguments are not numbers', async () => { 407 | const sql = stripIndents` 408 | select 409 | * 410 | from 411 | books 412 | where 413 | pages between symmetric '2025' and '2024' 414 | ` 415 | 416 | await expect(processSql(sql)).rejects.toThrowError() 417 | }) 418 | 419 | test('full text search using to_tsquery', async () => { 420 | const sql = stripIndents` 421 | select 422 | * 423 | from 424 | books 425 | where 426 | description @@ to_tsquery('cheese') 427 | ` 428 | 429 | const statement = await processSql(sql) 430 | const { method, fullPath } = await renderHttp(statement) 431 | 432 | expect(method).toBe('GET') 433 | expect(fullPath).toBe('/books?description=fts.cheese') 434 | }) 435 | 436 | test('full text search using plainto_tsquery', async () => { 437 | const sql = stripIndents` 438 | select 439 | * 440 | from 441 | books 442 | where 443 | description @@ plainto_tsquery('cheese') 444 | ` 445 | 446 | const statement = await processSql(sql) 447 | const { method, fullPath } = await renderHttp(statement) 448 | 449 | expect(method).toBe('GET') 450 | expect(fullPath).toBe('/books?description=plfts.cheese') 451 | }) 452 | 453 | test('full text search using phraseto_tsquery', async () => { 454 | const sql = stripIndents` 455 | select 456 | * 457 | from 458 | books 459 | where 460 | description @@ phraseto_tsquery('cheese') 461 | ` 462 | 463 | const statement = await processSql(sql) 464 | const { method, fullPath } = await renderHttp(statement) 465 | 466 | expect(method).toBe('GET') 467 | expect(fullPath).toBe('/books?description=phfts.cheese') 468 | }) 469 | 470 | test('full text search using websearch_to_tsquery', async () => { 471 | const sql = stripIndents` 472 | select 473 | * 474 | from 475 | books 476 | where 477 | description @@ websearch_to_tsquery('cheese') 478 | ` 479 | 480 | const statement = await processSql(sql) 481 | const { method, fullPath } = await renderHttp(statement) 482 | 483 | expect(method).toBe('GET') 484 | expect(fullPath).toBe('/books?description=wfts.cheese') 485 | }) 486 | 487 | test('full text search passing config to to_tsquery', async () => { 488 | const sql = stripIndents` 489 | select 490 | * 491 | from 492 | books 493 | where 494 | description @@ to_tsquery('english', 'cheese') 495 | ` 496 | 497 | const statement = await processSql(sql) 498 | const { method, fullPath } = await renderHttp(statement) 499 | 500 | expect(method).toBe('GET') 501 | expect(fullPath).toBe('/books?description=fts(english).cheese') 502 | }) 503 | 504 | test('full text search using unknown function on right side of operator fails', async () => { 505 | const sql = stripIndents` 506 | select 507 | * 508 | from 509 | books 510 | where 511 | description @@ something_else('cheese') 512 | ` 513 | 514 | await expect(processSql(sql)).rejects.toThrowError() 515 | }) 516 | 517 | test('full text search on column wrapped in to_tsvector', async () => { 518 | const sql = stripIndents` 519 | select 520 | * 521 | from 522 | books 523 | where 524 | to_tsvector(description) @@ to_tsquery('cheese') 525 | ` 526 | 527 | const statement = await processSql(sql) 528 | const { method, fullPath } = await renderHttp(statement) 529 | 530 | expect(method).toBe('GET') 531 | expect(fullPath).toBe('/books?description=fts.cheese') 532 | }) 533 | 534 | test('full text search with json column', async () => { 535 | const sql = stripIndents` 536 | select 537 | * 538 | from 539 | books 540 | where 541 | metadata->>'info' @@ to_tsquery('cheese') 542 | ` 543 | 544 | const statement = await processSql(sql) 545 | const { method, fullPath } = await renderHttp(statement) 546 | 547 | expect(method).toBe('GET') 548 | expect(fullPath).toBe('/books?metadata->>info=fts.cheese') 549 | }) 550 | 551 | test('full text search on json column wrapped in to_tsvector', async () => { 552 | const sql = stripIndents` 553 | select 554 | * 555 | from 556 | books 557 | where 558 | to_tsvector(metadata->>'info') @@ to_tsquery('cheese') 559 | ` 560 | 561 | const statement = await processSql(sql) 562 | const { method, fullPath } = await renderHttp(statement) 563 | 564 | expect(method).toBe('GET') 565 | expect(fullPath).toBe('/books?metadata->>info=fts.cheese') 566 | }) 567 | 568 | test('full text search on column wrapped in unknown function fails', async () => { 569 | const sql = stripIndents` 570 | select 571 | * 572 | from 573 | books 574 | where 575 | something_else(description) @@ to_tsquery('cheese') 576 | ` 577 | 578 | await expect(processSql(sql)).rejects.toThrowError() 579 | }) 580 | 581 | test('not between operator', async () => { 582 | const sql = stripIndents` 583 | select 584 | * 585 | from 586 | books 587 | where 588 | pages not between 10 and 20 589 | ` 590 | 591 | const statement = await processSql(sql) 592 | const { method, fullPath } = await renderHttp(statement) 593 | 594 | expect(method).toBe('GET') 595 | expect(fullPath).toBe('/books?not.and=(pages.gte.10,pages.lte.20)') 596 | }) 597 | 598 | test('not between symmetric operator', async () => { 599 | const sql = stripIndents` 600 | select 601 | * 602 | from 603 | books 604 | where 605 | pages not between symmetric 20 and 10 606 | ` 607 | 608 | const statement = await processSql(sql) 609 | const { method, fullPath } = await renderHttp(statement) 610 | 611 | expect(method).toBe('GET') 612 | expect(fullPath).toBe('/books?not.and=(pages.gte.10,pages.lte.20)') 613 | }) 614 | 615 | test('unknown operator fails', async () => { 616 | const sql = stripIndents` 617 | select 618 | * 619 | from 620 | books 621 | where 622 | embedding <=> '[1,2,3]' 623 | ` 624 | 625 | await expect(processSql(sql)).rejects.toThrowError("Unsupported operator '<=>'") 626 | }) 627 | 628 | test('"and" expression', async () => { 629 | const sql = stripIndents` 630 | select 631 | * 632 | from 633 | books 634 | where 635 | title = 'Cheese' and 636 | description ilike '%salsa%' 637 | ` 638 | 639 | const statement = await processSql(sql) 640 | const { method, fullPath } = await renderHttp(statement) 641 | 642 | expect(method).toBe('GET') 643 | expect(fullPath).toBe('/books?title=eq.Cheese&description=ilike.*salsa*') 644 | }) 645 | 646 | test('"and" expression using the same column multiple times', async () => { 647 | const sql = stripIndents` 648 | select 649 | * 650 | from 651 | books 652 | where 653 | pages > 100 and 654 | pages < 1000 655 | ` 656 | 657 | const statement = await processSql(sql) 658 | const { method, fullPath } = await renderHttp(statement) 659 | 660 | expect(method).toBe('GET') 661 | expect(fullPath).toBe('/books?pages=gt.100&pages=lt.1000') 662 | }) 663 | 664 | test('"or" expression', async () => { 665 | const sql = stripIndents` 666 | select 667 | * 668 | from 669 | books 670 | where 671 | title = 'Cheese' or 672 | title = 'Salsa' 673 | ` 674 | 675 | const statement = await processSql(sql) 676 | const { method, fullPath } = await renderHttp(statement) 677 | 678 | expect(method).toBe('GET') 679 | expect(fullPath).toBe('/books?or=(title.eq.Cheese,title.eq.Salsa)') 680 | }) 681 | 682 | test('negated column operator', async () => { 683 | const sql = stripIndents` 684 | select 685 | * 686 | from 687 | books 688 | where 689 | not ( 690 | title = 'Cheese' 691 | ) 692 | ` 693 | 694 | const statement = await processSql(sql) 695 | const { method, fullPath } = await renderHttp(statement) 696 | 697 | expect(method).toBe('GET') 698 | expect(fullPath).toBe('/books?title=not.eq.Cheese') 699 | }) 700 | 701 | test('negated "and" expression', async () => { 702 | const sql = stripIndents` 703 | select 704 | * 705 | from 706 | books 707 | where 708 | not ( 709 | title = 'Cheese' and 710 | description ilike '%salsa%' 711 | ) 712 | ` 713 | 714 | const statement = await processSql(sql) 715 | const { method, fullPath } = await renderHttp(statement) 716 | 717 | expect(method).toBe('GET') 718 | expect(fullPath).toBe('/books?not.and=(title.eq.Cheese,description.ilike.*salsa*)') 719 | }) 720 | 721 | test('negated "or" expression', async () => { 722 | const sql = stripIndents` 723 | select 724 | * 725 | from 726 | books 727 | where 728 | not ( 729 | title = 'Cheese' or 730 | title = 'Salsa' 731 | ) 732 | ` 733 | 734 | const statement = await processSql(sql) 735 | const { method, fullPath } = await renderHttp(statement) 736 | 737 | expect(method).toBe('GET') 738 | expect(fullPath).toBe('/books?not.or=(title.eq.Cheese,title.eq.Salsa)') 739 | }) 740 | 741 | test('"or" expression with negated column operator', async () => { 742 | const sql = stripIndents` 743 | select 744 | * 745 | from 746 | books 747 | where 748 | not (title = 'Cheese') 749 | or title = 'Salsa' 750 | ` 751 | 752 | const statement = await processSql(sql) 753 | const { method, fullPath } = await renderHttp(statement) 754 | 755 | expect(method).toBe('GET') 756 | expect(fullPath).toBe('/books?or=(title.not.eq.Cheese,title.eq.Salsa)') 757 | }) 758 | 759 | test('"or" expression with in operator', async () => { 760 | const sql = stripIndents` 761 | select 762 | * 763 | from 764 | books 765 | where 766 | title in ('Cheese', 'Salsa') 767 | or description ilike '%tacos%' 768 | ` 769 | 770 | const statement = await processSql(sql) 771 | const { method, fullPath } = await renderHttp(statement) 772 | 773 | expect(method).toBe('GET') 774 | expect(fullPath).toBe('/books?or=(title.in.(Cheese,Salsa),description.ilike.*tacos*)') 775 | }) 776 | 777 | test('"and" expression with nested "or"', async () => { 778 | const sql = stripIndents` 779 | select 780 | * 781 | from 782 | books 783 | where 784 | title like 'T%' and 785 | ( 786 | description ilike '%tacos%' or 787 | description ilike '%salsa%' 788 | ) 789 | ` 790 | 791 | const statement = await processSql(sql) 792 | const { method, fullPath } = await renderHttp(statement) 793 | 794 | expect(method).toBe('GET') 795 | expect(fullPath).toBe( 796 | '/books?title=like.T*&or=(description.ilike.*tacos*,description.ilike.*salsa*)' 797 | ) 798 | }) 799 | 800 | test('negated "and" expression with nested "or"', async () => { 801 | const sql = stripIndents` 802 | select 803 | * 804 | from 805 | books 806 | where 807 | not ( 808 | title like 'T%' and 809 | ( 810 | description ilike '%tacos%' or 811 | description ilike '%salsa%' 812 | ) 813 | ) 814 | ` 815 | 816 | const statement = await processSql(sql) 817 | const { method, fullPath } = await renderHttp(statement) 818 | 819 | expect(method).toBe('GET') 820 | expect(fullPath).toBe( 821 | '/books?not.and=(title.like.T*,or(description.ilike.*tacos*,description.ilike.*salsa*))' 822 | ) 823 | }) 824 | 825 | test('negated "and" expression with negated nested "or"', async () => { 826 | const sql = stripIndents` 827 | select 828 | * 829 | from 830 | books 831 | where 832 | not ( 833 | title like 'T%' and 834 | not ( 835 | description ilike '%tacos%' or 836 | description ilike '%salsa%' 837 | ) 838 | ) 839 | ` 840 | 841 | const statement = await processSql(sql) 842 | const { method, fullPath } = await renderHttp(statement) 843 | 844 | expect(method).toBe('GET') 845 | expect(fullPath).toBe( 846 | '/books?not.and=(title.like.T*,not.or(description.ilike.*tacos*,description.ilike.*salsa*))' 847 | ) 848 | }) 849 | 850 | test('order of operations', async () => { 851 | const sql = stripIndents` 852 | select 853 | * 854 | from 855 | books 856 | where 857 | title like 'T%' and 858 | description ilike '%tacos%' or 859 | description ilike '%salsa%' 860 | ` 861 | 862 | const statement = await processSql(sql) 863 | const { method, fullPath } = await renderHttp(statement) 864 | 865 | expect(method).toBe('GET') 866 | expect(fullPath).toBe( 867 | '/books?or=(and(title.like.T*,description.ilike.*tacos*),description.ilike.*salsa*)' 868 | ) 869 | }) 870 | 871 | test('limit', async () => { 872 | const sql = stripIndents` 873 | select 874 | * 875 | from 876 | books 877 | limit 878 | 5 879 | ` 880 | 881 | const statement = await processSql(sql) 882 | const { method, fullPath } = await renderHttp(statement) 883 | 884 | expect(method).toBe('GET') 885 | expect(fullPath).toBe('/books?limit=5') 886 | }) 887 | 888 | test('offset', async () => { 889 | const sql = stripIndents` 890 | select 891 | * 892 | from 893 | books 894 | offset 895 | 10 896 | ` 897 | 898 | const statement = await processSql(sql) 899 | const { method, fullPath } = await renderHttp(statement) 900 | 901 | expect(method).toBe('GET') 902 | expect(fullPath).toBe('/books?offset=10') 903 | }) 904 | 905 | test('limit and offset', async () => { 906 | const sql = stripIndents` 907 | select 908 | * 909 | from 910 | books 911 | limit 912 | 5 913 | offset 914 | 10 915 | ` 916 | 917 | const statement = await processSql(sql) 918 | const { method, fullPath } = await renderHttp(statement) 919 | 920 | expect(method).toBe('GET') 921 | expect(fullPath).toBe('/books?limit=5&offset=10') 922 | }) 923 | 924 | test('order by', async () => { 925 | const sql = stripIndents` 926 | select 927 | * 928 | from 929 | books 930 | order by 931 | title 932 | ` 933 | 934 | const statement = await processSql(sql) 935 | const { method, fullPath } = await renderHttp(statement) 936 | 937 | expect(method).toBe('GET') 938 | expect(fullPath).toBe('/books?order=title') 939 | }) 940 | 941 | test('order by multiple columns', async () => { 942 | const sql = stripIndents` 943 | select 944 | * 945 | from 946 | books 947 | order by 948 | title, 949 | description 950 | ` 951 | 952 | const statement = await processSql(sql) 953 | const { method, fullPath } = await renderHttp(statement) 954 | 955 | expect(method).toBe('GET') 956 | expect(fullPath).toBe('/books?order=title,description') 957 | }) 958 | 959 | test('order by asc', async () => { 960 | const sql = stripIndents` 961 | select 962 | * 963 | from 964 | books 965 | order by 966 | title asc 967 | ` 968 | 969 | const statement = await processSql(sql) 970 | const { method, fullPath } = await renderHttp(statement) 971 | 972 | expect(method).toBe('GET') 973 | expect(fullPath).toBe('/books?order=title.asc') 974 | }) 975 | 976 | test('order by desc', async () => { 977 | const sql = stripIndents` 978 | select 979 | * 980 | from 981 | books 982 | order by 983 | title desc 984 | ` 985 | 986 | const statement = await processSql(sql) 987 | const { method, fullPath } = await renderHttp(statement) 988 | 989 | expect(method).toBe('GET') 990 | expect(fullPath).toBe('/books?order=title.desc') 991 | }) 992 | 993 | test('order by nulls first', async () => { 994 | const sql = stripIndents` 995 | select 996 | * 997 | from 998 | books 999 | order by 1000 | title nulls first 1001 | ` 1002 | 1003 | const statement = await processSql(sql) 1004 | const { method, fullPath } = await renderHttp(statement) 1005 | 1006 | expect(method).toBe('GET') 1007 | expect(fullPath).toBe('/books?order=title.nullsfirst') 1008 | }) 1009 | 1010 | test('order by nulls last', async () => { 1011 | const sql = stripIndents` 1012 | select 1013 | * 1014 | from 1015 | books 1016 | order by 1017 | title nulls last 1018 | ` 1019 | 1020 | const statement = await processSql(sql) 1021 | const { method, fullPath } = await renderHttp(statement) 1022 | 1023 | expect(method).toBe('GET') 1024 | expect(fullPath).toBe('/books?order=title.nullslast') 1025 | }) 1026 | 1027 | test('order by desc nulls last', async () => { 1028 | const sql = stripIndents` 1029 | select 1030 | * 1031 | from 1032 | books 1033 | order by 1034 | title desc nulls last 1035 | ` 1036 | 1037 | const statement = await processSql(sql) 1038 | const { method, fullPath } = await renderHttp(statement) 1039 | 1040 | expect(method).toBe('GET') 1041 | expect(fullPath).toBe('/books?order=title.desc.nullslast') 1042 | }) 1043 | 1044 | test('cast', async () => { 1045 | const sql = stripIndents` 1046 | select 1047 | pages::float 1048 | from 1049 | books 1050 | ` 1051 | 1052 | const statement = await processSql(sql) 1053 | const { method, fullPath } = await renderHttp(statement) 1054 | 1055 | expect(method).toBe('GET') 1056 | expect(fullPath).toBe('/books?select=pages::float') 1057 | }) 1058 | 1059 | test('cast with alias', async () => { 1060 | const sql = stripIndents` 1061 | select 1062 | pages::float as "partialPages" 1063 | from 1064 | books 1065 | ` 1066 | 1067 | const statement = await processSql(sql) 1068 | const { method, fullPath } = await renderHttp(statement) 1069 | 1070 | expect(method).toBe('GET') 1071 | expect(fullPath).toBe('/books?select=partialPages:pages::float') 1072 | }) 1073 | 1074 | test('cast in where clause fails', async () => { 1075 | const sql = stripIndents` 1076 | select 1077 | pages 1078 | from 1079 | books 1080 | where 1081 | pages::float > 10.0 1082 | ` 1083 | 1084 | await expect(processSql(sql)).rejects.toThrowError() 1085 | }) 1086 | 1087 | test('cast in order by clause fails', async () => { 1088 | const sql = stripIndents` 1089 | select 1090 | pages 1091 | from 1092 | books 1093 | order by 1094 | pages::float desc 1095 | ` 1096 | 1097 | await expect(processSql(sql)).rejects.toThrowError() 1098 | }) 1099 | 1100 | test('multiple from relations fail', async () => { 1101 | const sql = stripIndents` 1102 | select 1103 | *, 1104 | authors.name 1105 | from 1106 | books, authors 1107 | ` 1108 | 1109 | await expect(processSql(sql)).rejects.toThrowError() 1110 | }) 1111 | 1112 | test('left join', async () => { 1113 | const sql = stripIndents` 1114 | select 1115 | *, 1116 | authors.name 1117 | from 1118 | books 1119 | left join 1120 | authors 1121 | on author_id = authors.id 1122 | ` 1123 | 1124 | const statement = await processSql(sql) 1125 | const { method, fullPath } = await renderHttp(statement) 1126 | 1127 | expect(method).toBe('GET') 1128 | expect(fullPath).toBe('/books?select=*,...authors(name)') 1129 | }) 1130 | 1131 | test('implicit inner join', async () => { 1132 | const sql = stripIndents` 1133 | select 1134 | *, 1135 | authors.name 1136 | from 1137 | books 1138 | join 1139 | authors 1140 | on author_id = authors.id 1141 | ` 1142 | 1143 | const statement = await processSql(sql) 1144 | const { method, fullPath } = await renderHttp(statement) 1145 | 1146 | expect(method).toBe('GET') 1147 | expect(fullPath).toBe('/books?select=*,...authors!inner(name)') 1148 | }) 1149 | 1150 | test('explicit inner join', async () => { 1151 | const sql = stripIndents` 1152 | select 1153 | *, 1154 | authors.name 1155 | from 1156 | books 1157 | inner join 1158 | authors 1159 | on author_id = authors.id 1160 | ` 1161 | 1162 | const statement = await processSql(sql) 1163 | const { method, fullPath } = await renderHttp(statement) 1164 | 1165 | expect(method).toBe('GET') 1166 | expect(fullPath).toBe('/books?select=*,...authors!inner(name)') 1167 | }) 1168 | 1169 | test('join that is not inner or left fails', async () => { 1170 | const sql = stripIndents` 1171 | select 1172 | *, 1173 | authors.name 1174 | from 1175 | books 1176 | right join 1177 | authors 1178 | on author_id = authors.id 1179 | ` 1180 | 1181 | await expect(processSql(sql)).rejects.toThrowError() 1182 | }) 1183 | 1184 | test('join on aliased relation strips alias when spread', async () => { 1185 | const sql = stripIndents` 1186 | select 1187 | *, 1188 | a.name 1189 | from 1190 | books 1191 | join 1192 | authors as a 1193 | on author_id = a.id 1194 | where 1195 | a.name = 'Bob' 1196 | order by 1197 | a.name 1198 | ` 1199 | 1200 | const statement = await processSql(sql) 1201 | const { method, fullPath } = await renderHttp(statement) 1202 | 1203 | expect(method).toBe('GET') 1204 | expect(fullPath).toBe( 1205 | '/books?select=*,...authors!inner(name)&authors.name=eq.Bob&order=authors(name)' 1206 | ) 1207 | }) 1208 | 1209 | test('join using primary relation in qualifier', async () => { 1210 | const sql = stripIndents` 1211 | select 1212 | *, 1213 | authors.name 1214 | from 1215 | books 1216 | join 1217 | authors 1218 | on books.author_id = authors.id 1219 | ` 1220 | 1221 | const statement = await processSql(sql) 1222 | const { method, fullPath } = await renderHttp(statement) 1223 | 1224 | expect(method).toBe('GET') 1225 | expect(fullPath).toBe('/books?select=*,...authors!inner(name)') 1226 | }) 1227 | 1228 | test('join using alias on primary relation', async () => { 1229 | const sql = stripIndents` 1230 | select 1231 | *, 1232 | a.name 1233 | from 1234 | books b 1235 | join 1236 | authors as a 1237 | on b.author_id = a.id 1238 | ` 1239 | 1240 | const statement = await processSql(sql) 1241 | const { method, fullPath } = await renderHttp(statement) 1242 | 1243 | expect(method).toBe('GET') 1244 | expect(fullPath).toBe('/books?select=*,...authors!inner(name)') 1245 | }) 1246 | 1247 | // TODO: add support for recursive relationships 1248 | test('join using same relation in qualifier fails', async () => { 1249 | const sql = stripIndents` 1250 | select 1251 | *, 1252 | authors.name 1253 | from 1254 | books 1255 | join 1256 | authors 1257 | on authors.id = authors.another_author_id 1258 | ` 1259 | 1260 | await expect(processSql(sql)).rejects.toThrowError() 1261 | }) 1262 | 1263 | test('join using unknown relation in qualifier fails', async () => { 1264 | const sql = stripIndents` 1265 | select 1266 | *, 1267 | authors.name 1268 | from 1269 | books 1270 | join 1271 | authors 1272 | on movies.author_id = authors.id 1273 | ` 1274 | 1275 | await expect(processSql(sql)).rejects.toThrowError() 1276 | }) 1277 | 1278 | test('join on aliased relation with target on original relation fails', async () => { 1279 | const sql = stripIndents` 1280 | select 1281 | *, 1282 | authors.name 1283 | from 1284 | books 1285 | join 1286 | authors as a 1287 | on author_id = authors.id 1288 | ` 1289 | 1290 | await expect(processSql(sql)).rejects.toThrowError() 1291 | }) 1292 | 1293 | test('foreign column in target list without join fails', async () => { 1294 | const sql = stripIndents` 1295 | select 1296 | *, 1297 | authors.name 1298 | from 1299 | books 1300 | ` 1301 | 1302 | await expect(processSql(sql)).rejects.toThrowError() 1303 | }) 1304 | 1305 | test('join with non-expression qualifier fails', async () => { 1306 | const sql = stripIndents` 1307 | select 1308 | *, 1309 | authors.name 1310 | from 1311 | books 1312 | join 1313 | authors 1314 | on true 1315 | ` 1316 | 1317 | await expect(processSql(sql)).rejects.toThrowError() 1318 | }) 1319 | 1320 | test('join with left side constant qualifier fails', async () => { 1321 | const sql = stripIndents` 1322 | select 1323 | *, 1324 | authors.name 1325 | from 1326 | books 1327 | join 1328 | authors 1329 | on 1 = authors.id 1330 | ` 1331 | 1332 | await expect(processSql(sql)).rejects.toThrowError() 1333 | }) 1334 | 1335 | test('join with right side constant qualifier fails', async () => { 1336 | const sql = stripIndents` 1337 | select 1338 | *, 1339 | authors.name 1340 | from 1341 | books 1342 | join 1343 | authors 1344 | on author_id = 1 1345 | ` 1346 | 1347 | await expect(processSql(sql)).rejects.toThrowError() 1348 | }) 1349 | 1350 | test('join with non-equal qualifier operator fails', async () => { 1351 | const sql = stripIndents` 1352 | select 1353 | *, 1354 | authors.name 1355 | from 1356 | books 1357 | join 1358 | authors 1359 | on author_id > authors.id 1360 | ` 1361 | 1362 | await expect(processSql(sql)).rejects.toThrowError() 1363 | }) 1364 | 1365 | test('join nested relations', async () => { 1366 | const sql = stripIndents` 1367 | select 1368 | *, 1369 | authors.name 1370 | from 1371 | books 1372 | join 1373 | authors 1374 | on author_id = authors.id 1375 | join 1376 | editors 1377 | on authors.editor_id = editors.id 1378 | ` 1379 | 1380 | const statement = await processSql(sql) 1381 | const { method, fullPath } = await renderHttp(statement) 1382 | 1383 | expect(method).toBe('GET') 1384 | expect(fullPath).toBe('/books?select=*,...authors!inner(name,...editors!inner())') 1385 | }) 1386 | 1387 | test('join qualifier missing column from joined relation fails', async () => { 1388 | const sql = stripIndents` 1389 | select 1390 | *, 1391 | authors.name 1392 | from 1393 | books 1394 | join 1395 | authors 1396 | on author_id = authors.id 1397 | join 1398 | editors 1399 | on authors.editor_id = author_id 1400 | ` 1401 | 1402 | await expect(processSql(sql)).rejects.toThrowError() 1403 | }) 1404 | 1405 | test('join qualifier columns work on either side of equation', async () => { 1406 | const sql = stripIndents` 1407 | select 1408 | *, 1409 | authors.name 1410 | from 1411 | books 1412 | join 1413 | authors 1414 | on authors.id = author_id 1415 | join 1416 | editors 1417 | on editors.id = authors.editor_id 1418 | ` 1419 | 1420 | const statement = await processSql(sql) 1421 | const { method, fullPath } = await renderHttp(statement) 1422 | 1423 | expect(method).toBe('GET') 1424 | expect(fullPath).toBe('/books?select=*,...authors!inner(name,...editors!inner())') 1425 | }) 1426 | 1427 | test('join multiple relations', async () => { 1428 | const sql = stripIndents` 1429 | select 1430 | *, 1431 | author.name 1432 | from 1433 | books 1434 | join authors author 1435 | on author_id = author.id 1436 | join editors editor 1437 | on author.editor_id = editor.id 1438 | join viewers viewer 1439 | on viewer_id = viewer.id 1440 | ` 1441 | 1442 | const statement = await processSql(sql) 1443 | const { method, fullPath } = await renderHttp(statement) 1444 | 1445 | expect(method).toBe('GET') 1446 | expect(fullPath).toBe( 1447 | '/books?select=*,...authors!inner(name,...editors!inner()),...viewers!inner()' 1448 | ) 1449 | }) 1450 | 1451 | test('select all columns from joined table', async () => { 1452 | const sql = stripIndents` 1453 | select 1454 | author.* 1455 | from 1456 | books 1457 | left join authors author 1458 | on author_id = author.id 1459 | ` 1460 | 1461 | const statement = await processSql(sql) 1462 | const { method, fullPath } = await renderHttp(statement) 1463 | 1464 | expect(method).toBe('GET') 1465 | expect(fullPath).toBe('/books?select=...authors(*)') 1466 | }) 1467 | 1468 | test('joined table order by', async () => { 1469 | const sql = stripIndents` 1470 | select 1471 | books.* 1472 | from 1473 | books 1474 | join authors author 1475 | on author_id = author.id 1476 | order by 1477 | author.name desc 1478 | ` 1479 | 1480 | const statement = await processSql(sql) 1481 | const { method, fullPath } = await renderHttp(statement) 1482 | 1483 | expect(method).toBe('GET') 1484 | expect(fullPath).toBe('/books?select=*,...authors!inner()&order=authors(name).desc') 1485 | }) 1486 | 1487 | test('select json column', async () => { 1488 | const sql = stripIndents` 1489 | select 1490 | address->'city'->>'name' 1491 | from 1492 | books 1493 | ` 1494 | 1495 | const statement = await processSql(sql) 1496 | const { method, fullPath } = await renderHttp(statement) 1497 | 1498 | expect(method).toBe('GET') 1499 | expect(fullPath).toBe('/books?select=address->city->>name') 1500 | }) 1501 | 1502 | test('right side of json operator can be an integer index', async () => { 1503 | const sql = stripIndents` 1504 | select 1505 | contributors->'names'->0 1506 | from 1507 | books 1508 | ` 1509 | 1510 | const statement = await processSql(sql) 1511 | const { method, fullPath } = await renderHttp(statement) 1512 | 1513 | expect(method).toBe('GET') 1514 | expect(fullPath).toBe('/books?select=contributors->names->0') 1515 | }) 1516 | 1517 | test('right side of json operator that is a float fails', async () => { 1518 | const sql = stripIndents` 1519 | select 1520 | contributors->'names'->0.5 1521 | from 1522 | books 1523 | ` 1524 | 1525 | await expect(processSql(sql)).rejects.toThrowError() 1526 | }) 1527 | 1528 | test('right side of json operator that is a column fails', async () => { 1529 | const sql = stripIndents` 1530 | select 1531 | address->city 1532 | from 1533 | books 1534 | ` 1535 | 1536 | await expect(processSql(sql)).rejects.toThrowError() 1537 | }) 1538 | 1539 | test('select json column with cast', async () => { 1540 | const sql = stripIndents` 1541 | select 1542 | order_details->'tax_amount'::numeric 1543 | from 1544 | orders 1545 | ` 1546 | 1547 | const statement = await processSql(sql) 1548 | const { method, fullPath } = await renderHttp(statement) 1549 | 1550 | expect(method).toBe('GET') 1551 | expect(fullPath).toBe('/orders?select=order_details->tax_amount::numeric') 1552 | }) 1553 | 1554 | test('filter by json column', async () => { 1555 | const sql = stripIndents` 1556 | select 1557 | address->'city'->>'name' 1558 | from 1559 | books 1560 | where 1561 | address->'city'->>'code' = 'SFO' 1562 | ` 1563 | 1564 | const statement = await processSql(sql) 1565 | const { method, fullPath } = await renderHttp(statement) 1566 | 1567 | expect(method).toBe('GET') 1568 | expect(fullPath).toBe('/books?select=address->city->>name&address->city->>code=eq.SFO') 1569 | }) 1570 | 1571 | test('order by json column', async () => { 1572 | const sql = stripIndents` 1573 | select 1574 | address->'city'->>'name' 1575 | from 1576 | books 1577 | order by 1578 | address->'city'->>'code' 1579 | ` 1580 | 1581 | const statement = await processSql(sql) 1582 | const { method, fullPath } = await renderHttp(statement) 1583 | 1584 | expect(method).toBe('GET') 1585 | expect(fullPath).toBe('/books?select=address->city->>name&order=address->city->>code') 1586 | }) 1587 | 1588 | test('aggregate', async () => { 1589 | const sql = stripIndents` 1590 | select 1591 | sum(amount) 1592 | from 1593 | orders 1594 | ` 1595 | 1596 | const statement = await processSql(sql) 1597 | const { method, fullPath } = await renderHttp(statement) 1598 | 1599 | expect(method).toBe('GET') 1600 | expect(fullPath).toBe('/orders?select=amount.sum()') 1601 | }) 1602 | 1603 | test('aggregate with alias', async () => { 1604 | const sql = stripIndents` 1605 | select 1606 | sum(amount) as total 1607 | from 1608 | orders 1609 | ` 1610 | 1611 | const statement = await processSql(sql) 1612 | const { method, fullPath } = await renderHttp(statement) 1613 | 1614 | expect(method).toBe('GET') 1615 | expect(fullPath).toBe('/orders?select=total:amount.sum()') 1616 | }) 1617 | 1618 | test('aggregate with input cast', async () => { 1619 | const sql = stripIndents` 1620 | select 1621 | sum(amount::float) 1622 | from 1623 | orders 1624 | ` 1625 | 1626 | const statement = await processSql(sql) 1627 | const { method, fullPath } = await renderHttp(statement) 1628 | 1629 | expect(method).toBe('GET') 1630 | expect(fullPath).toBe('/orders?select=amount::float.sum()') 1631 | }) 1632 | 1633 | test('aggregate with output cast', async () => { 1634 | const sql = stripIndents` 1635 | select 1636 | sum(amount)::float 1637 | from 1638 | orders 1639 | ` 1640 | 1641 | const statement = await processSql(sql) 1642 | const { method, fullPath } = await renderHttp(statement) 1643 | 1644 | expect(method).toBe('GET') 1645 | expect(fullPath).toBe('/orders?select=amount.sum()::float') 1646 | }) 1647 | 1648 | test('aggregate with input and output cast', async () => { 1649 | const sql = stripIndents` 1650 | select 1651 | sum(amount::int)::float 1652 | from 1653 | orders 1654 | ` 1655 | 1656 | const statement = await processSql(sql) 1657 | const { method, fullPath } = await renderHttp(statement) 1658 | 1659 | expect(method).toBe('GET') 1660 | expect(fullPath).toBe('/orders?select=amount::int.sum()::float') 1661 | }) 1662 | 1663 | test('unsupported aggregate function fails', async () => { 1664 | const sql = stripIndents` 1665 | select 1666 | custom_sum(amount::int)::float 1667 | from 1668 | orders 1669 | ` 1670 | 1671 | await expect(processSql(sql)).rejects.toThrowError() 1672 | }) 1673 | 1674 | test('aggregate on a json target', async () => { 1675 | const sql = stripIndents` 1676 | select 1677 | sum(order_details->'tax_amount'::numeric) 1678 | from 1679 | orders 1680 | ` 1681 | 1682 | const statement = await processSql(sql) 1683 | const { method, fullPath } = await renderHttp(statement) 1684 | 1685 | expect(method).toBe('GET') 1686 | expect(fullPath).toBe('/orders?select=order_details->tax_amount::numeric.sum()') 1687 | }) 1688 | 1689 | test('group by aggregates', async () => { 1690 | const sql = stripIndents` 1691 | select 1692 | sum(amount), 1693 | category 1694 | from 1695 | orders 1696 | group by 1697 | category 1698 | ` 1699 | 1700 | const statement = await processSql(sql) 1701 | const { method, fullPath } = await renderHttp(statement) 1702 | 1703 | expect(method).toBe('GET') 1704 | expect(fullPath).toBe('/orders?select=amount.sum(),category') 1705 | }) 1706 | 1707 | test('group by without select target fails', async () => { 1708 | const sql = stripIndents` 1709 | select 1710 | sum(amount) 1711 | from 1712 | orders 1713 | group by 1714 | category 1715 | ` 1716 | 1717 | await expect(processSql(sql)).rejects.toThrowError() 1718 | }) 1719 | 1720 | test('aggregate with another target column but no group by fails', async () => { 1721 | const sql = stripIndents` 1722 | select 1723 | sum(amount), 1724 | category 1725 | from 1726 | orders 1727 | ` 1728 | 1729 | await expect(processSql(sql)).rejects.toThrowError() 1730 | }) 1731 | 1732 | test('aggregate with missing group by column fails', async () => { 1733 | const sql = stripIndents` 1734 | select 1735 | sum(amount), 1736 | category, 1737 | name 1738 | from 1739 | orders 1740 | group by 1741 | category 1742 | ` 1743 | 1744 | await expect(processSql(sql)).rejects.toThrowError() 1745 | }) 1746 | 1747 | test('group by with having fails', async () => { 1748 | const sql = stripIndents` 1749 | select 1750 | sum(amount), 1751 | category 1752 | from 1753 | orders 1754 | group by 1755 | category 1756 | having sum(amount) > 1000 1757 | ` 1758 | 1759 | await expect(processSql(sql)).rejects.toThrowError() 1760 | }) 1761 | 1762 | test('group by a joined column', async () => { 1763 | const sql = stripIndents` 1764 | select 1765 | sum(amount), 1766 | customer.region 1767 | from 1768 | orders 1769 | join 1770 | customers customer 1771 | on customer_id = customer.id 1772 | group by 1773 | customer.region 1774 | ` 1775 | 1776 | const statement = await processSql(sql) 1777 | const { method, fullPath } = await renderHttp(statement) 1778 | 1779 | expect(method).toBe('GET') 1780 | expect(fullPath).toBe('/orders?select=amount.sum(),...customers!inner(region)') 1781 | }) 1782 | 1783 | test('aggregate on a joined column', async () => { 1784 | const sql = stripIndents` 1785 | select 1786 | name, 1787 | avg(orders.amount) as average_spend 1788 | from 1789 | customers 1790 | join 1791 | orders 1792 | on id = orders.customer_id 1793 | group by 1794 | name 1795 | ` 1796 | 1797 | const statement = await processSql(sql) 1798 | const { method, fullPath } = await renderHttp(statement) 1799 | 1800 | expect(method).toBe('GET') 1801 | expect(fullPath).toBe('/customers?select=name,...orders!inner(average_spend:amount.avg())') 1802 | }) 1803 | 1804 | test('aliased primary relation in group by', async () => { 1805 | const sql = stripIndents` 1806 | select 1807 | region, 1808 | max(age), 1809 | min(age) 1810 | from 1811 | profiles p 1812 | group by 1813 | p.region 1814 | ` 1815 | 1816 | const statement = await processSql(sql) 1817 | const { method, fullPath } = await renderHttp(statement) 1818 | 1819 | expect(method).toBe('GET') 1820 | expect(fullPath).toBe('/profiles?select=region,age.max(),age.min()') 1821 | }) 1822 | 1823 | test('count on column', async () => { 1824 | const sql = stripIndents` 1825 | select 1826 | count(avatar) 1827 | from 1828 | profiles 1829 | ` 1830 | 1831 | const statement = await processSql(sql) 1832 | const { method, fullPath } = await renderHttp(statement) 1833 | 1834 | expect(method).toBe('GET') 1835 | expect(fullPath).toBe('/profiles?select=avatar.count()') 1836 | }) 1837 | 1838 | test('count on all rows', async () => { 1839 | const sql = stripIndents` 1840 | select 1841 | count(*) 1842 | from 1843 | profiles 1844 | ` 1845 | 1846 | const statement = await processSql(sql) 1847 | const { method, fullPath } = await renderHttp(statement) 1848 | 1849 | expect(method).toBe('GET') 1850 | expect(fullPath).toBe('/profiles?select=count()') 1851 | }) 1852 | 1853 | test('count on all rows with alias', async () => { 1854 | const sql = stripIndents` 1855 | select 1856 | count(*) as total 1857 | from 1858 | profiles 1859 | ` 1860 | 1861 | const statement = await processSql(sql) 1862 | const { method, fullPath } = await renderHttp(statement) 1863 | 1864 | expect(method).toBe('GET') 1865 | expect(fullPath).toBe('/profiles?select=total:count()') 1866 | }) 1867 | 1868 | test('count on all rows with cast', async () => { 1869 | const sql = stripIndents` 1870 | select 1871 | count(*)::float 1872 | from 1873 | profiles 1874 | ` 1875 | 1876 | const statement = await processSql(sql) 1877 | const { method, fullPath } = await renderHttp(statement) 1878 | 1879 | expect(method).toBe('GET') 1880 | expect(fullPath).toBe('/profiles?select=count()::float') 1881 | }) 1882 | 1883 | test('count on all rows with group by', async () => { 1884 | const sql = stripIndents` 1885 | select 1886 | region, 1887 | count(*) 1888 | from 1889 | profiles 1890 | group by 1891 | region 1892 | ` 1893 | 1894 | const statement = await processSql(sql) 1895 | const { method, fullPath } = await renderHttp(statement) 1896 | 1897 | expect(method).toBe('GET') 1898 | expect(fullPath).toBe('/profiles?select=region,count()') 1899 | }) 1900 | 1901 | test('primary relation prefix stripped from qualified column', async () => { 1902 | const sql = stripIndents` 1903 | select 1904 | profiles.name 1905 | from 1906 | profiles 1907 | where 1908 | profiles.name = 'Bob' 1909 | order by 1910 | profiles.name 1911 | ` 1912 | 1913 | const statement = await processSql(sql) 1914 | const { method, fullPath } = await renderHttp(statement) 1915 | 1916 | expect(method).toBe('GET') 1917 | expect(fullPath).toBe('/profiles?select=name&name=eq.Bob&order=name') 1918 | }) 1919 | 1920 | test('primary relation alias prefix stripped from qualified column', async () => { 1921 | const sql = stripIndents` 1922 | select 1923 | p.name 1924 | from 1925 | profiles p 1926 | where 1927 | p.name = 'Bob' 1928 | order by 1929 | p.name 1930 | ` 1931 | 1932 | const statement = await processSql(sql) 1933 | const { method, fullPath } = await renderHttp(statement) 1934 | 1935 | expect(method).toBe('GET') 1936 | expect(fullPath).toBe('/profiles?select=name&name=eq.Bob&order=name') 1937 | }) 1938 | 1939 | test('primary relation prefix stripped from qualified json column', async () => { 1940 | const sql = stripIndents` 1941 | select 1942 | books.address->'city'->>'name' 1943 | from 1944 | books 1945 | where 1946 | books.address->'country'->>'code' = 'CA' 1947 | order by 1948 | books.address->'city'->>'code' 1949 | ` 1950 | 1951 | const statement = await processSql(sql) 1952 | const { method, fullPath } = await renderHttp(statement) 1953 | 1954 | expect(method).toBe('GET') 1955 | expect(fullPath).toBe( 1956 | '/books?select=address->city->>name&address->country->>code=eq.CA&order=address->city->>code' 1957 | ) 1958 | }) 1959 | 1960 | test('joined relation prefix retained in qualified json column', async () => { 1961 | const sql = stripIndents` 1962 | select 1963 | * 1964 | from 1965 | books 1966 | join author 1967 | on author_id = author.id 1968 | where 1969 | author.address->'country'->>'code' = 'CA' 1970 | order by 1971 | author.address->'city'->>'code' 1972 | ` 1973 | 1974 | const statement = await processSql(sql) 1975 | const { method, fullPath } = await renderHttp(statement) 1976 | 1977 | expect(method).toBe('GET') 1978 | expect(fullPath).toBe( 1979 | '/books?select=*,...author!inner()&author.address->country->>code=eq.CA&order=author.address->city->>code' 1980 | ) 1981 | }) 1982 | 1983 | test('reference to non-existent relation in select target list fails', async () => { 1984 | const sql = stripIndents` 1985 | select 1986 | editor.name 1987 | from 1988 | books 1989 | join author 1990 | on author_id = author.id 1991 | ` 1992 | 1993 | await expect(processSql(sql)).rejects.toThrowError() 1994 | }) 1995 | 1996 | test('reference to non-existent relation in where clause fails', async () => { 1997 | const sql = stripIndents` 1998 | select 1999 | * 2000 | from 2001 | books 2002 | join author 2003 | on author_id = author.id 2004 | where 2005 | editor.name = 'Bob' 2006 | ` 2007 | 2008 | await expect(processSql(sql)).rejects.toThrowError() 2009 | }) 2010 | 2011 | test('reference to non-existent relation in group by clause fails', async () => { 2012 | const sql = stripIndents` 2013 | select 2014 | * 2015 | from 2016 | books 2017 | join author 2018 | on author_id = author.id 2019 | group by 2020 | editor.name 2021 | ` 2022 | 2023 | await expect(processSql(sql)).rejects.toThrowError() 2024 | }) 2025 | 2026 | test('reference to non-existent relation in order by clause fails', async () => { 2027 | const sql = stripIndents` 2028 | select 2029 | * 2030 | from 2031 | books 2032 | join author 2033 | on author_id = author.id 2034 | order by 2035 | editor.name 2036 | ` 2037 | 2038 | await expect(processSql(sql)).rejects.toThrowError() 2039 | }) 2040 | }) 2041 | -------------------------------------------------------------------------------- /src/renderers/http.ts: -------------------------------------------------------------------------------- 1 | import { stripIndent } from 'common-tags' 2 | import { RenderError } from '../errors.js' 3 | import type { Filter, Select, Statement } from '../processor/index.js' 4 | import { renderFilter, renderTargets, uriEncode, uriEncodeParams } from './util.js' 5 | 6 | export type HttpRequest = { 7 | method: 'GET' 8 | path: string 9 | params: URLSearchParams 10 | fullPath: string 11 | } 12 | 13 | /** 14 | * Renders a `Statement` as an HTTP request. 15 | */ 16 | export async function renderHttp(processed: Statement): Promise { 17 | switch (processed.type) { 18 | case 'select': 19 | return formatSelect(processed) 20 | default: 21 | throw new RenderError(`Unsupported statement type '${processed.type}'`, 'http') 22 | } 23 | } 24 | 25 | async function formatSelect(select: Select): Promise { 26 | const { from, targets, filter, sorts, limit } = select 27 | const params = new URLSearchParams() 28 | 29 | if (targets.length > 0) { 30 | const [firstTarget] = targets 31 | 32 | // Exclude "select=*" if it's the only target 33 | if ( 34 | firstTarget!.type !== 'column-target' || 35 | firstTarget!.column !== '*' || 36 | targets.length !== 1 37 | ) { 38 | params.set('select', renderTargets(targets)) 39 | } 40 | } 41 | 42 | if (filter) { 43 | renderFilterRoot(params, filter) 44 | } 45 | 46 | if (sorts) { 47 | const columns = [] 48 | 49 | for (const sort of sorts) { 50 | let value = sort.column 51 | 52 | if (sort.direction) { 53 | value += `.${sort.direction}` 54 | } 55 | if (sort.nulls) { 56 | value += `.nulls${sort.nulls}` 57 | } 58 | 59 | columns.push(value) 60 | } 61 | 62 | if (columns.length > 0) { 63 | params.set('order', columns.join(',')) 64 | } 65 | } 66 | 67 | if (limit) { 68 | if (limit.count !== undefined) { 69 | params.set('limit', limit.count.toString()) 70 | } 71 | if (limit.offset !== undefined) { 72 | params.set('offset', limit.offset.toString()) 73 | } 74 | } 75 | 76 | const path = `/${from}` 77 | 78 | return { 79 | method: 'GET', 80 | path, 81 | params, 82 | get fullPath() { 83 | // params.size not available in older runtimes 84 | if (Array.from(params).length > 0) { 85 | return `${path}?${uriEncodeParams(params)}` 86 | } 87 | return path 88 | }, 89 | } 90 | } 91 | 92 | function renderFilterRoot(params: URLSearchParams, filter: Filter) { 93 | const { type } = filter 94 | 95 | // The `and` operator is a special case where we can format each nested 96 | // filter as a separate query param as long as the `and` is not negated 97 | if (type === 'logical' && filter.operator === 'and' && !filter.negate) { 98 | for (const subFilter of filter.values) { 99 | renderFilterRoot(params, subFilter) 100 | } 101 | } 102 | // Otherwise render as normal 103 | else { 104 | const [key, value] = renderFilter(filter) 105 | params.append(key, value) 106 | } 107 | } 108 | 109 | export function formatHttp(baseUrl: string, httpRequest: HttpRequest) { 110 | const { method, fullPath } = httpRequest 111 | const baseUrlObject = new URL(baseUrl) 112 | 113 | return stripIndent` 114 | ${method} ${baseUrlObject.pathname}${fullPath} HTTP/1.1 115 | Host: ${baseUrlObject.host} 116 | ` 117 | } 118 | 119 | export function formatCurl(baseUrl: string, httpRequest: HttpRequest) { 120 | const { method, path, params } = httpRequest 121 | const lines: string[] = [] 122 | const baseUrlObject = new URL(baseUrl) 123 | const formattedBaseUrl = (baseUrlObject.origin + baseUrlObject.pathname).replace(/\/+$/, '') 124 | const maybeGFlag = params.size > 0 ? '-G ' : '' 125 | 126 | if (method === 'GET') { 127 | lines.push(`curl ${maybeGFlag}${formattedBaseUrl}${path}`) 128 | for (const [key, value] of params) { 129 | lines.push(` -d "${uriEncode(key)}=${uriEncode(value)}"`) 130 | } 131 | } 132 | 133 | return lines.join(' \\\n') 134 | } 135 | -------------------------------------------------------------------------------- /src/renderers/supabase-js.test.ts: -------------------------------------------------------------------------------- 1 | import { stripIndent, stripIndents } from 'common-tags' 2 | import { describe, expect, test } from 'vitest' 3 | import { processSql } from '../processor/index.js' 4 | import { renderSupabaseJs } from './supabase-js.js' 5 | 6 | describe('select', () => { 7 | test('specified columns', async () => { 8 | const sql = stripIndents` 9 | select 10 | title, 11 | description 12 | from 13 | books 14 | ` 15 | 16 | const statement = await processSql(sql) 17 | const { code } = await renderSupabaseJs(statement) 18 | 19 | expect(code).toBe(stripIndent` 20 | const { data, error } = await supabase 21 | .from('books') 22 | .select( 23 | \` 24 | title, 25 | description 26 | \`, 27 | ) 28 | `) 29 | }) 30 | 31 | test('inline target expression fails', async () => { 32 | const sql = stripIndents` 33 | select 34 | 1 + 1 35 | from 36 | books 37 | ` 38 | 39 | await expect(processSql(sql)).rejects.toThrowError() 40 | }) 41 | 42 | test('missing table fails', async () => { 43 | const sql = stripIndents` 44 | select 'Test' 45 | ` 46 | 47 | await expect(processSql(sql)).rejects.toThrowError() 48 | }) 49 | 50 | test('aliased column', async () => { 51 | const sql = stripIndents` 52 | select 53 | title as my_title 54 | from 55 | books 56 | ` 57 | 58 | const statement = await processSql(sql) 59 | const { code } = await renderSupabaseJs(statement) 60 | 61 | expect(code).toBe(stripIndent` 62 | const { data, error } = await supabase 63 | .from('books') 64 | .select('my_title:title') 65 | `) 66 | }) 67 | 68 | test('remove alias when it matches column name', async () => { 69 | const sql = stripIndents` 70 | select 71 | title as title 72 | from 73 | books 74 | ` 75 | 76 | const statement = await processSql(sql) 77 | const { code } = await renderSupabaseJs(statement) 78 | 79 | expect(code).toBe(stripIndent` 80 | const { data, error } = await supabase 81 | .from('books') 82 | .select('title') 83 | `) 84 | }) 85 | 86 | test('equal', async () => { 87 | const sql = stripIndents` 88 | select 89 | * 90 | from 91 | books 92 | where 93 | title = 'Cheese' 94 | ` 95 | 96 | const statement = await processSql(sql) 97 | const { code } = await renderSupabaseJs(statement) 98 | 99 | expect(code).toBe(stripIndent` 100 | const { data, error } = await supabase 101 | .from('books') 102 | .select() 103 | .eq('title', 'Cheese') 104 | `) 105 | }) 106 | 107 | test('not equal', async () => { 108 | const sql = stripIndents` 109 | select 110 | * 111 | from 112 | books 113 | where 114 | title != 'Cheese' 115 | ` 116 | const statement = await processSql(sql) 117 | const { code } = await renderSupabaseJs(statement) 118 | 119 | expect(code).toBe(stripIndent` 120 | const { data, error } = await supabase 121 | .from('books') 122 | .select() 123 | .neq('title', 'Cheese') 124 | `) 125 | }) 126 | 127 | test('not wrapped equal', async () => { 128 | const sql = stripIndents` 129 | select 130 | * 131 | from 132 | books 133 | where 134 | not ( 135 | title = 'Cheese' 136 | ) 137 | ` 138 | 139 | const statement = await processSql(sql) 140 | const { code } = await renderSupabaseJs(statement) 141 | 142 | expect(code).toBe(stripIndent` 143 | const { data, error } = await supabase 144 | .from('books') 145 | .select() 146 | .not('title', 'eq', 'Cheese') 147 | `) 148 | }) 149 | 150 | test('null', async () => { 151 | const sql = stripIndents` 152 | select 153 | * 154 | from 155 | books 156 | where 157 | title is null 158 | ` 159 | 160 | const statement = await processSql(sql) 161 | const { code } = await renderSupabaseJs(statement) 162 | 163 | expect(code).toBe(stripIndent` 164 | const { data, error } = await supabase 165 | .from('books') 166 | .select() 167 | .is('title', null) 168 | `) 169 | }) 170 | 171 | test('not null', async () => { 172 | const sql = stripIndents` 173 | select 174 | * 175 | from 176 | books 177 | where 178 | title is not null 179 | ` 180 | 181 | const statement = await processSql(sql) 182 | const { code } = await renderSupabaseJs(statement) 183 | 184 | expect(code).toBe(stripIndent` 185 | const { data, error } = await supabase 186 | .from('books') 187 | .select() 188 | .not('title', 'is', null) 189 | `) 190 | }) 191 | 192 | test('greater than', async () => { 193 | const sql = stripIndents` 194 | select 195 | * 196 | from 197 | books 198 | where 199 | pages > 10 200 | ` 201 | 202 | const statement = await processSql(sql) 203 | const { code } = await renderSupabaseJs(statement) 204 | 205 | expect(code).toBe(stripIndent` 206 | const { data, error } = await supabase 207 | .from('books') 208 | .select() 209 | .gt('pages', 10) 210 | `) 211 | }) 212 | 213 | test('greater than or equal', async () => { 214 | const sql = stripIndents` 215 | select 216 | * 217 | from 218 | books 219 | where 220 | pages >= 10 221 | ` 222 | 223 | const statement = await processSql(sql) 224 | const { code } = await renderSupabaseJs(statement) 225 | 226 | expect(code).toBe(stripIndent` 227 | const { data, error } = await supabase 228 | .from('books') 229 | .select() 230 | .gte('pages', 10) 231 | `) 232 | }) 233 | 234 | test('less than', async () => { 235 | const sql = stripIndents` 236 | select 237 | * 238 | from 239 | books 240 | where 241 | pages < 10 242 | ` 243 | 244 | const statement = await processSql(sql) 245 | const { code } = await renderSupabaseJs(statement) 246 | 247 | expect(code).toBe(stripIndent` 248 | const { data, error } = await supabase 249 | .from('books') 250 | .select() 251 | .lt('pages', 10) 252 | `) 253 | }) 254 | 255 | test('less than or equal', async () => { 256 | const sql = stripIndents` 257 | select 258 | * 259 | from 260 | books 261 | where 262 | pages <= 10 263 | ` 264 | 265 | const statement = await processSql(sql) 266 | const { code } = await renderSupabaseJs(statement) 267 | 268 | expect(code).toBe(stripIndent` 269 | const { data, error } = await supabase 270 | .from('books') 271 | .select() 272 | .lte('pages', 10) 273 | `) 274 | }) 275 | 276 | test('like', async () => { 277 | const sql = stripIndents` 278 | select 279 | * 280 | from 281 | books 282 | where 283 | description like 'Cheese%' 284 | ` 285 | 286 | const statement = await processSql(sql) 287 | const { code } = await renderSupabaseJs(statement) 288 | 289 | expect(code).toBe(stripIndent` 290 | const { data, error } = await supabase 291 | .from('books') 292 | .select() 293 | .like('description', 'Cheese%') 294 | `) 295 | }) 296 | 297 | test('ilike', async () => { 298 | const sql = stripIndents` 299 | select 300 | * 301 | from 302 | books 303 | where 304 | description ilike '%cheese%' 305 | ` 306 | 307 | const statement = await processSql(sql) 308 | const { code } = await renderSupabaseJs(statement) 309 | 310 | expect(code).toBe(stripIndent` 311 | const { data, error } = await supabase 312 | .from('books') 313 | .select() 314 | .ilike('description', '%cheese%') 315 | `) 316 | }) 317 | 318 | test('match', async () => { 319 | const sql = stripIndents` 320 | select 321 | * 322 | from 323 | books 324 | where 325 | description ~ '^[a-zA-Z]+' 326 | ` 327 | 328 | const statement = await processSql(sql) 329 | const { code } = await renderSupabaseJs(statement) 330 | 331 | expect(code).toBe(stripIndent` 332 | const { data, error } = await supabase 333 | .from('books') 334 | .select() 335 | .match('description', '^[a-zA-Z]+') 336 | `) 337 | }) 338 | 339 | test('imatch', async () => { 340 | const sql = stripIndents` 341 | select 342 | * 343 | from 344 | books 345 | where 346 | description ~* '^[a-z]+' 347 | ` 348 | 349 | const statement = await processSql(sql) 350 | const { code } = await renderSupabaseJs(statement) 351 | 352 | expect(code).toBe(stripIndent` 353 | const { data, error } = await supabase 354 | .from('books') 355 | .select() 356 | .imatch('description', '^[a-z]+') 357 | `) 358 | }) 359 | 360 | test('in operator', async () => { 361 | const sql = stripIndents` 362 | select 363 | * 364 | from 365 | books 366 | where 367 | category in ('fiction', 'sci-fi') 368 | ` 369 | 370 | const statement = await processSql(sql) 371 | const { code } = await renderSupabaseJs(statement) 372 | 373 | expect(code).toBe(stripIndent` 374 | const { data, error } = await supabase 375 | .from('books') 376 | .select() 377 | .in('category', ['fiction', 'sci-fi']) 378 | `) 379 | }) 380 | 381 | test('full text search using to_tsquery', async () => { 382 | const sql = stripIndents` 383 | select 384 | * 385 | from 386 | books 387 | where 388 | description @@ to_tsquery('cheese') 389 | ` 390 | 391 | const statement = await processSql(sql) 392 | const { code } = await renderSupabaseJs(statement) 393 | 394 | expect(code).toBe(stripIndent` 395 | const { data, error } = await supabase 396 | .from('books') 397 | .select() 398 | .textSearch('description', 'cheese') 399 | `) 400 | }) 401 | 402 | test('full text search using plainto_tsquery', async () => { 403 | const sql = stripIndents` 404 | select 405 | * 406 | from 407 | books 408 | where 409 | description @@ plainto_tsquery('cheese') 410 | ` 411 | 412 | const statement = await processSql(sql) 413 | const { code } = await renderSupabaseJs(statement) 414 | 415 | expect(code).toBe(stripIndent` 416 | const { data, error } = await supabase 417 | .from('books') 418 | .select() 419 | .textSearch('description', 'cheese', { 420 | type: 'plain', 421 | }) 422 | `) 423 | }) 424 | 425 | test('full text search using phraseto_tsquery', async () => { 426 | const sql = stripIndents` 427 | select 428 | * 429 | from 430 | books 431 | where 432 | description @@ phraseto_tsquery('cheese') 433 | ` 434 | 435 | const statement = await processSql(sql) 436 | const { code } = await renderSupabaseJs(statement) 437 | 438 | expect(code).toBe(stripIndent` 439 | const { data, error } = await supabase 440 | .from('books') 441 | .select() 442 | .textSearch('description', 'cheese', { 443 | type: 'phrase', 444 | }) 445 | `) 446 | }) 447 | 448 | test('full text search using websearch_to_tsquery', async () => { 449 | const sql = stripIndents` 450 | select 451 | * 452 | from 453 | books 454 | where 455 | description @@ websearch_to_tsquery('cheese') 456 | ` 457 | 458 | const statement = await processSql(sql) 459 | const { code } = await renderSupabaseJs(statement) 460 | 461 | expect(code).toBe(stripIndent` 462 | const { data, error } = await supabase 463 | .from('books') 464 | .select() 465 | .textSearch('description', 'cheese', { 466 | type: 'websearch', 467 | }) 468 | `) 469 | }) 470 | 471 | test('full text search passing config to to_tsquery', async () => { 472 | const sql = stripIndents` 473 | select 474 | * 475 | from 476 | books 477 | where 478 | description @@ to_tsquery('english', 'cheese') 479 | ` 480 | 481 | const statement = await processSql(sql) 482 | const { code } = await renderSupabaseJs(statement) 483 | 484 | expect(code).toBe(stripIndent` 485 | const { data, error } = await supabase 486 | .from('books') 487 | .select() 488 | .textSearch('description', 'cheese', { 489 | config: 'english', 490 | }) 491 | `) 492 | }) 493 | 494 | test('full text search passing config to to_tsquery', async () => { 495 | const sql = stripIndents` 496 | select 497 | * 498 | from 499 | books 500 | where 501 | description @@ to_tsquery('english', 'cheese') 502 | ` 503 | 504 | const statement = await processSql(sql) 505 | const { code } = await renderSupabaseJs(statement) 506 | 507 | expect(code).toBe(stripIndent` 508 | const { data, error } = await supabase 509 | .from('books') 510 | .select() 511 | .textSearch('description', 'cheese', { 512 | config: 'english', 513 | }) 514 | `) 515 | }) 516 | 517 | test('negated full text search', async () => { 518 | const sql = stripIndents` 519 | select 520 | * 521 | from 522 | books 523 | where 524 | not (description @@ to_tsquery('cheese')) 525 | ` 526 | 527 | const statement = await processSql(sql) 528 | const { code } = await renderSupabaseJs(statement) 529 | 530 | expect(code).toBe(stripIndent` 531 | const { data, error } = await supabase 532 | .from('books') 533 | .select() 534 | .not('description', 'fts', 'cheese') 535 | `) 536 | }) 537 | 538 | test('negated full text search with config', async () => { 539 | const sql = stripIndents` 540 | select 541 | * 542 | from 543 | books 544 | where 545 | not (description @@ to_tsquery('english', 'cheese')) 546 | ` 547 | 548 | const statement = await processSql(sql) 549 | const { code } = await renderSupabaseJs(statement) 550 | 551 | expect(code).toBe(stripIndent` 552 | const { data, error } = await supabase 553 | .from('books') 554 | .select() 555 | .not( 556 | 'description', 557 | 'fts(english)', 558 | 'cheese', 559 | ) 560 | `) 561 | }) 562 | 563 | test('negated full text search using websearch_to_tsquery and config', async () => { 564 | const sql = stripIndents` 565 | select 566 | * 567 | from 568 | books 569 | where 570 | not (description @@ websearch_to_tsquery('english', 'cheese')) 571 | ` 572 | 573 | const statement = await processSql(sql) 574 | const { code } = await renderSupabaseJs(statement) 575 | 576 | expect(code).toBe(stripIndent` 577 | const { data, error } = await supabase 578 | .from('books') 579 | .select() 580 | .not( 581 | 'description', 582 | 'wfts(english)', 583 | 'cheese', 584 | ) 585 | `) 586 | }) 587 | 588 | test('"and" expression', async () => { 589 | const sql = stripIndents` 590 | select 591 | * 592 | from 593 | books 594 | where 595 | title = 'Cheese' and 596 | description ilike '%salsa%' 597 | ` 598 | 599 | const statement = await processSql(sql) 600 | const { code } = await renderSupabaseJs(statement) 601 | 602 | expect(code).toBe(stripIndent` 603 | const { data, error } = await supabase 604 | .from('books') 605 | .select() 606 | .eq('title', 'Cheese') 607 | .ilike('description', '%salsa%') 608 | `) 609 | }) 610 | 611 | test('"or" expression', async () => { 612 | const sql = stripIndents` 613 | select 614 | * 615 | from 616 | books 617 | where 618 | title = 'Cheese' or 619 | title = 'Salsa' 620 | ` 621 | 622 | const statement = await processSql(sql) 623 | const { code } = await renderSupabaseJs(statement) 624 | 625 | expect(code).toBe(stripIndent` 626 | const { data, error } = await supabase 627 | .from('books') 628 | .select() 629 | .or('title.eq.Cheese, title.eq.Salsa') 630 | `) 631 | }) 632 | 633 | test('negated "and" expression', async () => { 634 | const sql = stripIndents` 635 | select 636 | * 637 | from 638 | books 639 | where 640 | not ( 641 | title = 'Cheese' and 642 | description ilike '%salsa%' 643 | ) 644 | ` 645 | 646 | const statement = await processSql(sql) 647 | const { code } = await renderSupabaseJs(statement) 648 | 649 | expect(code).toBe(stripIndent` 650 | const { data, error } = await supabase 651 | .from('books') 652 | .select() 653 | .or( 654 | 'not.and(title.eq.Cheese, description.ilike.%salsa%)', 655 | ) 656 | `) 657 | }) 658 | 659 | test('negated "or" expression', async () => { 660 | const sql = stripIndents` 661 | select 662 | * 663 | from 664 | books 665 | where 666 | not ( 667 | title = 'Cheese' or 668 | title = 'Salsa' 669 | ) 670 | ` 671 | 672 | const statement = await processSql(sql) 673 | const { code } = await renderSupabaseJs(statement) 674 | 675 | expect(code).toBe(stripIndent` 676 | const { data, error } = await supabase 677 | .from('books') 678 | .select() 679 | .or( 680 | 'not.or(title.eq.Cheese, title.eq.Salsa)', 681 | ) 682 | `) 683 | }) 684 | 685 | test('"and" expression with nested "or"', async () => { 686 | const sql = stripIndents` 687 | select 688 | * 689 | from 690 | books 691 | where 692 | title like 'T%' and 693 | ( 694 | description ilike '%tacos%' or 695 | description ilike '%salsa%' 696 | ) 697 | ` 698 | 699 | const statement = await processSql(sql) 700 | const { code } = await renderSupabaseJs(statement) 701 | 702 | expect(code).toBe(stripIndent` 703 | const { data, error } = await supabase 704 | .from('books') 705 | .select() 706 | .like('title', 'T%') 707 | .or( 708 | 'description.ilike.%tacos%, description.ilike.%salsa%', 709 | ) 710 | `) 711 | }) 712 | 713 | test('negated "and" expression with nested "or"', async () => { 714 | const sql = stripIndents` 715 | select 716 | * 717 | from 718 | books 719 | where 720 | not ( 721 | title like 'T%' and 722 | ( 723 | description ilike '%tacos%' or 724 | description ilike '%salsa%' 725 | ) 726 | ) 727 | ` 728 | 729 | const statement = await processSql(sql) 730 | const { code } = await renderSupabaseJs(statement) 731 | 732 | expect(code).toBe(stripIndent` 733 | const { data, error } = await supabase 734 | .from('books') 735 | .select() 736 | .or( 737 | 'not.and(title.like.T%, or(description.ilike.%tacos%, description.ilike.%salsa%))', 738 | ) 739 | `) 740 | }) 741 | 742 | test('negated "and" expression with negated nested "or"', async () => { 743 | const sql = stripIndents` 744 | select 745 | * 746 | from 747 | books 748 | where 749 | not ( 750 | title like 'T%' and 751 | not ( 752 | description ilike '%tacos%' or 753 | description ilike '%salsa%' 754 | ) 755 | ) 756 | ` 757 | 758 | const statement = await processSql(sql) 759 | const { code } = await renderSupabaseJs(statement) 760 | 761 | expect(code).toBe(stripIndent` 762 | const { data, error } = await supabase 763 | .from('books') 764 | .select() 765 | .or( 766 | 'not.and(title.like.T%, not.or(description.ilike.%tacos%, description.ilike.%salsa%))', 767 | ) 768 | `) 769 | }) 770 | 771 | test('order of operations', async () => { 772 | const sql = stripIndents` 773 | select 774 | * 775 | from 776 | books 777 | where 778 | title like 'T%' and 779 | description ilike '%tacos%' or 780 | description ilike '%salsa%' 781 | ` 782 | 783 | const statement = await processSql(sql) 784 | const { code } = await renderSupabaseJs(statement) 785 | 786 | expect(code).toBe(stripIndent` 787 | const { data, error } = await supabase 788 | .from('books') 789 | .select() 790 | .or( 791 | 'and(title.like.T%, description.ilike.%tacos%), description.ilike.%salsa%', 792 | ) 793 | `) 794 | }) 795 | 796 | test('limit', async () => { 797 | const sql = stripIndents` 798 | select 799 | * 800 | from 801 | books 802 | limit 803 | 5 804 | ` 805 | 806 | const statement = await processSql(sql) 807 | const { code } = await renderSupabaseJs(statement) 808 | 809 | expect(code).toBe(stripIndent` 810 | const { data, error } = await supabase 811 | .from('books') 812 | .select() 813 | .limit(5) 814 | `) 815 | }) 816 | 817 | test('offset without limit fails', async () => { 818 | const sql = stripIndents` 819 | select 820 | * 821 | from 822 | books 823 | offset 824 | 10 825 | ` 826 | 827 | const statement = await processSql(sql) 828 | 829 | await expect(renderSupabaseJs(statement)).rejects.toThrowError() 830 | }) 831 | 832 | test('limit and offset', async () => { 833 | const sql = stripIndents` 834 | select 835 | * 836 | from 837 | books 838 | limit 839 | 5 840 | offset 841 | 10 842 | ` 843 | 844 | const statement = await processSql(sql) 845 | const { code } = await renderSupabaseJs(statement) 846 | 847 | expect(code).toBe(stripIndent` 848 | const { data, error } = await supabase 849 | .from('books') 850 | .select() 851 | .range(10, 15) 852 | `) 853 | }) 854 | 855 | test('order by', async () => { 856 | const sql = stripIndents` 857 | select 858 | * 859 | from 860 | books 861 | order by 862 | title 863 | ` 864 | 865 | const statement = await processSql(sql) 866 | const { code } = await renderSupabaseJs(statement) 867 | 868 | expect(code).toBe(stripIndent` 869 | const { data, error } = await supabase 870 | .from('books') 871 | .select() 872 | .order('title') 873 | `) 874 | }) 875 | 876 | test('order by multiple columns', async () => { 877 | const sql = stripIndents` 878 | select 879 | * 880 | from 881 | books 882 | order by 883 | title, 884 | description 885 | ` 886 | 887 | const statement = await processSql(sql) 888 | const { code } = await renderSupabaseJs(statement) 889 | 890 | expect(code).toBe(stripIndent` 891 | const { data, error } = await supabase 892 | .from('books') 893 | .select() 894 | .order('title') 895 | .order('description') 896 | `) 897 | }) 898 | 899 | test('order by asc', async () => { 900 | const sql = stripIndents` 901 | select 902 | * 903 | from 904 | books 905 | order by 906 | title asc 907 | ` 908 | 909 | const statement = await processSql(sql) 910 | const { code } = await renderSupabaseJs(statement) 911 | 912 | expect(code).toBe(stripIndent` 913 | const { data, error } = await supabase 914 | .from('books') 915 | .select() 916 | .order('title', { ascending: true }) 917 | `) 918 | }) 919 | 920 | test('order by desc', async () => { 921 | const sql = stripIndents` 922 | select 923 | * 924 | from 925 | books 926 | order by 927 | title desc 928 | ` 929 | 930 | const statement = await processSql(sql) 931 | const { code } = await renderSupabaseJs(statement) 932 | 933 | expect(code).toBe(stripIndent` 934 | const { data, error } = await supabase 935 | .from('books') 936 | .select() 937 | .order('title', { ascending: false }) 938 | `) 939 | }) 940 | 941 | test('order by nulls first', async () => { 942 | const sql = stripIndents` 943 | select 944 | * 945 | from 946 | books 947 | order by 948 | title nulls first 949 | ` 950 | 951 | const statement = await processSql(sql) 952 | const { code } = await renderSupabaseJs(statement) 953 | 954 | expect(code).toBe(stripIndent` 955 | const { data, error } = await supabase 956 | .from('books') 957 | .select() 958 | .order('title', { nullsFirst: true }) 959 | `) 960 | }) 961 | 962 | test('order by nulls last', async () => { 963 | const sql = stripIndents` 964 | select 965 | * 966 | from 967 | books 968 | order by 969 | title nulls last 970 | ` 971 | 972 | const statement = await processSql(sql) 973 | const { code } = await renderSupabaseJs(statement) 974 | 975 | expect(code).toBe(stripIndent` 976 | const { data, error } = await supabase 977 | .from('books') 978 | .select() 979 | .order('title', { nullsFirst: false }) 980 | `) 981 | }) 982 | 983 | test('order by desc nulls last', async () => { 984 | const sql = stripIndents` 985 | select 986 | * 987 | from 988 | books 989 | order by 990 | title desc nulls last 991 | ` 992 | 993 | const statement = await processSql(sql) 994 | const { code } = await renderSupabaseJs(statement) 995 | 996 | expect(code).toBe(stripIndent` 997 | const { data, error } = await supabase 998 | .from('books') 999 | .select() 1000 | .order('title', { 1001 | ascending: false, 1002 | nullsFirst: false, 1003 | }) 1004 | `) 1005 | }) 1006 | 1007 | test('cast', async () => { 1008 | const sql = stripIndents` 1009 | select 1010 | pages::float 1011 | from 1012 | books 1013 | ` 1014 | 1015 | const statement = await processSql(sql) 1016 | const { code } = await renderSupabaseJs(statement) 1017 | 1018 | expect(code).toBe(stripIndent` 1019 | const { data, error } = await supabase 1020 | .from('books') 1021 | .select('pages::float') 1022 | `) 1023 | }) 1024 | 1025 | test('cast with alias', async () => { 1026 | const sql = stripIndents` 1027 | select 1028 | pages::float as "partialPages" 1029 | from 1030 | books 1031 | ` 1032 | 1033 | const statement = await processSql(sql) 1034 | const { code } = await renderSupabaseJs(statement) 1035 | 1036 | expect(code).toBe(stripIndent` 1037 | const { data, error } = await supabase 1038 | .from('books') 1039 | .select('partialPages:pages::float') 1040 | `) 1041 | }) 1042 | 1043 | test('cast in where clause fails', async () => { 1044 | const sql = stripIndents` 1045 | select 1046 | pages 1047 | from 1048 | books 1049 | where 1050 | pages::float > 10.0 1051 | ` 1052 | 1053 | await expect(processSql(sql)).rejects.toThrowError() 1054 | }) 1055 | 1056 | test('cast in order by clause fails', async () => { 1057 | const sql = stripIndents` 1058 | select 1059 | pages 1060 | from 1061 | books 1062 | order by 1063 | pages::float desc 1064 | ` 1065 | 1066 | await expect(processSql(sql)).rejects.toThrowError() 1067 | }) 1068 | }) 1069 | -------------------------------------------------------------------------------- /src/renderers/supabase-js.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'prettier' 2 | import * as babel from 'prettier/plugins/babel' 3 | import * as estree from 'prettier/plugins/estree' 4 | import * as prettier from 'prettier/standalone' 5 | import { RenderError } from '../errors.js' 6 | import type { Filter, Select, Statement } from '../processor/index.js' 7 | import { renderNestedFilter, renderTargets } from './util.js' 8 | 9 | export type SupabaseJsQuery = { 10 | code: string 11 | } 12 | 13 | /** 14 | * Renders a `Statement` as a supabase-js query. 15 | */ 16 | export async function renderSupabaseJs(processed: Statement): Promise { 17 | switch (processed.type) { 18 | case 'select': 19 | return formatSelect(processed) 20 | default: 21 | throw new RenderError(`Unsupported statement type '${processed.type}'`, 'supabase-js') 22 | } 23 | } 24 | 25 | async function formatSelect(select: Select): Promise { 26 | const { from, targets, filter, sorts, limit } = select 27 | const lines = ['const { data, error } = await supabase', `.from('${from}')`] 28 | 29 | if (targets.length > 0) { 30 | const [firstTarget] = targets 31 | 32 | // Remove '*' from select() if it's the only target 33 | if ( 34 | firstTarget!.type === 'column-target' && 35 | firstTarget!.column === '*' && 36 | targets.length === 1 37 | ) { 38 | lines.push('.select()') 39 | } else if (targets.length > 1) { 40 | lines.push( 41 | `.select(\n \`\n${renderTargets(targets, { initialIndent: 4, indent: 2 })}\n \`\n )` 42 | ) 43 | } else { 44 | lines.push(`.select(${JSON.stringify(renderTargets(targets))})`) 45 | } 46 | } 47 | 48 | if (filter) { 49 | renderFilterRoot(lines, filter) 50 | } 51 | 52 | if (sorts) { 53 | for (const sort of sorts) { 54 | if (!sort.direction && !sort.nulls) { 55 | lines.push(`.order(${JSON.stringify(sort.column)})`) 56 | } else { 57 | const options = { 58 | ascending: sort.direction ? sort.direction === 'asc' : undefined, 59 | nullsFirst: sort.nulls ? sort.nulls === 'first' : undefined, 60 | } 61 | 62 | lines.push(`.order(${JSON.stringify(sort.column)}, ${JSON.stringify(options)})`) 63 | } 64 | } 65 | } 66 | 67 | if (limit) { 68 | if (limit.count !== undefined && limit.offset === undefined) { 69 | lines.push(`.limit(${limit.count})`) 70 | } else if (limit.count === undefined && limit.offset !== undefined) { 71 | throw new RenderError(`supabase-js doesn't support an offset without a limit`, 'supabase-js') 72 | } else if (limit.count !== undefined && limit.offset !== undefined) { 73 | lines.push(`.range(${limit.offset}, ${limit.offset + limit.count})`) 74 | } 75 | } 76 | 77 | // Join lines together and format 78 | const code = await prettier.format(lines.join('\n'), { 79 | parser: 'babel', 80 | plugins: [babel, estree as Plugin], 81 | printWidth: 40, 82 | semi: false, 83 | singleQuote: true, 84 | trailingComma: 'all', 85 | }) 86 | 87 | return { 88 | code: code.trim(), 89 | } 90 | } 91 | 92 | function renderFilterRoot(lines: string[], filter: Filter) { 93 | const { type } = filter 94 | 95 | if (filter.negate) { 96 | if (filter.type === 'column') { 97 | // Full-text search operators can have an optional config arg 98 | if ( 99 | filter.operator === 'fts' || 100 | filter.operator === 'plfts' || 101 | filter.operator === 'phfts' || 102 | filter.operator === 'wfts' 103 | ) { 104 | const maybeConfig = filter.config ? `(${filter.config})` : '' 105 | lines.push( 106 | `.not(${JSON.stringify(filter.column)}, ${JSON.stringify(`${filter.operator}${maybeConfig}`)}, ${JSON.stringify(filter.value)})` 107 | ) 108 | } else { 109 | lines.push( 110 | `.not(${JSON.stringify(filter.column)}, ${JSON.stringify(filter.operator)}, ${JSON.stringify(filter.value)})` 111 | ) 112 | } 113 | } 114 | // supabase-js doesn't support negated logical operators. 115 | // We work around this by wrapping the filter in an 'or' 116 | // with only 1 value (so the 'or' is a no-op, but we get nested PostgREST syntax) 117 | else if (filter.type === 'logical') { 118 | lines.push(`.or(${JSON.stringify(renderNestedFilter(filter, false, ', '))})`) 119 | } 120 | return 121 | } 122 | 123 | // Column filter, eg. .eq('title', 'Cheese') 124 | if (type === 'column') { 125 | if ( 126 | filter.operator === 'fts' || 127 | filter.operator === 'plfts' || 128 | filter.operator === 'phfts' || 129 | filter.operator === 'wfts' 130 | ) { 131 | const maybeOptions = 132 | filter.operator !== 'fts' || filter.config !== undefined 133 | ? `, ${JSON.stringify({ 134 | type: mapTextSearchType(filter.operator), 135 | config: filter.config, 136 | })}` 137 | : '' 138 | 139 | lines.push( 140 | `.textSearch(${JSON.stringify(filter.column)}, ${JSON.stringify(filter.value)}${maybeOptions})` 141 | ) 142 | } else { 143 | lines.push( 144 | `.${filter.operator}(${JSON.stringify(filter.column)}, ${JSON.stringify(filter.value)})` 145 | ) 146 | } 147 | } 148 | 149 | // Logical operator filter, eg. .or('title.eq.Cheese,title.eq.Salsa') 150 | else if (type === 'logical') { 151 | // The `and` operator is a a special case where we can format each nested 152 | // filter as a separate filter method 153 | if (filter.operator === 'and') { 154 | for (const subFilter of filter.values) { 155 | renderFilterRoot(lines, subFilter) 156 | } 157 | } 158 | // Otherwise use the .or(...) method 159 | else if (filter.operator === 'or') { 160 | lines.push( 161 | `.or(${JSON.stringify( 162 | filter.values.map((subFilter) => renderNestedFilter(subFilter, false, ', ')).join(', ') 163 | )})` 164 | ) 165 | } 166 | } else { 167 | throw new RenderError(`Unknown filter type '${type}'`, 'supabase-js') 168 | } 169 | } 170 | 171 | function mapTextSearchType(operator: 'fts' | 'plfts' | 'phfts' | 'wfts') { 172 | switch (operator) { 173 | case 'plfts': 174 | return 'plain' 175 | case 'phfts': 176 | return 'phrase' 177 | case 'wfts': 178 | return 'websearch' 179 | default: 180 | return undefined 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/renderers/util.ts: -------------------------------------------------------------------------------- 1 | import type { Filter, Target } from '../processor/index.js' 2 | 3 | // TODO: format multiline targets downstream instead of here 4 | export function renderTargets( 5 | targets: Target[], 6 | multiline?: { initialIndent: number; indent: number } 7 | ) { 8 | const indentation = multiline ? ' '.repeat(multiline.initialIndent) : '' 9 | const maybeNewline = multiline ? '\n' : '' 10 | 11 | return targets 12 | .map((target) => { 13 | // Regular columns 14 | if (target.type === 'column-target') { 15 | const { column, alias, cast } = target 16 | let value = column 17 | 18 | if (alias && alias !== column) { 19 | value = `${alias}:${value}` 20 | } 21 | 22 | if (cast) { 23 | value = `${value}::${cast}` 24 | } 25 | 26 | value = `${indentation}${value}` 27 | 28 | return value 29 | } 30 | // Special case for `count()` that has no column attached 31 | else if (target.type === 'aggregate-target' && !('column' in target)) { 32 | const { functionName, alias, outputCast } = target 33 | let value = `${functionName}()` 34 | 35 | if (alias) { 36 | value = `${alias}:${value}` 37 | } 38 | 39 | if (outputCast) { 40 | value = `${value}::${outputCast}` 41 | } 42 | 43 | value = `${indentation}${value}` 44 | 45 | return value 46 | } 47 | // Aggregate functions 48 | else if (target.type === 'aggregate-target') { 49 | const { column, alias, functionName, inputCast, outputCast } = target 50 | let value = column 51 | 52 | if (alias && alias !== column) { 53 | value = `${alias}:${value}` 54 | } 55 | 56 | if (inputCast) { 57 | value = `${value}::${inputCast}` 58 | } 59 | 60 | value = `${value}.${functionName}()` 61 | 62 | if (outputCast) { 63 | value = `${value}::${outputCast}` 64 | } 65 | 66 | value = `${indentation}${value}` 67 | 68 | return value 69 | } 70 | // Resource embeddings (joined tables) 71 | else if (target.type === 'embedded-target') { 72 | const { relation, alias, joinType, targets, flatten } = target 73 | let value = relation 74 | 75 | if (joinType === 'inner') { 76 | value = `${value}!inner` 77 | } 78 | 79 | // Resource embeddings can't have aliases when they're spread (flattened) 80 | if (alias && alias !== relation && !flatten) { 81 | value = `${alias}:${value}` 82 | } 83 | 84 | if (flatten) { 85 | value = `...${value}` 86 | } 87 | 88 | if (targets.length > 0) { 89 | value = `${indentation}${value}(${maybeNewline}${renderTargets(targets, multiline ? { ...multiline, initialIndent: multiline.initialIndent + multiline.indent } : undefined)}${maybeNewline}${indentation})` 90 | } else { 91 | value = `${indentation}${value}()` 92 | } 93 | 94 | return value 95 | } 96 | }) 97 | .join(',' + maybeNewline) 98 | } 99 | 100 | /** 101 | * Renders a filter in PostgREST syntax. 102 | * 103 | * @returns A key-value pair that can be used either directly 104 | * in query params (for HTTP rendering), or to render nested 105 | * filters (@see `renderNestedFilter`). 106 | */ 107 | export function renderFilter( 108 | filter: Filter, 109 | urlSafe: boolean = true, 110 | delimiter = ',' 111 | ): [key: string, value: string] { 112 | const { type } = filter 113 | const maybeNot = filter.negate ? 'not.' : '' 114 | 115 | // Column filter, eg. "title.eq.Cheese" 116 | if (type === 'column') { 117 | if (filter.operator === 'like' || filter.operator === 'ilike') { 118 | // Optionally convert '%' to URL-safe '*' 119 | const value = urlSafe ? filter.value.replaceAll('%', '*') : filter.value 120 | 121 | return [filter.column, `${maybeNot}${filter.operator}.${value}`] 122 | } else if (filter.operator === 'in') { 123 | const value = filter.value 124 | .map((value) => { 125 | // If an 'in' value contains a comma, wrap in double quotes 126 | if (value.toString().includes(',')) { 127 | return `"${value}"` 128 | } 129 | return value 130 | }) 131 | .join(',') 132 | return [filter.column, `${maybeNot}${filter.operator}.(${value})`] 133 | } else if ( 134 | filter.operator === 'fts' || 135 | filter.operator === 'plfts' || 136 | filter.operator === 'phfts' || 137 | filter.operator === 'wfts' 138 | ) { 139 | const maybeConfig = filter.config ? `(${filter.config})` : '' 140 | return [filter.column, `${maybeNot}${filter.operator}${maybeConfig}.${filter.value}`] 141 | } else { 142 | return [filter.column, `${maybeNot}${filter.operator}.${filter.value}`] 143 | } 144 | } 145 | // Logical operator filter, eg. "or(title.eq.Cheese,title.eq.Salsa)"" 146 | else if (type === 'logical') { 147 | return [ 148 | `${maybeNot}${filter.operator}`, 149 | `(${filter.values 150 | .map((subFilter) => renderNestedFilter(subFilter, urlSafe, delimiter)) 151 | .join(delimiter)})`, 152 | ] 153 | } else { 154 | throw new Error(`Unknown filter type '${type}'`) 155 | } 156 | } 157 | 158 | /** 159 | * Renders a filter in PostgREST syntax with key-values combined 160 | * for use within a nested filter. 161 | * 162 | * @returns A string containing the nested filter. 163 | */ 164 | export function renderNestedFilter(filter: Filter, urlSafe: boolean = true, delimiter = ',') { 165 | const [key, value] = renderFilter(filter, urlSafe, delimiter) 166 | const { type } = filter 167 | 168 | if (type === 'column') { 169 | return `${key}.${value}` 170 | } else if (type === 'logical') { 171 | return `${key}${value}` 172 | } else { 173 | throw new Error(`Unknown filter type '${type}'`) 174 | } 175 | } 176 | 177 | export const defaultCharacterWhitelist = ['*', '(', ')', ',', ':', '!', '>', '-', '[', ']'] 178 | 179 | /** 180 | * URI encodes query parameters with an optional character whitelist 181 | * that should not be encoded. 182 | */ 183 | export function uriEncodeParams( 184 | params: URLSearchParams, 185 | characterWhitelist: string[] = defaultCharacterWhitelist 186 | ) { 187 | return uriDecodeCharacters(params.toString(), characterWhitelist) 188 | } 189 | 190 | /** 191 | * URI encodes a string with an optional character whitelist 192 | * that should not be encoded. 193 | */ 194 | export function uriEncode(value: string, characterWhitelist: string[] = defaultCharacterWhitelist) { 195 | return uriDecodeCharacters(encodeURIComponent(value), characterWhitelist) 196 | } 197 | 198 | function uriDecodeCharacters(value: string, characterWhitelist: string[]) { 199 | let newValue = value 200 | 201 | // Convert whitelisted characters back from their hex representation (eg. '%2A' -> '*') 202 | for (const char of characterWhitelist) { 203 | const hexCode = char.charCodeAt(0).toString(16).toUpperCase() 204 | newValue = newValue.replaceAll(`%${hexCode}`, char) 205 | } 206 | 207 | return newValue 208 | } 209 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@total-typescript/tsconfig/tsc/no-dom/library", 3 | "include": ["src/**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig([ 4 | { 5 | entry: ['src/index.ts'], 6 | format: ['cjs', 'esm'], 7 | outDir: 'dist', 8 | sourcemap: true, 9 | dts: true, 10 | minify: true, 11 | }, 12 | ]) 13 | --------------------------------------------------------------------------------