@proofkit/fmodata
Version:
FileMaker OData API client
195 lines (163 loc) • 5.98 kB
text/typescript
import type { FFetchOptions } from "@fetchkit/ffetch";
import { BuilderInvariantError, InvalidLocationHeaderError } from "../../errors";
import type { FMTable } from "../../orm/table";
import { getTableName } from "../../orm/table";
import type { ExecuteOptions } from "../../types";
import { resolveTableId } from "./table-utils";
const ROWID_MATCH_REGEX = /ROWID=(\d+)/;
const PAREN_VALUE_REGEX = /\(['"]?([^'"]+)['"]?\)/;
type MutationMode = "byId" | "byFilter";
export interface RowIdRecordLocator {
ROWID: number;
}
export type RecordLocator = string | number | RowIdRecordLocator;
export interface FilterQueryBuilder {
getQueryString(options?: { useEntityIds?: boolean }): string;
}
function isRowIdRecordLocator(recordLocator: RecordLocator): recordLocator is RowIdRecordLocator {
return typeof recordLocator === "object" && recordLocator !== null && "ROWID" in recordLocator;
}
function escapeODataStringLiteral(value: string): string {
return value.replaceAll("'", "''");
}
export function buildRecordLocatorSegment(recordLocator: RecordLocator): string {
if (isRowIdRecordLocator(recordLocator)) {
return `(ROWID=${recordLocator.ROWID})`;
}
return `('${escapeODataStringLiteral(String(recordLocator))}')`;
}
export function buildRecordPath(pathPrefix: string, recordLocator: RecordLocator): string {
return `${pathPrefix}${buildRecordLocatorSegment(recordLocator)}`;
}
export function mergeMutationExecuteOptions(
options: (RequestInit & FFetchOptions & ExecuteOptions) | undefined,
databaseUseEntityIds: boolean,
databaseIncludeSpecialColumns: boolean,
): RequestInit & FFetchOptions & { useEntityIds?: boolean; includeSpecialColumns?: boolean } {
return {
...options,
useEntityIds: options?.useEntityIds ?? databaseUseEntityIds,
includeSpecialColumns: options?.includeSpecialColumns ?? databaseIncludeSpecialColumns,
};
}
export function mergePreferHeaderValues(...values: Array<string | undefined>): string | undefined {
const merged: string[] = [];
const seen = new Set<string>();
for (const value of values) {
if (!value) {
continue;
}
for (const part of value.split(",")) {
const normalized = part.trim();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
merged.push(normalized);
}
}
return merged.length > 0 ? merged.join(", ") : undefined;
}
export function resolveMutationTableId(
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
table: FMTable<any, any> | undefined,
useEntityIds: boolean,
builderName: string,
): string {
if (!table) {
throw new BuilderInvariantError(builderName, "table occurrence is required");
}
return resolveTableId(table, getTableName(table), useEntityIds);
}
export function buildMutationUrl(config: {
databaseName: string;
tableId: string;
tableName: string;
mode: MutationMode;
recordLocator?: RecordLocator;
queryBuilder?: FilterQueryBuilder;
useEntityIds?: boolean;
builderName: string;
}): string {
const { databaseName, tableId, tableName, mode, recordLocator, queryBuilder, useEntityIds, builderName } = config;
if (mode === "byId") {
if (recordLocator === undefined || recordLocator === null || recordLocator === "") {
throw new BuilderInvariantError(builderName, "recordLocator is required for byId mode");
}
return `/${databaseName}/${buildRecordPath(tableId, recordLocator)}`;
}
if (!queryBuilder) {
throw new BuilderInvariantError(builderName, "query builder is required for filter mode");
}
const queryString = queryBuilder.getQueryString({ useEntityIds });
const queryParams = stripTablePathPrefix(queryString, tableId, tableName);
return `/${databaseName}/${tableId}${queryParams}`;
}
export function stripTablePathPrefix(queryString: string, tableId: string, tableName: string): string {
if (queryString.startsWith(`/${tableId}`)) {
return queryString.slice(`/${tableId}`.length);
}
if (queryString.startsWith(`/${tableName}`)) {
return queryString.slice(`/${tableName}`.length);
}
return queryString;
}
export function extractAffectedRows(
response: unknown,
headers?: Pick<Headers, "get">,
fallback = 0,
countKey?: "updatedCount" | "deletedCount",
): number {
const headerValue = headers?.get("fmodata.affected_rows");
if (headerValue) {
const parsed = Number.parseInt(headerValue, 10);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
if (typeof response === "number") {
return response;
}
if (response && typeof response === "object") {
if (countKey && countKey in response) {
const count = Number((response as Record<string, unknown>)[countKey]);
if (!Number.isNaN(count)) {
return count;
}
}
const affected = Number((response as Record<string, unknown>)["fmodata.affected_rows"]);
if (!Number.isNaN(affected)) {
return affected;
}
}
return fallback;
}
/**
* Parse ROWID from Location header.
* Expected formats:
* - contacts(ROWID=4583)
* - contacts('4583')
*/
export function parseRowIdFromLocationHeader(locationHeader: string | undefined): number {
if (!locationHeader) {
throw new InvalidLocationHeaderError("Location header is required but was not provided");
}
const rowidMatch = locationHeader.match(ROWID_MATCH_REGEX);
if (rowidMatch?.[1]) {
return Number.parseInt(rowidMatch[1], 10);
}
const parenMatch = locationHeader.match(PAREN_VALUE_REGEX);
if (parenMatch?.[1]) {
const value = Number.parseInt(parenMatch[1], 10);
if (!Number.isNaN(value)) {
return value;
}
}
throw new InvalidLocationHeaderError(
`Could not extract ROWID from Location header: ${locationHeader}`,
locationHeader,
);
}
export function getLocationHeader(headers: Pick<Headers, "get">): string | undefined {
return headers.get("Location") || headers.get("location") || undefined;
}