@proofkit/fmodata
Version:
FileMaker OData API client
1,088 lines (1,014 loc) • 40.1 kB
text/typescript
/** 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,
});
}
}