@proofkit/fmodata
Version:
FileMaker OData API client
210 lines (187 loc) • 7.04 kB
text/typescript
import { Effect } from "effect";
import { requestFromService, runLayerResult } from "../effect";
import type { FMTable } from "../orm/table";
import { getTableName } from "../orm/table";
import type { FMODataLayer, ODataConfig } from "../services";
import type { ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, Result } from "../types";
import { getAcceptHeader } from "../types";
import {
buildMutationUrl,
extractAffectedRows,
mergeMutationExecuteOptions,
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 delete builder returned from EntitySet.delete()
* Requires calling .byId() or .where() before .execute() is available
*/
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
export class DeleteBuilder<Occ extends FMTable<any, any>> {
private readonly table: Occ;
private readonly layer: FMODataLayer;
private readonly config: ODataConfig;
constructor(config: {
occurrence: Occ;
layer: FMODataLayer;
}) {
this.table = config.occurrence;
const runtime = createClientRuntime(config.layer);
this.layer = runtime.layer;
this.config = runtime.config;
}
/**
* Delete a single record by ID
*/
byId(id: string | number): ExecutableDeleteBuilder<Occ> {
return new ExecutableDeleteBuilder<Occ>({
occurrence: this.table,
layer: this.layer,
mode: "byId",
recordLocator: id,
});
}
/**
* Delete a single record by ROWID
*/
byRowId(rowId: number): ExecutableDeleteBuilder<Occ> {
return new ExecutableDeleteBuilder<Occ>({
occurrence: this.table,
layer: this.layer,
mode: "byId",
recordLocator: { ROWID: rowId },
});
}
/**
* Delete records matching a filter query
* @param fn Callback that receives a QueryBuilder for building the filter
*/
where(fn: (q: QueryBuilder<Occ>) => QueryBuilder<Occ>): ExecutableDeleteBuilder<Occ> {
// 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 ExecutableDeleteBuilder<Occ>({
occurrence: this.table,
layer: this.layer,
mode: "byFilter",
queryBuilder: configuredBuilder,
});
}
}
/**
* Executable delete builder - has execute() method
* Returned after calling .byId() or .where()
*/
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
export class ExecutableDeleteBuilder<Occ extends FMTable<any, any>>
implements ExecutableBuilder<{ deletedCount: number }>
{
private readonly table: Occ;
private readonly mode: "byId" | "byFilter";
private readonly recordLocator?: RecordLocator;
private readonly queryBuilder?: QueryBuilder<Occ>;
private readonly layer: FMODataLayer;
private readonly config: ODataConfig;
constructor(config: {
occurrence: Occ;
layer: FMODataLayer;
mode: "byId" | "byFilter";
recordLocator?: RecordLocator;
queryBuilder?: QueryBuilder<Occ>;
}) {
this.table = config.occurrence;
this.layer = config.layer;
this.mode = config.mode;
this.recordLocator = config.recordLocator;
this.queryBuilder = config.queryBuilder;
this.config = createClientRuntime(this.layer).config;
}
execute(options?: ExecuteMethodOptions<ExecuteOptions>): Promise<Result<{ deletedCount: number }>> {
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, ...requestOptions } = mergedOptions as any;
const useEntityIds = mergedOptions.useEntityIds ?? this.config.useEntityIds;
const tableId = resolveMutationTableId(this.table, useEntityIds, "ExecutableDeleteBuilder");
const url = buildMutationUrl({
databaseName: this.config.databaseName,
tableId,
tableName: getTableName(this.table),
mode: this.mode,
recordLocator: this.recordLocator,
queryBuilder: this.queryBuilder,
useEntityIds,
builderName: "ExecutableDeleteBuilder",
});
const pipeline = Effect.gen(this, function* () {
// Make DELETE request via DI
const response = yield* requestFromService(url, {
...requestOptions,
method: "DELETE",
});
const deletedCount = extractAffectedRows(response, undefined, 0, "deletedCount");
return { deletedCount };
});
return runLayerResult(this.layer, pipeline, "fmodata.delete", {
"fmodata.table": getTableName(this.table),
});
}
// 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, "ExecutableDeleteBuilder");
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: "ExecutableDeleteBuilder",
});
return {
method: "DELETE",
url,
};
}
toRequest(baseUrl: string, options?: ExecuteOptions): Request {
const config = this.getRequestConfig();
const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, {
normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName,
})}`;
return new Request(fullUrl, {
method: config.method,
headers: {
Accept: getAcceptHeader(options?.includeODataAnnotations),
},
});
}
async processResponse(response: Response, _options?: ExecuteOptions): Promise<Result<{ deletedCount: number }>> {
// 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 deletedCount = extractAffectedRows(undefined, response.headers, 1, "deletedCount");
return { data: { deletedCount }, error: undefined };
}
const rawResponse = JSON.parse(text);
const deletedCount = extractAffectedRows(rawResponse, response.headers, 0, "deletedCount");
return { data: { deletedCount }, error: undefined };
}
}