@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
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve stage of the query extraction pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Contains all deterministic resolution logic — no LLM calls, no database writes.
|
|
5
|
+
* Every function is stateless: context is passed in as parameters rather than via `this`.
|
|
6
|
+
*/
|
|
7
|
+
import { Metadata, TypeScriptTypeFromSQLType } from "@memberjunction/core";
|
|
8
|
+
import { QueryCompositionEngine } from "@memberjunction/generic-database-provider";
|
|
9
|
+
import { UUIDsEqual } from "@memberjunction/global";
|
|
10
|
+
import { SQLParser } from "@memberjunction/sql-parser";
|
|
11
|
+
// ═══════════════════════════════════════════════════
|
|
12
|
+
// Composition Reference Resolution
|
|
13
|
+
// ═══════════════════════════════════════════════════
|
|
14
|
+
/**
|
|
15
|
+
* Resolves all {{query:"..."}} composition references in SQL to their QueryInfo targets.
|
|
16
|
+
*
|
|
17
|
+
* This is the single entry point for composition resolution. Both dependency syncing
|
|
18
|
+
* and passthrough parameter extraction consume the results.
|
|
19
|
+
*
|
|
20
|
+
* @throws If a referenced query does not exist in the metadata cache
|
|
21
|
+
* @throws If a referenced query is not marked Reusable
|
|
22
|
+
*/
|
|
23
|
+
export function ResolveCompositionReferences(sql, queryName, allQueries) {
|
|
24
|
+
const compositionEngine = new QueryCompositionEngine();
|
|
25
|
+
if (!compositionEngine.HasCompositionTokens(sql))
|
|
26
|
+
return [];
|
|
27
|
+
const tokens = compositionEngine.ParseCompositionTokens(sql);
|
|
28
|
+
if (tokens.length === 0)
|
|
29
|
+
return [];
|
|
30
|
+
const resolved = [];
|
|
31
|
+
for (const token of tokens) {
|
|
32
|
+
const depQuery = ResolveQueryByNameAndCategory(token.QueryName, token.CategorySegments, allQueries);
|
|
33
|
+
if (!depQuery) {
|
|
34
|
+
throw new Error(`Query "${queryName}" references "${token.FullPath}" via {{query:"..."}} syntax, ` +
|
|
35
|
+
`but no matching query was found in the metadata. ` +
|
|
36
|
+
`Ensure the referenced query exists and has been saved before using it in composition.`);
|
|
37
|
+
}
|
|
38
|
+
if (!depQuery.Reusable) {
|
|
39
|
+
throw new Error(`Query "${queryName}" references "${token.FullPath}" via composition syntax, ` +
|
|
40
|
+
`but "${depQuery.Name}" is not marked as Reusable. ` +
|
|
41
|
+
`Set Reusable=true on "${depQuery.Name}" before using it in {{query:"..."}} references.`);
|
|
42
|
+
}
|
|
43
|
+
const alias = ExtractAliasFromSQL(sql, token.FullToken);
|
|
44
|
+
const { parameterMapping, passthroughMappings } = BuildParameterMappings(token.Parameters);
|
|
45
|
+
resolved.push({
|
|
46
|
+
depQuery,
|
|
47
|
+
referencePath: token.FullPath,
|
|
48
|
+
alias,
|
|
49
|
+
parameterMapping,
|
|
50
|
+
passthroughMappings,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return resolved;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolves a query by name and optional category path segments from the metadata cache.
|
|
57
|
+
* Tries category-qualified lookup first, then falls back to name-only (if unambiguous).
|
|
58
|
+
*/
|
|
59
|
+
export function ResolveQueryByNameAndCategory(queryName, categorySegments, allQueries) {
|
|
60
|
+
const queryNameLower = queryName.toLowerCase();
|
|
61
|
+
if (categorySegments.length > 0) {
|
|
62
|
+
const expectedPath = `/${categorySegments.join('/')}/`;
|
|
63
|
+
const match = allQueries.find(q => q.Name.toLowerCase() === queryNameLower &&
|
|
64
|
+
q.CategoryPath.toLowerCase() === expectedPath.toLowerCase());
|
|
65
|
+
if (match)
|
|
66
|
+
return match;
|
|
67
|
+
}
|
|
68
|
+
const nameMatches = allQueries.filter(q => q.Name.toLowerCase() === queryNameLower);
|
|
69
|
+
return nameMatches.length === 1 ? nameMatches[0] : undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Builds the parameter mapping and extracts passthrough mappings from parsed composition parameters.
|
|
73
|
+
*/
|
|
74
|
+
export function BuildParameterMappings(params) {
|
|
75
|
+
if (params.length === 0)
|
|
76
|
+
return { parameterMapping: null, passthroughMappings: [] };
|
|
77
|
+
const parameterMapping = {};
|
|
78
|
+
const passthroughMappings = [];
|
|
79
|
+
for (const param of params) {
|
|
80
|
+
if (param.StaticValue !== null) {
|
|
81
|
+
parameterMapping[param.Name] = param.StaticValue;
|
|
82
|
+
}
|
|
83
|
+
else if (param.PassThroughName !== null) {
|
|
84
|
+
parameterMapping[param.Name] = `@${param.PassThroughName}`;
|
|
85
|
+
passthroughMappings.push({
|
|
86
|
+
parentParamName: param.PassThroughName,
|
|
87
|
+
depParamName: param.Name,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
parameterMapping: Object.keys(parameterMapping).length > 0 ? parameterMapping : null,
|
|
93
|
+
passthroughMappings,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Extracts the SQL alias following a {{query:"..."}} token.
|
|
98
|
+
* Returns null if no alias is found or the text following the token is a SQL keyword.
|
|
99
|
+
*/
|
|
100
|
+
export function ExtractAliasFromSQL(sql, fullToken) {
|
|
101
|
+
const tokenIndex = sql.indexOf(fullToken);
|
|
102
|
+
if (tokenIndex < 0)
|
|
103
|
+
return null;
|
|
104
|
+
const afterToken = sql.substring(tokenIndex + fullToken.length);
|
|
105
|
+
const aliasMatch = /^\s+(\w+)/i.exec(afterToken);
|
|
106
|
+
if (aliasMatch) {
|
|
107
|
+
const keywords = new Set([
|
|
108
|
+
'ON', 'WHERE', 'AND', 'OR', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER',
|
|
109
|
+
'CROSS', 'FULL', 'GROUP', 'ORDER', 'HAVING', 'UNION', 'EXCEPT',
|
|
110
|
+
'INTERSECT', 'LIMIT', 'OFFSET', 'FETCH',
|
|
111
|
+
]);
|
|
112
|
+
if (!keywords.has(aliasMatch[1].toUpperCase())) {
|
|
113
|
+
return aliasMatch[1];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
// ═══════════════════════════════════════════════════
|
|
119
|
+
// Passthrough Parameter Resolution
|
|
120
|
+
// ═══════════════════════════════════════════════════
|
|
121
|
+
/**
|
|
122
|
+
* Extracts passthrough MJParameterInfo entries and PassthroughParamContext from resolved refs.
|
|
123
|
+
* Inherits type, isRequired, defaultValue, description, and sampleValue from the dependency
|
|
124
|
+
* query's parameter metadata when available.
|
|
125
|
+
*/
|
|
126
|
+
export function BuildPassthroughParams(resolvedRefs) {
|
|
127
|
+
const passthroughParams = [];
|
|
128
|
+
const contextMap = new Map();
|
|
129
|
+
const seenParamNames = new Set();
|
|
130
|
+
for (const ref of resolvedRefs) {
|
|
131
|
+
for (const mapping of ref.passthroughMappings) {
|
|
132
|
+
const nameLower = mapping.parentParamName.toLowerCase();
|
|
133
|
+
if (seenParamNames.has(nameLower))
|
|
134
|
+
continue;
|
|
135
|
+
seenParamNames.add(nameLower);
|
|
136
|
+
const depParam = ref.depQuery.Parameters.find(p => p.Name.toLowerCase() === mapping.depParamName.toLowerCase());
|
|
137
|
+
passthroughParams.push({
|
|
138
|
+
name: mapping.parentParamName,
|
|
139
|
+
type: depParam ? MapQueryParamTypeToParserType(depParam.Type) : 'string',
|
|
140
|
+
isRequired: depParam ? depParam.IsRequired : true,
|
|
141
|
+
defaultValue: depParam?.DefaultValue ?? null,
|
|
142
|
+
filters: [],
|
|
143
|
+
usageLocations: [ref.referencePath],
|
|
144
|
+
});
|
|
145
|
+
contextMap.set(nameLower, {
|
|
146
|
+
description: depParam?.Description ?? null,
|
|
147
|
+
sampleValue: depParam?.SampleValue ?? null,
|
|
148
|
+
depQueryName: ref.depQuery.Name,
|
|
149
|
+
depParamName: mapping.depParamName,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { params: passthroughParams, contextMap };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Merges passthrough parameters into the deterministic parameter list,
|
|
157
|
+
* skipping any that are already detected as direct template expressions.
|
|
158
|
+
*/
|
|
159
|
+
export function MergePassthroughParams(deterministicParams, passthroughParams) {
|
|
160
|
+
if (passthroughParams.length === 0)
|
|
161
|
+
return deterministicParams;
|
|
162
|
+
const existingNames = new Set(deterministicParams.map(p => p.name.toLowerCase()));
|
|
163
|
+
const merged = [...deterministicParams];
|
|
164
|
+
for (const pt of passthroughParams) {
|
|
165
|
+
if (!existingNames.has(pt.name.toLowerCase())) {
|
|
166
|
+
merged.push(pt);
|
|
167
|
+
existingNames.add(pt.name.toLowerCase());
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return merged;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Maps a QueryParameterInfo.Type value to the MJParameterInfo type union.
|
|
174
|
+
*/
|
|
175
|
+
export function MapQueryParamTypeToParserType(type) {
|
|
176
|
+
const validTypes = new Set(['string', 'number', 'date', 'boolean', 'array']);
|
|
177
|
+
return validTypes.has(type) ? type : 'string';
|
|
178
|
+
}
|
|
179
|
+
// ═══════════════════════════════════════════════════
|
|
180
|
+
// Field Resolution
|
|
181
|
+
// ═══════════════════════════════════════════════════
|
|
182
|
+
/**
|
|
183
|
+
* Detects SELECT * and builds the complete field list deterministically
|
|
184
|
+
* from entity metadata and/or composition ref fields.
|
|
185
|
+
* Returns null if the SQL doesn't use SELECT * or if entities can't be resolved.
|
|
186
|
+
*/
|
|
187
|
+
export function BuildFieldsForSelectStar(sql, tableRefs, selectColumns, md) {
|
|
188
|
+
const selectStarRegex = /\bSELECT\s+(?:(?:TOP\s+\d+|DISTINCT)\s+)?(\*|[\w.]+\.\*)\s+FROM\b/i;
|
|
189
|
+
if (!selectStarRegex.test(sql))
|
|
190
|
+
return null;
|
|
191
|
+
const expandedFields = expandFieldsFromEntities(tableRefs, md);
|
|
192
|
+
if (expandedFields.length === 0) {
|
|
193
|
+
expandFieldsFromCompositionRefs(sql, expandedFields, md);
|
|
194
|
+
}
|
|
195
|
+
return expandedFields.length > 0 ? expandedFields : null;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Expands fields from entity metadata for table references in the SQL.
|
|
199
|
+
*/
|
|
200
|
+
function expandFieldsFromEntities(tableRefs, md) {
|
|
201
|
+
const expandedFields = [];
|
|
202
|
+
for (const tableRef of tableRefs) {
|
|
203
|
+
const matchingEntity = findEntityByTableRef(md, tableRef);
|
|
204
|
+
if (matchingEntity) {
|
|
205
|
+
for (const entityField of matchingEntity.Fields) {
|
|
206
|
+
if (!expandedFields.some(f => f.name === entityField.Name)) {
|
|
207
|
+
expandedFields.push(buildFieldFromEntityField(entityField, matchingEntity.Name));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return expandedFields;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Expands fields from composition references when no entity fields were found.
|
|
216
|
+
*/
|
|
217
|
+
function expandFieldsFromCompositionRefs(sql, expandedFields, md) {
|
|
218
|
+
const compositionRefs = SQLParser.ExtractCompositionRefs(sql);
|
|
219
|
+
for (const ref of compositionRefs) {
|
|
220
|
+
const referencedQuery = Metadata.Provider.Queries.find(q => q.Name.toLowerCase() === ref.queryName.toLowerCase());
|
|
221
|
+
if (referencedQuery && referencedQuery.Fields.length > 0) {
|
|
222
|
+
for (const qField of referencedQuery.Fields) {
|
|
223
|
+
if (!expandedFields.some(f => f.name === qField.Name)) {
|
|
224
|
+
expandedFields.push(buildFieldFromQueryField(qField, md));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Builds an ExtractedField from an entity field definition.
|
|
232
|
+
*/
|
|
233
|
+
function buildFieldFromEntityField(entityField, entityName) {
|
|
234
|
+
return {
|
|
235
|
+
name: entityField.Name,
|
|
236
|
+
description: entityField.Description || `${entityField.Name} field from ${entityName}`,
|
|
237
|
+
type: TypeScriptTypeFromSQLType(entityField.Type).toLowerCase(),
|
|
238
|
+
optional: false,
|
|
239
|
+
sourceEntity: entityName,
|
|
240
|
+
sourceFieldName: entityField.Name,
|
|
241
|
+
isComputed: false,
|
|
242
|
+
isSummary: false,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Builds an ExtractedField from a dependency query's field definition.
|
|
247
|
+
*/
|
|
248
|
+
function buildFieldFromQueryField(qField, md) {
|
|
249
|
+
const sourceEntityName = qField.SourceEntityID
|
|
250
|
+
? findEntityNameByID(md, qField.SourceEntityID)
|
|
251
|
+
: null;
|
|
252
|
+
return {
|
|
253
|
+
name: qField.Name,
|
|
254
|
+
description: qField.Description || `${qField.Name} from query`,
|
|
255
|
+
type: TypeScriptTypeFromSQLType(qField.SQLBaseType).toLowerCase(),
|
|
256
|
+
optional: false,
|
|
257
|
+
sourceEntity: sourceEntityName,
|
|
258
|
+
sourceFieldName: qField.SourceFieldName || null,
|
|
259
|
+
isComputed: qField.IsComputed || false,
|
|
260
|
+
isSummary: qField.IsSummary || false,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Enriches extracted fields with deterministic SQL types from composed query field metadata.
|
|
265
|
+
*
|
|
266
|
+
* For queries that reference other queries via {{query:"..."}}, resolves field types
|
|
267
|
+
* by matching field names against the dependency query's QueryFieldInfo.
|
|
268
|
+
*
|
|
269
|
+
* Uses the pre-parsed selectColumns for deterministic SELECT clause resolution,
|
|
270
|
+
* which handles direct columns and AS aliases via AST parsing.
|
|
271
|
+
*/
|
|
272
|
+
export function EnrichFieldTypesFromCompositions(fields, resolvedRefs, selectColumns) {
|
|
273
|
+
if (resolvedRefs.length === 0 || fields.length === 0)
|
|
274
|
+
return fields;
|
|
275
|
+
const aliasToRef = buildAliasToRefMap(resolvedRefs);
|
|
276
|
+
const selectColumnMap = buildSelectColumnMap(selectColumns);
|
|
277
|
+
const allDepFields = buildDependencyFieldLookup(resolvedRefs);
|
|
278
|
+
return fields.map(field => {
|
|
279
|
+
if (field.sqlBaseType && field.sqlFullType)
|
|
280
|
+
return field;
|
|
281
|
+
const matchedField = resolveFieldFromSelectColumns(field.name, selectColumnMap, aliasToRef)
|
|
282
|
+
?? allDepFields.get(field.name.toLowerCase());
|
|
283
|
+
if (matchedField) {
|
|
284
|
+
return applyQueryFieldMetadata(field, matchedField);
|
|
285
|
+
}
|
|
286
|
+
return field;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Enriches extracted fields with deterministic SQL types from entity view/table metadata.
|
|
291
|
+
*
|
|
292
|
+
* For queries that directly reference entity views (e.g., `FROM __mj.vwUsers u`),
|
|
293
|
+
* resolves field types by matching field names against the entity's field metadata.
|
|
294
|
+
*
|
|
295
|
+
* Uses the pre-parsed selectColumns for deterministic SELECT clause resolution
|
|
296
|
+
* and tableRefs for entity view resolution.
|
|
297
|
+
*/
|
|
298
|
+
export function EnrichFieldTypesFromEntityMetadata(fields, selectColumns, tableRefs, md) {
|
|
299
|
+
if (fields.length === 0 || tableRefs.length === 0)
|
|
300
|
+
return fields;
|
|
301
|
+
const aliasToEntity = buildAliasToEntityMap(tableRefs, md);
|
|
302
|
+
if (aliasToEntity.size === 0)
|
|
303
|
+
return fields;
|
|
304
|
+
const selectColumnMap = buildSelectColumnMap(selectColumns);
|
|
305
|
+
const flatLookup = buildFlatEntityFieldLookup(aliasToEntity);
|
|
306
|
+
return fields.map(field => {
|
|
307
|
+
if (field.sqlBaseType && field.sqlFullType)
|
|
308
|
+
return field;
|
|
309
|
+
const matched = matchFieldToEntityMetadata(field.name, selectColumnMap, aliasToEntity, flatLookup);
|
|
310
|
+
if (matched) {
|
|
311
|
+
return {
|
|
312
|
+
...field,
|
|
313
|
+
sqlBaseType: matched.field.Type,
|
|
314
|
+
sqlFullType: matched.field.SQLFullType,
|
|
315
|
+
sourceEntity: field.sourceEntity ?? matched.entityName,
|
|
316
|
+
sourceFieldName: field.sourceFieldName ?? matched.field.Name,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return field;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Expands wildcard (*) entries in the extracted fields list.
|
|
324
|
+
* When a field has name="*" or sourceFieldName="*", expands it into
|
|
325
|
+
* individual field entries by looking up the source from metadata.
|
|
326
|
+
*/
|
|
327
|
+
export function ExpandWildcardFields(fields, md) {
|
|
328
|
+
const hasWildcard = fields.some(f => f.name === '*' || f.sourceFieldName === '*');
|
|
329
|
+
if (!hasWildcard)
|
|
330
|
+
return fields;
|
|
331
|
+
const expandedFields = [];
|
|
332
|
+
for (const field of fields) {
|
|
333
|
+
const isWildcard = field.name === '*' || field.sourceFieldName === '*';
|
|
334
|
+
if (!isWildcard) {
|
|
335
|
+
expandedFields.push(field);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (field.sourceEntity) {
|
|
339
|
+
const expanded = expandWildcardFromSource(field, md);
|
|
340
|
+
expandedFields.push(...expanded);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
expandedFields.push(field);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return expandedFields;
|
|
347
|
+
}
|
|
348
|
+
// ═══════════════════════════════════════════════════
|
|
349
|
+
// Entity Metadata Extraction
|
|
350
|
+
// ═══════════════════════════════════════════════════
|
|
351
|
+
/**
|
|
352
|
+
* Extracts entity metadata from the SQL to provide context for parameter type inference.
|
|
353
|
+
* Uses SQLParser for robust SQL parsing with MJ template support.
|
|
354
|
+
*/
|
|
355
|
+
export function ExtractEntityMetadataFromSQL(sql, tableRefs, md) {
|
|
356
|
+
const results = [];
|
|
357
|
+
try {
|
|
358
|
+
const columnRefs = SQLParser.ExtractColumnRefs(sql);
|
|
359
|
+
for (const tableRef of tableRefs) {
|
|
360
|
+
const matchingEntity = findEntityByTableRef(md, tableRef);
|
|
361
|
+
if (matchingEntity) {
|
|
362
|
+
const relevantFields = extractRelevantFields(matchingEntity, tableRef, columnRefs);
|
|
363
|
+
if (relevantFields.length > 0) {
|
|
364
|
+
results.push({
|
|
365
|
+
name: matchingEntity.Name,
|
|
366
|
+
schemaName: matchingEntity.SchemaName,
|
|
367
|
+
baseView: matchingEntity.BaseView,
|
|
368
|
+
fields: relevantFields,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return results;
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
console.warn(`Error in ExtractEntityMetadataFromSQL: ${error}`);
|
|
377
|
+
return results;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// ═══════════════════════════════════════════════════
|
|
381
|
+
// Dependency Validation
|
|
382
|
+
// ═══════════════════════════════════════════════════
|
|
383
|
+
/**
|
|
384
|
+
* Detects circular dependencies by walking the dependency graph.
|
|
385
|
+
* Returns a descriptive error string if a cycle is found, or null if clean.
|
|
386
|
+
*/
|
|
387
|
+
export function DetectDependencyCycles(queryID, queryName, proposedDeps, allDependencies, allQueries) {
|
|
388
|
+
for (const dep of proposedDeps) {
|
|
389
|
+
const result = checkCycleFromDep(dep.dependsOnQueryID, queryID, queryName, [queryName], new Set(), allDependencies, allQueries);
|
|
390
|
+
if (result)
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Recursively walks from a dependency node to detect if it leads back to the source query.
|
|
397
|
+
*/
|
|
398
|
+
function checkCycleFromDep(currentID, sourceID, sourceName, path, visited, allDependencies, allQueries) {
|
|
399
|
+
if (UUIDsEqual(currentID, sourceID)) {
|
|
400
|
+
return [...path, sourceName].join(' \u2192 ');
|
|
401
|
+
}
|
|
402
|
+
if (visited.has(currentID))
|
|
403
|
+
return null;
|
|
404
|
+
visited.add(currentID);
|
|
405
|
+
const queryName = allQueries.find(q => UUIDsEqual(q.ID, currentID))?.Name || currentID;
|
|
406
|
+
const updatedPath = [...path, queryName];
|
|
407
|
+
const deps = allDependencies.filter(d => d.QueryID === currentID);
|
|
408
|
+
for (const d of deps) {
|
|
409
|
+
const result = checkCycleFromDep(d.DependsOnQueryID, sourceID, sourceName, updatedPath, visited, allDependencies, allQueries);
|
|
410
|
+
if (result)
|
|
411
|
+
return result;
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
// ═══════════════════════════════════════════════════
|
|
416
|
+
// Private Helpers — Composition Field Resolution
|
|
417
|
+
// ═══════════════════════════════════════════════════
|
|
418
|
+
/**
|
|
419
|
+
* Resolves a field's output name to a QueryFieldInfo by using the parsed SELECT columns.
|
|
420
|
+
*
|
|
421
|
+
* Uses the SQLParser-extracted SELECT column map to find the source column and table qualifier,
|
|
422
|
+
* then matches against the composition ref's dependency query fields.
|
|
423
|
+
*
|
|
424
|
+
* Handles:
|
|
425
|
+
* - Direct: `u.Name` -> OutputName="Name", SourceColumn="Name", TableQualifier="u"
|
|
426
|
+
* - AS alias: `e.Name AS EntityName` -> OutputName="EntityName", SourceColumn="Name", TableQualifier="e"
|
|
427
|
+
*/
|
|
428
|
+
function resolveFieldFromSelectColumns(fieldName, selectColumnMap, aliasToRef) {
|
|
429
|
+
const selectCol = selectColumnMap.get(fieldName.toLowerCase());
|
|
430
|
+
if (!selectCol || selectCol.IsExpression)
|
|
431
|
+
return undefined;
|
|
432
|
+
if (selectCol.TableQualifier) {
|
|
433
|
+
const ref = aliasToRef.get(selectCol.TableQualifier.toLowerCase());
|
|
434
|
+
if (ref) {
|
|
435
|
+
const match = ref.depQuery.Fields.find(f => f.Name.toLowerCase() === selectCol.SourceColumn.toLowerCase());
|
|
436
|
+
if (match)
|
|
437
|
+
return match;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// No table qualifier — try all composition refs
|
|
441
|
+
for (const ref of Array.from(aliasToRef.values())) {
|
|
442
|
+
const match = ref.depQuery.Fields.find(f => f.Name.toLowerCase() === selectCol.SourceColumn.toLowerCase());
|
|
443
|
+
if (match)
|
|
444
|
+
return match;
|
|
445
|
+
}
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Builds a flat field name -> QueryFieldInfo lookup across all dependency queries.
|
|
450
|
+
* If the same field name exists in multiple dependencies, the first match wins.
|
|
451
|
+
*/
|
|
452
|
+
function buildDependencyFieldLookup(resolvedRefs) {
|
|
453
|
+
const lookup = new Map();
|
|
454
|
+
for (const ref of resolvedRefs) {
|
|
455
|
+
for (const field of ref.depQuery.Fields) {
|
|
456
|
+
const nameLower = field.Name.toLowerCase();
|
|
457
|
+
if (!lookup.has(nameLower)) {
|
|
458
|
+
lookup.set(nameLower, field);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return lookup;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Applies matched QueryFieldInfo metadata onto an ExtractedField.
|
|
466
|
+
*/
|
|
467
|
+
function applyQueryFieldMetadata(field, matchedField) {
|
|
468
|
+
return {
|
|
469
|
+
...field,
|
|
470
|
+
sqlBaseType: matchedField.SQLBaseType,
|
|
471
|
+
sqlFullType: matchedField.SQLFullType,
|
|
472
|
+
sourceEntity: field.sourceEntity ?? (matchedField.SourceEntityID
|
|
473
|
+
? findEntityNameByID(Metadata.Provider, matchedField.SourceEntityID)
|
|
474
|
+
: null),
|
|
475
|
+
sourceFieldName: field.sourceFieldName ?? matchedField.SourceFieldName,
|
|
476
|
+
isComputed: field.isComputed ?? matchedField.IsComputed ?? false,
|
|
477
|
+
isSummary: field.isSummary ?? matchedField.IsSummary ?? false,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Builds alias -> entity map from table refs by matching against metadata entities.
|
|
482
|
+
*/
|
|
483
|
+
function buildAliasToEntityMap(tableRefs, md) {
|
|
484
|
+
const aliasToEntity = new Map();
|
|
485
|
+
for (const tableRef of tableRefs) {
|
|
486
|
+
const matchingEntity = findEntityByTableRef(md, tableRef);
|
|
487
|
+
if (matchingEntity) {
|
|
488
|
+
aliasToEntity.set(tableRef.Alias.toLowerCase(), { entity: matchingEntity });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return aliasToEntity;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Builds a flat unqualified field lookup from all entities in the alias map.
|
|
495
|
+
* First entity wins for ambiguous field names.
|
|
496
|
+
*/
|
|
497
|
+
function buildFlatEntityFieldLookup(aliasToEntity) {
|
|
498
|
+
const flatLookup = new Map();
|
|
499
|
+
for (const { entity } of Array.from(aliasToEntity.values())) {
|
|
500
|
+
for (const f of entity.Fields) {
|
|
501
|
+
const nameLower = f.Name.toLowerCase();
|
|
502
|
+
if (!flatLookup.has(nameLower)) {
|
|
503
|
+
flatLookup.set(nameLower, { field: f, entityName: entity.Name });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return flatLookup;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Matches a field name to entity metadata via the parsed SELECT columns.
|
|
511
|
+
* Tries alias-qualified match first, then unqualified by source column, then by output name.
|
|
512
|
+
*/
|
|
513
|
+
function matchFieldToEntityMetadata(fieldName, selectColumnMap, aliasToEntity, flatLookup) {
|
|
514
|
+
const selectCol = selectColumnMap.get(fieldName.toLowerCase());
|
|
515
|
+
let matched;
|
|
516
|
+
if (selectCol && !selectCol.IsExpression) {
|
|
517
|
+
matched = matchViaTableQualifier(selectCol, aliasToEntity);
|
|
518
|
+
if (!matched) {
|
|
519
|
+
matched = flatLookup.get(selectCol.SourceColumn.toLowerCase());
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (!matched) {
|
|
523
|
+
matched = flatLookup.get(fieldName.toLowerCase());
|
|
524
|
+
}
|
|
525
|
+
return matched;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Tries to match a select column to an entity field via its table qualifier.
|
|
529
|
+
*/
|
|
530
|
+
function matchViaTableQualifier(selectCol, aliasToEntity) {
|
|
531
|
+
if (!selectCol.TableQualifier)
|
|
532
|
+
return undefined;
|
|
533
|
+
const entityEntry = aliasToEntity.get(selectCol.TableQualifier.toLowerCase());
|
|
534
|
+
if (!entityEntry)
|
|
535
|
+
return undefined;
|
|
536
|
+
const entityField = entityEntry.entity.Fields.find(f => f.Name.toLowerCase() === selectCol.SourceColumn.toLowerCase());
|
|
537
|
+
if (entityField) {
|
|
538
|
+
return { field: entityField, entityName: entityEntry.entity.Name };
|
|
539
|
+
}
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
// ═══════════════════════════════════════════════════
|
|
543
|
+
// Private Helpers — Wildcard Expansion
|
|
544
|
+
// ═══════════════════════════════════════════════════
|
|
545
|
+
/**
|
|
546
|
+
* Expands a wildcard field from its source entity or composed query.
|
|
547
|
+
* Returns the original field in an array if no expansion is possible.
|
|
548
|
+
*/
|
|
549
|
+
function expandWildcardFromSource(field, md) {
|
|
550
|
+
const sourceEntityInfo = FindEntityByName(md, field.sourceEntity);
|
|
551
|
+
if (sourceEntityInfo) {
|
|
552
|
+
return expandFromEntityInfo(sourceEntityInfo, field.optional);
|
|
553
|
+
}
|
|
554
|
+
const composedQuery = Metadata.Provider.Queries.find(q => q.Name.toLowerCase() === field.sourceEntity.toLowerCase());
|
|
555
|
+
if (composedQuery && composedQuery.Fields.length > 0) {
|
|
556
|
+
return expandFromComposedQuery(composedQuery, field.optional, md);
|
|
557
|
+
}
|
|
558
|
+
return [field];
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Expands all fields from an entity into ExtractedField entries.
|
|
562
|
+
*/
|
|
563
|
+
function expandFromEntityInfo(entity, optional) {
|
|
564
|
+
return entity.Fields.map(entityField => ({
|
|
565
|
+
name: entityField.Name,
|
|
566
|
+
description: entityField.Description || `${entityField.Name} field from ${entity.Name}`,
|
|
567
|
+
type: TypeScriptTypeFromSQLType(entityField.Type).toLowerCase(),
|
|
568
|
+
optional,
|
|
569
|
+
sourceEntity: entity.Name,
|
|
570
|
+
sourceFieldName: entityField.Name,
|
|
571
|
+
isComputed: false,
|
|
572
|
+
isSummary: false,
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Expands all fields from a composed query into ExtractedField entries.
|
|
577
|
+
*/
|
|
578
|
+
function expandFromComposedQuery(query, optional, md) {
|
|
579
|
+
return query.Fields.map(qField => ({
|
|
580
|
+
name: qField.Name,
|
|
581
|
+
description: qField.Description || `${qField.Name} from query "${query.Name}"`,
|
|
582
|
+
type: TypeScriptTypeFromSQLType(qField.SQLBaseType).toLowerCase(),
|
|
583
|
+
optional,
|
|
584
|
+
sourceEntity: qField.SourceEntityID
|
|
585
|
+
? findEntityNameByID(md, qField.SourceEntityID)
|
|
586
|
+
: null,
|
|
587
|
+
sourceFieldName: qField.SourceFieldName || null,
|
|
588
|
+
isComputed: qField.IsComputed || false,
|
|
589
|
+
isSummary: qField.IsSummary || false,
|
|
590
|
+
}));
|
|
591
|
+
}
|
|
592
|
+
// ═══════════════════════════════════════════════════
|
|
593
|
+
// Private Helpers — Entity Metadata Extraction
|
|
594
|
+
// ═══════════════════════════════════════════════════
|
|
595
|
+
/**
|
|
596
|
+
* Extracts the relevant fields from an entity for a given table reference,
|
|
597
|
+
* filtering to columns actually referenced in the SQL plus primary keys.
|
|
598
|
+
* Caps at 20 fields to avoid excessive metadata.
|
|
599
|
+
*/
|
|
600
|
+
function extractRelevantFields(entity, tableRef, columnRefs) {
|
|
601
|
+
return entity.Fields
|
|
602
|
+
.filter(f => isFieldReferencedInSQL(f, tableRef, columnRefs))
|
|
603
|
+
.slice(0, 20)
|
|
604
|
+
.map(f => ({
|
|
605
|
+
name: f.Name,
|
|
606
|
+
type: f.Type,
|
|
607
|
+
isPrimaryKey: f.IsPrimaryKey,
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Checks whether a field is referenced in the SQL (by column refs) or is a primary key.
|
|
612
|
+
*/
|
|
613
|
+
function isFieldReferencedInSQL(field, tableRef, columnRefs) {
|
|
614
|
+
const fieldLower = field.Name.toLowerCase();
|
|
615
|
+
for (const colRef of columnRefs) {
|
|
616
|
+
const colLower = colRef.ColumnName.toLowerCase();
|
|
617
|
+
if (colLower === fieldLower)
|
|
618
|
+
return true;
|
|
619
|
+
if (colRef.TableQualifier) {
|
|
620
|
+
const qualLower = colRef.TableQualifier.toLowerCase();
|
|
621
|
+
if ((qualLower === tableRef.Alias.toLowerCase() ||
|
|
622
|
+
qualLower === tableRef.TableName.toLowerCase()) &&
|
|
623
|
+
colLower === fieldLower) {
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return field.IsPrimaryKey;
|
|
629
|
+
}
|
|
630
|
+
// ═══════════════════════════════════════════════════
|
|
631
|
+
// Private Helpers — Shared Utilities
|
|
632
|
+
// ═══════════════════════════════════════════════════
|
|
633
|
+
/**
|
|
634
|
+
* Finds an entity by name, checking both Name (which may have "MJ: " prefix) and DisplayName.
|
|
635
|
+
*/
|
|
636
|
+
export function FindEntityByName(md, name) {
|
|
637
|
+
const lower = name.toLowerCase();
|
|
638
|
+
return md.Entities.find(e => e.Name.toLowerCase() === lower || (e.DisplayName && e.DisplayName.toLowerCase() === lower));
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Finds an entity name by its ID from the metadata provider.
|
|
642
|
+
*/
|
|
643
|
+
function findEntityNameByID(md, entityID) {
|
|
644
|
+
const entity = md.Entities.find(e => UUIDsEqual(e.ID, entityID));
|
|
645
|
+
return entity?.Name ?? null;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Finds an entity by matching a SQL table reference against BaseView/BaseTable + SchemaName.
|
|
649
|
+
*/
|
|
650
|
+
function findEntityByTableRef(md, tableRef) {
|
|
651
|
+
return md.Entities.find(e => (e.BaseView.toLowerCase() === tableRef.TableName.toLowerCase() ||
|
|
652
|
+
e.BaseTable.toLowerCase() === tableRef.TableName.toLowerCase()) &&
|
|
653
|
+
e.SchemaName.toLowerCase() === tableRef.SchemaName.toLowerCase());
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Builds an output name -> SQLSelectColumn map for quick lookup.
|
|
657
|
+
*/
|
|
658
|
+
function buildSelectColumnMap(selectColumns) {
|
|
659
|
+
const map = new Map();
|
|
660
|
+
for (const col of selectColumns) {
|
|
661
|
+
map.set(col.OutputName.toLowerCase(), col);
|
|
662
|
+
}
|
|
663
|
+
return map;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Builds an alias -> resolved composition ref map.
|
|
667
|
+
*/
|
|
668
|
+
function buildAliasToRefMap(resolvedRefs) {
|
|
669
|
+
const aliasToRef = new Map();
|
|
670
|
+
for (const ref of resolvedRefs) {
|
|
671
|
+
if (ref.alias) {
|
|
672
|
+
aliasToRef.set(ref.alias.toLowerCase(), ref);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return aliasToRef;
|
|
676
|
+
}
|
|
677
|
+
//# sourceMappingURL=resolve.js.map
|