@prisma-next/adapter-postgres 0.3.0-dev.33 → 0.3.0-dev.36

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.
Files changed (88) hide show
  1. package/README.md +64 -2
  2. package/dist/adapter-DB1CK2jM.mjs +265 -0
  3. package/dist/adapter-DB1CK2jM.mjs.map +1 -0
  4. package/dist/adapter.d.mts +23 -0
  5. package/dist/adapter.d.mts.map +1 -0
  6. package/dist/adapter.mjs +3 -0
  7. package/dist/codec-ids-Bsm9c7ns.mjs +29 -0
  8. package/dist/codec-ids-Bsm9c7ns.mjs.map +1 -0
  9. package/dist/codec-types.d.mts +141 -0
  10. package/dist/codec-types.d.mts.map +1 -0
  11. package/dist/codec-types.mjs +3 -0
  12. package/dist/codecs-DcC1nPzh.mjs +206 -0
  13. package/dist/codecs-DcC1nPzh.mjs.map +1 -0
  14. package/dist/column-types.d.mts +110 -0
  15. package/dist/column-types.d.mts.map +1 -0
  16. package/dist/column-types.mjs +180 -0
  17. package/dist/column-types.mjs.map +1 -0
  18. package/dist/control.d.mts +111 -0
  19. package/dist/control.d.mts.map +1 -0
  20. package/dist/control.mjs +405 -0
  21. package/dist/control.mjs.map +1 -0
  22. package/dist/descriptor-meta-D7pxo-wo.mjs +996 -0
  23. package/dist/descriptor-meta-D7pxo-wo.mjs.map +1 -0
  24. package/dist/runtime.d.mts +19 -0
  25. package/dist/runtime.d.mts.map +1 -0
  26. package/dist/runtime.mjs +85 -0
  27. package/dist/runtime.mjs.map +1 -0
  28. package/dist/types-BY395pUv.d.mts +19 -0
  29. package/dist/types-BY395pUv.d.mts.map +1 -0
  30. package/dist/types.d.mts +2 -0
  31. package/dist/types.mjs +1 -0
  32. package/package.json +32 -41
  33. package/src/core/adapter.ts +90 -17
  34. package/src/core/codec-ids.ts +28 -0
  35. package/src/core/codecs.ts +316 -19
  36. package/src/core/control-adapter.ts +341 -180
  37. package/src/core/default-normalizer.ts +77 -0
  38. package/src/core/descriptor-meta.ts +221 -9
  39. package/src/core/enum-control-hooks.ts +735 -0
  40. package/src/core/json-schema-type-expression.ts +131 -0
  41. package/src/core/json-schema-validator.ts +53 -0
  42. package/src/core/parameterized-types.ts +118 -0
  43. package/src/core/sql-utils.ts +111 -0
  44. package/src/core/standard-schema.ts +71 -0
  45. package/src/exports/codec-types.ts +73 -1
  46. package/src/exports/column-types.ts +233 -9
  47. package/src/exports/control.ts +16 -9
  48. package/src/exports/runtime.ts +61 -18
  49. package/dist/chunk-HD5YISNQ.js +0 -47
  50. package/dist/chunk-HD5YISNQ.js.map +0 -1
  51. package/dist/chunk-J3XSOAM2.js +0 -162
  52. package/dist/chunk-J3XSOAM2.js.map +0 -1
  53. package/dist/chunk-Y6L4BBLR.js +0 -309
  54. package/dist/chunk-Y6L4BBLR.js.map +0 -1
  55. package/dist/core/adapter.d.ts +0 -19
  56. package/dist/core/adapter.d.ts.map +0 -1
  57. package/dist/core/codecs.d.ts +0 -110
  58. package/dist/core/codecs.d.ts.map +0 -1
  59. package/dist/core/control-adapter.d.ts +0 -33
  60. package/dist/core/control-adapter.d.ts.map +0 -1
  61. package/dist/core/descriptor-meta.d.ts +0 -72
  62. package/dist/core/descriptor-meta.d.ts.map +0 -1
  63. package/dist/core/types.d.ts +0 -16
  64. package/dist/core/types.d.ts.map +0 -1
  65. package/dist/exports/adapter.d.ts +0 -2
  66. package/dist/exports/adapter.d.ts.map +0 -1
  67. package/dist/exports/adapter.js +0 -8
  68. package/dist/exports/adapter.js.map +0 -1
  69. package/dist/exports/codec-types.d.ts +0 -11
  70. package/dist/exports/codec-types.d.ts.map +0 -1
  71. package/dist/exports/codec-types.js +0 -7
  72. package/dist/exports/codec-types.js.map +0 -1
  73. package/dist/exports/column-types.d.ts +0 -17
  74. package/dist/exports/column-types.d.ts.map +0 -1
  75. package/dist/exports/column-types.js +0 -49
  76. package/dist/exports/column-types.js.map +0 -1
  77. package/dist/exports/control.d.ts +0 -8
  78. package/dist/exports/control.d.ts.map +0 -1
  79. package/dist/exports/control.js +0 -279
  80. package/dist/exports/control.js.map +0 -1
  81. package/dist/exports/runtime.d.ts +0 -15
  82. package/dist/exports/runtime.d.ts.map +0 -1
  83. package/dist/exports/runtime.js +0 -20
  84. package/dist/exports/runtime.js.map +0 -1
  85. package/dist/exports/types.d.ts +0 -2
  86. package/dist/exports/types.d.ts.map +0 -1
  87. package/dist/exports/types.js +0 -1
  88. package/dist/exports/types.js.map +0 -1
