@mkja/o-data 0.0.1

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,533 @@
1
+ // ============================================================================
2
+ // Query String Serialization
3
+ // ============================================================================
4
+ import { createFilterHelpers, serializeFilter } from './filter.js';
5
+ import { buildQueryableEntity, findEntitySetsForEntityType } from './runtime.js';
6
+ // ============================================================================
7
+ // URL Path Normalization
8
+ // ============================================================================
9
+ /**
10
+ * Normalizes URL path segments by:
11
+ * - Removing trailing slashes from baseUrl (preserving protocol ://)
12
+ * - Removing leading slashes from path segments
13
+ * - Joining with single /
14
+ * - Normalizing multiple consecutive slashes (except protocol)
15
+ */
16
+ export function normalizePath(baseUrl, ...paths) {
17
+ // Remove trailing slashes from baseUrl, but preserve protocol ://
18
+ let normalized = baseUrl.replace(/([^:]\/)\/+$/, '$1');
19
+ // Process each path segment
20
+ for (const path of paths) {
21
+ if (!path)
22
+ continue;
23
+ // Remove leading slashes from path segment
24
+ const cleanPath = path.replace(/^\/+/, '');
25
+ if (!cleanPath)
26
+ continue;
27
+ // Ensure single / between baseUrl and path
28
+ if (normalized && !normalized.endsWith('/')) {
29
+ normalized += '/';
30
+ }
31
+ normalized += cleanPath;
32
+ }
33
+ // Normalize multiple consecutive slashes (except protocol ://)
34
+ normalized = normalized.replace(/([^:]\/)\/+/g, '$1');
35
+ return normalized;
36
+ }
37
+ // ============================================================================
38
+ // Serialize Expand Options
39
+ // ============================================================================
40
+ function serializeExpandOptions(navQuery, navEntityDef, schema) {
41
+ const nestedParams = [];
42
+ if (navQuery.select) {
43
+ nestedParams.push(`$select=${navQuery.select.join(',')}`);
44
+ }
45
+ if (navQuery.expand) {
46
+ const nestedExpandParams = [];
47
+ for (const [nestedNavKey, nestedNavQuery] of Object.entries(navQuery.expand)) {
48
+ if (nestedNavQuery) {
49
+ let nestedNavEntityDef;
50
+ if (navEntityDef && nestedNavKey in navEntityDef.navigations) {
51
+ const nav = navEntityDef.navigations[nestedNavKey];
52
+ if (nav) {
53
+ const targetEntitysetKey = nav.targetEntitysetKey;
54
+ nestedNavEntityDef = buildQueryableEntity(schema, targetEntitysetKey);
55
+ }
56
+ }
57
+ const nestedExpandOptionsStr = serializeExpandOptions(nestedNavQuery, nestedNavEntityDef, schema);
58
+ nestedExpandParams.push(`${nestedNavKey}${nestedExpandOptionsStr}`);
59
+ }
60
+ }
61
+ if (nestedExpandParams.length > 0) {
62
+ nestedParams.push(`$expand=${nestedExpandParams.join(',')}`);
63
+ }
64
+ }
65
+ const collectionQuery = navQuery;
66
+ if ('top' in collectionQuery && collectionQuery.top !== undefined) {
67
+ nestedParams.push(`$top=${collectionQuery.top}`);
68
+ }
69
+ if ('orderby' in collectionQuery && collectionQuery.orderby) {
70
+ const orderby = collectionQuery.orderby;
71
+ // orderby is readonly [keyof Properties, 'asc' | 'desc']
72
+ const [prop, dir] = orderby;
73
+ const orderbyValue = `${String(prop)} ${dir}`;
74
+ nestedParams.push(`$orderby=${orderbyValue}`);
75
+ }
76
+ if ('filter' in collectionQuery && collectionQuery.filter) {
77
+ if (typeof collectionQuery.filter === 'function') {
78
+ if (!navEntityDef) {
79
+ throw new Error('Entity definition required for filter builder in expand');
80
+ }
81
+ const helpers = createFilterHelpers(navEntityDef, schema);
82
+ const builder = collectionQuery.filter(helpers);
83
+ const state = builder.state;
84
+ const filterString = serializeFilter(state, 0, undefined, navEntityDef, schema);
85
+ nestedParams.push(`$filter=${encodeURIComponent(filterString)}`);
86
+ }
87
+ }
88
+ if ('count' in collectionQuery && collectionQuery.count) {
89
+ nestedParams.push('$count=true');
90
+ }
91
+ return nestedParams.length > 0 ? `(${nestedParams.join(';')})` : '';
92
+ }
93
+ // ============================================================================
94
+ // Build Query String
95
+ // ============================================================================
96
+ export function buildQueryString(query, entityDef, schema) {
97
+ const params = [];
98
+ // $select
99
+ if (query.select) {
100
+ params.push(`$select=${query.select.join(',')}`);
101
+ }
102
+ // $expand
103
+ if (query.expand) {
104
+ const expandParams = [];
105
+ for (const [navKey, navQuery] of Object.entries(query.expand)) {
106
+ if (navQuery) {
107
+ let navEntityDef;
108
+ if (navKey in entityDef.navigations) {
109
+ const nav = entityDef.navigations[navKey];
110
+ if (nav) {
111
+ const targetEntitysetKey = nav.targetEntitysetKey;
112
+ navEntityDef = buildQueryableEntity(schema, targetEntitysetKey);
113
+ }
114
+ }
115
+ const expandOptionsStr = serializeExpandOptions(navQuery, navEntityDef, schema);
116
+ expandParams.push(`${navKey}${expandOptionsStr}`);
117
+ }
118
+ }
119
+ if (expandParams.length > 0) {
120
+ params.push(`$expand=${expandParams.join(',')}`);
121
+ }
122
+ }
123
+ // Check if this is a collection query (has collection-specific params or filter/orderby)
124
+ // SingleQueryObject doesn't have filter/orderby, so if they exist, it's a CollectionQueryObject
125
+ const isCollectionQuery = 'top' in query ||
126
+ 'skip' in query ||
127
+ 'count' in query ||
128
+ 'filter' in query ||
129
+ 'orderby' in query;
130
+ if (isCollectionQuery) {
131
+ const collectionQuery = query;
132
+ if ('top' in collectionQuery && collectionQuery.top !== undefined) {
133
+ params.push(`$top=${collectionQuery.top}`);
134
+ }
135
+ if ('skip' in collectionQuery && collectionQuery.skip !== undefined) {
136
+ params.push(`$skip=${collectionQuery.skip}`);
137
+ }
138
+ if ('orderby' in collectionQuery && collectionQuery.orderby) {
139
+ const orderby = collectionQuery.orderby;
140
+ // orderby is readonly [keyof Properties, 'asc' | 'desc']
141
+ const [prop, dir] = orderby;
142
+ const orderbyValue = `${String(prop)} ${dir}`;
143
+ params.push(`$orderby=${orderbyValue}`);
144
+ }
145
+ if ('filter' in collectionQuery && collectionQuery.filter) {
146
+ if (typeof collectionQuery.filter === 'function') {
147
+ const helpers = createFilterHelpers(entityDef, schema);
148
+ const builder = collectionQuery.filter(helpers);
149
+ const state = builder.state;
150
+ const filterString = serializeFilter(state, 0, undefined, entityDef, schema);
151
+ params.push(`$filter=${encodeURIComponent(filterString)}`);
152
+ }
153
+ }
154
+ if ('count' in collectionQuery && collectionQuery.count) {
155
+ params.push('$count=true');
156
+ }
157
+ }
158
+ // Note: Single entity queries (SingleQueryObject) only have select and expand,
159
+ // so filter and orderby are not serialized (they don't exist on the type)
160
+ return params.length > 0 ? `?${params.join('&')}` : '';
161
+ }
162
+ // ============================================================================
163
+ // Create/Update Object Transformation
164
+ // ============================================================================
165
+ /**
166
+ * Transform create object to handle navigation properties with @odata.bind format
167
+ */
168
+ export function transformCreateObjectForBind(createObject, entityDef, schema) {
169
+ if (!entityDef || !entityDef.navigations)
170
+ return createObject;
171
+ const transformed = {};
172
+ for (const [key, value] of Object.entries(createObject)) {
173
+ // Check for batch reference first
174
+ if (typeof value === 'string' && value.startsWith('$')) {
175
+ transformed[`${key}@odata.bind`] = value;
176
+ continue;
177
+ }
178
+ const navDef = entityDef.navigations[key];
179
+ if (navDef && navDef.targetEntitysetKey) {
180
+ const isCollection = navDef.collection === true;
181
+ if (!isCollection) {
182
+ // Single-valued navigation
183
+ if (Array.isArray(value) &&
184
+ value.length === 2 &&
185
+ typeof value[0] === 'string' &&
186
+ (typeof value[1] === 'string' || typeof value[1] === 'number')) {
187
+ // Explicit entityset format: [entityset, id]
188
+ const [set, id] = value;
189
+ transformed[`${key}@odata.bind`] = `/${set}(${id})`;
190
+ }
191
+ else if (typeof value === 'string' || typeof value === 'number') {
192
+ // Plain ID - resolve entityset from navigation
193
+ const target = Array.isArray(navDef.targetEntitysetKey)
194
+ ? navDef.targetEntitysetKey[0]
195
+ : navDef.targetEntitysetKey;
196
+ transformed[`${key}@odata.bind`] = `/${target}(${value})`;
197
+ }
198
+ else if (typeof value === 'object' && value !== null) {
199
+ // Deep insert - recursive transformation
200
+ const targetEntitysetKey = Array.isArray(navDef.targetEntitysetKey)
201
+ ? navDef.targetEntitysetKey[0]
202
+ : navDef.targetEntitysetKey;
203
+ if (targetEntitysetKey != null) {
204
+ const targetEntity = buildQueryableEntity(schema, targetEntitysetKey);
205
+ transformed[key] = transformCreateObjectForBind(value, targetEntity, schema);
206
+ }
207
+ else {
208
+ transformed[key] = value;
209
+ }
210
+ }
211
+ else {
212
+ transformed[key] = value;
213
+ }
214
+ }
215
+ else {
216
+ // Collection navigation
217
+ if (Array.isArray(value)) {
218
+ if (value.length > 0 && (typeof value[0] === 'string' || typeof value[0] === 'number')) {
219
+ // Array of string/number IDs (or batch references)
220
+ const target = Array.isArray(navDef.targetEntitysetKey)
221
+ ? navDef.targetEntitysetKey[0]
222
+ : navDef.targetEntitysetKey;
223
+ transformed[`${key}@odata.bind`] = value.map((v) => typeof v === 'string' && v.startsWith('$') ? v : `/${target}(${v})`);
224
+ }
225
+ else if (value.length > 0 && Array.isArray(value[0])) {
226
+ // Array of [entityset, id] tuples
227
+ transformed[`${key}@odata.bind`] = value.map(([set, id]) => `/${set}(${id})`);
228
+ }
229
+ else {
230
+ // Array of objects - deep insert (recursive)
231
+ const targetEntitysetKey = Array.isArray(navDef.targetEntitysetKey)
232
+ ? navDef.targetEntitysetKey[0]
233
+ : navDef.targetEntitysetKey;
234
+ if (targetEntitysetKey != null) {
235
+ const targetEntity = buildQueryableEntity(schema, targetEntitysetKey);
236
+ transformed[key] = value.map((item) => typeof item === 'object' && item !== null
237
+ ? transformCreateObjectForBind(item, targetEntity, schema)
238
+ : item);
239
+ }
240
+ else {
241
+ transformed[key] = value;
242
+ }
243
+ }
244
+ }
245
+ else {
246
+ transformed[key] = value;
247
+ }
248
+ }
249
+ }
250
+ else {
251
+ transformed[key] = value;
252
+ }
253
+ }
254
+ return transformed;
255
+ }
256
+ /**
257
+ * Transform update object to handle navigation properties with @odata.bind format
258
+ */
259
+ export function transformUpdateObjectForBind(updateObject, entityDef, schema) {
260
+ if (!entityDef || !entityDef.navigations)
261
+ return updateObject;
262
+ const transformed = {};
263
+ for (const [key, value] of Object.entries(updateObject)) {
264
+ // Check for batch reference first
265
+ if (typeof value === 'string' && value.startsWith('$')) {
266
+ transformed[`${key}@odata.bind`] = value;
267
+ continue;
268
+ }
269
+ const navDef = entityDef.navigations[key];
270
+ if (navDef && navDef.targetEntitysetKey) {
271
+ if (value === null) {
272
+ // Set navigation to null
273
+ transformed[key] = null;
274
+ }
275
+ else if (Array.isArray(value) && !navDef.collection && value.length === 2) {
276
+ // Single-valued navigation with explicit entityset: [entityset, id]
277
+ const [set, id] = value;
278
+ transformed[`${key}@odata.bind`] = `/${set}(${id})`;
279
+ }
280
+ else if ((typeof value === 'string' || typeof value === 'number') && !navDef.collection) {
281
+ // Single-valued navigation with plain ID
282
+ const target = Array.isArray(navDef.targetEntitysetKey)
283
+ ? navDef.targetEntitysetKey[0]
284
+ : navDef.targetEntitysetKey;
285
+ transformed[`${key}@odata.bind`] = `/${target}(${value})`;
286
+ }
287
+ else if (typeof value === 'object' && value !== null) {
288
+ // Check if it's a collection operation spec
289
+ const spec = value;
290
+ if (spec.replace || spec.add || spec.remove) {
291
+ // Collection operation
292
+ const transformedSpec = {};
293
+ const targetEntitysetKey = Array.isArray(navDef.targetEntitysetKey)
294
+ ? navDef.targetEntitysetKey[0]
295
+ : navDef.targetEntitysetKey;
296
+ const formatRef = (v) => {
297
+ // Check for batch reference
298
+ if (typeof v === 'string' && v.startsWith('$'))
299
+ return v;
300
+ // Explicit entityset format
301
+ if (Array.isArray(v))
302
+ return `/${v[0]}(${v[1]})`;
303
+ // Use resolved entityset
304
+ return `/${targetEntitysetKey}(${v})`;
305
+ };
306
+ if (spec.replace && Array.isArray(spec.replace)) {
307
+ transformedSpec.replace = spec.replace.map(formatRef);
308
+ }
309
+ if (spec.add && Array.isArray(spec.add)) {
310
+ transformedSpec.add = spec.add.map(formatRef);
311
+ }
312
+ if (spec.remove && Array.isArray(spec.remove)) {
313
+ transformedSpec.remove = spec.remove.map(formatRef);
314
+ }
315
+ transformed[key] = transformedSpec;
316
+ }
317
+ else {
318
+ // Regular object - pass through (not a navigation operation)
319
+ transformed[key] = value;
320
+ }
321
+ }
322
+ else {
323
+ transformed[key] = value;
324
+ }
325
+ }
326
+ else {
327
+ transformed[key] = value;
328
+ }
329
+ }
330
+ return transformed;
331
+ }
332
+ // ============================================================================
333
+ // Build Create/Update Requests
334
+ // ============================================================================
335
+ /**
336
+ * Build HTTP Request for create operation
337
+ */
338
+ export function buildCreateRequest(path, createObject, options, baseUrl, entityDef, schema) {
339
+ let url = normalizePath(baseUrl, path);
340
+ const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
341
+ const select = options?.select;
342
+ const preferParts = [];
343
+ if (options?.prefer?.return_representation === true ||
344
+ (select && Array.isArray(select) && select.length > 0)) {
345
+ preferParts.push('return=representation');
346
+ }
347
+ if (preferParts.length > 0) {
348
+ headers.set('Prefer', preferParts.join(','));
349
+ }
350
+ if (options?.headers) {
351
+ for (const [key, value] of Object.entries(options.headers)) {
352
+ headers.set(key, value);
353
+ }
354
+ }
355
+ if (select && Array.isArray(select) && select.length > 0) {
356
+ url += `?$select=${select.join(',')}`;
357
+ }
358
+ const transformedObject = transformCreateObjectForBind(createObject, entityDef, schema);
359
+ return new Request(url, { method: 'POST', headers, body: JSON.stringify(transformedObject) });
360
+ }
361
+ /**
362
+ * Build HTTP Request for update operation
363
+ */
364
+ export function buildUpdateRequest(path, updateObject, options, baseUrl, entityDef, schema) {
365
+ let url = normalizePath(baseUrl, path);
366
+ const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
367
+ const select = options?.select;
368
+ const preferParts = [];
369
+ if (options?.prefer?.return_representation === true ||
370
+ (select && Array.isArray(select) && select.length > 0)) {
371
+ preferParts.push('return=representation');
372
+ }
373
+ if (preferParts.length > 0) {
374
+ headers.set('Prefer', preferParts.join(','));
375
+ }
376
+ if (options?.headers) {
377
+ for (const [key, value] of Object.entries(options.headers)) {
378
+ headers.set(key, value);
379
+ }
380
+ }
381
+ if (select && Array.isArray(select) && select.length > 0) {
382
+ url += `?$select=${select.join(',')}`;
383
+ }
384
+ const transformedObject = transformUpdateObjectForBind(updateObject, entityDef, schema);
385
+ return new Request(url, { method: 'PATCH', headers, body: JSON.stringify(transformedObject) });
386
+ }
387
+ // ============================================================================
388
+ // Action/Function Request Serialization
389
+ // ============================================================================
390
+ /**
391
+ * Transform action/function parameters to handle navigation (entity type) parameters.
392
+ * Converts string/number IDs to @odata.bind format and handles deep inserts.
393
+ */
394
+ export function transformActionParameters(parameters, parameterDefs, schema) {
395
+ const transformed = {};
396
+ for (const [key, value] of Object.entries(parameters)) {
397
+ const paramDef = parameterDefs[key];
398
+ // Check if this parameter is a navigation type (entity type parameter)
399
+ if (paramDef && typeof paramDef === 'object' && 'type' in paramDef && paramDef.type === 'navigation') {
400
+ const navDef = paramDef;
401
+ const targetEntityType = navDef.target;
402
+ const isCollection = navDef.collection === true;
403
+ // Resolve entityset(s) for this entity type
404
+ const entitysetKey = findEntitySetsForEntityType(schema, targetEntityType);
405
+ if (!entitysetKey) {
406
+ // No entityset found - pass through as-is (shouldn't happen in valid schemas)
407
+ transformed[key] = value;
408
+ continue;
409
+ }
410
+ // Resolve target entityset (use first if multiple)
411
+ const targetEntitysetKey = Array.isArray(entitysetKey) ? entitysetKey[0] : entitysetKey;
412
+ if (!isCollection) {
413
+ // Single-valued navigation parameter
414
+ if (typeof value === 'string' && value.startsWith('$')) {
415
+ // Batch reference
416
+ transformed[`${key}@odata.bind`] = value;
417
+ }
418
+ else if (Array.isArray(value) &&
419
+ value.length === 2 &&
420
+ typeof value[0] === 'string' &&
421
+ (typeof value[1] === 'string' || typeof value[1] === 'number')) {
422
+ // Explicit entityset format: [entityset, id]
423
+ const [set, id] = value;
424
+ transformed[`${key}@odata.bind`] = `/${set}(${id})`;
425
+ }
426
+ else if (typeof value === 'string' || typeof value === 'number') {
427
+ // Plain ID - resolve entityset from parameter definition
428
+ transformed[`${key}@odata.bind`] = `/${targetEntitysetKey}(${value})`;
429
+ }
430
+ else if (typeof value === 'object' && value !== null) {
431
+ // Deep insert - recursive transformation
432
+ if (targetEntitysetKey != null) {
433
+ const targetEntity = buildQueryableEntity(schema, targetEntitysetKey);
434
+ transformed[key] = transformCreateObjectForBind(value, targetEntity, schema);
435
+ }
436
+ else {
437
+ transformed[key] = value;
438
+ }
439
+ }
440
+ else {
441
+ transformed[key] = value;
442
+ }
443
+ }
444
+ else {
445
+ // Collection navigation parameter
446
+ if (Array.isArray(value)) {
447
+ if (value.length > 0 && (typeof value[0] === 'string' || typeof value[0] === 'number')) {
448
+ // Array of string/number IDs (or batch references)
449
+ transformed[`${key}@odata.bind`] = value.map((v) => typeof v === 'string' && v.startsWith('$') ? v : `/${targetEntitysetKey}(${v})`);
450
+ }
451
+ else if (value.length > 0 && Array.isArray(value[0])) {
452
+ // Array of [entityset, id] tuples
453
+ transformed[`${key}@odata.bind`] = value.map(([set, id]) => `/${set}(${id})`);
454
+ }
455
+ else {
456
+ // Array of objects - deep insert (recursive)
457
+ if (targetEntitysetKey != null) {
458
+ const targetEntity = buildQueryableEntity(schema, targetEntitysetKey);
459
+ transformed[key] = value.map((item) => typeof item === 'object' && item !== null
460
+ ? transformCreateObjectForBind(item, targetEntity, schema)
461
+ : item);
462
+ }
463
+ else {
464
+ transformed[key] = value;
465
+ }
466
+ }
467
+ }
468
+ else {
469
+ transformed[key] = value;
470
+ }
471
+ }
472
+ }
473
+ else {
474
+ // Not a navigation parameter - pass through as-is
475
+ transformed[key] = value;
476
+ }
477
+ }
478
+ return transformed;
479
+ }
480
+ /**
481
+ * Build a POST request for an OData action.
482
+ */
483
+ export function buildActionRequest(path, namespace, actionName, parameters, parameterDefs, schema, baseUrl = '', useFQN = true) {
484
+ const fullActionName = useFQN ? `${namespace}.${actionName}` : actionName;
485
+ const url = normalizePath(baseUrl, path, fullActionName);
486
+ const headers = new Headers({
487
+ 'Content-Type': 'application/json',
488
+ Accept: 'application/json',
489
+ });
490
+ // Transform parameters - handle entity parameters for deep inserts/binds
491
+ const transformedParams = transformActionParameters(parameters, parameterDefs, schema);
492
+ return new Request(url, {
493
+ method: 'POST',
494
+ headers,
495
+ body: JSON.stringify(transformedParams),
496
+ });
497
+ }
498
+ /**
499
+ * Build a GET request for an OData function.
500
+ */
501
+ export function buildFunctionRequest(path, namespace, functionName, parameters, baseUrl = '', useFQN = true) {
502
+ const fullFuncName = useFQN ? `${namespace}.${functionName}` : functionName;
503
+ const paramKeys = Object.keys(parameters);
504
+ let urlParamsStr = '';
505
+ const queryParams = [];
506
+ if (paramKeys.length > 0) {
507
+ urlParamsStr = '(' + paramKeys.map((k) => `${k}=@${k}`).join(',') + ')';
508
+ for (const [key, value] of Object.entries(parameters)) {
509
+ let serializedValue;
510
+ if (typeof value === 'string') {
511
+ serializedValue = `'${value}'`;
512
+ }
513
+ else if (value instanceof Date) {
514
+ serializedValue = value.toISOString();
515
+ }
516
+ else if (typeof value === 'object' && value !== null) {
517
+ serializedValue = JSON.stringify(value);
518
+ }
519
+ else {
520
+ serializedValue = String(value);
521
+ }
522
+ queryParams.push(`@${key}=${encodeURIComponent(serializedValue)}`);
523
+ }
524
+ }
525
+ let url = normalizePath(baseUrl, path, fullFuncName + urlParamsStr);
526
+ if (queryParams.length > 0) {
527
+ url += '?' + queryParams.join('&');
528
+ }
529
+ return new Request(url, {
530
+ method: 'GET',
531
+ headers: new Headers({ Accept: 'application/json' }),
532
+ });
533
+ }