meadow-connection-postgresql 1.0.0 → 1.0.2

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.
@@ -0,0 +1,1048 @@
1
+ /**
2
+ * Meadow PostgreSQL Schema Provider
3
+ *
4
+ * Handles table creation, dropping, and DDL generation for PostgreSQL.
5
+ * Separated from the connection provider to allow independent extension
6
+ * for indexing, foreign keys, and other schema operations.
7
+ *
8
+ * @author Steven Velozo <steven@velozo.com>
9
+ */
10
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
11
+
12
+ class MeadowSchemaPostgreSQL extends libFableServiceProviderBase
13
+ {
14
+ constructor(pFable, pOptions, pServiceHash)
15
+ {
16
+ super(pFable, pOptions, pServiceHash);
17
+
18
+ this.serviceType = 'MeadowSchemaPostgreSQL';
19
+
20
+ // Reference to the connection pool, set by the connection provider
21
+ this._ConnectionPool = false;
22
+ }
23
+
24
+ /**
25
+ * Set the connection pool reference for executing DDL statements.
26
+ * @param {object} pConnectionPool - PostgreSQL connection pool
27
+ * @returns {MeadowSchemaPostgreSQL} this (for chaining)
28
+ */
29
+ setConnectionPool(pConnectionPool)
30
+ {
31
+ this._ConnectionPool = pConnectionPool;
32
+ return this;
33
+ }
34
+
35
+ generateDropTableStatement(pTableName)
36
+ {
37
+ return `DROP TABLE IF EXISTS "${pTableName}";`;
38
+ }
39
+
40
+ generateCreateTableStatement(pMeadowTableSchema)
41
+ {
42
+ this.log.info(`--> Building the table create string for ${pMeadowTableSchema} ...`);
43
+
44
+ let tmpPrimaryKey = false;
45
+ let tmpCreateTableStatement = `-- [ ${pMeadowTableSchema.TableName} ]`;
46
+
47
+ tmpCreateTableStatement += `\nCREATE TABLE IF NOT EXISTS\n "${pMeadowTableSchema.TableName}"\n (`;
48
+ for (let j = 0; j < pMeadowTableSchema.Columns.length; j++)
49
+ {
50
+ let tmpColumn = pMeadowTableSchema.Columns[j];
51
+
52
+ // If we aren't the first column, append a comma.
53
+ if (j > 0)
54
+ {
55
+ tmpCreateTableStatement += `,`;
56
+ }
57
+
58
+ tmpCreateTableStatement += `\n`;
59
+ // Dump out each column......
60
+ switch (tmpColumn.DataType)
61
+ {
62
+ case 'ID':
63
+ tmpCreateTableStatement += ` "${tmpColumn.Column}" SERIAL PRIMARY KEY`;
64
+ tmpPrimaryKey = tmpColumn.Column;
65
+ break;
66
+ case 'GUID':
67
+ let tmpSize = tmpColumn.hasOwnProperty('Size') ? tmpColumn.Size : 36;
68
+ if (isNaN(tmpSize))
69
+ {
70
+ // Use the old default if Size is improper
71
+ tmpSize = 36;
72
+ }
73
+ tmpCreateTableStatement += ` "${tmpColumn.Column}" VARCHAR(${tmpSize}) DEFAULT '0xDe'`;
74
+ break;
75
+ case 'ForeignKey':
76
+ tmpCreateTableStatement += ` "${tmpColumn.Column}" INTEGER NOT NULL DEFAULT 0`;
77
+ break;
78
+ case 'Numeric':
79
+ tmpCreateTableStatement += ` "${tmpColumn.Column}" INTEGER NOT NULL DEFAULT 0`;
80
+ break;
81
+ case 'Decimal':
82
+ tmpCreateTableStatement += ` "${tmpColumn.Column}" DECIMAL(${tmpColumn.Size})`;
83
+ break;
84
+ case 'String':
85
+ tmpCreateTableStatement += ` "${tmpColumn.Column}" VARCHAR(${tmpColumn.Size}) NOT NULL DEFAULT ''`;
86
+ break;
87
+ case 'Text':
88
+ tmpCreateTableStatement += ` "${tmpColumn.Column}" TEXT`;
89
+ break;
90
+ case 'DateTime':
91
+ tmpCreateTableStatement += ` "${tmpColumn.Column}" TIMESTAMP`;
92
+ break;
93
+ case 'Boolean':
94
+ tmpCreateTableStatement += ` "${tmpColumn.Column}" BOOLEAN NOT NULL DEFAULT false`;
95
+ break;
96
+ case 'JSON':
97
+ tmpCreateTableStatement += ` "${tmpColumn.Column}" TEXT`;
98
+ break;
99
+ case 'JSONProxy':
100
+ tmpCreateTableStatement += ` "${tmpColumn.StorageColumn}" TEXT`;
101
+ break;
102
+ default:
103
+ break;
104
+ }
105
+ }
106
+ // PostgreSQL SERIAL PRIMARY KEY is declared inline; no separate PRIMARY KEY clause needed
107
+ tmpCreateTableStatement += `\n );`;
108
+
109
+ return tmpCreateTableStatement;
110
+ }
111
+
112
+ createTables(pMeadowSchema, fCallback)
113
+ {
114
+ this.fable.Utility.eachLimit(pMeadowSchema.Tables, 1,
115
+ (pTable, fCreateComplete) =>
116
+ {
117
+ return this.createTable(pTable, fCreateComplete);
118
+ },
119
+ (pCreateError) =>
120
+ {
121
+ if (pCreateError)
122
+ {
123
+ this.log.error(`Meadow-PostgreSQL Error creating tables from Schema: ${pCreateError}`, pCreateError);
124
+ }
125
+ this.log.info('Done creating tables!');
126
+ return fCallback(pCreateError);
127
+ });
128
+ }
129
+
130
+ createTable(pMeadowTableSchema, fCallback)
131
+ {
132
+ let tmpCreateTableStatement = this.generateCreateTableStatement(pMeadowTableSchema);
133
+ this._ConnectionPool.query(tmpCreateTableStatement,
134
+ (pError, pResult) =>
135
+ {
136
+ if (pError)
137
+ {
138
+ // Check for "already exists" type errors
139
+ if (pError.code === '42P07')
140
+ {
141
+ // 42P07 = duplicate_table
142
+ this.log.warn(`Meadow-PostgreSQL CREATE TABLE ${pMeadowTableSchema.TableName} executed but table already existed.`);
143
+ return fCallback();
144
+ }
145
+ else
146
+ {
147
+ this.log.error(`Meadow-PostgreSQL CREATE TABLE ${pMeadowTableSchema.TableName} failed!`, pError);
148
+ return fCallback(pError);
149
+ }
150
+ }
151
+ else
152
+ {
153
+ this.log.info(`Meadow-PostgreSQL CREATE TABLE ${pMeadowTableSchema.TableName} executed successfully.`);
154
+ return fCallback();
155
+ }
156
+ });
157
+ }
158
+
159
+ // ========================================================================
160
+ // Index Generation
161
+ // ========================================================================
162
+
163
+ /**
164
+ * Derive index definitions from a Meadow table schema.
165
+ *
166
+ * Automatically generates indices for:
167
+ * - GUID columns -> unique index AK_M_{Column}
168
+ * - ForeignKey columns -> regular index IX_M_{Column}
169
+ *
170
+ * Column-level Indexed property:
171
+ * - Indexed: true -> regular index IX_M_T_{Table}_C_{Column}
172
+ * - Indexed: 'unique' -> unique index AK_M_T_{Table}_C_{Column}
173
+ * - IndexName overrides the auto-generated name (for round-trip fidelity)
174
+ *
175
+ * Also includes any explicit entries from pMeadowTableSchema.Indices[]
176
+ * (for multi-column composite indices).
177
+ *
178
+ * Each index definition is:
179
+ * { Name, TableName, Columns[], Unique, Strategy }
180
+ *
181
+ * @param {object} pMeadowTableSchema - Meadow table schema object
182
+ * @returns {Array} Array of index definition objects
183
+ */
184
+ getIndexDefinitionsFromSchema(pMeadowTableSchema)
185
+ {
186
+ let tmpIndices = [];
187
+ let tmpTableName = pMeadowTableSchema.TableName;
188
+
189
+ // Auto-detect from column types
190
+ for (let j = 0; j < pMeadowTableSchema.Columns.length; j++)
191
+ {
192
+ let tmpColumn = pMeadowTableSchema.Columns[j];
193
+
194
+ switch (tmpColumn.DataType)
195
+ {
196
+ case 'GUID':
197
+ tmpIndices.push(
198
+ {
199
+ Name: `AK_M_${tmpColumn.Column}`,
200
+ TableName: tmpTableName,
201
+ Columns: [tmpColumn.Column],
202
+ Unique: true,
203
+ Strategy: ''
204
+ });
205
+ break;
206
+ case 'ForeignKey':
207
+ tmpIndices.push(
208
+ {
209
+ Name: `IX_M_${tmpColumn.Column}`,
210
+ TableName: tmpTableName,
211
+ Columns: [tmpColumn.Column],
212
+ Unique: false,
213
+ Strategy: ''
214
+ });
215
+ break;
216
+ default:
217
+ // Column-level Indexed property: generates a single-column index
218
+ // with a consistent naming convention.
219
+ // Indexed: true -> IX_M_T_{Table}_C_{Column} (regular)
220
+ // Indexed: 'unique' -> AK_M_T_{Table}_C_{Column} (unique)
221
+ // Optional IndexName property overrides the auto-generated name.
222
+ if (tmpColumn.Indexed)
223
+ {
224
+ let tmpIsUnique = (tmpColumn.Indexed === 'unique');
225
+ let tmpPrefix = tmpIsUnique ? 'AK_M_T' : 'IX_M_T';
226
+ let tmpAutoName = `${tmpPrefix}_${tmpTableName}_C_${tmpColumn.Column}`;
227
+ tmpIndices.push(
228
+ {
229
+ Name: tmpColumn.IndexName || tmpAutoName,
230
+ TableName: tmpTableName,
231
+ Columns: [tmpColumn.Column],
232
+ Unique: tmpIsUnique,
233
+ Strategy: ''
234
+ });
235
+ }
236
+ break;
237
+ }
238
+ }
239
+
240
+ // Include any explicitly defined indices on the schema
241
+ if (Array.isArray(pMeadowTableSchema.Indices))
242
+ {
243
+ for (let k = 0; k < pMeadowTableSchema.Indices.length; k++)
244
+ {
245
+ let tmpExplicitIndex = pMeadowTableSchema.Indices[k];
246
+ tmpIndices.push(
247
+ {
248
+ Name: tmpExplicitIndex.Name || `IX_${tmpTableName}_${k}`,
249
+ TableName: tmpTableName,
250
+ Columns: Array.isArray(tmpExplicitIndex.Columns) ? tmpExplicitIndex.Columns : [tmpExplicitIndex.Columns],
251
+ Unique: tmpExplicitIndex.Unique || false,
252
+ Strategy: tmpExplicitIndex.Strategy || ''
253
+ });
254
+ }
255
+ }
256
+
257
+ return tmpIndices;
258
+ }
259
+
260
+ /**
261
+ * Build the column list for an index, double-quoted and comma-separated.
262
+ * @param {Array} pColumns - Array of column name strings
263
+ * @returns {string}
264
+ */
265
+ _buildColumnList(pColumns)
266
+ {
267
+ return pColumns.map((pCol) => { return '"' + pCol + '"'; }).join(', ');
268
+ }
269
+
270
+ /**
271
+ * Generate a full idempotent SQL script for creating all indices on a table.
272
+ *
273
+ * PostgreSQL supports CREATE INDEX IF NOT EXISTS natively, so the
274
+ * idempotent script is straightforward.
275
+ *
276
+ * @param {object} pMeadowTableSchema - Meadow table schema object
277
+ * @returns {string} Complete SQL script
278
+ */
279
+ generateCreateIndexScript(pMeadowTableSchema)
280
+ {
281
+ let tmpIndices = this.getIndexDefinitionsFromSchema(pMeadowTableSchema);
282
+ let tmpTableName = pMeadowTableSchema.TableName;
283
+
284
+ if (tmpIndices.length === 0)
285
+ {
286
+ return `-- No indices to create for ${tmpTableName}\n`;
287
+ }
288
+
289
+ let tmpScript = `-- Index Definitions for ${tmpTableName} -- Generated ${new Date().toJSON()}\n\n`;
290
+
291
+ for (let i = 0; i < tmpIndices.length; i++)
292
+ {
293
+ let tmpIndex = tmpIndices[i];
294
+ let tmpColumnList = this._buildColumnList(tmpIndex.Columns);
295
+ let tmpCreateKeyword = tmpIndex.Unique ? 'CREATE UNIQUE INDEX' : 'CREATE INDEX';
296
+ let tmpStrategyClause = tmpIndex.Strategy ? ` USING ${tmpIndex.Strategy}` : '';
297
+
298
+ tmpScript += `-- Index: ${tmpIndex.Name}\n`;
299
+ tmpScript += `${tmpCreateKeyword} IF NOT EXISTS "${tmpIndex.Name}" ON "${tmpIndex.TableName}" (${tmpColumnList})${tmpStrategyClause};\n\n`;
300
+ }
301
+
302
+ return tmpScript;
303
+ }
304
+
305
+ /**
306
+ * Generate an array of individual CREATE INDEX SQL statements for a table.
307
+ *
308
+ * Each entry is an object with:
309
+ * { Name, Statement, CheckStatement }
310
+ *
311
+ * - Statement: the raw CREATE [UNIQUE] INDEX ... SQL
312
+ * - CheckStatement: a SELECT against pg_indexes that returns the count
313
+ * of matching indices (0 = does not exist)
314
+ *
315
+ * @param {object} pMeadowTableSchema - Meadow table schema object
316
+ * @returns {Array} Array of { Name, Statement, CheckStatement } objects
317
+ */
318
+ generateCreateIndexStatements(pMeadowTableSchema)
319
+ {
320
+ let tmpIndices = this.getIndexDefinitionsFromSchema(pMeadowTableSchema);
321
+ let tmpStatements = [];
322
+
323
+ for (let i = 0; i < tmpIndices.length; i++)
324
+ {
325
+ let tmpIndex = tmpIndices[i];
326
+ let tmpColumnList = this._buildColumnList(tmpIndex.Columns);
327
+ let tmpCreateKeyword = tmpIndex.Unique ? 'CREATE UNIQUE INDEX' : 'CREATE INDEX';
328
+ let tmpStrategyClause = tmpIndex.Strategy ? ` USING ${tmpIndex.Strategy}` : '';
329
+
330
+ tmpStatements.push(
331
+ {
332
+ Name: tmpIndex.Name,
333
+ Statement: `${tmpCreateKeyword} "${tmpIndex.Name}" ON "${tmpIndex.TableName}" (${tmpColumnList})${tmpStrategyClause}`,
334
+ CheckStatement: `SELECT COUNT(*) AS "IndexExists" FROM pg_indexes WHERE tablename = '${tmpIndex.TableName}' AND indexname = '${tmpIndex.Name}'`
335
+ });
336
+ }
337
+
338
+ return tmpStatements;
339
+ }
340
+
341
+ /**
342
+ * Programmatically create a single index on the database.
343
+ *
344
+ * Checks pg_indexes first; only runs CREATE INDEX if the index
345
+ * does not yet exist.
346
+ *
347
+ * @param {object} pIndexStatement - Object from generateCreateIndexStatements()
348
+ * @param {Function} fCallback - callback(pError)
349
+ */
350
+ createIndex(pIndexStatement, fCallback)
351
+ {
352
+ if (!this._ConnectionPool)
353
+ {
354
+ this.log.error(`Meadow-PostgreSQL CREATE INDEX ${pIndexStatement.Name} failed: not connected.`);
355
+ return fCallback(new Error('Not connected to PostgreSQL'));
356
+ }
357
+
358
+ // First check if the index already exists
359
+ this._ConnectionPool.query(pIndexStatement.CheckStatement,
360
+ (pCheckError, pCheckResult) =>
361
+ {
362
+ if (pCheckError)
363
+ {
364
+ this.log.error(`Meadow-PostgreSQL CHECK INDEX ${pIndexStatement.Name} failed!`, pCheckError);
365
+ return fCallback(pCheckError);
366
+ }
367
+
368
+ let tmpExists = pCheckResult && pCheckResult.rows && pCheckResult.rows[0] && parseInt(pCheckResult.rows[0].IndexExists) > 0;
369
+
370
+ if (tmpExists)
371
+ {
372
+ this.log.info(`Meadow-PostgreSQL INDEX ${pIndexStatement.Name} already exists, skipping.`);
373
+ return fCallback();
374
+ }
375
+
376
+ // Index does not exist; create it
377
+ this._ConnectionPool.query(pIndexStatement.Statement,
378
+ (pCreateError) =>
379
+ {
380
+ if (pCreateError)
381
+ {
382
+ this.log.error(`Meadow-PostgreSQL CREATE INDEX ${pIndexStatement.Name} failed!`, pCreateError);
383
+ return fCallback(pCreateError);
384
+ }
385
+ this.log.info(`Meadow-PostgreSQL CREATE INDEX ${pIndexStatement.Name} executed successfully.`);
386
+ return fCallback();
387
+ });
388
+ });
389
+ }
390
+
391
+ /**
392
+ * Programmatically create all indices for a single table.
393
+ *
394
+ * @param {object} pMeadowTableSchema - Meadow table schema object
395
+ * @param {Function} fCallback - callback(pError)
396
+ */
397
+ createIndices(pMeadowTableSchema, fCallback)
398
+ {
399
+ let tmpStatements = this.generateCreateIndexStatements(pMeadowTableSchema);
400
+
401
+ if (tmpStatements.length === 0)
402
+ {
403
+ this.log.info(`No indices to create for ${pMeadowTableSchema.TableName}.`);
404
+ return fCallback();
405
+ }
406
+
407
+ this.fable.Utility.eachLimit(tmpStatements, 1,
408
+ (pStatement, fCreateComplete) =>
409
+ {
410
+ return this.createIndex(pStatement, fCreateComplete);
411
+ },
412
+ (pCreateError) =>
413
+ {
414
+ if (pCreateError)
415
+ {
416
+ this.log.error(`Meadow-PostgreSQL Error creating indices for ${pMeadowTableSchema.TableName}: ${pCreateError}`, pCreateError);
417
+ }
418
+ else
419
+ {
420
+ this.log.info(`Done creating indices for ${pMeadowTableSchema.TableName}!`);
421
+ }
422
+ return fCallback(pCreateError);
423
+ });
424
+ }
425
+
426
+ /**
427
+ * Programmatically create all indices for all tables in a schema.
428
+ *
429
+ * @param {object} pMeadowSchema - Meadow schema object with Tables array
430
+ * @param {Function} fCallback - callback(pError)
431
+ */
432
+ createAllIndices(pMeadowSchema, fCallback)
433
+ {
434
+ this.fable.Utility.eachLimit(pMeadowSchema.Tables, 1,
435
+ (pTable, fCreateComplete) =>
436
+ {
437
+ return this.createIndices(pTable, fCreateComplete);
438
+ },
439
+ (pCreateError) =>
440
+ {
441
+ if (pCreateError)
442
+ {
443
+ this.log.error(`Meadow-PostgreSQL Error creating indices from schema: ${pCreateError}`, pCreateError);
444
+ }
445
+ this.log.info('Done creating all indices!');
446
+ return fCallback(pCreateError);
447
+ });
448
+ }
449
+
450
+ // ========================================================================
451
+ // Database Introspection
452
+ // ========================================================================
453
+
454
+ /**
455
+ * List all user tables in the connected PostgreSQL database.
456
+ *
457
+ * @param {Function} fCallback - callback(pError, pTableNames)
458
+ */
459
+ listTables(fCallback)
460
+ {
461
+ if (!this._ConnectionPool)
462
+ {
463
+ return fCallback(new Error('Not connected to PostgreSQL'));
464
+ }
465
+
466
+ this._ConnectionPool.query(
467
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
468
+ (pError, pResult) =>
469
+ {
470
+ if (pError)
471
+ {
472
+ this.log.error('Meadow-PostgreSQL listTables failed!', pError);
473
+ return fCallback(pError);
474
+ }
475
+ let tmpNames = pResult.rows.map((pRow) => { return pRow.tablename; });
476
+ return fCallback(null, tmpNames);
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Map a PostgreSQL native type to a Meadow DataType.
482
+ *
483
+ * @param {object} pColumnInfo - information_schema.columns row
484
+ * @param {boolean} pIsSerial - Whether this column is SERIAL (auto-increment PK)
485
+ * @param {Set} pForeignKeyColumns - Set of column names that have FK constraints
486
+ * @returns {object} { DataType, Size }
487
+ */
488
+ _mapPostgreSQLTypeToMeadow(pColumnInfo, pIsSerial, pForeignKeyColumns)
489
+ {
490
+ let tmpName = pColumnInfo.column_name;
491
+ let tmpType = (pColumnInfo.data_type || '').toLowerCase().trim();
492
+
493
+ // Priority 1: SERIAL auto-increment → ID
494
+ if (pIsSerial)
495
+ {
496
+ return { DataType: 'ID', Size: '' };
497
+ }
498
+
499
+ // Priority 2: Column name contains "GUID" and type is character → GUID
500
+ if (tmpName.toUpperCase().indexOf('GUID') >= 0 && (tmpType === 'character varying' || tmpType === 'character' || tmpType === 'text'))
501
+ {
502
+ return { DataType: 'GUID', Size: pColumnInfo.character_maximum_length ? String(pColumnInfo.character_maximum_length) : '' };
503
+ }
504
+
505
+ // Priority 3: Has FK constraint → ForeignKey
506
+ if (pForeignKeyColumns && pForeignKeyColumns.has(tmpName))
507
+ {
508
+ return { DataType: 'ForeignKey', Size: '' };
509
+ }
510
+
511
+ // Priority 4: Native type mapping
512
+ if (tmpType === 'numeric' || tmpType === 'decimal')
513
+ {
514
+ let tmpSize = '';
515
+ if (pColumnInfo.numeric_precision)
516
+ {
517
+ tmpSize = String(pColumnInfo.numeric_precision);
518
+ if (pColumnInfo.numeric_scale && pColumnInfo.numeric_scale > 0)
519
+ {
520
+ tmpSize += ',' + String(pColumnInfo.numeric_scale);
521
+ }
522
+ }
523
+ return { DataType: 'Decimal', Size: tmpSize };
524
+ }
525
+
526
+ if (tmpType === 'double precision' || tmpType === 'real')
527
+ {
528
+ return { DataType: 'Decimal', Size: '' };
529
+ }
530
+
531
+ if (tmpType === 'timestamp without time zone' || tmpType === 'timestamp with time zone' || tmpType === 'timestamp')
532
+ {
533
+ return { DataType: 'DateTime', Size: '' };
534
+ }
535
+
536
+ if (tmpType === 'boolean')
537
+ {
538
+ return { DataType: 'Boolean', Size: '' };
539
+ }
540
+
541
+ if (tmpType === 'text')
542
+ {
543
+ // Distinguish between String and Text: if NOT NULL with default '' → String, else Text
544
+ if (pColumnInfo.is_nullable === 'NO' && pColumnInfo.column_default === "''::text")
545
+ {
546
+ return { DataType: 'String', Size: '' };
547
+ }
548
+ return { DataType: 'Text', Size: '' };
549
+ }
550
+
551
+ if (tmpType === 'character varying')
552
+ {
553
+ let tmpSize = pColumnInfo.character_maximum_length ? String(pColumnInfo.character_maximum_length) : '';
554
+ return { DataType: 'String', Size: tmpSize };
555
+ }
556
+
557
+ if (tmpType === 'character')
558
+ {
559
+ let tmpSize = pColumnInfo.character_maximum_length ? String(pColumnInfo.character_maximum_length) : '';
560
+ return { DataType: 'String', Size: tmpSize };
561
+ }
562
+
563
+ if (tmpType === 'integer' || tmpType === 'bigint' || tmpType === 'smallint')
564
+ {
565
+ // Check for boolean naming hints
566
+ let tmpLowerName = tmpName.toLowerCase();
567
+ if (pColumnInfo.is_nullable === 'NO' && pColumnInfo.column_default === '0')
568
+ {
569
+ if (tmpLowerName.indexOf('is') === 0 || tmpLowerName.indexOf('has') === 0 ||
570
+ tmpLowerName.indexOf('in') === 0 || tmpLowerName === 'deleted' ||
571
+ tmpLowerName === 'active' || tmpLowerName === 'enabled')
572
+ {
573
+ return { DataType: 'Boolean', Size: '' };
574
+ }
575
+ }
576
+ return { DataType: 'Numeric', Size: '' };
577
+ }
578
+
579
+ // Default fallback
580
+ return { DataType: 'Text', Size: '' };
581
+ }
582
+
583
+ /**
584
+ * Get column definitions for a single table.
585
+ *
586
+ * @param {string} pTableName - Name of the table
587
+ * @param {Function} fCallback - callback(pError, pColumns)
588
+ */
589
+ introspectTableColumns(pTableName, fCallback)
590
+ {
591
+ if (!this._ConnectionPool)
592
+ {
593
+ return fCallback(new Error('Not connected to PostgreSQL'));
594
+ }
595
+
596
+ let tmpColumnQuery = `SELECT column_name, data_type, character_maximum_length, numeric_precision, numeric_scale, is_nullable, column_default, is_identity FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`;
597
+
598
+ let tmpFKQuery = `SELECT kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema WHERE tc.table_name = $1 AND tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'`;
599
+
600
+ this._ConnectionPool.query(tmpColumnQuery, [pTableName],
601
+ (pError, pColumnResult) =>
602
+ {
603
+ if (pError)
604
+ {
605
+ this.log.error(`Meadow-PostgreSQL introspectTableColumns for ${pTableName} failed!`, pError);
606
+ return fCallback(pError);
607
+ }
608
+
609
+ this._ConnectionPool.query(tmpFKQuery, [pTableName],
610
+ (pFKError, pFKResult) =>
611
+ {
612
+ if (pFKError)
613
+ {
614
+ this.log.error(`Meadow-PostgreSQL introspectTableColumns FK query for ${pTableName} failed!`, pFKError);
615
+ return fCallback(pFKError);
616
+ }
617
+
618
+ let tmpFKColumnSet = new Set(pFKResult.rows.map((pRow) => { return pRow.column_name; }));
619
+
620
+ let tmpResult = [];
621
+ for (let i = 0; i < pColumnResult.rows.length; i++)
622
+ {
623
+ let tmpCol = pColumnResult.rows[i];
624
+ // Detect SERIAL/IDENTITY: integer with nextval() default, or GENERATED AS IDENTITY
625
+ let tmpIsSerial = (tmpCol.data_type === 'integer' && tmpCol.column_default && tmpCol.column_default.indexOf('nextval') >= 0) || (tmpCol.is_identity === 'YES');
626
+ let tmpTypeInfo = this._mapPostgreSQLTypeToMeadow(tmpCol, tmpIsSerial, tmpFKColumnSet);
627
+
628
+ let tmpColumnDef = {
629
+ Column: tmpCol.column_name,
630
+ DataType: tmpTypeInfo.DataType
631
+ };
632
+
633
+ if (tmpTypeInfo.Size)
634
+ {
635
+ tmpColumnDef.Size = tmpTypeInfo.Size;
636
+ }
637
+
638
+ tmpResult.push(tmpColumnDef);
639
+ }
640
+
641
+ return fCallback(null, tmpResult);
642
+ });
643
+ });
644
+ }
645
+
646
+ /**
647
+ * Get raw index definitions for a single table from the database.
648
+ *
649
+ * @param {string} pTableName - Name of the table
650
+ * @param {Function} fCallback - callback(pError, pIndices)
651
+ */
652
+ introspectTableIndices(pTableName, fCallback)
653
+ {
654
+ if (!this._ConnectionPool)
655
+ {
656
+ return fCallback(new Error('Not connected to PostgreSQL'));
657
+ }
658
+
659
+ let tmpQuery = `SELECT i.relname AS index_name, a.attname AS column_name, ix.indisunique, ix.indisprimary FROM pg_class t JOIN pg_index ix ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) WHERE t.relname = $1 AND t.relkind = 'r' ORDER BY i.relname, array_position(ix.indkey, a.attnum)`;
660
+
661
+ this._ConnectionPool.query(tmpQuery, [pTableName],
662
+ (pError, pResult) =>
663
+ {
664
+ if (pError)
665
+ {
666
+ this.log.error(`Meadow-PostgreSQL introspectTableIndices for ${pTableName} failed!`, pError);
667
+ return fCallback(pError);
668
+ }
669
+
670
+ // Group by index name, skip primary key indices
671
+ let tmpIndexMap = {};
672
+ for (let i = 0; i < pResult.rows.length; i++)
673
+ {
674
+ let tmpRow = pResult.rows[i];
675
+ if (tmpRow.indisprimary)
676
+ {
677
+ continue;
678
+ }
679
+
680
+ if (!tmpIndexMap[tmpRow.index_name])
681
+ {
682
+ tmpIndexMap[tmpRow.index_name] = {
683
+ Name: tmpRow.index_name,
684
+ Columns: [],
685
+ Unique: tmpRow.indisunique
686
+ };
687
+ }
688
+ tmpIndexMap[tmpRow.index_name].Columns.push(tmpRow.column_name);
689
+ }
690
+
691
+ let tmpIndices = Object.values(tmpIndexMap);
692
+ return fCallback(null, tmpIndices);
693
+ });
694
+ }
695
+
696
+ /**
697
+ * Get foreign key relationships for a single table.
698
+ *
699
+ * @param {string} pTableName - Name of the table
700
+ * @param {Function} fCallback - callback(pError, pForeignKeys)
701
+ */
702
+ introspectTableForeignKeys(pTableName, fCallback)
703
+ {
704
+ if (!this._ConnectionPool)
705
+ {
706
+ return fCallback(new Error('Not connected to PostgreSQL'));
707
+ }
708
+
709
+ let tmpQuery = `SELECT kcu.column_name, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema WHERE tc.table_name = $1 AND tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'`;
710
+
711
+ this._ConnectionPool.query(tmpQuery, [pTableName],
712
+ (pError, pResult) =>
713
+ {
714
+ if (pError)
715
+ {
716
+ this.log.error(`Meadow-PostgreSQL introspectTableForeignKeys for ${pTableName} failed!`, pError);
717
+ return fCallback(pError);
718
+ }
719
+
720
+ let tmpResult = [];
721
+ for (let i = 0; i < pResult.rows.length; i++)
722
+ {
723
+ let tmpRow = pResult.rows[i];
724
+ tmpResult.push(
725
+ {
726
+ Column: tmpRow.column_name,
727
+ ReferencedTable: tmpRow.referenced_table,
728
+ ReferencedColumn: tmpRow.referenced_column
729
+ });
730
+ }
731
+
732
+ return fCallback(null, tmpResult);
733
+ });
734
+ }
735
+
736
+ /**
737
+ * Classify an index for round-trip fidelity.
738
+ *
739
+ * @param {object} pIndex - { Name, Columns[], Unique }
740
+ * @param {string} pTableName - Table name for pattern matching
741
+ * @returns {object} { type, column, indexed, indexName }
742
+ */
743
+ _classifyIndex(pIndex, pTableName)
744
+ {
745
+ if (pIndex.Columns.length !== 1)
746
+ {
747
+ return { type: 'explicit' };
748
+ }
749
+
750
+ let tmpColumn = pIndex.Columns[0];
751
+ let tmpName = pIndex.Name;
752
+
753
+ if (tmpName === `AK_M_${tmpColumn}`)
754
+ {
755
+ return { type: 'guid-auto', column: tmpColumn };
756
+ }
757
+
758
+ if (tmpName === `IX_M_${tmpColumn}`)
759
+ {
760
+ return { type: 'fk-auto', column: tmpColumn };
761
+ }
762
+
763
+ let tmpRegularAutoName = `IX_M_T_${pTableName}_C_${tmpColumn}`;
764
+ if (tmpName === tmpRegularAutoName && !pIndex.Unique)
765
+ {
766
+ return { type: 'column-auto', column: tmpColumn, indexed: true };
767
+ }
768
+
769
+ let tmpUniqueAutoName = `AK_M_T_${pTableName}_C_${tmpColumn}`;
770
+ if (tmpName === tmpUniqueAutoName && pIndex.Unique)
771
+ {
772
+ return { type: 'column-auto', column: tmpColumn, indexed: 'unique' };
773
+ }
774
+
775
+ return {
776
+ type: 'column-named',
777
+ column: tmpColumn,
778
+ indexed: pIndex.Unique ? 'unique' : true,
779
+ indexName: tmpName
780
+ };
781
+ }
782
+
783
+ /**
784
+ * Generate a complete DDL-level schema for a single table.
785
+ *
786
+ * @param {string} pTableName - Name of the table
787
+ * @param {Function} fCallback - callback(pError, pTableSchema)
788
+ */
789
+ introspectTableSchema(pTableName, fCallback)
790
+ {
791
+ this.introspectTableColumns(pTableName,
792
+ (pColumnError, pColumns) =>
793
+ {
794
+ if (pColumnError)
795
+ {
796
+ return fCallback(pColumnError);
797
+ }
798
+
799
+ this.introspectTableIndices(pTableName,
800
+ (pIndexError, pIndices) =>
801
+ {
802
+ if (pIndexError)
803
+ {
804
+ return fCallback(pIndexError);
805
+ }
806
+
807
+ this.introspectTableForeignKeys(pTableName,
808
+ (pFKError, pForeignKeys) =>
809
+ {
810
+ if (pFKError)
811
+ {
812
+ return fCallback(pFKError);
813
+ }
814
+
815
+ let tmpColumnMap = {};
816
+ for (let i = 0; i < pColumns.length; i++)
817
+ {
818
+ tmpColumnMap[pColumns[i].Column] = pColumns[i];
819
+ }
820
+
821
+ let tmpExplicitIndices = [];
822
+
823
+ for (let i = 0; i < pIndices.length; i++)
824
+ {
825
+ let tmpClassification = this._classifyIndex(pIndices[i], pTableName);
826
+
827
+ switch (tmpClassification.type)
828
+ {
829
+ case 'column-auto':
830
+ if (tmpColumnMap[tmpClassification.column])
831
+ {
832
+ tmpColumnMap[tmpClassification.column].Indexed = tmpClassification.indexed;
833
+ }
834
+ break;
835
+ case 'column-named':
836
+ if (tmpColumnMap[tmpClassification.column])
837
+ {
838
+ tmpColumnMap[tmpClassification.column].Indexed = tmpClassification.indexed;
839
+ tmpColumnMap[tmpClassification.column].IndexName = tmpClassification.indexName;
840
+ }
841
+ break;
842
+ case 'guid-auto':
843
+ if (tmpColumnMap[tmpClassification.column] &&
844
+ tmpColumnMap[tmpClassification.column].DataType !== 'GUID')
845
+ {
846
+ tmpColumnMap[tmpClassification.column].DataType = 'GUID';
847
+ }
848
+ break;
849
+ case 'fk-auto':
850
+ if (tmpColumnMap[tmpClassification.column] &&
851
+ tmpColumnMap[tmpClassification.column].DataType !== 'ForeignKey')
852
+ {
853
+ tmpColumnMap[tmpClassification.column].DataType = 'ForeignKey';
854
+ }
855
+ break;
856
+ case 'explicit':
857
+ tmpExplicitIndices.push(
858
+ {
859
+ Name: pIndices[i].Name,
860
+ Columns: pIndices[i].Columns,
861
+ Unique: pIndices[i].Unique
862
+ });
863
+ break;
864
+ }
865
+ }
866
+
867
+ let tmpSchema = {
868
+ TableName: pTableName,
869
+ Columns: pColumns
870
+ };
871
+
872
+ if (tmpExplicitIndices.length > 0)
873
+ {
874
+ tmpSchema.Indices = tmpExplicitIndices;
875
+ }
876
+
877
+ if (pForeignKeys.length > 0)
878
+ {
879
+ tmpSchema.ForeignKeys = pForeignKeys;
880
+ }
881
+
882
+ return fCallback(null, tmpSchema);
883
+ });
884
+ });
885
+ });
886
+ }
887
+
888
+ /**
889
+ * Generate DDL schemas for ALL tables in the database.
890
+ *
891
+ * @param {Function} fCallback - callback(pError, { Tables: [...] })
892
+ */
893
+ introspectDatabaseSchema(fCallback)
894
+ {
895
+ this.listTables(
896
+ (pError, pTableNames) =>
897
+ {
898
+ if (pError)
899
+ {
900
+ return fCallback(pError);
901
+ }
902
+
903
+ let tmpTables = [];
904
+
905
+ this.fable.Utility.eachLimit(pTableNames, 1,
906
+ (pTableName, fEachComplete) =>
907
+ {
908
+ this.introspectTableSchema(pTableName,
909
+ (pSchemaError, pSchema) =>
910
+ {
911
+ if (pSchemaError)
912
+ {
913
+ return fEachComplete(pSchemaError);
914
+ }
915
+ tmpTables.push(pSchema);
916
+ return fEachComplete();
917
+ });
918
+ },
919
+ (pEachError) =>
920
+ {
921
+ if (pEachError)
922
+ {
923
+ this.log.error('Meadow-PostgreSQL introspectDatabaseSchema failed!', pEachError);
924
+ return fCallback(pEachError);
925
+ }
926
+ return fCallback(null, { Tables: tmpTables });
927
+ });
928
+ });
929
+ }
930
+
931
+ /**
932
+ * Map a DDL DataType to a Meadow Package schema Type.
933
+ *
934
+ * @param {string} pDataType - The DDL-level DataType
935
+ * @param {string} pColumnName - The column name (for magic column detection)
936
+ * @returns {string} The Meadow Package Type
937
+ */
938
+ _mapDataTypeToMeadowType(pDataType, pColumnName)
939
+ {
940
+ let tmpLowerName = pColumnName.toLowerCase();
941
+
942
+ if (tmpLowerName === 'createdate') return 'CreateDate';
943
+ if (tmpLowerName === 'creatingiduser') return 'CreateIDUser';
944
+ if (tmpLowerName === 'updatedate') return 'UpdateDate';
945
+ if (tmpLowerName === 'updatingiduser') return 'UpdateIDUser';
946
+ if (tmpLowerName === 'deleted') return 'Deleted';
947
+ if (tmpLowerName === 'deletingiduser') return 'DeleteIDUser';
948
+ if (tmpLowerName === 'deletedate') return 'DeleteDate';
949
+
950
+ switch (pDataType)
951
+ {
952
+ case 'ID': return 'AutoIdentity';
953
+ case 'GUID': return 'AutoGUID';
954
+ case 'ForeignKey': return 'Numeric';
955
+ case 'Numeric': return 'Numeric';
956
+ case 'Decimal': return 'Numeric';
957
+ case 'String': return 'String';
958
+ case 'Text': return 'String';
959
+ case 'DateTime': return 'DateTime';
960
+ case 'Boolean': return 'Boolean';
961
+ case 'JSON': return 'JSON';
962
+ case 'JSONProxy': return 'JSONProxy';
963
+ default: return 'String';
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Get a default value for a given DataType.
969
+ *
970
+ * @param {string} pDataType - The DDL-level DataType
971
+ * @returns {*} The default value
972
+ */
973
+ _getDefaultValue(pDataType)
974
+ {
975
+ switch (pDataType)
976
+ {
977
+ case 'ID': return 0;
978
+ case 'GUID': return '';
979
+ case 'ForeignKey': return 0;
980
+ case 'Numeric': return 0;
981
+ case 'Decimal': return 0.0;
982
+ case 'String': return '';
983
+ case 'Text': return '';
984
+ case 'DateTime': return '';
985
+ case 'Boolean': return false;
986
+ case 'JSON': return {};
987
+ case 'JSONProxy': return {};
988
+ default: return '';
989
+ }
990
+ }
991
+
992
+ /**
993
+ * Generate a Meadow package JSON for a single table.
994
+ *
995
+ * @param {string} pTableName - Name of the table
996
+ * @param {Function} fCallback - callback(pError, pPackage)
997
+ */
998
+ generateMeadowPackageFromTable(pTableName, fCallback)
999
+ {
1000
+ this.introspectTableSchema(pTableName,
1001
+ (pError, pSchema) =>
1002
+ {
1003
+ if (pError)
1004
+ {
1005
+ return fCallback(pError);
1006
+ }
1007
+
1008
+ let tmpDefaultIdentifier = '';
1009
+ let tmpSchemaEntries = [];
1010
+ let tmpDefaultObject = {};
1011
+
1012
+ for (let i = 0; i < pSchema.Columns.length; i++)
1013
+ {
1014
+ let tmpCol = pSchema.Columns[i];
1015
+ let tmpMeadowType = this._mapDataTypeToMeadowType(tmpCol.DataType, tmpCol.Column);
1016
+
1017
+ if (tmpCol.DataType === 'ID')
1018
+ {
1019
+ tmpDefaultIdentifier = tmpCol.Column;
1020
+ }
1021
+
1022
+ let tmpEntry = {
1023
+ Column: tmpCol.Column,
1024
+ Type: tmpMeadowType
1025
+ };
1026
+
1027
+ if (tmpCol.Size)
1028
+ {
1029
+ tmpEntry.Size = tmpCol.Size;
1030
+ }
1031
+
1032
+ tmpSchemaEntries.push(tmpEntry);
1033
+ tmpDefaultObject[tmpCol.Column] = this._getDefaultValue(tmpCol.DataType);
1034
+ }
1035
+
1036
+ let tmpPackage = {
1037
+ Scope: pTableName,
1038
+ DefaultIdentifier: tmpDefaultIdentifier,
1039
+ Schema: tmpSchemaEntries,
1040
+ DefaultObject: tmpDefaultObject
1041
+ };
1042
+
1043
+ return fCallback(null, tmpPackage);
1044
+ });
1045
+ }
1046
+ }
1047
+
1048
+ module.exports = MeadowSchemaPostgreSQL;