@memberjunction/codegen-lib 6.1.0-edge.1 → 6.1.0-edge.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +180 -4
  2. package/README.md +39 -22
  3. package/dist/Angular/angular-codegen.d.ts +0 -8
  4. package/dist/Angular/angular-codegen.d.ts.map +1 -1
  5. package/dist/Angular/angular-codegen.js +32 -22
  6. package/dist/Angular/angular-codegen.js.map +1 -1
  7. package/dist/Angular/entity-data-grid-related-entity-component.js +2 -2
  8. package/dist/Angular/entity-data-grid-related-entity-component.js.map +1 -1
  9. package/dist/Angular/related-entity-components.d.ts +1 -1
  10. package/dist/Angular/related-entity-components.js +1 -1
  11. package/dist/Database/codeGenDatabaseProvider.d.ts +107 -1
  12. package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
  13. package/dist/Database/codeGenDatabaseProvider.js +77 -10
  14. package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
  15. package/dist/Database/manage-metadata.d.ts +354 -14
  16. package/dist/Database/manage-metadata.d.ts.map +1 -1
  17. package/dist/Database/manage-metadata.js +1308 -65
  18. package/dist/Database/manage-metadata.js.map +1 -1
  19. package/dist/Database/materializationAnalysis.d.ts +241 -0
  20. package/dist/Database/materializationAnalysis.d.ts.map +1 -0
  21. package/dist/Database/materializationAnalysis.js +415 -0
  22. package/dist/Database/materializationAnalysis.js.map +1 -0
  23. package/dist/Database/materializationBroadRender.d.ts +67 -0
  24. package/dist/Database/materializationBroadRender.d.ts.map +1 -0
  25. package/dist/Database/materializationBroadRender.js +104 -0
  26. package/dist/Database/materializationBroadRender.js.map +1 -0
  27. package/dist/Database/materializationDrift.d.ts +59 -0
  28. package/dist/Database/materializationDrift.d.ts.map +1 -0
  29. package/dist/Database/materializationDrift.js +85 -0
  30. package/dist/Database/materializationDrift.js.map +1 -0
  31. package/dist/Database/materializationParamClassifier.d.ts +92 -0
  32. package/dist/Database/materializationParamClassifier.d.ts.map +1 -0
  33. package/dist/Database/materializationParamClassifier.js +224 -0
  34. package/dist/Database/materializationParamClassifier.js.map +1 -0
  35. package/dist/Database/materializationParamVerifier.d.ts +71 -0
  36. package/dist/Database/materializationParamVerifier.d.ts.map +1 -0
  37. package/dist/Database/materializationParamVerifier.js +302 -0
  38. package/dist/Database/materializationParamVerifier.js.map +1 -0
  39. package/dist/Database/materializationSqlAst.d.ts +78 -0
  40. package/dist/Database/materializationSqlAst.d.ts.map +1 -0
  41. package/dist/Database/materializationSqlAst.js +142 -0
  42. package/dist/Database/materializationSqlAst.js.map +1 -0
  43. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +69 -30
  44. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
  45. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +325 -216
  46. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
  47. package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.d.ts +2 -0
  48. package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.d.ts.map +1 -0
  49. package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.js +119 -0
  50. package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.js.map +1 -0
  51. package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.d.ts +2 -0
  52. package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.d.ts.map +1 -0
  53. package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.js +155 -0
  54. package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.js.map +1 -0
  55. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +61 -2
  56. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
  57. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +306 -3
  58. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
  59. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.d.ts +2 -0
  60. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.d.ts.map +1 -0
  61. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js +547 -0
  62. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js.map +1 -0
  63. package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.d.ts +2 -0
  64. package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.d.ts.map +1 -0
  65. package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.js +721 -0
  66. package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.js.map +1 -0
  67. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.d.ts +2 -0
  68. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.d.ts.map +1 -0
  69. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.js +78 -0
  70. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.js.map +1 -0
  71. package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.d.ts +2 -0
  72. package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.d.ts.map +1 -0
  73. package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.js +155 -0
  74. package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.js.map +1 -0
  75. package/dist/Database/sql_codegen.d.ts +21 -5
  76. package/dist/Database/sql_codegen.d.ts.map +1 -1
  77. package/dist/Database/sql_codegen.js +85 -27
  78. package/dist/Database/sql_codegen.js.map +1 -1
  79. package/dist/Misc/action_subclasses_codegen.d.ts.map +1 -1
  80. package/dist/Misc/action_subclasses_codegen.js +4 -4
  81. package/dist/Misc/action_subclasses_codegen.js.map +1 -1
  82. package/dist/Misc/createNewUser.js +1 -1
  83. package/dist/Misc/createNewUser.js.map +1 -1
  84. package/dist/Misc/entity_subclasses_codegen.d.ts +44 -0
  85. package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
  86. package/dist/Misc/entity_subclasses_codegen.js +369 -156
  87. package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
  88. package/dist/Misc/graphql_server_codegen.d.ts +4 -58
  89. package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
  90. package/dist/Misc/graphql_server_codegen.js +22 -236
  91. package/dist/Misc/graphql_server_codegen.js.map +1 -1
  92. package/dist/Misc/sql_logging.d.ts +25 -0
  93. package/dist/Misc/sql_logging.d.ts.map +1 -1
  94. package/dist/Misc/sql_logging.js +51 -8
  95. package/dist/Misc/sql_logging.js.map +1 -1
  96. package/package.json +30 -30
