@dockstat/sqlite-wrapper 1.3.3 → 1.3.5
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 +114 -8
- package/package.json +1 -1
- package/src/index.ts +68 -4
- package/src/migration.ts +375 -0
- package/src/query-builder/select.ts +72 -25
- package/src/query-builder/where.ts +134 -3
- package/src/types.ts +16 -3
- package/src/utils/index.ts +2 -0
- package/src/utils/truncate.ts +6 -0
package/README.md
CHANGED
|
@@ -9,26 +9,31 @@ Schema-first table helpers, an expressive chainable QueryBuilder, safe defaults
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
## 🆕 What's New in v1.3
|
|
13
|
-
|
|
14
|
-
### Bug Fixes
|
|
15
|
-
- **Fixed Boolean parsing** — Boolean columns now correctly convert SQLite's `0`/`1` to JavaScript `true`/`false`
|
|
16
|
-
- **Fixed Wrong packing** — Before the `publish` script was added, workspace dependencies were not correctly propagated
|
|
12
|
+
## 🆕 What's New in v1.3.5
|
|
17
13
|
|
|
18
14
|
### New Features
|
|
15
|
+
|
|
16
|
+
- **Automatic Schema Migration** — Tables automatically migrate when schema changes are detected! Add/remove columns, change constraints, and preserve data without manual intervention
|
|
19
17
|
- **Auto-detection of JSON & Boolean columns** — No more manual parser configuration! Columns using `column.json()` or `column.boolean()` are automatically detected from schema
|
|
20
18
|
- **Automatic backups with retention** — Configure `autoBackup` to create periodic backups with automatic cleanup of old files
|
|
21
19
|
- **Backup & Restore API** — New `backup()`, `restore()`, and `listBackups()` methods
|
|
22
20
|
- **`getPath()` method** — Get the database file path
|
|
23
21
|
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
- **Fixed Boolean parsing** — Boolean columns now correctly convert SQLite's `0`/`1` to JavaScript `true`/`false`
|
|
25
|
+
- **Fixed Wrong packing** — Before the `publish` script was added, workspace dependencies were not correctly propagated
|
|
26
|
+
|
|
24
27
|
### Architecture Improvements
|
|
28
|
+
|
|
25
29
|
- **New `utils/` module** — Reusable utilities for SQL building, logging, and row transformation
|
|
26
30
|
- **Structured logging** — Cleaner, more consistent log output with dedicated loggers per component
|
|
27
31
|
- **Reduced code duplication** — Extracted common patterns into shared utilities
|
|
28
32
|
- **Better maintainability** — Clearer separation of concerns across modules
|
|
29
33
|
|
|
30
34
|
### Breaking Changes
|
|
31
|
-
|
|
35
|
+
|
|
36
|
+
- None! v1.4 is fully backward compatible with v1.3.x
|
|
32
37
|
|
|
33
38
|
---
|
|
34
39
|
|
|
@@ -96,6 +101,7 @@ const users = userTable
|
|
|
96
101
|
- 🚀 Designed for production workflows: WAL, pragmatic PRAGMAs, bulk ops, transactions
|
|
97
102
|
- 🔄 **Automatic JSON/Boolean detection** — no manual parser configuration needed
|
|
98
103
|
- 💾 **Built-in backup & restore** — with automatic retention policies
|
|
104
|
+
- 🔀 **Automatic Schema Migration** — seamlessly migrate tables when schemas change
|
|
99
105
|
|
|
100
106
|
---
|
|
101
107
|
|
|
@@ -217,6 +223,100 @@ const db = new DB("app.db", {
|
|
|
217
223
|
|
|
218
224
|
---
|
|
219
225
|
|
|
226
|
+
## Automatic Schema Migration
|
|
227
|
+
|
|
228
|
+
When you call `createTable()` with a schema that differs from an existing table, the wrapper automatically:
|
|
229
|
+
|
|
230
|
+
1. **Detects schema changes** — Compares existing columns with your new definition
|
|
231
|
+
2. **Migrates the table** — Creates a temporary table, copies data, and swaps tables
|
|
232
|
+
3. **Preserves data** — Maps columns by name, uses defaults for new columns
|
|
233
|
+
4. **Maintains indexes & triggers** — Recreates them after migration
|
|
234
|
+
|
|
235
|
+
### Basic Migration Example
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
// Initial table creation
|
|
239
|
+
const users = db.createTable("users", {
|
|
240
|
+
id: column.id(),
|
|
241
|
+
name: column.text({ notNull: true }),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
users.insert({ name: "Alice" });
|
|
245
|
+
|
|
246
|
+
// Later: Add email column (automatic migration!)
|
|
247
|
+
const updatedUsers = db.createTable("users", {
|
|
248
|
+
id: column.id(),
|
|
249
|
+
name: column.text({ notNull: true }),
|
|
250
|
+
email: column.text({ unique: true }), // New column
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Data is preserved, new column uses NULL or default
|
|
254
|
+
const user = updatedUsers.where({ name: "Alice" }).first();
|
|
255
|
+
console.log(user); // { id: 1, name: "Alice", email: null }
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Migration Options
|
|
259
|
+
|
|
260
|
+
Control migration behavior with the `migrate` option:
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
db.createTable("users", schema, {
|
|
264
|
+
migrate: true, // Default: enable migration
|
|
265
|
+
// OR provide detailed options:
|
|
266
|
+
migrate: {
|
|
267
|
+
preserveData: true, // Copy existing data (default: true)
|
|
268
|
+
dropMissingColumns: true, // Remove columns not in new schema (default: true)
|
|
269
|
+
onConflict: "fail", // How to handle constraint violations: "fail" | "ignore" | "replace"
|
|
270
|
+
tempTableSuffix: "_temp", // Suffix for temporary table during migration
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Disable Migration
|
|
276
|
+
|
|
277
|
+
For cases where you want to ensure a table matches an exact schema without migration:
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
db.createTable("users", schema, {
|
|
281
|
+
migrate: false, // Disable migration
|
|
282
|
+
ifNotExists: true, // Avoid errors if table exists
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Migration Features
|
|
287
|
+
|
|
288
|
+
- **Automatic column mapping** — Columns with matching names are preserved
|
|
289
|
+
- **Type conversions** — Best-effort conversion between compatible types
|
|
290
|
+
- **Constraint handling** — Adds/removes constraints as needed
|
|
291
|
+
- **Index preservation** — Indexes are recreated after migration
|
|
292
|
+
- **Trigger preservation** — Triggers are recreated after migration
|
|
293
|
+
- **Foreign key support** — Handles foreign key constraints properly
|
|
294
|
+
- **Transaction safety** — Migration runs in a transaction, rolls back on error
|
|
295
|
+
|
|
296
|
+
### When Migration Occurs
|
|
297
|
+
|
|
298
|
+
Migration is triggered when `createTable()` detects:
|
|
299
|
+
|
|
300
|
+
- **Column additions** — New columns in schema
|
|
301
|
+
- **Column removals** — Missing columns from schema
|
|
302
|
+
- **Constraint changes** — Different NOT NULL, UNIQUE, etc.
|
|
303
|
+
- **Type changes** — Different column types
|
|
304
|
+
|
|
305
|
+
### Migration Limitations
|
|
306
|
+
|
|
307
|
+
- **Data loss possible** — Removing columns or adding NOT NULL without defaults
|
|
308
|
+
- **Type incompatibility** — Some type conversions may fail
|
|
309
|
+
- **Performance** — Large tables take time to migrate
|
|
310
|
+
- **Downtime** — Table is briefly locked during migration
|
|
311
|
+
|
|
312
|
+
For production systems with large tables, consider:
|
|
313
|
+
|
|
314
|
+
- Running migrations during maintenance windows
|
|
315
|
+
- Testing migrations on a copy first
|
|
316
|
+
- Using `migrate: false` and handling manually for critical tables
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
220
320
|
## Manual Backup & Restore
|
|
221
321
|
|
|
222
322
|
### Creating Backups
|
|
@@ -284,7 +384,10 @@ const activeAdmins = userTable
|
|
|
284
384
|
.all();
|
|
285
385
|
|
|
286
386
|
// Get first match
|
|
287
|
-
const user = userTable
|
|
387
|
+
const user = userTable
|
|
388
|
+
.select(["*"])
|
|
389
|
+
.where({ email: "alice@example.com" })
|
|
390
|
+
.first();
|
|
288
391
|
|
|
289
392
|
// Count records
|
|
290
393
|
const count = userTable.where({ active: true }).count();
|
|
@@ -321,7 +424,10 @@ userTable.insertOrIgnore({ email: "existing@example.com", name: "Name" });
|
|
|
321
424
|
userTable.insertOrReplace({ email: "existing@example.com", name: "New Name" });
|
|
322
425
|
|
|
323
426
|
// Insert and get the row back
|
|
324
|
-
const newUser = userTable.insertAndGet({
|
|
427
|
+
const newUser = userTable.insertAndGet({
|
|
428
|
+
name: "Charlie",
|
|
429
|
+
email: "charlie@example.com",
|
|
430
|
+
});
|
|
325
431
|
```
|
|
326
432
|
|
|
327
433
|
### UPDATE Operations
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Database, type SQLQueryBindings } from "bun:sqlite"
|
|
2
2
|
import { Logger } from "@dockstat/logger"
|
|
3
|
-
import type { ColumnDefinition, IndexColumn, IndexMethod, Parser, TableOptions } from "./types"
|
|
4
3
|
import { backup as helperBackup } from "./lib/backup/backup"
|
|
4
|
+
import { listBackups as helperListBackups } from "./lib/backup/listBackups"
|
|
5
|
+
import { restore as helperRestore } from "./lib/backup/restore"
|
|
5
6
|
import { setupAutoBackup as helperSetupAutoBackup } from "./lib/backup/setupAutoBackup"
|
|
6
|
-
|
|
7
7
|
// helpers
|
|
8
8
|
import { createIndex as helperCreateIndex } from "./lib/index/createIndex"
|
|
9
9
|
import { dropIndex as helperDropIndex } from "./lib/index/dropIndex"
|
|
@@ -12,10 +12,17 @@ import { buildTableConstraints } from "./lib/table/buildTableConstraint"
|
|
|
12
12
|
import { getTableComment as helperGetTableComment } from "./lib/table/getTableComment"
|
|
13
13
|
import { isTableSchema } from "./lib/table/isTableSchema"
|
|
14
14
|
import { setTableComment as helperSetTableComment } from "./lib/table/setTableComment"
|
|
15
|
+
import { checkAndMigrate, tableExists } from "./migration"
|
|
15
16
|
import { QueryBuilder } from "./query-builder/index"
|
|
17
|
+
import type {
|
|
18
|
+
ColumnDefinition,
|
|
19
|
+
IndexColumn,
|
|
20
|
+
IndexMethod,
|
|
21
|
+
Parser,
|
|
22
|
+
TableOptions,
|
|
23
|
+
MigrationOptions,
|
|
24
|
+
} from "./types"
|
|
16
25
|
import { createLogger, type SqliteLogger } from "./utils"
|
|
17
|
-
import { listBackups as helperListBackups } from "./lib/backup/listBackups"
|
|
18
|
-
import { restore as helperRestore } from "./lib/backup/restore"
|
|
19
26
|
|
|
20
27
|
// Re-export all types and utilities
|
|
21
28
|
export { QueryBuilder }
|
|
@@ -29,6 +36,7 @@ export type {
|
|
|
29
36
|
ForeignKeyAction,
|
|
30
37
|
InsertOptions,
|
|
31
38
|
InsertResult,
|
|
39
|
+
MigrationOptions,
|
|
32
40
|
RegexCondition,
|
|
33
41
|
SQLiteType,
|
|
34
42
|
TableConstraints,
|
|
@@ -87,6 +95,7 @@ class DB {
|
|
|
87
95
|
private dbLog: SqliteLogger
|
|
88
96
|
private backupLog: SqliteLogger
|
|
89
97
|
private tableLog: SqliteLogger
|
|
98
|
+
private migrationLog: SqliteLogger
|
|
90
99
|
|
|
91
100
|
/**
|
|
92
101
|
* Open or create a SQLite database at `path`.
|
|
@@ -105,6 +114,7 @@ class DB {
|
|
|
105
114
|
this.dbLog = createLogger("DB", this.baseLogger)
|
|
106
115
|
this.backupLog = createLogger("Backup", this.baseLogger)
|
|
107
116
|
this.tableLog = createLogger("Table", this.baseLogger)
|
|
117
|
+
this.migrationLog = createLogger("Migration", this.baseLogger)
|
|
108
118
|
|
|
109
119
|
this.dbLog.connection(path, "open")
|
|
110
120
|
|
|
@@ -224,6 +234,49 @@ class DB {
|
|
|
224
234
|
columns: Record<keyof _T, ColumnDefinition>,
|
|
225
235
|
options?: TableOptions<_T>
|
|
226
236
|
): QueryBuilder<_T> {
|
|
237
|
+
// Handle migration if enabled (default: true)
|
|
238
|
+
const migrateOption = options?.migrate !== undefined ? options.migrate : true
|
|
239
|
+
|
|
240
|
+
// Check if table already exists
|
|
241
|
+
const tableAlreadyExists =
|
|
242
|
+
!options?.temporary && tableName !== ":memory:" && tableExists(this.db, tableName)
|
|
243
|
+
|
|
244
|
+
if (tableAlreadyExists) {
|
|
245
|
+
if (migrateOption !== false) {
|
|
246
|
+
// Migration is enabled, check if we need to migrate
|
|
247
|
+
const migrationOptions: MigrationOptions =
|
|
248
|
+
typeof migrateOption === "object" ? migrateOption : {}
|
|
249
|
+
|
|
250
|
+
// Build table constraints to pass to migration
|
|
251
|
+
let tableConstraints: string[] = []
|
|
252
|
+
if (isTableSchema(columns) && options?.constraints) {
|
|
253
|
+
tableConstraints = buildTableConstraints(options.constraints)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const migrated = checkAndMigrate(
|
|
257
|
+
this.db,
|
|
258
|
+
tableName,
|
|
259
|
+
columns as Record<string, ColumnDefinition>,
|
|
260
|
+
this.migrationLog,
|
|
261
|
+
migrationOptions,
|
|
262
|
+
tableConstraints
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if (migrated) {
|
|
266
|
+
// Table was migrated, skip creation and go directly to parser setup
|
|
267
|
+
return this._setupTableParser(tableName, columns, options)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Table exists and either:
|
|
272
|
+
// 1. Migration is disabled, or
|
|
273
|
+
// 2. Migration is enabled but no changes were needed
|
|
274
|
+
// In both cases, return the existing table without trying to create it
|
|
275
|
+
if (!options?.ifNotExists) {
|
|
276
|
+
return this._setupTableParser(tableName, columns, options)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
227
280
|
const temp = options?.temporary ? "TEMPORARY " : tableName === ":memory" ? "TEMPORARY " : ""
|
|
228
281
|
const ifNot = options?.ifNotExists ? "IF NOT EXISTS " : ""
|
|
229
282
|
const withoutRowId = options?.withoutRowId ? " WITHOUT ROWID" : ""
|
|
@@ -288,6 +341,17 @@ class DB {
|
|
|
288
341
|
helperSetTableComment(this.db, this.tableLog, tableName, options.comment)
|
|
289
342
|
}
|
|
290
343
|
|
|
344
|
+
return this._setupTableParser(tableName, columns, options)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Setup parser for table (extracted for reuse after migration)
|
|
349
|
+
*/
|
|
350
|
+
private _setupTableParser<_T extends Record<string, unknown> = Record<string, unknown>>(
|
|
351
|
+
tableName: string,
|
|
352
|
+
columns: Record<keyof _T, ColumnDefinition>,
|
|
353
|
+
options?: TableOptions<_T>
|
|
354
|
+
): QueryBuilder<_T> {
|
|
291
355
|
// Auto-detect JSON and BOOLEAN columns from schema
|
|
292
356
|
const autoDetectedJson: Array<keyof _T> = []
|
|
293
357
|
const autoDetectedBoolean: Array<keyof _T> = []
|
package/src/migration.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import type { ColumnDefinition, MigrationOptions } from "./types"
|
|
3
|
+
import { buildColumnSQL } from "./lib/table/buildColumnSQL"
|
|
4
|
+
import { SqliteLogger } from "./utils/logger"
|
|
5
|
+
|
|
6
|
+
export interface TableColumn {
|
|
7
|
+
cid: number
|
|
8
|
+
name: string
|
|
9
|
+
type: string
|
|
10
|
+
notnull: number
|
|
11
|
+
dflt_value: string | number | null
|
|
12
|
+
pk: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface IndexInfo {
|
|
16
|
+
name: string
|
|
17
|
+
sql: string | null
|
|
18
|
+
unique: boolean
|
|
19
|
+
origin: string
|
|
20
|
+
partial: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ForeignKeyInfo {
|
|
24
|
+
id: number
|
|
25
|
+
seq: number
|
|
26
|
+
table: string
|
|
27
|
+
from: string
|
|
28
|
+
to: string
|
|
29
|
+
on_update: string
|
|
30
|
+
on_delete: string
|
|
31
|
+
match: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the current schema of a table
|
|
36
|
+
*/
|
|
37
|
+
export function getTableColumns(db: Database, tableName: string): TableColumn[] {
|
|
38
|
+
const stmt = db.prepare(`PRAGMA table_info("${tableName}")`)
|
|
39
|
+
return stmt.all() as TableColumn[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get indexes for a table
|
|
44
|
+
*/
|
|
45
|
+
export function getTableIndexes(db: Database, tableName: string): IndexInfo[] {
|
|
46
|
+
interface PragmaIndex {
|
|
47
|
+
seq: number
|
|
48
|
+
name: string
|
|
49
|
+
unique: number
|
|
50
|
+
origin: string
|
|
51
|
+
partial: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SqliteMasterRow {
|
|
55
|
+
sql: string | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const stmt = db.prepare(`PRAGMA index_list("${tableName}")`)
|
|
59
|
+
const indexes = stmt.all() as PragmaIndex[]
|
|
60
|
+
|
|
61
|
+
return indexes.map((idx) => {
|
|
62
|
+
const infoStmt = db.prepare(`PRAGMA index_info("${idx.name}")`)
|
|
63
|
+
infoStmt.all() // Execute but don't need the result for this use case
|
|
64
|
+
|
|
65
|
+
// Get the CREATE INDEX statement if available
|
|
66
|
+
const sqlStmt = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`)
|
|
67
|
+
const sqlResult = sqlStmt.get(idx.name) as SqliteMasterRow | null
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: idx.name,
|
|
71
|
+
sql: sqlResult?.sql || null,
|
|
72
|
+
unique: idx.unique === 1,
|
|
73
|
+
origin: idx.origin,
|
|
74
|
+
partial: idx.partial,
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get foreign keys for a table
|
|
81
|
+
*/
|
|
82
|
+
export function getTableForeignKeys(db: Database, tableName: string): ForeignKeyInfo[] {
|
|
83
|
+
const stmt = db.prepare(`PRAGMA foreign_key_list("${tableName}")`)
|
|
84
|
+
return stmt.all() as ForeignKeyInfo[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get triggers for a table
|
|
89
|
+
*/
|
|
90
|
+
export function getTableTriggers(
|
|
91
|
+
db: Database,
|
|
92
|
+
tableName: string
|
|
93
|
+
): Array<{ name: string; sql: string }> {
|
|
94
|
+
const stmt = db.prepare(
|
|
95
|
+
`SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ?`
|
|
96
|
+
)
|
|
97
|
+
return stmt.all(tableName) as Array<{ name: string; sql: string }>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if a table exists
|
|
102
|
+
*/
|
|
103
|
+
export function tableExists(db: Database, tableName: string): boolean {
|
|
104
|
+
const stmt = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
105
|
+
const result = stmt.get(tableName)
|
|
106
|
+
return result !== null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compare two schemas to determine if migration is needed
|
|
111
|
+
*/
|
|
112
|
+
export function schemasAreDifferent(
|
|
113
|
+
currentSchema: TableColumn[],
|
|
114
|
+
newColumns: Record<string, ColumnDefinition>,
|
|
115
|
+
logger: SqliteLogger
|
|
116
|
+
): boolean {
|
|
117
|
+
logger.info("Comparing schemas")
|
|
118
|
+
const currentColumnNames = new Set(currentSchema.map((col) => col.name))
|
|
119
|
+
const newColumnNames = new Set(Object.keys(newColumns))
|
|
120
|
+
|
|
121
|
+
// Check if column count differs
|
|
122
|
+
if (currentColumnNames.size !== newColumnNames.size) {
|
|
123
|
+
logger.info("Column count differs")
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check if all column names match
|
|
128
|
+
for (const name of newColumnNames) {
|
|
129
|
+
if (!currentColumnNames.has(name)) {
|
|
130
|
+
logger.info(`Column ${name} does not exist`)
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
logger.debug(`Column ${name} exists`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Check column definitions
|
|
137
|
+
for (const currentCol of currentSchema) {
|
|
138
|
+
const newCol = newColumns[currentCol.name]
|
|
139
|
+
if (!newCol) {
|
|
140
|
+
logger.info(`Column ${currentCol.name} does not exist`)
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// For primary key columns, SQLite auto-adds AUTOINCREMENT which we need to account for
|
|
145
|
+
const isCurrentPK = currentCol.pk === 1
|
|
146
|
+
const isNewPK = newCol.primaryKey === true || newCol.autoincrement === true
|
|
147
|
+
|
|
148
|
+
// If both are primary keys, consider them the same (don't check other constraints)
|
|
149
|
+
if (isCurrentPK && isNewPK) {
|
|
150
|
+
logger.info(`Column ${currentCol.name} is a primary key`)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (isCurrentPK !== isNewPK) {
|
|
155
|
+
logger.info(
|
|
156
|
+
`Column ${currentCol.name} primary key status differs (current: ${isCurrentPK}, new: ${isNewPK})`
|
|
157
|
+
)
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Check if NOT NULL constraint differs (ignore for primary keys as they're always NOT NULL)
|
|
162
|
+
if (!isCurrentPK) {
|
|
163
|
+
const isCurrentNotNull = currentCol.notnull === 1
|
|
164
|
+
const isNewNotNull = newCol.notNull === true
|
|
165
|
+
if (isCurrentNotNull !== isNewNotNull) {
|
|
166
|
+
logger.info(
|
|
167
|
+
`Column ${currentCol.name} NOT NULL constraint differs (current: ${isCurrentNotNull}, new: ${isNewNotNull})`
|
|
168
|
+
)
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Basic type comparison (normalize types)
|
|
174
|
+
const normalizeType = (type: string) => type.toUpperCase().split("(")[0].trim()
|
|
175
|
+
const currentType = normalizeType(currentCol.type)
|
|
176
|
+
const newType = normalizeType(newCol.type)
|
|
177
|
+
|
|
178
|
+
// Handle type aliases
|
|
179
|
+
const typeAliases: Record<string, string> = {
|
|
180
|
+
INT: "INTEGER",
|
|
181
|
+
BOOL: "INTEGER",
|
|
182
|
+
BOOLEAN: "INTEGER",
|
|
183
|
+
JSON: "TEXT",
|
|
184
|
+
VARCHAR: "TEXT",
|
|
185
|
+
CHAR: "TEXT",
|
|
186
|
+
DATETIME: "TEXT",
|
|
187
|
+
DATE: "TEXT",
|
|
188
|
+
TIMESTAMP: "TEXT",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const normalizedCurrentType = typeAliases[currentType] || currentType
|
|
192
|
+
const normalizedNewType = typeAliases[newType] || newType
|
|
193
|
+
|
|
194
|
+
if (normalizedCurrentType !== normalizedNewType) {
|
|
195
|
+
logger.info(`Column ${currentCol.name} type differs`)
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
logger.info("No schema changes detected")
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Generate column mapping for data migration
|
|
206
|
+
*/
|
|
207
|
+
export function generateColumnMapping(
|
|
208
|
+
currentSchema: TableColumn[],
|
|
209
|
+
newColumns: Record<string, ColumnDefinition>
|
|
210
|
+
): { selectColumns: string[]; insertColumns: string[] } {
|
|
211
|
+
const currentColumnNames = new Set(currentSchema.map((col) => col.name))
|
|
212
|
+
const newColumnNames = Object.keys(newColumns)
|
|
213
|
+
|
|
214
|
+
// Find common columns
|
|
215
|
+
const commonColumns = newColumnNames.filter((name) => currentColumnNames.has(name))
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
selectColumns: commonColumns,
|
|
219
|
+
insertColumns: commonColumns,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Migrate a table to a new schema
|
|
225
|
+
*/
|
|
226
|
+
export function migrateTable(
|
|
227
|
+
db: Database,
|
|
228
|
+
tableName: string,
|
|
229
|
+
newColumns: Record<string, ColumnDefinition>,
|
|
230
|
+
options: MigrationOptions = {},
|
|
231
|
+
tableConstraints: string[] = [],
|
|
232
|
+
migrationLog: SqliteLogger
|
|
233
|
+
): void {
|
|
234
|
+
const { preserveData = true, onConflict = "fail", tempTableSuffix = "_migration_temp" } = options
|
|
235
|
+
|
|
236
|
+
migrationLog.info(`Starting migration for table: ${tableName}`)
|
|
237
|
+
|
|
238
|
+
// Get current table info
|
|
239
|
+
const currentSchema = getTableColumns(db, tableName)
|
|
240
|
+
const indexes = getTableIndexes(db, tableName)
|
|
241
|
+
const triggers = getTableTriggers(db, tableName)
|
|
242
|
+
|
|
243
|
+
// Check if migration is needed
|
|
244
|
+
if (!schemasAreDifferent(currentSchema, newColumns, migrationLog)) {
|
|
245
|
+
migrationLog.info(`No migration needed for table: ${tableName}`)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const tempTableName = `${tableName}${tempTableSuffix}`
|
|
250
|
+
|
|
251
|
+
// Start transaction for atomic migration
|
|
252
|
+
db.transaction(() => {
|
|
253
|
+
try {
|
|
254
|
+
// Step 1: Create temporary table with new schema
|
|
255
|
+
migrationLog.debug(`Creating temporary table: ${tempTableName}`)
|
|
256
|
+
const columnDefs: string[] = []
|
|
257
|
+
|
|
258
|
+
for (const [colName, colDef] of Object.entries(newColumns)) {
|
|
259
|
+
const sqlDef = buildColumnSQL(colName, colDef)
|
|
260
|
+
columnDefs.push(`"${colName}" ${sqlDef}`)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Include table constraints if provided
|
|
264
|
+
const allDefinitions =
|
|
265
|
+
tableConstraints.length > 0
|
|
266
|
+
? [...columnDefs, ...tableConstraints].join(", ")
|
|
267
|
+
: columnDefs.join(", ")
|
|
268
|
+
|
|
269
|
+
const createTempTableSql = `CREATE TABLE "${tempTableName}" (${allDefinitions})`
|
|
270
|
+
db.run(createTempTableSql)
|
|
271
|
+
|
|
272
|
+
// Step 2: Copy data if requested
|
|
273
|
+
if (preserveData && currentSchema.length > 0) {
|
|
274
|
+
const { selectColumns, insertColumns } = generateColumnMapping(currentSchema, newColumns)
|
|
275
|
+
|
|
276
|
+
if (selectColumns.length > 0) {
|
|
277
|
+
migrationLog.debug(`Copying data from ${tableName} to ${tempTableName}`)
|
|
278
|
+
|
|
279
|
+
const quotedSelectCols = selectColumns.map((col) => `"${col}"`).join(", ")
|
|
280
|
+
const quotedInsertCols = insertColumns.map((col) => `"${col}"`).join(", ")
|
|
281
|
+
|
|
282
|
+
let copySql = `INSERT INTO "${tempTableName}" (${quotedInsertCols})
|
|
283
|
+
SELECT ${quotedSelectCols} FROM "${tableName}"`
|
|
284
|
+
|
|
285
|
+
if (onConflict === "ignore") {
|
|
286
|
+
copySql = `INSERT OR IGNORE INTO "${tempTableName}" (${quotedInsertCols})
|
|
287
|
+
SELECT ${quotedSelectCols} FROM "${tableName}"`
|
|
288
|
+
} else if (onConflict === "replace") {
|
|
289
|
+
copySql = `INSERT OR REPLACE INTO "${tempTableName}" (${quotedInsertCols})
|
|
290
|
+
SELECT ${quotedSelectCols} FROM "${tableName}"`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
db.run(copySql)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Step 3: Drop the original table
|
|
298
|
+
migrationLog.debug(`Dropping original table: ${tableName}`)
|
|
299
|
+
|
|
300
|
+
// Temporarily disable foreign key constraints
|
|
301
|
+
interface ForeignKeyStatus {
|
|
302
|
+
foreign_keys: number
|
|
303
|
+
}
|
|
304
|
+
const fkStatus = db.prepare("PRAGMA foreign_keys").get() as ForeignKeyStatus | null
|
|
305
|
+
if (fkStatus && fkStatus.foreign_keys === 1) {
|
|
306
|
+
db.run("PRAGMA foreign_keys = OFF")
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
db.run(`DROP TABLE "${tableName}"`)
|
|
310
|
+
|
|
311
|
+
// Step 4: Rename temporary table to original name
|
|
312
|
+
migrationLog.debug(`Renaming ${tempTableName} to ${tableName}`)
|
|
313
|
+
db.run(`ALTER TABLE "${tempTableName}" RENAME TO "${tableName}"`)
|
|
314
|
+
|
|
315
|
+
// Step 5: Recreate indexes
|
|
316
|
+
for (const index of indexes) {
|
|
317
|
+
if (index.sql && !index.sql.includes("sqlite_autoindex")) {
|
|
318
|
+
migrationLog.debug(`Recreating index: ${index.name}`)
|
|
319
|
+
try {
|
|
320
|
+
db.run(index.sql)
|
|
321
|
+
} catch (err) {
|
|
322
|
+
migrationLog.warn(`Failed to recreate index ${index.name}: ${err}`)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Step 6: Recreate triggers
|
|
328
|
+
for (const trigger of triggers) {
|
|
329
|
+
migrationLog.debug(`Recreating trigger: ${trigger.name}`)
|
|
330
|
+
try {
|
|
331
|
+
db.run(trigger.sql)
|
|
332
|
+
} catch (err) {
|
|
333
|
+
migrationLog.warn(`Failed to recreate trigger ${trigger.name}: ${err}`)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Re-enable foreign key constraints if they were enabled
|
|
338
|
+
if (fkStatus && fkStatus.foreign_keys === 1) {
|
|
339
|
+
db.run("PRAGMA foreign_keys = ON")
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
migrationLog.info(`Successfully migrated table: ${tableName}`)
|
|
343
|
+
} catch (error) {
|
|
344
|
+
migrationLog.error(`Migration failed for table ${tableName}: ${error}`)
|
|
345
|
+
throw error
|
|
346
|
+
}
|
|
347
|
+
})()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Check if migration is needed and perform if necessary
|
|
352
|
+
*/
|
|
353
|
+
export function checkAndMigrate(
|
|
354
|
+
db: Database,
|
|
355
|
+
tableName: string,
|
|
356
|
+
newColumns: Record<string, ColumnDefinition>,
|
|
357
|
+
migrationLog: SqliteLogger,
|
|
358
|
+
options?: MigrationOptions,
|
|
359
|
+
tableConstraints: string[] = []
|
|
360
|
+
): boolean {
|
|
361
|
+
const logger = new SqliteLogger(tableName, migrationLog.getBaseLogger())
|
|
362
|
+
|
|
363
|
+
if (!tableExists(db, tableName)) {
|
|
364
|
+
return false // No existing table, no migration needed
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const currentSchema = getTableColumns(db, tableName)
|
|
368
|
+
|
|
369
|
+
if (schemasAreDifferent(currentSchema, newColumns, logger)) {
|
|
370
|
+
migrateTable(db, tableName, newColumns, options, tableConstraints, migrationLog)
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return false
|
|
375
|
+
}
|
|
@@ -175,8 +175,36 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
|
|
|
175
175
|
return result
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
private logSelectStart(
|
|
179
|
+
method: string,
|
|
180
|
+
details: { query?: string; optimizedQuery?: string; params?: unknown }
|
|
181
|
+
): void {
|
|
182
|
+
const { query, optimizedQuery, params } = details
|
|
183
|
+
const q = query ?? optimizedQuery ?? ""
|
|
184
|
+
this.selectLog.info(
|
|
185
|
+
`${method} | ${query ? "query" : "optimizedQuery"}=${q} params=${WhereQueryBuilder.safeStringify(params)}`
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private logSelectReturn(
|
|
190
|
+
method: string,
|
|
191
|
+
details: { returned?: unknown; length?: number; sample?: unknown }
|
|
192
|
+
): void {
|
|
193
|
+
const { returned, length, sample } = details
|
|
194
|
+
if (length !== undefined && sample !== undefined) {
|
|
195
|
+
this.selectLog.info(
|
|
196
|
+
`${method} | returned=${length} sample=${WhereQueryBuilder.safeStringify(sample)}`
|
|
197
|
+
)
|
|
198
|
+
} else {
|
|
199
|
+
this.selectLog.info(`${method} | returned=${WhereQueryBuilder.safeStringify(returned)}`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
178
203
|
// ===== Execution Methods =====
|
|
179
204
|
|
|
205
|
+
// Use the protected static helper inherited from WhereQueryBuilder: `safeStringify`
|
|
206
|
+
// (Removed duplicate implementation to avoid static-side conflicts with the base class.)
|
|
207
|
+
|
|
180
208
|
/**
|
|
181
209
|
* Execute the query and return all matching rows
|
|
182
210
|
*
|
|
@@ -187,20 +215,22 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
|
|
|
187
215
|
const hasRegex = this.hasRegexConditions()
|
|
188
216
|
const [query, params] = this.buildSelectQuery(!hasRegex)
|
|
189
217
|
|
|
218
|
+
this.logSelectStart("all", { query, params })
|
|
190
219
|
this.selectLog.query("SELECT", query, params)
|
|
191
220
|
|
|
192
221
|
const rows = this.getDb()
|
|
193
222
|
.prepare(query)
|
|
194
223
|
.all(...params) as T[]
|
|
195
|
-
|
|
196
224
|
this.selectLog.result("SELECT", rows.length)
|
|
197
225
|
|
|
198
|
-
// Transform rows (JSON/Boolean parsing)
|
|
199
226
|
const transformed = this.transformRowsFromDb(rows)
|
|
200
|
-
|
|
201
|
-
// Apply client-side operations if needed
|
|
202
227
|
const result = hasRegex ? this.applyClientSideOperations(transformed) : transformed
|
|
203
228
|
|
|
229
|
+
this.logSelectReturn("all", {
|
|
230
|
+
length: result.length,
|
|
231
|
+
sample: result[0] ?? null,
|
|
232
|
+
})
|
|
233
|
+
|
|
204
234
|
this.reset()
|
|
205
235
|
return result
|
|
206
236
|
}
|
|
@@ -211,42 +241,43 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
|
|
|
211
241
|
* Respects LIMIT if set, otherwise adds LIMIT 1 for efficiency
|
|
212
242
|
*/
|
|
213
243
|
get(): T | null {
|
|
214
|
-
// If no regex and no explicit limit, optimize with LIMIT 1
|
|
215
244
|
if (!this.hasRegexConditions() && this.limitValue === undefined) {
|
|
216
245
|
const [query, params] = this.buildSelectQuery(true)
|
|
217
246
|
const optimizedQuery = `${query} LIMIT 1`
|
|
218
247
|
|
|
248
|
+
this.logSelectStart("get", { optimizedQuery, params })
|
|
219
249
|
this.selectLog.query("SELECT (get)", optimizedQuery, params)
|
|
220
250
|
|
|
221
251
|
const row = this.getDb()
|
|
222
252
|
.prepare(optimizedQuery)
|
|
223
253
|
.get(...params) as T | null
|
|
224
|
-
|
|
225
254
|
this.selectLog.result("SELECT (get)", row ? 1 : 0)
|
|
226
255
|
|
|
227
256
|
const result = row ? this.transformRowFromDb(row) : null
|
|
257
|
+
this.logSelectReturn("get", { returned: result })
|
|
258
|
+
|
|
228
259
|
this.reset()
|
|
229
260
|
return result
|
|
230
261
|
}
|
|
231
262
|
|
|
232
|
-
// If limit is set or regex conditions exist, use standard flow
|
|
233
263
|
if (!this.hasRegexConditions()) {
|
|
234
264
|
const [query, params] = this.buildSelectQuery(true)
|
|
235
265
|
|
|
266
|
+
this.logSelectStart("get", { query, params })
|
|
236
267
|
this.selectLog.query("SELECT (get)", query, params)
|
|
237
268
|
|
|
238
269
|
const row = this.getDb()
|
|
239
270
|
.prepare(query)
|
|
240
271
|
.get(...params) as T | null
|
|
241
|
-
|
|
242
272
|
this.selectLog.result("SELECT (get)", row ? 1 : 0)
|
|
243
273
|
|
|
244
274
|
const result = row ? this.transformRowFromDb(row) : null
|
|
275
|
+
this.logSelectReturn("get", { returned: result })
|
|
276
|
+
|
|
245
277
|
this.reset()
|
|
246
278
|
return result
|
|
247
279
|
}
|
|
248
280
|
|
|
249
|
-
// Has regex conditions - fall back to all() and take first
|
|
250
281
|
const results = this.all()
|
|
251
282
|
return results[0] ?? null
|
|
252
283
|
}
|
|
@@ -268,21 +299,23 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
|
|
|
268
299
|
*/
|
|
269
300
|
count(): number {
|
|
270
301
|
if (!this.hasRegexConditions()) {
|
|
271
|
-
// Use SQL COUNT for efficiency
|
|
272
302
|
const [whereClause, whereParams] = this.buildWhereClause()
|
|
273
303
|
const query = `SELECT COUNT(*) AS __count FROM ${quoteIdentifier(this.getTableName())}${whereClause}`
|
|
274
304
|
|
|
305
|
+
this.logSelectStart("count", { query, params: whereParams })
|
|
275
306
|
this.selectLog.query("COUNT", query, whereParams)
|
|
276
307
|
|
|
277
308
|
const result = this.getDb()
|
|
278
309
|
.prepare(query)
|
|
279
310
|
.get(...whereParams) as { __count: number } | null
|
|
280
311
|
|
|
312
|
+
const count = result?.__count ?? 0
|
|
281
313
|
this.reset()
|
|
282
|
-
|
|
314
|
+
|
|
315
|
+
this.logSelectReturn("count", { returned: count })
|
|
316
|
+
return count
|
|
283
317
|
}
|
|
284
318
|
|
|
285
|
-
// Has regex conditions - count client-side
|
|
286
319
|
const results = this.all()
|
|
287
320
|
return results.length
|
|
288
321
|
}
|
|
@@ -292,44 +325,58 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
|
|
|
292
325
|
*/
|
|
293
326
|
exists(): boolean {
|
|
294
327
|
if (!this.hasRegexConditions()) {
|
|
295
|
-
// Use EXISTS for efficiency
|
|
296
328
|
const [whereClause, whereParams] = this.buildWhereClause()
|
|
297
329
|
const subquery = `SELECT 1 FROM ${quoteIdentifier(this.getTableName())}${whereClause} LIMIT 1`
|
|
298
330
|
const query = `SELECT EXISTS(${subquery}) AS __exists`
|
|
299
331
|
|
|
332
|
+
this.logSelectStart("exists", { query, params: whereParams })
|
|
300
333
|
this.selectLog.query("EXISTS", query, whereParams)
|
|
301
334
|
|
|
302
335
|
const result = this.getDb()
|
|
303
336
|
.prepare(query)
|
|
304
337
|
.get(...whereParams) as { __exists: number } | null
|
|
305
338
|
|
|
339
|
+
const exists = Boolean(result?.__exists)
|
|
306
340
|
this.reset()
|
|
307
|
-
|
|
341
|
+
|
|
342
|
+
this.logSelectReturn("exists", { returned: exists })
|
|
343
|
+
return exists
|
|
308
344
|
}
|
|
309
345
|
|
|
310
|
-
// Has regex conditions - check client-side
|
|
311
346
|
return this.count() > 0
|
|
312
347
|
}
|
|
313
348
|
|
|
349
|
+
private logColumnReturn(method: "value" | "pluck", column: string, returned: unknown): void {
|
|
350
|
+
this.selectLog.info(
|
|
351
|
+
`${method} | column=${column} returned=${WhereQueryBuilder.safeStringify(returned)}`
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
314
355
|
/**
|
|
315
|
-
* Get
|
|
356
|
+
* Get an array of values from a single column
|
|
316
357
|
*
|
|
317
358
|
* @example
|
|
318
|
-
* const
|
|
359
|
+
* const emails = table.where({ active: true }).pluck("email")
|
|
319
360
|
*/
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
361
|
+
pluck<K extends keyof T>(column: K): T[K][] {
|
|
362
|
+
const rows = this.all() || []
|
|
363
|
+
const values = rows.map((row) => row[column])
|
|
364
|
+
|
|
365
|
+
this.logColumnReturn("pluck", String(column), values)
|
|
366
|
+
return values
|
|
323
367
|
}
|
|
324
368
|
|
|
325
369
|
/**
|
|
326
|
-
* Get
|
|
370
|
+
* Get a single column value from the first matching row
|
|
327
371
|
*
|
|
328
372
|
* @example
|
|
329
|
-
* const
|
|
373
|
+
* const name = table.where({ id: 1 }).value("name")
|
|
330
374
|
*/
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
375
|
+
value<K extends keyof T>(column: K): T[K] | null {
|
|
376
|
+
const row = this.first()
|
|
377
|
+
const value = row ? row[column] : null
|
|
378
|
+
|
|
379
|
+
this.logColumnReturn("value", String(column), value)
|
|
380
|
+
return value
|
|
334
381
|
}
|
|
335
382
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { SQLQueryBindings } from "bun:sqlite"
|
|
2
2
|
import type { RegexCondition, WhereCondition } from "../types"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
buildBetweenClause,
|
|
5
|
+
buildInClause,
|
|
6
|
+
normalizeOperator,
|
|
7
|
+
quoteIdentifier,
|
|
8
|
+
truncate,
|
|
9
|
+
} from "../utils"
|
|
4
10
|
import { BaseQueryBuilder } from "./base"
|
|
5
11
|
|
|
6
12
|
/**
|
|
@@ -17,6 +23,20 @@ import { BaseQueryBuilder } from "./base"
|
|
|
17
23
|
*/
|
|
18
24
|
export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQueryBuilder<T> {
|
|
19
25
|
// ===== Private Helpers =====
|
|
26
|
+
protected logWhere(method: string, data: Record<string, unknown>): void {
|
|
27
|
+
const parts = Object.entries(data).map(
|
|
28
|
+
([key, value]) => `${key}=${WhereQueryBuilder.safeStringify(value)}`
|
|
29
|
+
)
|
|
30
|
+
this.log.info(`${method} | ${parts.join(" ")}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
protected logWhereState(method: string, extra: Record<string, unknown> = {}): void {
|
|
34
|
+
this.logWhere(method, {
|
|
35
|
+
whereConditions: this.state.whereConditions,
|
|
36
|
+
whereParams: this.state.whereParams,
|
|
37
|
+
...extra,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
20
40
|
|
|
21
41
|
/**
|
|
22
42
|
* Remove an existing condition for a column to prevent duplicates
|
|
@@ -54,6 +74,16 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
54
74
|
|
|
55
75
|
// ===== Public WHERE Methods =====
|
|
56
76
|
|
|
77
|
+
// Helper to stringify values safely (converts RegExp to string and falls back)
|
|
78
|
+
protected static safeStringify(obj: unknown): string {
|
|
79
|
+
try {
|
|
80
|
+
const dat = JSON.stringify(obj, (_k, v) => (v instanceof RegExp ? v.toString() : v))
|
|
81
|
+
return truncate(dat, 100)
|
|
82
|
+
} catch {
|
|
83
|
+
return truncate(String(obj), 100)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
57
87
|
/**
|
|
58
88
|
* Add simple equality conditions to the WHERE clause
|
|
59
89
|
*
|
|
@@ -66,6 +96,8 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
66
96
|
* // WHERE "deleted_at" IS NULL
|
|
67
97
|
*/
|
|
68
98
|
where(conditions: WhereCondition<T>): this {
|
|
99
|
+
this.logWhere("where", { conditions })
|
|
100
|
+
|
|
69
101
|
for (const [column, value] of Object.entries(conditions)) {
|
|
70
102
|
this.removeExistingCondition(column)
|
|
71
103
|
|
|
@@ -76,6 +108,8 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
76
108
|
this.state.whereParams.push(this.toSqliteValue(value))
|
|
77
109
|
}
|
|
78
110
|
}
|
|
111
|
+
|
|
112
|
+
this.logWhereState("where")
|
|
79
113
|
return this
|
|
80
114
|
}
|
|
81
115
|
|
|
@@ -86,6 +120,9 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
86
120
|
* .whereRgx({ email: /@gmail\.com$/ })
|
|
87
121
|
*/
|
|
88
122
|
whereRgx(conditions: RegexCondition<T>): this {
|
|
123
|
+
// Log invocation with a safe serializer for regex values
|
|
124
|
+
this.log.info(`whereRgx | conditions=${WhereQueryBuilder.safeStringify(conditions)}`)
|
|
125
|
+
|
|
89
126
|
for (const [column, value] of Object.entries(conditions)) {
|
|
90
127
|
this.removeExistingCondition(column)
|
|
91
128
|
|
|
@@ -105,6 +142,10 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
105
142
|
this.state.whereParams.push(value as SQLQueryBindings)
|
|
106
143
|
}
|
|
107
144
|
}
|
|
145
|
+
|
|
146
|
+
// Log resulting WHERE/regex state
|
|
147
|
+
this.logWhereState("whereRgx", { regexConditions: this.state.regexConditions })
|
|
148
|
+
|
|
108
149
|
return this
|
|
109
150
|
}
|
|
110
151
|
|
|
@@ -116,6 +157,9 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
116
157
|
* .whereExpr("created_at > datetime('now', '-1 day')")
|
|
117
158
|
*/
|
|
118
159
|
whereExpr(expr: string, params: SQLQueryBindings[] = []): this {
|
|
160
|
+
// Log invocation
|
|
161
|
+
this.log.info(`whereExpr | expr=${expr} params=${WhereQueryBuilder.safeStringify(params)}`)
|
|
162
|
+
|
|
119
163
|
if (!expr || typeof expr !== "string") {
|
|
120
164
|
throw new Error("whereExpr: expression must be a non-empty string")
|
|
121
165
|
}
|
|
@@ -127,6 +171,13 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
127
171
|
this.state.whereParams.push(...params)
|
|
128
172
|
}
|
|
129
173
|
|
|
174
|
+
// Log resulting WHERE clause state
|
|
175
|
+
this.log.info(
|
|
176
|
+
`whereExpr | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
|
|
177
|
+
this.state.whereParams
|
|
178
|
+
)}`
|
|
179
|
+
)
|
|
180
|
+
|
|
130
181
|
return this
|
|
131
182
|
}
|
|
132
183
|
|
|
@@ -134,6 +185,8 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
134
185
|
* Alias for whereExpr
|
|
135
186
|
*/
|
|
136
187
|
whereRaw(expr: string, params: SQLQueryBindings[] = []): this {
|
|
188
|
+
// Log alias invocation
|
|
189
|
+
this.log.info(`whereRaw | expr=${expr} params=${WhereQueryBuilder.safeStringify(params)}`)
|
|
137
190
|
return this.whereExpr(expr, params)
|
|
138
191
|
}
|
|
139
192
|
|
|
@@ -145,6 +198,11 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
145
198
|
* // WHERE "status" IN (?, ?)
|
|
146
199
|
*/
|
|
147
200
|
whereIn(column: keyof T, values: SQLQueryBindings[]): this {
|
|
201
|
+
// Log invocation
|
|
202
|
+
this.log.info(
|
|
203
|
+
`whereIn | column=${String(column)} values=${WhereQueryBuilder.safeStringify(values)}`
|
|
204
|
+
)
|
|
205
|
+
|
|
148
206
|
if (!Array.isArray(values) || values.length === 0) {
|
|
149
207
|
throw new Error("whereIn: values must be a non-empty array")
|
|
150
208
|
}
|
|
@@ -155,6 +213,13 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
155
213
|
this.state.whereConditions.push(sql)
|
|
156
214
|
this.state.whereParams.push(...params)
|
|
157
215
|
|
|
216
|
+
// Log resulting WHERE clause state
|
|
217
|
+
this.log.info(
|
|
218
|
+
`whereIn | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
|
|
219
|
+
this.state.whereParams
|
|
220
|
+
)}`
|
|
221
|
+
)
|
|
222
|
+
|
|
158
223
|
return this
|
|
159
224
|
}
|
|
160
225
|
|
|
@@ -166,6 +231,11 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
166
231
|
* // WHERE "role" NOT IN (?, ?)
|
|
167
232
|
*/
|
|
168
233
|
whereNotIn(column: keyof T, values: SQLQueryBindings[]): this {
|
|
234
|
+
// Log invocation
|
|
235
|
+
this.log.info(
|
|
236
|
+
`whereNotIn | column=${String(column)} values=${WhereQueryBuilder.safeStringify(values)}`
|
|
237
|
+
)
|
|
238
|
+
|
|
169
239
|
if (!Array.isArray(values) || values.length === 0) {
|
|
170
240
|
throw new Error("whereNotIn: values must be a non-empty array")
|
|
171
241
|
}
|
|
@@ -176,6 +246,13 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
176
246
|
this.state.whereConditions.push(sql)
|
|
177
247
|
this.state.whereParams.push(...params)
|
|
178
248
|
|
|
249
|
+
// Log resulting WHERE clause state
|
|
250
|
+
this.log.info(
|
|
251
|
+
`whereNotIn | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
|
|
252
|
+
this.state.whereParams
|
|
253
|
+
)}`
|
|
254
|
+
)
|
|
255
|
+
|
|
179
256
|
return this
|
|
180
257
|
}
|
|
181
258
|
|
|
@@ -189,17 +266,20 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
189
266
|
* .whereOp("name", "LIKE", "%smith%")
|
|
190
267
|
*/
|
|
191
268
|
whereOp(column: keyof T, op: string, value: SQLQueryBindings): this {
|
|
192
|
-
const normalizedOp = normalizeOperator(op)
|
|
193
269
|
const columnStr = String(column)
|
|
270
|
+
this.logWhere("whereOp", { column: columnStr, op, value })
|
|
271
|
+
|
|
272
|
+
const normalizedOp = normalizeOperator(op)
|
|
194
273
|
|
|
195
|
-
// Handle NULL special cases
|
|
196
274
|
if (value === null || value === undefined) {
|
|
197
275
|
if (normalizedOp === "=" || normalizedOp === "IS") {
|
|
198
276
|
this.state.whereConditions.push(`${quoteIdentifier(columnStr)} IS NULL`)
|
|
277
|
+
this.logWhere("whereOp", { column: columnStr, added: "IS NULL" })
|
|
199
278
|
return this
|
|
200
279
|
}
|
|
201
280
|
if (normalizedOp === "!=" || normalizedOp === "<>" || normalizedOp === "IS NOT") {
|
|
202
281
|
this.state.whereConditions.push(`${quoteIdentifier(columnStr)} IS NOT NULL`)
|
|
282
|
+
this.logWhere("whereOp", { column: columnStr, added: "IS NOT NULL" })
|
|
203
283
|
return this
|
|
204
284
|
}
|
|
205
285
|
}
|
|
@@ -207,6 +287,7 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
207
287
|
this.state.whereConditions.push(`${quoteIdentifier(columnStr)} ${normalizedOp} ?`)
|
|
208
288
|
this.state.whereParams.push(value)
|
|
209
289
|
|
|
290
|
+
this.logWhereState("whereOp")
|
|
210
291
|
return this
|
|
211
292
|
}
|
|
212
293
|
|
|
@@ -218,12 +299,26 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
218
299
|
* // WHERE "age" BETWEEN ? AND ?
|
|
219
300
|
*/
|
|
220
301
|
whereBetween(column: keyof T, min: SQLQueryBindings, max: SQLQueryBindings): this {
|
|
302
|
+
// Log invocation
|
|
303
|
+
this.log.info(
|
|
304
|
+
`whereBetween | column=${String(column)} min=${WhereQueryBuilder.safeStringify(min)} max=${WhereQueryBuilder.safeStringify(
|
|
305
|
+
max
|
|
306
|
+
)}`
|
|
307
|
+
)
|
|
308
|
+
|
|
221
309
|
this.removeExistingCondition(String(column), "BETWEEN")
|
|
222
310
|
|
|
223
311
|
const { sql, params } = buildBetweenClause(String(column), min, max, false)
|
|
224
312
|
this.state.whereConditions.push(sql)
|
|
225
313
|
this.state.whereParams.push(...params)
|
|
226
314
|
|
|
315
|
+
// Log resulting WHERE clause state
|
|
316
|
+
this.log.info(
|
|
317
|
+
`whereBetween | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
|
|
318
|
+
this.state.whereParams
|
|
319
|
+
)}`
|
|
320
|
+
)
|
|
321
|
+
|
|
227
322
|
return this
|
|
228
323
|
}
|
|
229
324
|
|
|
@@ -235,12 +330,26 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
235
330
|
* // WHERE "score" NOT BETWEEN ? AND ?
|
|
236
331
|
*/
|
|
237
332
|
whereNotBetween(column: keyof T, min: SQLQueryBindings, max: SQLQueryBindings): this {
|
|
333
|
+
// Log invocation
|
|
334
|
+
this.log.info(
|
|
335
|
+
`whereNotBetween | column=${String(column)} min=${WhereQueryBuilder.safeStringify(min)} max=${WhereQueryBuilder.safeStringify(
|
|
336
|
+
max
|
|
337
|
+
)}`
|
|
338
|
+
)
|
|
339
|
+
|
|
238
340
|
this.removeExistingCondition(String(column), "NOT BETWEEN")
|
|
239
341
|
|
|
240
342
|
const { sql, params } = buildBetweenClause(String(column), min, max, true)
|
|
241
343
|
this.state.whereConditions.push(sql)
|
|
242
344
|
this.state.whereParams.push(...params)
|
|
243
345
|
|
|
346
|
+
// Log resulting WHERE clause state
|
|
347
|
+
this.log.info(
|
|
348
|
+
`whereNotBetween | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
|
|
349
|
+
this.state.whereParams
|
|
350
|
+
)}`
|
|
351
|
+
)
|
|
352
|
+
|
|
244
353
|
return this
|
|
245
354
|
}
|
|
246
355
|
|
|
@@ -252,8 +361,19 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
252
361
|
* // WHERE "deleted_at" IS NULL
|
|
253
362
|
*/
|
|
254
363
|
whereNull(column: keyof T): this {
|
|
364
|
+
// Log invocation
|
|
365
|
+
this.log.info(`whereNull | column=${String(column)}`)
|
|
366
|
+
|
|
255
367
|
this.removeExistingCondition(String(column))
|
|
256
368
|
this.state.whereConditions.push(`${quoteIdentifier(String(column))} IS NULL`)
|
|
369
|
+
|
|
370
|
+
// Log resulting WHERE clause state
|
|
371
|
+
this.log.info(
|
|
372
|
+
`whereNull | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
|
|
373
|
+
this.state.whereParams
|
|
374
|
+
)}`
|
|
375
|
+
)
|
|
376
|
+
|
|
257
377
|
return this
|
|
258
378
|
}
|
|
259
379
|
|
|
@@ -265,8 +385,19 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
265
385
|
* // WHERE "email" IS NOT NULL
|
|
266
386
|
*/
|
|
267
387
|
whereNotNull(column: keyof T): this {
|
|
388
|
+
// Log invocation
|
|
389
|
+
this.log.info(`whereNotNull | column=${String(column)}`)
|
|
390
|
+
|
|
268
391
|
this.removeExistingCondition(String(column))
|
|
269
392
|
this.state.whereConditions.push(`${quoteIdentifier(String(column))} IS NOT NULL`)
|
|
393
|
+
|
|
394
|
+
// Log resulting WHERE clause state
|
|
395
|
+
this.log.info(
|
|
396
|
+
`whereNotNull | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
|
|
397
|
+
this.state.whereParams
|
|
398
|
+
)}`
|
|
399
|
+
)
|
|
400
|
+
|
|
270
401
|
return this
|
|
271
402
|
}
|
|
272
403
|
}
|
package/src/types.ts
CHANGED
|
@@ -237,6 +237,17 @@ export interface TableConstraints<T> {
|
|
|
237
237
|
/**
|
|
238
238
|
* Enhanced table options
|
|
239
239
|
*/
|
|
240
|
+
export interface MigrationOptions {
|
|
241
|
+
/** Whether to preserve data during migration (default: true) */
|
|
242
|
+
preserveData?: boolean
|
|
243
|
+
/** Whether to drop columns not in new schema (default: true) */
|
|
244
|
+
dropMissingColumns?: boolean
|
|
245
|
+
/** How to handle constraint violations during data copy */
|
|
246
|
+
onConflict?: "fail" | "ignore" | "replace"
|
|
247
|
+
/** Suffix for temporary table during migration (default: '_migration_temp') */
|
|
248
|
+
tempTableSuffix?: string
|
|
249
|
+
}
|
|
250
|
+
|
|
240
251
|
export interface TableOptions<T> {
|
|
241
252
|
/** Add IF NOT EXISTS clause */
|
|
242
253
|
ifNotExists?: boolean
|
|
@@ -248,6 +259,8 @@ export interface TableOptions<T> {
|
|
|
248
259
|
temporary?: boolean
|
|
249
260
|
/** Table comment */
|
|
250
261
|
comment?: string
|
|
262
|
+
/** Enable automatic schema migration (default: true) */
|
|
263
|
+
migrate?: boolean | MigrationOptions
|
|
251
264
|
|
|
252
265
|
parser?: Partial<Parser<T>>
|
|
253
266
|
}
|
|
@@ -461,9 +474,9 @@ export const column = {
|
|
|
461
474
|
/**
|
|
462
475
|
* Create a foreign key reference column
|
|
463
476
|
*/
|
|
464
|
-
foreignKey: (
|
|
477
|
+
foreignKey: <_T extends Record<string, unknown>>(
|
|
465
478
|
refTable: string,
|
|
466
|
-
refColumn = "id",
|
|
479
|
+
refColumn: keyof _T = "id",
|
|
467
480
|
constraints?: ColumnConstraints & {
|
|
468
481
|
onDelete?: ForeignKeyAction
|
|
469
482
|
onUpdate?: ForeignKeyAction
|
|
@@ -473,7 +486,7 @@ export const column = {
|
|
|
473
486
|
type: constraints?.type || SQLiteTypes.INTEGER,
|
|
474
487
|
references: {
|
|
475
488
|
table: refTable,
|
|
476
|
-
column: refColumn,
|
|
489
|
+
column: String(refColumn),
|
|
477
490
|
onDelete: constraints?.onDelete,
|
|
478
491
|
onUpdate: constraints?.onUpdate,
|
|
479
492
|
},
|
package/src/utils/index.ts
CHANGED