@mohsinonxrm/pptb-fetchxml-studio 1.0.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +250 -0
  3. package/eslint.config.js +23 -0
  4. package/index.html +71 -0
  5. package/package.json +53 -0
  6. package/public/vite.svg +1 -0
  7. package/src/app/AppShell.tsx +1733 -0
  8. package/src/assets/20px-Microsoft_Office_Excel_(2025/342/200/223present).svg.png +0 -0
  9. package/src/assets/react.svg +1 -0
  10. package/src/features/fetchxml/api/dataverseMetadata.ts +192 -0
  11. package/src/features/fetchxml/api/excelExport.ts +318 -0
  12. package/src/features/fetchxml/api/formattedValues.ts +107 -0
  13. package/src/features/fetchxml/api/pptbClient.ts +2635 -0
  14. package/src/features/fetchxml/model/displaySettings.ts +18 -0
  15. package/src/features/fetchxml/model/fetchxml.ts +346 -0
  16. package/src/features/fetchxml/model/fetchxmlIntellisense.ts +583 -0
  17. package/src/features/fetchxml/model/fetchxmlParser.ts +708 -0
  18. package/src/features/fetchxml/model/layoutxml.ts +372 -0
  19. package/src/features/fetchxml/model/nodes.ts +255 -0
  20. package/src/features/fetchxml/model/operators.ts +624 -0
  21. package/src/features/fetchxml/model/treeUtils.ts +174 -0
  22. package/src/features/fetchxml/state/builderStore.tsx +1160 -0
  23. package/src/features/fetchxml/state/cache.ts +393 -0
  24. package/src/features/fetchxml/ui/Dialogs/BulkDeleteDialog.tsx +352 -0
  25. package/src/features/fetchxml/ui/Dialogs/DeleteConfirmDialog.tsx +396 -0
  26. package/src/features/fetchxml/ui/Dialogs/SaveViewDialog.tsx +612 -0
  27. package/src/features/fetchxml/ui/Dialogs/SolutionPicker.tsx +114 -0
  28. package/src/features/fetchxml/ui/Dialogs/WorkflowPickerDialog.tsx +437 -0
  29. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/AttributeEditor.tsx +214 -0
  30. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ConditionEditor.tsx +533 -0
  31. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FetchOptionsEditor.tsx +296 -0
  32. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FilterEditor.tsx +106 -0
  33. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/LinkEntityEditor.tsx +433 -0
  34. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/OrderEditor.tsx +161 -0
  35. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ValueOfPicker.tsx +194 -0
  36. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel.tsx +225 -0
  37. package/src/features/fetchxml/ui/LeftPane/TreeView.tsx +604 -0
  38. package/src/features/fetchxml/ui/RightPane/AddColumnsPanel.tsx +800 -0
  39. package/src/features/fetchxml/ui/RightPane/ColumnConfigDialog.tsx +170 -0
  40. package/src/features/fetchxml/ui/RightPane/DataGridCellRenderers.tsx +356 -0
  41. package/src/features/fetchxml/ui/RightPane/EditColumnsPanel.tsx +372 -0
  42. package/src/features/fetchxml/ui/RightPane/FetchXmlEditor.tsx +399 -0
  43. package/src/features/fetchxml/ui/RightPane/FormattedValueUtils.ts +189 -0
  44. package/src/features/fetchxml/ui/RightPane/LayoutXmlViewer.tsx +171 -0
  45. package/src/features/fetchxml/ui/RightPane/PreviewTabs.tsx +471 -0
  46. package/src/features/fetchxml/ui/RightPane/ResultsCommandBar.tsx +430 -0
  47. package/src/features/fetchxml/ui/RightPane/ResultsGrid.tsx +983 -0
  48. package/src/features/fetchxml/ui/RightPane/XmlTextArea.tsx +65 -0
  49. package/src/features/fetchxml/ui/Settings/SettingsDrawer.tsx +175 -0
  50. package/src/features/fetchxml/ui/Toolbar/EntitySelector.tsx +788 -0
  51. package/src/features/fetchxml/ui/Toolbar/LoadViewPicker.tsx +274 -0
  52. package/src/features/fetchxml/ui/Toolbar/SaveViewButton.tsx +174 -0
  53. package/src/main.tsx +11 -0
  54. package/src/shared/components/AttributePicker.tsx +138 -0
  55. package/src/shared/components/BetweenInputPicker.tsx +120 -0
  56. package/src/shared/components/BooleanValuePicker.tsx +165 -0
  57. package/src/shared/components/DateTimeInputPicker.tsx +170 -0
  58. package/src/shared/components/DynamicValueList.tsx +128 -0
  59. package/src/shared/components/ExcelIcon.tsx +30 -0
  60. package/src/shared/components/MultiValuePicker.tsx +178 -0
  61. package/src/shared/components/NumericInputPicker.tsx +156 -0
  62. package/src/shared/components/OptionSetValuePicker.tsx +150 -0
  63. package/src/shared/components/RelationshipPicker.tsx +268 -0
  64. package/src/shared/contexts/ThemeContext.tsx +19 -0
  65. package/src/shared/hooks/useAccessMode.ts +77 -0
  66. package/src/shared/hooks/useLazyMetadata.ts +132 -0
  67. package/src/shared/hooks/usePptbContext.ts +170 -0
  68. package/src/shared/hooks/usePublisherFilter.ts +214 -0
  69. package/src/shared/hooks/useSolutionFilter.ts +162 -0
  70. package/src/shared/utils/debug.ts +87 -0
  71. package/tsconfig.app.json +28 -0
  72. package/tsconfig.json +7 -0
  73. package/tsconfig.node.json +26 -0
  74. package/vite.config.ts +7 -0
