@kysera/dialects 0.8.2 → 0.8.3

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/src/helpers.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Kysely } from 'kysely'
9
- import type { Dialect } from './types.js'
9
+ import type { Dialect, SchemaOptions, DatabaseErrorLike } from './types.js'
10
10
  import { getAdapter } from './factory.js'
11
11
 
12
12
  /**
@@ -21,7 +21,7 @@ const MAX_IDENTIFIER_LENGTH = 128
21
21
  const IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_.]*$/
22
22
 
23
23
  /**
24
- * Validate a SQL identifier (table name, column name, etc.)
24
+ * Validate a SQL identifier (table name, column name, schema name, etc.)
25
25
  *
26
26
  * @param name - The identifier to validate
27
27
  * @returns true if the identifier is valid, false otherwise
@@ -58,44 +58,425 @@ export function assertValidIdentifier(name: string, context = 'identifier'): voi
58
58
  }
59
59
  }
60
60
 
61
+ // ============================================================================
62
+ // Schema Resolution Utilities
63
+ // ============================================================================
64
+
65
+ /**
66
+ * Resolve schema name with validation
67
+ *
68
+ * This is the canonical implementation used by all dialect adapters.
69
+ * Eliminates code duplication across PostgreSQL, MySQL, SQLite, and MSSQL adapters.
70
+ *
71
+ * @param defaultSchema - The default schema to use if not specified in options
72
+ * @param options - Optional schema configuration
73
+ * @returns The resolved and validated schema name
74
+ * @throws Error if the schema name is invalid
75
+ *
76
+ * @example
77
+ * // Use with adapter's default schema
78
+ * const schema = resolveSchema('public', options)
79
+ *
80
+ * @example
81
+ * // Multi-tenant usage
82
+ * const schema = resolveSchema('public', { schema: `tenant_${tenantId}` })
83
+ */
84
+ export function resolveSchema(defaultSchema: string, options?: SchemaOptions): string {
85
+ const schema = options?.schema ?? defaultSchema
86
+ assertValidIdentifier(schema, 'schema name')
87
+ return schema
88
+ }
89
+
90
+ // ============================================================================
91
+ // Multi-tenant Schema Utilities
92
+ // ============================================================================
93
+
94
+ /**
95
+ * Default prefix for tenant schemas
96
+ */
97
+ const DEFAULT_TENANT_PREFIX = 'tenant_'
98
+
99
+ /**
100
+ * Configuration for multi-tenant schema operations
101
+ */
102
+ export interface TenantSchemaConfig {
103
+ /** Prefix for tenant schema names (default: 'tenant_') */
104
+ prefix?: string
105
+ }
106
+
107
+ /**
108
+ * Generate a tenant schema name from a tenant ID
109
+ *
110
+ * @param tenantId - The unique tenant identifier
111
+ * @param config - Optional configuration
112
+ * @returns The tenant schema name (validated)
113
+ * @throws Error if the resulting schema name is invalid
114
+ *
115
+ * @example
116
+ * getTenantSchemaName('123') // 'tenant_123'
117
+ * getTenantSchemaName('acme') // 'tenant_acme'
118
+ * getTenantSchemaName('corp', { prefix: 'org_' }) // 'org_corp'
119
+ */
120
+ export function getTenantSchemaName(tenantId: string, config?: TenantSchemaConfig): string {
121
+ if (!tenantId) {
122
+ throw new Error('Invalid tenant schema name: tenant ID cannot be empty')
123
+ }
124
+ const prefix = config?.prefix ?? DEFAULT_TENANT_PREFIX
125
+ const schemaName = `${prefix}${tenantId}`
126
+ assertValidIdentifier(schemaName, 'tenant schema name')
127
+ return schemaName
128
+ }
129
+
130
+ /**
131
+ * Extract tenant ID from a tenant schema name
132
+ *
133
+ * @param schemaName - The schema name to parse
134
+ * @param config - Optional configuration
135
+ * @returns The tenant ID if the schema matches the pattern, null otherwise
136
+ *
137
+ * @example
138
+ * parseTenantSchemaName('tenant_123') // '123'
139
+ * parseTenantSchemaName('tenant_acme') // 'acme'
140
+ * parseTenantSchemaName('public') // null
141
+ * parseTenantSchemaName('org_corp', { prefix: 'org_' }) // 'corp'
142
+ */
143
+ export function parseTenantSchemaName(schemaName: string, config?: TenantSchemaConfig): string | null {
144
+ const prefix = config?.prefix ?? DEFAULT_TENANT_PREFIX
145
+ if (schemaName.startsWith(prefix) && schemaName.length > prefix.length) {
146
+ return schemaName.slice(prefix.length)
147
+ }
148
+ return null
149
+ }
150
+
151
+ /**
152
+ * Check if a schema name matches the tenant schema pattern
153
+ *
154
+ * @param schemaName - The schema name to check
155
+ * @param config - Optional configuration
156
+ * @returns true if the schema matches the tenant pattern
157
+ *
158
+ * @example
159
+ * isTenantSchema('tenant_123') // true
160
+ * isTenantSchema('public') // false
161
+ * isTenantSchema('org_corp', { prefix: 'org_' }) // true
162
+ */
163
+ export function isTenantSchema(schemaName: string, config?: TenantSchemaConfig): boolean {
164
+ return parseTenantSchemaName(schemaName, config) !== null
165
+ }
166
+
167
+ /**
168
+ * Filter an array of schema names to only tenant schemas
169
+ *
170
+ * @param schemas - Array of schema names
171
+ * @param config - Optional configuration
172
+ * @returns Array of tenant schema names
173
+ *
174
+ * @example
175
+ * filterTenantSchemas(['public', 'tenant_1', 'tenant_2', 'auth'])
176
+ * // ['tenant_1', 'tenant_2']
177
+ */
178
+ export function filterTenantSchemas(schemas: string[], config?: TenantSchemaConfig): string[] {
179
+ return schemas.filter(schema => isTenantSchema(schema, config))
180
+ }
181
+
182
+ /**
183
+ * Extract tenant IDs from an array of schema names
184
+ *
185
+ * @param schemas - Array of schema names
186
+ * @param config - Optional configuration
187
+ * @returns Array of tenant IDs (excluding non-tenant schemas)
188
+ *
189
+ * @example
190
+ * extractTenantIds(['public', 'tenant_1', 'tenant_2', 'auth'])
191
+ * // ['1', '2']
192
+ */
193
+ export function extractTenantIds(schemas: string[], config?: TenantSchemaConfig): string[] {
194
+ return schemas
195
+ .map(schema => parseTenantSchemaName(schema, config))
196
+ .filter((id): id is string => id !== null)
197
+ }
198
+
199
+ // ============================================================================
200
+ // Schema Copying Utilities
201
+ // ============================================================================
202
+
203
+ /**
204
+ * Options for schema copying operations
205
+ */
206
+ export interface SchemaCopyOptions {
207
+ /** Include table data (default: false, structure only) */
208
+ includeData?: boolean
209
+ /** Tables to exclude from copying */
210
+ excludeTables?: string[]
211
+ /** Tables to include (if specified, only these are copied) */
212
+ includeTables?: string[]
213
+ }
214
+
215
+ /**
216
+ * Create a qualified table name with schema prefix
217
+ *
218
+ * @param schema - The schema name
219
+ * @param tableName - The table name
220
+ * @param escapeIdentifierFn - Function to escape identifiers for the specific dialect
221
+ * @returns Fully qualified table name (e.g., "public"."users")
222
+ *
223
+ * @example
224
+ * qualifyTableName('auth', 'users', escapeIdentifier)
225
+ * // PostgreSQL: "auth"."users"
226
+ * // MySQL: `auth`.`users`
227
+ */
228
+ export function qualifyTableName(
229
+ schema: string,
230
+ tableName: string,
231
+ escapeIdentifierFn: (id: string) => string
232
+ ): string {
233
+ return `${escapeIdentifierFn(schema)}.${escapeIdentifierFn(tableName)}`
234
+ }
235
+
236
+ // ============================================================================
237
+ // Error Detection Utilities
238
+ // ============================================================================
239
+
240
+ /**
241
+ * Error information extracted from a database error
242
+ */
243
+ export interface ExtractedErrorInfo {
244
+ /** Error code (e.g., '23505' for PostgreSQL unique constraint) */
245
+ code: string
246
+ /** Error message in lowercase for case-insensitive matching */
247
+ message: string
248
+ /** Original error message */
249
+ originalMessage: string
250
+ /** Error number (for MSSQL) - undefined if not present */
251
+ number: number | undefined
252
+ }
253
+
254
+ /**
255
+ * Extract error information from an unknown database error
256
+ *
257
+ * This utility normalizes error information across different database drivers,
258
+ * eliminating the need for repeated type assertions in each adapter.
259
+ *
260
+ * @param error - The unknown error from a database operation
261
+ * @returns Normalized error information
262
+ *
263
+ * @example
264
+ * try {
265
+ * await db.insertInto('users').values(data).execute()
266
+ * } catch (error) {
267
+ * const info = extractErrorInfo(error)
268
+ * if (info.code === '23505') {
269
+ * // Handle unique constraint violation
270
+ * }
271
+ * }
272
+ */
273
+ export function extractErrorInfo(error: unknown): ExtractedErrorInfo {
274
+ // Handle null/undefined gracefully
275
+ if (error == null || typeof error !== 'object') {
276
+ return {
277
+ code: '',
278
+ message: '',
279
+ originalMessage: '',
280
+ number: undefined
281
+ }
282
+ }
283
+ const e = error as DatabaseErrorLike
284
+ const originalMessage = e.message ?? ''
285
+ return {
286
+ code: e.code ?? '',
287
+ message: originalMessage.toLowerCase(),
288
+ originalMessage,
289
+ number: typeof e.number === 'number' ? e.number : undefined
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Error matcher configuration for a specific error type
295
+ */
296
+ export interface ErrorMatcherConfig {
297
+ /** PostgreSQL error codes */
298
+ codes?: string[]
299
+ /** MSSQL error numbers */
300
+ numbers?: number[]
301
+ /** Message substrings to match (case-insensitive) */
302
+ messages?: string[]
303
+ }
304
+
305
+ /**
306
+ * Create an error matcher function for a specific constraint type
307
+ *
308
+ * This factory eliminates code duplication across dialect adapters by creating
309
+ * reusable error detection functions.
310
+ *
311
+ * @param config - Configuration for matching the error
312
+ * @returns A function that checks if an error matches the configured patterns
313
+ *
314
+ * @example
315
+ * // Create a unique constraint error matcher for PostgreSQL
316
+ * const isUniqueConstraint = createErrorMatcher({
317
+ * codes: ['23505'],
318
+ * messages: ['unique constraint']
319
+ * })
320
+ *
321
+ * @example
322
+ * // Create a foreign key error matcher for MSSQL
323
+ * const isForeignKey = createErrorMatcher({
324
+ * numbers: [547],
325
+ * messages: ['foreign key']
326
+ * })
327
+ */
328
+ export function createErrorMatcher(
329
+ config: ErrorMatcherConfig
330
+ ): (error: unknown) => boolean {
331
+ const { codes = [], numbers = [], messages = [] } = config
332
+
333
+ return (error: unknown): boolean => {
334
+ const info = extractErrorInfo(error)
335
+
336
+ // Check error codes (PostgreSQL, MySQL)
337
+ if (codes.length > 0 && codes.includes(info.code)) {
338
+ return true
339
+ }
340
+
341
+ // Check error numbers (MSSQL)
342
+ if (numbers.length > 0 && info.number !== undefined && numbers.includes(info.number)) {
343
+ return true
344
+ }
345
+
346
+ // Check message patterns
347
+ if (messages.length > 0) {
348
+ for (const pattern of messages) {
349
+ if (info.message.includes(pattern.toLowerCase())) {
350
+ return true
351
+ }
352
+ }
353
+ }
354
+
355
+ return false
356
+ }
357
+ }
358
+
359
+ // ============================================================================
360
+ // Pre-built Error Matchers
361
+ // ============================================================================
362
+
363
+ /**
364
+ * Pre-built error matchers for common constraint violations
365
+ * These can be used directly or as reference for custom matchers
366
+ */
367
+ export const errorMatchers = {
368
+ postgres: {
369
+ uniqueConstraint: createErrorMatcher({ codes: ['23505'], messages: ['unique constraint'] }),
370
+ foreignKey: createErrorMatcher({ codes: ['23503'], messages: ['foreign key constraint'] }),
371
+ notNull: createErrorMatcher({ codes: ['23502'], messages: ['not-null constraint'] })
372
+ },
373
+ mysql: {
374
+ // Include both named codes (ER_*) and numeric codes (1062, etc.)
375
+ uniqueConstraint: createErrorMatcher({
376
+ codes: ['ER_DUP_ENTRY', '1062'],
377
+ messages: ['duplicate entry']
378
+ }),
379
+ foreignKey: createErrorMatcher({
380
+ codes: ['ER_NO_REFERENCED_ROW_2', 'ER_ROW_IS_REFERENCED_2', 'ER_ROW_IS_REFERENCED', 'ER_NO_REFERENCED_ROW', '1451', '1452'],
381
+ messages: ['foreign key constraint']
382
+ }),
383
+ notNull: createErrorMatcher({
384
+ codes: ['ER_BAD_NULL_ERROR', '1048'],
385
+ messages: ['cannot be null']
386
+ })
387
+ },
388
+ sqlite: {
389
+ uniqueConstraint: createErrorMatcher({ messages: ['unique constraint failed'] }),
390
+ foreignKey: createErrorMatcher({ messages: ['foreign key constraint failed'] }),
391
+ notNull: createErrorMatcher({ messages: ['not null constraint failed'] })
392
+ },
393
+ mssql: {
394
+ // MSSQL uses numeric error codes
395
+ uniqueConstraint: createErrorMatcher({
396
+ codes: ['2627', '2601'],
397
+ numbers: [2627, 2601],
398
+ messages: ['violation of unique key constraint', 'cannot insert duplicate key', 'unique constraint']
399
+ }),
400
+ foreignKey: createErrorMatcher({
401
+ codes: ['547'],
402
+ numbers: [547],
403
+ messages: ['foreign key constraint', 'conflicted with the foreign key']
404
+ }),
405
+ notNull: createErrorMatcher({
406
+ codes: ['515'],
407
+ numbers: [515],
408
+ messages: ['cannot insert the value null', 'does not allow nulls']
409
+ })
410
+ }
411
+ } as const
412
+
61
413
  /**
62
414
  * Check if table exists in the database
63
415
  *
416
+ * @param db - Kysely database instance
417
+ * @param tableName - Name of the table to check
418
+ * @param dialect - Database dialect
419
+ * @param options - Optional schema configuration
420
+ * @returns true if table exists, false otherwise
421
+ *
422
+ * @example
423
+ * // Check in default schema
424
+ * const exists = await tableExists(db, 'users', 'postgres')
425
+ *
64
426
  * @example
65
- * const exists = await tableExists(db, 'users', 'postgres');
427
+ * // Check in specific schema
428
+ * const exists = await tableExists(db, 'users', 'postgres', { schema: 'auth' })
66
429
  */
67
430
  export async function tableExists(
68
431
  db: Kysely<any>,
69
432
  tableName: string,
70
- dialect: Dialect
433
+ dialect: Dialect,
434
+ options?: SchemaOptions
71
435
  ): Promise<boolean> {
72
- return await getAdapter(dialect).tableExists(db, tableName)
436
+ return await getAdapter(dialect).tableExists(db, tableName, options)
73
437
  }
74
438
 
75
439
  /**
76
440
  * Get column names for a table
77
441
  *
442
+ * @param db - Kysely database instance
443
+ * @param tableName - Name of the table
444
+ * @param dialect - Database dialect
445
+ * @param options - Optional schema configuration
446
+ * @returns Array of column names
447
+ *
78
448
  * @example
79
- * const columns = await getTableColumns(db, 'users', 'postgres');
449
+ * const columns = await getTableColumns(db, 'users', 'postgres', { schema: 'auth' })
80
450
  * // ['id', 'name', 'email', 'created_at']
81
451
  */
82
452
  export async function getTableColumns(
83
453
  db: Kysely<any>,
84
454
  tableName: string,
85
- dialect: Dialect
455
+ dialect: Dialect,
456
+ options?: SchemaOptions
86
457
  ): Promise<string[]> {
87
- return await getAdapter(dialect).getTableColumns(db, tableName)
458
+ return await getAdapter(dialect).getTableColumns(db, tableName, options)
88
459
  }
89
460
 
90
461
  /**
91
- * Get all tables in the database
462
+ * Get all tables in the database/schema
463
+ *
464
+ * @param db - Kysely database instance
465
+ * @param dialect - Database dialect
466
+ * @param options - Optional schema configuration
467
+ * @returns Array of table names
92
468
  *
93
469
  * @example
94
- * const tables = await getTables(db, 'postgres');
95
- * // ['users', 'posts', 'comments']
470
+ * // Get tables in auth schema
471
+ * const tables = await getTables(db, 'postgres', { schema: 'auth' })
472
+ * // ['users', 'sessions', 'tokens']
96
473
  */
97
- export async function getTables(db: Kysely<any>, dialect: Dialect): Promise<string[]> {
98
- return await getAdapter(dialect).getTables(db)
474
+ export async function getTables(
475
+ db: Kysely<any>,
476
+ dialect: Dialect,
477
+ options?: SchemaOptions
478
+ ): Promise<string[]> {
479
+ return await getAdapter(dialect).getTables(db, options)
99
480
  }
100
481
 
101
482
  /**
@@ -187,16 +568,22 @@ export async function getDatabaseSize(
187
568
  }
188
569
 
189
570
  /**
190
- * Truncate all tables in the database (useful for testing)
571
+ * Truncate all tables in the database/schema (useful for testing)
572
+ *
573
+ * @param db - Kysely database instance
574
+ * @param dialect - Database dialect
575
+ * @param exclude - Array of table names to exclude
576
+ * @param options - Optional schema configuration
191
577
  *
192
578
  * @example
193
- * // Truncate all tables except migrations
194
- * await truncateAllTables(db, 'postgres', ['kysely_migrations']);
579
+ * // Truncate all tables in auth schema except migrations
580
+ * await truncateAllTables(db, 'postgres', ['kysely_migrations'], { schema: 'auth' })
195
581
  */
196
582
  export async function truncateAllTables(
197
583
  db: Kysely<any>,
198
584
  dialect: Dialect,
199
- exclude: string[] = []
585
+ exclude: string[] = [],
586
+ options?: SchemaOptions
200
587
  ): Promise<void> {
201
- await getAdapter(dialect).truncateAllTables(db, exclude)
588
+ await getAdapter(dialect).truncateAllTables(db, exclude, options)
202
589
  }
package/src/index.ts CHANGED
@@ -6,17 +6,36 @@
6
6
  *
7
7
  * @example
8
8
  * // Using the adapter interface
9
- * import { getAdapter } from '@kysera/dialects';
9
+ * import { getAdapter, createDialectAdapter } from '@kysera/dialects';
10
10
  *
11
+ * // Get default adapter (uses 'public' schema for postgres)
11
12
  * const adapter = getAdapter('postgres');
12
13
  * const exists = await adapter.tableExists(db, 'users');
13
- * const columns = await adapter.getTableColumns(db, 'users');
14
+ *
15
+ * // Create adapter with custom default schema
16
+ * const authAdapter = createDialectAdapter('postgres', { defaultSchema: 'auth' });
17
+ * const authTables = await authAdapter.getTables(db);
18
+ *
19
+ * // Override schema per-call
20
+ * const adminTables = await adapter.getTables(db, { schema: 'admin' });
21
+ *
22
+ * @example
23
+ * // PostgreSQL schema management
24
+ * import { PostgresAdapter, createPostgresAdapter } from '@kysera/dialects';
25
+ *
26
+ * const adapter = createPostgresAdapter({ defaultSchema: 'public' });
27
+ *
28
+ * // Schema operations (PostgreSQL/MSSQL only)
29
+ * await adapter.createSchema(db, 'tenant_123');
30
+ * const schemas = await adapter.getSchemas(db);
31
+ * await adapter.dropSchema(db, 'tenant_123', { cascade: true });
14
32
  *
15
33
  * @example
16
34
  * // Using helper functions (backward compatible)
17
35
  * import { tableExists, escapeIdentifier, isUniqueConstraintError } from '@kysera/dialects';
18
36
  *
19
37
  * const exists = await tableExists(db, 'users', 'postgres');
38
+ * const existsInAuth = await tableExists(db, 'users', 'postgres', { schema: 'auth' });
20
39
  * const escaped = escapeIdentifier('user-data', 'mysql');
21
40
  *
22
41
  * @example
@@ -28,14 +47,54 @@
28
47
  */
29
48
 
30
49
  // Types - Dialect is the canonical type from @kysera/core
31
- export type { Dialect, ConnectionConfig, DialectAdapter, DatabaseErrorLike } from './types.js'
50
+ export type {
51
+ Dialect,
52
+ ConnectionConfig,
53
+ DialectAdapter,
54
+ DialectAdapterOptions,
55
+ SchemaOptions,
56
+ DatabaseErrorLike
57
+ } from './types.js'
32
58
 
33
59
  // Factory and adapters
34
- export { getAdapter, createDialectAdapter, registerAdapter } from './factory.js'
35
- export { PostgresAdapter, postgresAdapter } from './adapters/postgres.js'
36
- export { MySQLAdapter, mysqlAdapter } from './adapters/mysql.js'
37
- export { SQLiteAdapter, sqliteAdapter } from './adapters/sqlite.js'
38
- export { MSSQLAdapter, mssqlAdapter } from './adapters/mssql.js'
60
+ export {
61
+ getAdapter,
62
+ createDialectAdapter,
63
+ registerAdapter,
64
+ type AdapterOptions
65
+ } from './factory.js'
66
+
67
+ // PostgreSQL adapter with schema management
68
+ export {
69
+ PostgresAdapter,
70
+ postgresAdapter,
71
+ createPostgresAdapter,
72
+ type PostgresAdapterOptions
73
+ } from './adapters/postgres.js'
74
+
75
+ // MySQL adapter
76
+ export {
77
+ MySQLAdapter,
78
+ mysqlAdapter,
79
+ createMySQLAdapter,
80
+ type MySQLAdapterOptions
81
+ } from './adapters/mysql.js'
82
+
83
+ // SQLite adapter
84
+ export {
85
+ SQLiteAdapter,
86
+ sqliteAdapter,
87
+ createSQLiteAdapter,
88
+ type SQLiteAdapterOptions
89
+ } from './adapters/sqlite.js'
90
+
91
+ // MSSQL adapter with schema management
92
+ export {
93
+ MSSQLAdapter,
94
+ mssqlAdapter,
95
+ createMSSQLAdapter,
96
+ type MSSQLAdapterOptions
97
+ } from './adapters/mssql.js'
39
98
 
40
99
  // Connection utilities
41
100
  export { parseConnectionUrl, buildConnectionUrl, getDefaultPort } from './connection.js'
@@ -54,5 +113,22 @@ export {
54
113
  isForeignKeyError,
55
114
  isNotNullError,
56
115
  getDatabaseSize,
57
- truncateAllTables
116
+ truncateAllTables,
117
+ // Schema utilities
118
+ resolveSchema,
119
+ qualifyTableName,
120
+ // Multi-tenant utilities
121
+ getTenantSchemaName,
122
+ parseTenantSchemaName,
123
+ isTenantSchema,
124
+ filterTenantSchemas,
125
+ extractTenantIds,
126
+ type TenantSchemaConfig,
127
+ type SchemaCopyOptions,
128
+ // Error detection utilities
129
+ extractErrorInfo,
130
+ createErrorMatcher,
131
+ errorMatchers,
132
+ type ExtractedErrorInfo,
133
+ type ErrorMatcherConfig
58
134
  } from './helpers.js'