diff --git a/drizzle-orm/src/libsql/driver-core.ts b/drizzle-orm/src/libsql/driver-core.ts index c427637e82..8303e777dd 100644 --- a/drizzle-orm/src/libsql/driver-core.ts +++ b/drizzle-orm/src/libsql/driver-core.ts @@ -27,6 +27,46 @@ export class LibSQLDatabase< ): Promise> { return this.session.batch(batch) as Promise>; } + + /** + * Attach an external SQLite database file to this connection. + * + * Tables in the attached database can be queried using the schema prefix. + * Ensure the schema was defined with `sqliteSchema(name)` and included + * in the drizzle() configuration. + * + * @param schemaName - Schema name to use for queries (e.g., 'bronze') + * @param dbPath - Absolute or relative path to database file + * + * @example + * ```typescript + * const bronze = sqliteSchema('bronze'); + * const messageSnapshot = bronze.table('message_snapshot', { ... }); + * + * const db = drizzle(client, { + * schema: { messageSnapshot, ...warehouseSchema } + * }); + * + * await db.$attach('bronze', './bronze.db'); + * + * // Now queries work + * await db.select().from(messageSnapshot).all(); + * ``` + */ + async $attach(schemaName: string, dbPath: string): Promise { + const sql = `ATTACH DATABASE '${dbPath}' AS ${schemaName}`; + await (this as any).$client.execute(sql); + } + + /** + * Detach a previously attached database. + * + * @param schemaName - Schema name to detach + */ + async $detach(schemaName: string): Promise { + const sql = `DETACH DATABASE ${schemaName}`; + await (this as any).$client.execute(sql); + } } /** @internal */ diff --git a/drizzle-orm/src/sqlite-core/index.ts b/drizzle-orm/src/sqlite-core/index.ts index ac2a19f0a3..7120e0c5c1 100644 --- a/drizzle-orm/src/sqlite-core/index.ts +++ b/drizzle-orm/src/sqlite-core/index.ts @@ -7,6 +7,7 @@ export * from './foreign-keys.ts'; export * from './indexes.ts'; export * from './primary-keys.ts'; export * from './query-builders/index.ts'; +export * from './schema.ts'; export * from './session.ts'; export * from './subquery.ts'; export * from './table.ts'; diff --git a/drizzle-orm/src/sqlite-core/schema.ts b/drizzle-orm/src/sqlite-core/schema.ts new file mode 100644 index 0000000000..3ee307b55b --- /dev/null +++ b/drizzle-orm/src/sqlite-core/schema.ts @@ -0,0 +1,81 @@ +import { entityKind, is } from '~/entity.ts'; +import { SQL, sql, type SQLWrapper } from '~/sql/sql.ts'; +import { type SQLiteTableFn, sqliteTableWithSchema } from './table.ts'; +import { type sqliteView, sqliteViewWithSchema } from './view.ts'; + +/** + * Represents a SQLite schema (database attached via ATTACH DATABASE). + * + * @example + * ```typescript + * const logs = sqliteSchema('logs'); + * + * export const auditLog = logs.table('audit_log', { + * id: text('id').primaryKey(), + * action: text('action'), + * }); + * ``` + */ +export class SQLiteSchema implements SQLWrapper { + static readonly [entityKind]: string = 'SQLiteSchema'; + + constructor(public readonly schemaName: TName) {} + + /** + * Define a table in this schema. + * Queries will generate: SELECT * FROM "schemaName"."tableName" + */ + table: SQLiteTableFn = ((name, columns, extraConfig) => { + return sqliteTableWithSchema(name, columns, extraConfig, this.schemaName); + }) as SQLiteTableFn; + + /** + * Define a view in this schema. + */ + view = ((name, columns) => { + return sqliteViewWithSchema(name, columns, this.schemaName); + }) as typeof sqliteView; + + getSQL(): SQL { + return new SQL([sql.identifier(this.schemaName)]); + } + + shouldOmitSQLParens(): boolean { + return true; + } +} + +export function isSQLiteSchema(obj: unknown): obj is SQLiteSchema { + return is(obj, SQLiteSchema); +} + +/** + * Define a SQLite schema for use with ATTACH DATABASE. + * + * SQLite supports attaching multiple database files to a single connection. + * Each attached database is accessed via a schema prefix. + * + * @param name - Schema name (must match ATTACH DATABASE ... AS name) + * + * @example + * ```typescript + * // 1. Define schema + * const logs = sqliteSchema('logs'); + * + * export const auditLog = logs.table('audit_log', { + * id: text('id').primaryKey(), + * timestamp: integer('timestamp'), + * }); + * + * // 2. Attach database + * const db = drizzle(client, { schema: { auditLog } }); + * await db.$attach('logs', './logs.db'); + * + * // 3. Query across databases + * await db.select().from(auditLog).all(); + * // Generates: SELECT * FROM "logs"."audit_log" + * ``` + */ +export function sqliteSchema(name: T): SQLiteSchema { + return new SQLiteSchema(name); +} diff --git a/drizzle-orm/src/sqlite-core/table.ts b/drizzle-orm/src/sqlite-core/table.ts index 290605b66c..153ae9c941 100644 --- a/drizzle-orm/src/sqlite-core/table.ts +++ b/drizzle-orm/src/sqlite-core/table.ts @@ -216,6 +216,29 @@ function sqliteTableBase< return table; } +export function sqliteTableWithSchema< + TTableName extends string, + TSchemaName extends string | undefined, + TColumnsMap extends Record, +>( + name: TTableName, + columns: TColumnsMap | ((columnTypes: SQLiteColumnBuilders) => TColumnsMap), + extraConfig: + | (( + self: BuildColumns, + ) => SQLiteTableExtraConfig | SQLiteTableExtraConfigValue[]) + | undefined, + schema: TSchemaName, + baseName = name, +): SQLiteTableWithColumns<{ + name: TTableName; + schema: TSchemaName; + columns: BuildColumns; + dialect: 'sqlite'; +}> { + return sqliteTableBase(name, columns, extraConfig, schema, baseName); +} + export const sqliteTable: SQLiteTableFn = (name, columns, extraConfig) => { return sqliteTableBase(name, columns, extraConfig); }; diff --git a/drizzle-orm/src/sqlite-core/view.ts b/drizzle-orm/src/sqlite-core/view.ts index 1caf211ce9..b6c1f261d3 100644 --- a/drizzle-orm/src/sqlite-core/view.ts +++ b/drizzle-orm/src/sqlite-core/view.ts @@ -29,6 +29,7 @@ export class ViewBuilderCore< constructor( protected name: TConfig['name'], + protected schema?: string, ) {} protected config: ViewBuilderConfig = {}; @@ -56,7 +57,7 @@ export class ViewBuilder extends ViewBuilderCore< // sqliteConfig: this.config, config: { name: this.name, - schema: undefined, + schema: this.schema, selectedFields: aliasedSelectedFields, query: qb.getSQL().inlineParams(), }, @@ -79,8 +80,9 @@ export class ManualViewBuilder< constructor( name: TName, columns: TColumns, + schema?: string, ) { - super(name); + super(name, schema); this.columns = getTableColumns(sqliteTable(name, columns)) as BuildColumns; } @@ -89,7 +91,7 @@ export class ManualViewBuilder< new SQLiteView({ config: { name: this.name, - schema: undefined, + schema: this.schema, selectedFields: this.columns, query: undefined, }, @@ -108,7 +110,7 @@ export class ManualViewBuilder< new SQLiteView({ config: { name: this.name, - schema: undefined, + schema: this.schema, selectedFields: this.columns, query: query.inlineParams(), }, @@ -163,4 +165,27 @@ export function sqliteView( return new ViewBuilder(name); } +export function sqliteViewWithSchema( + name: TName, + schema: string | undefined, +): ViewBuilder; +export function sqliteViewWithSchema< + TName extends string, + TColumns extends Record, +>( + name: TName, + columns: TColumns, + schema: string | undefined, +): ManualViewBuilder; +export function sqliteViewWithSchema( + name: string, + columnsOrSchema?: Record | string, + schema?: string, +): ViewBuilder | ManualViewBuilder { + if (typeof columnsOrSchema === 'object') { + return new ManualViewBuilder(name, columnsOrSchema, schema); + } + return new ViewBuilder(name, columnsOrSchema as string | undefined); +} + export const view = sqliteView; diff --git a/integration-tests/tests/sqlite/libsql-attach.test.ts b/integration-tests/tests/sqlite/libsql-attach.test.ts new file mode 100644 index 0000000000..795618e553 --- /dev/null +++ b/integration-tests/tests/sqlite/libsql-attach.test.ts @@ -0,0 +1,163 @@ +import { type Client, createClient } from '@libsql/client'; +import { eq, sql } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/libsql'; +import { integer, sqliteSchema, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import fs from 'fs'; +import path from 'path'; +import { afterAll, beforeAll, expect, test } from 'vitest'; + +// Define Bronze schema (attached database) +const bronze = sqliteSchema('bronze'); + +const bronzeMessageSnapshot = bronze.table('message_snapshot', { + id: text('id').primaryKey(), + body: text('body').notNull(), + occurredAt: integer('occurred_at').notNull(), +}); + +// Define Warehouse schema (main database) +const warehouseMessage = sqliteTable('message', { + id: text('id').primaryKey(), + body: text('body').notNull(), +}); + +// Test context +let client: Client; +let db: ReturnType; +let tmpDir: string; +let bronzePath: string; +let warehousePath: string; + +beforeAll(async () => { + // Create temporary directory + tmpDir = path.join('/tmp', `drizzle-attach-test-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + + bronzePath = path.join(tmpDir, 'bronze.db'); + warehousePath = path.join(tmpDir, 'warehouse.db'); + + // Initialize bronze.db + const bronzeClient = createClient({ url: `file:${bronzePath}` }); + await bronzeClient.execute(` + CREATE TABLE message_snapshot ( + id TEXT PRIMARY KEY, + body TEXT NOT NULL, + occurred_at INTEGER NOT NULL + ) + `); + await bronzeClient.execute(` + INSERT INTO message_snapshot (id, body, occurred_at) + VALUES ('msg1', 'Hello from Bronze', 1234567890) + `); + bronzeClient.close(); + + // Initialize warehouse.db + client = createClient({ url: `file:${warehousePath}` }); + await client.execute(` + CREATE TABLE message ( + id TEXT PRIMARY KEY, + body TEXT NOT NULL + ) + `); + await client.execute(` + INSERT INTO message (id, body) + VALUES ('msg1', 'Hello from Warehouse') + `); + + // Create Drizzle instance + db = drizzle(client, { + schema: { bronzeMessageSnapshot, warehouseMessage }, + }); + + // Attach bronze.db + await db.$attach('bronze', bronzePath); +}); + +afterAll(async () => { + client.close(); + // Clean up temp directory + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('sqliteSchema creates tables with schema prefix', () => { + // Verify table has schema metadata + expect(bronzeMessageSnapshot[Symbol.for('drizzle:Schema')]).toBe('bronze'); + expect(warehouseMessage[Symbol.for('drizzle:Schema')]).toBeUndefined(); +}); + +test('$attach method executes ATTACH statement', async () => { + // Verify attached database is accessible via raw SQL + const result = await client.execute(` + SELECT name FROM bronze.sqlite_master WHERE type='table' + `); + expect(result.rows.length).toBeGreaterThan(0); + expect(result.rows.some((r: any) => r.name === 'message_snapshot')).toBe(true); +}); + +test('query attached schema via Drizzle ORM', async () => { + const rows = await db.select().from(bronzeMessageSnapshot).all(); + + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ + id: 'msg1', + body: 'Hello from Bronze', + occurredAt: 1234567890, + }); +}); + +test('query main schema via Drizzle ORM', async () => { + const rows = await db.select().from(warehouseMessage).all(); + + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ + id: 'msg1', + body: 'Hello from Warehouse', + }); +}); + +test('cross-database JOIN works', async () => { + const joined = await db + .select({ + bronzeId: bronzeMessageSnapshot.id, + bronzeBody: bronzeMessageSnapshot.body, + warehouseBody: warehouseMessage.body, + }) + .from(bronzeMessageSnapshot) + .leftJoin(warehouseMessage, eq(bronzeMessageSnapshot.id, warehouseMessage.id)) + .all(); + + expect(joined).toHaveLength(1); + expect(joined[0]).toEqual({ + bronzeId: 'msg1', + bronzeBody: 'Hello from Bronze', + warehouseBody: 'Hello from Warehouse', + }); +}); + +test('SQL generation includes schema prefix', async () => { + // Execute query and capture generated SQL + const query = db + .select() + .from(bronzeMessageSnapshot) + .where(eq(bronzeMessageSnapshot.id, 'msg1')) + .toSQL(); + + // Verify SQL contains schema prefix + expect(query.sql).toContain('"bronze"."message_snapshot"'); +}); + +test('$detach method removes attached database', async () => { + // Detach + await db.$detach('bronze'); + + // Verify bronze schema is no longer accessible + try { + await client.execute(`SELECT * FROM bronze.message_snapshot`); + throw new Error('Should have thrown error'); + } catch (err: any) { + expect(err.message).toContain('no such table'); + } + + // Re-attach for cleanup + await db.$attach('bronze', bronzePath); +});