UNPKG

@proofkit/fmodata

Version:

FileMaker OData API client

1,088 lines (1,014 loc) 40.1 kB
/** biome-ignore-all lint/style/useReadonlyClassProperties: properties are reassigned in cloneWithChanges() */ import type { FFetchOptions } from "@fetchkit/ffetch"; import { Effect } from "effect"; import buildQuery, { type QueryOptions } from "odata-query"; import { requestFromService, runLayerResult } from "../../effect"; import type { FMODataErrorType } from "../../errors"; import { BuilderInvariantError, isFMODataError, RecordCountMismatchError } from "../../errors"; import type { InternalLogger } from "../../logger"; import { type Column, isColumn } from "../../orm/column"; import { type FilterExpression, isOrderByExpression, type OrderByExpression } from "../../orm/operators"; import { type ExtractTableName, type FMTable, getTableName, type InferSchemaOutputFromFMTable, type ValidExpandTarget, } from "../../orm/table"; import type { FMODataLayer, ODataConfig } from "../../services"; import { transformOrderByField } from "../../transform"; import type { ConditionallyWithODataAnnotations, ConditionallyWithSpecialColumns, ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, NormalizeIncludeSpecialColumns, Result, } from "../../types"; import { buildSelectExpandQueryString, cloneQueryReadBuilderState, createInitialQueryReadBuilderState, createODataRequest, ExpandBuilder, type ExpandConfig, type ExpandedRelations, mergeExecuteOptions, processQueryResponse, processSelectWithRenames, } from "../builders/index"; import { parseErrorResponse } from "../error-parser"; import { createClientRuntime } from "../runtime"; import { safeJsonParse } from "../sanitize-json"; import type { QueryReturnType, SystemColumnsOption, TypeSafeOrderBy } from "./types"; import { type NavigationConfig, QueryUrlBuilder } from "./url-builder"; export type { ExpandedRelations } from "../builders/index"; // Re-export types for backward compatibility export type { CountedListResult, QueryReturnType, SystemColumnsOption, TypeSafeOrderBy } from "./types"; /** * Default maximum number of records to return in a list query. * This prevents stack overflow issues with large datasets while still * allowing substantial data retrieval. Users can override with .top(). */ const _DEFAULT_TOP = 1000; function normalizeQueryBuildError(error: unknown): FMODataErrorType { if (isFMODataError(error)) { return error; } if (error instanceof Error) { return new BuilderInvariantError("QueryBuilder.execute", error.message, { cause: error }); } return new BuilderInvariantError("QueryBuilder.execute", String(error)); } type QueryBuilderHasSelect< // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration Occ extends FMTable<any, any>, Selected, // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration > = Selected extends Record<string, Column<any, any, any>> ? true : Selected extends keyof InferSchemaOutputFromFMTable<Occ> ? false : true; type BaseQueryBuilderReturn< // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration Occ extends FMTable<any, any>, Selected extends | keyof InferSchemaOutputFromFMTable<Occ> // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration | Record<string, Column<any, any, ExtractTableName<Occ>>>, SingleMode extends "exact" | "maybe" | false, IsCount extends boolean, Expands extends ExpandedRelations, IncludeCount extends boolean, SystemCols extends SystemColumnsOption | undefined, > = QueryReturnType< InferSchemaOutputFromFMTable<Occ>, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols >; type ExecutableQueryBuilderReturn< // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration Occ extends FMTable<any, any>, Selected extends | keyof InferSchemaOutputFromFMTable<Occ> // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration | Record<string, Column<any, any, ExtractTableName<Occ>>>, SingleMode extends "exact" | "maybe" | false, IsCount extends boolean, Expands extends ExpandedRelations, IncludeCount extends boolean, SystemCols extends SystemColumnsOption | undefined, > = | ConditionallyWithODataAnnotations< ConditionallyWithSpecialColumns< BaseQueryBuilderReturn<Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols>, true, QueryBuilderHasSelect<Occ, Selected> >, true > | ConditionallyWithODataAnnotations< ConditionallyWithSpecialColumns< BaseQueryBuilderReturn<Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols>, true, QueryBuilderHasSelect<Occ, Selected> >, false > | ConditionallyWithODataAnnotations< ConditionallyWithSpecialColumns< BaseQueryBuilderReturn<Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols>, false, QueryBuilderHasSelect<Occ, Selected> >, true > | ConditionallyWithODataAnnotations< ConditionallyWithSpecialColumns< BaseQueryBuilderReturn<Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols>, false, QueryBuilderHasSelect<Occ, Selected> >, false >; export class QueryBuilder< // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration Occ extends FMTable<any, any>, Selected extends | keyof InferSchemaOutputFromFMTable<Occ> // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration | Record<string, Column<any, any, ExtractTableName<Occ>>> = keyof InferSchemaOutputFromFMTable<Occ>, SingleMode extends "exact" | "maybe" | false = false, IsCount extends boolean = false, // biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default Expands extends ExpandedRelations = {}, IncludeCount extends boolean = false, DatabaseIncludeSpecialColumns extends boolean = false, SystemCols extends SystemColumnsOption | undefined = undefined, > implements ExecutableBuilder< ExecutableQueryBuilderReturn<Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols> > { private readState = createInitialQueryReadBuilderState<InferSchemaOutputFromFMTable<Occ>>(); private readonly occurrence: Occ; private readonly expandBuilder: ExpandBuilder; private urlBuilder: QueryUrlBuilder; private readonly layer: FMODataLayer; private readonly config: ODataConfig; private readonly logger: InternalLogger; // Compatibility accessors for internal modules that inspect builder internals via `as any`. private get queryOptions(): Partial<QueryOptions<InferSchemaOutputFromFMTable<Occ>>> { return this.readState.queryOptions; } private set queryOptions(queryOptions: Partial<QueryOptions<InferSchemaOutputFromFMTable<Occ>>>) { this.readState = cloneQueryReadBuilderState(this.readState, { queryOptions, }); } private get expandConfigs(): ExpandConfig[] { return this.readState.expandConfigs; } private set expandConfigs(expandConfigs: ExpandConfig[]) { this.readState = cloneQueryReadBuilderState(this.readState, { expandConfigs, }); } private get singleMode(): SingleMode { return this.readState.singleMode as SingleMode; } private set singleMode(singleMode: SingleMode) { this.readState = cloneQueryReadBuilderState(this.readState, { singleMode, }); } private get isCountMode(): IsCount { return this.readState.isCountMode as IsCount; } private set isCountMode(isCountMode: IsCount) { this.readState = cloneQueryReadBuilderState(this.readState, { isCountMode, }); } private get includeCountMode(): IncludeCount { return this.readState.includeCountMode as IncludeCount; } private set includeCountMode(includeCountMode: IncludeCount) { this.readState = cloneQueryReadBuilderState(this.readState, { includeCountMode, }); } private get fieldMapping(): Record<string, string> | undefined { return this.readState.fieldMapping; } private set fieldMapping(fieldMapping: Record<string, string> | undefined) { this.readState = cloneQueryReadBuilderState(this.readState, { fieldMapping, }); } private get systemColumns(): SystemColumnsOption | undefined { return this.readState.systemColumns; } private set systemColumns(systemColumns: SystemColumnsOption | undefined) { this.readState = cloneQueryReadBuilderState(this.readState, { systemColumns, }); } private get navigation(): NavigationConfig | undefined { return this.readState.navigation; } private set navigation(navigation: NavigationConfig | undefined) { this.setNavigation(navigation); } constructor(config: { occurrence: Occ; layer: FMODataLayer; }) { this.occurrence = config.occurrence; const runtime = createClientRuntime(config.layer); this.layer = runtime.layer; this.config = runtime.config; this.logger = runtime.logger; this.expandBuilder = new ExpandBuilder(this.config.useEntityIds, this.logger); this.urlBuilder = new QueryUrlBuilder(this.config.databaseName, this.occurrence, this.config.useEntityIds); } /** * Helper to merge database-level useEntityIds and includeSpecialColumns with per-request options */ private mergeExecuteOptions(options?: RequestInit & FFetchOptions & ExecuteOptions): RequestInit & FFetchOptions & { useEntityIds?: boolean; includeSpecialColumns?: boolean; } { const merged = mergeExecuteOptions(options, this.config.useEntityIds); return { ...merged, includeSpecialColumns: options?.includeSpecialColumns ?? this.config.includeSpecialColumns, }; } private patchQueryOptions(patch: Partial<QueryOptions<InferSchemaOutputFromFMTable<Occ>>>): void { this.readState = cloneQueryReadBuilderState(this.readState, { queryOptions: patch, }); } private setFilterExpression(expression: FilterExpression | undefined): void { this.readState = cloneQueryReadBuilderState(this.readState, { filterExpression: expression, }); } private setNavigation(navigation: NavigationConfig | undefined): void { this.readState = cloneQueryReadBuilderState(this.readState, { navigation, }); } /** * Creates a new QueryBuilder with modified configuration. * Used by single(), maybeSingle(), count(), and select() to create new instances. */ private cloneWithChanges< NewSelected extends | keyof InferSchemaOutputFromFMTable<Occ> // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration | Record<string, Column<any, any, ExtractTableName<Occ>>> = Selected, NewSingle extends "exact" | "maybe" | false = SingleMode, NewCount extends boolean = IsCount, NewIncludeCount extends boolean = IncludeCount, NewSystemCols extends SystemColumnsOption | undefined = SystemCols, >(changes: { selectedFields?: NewSelected; singleMode?: NewSingle; isCountMode?: NewCount; includeCountMode?: NewIncludeCount; queryOptions?: Partial<QueryOptions<InferSchemaOutputFromFMTable<Occ>>>; fieldMapping?: Record<string, string>; systemColumns?: NewSystemCols; }): QueryBuilder< Occ, NewSelected, NewSingle, NewCount, Expands, NewIncludeCount, DatabaseIncludeSpecialColumns, NewSystemCols > { const newBuilder = new QueryBuilder< Occ, NewSelected, NewSingle, NewCount, Expands, NewIncludeCount, DatabaseIncludeSpecialColumns, NewSystemCols >({ occurrence: this.occurrence, layer: this.layer, }); newBuilder.readState = cloneQueryReadBuilderState(this.readState, { queryOptions: changes.queryOptions, expandConfigs: this.readState.expandConfigs, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter singleMode: (changes.singleMode ?? this.readState.singleMode) as any, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter isCountMode: (changes.isCountMode ?? this.readState.isCountMode) as any, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter includeCountMode: (changes.includeCountMode ?? this.readState.includeCountMode) as any, fieldMapping: "fieldMapping" in changes ? changes.fieldMapping : this.readState.fieldMapping, systemColumns: changes.systemColumns !== undefined ? changes.systemColumns : this.readState.systemColumns, navigation: this.readState.navigation, }); newBuilder.urlBuilder = new QueryUrlBuilder(this.config.databaseName, this.occurrence, this.config.useEntityIds); return newBuilder; } /** * Select fields using column references, or pass "all" to clear any defaultSelect and fetch all fields. * Allows renaming fields by using different keys in the object. * Container fields cannot be selected and will cause a type error. * * @example * db.from(users).list().select({ * name: users.name, * userEmail: users.email // renamed! * }) * * @example * // Include system columns (ROWID, ROWMODID) when using select() * db.from(users).list().select( * { name: users.name }, * { ROWID: true, ROWMODID: true } * ) * * @example * // Override defaultSelect to fetch all fields * db.from(users).list().select("all") * * @param fields - Object mapping output keys to column references (container fields excluded), or "all" to select all fields * @param systemColumns - Optional object to request system columns (ROWID, ROWMODID) * @returns QueryBuilder with updated selected fields */ select( fields: "all", ): QueryBuilder< Occ, keyof InferSchemaOutputFromFMTable<Occ>, SingleMode, IsCount, Expands, IncludeCount, DatabaseIncludeSpecialColumns, undefined >; select< // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration TSelect extends Record<string, Column<any, any, ExtractTableName<Occ>, false>>, // biome-ignore lint/complexity/noBannedTypes: Empty object type represents no system columns by default TSystemCols extends SystemColumnsOption = {}, >( fields: TSelect, systemColumns?: TSystemCols, ): QueryBuilder<Occ, TSelect, SingleMode, IsCount, Expands, IncludeCount, DatabaseIncludeSpecialColumns, TSystemCols>; // biome-ignore lint/suspicious/noExplicitAny: Implementation signature hidden from callers select(fields: any, systemColumns?: any): any { if (fields === "all") { return this.cloneWithChanges({ queryOptions: { select: undefined, }, fieldMapping: undefined, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter systemColumns: undefined as any, }); } const tableName = getTableName(this.occurrence); const { selectedFields, fieldMapping } = processSelectWithRenames(fields, tableName, this.logger); // Add system columns to selectedFields if requested const finalSelectedFields = [...selectedFields]; if (systemColumns?.ROWID) { finalSelectedFields.push("ROWID"); } if (systemColumns?.ROWMODID) { finalSelectedFields.push("ROWMODID"); } return this.cloneWithChanges({ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter selectedFields: fields as any, queryOptions: { select: finalSelectedFields, }, fieldMapping: Object.keys(fieldMapping).length > 0 ? fieldMapping : undefined, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter systemColumns: systemColumns as any, }); } /** * Filter results using operator expressions (new ORM-style API). * Supports eq, gt, lt, and, or, etc. operators with Column references. * Also supports raw OData filter strings as an escape hatch. * * @example * .where(eq(users.hobby, "reading")) * .where(and(eq(users.active, true), gt(users.age, 18))) * .where("status eq 'active'") // Raw OData string escape hatch */ where( expression: FilterExpression | string, ): QueryBuilder< Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, DatabaseIncludeSpecialColumns, SystemCols > { // Handle raw string filters (escape hatch) if (typeof expression === "string") { this.setFilterExpression(undefined); this.patchQueryOptions({ filter: expression }); return this; } // Defer serialization until execute/getQueryString so per-request useEntityIds is honored this.setFilterExpression(expression); this.patchQueryOptions({ filter: undefined }); return this; } /** * Specify the sort order for query results. * * @example Single field (ascending by default) * ```ts * .orderBy("name") * .orderBy(users.name) // Column reference * .orderBy(asc(users.name)) // Explicit ascending * ``` * * @example Single field with explicit direction * ```ts * .orderBy(["name", "desc"]) * .orderBy([users.name, "desc"]) // Column reference * .orderBy(desc(users.name)) // Explicit descending * ``` * * @example Multiple fields with directions * ```ts * .orderBy([["name", "asc"], ["createdAt", "desc"]]) * .orderBy([[users.name, "asc"], [users.createdAt, "desc"]]) // Column references * .orderBy(users.name, desc(users.age)) // Variadic with helpers * ``` */ orderBy( ...orderByArgs: | [ | TypeSafeOrderBy<InferSchemaOutputFromFMTable<Occ>> // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration | Column<any, any, ExtractTableName<Occ>> | OrderByExpression<ExtractTableName<Occ>>, ] | [ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration Column<any, any, ExtractTableName<Occ>>, // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration ...Array<Column<any, any, ExtractTableName<Occ>> | OrderByExpression<ExtractTableName<Occ>>>, ] ): QueryBuilder< Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, DatabaseIncludeSpecialColumns, SystemCols > { const tableName = getTableName(this.occurrence); // Handle variadic arguments (multiple fields) if (orderByArgs.length > 1) { const orderByParts = orderByArgs.map((arg) => { if (isOrderByExpression(arg)) { // Validate table match if (arg.column.tableName !== tableName) { this.logger.warn( `Column ${arg.column.toString()} is from table "${arg.column.tableName}", but query is for table "${tableName}"`, ); } const fieldName = arg.column.fieldName; const transformedField = this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName; return `${transformedField} ${arg.direction}`; } if (isColumn(arg)) { // Validate table match if (arg.tableName !== tableName) { this.logger.warn( `Column ${arg.toString()} is from table "${arg.tableName}", but query is for table "${tableName}"`, ); } const fieldName = arg.fieldName; const transformedField = this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName; return transformedField; // Default to ascending } throw new Error("Variadic orderBy() only accepts Column or OrderByExpression arguments"); }); this.patchQueryOptions({ orderBy: orderByParts }); return this; } // Handle single argument const orderBy = orderByArgs[0]; // Handle OrderByExpression if (isOrderByExpression(orderBy)) { // Validate table match if (orderBy.column.tableName !== tableName) { this.logger.warn( `Column ${orderBy.column.toString()} is from table "${orderBy.column.tableName}", but query is for table "${tableName}"`, ); } const fieldName = orderBy.column.fieldName; const transformedField = this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName; this.patchQueryOptions({ orderBy: `${transformedField} ${orderBy.direction}` }); return this; } // Handle Column references if (isColumn(orderBy)) { // Validate table match if (orderBy.tableName !== tableName) { this.logger.warn( `Column ${orderBy.toString()} is from table "${orderBy.tableName}", but query is for table "${tableName}"`, ); } // Single Column reference without direction (defaults to ascending) const fieldName = orderBy.fieldName; this.patchQueryOptions({ orderBy: this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName, }); return this; } // Transform field names to FMFIDs if using entity IDs if (this.occurrence && orderBy) { if (Array.isArray(orderBy)) { // Check if it's a single tuple [field, direction] or array of tuples if ( orderBy.length === 2 && (typeof orderBy[0] === "string" || isColumn(orderBy[0])) && (orderBy[1] === "asc" || orderBy[1] === "desc") ) { // Single tuple: [field, direction] or [column, direction] const field = isColumn(orderBy[0]) ? orderBy[0].fieldName : orderBy[0]; const direction = orderBy[1] as "asc" | "desc"; this.patchQueryOptions({ orderBy: `${transformOrderByField(field, this.occurrence)} ${direction}` }); } else { // Array of tuples: [[field, dir], [field, dir], ...] this.patchQueryOptions({ orderBy: (orderBy as [unknown, "asc" | "desc"][]).map(([fieldOrCol, direction]) => { const field = isColumn(fieldOrCol) ? fieldOrCol.fieldName : String(fieldOrCol); const transformedField = this.occurrence ? transformOrderByField(field, this.occurrence) : field; return `${transformedField} ${direction}`; }), }); } } else { // Single field name (string) this.patchQueryOptions({ orderBy: transformOrderByField(String(orderBy), this.occurrence) }); } } else if (Array.isArray(orderBy)) { if ( orderBy.length === 2 && (typeof orderBy[0] === "string" || isColumn(orderBy[0])) && (orderBy[1] === "asc" || orderBy[1] === "desc") ) { // Single tuple: [field, direction] or [column, direction] const field = isColumn(orderBy[0]) ? orderBy[0].fieldName : orderBy[0]; const direction = orderBy[1] as "asc" | "desc"; this.patchQueryOptions({ orderBy: `${field} ${direction}` }); } else { // Array of tuples this.patchQueryOptions({ orderBy: (orderBy as [unknown, "asc" | "desc"][]).map(([fieldOrCol, direction]) => { const field = isColumn(fieldOrCol) ? fieldOrCol.fieldName : String(fieldOrCol); return `${field} ${direction}`; }), }); } } else { this.patchQueryOptions({ orderBy }); } return this; } top( count: number, ): QueryBuilder< Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, DatabaseIncludeSpecialColumns, SystemCols > { this.patchQueryOptions({ top: count }); return this; } skip( count: number, ): QueryBuilder< Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, DatabaseIncludeSpecialColumns, SystemCols > { this.patchQueryOptions({ skip: count }); return this; } expand< // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration TargetTable extends FMTable<any, any>, TSelected extends | keyof InferSchemaOutputFromFMTable<TargetTable> | Record< string, // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration Column<any, any, ExtractTableName<TargetTable>> > = keyof InferSchemaOutputFromFMTable<TargetTable>, // biome-ignore lint/complexity/noBannedTypes: Empty object type represents no nested expands by default TNestedExpands extends ExpandedRelations = {}, >( targetTable: ValidExpandTarget<Occ, TargetTable>, callback?: ( // biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands in initial builder builder: QueryBuilder<TargetTable, keyof InferSchemaOutputFromFMTable<TargetTable>, false, false, {}, false>, // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryBuilder configuration ) => QueryBuilder<TargetTable, TSelected, any, any, TNestedExpands, any, any>, ): QueryBuilder< Occ, Selected, SingleMode, IsCount, Expands & { [K in ExtractTableName<TargetTable>]: { schema: InferSchemaOutputFromFMTable<TargetTable>; selected: TSelected; nested: TNestedExpands; }; }, IncludeCount, DatabaseIncludeSpecialColumns, SystemCols > { // Use ExpandBuilder.processExpand to handle the expand logic type TargetBuilder = QueryBuilder< TargetTable, keyof InferSchemaOutputFromFMTable<TargetTable>, false, false, // biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands in new builder {}, false, DatabaseIncludeSpecialColumns >; const expandConfig = this.expandBuilder.processExpand<TargetTable, TargetBuilder>( targetTable, this.occurrence, callback as ((builder: TargetBuilder) => TargetBuilder) | undefined, () => // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryBuilder configuration new QueryBuilder<TargetTable, any, any, any, any, any, DatabaseIncludeSpecialColumns, undefined>({ occurrence: targetTable, layer: this.layer, }), ); this.readState = cloneQueryReadBuilderState(this.readState, { expandConfigs: [...this.readState.expandConfigs, expandConfig], }); // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type return this as any; } single( this: QueryBuilder<Occ, Selected, false, IsCount, Expands, false, DatabaseIncludeSpecialColumns, SystemCols>, ): QueryBuilder<Occ, Selected, "exact", IsCount, Expands, false, DatabaseIncludeSpecialColumns, SystemCols> { if (this.readState.includeCountMode) { throw new BuilderInvariantError("QueryBuilder.single", "count-enabled list queries cannot use single()"); } return this.cloneWithChanges({ singleMode: "exact" as const }); } maybeSingle( this: QueryBuilder<Occ, Selected, false, IsCount, Expands, false, DatabaseIncludeSpecialColumns, SystemCols>, ): QueryBuilder<Occ, Selected, "maybe", IsCount, Expands, false, DatabaseIncludeSpecialColumns, SystemCols> { if (this.readState.includeCountMode) { throw new BuilderInvariantError( "QueryBuilder.maybeSingle", "count-enabled list queries cannot use maybeSingle()", ); } return this.cloneWithChanges({ singleMode: "maybe" as const }); } count( this: QueryBuilder<Occ, Selected, false, IsCount, Expands, false, DatabaseIncludeSpecialColumns, SystemCols>, ): QueryBuilder<Occ, Selected, false, IsCount, Expands, true, DatabaseIncludeSpecialColumns, SystemCols> { if (this.readState.singleMode !== false) { throw new BuilderInvariantError( "QueryBuilder.count", "single() and maybeSingle() cannot be combined with count()", ); } return this.cloneWithChanges({ includeCountMode: true as const, queryOptions: { count: true }, }); } /** * Builds the OData query string from current query options and expand configs. */ private buildQueryString(includeSpecialColumns?: boolean, useEntityIds?: boolean): string { // Use provided useEntityIds if provided, otherwise use database-level default const finalUseEntityIds = useEntityIds ?? this.config.useEntityIds; // Build query without expand and select (we'll add them manually if using entity IDs) const queryOptionsWithoutExpandAndSelect = { ...this.readState.queryOptions }; if (this.readState.filterExpression) { queryOptionsWithoutExpandAndSelect.filter = this.readState.filterExpression.toODataFilter(finalUseEntityIds); } const originalSelect = queryOptionsWithoutExpandAndSelect.select; queryOptionsWithoutExpandAndSelect.expand = undefined; queryOptionsWithoutExpandAndSelect.select = undefined; let queryString = buildQuery(queryOptionsWithoutExpandAndSelect); // Use shared helper for select/expand portion let selectArray: string[] | undefined; if (originalSelect) { selectArray = Array.isArray(originalSelect) ? originalSelect.map(String) : [String(originalSelect)]; } // Use merged includeSpecialColumns if provided, otherwise use database-level default const finalIncludeSpecialColumns = includeSpecialColumns ?? this.config.includeSpecialColumns; const selectExpandString = buildSelectExpandQueryString({ selectedFields: selectArray, expandConfigs: this.readState.expandConfigs, table: this.occurrence, useEntityIds: finalUseEntityIds, logger: this.logger, includeSpecialColumns: finalIncludeSpecialColumns, }); // Append select/expand to existing query string if (selectExpandString) { // Strip leading ? from helper result and append with appropriate separator const params = selectExpandString.startsWith("?") ? selectExpandString.slice(1) : selectExpandString; const separator = queryString.includes("?") ? "&" : "?"; queryString = `${queryString}${separator}${params}`; } return queryString; } execute<EO extends ExecuteOptions>( options?: ExecuteMethodOptions<EO>, ): Promise< Result< ConditionallyWithODataAnnotations< ConditionallyWithSpecialColumns< QueryReturnType< InferSchemaOutputFromFMTable<Occ>, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols >, // Use the merged value: if explicitly provided in options, use that; otherwise use database default NormalizeIncludeSpecialColumns<EO["includeSpecialColumns"], DatabaseIncludeSpecialColumns>, // Check if select was applied: if Selected is Record (object select) or a subset of keys, select was applied // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration Selected extends Record<string, Column<any, any, any>> ? true : Selected extends keyof InferSchemaOutputFromFMTable<Occ> ? false : true >, EO["includeODataAnnotations"] extends true ? true : false > > > { type ExecuteResponse = ConditionallyWithODataAnnotations< ConditionallyWithSpecialColumns< QueryReturnType< InferSchemaOutputFromFMTable<Occ>, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols >, NormalizeIncludeSpecialColumns<EO["includeSpecialColumns"], DatabaseIncludeSpecialColumns>, // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration Selected extends Record<string, Column<any, any, any>> ? true : Selected extends keyof InferSchemaOutputFromFMTable<Occ> ? false : true >, EO["includeODataAnnotations"] extends true ? true : false >; const mergedOptions = this.mergeExecuteOptions(options); let queryString: string; try { queryString = this.buildQueryString(mergedOptions.includeSpecialColumns, mergedOptions.useEntityIds); } catch (error) { return Promise.resolve({ data: undefined, error: normalizeQueryBuildError(error), }) as Promise<Result<ExecuteResponse>>; } // Handle $count endpoint if (this.readState.isCountMode) { let url: string; try { url = this.urlBuilder.build(queryString, { isCount: true, useEntityIds: mergedOptions.useEntityIds, navigation: this.readState.navigation, }); } catch (error) { return Promise.resolve({ data: undefined, error: normalizeQueryBuildError(error), }) as Promise<Result<ExecuteResponse>>; } const pipeline = requestFromService(url, mergedOptions).pipe( Effect.map((data) => { const count = typeof data === "string" ? Number(data) : data; return count as number; }), ); return runLayerResult(this.layer, pipeline, "fmodata.query.count", { "fmodata.table": getTableName(this.occurrence), }) as Promise<Result<ExecuteResponse>>; } let url: string; try { url = this.urlBuilder.build(queryString, { isCount: this.readState.isCountMode, useEntityIds: mergedOptions.useEntityIds, navigation: this.readState.navigation, }); } catch (error) { return Promise.resolve({ data: undefined, error: normalizeQueryBuildError(error), }) as Promise<Result<ExecuteResponse>>; } const pipeline = requestFromService(url, mergedOptions).pipe( Effect.flatMap((data) => Effect.tryPromise({ try: () => processQueryResponse(data, { occurrence: this.occurrence, singleMode: this.readState.singleMode as SingleMode, queryOptions: this.readState.queryOptions as { select?: (keyof InferSchemaOutputFromFMTable<Occ>)[] | string[]; }, expandConfigs: this.readState.expandConfigs, skipValidation: options?.skipValidation, useEntityIds: mergedOptions.useEntityIds, includeSpecialColumns: mergedOptions.includeSpecialColumns, includeCount: this.readState.includeCountMode, fieldMapping: this.readState.fieldMapping, logger: this.logger, }), catch: (e) => (e instanceof Error ? e : new Error(String(e))), }), ), // processQueryResponse returns a Result, so we need to unwrap it Effect.flatMap((result) => (result.error ? Effect.fail(result.error) : Effect.succeed(result.data))), ); return runLayerResult( this.layer, pipeline, this.readState.singleMode ? "fmodata.query.single" : "fmodata.query.list", { "fmodata.table": getTableName(this.occurrence) }, ) as Promise<Result<ExecuteResponse>>; } getQueryString(options?: { useEntityIds?: boolean }): string { const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds; const queryString = this.buildQueryString(undefined, useEntityIds); return this.urlBuilder.buildPath(queryString, { useEntityIds, navigation: this.readState.navigation, }); } // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value getRequestConfig(): { method: string; url: string; body?: any } { const queryString = this.buildQueryString(); const url = this.urlBuilder.build(queryString, { isCount: this.readState.isCountMode, useEntityIds: this.config.useEntityIds, navigation: this.readState.navigation, }); 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< QueryReturnType< InferSchemaOutputFromFMTable<Occ>, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols > > > { // Check for error responses (important for batch operations) if (!response.ok) { const error = await parseErrorResponse( response, response.url || `/${this.config.databaseName}/${getTableName(this.occurrence)}`, ); return { data: undefined, error }; } // Handle 204 No Content (shouldn't happen for queries, but handle it gracefully) if (response.status === 204) { // Return empty list for list queries, null for single queries if (this.readState.singleMode !== false) { if (this.readState.singleMode === "maybe") { // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type return { data: null as any, error: undefined }; } return { data: undefined, error: new RecordCountMismatchError("one", 0), }; } // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type return { data: [] as any, error: undefined }; } // Parse the response body (using safeJsonParse to handle FileMaker's invalid JSON with unquoted ? values) let rawData: unknown; try { rawData = await safeJsonParse(response); } catch (err) { // Check if it's an empty body error (common with 204 responses) if (err instanceof SyntaxError && response.status === 204) { // Handled above, but just in case // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type return { data: [] as any, error: undefined }; } return { data: undefined, error: { name: "ResponseParseError", message: `Failed to parse response JSON: ${err instanceof Error ? err.message : "Unknown error"}`, timestamp: new Date(), // biome-ignore lint/suspicious/noExplicitAny: Type assertion for error object } as any, }; } if (!rawData) { return { data: undefined, error: { name: "ResponseError", message: "Response body was empty or null", timestamp: new Date(), // biome-ignore lint/suspicious/noExplicitAny: Type assertion for error object } as any, }; } const mergedOptions = this.mergeExecuteOptions(options); // Check if select was applied (runtime check) const _hasSelect = this.readState.queryOptions.select !== undefined; return processQueryResponse(rawData, { occurrence: this.occurrence, singleMode: this.readState.singleMode as SingleMode, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter queryOptions: this.readState.queryOptions as any, expandConfigs: this.readState.expandConfigs, skipValidation: options?.skipValidation, useEntityIds: mergedOptions.useEntityIds, includeSpecialColumns: mergedOptions.includeSpecialColumns, includeCount: this.readState.includeCountMode, fieldMapping: this.readState.fieldMapping, logger: this.logger, }); } }