@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
@@ -0,0 +1,515 @@
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 { escapeRegExp, isNonNullable } from '@finos/legend-shared';
18
+ import type { TDSServiceSchema } from '../LegendAITypes.js';
19
+ import {
20
+ AP_CALL_PATTERN,
21
+ HAS_LIMIT_PATTERN,
22
+ buildColumnByNameIndex,
23
+ buildServiceByPIdIndex,
24
+ findServiceForPId,
25
+ } from './LegendAISqlHelpers.js';
26
+
27
+ const JOIN_PATTERN = /\bJOIN\b/i;
28
+ const ORDER_BY_SPLIT = /\bORDER\s+BY\b/i;
29
+ const SELECT_ALIAS_PATTERN =
30
+ /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"\s+AS\s+(?:"(?<qAlias>[^"]+)"|(?<uAlias>\w+))/gi;
31
+ const ALIAS_DOT_COL_PATTERN = /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"/gi;
32
+ const JOIN_DRIVING_SIDE_SAMPLE_LIMIT = 1000;
33
+ const P_WITH_ALIAS_PATTERN =
34
+ /p\(\s*'(?<pId>[^']+)'\s*\)\s+AS\s+(?<alias>[a-z]\w*)/gi;
35
+ const COMPLEX_SQL_SHAPE_PATTERN =
36
+ /\bGROUP\s+BY\b|\bOVER\s*\(|\bWITH\b|\bUNION\b/i;
37
+ const PLAIN_ALIASED_COLUMN_PATTERN =
38
+ /^(?<alias>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"$/i;
39
+ const STAR_COLUMN_PATTERN = /^(?<alias>[a-z]\w*)\s*\.\s*\*$/i;
40
+ const AS_ALIAS_PATTERN = /\bAS\s+(?:"(?<q>[^"]+)"|(?<u>\w+))\s*$/i;
41
+ const BARE_QUOTED_COLUMN_PATTERN = /^"(?<col>[^"]+)"$/;
42
+
43
+ export function sanitizeJoinOrderBy(sql: string): string {
44
+ if (!JOIN_PATTERN.test(sql)) {
45
+ return sql;
46
+ }
47
+ const parts = sql.split(ORDER_BY_SPLIT);
48
+ if (parts.length < 2) {
49
+ return sql;
50
+ }
51
+
52
+ const beforeOrderBy = parts[0] ?? '';
53
+ const afterOrderBy = parts.slice(1).join('ORDER BY').replace(/^\s+/, '');
54
+
55
+ const selectAliases = new Map<string, string>();
56
+ for (const m of beforeOrderBy.matchAll(SELECT_ALIAS_PATTERN)) {
57
+ const tableAlias = (m.groups?.tbl ?? '').toLowerCase();
58
+ const colName = (m.groups?.col ?? '').toLowerCase();
59
+ const asAlias = m.groups?.qAlias ?? m.groups?.uAlias ?? '';
60
+ selectAliases.set(`${tableAlias}.${colName}`, asAlias);
61
+ }
62
+
63
+ if (selectAliases.size === 0) {
64
+ return sql;
65
+ }
66
+
67
+ const replacements = new Map<string, string>();
68
+ for (const m of afterOrderBy.matchAll(ALIAS_DOT_COL_PATTERN)) {
69
+ const tbl = (m.groups?.tbl ?? '').toLowerCase();
70
+ const col = (m.groups?.col ?? '').toLowerCase();
71
+ const alias = selectAliases.get(`${tbl}.${col}`);
72
+ if (alias) {
73
+ replacements.set(m[0], `"${alias}"`);
74
+ }
75
+ }
76
+
77
+ if (replacements.size === 0) {
78
+ return sql;
79
+ }
80
+
81
+ const rewritten = afterOrderBy.replaceAll(
82
+ ALIAS_DOT_COL_PATTERN,
83
+ (match) => replacements.get(match) ?? match,
84
+ );
85
+
86
+ if (rewritten === afterOrderBy) {
87
+ return sql;
88
+ }
89
+ return `${beforeOrderBy}ORDER BY ${rewritten}`;
90
+ }
91
+
92
+ // Splits a SELECT projection list on top-level commas, ignoring commas inside
93
+ // parentheses or quoted identifiers/string literals.
94
+ function splitTopLevelColumns(selectList: string): string[] {
95
+ const items: string[] = [];
96
+ let depth = 0;
97
+ let inDoubleQuote = false;
98
+ let inSingleQuote = false;
99
+ let current = '';
100
+ for (const ch of selectList) {
101
+ const inQuote = inDoubleQuote || inSingleQuote;
102
+ if (ch === '"' && !inSingleQuote) {
103
+ inDoubleQuote = !inDoubleQuote;
104
+ } else if (ch === "'" && !inDoubleQuote) {
105
+ inSingleQuote = !inSingleQuote;
106
+ } else if (!inQuote && ch === '(') {
107
+ depth += 1;
108
+ } else if (!inQuote && ch === ')') {
109
+ depth = Math.max(0, depth - 1);
110
+ }
111
+ if (!inQuote && depth === 0 && ch === ',') {
112
+ items.push(current);
113
+ current = '';
114
+ } else {
115
+ current += ch;
116
+ }
117
+ }
118
+ if (current.trim().length > 0) {
119
+ items.push(current);
120
+ }
121
+ return items;
122
+ }
123
+
124
+ // Maps each access-point table alias (`p('...') AS a`) to its schema column
125
+ // names, so `alias.*` projections can be expanded deterministically.
126
+ function buildAliasColumnMap(
127
+ sql: string,
128
+ services: TDSServiceSchema[] | undefined,
129
+ ): Map<string, string[]> {
130
+ const serviceByPId = buildServiceByPIdIndex(services);
131
+ const aliasColumns = new Map<string, string[]>();
132
+ for (const match of sql.matchAll(P_WITH_ALIAS_PATTERN)) {
133
+ const alias = match.groups?.alias?.toLowerCase();
134
+ const pId = match.groups?.pId;
135
+ if (alias === undefined || pId === undefined) {
136
+ continue;
137
+ }
138
+ const svc = serviceByPId.get(pId);
139
+ if (svc && svc.columns.length > 0) {
140
+ aliasColumns.set(
141
+ alias,
142
+ svc.columns.map((c) => c.name),
143
+ );
144
+ }
145
+ }
146
+ return aliasColumns;
147
+ }
148
+
149
+ // Resolves a projection item's output name: the `AS` alias when present, else
150
+ // the plain column name. Returns undefined for expressions we can't reason about.
151
+ function resolveOutputColumnName(item: string): string | undefined {
152
+ const asMatch = AS_ALIAS_PATTERN.exec(item);
153
+ if (asMatch) {
154
+ return (asMatch.groups?.q ?? asMatch.groups?.u ?? '').toLowerCase();
155
+ }
156
+ const plain = PLAIN_ALIASED_COLUMN_PATTERN.exec(item);
157
+ if (plain?.groups?.col !== undefined) {
158
+ return plain.groups.col.toLowerCase();
159
+ }
160
+ const bare = BARE_QUOTED_COLUMN_PATTERN.exec(item);
161
+ if (bare?.groups?.col !== undefined) {
162
+ return bare.groups.col.toLowerCase();
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ // Expands `alias.*` projection items into explicit `alias."col"` items using the
168
+ // access-point schema. Returns undefined when a `*` alias can't be resolved.
169
+ function expandStarColumns(
170
+ items: string[],
171
+ aliasColumns: Map<string, string[]>,
172
+ ): string[] | undefined {
173
+ const expanded: string[] = [];
174
+ for (const item of items) {
175
+ const star = STAR_COLUMN_PATTERN.exec(item);
176
+ const alias = star?.groups?.alias?.toLowerCase();
177
+ const starColumns = alias ? aliasColumns.get(alias) : undefined;
178
+ if (star && !starColumns) {
179
+ return undefined;
180
+ }
181
+ if (star && starColumns) {
182
+ for (const col of starColumns) {
183
+ expanded.push(`${star.groups?.alias ?? ''}."${col}"`);
184
+ }
185
+ } else {
186
+ expanded.push(item);
187
+ }
188
+ }
189
+ return expanded;
190
+ }
191
+
192
+ // Aliases any projection column whose output name collides with another, so the
193
+ // engine sees unique output names. Reports whether any column was renamed.
194
+ function rewriteDuplicateColumnNames(expanded: string[]): {
195
+ rewritten: string[];
196
+ anyRenamed: boolean;
197
+ } {
198
+ const nameCounts = new Map<string, number>();
199
+ for (const item of expanded) {
200
+ const name = resolveOutputColumnName(item);
201
+ if (name !== undefined) {
202
+ nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
203
+ }
204
+ }
205
+ let anyRenamed = false;
206
+ const rewritten = expanded.map((item) => {
207
+ const plain = PLAIN_ALIASED_COLUMN_PATTERN.exec(item);
208
+ const col = plain?.groups?.col;
209
+ const alias = plain?.groups?.alias;
210
+ if (col === undefined || alias === undefined) {
211
+ return item;
212
+ }
213
+ if ((nameCounts.get(col.toLowerCase()) ?? 0) <= 1) {
214
+ return item;
215
+ }
216
+ anyRenamed = true;
217
+ return `${alias}."${col}" AS "${alias}_${col}"`;
218
+ });
219
+ return { rewritten, anyRenamed };
220
+ }
221
+
222
+ // Guarantees a unique output projection for flat cross-access-point joins by
223
+ // expanding `alias.*` and aliasing colliding column names (the engine rejects
224
+ // duplicate output names). Aggregation/window/CTE/set-op shapes are untouched.
225
+ export function sanitizeJoinDuplicateColumns(
226
+ sql: string,
227
+ services?: TDSServiceSchema[],
228
+ ): string {
229
+ if (!JOIN_PATTERN.test(sql) || COMPLEX_SQL_SHAPE_PATTERN.test(sql)) {
230
+ return sql;
231
+ }
232
+ if ([...sql.matchAll(AP_CALL_PATTERN)].length < 2) {
233
+ return sql;
234
+ }
235
+
236
+ const leadMatch = /^\s*SELECT\s+(?:DISTINCT\s+)?/i.exec(sql);
237
+ const fromMatch = /\bFROM\b/i.exec(sql);
238
+ if (!leadMatch || !fromMatch || fromMatch.index <= leadMatch[0].length) {
239
+ return sql;
240
+ }
241
+ const lead = leadMatch[0];
242
+ const selectList = sql.slice(lead.length, fromMatch.index);
243
+ const rest = sql.slice(fromMatch.index);
244
+
245
+ const aliasColumns = buildAliasColumnMap(sql, services);
246
+ const items = splitTopLevelColumns(selectList)
247
+ .map((item) => item.trim())
248
+ .filter((item) => item.length > 0);
249
+ if (items.length === 0) {
250
+ return sql;
251
+ }
252
+
253
+ const expanded = expandStarColumns(items, aliasColumns);
254
+ if (!expanded) {
255
+ return sql;
256
+ }
257
+
258
+ const { rewritten, anyRenamed } = rewriteDuplicateColumnNames(expanded);
259
+ const changed = anyRenamed || expanded.length !== items.length;
260
+ if (!changed) {
261
+ return sql;
262
+ }
263
+
264
+ const finalNames = rewritten
265
+ .map((item) => resolveOutputColumnName(item))
266
+ .filter(isNonNullable);
267
+ if (new Set(finalNames).size !== finalNames.length) {
268
+ return sql;
269
+ }
270
+ return `${lead}${rewritten.join(', ')} ${rest}`;
271
+ }
272
+
273
+ // Rewrites a join that equates same-named keys from both inputs into a renaming
274
+ // subquery on the right side, so the engine sees distinct key names.
275
+ export function sanitizeJoinSameKeyColumns(
276
+ sql: string,
277
+ services?: TDSServiceSchema[],
278
+ ): string {
279
+ if (!JOIN_PATTERN.test(sql)) {
280
+ return sql;
281
+ }
282
+
283
+ const joinRegex =
284
+ /\bJOIN\s{1,5}p\(\s{0,5}'(?<pId>[^']{1,200})'\s{0,5}\)\s{1,5}AS\s{1,5}(?<rAlias>[a-z]\w{0,63})\s{1,5}ON\s{1,5}(?<onClause>[^\n]{1,500})/gi;
285
+
286
+ let result = sql;
287
+ let match: RegExpExecArray | null;
288
+
289
+ while ((match = joinRegex.exec(sql)) !== null) {
290
+ const pId = match.groups?.pId ?? '';
291
+ const rightAlias = match.groups?.rAlias ?? '';
292
+ const onClause = (match.groups?.onClause ?? '').slice(0, 500);
293
+
294
+ const sameKeyMatch =
295
+ /[a-z]\w{0,63} {0,5}\. {0,5}"(?<lCol>[^"]{1,200})" {0,5}= {0,5}(?<rAl>[a-z]\w{0,63}) {0,5}\. {0,5}"(?<rCol>[^"]{1,200})"/i.exec(
296
+ onClause,
297
+ );
298
+ if (!sameKeyMatch) {
299
+ continue;
300
+ }
301
+
302
+ const leftCol = sameKeyMatch.groups?.lCol ?? '';
303
+ const rightCol = sameKeyMatch.groups?.rCol ?? '';
304
+ const matchedRightAlias = sameKeyMatch.groups?.rAl ?? '';
305
+
306
+ if (
307
+ leftCol.toLowerCase() !== rightCol.toLowerCase() ||
308
+ matchedRightAlias.toLowerCase() !== rightAlias.toLowerCase()
309
+ ) {
310
+ continue;
311
+ }
312
+
313
+ const renamedKey = `${rightAlias}_${rightCol}`;
314
+ const columnList = buildSubqueryColumnList(
315
+ pId,
316
+ rightCol,
317
+ renamedKey,
318
+ services,
319
+ );
320
+ const subqueryFragment = `(SELECT ${columnList} FROM p('${pId}')) AS ${rightAlias}`;
321
+ const newOnRef = `${rightAlias}."${renamedKey}"`;
322
+
323
+ const originalFragmentPattern = new RegExp(
324
+ String.raw`p\(\s{0,5}'${escapeRegExp(pId)}'\s{0,5}\)\s{1,5}AS\s{1,5}${escapeRegExp(rightAlias)}`,
325
+ 'u',
326
+ );
327
+ const onRefPattern = new RegExp(
328
+ String.raw`(?<!\w)${escapeRegExp(rightAlias)}\s{0,5}\.\s{0,5}"${escapeRegExp(rightCol)}"`,
329
+ 'gu',
330
+ );
331
+
332
+ result = result.replace(originalFragmentPattern, () => subqueryFragment);
333
+ result = result.replace(onRefPattern, () => newOnRef);
334
+ }
335
+
336
+ return result;
337
+ }
338
+
339
+ interface QuoteScanState {
340
+ inSingle: boolean;
341
+ inDouble: boolean;
342
+ }
343
+
344
+ // Advances single/double-quote tracking for one char. `structural` is true when
345
+ // the char sits outside any quoted string (so callers may interpret parens etc.).
346
+ function advanceQuoteState(
347
+ ch: string,
348
+ state: QuoteScanState,
349
+ ): { state: QuoteScanState; structural: boolean } {
350
+ if (ch === "'" && !state.inDouble) {
351
+ return {
352
+ state: { inSingle: !state.inSingle, inDouble: state.inDouble },
353
+ structural: false,
354
+ };
355
+ }
356
+ if (ch === '"' && !state.inSingle) {
357
+ return {
358
+ state: { inSingle: state.inSingle, inDouble: !state.inDouble },
359
+ structural: false,
360
+ };
361
+ }
362
+ return { state, structural: !state.inSingle && !state.inDouble };
363
+ }
364
+
365
+ // Finds the index of the parenthesis that closes the `(` at `openIdx`,
366
+ // respecting single/double-quoted string content. Returns -1 if unbalanced.
367
+ function matchingCloseParen(sql: string, openIdx: number): number {
368
+ let depth = 0;
369
+ let quotes: QuoteScanState = { inSingle: false, inDouble: false };
370
+ for (let i = openIdx; i < sql.length; i++) {
371
+ const ch = sql[i] ?? '';
372
+ const step = advanceQuoteState(ch, quotes);
373
+ quotes = step.state;
374
+ if (!step.structural) {
375
+ continue;
376
+ }
377
+ if (ch === '(') {
378
+ depth += 1;
379
+ } else if (ch === ')') {
380
+ depth -= 1;
381
+ if (depth === 0) {
382
+ return i;
383
+ }
384
+ }
385
+ }
386
+ return -1;
387
+ }
388
+
389
+ // Returns the index of the top-level `FROM (` open paren (the driving-side
390
+ // subquery), or -1. Ignores `FROM (` nested in a SELECT-list subquery.
391
+ function topLevelFromOpenParenIndex(sql: string): number {
392
+ let depth = 0;
393
+ let quotes: QuoteScanState = { inSingle: false, inDouble: false };
394
+ for (let i = 0; i < sql.length; i++) {
395
+ const ch = sql[i] ?? '';
396
+ const step = advanceQuoteState(ch, quotes);
397
+ quotes = step.state;
398
+ if (!step.structural) {
399
+ continue;
400
+ }
401
+ if (ch === '(') {
402
+ depth += 1;
403
+ } else if (ch === ')') {
404
+ depth = Math.max(0, depth - 1);
405
+ } else if (depth === 0 && (ch === 'F' || ch === 'f')) {
406
+ const boundary = i === 0 || !/\w/u.test(sql[i - 1] ?? '');
407
+ const fromOpen = /^FROM\s*\(/iu.exec(sql.slice(i));
408
+ if (boundary && fromOpen) {
409
+ return i + fromOpen[0].length - 1;
410
+ }
411
+ }
412
+ }
413
+ return -1;
414
+ }
415
+
416
+ // Bounds the driving (left) side of a cross-access-point join with an inner
417
+ // LIMIT so the engine samples one feed instead of scanning both fully. Only
418
+ // applies when the driving side is a subquery lacking its own LIMIT.
419
+ export function boundCrossAccessPointJoinDrivingSide(sql: string): string {
420
+ if (!JOIN_PATTERN.test(sql) || COMPLEX_SQL_SHAPE_PATTERN.test(sql)) {
421
+ return sql;
422
+ }
423
+ if ([...sql.matchAll(AP_CALL_PATTERN)].length < 2) {
424
+ return sql;
425
+ }
426
+ const openIdx = topLevelFromOpenParenIndex(sql);
427
+ if (openIdx === -1) {
428
+ return sql;
429
+ }
430
+ const closeIdx = matchingCloseParen(sql, openIdx);
431
+ if (closeIdx === -1) {
432
+ return sql;
433
+ }
434
+ const subquery = sql.slice(openIdx, closeIdx + 1);
435
+ if (HAS_LIMIT_PATTERN.test(subquery)) {
436
+ return sql;
437
+ }
438
+ return `${sql.slice(0, closeIdx)} LIMIT ${JOIN_DRIVING_SIDE_SAMPLE_LIMIT}${sql.slice(closeIdx)}`;
439
+ }
440
+
441
+ // Wraps any bare `p('...') AS x` join input in a subquery that prefixes its
442
+ // columns per-alias, so the two inputs share no names (the SQL→Pure transpiler
443
+ // fails on bare overlapping p() inputs). No-op for non-cross-AP-join shapes.
444
+ export function wrapBareJoinAccessPoints(
445
+ sql: string,
446
+ services?: TDSServiceSchema[],
447
+ ): string {
448
+ if (!JOIN_PATTERN.test(sql) || COMPLEX_SQL_SHAPE_PATTERN.test(sql)) {
449
+ return sql;
450
+ }
451
+ if ([...sql.matchAll(AP_CALL_PATTERN)].length < 2) {
452
+ return sql;
453
+ }
454
+ const bareInputPattern =
455
+ /\b(?<kw>FROM|JOIN)\s+p\(\s*'(?<pId>[^']+)'\s*\)\s+AS\s+(?<alias>[a-z]\w*)/gi;
456
+ const serviceByPId = buildServiceByPIdIndex(services);
457
+ let result = sql;
458
+ for (const match of sql.matchAll(bareInputPattern)) {
459
+ const kw = match.groups?.kw ?? '';
460
+ const pId = match.groups?.pId;
461
+ const alias = match.groups?.alias;
462
+ if (pId === undefined || alias === undefined) {
463
+ continue;
464
+ }
465
+ const svc = serviceByPId.get(pId);
466
+ if (!svc || svc.columns.length === 0) {
467
+ continue;
468
+ }
469
+ const projection = svc.columns
470
+ .map((c) => `"${c.name}" AS "${alias}_${c.name}"`)
471
+ .join(', ');
472
+ const limit =
473
+ kw.toUpperCase() === 'FROM'
474
+ ? ` LIMIT ${JOIN_DRIVING_SIDE_SAMPLE_LIMIT}`
475
+ : '';
476
+ const columnByName = buildColumnByNameIndex(svc.columns);
477
+ const columnAlternation = svc.columns
478
+ .map((c) => escapeRegExp(c.name))
479
+ .join('|');
480
+ const refPattern = new RegExp(
481
+ String.raw`(?<!\w)${escapeRegExp(alias)}\s*\.\s*"(?<col>${columnAlternation})"`,
482
+ 'giu',
483
+ );
484
+ result = result.replace(refPattern, (matched: string, col: string) => {
485
+ const schemaColumn = columnByName.get(col.toLowerCase());
486
+ return schemaColumn
487
+ ? `${alias}."${alias}_${schemaColumn.name}"`
488
+ : matched;
489
+ });
490
+ result = result.replace(
491
+ match[0],
492
+ () => `${kw} (SELECT ${projection} FROM p('${pId}')${limit}) AS ${alias}`,
493
+ );
494
+ }
495
+ return result;
496
+ }
497
+
498
+ function buildSubqueryColumnList(
499
+ pId: string,
500
+ originalKey: string,
501
+ renamedKey: string,
502
+ services?: TDSServiceSchema[],
503
+ ): string {
504
+ const svc = findServiceForPId(pId, services);
505
+ if (!svc || svc.columns.length === 0) {
506
+ return `"${originalKey}" AS "${renamedKey}", *`;
507
+ }
508
+ return svc.columns
509
+ .map((c) =>
510
+ c.name.toLowerCase() === originalKey.toLowerCase()
511
+ ? `"${c.name}" AS "${renamedKey}"`
512
+ : `"${c.name}"`,
513
+ )
514
+ .join(', ');
515
+ }
package/tsconfig.json CHANGED
@@ -63,6 +63,9 @@
63
63
  "./src/legend-ai/components/LegendAIChatHelpers.ts",
64
64
  "./src/legend-ai/stores/LegendAIChatProcessors.ts",
65
65
  "./src/legend-ai/stores/LegendAIChatState.ts",
66
+ "./src/legend-ai/stores/LegendAIJoinAnalysis.ts",
67
+ "./src/legend-ai/stores/LegendAISqlHelpers.ts",
68
+ "./src/legend-ai/stores/LegendAISqlJoinSanitizers.ts",
66
69
  "./src/model-documentation/ModelDocumentationAnalysis.ts",
67
70
  "./src/model-documentation/index.ts",
68
71
  "./src/application/ActivityBar.tsx",
@@ -77,6 +80,7 @@
77
80
  "./src/data-grid/DataGrid.tsx",
78
81
  "./src/graph-editor/ElementIconUtils.tsx",
79
82
  "./src/graph-editor/PackageableElementOption.tsx",
83
+ "./src/legend-ai/LegendAIHostIntegration.tsx",
80
84
  "./src/legend-ai/components/LegendAIAnalysisPanel.tsx",
81
85
  "./src/legend-ai/components/LegendAICharts.tsx",
82
86
  "./src/legend-ai/components/LegendAIChat.tsx",