@proofkit/fmodata
Version:
FileMaker OData API client
374 lines (327 loc) • 12.6 kB
text/typescript
import type {
ExecutionContext,
ExecutableBuilder,
Result,
ODataRecordMetadata,
ODataFieldResponse,
InferSchemaType,
ExecuteOptions,
} from "../types";
import type { TableOccurrence } from "./table-occurrence";
import type { BaseTable } from "./base-table";
import { transformTableName, transformResponseFields, getTableIdentifiers } from "../transform";
import { QueryBuilder } from "./query-builder";
import { validateSingleResponse } from "../validation";
import { type FFetchOptions } from "@fetchkit/ffetch";
import { StandardSchemaV1 } from "@standard-schema/spec";
// import type { z } from "zod/v4";
// Helper type to extract schema from a TableOccurrence
type ExtractSchemaFromOccurrence<O> =
O extends TableOccurrence<infer BT, any, any, any>
? BT extends BaseTable<infer S, any, any, any>
? S
: never
: never;
// Helper type to extract navigation relation names from an occurrence
type ExtractNavigationNames<
O extends TableOccurrence<any, any, any, any> | undefined,
> =
O extends TableOccurrence<any, any, infer Nav, any>
? Nav extends Record<string, any>
? keyof Nav
: never
: never;
// Helper type to resolve a navigation item (handles both direct and lazy-loaded)
type ResolveNavigationItem<T> = T extends () => infer R ? R : T;
// Helper type to find target occurrence by relation name
type FindNavigationTarget<
O extends TableOccurrence<any, any, any, any> | undefined,
Name extends string,
> =
O extends TableOccurrence<any, any, infer Nav, any>
? Name extends keyof Nav
? ResolveNavigationItem<Nav[Name]>
: never
: never;
export class RecordBuilder<
T extends Record<string, any>,
IsSingleField extends boolean = false,
FieldKey extends keyof T = keyof T,
Occ extends TableOccurrence<any, any, any, any> | undefined =
| TableOccurrence<any, any, any, any>
| undefined,
> implements
ExecutableBuilder<
IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata
>
{
private occurrence?: Occ;
private tableName: string;
private databaseName: string;
private context: ExecutionContext;
private recordId: string | number;
private operation?: "getSingleField" | "navigate";
private operationParam?: string;
private isNavigateFromEntitySet?: boolean;
private navigateRelation?: string;
private navigateSourceTableName?: string;
private databaseUseEntityIds: boolean;
constructor(config: {
occurrence?: Occ;
tableName: string;
databaseName: string;
context: ExecutionContext;
recordId: string | number;
databaseUseEntityIds?: boolean;
}) {
this.occurrence = config.occurrence;
this.tableName = config.tableName;
this.databaseName = config.databaseName;
this.context = config.context;
this.recordId = config.recordId;
this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
}
/**
* Helper to merge database-level useEntityIds with per-request options
*/
private mergeExecuteOptions(
options?: RequestInit & FFetchOptions & ExecuteOptions,
): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
// If useEntityIds is not set in options, use the database-level setting
return {
...options,
useEntityIds: options?.useEntityIds ?? this.databaseUseEntityIds,
};
}
/**
* Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
* @param useEntityIds - Optional override for entity ID usage
*/
private getTableId(useEntityIds?: boolean): string {
if (!this.occurrence) {
return this.tableName;
}
const contextDefault = this.context._getUseEntityIds?.() ?? false;
const shouldUseIds = useEntityIds ?? contextDefault;
if (shouldUseIds) {
const identifiers = getTableIdentifiers(this.occurrence);
if (!identifiers.id) {
throw new Error(
`useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`
);
}
return identifiers.id;
}
return this.occurrence.getTableName();
}
getSingleField<K extends keyof T>(field: K): RecordBuilder<T, true, K, Occ> {
const newBuilder = new RecordBuilder<T, true, K, Occ>({
occurrence: this.occurrence,
tableName: this.tableName,
databaseName: this.databaseName,
context: this.context,
recordId: this.recordId,
});
newBuilder.operation = "getSingleField";
newBuilder.operationParam = field.toString();
// Preserve navigation context
newBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet;
newBuilder.navigateRelation = this.navigateRelation;
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
return newBuilder;
}
// Overload for valid relation names - returns typed QueryBuilder
navigate<RelationName extends ExtractNavigationNames<Occ>>(
relationName: RelationName,
): QueryBuilder<
ExtractSchemaFromOccurrence<
FindNavigationTarget<Occ, RelationName>
> extends Record<string, StandardSchemaV1>
? InferSchemaType<
ExtractSchemaFromOccurrence<FindNavigationTarget<Occ, RelationName>>
>
: Record<string, any>
>;
// Overload for arbitrary strings - returns generic QueryBuilder with system fields
navigate(
relationName: string,
): QueryBuilder<{ ROWID: number; ROWMODID: number; [key: string]: any }>;
// Implementation
navigate(relationName: string): QueryBuilder<any> {
// Use the target occurrence if available, otherwise allow untyped navigation
// (useful when types might be incomplete)
const targetOccurrence = this.occurrence?.navigation[relationName];
const builder = new QueryBuilder<any>({
occurrence: targetOccurrence,
tableName: targetOccurrence?.name ?? relationName,
databaseName: this.databaseName,
context: this.context,
});
// Store the navigation info - we'll use it in execute
// Transform relation name to FMTID if using entity IDs
const relationId = targetOccurrence
? transformTableName(targetOccurrence)
: relationName;
(builder as any).isNavigate = true;
(builder as any).navigateRecordId = this.recordId;
(builder as any).navigateRelation = relationId;
// If this RecordBuilder came from a navigated EntitySet, we need to preserve that base path
if (
this.isNavigateFromEntitySet &&
this.navigateSourceTableName &&
this.navigateRelation
) {
// Build the base path: /sourceTable/relation('recordId')/newRelation
(builder as any).navigateSourceTableName = this.navigateSourceTableName;
(builder as any).navigateBaseRelation = this.navigateRelation;
} else {
// Normal record navigation: /tableName('recordId')/relation
// Transform source table name to FMTID if using entity IDs
const sourceTableId = this.occurrence
? transformTableName(this.occurrence)
: this.tableName;
(builder as any).navigateSourceTableName = sourceTableId;
}
return builder;
}
async execute(
options?: RequestInit & FFetchOptions & { useEntityIds?: boolean },
): Promise<
Result<IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata>
> {
let url: string;
// Build the base URL depending on whether this came from a navigated EntitySet
if (
this.isNavigateFromEntitySet &&
this.navigateSourceTableName &&
this.navigateRelation
) {
// From navigated EntitySet: /sourceTable/relation('recordId')
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
} else {
// Normal record: /tableName('recordId') - use FMTID if configured
const tableId = this.getTableId(options?.useEntityIds ?? this.databaseUseEntityIds);
url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
}
if (this.operation === "getSingleField" && this.operationParam) {
url += `/${this.operationParam}`;
}
const mergedOptions = this.mergeExecuteOptions(options);
const result = await this.context._makeRequest(url, mergedOptions);
if (result.error) {
return { data: undefined, error: result.error };
}
let response = result.data;
// Handle single field operation
if (this.operation === "getSingleField") {
// Single field returns a JSON object with @context and value
const fieldResponse = response as ODataFieldResponse<T>;
return { data: fieldResponse.value as any, error: undefined };
}
// Transform response field IDs back to names if using entity IDs
// Only transform if useEntityIds resolves to true (respects per-request override)
const shouldUseIds = mergedOptions.useEntityIds ?? false;
if (this.occurrence?.baseTable && shouldUseIds) {
response = transformResponseFields(
response,
this.occurrence.baseTable,
undefined, // No expand configs for simple get
);
}
// Get schema from occurrence if available
const schema = this.occurrence?.baseTable?.schema;
// Validate the single record response
const validation = await validateSingleResponse<any>(
response,
schema,
undefined, // No selected fields for record.get()
undefined, // No expand configs
"exact", // Expect exactly one record
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
// Handle null response
if (validation.data === null) {
return { data: null as any, error: undefined };
}
return { data: validation.data, error: undefined };
}
getRequestConfig(): { method: string; url: string; body?: any } {
let url: string;
// Build the base URL depending on whether this came from a navigated EntitySet
if (
this.isNavigateFromEntitySet &&
this.navigateSourceTableName &&
this.navigateRelation
) {
// From navigated EntitySet: /sourceTable/relation('recordId')
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
} else {
// For batch operations, use database-level setting (no per-request override available here)
const tableId = this.getTableId(this.databaseUseEntityIds);
url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
}
if (this.operation === "getSingleField" && this.operationParam) {
url += `/${this.operationParam}`;
}
return {
method: "GET",
url,
};
}
toRequest(baseUrl: string): Request {
const config = this.getRequestConfig();
const fullUrl = `${baseUrl}${config.url}`;
return new Request(fullUrl, {
method: config.method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
}
async processResponse(
response: Response,
options?: ExecuteOptions,
): Promise<
Result<IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata>
> {
const rawResponse = await response.json();
// Handle single field operation
if (this.operation === "getSingleField") {
// Single field returns a JSON object with @context and value
const fieldResponse = rawResponse as ODataFieldResponse<T>;
return { data: fieldResponse.value as any, error: undefined };
}
// Transform response field IDs back to names if using entity IDs
// Only transform if useEntityIds resolves to true (respects per-request override)
const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds;
let transformedResponse = rawResponse;
if (this.occurrence?.baseTable && shouldUseIds) {
transformedResponse = transformResponseFields(
rawResponse,
this.occurrence.baseTable,
undefined, // No expand configs for simple get
);
}
// Get schema from occurrence if available
const schema = this.occurrence?.baseTable?.schema;
// Validate the single record response
const validation = await validateSingleResponse<any>(
transformedResponse,
schema,
undefined, // No selected fields for record.get()
undefined, // No expand configs
"exact", // Expect exactly one record
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
// Handle null response
if (validation.data === null) {
return { data: null as any, error: undefined };
}
return { data: validation.data, error: undefined };
}
}