@truto/sqlite-builder 2.0.1 → 2.0.2-canary.10
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/README.md +0 -675
- package/index.js +1 -0
- package/package.json +1 -88
- package/LICENSE +0 -21
- package/dist/constants.d.ts +0 -10
- package/dist/constants.d.ts.map +0 -1
- package/dist/filter.d.ts +0 -6
- package/dist/filter.d.ts.map +0 -1
- package/dist/fragment.d.ts +0 -14
- package/dist/fragment.d.ts.map +0 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -548
- package/dist/index.js.map +0 -13
- package/dist/sql.d.ts +0 -51
- package/dist/sql.d.ts.map +0 -1
- package/dist/types.d.ts +0 -74
- package/dist/types.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -223,678 +223,3 @@ const filter = {
|
|
|
223
223
|
|
|
224
224
|
const result = compileFilter(filter)
|
|
225
225
|
// Returns a branded SQL fragment: { text: '(("status" = ?) AND ("age" >= ?) AND ("age" < ?))', values: ['ACTIVE', 18, 65] }
|
|
226
|
-
|
|
227
|
-
// Interpolate it directly into a sql template — no sql.raw() needed. The
|
|
228
|
-
// filter's placeholders and values are collected automatically and stay aligned.
|
|
229
|
-
const query = sql`
|
|
230
|
-
SELECT * FROM users
|
|
231
|
-
WHERE ${result}
|
|
232
|
-
`
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
### Supported Operators
|
|
236
|
-
|
|
237
|
-
| Operator Family | JSON Form | SQL Fragment | Description |
|
|
238
|
-
| ------------------------- | ------------------------------------- | ---------------------------------------- | ----------------------------------- |
|
|
239
|
-
| **Equality** | `"field": value` | `"field" = ?` | Direct value comparison |
|
|
240
|
-
| **Inequality** | `"field": { "ne": value }` | `"field" <> ?` | Not equal comparison |
|
|
241
|
-
| **Comparison** | `"field": { "gt": value }` | `"field" > ?` | Greater than, gte, lt, lte |
|
|
242
|
-
| **Set Membership** | `"field": { "in": [1, 2, 3] }` | `"field" IN (?,?,?)` | Value in array |
|
|
243
|
-
| **Negative Set** | `"field": { "nin": [1, 2] }` | `"field" NOT IN (?,?)` | Value not in array |
|
|
244
|
-
| **NULL Checks** | `"field": { "exists": false }` | `"field" IS NULL` | Check for NULL/NOT NULL |
|
|
245
|
-
| **LIKE Patterns** | `"field": { "like": "john%" }` | `"field" LIKE ?` | Pattern matching |
|
|
246
|
-
| **Case-insensitive LIKE** | `"field": { "ilike": "%DOE%" }` | `"field" LIKE ? COLLATE NOCASE` | Case-insensitive patterns |
|
|
247
|
-
| **Regular Expressions** | `"field": { "regex": "^[A-Z]+" }` | `"field" REGEXP ?` | Regex patterns (requires extension) |
|
|
248
|
-
| **Logical AND** | `"and": [filter1, filter2]` | `(filter1 AND filter2)` | All conditions must match |
|
|
249
|
-
| **Logical OR** | `"or": [filter1, filter2]` | `(filter1 OR filter2)` | Any condition must match |
|
|
250
|
-
| **JSON Path** | `"profile.email": "test@example.com"` | `json_extract("profile", '$.email') = ?` | Query JSON column fields |
|
|
251
|
-
| **Alias Blocks** | `"$alias": { "field": value }` | `alias."field" = ?` | Table/alias qualified fields |
|
|
252
|
-
|
|
253
|
-
### Filter Examples
|
|
254
|
-
|
|
255
|
-
#### Basic Operations
|
|
256
|
-
|
|
257
|
-
```typescript
|
|
258
|
-
// Equality and comparison
|
|
259
|
-
const filter1 = {
|
|
260
|
-
status: 'ACTIVE',
|
|
261
|
-
age: { gte: 18, lt: 65 },
|
|
262
|
-
score: { gt: 80, lte: 100 },
|
|
263
|
-
}
|
|
264
|
-
// SQL: (("status" = ? AND "age" >= ? AND "age" < ? AND "score" > ? AND "score" <= ?))
|
|
265
|
-
|
|
266
|
-
// Set membership
|
|
267
|
-
const filter2 = {
|
|
268
|
-
role: { in: ['ADMIN', 'EDITOR'] },
|
|
269
|
-
department: { nin: ['ARCHIVED', 'DELETED'] },
|
|
270
|
-
}
|
|
271
|
-
// SQL: (("role" IN (?,?) AND "department" NOT IN (?,?)))
|
|
272
|
-
|
|
273
|
-
// NULL checks
|
|
274
|
-
const filter3 = {
|
|
275
|
-
email: { exists: true }, // IS NOT NULL
|
|
276
|
-
deleted_at: { exists: false }, // IS NULL
|
|
277
|
-
}
|
|
278
|
-
// SQL: (("email" IS NOT NULL AND "deleted_at" IS NULL))
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
#### Pattern Matching
|
|
282
|
-
|
|
283
|
-
```typescript
|
|
284
|
-
// LIKE patterns
|
|
285
|
-
const filter4 = {
|
|
286
|
-
username: { like: 'john%' }, // Starts with 'john'
|
|
287
|
-
email: { ilike: '%@EXAMPLE.COM%' }, // Case-insensitive contains
|
|
288
|
-
}
|
|
289
|
-
// SQL: (("username" LIKE ? AND "email" LIKE ? COLLATE NOCASE))
|
|
290
|
-
|
|
291
|
-
// Regular expressions (requires REGEXP extension)
|
|
292
|
-
const filter5 = {
|
|
293
|
-
email: { regex: '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$' },
|
|
294
|
-
phone: { regex: '^\\+1[0-9]{10}$' },
|
|
295
|
-
}
|
|
296
|
-
// SQL: (("email" REGEXP ? AND "phone" REGEXP ?))
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
#### Logical Operators
|
|
300
|
-
|
|
301
|
-
```typescript
|
|
302
|
-
// AND operator (explicit)
|
|
303
|
-
const filter6 = {
|
|
304
|
-
and: [
|
|
305
|
-
{ status: 'ACTIVE' },
|
|
306
|
-
{ age: { gte: 18 } },
|
|
307
|
-
{ country: { in: ['US', 'CA', 'GB'] } },
|
|
308
|
-
],
|
|
309
|
-
}
|
|
310
|
-
// SQL: ((("status" = ?) AND ("age" >= ?) AND ("country" IN (?,?,?))))
|
|
311
|
-
|
|
312
|
-
// OR operator
|
|
313
|
-
const filter7 = {
|
|
314
|
-
or: [
|
|
315
|
-
{ age: { lt: 18 } }, // Minors
|
|
316
|
-
{ age: { gte: 65 } }, // Seniors
|
|
317
|
-
],
|
|
318
|
-
}
|
|
319
|
-
// SQL: ((("age" < ?) OR ("age" >= ?)))
|
|
320
|
-
|
|
321
|
-
// Mixed AND/OR logic
|
|
322
|
-
const filter8 = {
|
|
323
|
-
status: 'ACTIVE', // Implicit AND
|
|
324
|
-
or: [{ tags: { ilike: '%urgent%' } }, { priority: { gte: 8 } }],
|
|
325
|
-
}
|
|
326
|
-
// SQL: (((("tags" LIKE ? COLLATE NOCASE) OR ("priority" >= ?)) AND ("status" = ?)))
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
#### JSON Path Querying
|
|
330
|
-
|
|
331
|
-
For SQLite JSON columns, use dot notation to query nested fields:
|
|
332
|
-
|
|
333
|
-
```typescript
|
|
334
|
-
// Query JSON fields
|
|
335
|
-
const filter9 = {
|
|
336
|
-
'profile.email': { regex: '.*@example\\.org$' },
|
|
337
|
-
'profile.age': { gte: 21 },
|
|
338
|
-
'settings.theme': { in: ['dark', 'light'] },
|
|
339
|
-
'metadata.tags': { exists: true },
|
|
340
|
-
}
|
|
341
|
-
// SQL: ((json_extract("profile", '$.email') REGEXP ? AND
|
|
342
|
-
// json_extract("profile", '$.age') >= ? AND
|
|
343
|
-
// json_extract("settings", '$.theme') IN (?,?) AND
|
|
344
|
-
// json_extract("metadata", '$.tags') IS NOT NULL))
|
|
345
|
-
|
|
346
|
-
// Complex nested JSON query
|
|
347
|
-
const filter10 = {
|
|
348
|
-
and: [
|
|
349
|
-
{ 'user.profile.email': { regex: '.*@company\\.com$' } },
|
|
350
|
-
{ or: [{ 'user.role': 'ADMIN' }, { 'user.permissions.canEdit': true }] },
|
|
351
|
-
],
|
|
352
|
-
}
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
#### Qualified Filters for JOINs
|
|
356
|
-
|
|
357
|
-
For complex queries involving multiple tables or aliases, use **alias blocks** with keys starting with `$`:
|
|
358
|
-
|
|
359
|
-
```typescript
|
|
360
|
-
// Basic alias usage
|
|
361
|
-
const joinFilter = {
|
|
362
|
-
// Main table fields (no prefix)
|
|
363
|
-
status: 'ACTIVE',
|
|
364
|
-
age: { gte: 18, lt: 65 },
|
|
365
|
-
|
|
366
|
-
// Table alias 't2'
|
|
367
|
-
$t2: {
|
|
368
|
-
column_in_table_2: { gte: 100 },
|
|
369
|
-
'stats.avg': { lt: 10 }, // JSON path in aliased table
|
|
370
|
-
},
|
|
371
|
-
|
|
372
|
-
// Table alias 'orders'
|
|
373
|
-
$orders: {
|
|
374
|
-
amount: { gt: 500 },
|
|
375
|
-
status: { in: ['completed', 'shipped'] },
|
|
376
|
-
},
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const result = compileFilter(joinFilter)
|
|
380
|
-
// SQL: ((("status" = ?) AND ("age" >= ? AND "age" < ?) AND
|
|
381
|
-
// ((t2."column_in_table_2" >= ?) AND (json_extract(t2."stats", '$.avg') < ?)) AND
|
|
382
|
-
// ((orders."amount" > ?) AND (orders."status" IN (?,?)))))
|
|
383
|
-
```
|
|
384
|
-
|
|
385
|
-
**Alias Block Rules:**
|
|
386
|
-
|
|
387
|
-
- **Alias names**: Must be valid SQL identifiers (`[A-Za-z_][A-Za-z0-9_]*`)
|
|
388
|
-
- **Prefixing**: All fields in alias blocks get prefixed with `alias.`
|
|
389
|
-
- **JSON paths**: Work seamlessly with aliases: `json_extract(alias."column", '$.path')`
|
|
390
|
-
- **Combination**: Alias blocks are AND-combined with root fields and each other
|
|
391
|
-
- **Order**: Regular fields processed first, then alias blocks
|
|
392
|
-
|
|
393
|
-
```typescript
|
|
394
|
-
// Complex alias example with logical operators
|
|
395
|
-
const complexJoinFilter = {
|
|
396
|
-
// Primary table conditions
|
|
397
|
-
user_status: 'ACTIVE',
|
|
398
|
-
|
|
399
|
-
// User profile table
|
|
400
|
-
$profile: {
|
|
401
|
-
verified: true,
|
|
402
|
-
'preferences.notifications': { ne: false },
|
|
403
|
-
or: [{ subscription_type: 'premium' }, { credits: { gte: 100 } }],
|
|
404
|
-
},
|
|
405
|
-
|
|
406
|
-
// Orders table with complex conditions
|
|
407
|
-
$orders: {
|
|
408
|
-
and: [
|
|
409
|
-
{ created_at: { gte: '2024-01-01' } },
|
|
410
|
-
{ or: [{ total_amount: { gt: 1000 } }, { item_count: { gte: 5 } }] },
|
|
411
|
-
],
|
|
412
|
-
},
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Use in JOIN queries
|
|
416
|
-
const query = sql`
|
|
417
|
-
SELECT u.id, u.name, p.subscription_type, o.total_amount
|
|
418
|
-
FROM users u
|
|
419
|
-
JOIN profiles p ON u.id = p.user_id
|
|
420
|
-
JOIN orders o ON u.id = o.user_id
|
|
421
|
-
WHERE ${compileFilter(complexJoinFilter)}
|
|
422
|
-
`
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
**Security & Validation:**
|
|
426
|
-
|
|
427
|
-
```typescript
|
|
428
|
-
// ✅ Valid alias identifiers
|
|
429
|
-
$users: { name: 'John' } // Simple identifier
|
|
430
|
-
$user_profiles: { age: 25 } // Underscore allowed
|
|
431
|
-
$_temp: { status: 'active' } // Starting underscore allowed
|
|
432
|
-
|
|
433
|
-
// ❌ Invalid alias identifiers (will throw SyntaxError)
|
|
434
|
-
$123invalid: { ... } // Cannot start with number
|
|
435
|
-
$'invalid-alias': { ... } // Hyphens not allowed
|
|
436
|
-
$'table.alias': { ... } // Dots not allowed in alias name
|
|
437
|
-
```
|
|
438
|
-
|
|
439
|
-
**Integration with Complex Queries:**
|
|
440
|
-
|
|
441
|
-
```typescript
|
|
442
|
-
// Real-world JOIN example
|
|
443
|
-
const userOrderFilter = {
|
|
444
|
-
// Users table
|
|
445
|
-
active: true,
|
|
446
|
-
email: { exists: true },
|
|
447
|
-
|
|
448
|
-
// User profiles
|
|
449
|
-
$profiles: {
|
|
450
|
-
'settings.email_notifications': true,
|
|
451
|
-
verified_at: { exists: true },
|
|
452
|
-
},
|
|
453
|
-
|
|
454
|
-
// Recent orders
|
|
455
|
-
$recent_orders: {
|
|
456
|
-
created_at: { gte: '2024-01-01' },
|
|
457
|
-
status: { in: ['completed', 'shipped'] },
|
|
458
|
-
total: { gt: 50 },
|
|
459
|
-
},
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const complexQuery = sql`
|
|
463
|
-
SELECT
|
|
464
|
-
u.id,
|
|
465
|
-
u.name,
|
|
466
|
-
u.email,
|
|
467
|
-
p.verified_at,
|
|
468
|
-
COUNT(ro.id) as recent_order_count,
|
|
469
|
-
SUM(ro.total) as recent_order_total
|
|
470
|
-
FROM users u
|
|
471
|
-
JOIN profiles p ON u.id = p.user_id
|
|
472
|
-
JOIN orders ro ON u.id = ro.user_id
|
|
473
|
-
WHERE ${compileFilter(userOrderFilter)}
|
|
474
|
-
GROUP BY u.id, u.name, u.email, p.verified_at
|
|
475
|
-
HAVING recent_order_count > 0
|
|
476
|
-
ORDER BY recent_order_total DESC
|
|
477
|
-
`
|
|
478
|
-
|
|
479
|
-
const results = db.prepare(complexQuery.text).all(...complexQuery.values)
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
#### Kitchen Sink Examples
|
|
483
|
-
|
|
484
|
-
Real-world complex filters:
|
|
485
|
-
|
|
486
|
-
```typescript
|
|
487
|
-
// Active users in specific regions, either minors/seniors or VIP
|
|
488
|
-
const complexFilter = {
|
|
489
|
-
and: [
|
|
490
|
-
{ status: 'ACTIVE' },
|
|
491
|
-
{ or: [{ age: { lt: 18 } }, { age: { gte: 65 } }, { membership: 'VIP' }] },
|
|
492
|
-
{ country: { in: ['US', 'CA', 'GB'] } },
|
|
493
|
-
{ email: { exists: true } },
|
|
494
|
-
{ 'profile.verified': true },
|
|
495
|
-
],
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Content filtering with multiple criteria
|
|
499
|
-
const contentFilter = {
|
|
500
|
-
name: { like: 'Project%' },
|
|
501
|
-
category: { nin: ['ARCHIVED', 'DELETED', 'SPAM'] },
|
|
502
|
-
created_at: { exists: true },
|
|
503
|
-
or: [
|
|
504
|
-
{ tags: { ilike: '%important%' } },
|
|
505
|
-
{ priority: { gte: 8 } },
|
|
506
|
-
{ 'metadata.featured': true },
|
|
507
|
-
],
|
|
508
|
-
}
|
|
509
|
-
```
|
|
510
|
-
|
|
511
|
-
### Integration with SQL Template
|
|
512
|
-
|
|
513
|
-
```typescript
|
|
514
|
-
import { sql, compileFilter } from '@truto/sqlite-builder'
|
|
515
|
-
|
|
516
|
-
// Build the WHERE clause
|
|
517
|
-
const filter = {
|
|
518
|
-
status: 'ACTIVE',
|
|
519
|
-
age: { gte: 18 },
|
|
520
|
-
role: { in: ['USER', 'ADMIN'] },
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// Use in complete query — the filter fragment and the LIMIT value are
|
|
524
|
-
// collected in order, so query.values lines up with the placeholders.
|
|
525
|
-
const query = sql`
|
|
526
|
-
SELECT id, name, email, created_at
|
|
527
|
-
FROM users
|
|
528
|
-
WHERE ${compileFilter(filter)}
|
|
529
|
-
ORDER BY created_at DESC
|
|
530
|
-
LIMIT ${limit}
|
|
531
|
-
`
|
|
532
|
-
|
|
533
|
-
// Execute with driver
|
|
534
|
-
const results = db.prepare(query.text).all(...query.values)
|
|
535
|
-
```
|
|
536
|
-
|
|
537
|
-
### Security & Validation
|
|
538
|
-
|
|
539
|
-
The JSON filter compiler includes comprehensive security measures:
|
|
540
|
-
|
|
541
|
-
- **Operator validation**: Only known operators are allowed
|
|
542
|
-
- **Identifier safety**: Field names are validated using the same rules as `sql.ident()`
|
|
543
|
-
- **Array limits**: IN/NIN arrays limited to 999 items (SQLite limitation)
|
|
544
|
-
- **DoS protection**: Nesting depth ≤ 10, total operators ≤ 100
|
|
545
|
-
- **Type validation**: Strict type checking for all operator values
|
|
546
|
-
- **SQL injection prevention**: All values are parameterized
|
|
547
|
-
|
|
548
|
-
```typescript
|
|
549
|
-
// ❌ These will throw errors
|
|
550
|
-
compileFilter({ age: { unknown: 18 } }) // Unknown operator
|
|
551
|
-
compileFilter({ 'user;--': 'value' }) // Invalid identifier (contains ';')
|
|
552
|
-
compileFilter({ role: { in: [] } }) // Empty array
|
|
553
|
-
compileFilter({ role: { in: new Array(1000).fill('x') } }) // Too large array
|
|
554
|
-
|
|
555
|
-
// ✅ These are safe and valid
|
|
556
|
-
compileFilter({ age: { gte: 18, lte: 65 } }) // Multiple operators
|
|
557
|
-
compileFilter({ 'profile.email': { exists: true } }) // JSON path
|
|
558
|
-
compileFilter({ or: [{ x: 1 }, { y: 2 }] }) // Logical operators
|
|
559
|
-
```
|
|
560
|
-
|
|
561
|
-
### REGEXP Extension
|
|
562
|
-
|
|
563
|
-
To use the `regex` operator, you need to load a REGEXP extension in SQLite:
|
|
564
|
-
|
|
565
|
-
```typescript
|
|
566
|
-
// With better-sqlite3
|
|
567
|
-
import sqlite3 from 'better-sqlite3'
|
|
568
|
-
|
|
569
|
-
const db = new sqlite3('database.db')
|
|
570
|
-
|
|
571
|
-
// Load REGEXP extension (varies by implementation)
|
|
572
|
-
// This is implementation-specific - check your SQLite setup
|
|
573
|
-
db.loadExtension('regexp') // Example - actual method may vary
|
|
574
|
-
|
|
575
|
-
// Now regex filters work
|
|
576
|
-
const filter = {
|
|
577
|
-
email: { regex: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$' },
|
|
578
|
-
}
|
|
579
|
-
```
|
|
580
|
-
|
|
581
|
-
## 🛡️ Security Model
|
|
582
|
-
|
|
583
|
-
### What's Protected
|
|
584
|
-
|
|
585
|
-
- **SQL Injection**: All interpolated values are parameterized
|
|
586
|
-
- **Unforgeable fragments**: Only fragments created by this library can contribute raw SQL text. A plain `{ text, values }` object (e.g. from `JSON.parse` or a request body) is treated as a value, never as SQL, closing the structural duck-typing bypass
|
|
587
|
-
- **Placeholder integrity**: The `sql` tag rejects any query whose `?` count does not match its bound-value count, catching raw fragments that smuggle or drop placeholders
|
|
588
|
-
- **Safe `sql.join()` separators**: String separators are validated so they cannot introduce string literals, comments, statement terminators, or unbalanced parentheses; use a `SqlFragment` separator to parameterize the connector itself
|
|
589
|
-
- **Stacked Queries**: Queries containing `;` followed by additional SQL are rejected (detection ignores semicolons inside string literals and comments)
|
|
590
|
-
- **Identifier Safety**: `sql.ident()` validates against ANSI identifier rules and caps each part at 255 characters
|
|
591
|
-
- **Length Limits**: Queries exceeding 100KB are rejected; `compileFilter()` enforces the same cap on its output
|
|
592
|
-
- **Pattern Limits**: `like`/`ilike`/`regex` patterns are capped at 1024 characters to bound matching cost at the SQLite layer
|
|
593
|
-
- **Filter Security**: JSON filters validate operators, identifiers, and enforce limits
|
|
594
|
-
|
|
595
|
-
### What's Your Responsibility
|
|
596
|
-
|
|
597
|
-
- **Never use `sql.raw()` with user input**
|
|
598
|
-
- **Validate identifiers before using `sql.ident()`** (though it has built-in validation)
|
|
599
|
-
- **Use `sql.in()` instead of string concatenation** for arrays
|
|
600
|
-
- **Keep your SQLite driver updated**
|
|
601
|
-
- **Load REGEXP extension safely** if using regex filters
|
|
602
|
-
|
|
603
|
-
### Supported Value Types
|
|
604
|
-
|
|
605
|
-
```typescript
|
|
606
|
-
// ✅ Safe types (automatically parameterized)
|
|
607
|
-
const query = sql`
|
|
608
|
-
INSERT INTO users (name, age, active, created_at, data, deleted_at)
|
|
609
|
-
VALUES (
|
|
610
|
-
${'John'}, // string
|
|
611
|
-
${30}, // number
|
|
612
|
-
${true}, // boolean
|
|
613
|
-
${new Date()}, // Date → 'YYYY-MM-DD HH:MM:SS'
|
|
614
|
-
${null}, // null
|
|
615
|
-
${undefined} // undefined → null
|
|
616
|
-
)
|
|
617
|
-
`
|
|
618
|
-
|
|
619
|
-
// ❌ Unsafe types (will throw TypeError)
|
|
620
|
-
sql`SELECT * FROM users WHERE data = ${Buffer.from('test')}` // Use sql.raw() for buffers
|
|
621
|
-
sql`SELECT * FROM users WHERE id = ${Symbol('test')}` // Unsupported type
|
|
622
|
-
```
|
|
623
|
-
|
|
624
|
-
## 📋 Examples
|
|
625
|
-
|
|
626
|
-
### Basic CRUD Operations
|
|
627
|
-
|
|
628
|
-
```typescript
|
|
629
|
-
import { sql } from '@truto/sqlite-builder'
|
|
630
|
-
|
|
631
|
-
// CREATE with array identifiers
|
|
632
|
-
const insertColumns = ['name', 'email', 'age']
|
|
633
|
-
const insertUser = sql`
|
|
634
|
-
INSERT INTO users (${sql.ident(insertColumns)})
|
|
635
|
-
VALUES (${name}, ${email}, ${age})
|
|
636
|
-
`
|
|
637
|
-
|
|
638
|
-
// READ with specific columns
|
|
639
|
-
const selectColumns = ['id', 'name', 'email', 'created_at']
|
|
640
|
-
const getUser = sql`
|
|
641
|
-
SELECT ${sql.ident(selectColumns)} FROM users
|
|
642
|
-
WHERE id = ${userId}
|
|
643
|
-
`
|
|
644
|
-
|
|
645
|
-
// UPDATE
|
|
646
|
-
const updateUser = sql`
|
|
647
|
-
UPDATE users
|
|
648
|
-
SET name = ${newName}, updated_at = ${new Date()}
|
|
649
|
-
WHERE id = ${userId}
|
|
650
|
-
`
|
|
651
|
-
|
|
652
|
-
// DELETE
|
|
653
|
-
const deleteUser = sql`
|
|
654
|
-
DELETE FROM users
|
|
655
|
-
WHERE id = ${userId}
|
|
656
|
-
`
|
|
657
|
-
```
|
|
658
|
-
|
|
659
|
-
### Dynamic Queries
|
|
660
|
-
|
|
661
|
-
```typescript
|
|
662
|
-
// Dynamic WHERE conditions
|
|
663
|
-
const filters = []
|
|
664
|
-
if (name) filters.push(sql`name = ${name}`)
|
|
665
|
-
if (minAge) filters.push(sql`age >= ${minAge}`)
|
|
666
|
-
if (isActive !== undefined) filters.push(sql`active = ${isActive}`)
|
|
667
|
-
|
|
668
|
-
const whereClause =
|
|
669
|
-
filters.length > 0 ? sql.join(filters, ' AND ') : sql.raw('1=1')
|
|
670
|
-
|
|
671
|
-
const query = sql`
|
|
672
|
-
SELECT * FROM users
|
|
673
|
-
WHERE ${whereClause}
|
|
674
|
-
ORDER BY created_at DESC
|
|
675
|
-
`
|
|
676
|
-
|
|
677
|
-
// Dynamic column selection (simplified with array support)
|
|
678
|
-
const columns = ['id', 'name', 'email']
|
|
679
|
-
const selectQuery = sql`SELECT ${sql.ident(columns)} FROM users`
|
|
680
|
-
|
|
681
|
-
// Alternative approach for more complex column expressions
|
|
682
|
-
const complexColumns = [
|
|
683
|
-
sql.ident('id'),
|
|
684
|
-
sql.ident('name'),
|
|
685
|
-
sql.raw('UPPER(email) as email_upper'),
|
|
686
|
-
]
|
|
687
|
-
const complexQuery = sql`SELECT ${sql.join(complexColumns)} FROM users`
|
|
688
|
-
```
|
|
689
|
-
|
|
690
|
-
### Dynamic Queries with JSON Filters
|
|
691
|
-
|
|
692
|
-
```typescript
|
|
693
|
-
import { sql, compileFilter } from '@truto/sqlite-builder'
|
|
694
|
-
|
|
695
|
-
// API endpoint that accepts JSON filter
|
|
696
|
-
app.get('/api/users', (req, res) => {
|
|
697
|
-
// User sends filter as JSON
|
|
698
|
-
const filter = req.body.filter || {}
|
|
699
|
-
|
|
700
|
-
// Safely compile to SQL and interpolate directly. Filter values and the
|
|
701
|
-
// LIMIT value are collected in order into query.values.
|
|
702
|
-
const query = sql`
|
|
703
|
-
SELECT id, name, email, created_at
|
|
704
|
-
FROM users
|
|
705
|
-
WHERE ${compileFilter(filter)}
|
|
706
|
-
ORDER BY created_at DESC
|
|
707
|
-
LIMIT ${req.query.limit || 20}
|
|
708
|
-
`
|
|
709
|
-
|
|
710
|
-
const users = db.prepare(query.text).all(...query.values)
|
|
711
|
-
res.json(users)
|
|
712
|
-
})
|
|
713
|
-
|
|
714
|
-
// Example API calls:
|
|
715
|
-
// POST /api/users { "filter": { "status": "ACTIVE", "age": { "gte": 18 } } }
|
|
716
|
-
// POST /api/users { "filter": { "or": [{ "role": "ADMIN" }, { "verified": true }] } }
|
|
717
|
-
```
|
|
718
|
-
|
|
719
|
-
### Array Identifiers & Qualified Identifiers
|
|
720
|
-
|
|
721
|
-
The `sql.ident()` function supports simple identifiers, qualified identifiers (table.column), arrays, and mixed arrays with SQL fragments:
|
|
722
|
-
|
|
723
|
-
```typescript
|
|
724
|
-
// ✅ Simple identifiers
|
|
725
|
-
const table = 'users'
|
|
726
|
-
const column = 'name'
|
|
727
|
-
const simpleQuery = sql`SELECT ${sql.ident(column)} FROM ${sql.ident(table)}`
|
|
728
|
-
// Result: SELECT "name" FROM "users"
|
|
729
|
-
|
|
730
|
-
// ✅ Qualified identifiers (table.column)
|
|
731
|
-
const qualifiedQuery = sql`SELECT ${sql.ident('u.name')}, ${sql.ident('p.title')} FROM users u JOIN posts p ON u.id = p.user_id`
|
|
732
|
-
// Result: SELECT "u"."name", "p"."title" FROM users u JOIN posts p ON u.id = p.user_id
|
|
733
|
-
|
|
734
|
-
// ✅ Pure identifier arrays (clean and concise)
|
|
735
|
-
const columns = ['id', 'name', 'email', 'created_at']
|
|
736
|
-
const arrayQuery = sql`SELECT ${sql.ident(columns)} FROM users`
|
|
737
|
-
// Result: SELECT "id", "name", "email", "created_at" FROM users
|
|
738
|
-
|
|
739
|
-
// ✅ Mixed qualified and simple identifiers in arrays
|
|
740
|
-
const mixedColumns = ['u.id', 'name', 'u.email', 'p.title', 'created_at']
|
|
741
|
-
const mixedQuery = sql`SELECT ${sql.ident(mixedColumns)} FROM users u LEFT JOIN posts p ON u.id = p.user_id`
|
|
742
|
-
// Result: SELECT "u"."id", "name", "u"."email", "p"."title", "created_at" FROM users u LEFT JOIN posts p ON u.id = p.user_id
|
|
743
|
-
|
|
744
|
-
// ✅ NEW: Mixed arrays with identifiers and SQL fragments
|
|
745
|
-
const mixedColumns = [
|
|
746
|
-
'id',
|
|
747
|
-
'name',
|
|
748
|
-
sql.raw('UPPER(email) as email_upper'),
|
|
749
|
-
sql.raw('COUNT(*) as total'),
|
|
750
|
-
]
|
|
751
|
-
const mixedQuery = sql`SELECT ${sql.ident(mixedColumns)} FROM users GROUP BY id, name, email`
|
|
752
|
-
// Result: SELECT "id", "name", UPPER(email) as email_upper, COUNT(*) as total FROM users GROUP BY id, name, email
|
|
753
|
-
|
|
754
|
-
// ✅ Mixed arrays with parameterized fragments
|
|
755
|
-
const status = 'premium'
|
|
756
|
-
const dynamicColumns = [
|
|
757
|
-
'id',
|
|
758
|
-
'name',
|
|
759
|
-
sql`CASE WHEN status = ${status} THEN 'Premium User' ELSE 'Regular User' END as user_type`,
|
|
760
|
-
sql.raw('created_at'),
|
|
761
|
-
]
|
|
762
|
-
const parameterizedQuery = sql`SELECT ${sql.ident(dynamicColumns)} FROM users WHERE active = ${true}`
|
|
763
|
-
// Combines identifiers, raw SQL, and parameterized values seamlessly
|
|
764
|
-
|
|
765
|
-
// ✅ Works great for INSERT statements
|
|
766
|
-
const insertData = { name: 'John', email: 'john@example.com', age: 30 }
|
|
767
|
-
const insertColumns = Object.keys(insertData)
|
|
768
|
-
const insertValues = Object.values(insertData)
|
|
769
|
-
const insertQuery = sql`
|
|
770
|
-
INSERT INTO users (${sql.ident(insertColumns)})
|
|
771
|
-
VALUES (${insertValues[0]}, ${insertValues[1]}, ${insertValues[2]})
|
|
772
|
-
`
|
|
773
|
-
|
|
774
|
-
// ✅ Dynamic column selection
|
|
775
|
-
const userFields = ['name', 'email']
|
|
776
|
-
const includeTimestamps = true
|
|
777
|
-
if (includeTimestamps) {
|
|
778
|
-
userFields.push('created_at', 'updated_at')
|
|
779
|
-
}
|
|
780
|
-
const dynamicQuery = sql`SELECT ${sql.ident(userFields)} FROM users`
|
|
781
|
-
|
|
782
|
-
// 🆚 OLD: Manual joining approach (still works, but much more verbose)
|
|
783
|
-
const oldWay = sql`SELECT ${sql.join([
|
|
784
|
-
sql.ident('id'),
|
|
785
|
-
sql.ident('name'),
|
|
786
|
-
sql.raw('UPPER(email) as email_upper'),
|
|
787
|
-
])} FROM users`
|
|
788
|
-
```
|
|
789
|
-
|
|
790
|
-
### Complex Joins
|
|
791
|
-
|
|
792
|
-
```typescript
|
|
793
|
-
const getUsersWithPosts = sql`
|
|
794
|
-
SELECT
|
|
795
|
-
${sql.ident('u')}.id,
|
|
796
|
-
${sql.ident('u')}.name,
|
|
797
|
-
COUNT(${sql.ident('p')}.id) as post_count
|
|
798
|
-
FROM ${sql.ident('users')} u
|
|
799
|
-
LEFT JOIN ${sql.ident('posts')} p ON u.id = p.user_id
|
|
800
|
-
WHERE u.created_at > ${startDate}
|
|
801
|
-
AND u.status IN ${sql.in(['active', 'premium'])}
|
|
802
|
-
GROUP BY u.id
|
|
803
|
-
HAVING post_count > ${minPosts}
|
|
804
|
-
ORDER BY post_count DESC
|
|
805
|
-
LIMIT ${limit}
|
|
806
|
-
`
|
|
807
|
-
|
|
808
|
-
// For simpler cases, you can use array identifiers directly
|
|
809
|
-
const getUsers = sql`
|
|
810
|
-
SELECT ${sql.ident(['id', 'name', 'email', 'created_at'])}
|
|
811
|
-
FROM ${sql.ident('users')}
|
|
812
|
-
WHERE status = ${'active'}
|
|
813
|
-
`
|
|
814
|
-
```
|
|
815
|
-
|
|
816
|
-
### Transactions
|
|
817
|
-
|
|
818
|
-
```typescript
|
|
819
|
-
// Works great with better-sqlite3 transactions
|
|
820
|
-
const insertUsers = db.transaction((users) => {
|
|
821
|
-
const stmt = db.prepare(
|
|
822
|
-
sql`
|
|
823
|
-
INSERT INTO users (name, email)
|
|
824
|
-
VALUES (?, ?)
|
|
825
|
-
`.text,
|
|
826
|
-
)
|
|
827
|
-
|
|
828
|
-
for (const user of users) {
|
|
829
|
-
const { values } = sql`${user.name}, ${user.email}`
|
|
830
|
-
stmt.run(...values)
|
|
831
|
-
}
|
|
832
|
-
})
|
|
833
|
-
|
|
834
|
-
insertUsers([
|
|
835
|
-
{ name: 'Alice', email: 'alice@example.com' },
|
|
836
|
-
{ name: 'Bob', email: 'bob@example.com' },
|
|
837
|
-
])
|
|
838
|
-
```
|
|
839
|
-
|
|
840
|
-
## 🧪 Testing
|
|
841
|
-
|
|
842
|
-
```bash
|
|
843
|
-
# Run tests
|
|
844
|
-
bun run test
|
|
845
|
-
|
|
846
|
-
# Run tests in watch mode
|
|
847
|
-
bun run dev
|
|
848
|
-
|
|
849
|
-
# Run tests with coverage
|
|
850
|
-
bun run test:coverage
|
|
851
|
-
|
|
852
|
-
# Run tests with UI
|
|
853
|
-
bun run test:ui
|
|
854
|
-
```
|
|
855
|
-
|
|
856
|
-
## 🤝 Contributing
|
|
857
|
-
|
|
858
|
-
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
|
859
|
-
|
|
860
|
-
### Development Setup
|
|
861
|
-
|
|
862
|
-
```bash
|
|
863
|
-
git clone https://github.com/truto/truto-sqlite-builder.git
|
|
864
|
-
cd truto-sqlite-builder
|
|
865
|
-
bun install
|
|
866
|
-
bun run dev # Start tests in watch mode
|
|
867
|
-
```
|
|
868
|
-
|
|
869
|
-
### Release Process
|
|
870
|
-
|
|
871
|
-
We use [changesets](https://github.com/changesets/changesets) for version management:
|
|
872
|
-
|
|
873
|
-
```bash
|
|
874
|
-
# Add a changeset
|
|
875
|
-
bunx changeset
|
|
876
|
-
|
|
877
|
-
# Release
|
|
878
|
-
bunx changeset version
|
|
879
|
-
bun run build
|
|
880
|
-
git commit -am "Release"
|
|
881
|
-
git push --follow-tags
|
|
882
|
-
```
|
|
883
|
-
|
|
884
|
-
## 🔒 Security Policy
|
|
885
|
-
|
|
886
|
-
If you discover a security vulnerability, please email eng@truto.one or create an issue.
|
|
887
|
-
|
|
888
|
-
## 📄 License
|
|
889
|
-
|
|
890
|
-
MIT © [Truto](https://github.com/trutohq)
|
|
891
|
-
|
|
892
|
-
## 💡 Inspiration
|
|
893
|
-
|
|
894
|
-
This library was inspired by:
|
|
895
|
-
|
|
896
|
-
- [sql-template-strings](https://github.com/felixfbecker/sql-template-strings)
|
|
897
|
-
- [slonik](https://github.com/gajus/slonik)
|
|
898
|
-
- [Postgres.js](https://github.com/porsager/postgres)
|
|
899
|
-
|
|
900
|
-
Built with ❤️ for the SQLite community.
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = {};
|