@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.
- package/LICENSE +180 -4
- package/README.md +39 -22
- package/dist/Angular/angular-codegen.d.ts +0 -8
- package/dist/Angular/angular-codegen.d.ts.map +1 -1
- package/dist/Angular/angular-codegen.js +32 -22
- package/dist/Angular/angular-codegen.js.map +1 -1
- package/dist/Angular/entity-data-grid-related-entity-component.js +2 -2
- package/dist/Angular/entity-data-grid-related-entity-component.js.map +1 -1
- package/dist/Angular/related-entity-components.d.ts +1 -1
- package/dist/Angular/related-entity-components.js +1 -1
- package/dist/Database/codeGenDatabaseProvider.d.ts +107 -1
- package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
- package/dist/Database/codeGenDatabaseProvider.js +77 -10
- package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
- package/dist/Database/manage-metadata.d.ts +354 -14
- package/dist/Database/manage-metadata.d.ts.map +1 -1
- package/dist/Database/manage-metadata.js +1308 -65
- package/dist/Database/manage-metadata.js.map +1 -1
- package/dist/Database/materializationAnalysis.d.ts +241 -0
- package/dist/Database/materializationAnalysis.d.ts.map +1 -0
- package/dist/Database/materializationAnalysis.js +415 -0
- package/dist/Database/materializationAnalysis.js.map +1 -0
- package/dist/Database/materializationBroadRender.d.ts +67 -0
- package/dist/Database/materializationBroadRender.d.ts.map +1 -0
- package/dist/Database/materializationBroadRender.js +104 -0
- package/dist/Database/materializationBroadRender.js.map +1 -0
- package/dist/Database/materializationDrift.d.ts +59 -0
- package/dist/Database/materializationDrift.d.ts.map +1 -0
- package/dist/Database/materializationDrift.js +85 -0
- package/dist/Database/materializationDrift.js.map +1 -0
- package/dist/Database/materializationParamClassifier.d.ts +92 -0
- package/dist/Database/materializationParamClassifier.d.ts.map +1 -0
- package/dist/Database/materializationParamClassifier.js +224 -0
- package/dist/Database/materializationParamClassifier.js.map +1 -0
- package/dist/Database/materializationParamVerifier.d.ts +71 -0
- package/dist/Database/materializationParamVerifier.d.ts.map +1 -0
- package/dist/Database/materializationParamVerifier.js +302 -0
- package/dist/Database/materializationParamVerifier.js.map +1 -0
- package/dist/Database/materializationSqlAst.d.ts +78 -0
- package/dist/Database/materializationSqlAst.d.ts.map +1 -0
- package/dist/Database/materializationSqlAst.js +142 -0
- package/dist/Database/materializationSqlAst.js.map +1 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +69 -30
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +325 -216
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.d.ts +2 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.d.ts.map +1 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.js +119 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.js.map +1 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.d.ts +2 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.d.ts.map +1 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.js +155 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.js.map +1 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +61 -2
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +306 -3
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
- package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.d.ts +2 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.d.ts.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js +547 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.d.ts +2 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.d.ts.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.js +721 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.js.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.d.ts +2 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.d.ts.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.js +78 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.js.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.d.ts +2 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.d.ts.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.js +155 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.js.map +1 -0
- package/dist/Database/sql_codegen.d.ts +21 -5
- package/dist/Database/sql_codegen.d.ts.map +1 -1
- package/dist/Database/sql_codegen.js +85 -27
- package/dist/Database/sql_codegen.js.map +1 -1
- package/dist/Misc/action_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/action_subclasses_codegen.js +4 -4
- package/dist/Misc/action_subclasses_codegen.js.map +1 -1
- package/dist/Misc/createNewUser.js +1 -1
- package/dist/Misc/createNewUser.js.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.d.ts +44 -0
- package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.js +369 -156
- package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
- package/dist/Misc/graphql_server_codegen.d.ts +4 -58
- package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
- package/dist/Misc/graphql_server_codegen.js +22 -236
- package/dist/Misc/graphql_server_codegen.js.map +1 -1
- package/dist/Misc/sql_logging.d.ts +25 -0
- package/dist/Misc/sql_logging.d.ts.map +1 -1
- package/dist/Misc/sql_logging.js +51 -8
- package/dist/Misc/sql_logging.js.map +1 -1
- 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
|