@proofkit/fmodata
Version:
FileMaker OData API client
429 lines (393 loc) • 15.4 kB
text/typescript
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,
};
}
}