Port bun-mariadb schema guard, targetId fix, lazy init and tests
- schema: add --dry-run (sync in a rolled-back transaction) and require confirmation (or --yes) before dropping tables or columns; unapproved destructive changes are refused - only rebuild tables whose CREATE TABLE statement changed - always AND targetId with the query, regardless of searchOperator - empty IN / NOT IN lists become 1 = 0 / 1 = 1 - initialize lazily on first call so importing never throws - read CLI version from package.json, chmod CLI on compile, pin @types/bun - add SQL generator, value and schema guard tests Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -32,6 +32,7 @@ A schema-driven SQLite manager for [Bun](https://bun.sh), featuring automatic sc
|
|||||||
- [TypeScript Type Generation](#typescript-type-generation)
|
- [TypeScript Type Generation](#typescript-type-generation)
|
||||||
- [Default Fields](#default-fields)
|
- [Default Fields](#default-fields)
|
||||||
- [Project Structure](#project-structure)
|
- [Project Structure](#project-structure)
|
||||||
|
- [Testing](#testing)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -272,6 +273,8 @@ bunx bun-sqlite schema [options]
|
|||||||
| ----------------- | ---------------------------------------------------------- |
|
| ----------------- | ---------------------------------------------------------- |
|
||||||
| `-v`, `--vector` | Drop and recreate all vector (`sqlite-vec`) virtual tables |
|
| `-v`, `--vector` | Drop and recreate all vector (`sqlite-vec`) virtual tables |
|
||||||
| `-t`, `--typedef` | Also generate TypeScript type definitions after syncing |
|
| `-t`, `--typedef` | Also generate TypeScript type definitions after syncing |
|
||||||
|
| `--dry-run` | Print the planned schema changes without applying them |
|
||||||
|
| `-y`, `--yes` | Apply destructive changes (dropped tables / columns) without confirmation |
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
@@ -284,8 +287,13 @@ bunx bun-sqlite schema --typedef
|
|||||||
|
|
||||||
# Sync schema, recreate vector tables, and regenerate types
|
# Sync schema, recreate vector tables, and regenerate types
|
||||||
bunx bun-sqlite schema --vector --typedef
|
bunx bun-sqlite schema --vector --typedef
|
||||||
|
|
||||||
|
# Review planned changes first
|
||||||
|
bunx bun-sqlite schema --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Only tables whose definition changed are rebuilt. Changes that discard data (dropping a table, or a column removed from the schema) are listed and need confirmation before they run. Non-interactive runs (CI, scripts) refuse them unless `--yes` is passed. The plan is exact: `--dry-run` runs the whole sync in a transaction and rolls it back.
|
||||||
|
|
||||||
### `typedef` — Generate TypeScript types only
|
### `typedef` — Generate TypeScript types only
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -844,6 +852,16 @@ bun-sqlite/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs the unit tests (`tests/`) and the schema sync tests (`src/lib/schema/*.test.ts`) against in-memory SQLite databases. No setup needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"sqlite-vec": "^0.1.7-alpha.2",
|
"sqlite-vec": "^0.1.7-alpha.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "1.3.14",
|
||||||
"@types/inquirer": "^9.0.9",
|
"@types/inquirer": "^9.0.9",
|
||||||
"@types/lodash": "^4.17.24",
|
"@types/lodash": "^4.17.24",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
"@inquirer/type": ["@inquirer/[email protected]", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA=="],
|
"@inquirer/type": ["@inquirer/[email protected]", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/[email protected].9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
"@types/bun": ["@types/[email protected].14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||||
|
|
||||||
"@types/inquirer": ["@types/[email protected]", "", { "dependencies": { "@types/through": "*", "rxjs": "^7.2.0" } }, "sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw=="],
|
"@types/inquirer": ["@types/[email protected]", "", { "dependencies": { "@types/through": "*", "rxjs": "^7.2.0" } }, "sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw=="],
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
|
|
||||||
"@types/through": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="],
|
"@types/through": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="],
|
||||||
|
|
||||||
"bun-types": ["[email protected].9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
"bun-types": ["[email protected].14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
"chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
"chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -26,13 +26,13 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
"compile": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json",
|
"compile": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json && chmod +x dist/commands/index.js",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "bun test src",
|
"test": "bun test src tests",
|
||||||
"prepublishOnly": "bun run compile"
|
"prepublishOnly": "bun run compile"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "1.3.14",
|
||||||
"@types/inquirer": "^9.0.9",
|
"@types/inquirer": "^9.0.9",
|
||||||
"@types/lodash": "^4.17.24",
|
"@types/lodash": "^4.17.24",
|
||||||
"@types/node": "^25.3.3"
|
"@types/node": "^25.3.3"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import exportCmd from "./export";
|
|||||||
import importCmd from "./import";
|
import importCmd from "./import";
|
||||||
import admin from "./admin";
|
import admin from "./admin";
|
||||||
import init from "../functions/init";
|
import init from "../functions/init";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
init();
|
init();
|
||||||
@@ -17,13 +18,18 @@ try {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolves to the package root from both src/commands and dist/commands */
|
||||||
|
const { version } = JSON.parse(
|
||||||
|
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Describe Program
|
* # Describe Program
|
||||||
*/
|
*/
|
||||||
program
|
program
|
||||||
.name(`bun-sqlite`)
|
.name(`bun-sqlite`)
|
||||||
.description(`SQLite manager for Bun`)
|
.description(`SQLite manager for Bun`)
|
||||||
.version(`1.0.0`);
|
.version(version);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Declare Commands
|
* # Declare Commands
|
||||||
|
|||||||
+93
-10
@@ -9,6 +9,8 @@ import { writeLiveSchema } from "../functions/live-schema";
|
|||||||
import grabDBDir from "../utils/grab-db-dir";
|
import grabDBDir from "../utils/grab-db-dir";
|
||||||
import snapshotDb from "../utils/snapshot-db";
|
import snapshotDb from "../utils/snapshot-db";
|
||||||
import replaceDbFile from "../utils/replace-db-file";
|
import replaceDbFile from "../utils/replace-db-file";
|
||||||
|
import { confirm } from "@inquirer/prompts";
|
||||||
|
import type { SchemaChange } from "../types";
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
return new Command("schema")
|
return new Command("schema")
|
||||||
@@ -18,6 +20,14 @@ export default function () {
|
|||||||
"Recreate Vector Tables. This will drop and rebuild all vector tables",
|
"Recreate Vector Tables. This will drop and rebuild all vector tables",
|
||||||
)
|
)
|
||||||
.option("-t, --typedef", "Generate typescript type definitions")
|
.option("-t, --typedef", "Generate typescript type definitions")
|
||||||
|
.option(
|
||||||
|
"--dry-run",
|
||||||
|
"Print the schema changes that would be applied, without applying them",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"-y, --yes",
|
||||||
|
"Apply destructive changes (dropped tables / columns) without confirmation",
|
||||||
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
console.log(`Starting process ...`);
|
console.log(`Starting process ...`);
|
||||||
|
|
||||||
@@ -27,20 +37,93 @@ export default function () {
|
|||||||
const { ROOT_DIR, BUN_SQLITE_TEMP_DB_FILE_PATH } = grabDirNames();
|
const { ROOT_DIR, BUN_SQLITE_TEMP_DB_FILE_PATH } = grabDirNames();
|
||||||
const { db_file_path } = grabDBDir({ config });
|
const { db_file_path } = grabDBDir({ config });
|
||||||
|
|
||||||
|
const isVector = Boolean(opts.vector || opts.v);
|
||||||
|
const isTypeDef = Boolean(opts.typedef || opts.t);
|
||||||
|
|
||||||
|
const finaldbSchema = appendDefaultFieldsToDbSchema({
|
||||||
|
dbSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
db: DbClient,
|
||||||
|
db_schema: finaldbSchema,
|
||||||
|
recreate_vector_table: isVector,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan: run the sync in a transaction that is rolled back
|
||||||
|
*/
|
||||||
|
const plan: SchemaChange[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
createDBSchema({ ...params, dry_run: true, changes: plan });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(chalk.red(`Schema sync ERROR => ${error.message}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destructive = plan.filter((change) => change.destructive);
|
||||||
|
|
||||||
|
if (opts.dryRun) {
|
||||||
|
if (!plan.length) {
|
||||||
|
console.log(chalk.green(`Schema is up to date.`));
|
||||||
|
} else {
|
||||||
|
console.log(chalk.bold(`Planned schema changes:\n`));
|
||||||
|
for (const change of plan) {
|
||||||
|
console.log(
|
||||||
|
change.destructive
|
||||||
|
? chalk.red(` ${change.description}`)
|
||||||
|
: ` ${change.description}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (destructive.length) {
|
||||||
|
console.log(
|
||||||
|
chalk.red(
|
||||||
|
`\n${destructive.length} destructive change(s) (in red) would discard data.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destructive.length && !opts.yes) {
|
||||||
|
console.log(
|
||||||
|
chalk.yellow(`\nThese changes will permanently discard data:\n`),
|
||||||
|
);
|
||||||
|
for (const change of destructive) {
|
||||||
|
console.log(chalk.red(` ${change.description}`));
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
console.error(
|
||||||
|
chalk.red(
|
||||||
|
`Refusing to apply destructive changes non-interactively. Re-run with --yes to apply them.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proceed = await confirm({
|
||||||
|
message: `Apply ${destructive.length} destructive change(s)?`,
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!proceed) {
|
||||||
|
console.log(chalk.dim(`Schema sync cancelled.`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
snapshotDb({ db: DbClient, dst: BUN_SQLITE_TEMP_DB_FILE_PATH });
|
snapshotDb({ db: DbClient, dst: BUN_SQLITE_TEMP_DB_FILE_PATH });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isVector = Boolean(opts.vector || opts.v);
|
|
||||||
const isTypeDef = Boolean(opts.typedef || opts.t);
|
|
||||||
|
|
||||||
const finaldbSchema = appendDefaultFieldsToDbSchema({
|
|
||||||
dbSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
createDBSchema({
|
createDBSchema({
|
||||||
db: DbClient,
|
...params,
|
||||||
db_schema: finaldbSchema,
|
allow_destructive: opts.yes
|
||||||
recreate_vector_table: isVector,
|
? true
|
||||||
|
: new Set(destructive.map((c) => c.description)),
|
||||||
});
|
});
|
||||||
|
|
||||||
DbClient.close();
|
DbClient.close();
|
||||||
|
|||||||
+16
-7
@@ -7,16 +7,25 @@ import DbUpdate from "./lib/sqlite/db-update";
|
|||||||
import grabDbSchema from "./utils/grab-db-schema";
|
import grabDbSchema from "./utils/grab-db-schema";
|
||||||
import grabJoinFieldsFromQueryObject from "./utils/grab-join-fields-from-query-object";
|
import grabJoinFieldsFromQueryObject from "./utils/grab-join-fields-from-query-object";
|
||||||
|
|
||||||
init();
|
/**
|
||||||
|
* Initialize lazily on first call, so importing the library never throws.
|
||||||
|
* Setup errors (missing config / schema) reject the call instead.
|
||||||
|
*/
|
||||||
|
function withInit<F extends (...args: any[]) => Promise<any>>(fn: F): F {
|
||||||
|
return (async (...args: Parameters<F>) => {
|
||||||
|
init();
|
||||||
|
return fn(...args);
|
||||||
|
}) as F;
|
||||||
|
}
|
||||||
|
|
||||||
const BunSQLite = {
|
const BunSQLite = {
|
||||||
select: DbSelect,
|
select: withInit(DbSelect),
|
||||||
insert: DbInsert,
|
insert: withInit(DbInsert),
|
||||||
update: DbUpdate,
|
update: withInit(DbUpdate),
|
||||||
delete: DbDelete,
|
delete: withInit(DbDelete),
|
||||||
sql: DbSQL,
|
sql: withInit(DbSQL),
|
||||||
utils: {
|
utils: {
|
||||||
grab_db_schema: grabDbSchema,
|
grab_db_schema: withInit(grabDbSchema),
|
||||||
grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject,
|
grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type {
|
||||||
|
BUN_SQLITE_DatabaseSchemaType,
|
||||||
|
BUN_SQLITE_TableSchemaType,
|
||||||
|
} from "../../types";
|
||||||
|
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
|
||||||
|
import buildColumnDefinition from "./build-column-definition";
|
||||||
|
import buildForeignKeyConstraint from "./build-foreign-key-constraint";
|
||||||
|
import resolveTable from "./resolve-table";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
db_schema: BUN_SQLITE_DatabaseSchemaType;
|
||||||
|
table: BUN_SQLITE_TableSchemaType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `CREATE TABLE` (or `CREATE VIRTUAL TABLE`) statement for a table
|
||||||
|
*/
|
||||||
|
export default function buildCreateTableSql({ db_schema, table }: Params) {
|
||||||
|
const new_table = resolveTable({ table, db_schema });
|
||||||
|
|
||||||
|
const columns: string[] = [];
|
||||||
|
const foreignKeys: string[] = [];
|
||||||
|
|
||||||
|
for (const field of new_table.fields) {
|
||||||
|
columns.push(buildColumnDefinition(field, new_table.isVector));
|
||||||
|
|
||||||
|
if (field.foreignKey && !new_table.isVector) {
|
||||||
|
foreignKeys.push(buildForeignKeyConstraint(field));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add unique constraints
|
||||||
|
for (const constraint of new_table.uniqueConstraints || []) {
|
||||||
|
if (!constraint.constraintTableFields?.length) continue;
|
||||||
|
|
||||||
|
const fieldNames = constraint.constraintTableFields.map((f) =>
|
||||||
|
String(f.value),
|
||||||
|
);
|
||||||
|
const constraintName =
|
||||||
|
constraint.constraintName || `unique_${fieldNames.join(", ")}`;
|
||||||
|
|
||||||
|
columns.push(
|
||||||
|
`CONSTRAINT ${q(constraintName)} UNIQUE (${fieldNames
|
||||||
|
.map(q)
|
||||||
|
.join(", ")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allConstraints = [...columns, ...foreignKeys];
|
||||||
|
|
||||||
|
return new_table.isVector
|
||||||
|
? `CREATE VIRTUAL TABLE ${q(new_table.tableName)} USING ${new_table.vectorType || "vec0"}(${allConstraints.join(", ")})`
|
||||||
|
: `CREATE TABLE ${q(new_table.tableName)} (${allConstraints.join(", ")})`;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Database } from "bun:sqlite";
|
|||||||
import type {
|
import type {
|
||||||
BUN_SQLITE_FieldSchemaType,
|
BUN_SQLITE_FieldSchemaType,
|
||||||
BUN_SQLITE_TableSchemaType,
|
BUN_SQLITE_TableSchemaType,
|
||||||
|
SchemaChange,
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
import createDBSchema from "./create-db-schema";
|
import createDBSchema from "./create-db-schema";
|
||||||
|
|
||||||
@@ -38,8 +39,13 @@ const posts: BUN_SQLITE_TableSchemaType = {
|
|||||||
|
|
||||||
let db: Database;
|
let db: Database;
|
||||||
|
|
||||||
function sync(tables: BUN_SQLITE_TableSchemaType[]) {
|
function sync(
|
||||||
createDBSchema({ db, db_schema: { tables } });
|
tables: BUN_SQLITE_TableSchemaType[],
|
||||||
|
options: { dry_run?: boolean; allow_destructive?: true | Set<string> } = {},
|
||||||
|
) {
|
||||||
|
const changes: SchemaChange[] = [];
|
||||||
|
createDBSchema({ db, db_schema: { tables }, changes, ...options });
|
||||||
|
return changes.map((c) => (c.destructive ? `! ${c.description}` : c.description));
|
||||||
}
|
}
|
||||||
|
|
||||||
function tableNames() {
|
function tableNames() {
|
||||||
@@ -143,4 +149,48 @@ describe("createDBSchema", () => {
|
|||||||
),
|
),
|
||||||
).toEqual(["id", "name"]);
|
).toEqual(["id", "name"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("leaves unchanged tables alone", () => {
|
||||||
|
sync([users, posts]);
|
||||||
|
|
||||||
|
expect(sync([users, posts])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records changes, marking data loss as destructive", () => {
|
||||||
|
sync([users, posts]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sync([{ ...users, fields: [id, { fieldName: "email", dataType: "TEXT" }] }]),
|
||||||
|
).toEqual([
|
||||||
|
`Rebuild table "users"`,
|
||||||
|
`! Drop column "users"."name"`,
|
||||||
|
`! Drop table "posts"`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dry run changes nothing", () => {
|
||||||
|
sync([users, posts]);
|
||||||
|
db.run(`INSERT INTO users (name) VALUES ('ada')`);
|
||||||
|
|
||||||
|
expect(sync([], { dry_run: true })).toEqual([
|
||||||
|
`! Drop table "users"`,
|
||||||
|
`! Drop table "posts"`,
|
||||||
|
]);
|
||||||
|
expect(tableNames()).toEqual(["posts", "users"]);
|
||||||
|
expect(db.query(`SELECT name FROM users`).all()).toEqual([
|
||||||
|
{ name: "ada" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses unapproved destructive changes and rolls back", () => {
|
||||||
|
sync([users, posts]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
sync([users], { allow_destructive: new Set() }),
|
||||||
|
).toThrow(/Refusing unapproved destructive change/);
|
||||||
|
expect(tableNames()).toEqual(["posts", "users"]);
|
||||||
|
|
||||||
|
sync([users], { allow_destructive: new Set([`Drop table "posts"`]) });
|
||||||
|
expect(tableNames()).toEqual(["users"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,21 +8,15 @@ import orderDBSchema from "./order-db-schema";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* # Sync the database to the schema
|
* # Sync the database to the schema
|
||||||
* @description Tables are rebuilt by copying into a new table and dropping
|
* @description Changed tables are rebuilt by copying into a new table and
|
||||||
* the old one. With foreign keys ON, `DROP TABLE` runs an implicit
|
* dropping the old one. With foreign keys ON, `DROP TABLE` runs an implicit
|
||||||
* `DELETE FROM` that fires `ON DELETE CASCADE` on child tables, so keys are
|
* `DELETE FROM` that fires `ON DELETE CASCADE` on child tables, so keys are
|
||||||
* disabled for the sync and checked before committing. (`PRAGMA
|
* disabled for the sync and checked before committing. (`PRAGMA
|
||||||
* foreign_keys` is a no-op inside a transaction.)
|
* foreign_keys` is a no-op inside a transaction.) With `dry_run` the
|
||||||
|
* transaction is rolled back, so `changes` is the exact plan.
|
||||||
*/
|
*/
|
||||||
export default function createDBSchema(params: CreateDBSchemaParams) {
|
export default function createDBSchema(params: CreateDBSchemaParams) {
|
||||||
const { db } = params;
|
const { db, dry_run } = params;
|
||||||
|
|
||||||
console.log("Starting schema synchronization...");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Schema Manager Table
|
|
||||||
*/
|
|
||||||
createDBManagerTable({ db });
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reorder Tables (parents before children with FKs)
|
* Reorder Tables (parents before children with FKs)
|
||||||
@@ -33,6 +27,11 @@ export default function createDBSchema(params: CreateDBSchemaParams) {
|
|||||||
db.run("BEGIN");
|
db.run("BEGIN");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
/**
|
||||||
|
* Create Schema Manager Table
|
||||||
|
*/
|
||||||
|
createDBManagerTable({ db });
|
||||||
|
|
||||||
const existing_tables = getExistingTablesFromTablesManagerTable({
|
const existing_tables = getExistingTablesFromTablesManagerTable({
|
||||||
db,
|
db,
|
||||||
});
|
});
|
||||||
@@ -53,7 +52,7 @@ export default function createDBSchema(params: CreateDBSchemaParams) {
|
|||||||
* Drop tables that no longer exist in schema
|
* Drop tables that no longer exist in schema
|
||||||
*/
|
*/
|
||||||
dropRemovedTables({
|
dropRemovedTables({
|
||||||
db,
|
...params,
|
||||||
existing_tables,
|
existing_tables,
|
||||||
schema_tables: db_schema.tables.map((t) => t.tableName),
|
schema_tables: db_schema.tables.map((t) => t.tableName),
|
||||||
});
|
});
|
||||||
@@ -72,13 +71,11 @@ export default function createDBSchema(params: CreateDBSchemaParams) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.run("COMMIT");
|
db.run(dry_run ? "ROLLBACK" : "COMMIT");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (db.inTransaction) db.run("ROLLBACK");
|
if (db.inTransaction) db.run("ROLLBACK");
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
db.run("PRAGMA foreign_keys = ON;");
|
db.run("PRAGMA foreign_keys = ON;");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Schema synchronization complete!");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import type {
|
|||||||
BUN_SQLITE_TableSchemaType,
|
BUN_SQLITE_TableSchemaType,
|
||||||
CreateDBSchemaParams,
|
CreateDBSchemaParams,
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
|
import buildCreateTableSql from "./build-create-table-sql";
|
||||||
import buildColumnDefinition from "./build-column-definition";
|
import recordSchemaChange from "./record-schema-change";
|
||||||
import buildForeignKeyConstraint from "./build-foreign-key-constraint";
|
|
||||||
import resolveTable from "./resolve-table";
|
|
||||||
|
|
||||||
type Params = CreateDBSchemaParams & {
|
type Params = CreateDBSchemaParams & {
|
||||||
table: BUN_SQLITE_TableSchemaType;
|
table: BUN_SQLITE_TableSchemaType;
|
||||||
@@ -14,46 +12,12 @@ type Params = CreateDBSchemaParams & {
|
|||||||
/**
|
/**
|
||||||
* Create a new table
|
* Create a new table
|
||||||
*/
|
*/
|
||||||
export default function createTable({ db, db_schema, table }: Params) {
|
export default function createTable(params: Params) {
|
||||||
if (!table.tableName.match(/_temp_\d+$/)) {
|
const { db, db_schema, table } = params;
|
||||||
console.log(`Creating table: ${table.tableName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const new_table = resolveTable({ table, db_schema });
|
recordSchemaChange(params, {
|
||||||
|
description: `Create table "${table.tableName}"`,
|
||||||
|
});
|
||||||
|
|
||||||
const columns: string[] = [];
|
db.run(buildCreateTableSql({ db_schema, table }));
|
||||||
const foreignKeys: string[] = [];
|
|
||||||
|
|
||||||
for (const field of new_table.fields) {
|
|
||||||
columns.push(buildColumnDefinition(field, new_table.isVector));
|
|
||||||
|
|
||||||
if (field.foreignKey && !new_table.isVector) {
|
|
||||||
foreignKeys.push(buildForeignKeyConstraint(field));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add unique constraints
|
|
||||||
for (const constraint of new_table.uniqueConstraints || []) {
|
|
||||||
if (!constraint.constraintTableFields?.length) continue;
|
|
||||||
|
|
||||||
const fieldNames = constraint.constraintTableFields.map((f) =>
|
|
||||||
String(f.value),
|
|
||||||
);
|
|
||||||
const constraintName =
|
|
||||||
constraint.constraintName || `unique_${fieldNames.join(", ")}`;
|
|
||||||
|
|
||||||
columns.push(
|
|
||||||
`CONSTRAINT ${q(constraintName)} UNIQUE (${fieldNames
|
|
||||||
.map(q)
|
|
||||||
.join(", ")})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allConstraints = [...columns, ...foreignKeys];
|
|
||||||
|
|
||||||
const sql = new_table.isVector
|
|
||||||
? `CREATE VIRTUAL TABLE ${q(new_table.tableName)} USING ${new_table.vectorType || "vec0"}(${allConstraints.join(", ")})`
|
|
||||||
: `CREATE TABLE ${q(new_table.tableName)} (${allConstraints.join(", ")})`;
|
|
||||||
|
|
||||||
db.run(sql);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { Database } from "bun:sqlite";
|
import _ from "lodash";
|
||||||
|
import type { CreateDBSchemaParams } from "../../types";
|
||||||
import { readLiveSchema } from "../../functions/live-schema";
|
import { readLiveSchema } from "../../functions/live-schema";
|
||||||
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
|
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
|
||||||
import removeDBManagerTable from "./remove-db-manager-table";
|
import removeDBManagerTable from "./remove-db-manager-table";
|
||||||
|
import recordSchemaChange from "./record-schema-change";
|
||||||
|
import checkIfTableExists from "./check-if-table-exists";
|
||||||
|
|
||||||
type Params = {
|
type Params = CreateDBSchemaParams & {
|
||||||
db: Database;
|
|
||||||
existing_tables: string[];
|
existing_tables: string[];
|
||||||
schema_tables: string[];
|
schema_tables: string[];
|
||||||
};
|
};
|
||||||
@@ -12,12 +14,8 @@ type Params = {
|
|||||||
/**
|
/**
|
||||||
* Drop tables that are no longer in the schema
|
* Drop tables that are no longer in the schema
|
||||||
*/
|
*/
|
||||||
export default function dropRemovedTables({
|
export default function dropRemovedTables(params: Params) {
|
||||||
db,
|
const { db, existing_tables, schema_tables } = params;
|
||||||
existing_tables,
|
|
||||||
schema_tables,
|
|
||||||
}: Params) {
|
|
||||||
console.log(`Cleaning up tables ...`);
|
|
||||||
|
|
||||||
const tablesToDrop = existing_tables.filter(
|
const tablesToDrop = existing_tables.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
@@ -35,8 +33,16 @@ export default function dropRemovedTables({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tableName of tablesToDrop) {
|
for (const tableName of _.uniq(tablesToDrop)) {
|
||||||
console.log(`Dropping table: ${tableName}`);
|
if (!checkIfTableExists({ db, table_name: tableName })) {
|
||||||
|
removeDBManagerTable({ db, table_name: tableName });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSchemaChange(params, {
|
||||||
|
description: `Drop table "${tableName}"`,
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
db.run(`DROP TABLE IF EXISTS ${q(tableName)}`);
|
db.run(`DROP TABLE IF EXISTS ${q(tableName)}`);
|
||||||
removeDBManagerTable({ db, table_name: tableName });
|
removeDBManagerTable({ db, table_name: tableName });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
|
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
|
||||||
import createTable from "./create-table";
|
import createTable from "./create-table";
|
||||||
import insertDBManagerTable from "./insert-db-manager-table";
|
import insertDBManagerTable from "./insert-db-manager-table";
|
||||||
|
import recordSchemaChange from "./record-schema-change";
|
||||||
import removeDBManagerTable from "./remove-db-manager-table";
|
import removeDBManagerTable from "./remove-db-manager-table";
|
||||||
import syncIndexes from "./sync-indexes";
|
import syncIndexes from "./sync-indexes";
|
||||||
import updateTable from "./update-table";
|
import updateTable from "./update-table";
|
||||||
@@ -28,9 +29,9 @@ export default function handleDBSchemaTable(params: Params) {
|
|||||||
table.tableNameOld !== table.tableName &&
|
table.tableNameOld !== table.tableName &&
|
||||||
existing_tables.includes(table.tableNameOld)
|
existing_tables.includes(table.tableNameOld)
|
||||||
) {
|
) {
|
||||||
console.log(
|
recordSchemaChange(params, {
|
||||||
`Renaming table: ${table.tableNameOld} -> ${table.tableName}`,
|
description: `Rename table "${table.tableNameOld}" -> "${table.tableName}"`,
|
||||||
);
|
});
|
||||||
db.run(
|
db.run(
|
||||||
`ALTER TABLE ${q(table.tableNameOld)} RENAME TO ${q(table.tableName)}`,
|
`ALTER TABLE ${q(table.tableNameOld)} RENAME TO ${q(table.tableName)}`,
|
||||||
);
|
);
|
||||||
@@ -46,5 +47,5 @@ export default function handleDBSchemaTable(params: Params) {
|
|||||||
updateTable(params);
|
updateTable(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
syncIndexes({ db, table });
|
syncIndexes(params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { CreateDBSchemaParams, SchemaChange } from "../../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Record a schema change before it runs
|
||||||
|
* @description Logs the change (unless this is a dry run) and refuses
|
||||||
|
* destructive changes that were not approved.
|
||||||
|
*/
|
||||||
|
export default function recordSchemaChange(
|
||||||
|
params: Pick<
|
||||||
|
CreateDBSchemaParams,
|
||||||
|
"dry_run" | "changes" | "allow_destructive"
|
||||||
|
>,
|
||||||
|
change: SchemaChange,
|
||||||
|
) {
|
||||||
|
params.changes?.push(change);
|
||||||
|
|
||||||
|
if (params.dry_run) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = params.allow_destructive;
|
||||||
|
|
||||||
|
if (
|
||||||
|
change.destructive &&
|
||||||
|
allowed !== undefined &&
|
||||||
|
allowed !== true &&
|
||||||
|
!allowed.has(change.description)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Refusing unapproved destructive change: ${change.description}. Re-run with --dry-run to review, or --yes to allow it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(change.description);
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import type { Database } from "bun:sqlite";
|
import type {
|
||||||
import type { BUN_SQLITE_TableSchemaType } from "../../types";
|
BUN_SQLITE_TableSchemaType,
|
||||||
|
CreateDBSchemaParams,
|
||||||
|
} from "../../types";
|
||||||
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
|
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
|
||||||
|
import recordSchemaChange from "./record-schema-change";
|
||||||
|
|
||||||
type Params = {
|
type Params = CreateDBSchemaParams & {
|
||||||
db: Database;
|
|
||||||
table: BUN_SQLITE_TableSchemaType;
|
table: BUN_SQLITE_TableSchemaType;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync indexes for a table
|
* Sync indexes for a table
|
||||||
*/
|
*/
|
||||||
export default function syncIndexes({ db, table }: Params) {
|
export default function syncIndexes(params: Params) {
|
||||||
|
const { db, table } = params;
|
||||||
|
|
||||||
if (!table.indexes || table.indexes.length === 0) {
|
if (!table.indexes || table.indexes.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -29,7 +33,9 @@ export default function syncIndexes({ db, table }: Params) {
|
|||||||
(idx) => idx.indexName === indexName,
|
(idx) => idx.indexName === indexName,
|
||||||
);
|
);
|
||||||
if (!stillExists) {
|
if (!stillExists) {
|
||||||
console.log(`Dropping index: ${indexName}`);
|
recordSchemaChange(params, {
|
||||||
|
description: `Drop index "${indexName}"`,
|
||||||
|
});
|
||||||
db.run(`DROP INDEX IF EXISTS ${q(indexName)}`);
|
db.run(`DROP INDEX IF EXISTS ${q(indexName)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,7 +47,9 @@ export default function syncIndexes({ db, table }: Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!existingIndexes.includes(index.indexName)) {
|
if (!existingIndexes.includes(index.indexName)) {
|
||||||
console.log(`Creating index: ${index.indexName}`);
|
recordSchemaChange(params, {
|
||||||
|
description: `Create index "${index.indexName}" on "${table.tableName}"`,
|
||||||
|
});
|
||||||
const fields = index.indexTableFields
|
const fields = index.indexTableFields
|
||||||
.map((f) => q(String(f)))
|
.map((f) => q(String(f)))
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import type {
|
|||||||
CreateDBSchemaParams,
|
CreateDBSchemaParams,
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
|
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
|
||||||
|
import buildCreateTableSql from "./build-create-table-sql";
|
||||||
import checkIfTableExists from "./check-if-table-exists";
|
import checkIfTableExists from "./check-if-table-exists";
|
||||||
import createTable from "./create-table";
|
|
||||||
import getTableColumns from "./get-table-columns";
|
import getTableColumns from "./get-table-columns";
|
||||||
|
import recordSchemaChange from "./record-schema-change";
|
||||||
import resolveTable from "./resolve-table";
|
import resolveTable from "./resolve-table";
|
||||||
|
|
||||||
type Params = CreateDBSchemaParams & {
|
type Params = CreateDBSchemaParams & {
|
||||||
@@ -14,36 +15,65 @@ type Params = CreateDBSchemaParams & {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* # Update an existing table
|
* # Update an existing table
|
||||||
* @description SQLite has limited ALTER TABLE support, so the table is
|
* @description SQLite has limited ALTER TABLE support, so a changed table is
|
||||||
* rebuilt: create a temp table with the new schema, copy the data over,
|
* rebuilt: create a temp table with the new schema, copy the data over,
|
||||||
* drop the old table and rename the temp table.
|
* drop the old table and rename the temp table. Unchanged tables are left
|
||||||
|
* alone.
|
||||||
*/
|
*/
|
||||||
export default function updateTable(params: Params) {
|
export default function updateTable(params: Params) {
|
||||||
const { db, db_schema, table } = params;
|
const { db, db_schema, table } = params;
|
||||||
|
|
||||||
console.log(`Updating table: ${table.tableName}`);
|
|
||||||
|
|
||||||
if (table.isVector) {
|
if (table.isVector) {
|
||||||
recreateVectorTable(params);
|
recreateVectorTable(params);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite keeps the `CREATE TABLE` text (with the renamed table name), so
|
||||||
|
* an identical statement means the table already matches the schema.
|
||||||
|
*/
|
||||||
|
const current = db
|
||||||
|
.query(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`)
|
||||||
|
.get(table.tableName) as { sql: string } | null;
|
||||||
|
|
||||||
|
if (current?.sql === buildCreateTableSql({ db_schema, table })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const tempTableName = `${table.tableName}_temp_${Date.now()}`;
|
const tempTableName = `${table.tableName}_temp_${Date.now()}`;
|
||||||
|
|
||||||
// Columns are matched by name only: SQLite converts values to the new
|
// Columns are matched by name only: SQLite converts values to the new
|
||||||
// column affinity on copy, so a type change keeps the data instead of
|
// column affinity on copy, so a type change keeps the data instead of
|
||||||
// silently dropping the column.
|
// silently dropping the column.
|
||||||
const existingColumns = getTableColumns({ db, table_name: table.tableName });
|
const existingColumns = getTableColumns({ db, table_name: table.tableName });
|
||||||
const columnsToKeep = resolveTable({ table, db_schema })
|
const schemaColumns = resolveTable({ table, db_schema }).fields.map(
|
||||||
.fields.filter(
|
(f) => f.fieldName,
|
||||||
(f) =>
|
);
|
||||||
f.fieldName &&
|
const columnsToKeep = existingColumns
|
||||||
existingColumns.find((c) => c.name == f.fieldName),
|
.map((c) => c.name)
|
||||||
)
|
.filter((name) => schemaColumns.includes(name));
|
||||||
.map((f) => f.fieldName as string);
|
const columnsToDrop = existingColumns
|
||||||
|
.map((c) => c.name)
|
||||||
|
.filter((name) => !schemaColumns.includes(name));
|
||||||
|
|
||||||
|
recordSchemaChange(params, {
|
||||||
|
description: `Rebuild table "${table.tableName}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const column of columnsToDrop) {
|
||||||
|
recordSchemaChange(params, {
|
||||||
|
description: `Drop column "${table.tableName}"."${column}"`,
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create temp table with new schema
|
// Create temp table with new schema
|
||||||
createTable({ ...params, table: { ...table, tableName: tempTableName } });
|
db.run(
|
||||||
|
buildCreateTableSql({
|
||||||
|
db_schema,
|
||||||
|
table: { ...table, tableName: tempTableName },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Copy data if there are common columns
|
// Copy data if there are common columns
|
||||||
if (columnsToKeep.length > 0) {
|
if (columnsToKeep.length > 0) {
|
||||||
@@ -61,12 +91,16 @@ export default function updateTable(params: Params) {
|
|||||||
* Vector tables are only rebuilt when `recreate_vector_table` is set.
|
* Vector tables are only rebuilt when `recreate_vector_table` is set.
|
||||||
*/
|
*/
|
||||||
function recreateVectorTable(params: Params) {
|
function recreateVectorTable(params: Params) {
|
||||||
const { db, table, recreate_vector_table } = params;
|
const { db, db_schema, table, recreate_vector_table } = params;
|
||||||
|
|
||||||
if (!recreate_vector_table) {
|
if (!recreate_vector_table) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordSchemaChange(params, {
|
||||||
|
description: `Recreate vector table "${table.tableName}"`,
|
||||||
|
});
|
||||||
|
|
||||||
let existingRows: { [k: string]: any }[] = [];
|
let existingRows: { [k: string]: any }[] = [];
|
||||||
|
|
||||||
if (checkIfTableExists({ db, table_name: table.tableName })) {
|
if (checkIfTableExists({ db, table_name: table.tableName })) {
|
||||||
@@ -77,7 +111,7 @@ function recreateVectorTable(params: Params) {
|
|||||||
db.run(`DROP TABLE ${q(table.tableName)}`);
|
db.run(`DROP TABLE ${q(table.tableName)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
createTable(params);
|
db.run(buildCreateTableSql({ db_schema, table }));
|
||||||
|
|
||||||
for (const row of existingRows) {
|
for (const row of existingRows) {
|
||||||
const columns = Object.keys(row);
|
const columns = Object.keys(row);
|
||||||
|
|||||||
@@ -30,25 +30,12 @@ export default async function DbDelete<
|
|||||||
let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
|
let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let finalQuery = query || {};
|
const finalQuery = query || {};
|
||||||
|
|
||||||
if (targetId !== undefined && targetId !== null && targetId !== "") {
|
|
||||||
finalQuery = _.merge<
|
|
||||||
ServerQueryParam<any>,
|
|
||||||
ServerQueryParam<any>,
|
|
||||||
ServerQueryParam<any>
|
|
||||||
>({}, finalQuery, {
|
|
||||||
query: {
|
|
||||||
id: {
|
|
||||||
value: String(targetId),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlObj = sqlGenerator({
|
sqlObj = sqlGenerator({
|
||||||
tableName: table,
|
tableName: table,
|
||||||
genObject: _.pick(finalQuery, ["query", "searchOperator"]),
|
genObject: _.pick(finalQuery, ["query", "searchOperator"]),
|
||||||
|
targetId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const whereClause = sqlObj.where.string;
|
const whereClause = sqlObj.where.string;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import dbHandler from "../db-handler";
|
import dbHandler from "../db-handler";
|
||||||
import _ from "lodash";
|
|
||||||
import type {
|
import type {
|
||||||
APIResponseObject,
|
APIResponseObject,
|
||||||
BunSQLiteConfig,
|
BunSQLiteConfig,
|
||||||
@@ -31,25 +30,12 @@ export default async function DbSelect<
|
|||||||
let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
|
let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let finalQuery = query || {};
|
const finalQuery = query || {};
|
||||||
|
|
||||||
if (targetId !== undefined && targetId !== null && targetId !== "") {
|
|
||||||
finalQuery = _.merge<
|
|
||||||
ServerQueryParam<any>,
|
|
||||||
ServerQueryParam<any>,
|
|
||||||
ServerQueryParam<any>
|
|
||||||
>({}, finalQuery, {
|
|
||||||
query: {
|
|
||||||
id: {
|
|
||||||
value: String(targetId),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlObj = sqlGenerator({
|
sqlObj = sqlGenerator({
|
||||||
tableName: table,
|
tableName: table,
|
||||||
genObject: finalQuery,
|
genObject: finalQuery,
|
||||||
|
targetId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sql = sqlObj.string;
|
const sql = sqlObj.string;
|
||||||
@@ -80,6 +66,7 @@ export default async function DbSelect<
|
|||||||
const count_sql_object = sqlGenerator({
|
const count_sql_object = sqlGenerator({
|
||||||
tableName: table,
|
tableName: table,
|
||||||
genObject: finalQuery,
|
genObject: finalQuery,
|
||||||
|
targetId,
|
||||||
count,
|
count,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,25 +37,12 @@ export default async function DbUpdate<
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let finalQuery = query || {};
|
const finalQuery = query || {};
|
||||||
|
|
||||||
if (targetId !== undefined && targetId !== null && targetId !== "") {
|
|
||||||
finalQuery = _.merge<
|
|
||||||
ServerQueryParam<any>,
|
|
||||||
ServerQueryParam<any>,
|
|
||||||
ServerQueryParam<any>
|
|
||||||
>({}, finalQuery, {
|
|
||||||
query: {
|
|
||||||
id: {
|
|
||||||
value: String(targetId),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sqlQueryObj = sqlGenerator({
|
const sqlQueryObj = sqlGenerator({
|
||||||
tableName: table,
|
tableName: table,
|
||||||
genObject: _.pick(finalQuery, ["query", "searchOperator"]),
|
genObject: _.pick(finalQuery, ["query", "searchOperator"]),
|
||||||
|
targetId,
|
||||||
});
|
});
|
||||||
|
|
||||||
let values: SQLInsertGenValueType[] = [];
|
let values: SQLInsertGenValueType[] = [];
|
||||||
|
|||||||
@@ -1580,6 +1580,28 @@ export type CreateDBSchemaParams = {
|
|||||||
db: import("bun:sqlite").Database;
|
db: import("bun:sqlite").Database;
|
||||||
db_schema: BUN_SQLITE_DatabaseSchemaType;
|
db_schema: BUN_SQLITE_DatabaseSchemaType;
|
||||||
recreate_vector_table?: boolean;
|
recreate_vector_table?: boolean;
|
||||||
|
/**
|
||||||
|
* Run the sync inside a transaction and roll it back. The planned
|
||||||
|
* changes are recorded in `changes`.
|
||||||
|
*/
|
||||||
|
dry_run?: boolean;
|
||||||
|
/**
|
||||||
|
* Collects every change made (or planned) by the sync
|
||||||
|
*/
|
||||||
|
changes?: SchemaChange[];
|
||||||
|
/**
|
||||||
|
* Destructive changes that may run. `true` (or unset) allows all of
|
||||||
|
* them; otherwise only changes whose `description` is in the set.
|
||||||
|
*/
|
||||||
|
allow_destructive?: true | Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A change made by the schema sync. Destructive changes discard data.
|
||||||
|
*/
|
||||||
|
export type SchemaChange = {
|
||||||
|
description: string;
|
||||||
|
destructive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ export default function sqlGenGenSearchStr({
|
|||||||
.filter(isPresent);
|
.filter(isPresent);
|
||||||
const equality = queryObj.equality;
|
const equality = queryObj.equality;
|
||||||
|
|
||||||
if (equality == "IN" || equality == "NOT IN") {
|
if ((equality == "IN" || equality == "NOT IN") && !rawValues.length) {
|
||||||
|
// Empty IN () is invalid SQL: IN matches nothing, NOT IN matches everything
|
||||||
|
str = equality == "IN" ? "1 = 0" : "1 = 1";
|
||||||
|
} else if (equality == "IN" || equality == "NOT IN") {
|
||||||
str = `${finalFieldName} ${equality} (${rawValues
|
str = `${finalFieldName} ${equality} (${rawValues
|
||||||
.map(() => "?")
|
.map(() => "?")
|
||||||
.join(", ")})`;
|
.join(", ")})`;
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
|
|||||||
tableName: string;
|
tableName: string;
|
||||||
dbFullName?: string;
|
dbFullName?: string;
|
||||||
count?: boolean;
|
count?: boolean;
|
||||||
|
/**
|
||||||
|
* Restrict to a single row id. Always combined with AND, regardless of
|
||||||
|
* `searchOperator`.
|
||||||
|
*/
|
||||||
|
targetId?: number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Return = {
|
type Return = {
|
||||||
@@ -40,7 +45,12 @@ type Return = {
|
|||||||
*/
|
*/
|
||||||
export default function sqlGenerator<
|
export default function sqlGenerator<
|
||||||
T extends { [key: string]: any } = { [key: string]: any },
|
T extends { [key: string]: any } = { [key: string]: any },
|
||||||
>({ tableName: passedTableName, genObject, count }: Param<T>): Return {
|
>({
|
||||||
|
tableName: passedTableName,
|
||||||
|
genObject,
|
||||||
|
count,
|
||||||
|
targetId,
|
||||||
|
}: Param<T>): Return {
|
||||||
const tableName = sqlIdent(passedTableName, "table name");
|
const tableName = sqlIdent(passedTableName, "table name");
|
||||||
const finalQuery = genObject?.query ? genObject.query : undefined;
|
const finalQuery = genObject?.query ? genObject.query : undefined;
|
||||||
|
|
||||||
@@ -115,11 +125,31 @@ export default function sqlGenerator<
|
|||||||
(str) => typeof str == "string",
|
(str) => typeof str == "string",
|
||||||
);
|
);
|
||||||
|
|
||||||
let whereString = "";
|
const whereConditions: string[] = [];
|
||||||
|
|
||||||
if (cleanedUpSearchStr?.[0]) {
|
if (cleanedUpSearchStr?.[0]) {
|
||||||
const stringOperator = sqlOperator(genObject?.searchOperator);
|
const stringOperator = sqlOperator(genObject?.searchOperator);
|
||||||
whereString = `WHERE ${cleanedUpSearchStr.join(` ${stringOperator} `)}`;
|
whereConditions.push(
|
||||||
|
`(${cleanedUpSearchStr.join(` ${stringOperator} `)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetId !== undefined && targetId !== null && targetId !== "") {
|
||||||
|
const id = Number(targetId);
|
||||||
|
|
||||||
|
if (!Number.isSafeInteger(id)) {
|
||||||
|
throw new Error(`Invalid targetId: ${JSON.stringify(targetId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
whereConditions.push(`${genObject?.join ? `${tableName}.` : ""}id = ?`);
|
||||||
|
whereValues.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereString = whereConditions.length
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (whereString) {
|
||||||
queryString += ` ${whereString}`;
|
queryString += ` ${whereString}`;
|
||||||
sqlSearhValues.push(...whereValues);
|
sqlSearhValues.push(...whereValues);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import sqlGenerator from "../src/utils/sql-generator";
|
||||||
|
|
||||||
|
const gen = (genObject: any, extra: Record<string, any> = {}) =>
|
||||||
|
sqlGenerator({ tableName: "users", genObject, ...extra });
|
||||||
|
|
||||||
|
describe("identifiers", () => {
|
||||||
|
test("binds values and keeps identifiers", () => {
|
||||||
|
const { string, values } = gen({ query: { email: { value: "[email protected]" } } });
|
||||||
|
expect(string).toBe("SELECT * FROM users WHERE (email = ?)");
|
||||||
|
expect(values).toEqual(["[email protected]"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["field name", { query: { "1=1 OR id": { value: 1 } } }],
|
||||||
|
["quoted field name", { query: { 'a" OR 1=1 --': { value: 1 } } }],
|
||||||
|
["select field", { selectFields: ["name; DROP TABLE users"] }],
|
||||||
|
])("rejects an injected %s", (_name, genObject) => {
|
||||||
|
expect(() => gen(genObject)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an injected table name", () => {
|
||||||
|
expect(() =>
|
||||||
|
sqlGenerator({ tableName: "users; DROP TABLE users", genObject: {} }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("input validation", () => {
|
||||||
|
test.each([
|
||||||
|
["order strategy", { order: { field: "id", strategy: "DESC; DROP TABLE x" } }],
|
||||||
|
["limit", { limit: "1; --" }],
|
||||||
|
["negative offset", { offset: -1 }],
|
||||||
|
["page", { limit: 10, page: "2 OR 1" }],
|
||||||
|
["search operator", { query: { a: { value: 1 } }, searchOperator: "OR 1=1" }],
|
||||||
|
["raw equality", { query: { a: { value: 1, raw_equality: "= 1 OR" } } }],
|
||||||
|
["join type", { join: [{ tableName: "p", joinType: "CROSS JOIN x;", match: { source: "id", target: "uid" } }] }],
|
||||||
|
])("rejects invalid %s", (_name, genObject) => {
|
||||||
|
expect(() => gen(genObject)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-numeric targetId", () => {
|
||||||
|
expect(() => gen({}, { targetId: "abc" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("targetId", () => {
|
||||||
|
test("is ANDed with the query, even with searchOperator OR", () => {
|
||||||
|
const { string, values } = gen(
|
||||||
|
{ query: { a: { value: 1 }, b: { value: 2 } }, searchOperator: "OR" },
|
||||||
|
{ targetId: 5 },
|
||||||
|
);
|
||||||
|
expect(string).toBe(
|
||||||
|
"SELECT * FROM users WHERE (a = ? OR b = ?) AND id = ?",
|
||||||
|
);
|
||||||
|
expect(values).toEqual([1, 2, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts numeric strings", () => {
|
||||||
|
const { string, values } = gen({}, { targetId: "7" });
|
||||||
|
expect(string).toBe("SELECT * FROM users WHERE id = ?");
|
||||||
|
expect(values).toEqual([7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("0 is a real id", () => {
|
||||||
|
expect(gen({}, { targetId: 0 }).values).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is table-qualified with joins", () => {
|
||||||
|
const { string } = gen(
|
||||||
|
{
|
||||||
|
join: [
|
||||||
|
{
|
||||||
|
tableName: "posts",
|
||||||
|
joinType: "LEFT JOIN",
|
||||||
|
match: { source: "id", target: "user_id" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ targetId: 3 },
|
||||||
|
);
|
||||||
|
expect(string).toEndWith("WHERE users.id = ?");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is part of the where clause used by update / delete", () => {
|
||||||
|
const { where } = gen({ query: { a: { value: 1 } } }, { targetId: 2 });
|
||||||
|
expect(where).toEqual({
|
||||||
|
string: "WHERE (a = ?) AND id = ?",
|
||||||
|
values: [1, 2],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("values", () => {
|
||||||
|
test("0 is a real value", () => {
|
||||||
|
expect(gen({ query: { age: { value: 0 } } }).values).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("IN binds each value", () => {
|
||||||
|
const { string, values } = gen({
|
||||||
|
query: { id: { value: [1, 2], equality: "IN" } },
|
||||||
|
});
|
||||||
|
expect(string).toBe("SELECT * FROM users WHERE (id IN (?, ?))");
|
||||||
|
expect(values).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty IN matches nothing, empty NOT IN matches everything", () => {
|
||||||
|
expect(gen({ query: { id: { value: [], equality: "IN" } } }).string).toBe(
|
||||||
|
"SELECT * FROM users WHERE (1 = 0)",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
gen({ query: { id: { value: [], equality: "NOT IN" } } }).string,
|
||||||
|
).toBe("SELECT * FROM users WHERE (1 = 1)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("BETWEEN needs exactly two values", () => {
|
||||||
|
expect(() =>
|
||||||
|
gen({ query: { age: { value: [1], equality: "BETWEEN" } } }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pagination", () => {
|
||||||
|
test("page is converted to an offset", () => {
|
||||||
|
expect(gen({ limit: 10, page: 3 }).string).toBe(
|
||||||
|
"SELECT * FROM users LIMIT 10 OFFSET 20",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("count queries skip pagination", () => {
|
||||||
|
expect(gen({ limit: 10, page: 3 }, { count: true }).string).not.toContain(
|
||||||
|
"LIMIT",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import sqlInsertGenerator, { sqlValue } from "../src/utils/sql-insert-generator";
|
||||||
|
|
||||||
|
describe("sqlValue", () => {
|
||||||
|
test("booleans become 0 / 1", () => {
|
||||||
|
expect(sqlValue(false)).toBe(0);
|
||||||
|
expect(sqlValue(true)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("0 and empty strings are kept", () => {
|
||||||
|
expect(sqlValue(0)).toBe(0);
|
||||||
|
expect(sqlValue("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("undefined becomes NULL", () => {
|
||||||
|
expect(sqlValue(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sqlInsertGenerator", () => {
|
||||||
|
test("builds a multi-row insert", () => {
|
||||||
|
const res = sqlInsertGenerator({
|
||||||
|
tableName: "items",
|
||||||
|
data: [
|
||||||
|
{ name: "a", active: false, qty: 0 },
|
||||||
|
{ name: "b", note: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res?.query).toBe(
|
||||||
|
"INSERT INTO items (name,active,qty,note) VALUES (?,?,?,?),(?,?,?,?)",
|
||||||
|
);
|
||||||
|
expect(res?.values).toEqual(["a", 0, 0, null, "b", null, null, ""]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("function values are called once", () => {
|
||||||
|
let calls = 0;
|
||||||
|
const res = sqlInsertGenerator({
|
||||||
|
tableName: "items",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
emb: () => {
|
||||||
|
calls++;
|
||||||
|
return { value: "[1,2]", placeholder: "vec_f32(?)" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(res?.query).toBe("INSERT INTO items (emb) VALUES (vec_f32(?))");
|
||||||
|
expect(res?.values).toEqual(["[1,2]"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects injected column names", () => {
|
||||||
|
expect(() =>
|
||||||
|
sqlInsertGenerator({
|
||||||
|
tableName: "items",
|
||||||
|
data: [{ "name) VALUES (1); --": "x" }],
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user