@kysera/dialects 0.8.2 → 0.8.4
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/dist/index.d.ts +787 -71
- package/dist/index.js +62 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/mssql.ts +203 -57
- package/src/adapters/mysql.ts +105 -36
- package/src/adapters/postgres.ts +619 -28
- package/src/adapters/sqlite.ts +126 -36
- package/src/factory.ts +71 -17
- package/src/helpers.ts +405 -18
- package/src/index.ts +85 -9
- package/src/types.ts +103 -9
package/src/adapters/postgres.ts
CHANGED
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PostgreSQL Dialect Adapter
|
|
3
|
+
*
|
|
4
|
+
* Supports PostgreSQL 12+ with full schema support for multi-tenant
|
|
5
|
+
* and modular database architectures.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
import type { Kysely } from 'kysely'
|
|
6
9
|
import { sql } from 'kysely'
|
|
7
10
|
import { silentLogger, type KyseraLogger } from '@kysera/core'
|
|
8
|
-
import type { DialectAdapter,
|
|
9
|
-
import {
|
|
11
|
+
import type { DialectAdapter, DialectAdapterOptions, SchemaOptions } from '../types.js'
|
|
12
|
+
import {
|
|
13
|
+
assertValidIdentifier,
|
|
14
|
+
resolveSchema as resolveSchemaUtil,
|
|
15
|
+
qualifyTableName,
|
|
16
|
+
errorMatchers
|
|
17
|
+
} from '../helpers.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* PostgreSQL-specific adapter options
|
|
21
|
+
*/
|
|
22
|
+
export interface PostgresAdapterOptions extends DialectAdapterOptions {
|
|
23
|
+
/** Logger instance for error reporting */
|
|
24
|
+
logger?: KyseraLogger
|
|
25
|
+
}
|
|
10
26
|
|
|
11
27
|
export class PostgresAdapter implements DialectAdapter {
|
|
12
28
|
readonly dialect = 'postgres' as const
|
|
29
|
+
readonly defaultSchema: string
|
|
13
30
|
private logger: KyseraLogger
|
|
14
31
|
|
|
15
|
-
constructor(
|
|
16
|
-
this.
|
|
32
|
+
constructor(options: PostgresAdapterOptions = {}) {
|
|
33
|
+
this.defaultSchema = options.defaultSchema ?? 'public'
|
|
34
|
+
this.logger = options.logger ?? silentLogger
|
|
17
35
|
}
|
|
18
36
|
|
|
19
37
|
getDefaultPort(): number {
|
|
@@ -33,34 +51,39 @@ export class PostgresAdapter implements DialectAdapter {
|
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
isUniqueConstraintError(error: unknown): boolean {
|
|
36
|
-
|
|
37
|
-
const message = e.message?.toLowerCase() || ''
|
|
38
|
-
const code = e.code || ''
|
|
39
|
-
return code === '23505' || message.includes('unique constraint')
|
|
54
|
+
return errorMatchers.postgres.uniqueConstraint(error)
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
isForeignKeyError(error: unknown): boolean {
|
|
43
|
-
|
|
44
|
-
const message = e.message?.toLowerCase() || ''
|
|
45
|
-
const code = e.code || ''
|
|
46
|
-
return code === '23503' || message.includes('foreign key constraint')
|
|
58
|
+
return errorMatchers.postgres.foreignKey(error)
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
isNotNullError(error: unknown): boolean {
|
|
50
|
-
|
|
51
|
-
const message = e.message?.toLowerCase() || ''
|
|
52
|
-
const code = e.code || ''
|
|
53
|
-
return code === '23502' || message.includes('not-null constraint')
|
|
62
|
+
return errorMatchers.postgres.notNull(error)
|
|
54
63
|
}
|
|
55
64
|
|
|
56
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the schema to use for an operation.
|
|
67
|
+
* Uses the shared resolveSchema utility from helpers.ts.
|
|
68
|
+
*/
|
|
69
|
+
private resolveSchema(options?: SchemaOptions): string {
|
|
70
|
+
return resolveSchemaUtil(this.defaultSchema, options)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async tableExists(
|
|
74
|
+
db: Kysely<any>,
|
|
75
|
+
tableName: string,
|
|
76
|
+
options?: SchemaOptions
|
|
77
|
+
): Promise<boolean> {
|
|
57
78
|
assertValidIdentifier(tableName, 'table name')
|
|
79
|
+
const schema = this.resolveSchema(options)
|
|
80
|
+
|
|
58
81
|
try {
|
|
59
82
|
const result = await db
|
|
60
83
|
.selectFrom('information_schema.tables')
|
|
61
84
|
.select('table_name')
|
|
62
85
|
.where('table_name', '=', tableName)
|
|
63
|
-
.where('table_schema', '=',
|
|
86
|
+
.where('table_schema', '=', schema)
|
|
64
87
|
.executeTakeFirst()
|
|
65
88
|
return !!result
|
|
66
89
|
} catch {
|
|
@@ -68,14 +91,20 @@ export class PostgresAdapter implements DialectAdapter {
|
|
|
68
91
|
}
|
|
69
92
|
}
|
|
70
93
|
|
|
71
|
-
async getTableColumns(
|
|
94
|
+
async getTableColumns(
|
|
95
|
+
db: Kysely<any>,
|
|
96
|
+
tableName: string,
|
|
97
|
+
options?: SchemaOptions
|
|
98
|
+
): Promise<string[]> {
|
|
72
99
|
assertValidIdentifier(tableName, 'table name')
|
|
100
|
+
const schema = this.resolveSchema(options)
|
|
101
|
+
|
|
73
102
|
try {
|
|
74
103
|
const results = await db
|
|
75
104
|
.selectFrom('information_schema.columns')
|
|
76
105
|
.select('column_name')
|
|
77
106
|
.where('table_name', '=', tableName)
|
|
78
|
-
.where('table_schema', '=',
|
|
107
|
+
.where('table_schema', '=', schema)
|
|
79
108
|
.execute()
|
|
80
109
|
return results.map(r => r.column_name as string)
|
|
81
110
|
} catch {
|
|
@@ -83,12 +112,14 @@ export class PostgresAdapter implements DialectAdapter {
|
|
|
83
112
|
}
|
|
84
113
|
}
|
|
85
114
|
|
|
86
|
-
async getTables(db: Kysely<any
|
|
115
|
+
async getTables(db: Kysely<any>, options?: SchemaOptions): Promise<string[]> {
|
|
116
|
+
const schema = this.resolveSchema(options)
|
|
117
|
+
|
|
87
118
|
try {
|
|
88
119
|
const results = await db
|
|
89
120
|
.selectFrom('information_schema.tables')
|
|
90
121
|
.select('table_name')
|
|
91
|
-
.where('table_schema', '=',
|
|
122
|
+
.where('table_schema', '=', schema)
|
|
92
123
|
.where('table_type', '=', 'BASE TABLE')
|
|
93
124
|
.execute()
|
|
94
125
|
return results.map(r => r.table_name as string)
|
|
@@ -111,11 +142,18 @@ export class PostgresAdapter implements DialectAdapter {
|
|
|
111
142
|
}
|
|
112
143
|
}
|
|
113
144
|
|
|
114
|
-
async truncateTable(
|
|
145
|
+
async truncateTable(
|
|
146
|
+
db: Kysely<any>,
|
|
147
|
+
tableName: string,
|
|
148
|
+
options?: SchemaOptions
|
|
149
|
+
): Promise<boolean> {
|
|
115
150
|
assertValidIdentifier(tableName, 'table name')
|
|
151
|
+
const schema = this.resolveSchema(options)
|
|
152
|
+
|
|
116
153
|
try {
|
|
154
|
+
const qualifiedTable = qualifyTableName(schema, tableName, this.escapeIdentifier.bind(this))
|
|
117
155
|
await sql
|
|
118
|
-
.raw(`TRUNCATE TABLE ${
|
|
156
|
+
.raw(`TRUNCATE TABLE ${qualifiedTable} RESTART IDENTITY CASCADE`)
|
|
119
157
|
.execute(db)
|
|
120
158
|
return true
|
|
121
159
|
} catch (error) {
|
|
@@ -128,19 +166,572 @@ export class PostgresAdapter implements DialectAdapter {
|
|
|
128
166
|
return false
|
|
129
167
|
}
|
|
130
168
|
// Log and rethrow unexpected errors
|
|
131
|
-
this.logger.error(`Failed to truncate table "${tableName}":`, error)
|
|
169
|
+
this.logger.error(`Failed to truncate table "${schema}.${tableName}":`, error)
|
|
132
170
|
throw error
|
|
133
171
|
}
|
|
134
172
|
}
|
|
135
173
|
|
|
136
|
-
async truncateAllTables(
|
|
137
|
-
|
|
174
|
+
async truncateAllTables(
|
|
175
|
+
db: Kysely<any>,
|
|
176
|
+
exclude: string[] = [],
|
|
177
|
+
options?: SchemaOptions
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
const tables = await this.getTables(db, options)
|
|
138
180
|
for (const table of tables) {
|
|
139
181
|
if (!exclude.includes(table)) {
|
|
140
|
-
await this.truncateTable(db, table)
|
|
182
|
+
await this.truncateTable(db, table, options)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check if a schema exists in the database
|
|
189
|
+
*
|
|
190
|
+
* @param db - Kysely database instance
|
|
191
|
+
* @param schemaName - Name of the schema to check
|
|
192
|
+
* @returns true if schema exists, false otherwise
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* const exists = await adapter.schemaExists(db, 'auth')
|
|
196
|
+
*/
|
|
197
|
+
async schemaExists(db: Kysely<any>, schemaName: string): Promise<boolean> {
|
|
198
|
+
assertValidIdentifier(schemaName, 'schema name')
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const result = await db
|
|
202
|
+
.selectFrom('information_schema.schemata')
|
|
203
|
+
.select('schema_name')
|
|
204
|
+
.where('schema_name', '=', schemaName)
|
|
205
|
+
.executeTakeFirst()
|
|
206
|
+
return !!result
|
|
207
|
+
} catch {
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get all schemas in the database (excluding system schemas)
|
|
214
|
+
*
|
|
215
|
+
* @param db - Kysely database instance
|
|
216
|
+
* @returns Array of schema names
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* const schemas = await adapter.getSchemas(db)
|
|
220
|
+
* // ['public', 'auth', 'admin', 'tenant_1']
|
|
221
|
+
*/
|
|
222
|
+
async getSchemas(db: Kysely<any>): Promise<string[]> {
|
|
223
|
+
try {
|
|
224
|
+
const results = await db
|
|
225
|
+
.selectFrom('information_schema.schemata')
|
|
226
|
+
.select('schema_name')
|
|
227
|
+
.where('schema_name', 'not like', 'pg_%')
|
|
228
|
+
.where('schema_name', '!=', 'information_schema')
|
|
229
|
+
.execute()
|
|
230
|
+
return results.map(r => r.schema_name as string)
|
|
231
|
+
} catch {
|
|
232
|
+
return []
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Create a new schema in the database
|
|
238
|
+
*
|
|
239
|
+
* @param db - Kysely database instance
|
|
240
|
+
* @param schemaName - Name of the schema to create
|
|
241
|
+
* @param options - Creation options
|
|
242
|
+
* @returns true if schema was created, false if it already exists
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* await adapter.createSchema(db, 'tenant_123')
|
|
246
|
+
*/
|
|
247
|
+
async createSchema(
|
|
248
|
+
db: Kysely<any>,
|
|
249
|
+
schemaName: string,
|
|
250
|
+
options: { ifNotExists?: boolean } = {}
|
|
251
|
+
): Promise<boolean> {
|
|
252
|
+
assertValidIdentifier(schemaName, 'schema name')
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const ifNotExists = options.ifNotExists ? 'IF NOT EXISTS ' : ''
|
|
256
|
+
await sql
|
|
257
|
+
.raw(`CREATE SCHEMA ${ifNotExists}${this.escapeIdentifier(schemaName)}`)
|
|
258
|
+
.execute(db)
|
|
259
|
+
return true
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const errorMessage = String(error)
|
|
262
|
+
if (errorMessage.includes('already exists')) {
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
this.logger.error(`Failed to create schema "${schemaName}":`, error)
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Drop a schema from the database
|
|
272
|
+
*
|
|
273
|
+
* @param db - Kysely database instance
|
|
274
|
+
* @param schemaName - Name of the schema to drop
|
|
275
|
+
* @param options - Drop options
|
|
276
|
+
* @returns true if schema was dropped, false if it doesn't exist
|
|
277
|
+
*
|
|
278
|
+
* @example
|
|
279
|
+
* await adapter.dropSchema(db, 'tenant_123', { cascade: true })
|
|
280
|
+
*/
|
|
281
|
+
async dropSchema(
|
|
282
|
+
db: Kysely<any>,
|
|
283
|
+
schemaName: string,
|
|
284
|
+
options: { ifExists?: boolean; cascade?: boolean } = {}
|
|
285
|
+
): Promise<boolean> {
|
|
286
|
+
assertValidIdentifier(schemaName, 'schema name')
|
|
287
|
+
|
|
288
|
+
// Prevent dropping protected schemas
|
|
289
|
+
const protectedSchemas = ['public', 'pg_catalog', 'information_schema']
|
|
290
|
+
if (protectedSchemas.includes(schemaName)) {
|
|
291
|
+
throw new Error(`Cannot drop protected schema: ${schemaName}`)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const ifExists = options.ifExists ? 'IF EXISTS ' : ''
|
|
296
|
+
const cascade = options.cascade ? ' CASCADE' : ''
|
|
297
|
+
await sql
|
|
298
|
+
.raw(`DROP SCHEMA ${ifExists}${this.escapeIdentifier(schemaName)}${cascade}`)
|
|
299
|
+
.execute(db)
|
|
300
|
+
return true
|
|
301
|
+
} catch (error) {
|
|
302
|
+
const errorMessage = String(error)
|
|
303
|
+
if (errorMessage.includes('does not exist')) {
|
|
304
|
+
return false
|
|
141
305
|
}
|
|
306
|
+
this.logger.error(`Failed to drop schema "${schemaName}":`, error)
|
|
307
|
+
throw error
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ============================================================================
|
|
312
|
+
// Schema Information & Inspection
|
|
313
|
+
// ============================================================================
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Get detailed information about a schema
|
|
317
|
+
*
|
|
318
|
+
* @param db - Kysely database instance
|
|
319
|
+
* @param schemaName - Name of the schema
|
|
320
|
+
* @returns Schema information including table count, size, and owner
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* const info = await adapter.getSchemaInfo(db, 'tenant_123')
|
|
324
|
+
* // { name: 'tenant_123', tableCount: 15, owner: 'app_user', sizeBytes: 1048576 }
|
|
325
|
+
*/
|
|
326
|
+
async getSchemaInfo(
|
|
327
|
+
db: Kysely<any>,
|
|
328
|
+
schemaName: string
|
|
329
|
+
): Promise<{
|
|
330
|
+
name: string
|
|
331
|
+
tableCount: number
|
|
332
|
+
owner: string | null
|
|
333
|
+
sizeBytes: number
|
|
334
|
+
}> {
|
|
335
|
+
assertValidIdentifier(schemaName, 'schema name')
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
// Get table count
|
|
339
|
+
const tablesResult = await db
|
|
340
|
+
.selectFrom('information_schema.tables')
|
|
341
|
+
.select(sql<number>`count(*)::int`.as('count'))
|
|
342
|
+
.where('table_schema', '=', schemaName)
|
|
343
|
+
.where('table_type', '=', 'BASE TABLE')
|
|
344
|
+
.executeTakeFirst()
|
|
345
|
+
|
|
346
|
+
// Get schema owner from pg_namespace
|
|
347
|
+
const ownerResult = await sql<{ owner: string }>`
|
|
348
|
+
SELECT pg_catalog.pg_get_userbyid(nspowner) as owner
|
|
349
|
+
FROM pg_catalog.pg_namespace
|
|
350
|
+
WHERE nspname = ${schemaName}
|
|
351
|
+
`.execute(db)
|
|
352
|
+
|
|
353
|
+
// Calculate schema size
|
|
354
|
+
const sizeResult = await sql<{ size: number }>`
|
|
355
|
+
SELECT COALESCE(
|
|
356
|
+
SUM(pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(tablename)))::bigint,
|
|
357
|
+
0
|
|
358
|
+
) as size
|
|
359
|
+
FROM pg_tables
|
|
360
|
+
WHERE schemaname = ${schemaName}
|
|
361
|
+
`.execute(db)
|
|
362
|
+
|
|
363
|
+
const rawSize = sizeResult.rows?.[0]?.size
|
|
364
|
+
return {
|
|
365
|
+
name: schemaName,
|
|
366
|
+
tableCount: tablesResult?.count ?? 0,
|
|
367
|
+
owner: ownerResult.rows?.[0]?.owner ?? null,
|
|
368
|
+
sizeBytes: typeof rawSize === 'number' ? rawSize : (rawSize ? parseInt(String(rawSize), 10) : 0)
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
this.logger.error(`Failed to get schema info for "${schemaName}":`, error)
|
|
372
|
+
return {
|
|
373
|
+
name: schemaName,
|
|
374
|
+
tableCount: 0,
|
|
375
|
+
owner: null,
|
|
376
|
+
sizeBytes: 0
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Get index information for all tables in a schema
|
|
383
|
+
*
|
|
384
|
+
* @param db - Kysely database instance
|
|
385
|
+
* @param options - Optional schema configuration
|
|
386
|
+
* @returns Array of index information
|
|
387
|
+
*
|
|
388
|
+
* @example
|
|
389
|
+
* const indexes = await adapter.getSchemaIndexes(db, { schema: 'auth' })
|
|
390
|
+
*/
|
|
391
|
+
async getSchemaIndexes(
|
|
392
|
+
db: Kysely<any>,
|
|
393
|
+
options?: SchemaOptions
|
|
394
|
+
): Promise<{
|
|
395
|
+
tableName: string
|
|
396
|
+
indexName: string
|
|
397
|
+
indexType: string
|
|
398
|
+
isUnique: boolean
|
|
399
|
+
isPrimary: boolean
|
|
400
|
+
columns: string[]
|
|
401
|
+
}[]> {
|
|
402
|
+
const schema = this.resolveSchema(options)
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
const result = await sql<{
|
|
406
|
+
table_name: string
|
|
407
|
+
index_name: string
|
|
408
|
+
index_type: string
|
|
409
|
+
is_unique: boolean
|
|
410
|
+
is_primary: boolean
|
|
411
|
+
column_names: string
|
|
412
|
+
}>`
|
|
413
|
+
SELECT
|
|
414
|
+
t.relname as table_name,
|
|
415
|
+
i.relname as index_name,
|
|
416
|
+
am.amname as index_type,
|
|
417
|
+
ix.indisunique as is_unique,
|
|
418
|
+
ix.indisprimary as is_primary,
|
|
419
|
+
string_agg(a.attname, ', ' ORDER BY array_position(ix.indkey, a.attnum)) as column_names
|
|
420
|
+
FROM pg_index ix
|
|
421
|
+
JOIN pg_class t ON t.oid = ix.indrelid
|
|
422
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
423
|
+
JOIN pg_am am ON am.oid = i.relam
|
|
424
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
425
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
|
426
|
+
WHERE n.nspname = ${schema}
|
|
427
|
+
AND t.relkind = 'r'
|
|
428
|
+
GROUP BY t.relname, i.relname, am.amname, ix.indisunique, ix.indisprimary
|
|
429
|
+
ORDER BY t.relname, i.relname
|
|
430
|
+
`.execute(db)
|
|
431
|
+
|
|
432
|
+
return (result.rows ?? []).map(row => ({
|
|
433
|
+
tableName: row.table_name,
|
|
434
|
+
indexName: row.index_name,
|
|
435
|
+
indexType: row.index_type,
|
|
436
|
+
isUnique: row.is_unique,
|
|
437
|
+
isPrimary: row.is_primary,
|
|
438
|
+
columns: row.column_names.split(', ')
|
|
439
|
+
}))
|
|
440
|
+
} catch (error) {
|
|
441
|
+
this.logger.error(`Failed to get indexes for schema "${schema}":`, error)
|
|
442
|
+
return []
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Get foreign key relationships in a schema
|
|
448
|
+
*
|
|
449
|
+
* @param db - Kysely database instance
|
|
450
|
+
* @param options - Optional schema configuration
|
|
451
|
+
* @returns Array of foreign key relationships
|
|
452
|
+
*
|
|
453
|
+
* @example
|
|
454
|
+
* const fks = await adapter.getSchemaForeignKeys(db, { schema: 'public' })
|
|
455
|
+
*/
|
|
456
|
+
async getSchemaForeignKeys(
|
|
457
|
+
db: Kysely<any>,
|
|
458
|
+
options?: SchemaOptions
|
|
459
|
+
): Promise<{
|
|
460
|
+
constraintName: string
|
|
461
|
+
tableName: string
|
|
462
|
+
columnName: string
|
|
463
|
+
referencedSchema: string
|
|
464
|
+
referencedTable: string
|
|
465
|
+
referencedColumn: string
|
|
466
|
+
onDelete: string
|
|
467
|
+
onUpdate: string
|
|
468
|
+
}[]> {
|
|
469
|
+
const schema = this.resolveSchema(options)
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const result = await sql<{
|
|
473
|
+
constraint_name: string
|
|
474
|
+
table_name: string
|
|
475
|
+
column_name: string
|
|
476
|
+
referenced_schema: string
|
|
477
|
+
referenced_table: string
|
|
478
|
+
referenced_column: string
|
|
479
|
+
on_delete: string
|
|
480
|
+
on_update: string
|
|
481
|
+
}>`
|
|
482
|
+
SELECT
|
|
483
|
+
tc.constraint_name,
|
|
484
|
+
tc.table_name,
|
|
485
|
+
kcu.column_name,
|
|
486
|
+
ccu.table_schema as referenced_schema,
|
|
487
|
+
ccu.table_name as referenced_table,
|
|
488
|
+
ccu.column_name as referenced_column,
|
|
489
|
+
rc.delete_rule as on_delete,
|
|
490
|
+
rc.update_rule as on_update
|
|
491
|
+
FROM information_schema.table_constraints tc
|
|
492
|
+
JOIN information_schema.key_column_usage kcu
|
|
493
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
494
|
+
AND tc.table_schema = kcu.table_schema
|
|
495
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
496
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
497
|
+
AND ccu.table_schema = tc.table_schema
|
|
498
|
+
JOIN information_schema.referential_constraints rc
|
|
499
|
+
ON tc.constraint_name = rc.constraint_name
|
|
500
|
+
AND tc.table_schema = rc.constraint_schema
|
|
501
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
502
|
+
AND tc.table_schema = ${schema}
|
|
503
|
+
ORDER BY tc.table_name, kcu.column_name
|
|
504
|
+
`.execute(db)
|
|
505
|
+
|
|
506
|
+
return (result.rows ?? []).map(row => ({
|
|
507
|
+
constraintName: row.constraint_name,
|
|
508
|
+
tableName: row.table_name,
|
|
509
|
+
columnName: row.column_name,
|
|
510
|
+
referencedSchema: row.referenced_schema,
|
|
511
|
+
referencedTable: row.referenced_table,
|
|
512
|
+
referencedColumn: row.referenced_column,
|
|
513
|
+
onDelete: row.on_delete,
|
|
514
|
+
onUpdate: row.on_update
|
|
515
|
+
}))
|
|
516
|
+
} catch (error) {
|
|
517
|
+
this.logger.error(`Failed to get foreign keys for schema "${schema}":`, error)
|
|
518
|
+
return []
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ============================================================================
|
|
523
|
+
// Search Path Management
|
|
524
|
+
// ============================================================================
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Get the current search_path setting
|
|
528
|
+
*
|
|
529
|
+
* @param db - Kysely database instance
|
|
530
|
+
* @returns Array of schema names in the search path
|
|
531
|
+
*
|
|
532
|
+
* @example
|
|
533
|
+
* const path = await adapter.getSearchPath(db)
|
|
534
|
+
* // ['public', 'tenant_123']
|
|
535
|
+
*/
|
|
536
|
+
async getSearchPath(db: Kysely<any>): Promise<string[]> {
|
|
537
|
+
try {
|
|
538
|
+
const result = await sql<{ search_path: string }>`SHOW search_path`.execute(db)
|
|
539
|
+
const rawPath = result.rows?.[0]?.search_path ?? ''
|
|
540
|
+
return rawPath
|
|
541
|
+
.split(',')
|
|
542
|
+
.map(s => s.trim().replace(/^"(.*)"$/, '$1'))
|
|
543
|
+
.filter(s => s.length > 0)
|
|
544
|
+
} catch (error) {
|
|
545
|
+
this.logger.error('Failed to get search_path:', error)
|
|
546
|
+
return []
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Set the search_path for the current session
|
|
552
|
+
*
|
|
553
|
+
* @param db - Kysely database instance
|
|
554
|
+
* @param schemas - Array of schema names to set in the search path
|
|
555
|
+
*
|
|
556
|
+
* @example
|
|
557
|
+
* // Set search path for multi-tenant query
|
|
558
|
+
* await adapter.setSearchPath(db, ['tenant_123', 'public'])
|
|
559
|
+
*/
|
|
560
|
+
async setSearchPath(db: Kysely<any>, schemas: string[]): Promise<void> {
|
|
561
|
+
for (const schema of schemas) {
|
|
562
|
+
assertValidIdentifier(schema, 'schema name')
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const escapedSchemas = schemas.map(s => this.escapeIdentifier(s)).join(', ')
|
|
566
|
+
await sql.raw(`SET search_path TO ${escapedSchemas}`).execute(db)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Execute a function with a temporary search_path, then restore the original
|
|
571
|
+
*
|
|
572
|
+
* @param db - Kysely database instance
|
|
573
|
+
* @param schemas - Temporary search path
|
|
574
|
+
* @param fn - Function to execute with the temporary search path
|
|
575
|
+
* @returns The result of the function
|
|
576
|
+
*
|
|
577
|
+
* @example
|
|
578
|
+
* const result = await adapter.withSearchPath(db, ['tenant_123'], async () => {
|
|
579
|
+
* // All queries in here will use tenant_123 schema by default
|
|
580
|
+
* return await db.selectFrom('users').selectAll().execute()
|
|
581
|
+
* })
|
|
582
|
+
*/
|
|
583
|
+
async withSearchPath<T>(
|
|
584
|
+
db: Kysely<any>,
|
|
585
|
+
schemas: string[],
|
|
586
|
+
fn: () => Promise<T>
|
|
587
|
+
): Promise<T> {
|
|
588
|
+
const originalPath = await this.getSearchPath(db)
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
await this.setSearchPath(db, schemas)
|
|
592
|
+
return await fn()
|
|
593
|
+
} finally {
|
|
594
|
+
if (originalPath.length > 0) {
|
|
595
|
+
await this.setSearchPath(db, originalPath)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ============================================================================
|
|
601
|
+
// Schema Cloning & Migration
|
|
602
|
+
// ============================================================================
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Clone a schema's structure (tables, indexes, constraints) to a new schema
|
|
606
|
+
*
|
|
607
|
+
* @param db - Kysely database instance
|
|
608
|
+
* @param sourceSchema - Source schema to clone from
|
|
609
|
+
* @param targetSchema - Target schema name to create
|
|
610
|
+
* @param options - Clone options
|
|
611
|
+
* @returns true if successful
|
|
612
|
+
*
|
|
613
|
+
* @example
|
|
614
|
+
* // Clone 'template' schema to create new tenant schema
|
|
615
|
+
* await adapter.cloneSchema(db, 'template', 'tenant_456')
|
|
616
|
+
*
|
|
617
|
+
* @example
|
|
618
|
+
* // Clone with data included
|
|
619
|
+
* await adapter.cloneSchema(db, 'template', 'tenant_456', { includeData: true })
|
|
620
|
+
*/
|
|
621
|
+
async cloneSchema(
|
|
622
|
+
db: Kysely<any>,
|
|
623
|
+
sourceSchema: string,
|
|
624
|
+
targetSchema: string,
|
|
625
|
+
options: {
|
|
626
|
+
includeData?: boolean
|
|
627
|
+
excludeTables?: string[]
|
|
628
|
+
} = {}
|
|
629
|
+
): Promise<boolean> {
|
|
630
|
+
assertValidIdentifier(sourceSchema, 'source schema name')
|
|
631
|
+
assertValidIdentifier(targetSchema, 'target schema name')
|
|
632
|
+
|
|
633
|
+
const { includeData = false, excludeTables = [] } = options
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
// Create target schema
|
|
637
|
+
await this.createSchema(db, targetSchema, { ifNotExists: true })
|
|
638
|
+
|
|
639
|
+
// Get all tables from source schema
|
|
640
|
+
const tables = await this.getTables(db, { schema: sourceSchema })
|
|
641
|
+
const tablesToClone = tables.filter(t => !excludeTables.includes(t))
|
|
642
|
+
|
|
643
|
+
// Clone each table
|
|
644
|
+
for (const tableName of tablesToClone) {
|
|
645
|
+
const sourceTable = qualifyTableName(sourceSchema, tableName, this.escapeIdentifier.bind(this))
|
|
646
|
+
const targetTable = qualifyTableName(targetSchema, tableName, this.escapeIdentifier.bind(this))
|
|
647
|
+
|
|
648
|
+
if (includeData) {
|
|
649
|
+
// Clone structure and data
|
|
650
|
+
await sql.raw(`CREATE TABLE ${targetTable} (LIKE ${sourceTable} INCLUDING ALL)`).execute(db)
|
|
651
|
+
await sql.raw(`INSERT INTO ${targetTable} SELECT * FROM ${sourceTable}`).execute(db)
|
|
652
|
+
} else {
|
|
653
|
+
// Clone structure only with all constraints, indexes, defaults
|
|
654
|
+
await sql.raw(`CREATE TABLE ${targetTable} (LIKE ${sourceTable} INCLUDING ALL)`).execute(db)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return true
|
|
659
|
+
} catch (error) {
|
|
660
|
+
this.logger.error(`Failed to clone schema "${sourceSchema}" to "${targetSchema}":`, error)
|
|
661
|
+
throw error
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Compare two schemas and return the differences
|
|
667
|
+
*
|
|
668
|
+
* @param db - Kysely database instance
|
|
669
|
+
* @param schema1 - First schema name
|
|
670
|
+
* @param schema2 - Second schema name
|
|
671
|
+
* @returns Comparison result with tables unique to each schema
|
|
672
|
+
*
|
|
673
|
+
* @example
|
|
674
|
+
* const diff = await adapter.compareSchemas(db, 'template', 'tenant_123')
|
|
675
|
+
* // { onlyInFirst: ['archived_users'], onlyInSecond: ['custom_settings'], inBoth: ['users', 'posts'] }
|
|
676
|
+
*/
|
|
677
|
+
async compareSchemas(
|
|
678
|
+
db: Kysely<any>,
|
|
679
|
+
schema1: string,
|
|
680
|
+
schema2: string
|
|
681
|
+
): Promise<{
|
|
682
|
+
onlyInFirst: string[]
|
|
683
|
+
onlyInSecond: string[]
|
|
684
|
+
inBoth: string[]
|
|
685
|
+
}> {
|
|
686
|
+
assertValidIdentifier(schema1, 'schema name')
|
|
687
|
+
assertValidIdentifier(schema2, 'schema name')
|
|
688
|
+
|
|
689
|
+
const tables1 = new Set(await this.getTables(db, { schema: schema1 }))
|
|
690
|
+
const tables2 = new Set(await this.getTables(db, { schema: schema2 }))
|
|
691
|
+
|
|
692
|
+
const onlyInFirst: string[] = []
|
|
693
|
+
const inBoth: string[] = []
|
|
694
|
+
|
|
695
|
+
for (const table of tables1) {
|
|
696
|
+
if (tables2.has(table)) {
|
|
697
|
+
inBoth.push(table)
|
|
698
|
+
} else {
|
|
699
|
+
onlyInFirst.push(table)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const onlyInSecond = [...tables2].filter(t => !tables1.has(t))
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
onlyInFirst: onlyInFirst.sort(),
|
|
707
|
+
onlyInSecond: onlyInSecond.sort(),
|
|
708
|
+
inBoth: inBoth.sort()
|
|
142
709
|
}
|
|
143
710
|
}
|
|
144
711
|
}
|
|
145
712
|
|
|
713
|
+
/**
|
|
714
|
+
* Default PostgreSQL adapter instance with 'public' schema
|
|
715
|
+
*/
|
|
146
716
|
export const postgresAdapter = new PostgresAdapter()
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Create a new PostgreSQL adapter with custom configuration
|
|
720
|
+
*
|
|
721
|
+
* @param options - Adapter configuration options
|
|
722
|
+
* @returns Configured PostgresAdapter instance
|
|
723
|
+
*
|
|
724
|
+
* @example
|
|
725
|
+
* // Create adapter with custom default schema
|
|
726
|
+
* const adapter = createPostgresAdapter({ defaultSchema: 'auth' })
|
|
727
|
+
*
|
|
728
|
+
* @example
|
|
729
|
+
* // Create adapter with logger
|
|
730
|
+
* const adapter = createPostgresAdapter({
|
|
731
|
+
* defaultSchema: 'app',
|
|
732
|
+
* logger: myLogger
|
|
733
|
+
* })
|
|
734
|
+
*/
|
|
735
|
+
export function createPostgresAdapter(options?: PostgresAdapterOptions): PostgresAdapter {
|
|
736
|
+
return new PostgresAdapter(options)
|
|
737
|
+
}
|