@truto/sqlite-builder 2.0.2-canary.3 โ†’ 2.0.2-canary.31

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 ADDED
@@ -0,0 +1,900 @@
1
+ # ๐Ÿ—๏ธ truto-sqlite-builder
2
+
3
+ [![npm version](https://badge.fury.io/js/%40truto%2Fsqlite-builder.svg)](https://badge.fury.io/js/%40truto%2Fsqlite-builder)
4
+ [![CI](https://github.com/trutohq/truto-sqlite-builder/actions/workflows/ci.yml/badge.svg)](https://github.com/trutohq/truto-sqlite-builder/actions/workflows/ci.yml)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ **Safe, zero-dependency template-literal tag for SQLite queries in any JS environment.**
9
+
10
+ `@truto/sqlite-builder` provides a secure and ergonomic way to build SQLite queries using tagged template literals. It prevents SQL injection attacks through parameterized queries while offering convenient helper functions for common SQL patterns.
11
+
12
+ ## โœจ Features
13
+
14
+ - ๐Ÿ”’ **Injection-safe**: All values are parameterized, preventing SQL injection
15
+ - ๐Ÿšซ **Defense in depth**: Multiple security layers including stacked query detection
16
+ - ๐Ÿชถ **Zero dependencies**: Pure TypeScript/JavaScript with no runtime dependencies
17
+ - ๐ŸŒ **Universal**: Works in Bun, Node.js, Deno, and modern browsers
18
+ - ๐ŸŽฏ **TypeScript-first**: Full type safety with excellent IDE support
19
+ - ๐Ÿ”ง **Helper functions**: Built-in utilities for identifiers, IN clauses, and more
20
+ - ๐Ÿ” **JSON Filter Language**: MongoDB-style JSON filters for WHERE clauses
21
+ - ๐Ÿ”— **Qualified Filters**: Table/alias scoping for complex JOINs using `$alias` blocks
22
+ - โšก **Lightweight**: Minimal bundle size with tree-shaking support
23
+
24
+ ## ๐Ÿ“ฆ Installation
25
+
26
+ ```bash
27
+ bun add @truto/sqlite-builder
28
+ ```
29
+
30
+ ```bash
31
+ npm install @truto/sqlite-builder
32
+ ```
33
+
34
+ ```bash
35
+ yarn add @truto/sqlite-builder
36
+ ```
37
+
38
+ ```bash
39
+ pnpm add @truto/sqlite-builder
40
+ ```
41
+
42
+ ## ๐Ÿš€ Quick Start
43
+
44
+ ```typescript
45
+ import sqlite3 from 'better-sqlite3'
46
+ import { sql, compileFilter } from '@truto/sqlite-builder'
47
+
48
+ const db = new sqlite3('database.db')
49
+
50
+ // Simple query
51
+ const name = 'Alice'
52
+ const { text, values } = sql`SELECT * FROM users WHERE name = ${name}`
53
+ const users = db.prepare(text).all(...values)
54
+
55
+ // JSON Filter queries
56
+ const filter = {
57
+ name: { like: 'John%' },
58
+ age: { gte: 18, lt: 65 },
59
+ or: [{ email: { regex: '.*@example.com$' } }, { phone: { exists: false } }],
60
+ }
61
+
62
+ // Interpolate the compiled filter directly: its placeholders and values are
63
+ // collected automatically, so query.values is always correctly aligned.
64
+ const query = sql`
65
+ SELECT * FROM users
66
+ WHERE ${compileFilter(filter)}
67
+ `
68
+
69
+ const results = db.prepare(query.text).all(...query.values)
70
+
71
+ // Qualified filters for JOINs with alias blocks
72
+ const joinFilter = {
73
+ status: 'ACTIVE', // Main table
74
+ $profiles: {
75
+ // Profile table alias
76
+ verified: true,
77
+ 'settings.theme': 'dark', // JSON path in profile
78
+ },
79
+ $orders: {
80
+ // Orders table alias
81
+ total: { gt: 100 },
82
+ },
83
+ }
84
+
85
+ const joinQuery = sql`
86
+ SELECT u.name, p.verified, o.total
87
+ FROM users u
88
+ JOIN profiles p ON u.id = p.user_id
89
+ JOIN orders o ON u.id = o.user_id
90
+ WHERE ${compileFilter(joinFilter)}
91
+ `
92
+ ```
93
+
94
+ ## ๐Ÿ“– API Reference
95
+
96
+ ### `sql` Tagged Template
97
+
98
+ The main function for building SQL queries.
99
+
100
+ ```typescript
101
+ const query = sql`SELECT * FROM users WHERE id = ${userId}`
102
+ // Returns: { text: "SELECT * FROM users WHERE id = ?", values: [userId] }
103
+ ```
104
+
105
+ **Parameters:**
106
+
107
+ - Template strings and interpolated values
108
+ - Returns a frozen `SqlQuery` object with `text` and `values` properties
109
+
110
+ ### `sql.ident(identifier: string | readonly (string | SqlFragment)[])`
111
+
112
+ Safely quotes SQL identifiers (table names, column names, etc.). Accepts single identifiers, arrays of identifiers, or mixed arrays containing both identifiers and SQL fragments.
113
+
114
+ ```typescript
115
+ // Single identifier
116
+ const table = 'users'
117
+ const query = sql`SELECT * FROM ${sql.ident(table)}`
118
+ // Returns: { text: 'SELECT * FROM "users"', values: [] }
119
+
120
+ // Qualified identifiers (table.column)
121
+ const qualifiedQuery = sql`SELECT ${sql.ident('u.name')}, ${sql.ident('u.email')} FROM users u`
122
+ // Returns: { text: 'SELECT "u"."name", "u"."email" FROM users u', values: [] }
123
+
124
+ // Array of identifiers (useful for column lists)
125
+ const columns = ['name', 'email', 'created_at']
126
+ const selectQuery = sql`SELECT ${sql.ident(columns)} FROM users`
127
+ // Returns: { text: 'SELECT "name", "email", "created_at" FROM users', values: [] }
128
+
129
+ // Mixed arrays with qualified and simple identifiers
130
+ const mixedColumns = ['u.id', 'name', 'u.email', 'p.title']
131
+ const joinQuery = sql`SELECT ${sql.ident(mixedColumns)} FROM users u JOIN posts p ON u.id = p.user_id`
132
+ // Returns: { text: 'SELECT "u"."id", "name", "u"."email", "p"."title" FROM users u JOIN posts p ON u.id = p.user_id', values: [] }
133
+
134
+ // Mixed arrays with identifiers and SQL fragments
135
+ const mixedColumns = ['id', 'name', sql.raw('UPPER(email) as email_upper')]
136
+ const mixedQuery = sql`SELECT ${sql.ident(mixedColumns)} FROM users`
137
+ // Returns: { text: 'SELECT "id", "name", UPPER(email) as email_upper FROM users', values: [] }
138
+
139
+ // Mixed arrays with parameterized fragments
140
+ const status = 'premium'
141
+ const dynamicColumns = [
142
+ 'id',
143
+ 'name',
144
+ sql`CASE WHEN status = ${status} THEN 'Premium' ELSE 'Regular' END as user_type`,
145
+ ]
146
+ const dynamicQuery = sql`SELECT ${sql.ident(dynamicColumns)} FROM users`
147
+ // Returns: { text: 'SELECT "id", "name", CASE WHEN status = ? THEN \'Premium\' ELSE \'Regular\' END as user_type FROM users', values: ['premium'] }
148
+
149
+ // In INSERT statements
150
+ const insertColumns = ['name', 'email', 'age']
151
+ const insertQuery = sql`
152
+ INSERT INTO users (${sql.ident(insertColumns)})
153
+ VALUES (${name}, ${email}, ${age})
154
+ `
155
+ // Returns: { text: 'INSERT INTO users ("name", "email", "age") VALUES (?, ?, ?)', values: [name, email, age] }
156
+ ```
157
+
158
+ **Security:** Only accepts valid ANSI identifiers (simple: `name`, qualified: `table.column`) for string elements, each capped at 255 characters. SQL fragments are passed through as-is, but **only fragments minted by this library** (e.g. `sql.raw`, nested `sql\`\``) are accepted โ€” a plain `{ text, values }`object (for example from`JSON.parse`) is rejected, so untrusted data can never masquerade as raw SQL.
159
+
160
+ ### `sql.in(array: readonly unknown[])`
161
+
162
+ Creates parameterized IN clauses from arrays.
163
+
164
+ ```typescript
165
+ const ids = [1, 2, 3]
166
+ const query = sql`SELECT * FROM users WHERE id IN ${sql.in(ids)}`
167
+ // Returns: { text: "SELECT * FROM users WHERE id IN (?,?,?)", values: [1, 2, 3] }
168
+ ```
169
+
170
+ **Features:**
171
+
172
+ - Rejects empty arrays (would create invalid SQL)
173
+ - Warns for arrays with >1000 items (performance consideration)
174
+
175
+ ### `sql.raw(rawSql: string)`
176
+
177
+ Embeds raw SQL without parameterization. **โš ๏ธ Use with extreme caution!**
178
+
179
+ ```typescript
180
+ const query = sql`SELECT * FROM users WHERE created_at > ${sql.raw('datetime("now", "-1 day")')}`
181
+ // Returns: { text: 'SELECT * FROM users WHERE created_at > datetime("now", "-1 day")', values: [] }
182
+ ```
183
+
184
+ **โš ๏ธ Warning:** `sql.raw()` is the library's single, explicit trust boundary โ€” its argument becomes SQL verbatim. Never pass user input to it. For dynamic WHERE clauses use `compileFilter()`; for identifiers use `sql.ident()`. As a safety net, the `sql` tag verifies that the number of `?` placeholders in the final query exactly matches the number of bound values, so a raw fragment that smuggles a stray `?` (or forgets to carry its value) throws instead of producing a misaligned query.
185
+
186
+ ### `sql.join(fragments: SqlFragment[], separator?: string | SqlFragment)`
187
+
188
+ Joins multiple SQL fragments with a separator.
189
+
190
+ ```typescript
191
+ const conditions = [
192
+ sql`name = ${'John'}`,
193
+ sql`age = ${30}`,
194
+ sql`active = ${true}`,
195
+ ]
196
+
197
+ const query = sql`SELECT * FROM users WHERE ${sql.join(conditions, ' AND ')}`
198
+ // Returns: { text: "SELECT * FROM users WHERE name = ? AND age = ? AND active = ?", values: ['John', 30, true] }
199
+ ```
200
+
201
+ **Fragments** must be library-minted fragments (forged `{ text, values }` objects are rejected).
202
+
203
+ **Separators** support any structural connector safely:
204
+
205
+ - A **string** separator (e.g. `', '`, `' AND '`, `' UNION ALL '`) is validated to be a pure connector. Separators containing string/identifier quotes (`'`, `"`, `` ` ``, `[`, `]`), statement terminators (`;`), comment markers (`--`, `/*`, `*/`), `NUL`, backslashes, or unbalanced parentheses are rejected โ€” so even an untrusted separator cannot break out of the expression.
206
+ - A **`SqlFragment`** separator (e.g. `sql\` OR weight = ${w} OR \``) lets you parameterize the connector itself; its values are interleaved between fragments automatically.
207
+
208
+ ## ๐Ÿ” JSON Filter Language
209
+
210
+ Build complex WHERE clauses using MongoDB-style JSON filters. Perfect for APIs and dynamic queries.
211
+
212
+ ### `compileFilter(filter: JsonFilter): FilterResult`
213
+
214
+ Compiles a JSON filter object into a parameterized SQL WHERE clause.
215
+
216
+ ```typescript
217
+ import { compileFilter } from '@truto/sqlite-builder'
218
+
219
+ const filter = {
220
+ status: 'ACTIVE',
221
+ age: { gte: 18, lt: 65 },
222
+ }
223
+
224
+ const result = compileFilter(filter)
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.