UNPKG

@proofkit/fmodata

Version:

FileMaker OData API client

176 lines (153 loc) 6.28 kB
import { Effect } from "effect"; import buildQuery, { type QueryOptions } from "odata-query"; import { requestFromService, runLayerResult } from "../effect"; import type { FMODataErrorType } from "../errors"; import { BuilderInvariantError, isFMODataError, ResponseStructureError } from "../errors"; import type { FilterExpression } from "../orm/operators"; import { type FMTable, getTableName, type InferSchemaOutputFromFMTable } from "../orm/table"; import type { FMODataLayer, ODataConfig } from "../services"; import type { ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, Result } from "../types"; import { createODataRequest, mergeExecuteOptions } from "./builders/index"; import { parseErrorResponse } from "./error-parser"; import { type NavigationConfig, QueryUrlBuilder } from "./query/url-builder"; import { createClientRuntime } from "./runtime"; function normalizeCountBuildError(error: unknown): FMODataErrorType { if (isFMODataError(error)) { return error; } if (error instanceof Error) { return new BuilderInvariantError("CountBuilder.execute", error.message, { cause: error }); } return new BuilderInvariantError("CountBuilder.execute", String(error)); } export class CountBuilder< // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration Occ extends FMTable<any, any>, DatabaseIncludeSpecialColumns extends boolean = false, > implements ExecutableBuilder<number> { private readonly occurrence: Occ; private readonly layer: FMODataLayer; private readonly config: ODataConfig; private readonly urlBuilder: QueryUrlBuilder; private filterExpression?: FilterExpression; private readonly queryOptions: Partial<QueryOptions<InferSchemaOutputFromFMTable<Occ>>> = {}; private navigationConfig?: NavigationConfig; constructor(config: { occurrence: Occ; layer: FMODataLayer; }) { this.occurrence = config.occurrence; const runtime = createClientRuntime(config.layer); this.layer = runtime.layer; this.config = runtime.config; this.urlBuilder = new QueryUrlBuilder(this.config.databaseName, this.occurrence, this.config.useEntityIds); } set navigation(navigation: NavigationConfig | undefined) { this.navigationConfig = navigation; } where(expression: FilterExpression | string): CountBuilder<Occ, DatabaseIncludeSpecialColumns> { if (typeof expression === "string") { this.filterExpression = undefined; this.queryOptions.filter = expression; return this; } this.filterExpression = expression; this.queryOptions.filter = undefined; return this; } private buildQueryString(useEntityIds?: boolean): string { const finalUseEntityIds = useEntityIds ?? this.config.useEntityIds; const queryOptions = { ...this.queryOptions }; if (this.filterExpression) { queryOptions.filter = this.filterExpression.toODataFilter(finalUseEntityIds); } queryOptions.count = undefined; queryOptions.select = undefined; queryOptions.expand = undefined; queryOptions.top = undefined; queryOptions.skip = undefined; queryOptions.orderBy = undefined; return buildQuery(queryOptions); } private parseCountValue(raw: unknown): number | ResponseStructureError { let count = Number.NaN; if (typeof raw === "number") { count = raw; } else if (typeof raw === "string" && raw.trim() !== "") { count = Number(raw); } return Number.isFinite(count) ? count : new ResponseStructureError("numeric count response", raw); } execute<EO extends ExecuteOptions>(options?: ExecuteMethodOptions<EO>): Promise<Result<number>> { const mergedOptions = mergeExecuteOptions(options, this.config.useEntityIds); let queryString: string; let url: string; try { queryString = this.buildQueryString(mergedOptions.useEntityIds); url = this.urlBuilder.build(queryString, { isCount: true, useEntityIds: mergedOptions.useEntityIds, navigation: this.navigationConfig, }); } catch (error) { return Promise.resolve({ data: undefined, error: normalizeCountBuildError(error), }); } const pipeline = requestFromService(url, mergedOptions).pipe( Effect.flatMap((data) => { const parsed = this.parseCountValue(data); return parsed instanceof ResponseStructureError ? Effect.fail(parsed) : Effect.succeed(parsed); }), ); return runLayerResult(this.layer, pipeline, "fmodata.query.count", { "fmodata.table": getTableName(this.occurrence), }); } getQueryString(options?: { useEntityIds?: boolean }): string { const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds; const queryString = this.buildQueryString(useEntityIds); return this.urlBuilder.buildPath(queryString, { isCount: true, useEntityIds, navigation: this.navigationConfig, }); } // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value getRequestConfig(): { method: string; url: string; body?: any } { const queryString = this.buildQueryString(this.config.useEntityIds); const url = this.urlBuilder.build(queryString, { isCount: true, useEntityIds: this.config.useEntityIds, navigation: this.navigationConfig, }); return { method: "GET", url, }; } toRequest(baseUrl: string, options?: ExecuteOptions): Request { const config = this.getRequestConfig(); return createODataRequest(baseUrl, config, { ...options, normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName, }); } async processResponse(response: Response, _options?: ExecuteOptions): Promise<Result<number>> { if (!response.ok) { const error = await parseErrorResponse( response, response.url || `/${this.config.databaseName}/${getTableName(this.occurrence)}/$count`, ); return { data: undefined, error }; } const raw = await response.text(); const parsed = this.parseCountValue(raw); if (parsed instanceof ResponseStructureError) { return { data: undefined, error: parsed }; } return { data: parsed, error: undefined }; } }