@finos/legend-lego 2.0.211 → 2.0.213
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/lib/index.css +2 -2
- package/lib/index.css.map +1 -1
- package/lib/legend-ai/LegendAIDocEnrichment.d.ts +12 -12
- package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -1
- package/lib/legend-ai/LegendAIDocEnrichment.js +317 -86
- package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -1
- package/lib/legend-ai/LegendAIHostIntegration.d.ts +31 -0
- package/lib/legend-ai/LegendAIHostIntegration.d.ts.map +1 -0
- package/lib/legend-ai/LegendAIHostIntegration.js +50 -0
- package/lib/legend-ai/LegendAIHostIntegration.js.map +1 -0
- package/lib/legend-ai/LegendAIServiceRetrieval.d.ts.map +1 -1
- package/lib/legend-ai/LegendAIServiceRetrieval.js +34 -35
- package/lib/legend-ai/LegendAIServiceRetrieval.js.map +1 -1
- package/lib/legend-ai/LegendAITypes.d.ts +85 -7
- package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
- package/lib/legend-ai/LegendAITypes.js +99 -1
- package/lib/legend-ai/LegendAITypes.js.map +1 -1
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +36 -3
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +29 -0
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +7 -0
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
- package/lib/legend-ai/components/LegendAICharts.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAICharts.js +6 -10
- package/lib/legend-ai/components/LegendAICharts.js.map +1 -1
- package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIChat.js +263 -41
- package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
- package/lib/legend-ai/components/LegendAIChatInput.d.ts +1 -1
- package/lib/legend-ai/components/LegendAIChatInput.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIChatInput.js +2 -1
- package/lib/legend-ai/components/LegendAIChatInput.js.map +1 -1
- package/lib/legend-ai/index.d.ts +5 -2
- package/lib/legend-ai/index.d.ts.map +1 -1
- package/lib/legend-ai/index.js +5 -2
- package/lib/legend-ai/index.js.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +9 -3
- package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatProcessors.js +794 -220
- package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatState.js +47 -21
- package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
- package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts +29 -0
- package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts.map +1 -0
- package/lib/legend-ai/stores/LegendAIJoinAnalysis.js +124 -0
- package/lib/legend-ai/stores/LegendAIJoinAnalysis.js.map +1 -0
- package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts +25 -0
- package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts.map +1 -0
- package/lib/legend-ai/stores/LegendAISqlHelpers.js +64 -0
- package/lib/legend-ai/stores/LegendAISqlHelpers.js.map +1 -0
- package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.d.ts +22 -0
- package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.d.ts.map +1 -0
- package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js +411 -0
- package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js.map +1 -0
- package/package.json +6 -6
- package/src/legend-ai/LegendAIDocEnrichment.ts +435 -97
- package/src/legend-ai/LegendAIHostIntegration.tsx +80 -0
- package/src/legend-ai/LegendAIServiceRetrieval.ts +50 -35
- package/src/legend-ai/LegendAITypes.ts +169 -5
- package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +73 -1
- package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +10 -0
- package/src/legend-ai/components/LegendAICharts.tsx +30 -33
- package/src/legend-ai/components/LegendAIChat.tsx +555 -91
- package/src/legend-ai/components/LegendAIChatInput.tsx +6 -2
- package/src/legend-ai/index.ts +22 -3
- package/src/legend-ai/stores/LegendAIChatProcessors.ts +1286 -322
- package/src/legend-ai/stores/LegendAIChatState.ts +73 -28
- package/src/legend-ai/stores/LegendAIJoinAnalysis.ts +165 -0
- package/src/legend-ai/stores/LegendAISqlHelpers.ts +82 -0
- package/src/legend-ai/stores/LegendAISqlJoinSanitizers.ts +515 -0
- package/tsconfig.json +4 -0
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
|
18
|
-
import { noop } from '@finos/legend-shared';
|
|
18
|
+
import { guaranteeNonNullable, noop } from '@finos/legend-shared';
|
|
19
19
|
import {
|
|
20
20
|
type TDSServiceSchema,
|
|
21
21
|
type LegendAIConfig,
|
|
@@ -30,10 +30,13 @@ import {
|
|
|
30
30
|
type LegendAIAssistantMessage,
|
|
31
31
|
LegendAIMessageRole,
|
|
32
32
|
LegendAIChatTelemetryEventType,
|
|
33
|
+
toRowCountBucket,
|
|
34
|
+
LEGEND_AI_DEFAULT_MODEL_LABEL,
|
|
33
35
|
} from '../LegendAITypes.js';
|
|
34
36
|
import {
|
|
35
37
|
type LegendAI_LegendApplicationPlugin_Extension,
|
|
36
38
|
type LegendAIOrchestratorDataProductCoordinates,
|
|
39
|
+
type LegendAIResolvedEntities,
|
|
37
40
|
} from '../LegendAI_LegendApplicationPlugin_Extension.js';
|
|
38
41
|
import type { QueryExplicitExecutionContextInfo } from '@finos/legend-graph';
|
|
39
42
|
import {
|
|
@@ -45,6 +48,8 @@ import {
|
|
|
45
48
|
processQuestionViaOrchestrator,
|
|
46
49
|
} from './LegendAIChatProcessors.js';
|
|
47
50
|
|
|
51
|
+
const LEGEND_AI_MCP_SCOPE_ID = 'legend-ai-mcp';
|
|
52
|
+
|
|
48
53
|
export const useLegendAIChatState = (
|
|
49
54
|
services: TDSServiceSchema[],
|
|
50
55
|
coordinates: string,
|
|
@@ -56,7 +61,6 @@ export const useLegendAIChatState = (
|
|
|
56
61
|
modelContext?: LegendAIModelContext,
|
|
57
62
|
onLogTelemetryEvent?: (event: LegendAIChatTelemetryEvent) => void,
|
|
58
63
|
): LegendAIChatState => {
|
|
59
|
-
const LEGEND_AI_MCP_SCOPE_ID = 'legend-ai-mcp';
|
|
60
64
|
const [questionText, setQuestionText] = useState('');
|
|
61
65
|
const [isSending, setIsSending] = useState(false);
|
|
62
66
|
const [messages, setMessages] = useState<LegendAIMessage[]>([]);
|
|
@@ -118,17 +122,47 @@ export const useLegendAIChatState = (
|
|
|
118
122
|
});
|
|
119
123
|
}, []);
|
|
120
124
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
:
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
const logScopeChanged = useCallback(
|
|
126
|
+
(next: LegendAIScopeItem[]): void => {
|
|
127
|
+
onLogTelemetryEvent?.({
|
|
128
|
+
type: LegendAIChatTelemetryEventType.SCOPE_CHANGED,
|
|
129
|
+
selectedCount: next.length,
|
|
130
|
+
includesMcp: next.some((s) => s.id === LEGEND_AI_MCP_SCOPE_ID),
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
[onLogTelemetryEvent],
|
|
134
|
+
);
|
|
128
135
|
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
136
|
+
const toggleScope = useCallback(
|
|
137
|
+
(scope: LegendAIScopeItem) => {
|
|
138
|
+
const next = selectedScopes.some((s) => s.id === scope.id)
|
|
139
|
+
? selectedScopes.filter((s) => s.id !== scope.id)
|
|
140
|
+
: [...selectedScopes, scope];
|
|
141
|
+
setSelectedScopes(next);
|
|
142
|
+
logScopeChanged(next);
|
|
143
|
+
},
|
|
144
|
+
[selectedScopes, logScopeChanged],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const removeScope = useCallback(
|
|
148
|
+
(scopeId: string) => {
|
|
149
|
+
const next = selectedScopes.filter((s) => s.id !== scopeId);
|
|
150
|
+
setSelectedScopes(next);
|
|
151
|
+
logScopeChanged(next);
|
|
152
|
+
},
|
|
153
|
+
[selectedScopes, logScopeChanged],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const handleSelectModel = useCallback(
|
|
157
|
+
(model: string | undefined): void => {
|
|
158
|
+
setSelectedModelName(model);
|
|
159
|
+
onLogTelemetryEvent?.({
|
|
160
|
+
type: LegendAIChatTelemetryEventType.MODEL_CHANGED,
|
|
161
|
+
model: model ?? LEGEND_AI_DEFAULT_MODEL_LABEL,
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
[onLogTelemetryEvent],
|
|
165
|
+
);
|
|
132
166
|
|
|
133
167
|
const configForRequest = useMemo(
|
|
134
168
|
() => ({
|
|
@@ -214,6 +248,15 @@ export const useLegendAIChatState = (
|
|
|
214
248
|
type: LegendAIChatTelemetryEventType.RESPONSE_RECEIVED,
|
|
215
249
|
outcome: classifyResponseOutcome(lastAssistant),
|
|
216
250
|
durationMs: Date.now() - startTime,
|
|
251
|
+
hasSql: Boolean(lastAssistant?.sql),
|
|
252
|
+
hasResultGrid: Boolean(lastAssistant?.gridData),
|
|
253
|
+
rowCountBucket: toRowCountBucket(
|
|
254
|
+
lastAssistant?.gridData?.rowData.length ?? 0,
|
|
255
|
+
),
|
|
256
|
+
suggestionCount: lastAssistant?.suggestedQueries.length ?? 0,
|
|
257
|
+
...(lastAssistant?.errorType
|
|
258
|
+
? { errorType: lastAssistant.errorType }
|
|
259
|
+
: {}),
|
|
217
260
|
});
|
|
218
261
|
}
|
|
219
262
|
sendTimeoutRef.current = undefined;
|
|
@@ -223,6 +266,14 @@ export const useLegendAIChatState = (
|
|
|
223
266
|
[isSending, messages, onLogTelemetryEvent],
|
|
224
267
|
);
|
|
225
268
|
|
|
269
|
+
const shouldRouteToOrchestrator = useCallback(
|
|
270
|
+
(): boolean =>
|
|
271
|
+
selectedScopes.some((scope) => scope.id === LEGEND_AI_MCP_SCOPE_ID) &&
|
|
272
|
+
Boolean(configForRequest.orchestratorUrl) &&
|
|
273
|
+
dataProductCoordinates !== undefined,
|
|
274
|
+
[selectedScopes, configForRequest.orchestratorUrl, dataProductCoordinates],
|
|
275
|
+
);
|
|
276
|
+
|
|
226
277
|
const askQuestion = useCallback(
|
|
227
278
|
(): void =>
|
|
228
279
|
dispatchQuestion(questionText, (trimmed, history) => {
|
|
@@ -232,14 +283,10 @@ export const useLegendAIChatState = (
|
|
|
232
283
|
history,
|
|
233
284
|
setMessages,
|
|
234
285
|
};
|
|
235
|
-
if (
|
|
236
|
-
selectedScopes.some((scope) => scope.id === LEGEND_AI_MCP_SCOPE_ID) &&
|
|
237
|
-
configForRequest.orchestratorUrl &&
|
|
238
|
-
dataProductCoordinates
|
|
239
|
-
) {
|
|
286
|
+
if (shouldRouteToOrchestrator()) {
|
|
240
287
|
return processQuestionViaOrchestrator(
|
|
241
288
|
trimmed,
|
|
242
|
-
dataProductCoordinates,
|
|
289
|
+
guaranteeNonNullable(dataProductCoordinates),
|
|
243
290
|
metadata,
|
|
244
291
|
operationContext,
|
|
245
292
|
pureExecutionContext,
|
|
@@ -268,7 +315,7 @@ export const useLegendAIChatState = (
|
|
|
268
315
|
plugin,
|
|
269
316
|
dataProductCoordinates,
|
|
270
317
|
pureExecutionContext,
|
|
271
|
-
|
|
318
|
+
shouldRouteToOrchestrator,
|
|
272
319
|
modelContext,
|
|
273
320
|
],
|
|
274
321
|
);
|
|
@@ -282,14 +329,10 @@ export const useLegendAIChatState = (
|
|
|
282
329
|
history,
|
|
283
330
|
setMessages,
|
|
284
331
|
};
|
|
285
|
-
if (
|
|
286
|
-
selectedScopes.some((scope) => scope.id === LEGEND_AI_MCP_SCOPE_ID) &&
|
|
287
|
-
configForRequest.orchestratorUrl &&
|
|
288
|
-
dataProductCoordinates
|
|
289
|
-
) {
|
|
332
|
+
if (shouldRouteToOrchestrator()) {
|
|
290
333
|
return processQuestionViaOrchestrator(
|
|
291
334
|
trimmed,
|
|
292
|
-
dataProductCoordinates,
|
|
335
|
+
guaranteeNonNullable(dataProductCoordinates),
|
|
293
336
|
metadata,
|
|
294
337
|
operationContext,
|
|
295
338
|
pureExecutionContext,
|
|
@@ -324,7 +367,7 @@ export const useLegendAIChatState = (
|
|
|
324
367
|
plugin,
|
|
325
368
|
dataProductCoordinates,
|
|
326
369
|
pureExecutionContext,
|
|
327
|
-
|
|
370
|
+
shouldRouteToOrchestrator,
|
|
328
371
|
modelContext,
|
|
329
372
|
],
|
|
330
373
|
);
|
|
@@ -335,6 +378,7 @@ export const useLegendAIChatState = (
|
|
|
335
378
|
return;
|
|
336
379
|
}
|
|
337
380
|
let question: string | undefined;
|
|
381
|
+
let preResolvedEntities: LegendAIResolvedEntities | undefined;
|
|
338
382
|
for (let i = 0; i < messages.length; i++) {
|
|
339
383
|
const msg = messages[i];
|
|
340
384
|
if (
|
|
@@ -342,6 +386,7 @@ export const useLegendAIChatState = (
|
|
|
342
386
|
msg.id === messageId &&
|
|
343
387
|
i > 0
|
|
344
388
|
) {
|
|
389
|
+
preResolvedEntities = msg.fallbackAction?.resolvedEntities;
|
|
345
390
|
const userMsg = messages[i - 1];
|
|
346
391
|
if (userMsg?.role === LegendAIMessageRole.USER) {
|
|
347
392
|
question = userMsg.text;
|
|
@@ -373,7 +418,7 @@ export const useLegendAIChatState = (
|
|
|
373
418
|
setMessages,
|
|
374
419
|
},
|
|
375
420
|
pureExecutionContext,
|
|
376
|
-
|
|
421
|
+
preResolvedEntities,
|
|
377
422
|
modelContext,
|
|
378
423
|
)
|
|
379
424
|
.catch(noop)
|
|
@@ -401,7 +446,7 @@ export const useLegendAIChatState = (
|
|
|
401
446
|
messages,
|
|
402
447
|
selectedModelName,
|
|
403
448
|
availableModelNames,
|
|
404
|
-
setSelectedModelName,
|
|
449
|
+
setSelectedModelName: handleSelectModel,
|
|
405
450
|
askQuestion,
|
|
406
451
|
askQuestionWithIntent,
|
|
407
452
|
runFallbackAction,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026-present, Goldman Sachs
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
type TDSServiceSchema,
|
|
19
|
+
TDSServiceSourceType,
|
|
20
|
+
} from '../LegendAITypes.js';
|
|
21
|
+
import {
|
|
22
|
+
AP_CALL_PATTERN,
|
|
23
|
+
buildColumnByNameIndex,
|
|
24
|
+
buildServiceByPIdIndex,
|
|
25
|
+
sharedColumnNames,
|
|
26
|
+
} from './LegendAISqlHelpers.js';
|
|
27
|
+
|
|
28
|
+
const MAX_JOINABLE_SUGGESTIONS = 3;
|
|
29
|
+
const SAMPLE_VALUE_PREVIEW_COUNT = 3;
|
|
30
|
+
const SUGGESTED_ROW_COUNT = 20;
|
|
31
|
+
|
|
32
|
+
/** Parses a comma-separated `sampleValues` string into a lowercased value set. */
|
|
33
|
+
export function parseSampleValueSet(
|
|
34
|
+
sampleValues: string | undefined,
|
|
35
|
+
): Set<string> {
|
|
36
|
+
if (sampleValues === undefined) {
|
|
37
|
+
return new Set();
|
|
38
|
+
}
|
|
39
|
+
return new Set(
|
|
40
|
+
sampleValues
|
|
41
|
+
.split(',')
|
|
42
|
+
.map((v) => v.trim().toLowerCase())
|
|
43
|
+
.filter((v) => v.length > 0),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// True when the two access points share a required (non-nullable) column — a
|
|
48
|
+
// candidate cross-access-point join key.
|
|
49
|
+
function hasSharedRequiredKey(
|
|
50
|
+
apA: TDSServiceSchema,
|
|
51
|
+
apB: TDSServiceSchema,
|
|
52
|
+
): boolean {
|
|
53
|
+
const colsA = buildColumnByNameIndex(apA.columns);
|
|
54
|
+
const colsB = buildColumnByNameIndex(apB.columns);
|
|
55
|
+
return sharedColumnNames(apA, apB).some((name) => {
|
|
56
|
+
const key = name.toLowerCase();
|
|
57
|
+
return (
|
|
58
|
+
colsA.get(key)?.nullable === false && colsB.get(key)?.nullable === false
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Resolves the access points referenced by a cross-`p()` join SQL to their
|
|
64
|
+
// schemas via `findServiceForPId`, de-duplicated by pId.
|
|
65
|
+
function resolveJoinedAccessPoints(
|
|
66
|
+
sql: string,
|
|
67
|
+
services: TDSServiceSchema[],
|
|
68
|
+
): TDSServiceSchema[] {
|
|
69
|
+
const serviceByPId = buildServiceByPIdIndex(services);
|
|
70
|
+
const resolved: TDSServiceSchema[] = [];
|
|
71
|
+
const seenPIds = new Set<string>();
|
|
72
|
+
for (const match of sql.matchAll(AP_CALL_PATTERN)) {
|
|
73
|
+
const pId = match.groups?.pId;
|
|
74
|
+
if (pId === undefined || seenPIds.has(pId)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
seenPIds.add(pId);
|
|
78
|
+
const svc = serviceByPId.get(pId);
|
|
79
|
+
if (svc?.sourceType === TDSServiceSourceType.ACCESS_POINT) {
|
|
80
|
+
resolved.push(svc);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return resolved;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Explains why a cross-access-point join returned 0 rows from grounded sample
|
|
88
|
+
* values. Returns undefined when this is not a cross-access-point join.
|
|
89
|
+
*/
|
|
90
|
+
export function buildCrossJoinZeroRowExplanation(
|
|
91
|
+
sql: string,
|
|
92
|
+
services: TDSServiceSchema[],
|
|
93
|
+
): string | undefined {
|
|
94
|
+
const involved = resolveJoinedAccessPoints(sql, services);
|
|
95
|
+
const apA = involved[0];
|
|
96
|
+
const apB = involved[1];
|
|
97
|
+
if (apA === undefined || apB === undefined) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const colsA = buildColumnByNameIndex(apA.columns);
|
|
101
|
+
const colsB = buildColumnByNameIndex(apB.columns);
|
|
102
|
+
for (const name of sharedColumnNames(apA, apB)) {
|
|
103
|
+
const key = name.toLowerCase();
|
|
104
|
+
const colA = colsA.get(key);
|
|
105
|
+
const colB = colsB.get(key);
|
|
106
|
+
const setA = parseSampleValueSet(colA?.sampleValues);
|
|
107
|
+
const setB = parseSampleValueSet(colB?.sampleValues);
|
|
108
|
+
if (setA.size === 0 || setB.size === 0) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (![...setA].some((v) => setB.has(v))) {
|
|
112
|
+
const previewA = colA?.sampleValues
|
|
113
|
+
?.split(',')
|
|
114
|
+
.slice(0, SAMPLE_VALUE_PREVIEW_COUNT)
|
|
115
|
+
.map((v) => v.trim())
|
|
116
|
+
.join(', ');
|
|
117
|
+
const previewB = colB?.sampleValues
|
|
118
|
+
?.split(',')
|
|
119
|
+
.slice(0, SAMPLE_VALUE_PREVIEW_COUNT)
|
|
120
|
+
.map((v) => v.trim())
|
|
121
|
+
.join(', ');
|
|
122
|
+
return `The join executed successfully but returned **0 rows**: **${apA.title}** and **${apB.title}** cover different data. Their shared column "${name}" has no values in common (e.g. ${previewA} vs ${previewB}), so there are no rows to join.`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return `The join executed successfully but returned **0 rows**: no rows share the join key between **${apA.title}** and **${apB.title}**. These access points may cover different products or time periods.`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Suggests alternative joins likely to return rows: same-group access points
|
|
130
|
+
* sharing a required key, plus a guaranteed single-access-point query.
|
|
131
|
+
*/
|
|
132
|
+
export function buildJoinablePairSuggestions(
|
|
133
|
+
selectedAPs: TDSServiceSchema[],
|
|
134
|
+
allAccessPoints: TDSServiceSchema[],
|
|
135
|
+
): string[] {
|
|
136
|
+
const involved = selectedAPs.filter(
|
|
137
|
+
(s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
|
|
138
|
+
);
|
|
139
|
+
const involvedPatterns = new Set(involved.map((s) => s.pattern));
|
|
140
|
+
const others = allAccessPoints.filter(
|
|
141
|
+
(s) =>
|
|
142
|
+
s.sourceType === TDSServiceSourceType.ACCESS_POINT &&
|
|
143
|
+
!involvedPatterns.has(s.pattern),
|
|
144
|
+
);
|
|
145
|
+
const candidates: string[] = [];
|
|
146
|
+
for (const base of involved) {
|
|
147
|
+
for (const other of others) {
|
|
148
|
+
if (
|
|
149
|
+
base.accessPointGroupTitle !== undefined &&
|
|
150
|
+
base.accessPointGroupTitle === other.accessPointGroupTitle &&
|
|
151
|
+
hasSharedRequiredKey(base, other)
|
|
152
|
+
) {
|
|
153
|
+
candidates.push(
|
|
154
|
+
`Join "${base.title}" and "${other.title}" and show ${SUGGESTED_ROW_COUNT} rows`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const suggestions = candidates.slice(0, MAX_JOINABLE_SUGGESTIONS - 1);
|
|
160
|
+
const first = involved[0];
|
|
161
|
+
if (first !== undefined) {
|
|
162
|
+
suggestions.push(`Show ${SUGGESTED_ROW_COUNT} rows from "${first.title}"`);
|
|
163
|
+
}
|
|
164
|
+
return suggestions.slice(0, MAX_JOINABLE_SUGGESTIONS);
|
|
165
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026-present, Goldman Sachs
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { TDSColumnSchema, TDSServiceSchema } from '../LegendAITypes.js';
|
|
18
|
+
|
|
19
|
+
export const AP_CALL_PATTERN = /(?<!\w)p\(\s*'(?<pId>[^']+)'\s*\)/g;
|
|
20
|
+
export const HAS_LIMIT_PATTERN = /\bLIMIT\s+\d+/i;
|
|
21
|
+
|
|
22
|
+
export function servicePId(service: TDSServiceSchema): string | undefined {
|
|
23
|
+
return service.dataProductPath === undefined
|
|
24
|
+
? undefined
|
|
25
|
+
: `${service.dataProductPath}.${service.pattern.replace(/^\//, '')}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function pureRelationColumnRef(column: string): string {
|
|
29
|
+
if (/^[A-Za-z_]\w*$/u.test(column)) {
|
|
30
|
+
return column;
|
|
31
|
+
}
|
|
32
|
+
const escaped = column.replaceAll("'", String.raw`\'`);
|
|
33
|
+
return `'${escaped}'`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Resolves the access-point schema whose `p('...')` identifier matches `pId`
|
|
37
|
+
// (`<dataProductPath>.<pattern>`). Shared by the join sanitizers and analysis.
|
|
38
|
+
export function findServiceForPId(
|
|
39
|
+
pId: string,
|
|
40
|
+
services: TDSServiceSchema[] | undefined,
|
|
41
|
+
): TDSServiceSchema | undefined {
|
|
42
|
+
return services?.find((s) => servicePId(s) === pId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Builds a `pId → service` index for callers that resolve many `p('...')`
|
|
46
|
+
// accessors in a loop, avoiding a linear scan (and per-call path strip) per
|
|
47
|
+
// accessor.
|
|
48
|
+
export function buildServiceByPIdIndex(
|
|
49
|
+
services: TDSServiceSchema[] | undefined,
|
|
50
|
+
): Map<string, TDSServiceSchema> {
|
|
51
|
+
const index = new Map<string, TDSServiceSchema>();
|
|
52
|
+
for (const service of services ?? []) {
|
|
53
|
+
const pId = servicePId(service);
|
|
54
|
+
if (pId !== undefined) {
|
|
55
|
+
index.set(pId, service);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return index;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Case-insensitive intersection of two access points' column names, returning
|
|
62
|
+
// the names as cased in `apA`.
|
|
63
|
+
export function sharedColumnNames(
|
|
64
|
+
apA: TDSServiceSchema,
|
|
65
|
+
apB: TDSServiceSchema,
|
|
66
|
+
): string[] {
|
|
67
|
+
const bColumns = new Set(apB.columns.map((c) => c.name.toLowerCase()));
|
|
68
|
+
return apA.columns
|
|
69
|
+
.map((c) => c.name)
|
|
70
|
+
.filter((name) => bColumns.has(name.toLowerCase()));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Indexes a column list by lowercased name for O(1) case-insensitive lookup.
|
|
74
|
+
export function buildColumnByNameIndex(
|
|
75
|
+
columns: readonly TDSColumnSchema[],
|
|
76
|
+
): Map<string, TDSColumnSchema> {
|
|
77
|
+
const index = new Map<string, TDSColumnSchema>();
|
|
78
|
+
for (const column of columns) {
|
|
79
|
+
index.set(column.name.toLowerCase(), column);
|
|
80
|
+
}
|
|
81
|
+
return index;
|
|
82
|
+
}
|