@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 +900 -0
- package/index.js +1 -0
- package/package.json +1 -1
- 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
ADDED
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
# ๐๏ธ truto-sqlite-builder
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/js/%40truto%2Fsqlite-builder)
|
|
4
|
+
[](https://github.com/trutohq/truto-sqlite-builder/actions/workflows/ci.yml)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+
[](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.
|