@memberjunction/codegen-lib 5.4.1 → 5.5.0
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/README.md +65 -2
- package/dist/Angular/angular-codegen.d.ts.map +1 -1
- package/dist/Angular/angular-codegen.js +26 -12
- package/dist/Angular/angular-codegen.js.map +1 -1
- package/dist/Angular/related-entity-components.js +2 -2
- package/dist/Angular/related-entity-components.js.map +1 -1
- package/dist/Config/config.d.ts +10 -0
- package/dist/Config/config.d.ts.map +1 -1
- package/dist/Config/config.js +10 -0
- package/dist/Config/config.js.map +1 -1
- package/dist/Database/codeGenDatabaseProvider.d.ts +544 -0
- package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -0
- package/dist/Database/codeGenDatabaseProvider.js +29 -0
- package/dist/Database/codeGenDatabaseProvider.js.map +1 -0
- package/dist/Database/manage-metadata.d.ts +165 -60
- package/dist/Database/manage-metadata.d.ts.map +1 -1
- package/dist/Database/manage-metadata.js +592 -483
- package/dist/Database/manage-metadata.js.map +1 -1
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.d.ts +53 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.d.ts.map +1 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.js +112 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.js.map +1 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +344 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +1567 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.d.ts +42 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.d.ts.map +1 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.js +84 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.js.map +1 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +372 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +1483 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -0
- package/dist/Database/reorder-columns.d.ts +2 -2
- package/dist/Database/reorder-columns.d.ts.map +1 -1
- package/dist/Database/reorder-columns.js +9 -9
- package/dist/Database/reorder-columns.js.map +1 -1
- package/dist/Database/sql.d.ts +10 -5
- package/dist/Database/sql.d.ts.map +1 -1
- package/dist/Database/sql.js +44 -228
- package/dist/Database/sql.js.map +1 -1
- package/dist/Database/sql_codegen.d.ts +31 -29
- package/dist/Database/sql_codegen.d.ts.map +1 -1
- package/dist/Database/sql_codegen.js +209 -842
- package/dist/Database/sql_codegen.js.map +1 -1
- package/dist/Misc/action_subclasses_codegen.js +3 -2
- package/dist/Misc/action_subclasses_codegen.js.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.d.ts +4 -4
- package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
- package/dist/Misc/graphql_server_codegen.d.ts +6 -1
- package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
- package/dist/Misc/graphql_server_codegen.js +33 -35
- package/dist/Misc/graphql_server_codegen.js.map +1 -1
- package/dist/Misc/sql_logging.d.ts +2 -2
- package/dist/Misc/sql_logging.d.ts.map +1 -1
- package/dist/Misc/sql_logging.js +1 -1
- package/dist/Misc/sql_logging.js.map +1 -1
- package/dist/Misc/system_integrity.d.ts +6 -6
- package/dist/Misc/system_integrity.d.ts.map +1 -1
- package/dist/Misc/system_integrity.js +33 -8
- package/dist/Misc/system_integrity.js.map +1 -1
- package/dist/Misc/temp_batch_file.d.ts.map +1 -1
- package/dist/Misc/temp_batch_file.js +4 -1
- package/dist/Misc/temp_batch_file.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/runCodeGen.d.ts +30 -75
- package/dist/runCodeGen.d.ts.map +1 -1
- package/dist/runCodeGen.js +123 -215
- package/dist/runCodeGen.js.map +1 -1
- package/package.json +18 -15
|
@@ -0,0 +1,1567 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var PostgreSQLCodeGenProvider_1;
|
|
8
|
+
import { EntityInfo } from '@memberjunction/core';
|
|
9
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
10
|
+
import { CodeGenDatabaseProvider, CRUDType, } from '../../codeGenDatabaseProvider.js';
|
|
11
|
+
import { configInfo } from '../../../Config/config.js';
|
|
12
|
+
import { logError, logWarning } from '../../../Misc/status_logging.js';
|
|
13
|
+
import { logIf } from '../../../Misc/util.js';
|
|
14
|
+
import { PostgreSQLDialect } from '@memberjunction/sql-dialect';
|
|
15
|
+
import { spawn } from 'child_process';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
const pgDialect = new PostgreSQLDialect();
|
|
18
|
+
/**
|
|
19
|
+
* PostgreSQL implementation of the CodeGen database provider.
|
|
20
|
+
* Generates PostgreSQL-native DDL for views, CRUD functions, triggers, indexes,
|
|
21
|
+
* full-text search, permissions, and other database objects.
|
|
22
|
+
*/
|
|
23
|
+
let PostgreSQLCodeGenProvider = class PostgreSQLCodeGenProvider extends CodeGenDatabaseProvider {
|
|
24
|
+
static { PostgreSQLCodeGenProvider_1 = this; }
|
|
25
|
+
/** @inheritdoc */
|
|
26
|
+
get Dialect() {
|
|
27
|
+
return pgDialect;
|
|
28
|
+
}
|
|
29
|
+
/** @inheritdoc */
|
|
30
|
+
get PlatformKey() {
|
|
31
|
+
return 'postgresql';
|
|
32
|
+
}
|
|
33
|
+
// ─── DROP GUARDS ─────────────────────────────────────────────────────
|
|
34
|
+
/**
|
|
35
|
+
* Generates a PostgreSQL `DROP ... IF EXISTS ... CASCADE` statement as a guard before
|
|
36
|
+
* creating or replacing a database object. For triggers, PostgreSQL relies on
|
|
37
|
+
* `CREATE OR REPLACE` on the trigger function, so a comment is emitted instead.
|
|
38
|
+
*/
|
|
39
|
+
generateDropGuard(objectType, schema, name) {
|
|
40
|
+
// PostgreSQL uses CREATE OR REPLACE for views and functions, so we mostly
|
|
41
|
+
// just need DROP IF EXISTS for procedures and triggers
|
|
42
|
+
switch (objectType) {
|
|
43
|
+
case 'VIEW':
|
|
44
|
+
return `DROP VIEW IF EXISTS ${pgDialect.QuoteSchema(schema, name)} CASCADE;`;
|
|
45
|
+
case 'FUNCTION':
|
|
46
|
+
return `DROP FUNCTION IF EXISTS ${pgDialect.QuoteSchema(schema, name)} CASCADE;`;
|
|
47
|
+
case 'PROCEDURE':
|
|
48
|
+
return `DROP PROCEDURE IF EXISTS ${pgDialect.QuoteSchema(schema, name)} CASCADE;`;
|
|
49
|
+
case 'TRIGGER':
|
|
50
|
+
// Triggers need table context, but for the guard alone we use the name
|
|
51
|
+
return `-- Trigger ${name} will be dropped via CREATE OR REPLACE on the function`;
|
|
52
|
+
default:
|
|
53
|
+
return `-- Unknown object type: ${objectType}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// ─── BASE VIEWS ──────────────────────────────────────────────────────
|
|
57
|
+
/**
|
|
58
|
+
* Generates a PostgreSQL `CREATE OR REPLACE VIEW` statement for an entity's base view.
|
|
59
|
+
* Includes all base table columns, parent/related field joins, and root field lateral
|
|
60
|
+
* joins. Applies a soft-delete `WHERE` filter when the entity uses soft deletes.
|
|
61
|
+
*/
|
|
62
|
+
generateBaseView(context) {
|
|
63
|
+
const { entity } = context;
|
|
64
|
+
const viewName = this.getBaseViewName(entity);
|
|
65
|
+
const alias = entity.BaseTableCodeName.charAt(0).toLowerCase();
|
|
66
|
+
const whereClause = this.buildSoftDeleteWhereClause(entity, alias);
|
|
67
|
+
const selectParts = this.buildBaseViewSelectParts(context, alias);
|
|
68
|
+
const fromParts = this.buildBaseViewFromParts(context, entity, alias);
|
|
69
|
+
// Permissions are handled separately by sql_codegen.ts via generateViewPermissions()
|
|
70
|
+
return `
|
|
71
|
+
------------------------------------------------------------
|
|
72
|
+
----- BASE VIEW FOR ENTITY: ${entity.Name}
|
|
73
|
+
----- SCHEMA: ${entity.SchemaName}
|
|
74
|
+
----- BASE TABLE: ${entity.BaseTable}
|
|
75
|
+
----- PRIMARY KEY: ${entity.PrimaryKeys.map((pk) => pk.Name).join(', ')}
|
|
76
|
+
------------------------------------------------------------
|
|
77
|
+
CREATE OR REPLACE VIEW ${pgDialect.QuoteSchema(entity.SchemaName, viewName)}
|
|
78
|
+
AS
|
|
79
|
+
SELECT
|
|
80
|
+
${selectParts}
|
|
81
|
+
FROM
|
|
82
|
+
${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)} AS ${alias}${fromParts}
|
|
83
|
+
${whereClause};
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
// ─── CRUD CREATE ─────────────────────────────────────────────────────
|
|
87
|
+
/**
|
|
88
|
+
* Generates a PostgreSQL `CREATE OR REPLACE FUNCTION` for inserting a new record.
|
|
89
|
+
* The function accepts typed parameters for each writable field, performs an `INSERT`
|
|
90
|
+
* into the base table, and returns the newly created row from the base view via
|
|
91
|
+
* `RETURN QUERY SELECT`. Handles auto-increment PKs (using `RETURNING ... INTO`),
|
|
92
|
+
* UUID PKs (with `COALESCE` to gen_random_uuid()), and composite PKs. Also emits
|
|
93
|
+
* `GRANT EXECUTE` permissions for authorized roles.
|
|
94
|
+
*/
|
|
95
|
+
generateCRUDCreate(entity) {
|
|
96
|
+
const fnName = this.getCRUDRoutineName(entity, CRUDType.Create);
|
|
97
|
+
const viewName = this.getBaseViewName(entity);
|
|
98
|
+
const paramString = this.generateCRUDParamString(entity.Fields, false);
|
|
99
|
+
const permissions = this.generateCRUDPermissions(entity, fnName, CRUDType.Create);
|
|
100
|
+
const insertColumns = this.generateInsertFieldString(entity, entity.Fields, '', false);
|
|
101
|
+
const insertValues = this.generateInsertFieldString(entity, entity.Fields, 'p_', false);
|
|
102
|
+
const firstKey = entity.FirstPrimaryKey;
|
|
103
|
+
const strategy = this.buildCreateInsertStrategy(entity, firstKey, insertColumns, insertValues);
|
|
104
|
+
return `
|
|
105
|
+
------------------------------------------------------------
|
|
106
|
+
----- CREATE FUNCTION FOR ${entity.BaseTable}
|
|
107
|
+
------------------------------------------------------------
|
|
108
|
+
CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(
|
|
109
|
+
${paramString}
|
|
110
|
+
) RETURNS SETOF ${pgDialect.QuoteSchema(entity.SchemaName, viewName)} AS $$
|
|
111
|
+
DECLARE
|
|
112
|
+
v_new_id ${firstKey.SQLFullType};
|
|
113
|
+
BEGIN
|
|
114
|
+
${strategy.preInsert}INSERT INTO ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
|
|
115
|
+
(
|
|
116
|
+
${strategy.finalColumns}
|
|
117
|
+
)
|
|
118
|
+
VALUES
|
|
119
|
+
(
|
|
120
|
+
${strategy.finalValues}
|
|
121
|
+
)
|
|
122
|
+
${strategy.returningClause};
|
|
123
|
+
|
|
124
|
+
RETURN QUERY
|
|
125
|
+
${strategy.selectClause};
|
|
126
|
+
END;
|
|
127
|
+
$$ LANGUAGE plpgsql;
|
|
128
|
+
${permissions}
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
// ─── CRUD UPDATE ─────────────────────────────────────────────────────
|
|
132
|
+
/**
|
|
133
|
+
* Generates a PostgreSQL `CREATE OR REPLACE FUNCTION` for updating an existing record.
|
|
134
|
+
* The function accepts typed parameters for all updatable fields plus primary key(s),
|
|
135
|
+
* performs an `UPDATE ... SET ... WHERE PK = param`, checks `ROW_COUNT` to detect
|
|
136
|
+
* missing rows, and returns the updated record from the base view via `RETURN QUERY
|
|
137
|
+
* SELECT`. Also generates the `__mj_UpdatedAt` timestamp trigger for the entity
|
|
138
|
+
* and emits `GRANT EXECUTE` permissions.
|
|
139
|
+
*/
|
|
140
|
+
generateCRUDUpdate(entity) {
|
|
141
|
+
const fnName = this.getCRUDRoutineName(entity, CRUDType.Update);
|
|
142
|
+
const viewName = this.getBaseViewName(entity);
|
|
143
|
+
const paramString = this.generateCRUDParamString(entity.Fields, true);
|
|
144
|
+
const permissions = this.generateCRUDPermissions(entity, fnName, CRUDType.Update);
|
|
145
|
+
const updateFields = this.generateUpdateFieldString(entity.Fields);
|
|
146
|
+
const whereClause = this.buildPrimaryKeyWhereClause(entity, 'p_');
|
|
147
|
+
const selectWhereClause = this.buildPrimaryKeyWhereClause(entity, 'p_');
|
|
148
|
+
const trigger = this.generateTimestampTrigger(entity);
|
|
149
|
+
return `
|
|
150
|
+
------------------------------------------------------------
|
|
151
|
+
----- UPDATE FUNCTION FOR ${entity.BaseTable}
|
|
152
|
+
------------------------------------------------------------
|
|
153
|
+
CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(
|
|
154
|
+
${paramString}
|
|
155
|
+
) RETURNS SETOF ${pgDialect.QuoteSchema(entity.SchemaName, viewName)} AS $$
|
|
156
|
+
DECLARE
|
|
157
|
+
v_updated_count INTEGER;
|
|
158
|
+
BEGIN
|
|
159
|
+
UPDATE ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
|
|
160
|
+
SET
|
|
161
|
+
${updateFields}
|
|
162
|
+
WHERE
|
|
163
|
+
${whereClause};
|
|
164
|
+
|
|
165
|
+
GET DIAGNOSTICS v_updated_count = ROW_COUNT;
|
|
166
|
+
|
|
167
|
+
IF v_updated_count = 0 THEN
|
|
168
|
+
-- Nothing was updated, return empty result set
|
|
169
|
+
RETURN;
|
|
170
|
+
END IF;
|
|
171
|
+
|
|
172
|
+
-- Return the updated record from the base view
|
|
173
|
+
RETURN QUERY
|
|
174
|
+
SELECT * FROM ${pgDialect.QuoteSchema(entity.SchemaName, viewName)}
|
|
175
|
+
WHERE ${selectWhereClause};
|
|
176
|
+
END;
|
|
177
|
+
$$ LANGUAGE plpgsql;
|
|
178
|
+
${permissions}
|
|
179
|
+
|
|
180
|
+
${trigger}
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
183
|
+
// ─── CRUD DELETE ─────────────────────────────────────────────────────
|
|
184
|
+
/**
|
|
185
|
+
* Generates a PostgreSQL `CREATE OR REPLACE FUNCTION` for deleting a record.
|
|
186
|
+
* Supports both hard deletes (`DELETE FROM`) and soft deletes (`UPDATE ... SET
|
|
187
|
+
* __mj_DeletedAt`). Prepends any cascade SQL for dependent records, uses
|
|
188
|
+
* `#variable_conflict use_column` to avoid PL/pgSQL naming conflicts, and returns
|
|
189
|
+
* the affected primary key(s) or NULLs if no row was found. Emits `GRANT EXECUTE`
|
|
190
|
+
* permissions for authorized roles.
|
|
191
|
+
*/
|
|
192
|
+
generateCRUDDelete(entity, cascadeSQL) {
|
|
193
|
+
const fnName = this.getCRUDRoutineName(entity, CRUDType.Delete);
|
|
194
|
+
const permissions = this.generateCRUDPermissions(entity, fnName, CRUDType.Delete);
|
|
195
|
+
const { paramDecl, deleteBody, returnType, returnStatement } = this.buildDeleteStrategy(entity, cascadeSQL);
|
|
196
|
+
return `
|
|
197
|
+
------------------------------------------------------------
|
|
198
|
+
----- DELETE FUNCTION FOR ${entity.BaseTable}
|
|
199
|
+
------------------------------------------------------------
|
|
200
|
+
CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(
|
|
201
|
+
${paramDecl}
|
|
202
|
+
) RETURNS ${returnType} AS $$
|
|
203
|
+
#variable_conflict use_column
|
|
204
|
+
DECLARE
|
|
205
|
+
v_affected_count INTEGER;
|
|
206
|
+
BEGIN
|
|
207
|
+
${cascadeSQL}
|
|
208
|
+
${deleteBody}
|
|
209
|
+
|
|
210
|
+
GET DIAGNOSTICS v_affected_count = ROW_COUNT;
|
|
211
|
+
|
|
212
|
+
${returnStatement}
|
|
213
|
+
END;
|
|
214
|
+
$$ LANGUAGE plpgsql;
|
|
215
|
+
${permissions}
|
|
216
|
+
`;
|
|
217
|
+
}
|
|
218
|
+
// ─── TIMESTAMP TRIGGER ───────────────────────────────────────────────
|
|
219
|
+
/**
|
|
220
|
+
* Generates a PL/pgSQL trigger function and a `BEFORE UPDATE` trigger that
|
|
221
|
+
* automatically sets the `__mj_UpdatedAt` column to the current UTC time on every
|
|
222
|
+
* row update. Uses `CREATE OR REPLACE FUNCTION` for the trigger function and
|
|
223
|
+
* `DROP TRIGGER IF EXISTS` + `CREATE TRIGGER` for idempotent trigger creation.
|
|
224
|
+
* Returns an empty string if the entity has no `__mj_UpdatedAt` field.
|
|
225
|
+
*/
|
|
226
|
+
generateTimestampTrigger(entity) {
|
|
227
|
+
const updatedAtField = entity.Fields.find((f) => f.Name.toLowerCase().trim() === EntityInfo.UpdatedAtFieldName.toLowerCase().trim());
|
|
228
|
+
if (!updatedAtField)
|
|
229
|
+
return '';
|
|
230
|
+
const trigFnName = `fn_trg_update_${this.toSnakeCase(entity.BaseTableCodeName)}`;
|
|
231
|
+
const trigName = `trg_update_${this.toSnakeCase(entity.BaseTableCodeName)}`;
|
|
232
|
+
return `
|
|
233
|
+
------------------------------------------------------------
|
|
234
|
+
----- TRIGGER FOR ${EntityInfo.UpdatedAtFieldName} field for the ${entity.BaseTable} table
|
|
235
|
+
------------------------------------------------------------
|
|
236
|
+
CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, trigFnName)}()
|
|
237
|
+
RETURNS TRIGGER AS $$
|
|
238
|
+
BEGIN
|
|
239
|
+
NEW.${EntityInfo.UpdatedAtFieldName} := NOW() AT TIME ZONE 'UTC';
|
|
240
|
+
RETURN NEW;
|
|
241
|
+
END;
|
|
242
|
+
$$ LANGUAGE plpgsql;
|
|
243
|
+
|
|
244
|
+
DROP TRIGGER IF EXISTS ${pgDialect.QuoteIdentifier(trigName)} ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)};
|
|
245
|
+
|
|
246
|
+
CREATE TRIGGER ${pgDialect.QuoteIdentifier(trigName)}
|
|
247
|
+
BEFORE UPDATE ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
|
|
248
|
+
FOR EACH ROW
|
|
249
|
+
EXECUTE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, trigFnName)}();
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
// ─── INDEXES ─────────────────────────────────────────────────────────
|
|
253
|
+
/**
|
|
254
|
+
* Generates `CREATE INDEX IF NOT EXISTS` statements for each foreign key column
|
|
255
|
+
* on the entity's base table. Index names follow the `idx_auto_mj_fkey_<table>_<column>`
|
|
256
|
+
* convention and are truncated to 63 characters (PostgreSQL's maximum identifier length).
|
|
257
|
+
* Skips primary key columns and virtual fields.
|
|
258
|
+
*/
|
|
259
|
+
generateForeignKeyIndexes(entity) {
|
|
260
|
+
const indexes = [];
|
|
261
|
+
for (const field of entity.Fields) {
|
|
262
|
+
if (field.RelatedEntityID && !field.IsPrimaryKey && !field.IsVirtual) {
|
|
263
|
+
const indexName = `idx_auto_mj_fkey_${this.toSnakeCase(entity.BaseTable)}_${this.toSnakeCase(field.Name)}`;
|
|
264
|
+
// Truncate to 63 chars (PG max identifier length)
|
|
265
|
+
const truncatedName = indexName.length > 63 ? indexName.substring(0, 63) : indexName;
|
|
266
|
+
indexes.push(`CREATE INDEX IF NOT EXISTS ${pgDialect.QuoteIdentifier(truncatedName)}\n` +
|
|
267
|
+
` ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)} (${pgDialect.QuoteIdentifier(field.Name)});`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return indexes;
|
|
271
|
+
}
|
|
272
|
+
// ─── FULL-TEXT SEARCH ────────────────────────────────────────────────
|
|
273
|
+
/**
|
|
274
|
+
* Generates a complete PostgreSQL full-text search infrastructure for an entity.
|
|
275
|
+
* This includes:
|
|
276
|
+
* 1. A `tsvector` column (`__mj_fts_vector`) added via conditional `ALTER TABLE`
|
|
277
|
+
* 2. A PL/pgSQL trigger function that concatenates search fields into a `tsvector`
|
|
278
|
+
* 3. A `BEFORE INSERT OR UPDATE` trigger to keep the vector column in sync
|
|
279
|
+
* 4. A GIN index on the `tsvector` column for fast lookups
|
|
280
|
+
* 5. A SQL `STABLE` search function that joins the base view with a `plainto_tsquery` match
|
|
281
|
+
* 6. A backfill `UPDATE` to populate existing rows where the vector is NULL
|
|
282
|
+
*
|
|
283
|
+
* @returns A {@link FullTextSearchResult} with the generated SQL and the search function name.
|
|
284
|
+
*/
|
|
285
|
+
generateFullTextSearch(entity, searchFields, _primaryKeyIndexName) {
|
|
286
|
+
const ftsColName = '__mj_fts_vector';
|
|
287
|
+
const trigName = `trg_fts_${this.toSnakeCase(entity.BaseTable)}`;
|
|
288
|
+
const indexName = `idx_fts_${this.toSnakeCase(entity.BaseTable)}`;
|
|
289
|
+
const fnName = `fn_search_${this.toSnakeCase(entity.BaseTable)}`;
|
|
290
|
+
const viewName = this.getBaseViewName(entity);
|
|
291
|
+
const fieldNames = searchFields.map((f) => pgDialect.QuoteIdentifier(f.Name));
|
|
292
|
+
const fieldList = fieldNames.join(', ');
|
|
293
|
+
const sql = `
|
|
294
|
+
------------------------------------------------------------
|
|
295
|
+
----- FULL-TEXT SEARCH FOR ${entity.BaseTable}
|
|
296
|
+
------------------------------------------------------------
|
|
297
|
+
-- Add tsvector column if it doesn't exist
|
|
298
|
+
DO $$
|
|
299
|
+
BEGIN
|
|
300
|
+
IF NOT EXISTS (
|
|
301
|
+
SELECT 1 FROM information_schema.columns
|
|
302
|
+
WHERE table_schema = '${entity.SchemaName}'
|
|
303
|
+
AND table_name = '${entity.BaseTable}'
|
|
304
|
+
AND column_name = '${ftsColName}'
|
|
305
|
+
) THEN
|
|
306
|
+
ALTER TABLE ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
|
|
307
|
+
ADD COLUMN ${ftsColName} TSVECTOR;
|
|
308
|
+
END IF;
|
|
309
|
+
END $$;
|
|
310
|
+
|
|
311
|
+
-- Create trigger to keep tsvector updated
|
|
312
|
+
CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, `fn_${trigName}`)}()
|
|
313
|
+
RETURNS TRIGGER AS $$
|
|
314
|
+
BEGIN
|
|
315
|
+
NEW.${ftsColName} := to_tsvector('english', ${fieldNames.map((n) => `COALESCE(NEW.${n}::TEXT, '')`).join(" || ' ' || ")});
|
|
316
|
+
RETURN NEW;
|
|
317
|
+
END;
|
|
318
|
+
$$ LANGUAGE plpgsql;
|
|
319
|
+
|
|
320
|
+
DROP TRIGGER IF EXISTS ${pgDialect.QuoteIdentifier(trigName)} ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)};
|
|
321
|
+
|
|
322
|
+
CREATE TRIGGER ${pgDialect.QuoteIdentifier(trigName)}
|
|
323
|
+
BEFORE INSERT OR UPDATE OF ${fieldList}
|
|
324
|
+
ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
|
|
325
|
+
FOR EACH ROW
|
|
326
|
+
EXECUTE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, `fn_${trigName}`)}();
|
|
327
|
+
|
|
328
|
+
-- Create GIN index for fast full-text search
|
|
329
|
+
CREATE INDEX IF NOT EXISTS ${pgDialect.QuoteIdentifier(indexName)}
|
|
330
|
+
ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)} USING GIN(${ftsColName});
|
|
331
|
+
|
|
332
|
+
-- Create search function
|
|
333
|
+
CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(
|
|
334
|
+
p_search_term TEXT
|
|
335
|
+
) RETURNS SETOF ${pgDialect.QuoteSchema(entity.SchemaName, viewName)} AS $$
|
|
336
|
+
SELECT v.*
|
|
337
|
+
FROM ${pgDialect.QuoteSchema(entity.SchemaName, viewName)} v
|
|
338
|
+
JOIN ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)} t
|
|
339
|
+
ON ${entity.PrimaryKeys.map((pk) => `v.${pgDialect.QuoteIdentifier(pk.Name)} = t.${pgDialect.QuoteIdentifier(pk.Name)}`).join(' AND ')}
|
|
340
|
+
WHERE t.${ftsColName} @@ plainto_tsquery('english', p_search_term);
|
|
341
|
+
$$ LANGUAGE sql STABLE;
|
|
342
|
+
|
|
343
|
+
-- Backfill existing rows
|
|
344
|
+
UPDATE ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
|
|
345
|
+
SET ${ftsColName} = to_tsvector('english', ${fieldNames.map((n) => `COALESCE(${n}::TEXT, '')`).join(" || ' ' || ")})
|
|
346
|
+
WHERE ${ftsColName} IS NULL;
|
|
347
|
+
`;
|
|
348
|
+
return { sql, functionName: fnName };
|
|
349
|
+
}
|
|
350
|
+
// ─── RECURSIVE ROOT ID FUNCTIONS ─────────────────────────────────────
|
|
351
|
+
/**
|
|
352
|
+
* Generates a PostgreSQL SQL `STABLE` function that walks a self-referencing hierarchy
|
|
353
|
+
* (e.g., ParentCategoryID) using a recursive CTE to find the root ancestor record.
|
|
354
|
+
* The CTE starts from `COALESCE(p_parent_id, p_record_id)` as the anchor and follows
|
|
355
|
+
* the parent FK upward, capped at 100 levels to prevent infinite loops. Returns the
|
|
356
|
+
* root record's primary key value.
|
|
357
|
+
*/
|
|
358
|
+
generateRootIDFunction(entity, field) {
|
|
359
|
+
const primaryKey = entity.FirstPrimaryKey.Name;
|
|
360
|
+
const primaryKeyType = this.mapSQLType(entity.FirstPrimaryKey.SQLFullType);
|
|
361
|
+
const fieldName = field.Name;
|
|
362
|
+
const fnName = `fn_${this.toSnakeCase(entity.BaseTable)}_${this.toSnakeCase(fieldName)}_get_root_id`;
|
|
363
|
+
return `
|
|
364
|
+
------------------------------------------------------------
|
|
365
|
+
----- ROOT ID FUNCTION FOR: ${entity.BaseTable}.${fieldName}
|
|
366
|
+
------------------------------------------------------------
|
|
367
|
+
CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(
|
|
368
|
+
p_record_id ${primaryKeyType},
|
|
369
|
+
p_parent_id ${primaryKeyType}
|
|
370
|
+
) RETURNS ${primaryKeyType} AS $$
|
|
371
|
+
WITH RECURSIVE cte_root_parent AS (
|
|
372
|
+
-- Anchor: Start from p_parent_id if not null, otherwise start from p_record_id
|
|
373
|
+
SELECT
|
|
374
|
+
${pgDialect.QuoteIdentifier(primaryKey)},
|
|
375
|
+
${pgDialect.QuoteIdentifier(fieldName)},
|
|
376
|
+
${pgDialect.QuoteIdentifier(primaryKey)} AS root_parent_id,
|
|
377
|
+
0 AS depth
|
|
378
|
+
FROM
|
|
379
|
+
${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
|
|
380
|
+
WHERE
|
|
381
|
+
${pgDialect.QuoteIdentifier(primaryKey)} = COALESCE(p_parent_id, p_record_id)
|
|
382
|
+
|
|
383
|
+
UNION ALL
|
|
384
|
+
|
|
385
|
+
-- Recursive: Keep going up the hierarchy
|
|
386
|
+
SELECT
|
|
387
|
+
c.${pgDialect.QuoteIdentifier(primaryKey)},
|
|
388
|
+
c.${pgDialect.QuoteIdentifier(fieldName)},
|
|
389
|
+
c.${pgDialect.QuoteIdentifier(primaryKey)} AS root_parent_id,
|
|
390
|
+
p.depth + 1 AS depth
|
|
391
|
+
FROM
|
|
392
|
+
${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)} c
|
|
393
|
+
INNER JOIN
|
|
394
|
+
cte_root_parent p ON c.${pgDialect.QuoteIdentifier(primaryKey)} = p.${pgDialect.QuoteIdentifier(fieldName)}
|
|
395
|
+
WHERE
|
|
396
|
+
p.depth < 100 -- Prevent infinite loops
|
|
397
|
+
)
|
|
398
|
+
SELECT root_parent_id
|
|
399
|
+
FROM cte_root_parent
|
|
400
|
+
WHERE ${pgDialect.QuoteIdentifier(fieldName)} IS NULL
|
|
401
|
+
ORDER BY root_parent_id
|
|
402
|
+
LIMIT 1;
|
|
403
|
+
$$ LANGUAGE sql STABLE;
|
|
404
|
+
`;
|
|
405
|
+
}
|
|
406
|
+
/** @inheritdoc */
|
|
407
|
+
generateRootFieldSelect(_entity, field, alias) {
|
|
408
|
+
const rootFieldName = `Root${field.Name}`;
|
|
409
|
+
return `${alias}.root_id AS ${pgDialect.QuoteIdentifier(rootFieldName)}`;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Generates a `LEFT JOIN LATERAL` clause that invokes the root ID function for a
|
|
413
|
+
* self-referencing field. PostgreSQL uses `LATERAL` joins (rather than SQL Server's
|
|
414
|
+
* `OUTER APPLY`) to call scalar functions inline within a view definition.
|
|
415
|
+
*/
|
|
416
|
+
generateRootFieldJoin(entity, field, alias) {
|
|
417
|
+
const fnName = `fn_${this.toSnakeCase(entity.BaseTable)}_${this.toSnakeCase(field.Name)}_get_root_id`;
|
|
418
|
+
const tableAlias = entity.BaseTableCodeName.charAt(0).toLowerCase();
|
|
419
|
+
return `LEFT JOIN LATERAL (
|
|
420
|
+
SELECT ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(${tableAlias}.${pgDialect.QuoteIdentifier(entity.FirstPrimaryKey.Name)}, ${tableAlias}.${pgDialect.QuoteIdentifier(field.Name)}) AS root_id
|
|
421
|
+
) AS ${alias} ON true`;
|
|
422
|
+
}
|
|
423
|
+
// ─── PERMISSIONS ─────────────────────────────────────────────────────
|
|
424
|
+
/** @inheritdoc */
|
|
425
|
+
generateViewPermissions(entity) {
|
|
426
|
+
const viewName = this.getBaseViewName(entity);
|
|
427
|
+
const roles = this.collectPermissionRoles(entity.Permissions);
|
|
428
|
+
if (roles.length === 0)
|
|
429
|
+
return '';
|
|
430
|
+
return roles.map((role) => `GRANT SELECT ON ${pgDialect.QuoteSchema(entity.SchemaName, viewName)} TO ${pgDialect.QuoteIdentifier(role)};`).join('\n');
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Generates `GRANT EXECUTE ON FUNCTION` statements for the given CRUD function,
|
|
434
|
+
* granting access to each role that has the corresponding permission (Create, Update,
|
|
435
|
+
* or Delete) on the entity.
|
|
436
|
+
*/
|
|
437
|
+
generateCRUDPermissions(entity, routineName, type) {
|
|
438
|
+
const roles = [];
|
|
439
|
+
for (const ep of entity.Permissions) {
|
|
440
|
+
if (!ep.RoleSQLName || ep.RoleSQLName.length === 0)
|
|
441
|
+
continue;
|
|
442
|
+
if ((type === CRUDType.Create && ep.CanCreate) ||
|
|
443
|
+
(type === CRUDType.Update && ep.CanUpdate) ||
|
|
444
|
+
(type === CRUDType.Delete && ep.CanDelete)) {
|
|
445
|
+
roles.push(ep.RoleSQLName);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (roles.length === 0)
|
|
449
|
+
return '';
|
|
450
|
+
return roles.map((role) => `GRANT EXECUTE ON FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, routineName)} TO ${pgDialect.QuoteIdentifier(role)};`).join('\n');
|
|
451
|
+
}
|
|
452
|
+
/** @inheritdoc */
|
|
453
|
+
generateFullTextSearchPermissions(entity, functionName) {
|
|
454
|
+
const roles = this.collectPermissionRoles(entity.Permissions);
|
|
455
|
+
if (roles.length === 0)
|
|
456
|
+
return '';
|
|
457
|
+
return roles.map((role) => `GRANT EXECUTE ON FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, functionName)} TO ${pgDialect.QuoteIdentifier(role)};`).join('\n');
|
|
458
|
+
}
|
|
459
|
+
// ─── CASCADE DELETES ─────────────────────────────────────────────────
|
|
460
|
+
/** @inheritdoc */
|
|
461
|
+
generateSingleCascadeOperation(context) {
|
|
462
|
+
const { parentEntity, relatedEntity, fkField, operation } = context;
|
|
463
|
+
// Use the operation type from the orchestrator's decision
|
|
464
|
+
if (operation === 'update') {
|
|
465
|
+
return this.generateCascadeUpdateToNull(parentEntity, relatedEntity, fkField);
|
|
466
|
+
}
|
|
467
|
+
return this.generateCascadeCursorDelete(parentEntity, relatedEntity, fkField);
|
|
468
|
+
}
|
|
469
|
+
// ─── TIMESTAMP COLUMNS ───────────────────────────────────────────────
|
|
470
|
+
/**
|
|
471
|
+
* Generates a PL/pgSQL `DO $$` block that conditionally adds `__mj_CreatedAt` and
|
|
472
|
+
* `__mj_UpdatedAt` columns to a table using `TIMESTAMPTZ` type with a UTC default.
|
|
473
|
+
* Uses `information_schema` checks to skip columns that already exist.
|
|
474
|
+
*/
|
|
475
|
+
generateTimestampColumns(schema, tableName) {
|
|
476
|
+
return `
|
|
477
|
+
-- Add timestamp columns to ${tableName}
|
|
478
|
+
DO $$
|
|
479
|
+
BEGIN
|
|
480
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = '${schema}' AND table_name = '${tableName}' AND column_name = '__mj_CreatedAt') THEN
|
|
481
|
+
ALTER TABLE ${pgDialect.QuoteSchema(schema, tableName)}
|
|
482
|
+
ADD COLUMN __mj_CreatedAt TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC');
|
|
483
|
+
END IF;
|
|
484
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = '${schema}' AND table_name = '${tableName}' AND column_name = '__mj_UpdatedAt') THEN
|
|
485
|
+
ALTER TABLE ${pgDialect.QuoteSchema(schema, tableName)}
|
|
486
|
+
ADD COLUMN __mj_UpdatedAt TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC');
|
|
487
|
+
END IF;
|
|
488
|
+
END $$;
|
|
489
|
+
`;
|
|
490
|
+
}
|
|
491
|
+
// ─── PARAMETER / FIELD HELPERS ───────────────────────────────────────
|
|
492
|
+
/**
|
|
493
|
+
* Builds the parameter declaration list for a PostgreSQL CRUD function signature.
|
|
494
|
+
* Each parameter is prefixed with `p_` and uses the PostgreSQL-mapped type. For
|
|
495
|
+
* CREATE functions, parameters with default values or primary keys get `DEFAULT NULL`
|
|
496
|
+
* to allow optional arguments, and PostgreSQL's requirement that all subsequent
|
|
497
|
+
* parameters also have defaults once the first default appears is respected.
|
|
498
|
+
*/
|
|
499
|
+
generateCRUDParamString(entityFields, isUpdate) {
|
|
500
|
+
const parts = [];
|
|
501
|
+
let foundDefault = false;
|
|
502
|
+
for (const ef of entityFields) {
|
|
503
|
+
if (!this.shouldIncludeFieldInParams(ef, isUpdate))
|
|
504
|
+
continue;
|
|
505
|
+
const paramName = `p_${this.toSnakeCase(ef.CodeName)}`;
|
|
506
|
+
const sqlType = this.mapSQLType(ef.SQLFullType);
|
|
507
|
+
let defaultVal = '';
|
|
508
|
+
if (!isUpdate && ef.IsPrimaryKey && !ef.AutoIncrement) {
|
|
509
|
+
defaultVal = ' DEFAULT NULL';
|
|
510
|
+
foundDefault = true;
|
|
511
|
+
}
|
|
512
|
+
else if (!isUpdate && ef.HasDefaultValue && !ef.AllowsNull) {
|
|
513
|
+
defaultVal = ' DEFAULT NULL';
|
|
514
|
+
foundDefault = true;
|
|
515
|
+
}
|
|
516
|
+
else if (!isUpdate && foundDefault) {
|
|
517
|
+
// PG requires all params after the first DEFAULT to also have DEFAULTs
|
|
518
|
+
defaultVal = ' DEFAULT NULL';
|
|
519
|
+
}
|
|
520
|
+
parts.push(`${paramName} ${sqlType}${defaultVal}`);
|
|
521
|
+
}
|
|
522
|
+
return parts.join(',\n ');
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Builds either the column name list or the value expression list for an INSERT
|
|
526
|
+
* statement, depending on whether {@link prefix} is empty (column names) or set
|
|
527
|
+
* (parameter values with `p_` prefix). Handles special date fields
|
|
528
|
+
* (`__mj_CreatedAt`, `__mj_UpdatedAt`) by substituting `NOW() AT TIME ZONE 'UTC'`
|
|
529
|
+
* and applies default-value COALESCE wrappers for fields with non-null defaults.
|
|
530
|
+
*/
|
|
531
|
+
generateInsertFieldString(entity, entityFields, prefix, excludePrimaryKey = false) {
|
|
532
|
+
const autoGeneratedPrimaryKey = entity.FirstPrimaryKey.AutoIncrement;
|
|
533
|
+
const parts = [];
|
|
534
|
+
for (const ef of entityFields) {
|
|
535
|
+
if (this.shouldSkipFieldForInsert(ef, autoGeneratedPrimaryKey, excludePrimaryKey))
|
|
536
|
+
continue;
|
|
537
|
+
if (prefix !== '' && ef.IsSpecialDateField) {
|
|
538
|
+
parts.push(this.getSpecialDateInsertValue(ef));
|
|
539
|
+
}
|
|
540
|
+
else if (prefix === '') {
|
|
541
|
+
// Column name side
|
|
542
|
+
parts.push(pgDialect.QuoteIdentifier(ef.Name));
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
// Parameter value side
|
|
546
|
+
parts.push(this.getParameterInsertValue(ef, prefix));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return parts.join(',\n ');
|
|
550
|
+
}
|
|
551
|
+
/** @inheritdoc */
|
|
552
|
+
generateUpdateFieldString(entityFields) {
|
|
553
|
+
const parts = [];
|
|
554
|
+
for (const ef of entityFields) {
|
|
555
|
+
if (ef.IsPrimaryKey || ef.IsVirtual || !ef.AllowUpdateAPI || ef.AutoIncrement || ef.IsSpecialDateField)
|
|
556
|
+
continue;
|
|
557
|
+
parts.push(`${pgDialect.QuoteIdentifier(ef.Name)} = p_${this.toSnakeCase(ef.CodeName)}`);
|
|
558
|
+
}
|
|
559
|
+
return parts.join(',\n ');
|
|
560
|
+
}
|
|
561
|
+
// ─── ROUTINE NAMING ──────────────────────────────────────────────────
|
|
562
|
+
/** @inheritdoc */
|
|
563
|
+
getCRUDRoutineName(entity, type) {
|
|
564
|
+
const snakeTable = this.toSnakeCase(entity.BaseTableCodeName);
|
|
565
|
+
switch (type) {
|
|
566
|
+
case CRUDType.Create:
|
|
567
|
+
return entity.spCreate || `fn_create_${snakeTable}`;
|
|
568
|
+
case CRUDType.Update:
|
|
569
|
+
return entity.spUpdate || `fn_update_${snakeTable}`;
|
|
570
|
+
case CRUDType.Delete:
|
|
571
|
+
return entity.spDelete || `fn_delete_${snakeTable}`;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// ─── SQL HEADERS ─────────────────────────────────────────────────────
|
|
575
|
+
/** @inheritdoc */
|
|
576
|
+
generateSQLFileHeader(entity, itemName) {
|
|
577
|
+
return `-- ============================================================
|
|
578
|
+
-- PostgreSQL Generated SQL for Entity: ${entity.Name}
|
|
579
|
+
-- Item: ${itemName}
|
|
580
|
+
-- Generated at: ${new Date().toISOString()}
|
|
581
|
+
-- ============================================================
|
|
582
|
+
`;
|
|
583
|
+
}
|
|
584
|
+
/** @inheritdoc */
|
|
585
|
+
generateAllEntitiesSQLFileHeader() {
|
|
586
|
+
return `-- ============================================================
|
|
587
|
+
-- PostgreSQL Generated SQL for All Entities
|
|
588
|
+
-- Generated at: ${new Date().toISOString()}
|
|
589
|
+
-- ============================================================
|
|
590
|
+
`;
|
|
591
|
+
}
|
|
592
|
+
// ─── UTILITY ─────────────────────────────────────────────────────────
|
|
593
|
+
/**
|
|
594
|
+
* Maps a SQL default value expression to its PostgreSQL equivalent. Translates
|
|
595
|
+
* SQL Server built-in functions (e.g., `NEWID()` to `gen_random_uuid()`,
|
|
596
|
+
* `GETUTCDATE()` to `NOW() AT TIME ZONE 'UTC'`), strips outer parentheses and
|
|
597
|
+
* surrounding single quotes, and re-applies quoting based on the {@link needsQuotes}
|
|
598
|
+
* flag. Returns `'NULL'` for empty or whitespace-only input.
|
|
599
|
+
*/
|
|
600
|
+
formatDefaultValue(defaultValue, needsQuotes) {
|
|
601
|
+
if (!defaultValue || defaultValue.trim().length === 0)
|
|
602
|
+
return 'NULL';
|
|
603
|
+
let trimmedValue = defaultValue.trim();
|
|
604
|
+
const lowerValue = trimmedValue.toLowerCase();
|
|
605
|
+
// Map SQL Server and PostgreSQL functions to canonical PostgreSQL equivalents
|
|
606
|
+
const functionMap = {
|
|
607
|
+
'newid()': 'gen_random_uuid()',
|
|
608
|
+
'newsequentialid()': 'gen_random_uuid()',
|
|
609
|
+
'gen_random_uuid()': 'gen_random_uuid()',
|
|
610
|
+
'getdate()': "NOW() AT TIME ZONE 'UTC'",
|
|
611
|
+
'getutcdate()': "NOW() AT TIME ZONE 'UTC'",
|
|
612
|
+
'sysdatetime()': "NOW() AT TIME ZONE 'UTC'",
|
|
613
|
+
'sysdatetimeoffset()': "NOW() AT TIME ZONE 'UTC'",
|
|
614
|
+
'now()': 'NOW()',
|
|
615
|
+
'current_timestamp': 'CURRENT_TIMESTAMP',
|
|
616
|
+
'user_name()': 'CURRENT_USER',
|
|
617
|
+
'suser_name()': 'SESSION_USER',
|
|
618
|
+
'system_user': 'CURRENT_USER',
|
|
619
|
+
};
|
|
620
|
+
for (const [sqlFunc, pgFunc] of Object.entries(functionMap)) {
|
|
621
|
+
if (lowerValue.includes(sqlFunc)) {
|
|
622
|
+
return pgFunc;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// Remove outer parentheses if present
|
|
626
|
+
if (trimmedValue.startsWith('(') && trimmedValue.endsWith(')')) {
|
|
627
|
+
trimmedValue = trimmedValue.substring(1, trimmedValue.length - 1);
|
|
628
|
+
}
|
|
629
|
+
// Remove surrounding quotes for clean value
|
|
630
|
+
let cleanValue = trimmedValue;
|
|
631
|
+
if (cleanValue.startsWith("'") && cleanValue.endsWith("'")) {
|
|
632
|
+
cleanValue = cleanValue.substring(1, cleanValue.length - 1);
|
|
633
|
+
}
|
|
634
|
+
if (needsQuotes)
|
|
635
|
+
return `'${cleanValue}'`;
|
|
636
|
+
return cleanValue;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Builds a set of PL/pgSQL components for working with an entity's primary key(s)
|
|
640
|
+
* in cascade operations: variable declarations, SELECT field list, FETCH INTO
|
|
641
|
+
* variable list, and named routine parameter assignments. Used by cascade delete
|
|
642
|
+
* and update-to-NULL generators to construct cursor-based loops.
|
|
643
|
+
*/
|
|
644
|
+
buildPrimaryKeyComponents(entity, prefix) {
|
|
645
|
+
const varPrefix = prefix || 'v_related_';
|
|
646
|
+
const varDecls = [];
|
|
647
|
+
const selectFlds = [];
|
|
648
|
+
const fetchVars = [];
|
|
649
|
+
const routineParamParts = [];
|
|
650
|
+
for (const pk of entity.PrimaryKeys) {
|
|
651
|
+
const varName = `${varPrefix}${this.toSnakeCase(pk.CodeName)}`;
|
|
652
|
+
const sqlType = this.mapSQLType(pk.SQLFullType);
|
|
653
|
+
varDecls.push(`${varName} ${sqlType}`);
|
|
654
|
+
selectFlds.push(pgDialect.QuoteIdentifier(pk.Name));
|
|
655
|
+
fetchVars.push(varName);
|
|
656
|
+
routineParamParts.push(`p_${this.toSnakeCase(pk.CodeName)} := ${varName}`);
|
|
657
|
+
}
|
|
658
|
+
return {
|
|
659
|
+
varDeclarations: varDecls.join(', '),
|
|
660
|
+
selectFields: selectFlds.join(', '),
|
|
661
|
+
fetchInto: fetchVars.join(', '),
|
|
662
|
+
routineParams: routineParamParts.join(', '),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
// ─── METADATA MANAGEMENT: STORED PROCEDURE CALLS ─────────────────
|
|
666
|
+
/** @inheritdoc */
|
|
667
|
+
callRoutineSQL(schema, routineName, params, _paramNames) {
|
|
668
|
+
const qualifiedName = pgDialect.QuoteSchema(schema, routineName);
|
|
669
|
+
const paramList = params.join(', ');
|
|
670
|
+
return `SELECT * FROM ${qualifiedName}(${paramList})`;
|
|
671
|
+
}
|
|
672
|
+
// ─── METADATA MANAGEMENT: CONDITIONAL INSERT ─────────────────────
|
|
673
|
+
/** @inheritdoc */
|
|
674
|
+
conditionalInsertSQL(checkQuery, insertSQL) {
|
|
675
|
+
return `DO $$ BEGIN\n IF NOT EXISTS (${checkQuery}) THEN\n ${insertSQL};\n END IF;\nEND $$`;
|
|
676
|
+
}
|
|
677
|
+
/** @inheritdoc */
|
|
678
|
+
wrapInsertWithConflictGuard(_conflictCheckSQL) {
|
|
679
|
+
return { prefix: '', suffix: 'ON CONFLICT DO NOTHING' };
|
|
680
|
+
}
|
|
681
|
+
// ─── METADATA MANAGEMENT: DDL OPERATIONS ─────────────────────────
|
|
682
|
+
/** @inheritdoc */
|
|
683
|
+
addColumnSQL(schema, tableName, columnName, dataType, nullable, defaultExpression) {
|
|
684
|
+
const table = pgDialect.QuoteSchema(schema, tableName);
|
|
685
|
+
const col = pgDialect.QuoteIdentifier(columnName);
|
|
686
|
+
const nullClause = nullable ? 'NULL' : 'NOT NULL';
|
|
687
|
+
const defaultClause = defaultExpression ? ` DEFAULT ${defaultExpression}` : '';
|
|
688
|
+
return `ALTER TABLE ${table} ADD COLUMN ${col} ${dataType} ${nullClause}${defaultClause}`;
|
|
689
|
+
}
|
|
690
|
+
/** @inheritdoc */
|
|
691
|
+
alterColumnTypeAndNullabilitySQL(schema, tableName, columnName, dataType, nullable) {
|
|
692
|
+
const table = pgDialect.QuoteSchema(schema, tableName);
|
|
693
|
+
const col = pgDialect.QuoteIdentifier(columnName);
|
|
694
|
+
const nullAction = nullable ? 'DROP NOT NULL' : 'SET NOT NULL';
|
|
695
|
+
return `ALTER TABLE ${table} ALTER COLUMN ${col} TYPE ${dataType}, ALTER COLUMN ${col} ${nullAction}`;
|
|
696
|
+
}
|
|
697
|
+
/** @inheritdoc */
|
|
698
|
+
addDefaultConstraintSQL(schema, tableName, columnName, defaultExpression) {
|
|
699
|
+
const table = pgDialect.QuoteSchema(schema, tableName);
|
|
700
|
+
const col = pgDialect.QuoteIdentifier(columnName);
|
|
701
|
+
return `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${defaultExpression}`;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Generates a PL/pgSQL `DO $$` block that drops both a named CHECK constraint (if one
|
|
705
|
+
* exists on the column, found via `pg_catalog.pg_constraint`) and the column's default
|
|
706
|
+
* value. Uses dynamic SQL (`EXECUTE format(...)`) to drop the constraint by name,
|
|
707
|
+
* then unconditionally runs `ALTER COLUMN ... DROP DEFAULT`.
|
|
708
|
+
*/
|
|
709
|
+
dropDefaultConstraintSQL(schema, tableName, columnName) {
|
|
710
|
+
const table = pgDialect.QuoteSchema(schema, tableName);
|
|
711
|
+
const col = pgDialect.QuoteIdentifier(columnName);
|
|
712
|
+
return `
|
|
713
|
+
DO $$
|
|
714
|
+
DECLARE
|
|
715
|
+
v_constraint_name TEXT;
|
|
716
|
+
BEGIN
|
|
717
|
+
SELECT con.conname INTO v_constraint_name
|
|
718
|
+
FROM pg_catalog.pg_constraint con
|
|
719
|
+
JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
|
|
720
|
+
JOIN pg_catalog.pg_namespace nsp ON nsp.oid = rel.relnamespace
|
|
721
|
+
JOIN pg_catalog.pg_attribute att ON att.attrelid = rel.oid AND att.attnum = ANY(con.conkey)
|
|
722
|
+
WHERE nsp.nspname = '${schema}'
|
|
723
|
+
AND rel.relname = '${tableName}'
|
|
724
|
+
AND att.attname = '${columnName}'
|
|
725
|
+
AND con.contype = 'c';
|
|
726
|
+
|
|
727
|
+
IF v_constraint_name IS NOT NULL THEN
|
|
728
|
+
EXECUTE format('ALTER TABLE %I.%I DROP CONSTRAINT %I', '${schema}', '${tableName}', v_constraint_name);
|
|
729
|
+
END IF;
|
|
730
|
+
|
|
731
|
+
-- Also drop any column default
|
|
732
|
+
ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;
|
|
733
|
+
END $$`;
|
|
734
|
+
}
|
|
735
|
+
/** @inheritdoc */
|
|
736
|
+
dropObjectSQL(objectType, schema, name) {
|
|
737
|
+
// PostgreSQL uses FUNCTION for both procedures and functions in DROP statements
|
|
738
|
+
const typeStr = objectType === 'PROCEDURE' ? 'FUNCTION' : objectType;
|
|
739
|
+
const qualifiedName = pgDialect.QuoteSchema(schema, name);
|
|
740
|
+
const cascade = (objectType === 'PROCEDURE' || objectType === 'FUNCTION') ? ' CASCADE' : '';
|
|
741
|
+
return `DROP ${typeStr} IF EXISTS ${qualifiedName}${cascade}`;
|
|
742
|
+
}
|
|
743
|
+
// ─── METADATA MANAGEMENT: VIEW INTROSPECTION ─────────────────────
|
|
744
|
+
/** @inheritdoc */
|
|
745
|
+
getViewExistsSQL() {
|
|
746
|
+
return `SELECT 1 FROM information_schema.views WHERE table_name = @ViewName AND table_schema = @SchemaName`;
|
|
747
|
+
}
|
|
748
|
+
/** @inheritdoc */
|
|
749
|
+
getViewColumnsSQL(schema, viewName) {
|
|
750
|
+
return `SELECT
|
|
751
|
+
column_name AS "FieldName",
|
|
752
|
+
data_type AS "Type",
|
|
753
|
+
COALESCE(character_maximum_length, 0) AS "Length",
|
|
754
|
+
COALESCE(numeric_precision, 0) AS "Precision",
|
|
755
|
+
COALESCE(numeric_scale, 0) AS "Scale",
|
|
756
|
+
CASE WHEN is_nullable = 'YES' THEN 1 ELSE 0 END AS "AllowsNull"
|
|
757
|
+
FROM information_schema.columns
|
|
758
|
+
WHERE table_schema = '${schema}'
|
|
759
|
+
AND table_name = '${viewName}'
|
|
760
|
+
ORDER BY ordinal_position`;
|
|
761
|
+
}
|
|
762
|
+
// ─── METADATA MANAGEMENT: TYPE SYSTEM ────────────────────────────
|
|
763
|
+
/** @inheritdoc */
|
|
764
|
+
get TimestampType() {
|
|
765
|
+
return 'TIMESTAMPTZ';
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Compares two PostgreSQL data type strings for equivalence, accounting for common
|
|
769
|
+
* aliases. For example, `'timestamptz'` and `'timestamp with time zone'` are
|
|
770
|
+
* considered equal. Returns `true` if the types match directly or via alias lookup.
|
|
771
|
+
*/
|
|
772
|
+
compareDataTypes(reported, expected) {
|
|
773
|
+
if (reported === expected)
|
|
774
|
+
return true;
|
|
775
|
+
const aliases = {
|
|
776
|
+
'timestamptz': 'timestamp with time zone',
|
|
777
|
+
'timestamp with time zone': 'timestamptz',
|
|
778
|
+
};
|
|
779
|
+
return aliases[reported] === expected;
|
|
780
|
+
}
|
|
781
|
+
// ─── METADATA MANAGEMENT: PLATFORM CONFIGURATION ─────────────────
|
|
782
|
+
/** @inheritdoc */
|
|
783
|
+
getSystemSchemasToExclude() {
|
|
784
|
+
return ['information_schema', 'pg_catalog', 'pg_toast', 'pg_temp_1', 'pg_toast_temp_1'];
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* PostgreSQL does not require view refresh after creation. Unlike SQL Server's
|
|
788
|
+
* `sp_refreshview`, PostgreSQL views automatically reflect column changes, so
|
|
789
|
+
* this always returns `false`.
|
|
790
|
+
*/
|
|
791
|
+
get NeedsViewRefresh() {
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
/** @inheritdoc */
|
|
795
|
+
generateViewRefreshSQL(_schema, _viewName) {
|
|
796
|
+
return '';
|
|
797
|
+
}
|
|
798
|
+
/** @inheritdoc */
|
|
799
|
+
generateViewTestQuerySQL(schema, viewName) {
|
|
800
|
+
return `SELECT * FROM ${pgDialect.QuoteSchema(schema, viewName)} LIMIT 1`;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* PostgreSQL requires a nullability fix for virtual (computed) fields in views.
|
|
804
|
+
* View columns derived from expressions may report incorrect nullability in
|
|
805
|
+
* `information_schema.columns`, so CodeGen must correct these after view creation.
|
|
806
|
+
*/
|
|
807
|
+
get NeedsVirtualFieldNullabilityFix() {
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
// ─── METADATA MANAGEMENT: SQL QUOTING ────────────────────────────
|
|
811
|
+
/**
|
|
812
|
+
* SQL keywords that should NOT be quoted even when they match PascalCase patterns.
|
|
813
|
+
*/
|
|
814
|
+
static { this._SQL_KEYWORDS = new Set([
|
|
815
|
+
// DML/DDL keywords
|
|
816
|
+
'SELECT', 'INSERT', 'INTO', 'UPDATE', 'DELETE', 'FROM', 'WHERE', 'AND', 'OR', 'NOT',
|
|
817
|
+
'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'CROSS', 'FULL', 'ON', 'AS', 'SET',
|
|
818
|
+
'VALUES', 'NULL', 'LIKE', 'IN', 'EXISTS', 'BETWEEN', 'CASE', 'WHEN', 'THEN',
|
|
819
|
+
'ELSE', 'END', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET', 'UNION',
|
|
820
|
+
'ALL', 'CREATE', 'ALTER', 'DROP', 'TABLE', 'INDEX', 'VIEW', 'EXEC', 'DECLARE',
|
|
821
|
+
'BEGIN', 'COMMIT', 'ROLLBACK', 'TRANSACTION', 'TRUE', 'FALSE', 'IS', 'ASC', 'DESC',
|
|
822
|
+
'DISTINCT', 'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT',
|
|
823
|
+
'IF', 'OBJECT', 'TOP', 'WITH', 'OVER', 'PARTITION', 'ROW_NUMBER', 'RANK',
|
|
824
|
+
'DENSE_RANK', 'LAG', 'LEAD', 'FIRST_VALUE', 'LAST_VALUE', 'ROWS', 'RANGE',
|
|
825
|
+
'PRECEDING', 'FOLLOWING', 'UNBOUNDED', 'CURRENT', 'ROW', 'FETCH', 'NEXT', 'ONLY',
|
|
826
|
+
'SCHEMA', 'CASCADE', 'RESTRICT', 'NO', 'ACTION', 'TRIGGER', 'FUNCTION', 'PROCEDURE',
|
|
827
|
+
'RETURNS', 'RETURN', 'EXECUTE', 'CALL', 'RAISE', 'NOTICE', 'EXCEPTION', 'PERFORM',
|
|
828
|
+
'GRANT', 'REVOKE', 'TO', 'USAGE', 'PRIVILEGES', 'OWNER',
|
|
829
|
+
// DDL sub-keywords
|
|
830
|
+
'ADD', 'COLUMN', 'DO', 'RENAME', 'COMMENT', 'UNIQUE', 'CHECK',
|
|
831
|
+
'CONFLICT', 'NOTHING', 'EXCLUDED', 'ZONE', 'AT', 'FOR', 'EACH', 'OF',
|
|
832
|
+
'BEFORE', 'AFTER', 'INSTEAD', 'USING', 'ANY', 'SOME',
|
|
833
|
+
'ENABLE', 'DISABLE', 'GENERATED', 'ALWAYS', 'IDENTITY',
|
|
834
|
+
'SECURITY', 'DEFINER', 'INVOKER', 'FORCE', 'COPY',
|
|
835
|
+
'TEMPORARY', 'TEMP', 'RECURSIVE', 'MATERIALIZED', 'CONCURRENTLY',
|
|
836
|
+
// PL/pgSQL control flow
|
|
837
|
+
'NEW', 'OLD', 'FOUND', 'LOOP', 'WHILE', 'EXIT', 'CONTINUE',
|
|
838
|
+
'ELSIF', 'ELSEIF', 'STRICT',
|
|
839
|
+
// SQL Server types
|
|
840
|
+
'NVARCHAR', 'VARCHAR', 'UNIQUEIDENTIFIER', 'DATETIMEOFFSET', 'DATETIME', 'DATETIME2',
|
|
841
|
+
'BIGINT', 'SMALLINT', 'TINYINT', 'FLOAT', 'REAL', 'DECIMAL', 'NUMERIC', 'MONEY',
|
|
842
|
+
'BIT', 'INT', 'TEXT', 'NTEXT', 'IMAGE', 'BINARY', 'VARBINARY', 'CHAR', 'NCHAR',
|
|
843
|
+
'XML', 'GEOGRAPHY', 'GEOMETRY', 'HIERARCHYID', 'SQL_VARIANT', 'SYSNAME',
|
|
844
|
+
'NEWSEQUENTIALID', 'NEWID', 'GETUTCDATE', 'GETDATE', 'SYSDATETIMEOFFSET',
|
|
845
|
+
'OBJECT_ID', 'SCOPE_IDENTITY',
|
|
846
|
+
// Aggregate / scalar functions
|
|
847
|
+
'COUNT', 'MAX', 'MIN', 'SUM', 'AVG', 'COALESCE', 'CAST', 'CONVERT', 'ISNULL',
|
|
848
|
+
'LEN', 'DATALENGTH', 'LOWER', 'UPPER', 'LTRIM', 'RTRIM', 'TRIM', 'REPLACE',
|
|
849
|
+
'SUBSTRING', 'CHARINDEX', 'PATINDEX', 'STUFF', 'CONCAT', 'FORMAT',
|
|
850
|
+
'DATEADD', 'DATEDIFF', 'DATEPART', 'YEAR', 'MONTH', 'DAY', 'HOUR', 'MINUTE',
|
|
851
|
+
'SECOND', 'NOW', 'CURRENT_TIMESTAMP',
|
|
852
|
+
// PostgreSQL specific
|
|
853
|
+
'BOOLEAN', 'SERIAL', 'BIGSERIAL', 'UUID', 'JSONB', 'JSON', 'ARRAY', 'TIMESTAMPTZ',
|
|
854
|
+
'TIMESTAMP', 'DATE', 'TIME', 'INTERVAL', 'CITEXT', 'INET', 'MACADDR',
|
|
855
|
+
'GEN_RANDOM_UUID', 'TO_CHAR', 'TO_DATE', 'TO_TIMESTAMP', 'TO_NUMBER',
|
|
856
|
+
'STRING_AGG', 'ARRAY_AGG', 'UNNEST', 'LATERAL', 'ILIKE',
|
|
857
|
+
'LANGUAGE', 'PLPGSQL', 'VOLATILE', 'STABLE', 'IMMUTABLE', 'SETOF', 'RECORD',
|
|
858
|
+
'INOUT', 'OUT', 'VARIADIC', 'PARALLEL', 'SAFE', 'UNSAFE',
|
|
859
|
+
// information_schema column names
|
|
860
|
+
'TABLE_SCHEMA', 'TABLE_NAME', 'TABLE_CATALOG', 'COLUMN_NAME', 'DATA_TYPE',
|
|
861
|
+
'IS_NULLABLE', 'COLUMN_DEFAULT', 'CHARACTER_MAXIMUM_LENGTH', 'NUMERIC_PRECISION',
|
|
862
|
+
'NUMERIC_SCALE', 'ORDINAL_POSITION', 'COLUMN_COMMENT',
|
|
863
|
+
// MJ SQL constructs
|
|
864
|
+
'INFORMATION_SCHEMA', 'COLUMNS', 'TABLES', 'ROUTINES',
|
|
865
|
+
]); }
|
|
866
|
+
/**
|
|
867
|
+
* Quotes mixed-case identifiers in a SQL string for PostgreSQL compatibility.
|
|
868
|
+
* Uses a tokenizer approach to skip string literals, already-quoted identifiers,
|
|
869
|
+
* dollar-quoted blocks, and SQL keywords. Any remaining PascalCase word gets
|
|
870
|
+
* double-quoted to preserve case.
|
|
871
|
+
*/
|
|
872
|
+
quoteSQLForExecution(sql) {
|
|
873
|
+
const result = [];
|
|
874
|
+
let i = 0;
|
|
875
|
+
const len = sql.length;
|
|
876
|
+
while (i < len) {
|
|
877
|
+
const ch = sql[i];
|
|
878
|
+
if (ch === "'") {
|
|
879
|
+
i = this.skipSingleQuotedString(sql, i, len, result);
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
if (ch === '$') {
|
|
883
|
+
i = this.skipDollarQuotedBlock(sql, i, len, result);
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (ch === '"') {
|
|
887
|
+
i = this.skipDoubleQuotedIdentifier(sql, i, len, result);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (ch === '[') {
|
|
891
|
+
i = this.skipBracketedIdentifier(sql, i, len, result);
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (ch === '@') {
|
|
895
|
+
i = this.skipAtParameter(sql, i, len, result);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (/[a-zA-Z_]/.test(ch)) {
|
|
899
|
+
i = this.processWord(sql, i, len, result);
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
result.push(ch);
|
|
903
|
+
i++;
|
|
904
|
+
}
|
|
905
|
+
return result.join('');
|
|
906
|
+
}
|
|
907
|
+
// ─── METADATA MANAGEMENT: DEFAULT VALUE PARSING ──────────────────
|
|
908
|
+
/**
|
|
909
|
+
* Parses a PostgreSQL column default value by stripping PG-specific type cast syntax
|
|
910
|
+
* (e.g., `'2024-01-01'::timestamp` becomes `'2024-01-01'`). Returns `null` for
|
|
911
|
+
* auto-increment sequences (`nextval(...)`) and for null/undefined input, indicating
|
|
912
|
+
* no meaningful default.
|
|
913
|
+
*/
|
|
914
|
+
parseColumnDefaultValue(sqlDefaultValue) {
|
|
915
|
+
if (sqlDefaultValue === null || sqlDefaultValue === undefined) {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
let sResult = sqlDefaultValue;
|
|
919
|
+
// Strip type casts like '2024-01-01'::timestamp, 'value'::character varying
|
|
920
|
+
const castMatch = sResult.match(/^'(.*)'::.*$/);
|
|
921
|
+
if (castMatch) {
|
|
922
|
+
sResult = castMatch[1];
|
|
923
|
+
}
|
|
924
|
+
// Strip nextval('...') for auto-increment sequences - treated as no default
|
|
925
|
+
if (sResult.match(/^nextval\(/i)) {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
return sResult;
|
|
929
|
+
}
|
|
930
|
+
// ─── METADATA MANAGEMENT: COMPLEX SQL GENERATION ─────────────────
|
|
931
|
+
/** @inheritdoc */
|
|
932
|
+
getPendingEntityFieldsSQL(mjCoreSchema) {
|
|
933
|
+
const qs = pgDialect.QuoteSchema.bind(pgDialect);
|
|
934
|
+
return this.buildPendingEntityFieldsQuery(mjCoreSchema, qs);
|
|
935
|
+
}
|
|
936
|
+
/** @inheritdoc */
|
|
937
|
+
getCheckConstraintsSchemaFilter(_excludeSchemas) {
|
|
938
|
+
// PostgreSQL view already handles schema filtering
|
|
939
|
+
return '';
|
|
940
|
+
}
|
|
941
|
+
/** @inheritdoc */
|
|
942
|
+
getEntitiesWithMissingBaseTablesFilter() {
|
|
943
|
+
// PostgreSQL query doesn't need this filter
|
|
944
|
+
return '';
|
|
945
|
+
}
|
|
946
|
+
/** @inheritdoc */
|
|
947
|
+
getFixVirtualFieldNullabilitySQL(mjCoreSchema) {
|
|
948
|
+
const qs = pgDialect.QuoteSchema.bind(pgDialect);
|
|
949
|
+
return this.buildFixVirtualFieldNullabilityUpdateSQL(mjCoreSchema, qs);
|
|
950
|
+
}
|
|
951
|
+
// ─── METADATA MANAGEMENT: SQL FILE EXECUTION ─────────────────────
|
|
952
|
+
/**
|
|
953
|
+
* Executes a SQL file against the PostgreSQL database using the `psql` CLI tool.
|
|
954
|
+
* Reads connection parameters from environment variables (`PG_HOST`, `PG_PORT`,
|
|
955
|
+
* `PG_DATABASE`, `PG_USERNAME`, `PG_PASSWORD`) with fallback to `configInfo` values.
|
|
956
|
+
* Resolves the file path to an absolute path before passing it to psql.
|
|
957
|
+
*/
|
|
958
|
+
async executeSQLFileViaShell(filePath) {
|
|
959
|
+
const pgHost = process.env.PG_HOST ?? configInfo.dbHost;
|
|
960
|
+
const pgPort = process.env.PG_PORT ?? String(configInfo.dbPort ?? 5432);
|
|
961
|
+
const pgDatabase = process.env.PG_DATABASE ?? configInfo.dbDatabase;
|
|
962
|
+
const pgUser = process.env.PG_USERNAME ?? configInfo.codeGenLogin;
|
|
963
|
+
const pgPassword = process.env.PG_PASSWORD ?? configInfo.codeGenPassword;
|
|
964
|
+
if (!pgUser || !pgPassword || !pgDatabase) {
|
|
965
|
+
throw new Error('PostgreSQL user, password, and database must be provided in the configuration or environment variables');
|
|
966
|
+
}
|
|
967
|
+
const absoluteFilePath = path.resolve(process.cwd(), filePath);
|
|
968
|
+
return this.executePsqlCommand(absoluteFilePath, pgHost, pgPort, pgUser, pgDatabase, pgPassword);
|
|
969
|
+
}
|
|
970
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
971
|
+
// PRIVATE HELPERS
|
|
972
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
973
|
+
/**
|
|
974
|
+
* Converts a PascalCase or camelCase string to snake_case.
|
|
975
|
+
* Handles consecutive uppercase letters (e.g., "ID" → "id", "HTMLParser" → "html_parser").
|
|
976
|
+
*/
|
|
977
|
+
toSnakeCase(name) {
|
|
978
|
+
return name
|
|
979
|
+
// Insert underscore between a lowercase letter and an uppercase letter
|
|
980
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
981
|
+
// Insert underscore between consecutive uppercase letters followed by a lowercase letter
|
|
982
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
983
|
+
.toLowerCase()
|
|
984
|
+
.replace(/__+/g, '_');
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Maps a SQL Server type string to its PostgreSQL equivalent.
|
|
988
|
+
*/
|
|
989
|
+
mapSQLType(sqlType) {
|
|
990
|
+
const lower = sqlType.toLowerCase().trim();
|
|
991
|
+
if (lower.startsWith('uniqueidentifier'))
|
|
992
|
+
return 'UUID';
|
|
993
|
+
if (lower.startsWith('nvarchar(max)') || lower.startsWith('varchar(max)'))
|
|
994
|
+
return 'TEXT';
|
|
995
|
+
if (lower.startsWith('nvarchar') || lower.startsWith('nchar'))
|
|
996
|
+
return sqlType.replace(/^n/i, '');
|
|
997
|
+
if (lower === 'bit')
|
|
998
|
+
return 'BOOLEAN';
|
|
999
|
+
if (lower === 'datetime' || lower === 'datetime2')
|
|
1000
|
+
return 'TIMESTAMP';
|
|
1001
|
+
if (lower === 'datetimeoffset')
|
|
1002
|
+
return 'TIMESTAMPTZ';
|
|
1003
|
+
if (lower === 'money' || lower === 'smallmoney')
|
|
1004
|
+
return 'NUMERIC(19,4)';
|
|
1005
|
+
if (lower === 'tinyint')
|
|
1006
|
+
return 'SMALLINT';
|
|
1007
|
+
if (lower.startsWith('image') || lower.startsWith('varbinary'))
|
|
1008
|
+
return 'BYTEA';
|
|
1009
|
+
if (lower === 'xml')
|
|
1010
|
+
return 'XML';
|
|
1011
|
+
if (lower === 'sql_variant')
|
|
1012
|
+
return 'TEXT';
|
|
1013
|
+
return sqlType; // Pass through INT, BIGINT, etc.
|
|
1014
|
+
}
|
|
1015
|
+
/** Gets the base view name for an entity */
|
|
1016
|
+
getBaseViewName(entity) {
|
|
1017
|
+
return entity.BaseView || `vw_${this.toSnakeCase(entity.CodeName)}`;
|
|
1018
|
+
}
|
|
1019
|
+
/** Builds the WHERE clause for soft-delete filtering */
|
|
1020
|
+
buildSoftDeleteWhereClause(entity, alias) {
|
|
1021
|
+
if (entity.DeleteType === 'Soft') {
|
|
1022
|
+
return `WHERE\n ${alias}.${pgDialect.QuoteIdentifier(EntityInfo.DeletedAtFieldName)} IS NULL\n`;
|
|
1023
|
+
}
|
|
1024
|
+
return '';
|
|
1025
|
+
}
|
|
1026
|
+
/** Assembles the SELECT parts for a base view */
|
|
1027
|
+
buildBaseViewSelectParts(context, alias) {
|
|
1028
|
+
// parentFieldsSelect and rootFieldsSelect have leading commas (e.g. ",\n Field AS Alias").
|
|
1029
|
+
// relatedFieldsSelect does NOT have a leading comma for the first field (starts with "\n Field...").
|
|
1030
|
+
let select = `${alias}.*`;
|
|
1031
|
+
if (context.parentFieldsSelect)
|
|
1032
|
+
select += context.parentFieldsSelect;
|
|
1033
|
+
if (context.relatedFieldsSelect)
|
|
1034
|
+
select += `,${context.relatedFieldsSelect}`;
|
|
1035
|
+
if (context.rootFieldsSelect)
|
|
1036
|
+
select += context.rootFieldsSelect;
|
|
1037
|
+
return select;
|
|
1038
|
+
}
|
|
1039
|
+
/** Assembles the FROM/JOIN parts for a base view */
|
|
1040
|
+
buildBaseViewFromParts(context, entity, _alias) {
|
|
1041
|
+
const joins = [];
|
|
1042
|
+
if (context.parentJoins)
|
|
1043
|
+
joins.push(context.parentJoins);
|
|
1044
|
+
if (context.relatedFieldsJoins)
|
|
1045
|
+
joins.push(context.relatedFieldsJoins);
|
|
1046
|
+
if (context.rootJoins)
|
|
1047
|
+
joins.push(context.rootJoins);
|
|
1048
|
+
return joins.length > 0 ? '\n' + joins.join('\n') : '';
|
|
1049
|
+
}
|
|
1050
|
+
/** Determines whether a field should be included in CRUD parameters */
|
|
1051
|
+
shouldIncludeFieldInParams(ef, isUpdate) {
|
|
1052
|
+
const autoGeneratedPrimaryKey = ef.AutoIncrement;
|
|
1053
|
+
return ((ef.AllowUpdateAPI || (ef.IsPrimaryKey && isUpdate) || (ef.IsPrimaryKey && !autoGeneratedPrimaryKey && !isUpdate)) &&
|
|
1054
|
+
!ef.IsVirtual &&
|
|
1055
|
+
(!ef.IsPrimaryKey || !autoGeneratedPrimaryKey || isUpdate) &&
|
|
1056
|
+
!ef.IsSpecialDateField);
|
|
1057
|
+
}
|
|
1058
|
+
/** Determines whether a field should be skipped for INSERT */
|
|
1059
|
+
shouldSkipFieldForInsert(ef, autoGeneratedPrimaryKey, excludePrimaryKey) {
|
|
1060
|
+
return ((excludePrimaryKey && ef.IsPrimaryKey) ||
|
|
1061
|
+
(ef.IsPrimaryKey && autoGeneratedPrimaryKey) ||
|
|
1062
|
+
ef.IsVirtual ||
|
|
1063
|
+
!ef.AllowUpdateAPI ||
|
|
1064
|
+
ef.AutoIncrement);
|
|
1065
|
+
}
|
|
1066
|
+
/** Gets the INSERT value for a special date field */
|
|
1067
|
+
getSpecialDateInsertValue(ef) {
|
|
1068
|
+
if (ef.IsCreatedAtField || ef.IsUpdatedAtField)
|
|
1069
|
+
return "NOW() AT TIME ZONE 'UTC'";
|
|
1070
|
+
return 'NULL'; // DeletedAt
|
|
1071
|
+
}
|
|
1072
|
+
/** Gets the parameter insert value, handling defaults */
|
|
1073
|
+
getParameterInsertValue(ef, prefix) {
|
|
1074
|
+
const paramName = `${prefix}${this.toSnakeCase(ef.CodeName)}`;
|
|
1075
|
+
if (ef.HasDefaultValue && !ef.AllowsNull) {
|
|
1076
|
+
const formattedDefault = this.formatDefaultValue(ef.DefaultValue, ef.NeedsQuotes);
|
|
1077
|
+
if (ef.IsUniqueIdentifier) {
|
|
1078
|
+
return `CASE WHEN ${paramName} = '00000000-0000-0000-0000-000000000000'::UUID THEN ${formattedDefault} ELSE COALESCE(${paramName}, ${formattedDefault}) END`;
|
|
1079
|
+
}
|
|
1080
|
+
return `COALESCE(${paramName}, ${formattedDefault})`;
|
|
1081
|
+
}
|
|
1082
|
+
return paramName;
|
|
1083
|
+
}
|
|
1084
|
+
/** Builds a WHERE clause using primary key fields with a parameter prefix */
|
|
1085
|
+
buildPrimaryKeyWhereClause(entity, prefix) {
|
|
1086
|
+
return entity.PrimaryKeys.map((k) => `${pgDialect.QuoteIdentifier(k.Name)} = ${prefix}${this.toSnakeCase(k.CodeName)}`).join(' AND ');
|
|
1087
|
+
}
|
|
1088
|
+
/** Builds the INSERT strategy for CREATE function based on PK type */
|
|
1089
|
+
buildCreateInsertStrategy(entity, firstKey, insertColumns, insertValues) {
|
|
1090
|
+
const viewName = this.getBaseViewName(entity);
|
|
1091
|
+
const pkCol = pgDialect.QuoteIdentifier(firstKey.Name);
|
|
1092
|
+
if (firstKey.AutoIncrement) {
|
|
1093
|
+
return {
|
|
1094
|
+
preInsert: '',
|
|
1095
|
+
returningClause: `RETURNING ${pkCol} INTO v_new_id`,
|
|
1096
|
+
selectClause: `SELECT * FROM ${pgDialect.QuoteSchema(entity.SchemaName, viewName)}\n WHERE ${pkCol} = v_new_id`,
|
|
1097
|
+
finalColumns: insertColumns,
|
|
1098
|
+
finalValues: insertValues,
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
if ((firstKey.Type.toLowerCase().trim() === 'uniqueidentifier' || firstKey.Type.toLowerCase().trim() === 'uuid') && entity.PrimaryKeys.length === 1) {
|
|
1102
|
+
const paramName = `p_${this.toSnakeCase(firstKey.CodeName)}`;
|
|
1103
|
+
return {
|
|
1104
|
+
preInsert: `v_new_id := COALESCE(${paramName}, gen_random_uuid());\n `,
|
|
1105
|
+
returningClause: '',
|
|
1106
|
+
selectClause: `SELECT * FROM ${pgDialect.QuoteSchema(entity.SchemaName, viewName)}\n WHERE ${pkCol} = v_new_id`,
|
|
1107
|
+
// Include the PK column in the INSERT so caller-provided IDs are respected
|
|
1108
|
+
finalColumns: `${pkCol},\n ${insertColumns}`,
|
|
1109
|
+
finalValues: `v_new_id,\n ${insertValues}`,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
// Composite keys or non-auto, non-UUID PKs
|
|
1113
|
+
const selectWhere = entity.PrimaryKeys.map((k) => `${pgDialect.QuoteIdentifier(k.Name)} = p_${this.toSnakeCase(k.CodeName)}`).join(' AND ');
|
|
1114
|
+
return {
|
|
1115
|
+
preInsert: '',
|
|
1116
|
+
returningClause: '',
|
|
1117
|
+
selectClause: `SELECT * FROM ${pgDialect.QuoteSchema(entity.SchemaName, viewName)}\n WHERE ${selectWhere}`,
|
|
1118
|
+
finalColumns: insertColumns,
|
|
1119
|
+
finalValues: insertValues,
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
/** Builds the DELETE body and return type based on entity delete type */
|
|
1123
|
+
buildDeleteStrategy(entity, cascadeSQL) {
|
|
1124
|
+
const paramParts = [];
|
|
1125
|
+
const selectParts = [];
|
|
1126
|
+
const nullParts = [];
|
|
1127
|
+
for (const k of entity.PrimaryKeys) {
|
|
1128
|
+
const paramName = `p_${this.toSnakeCase(k.CodeName)}`;
|
|
1129
|
+
paramParts.push(`${paramName} ${this.mapSQLType(k.SQLFullType)}`);
|
|
1130
|
+
selectParts.push(`${paramName} AS ${pgDialect.QuoteIdentifier(k.Name)}`);
|
|
1131
|
+
nullParts.push(`NULL::${this.mapSQLType(k.SQLFullType)} AS ${pgDialect.QuoteIdentifier(k.Name)}`);
|
|
1132
|
+
}
|
|
1133
|
+
const whereClause = entity.PrimaryKeys.map((k) => `${pgDialect.QuoteIdentifier(k.Name)} = p_${this.toSnakeCase(k.CodeName)}`).join(' AND ');
|
|
1134
|
+
let deleteBody;
|
|
1135
|
+
if (entity.DeleteType === 'Hard') {
|
|
1136
|
+
deleteBody = ` DELETE FROM ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}\n WHERE ${whereClause};`;
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
deleteBody = ` UPDATE ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
|
|
1140
|
+
SET ${pgDialect.QuoteIdentifier(EntityInfo.DeletedAtFieldName)} = NOW() AT TIME ZONE 'UTC'
|
|
1141
|
+
WHERE ${whereClause}
|
|
1142
|
+
AND ${pgDialect.QuoteIdentifier(EntityInfo.DeletedAtFieldName)} IS NULL;`;
|
|
1143
|
+
}
|
|
1144
|
+
// Return type is a TABLE with PK columns
|
|
1145
|
+
const returnCols = entity.PrimaryKeys.map((k) => `${pgDialect.QuoteIdentifier(k.Name)} ${this.mapSQLType(k.SQLFullType)}`).join(', ');
|
|
1146
|
+
const returnStatement = ` IF v_affected_count = 0 THEN
|
|
1147
|
+
RETURN QUERY SELECT ${nullParts.join(', ')};
|
|
1148
|
+
ELSE
|
|
1149
|
+
RETURN QUERY SELECT ${selectParts.join(', ')};
|
|
1150
|
+
END IF;`;
|
|
1151
|
+
return {
|
|
1152
|
+
paramDecl: paramParts.join(',\n '),
|
|
1153
|
+
deleteBody,
|
|
1154
|
+
returnType: `TABLE(${returnCols})`,
|
|
1155
|
+
returnStatement,
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
/** Generates cascade update-to-NULL SQL for nullable FK */
|
|
1159
|
+
generateCascadeUpdateToNull(parentEntity, relatedEntity, fkField) {
|
|
1160
|
+
if (!relatedEntity.AllowUpdateAPI) {
|
|
1161
|
+
return ` -- WARNING: Cannot cascade update ${relatedEntity.Name}.${fkField.Name} to NULL - entity does not allow updates`;
|
|
1162
|
+
}
|
|
1163
|
+
const updateFnName = this.getCRUDRoutineName(relatedEntity, CRUDType.Update);
|
|
1164
|
+
const whereClause = `${pgDialect.QuoteIdentifier(fkField.Name)} = p_${this.toSnakeCase(parentEntity.FirstPrimaryKey.CodeName)}`;
|
|
1165
|
+
return ` -- Cascade: Set ${relatedEntity.Name}.${fkField.Name} to NULL
|
|
1166
|
+
FOR v_rec IN
|
|
1167
|
+
SELECT ${relatedEntity.PrimaryKeys.map((pk) => pgDialect.QuoteIdentifier(pk.Name)).join(', ')}
|
|
1168
|
+
FROM ${pgDialect.QuoteSchema(relatedEntity.SchemaName, relatedEntity.BaseTable)}
|
|
1169
|
+
WHERE ${whereClause}
|
|
1170
|
+
LOOP
|
|
1171
|
+
-- Update related record to set FK to NULL
|
|
1172
|
+
UPDATE ${pgDialect.QuoteSchema(relatedEntity.SchemaName, relatedEntity.BaseTable)}
|
|
1173
|
+
SET ${pgDialect.QuoteIdentifier(fkField.Name)} = NULL
|
|
1174
|
+
WHERE ${relatedEntity.PrimaryKeys.map((pk) => `${pgDialect.QuoteIdentifier(pk.Name)} = v_rec.${pgDialect.QuoteIdentifier(pk.Name)}`).join(' AND ')};
|
|
1175
|
+
END LOOP;
|
|
1176
|
+
`;
|
|
1177
|
+
}
|
|
1178
|
+
/** Generates cascade cursor-based DELETE SQL for non-nullable FK */
|
|
1179
|
+
generateCascadeCursorDelete(parentEntity, relatedEntity, fkField) {
|
|
1180
|
+
if (!relatedEntity.AllowDeleteAPI) {
|
|
1181
|
+
return ` -- WARNING: Cannot cascade delete ${relatedEntity.Name} records - entity does not allow deletes`;
|
|
1182
|
+
}
|
|
1183
|
+
const deleteFnName = this.getCRUDRoutineName(relatedEntity, CRUDType.Delete);
|
|
1184
|
+
const whereClause = `${pgDialect.QuoteIdentifier(fkField.Name)} = p_${this.toSnakeCase(parentEntity.FirstPrimaryKey.CodeName)}`;
|
|
1185
|
+
return ` -- Cascade: Delete ${relatedEntity.Name} records via ${fkField.Name}
|
|
1186
|
+
FOR v_rec IN
|
|
1187
|
+
SELECT ${relatedEntity.PrimaryKeys.map((pk) => pgDialect.QuoteIdentifier(pk.Name)).join(', ')}
|
|
1188
|
+
FROM ${pgDialect.QuoteSchema(relatedEntity.SchemaName, relatedEntity.BaseTable)}
|
|
1189
|
+
WHERE ${whereClause}
|
|
1190
|
+
LOOP
|
|
1191
|
+
PERFORM ${pgDialect.QuoteSchema(relatedEntity.SchemaName, deleteFnName)}(${relatedEntity.PrimaryKeys.map((pk) => `v_rec.${pgDialect.QuoteIdentifier(pk.Name)}`).join(', ')});
|
|
1192
|
+
END LOOP;
|
|
1193
|
+
`;
|
|
1194
|
+
}
|
|
1195
|
+
/** Collects unique role SQL names from permissions that have a role name */
|
|
1196
|
+
collectPermissionRoles(permissions) {
|
|
1197
|
+
const roles = [];
|
|
1198
|
+
for (const ep of permissions) {
|
|
1199
|
+
if (ep.RoleSQLName && ep.RoleSQLName.length > 0 && !roles.includes(ep.RoleSQLName)) {
|
|
1200
|
+
roles.push(ep.RoleSQLName);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return roles;
|
|
1204
|
+
}
|
|
1205
|
+
// ─── DATABASE INTROSPECTION ──────────────────────────────────────────
|
|
1206
|
+
/** @inheritdoc */
|
|
1207
|
+
getViewDefinitionSQL(schema, viewName) {
|
|
1208
|
+
return `SELECT pg_get_viewdef('"${schema}"."${viewName}"'::regclass, true) AS "ViewDefinition"`;
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Generates a query against `pg_index`, `pg_class`, and `pg_namespace` to retrieve
|
|
1212
|
+
* the index name for a table's primary key constraint. Used by CodeGen to reference
|
|
1213
|
+
* the PK index in full-text search and other operations.
|
|
1214
|
+
*/
|
|
1215
|
+
getPrimaryKeyIndexNameSQL(schema, tableName) {
|
|
1216
|
+
return `SELECT
|
|
1217
|
+
i.relname AS "IndexName"
|
|
1218
|
+
FROM
|
|
1219
|
+
pg_index ix
|
|
1220
|
+
INNER JOIN
|
|
1221
|
+
pg_class t ON t.oid = ix.indrelid
|
|
1222
|
+
INNER JOIN
|
|
1223
|
+
pg_class i ON i.oid = ix.indexrelid
|
|
1224
|
+
INNER JOIN
|
|
1225
|
+
pg_namespace n ON n.oid = t.relnamespace
|
|
1226
|
+
WHERE
|
|
1227
|
+
ix.indisprimary = true
|
|
1228
|
+
AND t.relname = '${tableName}'
|
|
1229
|
+
AND n.nspname = '${schema}'`;
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Generates a query against `pg_index`, `pg_class`, `pg_namespace`, and `pg_attribute`
|
|
1233
|
+
* to check whether a column participates in a multi-column unique constraint. Returns
|
|
1234
|
+
* rows only when the unique index contains more than one column and includes the
|
|
1235
|
+
* specified column.
|
|
1236
|
+
*/
|
|
1237
|
+
getCompositeUniqueConstraintCheckSQL(schema, tableName, columnName) {
|
|
1238
|
+
return `SELECT ix.indexrelid AS index_id
|
|
1239
|
+
FROM pg_index ix
|
|
1240
|
+
INNER JOIN pg_class t ON t.oid = ix.indrelid
|
|
1241
|
+
INNER JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
1242
|
+
INNER JOIN pg_class i ON i.oid = ix.indexrelid
|
|
1243
|
+
WHERE ix.indisunique = true
|
|
1244
|
+
AND ix.indisprimary = false
|
|
1245
|
+
AND n.nspname = '${schema}'
|
|
1246
|
+
AND t.relname = '${tableName}'
|
|
1247
|
+
AND EXISTS (
|
|
1248
|
+
SELECT 1
|
|
1249
|
+
FROM pg_attribute a
|
|
1250
|
+
WHERE a.attrelid = t.oid
|
|
1251
|
+
AND a.attnum = ANY(ix.indkey)
|
|
1252
|
+
AND a.attname = '${columnName}'
|
|
1253
|
+
)
|
|
1254
|
+
AND array_length(ix.indkey, 1) > 1`;
|
|
1255
|
+
}
|
|
1256
|
+
/** @inheritdoc */
|
|
1257
|
+
getForeignKeyIndexExistsSQL(schema, tableName, indexName) {
|
|
1258
|
+
return `SELECT 1
|
|
1259
|
+
FROM pg_indexes
|
|
1260
|
+
WHERE schemaname = '${schema}'
|
|
1261
|
+
AND tablename = '${tableName}'
|
|
1262
|
+
AND indexname = '${indexName}'`;
|
|
1263
|
+
}
|
|
1264
|
+
// ─── TOKENIZER HELPERS (for quoteSQLForExecution) ────────────────
|
|
1265
|
+
/** Skips a single-quoted string literal, handling escaped quotes ('') */
|
|
1266
|
+
skipSingleQuotedString(sql, start, len, result) {
|
|
1267
|
+
let j = start + 1;
|
|
1268
|
+
while (j < len) {
|
|
1269
|
+
if (sql[j] === "'" && j + 1 < len && sql[j + 1] === "'") {
|
|
1270
|
+
j += 2;
|
|
1271
|
+
}
|
|
1272
|
+
else if (sql[j] === "'") {
|
|
1273
|
+
j++;
|
|
1274
|
+
break;
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
j++;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
result.push(sql.substring(start, j));
|
|
1281
|
+
return j;
|
|
1282
|
+
}
|
|
1283
|
+
/** Skips a dollar-quoted block ($$ ... $$ or $tag$ ... $tag$) */
|
|
1284
|
+
skipDollarQuotedBlock(sql, start, len, result) {
|
|
1285
|
+
let tagEnd = start + 1;
|
|
1286
|
+
if (tagEnd < len && sql[tagEnd] === '$') {
|
|
1287
|
+
// Simple $$ tag
|
|
1288
|
+
tagEnd = start + 2;
|
|
1289
|
+
}
|
|
1290
|
+
else {
|
|
1291
|
+
// Look for $identifier$ pattern
|
|
1292
|
+
while (tagEnd < len && /[a-zA-Z0-9_]/.test(sql[tagEnd]))
|
|
1293
|
+
tagEnd++;
|
|
1294
|
+
if (tagEnd < len && sql[tagEnd] === '$') {
|
|
1295
|
+
tagEnd++;
|
|
1296
|
+
}
|
|
1297
|
+
else {
|
|
1298
|
+
// Not a dollar-quote, just a $ character
|
|
1299
|
+
result.push(sql[start]);
|
|
1300
|
+
return start + 1;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
const tag = sql.substring(start, tagEnd);
|
|
1304
|
+
const closePos = sql.indexOf(tag, tagEnd);
|
|
1305
|
+
if (closePos !== -1) {
|
|
1306
|
+
const blockEnd = closePos + tag.length;
|
|
1307
|
+
result.push(sql.substring(start, blockEnd));
|
|
1308
|
+
return blockEnd;
|
|
1309
|
+
}
|
|
1310
|
+
// No closing tag found, pass through rest of string
|
|
1311
|
+
result.push(sql.substring(start));
|
|
1312
|
+
return len;
|
|
1313
|
+
}
|
|
1314
|
+
/** Skips an already double-quoted identifier */
|
|
1315
|
+
skipDoubleQuotedIdentifier(sql, start, len, result) {
|
|
1316
|
+
let j = start + 1;
|
|
1317
|
+
while (j < len && sql[j] !== '"')
|
|
1318
|
+
j++;
|
|
1319
|
+
if (j < len)
|
|
1320
|
+
j++;
|
|
1321
|
+
result.push(sql.substring(start, j));
|
|
1322
|
+
return j;
|
|
1323
|
+
}
|
|
1324
|
+
/** Skips a square-bracketed identifier (SQL Server style) */
|
|
1325
|
+
skipBracketedIdentifier(sql, start, len, result) {
|
|
1326
|
+
let j = start + 1;
|
|
1327
|
+
while (j < len && sql[j] !== ']')
|
|
1328
|
+
j++;
|
|
1329
|
+
if (j < len)
|
|
1330
|
+
j++;
|
|
1331
|
+
result.push(sql.substring(start, j));
|
|
1332
|
+
return j;
|
|
1333
|
+
}
|
|
1334
|
+
/** Skips an @-prefixed parameter */
|
|
1335
|
+
skipAtParameter(sql, start, len, result) {
|
|
1336
|
+
let j = start + 1;
|
|
1337
|
+
while (j < len && /[a-zA-Z0-9_]/.test(sql[j]))
|
|
1338
|
+
j++;
|
|
1339
|
+
result.push(sql.substring(start, j));
|
|
1340
|
+
return j;
|
|
1341
|
+
}
|
|
1342
|
+
/** Processes a word token - quotes it if it's a PascalCase identifier, not a keyword */
|
|
1343
|
+
processWord(sql, start, len, result) {
|
|
1344
|
+
let j = start + 1;
|
|
1345
|
+
while (j < len && /[a-zA-Z0-9_]/.test(sql[j]))
|
|
1346
|
+
j++;
|
|
1347
|
+
const word = sql.substring(start, j);
|
|
1348
|
+
const isKeyword = PostgreSQLCodeGenProvider_1._SQL_KEYWORDS.has(word.toUpperCase());
|
|
1349
|
+
const startsUpper = /^[A-Z]/.test(word);
|
|
1350
|
+
const isAllLower = word === word.toLowerCase();
|
|
1351
|
+
const isMJInternal = word.startsWith('__mj_');
|
|
1352
|
+
if (!isKeyword && !isAllLower && !isMJInternal && startsUpper) {
|
|
1353
|
+
result.push(pgDialect.QuoteIdentifier(word));
|
|
1354
|
+
}
|
|
1355
|
+
else {
|
|
1356
|
+
result.push(word);
|
|
1357
|
+
}
|
|
1358
|
+
return j;
|
|
1359
|
+
}
|
|
1360
|
+
// ─── COMPLEX SQL GENERATION HELPERS ──────────────────────────────
|
|
1361
|
+
/**
|
|
1362
|
+
* Builds the full pending entity fields query for PostgreSQL.
|
|
1363
|
+
* Uses CTEs for FK, PK, and UK caches, then joins against entity metadata
|
|
1364
|
+
* to find fields that exist in the database but not in MJ metadata.
|
|
1365
|
+
*/
|
|
1366
|
+
buildPendingEntityFieldsQuery(schema, qs) {
|
|
1367
|
+
return `
|
|
1368
|
+
WITH fk_cache AS (
|
|
1369
|
+
SELECT "column", "table", "schema_name", "referenced_table", "referenced_column", "referenced_schema"
|
|
1370
|
+
FROM ${qs(schema, 'vwForeignKeys')}
|
|
1371
|
+
),
|
|
1372
|
+
pk_cache AS (
|
|
1373
|
+
SELECT "TableName", "ColumnName", "SchemaName"
|
|
1374
|
+
FROM ${qs(schema, 'vwTablePrimaryKeys')}
|
|
1375
|
+
),
|
|
1376
|
+
uk_cache AS (
|
|
1377
|
+
SELECT "TableName", "ColumnName", "SchemaName"
|
|
1378
|
+
FROM ${qs(schema, 'vwTableUniqueKeys')}
|
|
1379
|
+
),
|
|
1380
|
+
max_sequences AS (
|
|
1381
|
+
SELECT
|
|
1382
|
+
"EntityID",
|
|
1383
|
+
COALESCE(MAX("Sequence"), 0) AS "MaxSequence"
|
|
1384
|
+
FROM
|
|
1385
|
+
${qs(schema, 'EntityField')}
|
|
1386
|
+
GROUP BY
|
|
1387
|
+
"EntityID"
|
|
1388
|
+
),
|
|
1389
|
+
numbered_rows AS (
|
|
1390
|
+
SELECT
|
|
1391
|
+
sf."EntityID",
|
|
1392
|
+
COALESCE(ms."MaxSequence", 0) + 100000 + sf."Sequence" AS "Sequence",
|
|
1393
|
+
sf."FieldName",
|
|
1394
|
+
sf."Description",
|
|
1395
|
+
sf."Type",
|
|
1396
|
+
sf."Length",
|
|
1397
|
+
sf."Precision",
|
|
1398
|
+
sf."Scale",
|
|
1399
|
+
sf."AllowsNull",
|
|
1400
|
+
sf."DefaultValue",
|
|
1401
|
+
sf."AutoIncrement",
|
|
1402
|
+
${this.buildAllowUpdateAPICase()},
|
|
1403
|
+
sf."IsVirtual",
|
|
1404
|
+
e."RelationshipDefaultDisplayType",
|
|
1405
|
+
e."Name" AS "EntityName",
|
|
1406
|
+
re."ID" AS "RelatedEntityID",
|
|
1407
|
+
fk."referenced_column" AS "RelatedEntityFieldName",
|
|
1408
|
+
CASE WHEN sf."FieldName" = 'Name' THEN 1 ELSE 0 END AS "IsNameField",
|
|
1409
|
+
CASE WHEN pk."ColumnName" IS NOT NULL THEN 1 ELSE 0 END AS "IsPrimaryKey",
|
|
1410
|
+
CASE
|
|
1411
|
+
WHEN pk."ColumnName" IS NOT NULL THEN 1
|
|
1412
|
+
WHEN uk."ColumnName" IS NOT NULL THEN 1
|
|
1413
|
+
ELSE 0
|
|
1414
|
+
END AS "IsUnique",
|
|
1415
|
+
ROW_NUMBER() OVER (PARTITION BY sf."EntityID", sf."FieldName" ORDER BY (SELECT NULL)) AS rn
|
|
1416
|
+
FROM
|
|
1417
|
+
${qs(schema, 'vwSQLColumnsAndEntityFields')} sf
|
|
1418
|
+
LEFT OUTER JOIN
|
|
1419
|
+
max_sequences ms ON sf."EntityID" = ms."EntityID"
|
|
1420
|
+
LEFT OUTER JOIN
|
|
1421
|
+
${qs(schema, 'Entity')} e ON sf."EntityID" = e."ID"
|
|
1422
|
+
LEFT OUTER JOIN
|
|
1423
|
+
fk_cache fk ON sf."FieldName" = fk."column" AND e."BaseTable" = fk."table" AND e."SchemaName" = fk."schema_name"
|
|
1424
|
+
LEFT OUTER JOIN
|
|
1425
|
+
${qs(schema, 'Entity')} re ON re."BaseTable" = fk."referenced_table" AND re."SchemaName" = fk."referenced_schema"
|
|
1426
|
+
LEFT OUTER JOIN
|
|
1427
|
+
pk_cache pk ON e."BaseTable" = pk."TableName" AND sf."FieldName" = pk."ColumnName" AND e."SchemaName" = pk."SchemaName"
|
|
1428
|
+
LEFT OUTER JOIN
|
|
1429
|
+
uk_cache uk ON e."BaseTable" = uk."TableName" AND sf."FieldName" = uk."ColumnName" AND e."SchemaName" = uk."SchemaName"
|
|
1430
|
+
WHERE
|
|
1431
|
+
"EntityFieldID" IS NULL
|
|
1432
|
+
)
|
|
1433
|
+
SELECT *
|
|
1434
|
+
FROM numbered_rows
|
|
1435
|
+
WHERE rn = 1
|
|
1436
|
+
ORDER BY "EntityID", "Sequence";
|
|
1437
|
+
`;
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Builds the CASE expression for AllowUpdateAPI in the pending entity fields query.
|
|
1441
|
+
*/
|
|
1442
|
+
buildAllowUpdateAPICase() {
|
|
1443
|
+
return `CASE WHEN sf."IsVirtual" = true THEN 0
|
|
1444
|
+
WHEN sf."FieldName" = '${EntityInfo.CreatedAtFieldName}' THEN 0
|
|
1445
|
+
WHEN sf."FieldName" = '${EntityInfo.UpdatedAtFieldName}' THEN 0
|
|
1446
|
+
WHEN sf."FieldName" = '${EntityInfo.DeletedAtFieldName}' THEN 0
|
|
1447
|
+
WHEN pk."ColumnName" IS NOT NULL THEN 0
|
|
1448
|
+
ELSE 1
|
|
1449
|
+
END AS "AllowUpdateAPI"`;
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Builds the UPDATE SQL to fix virtual field nullability.
|
|
1453
|
+
* Updates AllowsNull for virtual fields based on the FK column's nullability.
|
|
1454
|
+
*/
|
|
1455
|
+
buildFixVirtualFieldNullabilityUpdateSQL(mjCoreSchema, qs) {
|
|
1456
|
+
return `
|
|
1457
|
+
UPDATE ${qs(mjCoreSchema, 'EntityField')} vf
|
|
1458
|
+
SET "AllowsNull" = fk."AllowsNull"
|
|
1459
|
+
FROM ${qs(mjCoreSchema, 'EntityField')} fk
|
|
1460
|
+
WHERE vf."IsVirtual" = true
|
|
1461
|
+
AND fk."IsVirtual" = false
|
|
1462
|
+
AND vf."EntityID" = fk."EntityID"
|
|
1463
|
+
AND fk."RelatedEntityID" IS NOT NULL
|
|
1464
|
+
AND (
|
|
1465
|
+
(LENGTH(fk."Name") > 2
|
|
1466
|
+
AND LOWER(vf."Name") = LOWER(LEFT(fk."Name", LENGTH(fk."Name") - 2)))
|
|
1467
|
+
OR
|
|
1468
|
+
(LENGTH(fk."Name") > 2
|
|
1469
|
+
AND LOWER(vf."Name") = LOWER(LEFT(fk."Name", LENGTH(fk."Name") - 2) || '_Virtual'))
|
|
1470
|
+
OR
|
|
1471
|
+
(fk."RelatedEntityNameFieldMap" IS NOT NULL
|
|
1472
|
+
AND fk."RelatedEntityNameFieldMap" != ''
|
|
1473
|
+
AND LOWER(vf."Name") = LOWER(fk."RelatedEntityNameFieldMap"))
|
|
1474
|
+
)
|
|
1475
|
+
AND vf."AllowsNull" != fk."AllowsNull"`;
|
|
1476
|
+
}
|
|
1477
|
+
// ─── SHELL EXECUTION HELPERS ─────────────────────────────────────
|
|
1478
|
+
/**
|
|
1479
|
+
* Executes a SQL file using the psql CLI.
|
|
1480
|
+
*/
|
|
1481
|
+
async executePsqlCommand(absoluteFilePath, pgHost, pgPort, pgUser, pgDatabase, pgPassword) {
|
|
1482
|
+
const args = [
|
|
1483
|
+
'-h', pgHost,
|
|
1484
|
+
'-p', pgPort,
|
|
1485
|
+
'-U', pgUser,
|
|
1486
|
+
'-d', pgDatabase,
|
|
1487
|
+
'-v', 'ON_ERROR_STOP=1',
|
|
1488
|
+
'-f', absoluteFilePath,
|
|
1489
|
+
];
|
|
1490
|
+
logIf(configInfo.verboseOutput, `Executing SQL file (psql): ${absoluteFilePath} as ${pgUser}@${pgHost}:${pgPort}/${pgDatabase}`);
|
|
1491
|
+
try {
|
|
1492
|
+
const result = await this.spawnPsql(args, pgPassword);
|
|
1493
|
+
this.logPsqlOutput(result.stdout, result.stderr);
|
|
1494
|
+
return true;
|
|
1495
|
+
}
|
|
1496
|
+
catch (e) {
|
|
1497
|
+
this.logPsqlError(e, pgPassword);
|
|
1498
|
+
return false;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Spawns a psql child process and returns its output.
|
|
1503
|
+
*/
|
|
1504
|
+
spawnPsql(args, pgPassword) {
|
|
1505
|
+
return new Promise((resolve, reject) => {
|
|
1506
|
+
const child = spawn('psql', args, {
|
|
1507
|
+
shell: false,
|
|
1508
|
+
env: { ...process.env, PGPASSWORD: pgPassword },
|
|
1509
|
+
});
|
|
1510
|
+
let stdout = '';
|
|
1511
|
+
let stderr = '';
|
|
1512
|
+
child.stdout?.on('data', (data) => {
|
|
1513
|
+
stdout += data.toString();
|
|
1514
|
+
});
|
|
1515
|
+
child.stderr?.on('data', (data) => {
|
|
1516
|
+
stderr += data.toString();
|
|
1517
|
+
});
|
|
1518
|
+
child.on('error', (error) => {
|
|
1519
|
+
reject(error);
|
|
1520
|
+
});
|
|
1521
|
+
child.on('close', (code) => {
|
|
1522
|
+
if (code === 0) {
|
|
1523
|
+
resolve({ stdout, stderr });
|
|
1524
|
+
}
|
|
1525
|
+
else {
|
|
1526
|
+
const error = new Error(`psql exited with code ${code}`);
|
|
1527
|
+
Object.assign(error, { stdout, stderr, code });
|
|
1528
|
+
reject(error);
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* Logs psql stdout/stderr output, filtering out informational NOTICE messages.
|
|
1535
|
+
*/
|
|
1536
|
+
logPsqlOutput(stdout, stderr) {
|
|
1537
|
+
if (stdout && stdout.trim().length > 0) {
|
|
1538
|
+
logIf(configInfo.verboseOutput, `PostgreSQL output: ${stdout.trim()}`);
|
|
1539
|
+
}
|
|
1540
|
+
if (stderr && stderr.trim().length > 0) {
|
|
1541
|
+
const nonNoticeLines = stderr.split('\n').filter((l) => !l.trim().startsWith('NOTICE:') && !l.trim().startsWith('psql:') && l.trim().length > 0);
|
|
1542
|
+
if (nonNoticeLines.length > 0) {
|
|
1543
|
+
logWarning(`PostgreSQL stderr: ${nonNoticeLines.join('\n')}`);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Logs psql execution errors with password masking.
|
|
1549
|
+
*/
|
|
1550
|
+
logPsqlError(e, pgPassword) {
|
|
1551
|
+
let message = (e instanceof Error) ? e.message : String(e);
|
|
1552
|
+
const errRecord = e;
|
|
1553
|
+
if (errRecord.stdout) {
|
|
1554
|
+
message += `\n PostgreSQL output: ${errRecord.stdout}`;
|
|
1555
|
+
}
|
|
1556
|
+
if (errRecord.stderr) {
|
|
1557
|
+
message += `\n PostgreSQL error: ${errRecord.stderr}`;
|
|
1558
|
+
}
|
|
1559
|
+
const errorMessage = pgPassword ? message.replace(pgPassword, 'XXXXX') : message;
|
|
1560
|
+
logError('Error executing PostgreSQL SQL file: ' + errorMessage);
|
|
1561
|
+
}
|
|
1562
|
+
};
|
|
1563
|
+
PostgreSQLCodeGenProvider = PostgreSQLCodeGenProvider_1 = __decorate([
|
|
1564
|
+
RegisterClass(CodeGenDatabaseProvider, 'PostgreSQLCodeGenProvider')
|
|
1565
|
+
], PostgreSQLCodeGenProvider);
|
|
1566
|
+
export { PostgreSQLCodeGenProvider };
|
|
1567
|
+
//# sourceMappingURL=PostgreSQLCodeGenProvider.js.map
|