@memberjunction/core-entities-server 5.15.0 → 5.17.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/dist/custom/MJQueryEntityServer.server.d.ts +10 -89
- package/dist/custom/MJQueryEntityServer.server.d.ts.map +1 -1
- package/dist/custom/MJQueryEntityServer.server.js +68 -1089
- package/dist/custom/MJQueryEntityServer.server.js.map +1 -1
- package/dist/custom/query-extraction/dialect.d.ts +23 -0
- package/dist/custom/query-extraction/dialect.d.ts.map +1 -0
- package/dist/custom/query-extraction/dialect.js +45 -0
- package/dist/custom/query-extraction/dialect.js.map +1 -0
- package/dist/custom/query-extraction/enrich.d.ts +51 -0
- package/dist/custom/query-extraction/enrich.d.ts.map +1 -0
- package/dist/custom/query-extraction/enrich.js +180 -0
- package/dist/custom/query-extraction/enrich.js.map +1 -0
- package/dist/custom/query-extraction/index.d.ts +9 -0
- package/dist/custom/query-extraction/index.d.ts.map +1 -0
- package/dist/custom/query-extraction/index.js +15 -0
- package/dist/custom/query-extraction/index.js.map +1 -0
- package/dist/custom/query-extraction/parse.d.ts +7 -0
- package/dist/custom/query-extraction/parse.d.ts.map +1 -0
- package/dist/custom/query-extraction/parse.js +13 -0
- package/dist/custom/query-extraction/parse.js.map +1 -0
- package/dist/custom/query-extraction/pipeline.d.ts +24 -0
- package/dist/custom/query-extraction/pipeline.d.ts.map +1 -0
- package/dist/custom/query-extraction/pipeline.js +101 -0
- package/dist/custom/query-extraction/pipeline.js.map +1 -0
- package/dist/custom/query-extraction/resolve.d.ts +110 -0
- package/dist/custom/query-extraction/resolve.d.ts.map +1 -0
- package/dist/custom/query-extraction/resolve.js +677 -0
- package/dist/custom/query-extraction/resolve.js.map +1 -0
- package/dist/custom/query-extraction/sync.d.ts +30 -0
- package/dist/custom/query-extraction/sync.d.ts.map +1 -0
- package/dist/custom/query-extraction/sync.js +456 -0
- package/dist/custom/query-extraction/sync.js.map +1 -0
- package/dist/custom/query-extraction/types.d.ts +114 -0
- package/dist/custom/query-extraction/types.d.ts.map +1 -0
- package/dist/custom/query-extraction/types.js +2 -0
- package/dist/custom/query-extraction/types.js.map +1 -0
- package/package.json +19 -19
|
@@ -4,16 +4,11 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
4
4
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
|
-
import { BaseEntity, LogError, Metadata,
|
|
8
|
-
import { QueryCompositionEngine } from "@memberjunction/generic-database-provider";
|
|
7
|
+
import { BaseEntity, LogError, Metadata, } from "@memberjunction/core";
|
|
9
8
|
import { MJQueryEntity } from "@memberjunction/core-entities";
|
|
10
9
|
import { RegisterClass, MJGlobal, UUIDsEqual } from "@memberjunction/global";
|
|
11
|
-
import { AIEngine } from "@memberjunction/aiengine";
|
|
12
|
-
import { AIPromptRunner } from "@memberjunction/ai-prompts";
|
|
13
|
-
import { AIPromptParams } from "@memberjunction/ai-core-plus";
|
|
14
|
-
import { removeNPrefix, convertIdentifiers, convertCommonFunctions, convertDateFunctions, convertCharIndex, convertStuff, convertIIF, convertConvertFunction, convertCastTypes, convertTopToLimit, convertStringConcat, removeCollate, } from "@memberjunction/sql-converter";
|
|
15
10
|
import { EmbedTextLocalHelper } from "./util.js";
|
|
16
|
-
import {
|
|
11
|
+
import { RunExtractionPipeline, CleanupQueryData, ConvertTSQLToPostgreSQL, } from "./query-extraction/index.js";
|
|
17
12
|
let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
18
13
|
constructor() {
|
|
19
14
|
super(...arguments);
|
|
@@ -34,10 +29,9 @@ let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
|
34
29
|
get QueryPermissions() {
|
|
35
30
|
return this._queryPermissions;
|
|
36
31
|
}
|
|
32
|
+
// ─── Embedding Methods ───────────────────────────────────────────────────────
|
|
37
33
|
/**
|
|
38
34
|
* Simple proxy to local helper method for embeddings. Needed for BaseEntity sub-classes that want to use embeddings built into BaseEntity
|
|
39
|
-
* @param textToEmbed
|
|
40
|
-
* @returns
|
|
41
35
|
*/
|
|
42
36
|
async EmbedTextLocal(textToEmbed) {
|
|
43
37
|
return EmbedTextLocalHelper(this, textToEmbed);
|
|
@@ -64,51 +58,45 @@ let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
|
64
58
|
this.EmbeddingModelID = result.modelID;
|
|
65
59
|
}
|
|
66
60
|
}
|
|
61
|
+
// ─── Save / Delete Overrides ─────────────────────────────────────────────────
|
|
67
62
|
async Save(options) {
|
|
68
63
|
try {
|
|
69
|
-
// Check if this is a new record or if SQL/Description has changed
|
|
70
64
|
const sqlField = this.GetFieldByName('SQL');
|
|
71
65
|
const nameField = this.GetFieldByName('Name');
|
|
72
66
|
const descriptionField = this.GetFieldByName('Description');
|
|
73
67
|
const userQuestionField = this.GetFieldByName('UserQuestion');
|
|
74
68
|
const shouldExtractData = !this.IsSaved || sqlField.Dirty;
|
|
75
69
|
const shouldGenerateEmbedding = !this.IsSaved || nameField.Dirty || descriptionField.Dirty || userQuestionField.Dirty;
|
|
76
|
-
// Generate embedding from composite text
|
|
70
|
+
// Generate embedding from composite text for better semantic search
|
|
77
71
|
if (shouldGenerateEmbedding) {
|
|
78
72
|
await this.GenerateCompositeEmbedding();
|
|
79
73
|
}
|
|
80
74
|
else if (!this.Description || this.Description.trim().length === 0) {
|
|
81
|
-
// Clear embedding if description is empty
|
|
82
75
|
this.EmbeddingVector = null;
|
|
83
76
|
this.EmbeddingModelID = null;
|
|
84
77
|
}
|
|
85
|
-
// Auto-set Reusable=true for queries in Golden-Queries/ categories
|
|
86
|
-
// Golden Queries are composable building blocks, so they should always be reusable.
|
|
78
|
+
// Auto-set Reusable=true for queries in Golden-Queries/ categories
|
|
87
79
|
if (this.CategoryID) {
|
|
88
80
|
const categoryPath = this.buildCategoryPathFromID(this.CategoryID);
|
|
89
81
|
if (categoryPath.toLowerCase().startsWith('golden-queries/') || categoryPath.toLowerCase() === 'golden-queries') {
|
|
90
82
|
this.Reusable = true;
|
|
91
83
|
}
|
|
92
84
|
}
|
|
93
|
-
// Save the query first without AI processing
|
|
85
|
+
// Save the query first without AI processing
|
|
94
86
|
const saveResult = await super.Save(options);
|
|
95
87
|
if (!saveResult) {
|
|
96
88
|
return false;
|
|
97
89
|
}
|
|
98
90
|
// Extract and sync parameters AFTER saving, outside of any transaction
|
|
99
|
-
// This prevents connection pool exhaustion from long-running AI operations
|
|
100
91
|
if (shouldExtractData && this.SQL && this.SQL.trim().length > 0) {
|
|
101
92
|
await this.extractAndSyncDataAsync();
|
|
102
|
-
// Auto-convert SQL to other dialects if configured
|
|
103
93
|
await this.autoConvertDialectsAsync();
|
|
104
|
-
await this.RefreshRelatedMetadata(true);
|
|
94
|
+
await this.RefreshRelatedMetadata(true);
|
|
105
95
|
}
|
|
106
96
|
else if (!this.SQL || this.SQL.trim().length === 0) {
|
|
107
|
-
// If SQL is empty, ensure UsesTemplate is false and remove all related data
|
|
108
|
-
// This can also happen asynchronously since it's cleanup work
|
|
109
97
|
this.UsesTemplate = false;
|
|
110
98
|
await this.cleanupEmptyQueryAsync();
|
|
111
|
-
await this.RefreshRelatedMetadata(true);
|
|
99
|
+
await this.RefreshRelatedMetadata(true);
|
|
112
100
|
}
|
|
113
101
|
return true;
|
|
114
102
|
}
|
|
@@ -120,14 +108,11 @@ let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
|
120
108
|
}
|
|
121
109
|
async Delete(options) {
|
|
122
110
|
try {
|
|
123
|
-
// Perform the actual delete operation
|
|
124
111
|
const deleteResult = await super.Delete(options);
|
|
125
112
|
if (!deleteResult) {
|
|
126
113
|
return false;
|
|
127
114
|
}
|
|
128
|
-
//
|
|
129
|
-
// This ensures that MJAPI and any other services using cached metadata
|
|
130
|
-
// immediately see that this query no longer exists
|
|
115
|
+
// Refresh metadata cache after deletion to prevent stale query references
|
|
131
116
|
await this.RefreshRelatedMetadata(true);
|
|
132
117
|
return true;
|
|
133
118
|
}
|
|
@@ -137,6 +122,7 @@ let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
|
137
122
|
return false;
|
|
138
123
|
}
|
|
139
124
|
}
|
|
125
|
+
// ─── Pipeline Delegation ─────────────────────────────────────────────────────
|
|
140
126
|
/**
|
|
141
127
|
* Builds a category path string from a CategoryID by walking up the category hierarchy.
|
|
142
128
|
* Returns a path like "Golden-Queries/Sales" or "Golden-Queries".
|
|
@@ -155,14 +141,29 @@ let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
|
155
141
|
return segments.join('/');
|
|
156
142
|
}
|
|
157
143
|
/**
|
|
158
|
-
*
|
|
159
|
-
|
|
144
|
+
* Builds the QuerySyncContext from this entity instance for use by the extraction pipeline.
|
|
145
|
+
*/
|
|
146
|
+
buildSyncContext() {
|
|
147
|
+
return {
|
|
148
|
+
queryID: this.ID,
|
|
149
|
+
queryName: this.Name,
|
|
150
|
+
sql: this.SQL,
|
|
151
|
+
isSaved: this.IsSaved,
|
|
152
|
+
contextUser: this.ContextCurrentUser,
|
|
153
|
+
metadataProvider: this.ProviderToUse,
|
|
154
|
+
runViewProvider: this.RunViewProviderToUse,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Runs the extraction pipeline and saves the UsesTemplate flag.
|
|
159
|
+
* On error, sets UsesTemplate=false and attempts to save so the query remains usable.
|
|
160
160
|
*/
|
|
161
161
|
async extractAndSyncDataAsync() {
|
|
162
162
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
const ctx = this.buildSyncContext();
|
|
164
|
+
const pipelineResult = await RunExtractionPipeline(ctx);
|
|
165
|
+
this.UsesTemplate = pipelineResult.usesTemplate;
|
|
166
|
+
// Save the query again to persist UsesTemplate and any changes from AI processing
|
|
166
167
|
const updateResult = await super.Save();
|
|
167
168
|
if (!updateResult) {
|
|
168
169
|
LogError('Failed to save query after AI processing completed');
|
|
@@ -170,8 +171,6 @@ let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
|
170
171
|
}
|
|
171
172
|
catch (e) {
|
|
172
173
|
LogError('Error in async AI processing:', e);
|
|
173
|
-
// Set UsesTemplate to false on error and save
|
|
174
|
-
// This ensures the query is still usable even if AI extraction fails
|
|
175
174
|
this.UsesTemplate = false;
|
|
176
175
|
try {
|
|
177
176
|
await super.Save();
|
|
@@ -182,17 +181,14 @@ let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
|
182
181
|
}
|
|
183
182
|
}
|
|
184
183
|
/**
|
|
185
|
-
*
|
|
184
|
+
* Cleans up all extraction data when SQL is empty, then saves the updated UsesTemplate flag.
|
|
186
185
|
*/
|
|
187
186
|
async cleanupEmptyQueryAsync() {
|
|
188
187
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
this.removeAllQuerySQLRecordsAsync()
|
|
194
|
-
]);
|
|
195
|
-
// Save the updated UsesTemplate flag
|
|
188
|
+
const ctx = this.buildSyncContext();
|
|
189
|
+
await CleanupQueryData(ctx);
|
|
190
|
+
// Also remove dialect records (not managed by the pipeline)
|
|
191
|
+
await this.removeAllQuerySQLRecordsAsync();
|
|
196
192
|
const updateResult = await super.Save();
|
|
197
193
|
if (!updateResult) {
|
|
198
194
|
LogError('Failed to save query after cleanup');
|
|
@@ -202,1081 +198,66 @@ let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
|
202
198
|
LogError('Error in async cleanup:', e);
|
|
203
199
|
}
|
|
204
200
|
}
|
|
205
|
-
|
|
206
|
-
try {
|
|
207
|
-
if (!this.SQL) {
|
|
208
|
-
this.UsesTemplate = false;
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
// ── Deterministic analysis via SQLParser (source of truth) ──
|
|
212
|
-
const analysis = SQLParser.Analyze(this.SQL);
|
|
213
|
-
const deterministicParams = SQLParser.ExtractParameterInfo(this.SQL);
|
|
214
|
-
const hasTemplateParameters = analysis.hasTemplateExpressions && deterministicParams.length > 0;
|
|
215
|
-
// Extract entity metadata deterministically (for LLM context and entity sync)
|
|
216
|
-
const entityMetadata = await this.extractEntityMetadataFromSQL();
|
|
217
|
-
// ── LLM enrichment (descriptions and sample values only) ──
|
|
218
|
-
const llmResult = await this.runLLMEnrichment(entityMetadata);
|
|
219
|
-
// ── Merge: deterministic structure + LLM descriptions ──
|
|
220
|
-
const syncPromises = [];
|
|
221
|
-
// Parameters: SQLParser provides name, type, isRequired, defaultValue.
|
|
222
|
-
// LLM provides description and sampleValue as enrichment.
|
|
223
|
-
if (hasTemplateParameters) {
|
|
224
|
-
const enrichedParams = this.mergeParametersWithLLM(deterministicParams, llmResult);
|
|
225
|
-
syncPromises.push(this.syncQueryParameters(enrichedParams));
|
|
226
|
-
}
|
|
227
|
-
else {
|
|
228
|
-
syncPromises.push(this.removeAllQueryParameters());
|
|
229
|
-
}
|
|
230
|
-
// Fields: deterministic for SELECT *, LLM for explicit column lists
|
|
231
|
-
const selectStarFields = this.buildFieldsForSelectStar();
|
|
232
|
-
if (selectStarFields) {
|
|
233
|
-
syncPromises.push(this.syncQueryFields(selectStarFields));
|
|
234
|
-
}
|
|
235
|
-
else if (llmResult?.selectClause && Array.isArray(llmResult.selectClause) && llmResult.selectClause.length > 0) {
|
|
236
|
-
syncPromises.push(this.syncQueryFields(llmResult.selectClause));
|
|
237
|
-
}
|
|
238
|
-
// Entities: deterministic SQL parsing (more reliable than LLM)
|
|
239
|
-
if (entityMetadata.length > 0) {
|
|
240
|
-
syncPromises.push(this.syncQueryEntities(entityMetadata));
|
|
241
|
-
}
|
|
242
|
-
// Dependencies: deterministic composition token parsing (no LLM)
|
|
243
|
-
syncPromises.push(this.extractAndSyncQueryDependencies());
|
|
244
|
-
await Promise.all(syncPromises);
|
|
245
|
-
this.UsesTemplate = hasTemplateParameters;
|
|
246
|
-
}
|
|
247
|
-
catch (e) {
|
|
248
|
-
LogError(`Query "${this.Name}" extraction error:`, e);
|
|
249
|
-
this.UsesTemplate = false;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
/**
|
|
253
|
-
* Runs the LLM enrichment prompt to get descriptions and sample values.
|
|
254
|
-
* Returns null if the LLM is unavailable or fails (non-fatal).
|
|
255
|
-
*/
|
|
256
|
-
async runLLMEnrichment(entityMetadata) {
|
|
257
|
-
try {
|
|
258
|
-
await AIEngine.Instance.Config(false, this.ContextCurrentUser, this.ProviderToUse);
|
|
259
|
-
const aiPrompt = AIEngine.Instance.Prompts.find(p => p.Name === 'SQL Query Parameter Extraction' &&
|
|
260
|
-
p.Category === 'MJ: System');
|
|
261
|
-
if (!aiPrompt) {
|
|
262
|
-
console.warn('AI prompt for SQL Query Parameter Extraction not found. Descriptions will use heuristics.');
|
|
263
|
-
return null;
|
|
264
|
-
}
|
|
265
|
-
const promptRunner = new AIPromptRunner();
|
|
266
|
-
const params = new AIPromptParams();
|
|
267
|
-
params.prompt = aiPrompt;
|
|
268
|
-
params.data = { templateText: this.SQL, entities: entityMetadata };
|
|
269
|
-
params.contextUser = this.ContextCurrentUser;
|
|
270
|
-
const result = await promptRunner.ExecutePrompt(params);
|
|
271
|
-
if (!result.success || !result.result) {
|
|
272
|
-
console.warn(`Query "${this.Name}" - LLM enrichment failed, using heuristic descriptions.`);
|
|
273
|
-
return null;
|
|
274
|
-
}
|
|
275
|
-
return result.result;
|
|
276
|
-
}
|
|
277
|
-
catch (e) {
|
|
278
|
-
console.warn(`Query "${this.Name}" - LLM enrichment error, using heuristic descriptions:`, e);
|
|
279
|
-
return null;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
/**
|
|
283
|
-
* Merges deterministic parameter extraction (SQLParser) with LLM enrichment.
|
|
284
|
-
*
|
|
285
|
-
* SQLParser is the authority for: name, type, isRequired, defaultValue
|
|
286
|
-
* LLM provides: description, sampleValue (with heuristic fallbacks if LLM unavailable)
|
|
287
|
-
*/
|
|
288
|
-
mergeParametersWithLLM(deterministicParams, llmResult) {
|
|
289
|
-
const llmParams = llmResult?.parameters ?? [];
|
|
290
|
-
return deterministicParams.map(dp => {
|
|
291
|
-
// Find matching LLM parameter by name (case-insensitive)
|
|
292
|
-
const llmMatch = llmParams.find(lp => lp.name.toLowerCase() === dp.name.toLowerCase());
|
|
293
|
-
return {
|
|
294
|
-
name: dp.name,
|
|
295
|
-
type: dp.type === 'unknown' ? (llmMatch?.type ?? 'string') : dp.type,
|
|
296
|
-
isRequired: dp.isRequired,
|
|
297
|
-
description: llmMatch?.description ?? this.generateParameterDescription(dp),
|
|
298
|
-
usage: llmMatch?.usage ?? dp.usageLocations,
|
|
299
|
-
defaultValue: dp.defaultValue !== null ? String(dp.defaultValue) : (llmMatch?.defaultValue ?? null),
|
|
300
|
-
sampleValue: llmMatch?.sampleValue ?? this.generateSampleValue(dp),
|
|
301
|
-
};
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
/**
|
|
305
|
-
* Generates a deterministic description for a parameter when LLM is unavailable.
|
|
306
|
-
* Uses the parameter name, type, and filter information to create a meaningful description.
|
|
307
|
-
*/
|
|
308
|
-
generateParameterDescription(param) {
|
|
309
|
-
const typeDescriptions = {
|
|
310
|
-
'string': 'text value',
|
|
311
|
-
'number': 'numeric value',
|
|
312
|
-
'date': 'date value',
|
|
313
|
-
'boolean': 'true/false flag',
|
|
314
|
-
'array': 'list of values',
|
|
315
|
-
};
|
|
316
|
-
const typeDesc = typeDescriptions[param.type] || 'value';
|
|
317
|
-
const requiredDesc = param.isRequired ? 'Required' : 'Optional';
|
|
318
|
-
const humanName = param.name.replace(/([A-Z])/g, ' $1').trim();
|
|
319
|
-
const defaultDesc = param.defaultValue !== null ? ` (default: ${param.defaultValue})` : '';
|
|
320
|
-
return `${requiredDesc} ${typeDesc} for ${humanName}${defaultDesc}`;
|
|
321
|
-
}
|
|
322
|
-
/**
|
|
323
|
-
* Generates a deterministic sample value based on the parameter type.
|
|
324
|
-
*/
|
|
325
|
-
generateSampleValue(param) {
|
|
326
|
-
if (param.defaultValue !== null)
|
|
327
|
-
return String(param.defaultValue);
|
|
328
|
-
switch (param.type) {
|
|
329
|
-
case 'string': return 'Example';
|
|
330
|
-
case 'number': return '10';
|
|
331
|
-
case 'date': return '2024-01-01';
|
|
332
|
-
case 'boolean': return 'true';
|
|
333
|
-
case 'array': return 'Value1,Value2';
|
|
334
|
-
default: return null;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
/**
|
|
338
|
-
* Extracts entity metadata from the SQL to provide context to the LLM for parameter type inference.
|
|
339
|
-
* Uses SQLParser for robust SQL parsing with MJ template support.
|
|
340
|
-
*/
|
|
341
|
-
async extractEntityMetadataFromSQL() {
|
|
342
|
-
const md = this.ProviderToUse;
|
|
343
|
-
const results = [];
|
|
344
|
-
if (!this.SQL)
|
|
345
|
-
return results;
|
|
346
|
-
try {
|
|
347
|
-
const tableRefs = SQLParser.ExtractTableRefs(this.SQL);
|
|
348
|
-
const columnRefs = SQLParser.ExtractColumnRefs(this.SQL);
|
|
349
|
-
for (const tableRef of tableRefs) {
|
|
350
|
-
const matchingEntity = md.Entities.find(e => (e.BaseView.toLowerCase() === tableRef.TableName.toLowerCase() ||
|
|
351
|
-
e.BaseTable.toLowerCase() === tableRef.TableName.toLowerCase()) &&
|
|
352
|
-
e.SchemaName.toLowerCase() === tableRef.SchemaName.toLowerCase());
|
|
353
|
-
if (matchingEntity) {
|
|
354
|
-
const relevantFields = matchingEntity.Fields
|
|
355
|
-
.filter(f => {
|
|
356
|
-
const fieldLower = f.Name.toLowerCase();
|
|
357
|
-
for (const colRef of columnRefs) {
|
|
358
|
-
const colLower = colRef.ColumnName.toLowerCase();
|
|
359
|
-
if (colLower === fieldLower)
|
|
360
|
-
return true;
|
|
361
|
-
if (colRef.TableQualifier) {
|
|
362
|
-
const qualLower = colRef.TableQualifier.toLowerCase();
|
|
363
|
-
if ((qualLower === tableRef.Alias.toLowerCase() ||
|
|
364
|
-
qualLower === tableRef.TableName.toLowerCase()) &&
|
|
365
|
-
colLower === fieldLower) {
|
|
366
|
-
return true;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
return f.IsPrimaryKey;
|
|
371
|
-
})
|
|
372
|
-
.slice(0, 20)
|
|
373
|
-
.map(f => ({
|
|
374
|
-
name: f.Name,
|
|
375
|
-
type: f.Type,
|
|
376
|
-
isPrimaryKey: f.IsPrimaryKey
|
|
377
|
-
}));
|
|
378
|
-
if (relevantFields.length > 0) {
|
|
379
|
-
results.push({
|
|
380
|
-
name: matchingEntity.Name,
|
|
381
|
-
schemaName: matchingEntity.SchemaName,
|
|
382
|
-
baseView: matchingEntity.BaseView,
|
|
383
|
-
fields: relevantFields
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
return results;
|
|
389
|
-
}
|
|
390
|
-
catch (error) {
|
|
391
|
-
console.warn(`Error in extractEntityMetadataFromSQL: ${error}`);
|
|
392
|
-
return results;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
async syncQueryParameters(extractedParams) {
|
|
396
|
-
// Filter out any parameter named "query" that came from {{query:"..."}} composition tokens.
|
|
397
|
-
// The AI extraction prompt should skip these, but this is a safety net.
|
|
398
|
-
const compositionTokenRegex = /\{\{query:"[^"]+"\}\}/;
|
|
399
|
-
const filteredParams = extractedParams.filter(p => {
|
|
400
|
-
if (p.name === 'query' && this.SQL && compositionTokenRegex.test(this.SQL)) {
|
|
401
|
-
return false;
|
|
402
|
-
}
|
|
403
|
-
return true;
|
|
404
|
-
});
|
|
405
|
-
extractedParams = filteredParams;
|
|
406
|
-
// Use the entity's provider instead of creating new Metadata instance
|
|
407
|
-
// Use same casting pattern as RefreshRelatedMetadata method
|
|
408
|
-
const md = this.ProviderToUse;
|
|
409
|
-
try {
|
|
410
|
-
// Get existing query parameters
|
|
411
|
-
const rv = this.RunViewProviderToUse;
|
|
412
|
-
const existingParams = [];
|
|
413
|
-
if (this.IsSaved) {
|
|
414
|
-
const existingParamsResult = await rv.RunView({
|
|
415
|
-
EntityName: 'MJ: Query Parameters',
|
|
416
|
-
ExtraFilter: `QueryID='${this.ID}'`,
|
|
417
|
-
ResultType: 'entity_object'
|
|
418
|
-
}, this.ContextCurrentUser);
|
|
419
|
-
if (!existingParamsResult.Success) {
|
|
420
|
-
throw new Error(`Failed to load existing query parameters: ${existingParamsResult.ErrorMessage}`);
|
|
421
|
-
}
|
|
422
|
-
existingParams.push(...existingParamsResult.Results || []);
|
|
423
|
-
}
|
|
424
|
-
// Convert extracted param names to lowercase for comparison
|
|
425
|
-
const extractedParamNames = extractedParams.map(p => p.name.toLowerCase());
|
|
426
|
-
// Find parameters to add, update, or remove
|
|
427
|
-
const paramsToAdd = extractedParams.filter(p => !existingParams.some(ep => ep.Name.toLowerCase() === p.name.toLowerCase()));
|
|
428
|
-
const paramsToUpdate = existingParams.filter(ep => extractedParams.some(p => p.name.toLowerCase() === ep.Name.toLowerCase()));
|
|
429
|
-
const paramsToRemove = existingParams.filter(ep => !extractedParamNames.includes(ep.Name.toLowerCase()));
|
|
430
|
-
// Prepare all save/delete operations
|
|
431
|
-
const promises = [];
|
|
432
|
-
// Add new parameters
|
|
433
|
-
for (const param of paramsToAdd) {
|
|
434
|
-
const newParam = await md.GetEntityObject('MJ: Query Parameters', this.ContextCurrentUser);
|
|
435
|
-
newParam.QueryID = this.ID;
|
|
436
|
-
newParam.Name = param.name;
|
|
437
|
-
// Normalize type to lowercase for case-insensitive matching
|
|
438
|
-
const normalizedType = param.type?.toLowerCase();
|
|
439
|
-
switch (normalizedType) {
|
|
440
|
-
case "array":
|
|
441
|
-
case "boolean":
|
|
442
|
-
case "string":
|
|
443
|
-
case "date":
|
|
444
|
-
case "number":
|
|
445
|
-
newParam.Type = normalizedType;
|
|
446
|
-
break;
|
|
447
|
-
case "object":
|
|
448
|
-
// Object type is supported in Nunjucks/RunQuery but not yet in database schema
|
|
449
|
-
// Store as string for now - the actual validation happens at runtime
|
|
450
|
-
console.log(`Query "${this.Name}" - Parameter "${param.name}" is type "object", storing as "string" (runtime will handle object validation)`);
|
|
451
|
-
newParam.Type = 'string';
|
|
452
|
-
break;
|
|
453
|
-
default:
|
|
454
|
-
console.warn(`Query "${this.Name}" - Unknown parameter type "${param.type}" for parameter "${param.name}", defaulting to "string"`);
|
|
455
|
-
newParam.Type = 'string';
|
|
456
|
-
}
|
|
457
|
-
newParam.IsRequired = param.isRequired;
|
|
458
|
-
newParam.DefaultValue = param.defaultValue;
|
|
459
|
-
newParam.Description = param.description;
|
|
460
|
-
newParam.SampleValue = param.sampleValue;
|
|
461
|
-
newParam.DetectionMethod = 'AI'; // Structure from SQLParser AST, descriptions enriched by LLM
|
|
462
|
-
promises.push(newParam.Save());
|
|
463
|
-
}
|
|
464
|
-
// Update existing parameters if properties changed
|
|
465
|
-
for (const existingParam of paramsToUpdate) {
|
|
466
|
-
const extractedParam = extractedParams.find(p => p.name.toLowerCase() === existingParam.Name.toLowerCase());
|
|
467
|
-
if (extractedParam) {
|
|
468
|
-
let hasChanges = false;
|
|
469
|
-
// Normalize type to lowercase for case-insensitive comparison
|
|
470
|
-
const normalizedType = extractedParam.type?.toLowerCase();
|
|
471
|
-
const validTypes = ['array', 'boolean', 'string', 'date', 'number'];
|
|
472
|
-
const isValidType = validTypes.includes(normalizedType);
|
|
473
|
-
let targetType;
|
|
474
|
-
if (isValidType) {
|
|
475
|
-
targetType = normalizedType;
|
|
476
|
-
}
|
|
477
|
-
else if (normalizedType === 'object') {
|
|
478
|
-
// Object type is supported in Nunjucks/RunQuery but not yet in database schema
|
|
479
|
-
console.log(`Query "${this.Name}" - Parameter "${extractedParam.name}" is type "object", storing as "string" (runtime will handle object validation)`);
|
|
480
|
-
targetType = 'string';
|
|
481
|
-
}
|
|
482
|
-
else {
|
|
483
|
-
console.warn(`Query "${this.Name}" - Unknown parameter type "${extractedParam.type}" for parameter "${extractedParam.name}", defaulting to "string"`);
|
|
484
|
-
targetType = 'string';
|
|
485
|
-
}
|
|
486
|
-
// Check each property for changes
|
|
487
|
-
if (existingParam.Type !== targetType) {
|
|
488
|
-
existingParam.Type = targetType;
|
|
489
|
-
hasChanges = true;
|
|
490
|
-
}
|
|
491
|
-
if (existingParam.IsRequired !== extractedParam.isRequired) {
|
|
492
|
-
existingParam.IsRequired = extractedParam.isRequired;
|
|
493
|
-
hasChanges = true;
|
|
494
|
-
}
|
|
495
|
-
if (existingParam.DefaultValue !== extractedParam.defaultValue) {
|
|
496
|
-
existingParam.DefaultValue = extractedParam.defaultValue;
|
|
497
|
-
hasChanges = true;
|
|
498
|
-
}
|
|
499
|
-
if (existingParam.Description !== extractedParam.description) {
|
|
500
|
-
existingParam.Description = extractedParam.description;
|
|
501
|
-
hasChanges = true;
|
|
502
|
-
}
|
|
503
|
-
if (existingParam.SampleValue !== extractedParam.sampleValue) {
|
|
504
|
-
existingParam.SampleValue = extractedParam.sampleValue;
|
|
505
|
-
hasChanges = true;
|
|
506
|
-
}
|
|
507
|
-
if (existingParam.DetectionMethod !== 'AI') {
|
|
508
|
-
existingParam.DetectionMethod = 'AI';
|
|
509
|
-
hasChanges = true;
|
|
510
|
-
}
|
|
511
|
-
if (hasChanges) {
|
|
512
|
-
promises.push(existingParam.Save());
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
// Remove parameters that are no longer in the SQL
|
|
517
|
-
for (const paramToRemove of paramsToRemove) {
|
|
518
|
-
promises.push(paramToRemove.Delete());
|
|
519
|
-
}
|
|
520
|
-
// Execute all operations in parallel
|
|
521
|
-
if (promises.length > 0) {
|
|
522
|
-
await Promise.all(promises);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
catch (e) {
|
|
526
|
-
LogError(`Query "${this.Name}" - Failed to sync parameters:`, e);
|
|
527
|
-
throw e;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
async removeAllQueryParameters() {
|
|
531
|
-
try {
|
|
532
|
-
if (this.IsSaved) {
|
|
533
|
-
// Get all existing query parameters
|
|
534
|
-
const rv = this.RunViewProviderToUse;
|
|
535
|
-
const existingParamsResult = await rv.RunView({
|
|
536
|
-
EntityName: 'MJ: Query Parameters',
|
|
537
|
-
ExtraFilter: `QueryID='${this.ID}'`,
|
|
538
|
-
ResultType: 'entity_object'
|
|
539
|
-
}, this.ContextCurrentUser);
|
|
540
|
-
if (!existingParamsResult.Success) {
|
|
541
|
-
throw new Error(`Failed to load existing query parameters: ${existingParamsResult.ErrorMessage}`);
|
|
542
|
-
}
|
|
543
|
-
const existingParams = existingParamsResult.Results || [];
|
|
544
|
-
// Delete all existing parameters
|
|
545
|
-
const deletePromises = existingParams.map(param => param.Delete());
|
|
546
|
-
if (deletePromises.length > 0) {
|
|
547
|
-
await Promise.all(deletePromises);
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
catch (e) {
|
|
552
|
-
LogError('Failed to remove query parameters:', e);
|
|
553
|
-
throw e; // Re-throw since we're in a transaction
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
/**
|
|
557
|
-
* Detects if the SQL uses SELECT * and if so, builds the complete field list deterministically
|
|
558
|
-
* from entity metadata without relying on the AI (which can't enumerate * columns).
|
|
559
|
-
* Returns null if the SQL doesn't use SELECT * or if entities can't be resolved.
|
|
560
|
-
*/
|
|
561
|
-
buildFieldsForSelectStar() {
|
|
562
|
-
if (!this.SQL)
|
|
563
|
-
return null;
|
|
564
|
-
// Check if SELECT clause contains * (possibly with table alias like "a.*")
|
|
565
|
-
const selectStarRegex = /\bSELECT\s+(?:(?:TOP\s+\d+|DISTINCT)\s+)?(\*|[\w.]+\.\*)\s+FROM\b/i;
|
|
566
|
-
if (!selectStarRegex.test(this.SQL))
|
|
567
|
-
return null;
|
|
568
|
-
const md = this.ProviderToUse;
|
|
569
|
-
try {
|
|
570
|
-
const tableRefs = SQLParser.ExtractTableRefs(this.SQL);
|
|
571
|
-
const expandedFields = [];
|
|
572
|
-
for (const tableRef of tableRefs) {
|
|
573
|
-
const matchingEntity = md.Entities.find(e => (e.BaseView.toLowerCase() === tableRef.TableName.toLowerCase() ||
|
|
574
|
-
e.BaseTable.toLowerCase() === tableRef.TableName.toLowerCase()) &&
|
|
575
|
-
e.SchemaName.toLowerCase() === tableRef.SchemaName.toLowerCase());
|
|
576
|
-
if (matchingEntity) {
|
|
577
|
-
for (const entityField of matchingEntity.Fields) {
|
|
578
|
-
if (!expandedFields.some(f => f.name === entityField.Name)) {
|
|
579
|
-
expandedFields.push({
|
|
580
|
-
name: entityField.Name,
|
|
581
|
-
description: entityField.Description || `${entityField.Name} field from ${matchingEntity.Name}`,
|
|
582
|
-
type: TypeScriptTypeFromSQLType(entityField.Type).toLowerCase(),
|
|
583
|
-
optional: false,
|
|
584
|
-
sourceEntity: matchingEntity.Name,
|
|
585
|
-
sourceFieldName: entityField.Name,
|
|
586
|
-
isComputed: false,
|
|
587
|
-
isSummary: false
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
// Also check for {{query:"..."}} composition tokens in FROM clause
|
|
594
|
-
if (expandedFields.length === 0) {
|
|
595
|
-
const compositionRefs = SQLParser.ExtractCompositionRefs(this.SQL);
|
|
596
|
-
for (const ref of compositionRefs) {
|
|
597
|
-
const referencedQuery = Metadata.Provider.Queries.find(q => q.Name.toLowerCase() === ref.queryName.toLowerCase());
|
|
598
|
-
if (referencedQuery && referencedQuery.Fields.length > 0) {
|
|
599
|
-
for (const qField of referencedQuery.Fields) {
|
|
600
|
-
if (!expandedFields.some(f => f.name === qField.Name)) {
|
|
601
|
-
expandedFields.push({
|
|
602
|
-
name: qField.Name,
|
|
603
|
-
description: qField.Description || `${qField.Name} from query "${referencedQuery.Name}"`,
|
|
604
|
-
type: TypeScriptTypeFromSQLType(qField.SQLBaseType).toLowerCase(),
|
|
605
|
-
optional: false,
|
|
606
|
-
sourceEntity: qField.SourceEntityID ? md.Entities.find(e => UUIDsEqual(e.ID, qField.SourceEntityID))?.Name || null : null,
|
|
607
|
-
sourceFieldName: qField.SourceFieldName || null,
|
|
608
|
-
isComputed: qField.IsComputed || false,
|
|
609
|
-
isSummary: qField.IsSummary || false
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
return expandedFields.length > 0 ? expandedFields : null;
|
|
617
|
-
}
|
|
618
|
-
catch (error) {
|
|
619
|
-
console.warn(`Query "${this.Name}" - Error in buildFieldsForSelectStar:`, error);
|
|
620
|
-
return null;
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
/**
|
|
624
|
-
* Finds an entity by name, checking both Name (which may have "MJ: " prefix) and DisplayName.
|
|
625
|
-
* The AI often returns entity names without the "MJ: " prefix, so we need to match both.
|
|
626
|
-
*/
|
|
627
|
-
findEntityByName(md, name) {
|
|
628
|
-
const lower = name.toLowerCase();
|
|
629
|
-
return md.Entities.find(e => e.Name.toLowerCase() === lower || (e.DisplayName && e.DisplayName.toLowerCase() === lower));
|
|
630
|
-
}
|
|
631
|
-
/**
|
|
632
|
-
* Expands wildcard (*) entries in the extracted fields list.
|
|
633
|
-
* When AI returns a field with name="*" or sourceFieldName="*", we expand it into
|
|
634
|
-
* individual field entries by looking up the source from metadata.
|
|
635
|
-
* If the AI didn't provide a sourceEntity, we parse the SQL's FROM clause to find it.
|
|
636
|
-
*/
|
|
637
|
-
expandWildcardFields(extractedFields, md) {
|
|
638
|
-
// Check if any field is a wildcard that needs expansion
|
|
639
|
-
const hasWildcard = extractedFields.some(f => f.name === '*' || f.sourceFieldName === '*');
|
|
640
|
-
if (!hasWildcard)
|
|
641
|
-
return extractedFields;
|
|
642
|
-
const expandedFields = [];
|
|
643
|
-
for (const field of extractedFields) {
|
|
644
|
-
const isWildcard = field.name === '*' || field.sourceFieldName === '*';
|
|
645
|
-
if (!isWildcard) {
|
|
646
|
-
expandedFields.push(field);
|
|
647
|
-
continue;
|
|
648
|
-
}
|
|
649
|
-
// If we have a sourceEntity, try to resolve from it
|
|
650
|
-
if (field.sourceEntity) {
|
|
651
|
-
const sourceEntityInfo = this.findEntityByName(md, field.sourceEntity);
|
|
652
|
-
if (sourceEntityInfo) {
|
|
653
|
-
for (const entityField of sourceEntityInfo.Fields) {
|
|
654
|
-
expandedFields.push({
|
|
655
|
-
name: entityField.Name,
|
|
656
|
-
description: entityField.Description || `${entityField.Name} field from ${sourceEntityInfo.Name}`,
|
|
657
|
-
type: TypeScriptTypeFromSQLType(entityField.Type).toLowerCase(),
|
|
658
|
-
optional: field.optional,
|
|
659
|
-
sourceEntity: sourceEntityInfo.Name,
|
|
660
|
-
sourceFieldName: entityField.Name,
|
|
661
|
-
isComputed: false,
|
|
662
|
-
isSummary: false
|
|
663
|
-
});
|
|
664
|
-
}
|
|
665
|
-
continue;
|
|
666
|
-
}
|
|
667
|
-
// sourceEntity didn't match an entity — try as a query name
|
|
668
|
-
const composedQuery = Metadata.Provider.Queries.find(q => q.Name.toLowerCase() === field.sourceEntity.toLowerCase());
|
|
669
|
-
if (composedQuery && composedQuery.Fields.length > 0) {
|
|
670
|
-
for (const qField of composedQuery.Fields) {
|
|
671
|
-
expandedFields.push({
|
|
672
|
-
name: qField.Name,
|
|
673
|
-
description: qField.Description || `${qField.Name} from query "${composedQuery.Name}"`,
|
|
674
|
-
type: TypeScriptTypeFromSQLType(qField.SQLBaseType).toLowerCase(),
|
|
675
|
-
optional: field.optional,
|
|
676
|
-
sourceEntity: qField.SourceEntityID ? md.Entities.find(e => UUIDsEqual(e.ID, qField.SourceEntityID))?.Name || null : null,
|
|
677
|
-
sourceFieldName: qField.SourceFieldName || null,
|
|
678
|
-
isComputed: qField.IsComputed || false,
|
|
679
|
-
isSummary: qField.IsSummary || false
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
continue;
|
|
683
|
-
}
|
|
684
|
-
expandedFields.push(field);
|
|
685
|
-
continue;
|
|
686
|
-
}
|
|
687
|
-
// No sourceEntity and no match — keep the raw entry as-is
|
|
688
|
-
expandedFields.push(field);
|
|
689
|
-
}
|
|
690
|
-
return expandedFields;
|
|
691
|
-
}
|
|
692
|
-
async syncQueryFields(extractedFields) {
|
|
693
|
-
// Use the entity's provider instead of creating new Metadata instance
|
|
694
|
-
// Use same casting pattern as RefreshRelatedMetadata method
|
|
695
|
-
const md = this.ProviderToUse;
|
|
696
|
-
// Expand any wildcard (*) entries before processing
|
|
697
|
-
const fieldsToSync = this.expandWildcardFields(extractedFields, md);
|
|
698
|
-
try {
|
|
699
|
-
const existingFields = [];
|
|
700
|
-
if (this.IsSaved) {
|
|
701
|
-
// Get existing query fields
|
|
702
|
-
const rv = this.RunViewProviderToUse;
|
|
703
|
-
const existingFieldsResult = await rv.RunView({
|
|
704
|
-
EntityName: 'MJ: Query Fields',
|
|
705
|
-
ExtraFilter: `QueryID='${this.ID}'`,
|
|
706
|
-
ResultType: 'entity_object'
|
|
707
|
-
}, this.ContextCurrentUser);
|
|
708
|
-
if (!existingFieldsResult.Success) {
|
|
709
|
-
throw new Error(`Failed to load existing query fields: ${existingFieldsResult.ErrorMessage}`);
|
|
710
|
-
}
|
|
711
|
-
existingFields.push(...existingFieldsResult.Results || []);
|
|
712
|
-
}
|
|
713
|
-
// Convert field names to lowercase for comparison (using expanded fieldsToSync)
|
|
714
|
-
const fieldNamesToSync = fieldsToSync.map(f => f.name.toLowerCase());
|
|
715
|
-
// Find fields to add, update, or remove
|
|
716
|
-
const fieldsToAdd = fieldsToSync.filter(f => !existingFields.some(ef => ef.Name.toLowerCase() === f.name.toLowerCase()));
|
|
717
|
-
const fieldsToUpdate = existingFields.filter(ef => fieldsToSync.some(f => f.name.toLowerCase() === ef.Name.toLowerCase()));
|
|
718
|
-
const fieldsToRemove = existingFields.filter(ef => !fieldNamesToSync.includes(ef.Name.toLowerCase()));
|
|
719
|
-
// Prepare all save/delete operations
|
|
720
|
-
const promises = [];
|
|
721
|
-
// Add new fields
|
|
722
|
-
for (let i = 0; i < fieldsToAdd.length; i++) {
|
|
723
|
-
const field = fieldsToAdd[i];
|
|
724
|
-
const newField = await md.GetEntityObject('MJ: Query Fields', this.ContextCurrentUser);
|
|
725
|
-
newField.QueryID = this.ID;
|
|
726
|
-
newField.Name = field.name;
|
|
727
|
-
newField.Description = field.description;
|
|
728
|
-
newField.Sequence = i + 1;
|
|
729
|
-
// Map type to SQL types
|
|
730
|
-
switch (field.type) {
|
|
731
|
-
case 'number':
|
|
732
|
-
newField.SQLBaseType = 'decimal';
|
|
733
|
-
newField.SQLFullType = 'decimal(18,2)';
|
|
734
|
-
break;
|
|
735
|
-
case 'date':
|
|
736
|
-
newField.SQLBaseType = 'datetime';
|
|
737
|
-
newField.SQLFullType = 'datetime';
|
|
738
|
-
break;
|
|
739
|
-
case 'boolean':
|
|
740
|
-
newField.SQLBaseType = 'bit';
|
|
741
|
-
newField.SQLFullType = 'bit';
|
|
742
|
-
break;
|
|
743
|
-
default:
|
|
744
|
-
newField.SQLBaseType = 'nvarchar';
|
|
745
|
-
newField.SQLFullType = 'nvarchar(MAX)';
|
|
746
|
-
}
|
|
747
|
-
// Set computed/summary flags
|
|
748
|
-
newField.IsComputed = field.isComputed || field.dynamicName || false;
|
|
749
|
-
newField.IsSummary = field.isSummary || false;
|
|
750
|
-
if (field.computationDescription) {
|
|
751
|
-
newField.ComputationDescription = field.computationDescription;
|
|
752
|
-
}
|
|
753
|
-
// Set source entity tracking
|
|
754
|
-
if (field.sourceEntity) {
|
|
755
|
-
// Look up entity ID from entity name (check both Name and DisplayName for "MJ: " prefix)
|
|
756
|
-
const sourceEntityInfo = this.findEntityByName(md, field.sourceEntity);
|
|
757
|
-
if (sourceEntityInfo) {
|
|
758
|
-
newField.SourceEntityID = sourceEntityInfo.ID;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
newField.SourceFieldName = field.sourceFieldName || (field.dynamicName ? field.name : null);
|
|
762
|
-
promises.push(newField.Save());
|
|
763
|
-
}
|
|
764
|
-
// Update existing fields if properties changed
|
|
765
|
-
for (const existingField of fieldsToUpdate) {
|
|
766
|
-
const extractedField = fieldsToSync.find(f => f.name.toLowerCase() === existingField.Name.toLowerCase());
|
|
767
|
-
if (extractedField) {
|
|
768
|
-
let hasChanges = false;
|
|
769
|
-
if (existingField.Description !== extractedField.description) {
|
|
770
|
-
existingField.Description = extractedField.description;
|
|
771
|
-
hasChanges = true;
|
|
772
|
-
}
|
|
773
|
-
const newIsComputed = extractedField.isComputed || extractedField.dynamicName || false;
|
|
774
|
-
if (existingField.IsComputed !== newIsComputed) {
|
|
775
|
-
existingField.IsComputed = newIsComputed;
|
|
776
|
-
hasChanges = true;
|
|
777
|
-
}
|
|
778
|
-
const newIsSummary = extractedField.isSummary || false;
|
|
779
|
-
if (existingField.IsSummary !== newIsSummary) {
|
|
780
|
-
existingField.IsSummary = newIsSummary;
|
|
781
|
-
hasChanges = true;
|
|
782
|
-
}
|
|
783
|
-
if (extractedField.computationDescription && existingField.ComputationDescription !== extractedField.computationDescription) {
|
|
784
|
-
existingField.ComputationDescription = extractedField.computationDescription;
|
|
785
|
-
hasChanges = true;
|
|
786
|
-
}
|
|
787
|
-
// Update source entity tracking
|
|
788
|
-
if (extractedField.sourceEntity) {
|
|
789
|
-
const sourceEntityInfo = this.findEntityByName(md, extractedField.sourceEntity);
|
|
790
|
-
if (sourceEntityInfo && !UUIDsEqual(existingField.SourceEntityID, sourceEntityInfo.ID)) {
|
|
791
|
-
existingField.SourceEntityID = sourceEntityInfo.ID;
|
|
792
|
-
hasChanges = true;
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
else if (existingField.SourceEntityID != null) {
|
|
796
|
-
existingField.SourceEntityID = null;
|
|
797
|
-
hasChanges = true;
|
|
798
|
-
}
|
|
799
|
-
const newSourceFieldName = extractedField.sourceFieldName || (extractedField.dynamicName ? extractedField.name : null);
|
|
800
|
-
if (existingField.SourceFieldName !== newSourceFieldName) {
|
|
801
|
-
existingField.SourceFieldName = newSourceFieldName;
|
|
802
|
-
hasChanges = true;
|
|
803
|
-
}
|
|
804
|
-
if (existingField.Sequence !== fieldsToSync.indexOf(extractedField) + 1) {
|
|
805
|
-
existingField.Sequence = fieldsToSync.indexOf(extractedField) + 1;
|
|
806
|
-
hasChanges = true;
|
|
807
|
-
}
|
|
808
|
-
if (hasChanges) {
|
|
809
|
-
promises.push(existingField.Save());
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
// Remove fields that are no longer in the SQL
|
|
814
|
-
for (const fieldToRemove of fieldsToRemove) {
|
|
815
|
-
promises.push(fieldToRemove.Delete());
|
|
816
|
-
}
|
|
817
|
-
// Execute all operations in parallel
|
|
818
|
-
if (promises.length > 0) {
|
|
819
|
-
await Promise.all(promises);
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
catch (e) {
|
|
823
|
-
LogError(`Query "${this.Name}" - Failed to sync fields:`, e);
|
|
824
|
-
throw e;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
async syncQueryEntities(extractedEntities) {
|
|
828
|
-
// Use the entity's provider instead of creating new Metadata instance
|
|
829
|
-
// Use same casting pattern as RefreshRelatedMetadata method
|
|
830
|
-
const md = this.ProviderToUse;
|
|
831
|
-
try {
|
|
832
|
-
// Get existing query entities
|
|
833
|
-
const existingEntities = [];
|
|
834
|
-
if (this.IsSaved) {
|
|
835
|
-
const rv = this.RunViewProviderToUse;
|
|
836
|
-
const existingEntitiesResult = await rv.RunView({
|
|
837
|
-
EntityName: 'MJ: Query Entities',
|
|
838
|
-
ExtraFilter: `QueryID='${this.ID}'`,
|
|
839
|
-
ResultType: 'entity_object'
|
|
840
|
-
}, this.ContextCurrentUser);
|
|
841
|
-
if (!existingEntitiesResult.Success) {
|
|
842
|
-
throw new Error(`Failed to load existing query entities: ${existingEntitiesResult.ErrorMessage}`);
|
|
843
|
-
}
|
|
844
|
-
existingEntities.push(...existingEntitiesResult.Results || []);
|
|
845
|
-
}
|
|
846
|
-
// Look up MJ entity IDs for the extracted entities using pre-loaded metadata
|
|
847
|
-
// Since extractEntityMetadataFromSQL already matched entities, we just need to find them by name
|
|
848
|
-
const entityMappings = extractedEntities.map(extracted => {
|
|
849
|
-
// Find matching entity in metadata by name (already matched during extraction)
|
|
850
|
-
const matchingEntity = md.Entities.find(e => e.Name === extracted.name &&
|
|
851
|
-
e.SchemaName.toLowerCase() === extracted.schemaName.toLowerCase());
|
|
852
|
-
if (matchingEntity) {
|
|
853
|
-
return {
|
|
854
|
-
extracted,
|
|
855
|
-
entityID: matchingEntity.ID,
|
|
856
|
-
entityName: matchingEntity.Name
|
|
857
|
-
};
|
|
858
|
-
}
|
|
859
|
-
return null;
|
|
860
|
-
}).filter(m => m !== null);
|
|
861
|
-
// Find entities to add or remove
|
|
862
|
-
const entitiesToAdd = entityMappings.filter(mapping => !existingEntities.some(ee => UUIDsEqual(ee.EntityID, mapping.entityID)));
|
|
863
|
-
const entitiesToRemove = existingEntities.filter(ee => !entityMappings.some(mapping => UUIDsEqual(mapping.entityID, ee.EntityID)));
|
|
864
|
-
// Prepare all save/delete operations
|
|
865
|
-
const promises = [];
|
|
866
|
-
// Add new query entity relationships
|
|
867
|
-
for (const mapping of entitiesToAdd) {
|
|
868
|
-
if (mapping) {
|
|
869
|
-
const newEntity = await md.GetEntityObject('MJ: Query Entities', this.ContextCurrentUser);
|
|
870
|
-
newEntity.QueryID = this.ID;
|
|
871
|
-
newEntity.EntityID = mapping.entityID;
|
|
872
|
-
newEntity.DetectionMethod = 'AI'; // Using 'AI' as it's the closest match to automated detection
|
|
873
|
-
newEntity.AutoDetectConfidenceScore = 1.0; // 100% confidence since we're using deterministic SQL parsing
|
|
874
|
-
promises.push(newEntity.Save());
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
// Remove entities that are no longer in the SQL
|
|
878
|
-
for (const entityToRemove of entitiesToRemove) {
|
|
879
|
-
promises.push(entityToRemove.Delete());
|
|
880
|
-
}
|
|
881
|
-
// Execute all operations in parallel
|
|
882
|
-
if (promises.length > 0) {
|
|
883
|
-
await Promise.all(promises);
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
catch (e) {
|
|
887
|
-
LogError(`Query "${this.Name}" - Failed to sync entities:`, e);
|
|
888
|
-
throw e;
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
async removeAllQueryFields() {
|
|
892
|
-
try {
|
|
893
|
-
if (!this.IsSaved)
|
|
894
|
-
return; // Nothing to remove if not saved
|
|
895
|
-
const rv = this.RunViewProviderToUse;
|
|
896
|
-
const existingFieldsResult = await rv.RunView({
|
|
897
|
-
EntityName: 'MJ: Query Fields',
|
|
898
|
-
ExtraFilter: `QueryID='${this.ID}'`,
|
|
899
|
-
ResultType: 'entity_object'
|
|
900
|
-
}, this.ContextCurrentUser);
|
|
901
|
-
if (!existingFieldsResult.Success) {
|
|
902
|
-
throw new Error(`Failed to load existing query fields: ${existingFieldsResult.ErrorMessage}`);
|
|
903
|
-
}
|
|
904
|
-
const existingFields = existingFieldsResult.Results || [];
|
|
905
|
-
const deletePromises = existingFields.map(field => field.Delete());
|
|
906
|
-
if (deletePromises.length > 0) {
|
|
907
|
-
await Promise.all(deletePromises);
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
catch (e) {
|
|
911
|
-
LogError('Failed to remove query fields:', e);
|
|
912
|
-
throw e;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
async removeAllQueryEntities() {
|
|
916
|
-
try {
|
|
917
|
-
if (!this.IsSaved)
|
|
918
|
-
return; // Nothing to remove if not saved
|
|
919
|
-
const rv = this.RunViewProviderToUse;
|
|
920
|
-
const existingEntitiesResult = await rv.RunView({
|
|
921
|
-
EntityName: 'MJ: Query Entities',
|
|
922
|
-
ExtraFilter: `QueryID='${this.ID}'`,
|
|
923
|
-
ResultType: 'entity_object'
|
|
924
|
-
}, this.ContextCurrentUser);
|
|
925
|
-
if (!existingEntitiesResult.Success) {
|
|
926
|
-
throw new Error(`Failed to load existing query entities: ${existingEntitiesResult.ErrorMessage}`);
|
|
927
|
-
}
|
|
928
|
-
const existingEntities = existingEntitiesResult.Results || [];
|
|
929
|
-
const deletePromises = existingEntities.map(entity => entity.Delete());
|
|
930
|
-
if (deletePromises.length > 0) {
|
|
931
|
-
await Promise.all(deletePromises);
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
catch (e) {
|
|
935
|
-
LogError('Failed to remove query entities:', e);
|
|
936
|
-
throw e;
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
/**
|
|
940
|
-
* Deterministically extracts {{query:"..."}} composition references from this query's SQL
|
|
941
|
-
* and syncs the QueryDependency records accordingly. No LLM needed — uses regex parsing.
|
|
942
|
-
*/
|
|
943
|
-
async extractAndSyncQueryDependencies() {
|
|
944
|
-
const compositionEngine = new QueryCompositionEngine();
|
|
945
|
-
const sql = this.SQL;
|
|
946
|
-
if (!sql || !compositionEngine.HasCompositionTokens(sql)) {
|
|
947
|
-
// No composition tokens — remove any stale dependency records
|
|
948
|
-
await this.removeAllQueryDependencies();
|
|
949
|
-
return;
|
|
950
|
-
}
|
|
951
|
-
// Parse all {{query:"..."}} tokens
|
|
952
|
-
const tokens = compositionEngine.ParseCompositionTokens(sql);
|
|
953
|
-
if (tokens.length === 0) {
|
|
954
|
-
await this.removeAllQueryDependencies();
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
957
|
-
// Resolve each token to a QueryInfo to get its ID
|
|
958
|
-
const extractedDeps = this.resolveTokensToQueryDependencies(tokens);
|
|
959
|
-
// Check for cycles before syncing
|
|
960
|
-
const cycleError = await this.detectDependencyCycles(extractedDeps);
|
|
961
|
-
if (cycleError) {
|
|
962
|
-
LogError(`Query "${this.Name}" creates circular dependency: ${cycleError}. Dependencies will not be synced.`);
|
|
963
|
-
return;
|
|
964
|
-
}
|
|
965
|
-
await this.syncQueryDependencies(extractedDeps);
|
|
966
|
-
}
|
|
967
|
-
/**
|
|
968
|
-
* Resolves parsed composition tokens to dependency records with QueryIDs.
|
|
969
|
-
*/
|
|
970
|
-
resolveTokensToQueryDependencies(tokens) {
|
|
971
|
-
const allQueries = Metadata.Provider.Queries;
|
|
972
|
-
const deps = [];
|
|
973
|
-
for (const token of tokens) {
|
|
974
|
-
const queryName = token.QueryName.toLowerCase();
|
|
975
|
-
// Try category-qualified lookup first
|
|
976
|
-
let matchedQuery;
|
|
977
|
-
if (token.CategorySegments.length > 0) {
|
|
978
|
-
const expectedPath = `/${token.CategorySegments.join('/')}/`;
|
|
979
|
-
matchedQuery = allQueries.find(q => q.Name.toLowerCase() === queryName &&
|
|
980
|
-
q.CategoryPath.toLowerCase() === expectedPath.toLowerCase());
|
|
981
|
-
}
|
|
982
|
-
// Fall back to name-only
|
|
983
|
-
if (!matchedQuery) {
|
|
984
|
-
const nameMatches = allQueries.filter(q => q.Name.toLowerCase() === queryName);
|
|
985
|
-
if (nameMatches.length === 1) {
|
|
986
|
-
matchedQuery = nameMatches[0];
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
if (!matchedQuery) {
|
|
990
|
-
LogError(`Query "${this.Name}": Referenced query not found: "${token.FullPath}". Dependency will not be tracked.`);
|
|
991
|
-
continue;
|
|
992
|
-
}
|
|
993
|
-
// Validate the referenced query is marked Reusable
|
|
994
|
-
if (!matchedQuery.Reusable) {
|
|
995
|
-
throw new Error(`Query "${this.Name}" references "${token.FullPath}" via composition syntax, ` +
|
|
996
|
-
`but "${matchedQuery.Name}" is not marked as Reusable. ` +
|
|
997
|
-
`Set Reusable=true on "${matchedQuery.Name}" before using it in {{query:"..."}} references.`);
|
|
998
|
-
}
|
|
999
|
-
// Extract alias from SQL context (look for the token followed by whitespace + identifier)
|
|
1000
|
-
const alias = this.extractAliasFromSQL(token.FullToken);
|
|
1001
|
-
// Build parameter mapping
|
|
1002
|
-
let parameterMapping = null;
|
|
1003
|
-
if (token.Parameters.length > 0) {
|
|
1004
|
-
parameterMapping = {};
|
|
1005
|
-
for (const param of token.Parameters) {
|
|
1006
|
-
if (param.StaticValue !== null) {
|
|
1007
|
-
parameterMapping[param.Name] = param.StaticValue;
|
|
1008
|
-
}
|
|
1009
|
-
else if (param.PassThroughName !== null) {
|
|
1010
|
-
parameterMapping[param.Name] = `@${param.PassThroughName}`;
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
deps.push({
|
|
1015
|
-
dependsOnQueryID: matchedQuery.ID,
|
|
1016
|
-
referencePath: token.FullPath,
|
|
1017
|
-
alias,
|
|
1018
|
-
parameterMapping
|
|
1019
|
-
});
|
|
1020
|
-
}
|
|
1021
|
-
return deps;
|
|
1022
|
-
}
|
|
1023
|
-
/**
|
|
1024
|
-
* Extracts the SQL alias following a {{query:"..."}} token from this query's SQL.
|
|
1025
|
-
*/
|
|
1026
|
-
extractAliasFromSQL(fullToken) {
|
|
1027
|
-
if (!this.SQL)
|
|
1028
|
-
return null;
|
|
1029
|
-
const tokenIndex = this.SQL.indexOf(fullToken);
|
|
1030
|
-
if (tokenIndex < 0)
|
|
1031
|
-
return null;
|
|
1032
|
-
const afterToken = this.SQL.substring(tokenIndex + fullToken.length);
|
|
1033
|
-
const aliasMatch = /^\s+(\w+)/i.exec(afterToken);
|
|
1034
|
-
// Make sure it's not a SQL keyword
|
|
1035
|
-
if (aliasMatch) {
|
|
1036
|
-
const keywords = new Set(['ON', 'WHERE', 'AND', 'OR', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'CROSS', 'FULL', 'GROUP', 'ORDER', 'HAVING', 'UNION', 'EXCEPT', 'INTERSECT', 'LIMIT', 'OFFSET', 'FETCH']);
|
|
1037
|
-
if (!keywords.has(aliasMatch[1].toUpperCase())) {
|
|
1038
|
-
return aliasMatch[1];
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
return null;
|
|
1042
|
-
}
|
|
1043
|
-
/**
|
|
1044
|
-
* Detects circular dependencies by walking the dependency graph.
|
|
1045
|
-
* Returns a descriptive error string if a cycle is found, or null if clean.
|
|
1046
|
-
*/
|
|
1047
|
-
async detectDependencyCycles(proposedDeps) {
|
|
1048
|
-
const allDependencies = Metadata.Provider.QueryDependencies;
|
|
1049
|
-
const visited = new Set();
|
|
1050
|
-
const stack = new Set();
|
|
1051
|
-
const hasCycle = (queryID, path) => {
|
|
1052
|
-
if (stack.has(queryID)) {
|
|
1053
|
-
const queryName = Metadata.Provider.Queries.find(q => UUIDsEqual(q.ID, queryID))?.Name || queryID;
|
|
1054
|
-
return [...path, queryName].join(' → ');
|
|
1055
|
-
}
|
|
1056
|
-
if (visited.has(queryID))
|
|
1057
|
-
return null;
|
|
1058
|
-
visited.add(queryID);
|
|
1059
|
-
stack.add(queryID);
|
|
1060
|
-
const queryName = Metadata.Provider.Queries.find(q => UUIDsEqual(q.ID, queryID))?.Name || queryID;
|
|
1061
|
-
path.push(queryName);
|
|
1062
|
-
// Get existing dependencies for this query
|
|
1063
|
-
const deps = allDependencies.filter(d => d.QueryID === queryID);
|
|
1064
|
-
for (const dep of deps) {
|
|
1065
|
-
const result = hasCycle(dep.DependsOnQueryID, [...path]);
|
|
1066
|
-
if (result)
|
|
1067
|
-
return result;
|
|
1068
|
-
}
|
|
1069
|
-
stack.delete(queryID);
|
|
1070
|
-
return null;
|
|
1071
|
-
};
|
|
1072
|
-
// Check each proposed dependency
|
|
1073
|
-
for (const dep of proposedDeps) {
|
|
1074
|
-
// Simulate: dep.dependsOnQueryID → (its dependencies) → ... → back to this.ID?
|
|
1075
|
-
const visited2 = new Set();
|
|
1076
|
-
const stack2 = new Set([this.ID]);
|
|
1077
|
-
const checkFromDep = (queryID, path) => {
|
|
1078
|
-
if (UUIDsEqual(queryID, this.ID)) {
|
|
1079
|
-
return [...path, this.Name].join(' → ');
|
|
1080
|
-
}
|
|
1081
|
-
if (visited2.has(queryID))
|
|
1082
|
-
return null;
|
|
1083
|
-
visited2.add(queryID);
|
|
1084
|
-
const queryName = Metadata.Provider.Queries.find(q => UUIDsEqual(q.ID, queryID))?.Name || queryID;
|
|
1085
|
-
path.push(queryName);
|
|
1086
|
-
const deps = allDependencies.filter(d => d.QueryID === queryID);
|
|
1087
|
-
for (const d of deps) {
|
|
1088
|
-
const result = checkFromDep(d.DependsOnQueryID, [...path]);
|
|
1089
|
-
if (result)
|
|
1090
|
-
return result;
|
|
1091
|
-
}
|
|
1092
|
-
return null;
|
|
1093
|
-
};
|
|
1094
|
-
const result = checkFromDep(dep.dependsOnQueryID, [this.Name]);
|
|
1095
|
-
if (result)
|
|
1096
|
-
return result;
|
|
1097
|
-
}
|
|
1098
|
-
return null;
|
|
1099
|
-
}
|
|
1100
|
-
/**
|
|
1101
|
-
* Syncs extracted query dependencies against existing QueryDependency records.
|
|
1102
|
-
* Follows the same add/update/delete pattern as syncQueryParameters/Fields/Entities.
|
|
1103
|
-
*/
|
|
1104
|
-
async syncQueryDependencies(extractedDeps) {
|
|
1105
|
-
const md = new Metadata();
|
|
1106
|
-
try {
|
|
1107
|
-
const existingDeps = [];
|
|
1108
|
-
if (this.IsSaved) {
|
|
1109
|
-
const rv = this.RunViewProviderToUse;
|
|
1110
|
-
const existingResult = await rv.RunView({
|
|
1111
|
-
EntityName: 'MJ: Query Dependencies',
|
|
1112
|
-
ExtraFilter: `QueryID='${this.ID}'`,
|
|
1113
|
-
ResultType: 'entity_object'
|
|
1114
|
-
}, this.ContextCurrentUser);
|
|
1115
|
-
if (!existingResult.Success) {
|
|
1116
|
-
throw new Error(`Failed to load existing query dependencies: ${existingResult.ErrorMessage}`);
|
|
1117
|
-
}
|
|
1118
|
-
existingDeps.push(...(existingResult.Results || []));
|
|
1119
|
-
}
|
|
1120
|
-
// Compare: match on DependsOnQueryID + ReferencePath for uniqueness
|
|
1121
|
-
const depsToAdd = extractedDeps.filter(d => !existingDeps.some(ed => UUIDsEqual(ed.DependsOnQueryID, d.dependsOnQueryID) &&
|
|
1122
|
-
ed.ReferencePath === d.referencePath));
|
|
1123
|
-
const depsToUpdate = existingDeps.filter(ed => extractedDeps.some(d => UUIDsEqual(ed.DependsOnQueryID, d.dependsOnQueryID) &&
|
|
1124
|
-
ed.ReferencePath === d.referencePath));
|
|
1125
|
-
const depsToRemove = existingDeps.filter(ed => !extractedDeps.some(d => UUIDsEqual(ed.DependsOnQueryID, d.dependsOnQueryID) &&
|
|
1126
|
-
ed.ReferencePath === d.referencePath));
|
|
1127
|
-
const promises = [];
|
|
1128
|
-
// Add new dependencies
|
|
1129
|
-
for (const dep of depsToAdd) {
|
|
1130
|
-
const newDep = await md.GetEntityObject('MJ: Query Dependencies', this.ContextCurrentUser);
|
|
1131
|
-
newDep.QueryID = this.ID;
|
|
1132
|
-
newDep.DependsOnQueryID = dep.dependsOnQueryID;
|
|
1133
|
-
newDep.ReferencePath = dep.referencePath;
|
|
1134
|
-
newDep.Alias = dep.alias;
|
|
1135
|
-
newDep.ParameterMapping = dep.parameterMapping ? JSON.stringify(dep.parameterMapping) : null;
|
|
1136
|
-
newDep.DetectionMethod = 'Auto';
|
|
1137
|
-
promises.push(newDep.Save());
|
|
1138
|
-
}
|
|
1139
|
-
// Update existing dependencies (check for changes)
|
|
1140
|
-
for (const existingDep of depsToUpdate) {
|
|
1141
|
-
const extractedDep = extractedDeps.find(d => UUIDsEqual(existingDep.DependsOnQueryID, d.dependsOnQueryID) &&
|
|
1142
|
-
existingDep.ReferencePath === d.referencePath);
|
|
1143
|
-
if (extractedDep) {
|
|
1144
|
-
let hasChanges = false;
|
|
1145
|
-
if (existingDep.Alias !== extractedDep.alias) {
|
|
1146
|
-
existingDep.Alias = extractedDep.alias;
|
|
1147
|
-
hasChanges = true;
|
|
1148
|
-
}
|
|
1149
|
-
const newMapping = extractedDep.parameterMapping ? JSON.stringify(extractedDep.parameterMapping) : null;
|
|
1150
|
-
if (existingDep.ParameterMapping !== newMapping) {
|
|
1151
|
-
existingDep.ParameterMapping = newMapping;
|
|
1152
|
-
hasChanges = true;
|
|
1153
|
-
}
|
|
1154
|
-
if (hasChanges) {
|
|
1155
|
-
promises.push(existingDep.Save());
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
// Remove stale dependencies
|
|
1160
|
-
for (const depToRemove of depsToRemove) {
|
|
1161
|
-
promises.push(depToRemove.Delete());
|
|
1162
|
-
}
|
|
1163
|
-
if (promises.length > 0) {
|
|
1164
|
-
await Promise.all(promises);
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
catch (e) {
|
|
1168
|
-
LogError(`Query "${this.Name}" - Failed to sync dependencies:`, e);
|
|
1169
|
-
throw e;
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
/**
|
|
1173
|
-
* Removes all QueryDependency records for this query.
|
|
1174
|
-
*/
|
|
1175
|
-
async removeAllQueryDependencies() {
|
|
1176
|
-
try {
|
|
1177
|
-
if (!this.IsSaved)
|
|
1178
|
-
return;
|
|
1179
|
-
const rv = this.RunViewProviderToUse;
|
|
1180
|
-
const existingResult = await rv.RunView({
|
|
1181
|
-
EntityName: 'MJ: Query Dependencies',
|
|
1182
|
-
ExtraFilter: `QueryID='${this.ID}'`,
|
|
1183
|
-
ResultType: 'entity_object'
|
|
1184
|
-
}, this.ContextCurrentUser);
|
|
1185
|
-
if (!existingResult.Success) {
|
|
1186
|
-
throw new Error(`Failed to load existing query dependencies: ${existingResult.ErrorMessage}`);
|
|
1187
|
-
}
|
|
1188
|
-
const existingDeps = existingResult.Results || [];
|
|
1189
|
-
const deletePromises = existingDeps.map(dep => dep.Delete());
|
|
1190
|
-
if (deletePromises.length > 0) {
|
|
1191
|
-
await Promise.all(deletePromises);
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
catch (e) {
|
|
1195
|
-
LogError('Failed to remove query dependencies:', e);
|
|
1196
|
-
throw e;
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
201
|
+
// ─── Dialect Conversion ──────────────────────────────────────────────────────
|
|
1199
202
|
/**
|
|
1200
203
|
* Auto-converts the query's SQL to other dialects based on the queryDialects
|
|
1201
204
|
* configuration stored in GlobalObjectStore. Best-effort: failures never block the save.
|
|
1202
205
|
*/
|
|
1203
206
|
async autoConvertDialectsAsync() {
|
|
1204
207
|
try {
|
|
1205
|
-
// Read config from GlobalObjectStore (set by MJServer at startup)
|
|
1206
208
|
const config = MJGlobal.Instance.GetGlobalObjectStore()?.['queryDialects'];
|
|
1207
209
|
if (!config?.autoConvertOnSave || !config.targetPlatforms?.length) {
|
|
1208
|
-
return;
|
|
210
|
+
return;
|
|
1209
211
|
}
|
|
1210
212
|
if (!this.SQL || this.SQL.trim().length === 0) {
|
|
1211
|
-
return;
|
|
213
|
+
return;
|
|
1212
214
|
}
|
|
1213
215
|
const md = this.ProviderToUse;
|
|
1214
216
|
const dialects = md.SQLDialects;
|
|
1215
|
-
// Determine source dialect from query's SQLDialectID
|
|
1216
217
|
const sourceDialect = this.SQLDialectID
|
|
1217
218
|
? dialects.find(d => UUIDsEqual(d.ID, this.SQLDialectID))
|
|
1218
|
-
: dialects.find(d => d.PlatformKey === 'sqlserver');
|
|
219
|
+
: dialects.find(d => d.PlatformKey === 'sqlserver');
|
|
1219
220
|
if (!sourceDialect) {
|
|
1220
221
|
console.warn(`Query "${this.Name}" - Could not determine source dialect, skipping auto-convert`);
|
|
1221
222
|
return;
|
|
1222
223
|
}
|
|
1223
|
-
// Process each target platform independently
|
|
1224
224
|
for (const targetPlatformKey of config.targetPlatforms) {
|
|
1225
|
-
|
|
1226
|
-
// Skip if target is same as source
|
|
1227
|
-
if (targetPlatformKey === sourceDialect.PlatformKey) {
|
|
1228
|
-
continue;
|
|
1229
|
-
}
|
|
1230
|
-
const targetDialect = dialects.find(d => d.PlatformKey === targetPlatformKey);
|
|
1231
|
-
if (!targetDialect) {
|
|
1232
|
-
console.warn(`Query "${this.Name}" - Target dialect "${targetPlatformKey}" not found, skipping`);
|
|
1233
|
-
continue;
|
|
1234
|
-
}
|
|
1235
|
-
// Currently only T-SQL → PostgreSQL is supported
|
|
1236
|
-
if (sourceDialect.PlatformKey !== 'sqlserver' || targetPlatformKey !== 'postgresql') {
|
|
1237
|
-
console.warn(`Query "${this.Name}" - Conversion from "${sourceDialect.PlatformKey}" to "${targetPlatformKey}" not yet supported`);
|
|
1238
|
-
continue;
|
|
1239
|
-
}
|
|
1240
|
-
const convertedSQL = this.convertTSQLToPostgreSQL(this.SQL);
|
|
1241
|
-
await this.upsertQuerySQLRecord(targetDialect, convertedSQL, md);
|
|
1242
|
-
}
|
|
1243
|
-
catch (platformError) {
|
|
1244
|
-
// Per-platform isolation: failing on one target doesn't affect others
|
|
1245
|
-
console.warn(`Query "${this.Name}" - Auto-convert to "${targetPlatformKey}" failed:`, platformError);
|
|
1246
|
-
}
|
|
225
|
+
await this.convertToTargetDialect(sourceDialect, targetPlatformKey, md, dialects);
|
|
1247
226
|
}
|
|
1248
227
|
}
|
|
1249
228
|
catch (e) {
|
|
1250
|
-
// Best-effort: never block the save
|
|
1251
229
|
console.warn(`Query "${this.Name}" - Auto-convert dialects failed:`, e);
|
|
1252
230
|
}
|
|
1253
231
|
}
|
|
1254
232
|
/**
|
|
1255
|
-
* Converts
|
|
1256
|
-
* Applies the standard transform pipeline for statement-level conversions.
|
|
233
|
+
* Converts and persists SQL for a single target dialect. Logs warnings on skip/failure.
|
|
1257
234
|
*/
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
235
|
+
async convertToTargetDialect(sourceDialect, targetPlatformKey, md, dialects) {
|
|
236
|
+
try {
|
|
237
|
+
if (targetPlatformKey === sourceDialect.PlatformKey) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const targetDialect = dialects.find(d => d.PlatformKey === targetPlatformKey);
|
|
241
|
+
if (!targetDialect) {
|
|
242
|
+
console.warn(`Query "${this.Name}" - Target dialect "${targetPlatformKey}" not found, skipping`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (sourceDialect.PlatformKey !== 'sqlserver' || targetPlatformKey !== 'postgresql') {
|
|
246
|
+
console.warn(`Query "${this.Name}" - Conversion from "${sourceDialect.PlatformKey}" to "${targetPlatformKey}" not yet supported`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const convertedSQL = ConvertTSQLToPostgreSQL(this.SQL);
|
|
250
|
+
await this.upsertQuerySQLRecord(targetDialect, convertedSQL, md);
|
|
251
|
+
}
|
|
252
|
+
catch (platformError) {
|
|
253
|
+
console.warn(`Query "${this.Name}" - Auto-convert to "${targetPlatformKey}" failed:`, platformError);
|
|
254
|
+
}
|
|
1273
255
|
}
|
|
1274
256
|
/**
|
|
1275
257
|
* Creates or updates a QuerySQL record for the given dialect.
|
|
1276
258
|
*/
|
|
1277
259
|
async upsertQuerySQLRecord(targetDialect, convertedSQL, md) {
|
|
1278
260
|
const rv = this.RunViewProviderToUse;
|
|
1279
|
-
// Look for existing QuerySQL record for this query + dialect
|
|
1280
261
|
const existingResult = await rv.RunView({
|
|
1281
262
|
EntityName: 'MJ: Query SQLs',
|
|
1282
263
|
ExtraFilter: `QueryID='${this.ID}' AND SQLDialectID='${targetDialect.ID}'`,
|
|
@@ -1288,11 +269,9 @@ let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
|
1288
269
|
}
|
|
1289
270
|
let record;
|
|
1290
271
|
if (existingResult.Results?.length > 0) {
|
|
1291
|
-
// Update existing
|
|
1292
272
|
record = existingResult.Results[0];
|
|
1293
273
|
}
|
|
1294
274
|
else {
|
|
1295
|
-
// Create new
|
|
1296
275
|
record = await md.GetEntityObject('MJ: Query SQLs', this.ContextCurrentUser);
|
|
1297
276
|
record.QueryID = this.ID;
|
|
1298
277
|
record.SQLDialectID = targetDialect.ID;
|
|
@@ -1329,6 +308,7 @@ let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
|
1329
308
|
console.warn(`Query "${this.Name}" - Failed to remove QuerySQL records:`, e);
|
|
1330
309
|
}
|
|
1331
310
|
}
|
|
311
|
+
// ─── Load Overrides ──────────────────────────────────────────────────────────
|
|
1332
312
|
async Load(ID, EntityRelationshipsToLoad) {
|
|
1333
313
|
const result = await super.Load(ID, EntityRelationshipsToLoad);
|
|
1334
314
|
await this.RefreshRelatedMetadata(false);
|
|
@@ -1339,25 +319,24 @@ let MJQueryEntityServer = class MJQueryEntityServer extends MJQueryEntity {
|
|
|
1339
319
|
await this.RefreshRelatedMetadata(false);
|
|
1340
320
|
return result;
|
|
1341
321
|
}
|
|
322
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BaseEntity.LoadFromData uses `any` for the data parameter
|
|
1342
323
|
async LoadFromData(data, _replaceOldValues) {
|
|
1343
324
|
const result = await super.LoadFromData(data, _replaceOldValues);
|
|
1344
325
|
await this.RefreshRelatedMetadata(false);
|
|
1345
326
|
return result;
|
|
1346
327
|
}
|
|
328
|
+
// ─── Metadata Refresh ────────────────────────────────────────────────────────
|
|
1347
329
|
/**
|
|
1348
330
|
* Refreshes this record's related metadata from the provider, refreshing
|
|
1349
|
-
* all the way up from the database if refreshFromDB is true, otherwise from
|
|
1350
|
-
* cache.
|
|
1351
|
-
* @param refreshFromDB
|
|
331
|
+
* all the way up from the database if refreshFromDB is true, otherwise from cache.
|
|
1352
332
|
*/
|
|
1353
333
|
async RefreshRelatedMetadata(refreshFromDB) {
|
|
1354
334
|
const md = this.ProviderToUse;
|
|
1355
335
|
if (refreshFromDB) {
|
|
1356
336
|
const globalMetadataProvider = Metadata.Provider;
|
|
1357
|
-
await globalMetadataProvider.Refresh(md);
|
|
337
|
+
await globalMetadataProvider.Refresh(md);
|
|
1358
338
|
if (globalMetadataProvider !== md) {
|
|
1359
|
-
|
|
1360
|
-
await md.Refresh(); // will refresh FROM the global provider, meaning we do NOT hit the DB again, we just copy the data into our MD instance that is part of our trans scope
|
|
339
|
+
await md.Refresh();
|
|
1361
340
|
}
|
|
1362
341
|
}
|
|
1363
342
|
this._queryPermissions = md.QueryPermissions.filter(p => UUIDsEqual(p.QueryID, this.ID));
|