postgrest-parser 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1082 -0
- package/package.json +44 -0
- package/postgrest_parser.d.ts +121 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 PostgREST Parser Contributors
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
# PostgREST Parser for Rust
|
|
2
|
+
|
|
3
|
+
[](https://crates.io/crates/postgrest-parser)
|
|
4
|
+
[](https://docs.rs/postgrest-parser)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
A high-performance Rust implementation of the PostgREST URL-to-SQL parser, supporting both native and WASM targets.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- ✅ **Complete PostgREST API**: All 22+ filter operators fully implemented
|
|
12
|
+
- ✅ **Logic Operators**: AND, OR, NOT with arbitrary nesting depth
|
|
13
|
+
- ✅ **Select Parsing**: Fields, relations, spreads, aliases, JSON paths, type casting
|
|
14
|
+
- ✅ **Full-Text Search**: Multiple FTS operators with language support
|
|
15
|
+
- ✅ **Array/Range Operations**: PostgreSQL array and range type support
|
|
16
|
+
- ✅ **Quantifiers**: `any` and `all` for array comparisons
|
|
17
|
+
- ✅ **Order Parsing**: Multi-column ordering with nulls handling
|
|
18
|
+
- ✅ **Parameterized SQL**: Safe SQL generation with $1, $2, etc. placeholders
|
|
19
|
+
- ✅ **Zero Regex**: Uses nom parser combinators for better performance
|
|
20
|
+
- ✅ **Type Safe**: Comprehensive error handling with thiserror
|
|
21
|
+
- ✅ **WASM Support**: Full TypeScript/JavaScript bindings for browser and Deno (optional feature)
|
|
22
|
+
- ✅ **TypeScript Client**: Type-safe API with zero `any` types, object-based APIs, and IntelliSense
|
|
23
|
+
- ✅ **171 Tests**: Comprehensive test coverage (148 Rust + 23 WASM integration tests)
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
### Rust
|
|
28
|
+
|
|
29
|
+
Add to your `Cargo.toml`:
|
|
30
|
+
|
|
31
|
+
```toml
|
|
32
|
+
[dependencies]
|
|
33
|
+
postgrest-parser = "0.1.0"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### TypeScript/JavaScript (WASM)
|
|
37
|
+
|
|
38
|
+
The parser is available as a WebAssembly module with full TypeScript support for use in browsers, Node.js, Deno, and edge runtimes.
|
|
39
|
+
|
|
40
|
+
#### Quick Start for TypeScript Projects
|
|
41
|
+
|
|
42
|
+
**1. Build the WASM package:**
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Install wasm-pack if you haven't already
|
|
46
|
+
cargo install wasm-pack
|
|
47
|
+
|
|
48
|
+
# Build for web (browsers, Deno, Cloudflare Workers, etc.)
|
|
49
|
+
wasm-pack build --target web --features wasm
|
|
50
|
+
|
|
51
|
+
# Or for Node.js
|
|
52
|
+
wasm-pack build --target nodejs --features wasm
|
|
53
|
+
|
|
54
|
+
# Development build (faster compilation, larger file)
|
|
55
|
+
wasm-pack build --dev --target web --features wasm
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**2. Copy to your TypeScript project:**
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Copy the entire pkg/ directory to your project
|
|
62
|
+
cp -r pkg/ /path/to/your/project/postgrest-parser/
|
|
63
|
+
|
|
64
|
+
# Or publish to npm (requires package.json configuration)
|
|
65
|
+
cd pkg && npm publish
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**3. Import and use:**
|
|
69
|
+
|
|
70
|
+
> **🎉 NEW: Type-Safe Client API**
|
|
71
|
+
>
|
|
72
|
+
> We now provide a fully type-safe TypeScript client with zero `any` types, object-based APIs, and better IntelliSense support. See [TYPESCRIPT_GUIDE.md](docs/TYPESCRIPT_GUIDE.md) for details.
|
|
73
|
+
>
|
|
74
|
+
> ```typescript
|
|
75
|
+
> // Recommended: Type-safe client (client.ts)
|
|
76
|
+
> import { createClient } from './postgrest-parser/client.js';
|
|
77
|
+
> const client = createClient();
|
|
78
|
+
>
|
|
79
|
+
> const result = client.select("users", {
|
|
80
|
+
> filters: { age: "gte.18", status: "eq.active" },
|
|
81
|
+
> order: ["name.asc"],
|
|
82
|
+
> limit: 10
|
|
83
|
+
> });
|
|
84
|
+
> // Full IntelliSense, no 'any' types, native objects
|
|
85
|
+
> ```
|
|
86
|
+
>
|
|
87
|
+
> ```typescript
|
|
88
|
+
> // Alternative: Low-level WASM API (postgrest_parser.js)
|
|
89
|
+
> import init, { parseRequest } from './postgrest-parser/postgrest_parser.js';
|
|
90
|
+
> await init();
|
|
91
|
+
>
|
|
92
|
+
> const result = parseRequest("GET", "users", "age=gte.18&limit=10", null, null);
|
|
93
|
+
> // Direct WASM bindings, requires manual query string construction
|
|
94
|
+
> ```
|
|
95
|
+
|
|
96
|
+
#### TypeScript Integration Guide
|
|
97
|
+
|
|
98
|
+
##### 1. HTTP Method Routing (Recommended Approach)
|
|
99
|
+
|
|
100
|
+
The `parseRequest()` function is the **primary entry point** - it automatically routes HTTP methods to SQL operations following PostgREST conventions:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import init, { parseRequest } from './postgrest-parser/postgrest_parser.js';
|
|
104
|
+
|
|
105
|
+
await init();
|
|
106
|
+
|
|
107
|
+
// GET → SELECT
|
|
108
|
+
const getUsers = parseRequest(
|
|
109
|
+
"GET",
|
|
110
|
+
"users",
|
|
111
|
+
"age=gte.18&status=eq.active&order=name.asc&limit=10",
|
|
112
|
+
null,
|
|
113
|
+
null
|
|
114
|
+
);
|
|
115
|
+
// Generates: SELECT * FROM "users" WHERE "age" >= $1 AND "status" = $2 ORDER BY "name" ASC LIMIT $3
|
|
116
|
+
|
|
117
|
+
// POST → INSERT
|
|
118
|
+
const createUser = parseRequest(
|
|
119
|
+
"POST",
|
|
120
|
+
"users",
|
|
121
|
+
"returning=id,name,email",
|
|
122
|
+
JSON.stringify({ name: "Alice", email: "alice@example.com" }),
|
|
123
|
+
JSON.stringify({ Prefer: "return=representation" })
|
|
124
|
+
);
|
|
125
|
+
// Generates: INSERT INTO "users" ("name", "email") VALUES ($1, $2) RETURNING "id", "name", "email"
|
|
126
|
+
|
|
127
|
+
// PUT → UPSERT (auto ON CONFLICT from query filters)
|
|
128
|
+
const upsertUser = parseRequest(
|
|
129
|
+
"PUT",
|
|
130
|
+
"users",
|
|
131
|
+
"email=eq.alice@example.com&returning=*",
|
|
132
|
+
JSON.stringify({ email: "alice@example.com", name: "Alice Updated" }),
|
|
133
|
+
null
|
|
134
|
+
);
|
|
135
|
+
// Generates: INSERT ... ON CONFLICT ("email") DO UPDATE SET ... RETURNING *
|
|
136
|
+
|
|
137
|
+
// PATCH → UPDATE
|
|
138
|
+
const updateUser = parseRequest(
|
|
139
|
+
"PATCH",
|
|
140
|
+
"users",
|
|
141
|
+
"id=eq.123&returning=id,status",
|
|
142
|
+
JSON.stringify({ status: "verified" }),
|
|
143
|
+
null
|
|
144
|
+
);
|
|
145
|
+
// Generates: UPDATE "users" SET "status" = $1 WHERE "id" = $2 RETURNING "id", "status"
|
|
146
|
+
|
|
147
|
+
// DELETE → DELETE
|
|
148
|
+
const deleteUser = parseRequest(
|
|
149
|
+
"DELETE",
|
|
150
|
+
"users",
|
|
151
|
+
"id=eq.123&returning=id",
|
|
152
|
+
null,
|
|
153
|
+
null
|
|
154
|
+
);
|
|
155
|
+
// Generates: DELETE FROM "users" WHERE "id" = $1 RETURNING "id"
|
|
156
|
+
|
|
157
|
+
// RPC → Function call
|
|
158
|
+
const rpcResult = parseRequest(
|
|
159
|
+
"POST",
|
|
160
|
+
"rpc/calculate_total",
|
|
161
|
+
"select=total,tax",
|
|
162
|
+
JSON.stringify({ order_id: 123, tax_rate: 0.08 }),
|
|
163
|
+
null
|
|
164
|
+
);
|
|
165
|
+
// Generates: SELECT * FROM calculate_total($1, $2)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
##### 2. Express.js Integration
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
import express from 'express';
|
|
172
|
+
import init, { parseRequest } from './postgrest-parser/postgrest_parser.js';
|
|
173
|
+
import pg from 'pg';
|
|
174
|
+
|
|
175
|
+
const app = express();
|
|
176
|
+
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
|
177
|
+
|
|
178
|
+
// Initialize WASM once at startup
|
|
179
|
+
await init();
|
|
180
|
+
|
|
181
|
+
app.use(express.json());
|
|
182
|
+
|
|
183
|
+
// Universal PostgREST-compatible endpoint
|
|
184
|
+
app.all('/api/:table', async (req, res) => {
|
|
185
|
+
try {
|
|
186
|
+
const result = parseRequest(
|
|
187
|
+
req.method,
|
|
188
|
+
req.params.table,
|
|
189
|
+
new URLSearchParams(req.query).toString(),
|
|
190
|
+
req.body ? JSON.stringify(req.body) : null,
|
|
191
|
+
JSON.stringify(req.headers)
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const { rows } = await db.query(result.query, result.params);
|
|
195
|
+
res.json(rows);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
res.status(400).json({ error: error.message });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
app.listen(3000);
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Now your API supports:
|
|
205
|
+
```bash
|
|
206
|
+
GET /api/users?age=gte.18&select=id,name
|
|
207
|
+
POST /api/users + body { "name": "Alice" }
|
|
208
|
+
PUT /api/users?id=eq.123 + body { "id": 123, "name": "Alice" }
|
|
209
|
+
PATCH /api/users?id=eq.123 + body { "status": "active" }
|
|
210
|
+
DELETE /api/users?id=eq.123
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
##### 3. Next.js API Route
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
// pages/api/[table].ts
|
|
217
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
218
|
+
import init, { parseRequest } from '@/lib/postgrest-parser/postgrest_parser.js';
|
|
219
|
+
import { query } from '@/lib/db';
|
|
220
|
+
|
|
221
|
+
let initialized = false;
|
|
222
|
+
|
|
223
|
+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
224
|
+
if (!initialized) {
|
|
225
|
+
await init();
|
|
226
|
+
initialized = true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const { table } = req.query;
|
|
230
|
+
const queryString = new URLSearchParams(req.query as Record<string, string>).toString();
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const result = parseRequest(
|
|
234
|
+
req.method!,
|
|
235
|
+
table as string,
|
|
236
|
+
queryString,
|
|
237
|
+
req.body ? JSON.stringify(req.body) : null,
|
|
238
|
+
JSON.stringify(req.headers)
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const rows = await query(result.query, result.params);
|
|
242
|
+
res.status(200).json(rows);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
res.status(400).json({ error: (error as Error).message });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
##### 4. Deno Edge Function
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
// supabase/functions/postgrest-proxy/index.ts
|
|
253
|
+
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
|
254
|
+
import init, { parseRequest } from './postgrest_parser.js';
|
|
255
|
+
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
|
256
|
+
|
|
257
|
+
await init();
|
|
258
|
+
|
|
259
|
+
const supabase = createClient(
|
|
260
|
+
Deno.env.get('SUPABASE_URL')!,
|
|
261
|
+
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
serve(async (req) => {
|
|
265
|
+
const url = new URL(req.url);
|
|
266
|
+
const path = url.pathname.slice(1);
|
|
267
|
+
const query = url.search.slice(1);
|
|
268
|
+
|
|
269
|
+
let body = null;
|
|
270
|
+
if (req.method !== 'GET' && req.method !== 'DELETE') {
|
|
271
|
+
body = await req.text();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const result = parseRequest(
|
|
276
|
+
req.method,
|
|
277
|
+
path,
|
|
278
|
+
query,
|
|
279
|
+
body,
|
|
280
|
+
JSON.stringify(Object.fromEntries(req.headers))
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const { data, error } = await supabase.rpc('execute_sql', {
|
|
284
|
+
query: result.query,
|
|
285
|
+
params: result.params
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (error) throw error;
|
|
289
|
+
|
|
290
|
+
return new Response(JSON.stringify(data), {
|
|
291
|
+
headers: { 'Content-Type': 'application/json' }
|
|
292
|
+
});
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return new Response(JSON.stringify({ error: error.message }), {
|
|
295
|
+
status: 400,
|
|
296
|
+
headers: { 'Content-Type': 'application/json' }
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
##### 5. Type-Safe Wrapper
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
// lib/postgrest.ts
|
|
306
|
+
import init, { parseRequest, WasmQueryResult } from './postgrest-parser/postgrest_parser.js';
|
|
307
|
+
|
|
308
|
+
let initialized = false;
|
|
309
|
+
|
|
310
|
+
async function ensureInit() {
|
|
311
|
+
if (!initialized) {
|
|
312
|
+
await init();
|
|
313
|
+
initialized = true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface QueryOptions {
|
|
318
|
+
select?: string;
|
|
319
|
+
filters?: Record<string, string>;
|
|
320
|
+
order?: string;
|
|
321
|
+
limit?: number;
|
|
322
|
+
offset?: number;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export class PostgRESTClient {
|
|
326
|
+
constructor(private executeQuery: (sql: string, params: any[]) => Promise<any[]>) {}
|
|
327
|
+
|
|
328
|
+
async select(table: string, options: QueryOptions = {}): Promise<any[]> {
|
|
329
|
+
await ensureInit();
|
|
330
|
+
|
|
331
|
+
const params = new URLSearchParams();
|
|
332
|
+
if (options.select) params.set('select', options.select);
|
|
333
|
+
if (options.filters) Object.entries(options.filters).forEach(([k, v]) => params.set(k, v));
|
|
334
|
+
if (options.order) params.set('order', options.order);
|
|
335
|
+
if (options.limit) params.set('limit', String(options.limit));
|
|
336
|
+
if (options.offset) params.set('offset', String(options.offset));
|
|
337
|
+
|
|
338
|
+
const result = parseRequest('GET', table, params.toString(), null, null);
|
|
339
|
+
return this.executeQuery(result.query, result.params);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async insert(table: string, data: any | any[], returning = '*'): Promise<any[]> {
|
|
343
|
+
await ensureInit();
|
|
344
|
+
|
|
345
|
+
const result = parseRequest(
|
|
346
|
+
'POST',
|
|
347
|
+
table,
|
|
348
|
+
`returning=${returning}`,
|
|
349
|
+
JSON.stringify(data),
|
|
350
|
+
JSON.stringify({ Prefer: 'return=representation' })
|
|
351
|
+
);
|
|
352
|
+
return this.executeQuery(result.query, result.params);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async upsert(
|
|
356
|
+
table: string,
|
|
357
|
+
data: any,
|
|
358
|
+
conflictColumns: string[],
|
|
359
|
+
returning = '*'
|
|
360
|
+
): Promise<any[]> {
|
|
361
|
+
await ensureInit();
|
|
362
|
+
|
|
363
|
+
const filters = conflictColumns.map(col => `${col}=eq.${data[col]}`).join('&');
|
|
364
|
+
const result = parseRequest(
|
|
365
|
+
'PUT',
|
|
366
|
+
table,
|
|
367
|
+
`${filters}&returning=${returning}`,
|
|
368
|
+
JSON.stringify(data),
|
|
369
|
+
null
|
|
370
|
+
);
|
|
371
|
+
return this.executeQuery(result.query, result.params);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async update(
|
|
375
|
+
table: string,
|
|
376
|
+
data: any,
|
|
377
|
+
filters: Record<string, string>,
|
|
378
|
+
returning = '*'
|
|
379
|
+
): Promise<any[]> {
|
|
380
|
+
await ensureInit();
|
|
381
|
+
|
|
382
|
+
const params = new URLSearchParams(filters);
|
|
383
|
+
params.set('returning', returning);
|
|
384
|
+
|
|
385
|
+
const result = parseRequest('PATCH', table, params.toString(), JSON.stringify(data), null);
|
|
386
|
+
return this.executeQuery(result.query, result.params);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async delete(table: string, filters: Record<string, string>, returning = 'id'): Promise<any[]> {
|
|
390
|
+
await ensureInit();
|
|
391
|
+
|
|
392
|
+
const params = new URLSearchParams(filters);
|
|
393
|
+
params.set('returning', returning);
|
|
394
|
+
|
|
395
|
+
const result = parseRequest('DELETE', table, params.toString(), null, null);
|
|
396
|
+
return this.executeQuery(result.query, result.params);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async rpc(functionName: string, args: any = {}, returning?: string): Promise<any[]> {
|
|
400
|
+
await ensureInit();
|
|
401
|
+
|
|
402
|
+
const queryString = returning ? `returning=${returning}` : '';
|
|
403
|
+
const result = parseRequest(
|
|
404
|
+
'POST',
|
|
405
|
+
`rpc/${functionName}`,
|
|
406
|
+
queryString,
|
|
407
|
+
Object.keys(args).length > 0 ? JSON.stringify(args) : null,
|
|
408
|
+
null
|
|
409
|
+
);
|
|
410
|
+
return this.executeQuery(result.query, result.params);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Usage:
|
|
415
|
+
// const client = new PostgRESTClient(async (sql, params) => {
|
|
416
|
+
// const { rows } = await db.query(sql, params);
|
|
417
|
+
// return rows;
|
|
418
|
+
// });
|
|
419
|
+
//
|
|
420
|
+
// const users = await client.select('users', {
|
|
421
|
+
// select: 'id,name,email',
|
|
422
|
+
// filters: { 'age': 'gte.18', 'status': 'eq.active' },
|
|
423
|
+
// order: 'name.asc',
|
|
424
|
+
// limit: 10
|
|
425
|
+
// });
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
##### 6. Basic Usage (Browser/Deno)
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
import init, { parseQueryString } from './postgrest_parser.js';
|
|
432
|
+
|
|
433
|
+
// Initialize WASM module (call once)
|
|
434
|
+
await init();
|
|
435
|
+
|
|
436
|
+
// Parse a PostgREST query string
|
|
437
|
+
const result = parseQueryString(
|
|
438
|
+
"users",
|
|
439
|
+
"age=gte.18&status=in.(active,pending)&order=created_at.desc&limit=10"
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
console.log('SQL:', result.query);
|
|
443
|
+
// SELECT * FROM "users" WHERE "age" >= $1 AND "status" = ANY($2) ORDER BY "created_at" DESC LIMIT $3
|
|
444
|
+
|
|
445
|
+
console.log('Params:', result.params);
|
|
446
|
+
// ["18", ["active", "pending"], 10]
|
|
447
|
+
|
|
448
|
+
console.log('Tables:', result.tables);
|
|
449
|
+
// ["users"]
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
##### Parse Only (Without SQL Generation)
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
import init, { parseOnly } from './postgrest_parser.js';
|
|
456
|
+
|
|
457
|
+
await init();
|
|
458
|
+
|
|
459
|
+
// Parse query structure without generating SQL
|
|
460
|
+
const parsed = parseOnly("select=id,name&age=gte.18&limit=10");
|
|
461
|
+
|
|
462
|
+
console.log(parsed);
|
|
463
|
+
// {
|
|
464
|
+
// select: [{ field: "id" }, { field: "name" }],
|
|
465
|
+
// filters: [...],
|
|
466
|
+
// limit: 10,
|
|
467
|
+
// offset: null,
|
|
468
|
+
// order: []
|
|
469
|
+
// }
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
##### Using with Deno
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
// run_parser.ts
|
|
476
|
+
import init, { parseQueryString } from "./pkg/postgrest_parser.js";
|
|
477
|
+
|
|
478
|
+
await init();
|
|
479
|
+
|
|
480
|
+
const result = parseQueryString(
|
|
481
|
+
"posts",
|
|
482
|
+
"select=id,title,author(name)&status=eq.published&created_at=gte.2024-01-01"
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
console.log("Generated SQL:", result.query);
|
|
486
|
+
console.log("Parameters:", result.params);
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Run with:
|
|
490
|
+
```bash
|
|
491
|
+
deno run --allow-read run_parser.ts
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
##### Real-World Example: API Endpoint
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
// api/posts.ts
|
|
498
|
+
import init, { parseQueryString } from '../postgrest_parser.js';
|
|
499
|
+
|
|
500
|
+
// Initialize once at startup
|
|
501
|
+
await init();
|
|
502
|
+
|
|
503
|
+
export async function handlePostsRequest(request: Request) {
|
|
504
|
+
const url = new URL(request.url);
|
|
505
|
+
const queryString = url.search.slice(1); // Remove leading '?'
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
const result = parseQueryString("posts", queryString);
|
|
509
|
+
|
|
510
|
+
// Execute query with your database client
|
|
511
|
+
const rows = await db.query(result.query, result.params);
|
|
512
|
+
|
|
513
|
+
return new Response(JSON.stringify(rows), {
|
|
514
|
+
headers: { 'Content-Type': 'application/json' }
|
|
515
|
+
});
|
|
516
|
+
} catch (error) {
|
|
517
|
+
return new Response(
|
|
518
|
+
JSON.stringify({ error: error.message }),
|
|
519
|
+
{ status: 400 }
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
##### JSON Serialization
|
|
526
|
+
|
|
527
|
+
```typescript
|
|
528
|
+
const result = parseQueryString("users", "age=gte.18");
|
|
529
|
+
|
|
530
|
+
// Convert to plain JSON object
|
|
531
|
+
const json = result.toJSON();
|
|
532
|
+
console.log(JSON.stringify(json, null, 2));
|
|
533
|
+
// {
|
|
534
|
+
// "query": "SELECT * FROM \"users\" WHERE \"age\" >= $1",
|
|
535
|
+
// "params": ["18"],
|
|
536
|
+
// "tables": ["users"]
|
|
537
|
+
// }
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
#### Complete WASM API Reference
|
|
541
|
+
|
|
542
|
+
The WASM module provides comprehensive TypeScript/JavaScript bindings for all PostgREST operations.
|
|
543
|
+
|
|
544
|
+
**📚 Full Documentation:** See [WASM_API.md](docs/WASM_API.md) for complete API reference with 40+ examples.
|
|
545
|
+
|
|
546
|
+
**Core Functions:**
|
|
547
|
+
|
|
548
|
+
| Function | Purpose | HTTP Method Equivalent |
|
|
549
|
+
|----------|---------|----------------------|
|
|
550
|
+
| `parseRequest(method, path, qs, body?, headers?)` | **Main entry point** - Routes HTTP methods to SQL | All methods |
|
|
551
|
+
| `parseQueryString(table, queryString)` | Direct SELECT generation | GET |
|
|
552
|
+
| `parseInsert(table, body, qs?, headers?)` | Direct INSERT generation | POST |
|
|
553
|
+
| `parseUpdate(table, body, qs, headers?)` | Direct UPDATE generation | PATCH |
|
|
554
|
+
| `parseDelete(table, qs, headers?)` | Direct DELETE generation | DELETE |
|
|
555
|
+
| `parseRpc(function, body?, qs?, headers?)` | Direct RPC call | POST to rpc/* |
|
|
556
|
+
| `parseOnly(queryString)` | Parse without SQL generation | N/A |
|
|
557
|
+
| `buildFilterClause(filters)` | Build WHERE clause from filters | N/A |
|
|
558
|
+
|
|
559
|
+
**Return Type:**
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
interface WasmQueryResult {
|
|
563
|
+
query: string; // Parameterized SQL with $1, $2, ... placeholders
|
|
564
|
+
params: any[]; // Parameter values (strings, numbers, arrays, etc.)
|
|
565
|
+
tables: string[]; // Referenced table names
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
**HTTP Method Routing:**
|
|
570
|
+
|
|
571
|
+
```typescript
|
|
572
|
+
parseRequest("GET", path, qs) // → SELECT
|
|
573
|
+
parseRequest("POST", path, qs) // → INSERT (or RPC if path starts with "rpc/")
|
|
574
|
+
parseRequest("PUT", path, qs) // → UPSERT (auto ON CONFLICT from filters)
|
|
575
|
+
parseRequest("PATCH", path, qs) // → UPDATE
|
|
576
|
+
parseRequest("DELETE", path, qs) // → DELETE
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
**Examples:** See [examples/wasm_mutations_example.ts](examples/wasm_mutations_example.ts) for 21 comprehensive examples covering all operations.
|
|
580
|
+
|
|
581
|
+
#### Running Examples and Tests
|
|
582
|
+
|
|
583
|
+
**Run comprehensive examples:**
|
|
584
|
+
|
|
585
|
+
```bash
|
|
586
|
+
# Build WASM
|
|
587
|
+
wasm-pack build --target web --features wasm
|
|
588
|
+
|
|
589
|
+
# Run SELECT examples (20 examples)
|
|
590
|
+
deno run --allow-read examples/wasm_example.ts
|
|
591
|
+
|
|
592
|
+
# Run mutation examples (21 examples: INSERT, UPDATE, DELETE, RPC, HTTP routing)
|
|
593
|
+
deno run --allow-read examples/wasm_mutations_example.ts
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**Run integration tests:**
|
|
597
|
+
|
|
598
|
+
```bash
|
|
599
|
+
# Install Deno (if not already installed)
|
|
600
|
+
curl -fsSL https://deno.land/install.sh | sh
|
|
601
|
+
|
|
602
|
+
# Run WASM integration tests
|
|
603
|
+
deno test --allow-read tests/integration/wasm_test.ts
|
|
604
|
+
|
|
605
|
+
# Or use the Deno task
|
|
606
|
+
deno task test:wasm
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
See [tests/integration/README.md](tests/integration/README.md) for detailed test documentation.
|
|
610
|
+
|
|
611
|
+
**TypeScript Type Definitions:**
|
|
612
|
+
|
|
613
|
+
The WASM package includes full TypeScript definitions in `pkg/postgrest_parser.d.ts`. Your IDE will automatically provide:
|
|
614
|
+
- IntelliSense/autocomplete for all functions
|
|
615
|
+
- Type checking for parameters and return values
|
|
616
|
+
- JSDoc documentation on hover
|
|
617
|
+
|
|
618
|
+
```typescript
|
|
619
|
+
import init, { parseRequest, WasmQueryResult } from './postgrest-parser/postgrest_parser.js';
|
|
620
|
+
|
|
621
|
+
// TypeScript knows the exact shape of WasmQueryResult
|
|
622
|
+
const result: WasmQueryResult = parseRequest("GET", "users", "age=gte.18", null, null);
|
|
623
|
+
// ^-- Type: { query: string; params: any[]; tables: string[] }
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
#### Performance
|
|
627
|
+
|
|
628
|
+
WASM performance benchmarks (from integration tests):
|
|
629
|
+
|
|
630
|
+
- **Average parse time:** ~0.01ms per query
|
|
631
|
+
- **100 queries:** ~1ms total
|
|
632
|
+
- **Throughput:** ~100,000 queries/second in browser
|
|
633
|
+
|
|
634
|
+
The WASM build maintains near-native performance while running in JavaScript environments.
|
|
635
|
+
|
|
636
|
+
#### Browser Compatibility
|
|
637
|
+
|
|
638
|
+
Tested and working in:
|
|
639
|
+
- ✅ Chrome 90+
|
|
640
|
+
- ✅ Firefox 89+
|
|
641
|
+
- ✅ Safari 15+
|
|
642
|
+
- ✅ Edge 90+
|
|
643
|
+
- ✅ Deno 1.x+
|
|
644
|
+
- ✅ Node.js 16+ (with `--target nodejs`)
|
|
645
|
+
|
|
646
|
+
#### Troubleshooting
|
|
647
|
+
|
|
648
|
+
**Module not found:**
|
|
649
|
+
```typescript
|
|
650
|
+
// Make sure to use the correct path to pkg/
|
|
651
|
+
import init from './pkg/postgrest_parser.js'; // ✅
|
|
652
|
+
import init from './postgrest_parser.js'; // ❌
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
**WASM initialization:**
|
|
656
|
+
```typescript
|
|
657
|
+
// Always call init() before using other functions
|
|
658
|
+
await init(); // ✅
|
|
659
|
+
parseQueryString(...); // ✅
|
|
660
|
+
|
|
661
|
+
parseQueryString(...); // ❌ Will fail - init() not called
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
**Type errors in TypeScript:**
|
|
665
|
+
```typescript
|
|
666
|
+
// Use generated .d.ts files
|
|
667
|
+
import init, { parseQueryString } from './pkg/postgrest_parser.js';
|
|
668
|
+
// Type definitions are in ./pkg/postgrest_parser.d.ts
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
## Usage
|
|
672
|
+
|
|
673
|
+
### Rust: Basic Query Parsing
|
|
674
|
+
|
|
675
|
+
```rust
|
|
676
|
+
use postgrest_parser::*;
|
|
677
|
+
|
|
678
|
+
// Parse a query string
|
|
679
|
+
let params = parse_query_string("select=id,name&id=eq.1&order=id.desc&limit=10")?;
|
|
680
|
+
assert!(params.has_select());
|
|
681
|
+
assert!(params.has_filters());
|
|
682
|
+
|
|
683
|
+
// Generate SQL
|
|
684
|
+
let result = to_sql("users", ¶ms)?;
|
|
685
|
+
println!("Query: {}", result.query);
|
|
686
|
+
println!("Params: {:?}", result.params);
|
|
687
|
+
// Output:
|
|
688
|
+
// Query: SELECT "id", "name" FROM "users" WHERE "id" = $1 ORDER BY "id" DESC LIMIT $2
|
|
689
|
+
// Params: [String("1"), Number(10)]
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
### Filter Operators
|
|
693
|
+
|
|
694
|
+
#### Comparison Operators
|
|
695
|
+
|
|
696
|
+
```rust
|
|
697
|
+
// Equality
|
|
698
|
+
let params = parse_query_string("id=eq.1")?; // WHERE "id" = $1
|
|
699
|
+
let params = parse_query_string("status=neq.deleted")?; // WHERE "status" <> $1
|
|
700
|
+
|
|
701
|
+
// Comparison
|
|
702
|
+
let params = parse_query_string("age=gt.18")?; // WHERE "age" > $1
|
|
703
|
+
let params = parse_query_string("age=gte.18")?; // WHERE "age" >= $1
|
|
704
|
+
let params = parse_query_string("age=lt.65")?; // WHERE "age" < $1
|
|
705
|
+
let params = parse_query_string("age=lte.65")?; // WHERE "age" <= $1
|
|
706
|
+
|
|
707
|
+
// Negation works with all operators
|
|
708
|
+
let params = parse_query_string("age=not.gt.18")?; // WHERE "age" <= $1
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
#### Pattern Matching
|
|
712
|
+
|
|
713
|
+
```rust
|
|
714
|
+
// SQL LIKE operators
|
|
715
|
+
let params = parse_query_string("name=like.*Smith%")?; // WHERE "name" LIKE $1
|
|
716
|
+
let params = parse_query_string("name=ilike.*smith%")?; // WHERE "name" ILIKE $1 (case-insensitive)
|
|
717
|
+
|
|
718
|
+
// POSIX regex
|
|
719
|
+
let params = parse_query_string("name=match.^John")?; // WHERE "name" ~ $1
|
|
720
|
+
let params = parse_query_string("name=imatch.^john")?; // WHERE "name" ~* $1 (case-insensitive)
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
#### List and Array Operators
|
|
724
|
+
|
|
725
|
+
```rust
|
|
726
|
+
// IN operator
|
|
727
|
+
let params = parse_query_string("status=in.(active,pending)")?; // WHERE "status" = ANY($1)
|
|
728
|
+
|
|
729
|
+
// Array contains
|
|
730
|
+
let params = parse_query_string("tags=cs.{rust}")?; // WHERE "tags" @> $1
|
|
731
|
+
let params = parse_query_string("tags=cd.{rust,elixir}")?; // WHERE "tags" <@ $1
|
|
732
|
+
|
|
733
|
+
// Array overlap
|
|
734
|
+
let params = parse_query_string("tags=ov.(rust,elixir)")?; // WHERE "tags" && $1
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
#### Full-Text Search
|
|
738
|
+
|
|
739
|
+
```rust
|
|
740
|
+
// Basic FTS (uses plainto_tsquery)
|
|
741
|
+
let params = parse_query_string("content=fts.search term")?;
|
|
742
|
+
// WHERE to_tsvector('english', "content") @@ plainto_tsquery('english', $1)
|
|
743
|
+
|
|
744
|
+
// With custom language
|
|
745
|
+
let params = parse_query_string("content=fts(french).terme")?;
|
|
746
|
+
// WHERE to_tsvector('french', "content") @@ plainto_tsquery('french', $1)
|
|
747
|
+
|
|
748
|
+
// Phrase search
|
|
749
|
+
let params = parse_query_string("content=phfts.exact phrase")?;
|
|
750
|
+
// WHERE to_tsvector('english', "content") @@ phraseto_tsquery('english', $1)
|
|
751
|
+
|
|
752
|
+
// Websearch (most lenient)
|
|
753
|
+
let params = parse_query_string("content=wfts.search query")?;
|
|
754
|
+
// WHERE to_tsvector('english', "content") @@ websearch_to_tsquery('english', $1)
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
#### Range Operators (PostgreSQL ranges)
|
|
758
|
+
|
|
759
|
+
```rust
|
|
760
|
+
let params = parse_query_string("range=sl.[1,10)")?; // WHERE "range" << $1 (strictly left)
|
|
761
|
+
let params = parse_query_string("range=sr.[1,10)")?; // WHERE "range" >> $1 (strictly right)
|
|
762
|
+
let params = parse_query_string("range=nxl.[1,10)")?; // WHERE "range" &< $1
|
|
763
|
+
let params = parse_query_string("range=nxr.[1,10)")?; // WHERE "range" &> $1
|
|
764
|
+
let params = parse_query_string("range=adj.[1,10)")?; // WHERE "range" -|- $1 (adjacent)
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
#### Special Operators
|
|
768
|
+
|
|
769
|
+
```rust
|
|
770
|
+
// IS operator
|
|
771
|
+
let params = parse_query_string("deleted_at=is.null")?; // WHERE "deleted_at" IS NULL
|
|
772
|
+
let params = parse_query_string("deleted_at=is.not_null")?; // WHERE "deleted_at" IS NOT NULL
|
|
773
|
+
let params = parse_query_string("active=is.true")?; // WHERE "active" IS TRUE
|
|
774
|
+
let params = parse_query_string("active=is.false")?; // WHERE "active" IS FALSE
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
#### Quantifiers
|
|
778
|
+
|
|
779
|
+
```rust
|
|
780
|
+
// ANY quantifier
|
|
781
|
+
let params = parse_query_string("tags=eq(any).{rust,elixir}")?; // WHERE "tags" = ANY($1)
|
|
782
|
+
|
|
783
|
+
// ALL quantifier
|
|
784
|
+
let params = parse_query_string("tags=eq(all).{rust}")?; // WHERE "tags" = ALL($1)
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
#### JSON Path Navigation
|
|
788
|
+
|
|
789
|
+
```rust
|
|
790
|
+
// Arrow operator (returns JSON)
|
|
791
|
+
let params = parse_query_string("data->name=eq.test")?;
|
|
792
|
+
// WHERE "data"->'name' = $1
|
|
793
|
+
|
|
794
|
+
// Double arrow operator (returns text)
|
|
795
|
+
let params = parse_query_string("data->>email=like.*@example.com")?;
|
|
796
|
+
// WHERE "data"->>'email' LIKE $1
|
|
797
|
+
|
|
798
|
+
// Nested paths
|
|
799
|
+
let params = parse_query_string("data->user->name=eq.John")?;
|
|
800
|
+
// WHERE "data"->'user'->'name' = $1
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
#### Type Casting
|
|
804
|
+
|
|
805
|
+
```rust
|
|
806
|
+
let params = parse_query_string("price::numeric=gt.100")?;
|
|
807
|
+
// WHERE "price"::numeric > $1
|
|
808
|
+
|
|
809
|
+
let params = parse_query_string("data->age::int=gte.18")?;
|
|
810
|
+
// WHERE ("data"->'age')::int >= $1
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
### Logic Trees
|
|
814
|
+
|
|
815
|
+
```rust
|
|
816
|
+
// AND conditions
|
|
817
|
+
let params = parse_query_string("and=(id.eq.1,name.eq.john)")?;
|
|
818
|
+
|
|
819
|
+
// OR conditions
|
|
820
|
+
let params = parse_query_string("or=(status.eq.pending,status.eq.processing)")?;
|
|
821
|
+
|
|
822
|
+
// Nested logic
|
|
823
|
+
let params = parse_query_string("and=(id.eq.1,or(status.eq.active,status.eq.pending))")?;
|
|
824
|
+
|
|
825
|
+
// Negated logic
|
|
826
|
+
let params = parse_query_string("not.and=(id.eq.1,name.eq.john)")?;
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
### Select with Relations
|
|
830
|
+
|
|
831
|
+
```rust
|
|
832
|
+
let params = parse_query_string("select=id,client(id,name),posts(title)")?;
|
|
833
|
+
assert!(params.select.is_some());
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
### Ordering
|
|
837
|
+
|
|
838
|
+
```rust
|
|
839
|
+
// Single column
|
|
840
|
+
let params = parse_query_string("order=id.desc")?;
|
|
841
|
+
|
|
842
|
+
// Multiple columns
|
|
843
|
+
let params = parse_query_string("order=id.desc,name.asc")?;
|
|
844
|
+
|
|
845
|
+
// With nulls handling
|
|
846
|
+
let params = parse_query_string("order=id.desc.nullslast")?;
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
## Development
|
|
850
|
+
|
|
851
|
+
### Building
|
|
852
|
+
|
|
853
|
+
```bash
|
|
854
|
+
# Native
|
|
855
|
+
cargo build --release
|
|
856
|
+
|
|
857
|
+
# WASM
|
|
858
|
+
cargo build --release --target wasm32-unknown-unknown
|
|
859
|
+
wasm-pack build --target web
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
### Testing
|
|
863
|
+
|
|
864
|
+
```bash
|
|
865
|
+
# Run all tests
|
|
866
|
+
cargo test
|
|
867
|
+
|
|
868
|
+
# Run specific test
|
|
869
|
+
cargo test test_parse_query_string
|
|
870
|
+
|
|
871
|
+
# Run with output
|
|
872
|
+
cargo test -- --nocapture
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
### Benchmarks
|
|
876
|
+
|
|
877
|
+
```bash
|
|
878
|
+
# Run all benchmarks
|
|
879
|
+
cargo bench
|
|
880
|
+
|
|
881
|
+
# Run specific benchmark group
|
|
882
|
+
cargo bench simple_parsing
|
|
883
|
+
cargo bench realistic_workloads
|
|
884
|
+
|
|
885
|
+
# See docs/BENCHMARKS.md for detailed results and analysis
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
#### Latest Benchmark Results
|
|
889
|
+
|
|
890
|
+
Benchmarked on Darwin 24.6.0 (macOS) with release optimizations.
|
|
891
|
+
|
|
892
|
+
**Simple Parsing Performance:**
|
|
893
|
+
|
|
894
|
+
| Operation | Time (median) | Throughput |
|
|
895
|
+
|-----------|---------------|------------|
|
|
896
|
+
| `select=id,name,email` | 1.14 µs | 880K ops/s |
|
|
897
|
+
| `age=gte.18` | 781 ns | 1.28M ops/s |
|
|
898
|
+
| `order=created_at.desc` | 1.27 µs | 790K ops/s |
|
|
899
|
+
| `limit=10&offset=20` | 520 ns | 1.92M ops/s |
|
|
900
|
+
|
|
901
|
+
**Realistic Workload Performance:**
|
|
902
|
+
|
|
903
|
+
| Workload | Time (median) | Throughput | Description |
|
|
904
|
+
|----------|---------------|------------|-------------|
|
|
905
|
+
| User Search | 7.19 µs | 139K ops/s | SELECT + 2 filters + ORDER + LIMIT |
|
|
906
|
+
| Paginated List | 6.84 µs | 146K ops/s | SELECT + relation + filter + ORDER + pagination |
|
|
907
|
+
| Filtered Report | 9.92 µs | 101K ops/s | SELECT + relation + 4 filters + ORDER |
|
|
908
|
+
| Complex Search | 10.66 µs | 94K ops/s | SELECT + FTS + array ops + 3 filters + ORDER |
|
|
909
|
+
| Dashboard Aggregation | 7.84 µs | 128K ops/s | Complex logic tree + date range + ORDER |
|
|
910
|
+
|
|
911
|
+
**Operator Performance:**
|
|
912
|
+
|
|
913
|
+
| Operator Category | Example | Time (median) |
|
|
914
|
+
|-------------------|---------|---------------|
|
|
915
|
+
| Comparison (`eq`, `gte`) | `id=eq.1` | ~750-783 ns |
|
|
916
|
+
| Pattern Match | `name=like.*Smith*` | ~783-803 ns |
|
|
917
|
+
| List Operations | `status=in.(a,b,c)` | ~1.07 µs |
|
|
918
|
+
| Full-Text Search | `content=fts.term` | ~941 ns |
|
|
919
|
+
| FTS with Language | `content=fts(french).terme` | ~974 ns |
|
|
920
|
+
| Array Operations | `tags=cs.{rust,elixir}` | ~787-1.06 µs |
|
|
921
|
+
| Range Operations | `range=sl.[1,10)` | ~1.01 µs |
|
|
922
|
+
| JSON Path | `data->name=eq.test` | ~934-1.13 µs |
|
|
923
|
+
| Type Casting | `price::numeric=gt.100` | ~1.10 µs |
|
|
924
|
+
| Quantifiers | `tags=eq(any).{a,b}` | ~907-1.04 µs |
|
|
925
|
+
|
|
926
|
+
**SQL Generation (End-to-End):**
|
|
927
|
+
|
|
928
|
+
| Query Type | Time (median) | Throughput |
|
|
929
|
+
|------------|---------------|------------|
|
|
930
|
+
| Simple SELECT | 2.21 µs | 452K ops/s |
|
|
931
|
+
| With Filters | 3.03 µs | 330K ops/s |
|
|
932
|
+
| With ORDER | 3.75 µs | 267K ops/s |
|
|
933
|
+
| Complex Query | 7.57 µs | 132K ops/s |
|
|
934
|
+
|
|
935
|
+
**Query Scaling:**
|
|
936
|
+
|
|
937
|
+
- 1 filter: 1.97 µs
|
|
938
|
+
- 3 filters: 4.09 µs (2.1x)
|
|
939
|
+
- 5 filters: 6.79 µs (3.4x)
|
|
940
|
+
- 10 filters: 13.40 µs (6.8x)
|
|
941
|
+
|
|
942
|
+
**Performance vs Reference Implementation:**
|
|
943
|
+
- Simple queries: **1.3-6x faster**
|
|
944
|
+
- Complex queries: **1.3x faster**
|
|
945
|
+
- All operations under 15 µs
|
|
946
|
+
|
|
947
|
+
See [BENCHMARKS.md](docs/BENCHMARKS.md) for complete performance analysis.
|
|
948
|
+
|
|
949
|
+
## Complete Operator Reference
|
|
950
|
+
|
|
951
|
+
| Operator | PostgREST | SQL | Example |
|
|
952
|
+
|----------|-----------|-----|---------|
|
|
953
|
+
| `eq` | Equal | `=` | `id=eq.1` |
|
|
954
|
+
| `neq` | Not equal | `<>` | `status=neq.deleted` |
|
|
955
|
+
| `gt` | Greater than | `>` | `age=gt.18` |
|
|
956
|
+
| `gte` | Greater than or equal | `>=` | `age=gte.18` |
|
|
957
|
+
| `lt` | Less than | `<` | `age=lt.65` |
|
|
958
|
+
| `lte` | Less than or equal | `<=` | `age=lte.65` |
|
|
959
|
+
| `like` | LIKE pattern | `LIKE` | `name=like.*Smith*` |
|
|
960
|
+
| `ilike` | Case-insensitive LIKE | `ILIKE` | `name=ilike.*smith*` |
|
|
961
|
+
| `match` | POSIX regex | `~` | `name=match.^John` |
|
|
962
|
+
| `imatch` | Case-insensitive regex | `~*` | `name=imatch.^john` |
|
|
963
|
+
| `in` | In list | `= ANY($1)` | `status=in.(active,pending)` |
|
|
964
|
+
| `is` | IS check | `IS` | `deleted=is.null` |
|
|
965
|
+
| `fts` | Full-text search | `@@` | `content=fts.search` |
|
|
966
|
+
| `plfts` | Plain FTS | `@@` | `content=plfts.search` |
|
|
967
|
+
| `phfts` | Phrase FTS | `@@` | `content=phfts.exact phrase` |
|
|
968
|
+
| `wfts` | Websearch FTS | `@@` | `content=wfts.query` |
|
|
969
|
+
| `cs` | Contains | `@>` | `tags=cs.{rust}` |
|
|
970
|
+
| `cd` | Contained in | `<@` | `tags=cd.{rust,elixir}` |
|
|
971
|
+
| `ov` | Overlaps | `&&` | `tags=ov.(rust,elixir)` |
|
|
972
|
+
| `sl` | Strictly left | `<<` | `range=sl.[1,10)` |
|
|
973
|
+
| `sr` | Strictly right | `>>` | `range=sr.[1,10)` |
|
|
974
|
+
| `nxl` | Not extends right | `&<` | `range=nxl.[1,10)` |
|
|
975
|
+
| `nxr` | Not extends left | `&>` | `range=nxr.[1,10)` |
|
|
976
|
+
| `adj` | Adjacent | `-|-` | `range=adj.[1,10)` |
|
|
977
|
+
|
|
978
|
+
## Performance
|
|
979
|
+
|
|
980
|
+
- **Zero-copy parsing** where possible with nom combinators
|
|
981
|
+
- **No regex usage** - all parsing done with efficient pattern matching
|
|
982
|
+
- **148 passing tests** with comprehensive coverage
|
|
983
|
+
- **Optimized for Rust** - leverages Rust's zero-cost abstractions
|
|
984
|
+
|
|
985
|
+
## Architecture
|
|
986
|
+
|
|
987
|
+
- **AST** ([src/ast/](src/ast/)): Typed intermediate representation of parsed queries
|
|
988
|
+
- **Parser** ([src/parser/](src/parser/)): nom-based combinator parsers (no regex)
|
|
989
|
+
- `common.rs` - Shared parsing utilities (identifiers, lists, JSON paths)
|
|
990
|
+
- `filter.rs` - Filter/operator parsing
|
|
991
|
+
- `logic.rs` - Logic tree parsing (AND/OR/NOT)
|
|
992
|
+
- `order.rs` - ORDER BY clause parsing
|
|
993
|
+
- `select.rs` - SELECT clause parsing with relations
|
|
994
|
+
- **SQL Builder** ([src/sql/](src/sql/)): Parameterized PostgreSQL SQL generation
|
|
995
|
+
- **Error Handling** ([src/error/](src/error/)): Typed errors using thiserror
|
|
996
|
+
|
|
997
|
+
## Development
|
|
998
|
+
|
|
999
|
+
### Building
|
|
1000
|
+
|
|
1001
|
+
```bash
|
|
1002
|
+
# Native
|
|
1003
|
+
cargo build --release
|
|
1004
|
+
|
|
1005
|
+
# With all features
|
|
1006
|
+
cargo build --release --features full
|
|
1007
|
+
|
|
1008
|
+
# WASM (if you need browser support)
|
|
1009
|
+
cargo build --release --target wasm32-unknown-unknown --features wasm
|
|
1010
|
+
wasm-pack build --target web
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
### Testing
|
|
1014
|
+
|
|
1015
|
+
```bash
|
|
1016
|
+
# Run all Rust tests (148 tests)
|
|
1017
|
+
cargo test
|
|
1018
|
+
|
|
1019
|
+
# Run specific test
|
|
1020
|
+
cargo test test_parse_query_string
|
|
1021
|
+
|
|
1022
|
+
# Run with output
|
|
1023
|
+
cargo test -- --nocapture
|
|
1024
|
+
|
|
1025
|
+
# Check code quality
|
|
1026
|
+
cargo clippy -- -D warnings
|
|
1027
|
+
|
|
1028
|
+
# Run WASM integration tests (23 tests)
|
|
1029
|
+
# Requires Deno: https://deno.land
|
|
1030
|
+
wasm-pack build --target web --features wasm
|
|
1031
|
+
deno test --allow-read tests/integration/wasm_test.ts
|
|
1032
|
+
|
|
1033
|
+
# Or use Deno task
|
|
1034
|
+
deno task test:wasm
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
### Code Quality
|
|
1038
|
+
|
|
1039
|
+
```bash
|
|
1040
|
+
# Format code
|
|
1041
|
+
cargo fmt
|
|
1042
|
+
|
|
1043
|
+
# Run linter
|
|
1044
|
+
cargo clippy
|
|
1045
|
+
|
|
1046
|
+
# Check for security vulnerabilities
|
|
1047
|
+
cargo audit
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
## Roadmap
|
|
1051
|
+
|
|
1052
|
+
- [x] Complete PostgREST filter operator support (22+ operators)
|
|
1053
|
+
- [x] Logic trees with arbitrary nesting
|
|
1054
|
+
- [x] Full-text search with language support
|
|
1055
|
+
- [x] Array and range operators
|
|
1056
|
+
- [x] Quantifiers (any/all)
|
|
1057
|
+
- [x] Comprehensive test coverage (148 tests)
|
|
1058
|
+
- [x] WASM bindings for TypeScript/JavaScript with Deno integration tests
|
|
1059
|
+
- [x] Benchmark suite comparing to reference implementation
|
|
1060
|
+
- [ ] Count aggregation support
|
|
1061
|
+
- [ ] `on_conflict` parameter support
|
|
1062
|
+
- [ ] Relation column filtering
|
|
1063
|
+
|
|
1064
|
+
## Contributing
|
|
1065
|
+
|
|
1066
|
+
Contributions are welcome! Areas of interest:
|
|
1067
|
+
|
|
1068
|
+
- Additional test cases and edge cases
|
|
1069
|
+
- Performance optimizations
|
|
1070
|
+
- WASM/JavaScript bindings
|
|
1071
|
+
- Documentation improvements
|
|
1072
|
+
- Bug reports and fixes
|
|
1073
|
+
|
|
1074
|
+
## License
|
|
1075
|
+
|
|
1076
|
+
MIT
|
|
1077
|
+
|
|
1078
|
+
## Acknowledgments
|
|
1079
|
+
|
|
1080
|
+
- Inspired by the [PostgREST](https://postgrest.org/) project
|
|
1081
|
+
- Parser built with [nom](https://github.com/rust-bakery/nom)
|
|
1082
|
+
- Reference implementation: [postgrest_parser (Elixir)](https://github.com/supabase/postgrest_parser)
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "postgrest-parser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "PostgREST URL-to-SQL parser with WASM bindings for TypeScript/JavaScript",
|
|
5
|
+
"main": "pkg/postgrest_parser.js",
|
|
6
|
+
"types": "postgrest_parser.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "wasm-pack build --target web --out-dir pkg -- --features wasm && cd pkg && npm install && npm run build",
|
|
10
|
+
"build:wasm": "wasm-pack build --target web --out-dir pkg -- --features wasm",
|
|
11
|
+
"build:node": "wasm-pack build --target nodejs --out-dir pkg-node -- --features wasm",
|
|
12
|
+
"build:bundler": "wasm-pack build --target bundler --out-dir pkg-bundler -- --features wasm",
|
|
13
|
+
"test:wasm": "wasm-pack test --headless --firefox -- --features wasm",
|
|
14
|
+
"example": "tsx example.ts"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"postgrest",
|
|
18
|
+
"sql",
|
|
19
|
+
"parser",
|
|
20
|
+
"postgresql",
|
|
21
|
+
"wasm",
|
|
22
|
+
"typescript",
|
|
23
|
+
"query-builder"
|
|
24
|
+
],
|
|
25
|
+
"author": "Your Name",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/your-org/postgrest-parser-rust"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^20.0.0",
|
|
33
|
+
"tsx": "^4.0.0"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"pkg/**/*",
|
|
37
|
+
"postgrest_parser.d.ts",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18.0.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript definitions for postgrest-parser WASM bindings
|
|
3
|
+
*
|
|
4
|
+
* @module postgrest-parser
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Query result containing SQL, parameters, and affected tables
|
|
9
|
+
*/
|
|
10
|
+
export interface QueryResult {
|
|
11
|
+
/**
|
|
12
|
+
* The generated PostgreSQL SELECT query with parameter placeholders ($1, $2, etc.)
|
|
13
|
+
*/
|
|
14
|
+
readonly query: string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Array of parameter values corresponding to placeholders in the query
|
|
18
|
+
*/
|
|
19
|
+
readonly params: any[];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* List of table names referenced in the query
|
|
23
|
+
*/
|
|
24
|
+
readonly tables: string[];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert the result to a plain JSON object
|
|
28
|
+
*/
|
|
29
|
+
toJSON(): QueryResult;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Filter clause result containing WHERE clause and parameters
|
|
34
|
+
*/
|
|
35
|
+
export interface FilterClauseResult {
|
|
36
|
+
/**
|
|
37
|
+
* The WHERE clause SQL fragment (without the "WHERE" keyword)
|
|
38
|
+
*/
|
|
39
|
+
clause: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parameter values referenced in the clause
|
|
43
|
+
*/
|
|
44
|
+
params: any[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a PostgREST query string and convert it to SQL.
|
|
49
|
+
*
|
|
50
|
+
* @param table - The table name to query
|
|
51
|
+
* @param queryString - The PostgREST query string (e.g., "select=id,name&age=gte.18")
|
|
52
|
+
* @returns Query result with SQL, params, and tables
|
|
53
|
+
* @throws Error if parsing or SQL generation fails
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* const result = parseQueryString("users", "age=gte.18&status=eq.active&limit=10");
|
|
58
|
+
* console.log(result.query); // SELECT * FROM "users" WHERE "age" >= $1 AND "status" = $2 LIMIT $3
|
|
59
|
+
* console.log(result.params); // ["18", "active", 10]
|
|
60
|
+
* console.log(result.tables); // ["users"]
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function parseQueryString(table: string, queryString: string): QueryResult;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse a query string without generating SQL.
|
|
67
|
+
*
|
|
68
|
+
* Useful for inspecting the parsed structure or validating queries.
|
|
69
|
+
*
|
|
70
|
+
* @param queryString - The PostgREST query string
|
|
71
|
+
* @returns Parsed parameters as a JSON object
|
|
72
|
+
* @throws Error if parsing fails
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```typescript
|
|
76
|
+
* const parsed = parseOnly("age=gte.18&order=name.asc");
|
|
77
|
+
* console.log(parsed.filters); // Array of filter conditions
|
|
78
|
+
* console.log(parsed.order); // Array of order terms
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function parseOnly(queryString: string): any;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build a WHERE clause from filter conditions.
|
|
85
|
+
*
|
|
86
|
+
* @param filters - JSON array of filter conditions
|
|
87
|
+
* @returns Object with clause (SQL string) and params (array of values)
|
|
88
|
+
* @throws Error if building the clause fails
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```typescript
|
|
92
|
+
* const filters = [{
|
|
93
|
+
* Filter: {
|
|
94
|
+
* field: { name: "age", json_path: [], cast: null },
|
|
95
|
+
* operator: "Gte",
|
|
96
|
+
* value: { Single: "18" },
|
|
97
|
+
* quantifier: null,
|
|
98
|
+
* language: null,
|
|
99
|
+
* negated: false
|
|
100
|
+
* }
|
|
101
|
+
* }];
|
|
102
|
+
*
|
|
103
|
+
* const result = buildFilterClause(filters);
|
|
104
|
+
* console.log(result.clause); // "age" >= $1
|
|
105
|
+
* console.log(result.params); // ["18"]
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function buildFilterClause(filters: any[]): FilterClauseResult;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Initialize the WASM module. Must be called before using any functions.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```typescript
|
|
115
|
+
* import init, { parseQueryString } from './postgrest_parser.js';
|
|
116
|
+
*
|
|
117
|
+
* await init();
|
|
118
|
+
* const result = parseQueryString("users", "id=eq.1");
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export default function init(input?: RequestInfo | URL): Promise<void>;
|