UNPKG

@proofkit/fmodata

Version:
374 lines (327 loc) 12.6 kB
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 }; } }