@memberjunction/codegen-lib 5.4.1 → 5.6.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,1483 @@
|
|
|
1
|
+
import { EntityInfo, CodeNameFromString } from '@memberjunction/core';
|
|
2
|
+
import { CodeGenDatabaseProvider, } from '../../codeGenDatabaseProvider.js';
|
|
3
|
+
import { SQLServerDialect } from '@memberjunction/sql-dialect';
|
|
4
|
+
import { logIf, sortBySequenceAndCreatedAt } from '../../../Misc/util.js';
|
|
5
|
+
import { configInfo, dbDatabase } from '../../../Config/config.js';
|
|
6
|
+
import { sqlConfig } from '../../../Config/db-connection.js';
|
|
7
|
+
import { logError, logWarning } from '../../../Misc/status_logging.js';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { execSync, spawn } from 'child_process';
|
|
10
|
+
const ssDialect = new SQLServerDialect();
|
|
11
|
+
/**
|
|
12
|
+
* SQL Server implementation of the CodeGen database provider.
|
|
13
|
+
* Generates SQL Server-native DDL for views, stored procedures, triggers, indexes,
|
|
14
|
+
* full-text search, permissions, and other database objects.
|
|
15
|
+
*
|
|
16
|
+
* This provider extracts the SQL Server-specific generation logic that was previously
|
|
17
|
+
* hardcoded in SQLCodeGenBase, enabling the orchestrator to be database-agnostic.
|
|
18
|
+
*/
|
|
19
|
+
export class SQLServerCodeGenProvider extends CodeGenDatabaseProvider {
|
|
20
|
+
/** @inheritdoc */
|
|
21
|
+
get Dialect() {
|
|
22
|
+
return ssDialect;
|
|
23
|
+
}
|
|
24
|
+
/** @inheritdoc */
|
|
25
|
+
get PlatformKey() {
|
|
26
|
+
return 'sqlserver';
|
|
27
|
+
}
|
|
28
|
+
// ─── DROP GUARDS ─────────────────────────────────────────────────────
|
|
29
|
+
/**
|
|
30
|
+
* Generates SQL Server-style conditional DROP guards using the `IF OBJECT_ID(...) IS NOT NULL`
|
|
31
|
+
* pattern. Maps each object type to its SQL Server type code: `'V'` for views, `'P'` for
|
|
32
|
+
* procedures, `'IF'` for inline table-valued functions, and `'TR'` for triggers.
|
|
33
|
+
* Each guard is terminated with a `GO` batch separator.
|
|
34
|
+
*/
|
|
35
|
+
generateDropGuard(objectType, schema, name) {
|
|
36
|
+
switch (objectType) {
|
|
37
|
+
case 'VIEW':
|
|
38
|
+
return `IF OBJECT_ID('[${schema}].[${name}]', 'V') IS NOT NULL\n DROP VIEW [${schema}].[${name}];\nGO`;
|
|
39
|
+
case 'PROCEDURE':
|
|
40
|
+
return `IF OBJECT_ID('[${schema}].[${name}]', 'P') IS NOT NULL\n DROP PROCEDURE [${schema}].[${name}];\nGO`;
|
|
41
|
+
case 'FUNCTION':
|
|
42
|
+
return `IF OBJECT_ID('[${schema}].[${name}]', 'IF') IS NOT NULL\n DROP FUNCTION [${schema}].[${name}];\nGO`;
|
|
43
|
+
case 'TRIGGER':
|
|
44
|
+
return `IF OBJECT_ID('[${schema}].[${name}]', 'TR') IS NOT NULL\n DROP TRIGGER [${schema}].[${name}];\nGO`;
|
|
45
|
+
default:
|
|
46
|
+
return `-- Unknown object type: ${objectType}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// ─── BASE VIEWS ──────────────────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Generates a SQL Server `CREATE VIEW` statement for an entity's base view. The view
|
|
52
|
+
* selects all columns from the base table plus any related, parent, and root ID fields
|
|
53
|
+
* supplied by the orchestrator context. For entities with soft-delete enabled, appends
|
|
54
|
+
* a `WHERE ... IS NULL` clause filtering out soft-deleted rows. Includes the conditional
|
|
55
|
+
* DROP guard and a descriptive comment header.
|
|
56
|
+
*/
|
|
57
|
+
generateBaseView(context) {
|
|
58
|
+
const entity = context.entity;
|
|
59
|
+
const viewName = entity.BaseView ? entity.BaseView : `vw${entity.CodeName}`;
|
|
60
|
+
const alias = entity.BaseTableCodeName.charAt(0).toLowerCase();
|
|
61
|
+
const whereClause = entity.DeleteType === 'Soft'
|
|
62
|
+
? `WHERE\n ${alias}.[${EntityInfo.DeletedAtFieldName}] IS NULL\n`
|
|
63
|
+
: '';
|
|
64
|
+
return `
|
|
65
|
+
------------------------------------------------------------
|
|
66
|
+
----- BASE VIEW FOR ENTITY: ${entity.Name}
|
|
67
|
+
----- SCHEMA: ${entity.SchemaName}
|
|
68
|
+
----- BASE TABLE: ${entity.BaseTable}
|
|
69
|
+
----- PRIMARY KEY: ${entity.PrimaryKeys.map(pk => pk.Name).join(', ')}
|
|
70
|
+
------------------------------------------------------------
|
|
71
|
+
IF OBJECT_ID('[${entity.SchemaName}].[${viewName}]', 'V') IS NOT NULL
|
|
72
|
+
DROP VIEW [${entity.SchemaName}].[${viewName}];
|
|
73
|
+
GO
|
|
74
|
+
|
|
75
|
+
CREATE VIEW [${entity.SchemaName}].[${viewName}]
|
|
76
|
+
AS
|
|
77
|
+
SELECT
|
|
78
|
+
${alias}.*${context.parentFieldsSelect}${context.relatedFieldsSelect.length > 0 ? ',' : ''}${context.relatedFieldsSelect}${context.rootFieldsSelect}
|
|
79
|
+
FROM
|
|
80
|
+
[${entity.SchemaName}].[${entity.BaseTable}] AS ${alias}${context.parentJoins ? '\n' + context.parentJoins : ''}${context.relatedFieldsJoins ? '\n' + context.relatedFieldsJoins : ''}${context.rootJoins}
|
|
81
|
+
${whereClause}GO`;
|
|
82
|
+
}
|
|
83
|
+
// ─── CRUD ROUTINES ───────────────────────────────────────────────────
|
|
84
|
+
/**
|
|
85
|
+
* Generates the `spCreate` stored procedure for a SQL Server entity. Handles three
|
|
86
|
+
* primary key strategies:
|
|
87
|
+
*
|
|
88
|
+
* 1. **Auto-increment**: Uses `SCOPE_IDENTITY()` to retrieve the generated key.
|
|
89
|
+
* 2. **UNIQUEIDENTIFIER with default** (e.g., `NEWSEQUENTIALID()`): Uses an `OUTPUT`
|
|
90
|
+
* clause into a table variable with a two-branch `IF @PK IS NOT NULL` pattern so
|
|
91
|
+
* callers can optionally supply their own GUID or let the database default apply.
|
|
92
|
+
* 3. **UNIQUEIDENTIFIER without default**: Falls back to `ISNULL(@PK, NEWID())`.
|
|
93
|
+
* 4. **Composite / other PKs**: Constructs a multi-column WHERE clause for retrieval.
|
|
94
|
+
*
|
|
95
|
+
* The procedure always returns the newly created record via the entity's base view.
|
|
96
|
+
* Includes GRANT EXECUTE permissions for authorized roles.
|
|
97
|
+
*/
|
|
98
|
+
generateCRUDCreate(entity) {
|
|
99
|
+
const spName = this.getCRUDRoutineName(entity, 'Create');
|
|
100
|
+
const firstKey = entity.FirstPrimaryKey;
|
|
101
|
+
const efString = this.generateCRUDParamString(entity.Fields, false);
|
|
102
|
+
const permissions = this.generateCRUDPermissions(entity, spName, 'Create');
|
|
103
|
+
let preInsertCode = '';
|
|
104
|
+
let outputCode = '';
|
|
105
|
+
let selectInsertedRecord = '';
|
|
106
|
+
let additionalFieldList = '';
|
|
107
|
+
let additionalValueList = '';
|
|
108
|
+
if (firstKey.AutoIncrement) {
|
|
109
|
+
selectInsertedRecord = `SELECT * FROM [${entity.SchemaName}].[${entity.BaseView}] WHERE [${firstKey.Name}] = SCOPE_IDENTITY()`;
|
|
110
|
+
}
|
|
111
|
+
else if (firstKey.Type.toLowerCase().trim() === 'uniqueidentifier' && entity.PrimaryKeys.length === 1) {
|
|
112
|
+
const hasDefaultValue = firstKey.DefaultValue && firstKey.DefaultValue.trim().length > 0;
|
|
113
|
+
if (hasDefaultValue) {
|
|
114
|
+
preInsertCode = `DECLARE @InsertedRow TABLE ([${firstKey.Name}] UNIQUEIDENTIFIER)
|
|
115
|
+
|
|
116
|
+
IF @${firstKey.Name} IS NOT NULL
|
|
117
|
+
BEGIN
|
|
118
|
+
-- User provided a value, use it
|
|
119
|
+
INSERT INTO [${entity.SchemaName}].[${entity.BaseTable}]
|
|
120
|
+
(
|
|
121
|
+
[${firstKey.Name}],
|
|
122
|
+
${this.generateInsertFieldString(entity, entity.Fields, '', true)}
|
|
123
|
+
)
|
|
124
|
+
OUTPUT INSERTED.[${firstKey.Name}] INTO @InsertedRow
|
|
125
|
+
VALUES
|
|
126
|
+
(
|
|
127
|
+
@${firstKey.Name},
|
|
128
|
+
${this.generateInsertFieldString(entity, entity.Fields, '@', true)}
|
|
129
|
+
)
|
|
130
|
+
END
|
|
131
|
+
ELSE
|
|
132
|
+
BEGIN
|
|
133
|
+
-- No value provided, let database use its default (e.g., NEWSEQUENTIALID())
|
|
134
|
+
INSERT INTO [${entity.SchemaName}].[${entity.BaseTable}]
|
|
135
|
+
(
|
|
136
|
+
${this.generateInsertFieldString(entity, entity.Fields, '', true)}
|
|
137
|
+
)
|
|
138
|
+
OUTPUT INSERTED.[${firstKey.Name}] INTO @InsertedRow
|
|
139
|
+
VALUES
|
|
140
|
+
(
|
|
141
|
+
${this.generateInsertFieldString(entity, entity.Fields, '@', true)}
|
|
142
|
+
)
|
|
143
|
+
END`;
|
|
144
|
+
additionalFieldList = '';
|
|
145
|
+
additionalValueList = '';
|
|
146
|
+
outputCode = '';
|
|
147
|
+
selectInsertedRecord = `SELECT * FROM [${entity.SchemaName}].[${entity.BaseView}] WHERE [${firstKey.Name}] = (SELECT [${firstKey.Name}] FROM @InsertedRow)`;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
preInsertCode = `DECLARE @ActualID UNIQUEIDENTIFIER = ISNULL(@${firstKey.Name}, NEWID())`;
|
|
151
|
+
additionalFieldList = ',\n [' + firstKey.Name + ']';
|
|
152
|
+
additionalValueList = ',\n @ActualID';
|
|
153
|
+
outputCode = '';
|
|
154
|
+
selectInsertedRecord = `SELECT * FROM [${entity.SchemaName}].[${entity.BaseView}] WHERE [${firstKey.Name}] = @ActualID`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
selectInsertedRecord = `SELECT * FROM [${entity.SchemaName}].[${entity.BaseView}] WHERE `;
|
|
159
|
+
let isFirst = true;
|
|
160
|
+
for (const k of entity.PrimaryKeys) {
|
|
161
|
+
if (!isFirst)
|
|
162
|
+
selectInsertedRecord += ' AND ';
|
|
163
|
+
selectInsertedRecord += `[${k.Name}] = @${k.CodeName}`;
|
|
164
|
+
isFirst = false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return `
|
|
168
|
+
------------------------------------------------------------
|
|
169
|
+
----- CREATE PROCEDURE FOR ${entity.BaseTable}
|
|
170
|
+
------------------------------------------------------------
|
|
171
|
+
IF OBJECT_ID('[${entity.SchemaName}].[${spName}]', 'P') IS NOT NULL
|
|
172
|
+
DROP PROCEDURE [${entity.SchemaName}].[${spName}];
|
|
173
|
+
GO
|
|
174
|
+
|
|
175
|
+
CREATE PROCEDURE [${entity.SchemaName}].[${spName}]
|
|
176
|
+
${efString}
|
|
177
|
+
AS
|
|
178
|
+
BEGIN
|
|
179
|
+
SET NOCOUNT ON;
|
|
180
|
+
${preInsertCode}${preInsertCode.includes('INSERT INTO') ? '' : `
|
|
181
|
+
INSERT INTO
|
|
182
|
+
[${entity.SchemaName}].[${entity.BaseTable}]
|
|
183
|
+
(
|
|
184
|
+
${this.generateInsertFieldString(entity, entity.Fields, '')}${additionalFieldList}
|
|
185
|
+
)
|
|
186
|
+
${outputCode}VALUES
|
|
187
|
+
(
|
|
188
|
+
${this.generateInsertFieldString(entity, entity.Fields, '@')}${additionalValueList}
|
|
189
|
+
)`}
|
|
190
|
+
-- return the new record from the base view, which might have some calculated fields
|
|
191
|
+
${selectInsertedRecord}
|
|
192
|
+
END
|
|
193
|
+
GO${permissions}
|
|
194
|
+
`;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Generates the `spUpdate` stored procedure for a SQL Server entity. Performs a standard
|
|
198
|
+
* `UPDATE ... SET ... WHERE PK = @PK` and uses `@@ROWCOUNT` to detect whether the row
|
|
199
|
+
* was found: if no rows were updated (e.g., stale PK or concurrent delete), returns an
|
|
200
|
+
* empty result set with the base view's column structure (`SELECT TOP 0 ... WHERE 1=0`);
|
|
201
|
+
* otherwise returns the updated record from the base view. Also generates the
|
|
202
|
+
* `__mj_UpdatedAt` timestamp trigger if the entity has that column.
|
|
203
|
+
*/
|
|
204
|
+
generateCRUDUpdate(entity) {
|
|
205
|
+
const spName = this.getCRUDRoutineName(entity, 'Update');
|
|
206
|
+
const efParamString = this.generateCRUDParamString(entity.Fields, true);
|
|
207
|
+
const permissions = this.generateCRUDPermissions(entity, spName, 'Update');
|
|
208
|
+
const hasUpdatedAtField = entity.Fields.find(f => f.Name.toLowerCase().trim() === EntityInfo.UpdatedAtFieldName.trim().toLowerCase()) !== undefined;
|
|
209
|
+
const updatedAtTrigger = hasUpdatedAtField ? this.generateTimestampTrigger(entity) : '';
|
|
210
|
+
const selectUpdatedRecord = `SELECT
|
|
211
|
+
*
|
|
212
|
+
FROM
|
|
213
|
+
[${entity.SchemaName}].[${entity.BaseView}]
|
|
214
|
+
WHERE
|
|
215
|
+
${entity.PrimaryKeys.map(k => `[${k.Name}] = @${k.CodeName}`).join(' AND ')}
|
|
216
|
+
`;
|
|
217
|
+
return `
|
|
218
|
+
------------------------------------------------------------
|
|
219
|
+
----- UPDATE PROCEDURE FOR ${entity.BaseTable}
|
|
220
|
+
------------------------------------------------------------
|
|
221
|
+
IF OBJECT_ID('[${entity.SchemaName}].[${spName}]', 'P') IS NOT NULL
|
|
222
|
+
DROP PROCEDURE [${entity.SchemaName}].[${spName}];
|
|
223
|
+
GO
|
|
224
|
+
|
|
225
|
+
CREATE PROCEDURE [${entity.SchemaName}].[${spName}]
|
|
226
|
+
${efParamString}
|
|
227
|
+
AS
|
|
228
|
+
BEGIN
|
|
229
|
+
SET NOCOUNT ON;
|
|
230
|
+
UPDATE
|
|
231
|
+
[${entity.SchemaName}].[${entity.BaseTable}]
|
|
232
|
+
SET
|
|
233
|
+
${this.generateUpdateFieldString(entity.Fields)}
|
|
234
|
+
WHERE
|
|
235
|
+
${entity.PrimaryKeys.map(k => `[${k.Name}] = @${k.CodeName}`).join(' AND ')}
|
|
236
|
+
|
|
237
|
+
-- Check if the update was successful
|
|
238
|
+
IF @@ROWCOUNT = 0
|
|
239
|
+
-- Nothing was updated, return no rows, but column structure from base view intact, semantically correct this way.
|
|
240
|
+
SELECT TOP 0 * FROM [${entity.SchemaName}].[${entity.BaseView}] WHERE 1=0
|
|
241
|
+
ELSE
|
|
242
|
+
-- Return the updated record so the caller can see the updated values and any calculated fields
|
|
243
|
+
${selectUpdatedRecord}
|
|
244
|
+
END
|
|
245
|
+
GO
|
|
246
|
+
${permissions}
|
|
247
|
+
GO
|
|
248
|
+
${updatedAtTrigger}
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Generates the `spDelete` stored procedure for a SQL Server entity. Supports both
|
|
253
|
+
* hard delete (`DELETE FROM`) and soft delete (`UPDATE ... SET __mj_DeletedAt = GETUTCDATE()`
|
|
254
|
+
* with a guard against re-deleting already soft-deleted rows). Prepends any cascade
|
|
255
|
+
* delete/update SQL for related entities. Uses `@@ROWCOUNT` to determine success:
|
|
256
|
+
* returns the PK values on success or NULL PK values if no row was affected.
|
|
257
|
+
*/
|
|
258
|
+
generateCRUDDelete(entity, cascadeSQL) {
|
|
259
|
+
const spName = this.getCRUDRoutineName(entity, 'Delete');
|
|
260
|
+
const permissions = this.generateCRUDPermissions(entity, spName, 'Delete');
|
|
261
|
+
let sVariables = '';
|
|
262
|
+
let sSelect = '';
|
|
263
|
+
for (const k of entity.PrimaryKeys) {
|
|
264
|
+
if (sVariables !== '')
|
|
265
|
+
sVariables += ', ';
|
|
266
|
+
sVariables += `@${k.CodeName} ${k.SQLFullType}`;
|
|
267
|
+
if (sSelect !== '')
|
|
268
|
+
sSelect += ', ';
|
|
269
|
+
sSelect += `@${k.CodeName} AS [${k.Name}]`;
|
|
270
|
+
}
|
|
271
|
+
let deleteCode = ` WHERE
|
|
272
|
+
${entity.PrimaryKeys.map(k => `[${k.Name}] = @${k.CodeName}`).join(' AND ')}
|
|
273
|
+
`;
|
|
274
|
+
if (entity.DeleteType === 'Hard') {
|
|
275
|
+
deleteCode = ` DELETE FROM
|
|
276
|
+
[${entity.SchemaName}].[${entity.BaseTable}]
|
|
277
|
+
${deleteCode}`;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
deleteCode = ` UPDATE
|
|
281
|
+
[${entity.SchemaName}].[${entity.BaseTable}]
|
|
282
|
+
SET
|
|
283
|
+
${EntityInfo.DeletedAtFieldName} = GETUTCDATE()
|
|
284
|
+
${deleteCode} AND ${EntityInfo.DeletedAtFieldName} IS NULL -- don't update the record if it's already been deleted via a soft delete`;
|
|
285
|
+
}
|
|
286
|
+
let sNullSelect = '';
|
|
287
|
+
for (const k of entity.PrimaryKeys) {
|
|
288
|
+
if (sNullSelect !== '')
|
|
289
|
+
sNullSelect += ', ';
|
|
290
|
+
sNullSelect += `NULL AS [${k.Name}]`;
|
|
291
|
+
}
|
|
292
|
+
return `
|
|
293
|
+
------------------------------------------------------------
|
|
294
|
+
----- DELETE PROCEDURE FOR ${entity.BaseTable}
|
|
295
|
+
------------------------------------------------------------
|
|
296
|
+
IF OBJECT_ID('[${entity.SchemaName}].[${spName}]', 'P') IS NOT NULL
|
|
297
|
+
DROP PROCEDURE [${entity.SchemaName}].[${spName}];
|
|
298
|
+
GO
|
|
299
|
+
|
|
300
|
+
CREATE PROCEDURE [${entity.SchemaName}].[${spName}]
|
|
301
|
+
${sVariables}
|
|
302
|
+
AS
|
|
303
|
+
BEGIN
|
|
304
|
+
SET NOCOUNT ON;${cascadeSQL}
|
|
305
|
+
|
|
306
|
+
${deleteCode}
|
|
307
|
+
|
|
308
|
+
-- Check if the delete was successful
|
|
309
|
+
IF @@ROWCOUNT = 0
|
|
310
|
+
SELECT ${sNullSelect} -- Return NULL for all primary key fields to indicate no record was deleted
|
|
311
|
+
ELSE
|
|
312
|
+
SELECT ${sSelect} -- Return the primary key values to indicate we successfully deleted the record
|
|
313
|
+
END
|
|
314
|
+
GO${permissions}
|
|
315
|
+
`;
|
|
316
|
+
}
|
|
317
|
+
// ─── TRIGGERS ────────────────────────────────────────────────────────
|
|
318
|
+
/**
|
|
319
|
+
* Generates a SQL Server `AFTER UPDATE` trigger that automatically sets the
|
|
320
|
+
* `__mj_UpdatedAt` column to `GETUTCDATE()` on every update. The trigger joins
|
|
321
|
+
* the base table to the `INSERTED` pseudo-table on all primary key columns to
|
|
322
|
+
* target only the affected rows. Returns an empty string if the entity does not
|
|
323
|
+
* have an `__mj_UpdatedAt` field.
|
|
324
|
+
*/
|
|
325
|
+
generateTimestampTrigger(entity) {
|
|
326
|
+
const updatedAtField = entity.Fields.find(f => f.Name.toLowerCase().trim() === EntityInfo.UpdatedAtFieldName.toLowerCase().trim());
|
|
327
|
+
if (!updatedAtField)
|
|
328
|
+
return '';
|
|
329
|
+
return `
|
|
330
|
+
------------------------------------------------------------
|
|
331
|
+
----- TRIGGER FOR ${EntityInfo.UpdatedAtFieldName} field for the ${entity.BaseTable} table
|
|
332
|
+
------------------------------------------------------------
|
|
333
|
+
IF OBJECT_ID('[${entity.SchemaName}].[trgUpdate${entity.BaseTableCodeName}]', 'TR') IS NOT NULL
|
|
334
|
+
DROP TRIGGER [${entity.SchemaName}].[trgUpdate${entity.BaseTableCodeName}];
|
|
335
|
+
GO
|
|
336
|
+
CREATE TRIGGER [${entity.SchemaName}].trgUpdate${entity.BaseTableCodeName}
|
|
337
|
+
ON [${entity.SchemaName}].[${entity.BaseTable}]
|
|
338
|
+
AFTER UPDATE
|
|
339
|
+
AS
|
|
340
|
+
BEGIN
|
|
341
|
+
SET NOCOUNT ON;
|
|
342
|
+
UPDATE
|
|
343
|
+
[${entity.SchemaName}].[${entity.BaseTable}]
|
|
344
|
+
SET
|
|
345
|
+
${EntityInfo.UpdatedAtFieldName} = GETUTCDATE()
|
|
346
|
+
FROM
|
|
347
|
+
[${entity.SchemaName}].[${entity.BaseTable}] AS _organicTable
|
|
348
|
+
INNER JOIN
|
|
349
|
+
INSERTED AS I ON
|
|
350
|
+
${entity.PrimaryKeys.map(k => `_organicTable.[${k.Name}] = I.[${k.Name}]`).join(' AND ')};
|
|
351
|
+
END;
|
|
352
|
+
GO`;
|
|
353
|
+
}
|
|
354
|
+
// ─── INDEXES ─────────────────────────────────────────────────────────
|
|
355
|
+
/**
|
|
356
|
+
* Generates conditional `CREATE INDEX` statements for all foreign key columns on an entity.
|
|
357
|
+
* Each index uses the naming convention `IDX_AUTO_MJ_FKEY_{Table}_{Column}`, truncated
|
|
358
|
+
* to 128 characters (SQL Server's identifier length limit). Wraps each statement in an
|
|
359
|
+
* `IF NOT EXISTS` check against `sys.indexes` to avoid duplicate index creation.
|
|
360
|
+
*/
|
|
361
|
+
generateForeignKeyIndexes(entity) {
|
|
362
|
+
const indexes = [];
|
|
363
|
+
for (const f of entity.Fields) {
|
|
364
|
+
if (f.RelatedEntity && f.RelatedEntity.length > 0) {
|
|
365
|
+
let indexName = `IDX_AUTO_MJ_FKEY_${entity.BaseTableCodeName}_${f.CodeName}`;
|
|
366
|
+
if (indexName.length > 128)
|
|
367
|
+
indexName = indexName.substring(0, 128);
|
|
368
|
+
indexes.push(`-- Index for foreign key ${f.Name} in table ${entity.BaseTable}
|
|
369
|
+
IF NOT EXISTS (
|
|
370
|
+
SELECT 1
|
|
371
|
+
FROM sys.indexes
|
|
372
|
+
WHERE name = '${indexName}'
|
|
373
|
+
AND object_id = OBJECT_ID('[${entity.SchemaName}].[${entity.BaseTable}]')
|
|
374
|
+
)
|
|
375
|
+
CREATE INDEX ${indexName} ON [${entity.SchemaName}].[${entity.BaseTable}] ([${f.Name}]);`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return indexes;
|
|
379
|
+
}
|
|
380
|
+
// ─── FULL-TEXT SEARCH ────────────────────────────────────────────────
|
|
381
|
+
/**
|
|
382
|
+
* Generates SQL Server full-text search infrastructure for an entity, conditionally
|
|
383
|
+
* producing up to three components based on entity metadata flags:
|
|
384
|
+
*
|
|
385
|
+
* 1. **Full-text catalog** (`FullTextCatalogGenerated`): `CREATE FULLTEXT CATALOG` if
|
|
386
|
+
* one doesn't already exist, defaulting to `MJ_FullTextCatalog`.
|
|
387
|
+
* 2. **Full-text index** (`FullTextIndexGenerated`): Drops any existing FT index on the
|
|
388
|
+
* table, then creates a new one covering the specified search fields with English language.
|
|
389
|
+
* 3. **Search function** (`FullTextSearchFunctionGenerated`): An inline table-valued
|
|
390
|
+
* function that wraps `CONTAINS()` to return matching primary key values.
|
|
391
|
+
*
|
|
392
|
+
* @returns The generated SQL and the resolved function name for permission grants.
|
|
393
|
+
*/
|
|
394
|
+
generateFullTextSearch(entity, searchFields, primaryKeyIndexName) {
|
|
395
|
+
let sql = '';
|
|
396
|
+
const catalogName = entity.FullTextCatalog && entity.FullTextCatalog.length > 0
|
|
397
|
+
? entity.FullTextCatalog
|
|
398
|
+
: dbDatabase + '_FullTextCatalog';
|
|
399
|
+
if (entity.FullTextCatalogGenerated) {
|
|
400
|
+
sql += ` -- CREATE THE FULL TEXT CATALOG
|
|
401
|
+
IF NOT EXISTS (
|
|
402
|
+
SELECT * FROM sys.fulltext_catalogs WHERE name = '${catalogName}'
|
|
403
|
+
)
|
|
404
|
+
CREATE FULLTEXT CATALOG ${catalogName};
|
|
405
|
+
GO
|
|
406
|
+
`;
|
|
407
|
+
}
|
|
408
|
+
if (entity.FullTextIndexGenerated) {
|
|
409
|
+
const fullTextFields = searchFields.map(f => `${f.Name} LANGUAGE 'English'`).join(', ');
|
|
410
|
+
sql += ` -- DROP AND RECREATE THE FULL TEXT INDEX
|
|
411
|
+
IF EXISTS (
|
|
412
|
+
SELECT * FROM sys.fulltext_indexes
|
|
413
|
+
WHERE object_id = OBJECT_ID('${entity.SchemaName}.${entity.BaseTable}')
|
|
414
|
+
)
|
|
415
|
+
BEGIN
|
|
416
|
+
DROP FULLTEXT INDEX ON [${entity.SchemaName}].[${entity.BaseTable}];
|
|
417
|
+
END
|
|
418
|
+
GO
|
|
419
|
+
|
|
420
|
+
IF NOT EXISTS (
|
|
421
|
+
SELECT * FROM sys.fulltext_indexes
|
|
422
|
+
WHERE object_id = OBJECT_ID('${entity.SchemaName}.${entity.BaseTable}')
|
|
423
|
+
)
|
|
424
|
+
BEGIN
|
|
425
|
+
CREATE FULLTEXT INDEX ON [${entity.SchemaName}].[${entity.BaseTable}]
|
|
426
|
+
(${fullTextFields})
|
|
427
|
+
KEY INDEX ${primaryKeyIndexName}
|
|
428
|
+
ON ${catalogName};
|
|
429
|
+
END
|
|
430
|
+
GO
|
|
431
|
+
`;
|
|
432
|
+
}
|
|
433
|
+
const functionName = entity.FullTextSearchFunction && entity.FullTextSearchFunction.length > 0
|
|
434
|
+
? entity.FullTextSearchFunction
|
|
435
|
+
: `fnSearch${entity.CodeName}`;
|
|
436
|
+
if (entity.FullTextSearchFunctionGenerated) {
|
|
437
|
+
const fullTextFieldsSimple = searchFields.map(f => '[' + f.Name + ']').join(', ');
|
|
438
|
+
const pkeyList = entity.PrimaryKeys.map(pk => '[' + pk.Name + ']').join(', ');
|
|
439
|
+
sql += ` -- DROP AND RECREATE THE FULL TEXT SEARCH FUNCTION
|
|
440
|
+
IF OBJECT_ID('${entity.SchemaName}.${functionName}', 'IF') IS NOT NULL
|
|
441
|
+
DROP FUNCTION ${entity.SchemaName}.${functionName};
|
|
442
|
+
GO
|
|
443
|
+
CREATE FUNCTION ${entity.SchemaName}.${functionName} (@searchTerm NVARCHAR(255))
|
|
444
|
+
RETURNS TABLE
|
|
445
|
+
AS
|
|
446
|
+
RETURN (
|
|
447
|
+
SELECT ${pkeyList}
|
|
448
|
+
FROM [${entity.SchemaName}].[${entity.BaseTable}]
|
|
449
|
+
WHERE CONTAINS((${fullTextFieldsSimple}), @searchTerm)
|
|
450
|
+
)
|
|
451
|
+
GO
|
|
452
|
+
`;
|
|
453
|
+
}
|
|
454
|
+
return { sql, functionName };
|
|
455
|
+
}
|
|
456
|
+
// ─── RECURSIVE FUNCTIONS (ROOT ID) ───────────────────────────────────
|
|
457
|
+
/**
|
|
458
|
+
* Generates a SQL Server inline table-valued function that resolves the root ancestor ID
|
|
459
|
+
* for a self-referencing foreign key hierarchy. Uses a recursive CTE starting from the
|
|
460
|
+
* given record (or its parent if provided), walking up the parent chain until it finds
|
|
461
|
+
* a record with a NULL parent pointer. Enforces a maximum recursion depth of 100 to
|
|
462
|
+
* prevent infinite loops from circular references. The function is named
|
|
463
|
+
* `fn{BaseTable}{FieldName}_GetRootID` and returns a single-column result (`RootID`),
|
|
464
|
+
* designed to be consumed via `OUTER APPLY` in the entity's base view.
|
|
465
|
+
*/
|
|
466
|
+
generateRootIDFunction(entity, field) {
|
|
467
|
+
const primaryKey = entity.FirstPrimaryKey.Name;
|
|
468
|
+
const primaryKeyType = entity.FirstPrimaryKey.SQLFullType;
|
|
469
|
+
const schemaName = entity.SchemaName;
|
|
470
|
+
const tableName = entity.BaseTable;
|
|
471
|
+
const fieldName = field.Name;
|
|
472
|
+
const functionName = `fn${entity.BaseTable}${fieldName}_GetRootID`;
|
|
473
|
+
return `------------------------------------------------------------
|
|
474
|
+
----- ROOT ID FUNCTION FOR: [${tableName}].[${fieldName}]
|
|
475
|
+
------------------------------------------------------------
|
|
476
|
+
IF OBJECT_ID('[${schemaName}].[${functionName}]', 'IF') IS NOT NULL
|
|
477
|
+
DROP FUNCTION [${schemaName}].[${functionName}];
|
|
478
|
+
GO
|
|
479
|
+
|
|
480
|
+
CREATE FUNCTION [${schemaName}].[${functionName}]
|
|
481
|
+
(
|
|
482
|
+
@RecordID ${primaryKeyType},
|
|
483
|
+
@ParentID ${primaryKeyType}
|
|
484
|
+
)
|
|
485
|
+
RETURNS TABLE
|
|
486
|
+
AS
|
|
487
|
+
RETURN
|
|
488
|
+
(
|
|
489
|
+
WITH CTE_RootParent AS (
|
|
490
|
+
SELECT
|
|
491
|
+
[${primaryKey}],
|
|
492
|
+
[${fieldName}],
|
|
493
|
+
[${primaryKey}] AS [RootParentID],
|
|
494
|
+
0 AS [Depth]
|
|
495
|
+
FROM
|
|
496
|
+
[${schemaName}].[${tableName}]
|
|
497
|
+
WHERE
|
|
498
|
+
[${primaryKey}] = COALESCE(@ParentID, @RecordID)
|
|
499
|
+
|
|
500
|
+
UNION ALL
|
|
501
|
+
|
|
502
|
+
SELECT
|
|
503
|
+
c.[${primaryKey}],
|
|
504
|
+
c.[${fieldName}],
|
|
505
|
+
c.[${primaryKey}] AS [RootParentID],
|
|
506
|
+
p.[Depth] + 1 AS [Depth]
|
|
507
|
+
FROM
|
|
508
|
+
[${schemaName}].[${tableName}] c
|
|
509
|
+
INNER JOIN
|
|
510
|
+
CTE_RootParent p ON c.[${primaryKey}] = p.[${fieldName}]
|
|
511
|
+
WHERE
|
|
512
|
+
p.[Depth] < 100
|
|
513
|
+
)
|
|
514
|
+
SELECT TOP 1
|
|
515
|
+
[RootParentID] AS RootID
|
|
516
|
+
FROM
|
|
517
|
+
CTE_RootParent
|
|
518
|
+
WHERE
|
|
519
|
+
[${fieldName}] IS NULL
|
|
520
|
+
ORDER BY
|
|
521
|
+
[RootParentID]
|
|
522
|
+
);
|
|
523
|
+
GO
|
|
524
|
+
`;
|
|
525
|
+
}
|
|
526
|
+
/** @inheritdoc */
|
|
527
|
+
generateRootFieldSelect(_entity, field, alias) {
|
|
528
|
+
const rootFieldName = `Root${field.Name}`;
|
|
529
|
+
return `${alias}.RootID AS [${rootFieldName}]`;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Generates a SQL Server `OUTER APPLY` clause to join the root ID inline table-valued
|
|
533
|
+
* function into the entity's base view. The function is invoked with the record's primary
|
|
534
|
+
* key and the self-referencing FK value as arguments.
|
|
535
|
+
*/
|
|
536
|
+
generateRootFieldJoin(entity, field, alias) {
|
|
537
|
+
const classNameFirstChar = entity.BaseTableCodeName.charAt(0).toLowerCase();
|
|
538
|
+
const schemaName = entity.SchemaName;
|
|
539
|
+
const functionName = `fn${entity.BaseTable}${field.Name}_GetRootID`;
|
|
540
|
+
const primaryKey = entity.FirstPrimaryKey.Name;
|
|
541
|
+
return `OUTER APPLY\n [${schemaName}].[${functionName}]([${classNameFirstChar}].[${primaryKey}], [${classNameFirstChar}].[${field.Name}]) AS ${alias}`;
|
|
542
|
+
}
|
|
543
|
+
// ─── PERMISSIONS ─────────────────────────────────────────────────────
|
|
544
|
+
/** @inheritdoc */
|
|
545
|
+
generateViewPermissions(entity) {
|
|
546
|
+
let sOutput = '';
|
|
547
|
+
for (const ep of entity.Permissions) {
|
|
548
|
+
if (ep.RoleSQLName && ep.RoleSQLName.length > 0) {
|
|
549
|
+
sOutput += (sOutput === '' ? `GRANT SELECT ON [${entity.SchemaName}].[${entity.BaseView}] TO ` : ', ') + `[${ep.RoleSQLName}]`;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return (sOutput === '' ? '' : '\n') + sOutput;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Generates `GRANT EXECUTE` SQL for a CRUD stored procedure, granting permission only
|
|
556
|
+
* to roles whose `EntityPermission` record allows the specified CRUD operation type.
|
|
557
|
+
* Produces a single comma-separated `GRANT EXECUTE ON ... TO [role1], [role2]` statement.
|
|
558
|
+
*/
|
|
559
|
+
generateCRUDPermissions(entity, routineName, type) {
|
|
560
|
+
let sOutput = '';
|
|
561
|
+
for (const ep of entity.Permissions) {
|
|
562
|
+
if (ep.RoleSQLName && ep.RoleSQLName.length > 0) {
|
|
563
|
+
const hasPermission = (type === 'Create' && ep.CanCreate) ||
|
|
564
|
+
(type === 'Update' && ep.CanUpdate) ||
|
|
565
|
+
(type === 'Delete' && ep.CanDelete);
|
|
566
|
+
if (hasPermission) {
|
|
567
|
+
sOutput += (sOutput === '' ? `GRANT EXECUTE ON [${entity.SchemaName}].[${routineName}] TO ` : ', ') + `[${ep.RoleSQLName}]`;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return (sOutput === '' ? '' : '\n') + sOutput;
|
|
572
|
+
}
|
|
573
|
+
/** @inheritdoc */
|
|
574
|
+
generateFullTextSearchPermissions(entity, functionName) {
|
|
575
|
+
let sOutput = '';
|
|
576
|
+
for (const ep of entity.Permissions) {
|
|
577
|
+
if (ep.CanRead) {
|
|
578
|
+
if (ep.RoleSQLName && ep.RoleSQLName.length > 0) {
|
|
579
|
+
sOutput += (sOutput === '' ? `GRANT SELECT ON [${entity.SchemaName}].[${functionName}] TO ` : ', ') + `[${ep.RoleSQLName}]`;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return (sOutput === '' ? '' : '\n') + sOutput;
|
|
584
|
+
}
|
|
585
|
+
// ─── CASCADE DELETES ─────────────────────────────────────────────────
|
|
586
|
+
/** @inheritdoc */
|
|
587
|
+
generateSingleCascadeOperation(context) {
|
|
588
|
+
const { parentEntity, relatedEntity, fkField, operation } = context;
|
|
589
|
+
if (operation === 'update') {
|
|
590
|
+
return this.generateCascadeCursorUpdate(parentEntity, relatedEntity, fkField);
|
|
591
|
+
}
|
|
592
|
+
return this.generateCascadeCursorDelete(parentEntity, relatedEntity, fkField);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Generates cursor-based cascade DELETE SQL for SQL Server.
|
|
596
|
+
* Uses a cursor to iterate related records and call the entity's spDelete for each.
|
|
597
|
+
*/
|
|
598
|
+
generateCascadeCursorDelete(parentEntity, relatedEntity, fkField) {
|
|
599
|
+
const qi = this.Dialect.QuoteIdentifier.bind(this.Dialect);
|
|
600
|
+
const qs = this.Dialect.QuoteSchema.bind(this.Dialect);
|
|
601
|
+
const whereClause = `${qi(fkField.CodeName)} = @${parentEntity.FirstPrimaryKey.CodeName}`;
|
|
602
|
+
const spName = this.getCRUDRoutineName(relatedEntity, 'Delete');
|
|
603
|
+
const variablePrefix = `${relatedEntity.CodeName}_${fkField.CodeName}`;
|
|
604
|
+
const pkComponents = this.buildPrimaryKeyComponents(relatedEntity, variablePrefix);
|
|
605
|
+
const cursorName = `cascade_delete_${relatedEntity.CodeName}_${fkField.CodeName}_cursor`;
|
|
606
|
+
return `
|
|
607
|
+
-- Cascade delete from ${relatedEntity.BaseTable} using cursor to call ${spName}
|
|
608
|
+
DECLARE ${pkComponents.varDeclarations}
|
|
609
|
+
DECLARE ${cursorName} CURSOR FOR
|
|
610
|
+
SELECT ${pkComponents.selectFields}
|
|
611
|
+
FROM ${qs(relatedEntity.SchemaName, relatedEntity.BaseTable)}
|
|
612
|
+
WHERE ${whereClause}
|
|
613
|
+
|
|
614
|
+
OPEN ${cursorName}
|
|
615
|
+
FETCH NEXT FROM ${cursorName} INTO ${pkComponents.fetchInto}
|
|
616
|
+
|
|
617
|
+
WHILE @@FETCH_STATUS = 0
|
|
618
|
+
BEGIN
|
|
619
|
+
EXEC ${qs(relatedEntity.SchemaName, spName)} ${pkComponents.routineParams}
|
|
620
|
+
|
|
621
|
+
FETCH NEXT FROM ${cursorName} INTO ${pkComponents.fetchInto}
|
|
622
|
+
END
|
|
623
|
+
|
|
624
|
+
CLOSE ${cursorName}
|
|
625
|
+
DEALLOCATE ${cursorName}`;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Generates cursor-based cascade UPDATE SQL for SQL Server.
|
|
629
|
+
* Used for nullable FK fields: iterates related records and calls spUpdate
|
|
630
|
+
* to set the FK to NULL for each.
|
|
631
|
+
*/
|
|
632
|
+
generateCascadeCursorUpdate(parentEntity, relatedEntity, fkField) {
|
|
633
|
+
const qi = this.Dialect.QuoteIdentifier.bind(this.Dialect);
|
|
634
|
+
const qs = this.Dialect.QuoteSchema.bind(this.Dialect);
|
|
635
|
+
const whereClause = `${qi(fkField.CodeName)} = @${parentEntity.FirstPrimaryKey.CodeName}`;
|
|
636
|
+
const variablePrefix = `${relatedEntity.CodeName}_${fkField.CodeName}`;
|
|
637
|
+
const spName = this.getCRUDRoutineName(relatedEntity, 'Update');
|
|
638
|
+
const updateParams = this.buildUpdateCursorParameters(relatedEntity, fkField, variablePrefix);
|
|
639
|
+
const cursorName = `cascade_update_${relatedEntity.CodeName}_${fkField.CodeName}_cursor`;
|
|
640
|
+
return `
|
|
641
|
+
-- Cascade update on ${relatedEntity.BaseTable} using cursor to call ${spName}
|
|
642
|
+
${updateParams.declarations}
|
|
643
|
+
DECLARE ${cursorName} CURSOR FOR
|
|
644
|
+
SELECT ${updateParams.selectFields}
|
|
645
|
+
FROM ${qs(relatedEntity.SchemaName, relatedEntity.BaseTable)}
|
|
646
|
+
WHERE ${whereClause}
|
|
647
|
+
|
|
648
|
+
OPEN ${cursorName}
|
|
649
|
+
FETCH NEXT FROM ${cursorName} INTO ${updateParams.fetchInto}
|
|
650
|
+
|
|
651
|
+
WHILE @@FETCH_STATUS = 0
|
|
652
|
+
BEGIN
|
|
653
|
+
-- Set the FK field to NULL
|
|
654
|
+
SET @${variablePrefix}_${fkField.CodeName} = NULL
|
|
655
|
+
|
|
656
|
+
-- Call the update SP for the related entity
|
|
657
|
+
EXEC ${qs(relatedEntity.SchemaName, spName)} ${updateParams.allParams}
|
|
658
|
+
|
|
659
|
+
FETCH NEXT FROM ${cursorName} INTO ${updateParams.fetchInto}
|
|
660
|
+
END
|
|
661
|
+
|
|
662
|
+
CLOSE ${cursorName}
|
|
663
|
+
DEALLOCATE ${cursorName}`;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Builds all the cursor parameters needed for a cascade UPDATE operation.
|
|
667
|
+
* Includes primary key fields and all updateable fields, with proper
|
|
668
|
+
* DECLARE statements, SELECT fields, FETCH INTO variables, and SP parameters.
|
|
669
|
+
*/
|
|
670
|
+
buildUpdateCursorParameters(entity, _fkField, prefix = '') {
|
|
671
|
+
const qi = this.Dialect.QuoteIdentifier.bind(this.Dialect);
|
|
672
|
+
let declarations = '';
|
|
673
|
+
let selectFields = '';
|
|
674
|
+
let fetchInto = '';
|
|
675
|
+
let allParams = '';
|
|
676
|
+
const varPrefix = prefix || entity.CodeName;
|
|
677
|
+
// First, handle primary keys with the entity-specific prefix
|
|
678
|
+
const pkComponents = this.buildPrimaryKeyComponents(entity, varPrefix);
|
|
679
|
+
// Add primary key declarations to the declarations string
|
|
680
|
+
// Need to add DECLARE keyword since buildPrimaryKeyComponents doesn't include it
|
|
681
|
+
declarations = pkComponents.varDeclarations.split(', ').map((decl) => `DECLARE ${decl}`).join('\n ');
|
|
682
|
+
selectFields = pkComponents.selectFields;
|
|
683
|
+
fetchInto = pkComponents.fetchInto;
|
|
684
|
+
allParams = pkComponents.routineParams;
|
|
685
|
+
// Then, add all updateable fields with the same prefix
|
|
686
|
+
const sortedFields = sortBySequenceAndCreatedAt(entity.Fields);
|
|
687
|
+
for (const ef of sortedFields) {
|
|
688
|
+
if (!ef.IsPrimaryKey && !ef.IsVirtual && ef.AllowUpdateAPI && !ef.AutoIncrement && !ef.IsSpecialDateField) {
|
|
689
|
+
if (declarations !== '')
|
|
690
|
+
declarations += '\n ';
|
|
691
|
+
declarations += `DECLARE @${varPrefix}_${ef.CodeName} ${ef.SQLFullType}`;
|
|
692
|
+
if (selectFields !== '')
|
|
693
|
+
selectFields += ', ';
|
|
694
|
+
selectFields += qi(ef.Name);
|
|
695
|
+
if (fetchInto !== '')
|
|
696
|
+
fetchInto += ', ';
|
|
697
|
+
fetchInto += `@${varPrefix}_${ef.CodeName}`;
|
|
698
|
+
if (allParams !== '')
|
|
699
|
+
allParams += ', ';
|
|
700
|
+
// Use named parameters: @ParamName = @VariableValue
|
|
701
|
+
allParams += `@${ef.CodeName} = @${varPrefix}_${ef.CodeName}`;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return { declarations, selectFields, fetchInto, allParams };
|
|
705
|
+
}
|
|
706
|
+
// ─── TIMESTAMP COLUMNS ───────────────────────────────────────────────
|
|
707
|
+
/** @inheritdoc */
|
|
708
|
+
generateTimestampColumns(schema, tableName) {
|
|
709
|
+
return `ALTER TABLE [${schema}].[${tableName}] ADD
|
|
710
|
+
[${EntityInfo.CreatedAtFieldName}] DATETIMEOFFSET NOT NULL DEFAULT GETUTCDATE(),
|
|
711
|
+
[${EntityInfo.UpdatedAtFieldName}] DATETIMEOFFSET NOT NULL DEFAULT GETUTCDATE();`;
|
|
712
|
+
}
|
|
713
|
+
// ─── PARAMETER / FIELD HELPERS ───────────────────────────────────────
|
|
714
|
+
/**
|
|
715
|
+
* Builds the SQL Server parameter declaration list for a CRUD stored procedure.
|
|
716
|
+
* Produces `@ParamName TYPE` entries separated by commas. Filters fields based on
|
|
717
|
+
* update/create context: includes primary keys only on updates (or non-auto-increment
|
|
718
|
+
* creates), excludes virtual and special date fields, and adds `= NULL` default values
|
|
719
|
+
* for optional primary keys on create and for non-nullable fields with database defaults.
|
|
720
|
+
*/
|
|
721
|
+
generateCRUDParamString(entityFields, isUpdate) {
|
|
722
|
+
let sOutput = '';
|
|
723
|
+
let isFirst = true;
|
|
724
|
+
for (const ef of entityFields) {
|
|
725
|
+
const autoGeneratedPrimaryKey = ef.AutoIncrement;
|
|
726
|
+
if ((ef.AllowUpdateAPI || (ef.IsPrimaryKey && isUpdate) || (ef.IsPrimaryKey && !autoGeneratedPrimaryKey && !isUpdate)) &&
|
|
727
|
+
!ef.IsVirtual &&
|
|
728
|
+
(!ef.IsPrimaryKey || !autoGeneratedPrimaryKey || isUpdate) &&
|
|
729
|
+
!ef.IsSpecialDateField) {
|
|
730
|
+
if (!isFirst)
|
|
731
|
+
sOutput += ',\n ';
|
|
732
|
+
else
|
|
733
|
+
isFirst = false;
|
|
734
|
+
let defaultParamValue = '';
|
|
735
|
+
if (!isUpdate && ef.IsPrimaryKey && !ef.AutoIncrement) {
|
|
736
|
+
defaultParamValue = ' = NULL';
|
|
737
|
+
}
|
|
738
|
+
else if (!isUpdate && ef.HasDefaultValue && !ef.AllowsNull) {
|
|
739
|
+
defaultParamValue = ' = NULL';
|
|
740
|
+
}
|
|
741
|
+
sOutput += `@${ef.CodeName} ${ef.SQLFullType}${defaultParamValue}`;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return sOutput;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Generates either the column-name list or the value-expression list for an INSERT
|
|
748
|
+
* statement, depending on the `prefix` parameter:
|
|
749
|
+
*
|
|
750
|
+
* - **Empty prefix** (`''`): Produces bracketed column names (e.g., `[Name], [Email]`).
|
|
751
|
+
* - **`'@'` prefix**: Produces parameter references with smart default handling:
|
|
752
|
+
* - Special date fields emit `GETUTCDATE()` for created/updated-at, `NULL` for deleted-at.
|
|
753
|
+
* - UNIQUEIDENTIFIER fields with defaults use a `CASE` expression that detects the
|
|
754
|
+
* empty GUID sentinel (`00000000-...`) and falls back to the database default.
|
|
755
|
+
* - Other non-nullable fields with defaults are wrapped in `ISNULL(@Param, default)`.
|
|
756
|
+
*
|
|
757
|
+
* Skips auto-increment, virtual, and non-updatable fields. Optionally excludes the
|
|
758
|
+
* primary key column (used by the two-branch GUID insert pattern in `generateCRUDCreate`).
|
|
759
|
+
*/
|
|
760
|
+
generateInsertFieldString(entity, entityFields, prefix, excludePrimaryKey = false) {
|
|
761
|
+
const autoGeneratedPrimaryKey = entity.FirstPrimaryKey.AutoIncrement;
|
|
762
|
+
let sOutput = '';
|
|
763
|
+
let isFirst = true;
|
|
764
|
+
for (const ef of entityFields) {
|
|
765
|
+
if ((excludePrimaryKey && ef.IsPrimaryKey) ||
|
|
766
|
+
(ef.IsPrimaryKey && autoGeneratedPrimaryKey) ||
|
|
767
|
+
ef.IsVirtual ||
|
|
768
|
+
!ef.AllowUpdateAPI ||
|
|
769
|
+
ef.AutoIncrement) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (!isFirst)
|
|
773
|
+
sOutput += ',\n ';
|
|
774
|
+
else
|
|
775
|
+
isFirst = false;
|
|
776
|
+
if (prefix !== '' && ef.IsSpecialDateField) {
|
|
777
|
+
if (ef.IsCreatedAtField || ef.IsUpdatedAtField)
|
|
778
|
+
sOutput += `GETUTCDATE()`;
|
|
779
|
+
else
|
|
780
|
+
sOutput += `NULL`;
|
|
781
|
+
}
|
|
782
|
+
else if (prefix && prefix !== '' && !ef.IsPrimaryKey && ef.IsUniqueIdentifier && ef.HasDefaultValue && !ef.AllowsNull) {
|
|
783
|
+
const formattedDefault = this.formatDefaultValue(ef.DefaultValue, ef.NeedsQuotes);
|
|
784
|
+
sOutput += `CASE @${ef.CodeName} WHEN '00000000-0000-0000-0000-000000000000' THEN ${formattedDefault} ELSE ISNULL(@${ef.CodeName}, ${formattedDefault}) END`;
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
let sVal = '';
|
|
788
|
+
if (!prefix || prefix.length === 0) {
|
|
789
|
+
sVal = '[' + ef.Name + ']';
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
sVal = prefix + ef.CodeName;
|
|
793
|
+
if (ef.HasDefaultValue && !ef.AllowsNull) {
|
|
794
|
+
const formattedDefault = this.formatDefaultValue(ef.DefaultValue, ef.NeedsQuotes);
|
|
795
|
+
if (ef.IsUniqueIdentifier) {
|
|
796
|
+
sVal = `CASE ${sVal} WHEN '00000000-0000-0000-0000-000000000000' THEN ${formattedDefault} ELSE ISNULL(${sVal}, ${formattedDefault}) END`;
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
sVal = `ISNULL(${sVal}, ${formattedDefault})`;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
sOutput += sVal;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return sOutput;
|
|
807
|
+
}
|
|
808
|
+
/** @inheritdoc */
|
|
809
|
+
generateUpdateFieldString(entityFields) {
|
|
810
|
+
let sOutput = '';
|
|
811
|
+
let isFirst = true;
|
|
812
|
+
for (const ef of entityFields) {
|
|
813
|
+
if (!ef.IsPrimaryKey &&
|
|
814
|
+
!ef.IsVirtual &&
|
|
815
|
+
ef.AllowUpdateAPI &&
|
|
816
|
+
!ef.AutoIncrement &&
|
|
817
|
+
!ef.IsSpecialDateField) {
|
|
818
|
+
if (!isFirst)
|
|
819
|
+
sOutput += ',\n ';
|
|
820
|
+
else
|
|
821
|
+
isFirst = false;
|
|
822
|
+
sOutput += `[${ef.Name}] = @${ef.CodeName}`;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return sOutput;
|
|
826
|
+
}
|
|
827
|
+
// ─── ROUTINE NAMING ──────────────────────────────────────────────────
|
|
828
|
+
/** @inheritdoc */
|
|
829
|
+
getCRUDRoutineName(entity, type) {
|
|
830
|
+
switch (type) {
|
|
831
|
+
case 'Create':
|
|
832
|
+
return entity.spCreate && entity.spCreate.length > 0 ? entity.spCreate : 'spCreate' + entity.BaseTableCodeName;
|
|
833
|
+
case 'Update':
|
|
834
|
+
return entity.spUpdate && entity.spUpdate.length > 0 ? entity.spUpdate : 'spUpdate' + entity.BaseTableCodeName;
|
|
835
|
+
case 'Delete':
|
|
836
|
+
return entity.spDelete && entity.spDelete.length > 0 ? entity.spDelete : 'spDelete' + entity.BaseTableCodeName;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// ─── SQL HEADERS ─────────────────────────────────────────────────────
|
|
840
|
+
/** @inheritdoc */
|
|
841
|
+
generateSQLFileHeader(entity, itemName) {
|
|
842
|
+
return `-----------------------------------------------------------------
|
|
843
|
+
-- SQL Code Generation
|
|
844
|
+
-- Entity: ${entity.Name}
|
|
845
|
+
-- Item: ${itemName}
|
|
846
|
+
--
|
|
847
|
+
-- This was generated by the MemberJunction CodeGen tool.
|
|
848
|
+
-- This file should NOT be edited by hand.
|
|
849
|
+
-----------------------------------------------------------------
|
|
850
|
+
`;
|
|
851
|
+
}
|
|
852
|
+
/** @inheritdoc */
|
|
853
|
+
generateAllEntitiesSQLFileHeader() {
|
|
854
|
+
return `-----------------------------------------------------------------
|
|
855
|
+
-- SQL Code Generation for Entities
|
|
856
|
+
--
|
|
857
|
+
-- This file contains the SQL code for the entities in the database
|
|
858
|
+
-- that are included in the API and have generated SQL elements like views and
|
|
859
|
+
-- stored procedures.
|
|
860
|
+
--
|
|
861
|
+
-- It is generated by the MemberJunction CodeGen tool.
|
|
862
|
+
-- It is not intended to be edited by hand.
|
|
863
|
+
-----------------------------------------------------------------
|
|
864
|
+
`;
|
|
865
|
+
}
|
|
866
|
+
// ─── UTILITY ─────────────────────────────────────────────────────────
|
|
867
|
+
/**
|
|
868
|
+
* Formats a raw default value string for embedding in generated SQL. Recognizes common
|
|
869
|
+
* SQL Server functions (`NEWID()`, `NEWSEQUENTIALID()`, `GETDATE()`, `GETUTCDATE()`,
|
|
870
|
+
* `SYSDATETIME()`, `SYSDATETIMEOFFSET()`, `CURRENT_TIMESTAMP`, `USER_NAME()`,
|
|
871
|
+
* `SUSER_NAME()`, `SYSTEM_USER`) and strips wrapping parentheses from them. For literal
|
|
872
|
+
* values, strips surrounding single quotes and re-applies them only if `needsQuotes`
|
|
873
|
+
* is true. Returns `'NULL'` for empty or whitespace-only inputs.
|
|
874
|
+
*/
|
|
875
|
+
formatDefaultValue(defaultValue, needsQuotes) {
|
|
876
|
+
if (!defaultValue || defaultValue.trim().length === 0) {
|
|
877
|
+
return 'NULL';
|
|
878
|
+
}
|
|
879
|
+
let trimmedValue = defaultValue.trim();
|
|
880
|
+
const lowerValue = trimmedValue.toLowerCase();
|
|
881
|
+
const sqlFunctions = [
|
|
882
|
+
'newid()', 'newsequentialid()', 'getdate()', 'getutcdate()',
|
|
883
|
+
'sysdatetime()', 'sysdatetimeoffset()', 'current_timestamp',
|
|
884
|
+
'user_name()', 'suser_name()', 'system_user'
|
|
885
|
+
];
|
|
886
|
+
for (const func of sqlFunctions) {
|
|
887
|
+
if (lowerValue.includes(func)) {
|
|
888
|
+
if (trimmedValue.startsWith('(') && trimmedValue.endsWith(')')) {
|
|
889
|
+
trimmedValue = trimmedValue.substring(1, trimmedValue.length - 1);
|
|
890
|
+
}
|
|
891
|
+
return trimmedValue;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
let cleanValue = trimmedValue;
|
|
895
|
+
if (cleanValue.startsWith("'") && cleanValue.endsWith("'")) {
|
|
896
|
+
cleanValue = cleanValue.substring(1, cleanValue.length - 1);
|
|
897
|
+
}
|
|
898
|
+
if (needsQuotes) {
|
|
899
|
+
return `'${cleanValue}'`;
|
|
900
|
+
}
|
|
901
|
+
return cleanValue;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Builds the four SQL fragments needed to work with an entity's primary key columns
|
|
905
|
+
* in cursor-based cascade operations:
|
|
906
|
+
*
|
|
907
|
+
* - `varDeclarations`: `@{prefix}{PK} TYPE` variable declarations for `DECLARE`.
|
|
908
|
+
* - `selectFields`: Bracketed column names for the cursor `SELECT`.
|
|
909
|
+
* - `fetchInto`: `@{prefix}{PK}` variable references for `FETCH INTO`.
|
|
910
|
+
* - `routineParams`: Named parameter assignments (`@PK = @{prefix}{PK}`) for `EXEC`.
|
|
911
|
+
*
|
|
912
|
+
* Supports composite primary keys by iterating all PK fields. The optional `prefix`
|
|
913
|
+
* defaults to `'Related'` to avoid variable name collisions in nested cursor blocks.
|
|
914
|
+
*/
|
|
915
|
+
buildPrimaryKeyComponents(entity, prefix) {
|
|
916
|
+
let varDeclarations = '';
|
|
917
|
+
let selectFields = '';
|
|
918
|
+
let fetchInto = '';
|
|
919
|
+
let routineParams = '';
|
|
920
|
+
const varPrefix = prefix || 'Related';
|
|
921
|
+
for (const pk of entity.PrimaryKeys) {
|
|
922
|
+
if (varDeclarations !== '')
|
|
923
|
+
varDeclarations += ', ';
|
|
924
|
+
varDeclarations += `@${varPrefix}${pk.CodeName} ${pk.SQLFullType}`;
|
|
925
|
+
if (selectFields !== '')
|
|
926
|
+
selectFields += ', ';
|
|
927
|
+
selectFields += `[${pk.Name}]`;
|
|
928
|
+
if (fetchInto !== '')
|
|
929
|
+
fetchInto += ', ';
|
|
930
|
+
fetchInto += `@${varPrefix}${pk.CodeName}`;
|
|
931
|
+
if (routineParams !== '')
|
|
932
|
+
routineParams += ', ';
|
|
933
|
+
routineParams += `@${pk.CodeName} = @${varPrefix}${pk.CodeName}`;
|
|
934
|
+
}
|
|
935
|
+
return { varDeclarations, selectFields, fetchInto, routineParams };
|
|
936
|
+
}
|
|
937
|
+
// ─── DATABASE INTROSPECTION ──────────────────────────────────────────
|
|
938
|
+
/** @inheritdoc */
|
|
939
|
+
getViewDefinitionSQL(schema, viewName) {
|
|
940
|
+
return `SELECT OBJECT_DEFINITION(OBJECT_ID('[${schema}].[${viewName}]')) AS ViewDefinition`;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Returns a SQL query that retrieves the primary key index name for a table by joining
|
|
944
|
+
* `sys.indexes`, `sys.objects`, and `sys.key_constraints` and filtering on constraint
|
|
945
|
+
* type `'PK'`. The result column is named `IndexName` as expected by the orchestrator.
|
|
946
|
+
*/
|
|
947
|
+
getPrimaryKeyIndexNameSQL(schema, tableName) {
|
|
948
|
+
return `SELECT
|
|
949
|
+
i.name AS IndexName
|
|
950
|
+
FROM
|
|
951
|
+
sys.indexes i
|
|
952
|
+
INNER JOIN
|
|
953
|
+
sys.objects o ON i.object_id = o.object_id
|
|
954
|
+
INNER JOIN
|
|
955
|
+
sys.key_constraints kc ON i.object_id = kc.parent_object_id AND
|
|
956
|
+
i.index_id = kc.unique_index_id
|
|
957
|
+
WHERE
|
|
958
|
+
o.name = '${tableName}' AND
|
|
959
|
+
o.schema_id = SCHEMA_ID('${schema}') AND
|
|
960
|
+
kc.type = 'PK'`;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Returns a SQL query that checks whether a column participates in a composite (multi-column)
|
|
964
|
+
* unique constraint. Queries `sys.indexes`, `sys.index_columns`, and `sys.columns` to find
|
|
965
|
+
* non-primary-key unique indexes that include the specified column AND have more than one
|
|
966
|
+
* column. Returns rows if the column is part of such a constraint; the orchestrator checks
|
|
967
|
+
* `recordset.length > 0`.
|
|
968
|
+
*/
|
|
969
|
+
getCompositeUniqueConstraintCheckSQL(schema, tableName, columnName) {
|
|
970
|
+
return `SELECT i.index_id
|
|
971
|
+
FROM sys.indexes i
|
|
972
|
+
INNER JOIN sys.tables t ON i.object_id = t.object_id
|
|
973
|
+
INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
|
|
974
|
+
WHERE i.is_unique = 1
|
|
975
|
+
AND i.is_primary_key = 0
|
|
976
|
+
AND s.name = '${schema}'
|
|
977
|
+
AND t.name = '${tableName}'
|
|
978
|
+
AND EXISTS (
|
|
979
|
+
SELECT 1
|
|
980
|
+
FROM sys.index_columns ic
|
|
981
|
+
INNER JOIN sys.columns c ON ic.object_id = c.object_id AND c.column_id = ic.column_id
|
|
982
|
+
WHERE ic.object_id = i.object_id
|
|
983
|
+
AND ic.index_id = i.index_id
|
|
984
|
+
AND c.name = '${columnName}'
|
|
985
|
+
)
|
|
986
|
+
AND (SELECT COUNT(*)
|
|
987
|
+
FROM sys.index_columns ic2
|
|
988
|
+
WHERE ic2.object_id = i.object_id
|
|
989
|
+
AND ic2.index_id = i.index_id) > 1`;
|
|
990
|
+
}
|
|
991
|
+
/** @inheritdoc */
|
|
992
|
+
getForeignKeyIndexExistsSQL(schema, tableName, indexName) {
|
|
993
|
+
return `SELECT 1
|
|
994
|
+
FROM sys.indexes
|
|
995
|
+
WHERE name = '${indexName}'
|
|
996
|
+
AND object_id = OBJECT_ID('[${schema}].[${tableName}]')`;
|
|
997
|
+
}
|
|
998
|
+
// ─── METADATA MANAGEMENT: STORED PROCEDURE CALLS ─────────────────
|
|
999
|
+
/**
|
|
1000
|
+
* Builds a SQL Server `EXEC` statement for invoking a stored procedure. When `paramNames`
|
|
1001
|
+
* are provided, generates named parameter syntax (`@ParamName=value`); otherwise uses
|
|
1002
|
+
* positional parameter values. Returns just `EXEC [schema].[routine]` if no params.
|
|
1003
|
+
*/
|
|
1004
|
+
callRoutineSQL(schema, routineName, params, paramNames) {
|
|
1005
|
+
const qualifiedName = `[${schema}].[${routineName}]`;
|
|
1006
|
+
if (!params || params.length === 0) {
|
|
1007
|
+
return `EXEC ${qualifiedName}`;
|
|
1008
|
+
}
|
|
1009
|
+
const paramParts = params.map((val, i) => {
|
|
1010
|
+
if (paramNames && paramNames[i]) {
|
|
1011
|
+
return `@${paramNames[i]}=${val}`;
|
|
1012
|
+
}
|
|
1013
|
+
return val;
|
|
1014
|
+
});
|
|
1015
|
+
return `EXEC ${qualifiedName} ${paramParts.join(', ')}`;
|
|
1016
|
+
}
|
|
1017
|
+
// ─── METADATA MANAGEMENT: CONDITIONAL INSERT ─────────────────────
|
|
1018
|
+
/** @inheritdoc */
|
|
1019
|
+
conditionalInsertSQL(checkQuery, insertSQL) {
|
|
1020
|
+
return `IF NOT EXISTS (\n ${checkQuery}\n )\n BEGIN\n ${insertSQL}\n END`;
|
|
1021
|
+
}
|
|
1022
|
+
/** @inheritdoc */
|
|
1023
|
+
wrapInsertWithConflictGuard(conflictCheckSQL) {
|
|
1024
|
+
return {
|
|
1025
|
+
prefix: `IF NOT EXISTS (${conflictCheckSQL}) BEGIN`,
|
|
1026
|
+
suffix: `END`
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
// ─── METADATA MANAGEMENT: DDL OPERATIONS ─────────────────────────
|
|
1030
|
+
/** @inheritdoc */
|
|
1031
|
+
addColumnSQL(schema, tableName, columnName, dataType, nullable, defaultExpression) {
|
|
1032
|
+
const nullClause = nullable ? 'NULL' : 'NOT NULL';
|
|
1033
|
+
const defaultClause = defaultExpression ? ` DEFAULT ${defaultExpression}` : '';
|
|
1034
|
+
return `ALTER TABLE [${schema}].[${tableName}] ADD ${columnName} ${dataType} ${nullClause}${defaultClause}`;
|
|
1035
|
+
}
|
|
1036
|
+
/** @inheritdoc */
|
|
1037
|
+
alterColumnTypeAndNullabilitySQL(schema, tableName, columnName, dataType, nullable) {
|
|
1038
|
+
const nullClause = nullable ? 'NULL' : 'NOT NULL';
|
|
1039
|
+
return `ALTER TABLE [${schema}].[${tableName}] ALTER COLUMN ${columnName} ${dataType} ${nullClause}`;
|
|
1040
|
+
}
|
|
1041
|
+
/** @inheritdoc */
|
|
1042
|
+
addDefaultConstraintSQL(schema, tableName, columnName, defaultExpression) {
|
|
1043
|
+
const constraintName = `DF_${schema}_${CodeNameFromString(tableName)}_${columnName}`;
|
|
1044
|
+
return `ALTER TABLE [${schema}].[${tableName}] ADD CONSTRAINT ${constraintName} DEFAULT ${defaultExpression} FOR [${columnName}]`;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Generates SQL to dynamically find and drop the default constraint on a column.
|
|
1048
|
+
* Unlike PostgreSQL's simple `ALTER COLUMN DROP DEFAULT`, SQL Server requires looking
|
|
1049
|
+
* up the constraint name from `sys.default_constraints` joined through `sys.tables`,
|
|
1050
|
+
* `sys.schemas`, and `sys.columns`, then executing a dynamic `ALTER TABLE DROP CONSTRAINT`
|
|
1051
|
+
* via `EXEC()`. The generated SQL is safe to run even if no default constraint exists
|
|
1052
|
+
* (guarded by `IF @constraintName IS NOT NULL`).
|
|
1053
|
+
*/
|
|
1054
|
+
dropDefaultConstraintSQL(schema, tableName, columnName) {
|
|
1055
|
+
return `DECLARE @constraintName NVARCHAR(255);
|
|
1056
|
+
|
|
1057
|
+
SELECT @constraintName = d.name
|
|
1058
|
+
FROM sys.tables t
|
|
1059
|
+
JOIN sys.schemas s ON t.schema_id = s.schema_id
|
|
1060
|
+
JOIN sys.columns c ON t.object_id = c.object_id
|
|
1061
|
+
JOIN sys.default_constraints d ON c.default_object_id = d.object_id
|
|
1062
|
+
WHERE s.name = '${schema}'
|
|
1063
|
+
AND t.name = '${tableName}'
|
|
1064
|
+
AND c.name = '${columnName}';
|
|
1065
|
+
|
|
1066
|
+
IF @constraintName IS NOT NULL
|
|
1067
|
+
BEGIN
|
|
1068
|
+
EXEC('ALTER TABLE [${schema}].[${tableName}] DROP CONSTRAINT ' + @constraintName);
|
|
1069
|
+
END`;
|
|
1070
|
+
}
|
|
1071
|
+
/** @inheritdoc */
|
|
1072
|
+
dropObjectSQL(objectType, schema, name) {
|
|
1073
|
+
const typeCode = objectType === 'PROCEDURE' ? 'P' : objectType === 'VIEW' ? 'V' : 'FN';
|
|
1074
|
+
return `IF OBJECT_ID('[${schema}].[${name}]', '${typeCode}') IS NOT NULL\n DROP ${objectType} [${schema}].[${name}]`;
|
|
1075
|
+
}
|
|
1076
|
+
// ─── METADATA MANAGEMENT: VIEW INTROSPECTION ─────────────────────
|
|
1077
|
+
/** @inheritdoc */
|
|
1078
|
+
getViewExistsSQL() {
|
|
1079
|
+
return `SELECT 1 FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_NAME = @ViewName AND TABLE_SCHEMA = @SchemaName`;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Returns a SQL query that retrieves column metadata for a view by joining `sys.columns`,
|
|
1083
|
+
* `sys.types`, `sys.views`, and `sys.schemas`. Returns columns named `FieldName`, `Type`,
|
|
1084
|
+
* `Length`, `Precision`, `Scale`, and `AllowsNull`, ordered by `column_id` to preserve
|
|
1085
|
+
* the original column definition order.
|
|
1086
|
+
*/
|
|
1087
|
+
getViewColumnsSQL(schema, viewName) {
|
|
1088
|
+
return `SELECT
|
|
1089
|
+
c.name AS FieldName,
|
|
1090
|
+
t.name AS Type,
|
|
1091
|
+
c.max_length AS Length,
|
|
1092
|
+
c.precision AS Precision,
|
|
1093
|
+
c.scale AS Scale,
|
|
1094
|
+
c.is_nullable AS AllowsNull
|
|
1095
|
+
FROM
|
|
1096
|
+
sys.columns c
|
|
1097
|
+
INNER JOIN
|
|
1098
|
+
sys.types t ON c.user_type_id = t.user_type_id
|
|
1099
|
+
INNER JOIN
|
|
1100
|
+
sys.views v ON c.object_id = v.object_id
|
|
1101
|
+
INNER JOIN
|
|
1102
|
+
sys.schemas s ON v.schema_id = s.schema_id
|
|
1103
|
+
WHERE
|
|
1104
|
+
s.name = '${schema}'
|
|
1105
|
+
AND v.name = '${viewName}'
|
|
1106
|
+
ORDER BY
|
|
1107
|
+
c.column_id`;
|
|
1108
|
+
}
|
|
1109
|
+
// ─── METADATA MANAGEMENT: TYPE SYSTEM ────────────────────────────
|
|
1110
|
+
/** @inheritdoc */
|
|
1111
|
+
get TimestampType() {
|
|
1112
|
+
return 'DATETIMEOFFSET';
|
|
1113
|
+
}
|
|
1114
|
+
/** @inheritdoc */
|
|
1115
|
+
compareDataTypes(reported, expected) {
|
|
1116
|
+
return reported.trim().toLowerCase() === expected.trim().toLowerCase();
|
|
1117
|
+
}
|
|
1118
|
+
// ─── METADATA MANAGEMENT: PLATFORM CONFIGURATION ─────────────────
|
|
1119
|
+
/** @inheritdoc */
|
|
1120
|
+
getSystemSchemasToExclude() {
|
|
1121
|
+
return [];
|
|
1122
|
+
}
|
|
1123
|
+
/** @inheritdoc */
|
|
1124
|
+
get NeedsViewRefresh() {
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
/** @inheritdoc */
|
|
1128
|
+
generateViewRefreshSQL(schema, viewName) {
|
|
1129
|
+
return `EXEC sp_refreshview '${schema}.${viewName}';`;
|
|
1130
|
+
}
|
|
1131
|
+
/** @inheritdoc */
|
|
1132
|
+
generateViewTestQuerySQL(schema, viewName) {
|
|
1133
|
+
return `SELECT TOP 1 * FROM [${schema}].[${viewName}]`;
|
|
1134
|
+
}
|
|
1135
|
+
/** @inheritdoc */
|
|
1136
|
+
get NeedsVirtualFieldNullabilityFix() {
|
|
1137
|
+
return false;
|
|
1138
|
+
}
|
|
1139
|
+
// ─── METADATA MANAGEMENT: SQL QUOTING ────────────────────────────
|
|
1140
|
+
/** @inheritdoc */
|
|
1141
|
+
quoteSQLForExecution(sql) {
|
|
1142
|
+
return sql;
|
|
1143
|
+
}
|
|
1144
|
+
// ─── METADATA MANAGEMENT: DEFAULT VALUE PARSING ──────────────────
|
|
1145
|
+
/**
|
|
1146
|
+
* Parses a raw SQL Server column default value from the system catalog into a clean form.
|
|
1147
|
+
* SQL Server wraps defaults in parentheses (e.g., `(getdate())`, `((1))`, `(N'foo')`),
|
|
1148
|
+
* so this method applies three successive stripping passes:
|
|
1149
|
+
*
|
|
1150
|
+
* 1. Strips one layer of wrapping parentheses.
|
|
1151
|
+
* 2. Strips the `N'...'` Unicode string prefix.
|
|
1152
|
+
* 3. Strips surrounding single quotes from plain string literals.
|
|
1153
|
+
*
|
|
1154
|
+
* Returns `null` if the input is null or undefined.
|
|
1155
|
+
*/
|
|
1156
|
+
parseColumnDefaultValue(sqlDefaultValue) {
|
|
1157
|
+
if (sqlDefaultValue === null || sqlDefaultValue === undefined) {
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
let result = this.stripWrappingParentheses(sqlDefaultValue);
|
|
1161
|
+
result = this.stripNPrefixQuotes(result);
|
|
1162
|
+
result = this.stripSingleQuotes(result);
|
|
1163
|
+
return result;
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Strips wrapping parentheses from a SQL Server default value.
|
|
1167
|
+
* Example: `(getdate())` becomes `getdate()`, `((1))` becomes `(1)`.
|
|
1168
|
+
*/
|
|
1169
|
+
stripWrappingParentheses(value) {
|
|
1170
|
+
if (value.startsWith('(') && value.endsWith(')')) {
|
|
1171
|
+
return value.substring(1, value.length - 1);
|
|
1172
|
+
}
|
|
1173
|
+
return value;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Strips the N'' prefix from a SQL Server Unicode string literal.
|
|
1177
|
+
* Example: `N'SomeValue'` becomes `SomeValue`.
|
|
1178
|
+
*/
|
|
1179
|
+
stripNPrefixQuotes(value) {
|
|
1180
|
+
if (value.toUpperCase().startsWith("N'") && value.endsWith("'")) {
|
|
1181
|
+
return value.substring(2, value.length - 1);
|
|
1182
|
+
}
|
|
1183
|
+
return value;
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Strips surrounding single quotes from a value.
|
|
1187
|
+
* Example: `'SomeValue'` becomes `SomeValue`.
|
|
1188
|
+
*/
|
|
1189
|
+
stripSingleQuotes(value) {
|
|
1190
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
1191
|
+
return value.substring(1, value.length - 1);
|
|
1192
|
+
}
|
|
1193
|
+
return value;
|
|
1194
|
+
}
|
|
1195
|
+
// ─── METADATA MANAGEMENT: COMPLEX SQL GENERATION ─────────────────
|
|
1196
|
+
/**
|
|
1197
|
+
* Generates the full SQL query to retrieve entity fields that exist in the database schema
|
|
1198
|
+
* but are not yet registered in MJ metadata. The query is assembled from three parts:
|
|
1199
|
+
*
|
|
1200
|
+
* 1. **Temp table materialization**: Copies `vwForeignKeys`, `vwTablePrimaryKeys`, and
|
|
1201
|
+
* `vwTableUniqueKeys` into temp tables so SQL Server can build real statistics instead
|
|
1202
|
+
* of expanding nested view-on-view joins with bad cardinality estimates.
|
|
1203
|
+
* 2. **Main CTE query**: Uses `MaxSequences` CTE to calculate proper field ordering and
|
|
1204
|
+
* `NumberedRows` CTE to deduplicate results, joining against the temp tables for FK,
|
|
1205
|
+
* PK, and unique key detection.
|
|
1206
|
+
* 3. **Cleanup**: Drops the temp tables.
|
|
1207
|
+
*/
|
|
1208
|
+
getPendingEntityFieldsSQL(mjCoreSchema) {
|
|
1209
|
+
return this.buildPendingFieldsTempTables(mjCoreSchema) +
|
|
1210
|
+
this.buildPendingFieldsMainQuery(mjCoreSchema) +
|
|
1211
|
+
this.buildPendingFieldsCleanup();
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Builds the temp table materialization statements for the pending entity fields query.
|
|
1215
|
+
* Materializes system DMV views into temp tables for optimal SQL Server query plan statistics.
|
|
1216
|
+
*/
|
|
1217
|
+
buildPendingFieldsTempTables(schema) {
|
|
1218
|
+
return `
|
|
1219
|
+
-- Materialize system DMV views into temp tables so SQL Server gets real statistics
|
|
1220
|
+
-- instead of expanding nested view-on-view joins with bad cardinality estimates
|
|
1221
|
+
-- Drop first in case a prior run on this connection left them behind
|
|
1222
|
+
IF OBJECT_ID('tempdb..#__mj__CodeGen__vwForeignKeys') IS NOT NULL DROP TABLE #__mj__CodeGen__vwForeignKeys;
|
|
1223
|
+
IF OBJECT_ID('tempdb..#__mj__CodeGen__vwTablePrimaryKeys') IS NOT NULL DROP TABLE #__mj__CodeGen__vwTablePrimaryKeys;
|
|
1224
|
+
IF OBJECT_ID('tempdb..#__mj__CodeGen__vwTableUniqueKeys') IS NOT NULL DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
|
|
1225
|
+
|
|
1226
|
+
SELECT [column], [table], [schema_name], referenced_table, referenced_column, [referenced_schema]
|
|
1227
|
+
INTO #__mj__CodeGen__vwForeignKeys
|
|
1228
|
+
FROM [${schema}].[vwForeignKeys];
|
|
1229
|
+
|
|
1230
|
+
SELECT TableName, ColumnName, SchemaName
|
|
1231
|
+
INTO #__mj__CodeGen__vwTablePrimaryKeys
|
|
1232
|
+
FROM [${schema}].[vwTablePrimaryKeys];
|
|
1233
|
+
|
|
1234
|
+
SELECT TableName, ColumnName, SchemaName
|
|
1235
|
+
INTO #__mj__CodeGen__vwTableUniqueKeys
|
|
1236
|
+
FROM [${schema}].[vwTableUniqueKeys];
|
|
1237
|
+
`;
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Builds the main CTE query for finding pending entity fields.
|
|
1241
|
+
* Uses MaxSequences CTE to calculate proper field ordering and NumberedRows
|
|
1242
|
+
* CTE to deduplicate results.
|
|
1243
|
+
*/
|
|
1244
|
+
buildPendingFieldsMainQuery(schema) {
|
|
1245
|
+
return `WITH MaxSequences AS (
|
|
1246
|
+
SELECT
|
|
1247
|
+
EntityID,
|
|
1248
|
+
ISNULL(MAX(Sequence), 0) AS MaxSequence
|
|
1249
|
+
FROM
|
|
1250
|
+
[${schema}].[EntityField]
|
|
1251
|
+
GROUP BY
|
|
1252
|
+
EntityID
|
|
1253
|
+
),
|
|
1254
|
+
NumberedRows AS (
|
|
1255
|
+
SELECT
|
|
1256
|
+
sf.EntityID,
|
|
1257
|
+
ISNULL(ms.MaxSequence, 0) + 100000 + sf.Sequence AS Sequence,
|
|
1258
|
+
sf.FieldName,
|
|
1259
|
+
sf.Description,
|
|
1260
|
+
sf.Type,
|
|
1261
|
+
sf.Length,
|
|
1262
|
+
sf.Precision,
|
|
1263
|
+
sf.Scale,
|
|
1264
|
+
sf.AllowsNull,
|
|
1265
|
+
sf.DefaultValue,
|
|
1266
|
+
sf.AutoIncrement,
|
|
1267
|
+
IIF(sf.IsVirtual = 1, 0, IIF(sf.FieldName = '${EntityInfo.CreatedAtFieldName}' OR
|
|
1268
|
+
sf.FieldName = '${EntityInfo.UpdatedAtFieldName}' OR
|
|
1269
|
+
sf.FieldName = '${EntityInfo.DeletedAtFieldName}' OR
|
|
1270
|
+
pk.ColumnName IS NOT NULL, 0, 1)) AllowUpdateAPI,
|
|
1271
|
+
sf.IsVirtual,
|
|
1272
|
+
e.RelationshipDefaultDisplayType,
|
|
1273
|
+
e.Name EntityName,
|
|
1274
|
+
re.ID RelatedEntityID,
|
|
1275
|
+
fk.referenced_column RelatedEntityFieldName,
|
|
1276
|
+
IIF(sf.FieldName = 'Name', 1, 0) IsNameField,
|
|
1277
|
+
IsPrimaryKey = CASE WHEN pk.ColumnName IS NOT NULL THEN 1 ELSE 0 END,
|
|
1278
|
+
IsUnique = CASE
|
|
1279
|
+
WHEN pk.ColumnName IS NOT NULL THEN 1
|
|
1280
|
+
WHEN uk.ColumnName IS NOT NULL THEN 1
|
|
1281
|
+
ELSE 0
|
|
1282
|
+
END,
|
|
1283
|
+
ROW_NUMBER() OVER (PARTITION BY sf.EntityID, sf.FieldName ORDER BY (SELECT NULL)) AS rn
|
|
1284
|
+
FROM
|
|
1285
|
+
[${schema}].[vwSQLColumnsAndEntityFields] sf
|
|
1286
|
+
LEFT OUTER JOIN
|
|
1287
|
+
MaxSequences ms ON sf.EntityID = ms.EntityID
|
|
1288
|
+
LEFT OUTER JOIN
|
|
1289
|
+
[${schema}].[Entity] e ON sf.EntityID = e.ID
|
|
1290
|
+
LEFT OUTER JOIN
|
|
1291
|
+
#__mj__CodeGen__vwForeignKeys fk
|
|
1292
|
+
ON sf.FieldName = fk.[column] AND e.BaseTable = fk.[table] AND e.SchemaName = fk.[schema_name]
|
|
1293
|
+
LEFT OUTER JOIN
|
|
1294
|
+
[${schema}].[Entity] re ON re.BaseTable = fk.referenced_table AND re.SchemaName = fk.[referenced_schema]
|
|
1295
|
+
LEFT OUTER JOIN
|
|
1296
|
+
#__mj__CodeGen__vwTablePrimaryKeys pk
|
|
1297
|
+
ON e.BaseTable = pk.TableName AND sf.FieldName = pk.ColumnName AND e.SchemaName = pk.SchemaName
|
|
1298
|
+
LEFT OUTER JOIN
|
|
1299
|
+
#__mj__CodeGen__vwTableUniqueKeys uk
|
|
1300
|
+
ON e.BaseTable = uk.TableName AND sf.FieldName = uk.ColumnName AND e.SchemaName = uk.SchemaName
|
|
1301
|
+
WHERE
|
|
1302
|
+
EntityFieldID IS NULL
|
|
1303
|
+
)
|
|
1304
|
+
SELECT *
|
|
1305
|
+
FROM NumberedRows
|
|
1306
|
+
WHERE rn = 1
|
|
1307
|
+
ORDER BY EntityID, Sequence;
|
|
1308
|
+
`;
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Builds the cleanup statements to drop temp tables after the pending fields query.
|
|
1312
|
+
*/
|
|
1313
|
+
buildPendingFieldsCleanup() {
|
|
1314
|
+
return `
|
|
1315
|
+
DROP TABLE #__mj__CodeGen__vwForeignKeys;
|
|
1316
|
+
DROP TABLE #__mj__CodeGen__vwTablePrimaryKeys;
|
|
1317
|
+
DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
|
|
1318
|
+
`;
|
|
1319
|
+
}
|
|
1320
|
+
/** @inheritdoc */
|
|
1321
|
+
getCheckConstraintsSchemaFilter(excludeSchemas) {
|
|
1322
|
+
if (!excludeSchemas || excludeSchemas.length === 0) {
|
|
1323
|
+
return '';
|
|
1324
|
+
}
|
|
1325
|
+
const quotedSchemas = excludeSchemas.map(s => `'${s}'`).join(',');
|
|
1326
|
+
return ` WHERE SchemaName NOT IN (${quotedSchemas})`;
|
|
1327
|
+
}
|
|
1328
|
+
/** @inheritdoc */
|
|
1329
|
+
getEntitiesWithMissingBaseTablesFilter() {
|
|
1330
|
+
return ` WHERE VirtualEntity=0`;
|
|
1331
|
+
}
|
|
1332
|
+
/** @inheritdoc */
|
|
1333
|
+
getFixVirtualFieldNullabilitySQL(_mjCoreSchema) {
|
|
1334
|
+
return '';
|
|
1335
|
+
}
|
|
1336
|
+
// ─── METADATA MANAGEMENT: SQL FILE EXECUTION ─────────────────────
|
|
1337
|
+
/**
|
|
1338
|
+
* Executes a SQL file against SQL Server using the `sqlcmd` command-line utility. Reads
|
|
1339
|
+
* connection details (server, port, instance, user, password, database) from the shared
|
|
1340
|
+
* `sqlConfig` object. On Windows, converts file paths containing spaces to 8.3 short
|
|
1341
|
+
* format to avoid quoting issues with `sqlcmd`. Spawns the process with `QUOTED_IDENTIFIER`
|
|
1342
|
+
* enabled (`-I`) and severity threshold 17 (`-V 17`). Optionally adds `-C` for
|
|
1343
|
+
* `trustServerCertificate`. Returns `true` on successful execution, `false` on failure.
|
|
1344
|
+
*/
|
|
1345
|
+
async executeSQLFileViaShell(filePath) {
|
|
1346
|
+
try {
|
|
1347
|
+
this.validateSqlConfig();
|
|
1348
|
+
const serverSpec = this.buildServerSpec();
|
|
1349
|
+
const absoluteFilePath = this.resolveAndShortPathConvert(filePath);
|
|
1350
|
+
const args = this.buildSqlcmdArgs(serverSpec, absoluteFilePath);
|
|
1351
|
+
return await this.spawnSqlcmd(args, filePath);
|
|
1352
|
+
}
|
|
1353
|
+
catch (e) {
|
|
1354
|
+
this.logSqlcmdError(e);
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Validates that required SQL Server connection configuration is present.
|
|
1360
|
+
*/
|
|
1361
|
+
validateSqlConfig() {
|
|
1362
|
+
if (sqlConfig.user === undefined || sqlConfig.password === undefined || sqlConfig.database === undefined) {
|
|
1363
|
+
throw new Error('SQL Server user, password, and database must be provided in the configuration');
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Builds the server specification string for sqlcmd (server[,port][\instance]).
|
|
1368
|
+
*/
|
|
1369
|
+
buildServerSpec() {
|
|
1370
|
+
let serverSpec = sqlConfig.server;
|
|
1371
|
+
if (sqlConfig.port) {
|
|
1372
|
+
serverSpec += `,${sqlConfig.port}`;
|
|
1373
|
+
}
|
|
1374
|
+
if (sqlConfig.options?.instanceName) {
|
|
1375
|
+
serverSpec += `\\${sqlConfig.options.instanceName}`;
|
|
1376
|
+
}
|
|
1377
|
+
return serverSpec;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Resolves the file path to absolute and converts to 8.3 short path on Windows if needed.
|
|
1381
|
+
*/
|
|
1382
|
+
resolveAndShortPathConvert(filePath) {
|
|
1383
|
+
const cwd = path.resolve(process.cwd());
|
|
1384
|
+
let absoluteFilePath = path.resolve(cwd, filePath);
|
|
1385
|
+
const isWindows = process.platform === 'win32';
|
|
1386
|
+
if (isWindows && absoluteFilePath.includes(' ')) {
|
|
1387
|
+
absoluteFilePath = this.tryConvertToShortPath(absoluteFilePath);
|
|
1388
|
+
}
|
|
1389
|
+
return absoluteFilePath;
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Attempts to convert a Windows path to 8.3 short format to avoid quoting issues.
|
|
1393
|
+
*/
|
|
1394
|
+
tryConvertToShortPath(absoluteFilePath) {
|
|
1395
|
+
try {
|
|
1396
|
+
const result = execSync(`for %I in ("${absoluteFilePath}") do @echo %~sI`, {
|
|
1397
|
+
encoding: 'utf8',
|
|
1398
|
+
shell: 'cmd.exe'
|
|
1399
|
+
}).trim();
|
|
1400
|
+
if (result && !result.includes('ERROR') && !result.includes('%~sI')) {
|
|
1401
|
+
logIf(configInfo.verboseOutput, `Converted path to short format: ${result}`);
|
|
1402
|
+
return result;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
catch (e) {
|
|
1406
|
+
logIf(configInfo.verboseOutput, `Could not convert to short path, using original: ${e}`);
|
|
1407
|
+
}
|
|
1408
|
+
return absoluteFilePath;
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Builds the argument array for the sqlcmd CLI tool.
|
|
1412
|
+
*/
|
|
1413
|
+
buildSqlcmdArgs(serverSpec, absoluteFilePath) {
|
|
1414
|
+
const args = [
|
|
1415
|
+
'-S', serverSpec,
|
|
1416
|
+
'-U', sqlConfig.user,
|
|
1417
|
+
'-P', sqlConfig.password,
|
|
1418
|
+
'-d', sqlConfig.database,
|
|
1419
|
+
'-I', // Enable QUOTED_IDENTIFIER
|
|
1420
|
+
'-V', '17', // Only fail on severity >= 17
|
|
1421
|
+
'-i', absoluteFilePath
|
|
1422
|
+
];
|
|
1423
|
+
if (sqlConfig.options?.trustServerCertificate) {
|
|
1424
|
+
args.push('-C');
|
|
1425
|
+
}
|
|
1426
|
+
return args;
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Spawns the sqlcmd process and waits for completion.
|
|
1430
|
+
*/
|
|
1431
|
+
async spawnSqlcmd(args, filePath) {
|
|
1432
|
+
logIf(configInfo.verboseOutput, `Executing SQL file: ${filePath} as ${sqlConfig.user}@${sqlConfig.server}:${sqlConfig.port}/${sqlConfig.database}`);
|
|
1433
|
+
const isWindows = process.platform === 'win32';
|
|
1434
|
+
const sqlcmdCommand = isWindows ? 'sqlcmd.exe' : 'sqlcmd';
|
|
1435
|
+
const spawnOptions = { shell: false };
|
|
1436
|
+
if (isWindows) {
|
|
1437
|
+
spawnOptions['windowsVerbatimArguments'] = true;
|
|
1438
|
+
}
|
|
1439
|
+
const result = await new Promise((resolve, reject) => {
|
|
1440
|
+
const child = spawn(sqlcmdCommand, args, spawnOptions);
|
|
1441
|
+
let stdout = '';
|
|
1442
|
+
let stderr = '';
|
|
1443
|
+
child.stdout?.on('data', (data) => { stdout += data.toString(); });
|
|
1444
|
+
child.stderr?.on('data', (data) => { stderr += data.toString(); });
|
|
1445
|
+
child.on('error', (error) => { reject(error); });
|
|
1446
|
+
child.on('close', (code) => {
|
|
1447
|
+
if (code === 0) {
|
|
1448
|
+
resolve({ stdout, stderr });
|
|
1449
|
+
}
|
|
1450
|
+
else {
|
|
1451
|
+
const error = new Error(`sqlcmd exited with code ${code}`);
|
|
1452
|
+
Object.assign(error, { stdout, stderr, code });
|
|
1453
|
+
reject(error);
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
});
|
|
1457
|
+
if (result.stdout && result.stdout.trim().length > 0) {
|
|
1458
|
+
logWarning(`SQL Server message: ${result.stdout.trim()}`);
|
|
1459
|
+
}
|
|
1460
|
+
if (result.stderr && result.stderr.trim().length > 0) {
|
|
1461
|
+
logWarning(`SQL Server stderr: ${result.stderr.trim()}`);
|
|
1462
|
+
}
|
|
1463
|
+
return true;
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Logs a sqlcmd execution error, masking the password in the output.
|
|
1467
|
+
*/
|
|
1468
|
+
logSqlcmdError(e) {
|
|
1469
|
+
const errRecord = e;
|
|
1470
|
+
let message = (e instanceof Error) ? e.message : String(e);
|
|
1471
|
+
if (errRecord['stdout']) {
|
|
1472
|
+
message += `\n SQL Server message: ${errRecord['stdout']}`;
|
|
1473
|
+
}
|
|
1474
|
+
if (errRecord['stderr']) {
|
|
1475
|
+
message += `\n SQL Server error: ${errRecord['stderr']}`;
|
|
1476
|
+
}
|
|
1477
|
+
const errorMessage = sqlConfig.password
|
|
1478
|
+
? message.replace(sqlConfig.password, 'XXXXX')
|
|
1479
|
+
: message;
|
|
1480
|
+
logError('Error executing batch SQL file: ' + errorMessage);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
//# sourceMappingURL=SQLServerCodeGenProvider.js.map
|