@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.
Files changed (74) hide show
  1. package/lib/index.css +2 -2
  2. package/lib/index.css.map +1 -1
  3. package/lib/legend-ai/LegendAIDocEnrichment.d.ts +12 -12
  4. package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -1
  5. package/lib/legend-ai/LegendAIDocEnrichment.js +317 -86
  6. package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -1
  7. package/lib/legend-ai/LegendAIHostIntegration.d.ts +31 -0
  8. package/lib/legend-ai/LegendAIHostIntegration.d.ts.map +1 -0
  9. package/lib/legend-ai/LegendAIHostIntegration.js +50 -0
  10. package/lib/legend-ai/LegendAIHostIntegration.js.map +1 -0
  11. package/lib/legend-ai/LegendAIServiceRetrieval.d.ts.map +1 -1
  12. package/lib/legend-ai/LegendAIServiceRetrieval.js +34 -35
  13. package/lib/legend-ai/LegendAIServiceRetrieval.js.map +1 -1
  14. package/lib/legend-ai/LegendAITypes.d.ts +85 -7
  15. package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
  16. package/lib/legend-ai/LegendAITypes.js +99 -1
  17. package/lib/legend-ai/LegendAITypes.js.map +1 -1
  18. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +36 -3
  19. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
  20. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +29 -0
  21. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
  22. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
  23. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +7 -0
  24. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
  25. package/lib/legend-ai/components/LegendAICharts.d.ts.map +1 -1
  26. package/lib/legend-ai/components/LegendAICharts.js +6 -10
  27. package/lib/legend-ai/components/LegendAICharts.js.map +1 -1
  28. package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
  29. package/lib/legend-ai/components/LegendAIChat.js +263 -41
  30. package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
  31. package/lib/legend-ai/components/LegendAIChatInput.d.ts +1 -1
  32. package/lib/legend-ai/components/LegendAIChatInput.d.ts.map +1 -1
  33. package/lib/legend-ai/components/LegendAIChatInput.js +2 -1
  34. package/lib/legend-ai/components/LegendAIChatInput.js.map +1 -1
  35. package/lib/legend-ai/index.d.ts +5 -2
  36. package/lib/legend-ai/index.d.ts.map +1 -1
  37. package/lib/legend-ai/index.js +5 -2
  38. package/lib/legend-ai/index.js.map +1 -1
  39. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +9 -3
  40. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -1
  41. package/lib/legend-ai/stores/LegendAIChatProcessors.js +794 -220
  42. package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -1
  43. package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
  44. package/lib/legend-ai/stores/LegendAIChatState.js +47 -21
  45. package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
  46. package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts +29 -0
  47. package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts.map +1 -0
  48. package/lib/legend-ai/stores/LegendAIJoinAnalysis.js +124 -0
  49. package/lib/legend-ai/stores/LegendAIJoinAnalysis.js.map +1 -0
  50. package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts +25 -0
  51. package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts.map +1 -0
  52. package/lib/legend-ai/stores/LegendAISqlHelpers.js +64 -0
  53. package/lib/legend-ai/stores/LegendAISqlHelpers.js.map +1 -0
  54. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.d.ts +22 -0
  55. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.d.ts.map +1 -0
  56. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js +411 -0
  57. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js.map +1 -0
  58. package/package.json +6 -6
  59. package/src/legend-ai/LegendAIDocEnrichment.ts +435 -97
  60. package/src/legend-ai/LegendAIHostIntegration.tsx +80 -0
  61. package/src/legend-ai/LegendAIServiceRetrieval.ts +50 -35
  62. package/src/legend-ai/LegendAITypes.ts +169 -5
  63. package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +73 -1
  64. package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +10 -0
  65. package/src/legend-ai/components/LegendAICharts.tsx +30 -33
  66. package/src/legend-ai/components/LegendAIChat.tsx +555 -91
  67. package/src/legend-ai/components/LegendAIChatInput.tsx +6 -2
  68. package/src/legend-ai/index.ts +22 -3
  69. package/src/legend-ai/stores/LegendAIChatProcessors.ts +1286 -322
  70. package/src/legend-ai/stores/LegendAIChatState.ts +73 -28
  71. package/src/legend-ai/stores/LegendAIJoinAnalysis.ts +165 -0
  72. package/src/legend-ai/stores/LegendAISqlHelpers.ts +82 -0
  73. package/src/legend-ai/stores/LegendAISqlJoinSanitizers.ts +515 -0
  74. 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 toggleScope = useCallback((scope: LegendAIScopeItem) => {
122
- setSelectedScopes((prev) =>
123
- prev.some((s) => s.id === scope.id)
124
- ? prev.filter((s) => s.id !== scope.id)
125
- : [...prev, scope],
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 removeScope = useCallback((scopeId: string) => {
130
- setSelectedScopes((prev) => prev.filter((s) => s.id !== scopeId));
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
- selectedScopes,
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
- selectedScopes,
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
- undefined,
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
+ }