simplesvelte 2.2.11 → 2.2.12

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.
@@ -0,0 +1,789 @@
1
+ // ============================================================================
2
+ // Helper: Build OData $select Clause
3
+ // ============================================================================
4
+ /**
5
+ * Builds the OData $select query parameter from column selections
6
+ *
7
+ * @param columns - Array of column names to select
8
+ * @returns OData $select string (e.g., "name,revenue,statecode")
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * buildODataSelect(['accountid', 'name', 'revenue'])
13
+ * // Returns: "accountid,name,revenue"
14
+ *
15
+ * buildODataSelect(['contactid', 'fullname', 'parentcustomerid_account/name'])
16
+ * // Returns: "contactid,fullname,parentcustomerid_account/name"
17
+ * ```
18
+ */
19
+ export function buildODataSelect(columns) {
20
+ if (!columns || columns.length === 0)
21
+ return undefined;
22
+ return columns.join(',');
23
+ }
24
+ // ============================================================================
25
+ // Helper: Build OData $orderby Clause
26
+ // ============================================================================
27
+ /**
28
+ * Builds the OData $orderby query parameter from AG Grid sort model
29
+ *
30
+ * OData orderby syntax: "field1 asc,field2 desc"
31
+ *
32
+ * @param sortModel - AG Grid sort model
33
+ * @param defaultOrderBy - Fallback sort if no sort model provided
34
+ * @returns OData $orderby string (e.g., "name asc,revenue desc")
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * buildODataOrderBy([
39
+ * { colId: 'name', sort: 'asc' },
40
+ * { colId: 'revenue', sort: 'desc' }
41
+ * ])
42
+ * // Returns: "name asc,revenue desc"
43
+ * ```
44
+ */
45
+ export function buildODataOrderBy(sortModel, defaultOrderBy) {
46
+ if (!sortModel || sortModel.length === 0) {
47
+ return defaultOrderBy;
48
+ }
49
+ return sortModel.map((sort) => `${sort.colId} ${sort.sort}`).join(',');
50
+ }
51
+ // ============================================================================
52
+ // Helper: Build OData $filter Clause
53
+ // ============================================================================
54
+ /**
55
+ * Escapes single quotes in OData string values
56
+ * OData requires single quotes to be doubled (e.g., "O'Bryan" -> "O''Bryan")
57
+ */
58
+ function escapeODataString(value) {
59
+ return value.replace(/'/g, "''");
60
+ }
61
+ /**
62
+ * Formats a value for use in OData filter expressions
63
+ */
64
+ function formatODataValue(value) {
65
+ if (value === null || value === undefined) {
66
+ return 'null';
67
+ }
68
+ if (typeof value === 'string') {
69
+ // Dates in ISO format should not be quoted
70
+ if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
71
+ return value;
72
+ }
73
+ return `'${escapeODataString(value)}'`;
74
+ }
75
+ if (typeof value === 'boolean') {
76
+ return value ? 'true' : 'false';
77
+ }
78
+ if (value instanceof Date) {
79
+ return value.toISOString();
80
+ }
81
+ // Numbers and other primitives
82
+ return String(value);
83
+ }
84
+ /**
85
+ * Converts AG Grid text filter to OData expression
86
+ */
87
+ function convertTextFilter(field, filter) {
88
+ const type = filter.type;
89
+ const value = filter.filter;
90
+ switch (type) {
91
+ case 'equals':
92
+ return `${field} eq ${formatODataValue(value)}`;
93
+ case 'notEqual':
94
+ return `${field} ne ${formatODataValue(value)}`;
95
+ case 'contains':
96
+ return `contains(${field},${formatODataValue(value)})`;
97
+ case 'notContains':
98
+ return `not contains(${field},${formatODataValue(value)})`;
99
+ case 'startsWith':
100
+ return `startswith(${field},${formatODataValue(value)})`;
101
+ case 'endsWith':
102
+ return `endswith(${field},${formatODataValue(value)})`;
103
+ case 'blank':
104
+ case 'empty':
105
+ return `${field} eq null`;
106
+ case 'notBlank':
107
+ return `${field} ne null`;
108
+ default:
109
+ console.warn('Unknown text filter type:', type);
110
+ return '';
111
+ }
112
+ }
113
+ /**
114
+ * Converts AG Grid number filter to OData expression
115
+ */
116
+ function convertNumberFilter(field, filter) {
117
+ const type = filter.type;
118
+ const value = filter.filter;
119
+ const valueTo = filter.filterTo;
120
+ switch (type) {
121
+ case 'equals':
122
+ return `${field} eq ${value}`;
123
+ case 'notEqual':
124
+ return `${field} ne ${value}`;
125
+ case 'greaterThan':
126
+ return `${field} gt ${value}`;
127
+ case 'greaterThanOrEqual':
128
+ return `${field} ge ${value}`;
129
+ case 'lessThan':
130
+ return `${field} lt ${value}`;
131
+ case 'lessThanOrEqual':
132
+ return `${field} le ${value}`;
133
+ case 'inRange':
134
+ return `${field} ge ${value} and ${field} le ${valueTo}`;
135
+ case 'blank':
136
+ case 'empty':
137
+ return `${field} eq null`;
138
+ case 'notBlank':
139
+ return `${field} ne null`;
140
+ default:
141
+ console.warn('Unknown number filter type:', type);
142
+ return '';
143
+ }
144
+ }
145
+ /**
146
+ * Converts AG Grid date filter to OData expression
147
+ */
148
+ function convertDateFilter(field, filter) {
149
+ const type = filter.type;
150
+ let dateFrom = filter.dateFrom;
151
+ let dateTo = filter.dateTo;
152
+ // Ensure dates are in ISO format
153
+ if (dateFrom && !/^\d{4}-\d{2}-\d{2}T/.test(dateFrom)) {
154
+ dateFrom = new Date(dateFrom).toISOString();
155
+ }
156
+ if (dateTo && !/^\d{4}-\d{2}-\d{2}T/.test(dateTo)) {
157
+ dateTo = new Date(dateTo).toISOString();
158
+ }
159
+ switch (type) {
160
+ case 'equals':
161
+ return `${field} eq ${dateFrom}`;
162
+ case 'notEqual':
163
+ return `${field} ne ${dateFrom}`;
164
+ case 'greaterThan':
165
+ return `${field} gt ${dateFrom}`;
166
+ case 'greaterThanOrEqual':
167
+ return `${field} ge ${dateFrom}`;
168
+ case 'lessThan':
169
+ return `${field} lt ${dateFrom}`;
170
+ case 'lessThanOrEqual':
171
+ return `${field} le ${dateFrom}`;
172
+ case 'inRange':
173
+ return `${field} ge ${dateFrom} and ${field} le ${dateTo}`;
174
+ case 'blank':
175
+ case 'empty':
176
+ return `${field} eq null`;
177
+ case 'notBlank':
178
+ return `${field} ne null`;
179
+ default:
180
+ console.warn('Unknown date filter type:', type);
181
+ return '';
182
+ }
183
+ }
184
+ /**
185
+ * Converts AG Grid set filter (multi-select) to OData expression
186
+ */
187
+ function convertSetFilter(field, filter) {
188
+ const values = filter.values;
189
+ if (!values || values.length === 0) {
190
+ return '';
191
+ }
192
+ // Check if this is a boolean filter
193
+ const booleanValues = new Set(['Yes', 'No', 'true', 'false', true, false]);
194
+ const isBooleanFilter = values.every((v) => booleanValues.has(v));
195
+ if (isBooleanFilter) {
196
+ // Convert to boolean
197
+ const converted = values.map((v) => v === 'Yes' || v === 'true' || v === true);
198
+ // Single value - use direct equality
199
+ if (converted.length === 1) {
200
+ return `${field} eq ${converted[0]}`;
201
+ }
202
+ // Both true and false selected - no filter needed (show all)
203
+ if (converted.length === 2) {
204
+ return '';
205
+ }
206
+ }
207
+ // For multiple values, use 'or' conditions
208
+ // OData doesn't have a native 'in' operator like SQL, so we use multiple 'or' conditions
209
+ const conditions = values.map((v) => `${field} eq ${formatODataValue(v)}`);
210
+ return `(${conditions.join(' or ')})`;
211
+ }
212
+ /**
213
+ * Converts a single AG Grid filter to OData expression
214
+ */
215
+ function convertFilterToOData(field, filterValue) {
216
+ if (!filterValue || typeof filterValue !== 'object') {
217
+ return '';
218
+ }
219
+ const filter = filterValue;
220
+ const filterType = filter.filterType;
221
+ switch (filterType) {
222
+ case 'text':
223
+ return convertTextFilter(field, filter);
224
+ case 'number':
225
+ return convertNumberFilter(field, filter);
226
+ case 'date':
227
+ return convertDateFilter(field, filter);
228
+ case 'set':
229
+ return convertSetFilter(field, filter);
230
+ default:
231
+ console.warn('Unknown filter type:', filterType, 'for field:', field);
232
+ return '';
233
+ }
234
+ }
235
+ /**
236
+ * Builds the OData $filter query parameter from AG Grid filter model
237
+ *
238
+ * Converts AG Grid's filter model to OData filter syntax, supporting:
239
+ * - Text filters: eq, ne, contains, startswith, endswith
240
+ * - Number filters: eq, ne, gt, ge, lt, le, inRange
241
+ * - Date filters: eq, ne, gt, ge, lt, le, inRange
242
+ * - Set filters: multi-value OR conditions
243
+ * - Null checks: blank/notBlank
244
+ *
245
+ * @param filterModel - AG Grid filter model
246
+ * @returns OData $filter string
247
+ *
248
+ * @example
249
+ * ```typescript
250
+ * // Text filter
251
+ * buildODataFilter({ name: { filterType: 'text', type: 'contains', filter: 'John' } })
252
+ * // Returns: "contains(name,'John')"
253
+ *
254
+ * // Number range
255
+ * buildODataFilter({
256
+ * revenue: { filterType: 'number', type: 'inRange', filter: 1000, filterTo: 5000 }
257
+ * })
258
+ * // Returns: "revenue ge 1000 and revenue le 5000"
259
+ *
260
+ * // Multiple filters (AND logic)
261
+ * buildODataFilter({
262
+ * name: { filterType: 'text', type: 'contains', filter: 'Corp' },
263
+ * revenue: { filterType: 'number', type: 'greaterThan', filter: 10000 }
264
+ * })
265
+ * // Returns: "contains(name,'Corp') and revenue gt 10000"
266
+ *
267
+ * // Set filter (OR logic for multiple values)
268
+ * buildODataFilter({
269
+ * status: { filterType: 'set', values: ['Active', 'Pending'] }
270
+ * })
271
+ * // Returns: "(status eq 'Active' or status eq 'Pending')"
272
+ * ```
273
+ */
274
+ export function buildODataFilter(filterModel) {
275
+ if (!filterModel || Object.keys(filterModel).length === 0) {
276
+ return undefined;
277
+ }
278
+ const filters = [];
279
+ for (const [field, filterValue] of Object.entries(filterModel)) {
280
+ const filterExpression = convertFilterToOData(field, filterValue);
281
+ if (filterExpression) {
282
+ filters.push(filterExpression);
283
+ }
284
+ }
285
+ if (filters.length === 0) {
286
+ return undefined;
287
+ }
288
+ // Multiple filters are combined with AND
289
+ return filters.length === 1 ? filters[0] : filters.join(' and ');
290
+ }
291
+ /**
292
+ * Builds OData expand options for a single navigation property
293
+ */
294
+ function buildExpandOptions(config) {
295
+ const options = [];
296
+ if (config.select && config.select.length > 0) {
297
+ options.push(`$select=${config.select.join(',')}`);
298
+ }
299
+ if (config.filter) {
300
+ options.push(`$filter=${config.filter}`);
301
+ }
302
+ if (config.orderBy) {
303
+ options.push(`$orderby=${config.orderBy}`);
304
+ }
305
+ if (config.top !== undefined) {
306
+ options.push(`$top=${config.top}`);
307
+ }
308
+ if (config.expand && config.expand.length > 0) {
309
+ const nestedExpands = config.expand.map(buildSingleExpand).join(',');
310
+ options.push(`$expand=${nestedExpands}`);
311
+ }
312
+ return options.join(';');
313
+ }
314
+ /**
315
+ * Builds a single expand expression
316
+ */
317
+ function buildSingleExpand(config) {
318
+ const options = buildExpandOptions(config);
319
+ if (options) {
320
+ return `${config.navigationProperty}(${options})`;
321
+ }
322
+ return config.navigationProperty;
323
+ }
324
+ /**
325
+ * Builds the OData $expand query parameter for joining related tables
326
+ *
327
+ * Supports:
328
+ * - Single-valued navigation properties (many-to-one lookups)
329
+ * - Collection-valued navigation properties (one-to-many relationships)
330
+ * - Nested expands (up to recommended limit)
331
+ * - Expand with $select, $filter, $orderby, $top
332
+ *
333
+ * Limitations:
334
+ * - Max 15 $expand options recommended per query
335
+ * - $orderby and $top not supported with nested $expand on collections
336
+ * - Nested $expand not supported with N:N relationships
337
+ *
338
+ * @param expands - Array of expand configurations
339
+ * @returns OData $expand string
340
+ *
341
+ * @example Single expand with select
342
+ * ```typescript
343
+ * buildODataExpand([
344
+ * { navigationProperty: 'primarycontactid', select: ['fullname', 'emailaddress1'] }
345
+ * ])
346
+ * // Returns: "primarycontactid($select=fullname,emailaddress1)"
347
+ * ```
348
+ *
349
+ * @example Multiple expands
350
+ * ```typescript
351
+ * buildODataExpand([
352
+ * { navigationProperty: 'primarycontactid', select: ['fullname'] },
353
+ * { navigationProperty: 'createdby', select: ['fullname'] }
354
+ * ])
355
+ * // Returns: "primarycontactid($select=fullname),createdby($select=fullname)"
356
+ * ```
357
+ *
358
+ * @example Collection with filter and order
359
+ * ```typescript
360
+ * buildODataExpand([
361
+ * {
362
+ * navigationProperty: 'Account_Tasks',
363
+ * select: ['subject', 'createdon'],
364
+ * filter: "contains(subject,'Task')",
365
+ * orderBy: 'createdon desc',
366
+ * top: 10
367
+ * }
368
+ * ])
369
+ * // Returns: "Account_Tasks($select=subject,createdon;$filter=contains(subject,'Task');$orderby=createdon desc;$top=10)"
370
+ * ```
371
+ *
372
+ * @example Nested expand
373
+ * ```typescript
374
+ * buildODataExpand([
375
+ * {
376
+ * navigationProperty: 'primarycontactid',
377
+ * select: ['fullname'],
378
+ * expand: [
379
+ * { navigationProperty: 'createdby', select: ['fullname'] }
380
+ * ]
381
+ * }
382
+ * ])
383
+ * // Returns: "primarycontactid($select=fullname;$expand=createdby($select=fullname))"
384
+ * ```
385
+ */
386
+ export function buildODataExpand(expands) {
387
+ if (!expands || expands.length === 0) {
388
+ return undefined;
389
+ }
390
+ // Warning for too many expands (affects performance)
391
+ if (expands.length > 15) {
392
+ console.warn(`Query contains ${expands.length} $expand options. ` + 'Consider limiting to 15 or fewer for better performance.');
393
+ }
394
+ return expands.map(buildSingleExpand).join(',');
395
+ }
396
+ /**
397
+ * Simplified expand builder for basic use cases
398
+ * Use this when you just need to expand navigation properties with optional column selection
399
+ *
400
+ * @param navigationProperties - Array of navigation property names or objects with select
401
+ * @returns OData $expand string
402
+ *
403
+ * @example
404
+ * ```typescript
405
+ * buildSimpleExpand(['primarycontactid', 'createdby'])
406
+ * // Returns: "primarycontactid,createdby"
407
+ *
408
+ * buildSimpleExpand([
409
+ * 'primarycontactid',
410
+ * { navigationProperty: 'createdby', select: ['fullname'] }
411
+ * ])
412
+ * // Returns: "primarycontactid,createdby($select=fullname)"
413
+ * ```
414
+ */
415
+ export function buildSimpleExpand(navigationProperties) {
416
+ if (!navigationProperties || navigationProperties.length === 0) {
417
+ return undefined;
418
+ }
419
+ const configs = navigationProperties.map((prop) => {
420
+ if (typeof prop === 'string') {
421
+ return { navigationProperty: prop };
422
+ }
423
+ return prop;
424
+ });
425
+ return buildODataExpand(configs);
426
+ }
427
+ // ============================================================================
428
+ // Main Query Builder
429
+ // ============================================================================
430
+ /**
431
+ * Creates a Power Apps query handler for server-side data fetching
432
+ *
433
+ * This is the main API for querying Power Apps Web API. It accepts AG Grid requests
434
+ * and returns data in AG Grid response format, handling all OData translation automatically.
435
+ *
436
+ * @param config - Power Apps query configuration
437
+ * @returns Function that processes AG Grid requests and returns responses
438
+ *
439
+ * @example Basic usage
440
+ * ```typescript
441
+ * const accountsQuery = createPowerAppsQuery({
442
+ * baseUrl: 'https://org.crm.dynamics.com/api/data/v9.2',
443
+ * entitySet: 'accounts',
444
+ * headers: {
445
+ * 'Authorization': 'Bearer YOUR_TOKEN',
446
+ * 'Prefer': 'odata.include-annotations="*"'
447
+ * },
448
+ * defaultSelect: ['accountid', 'name', 'revenue']
449
+ * })
450
+ *
451
+ * const response = await accountsQuery(agGridRequest)
452
+ * // Returns: { rows: [...], lastRow: 1000 }
453
+ * ```
454
+ *
455
+ * @example With related data
456
+ * ```typescript
457
+ * const contactsQuery = createPowerAppsQuery({
458
+ * baseUrl: 'https://org.crm.dynamics.com/api/data/v9.2',
459
+ * entitySet: 'contacts',
460
+ * expand: [
461
+ * { navigationProperty: 'parentcustomerid_account', select: ['name'] }
462
+ * ]
463
+ * })
464
+ * ```
465
+ *
466
+ * @example With transformation
467
+ * ```typescript
468
+ * const query = createPowerAppsQuery({
469
+ * baseUrl: 'https://org.crm.dynamics.com/api/data/v9.2',
470
+ * entitySet: 'accounts',
471
+ * transformResponse: (data) => data.map(item => ({
472
+ * id: item.accountid,
473
+ * name: item.name
474
+ * }))
475
+ * })
476
+ * ```
477
+ */
478
+ export function createPowerAppsQuery(config) {
479
+ return async (request) => {
480
+ const { startRow = 0, endRow = 100, filterModel, sortModel } = request;
481
+ // Build query parameters
482
+ const params = {
483
+ $count: true, // Always request count for total rows
484
+ };
485
+ // Add select
486
+ if (config.defaultSelect) {
487
+ params.$select = buildODataSelect(config.defaultSelect);
488
+ }
489
+ // Add filter
490
+ if (filterModel && Object.keys(filterModel).length > 0) {
491
+ params.$filter = buildODataFilter(filterModel);
492
+ }
493
+ // Add orderby
494
+ if (sortModel && sortModel.length > 0) {
495
+ params.$orderby = buildODataOrderBy(sortModel, config.defaultOrderBy);
496
+ }
497
+ else if (config.defaultOrderBy) {
498
+ params.$orderby = config.defaultOrderBy;
499
+ }
500
+ // Add pagination
501
+ const pageSize = endRow - startRow;
502
+ params.$top = pageSize;
503
+ // Add expand
504
+ if (config.expand) {
505
+ const expandConfigs = config.expand.map((exp) => typeof exp === 'string' ? { navigationProperty: exp } : exp);
506
+ params.$expand = buildODataExpand(expandConfigs);
507
+ }
508
+ // Build URL
509
+ const url = buildQueryUrl(config.baseUrl, config.entitySet, params);
510
+ // Check URL length
511
+ if (url.length > (config.maxUrlLength || 32768)) {
512
+ console.warn(`Query URL length (${url.length} chars) exceeds recommended limit. ` +
513
+ 'Consider using $batch operations for complex queries. ' +
514
+ 'See: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/execute-batch-operations-using-web-api');
515
+ }
516
+ // Build headers
517
+ const headers = {
518
+ Accept: 'application/json',
519
+ 'OData-MaxVersion': '4.0',
520
+ 'OData-Version': '4.0',
521
+ Prefer: `odata.maxpagesize=${pageSize}`,
522
+ 'If-None-Match': 'null', // Override browser caching for fresh data
523
+ ...config.headers,
524
+ };
525
+ try {
526
+ // Execute request
527
+ const response = await fetch(url, {
528
+ method: 'GET',
529
+ headers,
530
+ });
531
+ if (!response.ok) {
532
+ throw await parseErrorResponse(response);
533
+ }
534
+ const data = await response.json();
535
+ // Extract rows
536
+ let rows = data.value || [];
537
+ // Apply transformation if provided
538
+ if (config.transformResponse) {
539
+ rows = config.transformResponse(rows);
540
+ }
541
+ // Calculate total count
542
+ const totalCount = data['@odata.count'];
543
+ const lastRow = totalCount !== undefined ? totalCount : undefined;
544
+ return {
545
+ rows,
546
+ lastRow,
547
+ };
548
+ }
549
+ catch (error) {
550
+ console.error('Power Apps query failed:', error);
551
+ throw error;
552
+ }
553
+ };
554
+ }
555
+ /**
556
+ * Builds the complete query URL with parameters
557
+ */
558
+ function buildQueryUrl(baseUrl, entitySet, params) {
559
+ // Remove trailing slash from baseUrl
560
+ const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
561
+ // Build query string
562
+ const queryParts = [];
563
+ if (params.$select) {
564
+ queryParts.push(`$select=${encodeURIComponent(params.$select)}`);
565
+ }
566
+ if (params.$filter) {
567
+ queryParts.push(`$filter=${encodeURIComponent(params.$filter)}`);
568
+ }
569
+ if (params.$orderby) {
570
+ queryParts.push(`$orderby=${encodeURIComponent(params.$orderby)}`);
571
+ }
572
+ if (params.$expand) {
573
+ queryParts.push(`$expand=${encodeURIComponent(params.$expand)}`);
574
+ }
575
+ if (params.$top !== undefined) {
576
+ queryParts.push(`$top=${params.$top}`);
577
+ }
578
+ if (params.$skip !== undefined) {
579
+ queryParts.push(`$skip=${params.$skip}`);
580
+ }
581
+ if (params.$count) {
582
+ queryParts.push('$count=true');
583
+ }
584
+ const queryString = queryParts.join('&');
585
+ return `${base}/${entitySet}${queryString ? '?' + queryString : ''}`;
586
+ }
587
+ /**
588
+ * Parses error response from Power Apps API
589
+ */
590
+ async function parseErrorResponse(response) {
591
+ try {
592
+ const errorData = await response.json();
593
+ const message = errorData.error?.message || 'Unknown error';
594
+ const code = errorData.error?.code || 'UNKNOWN';
595
+ return new Error(`Power Apps API Error [${code}]: ${message}`);
596
+ }
597
+ catch {
598
+ return new Error(`Power Apps API Error: ${response.status} ${response.statusText}`);
599
+ }
600
+ }
601
+ /**
602
+ * Fetches the next page using the @odata.nextLink URL
603
+ *
604
+ * Use this to implement manual pagination when you need to fetch additional pages
605
+ * beyond what AG Grid requests automatically.
606
+ *
607
+ * @param nextLink - The @odata.nextLink URL from a previous response
608
+ * @param headers - Headers to include in the request (auth, etc.)
609
+ * @returns The next page of data
610
+ *
611
+ * @example
612
+ * ```typescript
613
+ * const firstPage = await accountsQuery(agGridRequest)
614
+ *
615
+ * // If there's more data, fetch the next page
616
+ * if (firstPage.nextLink) {
617
+ * const secondPage = await fetchNextPage(firstPage.nextLink, {
618
+ * 'Authorization': 'Bearer YOUR_TOKEN'
619
+ * })
620
+ * }
621
+ * ```
622
+ */
623
+ export async function fetchNextPage(nextLink, headers) {
624
+ const defaultHeaders = {
625
+ Accept: 'application/json',
626
+ 'OData-MaxVersion': '4.0',
627
+ 'OData-Version': '4.0',
628
+ ...headers,
629
+ };
630
+ const response = await fetch(nextLink, {
631
+ method: 'GET',
632
+ headers: defaultHeaders,
633
+ });
634
+ if (!response.ok) {
635
+ throw await parseErrorResponse(response);
636
+ }
637
+ return await response.json();
638
+ }
639
+ /**
640
+ * Enhanced query function that returns additional metadata
641
+ *
642
+ * Use this when you need access to pagination links and other OData metadata
643
+ *
644
+ * @param config - Power Apps query configuration
645
+ * @returns Function that processes requests and returns enhanced responses
646
+ *
647
+ * @example
648
+ * ```typescript
649
+ * const query = createPowerAppsQueryWithMetadata({
650
+ * baseUrl: 'https://org.crm.dynamics.com/api/data/v9.2',
651
+ * entitySet: 'accounts'
652
+ * })
653
+ *
654
+ * const result = await query(agGridRequest)
655
+ * console.log(`Got ${result.rows.length} rows, total: ${result.totalCount}`)
656
+ *
657
+ * if (result.nextLink) {
658
+ * console.log('More data available at:', result.nextLink)
659
+ * }
660
+ * ```
661
+ */
662
+ export function createPowerAppsQueryWithMetadata(config) {
663
+ return async (request) => {
664
+ const { startRow = 0, endRow = 100, filterModel, sortModel } = request;
665
+ // Build query parameters
666
+ const params = {
667
+ $count: true,
668
+ };
669
+ if (config.defaultSelect) {
670
+ params.$select = buildODataSelect(config.defaultSelect);
671
+ }
672
+ if (filterModel && Object.keys(filterModel).length > 0) {
673
+ params.$filter = buildODataFilter(filterModel);
674
+ }
675
+ if (sortModel && sortModel.length > 0) {
676
+ params.$orderby = buildODataOrderBy(sortModel, config.defaultOrderBy);
677
+ }
678
+ else if (config.defaultOrderBy) {
679
+ params.$orderby = config.defaultOrderBy;
680
+ }
681
+ const pageSize = endRow - startRow;
682
+ params.$top = pageSize;
683
+ if (config.expand) {
684
+ const expandConfigs = config.expand.map((exp) => typeof exp === 'string' ? { navigationProperty: exp } : exp);
685
+ params.$expand = buildODataExpand(expandConfigs);
686
+ }
687
+ const url = buildQueryUrl(config.baseUrl, config.entitySet, params);
688
+ if (url.length > (config.maxUrlLength || 32768)) {
689
+ console.warn(`Query URL length (${url.length} chars) exceeds recommended limit. ` +
690
+ 'Consider using $batch operations for complex queries.');
691
+ }
692
+ const headers = {
693
+ Accept: 'application/json',
694
+ 'OData-MaxVersion': '4.0',
695
+ 'OData-Version': '4.0',
696
+ Prefer: `odata.maxpagesize=${pageSize}`,
697
+ 'If-None-Match': 'null',
698
+ ...config.headers,
699
+ };
700
+ try {
701
+ const response = await fetch(url, {
702
+ method: 'GET',
703
+ headers,
704
+ });
705
+ if (!response.ok) {
706
+ throw await parseErrorResponse(response);
707
+ }
708
+ const data = await response.json();
709
+ let rows = data.value || [];
710
+ if (config.transformResponse) {
711
+ rows = config.transformResponse(rows);
712
+ }
713
+ return {
714
+ rows,
715
+ lastRow: data['@odata.count'],
716
+ nextLink: data['@odata.nextLink'],
717
+ totalCount: data['@odata.count'],
718
+ odataContext: data['@odata.context'],
719
+ };
720
+ }
721
+ catch (error) {
722
+ console.error('Power Apps query failed:', error);
723
+ throw error;
724
+ }
725
+ };
726
+ }
727
+ /**
728
+ * Utility to fetch all pages of data (use with caution for large datasets)
729
+ *
730
+ * This will continue fetching pages until no more data is available.
731
+ * Be careful with large datasets as this can consume significant memory and time.
732
+ *
733
+ * @param queryFn - The query function created by createPowerAppsQueryWithMetadata
734
+ * @param request - Initial AG Grid request
735
+ * @param maxPages - Optional limit on number of pages to fetch (default: unlimited)
736
+ * @returns All rows across all pages
737
+ *
738
+ * @example
739
+ * ```typescript
740
+ * const query = createPowerAppsQueryWithMetadata({
741
+ * baseUrl: 'https://org.crm.dynamics.com/api/data/v9.2',
742
+ * entitySet: 'accounts'
743
+ * })
744
+ *
745
+ * // Fetch up to 10 pages (1000 records if pageSize is 100)
746
+ * const allData = await fetchAllPages(query, agGridRequest, 10)
747
+ * console.log(`Fetched ${allData.length} total records`)
748
+ * ```
749
+ */
750
+ export async function fetchAllPages(queryFn, request, maxPages) {
751
+ const allRows = [];
752
+ let pageCount = 0;
753
+ const currentResult = await queryFn(request);
754
+ allRows.push(...currentResult.rows);
755
+ pageCount++;
756
+ // Note: Full pagination would require parsing nextLink and creating new requests
757
+ // For now, this serves as a placeholder for the pattern
758
+ if (currentResult.nextLink && (!maxPages || pageCount < maxPages)) {
759
+ console.warn('fetchAllPages requires manual nextLink handling. ' + 'Use fetchNextPage() directly for better control.');
760
+ }
761
+ return allRows;
762
+ }
763
+ // ============================================================================
764
+ // Export Summary
765
+ // ============================================================================
766
+ /**
767
+ * Main exports:
768
+ *
769
+ * Query Builders:
770
+ * - createPowerAppsQuery() - Main query function (returns AG Grid format)
771
+ * - createPowerAppsQueryWithMetadata() - Enhanced query with pagination metadata
772
+ *
773
+ * Helper Functions:
774
+ * - buildODataSelect() - Build $select clause
775
+ * - buildODataOrderBy() - Build $orderby clause
776
+ * - buildODataFilter() - Build $filter clause (from AG Grid filters)
777
+ * - buildODataExpand() - Build $expand clause
778
+ * - buildSimpleExpand() - Simplified expand builder
779
+ * - fetchNextPage() - Fetch next page using @odata.nextLink
780
+ * - fetchAllPages() - Fetch all pages (use with caution)
781
+ *
782
+ * Types:
783
+ * - PowerAppsQueryConfig - Configuration for query builder
784
+ * - PowerAppsODataResponse - OData response format
785
+ * - PowerAppsError - Error response format
786
+ * - PowerAppsQueryResult - Enhanced response with metadata
787
+ * - ExpandConfig - Configuration for expanding related entities
788
+ * - ODataQueryParams - OData query parameters
789
+ */