@@ -0,0 +1,721 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { EntityInfo } from '@memberjunction/core';
6
+ import { SQLServerDialect } from '@memberjunction/sql-dialect';
7
+ import { SQLServerCodeGenProvider } from '../SQLServerCodeGenProvider.js';
8
+ import { SQLCodeGenBase } from '../../../sql_codegen.js';
9
+ import { ManageMetadataBase } from '../../../manage-metadata.js';
10
+ import { configInfo } from '../../../../Config/config.js';
11
+ import { SQLLogging } from '../../../../Misc/sql_logging.js';
12
+ import { TempBatchFile } from '../../../../Misc/temp_batch_file.js';
13
+ /**
14
+ * Golden-master suite for the base-view REGENERATION DECISION in SQLCodeGenBase
15
+ * (sql_codegen.ts ~929-1198): the machinery that decides, per entity, whether the
16
+ * base view in the database has drifted from what CodeGen would emit today, and —
17
+ * when it has — force-logs the view plus every routine that compiles it in into the
18
+ * migration log. A wrong "unchanged" silently ships schema drift to every target
19
+ * environment; a wrong "changed" spams a migration file on every CodeGen run.
20
+ *
21
+ * Three layers are pinned, bottom-up:
22
+ * 1. extractViewSelectBody — the normalization contract the comparison rests on
23
+ * 2. checkBaseViewChangedInDB — the drift decision itself (SQL Server text path
24
+ * + platform routing to the PG column-set path)
25
+ * 3. logSQLForNewOrModifiedEntity + generateSingleEntitySQLToSeparateFiles — the
26
+ * force-log gate and its end-to-end wiring (view → permissions → all SPs)
27
+ *
28
+ * Style follows SQLServerCodeGenProvider.test.ts: named golden shapes, explicit
29
+ * inline expected strings, real production code under test (no snapshots).
30
+ */
31
+ // ─── Probe: expose the protected decision internals ──────────────────────────
32
+ class DecisionProbe extends SQLCodeGenBase {
33
+ Check(pool, entity, generatedViewSQL) {
34
+ return this.checkBaseViewChangedInDB(pool, entity, generatedViewSQL);
35
+ }
36
+ Extract(viewSQL) {
37
+ return this.extractViewSelectBody(viewSQL);
38
+ }
39
+ Log(entity, sql, description, logSql, forceLog) {
40
+ this.logSQLForNewOrModifiedEntity(entity, sql, description, logSql, forceLog);
41
+ }
42
+ MarkCascadeDeleteRegen(entityID) {
43
+ this.entitiesNeedingDeleteSPRegeneration.add(entityID);
44
+ }
45
+ RestrictForcedRegenerationTo(entityNames) {
46
+ this.filterEntitiesQualifiedForRegeneration = true;
47
+ this.entitiesQualifiedForForcedRegeneration = entityNames;
48
+ }
49
+ }
50
+ // ─── Mock connection ─────────────────────────────────────────────────────────
51
+ /**
52
+ * Records every query. Answers the SQL Server OBJECT_DEFINITION lookup with the
53
+ * configured stored view definition (null = view missing), and the PG
54
+ * information_schema.columns lookup with the configured column list.
55
+ */
56
+ class MockConnection {
57
+ constructor(storedViewDefinition = null) {
58
+ this.Queries = [];
59
+ this.pgColumns = [];
60
+ this.throwOnQuery = false;
61
+ this.storedViewDefinition = storedViewDefinition;
62
+ }
63
+ get Dialect() {
64
+ return new SQLServerDialect();
65
+ }
66
+ WithPGColumns(columns) {
67
+ this.pgColumns = columns;
68
+ return this;
69
+ }
70
+ ThrowOnQuery() {
71
+ this.throwOnQuery = true;
72
+ return this;
73
+ }
74
+ async query(sql) {
75
+ this.Queries.push(sql);
76
+ if (this.throwOnQuery) {
77
+ throw new Error('simulated database failure');
78
+ }
79
+ if (sql.includes('information_schema.columns')) {
80
+ return { recordset: this.pgColumns.map((c) => ({ column_name: c })) };
81
+ }
82
+ if (this.storedViewDefinition === null) {
83
+ return { recordset: [] };
84
+ }
85
+ return { recordset: [{ ViewDefinition: this.storedViewDefinition }] };
86
+ }
87
+ async queryWithParams() {
88
+ throw new Error('MockConnection: queryWithParams() must not be called by the decision path');
89
+ }
90
+ async executeStoredProcedure() {
91
+ throw new Error('MockConnection: executeStoredProcedure() must not be called by the decision path');
92
+ }
93
+ async beginTransaction() {
94
+ throw new Error('MockConnection: beginTransaction() must not be called by the decision path');
95
+ }
96
+ }
97
+ // ─── Entity fixtures ─────────────────────────────────────────────────────────
98
+ function makeEntity(overrides = {}, fieldOverrides) {
99
+ const defaultFields = fieldOverrides || [
100
+ {
101
+ ID: 'pk-1',
102
+ Name: 'ID',
103
+ Type: 'uniqueidentifier',
104
+ Length: 16,
105
+ IsPrimaryKey: true,
106
+ AllowsNull: false,
107
+ AllowUpdateAPI: true,
108
+ IsVirtual: false,
109
+ AutoIncrement: false,
110
+ DefaultValue: 'newsequentialid()',
111
+ },
112
+ {
113
+ ID: 'f-2',
114
+ Name: 'Name',
115
+ Type: 'nvarchar',
116
+ Length: 510,
117
+ IsPrimaryKey: false,
118
+ AllowsNull: false,
119
+ AllowUpdateAPI: true,
120
+ IsVirtual: false,
121
+ AutoIncrement: false,
122
+ DefaultValue: '',
123
+ },
124
+ ];
125
+ return new EntityInfo({
126
+ ID: 'REGEN-ENTITY-1',
127
+ Name: 'Regen Target',
128
+ SchemaName: '__mj',
129
+ BaseTable: 'RegenTarget',
130
+ BaseTableCodeName: 'RegenTarget',
131
+ BaseView: 'vwRegenTargets',
132
+ BaseViewGenerated: true,
133
+ IncludeInAPI: true,
134
+ AllowCreateAPI: true,
135
+ AllowUpdateAPI: true,
136
+ AllowDeleteAPI: true,
137
+ spCreateGenerated: true,
138
+ spUpdateGenerated: true,
139
+ spDeleteGenerated: true,
140
+ CascadeDeletes: false,
141
+ DeleteType: 'Hard',
142
+ spCreate: '',
143
+ spUpdate: '',
144
+ spDelete: '',
145
+ EntityFields: defaultFields,
146
+ EntityPermissions: [{ RoleSQLName: 'cdp_UI' }],
147
+ ...overrides,
148
+ });
149
+ }
150
+ /**
151
+ * Exactly what SQL Server's OBJECT_DEFINITION() returns for the view the emission
152
+ * pipeline creates for the fixture above: the CREATE VIEW batch text — no comment
153
+ * header, no DROP guard, no GO, no GRANTs (those live in other batches).
154
+ */
155
+ const STORED_DEFINITION_IN_SYNC = `CREATE VIEW [__mj].[vwRegenTargets]
156
+ AS
157
+ SELECT
158
+ r.*
159
+ FROM
160
+ [__mj].[RegenTarget] AS r
161
+ `;
162
+ /** The normalized SELECT body both sides of an in-sync comparison reduce to. */
163
+ const NORMALIZED_BODY = 'SELECT r.* FROM [__mj].[RegenTarget] AS r';
164
+ // ─── Shared state hygiene ────────────────────────────────────────────────────
165
+ let savedNewEntities;
166
+ let savedModifiedEntities;
167
+ let probe;
168
+ /** Real generated emission for the fixture: file header + view DDL + GRANTs — the exact string production hands to checkBaseViewChangedInDB. */
169
+ async function generatedEmission(entity) {
170
+ const pool = new MockConnection();
171
+ return (probe.generateSingleEntitySQLFileHeader(entity, entity.GeneratedViewName) +
172
+ (await probe.generateBaseView(pool, entity)));
173
+ }
174
+ beforeEach(() => {
175
+ savedNewEntities = [...ManageMetadataBase.newEntityList];
176
+ savedModifiedEntities = [...ManageMetadataBase.modifiedEntityList];
177
+ ManageMetadataBase.newEntityList.length = 0;
178
+ ManageMetadataBase.modifiedEntityList.length = 0;
179
+ probe = new DecisionProbe();
180
+ probe.DBProvider = new SQLServerCodeGenProvider();
181
+ });
182
+ afterEach(() => {
183
+ ManageMetadataBase.newEntityList.length = 0;
184
+ ManageMetadataBase.newEntityList.push(...savedNewEntities);
185
+ ManageMetadataBase.modifiedEntityList.length = 0;
186
+ ManageMetadataBase.modifiedEntityList.push(...savedModifiedEntities);
187
+ vi.restoreAllMocks();
188
+ });
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+ // 1. extractViewSelectBody — the normalization contract
191
+ // ─────────────────────────────────────────────────────────────────────────────
192
+ describe('extractViewSelectBody — the normalization contract the drift decision rests on', () => {
193
+ it('GM-EXTRACT-01: strips the file header, comment banner, DROP guard, GO, and GRANTs from a full generated emission', async () => {
194
+ const generated = await generatedEmission(makeEntity());
195
+ // Sanity: the raw emission really contains all the layers being stripped
196
+ expect(generated).toContain('-- SQL Code Generation');
197
+ expect(generated).toContain('DROP VIEW [__mj].[vwRegenTargets];');
198
+ expect(generated).toContain('GRANT SELECT ON [__mj].[vwRegenTargets] TO [cdp_UI]');
199
+ expect(probe.Extract(generated)).toBe(NORMALIZED_BODY);
200
+ });
201
+ it('GM-EXTRACT-02: reduces the DB-stored definition to the identical normalized body', async () => {
202
+ const dbBody = probe.Extract(STORED_DEFINITION_IN_SYNC);
203
+ expect(dbBody).toBe(NORMALIZED_BODY);
204
+ expect(dbBody).toBe(probe.Extract(await generatedEmission(makeEntity())));
205
+ });
206
+ it('GM-EXTRACT-03: a definition with AS and SELECT on the SAME line falls back to whole-text, with NO whitespace normalization', () => {
207
+ // The body-locator regex requires a newline between AS and SELECT. SQL Server
208
+ // stores view text verbatim, so a view originally created as
209
+ // `CREATE VIEW x AS SELECT ...` never matches and the method returns the whole
210
+ // trimmed string — including the CREATE VIEW prefix, and WITHOUT collapsing
211
+ // whitespace (the fallback path skips the normalization step entirely).
212
+ const singleLine = 'CREATE VIEW [__mj].[vwRegenTargets] AS SELECT r.* FROM [__mj].[RegenTarget] AS r';
213
+ expect(probe.Extract(singleLine)).toBe(singleLine);
214
+ });
215
+ it('GM-EXTRACT-04: the GO batch separator is only honored at line start — an indented GO leaks the grants into the body', () => {
216
+ const emission = `CREATE VIEW [__mj].[vwRegenTargets]
217
+ AS
218
+ SELECT
219
+ r.*
220
+ FROM
221
+ [__mj].[RegenTarget] AS r
222
+ GO
223
+ GRANT SELECT ON [__mj].[vwRegenTargets] TO [cdp_UI]`;
224
+ expect(probe.Extract(emission)).toBe('SELECT r.* FROM [__mj].[RegenTarget] AS r GO GRANT SELECT ON [__mj].[vwRegenTargets] TO [cdp_UI]');
225
+ });
226
+ it('GM-EXTRACT-05: a lowercase `go` separator is honored (case-insensitive match)', () => {
227
+ const emission = `CREATE VIEW [__mj].[vwRegenTargets]
228
+ AS
229
+ SELECT
230
+ r.*
231
+ FROM
232
+ [__mj].[RegenTarget] AS r
233
+ go
234
+ GRANT SELECT ON [__mj].[vwRegenTargets] TO [cdp_UI]`;
235
+ expect(probe.Extract(emission)).toBe(NORMALIZED_BODY);
236
+ });
237
+ it('GM-EXTRACT-06: comments INSIDE the SELECT body are NOT stripped — only whitespace is normalized', () => {
238
+ // Cousin of the "comment-aware ORDER BY stripping" bug class: this code's
239
+ // normalization is whitespace-only. Comment text is compared verbatim.
240
+ const emission = `CREATE VIEW [__mj].[vwRegenTargets]
241
+ AS
242
+ SELECT -- audit hack, do not regenerate
243
+ r.*
244
+ FROM
245
+ [__mj].[RegenTarget] AS r
246
+ `;
247
+ expect(probe.Extract(emission)).toBe('SELECT -- audit hack, do not regenerate r.* FROM [__mj].[RegenTarget] AS r');
248
+ });
249
+ });
250
+ // ─────────────────────────────────────────────────────────────────────────────
251
+ // 2. checkBaseViewChangedInDB — the drift decision
252
+ // ─────────────────────────────────────────────────────────────────────────────
253
+ describe('checkBaseViewChangedInDB — SELECT-body drift decision (SQL Server text path)', () => {
254
+ it('GM-DECIDE-01: identical view → NO regeneration', async () => {
255
+ const pool = new MockConnection(STORED_DEFINITION_IN_SYNC);
256
+ expect(await probe.Check(pool, makeEntity(), await generatedEmission(makeEntity()))).toBe(false);
257
+ });
258
+ it('GM-DECIDE-02: whitespace-only differences (indentation, CRLF, collapsed lines) → NO regeneration', async () => {
259
+ const reformatted = 'CREATE VIEW [__mj].[vwRegenTargets]\r\nAS\r\nSELECT r.* FROM\r\n\t\t[__mj].[RegenTarget] AS r\r\n';
260
+ const pool = new MockConnection(reformatted);
261
+ expect(await probe.Check(pool, makeEntity(), await generatedEmission(makeEntity()))).toBe(false);
262
+ });
263
+ it('GM-DECIDE-03: GRANT/permission changes never trigger view regeneration (grants are cut at the GO separator)', async () => {
264
+ // Same stored view, but the entity now grants to a different role set. The
265
+ // generated emission's GRANT text changed — yet the comparison stops at GO,
266
+ // so permission churn cannot force-log the view.
267
+ const entityWithMoreRoles = makeEntity({
268
+ EntityPermissions: [{ RoleSQLName: 'cdp_UI' }, { RoleSQLName: 'cdp_Developer' }, { RoleSQLName: 'cdp_Integration' }],
269
+ });
270
+ const pool = new MockConnection(STORED_DEFINITION_IN_SYNC);
271
+ expect(await probe.Check(pool, entityWithMoreRoles, await generatedEmission(entityWithMoreRoles))).toBe(false);
272
+ });
273
+ it('GM-DECIDE-04: hand-added column in the DB view (SELECT-body drift) → regenerate + force-log', async () => {
274
+ const handEdited = `CREATE VIEW [__mj].[vwRegenTargets]
275
+ AS
276
+ SELECT
277
+ r.*,
278
+ r.[Name] AS [LegacyName]
279
+ FROM
280
+ [__mj].[RegenTarget] AS r
281
+ `;
282
+ const pool = new MockConnection(handEdited);
283
+ expect(await probe.Check(pool, makeEntity(), await generatedEmission(makeEntity()))).toBe(true);
284
+ });
285
+ it('GM-DECIDE-05: FK display join added in metadata while the DB still has the old plain view → regenerate', async () => {
286
+ // The c31d487cb bug-class cousin for GENERATED views: a foreign key gained a
287
+ // display field, so today's emission carries a JOIN + alias the DB view lacks.
288
+ const provider = new SQLServerCodeGenProvider();
289
+ const context = {
290
+ entity: makeEntity(),
291
+ relatedFieldsSelect: '\n Customer_CustomerID.[Name] AS [Customer]',
292
+ relatedFieldsJoins: 'INNER JOIN\n [__mj].[Customer] AS Customer_CustomerID\n ON\n [r].[CustomerID] = Customer_CustomerID.[ID]',
293
+ parentFieldsSelect: '',
294
+ parentJoins: '',
295
+ rootFieldsSelect: '',
296
+ rootJoins: '',
297
+ };
298
+ const generatedWithJoin = provider.generateBaseView(context);
299
+ const pool = new MockConnection(STORED_DEFINITION_IN_SYNC);
300
+ expect(await probe.Check(pool, makeEntity(), generatedWithJoin)).toBe(true);
301
+ });
302
+ it('GM-DECIDE-06: display column REMOVED from metadata while the DB view still carries it → regenerate', async () => {
303
+ const dbStillHasJoin = `CREATE VIEW [__mj].[vwRegenTargets]
304
+ AS
305
+ SELECT
306
+ r.*,
307
+ Customer_CustomerID.[Name] AS [Customer]
308
+ FROM
309
+ [__mj].[RegenTarget] AS r
310
+ INNER JOIN
311
+ [__mj].[Customer] AS Customer_CustomerID
312
+ ON
313
+ [r].[CustomerID] = Customer_CustomerID.[ID]
314
+ `;
315
+ const pool = new MockConnection(dbStillHasJoin);
316
+ expect(await probe.Check(pool, makeEntity(), await generatedEmission(makeEntity()))).toBe(true);
317
+ });
318
+ it('GM-DECIDE-07: display column RENAMED in the DB view → regenerate', async () => {
319
+ const provider = new SQLServerCodeGenProvider();
320
+ const context = {
321
+ entity: makeEntity(),
322
+ relatedFieldsSelect: '\n Customer_CustomerID.[Name] AS [Customer]',
323
+ relatedFieldsJoins: 'INNER JOIN\n [__mj].[Customer] AS Customer_CustomerID\n ON\n [r].[CustomerID] = Customer_CustomerID.[ID]',
324
+ parentFieldsSelect: '',
325
+ parentJoins: '',
326
+ rootFieldsSelect: '',
327
+ rootJoins: '',
328
+ };
329
+ const generated = provider.generateBaseView(context);
330
+ const dbRenamedAlias = `CREATE VIEW [__mj].[vwRegenTargets]
331
+ AS
332
+ SELECT
333
+ r.*,
334
+ Customer_CustomerID.[Name] AS [CustomerName]
335
+ FROM
336
+ [__mj].[RegenTarget] AS r
337
+ INNER JOIN
338
+ [__mj].[Customer] AS Customer_CustomerID
339
+ ON
340
+ [r].[CustomerID] = Customer_CustomerID.[ID]
341
+ `;
342
+ const pool = new MockConnection(dbRenamedAlias);
343
+ expect(await probe.Check(pool, makeEntity(), generated)).toBe(true);
344
+ });
345
+ it('GM-DECIDE-08: soft-delete WHERE clause added to the emission (DeleteType flipped to Soft) → regenerate', async () => {
346
+ const softDeleteEntity = makeEntity({ DeleteType: 'Soft' });
347
+ const pool = new MockConnection(STORED_DEFINITION_IN_SYNC);
348
+ expect(await probe.Check(pool, softDeleteEntity, await generatedEmission(softDeleteEntity))).toBe(true);
349
+ });
350
+ it('GM-DECIDE-09: a COMMENT-only hand edit inside the DB view body → regenerate (comments are compared verbatim)', async () => {
351
+ // Documents current behavior: normalization is whitespace-only, so a DBA
352
+ // leaving `-- reviewed` inside the SELECT forces a regeneration + migration
353
+ // log entry even though the SQL is semantically identical.
354
+ const commented = `CREATE VIEW [__mj].[vwRegenTargets]
355
+ AS
356
+ SELECT -- reviewed 2026-01-15
357
+ r.*
358
+ FROM
359
+ [__mj].[RegenTarget] AS r
360
+ `;
361
+ const pool = new MockConnection(commented);
362
+ expect(await probe.Check(pool, makeEntity(), await generatedEmission(makeEntity()))).toBe(true);
363
+ });
364
+ it('GM-DECIDE-10: ⚠️ CURRENT BEHAVIOR — drift INSIDE a string literal that differs only by whitespace is silently MISSED', async () => {
365
+ // ⚠️ The whitespace collapse runs over the entire body, string literals
366
+ // included. `N'A B'` and `N'A B'` normalize to the same text, so a hand
367
+ // edit that changes only spacing inside a literal is real semantic drift the
368
+ // decision reports as "unchanged". Pinned as documentation, not endorsement.
369
+ const generatedWithLiteral = `CREATE VIEW [__mj].[vwRegenTargets]
370
+ AS
371
+ SELECT
372
+ r.*,
373
+ CASE WHEN r.[Name] = N'A B' THEN 1 ELSE 0 END AS [Flag]
374
+ FROM
375
+ [__mj].[RegenTarget] AS r
376
+ GO
377
+ `;
378
+ const dbLiteralDrifted = `CREATE VIEW [__mj].[vwRegenTargets]
379
+ AS
380
+ SELECT
381
+ r.*,
382
+ CASE WHEN r.[Name] = N'A B' THEN 1 ELSE 0 END AS [Flag]
383
+ FROM
384
+ [__mj].[RegenTarget] AS r
385
+ `;
386
+ const pool = new MockConnection(dbLiteralDrifted);
387
+ expect(await probe.Check(pool, makeEntity(), generatedWithLiteral)).toBe(false);
388
+ });
389
+ it('GM-DECIDE-11: ⚠️ CURRENT BEHAVIOR — a DB view stored with single-line `AS SELECT` is flagged changed on EVERY run', async () => {
390
+ // ⚠️ SQL Server stores view text verbatim. A pre-existing view created as
391
+ // `CREATE VIEW x AS SELECT ...` (AS and SELECT on one line) never matches the
392
+ // body-locator regex, so its "body" is the whole definition including the
393
+ // CREATE VIEW prefix — which can never equal the generated SELECT body. Such
394
+ // a view is force-logged into the migration on every single CodeGen run until
395
+ // CodeGen itself rewrites it in the multi-line form. Perpetual-regeneration
396
+ // hazard, pinned as documentation.
397
+ const singleLineStored = 'CREATE VIEW [__mj].[vwRegenTargets] AS SELECT r.* FROM [__mj].[RegenTarget] AS r';
398
+ const pool = new MockConnection(singleLineStored);
399
+ expect(await probe.Check(pool, makeEntity(), await generatedEmission(makeEntity()))).toBe(true);
400
+ });
401
+ it('GM-DECIDE-12: view missing from the database → regenerate (self-heal for a failed prior CREATE)', async () => {
402
+ const pool = new MockConnection(null);
403
+ expect(await probe.Check(pool, makeEntity(), await generatedEmission(makeEntity()))).toBe(true);
404
+ expect(pool.Queries.length).toBe(1);
405
+ expect(pool.Queries[0]).toContain("OBJECT_DEFINITION(OBJECT_ID('[__mj].[vwRegenTargets]'))");
406
+ });
407
+ it('GM-DECIDE-13: entity in newEntityList → NO force-log and the DB is never queried (new-entity logic owns it)', async () => {
408
+ ManageMetadataBase.newEntityList.push('Regen Target');
409
+ const pool = new MockConnection(null); // would return "missing" → true if consulted
410
+ expect(await probe.Check(pool, makeEntity(), await generatedEmission(makeEntity()))).toBe(false);
411
+ expect(pool.Queries.length).toBe(0);
412
+ });
413
+ it('GM-DECIDE-14: entity in modifiedEntityList → NO force-log and the DB is never queried (modified-entity logic owns it)', async () => {
414
+ ManageMetadataBase.modifiedEntityList.push('Regen Target');
415
+ const pool = new MockConnection(null);
416
+ expect(await probe.Check(pool, makeEntity(), await generatedEmission(makeEntity()))).toBe(false);
417
+ expect(pool.Queries.length).toBe(0);
418
+ });
419
+ it('GM-DECIDE-15: ⚠️ CURRENT BEHAVIOR — a query failure is swallowed and reported as "unchanged"', async () => {
420
+ // ⚠️ The catch block treats any comparison failure as non-fatal and returns
421
+ // false, so a transient connectivity error masks real drift for that run.
422
+ // Deliberate (the modified-entity machinery still runs), pinned here so a
423
+ // future refactor makes this trade-off consciously.
424
+ const pool = new MockConnection(STORED_DEFINITION_IN_SYNC).ThrowOnQuery();
425
+ expect(await probe.Check(pool, makeEntity(), await generatedEmission(makeEntity()))).toBe(false);
426
+ });
427
+ it('GM-DECIDE-16: on PostgreSQL the decision routes to the column-set comparison, never the text diff', async () => {
428
+ const originalPlatform = configInfo.dbPlatform;
429
+ try {
430
+ configInfo.dbPlatform = 'postgresql';
431
+ // Column set matches the entity fields (ID, Name) → unchanged
432
+ const inSync = new MockConnection(null).WithPGColumns(['id', 'name']);
433
+ expect(await probe.Check(inSync, makeEntity(), 'irrelevant — PG path ignores the generated text')).toBe(false);
434
+ expect(inSync.Queries.length).toBe(1);
435
+ expect(inSync.Queries[0]).toContain('information_schema.columns');
436
+ expect(inSync.Queries[0]).not.toContain('OBJECT_DEFINITION');
437
+ // Column drifted (missing Name) → changed
438
+ const drifted = new MockConnection(null).WithPGColumns(['id']);
439
+ expect(await probe.Check(drifted, makeEntity(), 'irrelevant')).toBe(true);
440
+ }
441
+ finally {
442
+ configInfo.dbPlatform = originalPlatform;
443
+ }
444
+ });
445
+ });
446
+ // ─────────────────────────────────────────────────────────────────────────────
447
+ // 3. logSQLForNewOrModifiedEntity — the migration-log gate
448
+ // ─────────────────────────────────────────────────────────────────────────────
449
+ describe('logSQLForNewOrModifiedEntity — the migration-log (force-log) gate', () => {
450
+ let appendSpy;
451
+ let tempBatchSpy;
452
+ /** Descriptions of everything the gate decided to log. */
453
+ function loggedDescriptions() {
454
+ return appendSpy.mock.calls.map((call) => call[1]);
455
+ }
456
+ const savedForceRegen = configInfo.forceRegeneration;
457
+ beforeEach(() => {
458
+ appendSpy = vi.spyOn(SQLLogging, 'appendToSQLLogFile').mockResolvedValue(undefined);
459
+ tempBatchSpy = vi.spyOn(TempBatchFile, 'appendToTempBatchFile').mockReturnValue(undefined);
460
+ });
461
+ afterEach(() => {
462
+ configInfo.forceRegeneration = savedForceRegen;
463
+ });
464
+ function forceRegen(overrides) {
465
+ return {
466
+ enabled: true,
467
+ baseViews: false,
468
+ spCreate: false,
469
+ spUpdate: false,
470
+ spDelete: false,
471
+ allStoredProcedures: false,
472
+ indexes: false,
473
+ fullTextSearch: false,
474
+ ...overrides,
475
+ };
476
+ }
477
+ it('GM-GATE-01: forceLog=true logs even when the batch logging switch (logSql) is OFF — drift always reaches the migration', () => {
478
+ probe.Log(makeEntity(), 'SQL', 'Base View SQL for Regen Target', false, true);
479
+ expect(loggedDescriptions()).toEqual(['Base View SQL for Regen Target']);
480
+ });
481
+ it('GM-GATE-02: an untracked, unchanged entity logs nothing', () => {
482
+ probe.Log(makeEntity(), 'SQL', 'Base View SQL for Regen Target', true, false);
483
+ expect(appendSpy).not.toHaveBeenCalled();
484
+ });
485
+ it('GM-GATE-03: a NEW entity logs when logSql is on', () => {
486
+ ManageMetadataBase.newEntityList.push('Regen Target');
487
+ probe.Log(makeEntity(), 'SQL', 'spCreate SQL for Regen Target', true, false);
488
+ expect(loggedDescriptions()).toEqual(['spCreate SQL for Regen Target']);
489
+ });
490
+ it('GM-GATE-04: a MODIFIED entity logs when logSql is on', () => {
491
+ ManageMetadataBase.modifiedEntityList.push('Regen Target');
492
+ probe.Log(makeEntity(), 'SQL', 'spUpdate SQL for Regen Target', true, false);
493
+ expect(loggedDescriptions()).toEqual(['spUpdate SQL for Regen Target']);
494
+ });
495
+ it('GM-GATE-05: logSql=false suppresses even NEW-entity logging (the metadata-driven branches sit behind the switch)', () => {
496
+ ManageMetadataBase.newEntityList.push('Regen Target');
497
+ probe.Log(makeEntity(), 'SQL', 'spCreate SQL for Regen Target', false, false);
498
+ expect(appendSpy).not.toHaveBeenCalled();
499
+ });
500
+ it('GM-GATE-06: ⚠️ CURRENT BEHAVIOR — an entity with RelatedEntityJoinFieldsConfig logs its base view on EVERY run, changed or not', () => {
501
+ // ⚠️ The gate keys off the mere PRESENCE of a join-fields config, not off any
502
+ // drift check — so such an entity re-enters the migration log on every
503
+ // CodeGen run with logging enabled. Applies to base-view descriptions only.
504
+ const entity = makeEntity({}, [
505
+ {
506
+ ID: 'pk-1', Name: 'ID', Type: 'uniqueidentifier', Length: 16, IsPrimaryKey: true,
507
+ AllowsNull: false, AllowUpdateAPI: true, IsVirtual: false, AutoIncrement: false, DefaultValue: '',
508
+ },
509
+ {
510
+ ID: 'f-2', Name: 'AccountID', Type: 'uniqueidentifier', Length: 16, IsPrimaryKey: false,
511
+ AllowsNull: true, AllowUpdateAPI: true, IsVirtual: false, AutoIncrement: false, DefaultValue: '',
512
+ RelatedEntityID: 'ACCOUNT-ENTITY-1',
513
+ RelatedEntityJoinFields: '{"mode":"extend"}',
514
+ },
515
+ ]);
516
+ probe.Log(entity, 'SQL', 'Base View SQL for Regen Target', true, false);
517
+ probe.Log(entity, 'SQL', 'Base View Permissions SQL for Regen Target', true, false);
518
+ probe.Log(entity, 'SQL', 'spCreate SQL for Regen Target', true, false);
519
+ expect(loggedDescriptions()).toEqual([
520
+ 'Base View SQL for Regen Target',
521
+ 'Base View Permissions SQL for Regen Target',
522
+ // spCreate did NOT log — the branch is scoped to 'base view' descriptions
523
+ ]);
524
+ });
525
+ it('GM-GATE-07: cascade-delete dependency regeneration logs spDelete — and ONLY spDelete', () => {
526
+ probe.MarkCascadeDeleteRegen('REGEN-ENTITY-1');
527
+ probe.Log(makeEntity(), 'SQL', 'spDelete SQL for Regen Target', true, false);
528
+ probe.Log(makeEntity(), 'SQL', 'spCreate SQL for Regen Target', true, false);
529
+ expect(loggedDescriptions()).toEqual(['spDelete SQL for Regen Target']);
530
+ });
531
+ it('GM-GATE-08: forceRegeneration.baseViews covers base views AND their root-ID TVFs, not the SPs', () => {
532
+ configInfo.forceRegeneration = forceRegen({ baseViews: true });
533
+ probe.Log(makeEntity(), 'SQL', 'Base View SQL for Regen Target', true, false);
534
+ probe.Log(makeEntity(), 'SQL', 'Root ID Function SQL for Regen Target.ParentID', true, false);
535
+ probe.Log(makeEntity(), 'SQL', 'spCreate SQL for Regen Target', true, false);
536
+ expect(loggedDescriptions()).toEqual([
537
+ 'Base View SQL for Regen Target',
538
+ 'Root ID Function SQL for Regen Target.ParentID',
539
+ ]);
540
+ });
541
+ it('GM-GATE-09: forceRegeneration.spCreate targets only spCreate', () => {
542
+ configInfo.forceRegeneration = forceRegen({ spCreate: true });
543
+ probe.Log(makeEntity(), 'SQL', 'spCreate SQL for Regen Target', true, false);
544
+ probe.Log(makeEntity(), 'SQL', 'spUpdate SQL for Regen Target', true, false);
545
+ expect(loggedDescriptions()).toEqual(['spCreate SQL for Regen Target']);
546
+ });
547
+ it('GM-GATE-10: forceRegeneration.allStoredProcedures covers all three CRUD SPs but not the base view', () => {
548
+ configInfo.forceRegeneration = forceRegen({ allStoredProcedures: true });
549
+ probe.Log(makeEntity(), 'SQL', 'spCreate SQL for Regen Target', true, false);
550
+ probe.Log(makeEntity(), 'SQL', 'spUpdate SQL for Regen Target', true, false);
551
+ probe.Log(makeEntity(), 'SQL', 'spDelete SQL for Regen Target', true, false);
552
+ probe.Log(makeEntity(), 'SQL', 'Base View SQL for Regen Target', true, false);
553
+ expect(loggedDescriptions()).toEqual([
554
+ 'spCreate SQL for Regen Target',
555
+ 'spUpdate SQL for Regen Target',
556
+ 'spDelete SQL for Regen Target',
557
+ ]);
558
+ });
559
+ it('GM-GATE-11: the entityWhereClause filter restricts forced regeneration to qualified entities', () => {
560
+ configInfo.forceRegeneration = forceRegen({ baseViews: true });
561
+ probe.RestrictForcedRegenerationTo(['Some Other Entity']);
562
+ probe.Log(makeEntity(), 'SQL', 'Base View SQL for Regen Target', true, false);
563
+ expect(appendSpy).not.toHaveBeenCalled();
564
+ probe.RestrictForcedRegenerationTo(['Regen Target']);
565
+ probe.Log(makeEntity(), 'SQL', 'Base View SQL for Regen Target', true, false);
566
+ expect(loggedDescriptions()).toEqual(['Base View SQL for Regen Target']);
567
+ });
568
+ it('GM-GATE-12: a logged statement goes to BOTH the migration log and the schema temp batch file', () => {
569
+ probe.Log(makeEntity(), 'THE SQL', 'Base View SQL for Regen Target', false, true);
570
+ expect(appendSpy).toHaveBeenCalledWith('THE SQL', 'Base View SQL for Regen Target');
571
+ expect(tempBatchSpy).toHaveBeenCalledWith('THE SQL', '__mj');
572
+ });
573
+ });
574
+ // ─────────────────────────────────────────────────────────────────────────────
575
+ // 4. generateSingleEntitySQLToSeparateFiles — decision wiring, end-to-end
576
+ // ─────────────────────────────────────────────────────────────────────────────
577
+ describe('generateSingleEntitySQLToSeparateFiles — decision wiring end-to-end', () => {
578
+ let appendSpy;
579
+ let tempDir;
580
+ beforeEach(() => {
581
+ appendSpy = vi.spyOn(SQLLogging, 'appendToSQLLogFile').mockResolvedValue(undefined);
582
+ vi.spyOn(TempBatchFile, 'appendToTempBatchFile').mockReturnValue(undefined);
583
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mj-regen-decision-'));
584
+ });
585
+ afterEach(() => {
586
+ fs.rmSync(tempDir, { recursive: true, force: true });
587
+ });
588
+ function loggedDescriptions() {
589
+ return appendSpy.mock.calls.map((call) => call[1]);
590
+ }
591
+ function fileNames(files) {
592
+ return files.map((f) => path.relative(tempDir, f)).sort();
593
+ }
594
+ it('GM-E2E-01: SELECT-body drift force-logs the view, its permissions, and ALL CRUD SPs — but NOT the FK index', async () => {
595
+ const drifted = `CREATE VIEW [__mj].[vwRegenTargets]
596
+ AS
597
+ SELECT
598
+ r.*,
599
+ r.[Name] AS [LegacyName]
600
+ FROM
601
+ [__mj].[RegenTarget] AS r
602
+ `;
603
+ const pool = new MockConnection(drifted);
604
+ const result = await probe.generateSingleEntitySQLToSeparateFiles({
605
+ pool,
606
+ entity: makeEntity(),
607
+ directory: tempDir,
608
+ onlyPermissions: false,
609
+ writeFiles: true,
610
+ skipExecution: true,
611
+ enableSQLLoggingForNewOrModifiedEntities: true,
612
+ });
613
+ expect(result).not.toBeNull();
614
+ // The force-log package, in emission order. The FK-index statement is
615
+ // deliberately absent: it is emitted BEFORE the drift decision is computed,
616
+ // so index SQL never rides along with a view force-log.
617
+ expect(loggedDescriptions()).toEqual([
618
+ 'Base View SQL for Regen Target',
619
+ 'Base View Permissions SQL for Regen Target',
620
+ 'spCreate SQL for Regen Target',
621
+ 'spCreate Permissions for Regen Target',
622
+ 'spUpdate SQL for Regen Target',
623
+ 'spUpdate Permissions for Regen Target',
624
+ 'spDelete SQL for Regen Target',
625
+ 'spDelete Permissions for Regen Target',
626
+ ]);
627
+ // Only the drift-decision query hit the database
628
+ expect(pool.Queries.length).toBe(1);
629
+ expect(pool.Queries[0]).toContain('OBJECT_DEFINITION');
630
+ // All nine artifacts written
631
+ expect(fileNames(result.files)).toEqual([
632
+ path.join('__mj', 'RegenTarget.index.generated.sql'),
633
+ path.join('__mj', 'spCreateRegenTarget.sp.generated.sql'),
634
+ path.join('__mj', 'spCreateRegenTarget.sp.permissions.generated.sql'),
635
+ path.join('__mj', 'spDeleteRegenTarget.sp.generated.sql'),
636
+ path.join('__mj', 'spDeleteRegenTarget.sp.permissions.generated.sql'),
637
+ path.join('__mj', 'spUpdateRegenTarget.sp.generated.sql'),
638
+ path.join('__mj', 'spUpdateRegenTarget.sp.permissions.generated.sql'),
639
+ path.join('__mj', 'vwRegenTargets.view.generated.sql'),
640
+ path.join('__mj', 'vwRegenTargets.view.permissions.generated.sql'),
641
+ ].sort());
642
+ });
643
+ it('GM-E2E-02: an in-sync view logs NOTHING — files are still (re)written, the migration stays clean', async () => {
644
+ const pool = new MockConnection(STORED_DEFINITION_IN_SYNC);
645
+ const result = await probe.generateSingleEntitySQLToSeparateFiles({
646
+ pool,
647
+ entity: makeEntity(),
648
+ directory: tempDir,
649
+ onlyPermissions: false,
650
+ writeFiles: true,
651
+ skipExecution: true,
652
+ enableSQLLoggingForNewOrModifiedEntities: true,
653
+ });
654
+ expect(result).not.toBeNull();
655
+ expect(appendSpy).not.toHaveBeenCalled();
656
+ expect(result.files.length).toBe(9);
657
+ expect(pool.Queries.length).toBe(1); // only the drift-decision query
658
+ });
659
+ it('GM-E2E-03: custom base view (BaseViewGenerated=false) — CodeGen never emits, compares, or clobbers the view', async () => {
660
+ const customViewEntity = makeEntity({
661
+ Name: 'Widgets',
662
+ SchemaName: 'custom',
663
+ BaseTable: 'Widget',
664
+ BaseTableCodeName: 'Widget',
665
+ BaseView: 'vwWidgets',
666
+ BaseViewGenerated: false,
667
+ });
668
+ const pool = new MockConnection(STORED_DEFINITION_IN_SYNC);
669
+ const result = await probe.generateSingleEntitySQLToSeparateFiles({
670
+ pool,
671
+ entity: customViewEntity,
672
+ directory: tempDir,
673
+ onlyPermissions: false,
674
+ writeFiles: true,
675
+ skipExecution: true,
676
+ enableSQLLoggingForNewOrModifiedEntities: true,
677
+ });
678
+ expect(result).not.toBeNull();
679
+ // The application's view is protected: no CREATE VIEW, no DROP VIEW, and the
680
+ // drift comparison never even queries the database for it.
681
+ expect(result.sql).not.toContain('CREATE VIEW');
682
+ expect(result.sql).not.toContain('DROP VIEW');
683
+ expect(pool.Queries.length).toBe(0);
684
+ // Instead CodeGen refreshes the custom view (so schema changes surface) and
685
+ // re-applies the grants against it.
686
+ expect(result.sql).toContain("EXEC sp_refreshview 'custom.vwWidgets';");
687
+ expect(result.sql).toContain('GRANT SELECT ON [custom].[vwWidgets] TO [cdp_UI]');
688
+ // No view DDL file — but the view permissions file IS written.
689
+ const names = fileNames(result.files);
690
+ expect(names).not.toContain(path.join('custom', 'vwWidgets.view.generated.sql'));
691
+ expect(names).toContain(path.join('custom', 'vwWidgets.view.permissions.generated.sql'));
692
+ });
693
+ it('GM-E2E-04: virtual entity — only view permissions are emitted (no view DDL, no SPs, no index, no refresh)', async () => {
694
+ const virtualEntity = makeEntity({
695
+ Name: 'Virtual Things',
696
+ SchemaName: 'custom',
697
+ BaseTable: 'VirtualThing',
698
+ BaseTableCodeName: 'VirtualThing',
699
+ BaseView: 'vwVirtualThings',
700
+ BaseViewGenerated: false,
701
+ VirtualEntity: true,
702
+ });
703
+ const pool = new MockConnection(null);
704
+ const result = await probe.generateSingleEntitySQLToSeparateFiles({
705
+ pool,
706
+ entity: virtualEntity,
707
+ directory: tempDir,
708
+ onlyPermissions: false,
709
+ writeFiles: true,
710
+ skipExecution: true,
711
+ enableSQLLoggingForNewOrModifiedEntities: true,
712
+ });
713
+ expect(result).not.toBeNull();
714
+ expect(pool.Queries.length).toBe(0);
715
+ expect(result.sql).not.toContain('CREATE VIEW');
716
+ expect(result.sql).not.toContain('sp_refreshview'); // views over virtual entities aren't refreshed
717
+ expect(result.sql).toContain('GRANT SELECT ON [custom].[vwVirtualThings] TO [cdp_UI]');
718
+ expect(fileNames(result.files)).toEqual([path.join('custom', 'vwVirtualThings.view.permissions.generated.sql')]);
719
+ });
720
+ });
721
+ //# sourceMappingURL=BaseViewRegenDecision.test.js.map