fms-odata-js 0.3.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.
package/src/query.ts ADDED
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Fluent OData query builder.
3
+ *
4
+ * `Query` accumulates `$select`, `$filter`, `$expand`, `$orderby`, `$top`,
5
+ * `$skip`, `$count`, and `$search` options and serializes them into an absolute
6
+ * URL via `toURL()`. Actual HTTP execution (`.get()`) lands in M3 alongside
7
+ * auth, error handling, and the mock server.
8
+ */
9
+
10
+ import type { FMSOData } from './client.js'
11
+ import {
12
+ buildContainerJsonBody,
13
+ type ContainerJsonValue,
14
+ } from './containers.js'
15
+ import { EntityRef } from './entity.js'
16
+ import { executeJson } from './http.js'
17
+ import { runScriptAtEntitySet, type ScriptOptions, type ScriptResult } from './scripts.js'
18
+ import type { RequestOptions } from './types.js'
19
+ import {
20
+ buildQueryString,
21
+ encodePathSegment,
22
+ formatLiteral,
23
+ type ODataLiteral,
24
+ } from './url.js'
25
+ import type { AggregateFunction } from '@fms-odata/spec-ts'
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Filter
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Opaque filter expression produced by `filterFactory`. Use `.and()`, `.or()`,
33
+ * `.not()` to compose; pass to `Query#filter`.
34
+ */
35
+ export class Filter {
36
+ constructor(readonly expr: string) {}
37
+
38
+ toString(): string {
39
+ return this.expr
40
+ }
41
+
42
+ and(other: Filter | string): Filter {
43
+ return new Filter(`(${this.expr}) and (${Filter.coerce(other)})`)
44
+ }
45
+
46
+ or(other: Filter | string): Filter {
47
+ return new Filter(`(${this.expr}) or (${Filter.coerce(other)})`)
48
+ }
49
+
50
+ not(): Filter {
51
+ return new Filter(`not (${this.expr})`)
52
+ }
53
+
54
+ /** @internal */
55
+ static coerce(x: Filter | string): string {
56
+ return x instanceof Filter ? x.expr : x
57
+ }
58
+ }
59
+
60
+ /** Factory passed to callback-form `Query#filter(f => ...)`. */
61
+ export interface FilterFactory {
62
+ eq(field: string, value: ODataLiteral): Filter
63
+ ne(field: string, value: ODataLiteral): Filter
64
+ gt(field: string, value: ODataLiteral): Filter
65
+ ge(field: string, value: ODataLiteral): Filter
66
+ lt(field: string, value: ODataLiteral): Filter
67
+ le(field: string, value: ODataLiteral): Filter
68
+ startswith(field: string, value: string): Filter
69
+ endswith(field: string, value: string): Filter
70
+ contains(field: string, value: string): Filter
71
+ and(a: Filter | string, b: Filter | string): Filter
72
+ or(a: Filter | string, b: Filter | string): Filter
73
+ not(a: Filter | string): Filter
74
+ /** Escape hatch: embed a raw OData filter fragment verbatim. */
75
+ raw(expr: string): Filter
76
+ }
77
+
78
+ export const filterFactory: FilterFactory = {
79
+ eq: (f, v) => new Filter(`${f} eq ${formatLiteral(v)}`),
80
+ ne: (f, v) => new Filter(`${f} ne ${formatLiteral(v)}`),
81
+ gt: (f, v) => new Filter(`${f} gt ${formatLiteral(v)}`),
82
+ ge: (f, v) => new Filter(`${f} ge ${formatLiteral(v)}`),
83
+ lt: (f, v) => new Filter(`${f} lt ${formatLiteral(v)}`),
84
+ le: (f, v) => new Filter(`${f} le ${formatLiteral(v)}`),
85
+ startswith: (f, v) => new Filter(`startswith(${f},${formatLiteral(v)})`),
86
+ endswith: (f, v) => new Filter(`endswith(${f},${formatLiteral(v)})`),
87
+ contains: (f, v) => new Filter(`contains(${f},${formatLiteral(v)})`),
88
+ and: (a, b) => new Filter(`(${Filter.coerce(a)}) and (${Filter.coerce(b)})`),
89
+ or: (a, b) => new Filter(`(${Filter.coerce(a)}) or (${Filter.coerce(b)})`),
90
+ not: (a) => new Filter(`not (${Filter.coerce(a)})`),
91
+ raw: (s) => new Filter(s),
92
+ }
93
+
94
+ export type FilterInput =
95
+ | Filter
96
+ | string
97
+ | ((f: FilterFactory) => Filter | string)
98
+
99
+ function resolveFilter(input: FilterInput): string {
100
+ if (typeof input === 'function') return Filter.coerce(input(filterFactory))
101
+ return Filter.coerce(input)
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Query state
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export type OrderDir = 'asc' | 'desc'
109
+
110
+ /** @internal */
111
+ export interface QueryOptionsState {
112
+ select?: string[]
113
+ filter?: string
114
+ expand?: Array<{ name: string; options?: QueryOptionsState }>
115
+ orderby?: Array<{ field: string; dir: OrderDir }>
116
+ top?: number
117
+ skip?: number
118
+ count?: boolean
119
+ search?: string
120
+ apply?: string
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Query builder
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Fluent query builder. Methods mutate and return `this` for chaining.
129
+ */
130
+ /** Result envelope returned by `Query#get()`. */
131
+ export interface QueryResult<T> {
132
+ value: T[]
133
+ /** Present when `.count()` was enabled on the query. */
134
+ count?: number
135
+ /** Server-driven paging link. */
136
+ nextLink?: string
137
+ }
138
+
139
+ export class Query<T = Record<string, unknown>> {
140
+ /** @internal */ readonly _state: QueryOptionsState = {}
141
+ /** @internal */ readonly _baseUrl: string
142
+ /** @internal */ readonly _entitySet: string
143
+ /** @internal */ readonly _client: FMSOData | undefined
144
+
145
+ constructor(baseUrl: string, entitySet: string, client?: FMSOData) {
146
+ this._baseUrl = baseUrl
147
+ this._entitySet = entitySet
148
+ if (client) this._client = client
149
+ }
150
+
151
+ select(...fields: string[]): this {
152
+ this._state.select = [...(this._state.select ?? []), ...fields]
153
+ return this
154
+ }
155
+
156
+ filter(input: FilterInput): this {
157
+ const expr = resolveFilter(input)
158
+ this._state.filter = this._state.filter
159
+ ? `(${this._state.filter}) and (${expr})`
160
+ : expr
161
+ return this
162
+ }
163
+
164
+ or(input: FilterInput): this {
165
+ const expr = resolveFilter(input)
166
+ this._state.filter = this._state.filter
167
+ ? `(${this._state.filter}) or (${expr})`
168
+ : expr
169
+ return this
170
+ }
171
+
172
+ expand(name: string, build?: (q: Query) => Query | void): this {
173
+ const entry: { name: string; options?: QueryOptionsState } = { name }
174
+ if (build) {
175
+ const nested = new Query('', name)
176
+ build(nested)
177
+ entry.options = nested._state
178
+ }
179
+ this._state.expand = [...(this._state.expand ?? []), entry]
180
+ return this
181
+ }
182
+
183
+ orderby(field: string, dir: OrderDir = 'asc'): this {
184
+ this._state.orderby = [...(this._state.orderby ?? []), { field, dir }]
185
+ return this
186
+ }
187
+
188
+ top(n: number): this {
189
+ if (!Number.isInteger(n) || n < 0) {
190
+ throw new RangeError(`Query#top: expected non-negative integer, got ${n}`)
191
+ }
192
+ this._state.top = n
193
+ return this
194
+ }
195
+
196
+ skip(n: number): this {
197
+ if (!Number.isInteger(n) || n < 0) {
198
+ throw new RangeError(`Query#skip: expected non-negative integer, got ${n}`)
199
+ }
200
+ this._state.skip = n
201
+ return this
202
+ }
203
+
204
+ count(enabled: boolean = true): this {
205
+ this._state.count = enabled
206
+ return this
207
+ }
208
+
209
+ search(term: string): this {
210
+ this._state.search = term
211
+ return this
212
+ }
213
+
214
+ // -------------------------------------------------------------------------
215
+ // $apply (aggregation) — requires FMS 22.0.1+ (FileMaker 2024)
216
+ // -------------------------------------------------------------------------
217
+
218
+ /**
219
+ * Set a raw `$apply` expression. Use this for advanced transformations
220
+ * that the `aggregate()` / `groupBy()` helpers don't cover.
221
+ *
222
+ * Requires FileMaker Server 2024+ (v22). Use `db.hasFeature('applyAggregation')`
223
+ * to check before calling.
224
+ *
225
+ * @example
226
+ * ```ts
227
+ * const result = await db.from('orders').apply('aggregate(total with sum as totalSum)')
228
+ * .get()
229
+ * ```
230
+ */
231
+ apply(expr: string): this {
232
+ this._state.apply = expr
233
+ return this
234
+ }
235
+
236
+ /**
237
+ * Aggregate the entity set. Produces a `$apply=aggregate(...)` expression.
238
+ *
239
+ * Requires FileMaker Server 2024+ (v22).
240
+ *
241
+ * @example
242
+ * ```ts
243
+ * const result = await db.from('orders')
244
+ * .aggregate([{ field: 'total', function: 'sum', alias: 'totalSum' }])
245
+ * .get()
246
+ * // $apply=aggregate(total with sum as totalSum)
247
+ * ```
248
+ */
249
+ aggregate(expressions: Array<{ field: string; function: AggregateFunction; alias: string }>): this {
250
+ const parts = expressions.map((e) => `${e.field} with ${e.function} as ${e.alias}`)
251
+ this._state.apply = `aggregate(${parts.join(',')})`
252
+ return this
253
+ }
254
+
255
+ /**
256
+ * Group the entity set by one or more fields, optionally with aggregation.
257
+ * Produces a `$apply=groupby((fields), aggregate(...))` expression.
258
+ *
259
+ * Requires FileMaker Server 2024+ (v22).
260
+ *
261
+ * @example
262
+ * ```ts
263
+ * const result = await db.from('orders')
264
+ * .groupBy(
265
+ * ['customerId'],
266
+ * [{ field: 'total', function: 'sum', alias: 'totalSum' }],
267
+ * )
268
+ * .get()
269
+ * // $apply=groupby((customerId),aggregate(total with sum as totalSum))
270
+ * ```
271
+ */
272
+ groupBy(
273
+ fields: string[],
274
+ aggregateExpressions?: Array<{ field: string; function: AggregateFunction; alias: string }>,
275
+ ): this {
276
+ const fieldList = fields.join(',')
277
+ if (aggregateExpressions && aggregateExpressions.length > 0) {
278
+ const aggParts = aggregateExpressions.map((e) => `${e.field} with ${e.function} as ${e.alias}`)
279
+ this._state.apply = `groupby((${fieldList}),aggregate(${aggParts.join(',')}))`
280
+ } else {
281
+ this._state.apply = `groupby((${fieldList}))`
282
+ }
283
+ return this
284
+ }
285
+
286
+ /** Build the absolute request URL for this query. */
287
+ toURL(): string {
288
+ const qs = serializeOptions(this._state, { topLevel: true })
289
+ const base = `${this._baseUrl}/${encodePathSegment(this._entitySet)}`
290
+ return qs ? `${base}?${qs}` : base
291
+ }
292
+
293
+ /**
294
+ * Get a handle to a single entity by its primary key. Subsequent operations
295
+ * (`.get()`, `.patch()`, `.delete()`) hit `/<EntitySet>(<key>)`.
296
+ */
297
+ byKey(key: string | number): EntityRef<T> {
298
+ if (!this._client) {
299
+ throw new Error('Query#byKey: no client attached (use FMSOData#from)')
300
+ }
301
+ return new EntityRef<T>(this._client, this._entitySet, key)
302
+ }
303
+
304
+ /**
305
+ * `POST` a new entity to the collection. Returns the created row (FMS echoes
306
+ * it by default).
307
+ */
308
+ async create(
309
+ body: Partial<T> | Record<string, unknown>,
310
+ opts: RequestOptions = {},
311
+ ): Promise<T> {
312
+ if (!this._client) {
313
+ throw new Error('Query#create: no client attached (use FMSOData#from)')
314
+ }
315
+ const url = `${this._baseUrl}/${encodePathSegment(this._entitySet)}`
316
+ const json = await executeJson<T>(this._client._ctx, url, {
317
+ method: 'POST',
318
+ headers: { 'Content-Type': 'application/json' },
319
+ body: JSON.stringify(body),
320
+ accept: 'json',
321
+ ...(opts.signal ? { signal: opts.signal } : {}),
322
+ })
323
+ return json
324
+ }
325
+
326
+ /**
327
+ * `POST` a new entity carrying one or more container fields. Maps to the
328
+ * Claris "Operation 1" — the request body is a JSON object containing
329
+ * regular field values plus, for each container field, the base64 data
330
+ * and the `@com.filemaker.odata.{ContentType,Filename}` annotations.
331
+ *
332
+ * Each container value's `data` must already be base64-encoded.
333
+ *
334
+ * @example
335
+ * await db.from('contact').createWithContainers(
336
+ * { first_name: 'Bob', last_name: 'Jones' },
337
+ * { photo: { data: photoB64, contentType: 'image/png', filename: 'BJONES.png' } },
338
+ * )
339
+ */
340
+ async createWithContainers(
341
+ regularFields: Partial<T> | Record<string, unknown>,
342
+ containers: Record<string, ContainerJsonValue>,
343
+ opts: RequestOptions = {},
344
+ ): Promise<T> {
345
+ if (!this._client) {
346
+ throw new Error('Query#createWithContainers: no client attached (use FMSOData#from)')
347
+ }
348
+ const url = `${this._baseUrl}/${encodePathSegment(this._entitySet)}`
349
+ const body = buildContainerJsonBody(
350
+ containers,
351
+ regularFields as Record<string, unknown>,
352
+ )
353
+ const json = await executeJson<T>(this._client._ctx, url, {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify(body),
357
+ accept: 'json',
358
+ ...(opts.signal ? { signal: opts.signal } : {}),
359
+ })
360
+ return json
361
+ }
362
+
363
+ /**
364
+ * Execute the query. Returns the parsed OData collection envelope.
365
+ */
366
+ async get(opts: RequestOptions = {}): Promise<QueryResult<T>> {
367
+ if (!this._client) {
368
+ throw new Error('Query#get: no client attached (use FMSOData#from)')
369
+ }
370
+ type Envelope = {
371
+ value?: T[]
372
+ '@odata.count'?: number
373
+ '@odata.nextLink'?: string
374
+ }
375
+ const json = await executeJson<Envelope>(this._client._ctx, this.toURL(), {
376
+ method: 'GET',
377
+ accept: 'json',
378
+ ...(opts.signal ? { signal: opts.signal } : {}),
379
+ })
380
+ const out: QueryResult<T> = { value: json?.value ?? [] }
381
+ if (json && typeof json['@odata.count'] === 'number') out.count = json['@odata.count']
382
+ if (json && typeof json['@odata.nextLink'] === 'string') out.nextLink = json['@odata.nextLink']
383
+ return out
384
+ }
385
+
386
+ /**
387
+ * Invoke a FileMaker script in the context of this query's entity set.
388
+ *
389
+ * Filter / select / orderby / paging state on the `Query` is **ignored** —
390
+ * the underlying OData Action only cares about the entity set. Use
391
+ * `EntityRef#script` to run a script in the context of a specific record.
392
+ */
393
+ async script(name: string, opts: ScriptOptions = {}): Promise<ScriptResult> {
394
+ if (!this._client) {
395
+ throw new Error('Query#script: no client attached (use FMSOData#from)')
396
+ }
397
+ return runScriptAtEntitySet(this._client, this._entitySet, name, opts)
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Serialize query options either as a top-level querystring (percent-encoded)
403
+ * or as a nested `$expand` option block (semicolon-joined, unencoded — the
404
+ * outer param encoder will encode the whole block once).
405
+ *
406
+ * @internal
407
+ */
408
+ export function serializeOptions(
409
+ s: QueryOptionsState,
410
+ opts: { topLevel: boolean },
411
+ ): string {
412
+ const pairs: Array<[string, string]> = []
413
+
414
+ if (s.select && s.select.length > 0) {
415
+ pairs.push(['$select', s.select.join(',')])
416
+ }
417
+ if (s.filter) {
418
+ pairs.push(['$filter', s.filter])
419
+ }
420
+ if (s.expand && s.expand.length > 0) {
421
+ const parts = s.expand.map((e) => {
422
+ if (!e.options) return e.name
423
+ const inner = serializeOptions(e.options, { topLevel: false })
424
+ return inner ? `${e.name}(${inner})` : e.name
425
+ })
426
+ pairs.push(['$expand', parts.join(',')])
427
+ }
428
+ if (s.orderby && s.orderby.length > 0) {
429
+ pairs.push([
430
+ '$orderby',
431
+ s.orderby.map((o) => `${o.field} ${o.dir}`).join(','),
432
+ ])
433
+ }
434
+ if (s.top !== undefined) pairs.push(['$top', String(s.top)])
435
+ if (s.skip !== undefined) pairs.push(['$skip', String(s.skip)])
436
+ if (s.count) pairs.push(['$count', 'true'])
437
+ if (s.search) pairs.push(['$search', s.search])
438
+ if (s.apply) pairs.push(['$apply', s.apply])
439
+
440
+ if (opts.topLevel) return buildQueryString(pairs)
441
+ return pairs.map(([k, v]) => `${k}=${v}`).join(';')
442
+ }