@dockstat/sqlite-wrapper 1.3.4 → 1.3.6
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 +103 -7
- package/package.json +1 -1
- package/src/index.ts +76 -5
- package/src/migration.ts +349 -0
- package/src/query-builder/select.ts +60 -72
- package/src/query-builder/where.ts +32 -33
- package/src/types.ts +46 -3
- package/src/utils/index.ts +2 -0
- package/src/utils/truncate.ts +6 -0
package/README.md
CHANGED
|
@@ -9,20 +9,21 @@ 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
|
-
|
|
16
|
-
- **Fixed Boolean parsing** — Boolean columns now correctly convert SQLite's `0`/`1` to JavaScript `true`/`false`
|
|
17
|
-
- **Fixed Wrong packing** — Before the `publish` script was added, workspace dependencies were not correctly propagated
|
|
12
|
+
## 🆕 What's New in v1.3.5
|
|
18
13
|
|
|
19
14
|
### New Features
|
|
20
15
|
|
|
16
|
+
- **Automatic Schema Migration** — Tables automatically migrate when schema changes are detected! Add/remove columns, change constraints, and preserve data without manual intervention
|
|
21
17
|
- **Auto-detection of JSON & Boolean columns** — No more manual parser configuration! Columns using `column.json()` or `column.boolean()` are automatically detected from schema
|
|
22
18
|
- **Automatic backups with retention** — Configure `autoBackup` to create periodic backups with automatic cleanup of old files
|
|
23
19
|
- **Backup & Restore API** — New `backup()`, `restore()`, and `listBackups()` methods
|
|
24
20
|
- **`getPath()` method** — Get the database file path
|
|
25
21
|
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
- **Fixed Boolean parsing** — Boolean columns now correctly convert SQLite's `0`/`1` to JavaScript `true`/`false`
|
|
25
|
+
- **Fixed incorrect packaging** — Before the `publish` script was added, workspace dependencies were not correctly propagated
|
|
26
|
+
|
|
26
27
|
### Architecture Improvements
|
|
27
28
|
|
|
28
29
|
- **New `utils/` module** — Reusable utilities for SQL building, logging, and row transformation
|
|
@@ -32,7 +33,7 @@ Schema-first table helpers, an expressive chainable QueryBuilder, safe defaults
|
|
|
32
33
|
|
|
33
34
|
### Breaking Changes
|
|
34
35
|
|
|
35
|
-
- None! v1.
|
|
36
|
+
- None! v1.4 is fully backward compatible with v1.3.x
|
|
36
37
|
|
|
37
38
|
---
|
|
38
39
|
|
|
@@ -100,6 +101,7 @@ const users = userTable
|
|
|
100
101
|
- 🚀 Designed for production workflows: WAL, pragmatic PRAGMAs, bulk ops, transactions
|
|
101
102
|
- 🔄 **Automatic JSON/Boolean detection** — no manual parser configuration needed
|
|
102
103
|
- 💾 **Built-in backup & restore** — with automatic retention policies
|
|
104
|
+
- 🔀 **Automatic Schema Migration** — seamlessly migrate tables when schemas change
|
|
103
105
|
|
|
104
106
|
---
|
|
105
107
|
|
|
@@ -221,6 +223,100 @@ const db = new DB("app.db", {
|
|
|
221
223
|
|
|
222
224
|
---
|
|
223
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
|
+
|
|
224
320
|
## Manual Backup & Restore
|
|
225
321
|
|
|
226
322
|
### Creating Backups
|
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
|
+
MigrationOptions,
|
|
22
|
+
Parser,
|
|
23
|
+
TableOptions,
|
|
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
|
|
|
@@ -210,6 +220,23 @@ class DB {
|
|
|
210
220
|
}
|
|
211
221
|
}
|
|
212
222
|
|
|
223
|
+
private normalizeMigrationOptions(migrate: TableOptions<Record<string, unknown>>["migrate"]): {
|
|
224
|
+
enabled: boolean
|
|
225
|
+
options: MigrationOptions
|
|
226
|
+
} {
|
|
227
|
+
if (migrate === false) return { enabled: false, options: {} }
|
|
228
|
+
if (migrate && typeof migrate === "object") {
|
|
229
|
+
return { enabled: true, options: migrate }
|
|
230
|
+
}
|
|
231
|
+
return { enabled: true, options: {} }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private shouldCheckExistingTable<_T>(tableName: string, options?: TableOptions<_T>): boolean {
|
|
235
|
+
if (options?.temporary) return false
|
|
236
|
+
if (tableName === ":memory:") return false
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
|
|
213
240
|
/**
|
|
214
241
|
* Create a table with comprehensive type safety and feature support.
|
|
215
242
|
*
|
|
@@ -224,7 +251,40 @@ class DB {
|
|
|
224
251
|
columns: Record<keyof _T, ColumnDefinition>,
|
|
225
252
|
options?: TableOptions<_T>
|
|
226
253
|
): QueryBuilder<_T> {
|
|
227
|
-
const
|
|
254
|
+
const { enabled: migrateEnabled, options: migrationOptions } = this.normalizeMigrationOptions(
|
|
255
|
+
options?.migrate
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
const tableAlreadyExists =
|
|
259
|
+
this.shouldCheckExistingTable(tableName, options) && tableExists(this.db, tableName)
|
|
260
|
+
|
|
261
|
+
if (tableAlreadyExists) {
|
|
262
|
+
if (migrateEnabled) {
|
|
263
|
+
let tableConstraints: string[] = []
|
|
264
|
+
if (isTableSchema(columns) && options?.constraints) {
|
|
265
|
+
tableConstraints = buildTableConstraints(options.constraints)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const migrated = checkAndMigrate(
|
|
269
|
+
this.db,
|
|
270
|
+
tableName,
|
|
271
|
+
columns as Record<string, ColumnDefinition>,
|
|
272
|
+
this.migrationLog,
|
|
273
|
+
migrationOptions,
|
|
274
|
+
tableConstraints
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if (migrated) {
|
|
278
|
+
return this._setupTableParser(tableName, columns, options)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!options?.ifNotExists) {
|
|
283
|
+
return this._setupTableParser(tableName, columns, options)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const temp = options?.temporary ? "TEMPORARY " : tableName === ":memory:" ? "TEMPORARY " : ""
|
|
228
288
|
const ifNot = options?.ifNotExists ? "IF NOT EXISTS " : ""
|
|
229
289
|
const withoutRowId = options?.withoutRowId ? " WITHOUT ROWID" : ""
|
|
230
290
|
|
|
@@ -288,6 +348,17 @@ class DB {
|
|
|
288
348
|
helperSetTableComment(this.db, this.tableLog, tableName, options.comment)
|
|
289
349
|
}
|
|
290
350
|
|
|
351
|
+
return this._setupTableParser(tableName, columns, options)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Setup parser for table (extracted for reuse after migration)
|
|
356
|
+
*/
|
|
357
|
+
private _setupTableParser<_T extends Record<string, unknown> = Record<string, unknown>>(
|
|
358
|
+
tableName: string,
|
|
359
|
+
columns: Record<keyof _T, ColumnDefinition>,
|
|
360
|
+
options?: TableOptions<_T>
|
|
361
|
+
): QueryBuilder<_T> {
|
|
291
362
|
// Auto-detect JSON and BOOLEAN columns from schema
|
|
292
363
|
const autoDetectedJson: Array<keyof _T> = []
|
|
293
364
|
const autoDetectedBoolean: Array<keyof _T> = []
|
package/src/migration.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import { buildColumnSQL } from "./lib/table/buildColumnSQL"
|
|
3
|
+
import type {
|
|
4
|
+
ColumnDefinition,
|
|
5
|
+
ForeignKeyInfo,
|
|
6
|
+
ForeignKeyStatus,
|
|
7
|
+
IndexInfo,
|
|
8
|
+
MigrationOptions,
|
|
9
|
+
TableColumn,
|
|
10
|
+
} from "./types"
|
|
11
|
+
import { SqliteLogger } from "./utils/logger"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the current schema of a table
|
|
15
|
+
*/
|
|
16
|
+
export function getTableColumns(db: Database, tableName: string): TableColumn[] {
|
|
17
|
+
const stmt = db.prepare(`PRAGMA table_info("${tableName}")`)
|
|
18
|
+
return stmt.all() as TableColumn[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get indexes for a table
|
|
23
|
+
*/
|
|
24
|
+
export function getTableIndexes(db: Database, tableName: string): IndexInfo[] {
|
|
25
|
+
interface PragmaIndex {
|
|
26
|
+
seq: number
|
|
27
|
+
name: string
|
|
28
|
+
unique: number
|
|
29
|
+
origin: string
|
|
30
|
+
partial: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface SqliteMasterRow {
|
|
34
|
+
sql: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const stmt = db.prepare(`PRAGMA index_list("${tableName}")`)
|
|
38
|
+
const indexes = stmt.all() as PragmaIndex[]
|
|
39
|
+
|
|
40
|
+
return indexes.map((idx) => {
|
|
41
|
+
const sqlStmt = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`)
|
|
42
|
+
const sqlResult = sqlStmt.get(idx.name) as SqliteMasterRow | null
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
name: idx.name,
|
|
46
|
+
sql: sqlResult?.sql || null,
|
|
47
|
+
unique: idx.unique === 1,
|
|
48
|
+
origin: idx.origin,
|
|
49
|
+
partial: idx.partial,
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get foreign keys for a table
|
|
56
|
+
*/
|
|
57
|
+
export function getTableForeignKeys(db: Database, tableName: string): ForeignKeyInfo[] {
|
|
58
|
+
const stmt = db.prepare(`PRAGMA foreign_key_list("${tableName}")`)
|
|
59
|
+
return stmt.all() as ForeignKeyInfo[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get triggers for a table
|
|
64
|
+
*/
|
|
65
|
+
export function getTableTriggers(
|
|
66
|
+
db: Database,
|
|
67
|
+
tableName: string
|
|
68
|
+
): Array<{ name: string; sql: string }> {
|
|
69
|
+
const stmt = db.prepare(
|
|
70
|
+
`SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ?`
|
|
71
|
+
)
|
|
72
|
+
return stmt.all(tableName) as Array<{ name: string; sql: string }>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if a table exists
|
|
77
|
+
*/
|
|
78
|
+
export function tableExists(db: Database, tableName: string): boolean {
|
|
79
|
+
const stmt = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
80
|
+
const result = stmt.get(tableName)
|
|
81
|
+
return result !== null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compare two schemas to determine if migration is needed
|
|
86
|
+
*/
|
|
87
|
+
export function schemasAreDifferent(
|
|
88
|
+
currentSchema: TableColumn[],
|
|
89
|
+
newColumns: Record<string, ColumnDefinition>,
|
|
90
|
+
logger: SqliteLogger
|
|
91
|
+
): boolean {
|
|
92
|
+
logger.info("Comparing schemas")
|
|
93
|
+
const currentColumnNames = new Set(currentSchema.map((col) => col.name))
|
|
94
|
+
const newColumnNames = new Set(Object.keys(newColumns))
|
|
95
|
+
|
|
96
|
+
// Check if column count differs
|
|
97
|
+
if (currentColumnNames.size !== newColumnNames.size) {
|
|
98
|
+
logger.info(
|
|
99
|
+
`Column count differs (current: ${currentColumnNames.size} [${Array.from(currentColumnNames).join(", ")}], new: ${newColumnNames.size} [${Array.from(newColumnNames).join(", ")}])`
|
|
100
|
+
)
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check if all column names match
|
|
105
|
+
for (const name of newColumnNames) {
|
|
106
|
+
if (!currentColumnNames.has(name)) {
|
|
107
|
+
logger.info(`Column ${name} does not exist`)
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
logger.debug(`Column ${name} exists`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check column definitions
|
|
114
|
+
for (const currentCol of currentSchema) {
|
|
115
|
+
const newCol = newColumns[currentCol.name]
|
|
116
|
+
if (!newCol) {
|
|
117
|
+
logger.info(`Column ${currentCol.name} does not exist`)
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// For primary key columns, SQLite auto-adds AUTOINCREMENT which we need to account for
|
|
122
|
+
const isCurrentPK = currentCol.pk === 1
|
|
123
|
+
const isNewPK = newCol.primaryKey === true || newCol.autoincrement === true
|
|
124
|
+
|
|
125
|
+
// If both are primary keys, consider them the same (don't check other constraints)
|
|
126
|
+
if (isCurrentPK && isNewPK) {
|
|
127
|
+
logger.info(`Column ${currentCol.name} is a primary key`)
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isCurrentPK !== isNewPK) {
|
|
132
|
+
logger.info(
|
|
133
|
+
`Column ${currentCol.name} primary key status differs (current: ${isCurrentPK}, new: ${isNewPK})`
|
|
134
|
+
)
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Check if NOT NULL constraint differs (ignore for primary keys as they're always NOT NULL)
|
|
139
|
+
if (!isCurrentPK) {
|
|
140
|
+
const isCurrentNotNull = currentCol.notnull === 1
|
|
141
|
+
const isNewNotNull = newCol.notNull === true
|
|
142
|
+
if (isCurrentNotNull !== isNewNotNull) {
|
|
143
|
+
logger.info(
|
|
144
|
+
`Column ${currentCol.name} NOT NULL constraint differs (current: ${isCurrentNotNull}, new: ${isNewNotNull})`
|
|
145
|
+
)
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Basic type comparison (normalize types)
|
|
151
|
+
const normalizeType = (type: string) => type.toUpperCase().split("(")[0].trim()
|
|
152
|
+
const currentType = normalizeType(currentCol.type)
|
|
153
|
+
const newType = normalizeType(newCol.type)
|
|
154
|
+
|
|
155
|
+
// Handle type aliases
|
|
156
|
+
const typeAliases: Record<string, string> = {
|
|
157
|
+
INT: "INTEGER",
|
|
158
|
+
BOOL: "INTEGER",
|
|
159
|
+
BOOLEAN: "INTEGER",
|
|
160
|
+
JSON: "TEXT",
|
|
161
|
+
VARCHAR: "TEXT",
|
|
162
|
+
CHAR: "TEXT",
|
|
163
|
+
DATETIME: "TEXT",
|
|
164
|
+
DATE: "TEXT",
|
|
165
|
+
TIMESTAMP: "TEXT",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const normalizedCurrentType = typeAliases[currentType] || currentType
|
|
169
|
+
const normalizedNewType = typeAliases[newType] || newType
|
|
170
|
+
|
|
171
|
+
if (normalizedCurrentType !== normalizedNewType) {
|
|
172
|
+
logger.info(`Column ${currentCol.name} type differs`)
|
|
173
|
+
return true
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
logger.info("No schema changes detected")
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Generate column mapping for data migration
|
|
183
|
+
*/
|
|
184
|
+
export function generateColumnMapping(
|
|
185
|
+
currentSchema: TableColumn[],
|
|
186
|
+
newColumns: Record<string, ColumnDefinition>
|
|
187
|
+
): { selectColumns: string[]; insertColumns: string[] } {
|
|
188
|
+
const currentColumnNames = new Set(currentSchema.map((col) => col.name))
|
|
189
|
+
const newColumnNames = Object.keys(newColumns)
|
|
190
|
+
|
|
191
|
+
// Find common columns
|
|
192
|
+
const commonColumns = newColumnNames.filter((name) => currentColumnNames.has(name))
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
selectColumns: commonColumns,
|
|
196
|
+
insertColumns: commonColumns,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Migrate a table to a new schema
|
|
202
|
+
*/
|
|
203
|
+
export function migrateTable(
|
|
204
|
+
db: Database,
|
|
205
|
+
tableName: string,
|
|
206
|
+
newColumns: Record<string, ColumnDefinition>,
|
|
207
|
+
options: MigrationOptions = {},
|
|
208
|
+
tableConstraints: string[] = [],
|
|
209
|
+
migrationLog: SqliteLogger,
|
|
210
|
+
currentSchema?: TableColumn[]
|
|
211
|
+
): void {
|
|
212
|
+
const { preserveData = true, onConflict = "fail", tempTableSuffix = "_migration_temp" } = options
|
|
213
|
+
|
|
214
|
+
migrationLog.info(`Starting migration for table: ${tableName}`)
|
|
215
|
+
|
|
216
|
+
// Get current table info
|
|
217
|
+
const effectiveSchema = currentSchema ?? getTableColumns(db, tableName)
|
|
218
|
+
const indexes = getTableIndexes(db, tableName)
|
|
219
|
+
const triggers = getTableTriggers(db, tableName)
|
|
220
|
+
|
|
221
|
+
// Check if migration is needed
|
|
222
|
+
if (!schemasAreDifferent(effectiveSchema, newColumns, migrationLog)) {
|
|
223
|
+
migrationLog.info(`No migration needed for table: ${tableName}`)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const tempTableName = `${tableName}${tempTableSuffix}`
|
|
228
|
+
|
|
229
|
+
const fkStatus = db.prepare("PRAGMA foreign_keys").get() as ForeignKeyStatus | null
|
|
230
|
+
if (fkStatus && fkStatus.foreign_keys === 1) {
|
|
231
|
+
db.run("PRAGMA foreign_keys = OFF")
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
db.transaction(() => {
|
|
235
|
+
try {
|
|
236
|
+
// Step 1: Create temporary table with new schema
|
|
237
|
+
migrationLog.debug(`Creating temporary table: ${tempTableName}`)
|
|
238
|
+
const columnDefs: string[] = []
|
|
239
|
+
|
|
240
|
+
for (const [colName, colDef] of Object.entries(newColumns)) {
|
|
241
|
+
const sqlDef = buildColumnSQL(colName, colDef)
|
|
242
|
+
columnDefs.push(`"${colName}" ${sqlDef}`)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Include table constraints if provided
|
|
246
|
+
const allDefinitions =
|
|
247
|
+
tableConstraints.length > 0
|
|
248
|
+
? [...columnDefs, ...tableConstraints].join(", ")
|
|
249
|
+
: columnDefs.join(", ")
|
|
250
|
+
|
|
251
|
+
const createTempTableSql = `CREATE TABLE "${tempTableName}" (${allDefinitions})`
|
|
252
|
+
db.run(createTempTableSql)
|
|
253
|
+
|
|
254
|
+
// Step 2: Copy data if requested
|
|
255
|
+
if (preserveData && effectiveSchema.length > 0) {
|
|
256
|
+
const { selectColumns, insertColumns } = generateColumnMapping(effectiveSchema, newColumns)
|
|
257
|
+
|
|
258
|
+
if (selectColumns.length > 0) {
|
|
259
|
+
migrationLog.debug(`Copying data from ${tableName} to ${tempTableName}`)
|
|
260
|
+
|
|
261
|
+
const quotedSelectCols = selectColumns.map((col) => `"${col}"`).join(", ")
|
|
262
|
+
const quotedInsertCols = insertColumns.map((col) => `"${col}"`).join(", ")
|
|
263
|
+
|
|
264
|
+
let copySql = `INSERT INTO "${tempTableName}" (${quotedInsertCols})
|
|
265
|
+
SELECT ${quotedSelectCols} FROM "${tableName}"`
|
|
266
|
+
|
|
267
|
+
if (onConflict === "ignore") {
|
|
268
|
+
copySql = `INSERT OR IGNORE INTO "${tempTableName}" (${quotedInsertCols})
|
|
269
|
+
SELECT ${quotedSelectCols} FROM "${tableName}"`
|
|
270
|
+
} else if (onConflict === "replace") {
|
|
271
|
+
copySql = `INSERT OR REPLACE INTO "${tempTableName}" (${quotedInsertCols})
|
|
272
|
+
SELECT ${quotedSelectCols} FROM "${tableName}"`
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
db.run(copySql)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Step 3: Drop the original table
|
|
280
|
+
migrationLog.debug(`Dropping original table: ${tableName}`)
|
|
281
|
+
|
|
282
|
+
db.run(`DROP TABLE "${tableName}"`)
|
|
283
|
+
|
|
284
|
+
// Step 4: Rename temporary table to original name
|
|
285
|
+
migrationLog.debug(`Renaming ${tempTableName} to ${tableName}`)
|
|
286
|
+
db.run(`ALTER TABLE "${tempTableName}" RENAME TO "${tableName}"`)
|
|
287
|
+
|
|
288
|
+
// Step 5: Recreate indexes
|
|
289
|
+
for (const index of indexes) {
|
|
290
|
+
if (index.sql && !index.sql.includes("sqlite_autoindex")) {
|
|
291
|
+
migrationLog.debug(`Recreating index: ${index.name}`)
|
|
292
|
+
try {
|
|
293
|
+
db.run(index.sql)
|
|
294
|
+
} catch (err) {
|
|
295
|
+
migrationLog.warn(`Failed to recreate index ${index.name}: ${err}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Step 6: Recreate triggers
|
|
301
|
+
for (const trigger of triggers) {
|
|
302
|
+
migrationLog.debug(`Recreating trigger: ${trigger.name}`)
|
|
303
|
+
try {
|
|
304
|
+
db.run(trigger.sql)
|
|
305
|
+
} catch (err) {
|
|
306
|
+
migrationLog.warn(`Failed to recreate trigger ${trigger.name}: ${err}`)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Re-enable foreign key constraints if they were enabled
|
|
311
|
+
|
|
312
|
+
migrationLog.info(`Successfully migrated table: ${tableName}`)
|
|
313
|
+
} catch (error) {
|
|
314
|
+
migrationLog.error(`Migration failed for table ${tableName}: ${error}`)
|
|
315
|
+
throw error
|
|
316
|
+
}
|
|
317
|
+
})()
|
|
318
|
+
|
|
319
|
+
if (fkStatus && fkStatus.foreign_keys === 1) {
|
|
320
|
+
db.run("PRAGMA foreign_keys = ON")
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Check if migration is needed and perform if necessary
|
|
326
|
+
*/
|
|
327
|
+
export function checkAndMigrate(
|
|
328
|
+
db: Database,
|
|
329
|
+
tableName: string,
|
|
330
|
+
newColumns: Record<string, ColumnDefinition>,
|
|
331
|
+
migrationLog: SqliteLogger,
|
|
332
|
+
options?: MigrationOptions,
|
|
333
|
+
tableConstraints: string[] = []
|
|
334
|
+
): boolean {
|
|
335
|
+
const logger = new SqliteLogger(tableName, migrationLog.getBaseLogger())
|
|
336
|
+
|
|
337
|
+
if (!tableExists(db, tableName)) {
|
|
338
|
+
return false
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const currentSchema = getTableColumns(db, tableName)
|
|
342
|
+
|
|
343
|
+
if (schemasAreDifferent(currentSchema, newColumns, logger)) {
|
|
344
|
+
migrateTable(db, tableName, newColumns, options, tableConstraints, migrationLog, currentSchema)
|
|
345
|
+
return true
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return false
|
|
349
|
+
}
|
|
@@ -175,6 +175,31 @@ 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
|
|
|
180
205
|
// Use the protected static helper inherited from WhereQueryBuilder: `safeStringify`
|
|
@@ -190,27 +215,21 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
|
|
|
190
215
|
const hasRegex = this.hasRegexConditions()
|
|
191
216
|
const [query, params] = this.buildSelectQuery(!hasRegex)
|
|
192
217
|
|
|
193
|
-
|
|
194
|
-
this.selectLog.info(`all | query=${query} params=${WhereQueryBuilder.safeStringify(params)}`)
|
|
195
|
-
|
|
218
|
+
this.logSelectStart("all", { query, params })
|
|
196
219
|
this.selectLog.query("SELECT", query, params)
|
|
197
220
|
|
|
198
221
|
const rows = this.getDb()
|
|
199
222
|
.prepare(query)
|
|
200
223
|
.all(...params) as T[]
|
|
201
|
-
|
|
202
224
|
this.selectLog.result("SELECT", rows.length)
|
|
203
225
|
|
|
204
|
-
// Transform rows (JSON/Boolean parsing)
|
|
205
226
|
const transformed = this.transformRowsFromDb(rows)
|
|
206
|
-
|
|
207
|
-
// Apply client-side operations if needed
|
|
208
227
|
const result = hasRegex ? this.applyClientSideOperations(transformed) : transformed
|
|
209
228
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
)
|
|
229
|
+
this.logSelectReturn("all", {
|
|
230
|
+
length: result.length,
|
|
231
|
+
sample: result[0] ?? null,
|
|
232
|
+
})
|
|
214
233
|
|
|
215
234
|
this.reset()
|
|
216
235
|
return result
|
|
@@ -222,58 +241,43 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
|
|
|
222
241
|
* Respects LIMIT if set, otherwise adds LIMIT 1 for efficiency
|
|
223
242
|
*/
|
|
224
243
|
get(): T | null {
|
|
225
|
-
// If no regex and no explicit limit, optimize with LIMIT 1
|
|
226
244
|
if (!this.hasRegexConditions() && this.limitValue === undefined) {
|
|
227
245
|
const [query, params] = this.buildSelectQuery(true)
|
|
228
246
|
const optimizedQuery = `${query} LIMIT 1`
|
|
229
247
|
|
|
230
|
-
|
|
231
|
-
this.selectLog.info(
|
|
232
|
-
`get | optimizedQuery=${optimizedQuery} params=${WhereQueryBuilder.safeStringify(params)}`
|
|
233
|
-
)
|
|
234
|
-
|
|
248
|
+
this.logSelectStart("get", { optimizedQuery, params })
|
|
235
249
|
this.selectLog.query("SELECT (get)", optimizedQuery, params)
|
|
236
250
|
|
|
237
251
|
const row = this.getDb()
|
|
238
252
|
.prepare(optimizedQuery)
|
|
239
253
|
.get(...params) as T | null
|
|
240
|
-
|
|
241
254
|
this.selectLog.result("SELECT (get)", row ? 1 : 0)
|
|
242
255
|
|
|
243
256
|
const result = row ? this.transformRowFromDb(row) : null
|
|
244
|
-
|
|
245
|
-
// Info-level log for returned row
|
|
246
|
-
this.selectLog.info(`get | returned=${WhereQueryBuilder.safeStringify(result)}`)
|
|
257
|
+
this.logSelectReturn("get", { returned: result })
|
|
247
258
|
|
|
248
259
|
this.reset()
|
|
249
260
|
return result
|
|
250
261
|
}
|
|
251
262
|
|
|
252
|
-
// If limit is set or regex conditions exist, use standard flow
|
|
253
263
|
if (!this.hasRegexConditions()) {
|
|
254
264
|
const [query, params] = this.buildSelectQuery(true)
|
|
255
265
|
|
|
256
|
-
|
|
257
|
-
this.selectLog.info(`get | query=${query} params=${WhereQueryBuilder.safeStringify(params)}`)
|
|
258
|
-
|
|
266
|
+
this.logSelectStart("get", { query, params })
|
|
259
267
|
this.selectLog.query("SELECT (get)", query, params)
|
|
260
268
|
|
|
261
269
|
const row = this.getDb()
|
|
262
270
|
.prepare(query)
|
|
263
271
|
.get(...params) as T | null
|
|
264
|
-
|
|
265
272
|
this.selectLog.result("SELECT (get)", row ? 1 : 0)
|
|
266
273
|
|
|
267
274
|
const result = row ? this.transformRowFromDb(row) : null
|
|
268
|
-
|
|
269
|
-
// Info-level log for returned row
|
|
270
|
-
this.selectLog.info(`get | returned=${WhereQueryBuilder.safeStringify(result)}`)
|
|
275
|
+
this.logSelectReturn("get", { returned: result })
|
|
271
276
|
|
|
272
277
|
this.reset()
|
|
273
278
|
return result
|
|
274
279
|
}
|
|
275
280
|
|
|
276
|
-
// Has regex conditions - fall back to all() and take first
|
|
277
281
|
const results = this.all()
|
|
278
282
|
return results[0] ?? null
|
|
279
283
|
}
|
|
@@ -295,30 +299,23 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
|
|
|
295
299
|
*/
|
|
296
300
|
count(): number {
|
|
297
301
|
if (!this.hasRegexConditions()) {
|
|
298
|
-
// Use SQL COUNT for efficiency
|
|
299
302
|
const [whereClause, whereParams] = this.buildWhereClause()
|
|
300
303
|
const query = `SELECT COUNT(*) AS __count FROM ${quoteIdentifier(this.getTableName())}${whereClause}`
|
|
301
304
|
|
|
302
|
-
|
|
303
|
-
this.selectLog.info(
|
|
304
|
-
`count | query=${query} params=${WhereQueryBuilder.safeStringify(whereParams)}`
|
|
305
|
-
)
|
|
306
|
-
|
|
305
|
+
this.logSelectStart("count", { query, params: whereParams })
|
|
307
306
|
this.selectLog.query("COUNT", query, whereParams)
|
|
308
307
|
|
|
309
308
|
const result = this.getDb()
|
|
310
309
|
.prepare(query)
|
|
311
310
|
.get(...whereParams) as { __count: number } | null
|
|
312
311
|
|
|
312
|
+
const count = result?.__count ?? 0
|
|
313
313
|
this.reset()
|
|
314
314
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return result?.__count ?? 0
|
|
315
|
+
this.logSelectReturn("count", { returned: count })
|
|
316
|
+
return count
|
|
319
317
|
}
|
|
320
318
|
|
|
321
|
-
// Has regex conditions - count client-side
|
|
322
319
|
const results = this.all()
|
|
323
320
|
return results.length
|
|
324
321
|
}
|
|
@@ -328,50 +325,31 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
|
|
|
328
325
|
*/
|
|
329
326
|
exists(): boolean {
|
|
330
327
|
if (!this.hasRegexConditions()) {
|
|
331
|
-
// Use EXISTS for efficiency
|
|
332
328
|
const [whereClause, whereParams] = this.buildWhereClause()
|
|
333
329
|
const subquery = `SELECT 1 FROM ${quoteIdentifier(this.getTableName())}${whereClause} LIMIT 1`
|
|
334
330
|
const query = `SELECT EXISTS(${subquery}) AS __exists`
|
|
335
331
|
|
|
336
|
-
|
|
337
|
-
this.selectLog.info(
|
|
338
|
-
`exists | query=${query} params=${WhereQueryBuilder.safeStringify(whereParams)}`
|
|
339
|
-
)
|
|
340
|
-
|
|
332
|
+
this.logSelectStart("exists", { query, params: whereParams })
|
|
341
333
|
this.selectLog.query("EXISTS", query, whereParams)
|
|
342
334
|
|
|
343
335
|
const result = this.getDb()
|
|
344
336
|
.prepare(query)
|
|
345
337
|
.get(...whereParams) as { __exists: number } | null
|
|
346
338
|
|
|
339
|
+
const exists = Boolean(result?.__exists)
|
|
347
340
|
this.reset()
|
|
348
341
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
return Boolean(result?.__exists)
|
|
342
|
+
this.logSelectReturn("exists", { returned: exists })
|
|
343
|
+
return exists
|
|
353
344
|
}
|
|
354
345
|
|
|
355
|
-
// Has regex conditions - check client-side
|
|
356
346
|
return this.count() > 0
|
|
357
347
|
}
|
|
358
348
|
|
|
359
|
-
|
|
360
|
-
* Get a single column value from the first matching row
|
|
361
|
-
*
|
|
362
|
-
* @example
|
|
363
|
-
* const name = table.where({ id: 1 }).value("name")
|
|
364
|
-
*/
|
|
365
|
-
value<K extends keyof T>(column: K): T[K] | null {
|
|
366
|
-
const row = this.first()
|
|
367
|
-
const value = row ? row[column] : null
|
|
368
|
-
|
|
369
|
-
// Info-level log
|
|
349
|
+
private logColumnReturn(method: "value" | "pluck", column: string, returned: unknown): void {
|
|
370
350
|
this.selectLog.info(
|
|
371
|
-
|
|
351
|
+
`${method} | column=${column} returned=${WhereQueryBuilder.safeStringify(returned)}`
|
|
372
352
|
)
|
|
373
|
-
|
|
374
|
-
return value
|
|
375
353
|
}
|
|
376
354
|
|
|
377
355
|
/**
|
|
@@ -381,14 +359,24 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
|
|
|
381
359
|
* const emails = table.where({ active: true }).pluck("email")
|
|
382
360
|
*/
|
|
383
361
|
pluck<K extends keyof T>(column: K): T[K][] {
|
|
384
|
-
const rows = this.all()
|
|
362
|
+
const rows = this.all() || []
|
|
385
363
|
const values = rows.map((row) => row[column])
|
|
386
364
|
|
|
387
|
-
|
|
388
|
-
this.selectLog.info(
|
|
389
|
-
`pluck | column=${String(column)} returned=${WhereQueryBuilder.safeStringify(values)}`
|
|
390
|
-
)
|
|
391
|
-
|
|
365
|
+
this.logColumnReturn("pluck", String(column), values)
|
|
392
366
|
return values
|
|
393
367
|
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Get a single column value from the first matching row
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* const name = table.where({ id: 1 }).value("name")
|
|
374
|
+
*/
|
|
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
|
|
381
|
+
}
|
|
394
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
|
|
@@ -57,9 +77,10 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
57
77
|
// Helper to stringify values safely (converts RegExp to string and falls back)
|
|
58
78
|
protected static safeStringify(obj: unknown): string {
|
|
59
79
|
try {
|
|
60
|
-
|
|
80
|
+
const dat = JSON.stringify(obj, (_k, v) => (v instanceof RegExp ? v.toString() : v))
|
|
81
|
+
return truncate(dat, 100)
|
|
61
82
|
} catch {
|
|
62
|
-
return String(obj)
|
|
83
|
+
return truncate(String(obj), 100)
|
|
63
84
|
}
|
|
64
85
|
}
|
|
65
86
|
|
|
@@ -75,8 +96,7 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
75
96
|
* // WHERE "deleted_at" IS NULL
|
|
76
97
|
*/
|
|
77
98
|
where(conditions: WhereCondition<T>): this {
|
|
78
|
-
|
|
79
|
-
this.log.info(`where | conditions=${WhereQueryBuilder.safeStringify(conditions)}`)
|
|
99
|
+
this.logWhere("where", { conditions })
|
|
80
100
|
|
|
81
101
|
for (const [column, value] of Object.entries(conditions)) {
|
|
82
102
|
this.removeExistingCondition(column)
|
|
@@ -89,13 +109,7 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
89
109
|
}
|
|
90
110
|
}
|
|
91
111
|
|
|
92
|
-
|
|
93
|
-
this.log.info(
|
|
94
|
-
`where | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
|
|
95
|
-
this.state.whereParams
|
|
96
|
-
)}`
|
|
97
|
-
)
|
|
98
|
-
|
|
112
|
+
this.logWhereState("where")
|
|
99
113
|
return this
|
|
100
114
|
}
|
|
101
115
|
|
|
@@ -130,11 +144,7 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
// Log resulting WHERE/regex state
|
|
133
|
-
this.
|
|
134
|
-
`whereRgx | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} regexConditions=${WhereQueryBuilder.safeStringify(
|
|
135
|
-
this.state.regexConditions
|
|
136
|
-
)} params=${WhereQueryBuilder.safeStringify(this.state.whereParams)}`
|
|
137
|
-
)
|
|
147
|
+
this.logWhereState("whereRgx", { regexConditions: this.state.regexConditions })
|
|
138
148
|
|
|
139
149
|
return this
|
|
140
150
|
}
|
|
@@ -256,25 +266,20 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
256
266
|
* .whereOp("name", "LIKE", "%smith%")
|
|
257
267
|
*/
|
|
258
268
|
whereOp(column: keyof T, op: string, value: SQLQueryBindings): this {
|
|
259
|
-
|
|
260
|
-
this.
|
|
261
|
-
`whereOp | column=${String(column)} op=${op} value=${WhereQueryBuilder.safeStringify(value)}`
|
|
262
|
-
)
|
|
269
|
+
const columnStr = String(column)
|
|
270
|
+
this.logWhere("whereOp", { column: columnStr, op, value })
|
|
263
271
|
|
|
264
272
|
const normalizedOp = normalizeOperator(op)
|
|
265
|
-
const columnStr = String(column)
|
|
266
273
|
|
|
267
|
-
// Handle NULL special cases
|
|
268
274
|
if (value === null || value === undefined) {
|
|
269
275
|
if (normalizedOp === "=" || normalizedOp === "IS") {
|
|
270
276
|
this.state.whereConditions.push(`${quoteIdentifier(columnStr)} IS NULL`)
|
|
271
|
-
|
|
272
|
-
this.log.info(`whereOp | added IS NULL for ${columnStr}`)
|
|
277
|
+
this.logWhere("whereOp", { column: columnStr, added: "IS NULL" })
|
|
273
278
|
return this
|
|
274
279
|
}
|
|
275
280
|
if (normalizedOp === "!=" || normalizedOp === "<>" || normalizedOp === "IS NOT") {
|
|
276
281
|
this.state.whereConditions.push(`${quoteIdentifier(columnStr)} IS NOT NULL`)
|
|
277
|
-
this.
|
|
282
|
+
this.logWhere("whereOp", { column: columnStr, added: "IS NOT NULL" })
|
|
278
283
|
return this
|
|
279
284
|
}
|
|
280
285
|
}
|
|
@@ -282,13 +287,7 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
|
|
|
282
287
|
this.state.whereConditions.push(`${quoteIdentifier(columnStr)} ${normalizedOp} ?`)
|
|
283
288
|
this.state.whereParams.push(value)
|
|
284
289
|
|
|
285
|
-
|
|
286
|
-
this.log.info(
|
|
287
|
-
`whereOp | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
|
|
288
|
-
this.state.whereParams
|
|
289
|
-
)}`
|
|
290
|
-
)
|
|
291
|
-
|
|
290
|
+
this.logWhereState("whereOp")
|
|
292
291
|
return this
|
|
293
292
|
}
|
|
294
293
|
|
package/src/types.ts
CHANGED
|
@@ -237,6 +237,15 @@ 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
|
+
/** How to handle constraint violations during data copy */
|
|
244
|
+
onConflict?: "fail" | "ignore" | "replace"
|
|
245
|
+
/** Suffix for temporary table during migration (default: '_migration_temp') */
|
|
246
|
+
tempTableSuffix?: string
|
|
247
|
+
}
|
|
248
|
+
|
|
240
249
|
export interface TableOptions<T> {
|
|
241
250
|
/** Add IF NOT EXISTS clause */
|
|
242
251
|
ifNotExists?: boolean
|
|
@@ -248,6 +257,8 @@ export interface TableOptions<T> {
|
|
|
248
257
|
temporary?: boolean
|
|
249
258
|
/** Table comment */
|
|
250
259
|
comment?: string
|
|
260
|
+
/** Enable automatic schema migration (default: true) */
|
|
261
|
+
migrate?: boolean | MigrationOptions
|
|
251
262
|
|
|
252
263
|
parser?: Partial<Parser<T>>
|
|
253
264
|
}
|
|
@@ -461,9 +472,9 @@ export const column = {
|
|
|
461
472
|
/**
|
|
462
473
|
* Create a foreign key reference column
|
|
463
474
|
*/
|
|
464
|
-
foreignKey: (
|
|
475
|
+
foreignKey: <_T extends Record<string, unknown>>(
|
|
465
476
|
refTable: string,
|
|
466
|
-
refColumn = "id",
|
|
477
|
+
refColumn: keyof _T = "id",
|
|
467
478
|
constraints?: ColumnConstraints & {
|
|
468
479
|
onDelete?: ForeignKeyAction
|
|
469
480
|
onUpdate?: ForeignKeyAction
|
|
@@ -473,7 +484,7 @@ export const column = {
|
|
|
473
484
|
type: constraints?.type || SQLiteTypes.INTEGER,
|
|
474
485
|
references: {
|
|
475
486
|
table: refTable,
|
|
476
|
-
column: refColumn,
|
|
487
|
+
column: String(refColumn),
|
|
477
488
|
onDelete: constraints?.onDelete,
|
|
478
489
|
onUpdate: constraints?.onUpdate,
|
|
479
490
|
},
|
|
@@ -617,3 +628,35 @@ export type SqlParameter = SQLQueryBindings
|
|
|
617
628
|
* Database row with unknown structure
|
|
618
629
|
*/
|
|
619
630
|
export type DatabaseRowData = Record<string, SQLQueryBindings>
|
|
631
|
+
|
|
632
|
+
export interface TableColumn {
|
|
633
|
+
cid: number
|
|
634
|
+
name: string
|
|
635
|
+
type: string
|
|
636
|
+
notnull: number
|
|
637
|
+
dflt_value: string | number | null
|
|
638
|
+
pk: number
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export interface IndexInfo {
|
|
642
|
+
name: string
|
|
643
|
+
sql: string | null
|
|
644
|
+
unique: boolean
|
|
645
|
+
origin: string
|
|
646
|
+
partial: number
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export interface ForeignKeyInfo {
|
|
650
|
+
id: number
|
|
651
|
+
seq: number
|
|
652
|
+
table: string
|
|
653
|
+
from: string
|
|
654
|
+
to: string
|
|
655
|
+
on_update: string
|
|
656
|
+
on_delete: string
|
|
657
|
+
match: string
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export interface ForeignKeyStatus {
|
|
661
|
+
foreign_keys: number
|
|
662
|
+
}
|
package/src/utils/index.ts
CHANGED