@@ -0,0 +1,708 @@
1
+ /**
2
+ * FetchXML Parser - Convert FetchXML string to FetchNode tree
3
+ * Strict XML syntax validation, lenient schema validation (warnings)
4
+ */
5
+
6
+ import type {
7
+ FetchNode,
8
+ EntityNode,
9
+ AttributeNode,
10
+ AllAttributesNode,
11
+ OrderNode,
12
+ FilterNode,
13
+ ConditionNode,
14
+ LinkEntityNode,
15
+ LinkType,
16
+ OperatorType,
17
+ NodeId,
18
+ } from "./nodes";
19
+
20
+ // ID generator for parsed nodes
21
+ let parseIdCounter = 0;
22
+ const generateParseId = (): NodeId => `parsed_${++parseIdCounter}`;
23
+
24
+ /**
25
+ * Reset the ID counter (useful for testing)
26
+ */
27
+ export function resetParserIdCounter(): void {
28
+ parseIdCounter = 0;
29
+ }
30
+
31
+ /**
32
+ * Parsing result with optional warnings
33
+ */
34
+ export interface ParseResult {
35
+ success: boolean;
36
+ fetchNode?: FetchNode;
37
+ errors: ParseError[];
38
+ warnings: ParseWarning[];
39
+ }
40
+
41
+ export interface ParseError {
42
+ message: string;
43
+ line?: number;
44
+ column?: number;
45
+ }
46
+
47
+ export interface ParseWarning {
48
+ message: string;
49
+ element?: string;
50
+ attribute?: string;
51
+ }
52
+
53
+ /**
54
+ * Main entry point: Parse FetchXML string to FetchNode
55
+ */
56
+ export function parseFetchXml(xmlString: string): ParseResult {
57
+ const errors: ParseError[] = [];
58
+ const warnings: ParseWarning[] = [];
59
+
60
+ // Validate input
61
+ if (!xmlString || typeof xmlString !== "string") {
62
+ return {
63
+ success: false,
64
+ errors: [{ message: "FetchXML string is required" }],
65
+ warnings: [],
66
+ };
67
+ }
68
+
69
+ const trimmedXml = xmlString.trim();
70
+ if (!trimmedXml) {
71
+ return {
72
+ success: false,
73
+ errors: [{ message: "FetchXML string is empty" }],
74
+ warnings: [],
75
+ };
76
+ }
77
+
78
+ // Parse XML using DOMParser
79
+ let doc: Document;
80
+ try {
81
+ const parser = new DOMParser();
82
+ doc = parser.parseFromString(trimmedXml, "application/xml");
83
+
84
+ // Check for parsing errors
85
+ const parserError = doc.querySelector("parsererror");
86
+ if (parserError) {
87
+ // Extract error message from parsererror element
88
+ const errorText = parserError.textContent || "Invalid XML syntax";
89
+ return {
90
+ success: false,
91
+ errors: [{ message: `XML Parsing Error: ${errorText}` }],
92
+ warnings: [],
93
+ };
94
+ }
95
+ } catch (e) {
96
+ return {
97
+ success: false,
98
+ errors: [{ message: `XML Parsing Error: ${e instanceof Error ? e.message : String(e)}` }],
99
+ warnings: [],
100
+ };
101
+ }
102
+
103
+ // Validate root element is <fetch>
104
+ const fetchElement = doc.documentElement;
105
+ if (!fetchElement || fetchElement.nodeName.toLowerCase() !== "fetch") {
106
+ return {
107
+ success: false,
108
+ errors: [
109
+ { message: `Root element must be <fetch>, found <${fetchElement?.nodeName || "none"}>` },
110
+ ],
111
+ warnings: [],
112
+ };
113
+ }
114
+
115
+ // Find the entity element (required)
116
+ const entityElement = fetchElement.querySelector(":scope > entity");
117
+ if (!entityElement) {
118
+ return {
119
+ success: false,
120
+ errors: [{ message: "Missing required <entity> element inside <fetch>" }],
121
+ warnings: [],
122
+ };
123
+ }
124
+
125
+ // Validate entity has a name attribute
126
+ const entityName = entityElement.getAttribute("name");
127
+ if (!entityName) {
128
+ return {
129
+ success: false,
130
+ errors: [{ message: "Entity element must have a 'name' attribute" }],
131
+ warnings: [],
132
+ };
133
+ }
134
+
135
+ try {
136
+ // Parse the fetch options
137
+ const options = parseFetchOptions(fetchElement, warnings);
138
+
139
+ // Parse the entity tree
140
+ const entity = parseEntity(entityElement, warnings);
141
+
142
+ const fetchNode: FetchNode = {
143
+ id: generateParseId(),
144
+ type: "fetch",
145
+ entity,
146
+ options,
147
+ };
148
+
149
+ return {
150
+ success: true,
151
+ fetchNode,
152
+ errors,
153
+ warnings,
154
+ };
155
+ } catch (e) {
156
+ return {
157
+ success: false,
158
+ errors: [{ message: `Parsing Error: ${e instanceof Error ? e.message : String(e)}` }],
159
+ warnings,
160
+ };
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Parse fetch element attributes into options
166
+ */
167
+ function parseFetchOptions(fetchElement: Element, warnings: ParseWarning[]): FetchNode["options"] {
168
+ const options: FetchNode["options"] = {};
169
+
170
+ // Boolean options
171
+ if (fetchElement.getAttribute("aggregate") === "true") {
172
+ options.aggregate = true;
173
+ }
174
+ if (fetchElement.getAttribute("distinct") === "true") {
175
+ options.distinct = true;
176
+ }
177
+ if (fetchElement.getAttribute("returntotalrecordcount") === "true") {
178
+ options.returnTotalRecordCount = true;
179
+ }
180
+ if (fetchElement.getAttribute("no-lock") === "true") {
181
+ options.noLock = true;
182
+ }
183
+ if (fetchElement.getAttribute("latematerialize") === "true") {
184
+ options.latematerialize = true;
185
+ }
186
+
187
+ // Numeric options
188
+ const top = fetchElement.getAttribute("top");
189
+ if (top !== null) {
190
+ const topNum = parseInt(top, 10);
191
+ if (!isNaN(topNum)) {
192
+ options.top = topNum;
193
+ } else {
194
+ warnings.push({ message: `Invalid 'top' value: ${top}`, element: "fetch", attribute: "top" });
195
+ }
196
+ }
197
+
198
+ const count = fetchElement.getAttribute("count");
199
+ if (count !== null) {
200
+ const countNum = parseInt(count, 10);
201
+ if (!isNaN(countNum)) {
202
+ options.count = countNum;
203
+ } else {
204
+ warnings.push({
205
+ message: `Invalid 'count' value: ${count}`,
206
+ element: "fetch",
207
+ attribute: "count",
208
+ });
209
+ }
210
+ }
211
+
212
+ const page = fetchElement.getAttribute("page");
213
+ if (page !== null) {
214
+ const pageNum = parseInt(page, 10);
215
+ if (!isNaN(pageNum)) {
216
+ options.page = pageNum;
217
+ } else {
218
+ warnings.push({
219
+ message: `Invalid 'page' value: ${page}`,
220
+ element: "fetch",
221
+ attribute: "page",
222
+ });
223
+ }
224
+ }
225
+
226
+ const utcOffset = fetchElement.getAttribute("utc-offset");
227
+ if (utcOffset !== null) {
228
+ const utcNum = parseInt(utcOffset, 10);
229
+ if (!isNaN(utcNum)) {
230
+ options.utcOffset = utcNum;
231
+ } else {
232
+ warnings.push({
233
+ message: `Invalid 'utc-offset' value: ${utcOffset}`,
234
+ element: "fetch",
235
+ attribute: "utc-offset",
236
+ });
237
+ }
238
+ }
239
+
240
+ // String options
241
+ const pagingCookie = fetchElement.getAttribute("paging-cookie");
242
+ if (pagingCookie) {
243
+ options.pagingCookie = pagingCookie;
244
+ }
245
+
246
+ // Check for unknown attributes (schema warnings)
247
+ const knownFetchAttrs = new Set([
248
+ "aggregate",
249
+ "distinct",
250
+ "top",
251
+ "count",
252
+ "page",
253
+ "paging-cookie",
254
+ "returntotalrecordcount",
255
+ "no-lock",
256
+ "utc-offset",
257
+ "latematerialize",
258
+ "version",
259
+ "mapping",
260
+ "output-format",
261
+ "min-active-row-version",
262
+ "datasource",
263
+ "options",
264
+ ]);
265
+
266
+ for (const attr of fetchElement.attributes) {
267
+ if (!knownFetchAttrs.has(attr.name.toLowerCase())) {
268
+ warnings.push({
269
+ message: `Unknown attribute '${attr.name}' on <fetch> element`,
270
+ element: "fetch",
271
+ attribute: attr.name,
272
+ });
273
+ }
274
+ }
275
+
276
+ return options;
277
+ }
278
+
279
+ /**
280
+ * Parse entity element
281
+ */
282
+ function parseEntity(entityElement: Element, warnings: ParseWarning[]): EntityNode {
283
+ const name = entityElement.getAttribute("name") || "";
284
+
285
+ const entity: EntityNode = {
286
+ id: generateParseId(),
287
+ type: "entity",
288
+ name,
289
+ attributes: [],
290
+ orders: [],
291
+ filters: [],
292
+ links: [],
293
+ };
294
+
295
+ // Parse optional entity attributes
296
+ if (entityElement.getAttribute("enableprefiltering") === "true") {
297
+ entity.enablePrefiltering = true;
298
+ }
299
+ const prefilterParam = entityElement.getAttribute("prefilterparametername");
300
+ if (prefilterParam) {
301
+ entity.prefilterParameterName = prefilterParam;
302
+ }
303
+
304
+ // Parse child elements
305
+ for (const child of entityElement.children) {
306
+ const tagName = child.nodeName.toLowerCase();
307
+
308
+ switch (tagName) {
309
+ case "all-attributes":
310
+ entity.allAttributes = parseAllAttributes(child, warnings);
311
+ break;
312
+ case "attribute":
313
+ entity.attributes.push(parseAttribute(child, warnings));
314
+ break;
315
+ case "order":
316
+ entity.orders.push(parseOrder(child, warnings));
317
+ break;
318
+ case "filter":
319
+ entity.filters.push(parseFilter(child, warnings));
320
+ break;
321
+ case "link-entity":
322
+ entity.links.push(parseLinkEntity(child, warnings));
323
+ break;
324
+ default:
325
+ warnings.push({
326
+ message: `Unknown element <${tagName}> inside <entity>`,
327
+ element: tagName,
328
+ });
329
+ }
330
+ }
331
+
332
+ return entity;
333
+ }
334
+
335
+ /**
336
+ * Parse all-attributes element
337
+ * Note: Parameters reserved for future use (potential warnings for deprecated usage)
338
+ */
339
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
340
+ function parseAllAttributes(_element: Element, _warnings: ParseWarning[]): AllAttributesNode {
341
+ return {
342
+ id: generateParseId(),
343
+ type: "all-attributes",
344
+ enabled: true,
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Parse attribute element
350
+ */
351
+ function parseAttribute(attrElement: Element, warnings: ParseWarning[]): AttributeNode {
352
+ const name = attrElement.getAttribute("name") || "";
353
+
354
+ if (!name) {
355
+ warnings.push({
356
+ message: "Attribute element missing 'name'",
357
+ element: "attribute",
358
+ });
359
+ }
360
+
361
+ const attr: AttributeNode = {
362
+ id: generateParseId(),
363
+ type: "attribute",
364
+ name,
365
+ };
366
+
367
+ // Optional attributes
368
+ const alias = attrElement.getAttribute("alias");
369
+ if (alias) attr.alias = alias;
370
+
371
+ if (attrElement.getAttribute("groupby") === "true") {
372
+ attr.groupby = true;
373
+ }
374
+
375
+ const aggregate = attrElement.getAttribute("aggregate");
376
+ if (aggregate) {
377
+ const validAggregates = ["sum", "count", "countcolumn", "min", "max", "avg", "rowaggregate"];
378
+ if (validAggregates.includes(aggregate)) {
379
+ attr.aggregate = aggregate as AttributeNode["aggregate"];
380
+ } else {
381
+ warnings.push({
382
+ message: `Invalid aggregate value '${aggregate}'`,
383
+ element: "attribute",
384
+ attribute: "aggregate",
385
+ });
386
+ }
387
+ }
388
+
389
+ const dategrouping = attrElement.getAttribute("dategrouping");
390
+ if (dategrouping) {
391
+ const validDateGroupings = [
392
+ "day",
393
+ "week",
394
+ "month",
395
+ "quarter",
396
+ "year",
397
+ "fiscal-period",
398
+ "fiscal-year",
399
+ ];
400
+ if (validDateGroupings.includes(dategrouping)) {
401
+ attr.dategrouping = dategrouping as AttributeNode["dategrouping"];
402
+ } else {
403
+ warnings.push({
404
+ message: `Invalid dategrouping value '${dategrouping}'`,
405
+ element: "attribute",
406
+ attribute: "dategrouping",
407
+ });
408
+ }
409
+ }
410
+
411
+ if (attrElement.hasAttribute("usertimezone")) {
412
+ attr.usertimezone = attrElement.getAttribute("usertimezone") === "true";
413
+ }
414
+
415
+ return attr;
416
+ }
417
+
418
+ /**
419
+ * Parse order element
420
+ */
421
+ function parseOrder(orderElement: Element, warnings: ParseWarning[]): OrderNode {
422
+ const attribute = orderElement.getAttribute("attribute") || "";
423
+
424
+ if (!attribute) {
425
+ warnings.push({
426
+ message: "Order element missing 'attribute'",
427
+ element: "order",
428
+ });
429
+ }
430
+
431
+ const order: OrderNode = {
432
+ id: generateParseId(),
433
+ type: "order",
434
+ attribute,
435
+ };
436
+
437
+ if (orderElement.getAttribute("descending") === "true") {
438
+ order.descending = true;
439
+ }
440
+
441
+ const entityname = orderElement.getAttribute("entityname");
442
+ if (entityname) {
443
+ order.entityname = entityname;
444
+ }
445
+
446
+ return order;
447
+ }
448
+
449
+ /**
450
+ * Parse filter element (recursive for subfilters)
451
+ */
452
+ function parseFilter(filterElement: Element, warnings: ParseWarning[]): FilterNode {
453
+ const type = filterElement.getAttribute("type") || "and";
454
+
455
+ const filter: FilterNode = {
456
+ id: generateParseId(),
457
+ type: "filter",
458
+ conjunction: type === "or" ? "or" : "and",
459
+ conditions: [],
460
+ subfilters: [],
461
+ links: [],
462
+ };
463
+
464
+ const hint = filterElement.getAttribute("hint");
465
+ if (hint === "union") {
466
+ filter.hint = "union";
467
+ }
468
+
469
+ // Parse child elements
470
+ for (const child of filterElement.children) {
471
+ const tagName = child.nodeName.toLowerCase();
472
+
473
+ switch (tagName) {
474
+ case "condition":
475
+ filter.conditions.push(parseCondition(child, warnings));
476
+ break;
477
+ case "filter":
478
+ filter.subfilters.push(parseFilter(child, warnings));
479
+ break;
480
+ case "link-entity":
481
+ // Parse link-entity inside filter (for any/not any/all/not all)
482
+ filter.links.push(parseLinkEntity(child, warnings));
483
+ break;
484
+ default:
485
+ warnings.push({
486
+ message: `Unknown element <${tagName}> inside <filter>`,
487
+ element: tagName,
488
+ });
489
+ }
490
+ }
491
+
492
+ return filter;
493
+ }
494
+
495
+ /**
496
+ * Parse condition element
497
+ */
498
+ function parseCondition(condElement: Element, warnings: ParseWarning[]): ConditionNode {
499
+ const attribute = condElement.getAttribute("attribute") || "";
500
+ const operator = condElement.getAttribute("operator") || "eq";
501
+
502
+ if (!attribute) {
503
+ warnings.push({
504
+ message: "Condition element missing 'attribute'",
505
+ element: "condition",
506
+ });
507
+ }
508
+
509
+ const condition: ConditionNode = {
510
+ id: generateParseId(),
511
+ type: "condition",
512
+ attribute,
513
+ operator: operator as OperatorType,
514
+ };
515
+
516
+ // Optional attributes
517
+ const entityname = condElement.getAttribute("entityname");
518
+ if (entityname) condition.entityname = entityname;
519
+
520
+ const valueof = condElement.getAttribute("valueof");
521
+ if (valueof) condition.valueof = valueof;
522
+
523
+ const aggregate = condElement.getAttribute("aggregate");
524
+ if (aggregate) {
525
+ const validAggregates = ["sum", "count", "countcolumn", "min", "max", "avg"];
526
+ if (validAggregates.includes(aggregate)) {
527
+ condition.aggregate = aggregate as ConditionNode["aggregate"];
528
+ }
529
+ }
530
+
531
+ // Parse value - can be from attribute or child <value> elements
532
+ const valueAttr = condElement.getAttribute("value");
533
+ const valueElements = condElement.querySelectorAll(":scope > value");
534
+
535
+ if (valueElements.length > 0) {
536
+ // Multiple value elements (for in, not-in, between, etc.)
537
+ const values: (string | number)[] = [];
538
+ for (const valEl of valueElements) {
539
+ const valText = valEl.textContent?.trim() || "";
540
+ // Try to parse as number if it looks like one
541
+ const numVal = parseFloat(valText);
542
+ if (!isNaN(numVal) && valText === numVal.toString()) {
543
+ values.push(numVal);
544
+ } else {
545
+ values.push(valText);
546
+ }
547
+ }
548
+ condition.value = values;
549
+ } else if (valueAttr !== null) {
550
+ // Single value attribute
551
+ const numVal = parseFloat(valueAttr);
552
+ if (!isNaN(numVal) && valueAttr === numVal.toString()) {
553
+ condition.value = numVal;
554
+ } else if (valueAttr === "true") {
555
+ condition.value = true;
556
+ } else if (valueAttr === "false") {
557
+ condition.value = false;
558
+ } else {
559
+ condition.value = valueAttr;
560
+ }
561
+ }
562
+
563
+ return condition;
564
+ }
565
+
566
+ /**
567
+ * Parse link-entity element (recursive)
568
+ */
569
+ function parseLinkEntity(linkElement: Element, warnings: ParseWarning[]): LinkEntityNode {
570
+ const name = linkElement.getAttribute("name") || "";
571
+ const from = linkElement.getAttribute("from") || "";
572
+ const to = linkElement.getAttribute("to") || "";
573
+
574
+ if (!name) {
575
+ warnings.push({
576
+ message: "Link-entity element missing 'name'",
577
+ element: "link-entity",
578
+ });
579
+ }
580
+ if (!from) {
581
+ warnings.push({
582
+ message: "Link-entity element missing 'from'",
583
+ element: "link-entity",
584
+ });
585
+ }
586
+ if (!to) {
587
+ warnings.push({
588
+ message: "Link-entity element missing 'to'",
589
+ element: "link-entity",
590
+ });
591
+ }
592
+
593
+ const linkTypeAttr = linkElement.getAttribute("link-type") || "inner";
594
+ const validLinkTypes: LinkType[] = [
595
+ "inner",
596
+ "outer",
597
+ "any",
598
+ "not any",
599
+ "all",
600
+ "not all",
601
+ "exists",
602
+ "in",
603
+ "matchfirstrowusingcrossapply",
604
+ ];
605
+
606
+ let linkType: LinkType = "inner";
607
+ if (validLinkTypes.includes(linkTypeAttr as LinkType)) {
608
+ linkType = linkTypeAttr as LinkType;
609
+ } else {
610
+ warnings.push({
611
+ message: `Invalid link-type '${linkTypeAttr}', defaulting to 'inner'`,
612
+ element: "link-entity",
613
+ attribute: "link-type",
614
+ });
615
+ }
616
+
617
+ const link: LinkEntityNode = {
618
+ id: generateParseId(),
619
+ type: "link-entity",
620
+ name,
621
+ from,
622
+ to,
623
+ linkType,
624
+ attributes: [],
625
+ orders: [],
626
+ filters: [],
627
+ links: [],
628
+ };
629
+
630
+ // Optional attributes
631
+ const alias = linkElement.getAttribute("alias");
632
+ if (alias) link.alias = alias;
633
+
634
+ if (linkElement.getAttribute("intersect") === "true") {
635
+ link.intersect = true;
636
+ }
637
+
638
+ if (linkElement.hasAttribute("visible")) {
639
+ link.visible = linkElement.getAttribute("visible") === "true";
640
+ }
641
+
642
+ // Parse child elements
643
+ for (const child of linkElement.children) {
644
+ const tagName = child.nodeName.toLowerCase();
645
+
646
+ switch (tagName) {
647
+ case "all-attributes":
648
+ link.allAttributes = parseAllAttributes(child, warnings);
649
+ break;
650
+ case "attribute":
651
+ link.attributes.push(parseAttribute(child, warnings));
652
+ break;
653
+ case "order":
654
+ link.orders.push(parseOrder(child, warnings));
655
+ break;
656
+ case "filter":
657
+ link.filters.push(parseFilter(child, warnings));
658
+ break;
659
+ case "link-entity":
660
+ link.links.push(parseLinkEntity(child, warnings));
661
+ break;
662
+ default:
663
+ warnings.push({
664
+ message: `Unknown element <${tagName}> inside <link-entity>`,
665
+ element: tagName,
666
+ });
667
+ }
668
+ }
669
+
670
+ return link;
671
+ }
672
+
673
+ /**
674
+ * Validate a FetchXML string without fully parsing it
675
+ * Quick syntax check
676
+ */
677
+ export function validateFetchXmlSyntax(xmlString: string): { valid: boolean; error?: string } {
678
+ if (!xmlString?.trim()) {
679
+ return { valid: false, error: "FetchXML string is empty" };
680
+ }
681
+
682
+ try {
683
+ const parser = new DOMParser();
684
+ const doc = parser.parseFromString(xmlString.trim(), "application/xml");
685
+ const parserError = doc.querySelector("parsererror");
686
+ if (parserError) {
687
+ return { valid: false, error: parserError.textContent || "Invalid XML syntax" };
688
+ }
689
+
690
+ const fetchElement = doc.documentElement;
691
+ if (!fetchElement || fetchElement.nodeName.toLowerCase() !== "fetch") {
692
+ return { valid: false, error: "Root element must be <fetch>" };
693
+ }
694
+
695
+ const entityElement = fetchElement.querySelector(":scope > entity");
696
+ if (!entityElement) {
697
+ return { valid: false, error: "Missing required <entity> element" };
698
+ }
699
+
700
+ if (!entityElement.getAttribute("name")) {
701
+ return { valid: false, error: "Entity element must have a 'name' attribute" };
702
+ }
703
+
704
+ return { valid: true };
705
+ } catch (e) {
706
+ return { valid: false, error: e instanceof Error ? e.message : String(e) };
707
+ }
708
+ }