diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5549b3dd..24764a03 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -32,6 +32,7 @@ jobs: dialect: - postgres - cockroachdb + - sqlite steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/packages/cli/src/config/loader.ts b/packages/cli/src/config/loader.ts index aaca5d25..70e50a53 100644 --- a/packages/cli/src/config/loader.ts +++ b/packages/cli/src/config/loader.ts @@ -7,6 +7,7 @@ import { foreignKeyConstraintSchema, } from "../operations/shared/types"; import { buildContainerDevDatabaseConfigSchema } from "../dev/providers/container"; +import { buildFileDevDatabaseConfig } from "../dev/providers/file"; const columnSchema = z.object({ type: z.string(), @@ -29,7 +30,7 @@ const indexSchema = z.object({ }); export type IndexSchema = z.infer; -const dialectEnum = z.enum(["postgres", "cockroachdb"]); +const dialectEnum = z.enum(["postgres", "cockroachdb", "sqlite"]); export type DialectEnum = z.infer; const databaseSchema = z.object({ @@ -41,6 +42,8 @@ export type DatabaseValue = z.infer; const devDatabaseSchema = z.union([ // Container-based configuration buildContainerDevDatabaseConfigSchema({ defaultImage: "" }), + // File-based configuration + buildFileDevDatabaseConfig(), ]); export type DevDatabaseValue = z.infer; diff --git a/packages/cli/src/dev/providers/container.ts b/packages/cli/src/dev/providers/container.ts index ea888164..fc0befe1 100644 --- a/packages/cli/src/dev/providers/container.ts +++ b/packages/cli/src/dev/providers/container.ts @@ -16,10 +16,14 @@ export const buildContainerDevDatabaseConfigSchema = (options: { defaultImage: string; }) => z.object({ - container: z.object({ - image: z.string().default(options.defaultImage), - name: z.string().optional(), - }), + container: z + .object({ + image: z.string().default(options.defaultImage), + name: z.string().optional(), + }) + .default({ + image: options.defaultImage, + }), }); export type ContainerDevDatabaseConfig = z.infer< ReturnType @@ -150,13 +154,13 @@ class ContainerDevDatabaseInstance implements DevDatabaseInstance { if (runningContainer) { const r = await runningContainer.inspect(); return { - type: "container", + type: "container" as const, imageName: r.Config.Image, containerID: r.Id, }; } - return { type: "unavailable" }; + return { type: "unavailable" as const }; } async isAvailable(): Promise { diff --git a/packages/cli/src/dev/providers/file.ts b/packages/cli/src/dev/providers/file.ts new file mode 100644 index 00000000..53727ee6 --- /dev/null +++ b/packages/cli/src/dev/providers/file.ts @@ -0,0 +1,159 @@ +import { + DevDatabaseProvider, + DevDatabaseInstance, + DevDatabaseManageType, + DevDatabaseStatus, +} from "../types"; +import * as fs from "fs/promises"; +import * as path from "path"; +import * as os from "os"; +import z from "zod"; + +const defaultName = "default"; +export const buildFileDevDatabaseConfig = () => + z.object({ + file: z + .object({ + name: z.string().default(defaultName), + }) + .default({ + name: defaultName, + }), + }); +export type FileDevDatabaseConfig = z.infer< + ReturnType +>; + +/** + * File-based dev database provider + * + * Provides file-based or memory-based development database environments. + * Used by SQLite dialect. + */ +export class FileDevDatabaseProvider implements DevDatabaseProvider { + async setup( + config: FileDevDatabaseConfig, + manageType: DevDatabaseManageType + ): Promise { + return new FileDevDatabaseInstance({ + manageType: manageType, + name: config.file?.name, + }); + } + + async hasExisting(manageType: DevDatabaseManageType): Promise { + if (manageType === "dev-start") { + // dev-startの固定パスファイルが存在するかチェック + const devStartPath = this.getDevStartPath(); + try { + await fs.access(devStartPath); + return true; + } catch { + return false; + } + } + return false; // one-offは常に新規作成(memory) + } + + private getDevStartPath(name?: string): string { + const basename = name || defaultName; + return path.join(os.tmpdir(), `kyrage___dev-${basename}.db`); + } + + async cleanup(): Promise { + // Clean up temporary kyrage database files + try { + const tempDir = os.tmpdir(); + const files = await fs.readdir(tempDir); + const kyrageFiles = files.filter((f) => f.startsWith("kyrage___dev-")); + + await Promise.allSettled( + kyrageFiles.map((f) => fs.unlink(path.join(tempDir, f))) + ); + } catch { + // Ignore cleanup errors + } + } +} + +/** + * File-based dev database instance + * + * Manages the lifecycle of a SQLite database file or memory database. + */ +class FileDevDatabaseInstance implements DevDatabaseInstance { + private connectionString: string | null = null; + private filePath: string | null = null; + + constructor( + private options: { + manageType: DevDatabaseManageType; + name?: string; + } + ) {} + + async start(): Promise { + if (this.options.manageType === "dev-start") { + // dev-start: 固定名の再利用可能ファイル(tmpdir内) + this.filePath = this.getDevStartPath(); + this.connectionString = this.filePath; + } else { + // one-off: メモリベース(高速、自動削除) + this.connectionString = ":memory:"; + } + } + + async stop(): Promise { + // SQLite doesn't require explicit stopping + // Connection will be closed when database client is disposed + } + + async remove(): Promise { + if (this.filePath) { + try { + await fs.unlink(this.filePath); + } catch { + // Ignore file removal errors + } + } + this.connectionString = null; + this.filePath = null; + } + + getConnectionString(): string { + if (!this.connectionString) { + throw new Error("File database is not started"); + } + return this.connectionString; + } + + async getStatus(): Promise { + if (!this.connectionString) { + return { type: "unavailable" as const }; + } + + if (this.connectionString === ":memory:") { + return { + type: "file" as const, + filePath: ":memory:", + mode: "memory", + }; + } + + return { + type: "file" as const, + filePath: this.connectionString, + mode: "file", + }; + } + + async isAvailable(): Promise { + const status = await this.getStatus(); + return status.type === "file"; + } + + private getDevStartPath(): string { + const basename = this.options.name || defaultName; + return path.join(os.tmpdir(), `kyrage___dev-${basename}.db`); + } +} diff --git a/packages/cli/src/dialect/factory.ts b/packages/cli/src/dialect/factory.ts index acc92df5..0204bc52 100644 --- a/packages/cli/src/dialect/factory.ts +++ b/packages/cli/src/dialect/factory.ts @@ -2,10 +2,12 @@ import { KyrageDialect } from "./types"; import { PostgresKyrageDialect } from "./postgres"; import { CockroachDBKyrageDialect } from "./cockroachdb"; import { DialectEnum } from "../config/loader"; +import { SQLiteKyrageDialect } from "./sqlite"; const dialects = { postgres: new PostgresKyrageDialect(), cockroachdb: new CockroachDBKyrageDialect(), + sqlite: new SQLiteKyrageDialect(), } as const; export const getDialect = (dialectName: DialectEnum) => { diff --git a/packages/cli/src/dialect/sqlite.ts b/packages/cli/src/dialect/sqlite.ts new file mode 100644 index 00000000..3e23c374 --- /dev/null +++ b/packages/cli/src/dialect/sqlite.ts @@ -0,0 +1,288 @@ +import { SqliteDialect, sql } from "kysely"; +import Database from "better-sqlite3"; +import { IntrospectProps, KyrageDialect } from "./types"; +import { DBClient, PlannableKysely } from "../client"; +import { computeAutoGeneratedIndexesAndConstraints } from "./shared"; +import { + buildFileDevDatabaseConfig, + FileDevDatabaseProvider, +} from "../dev/providers/file"; +import { ReferentialActions } from "../operations/shared/types"; + +export class SQLiteKyrageDialect implements KyrageDialect { + getName() { + return "sqlite" as const; + } + + createKyselyDialect(connectionString: string) { + return new SqliteDialect({ + database: new Database(connectionString), + }); + } + + createIntrospectionDriver(client: DBClient) { + return { + convertTypeName: convertSQLiteTypeName, + introspect: doSQLiteIntrospect(client), + }; + } + + createDevDatabaseProvider() { + return new FileDevDatabaseProvider(); + } + + parseDevDatabaseConfig(config: unknown) { + // SQLite supports file-based dev databases only + return buildFileDevDatabaseConfig().parse(config); + } + + async hasReusableDevDatabase(): Promise { + // File-based dialects don't support reusable dev databases + return false; + } +} + +export const doSQLiteIntrospect = + (client: DBClient) => async (props: IntrospectProps) => { + await using db = client.getDB(); + const [tables, indexes, constraints] = await Promise.all([ + introspectSQLiteTables(db), + introspectSQLiteIndexes(db), + introspectSQLiteConstraints(db), + ]); + const { + indexes: normalizedIndexes, + uniqueConstraints: normalizedUniqueConstraints, + } = computeAutoGeneratedIndexesAndConstraints(props.config, { + indexes, + constraints, + }); + + return { + tables, + indexes: normalizedIndexes, + constraints: { + ...constraints, + unique: normalizedUniqueConstraints, + }, + }; + }; + +/* + * Convert SQLite type names to more general SQL type names. + */ +export const convertSQLiteTypeName = (typeName: string) => { + const nameDict = { + INTEGER: "integer", + TEXT: "text", + REAL: "real", + BLOB: "blob", + NUMERIC: "numeric", + }; + + return ( + nameDict[typeName.toUpperCase() as keyof typeof nameDict] ?? + typeName.toLowerCase() + ); +}; + +export const introspectSQLiteTables = async (db: PlannableKysely) => { + // Get list of tables excluding sqlite system tables + const { rows: tables } = await sql` + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name != 'kysely_migration' + AND name != 'kysely_migration_lock' + ` + .$castTo<{ name: string }>() + .execute(db); + + const tableInfos = []; + + for (const table of tables) { + // Get column information for each table using PRAGMA table_info + const { rows: columns } = await sql` + SELECT * FROM pragma_table_info(${table.name}) + ` + .$castTo() + .execute(db); + + tableInfos.push( + ...columns.map((col) => ({ + schema: "main", // SQLite default schema + table: table.name, + name: col.name, + default: col.dflt_value, + characterMaximumLength: null, // SQLite doesn't enforce string length limits + })) + ); + } + + return tableInfos; +}; + +type SQLiteColumnInfo = { + cid: number; + name: string; + type: string; + notnull: number; + dflt_value: string | null; + pk: number; +}; + +export const introspectSQLiteIndexes = async (db: PlannableKysely) => { + const { rows: tables } = await sql` + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name != 'kysely_migration' + AND name != 'kysely_migration_lock' + ` + .$castTo<{ name: string }>() + .execute(db); + + const indexes = []; + + for (const table of tables) { + const { rows: indexList } = await sql` + SELECT * FROM pragma_index_list(${table.name}) + ` + .$castTo() + .execute(db); + + for (const index of indexList) { + // Only include indexes created by CREATE INDEX (origin 'c'), not auto-generated ones + if (index.origin === "c") { + const { rows: indexInfo } = await sql` + SELECT * FROM pragma_index_info(${index.name}) + ` + .$castTo() + .execute(db); + + indexes.push({ + table: table.name, + name: index.name, + columns: indexInfo.map((info) => info.name), + unique: index.unique === 1, + }); + } + } + } + + return indexes; +}; + +type SQLiteIndexList = { + seq: number; + name: string; + unique: number; + origin: string; + partial: number; +}; + +type SQLiteIndexInfo = { + seqno: number; + cid: number; + name: string; +}; + +export const introspectSQLiteConstraints = async (db: PlannableKysely) => { + const { rows: tables } = await sql` + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name != 'kysely_migration' + AND name != 'kysely_migration_lock' + ` + .$castTo<{ name: string }>() + .execute(db); + + const primaryKeys = []; + const uniqueConstraints: ReadonlyArray = []; + const foreignKeys: any[] = []; + + for (const table of tables) { + // Get primary key information + const { rows: tableInfo } = await sql` + SELECT * FROM pragma_table_info(${table.name}) + ` + .$castTo() + .execute(db); + + const pkColumns = tableInfo + .filter((col) => col.pk > 0) + .sort((a, b) => a.pk - b.pk) + .map((col) => col.name); + + if (pkColumns.length > 0) { + primaryKeys.push({ + schema: "main", + table: table.name, + name: `${table.name}_primary_key`, + type: "PRIMARY KEY" as const, + columns: pkColumns, + }); + } + + // Get foreign key information + const { rows: fkList } = await sql` + SELECT * FROM pragma_foreign_key_list(${table.name}) + ` + .$castTo() + .execute(db); + + // Group foreign keys by id + const fkGroups = fkList.reduce( + (acc, fk) => { + if (!acc[fk.id]) acc[fk.id] = []; + acc[fk.id].push(fk); + return acc; + }, + {} as Record + ); + + Object.values(fkGroups).forEach((group) => { + foreignKeys.push({ + schema: "main", + table: table.name, + name: `fk_${table.name}_${group[0].table}`, + type: "FOREIGN KEY" as const, + columns: group.map((fk) => fk.from), + referencedTable: group[0].table, + referencedColumns: group.map((fk) => fk.to), + onDelete: mapSQLiteAction(group[0].on_delete), + onUpdate: mapSQLiteAction(group[0].on_update), + }); + }); + } + + return { + primaryKey: primaryKeys, + unique: uniqueConstraints, + foreignKey: foreignKeys, + }; +}; + +type SQLiteForeignKey = { + id: number; + seq: number; + table: string; + from: string; + to: string; + on_update: string; + on_delete: string; + match: string; +}; + +const mapSQLiteAction = (action: string): ReferentialActions | undefined => { + const actionMap: Record = { + CASCADE: "cascade", + "SET NULL": "set null", + "SET DEFAULT": "set default", + RESTRICT: "restrict", + "NO ACTION": "no action", + }; + + return actionMap[action.toUpperCase()]; +}; diff --git a/packages/cli/tests/helper.ts b/packages/cli/tests/helper.ts index 65ca1473..1af19a3a 100644 --- a/packages/cli/tests/helper.ts +++ b/packages/cli/tests/helper.ts @@ -29,6 +29,8 @@ const getConfigForTest = (kyrageDialect: KyrageDialect) => { image: "cockroachdb/cockroach:latest-v24.3", }, }; + case "sqlite": + return {}; default: throw new Error("unsupported dialect specified"); } diff --git a/packages/cli/tests/regenerate.test.ts b/packages/cli/tests/regenerate.test.ts index 963ddc9a..487441ac 100644 --- a/packages/cli/tests/regenerate.test.ts +++ b/packages/cli/tests/regenerate.test.ts @@ -18,8 +18,11 @@ beforeAll(async () => { name TEXT NOT NULL, email TEXT NOT NULL CONSTRAINT members_email_unique UNIQUE ); + `.execute(db); + await sql` CREATE UNIQUE INDEX "idx_members_name_email" ON "members" ("name", "email"); - + `.execute(db); + await sql` CREATE TABLE orders ( customer_id UUID NOT NULL, product_id UUID NOT NULL,