UNPKG

@proofkit/fmodata

Version:

FileMaker OData API client

429 lines (393 loc) 15.4 kB
import { Effect } from "effect"; import { requestFromService, runLayerResult, tryEffect } from "../effect"; import type { FMODataErrorType } from "../errors"; import { BuilderInvariantError } from "../errors"; import type { FMTable, InferSchemaOutputFromFMTable } from "../orm/table"; import { getBaseTableConfig, getTableName } from "../orm/table"; import type { FMODataLayer, ODataConfig } from "../services"; import { transformFieldNamesToIds, transformResponseFields } from "../transform"; import type { ConditionallyWithSpecialColumns, ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, NormalizeIncludeSpecialColumns, Result, } from "../types"; import { getAcceptHeader } from "../types"; import { validateAndTransformInput, validateSingleResponse } from "../validation"; import { buildMutationUrl, extractAffectedRows, mergeMutationExecuteOptions, mergePreferHeaderValues, type RecordLocator, resolveMutationTableId, } from "./builders/mutation-helpers"; import { normalizeDatabasePath } from "./database-name"; import { parseErrorResponse } from "./error-parser"; import { QueryBuilder } from "./query-builder"; import { createClientRuntime } from "./runtime"; /** * Initial update builder returned from EntitySet.update(data) * Requires calling .byId() or .where() before .execute() is available */ export class UpdateBuilder< // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration Occ extends FMTable<any, any>, ReturnPreference extends "minimal" | "representation" = "minimal", DatabaseIncludeSpecialColumns extends boolean = false, > { private readonly table: Occ; private readonly data: Partial<InferSchemaOutputFromFMTable<Occ>>; private readonly returnPreference: ReturnPreference; private readonly layer: FMODataLayer; private readonly config: ODataConfig; constructor(config: { occurrence: Occ; layer: FMODataLayer; data: Partial<InferSchemaOutputFromFMTable<Occ>>; returnPreference: ReturnPreference; }) { this.table = config.occurrence; const runtime = createClientRuntime(config.layer); this.layer = runtime.layer; this.data = config.data; this.returnPreference = config.returnPreference; this.config = runtime.config; } /** * Update a single record by ID * Returns updated count by default, or full record if returnFullRecord was set to true */ byId(id: string | number): ExecutableUpdateBuilder<Occ, true, ReturnPreference, DatabaseIncludeSpecialColumns> { return new ExecutableUpdateBuilder<Occ, true, ReturnPreference, DatabaseIncludeSpecialColumns>({ occurrence: this.table, layer: this.layer, data: this.data, mode: "byId", recordLocator: id, returnPreference: this.returnPreference, }); } /** * Update a single record by ROWID */ byRowId(rowId: number): ExecutableUpdateBuilder<Occ, true, ReturnPreference> { return new ExecutableUpdateBuilder<Occ, true, ReturnPreference>({ occurrence: this.table, layer: this.layer, data: this.data, mode: "byId", recordLocator: { ROWID: rowId }, returnPreference: this.returnPreference, }); } /** * Update records matching a filter query * Returns updated count by default, or full record if returnFullRecord was set to true * @param fn Callback that receives a QueryBuilder for building the filter */ where( fn: (q: QueryBuilder<Occ>) => QueryBuilder<Occ>, ): ExecutableUpdateBuilder<Occ, true, ReturnPreference, DatabaseIncludeSpecialColumns> { // Create a QueryBuilder for the user to configure const queryBuilder = new QueryBuilder<Occ>({ occurrence: this.table, layer: this.layer, }); // Let the user configure it const configuredBuilder = fn(queryBuilder); return new ExecutableUpdateBuilder<Occ, true, ReturnPreference, DatabaseIncludeSpecialColumns>({ occurrence: this.table, layer: this.layer, data: this.data, mode: "byFilter", queryBuilder: configuredBuilder, returnPreference: this.returnPreference, }); } } /** * Executable update builder - has execute() method * Returned after calling .byId() or .where() * Can return either updated count or full record based on returnFullRecord option */ export class ExecutableUpdateBuilder< // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration Occ extends FMTable<any, any>, _IsByFilter extends boolean, ReturnPreference extends "minimal" | "representation" = "minimal", DatabaseIncludeSpecialColumns extends boolean = false, > implements ExecutableBuilder< ReturnPreference extends "minimal" ? { updatedCount: number } : ConditionallyWithSpecialColumns<InferSchemaOutputFromFMTable<Occ>, DatabaseIncludeSpecialColumns, false> > { private readonly table: Occ; private readonly data: Partial<InferSchemaOutputFromFMTable<Occ>>; private readonly mode: "byId" | "byFilter"; private readonly recordLocator?: RecordLocator; private readonly queryBuilder?: QueryBuilder<Occ>; private readonly returnPreference: ReturnPreference; private readonly layer: FMODataLayer; private readonly config: ODataConfig; constructor(config: { occurrence: Occ; layer: FMODataLayer; data: Partial<InferSchemaOutputFromFMTable<Occ>>; mode: "byId" | "byFilter"; recordLocator?: RecordLocator; queryBuilder?: QueryBuilder<Occ>; returnPreference: ReturnPreference; }) { this.table = config.occurrence; this.layer = config.layer; this.data = config.data; this.mode = config.mode; this.recordLocator = config.recordLocator; this.queryBuilder = config.queryBuilder; this.returnPreference = config.returnPreference; const runtime = createClientRuntime(this.layer); this.config = runtime.config; } execute<EO extends ExecuteOptions>( options?: ExecuteMethodOptions<EO>, ): Promise< Result< ReturnPreference extends "minimal" ? { updatedCount: number } : ConditionallyWithSpecialColumns< InferSchemaOutputFromFMTable<Occ>, NormalizeIncludeSpecialColumns<EO["includeSpecialColumns"], DatabaseIncludeSpecialColumns>, false > > > { const mergedOptions = mergeMutationExecuteOptions( options, this.config.useEntityIds, this.config.includeSpecialColumns, ); // biome-ignore lint/suspicious/noExplicitAny: Execute options include dynamic fetch fields const { method: _method, body: _body, headers: callerHeaders, ...requestOptions } = mergedOptions as any; const shouldUseIds = mergedOptions.useEntityIds ?? this.config.useEntityIds; const includeSpecialColumns = mergedOptions.includeSpecialColumns ?? this.config.includeSpecialColumns; const tableId = resolveMutationTableId(this.table, shouldUseIds, "ExecutableUpdateBuilder"); const url = buildMutationUrl({ databaseName: this.config.databaseName, tableId, tableName: getTableName(this.table), mode: this.mode, recordLocator: this.recordLocator, queryBuilder: this.queryBuilder, useEntityIds: shouldUseIds, builderName: "ExecutableUpdateBuilder", }); const requestHeaders = new Headers(callerHeaders || {}); const preferHeader = mergePreferHeaderValues( this.returnPreference === "representation" ? "return=representation" : undefined, shouldUseIds ? "fmodata.entity-ids" : undefined, includeSpecialColumns ? "fmodata.include-specialcolumns" : undefined, requestHeaders.get("Prefer") ?? undefined, ); requestHeaders.set("Content-Type", "application/json"); if (preferHeader) { requestHeaders.set("Prefer", preferHeader); } else { requestHeaders.delete("Prefer"); } const pipeline = Effect.gen(this, function* () { // Step 1: Validate input let validatedData = this.data; if (this.table) { const baseTableConfig = getBaseTableConfig(this.table); validatedData = yield* tryEffect( () => validateAndTransformInput(this.data, baseTableConfig.inputSchema), (e) => (e instanceof Error ? e : new BuilderInvariantError("ExecutableUpdateBuilder.execute", String(e))) as FMODataErrorType, ); } // Step 2: Transform field names const transformedData = this.table && shouldUseIds ? transformFieldNamesToIds(validatedData, this.table) : validatedData; // Step 3: Make PATCH request via DI const response = yield* requestFromService(url, { ...requestOptions, method: "PATCH", headers: requestHeaders, body: JSON.stringify(transformedData), }); // Step 4: Handle response based on return preference if (this.returnPreference === "representation") { return response; } const updatedCount = extractAffectedRows(response, undefined, 0, "updatedCount"); return { updatedCount }; }); return runLayerResult(this.layer, pipeline, "fmodata.update", { "fmodata.table": getTableName(this.table), }) as Promise< Result< ReturnPreference extends "minimal" ? { updatedCount: number } : ConditionallyWithSpecialColumns< InferSchemaOutputFromFMTable<Occ>, NormalizeIncludeSpecialColumns<EO["includeSpecialColumns"], DatabaseIncludeSpecialColumns>, false > > >; } // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value getRequestConfig(): { method: string; url: string; body?: any } { const tableId = resolveMutationTableId(this.table, this.config.useEntityIds, "ExecutableUpdateBuilder"); // Transform field names to FMFIDs if using entity IDs const transformedData = this.table && this.config.useEntityIds ? transformFieldNamesToIds(this.data, this.table) : this.data; const url = buildMutationUrl({ databaseName: this.config.databaseName, tableId, tableName: getTableName(this.table), mode: this.mode, recordLocator: this.recordLocator, queryBuilder: this.queryBuilder, useEntityIds: this.config.useEntityIds, builderName: "ExecutableUpdateBuilder", }); return { method: "PATCH", url, body: JSON.stringify(transformedData), }; } toRequest(baseUrl: string, options?: ExecuteOptions): Request { const config = this.getRequestConfig(); const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, { normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName, })}`; const preferHeader = mergePreferHeaderValues( this.returnPreference === "representation" ? "return=representation" : undefined, (options?.useEntityIds ?? this.config.useEntityIds) ? "fmodata.entity-ids" : undefined, (options?.includeSpecialColumns ?? this.config.includeSpecialColumns) ? "fmodata.include-specialcolumns" : undefined, ); return new Request(fullUrl, { method: config.method, headers: { "Content-Type": "application/json", Accept: getAcceptHeader(options?.includeODataAnnotations), ...(preferHeader ? { Prefer: preferHeader } : {}), }, body: config.body, }); } async processResponse<EO extends ExecuteOptions | undefined = undefined>( response: Response, options?: EO, ): Promise< Result< ReturnPreference extends "minimal" ? { updatedCount: number } : ConditionallyWithSpecialColumns< InferSchemaOutputFromFMTable<Occ>, NormalizeIncludeSpecialColumns< EO extends ExecuteOptions ? EO["includeSpecialColumns"] : undefined, DatabaseIncludeSpecialColumns >, false > > > { type UpdateResponse = ReturnPreference extends "minimal" ? { updatedCount: number } : ConditionallyWithSpecialColumns< InferSchemaOutputFromFMTable<Occ>, NormalizeIncludeSpecialColumns< EO extends ExecuteOptions ? EO["includeSpecialColumns"] : undefined, DatabaseIncludeSpecialColumns >, false >; // Check for error responses (important for batch operations) if (!response.ok) { const tableName = getTableName(this.table); const error = await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${tableName}`); return { data: undefined, error }; } // Check for empty response (204 No Content) const text = await response.text(); if (!text || text.trim() === "") { const updatedCount = extractAffectedRows(undefined, response.headers, 1, "updatedCount"); return { data: { updatedCount } as unknown as UpdateResponse, error: undefined, }; } const rawResponse = JSON.parse(text); // Validate and transform input data using input validators (writeValidators) // This is needed for processResponse because it's called from batch operations // where the data hasn't been validated yet let _validatedData = this.data; if (this.table) { const baseTableConfig = getBaseTableConfig(this.table); const inputSchema = baseTableConfig.inputSchema; try { _validatedData = await validateAndTransformInput(this.data, inputSchema); } catch (error) { return { data: undefined, error: error instanceof Error ? error : new BuilderInvariantError("ExecutableUpdateBuilder.processResponse", String(error)), // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type } as any; } } // Handle based on return preference if (this.returnPreference === "representation") { const shouldUseIds = options?.useEntityIds ?? this.config.useEntityIds; const includeSpecialColumns = options?.includeSpecialColumns ?? this.config.includeSpecialColumns; let transformedResponse = rawResponse; if (this.table && shouldUseIds) { transformedResponse = transformResponseFields(rawResponse, this.table, undefined); } const validation = await validateSingleResponse<InferSchemaOutputFromFMTable<Occ>>( transformedResponse, getBaseTableConfig(this.table).schema, undefined, undefined, "exact", includeSpecialColumns, ); if (!validation.valid) { return { data: undefined, error: validation.error }; } if (validation.data === null) { return { data: undefined, error: new BuilderInvariantError( "ExecutableUpdateBuilder.processResponse", "update operation returned null response", ), }; } return { data: validation.data as unknown as UpdateResponse, error: undefined, }; } // Return updated count (minimal) const updatedCount = extractAffectedRows(rawResponse, response.headers, 0, "updatedCount"); return { data: { updatedCount } as unknown as UpdateResponse, error: undefined, }; } }