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