@@ -9,6 +9,9 @@ import type {
9
9
  SqlTableIR,
10
10
  SqlUniqueIR,
11
11
  } from '@prisma-next/sql-schema-ir/types';
12
+ import { ifDefined } from '@prisma-next/utils/defined';
13
+ import { parsePostgresDefault } from './default-normalizer';
14
+ import { pgEnumControlHooks } from './enum-control-hooks';
12
15
 
13
16
  /**
14
17
  * Postgres control plane adapter for control-plane operations like introspection.
@@ -22,6 +25,19 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
22
25
  */
23
26
  readonly target = 'postgres' as const;
24
27
 
28
+ /**
29
+ * Target-specific normalizer for raw Postgres default expressions.
30
+ * Used by schema verification to normalize raw defaults before comparison.
31
+ */
32
+ readonly normalizeDefault = parsePostgresDefault;
33
+
34
+ /**
35
+ * Target-specific normalizer for Postgres schema native type names.
36
+ * Used by schema verification to normalize introspected type names
37
+ * before comparison with contract native types.
38
+ */
39
+ readonly normalizeNativeType = normalizeSchemaNativeType;
40
+
25
41
  /**
26
42
  * Introspects a Postgres database schema and returns a raw SqlSchemaIR.
27
43
  *
@@ -29,6 +45,8 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
29
45
  * and returns the schema structure without type mapping or contract enrichment.
30
46
  * Type mapping and enrichment are handled separately by enrichment helpers.
31
47
  *
48
+ * Uses batched queries to minimize database round trips (7 queries instead of 5T+3).
49
+ *
32
50
  * @param driver - ControlDriverInstance<'sql', 'postgres'> instance for executing queries
33
51
  * @param contractIR - Optional contract IR for contract-guided introspection (filtering, optimization)
34
52
  * @param schema - Schema name to introspect (defaults to 'public')
@@ -39,25 +57,28 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
39
57
  _contractIR?: unknown,
40
58
  schema = 'public',
41
59
  ): Promise<SqlSchemaIR> {
42
- // Query tables
43
- const tablesResult = await driver.query<{
44
- table_name: string;
45
- }>(
46
- `SELECT table_name
47
- FROM information_schema.tables
48
- WHERE table_schema = $1
49
- AND table_type = 'BASE TABLE'
50
- ORDER BY table_name`,
51
- [schema],
52
- );
53
-
54
- const tables: Record<string, SqlTableIR> = {};
55
-
56
- for (const tableRow of tablesResult.rows) {
57
- const tableName = tableRow.table_name;
58
-
59
- // Query columns for this table
60
- const columnsResult = await driver.query<{
60
+ // Execute all queries in parallel for efficiency (7 queries instead of 5T+3)
61
+ const [
62
+ tablesResult,
63
+ columnsResult,
64
+ pkResult,
65
+ fkResult,
66
+ uniqueResult,
67
+ indexResult,
68
+ extensionsResult,
69
+ ] = await Promise.all([
70
+ // Query all tables
71
+ driver.query<{ table_name: string }>(
72
+ `SELECT table_name
73
+ FROM information_schema.tables
74
+ WHERE table_schema = $1
75
+ AND table_type = 'BASE TABLE'
76
+ ORDER BY table_name`,
77
+ [schema],
78
+ ),
79
+ // Query all columns for all tables in schema
80
+ driver.query<{
81
+ table_name: string;
61
82
  column_name: string;
62
83
  data_type: string;
63
84
  udt_name: string;
@@ -65,58 +86,44 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
65
86
  character_maximum_length: number | null;
66
87
  numeric_precision: number | null;
67
88
  numeric_scale: number | null;
89
+ column_default: string | null;
90
+ formatted_type: string | null;
68
91
  }>(
69
92
  `SELECT
93
+ c.table_name,
70
94
  column_name,
71
95
  data_type,
72
96
  udt_name,
73
97
  is_nullable,
74
98
  character_maximum_length,
75
99
  numeric_precision,
76
- numeric_scale
77
- FROM information_schema.columns
78
- WHERE table_schema = $1
79
- AND table_name = $2
80
- ORDER BY ordinal_position`,
81
- [schema, tableName],
82
- );
83
-
84
- const columns: Record<string, SqlColumnIR> = {};
85
- for (const colRow of columnsResult.rows) {
86
- // Build native type string from catalog data
87
- let nativeType = colRow.udt_name;
88
- if (colRow.data_type === 'character varying' || colRow.data_type === 'character') {
89
- if (colRow.character_maximum_length) {
90
- nativeType = `${colRow.data_type}(${colRow.character_maximum_length})`;
91
- } else {
92
- nativeType = colRow.data_type;
93
- }
94
- } else if (colRow.data_type === 'numeric' || colRow.data_type === 'decimal') {
95
- if (colRow.numeric_precision && colRow.numeric_scale !== null) {
96
- nativeType = `${colRow.data_type}(${colRow.numeric_precision},${colRow.numeric_scale})`;
97
- } else if (colRow.numeric_precision) {
98
- nativeType = `${colRow.data_type}(${colRow.numeric_precision})`;
99
- } else {
100
- nativeType = colRow.data_type;
101
- }
102
- } else {
103
- nativeType = colRow.udt_name || colRow.data_type;
104
- }
105
-
106
- columns[colRow.column_name] = {
107
- name: colRow.column_name,
108
- nativeType,
109
- nullable: colRow.is_nullable === 'YES',
110
- };
111
- }
112
-
113
- // Query primary key
114
- const pkResult = await driver.query<{
100
+ numeric_scale,
101
+ column_default,
102
+ format_type(a.atttypid, a.atttypmod) AS formatted_type
103
+ FROM information_schema.columns c
104
+ JOIN pg_catalog.pg_class cl
105
+ ON cl.relname = c.table_name
106
+ JOIN pg_catalog.pg_namespace ns
107
+ ON ns.nspname = c.table_schema
108
+ AND ns.oid = cl.relnamespace
109
+ JOIN pg_catalog.pg_attribute a
110
+ ON a.attrelid = cl.oid
111
+ AND a.attname = c.column_name
112
+ AND a.attnum > 0
113
+ AND NOT a.attisdropped
114
+ WHERE c.table_schema = $1
115
+ ORDER BY c.table_name, c.ordinal_position`,
116
+ [schema],
117
+ ),
118
+ // Query all primary keys for all tables in schema
119
+ driver.query<{
120
+ table_name: string;
115
121
  constraint_name: string;
116
122
  column_name: string;
117
123
  ordinal_position: number;
118
124
  }>(
119
125
  `SELECT
126
+ tc.table_name,
120
127
  tc.constraint_name,
121
128
  kcu.column_name,
122
129
  kcu.ordinal_position
@@ -126,27 +133,13 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
126
133
  AND tc.table_schema = kcu.table_schema
127
134
  AND tc.table_name = kcu.table_name
128
135
  WHERE tc.table_schema = $1
129
- AND tc.table_name = $2
130
136
  AND tc.constraint_type = 'PRIMARY KEY'
131
- ORDER BY kcu.ordinal_position`,
132
- [schema, tableName],
133
- );
134
-
135
- const primaryKeyColumns = pkResult.rows
136
- .sort((a, b) => a.ordinal_position - b.ordinal_position)
137
- .map((row) => row.column_name);
138
- const primaryKey: PrimaryKey | undefined =
139
- primaryKeyColumns.length > 0
140
- ? {
141
- columns: primaryKeyColumns,
142
- ...(pkResult.rows[0]?.constraint_name
143
- ? { name: pkResult.rows[0].constraint_name }
144
- : {}),
145
- }
146
- : undefined;
147
-
148
- // Query foreign keys
149
- const fkResult = await driver.query<{
137
+ ORDER BY tc.table_name, kcu.ordinal_position`,
138
+ [schema],
139
+ ),
140
+ // Query all foreign keys for all tables in schema
141
+ driver.query<{
142
+ table_name: string;
150
143
  constraint_name: string;
151
144
  column_name: string;
152
145
  ordinal_position: number;
@@ -155,6 +148,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
155
148
  referenced_column_name: string;
156
149
  }>(
157
150
  `SELECT
151
+ tc.table_name,
158
152
  tc.constraint_name,
159
153
  kcu.column_name,
160
154
  kcu.ordinal_position,
@@ -170,52 +164,19 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
170
164
  ON ccu.constraint_name = tc.constraint_name
171
165
  AND ccu.table_schema = tc.table_schema
172
166
  WHERE tc.table_schema = $1
173
- AND tc.table_name = $2
174
167
  AND tc.constraint_type = 'FOREIGN KEY'
175
- ORDER BY tc.constraint_name, kcu.ordinal_position`,
176
- [schema, tableName],
177
- );
178
-
179
- const foreignKeysMap = new Map<
180
- string,
181
- {
182
- columns: string[];
183
- referencedTable: string;
184
- referencedColumns: string[];
185
- name: string;
186
- }
187
- >();
188
- for (const fkRow of fkResult.rows) {
189
- const existing = foreignKeysMap.get(fkRow.constraint_name);
190
- if (existing) {
191
- // Multi-column FK - add column
192
- existing.columns.push(fkRow.column_name);
193
- existing.referencedColumns.push(fkRow.referenced_column_name);
194
- } else {
195
- foreignKeysMap.set(fkRow.constraint_name, {
196
- columns: [fkRow.column_name],
197
- referencedTable: fkRow.referenced_table_name,
198
- referencedColumns: [fkRow.referenced_column_name],
199
- name: fkRow.constraint_name,
200
- });
201
- }
202
- }
203
- const foreignKeys: readonly SqlForeignKeyIR[] = Array.from(foreignKeysMap.values()).map(
204
- (fk) => ({
205
- columns: Object.freeze([...fk.columns]) as readonly string[],
206
- referencedTable: fk.referencedTable,
207
- referencedColumns: Object.freeze([...fk.referencedColumns]) as readonly string[],
208
- name: fk.name,
209
- }),
210
- );
211
-
212
- // Query unique constraints (excluding PK)
213
- const uniqueResult = await driver.query<{
168
+ ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position`,
169
+ [schema],
170
+ ),
171
+ // Query all unique constraints for all tables in schema (excluding PKs)
172
+ driver.query<{
173
+ table_name: string;
214
174
  constraint_name: string;
215
175
  column_name: string;
216
176
  ordinal_position: number;
217
177
  }>(
218
178
  `SELECT
179
+ tc.table_name,
219
180
  tc.constraint_name,
220
181
  kcu.column_name,
221
182
  kcu.ordinal_position
@@ -225,50 +186,20 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
225
186
  AND tc.table_schema = kcu.table_schema
226
187
  AND tc.table_name = kcu.table_name
227
188
  WHERE tc.table_schema = $1
228
- AND tc.table_name = $2
229
189
  AND tc.constraint_type = 'UNIQUE'
230
- AND tc.constraint_name NOT IN (
231
- SELECT constraint_name
232
- FROM information_schema.table_constraints
233
- WHERE table_schema = $1
234
- AND table_name = $2
235
- AND constraint_type = 'PRIMARY KEY'
236
- )
237
- ORDER BY tc.constraint_name, kcu.ordinal_position`,
238
- [schema, tableName],
239
- );
240
-
241
- const uniquesMap = new Map<
242
- string,
243
- {
244
- columns: string[];
245
- name: string;
246
- }
247
- >();
248
- for (const uniqueRow of uniqueResult.rows) {
249
- const existing = uniquesMap.get(uniqueRow.constraint_name);
250
- if (existing) {
251
- existing.columns.push(uniqueRow.column_name);
252
- } else {
253
- uniquesMap.set(uniqueRow.constraint_name, {
254
- columns: [uniqueRow.column_name],
255
- name: uniqueRow.constraint_name,
256
- });
257
- }
258
- }
259
- const uniques: readonly SqlUniqueIR[] = Array.from(uniquesMap.values()).map((uq) => ({
260
- columns: Object.freeze([...uq.columns]) as readonly string[],
261
- name: uq.name,
262
- }));
263
-
264
- // Query indexes (excluding PK and unique constraints)
265
- const indexResult = await driver.query<{
190
+ ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position`,
191
+ [schema],
192
+ ),
193
+ // Query all indexes for all tables in schema (excluding constraints)
194
+ driver.query<{
195
+ tablename: string;
266
196
  indexname: string;
267
197
  indisunique: boolean;
268
198
  attname: string;
269
199
  attnum: number;
270
200
  }>(
271
201
  `SELECT
202
+ i.tablename,
272
203
  i.indexname,
273
204
  ix.indisunique,
274
205
  a.attname,
@@ -281,28 +212,150 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
281
212
  JOIN pg_namespace tn ON tn.oid = t.relnamespace AND tn.nspname = $1
282
213
  LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) AND a.attnum > 0
283
214
  WHERE i.schemaname = $1
284
- AND i.tablename = $2
285
215
  AND NOT EXISTS (
286
216
  SELECT 1
287
217
  FROM information_schema.table_constraints tc
288
218
  WHERE tc.table_schema = $1
289
- AND tc.table_name = $2
219
+ AND tc.table_name = i.tablename
290
220
  AND tc.constraint_name = i.indexname
291
221
  )
292
- ORDER BY i.indexname, a.attnum`,
293
- [schema, tableName],
294
- );
222
+ ORDER BY i.tablename, i.indexname, a.attnum`,
223
+ [schema],
224
+ ),
225
+ // Query extensions
226
+ driver.query<{ extname: string }>(
227
+ `SELECT extname
228
+ FROM pg_extension
229
+ ORDER BY extname`,
230
+ [],
231
+ ),
232
+ ]);
295
233
 
296
- const indexesMap = new Map<
297
- string,
298
- {
299
- columns: string[];
300
- name: string;
301
- unique: boolean;
234
+ // Group results by table name for efficient lookup
235
+ const columnsByTable = groupBy(columnsResult.rows, 'table_name');
236
+ const pksByTable = groupBy(pkResult.rows, 'table_name');
237
+ const fksByTable = groupBy(fkResult.rows, 'table_name');
238
+ const uniquesByTable = groupBy(uniqueResult.rows, 'table_name');
239
+ const indexesByTable = groupBy(indexResult.rows, 'tablename');
240
+
241
+ // Get set of PK constraint names per table (to exclude from uniques)
242
+ const pkConstraintsByTable = new Map<string, Set<string>>();
243
+ for (const row of pkResult.rows) {
244
+ let constraints = pkConstraintsByTable.get(row.table_name);
245
+ if (!constraints) {
246
+ constraints = new Set();
247
+ pkConstraintsByTable.set(row.table_name, constraints);
248
+ }
249
+ constraints.add(row.constraint_name);
250
+ }
251
+
252
+ const tables: Record<string, SqlTableIR> = {};
253
+
254
+ for (const tableRow of tablesResult.rows) {
255
+ const tableName = tableRow.table_name;
256
+
257
+ // Process columns for this table
258
+ const columns: Record<string, SqlColumnIR> = {};
259
+ for (const colRow of columnsByTable.get(tableName) ?? []) {
260
+ let nativeType = colRow.udt_name;
261
+ const formattedType = colRow.formatted_type
262
+ ? normalizeFormattedType(colRow.formatted_type, colRow.data_type, colRow.udt_name)
263
+ : null;
264
+ if (formattedType) {
265
+ nativeType = formattedType;
266
+ } else if (colRow.data_type === 'character varying' || colRow.data_type === 'character') {
267
+ if (colRow.character_maximum_length) {
268
+ nativeType = `${colRow.data_type}(${colRow.character_maximum_length})`;
269
+ } else {
270
+ nativeType = colRow.data_type;
271
+ }
272
+ } else if (colRow.data_type === 'numeric' || colRow.data_type === 'decimal') {
273
+ if (colRow.numeric_precision && colRow.numeric_scale !== null) {
274
+ nativeType = `${colRow.data_type}(${colRow.numeric_precision},${colRow.numeric_scale})`;
275
+ } else if (colRow.numeric_precision) {
276
+ nativeType = `${colRow.data_type}(${colRow.numeric_precision})`;
277
+ } else {
278
+ nativeType = colRow.data_type;
279
+ }
280
+ } else {
281
+ nativeType = colRow.udt_name || colRow.data_type;
302
282
  }
283
+
284
+ columns[colRow.column_name] = {
285
+ name: colRow.column_name,
286
+ nativeType,
287
+ nullable: colRow.is_nullable === 'YES',
288
+ ...ifDefined('default', colRow.column_default ?? undefined),
289
+ };
290
+ }
291
+
292
+ // Process primary key
293
+ const pkRows = [...(pksByTable.get(tableName) ?? [])];
294
+ const primaryKeyColumns = pkRows
295
+ .sort((a, b) => a.ordinal_position - b.ordinal_position)
296
+ .map((row) => row.column_name);
297
+ const primaryKey: PrimaryKey | undefined =
298
+ primaryKeyColumns.length > 0
299
+ ? {
300
+ columns: primaryKeyColumns,
301
+ ...(pkRows[0]?.constraint_name ? { name: pkRows[0].constraint_name } : {}),
302
+ }
303
+ : undefined;
304
+
305
+ // Process foreign keys
306
+ const foreignKeysMap = new Map<
307
+ string,
308
+ { columns: string[]; referencedTable: string; referencedColumns: string[]; name: string }
303
309
  >();
304
- for (const idxRow of indexResult.rows) {
305
- // Skip rows where attname is null (system columns or invalid attnum)
310
+ for (const fkRow of fksByTable.get(tableName) ?? []) {
311
+ const existing = foreignKeysMap.get(fkRow.constraint_name);
312
+ if (existing) {
313
+ existing.columns.push(fkRow.column_name);
314
+ existing.referencedColumns.push(fkRow.referenced_column_name);
315
+ } else {
316
+ foreignKeysMap.set(fkRow.constraint_name, {
317
+ columns: [fkRow.column_name],
318
+ referencedTable: fkRow.referenced_table_name,
319
+ referencedColumns: [fkRow.referenced_column_name],
320
+ name: fkRow.constraint_name,
321
+ });
322
+ }
323
+ }
324
+ const foreignKeys: readonly SqlForeignKeyIR[] = Array.from(foreignKeysMap.values()).map(
325
+ (fk) => ({
326
+ columns: Object.freeze([...fk.columns]) as readonly string[],
327
+ referencedTable: fk.referencedTable,
328
+ referencedColumns: Object.freeze([...fk.referencedColumns]) as readonly string[],
329
+ name: fk.name,
330
+ }),
331
+ );
332
+
333
+ // Process unique constraints (excluding those that are also PKs)
334
+ const pkConstraints = pkConstraintsByTable.get(tableName) ?? new Set();
335
+ const uniquesMap = new Map<string, { columns: string[]; name: string }>();
336
+ for (const uniqueRow of uniquesByTable.get(tableName) ?? []) {
337
+ // Skip if this constraint is also a primary key
338
+ if (pkConstraints.has(uniqueRow.constraint_name)) {
339
+ continue;
340
+ }
341
+ const existing = uniquesMap.get(uniqueRow.constraint_name);
342
+ if (existing) {
343
+ existing.columns.push(uniqueRow.column_name);
344
+ } else {
345
+ uniquesMap.set(uniqueRow.constraint_name, {
346
+ columns: [uniqueRow.column_name],
347
+ name: uniqueRow.constraint_name,
348
+ });
349
+ }
350
+ }
351
+ const uniques: readonly SqlUniqueIR[] = Array.from(uniquesMap.values()).map((uq) => ({
352
+ columns: Object.freeze([...uq.columns]) as readonly string[],
353
+ name: uq.name,
354
+ }));
355
+
356
+ // Process indexes
357
+ const indexesMap = new Map<string, { columns: string[]; name: string; unique: boolean }>();
358
+ for (const idxRow of indexesByTable.get(tableName) ?? []) {
306
359
  if (!idxRow.attname) {
307
360
  continue;
308
361
  }
@@ -326,30 +379,26 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
326
379
  tables[tableName] = {
327
380
  name: tableName,
328
381
  columns,
329
- ...(primaryKey ? { primaryKey } : {}),
382
+ ...ifDefined('primaryKey', primaryKey),
330
383
  foreignKeys,
331
384
  uniques,
332
385
  indexes,
333
386
  };
334
387
  }
335
388
 
336
- // Query extensions
337
- const extensionsResult = await driver.query<{
338
- extname: string;
339
- }>(
340
- `SELECT extname
341
- FROM pg_extension
342
- ORDER BY extname`,
343
- [],
344
- );
345
-
346
389
  const extensions = extensionsResult.rows.map((row) => row.extname);
347
390
 
348
- // Build annotations with Postgres-specific metadata
391
+ const storageTypes =
392
+ (await pgEnumControlHooks.introspectTypes?.({ driver, schemaName: schema })) ?? {};
393
+
349
394
  const annotations = {
350
395
  pg: {
351
396
  schema,
352
397
  version: await this.getPostgresVersion(driver),
398
+ ...ifDefined(
399
+ 'storageTypes',
400
+ Object.keys(storageTypes).length > 0 ? storageTypes : undefined,
401
+ ),
353
402
  },
354
403
  };
355
404
 
@@ -373,3 +422,115 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
373
422
  return match?.[1] ?? 'unknown';
374
423
  }
375
424
  }
425
+
426
+ /**
427
+ * Pre-computed lookup map for simple prefix-based type normalization.
428
+ * Maps short Postgres type names to their canonical SQL names.
429
+ * Using a Map for O(1) lookup instead of multiple startsWith checks.
430
+ */
431
+ const TYPE_PREFIX_MAP: ReadonlyMap<string, string> = new Map([
432
+ ['varchar', 'character varying'],
433
+ ['bpchar', 'character'],
434
+ ['varbit', 'bit varying'],
435
+ ]);
436
+
437
+ /**
438
+ * Normalizes a Postgres schema native type to its canonical form for comparison.
439
+ *
440
+ * Uses a pre-computed lookup map for simple prefix replacements (O(1))
441
+ * and handles complex temporal type normalization separately.
442
+ */
443
+ export function normalizeSchemaNativeType(nativeType: string): string {
444
+ const trimmed = nativeType.trim();
445
+
446
+ // Fast path: check simple prefix replacements using the lookup map
447
+ for (const [prefix, replacement] of TYPE_PREFIX_MAP) {
448
+ if (trimmed.startsWith(prefix)) {
449
+ return replacement + trimmed.slice(prefix.length);
450
+ }
451
+ }
452
+
453
+ // Temporal types with time zone handling
454
+ // Check for 'with time zone' suffix first (more specific)
455
+ if (trimmed.includes(' with time zone')) {
456
+ if (trimmed.startsWith('timestamp')) {
457
+ return `timestamptz${trimmed.slice(9).replace(' with time zone', '')}`;
458
+ }
459
+ if (trimmed.startsWith('time')) {
460
+ return `timetz${trimmed.slice(4).replace(' with time zone', '')}`;
461
+ }
462
+ }
463
+
464
+ // Handle 'without time zone' suffix - just strip it
465
+ if (trimmed.includes(' without time zone')) {
466
+ return trimmed.replace(' without time zone', '');
467
+ }
468
+
469
+ return trimmed;
470
+ }
471
+
472
+ function normalizeFormattedType(formattedType: string, dataType: string, udtName: string): string {
473
+ if (formattedType === 'integer') {
474
+ return 'int4';
475
+ }
476
+ if (formattedType === 'smallint') {
477
+ return 'int2';
478
+ }
479
+ if (formattedType === 'bigint') {
480
+ return 'int8';
481
+ }
482
+ if (formattedType === 'real') {
483
+ return 'float4';
484
+ }
485
+ if (formattedType === 'double precision') {
486
+ return 'float8';
487
+ }
488
+ if (formattedType === 'boolean') {
489
+ return 'bool';
490
+ }
491
+ if (formattedType.startsWith('varchar')) {
492
+ return formattedType.replace('varchar', 'character varying');
493
+ }
494
+ if (formattedType.startsWith('bpchar')) {
495
+ return formattedType.replace('bpchar', 'character');
496
+ }
497
+ if (formattedType.startsWith('varbit')) {
498
+ return formattedType.replace('varbit', 'bit varying');
499
+ }
500
+ if (dataType === 'timestamp with time zone' || udtName === 'timestamptz') {
501
+ return formattedType.replace('timestamp', 'timestamptz').replace(' with time zone', '').trim();
502
+ }
503
+ if (dataType === 'timestamp without time zone' || udtName === 'timestamp') {
504
+ return formattedType.replace(' without time zone', '').trim();
505
+ }
506
+ if (dataType === 'time with time zone' || udtName === 'timetz') {
507
+ return formattedType.replace('time', 'timetz').replace(' with time zone', '').trim();
508
+ }
509
+ if (dataType === 'time without time zone' || udtName === 'time') {
510
+ return formattedType.replace(' without time zone', '').trim();
511
+ }
512
+ // Only dataType === 'USER-DEFINED' should ever be quoted, but this should be safe without
513
+ // checking that explicitly either way
514
+ if (formattedType.startsWith('"') && formattedType.endsWith('"')) {
515
+ return formattedType.slice(1, -1);
516
+ }
517
+ return formattedType;
518
+ }
519
+
520
+ /**
521
+ * Groups an array of objects by a specified key.
522
+ * Returns a Map for O(1) lookup by group key.
523
+ */
524
+ function groupBy<T, K extends keyof T>(items: readonly T[], key: K): Map<T[K], T[]> {
525
+ const map = new Map<T[K], T[]>();
526
+ for (const item of items) {
527
+ const groupKey = item[key];
528
+ let group = map.get(groupKey);
529
+ if (!group) {
530
+ group = [];
531
+ map.set(groupKey, group);
532
+ }
533
+ group.push(item);
534
+ }
535
+ return map;
536
+ }