@proofkit/fmodata
Version:
FileMaker OData API client
1,456 lines (1,318 loc) • 47.4 kB
text/typescript
import { QueryOptions } from "odata-query";
import buildQuery from "odata-query";
import type {
ExecutionContext,
ExecutableBuilder,
WithSystemFields,
Result,
InferSchemaType,
ExecuteOptions,
ConditionallyWithODataAnnotations,
ExtractSchemaFromOccurrence,
} from "../types";
import type { Filter } from "../filter-types";
import type { TableOccurrence } from "./table-occurrence";
import type { BaseTable } from "./base-table";
import { validateListResponse, validateSingleResponse } from "../validation";
import { RecordCountMismatchError } from "../errors";
import { type FFetchOptions } from "@fetchkit/ffetch";
import type { StandardSchemaV1 } from "@standard-schema/spec";
import {
transformFieldNamesArray,
transformFieldName,
transformOrderByField,
transformResponseFields,
getTableIdentifiers,
} from "../transform";
/**
* 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;
// 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 & string
: 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>
? Nav extends Record<string, any>
? Name extends keyof Nav
? ResolveNavigationItem<Nav[Name]>
: TableOccurrence<
BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
any,
any,
any
>
: TableOccurrence<
BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
any,
any,
any
>
: TableOccurrence<
BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
any,
any,
any
>;
// Helper type to get the inferred schema type from a target occurrence
type GetTargetSchemaType<
O extends TableOccurrence<any, any, any, any> | undefined,
Rel extends string,
> = [FindNavigationTarget<O, Rel>] extends [
TableOccurrence<infer BT, any, any, any>,
]
? [BT] extends [BaseTable<infer S, any, any, any>]
? [S] extends [Record<string, StandardSchemaV1>]
? InferSchemaType<S>
: Record<string, any>
: Record<string, any>
: Record<string, any>;
// Internal type for expand configuration
type ExpandConfig = {
relation: string;
options?: Partial<QueryOptions<any>>;
};
// Type to represent expanded relations
export type ExpandedRelations = Record<string, { schema: any; selected: any }>;
export type QueryReturnType<
T extends Record<string, any>,
Selected extends keyof T,
SingleMode extends "exact" | "maybe" | false,
IsCount extends boolean,
Expands extends ExpandedRelations,
> = IsCount extends true
? number
: SingleMode extends "exact"
? Pick<T, Selected> & {
[K in keyof Expands]: Pick<
Expands[K]["schema"],
Expands[K]["selected"]
>[];
}
: SingleMode extends "maybe"
?
| (Pick<T, Selected> & {
[K in keyof Expands]: Pick<
Expands[K]["schema"],
Expands[K]["selected"]
>[];
})
| null
: (Pick<T, Selected> & {
[K in keyof Expands]: Pick<
Expands[K]["schema"],
Expands[K]["selected"]
>[];
})[];
export class QueryBuilder<
T extends Record<string, any>,
Selected extends keyof T = keyof T,
SingleMode extends "exact" | "maybe" | false = false,
IsCount extends boolean = false,
Occ extends TableOccurrence<any, any, any, any> | undefined = undefined,
Expands extends ExpandedRelations = {},
> implements
ExecutableBuilder<
QueryReturnType<T, Selected, SingleMode, IsCount, Expands>
>
{
private queryOptions: Partial<QueryOptions<T>> = {};
private expandConfigs: ExpandConfig[] = [];
private singleMode: SingleMode = false as SingleMode;
private isCountMode = false as IsCount;
private occurrence?: Occ;
private tableName: string;
private databaseName: string;
private context: ExecutionContext;
private isNavigate?: boolean;
private navigateRecordId?: string | number;
private navigateRelation?: string;
private navigateSourceTableName?: string;
private navigateBaseRelation?: string;
private databaseUseEntityIds: boolean;
constructor(config: {
occurrence?: Occ;
tableName: string;
databaseName: string;
context: ExecutionContext;
databaseUseEntityIds?: boolean;
}) {
this.occurrence = config.occurrence;
this.tableName = config.tableName;
this.databaseName = config.databaseName;
this.context = config.context;
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 === undefined
? this.databaseUseEntityIds
: options.useEntityIds,
};
}
/**
* Helper to conditionally strip OData annotations based on options
*/
private stripODataAnnotationsIfNeeded<T extends Record<string, any>>(
data: T,
options?: ExecuteOptions,
): T {
// Only include annotations if explicitly requested
if (options?.includeODataAnnotations === true) {
return data;
}
// Strip OData annotations
const { "@id": _id, "@editLink": _editLink, ...rest } = data;
return rest as T;
}
/**
* 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();
}
select<K extends keyof T>(
...fields: K[]
): QueryBuilder<T, K, SingleMode, IsCount, Occ, Expands> {
const uniqueFields = [...new Set(fields)];
const newBuilder = new QueryBuilder<
T,
K,
SingleMode,
IsCount,
Occ,
Expands
>({
occurrence: this.occurrence,
tableName: this.tableName,
databaseName: this.databaseName,
context: this.context,
databaseUseEntityIds: this.databaseUseEntityIds,
});
newBuilder.queryOptions = {
...this.queryOptions,
select: uniqueFields as string[],
};
newBuilder.expandConfigs = [...this.expandConfigs];
newBuilder.singleMode = this.singleMode;
newBuilder.isCountMode = this.isCountMode;
// Preserve navigation metadata
newBuilder.isNavigate = this.isNavigate;
newBuilder.navigateRecordId = this.navigateRecordId;
newBuilder.navigateRelation = this.navigateRelation;
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
newBuilder.navigateBaseRelation = this.navigateBaseRelation;
return newBuilder;
}
/**
* Transforms our filter format to odata-query's expected format
* - Arrays of operators are converted to AND conditions
* - Single operator objects pass through as-is
* - Shorthand values are handled by odata-query
*/
private transformFilter(
filter: Filter<ExtractSchemaFromOccurrence<Occ>>,
): QueryOptions<T>["filter"] {
if (typeof filter === "string") {
// Raw string filters pass through
return filter;
}
if (Array.isArray(filter)) {
// Array of filters - odata-query handles this as implicit AND
return filter.map((f) => this.transformFilter(f as any)) as any;
}
// Check if it's a logical filter (and/or/not)
if ("and" in filter || "or" in filter || "not" in filter) {
const result: any = {};
if ("and" in filter && Array.isArray(filter.and)) {
result.and = filter.and.map((f: any) => this.transformFilter(f));
}
if ("or" in filter && Array.isArray(filter.or)) {
result.or = filter.or.map((f: any) => this.transformFilter(f));
}
if ("not" in filter && filter.not) {
result.not = this.transformFilter(filter.not as any);
}
return result;
}
// Transform field filters
const result: any = {};
const andConditions: any[] = [];
for (const [field, value] of Object.entries(filter)) {
// Transform field name to FMFID if using entity IDs
const fieldId = this.occurrence?.baseTable
? transformFieldName(field, this.occurrence.baseTable)
: field;
if (Array.isArray(value)) {
// Array of operators - convert to AND conditions
if (value.length === 1) {
// Single operator in array - unwrap it
result[fieldId] = value[0];
} else {
// Multiple operators - combine with AND
// Create separate conditions for each operator
for (const op of value) {
andConditions.push({ [fieldId]: op });
}
}
} else if (
value &&
typeof value === "object" &&
!(value instanceof Date) &&
!Array.isArray(value)
) {
// Check if it's an operator object (has operator keys like eq, gt, etc.)
const operatorKeys = [
"eq",
"ne",
"gt",
"ge",
"lt",
"le",
"contains",
"startswith",
"endswith",
"in",
];
const isOperatorObject = operatorKeys.some((key) => key in value);
if (isOperatorObject) {
// Single operator object - pass through
result[fieldId] = value;
} else {
// Regular object - might be nested filter, pass through
result[fieldId] = value;
}
} else {
// Primitive value (shorthand) - pass through
result[fieldId] = value;
}
}
// If we have AND conditions from arrays, combine them
if (andConditions.length > 0) {
if (Object.keys(result).length > 0) {
// We have both regular fields and array-derived AND conditions
// Combine everything with AND
return { and: [...andConditions, result] };
} else {
// Only array-derived AND conditions
return { and: andConditions };
}
}
return result;
}
filter(
filter: Filter<ExtractSchemaFromOccurrence<Occ>>,
): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
// Transform our filter format to odata-query's expected format
this.queryOptions.filter = this.transformFilter(filter) as any;
return this;
}
orderBy(
orderBy: QueryOptions<T>["orderBy"],
): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
// Transform field names to FMFIDs if using entity IDs
if (this.occurrence?.baseTable && orderBy) {
if (Array.isArray(orderBy)) {
this.queryOptions.orderBy = orderBy.map((field) =>
transformOrderByField(String(field), this.occurrence!.baseTable),
);
} else {
this.queryOptions.orderBy = transformOrderByField(
String(orderBy),
this.occurrence.baseTable,
);
}
} else {
this.queryOptions.orderBy = orderBy;
}
return this;
}
top(
count: number,
): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
this.queryOptions.top = count;
return this;
}
skip(
count: number,
): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
this.queryOptions.skip = count;
return this;
}
/**
* Formats select fields for use in query strings.
* - Transforms field names to FMFIDs if using entity IDs
* - Wraps "id" fields in double quotes
* - URL-encodes special characters but preserves spaces
*/
private formatSelectFields(
select: QueryOptions<any>["select"],
baseTable?: BaseTable<any, any, any, any>,
): string {
if (!select) return "";
const selectFieldsArray = Array.isArray(select) ? select : [select];
// Transform to field IDs if using entity IDs
const transformedFields = baseTable
? transformFieldNamesArray(
selectFieldsArray.map((f) => String(f)),
baseTable,
)
: selectFieldsArray.map((f) => String(f));
return transformedFields
.map((field) => {
if (field === "id") return `"id"`;
const encodedField = encodeURIComponent(String(field));
return encodedField.replace(/%20/g, " ");
})
.join(",");
}
/**
* Builds expand validation configs from internal expand configurations.
* These are used to validate expanded navigation properties.
*/
private buildExpandValidationConfigs(
configs: ExpandConfig[],
): import("../validation").ExpandValidationConfig[] {
return configs.map((config) => {
// Look up target occurrence from navigation
const targetOccurrence = this.occurrence?.navigation[config.relation];
const targetSchema = targetOccurrence?.baseTable?.schema;
// Extract selected fields from options
const selectedFields = config.options?.select
? Array.isArray(config.options.select)
? config.options.select.map((f) => String(f))
: [String(config.options.select)]
: undefined;
return {
relation: config.relation,
targetSchema: targetSchema,
targetOccurrence: targetOccurrence,
targetBaseTable: targetOccurrence?.baseTable,
occurrence: targetOccurrence, // Add occurrence for transformation
selectedFields: selectedFields,
nestedExpands: undefined, // TODO: Handle nested expands if needed
};
});
}
/**
* Builds OData expand query string from expand configurations.
* Handles nested expands recursively.
* Transforms relation names to FMTIDs if using entity IDs.
*/
private buildExpandString(configs: ExpandConfig[]): string {
if (configs.length === 0) {
return "";
}
return configs
.map((config) => {
// Get target occurrence for this relation
const targetOccurrence = this.occurrence?.navigation[config.relation];
// When using entity IDs, use the target table's FMTID in the expand parameter
// FileMaker expects FMTID in $expand when Prefer header is set
const relationName =
targetOccurrence && targetOccurrence.isUsingTableId()
? targetOccurrence.getTableId()
: config.relation;
if (!config.options || Object.keys(config.options).length === 0) {
// Simple expand without options
return relationName;
}
// Build query options for this expand
const parts: string[] = [];
if (config.options.select) {
// Pass target base table for field transformation
const selectFields = this.formatSelectFields(
config.options.select,
targetOccurrence?.baseTable,
);
parts.push(`$select=${selectFields}`);
}
if (config.options.filter) {
// Filter should already be transformed by the nested builder
// Use odata-query to build filter string
const filterQuery = buildQuery({ filter: config.options.filter });
const filterMatch = filterQuery.match(/\$filter=([^&]+)/);
if (filterMatch) {
parts.push(`$filter=${filterMatch[1]}`);
}
}
if (config.options.orderBy) {
// OrderBy should already be transformed by the nested builder
const orderByValue = Array.isArray(config.options.orderBy)
? config.options.orderBy.join(",")
: config.options.orderBy;
parts.push(`$orderby=${String(orderByValue)}`);
}
if (config.options.top !== undefined) {
parts.push(`$top=${config.options.top}`);
}
if (config.options.skip !== undefined) {
parts.push(`$skip=${config.options.skip}`);
}
// Handle nested expands (from expand configs)
if (config.options.expand) {
// If expand is a string, it's already been built
if (typeof config.options.expand === "string") {
parts.push(`$expand=${config.options.expand}`);
}
}
if (parts.length === 0) {
return relationName;
}
return `${relationName}(${parts.join(";")})`;
})
.join(",");
}
expand<
Rel extends ExtractNavigationNames<Occ> | (string & {}),
TargetOcc extends FindNavigationTarget<Occ, Rel> = FindNavigationTarget<
Occ,
Rel
>,
TargetSchema extends GetTargetSchemaType<Occ, Rel> = GetTargetSchemaType<
Occ,
Rel
>,
TargetSelected extends keyof TargetSchema = keyof TargetSchema,
>(
relation: Rel,
callback?: (
builder: QueryBuilder<
TargetSchema,
keyof TargetSchema,
false,
false,
TargetOcc extends TableOccurrence<any, any, any, any>
? TargetOcc
: undefined
>,
) => QueryBuilder<
WithSystemFields<TargetSchema>,
TargetSelected,
any,
any,
any
>,
): QueryBuilder<
T,
Selected,
SingleMode,
IsCount,
Occ,
Expands & {
[K in Rel]: { schema: TargetSchema; selected: TargetSelected };
}
> {
// Look up target occurrence from navigation
const targetOccurrence = this.occurrence?.navigation[relation as string];
if (callback) {
// Create a new QueryBuilder for the target occurrence
const targetBuilder = new QueryBuilder<any>({
occurrence: targetOccurrence,
tableName: targetOccurrence?.name ?? (relation as string),
databaseName: this.databaseName,
context: this.context,
databaseUseEntityIds: this.databaseUseEntityIds,
});
// Cast to the expected type for the callback
// At runtime, the builder is untyped (any), but at compile-time we enforce proper types
const typedBuilder = targetBuilder as QueryBuilder<
TargetSchema,
keyof TargetSchema,
false,
false,
TargetOcc extends TableOccurrence<any, any, any, any>
? TargetOcc
: undefined
>;
// Pass to callback and get configured builder
const configuredBuilder = callback(typedBuilder);
// Extract the builder's query options
const expandOptions: Partial<QueryOptions<any>> = {
...configuredBuilder.queryOptions,
};
// If the configured builder has nested expands, we need to include them
if (configuredBuilder.expandConfigs.length > 0) {
// Build nested expand string from the configured builder's expand configs
const nestedExpandString = this.buildExpandString(
configuredBuilder.expandConfigs,
);
if (nestedExpandString) {
// Add nested expand to options
expandOptions.expand = nestedExpandString as any;
}
}
const expandConfig: ExpandConfig = {
relation: relation as string,
options: expandOptions,
};
this.expandConfigs.push(expandConfig);
} else {
// Simple expand without callback
this.expandConfigs.push({ relation: relation as string });
}
return this as any;
}
single(): QueryBuilder<T, Selected, "exact", IsCount, Occ, Expands> {
const newBuilder = new QueryBuilder<
T,
Selected,
"exact",
IsCount,
Occ,
Expands
>({
occurrence: this.occurrence,
tableName: this.tableName,
databaseName: this.databaseName,
context: this.context,
databaseUseEntityIds: this.databaseUseEntityIds,
});
newBuilder.queryOptions = { ...this.queryOptions };
newBuilder.expandConfigs = [...this.expandConfigs];
newBuilder.singleMode = "exact";
newBuilder.isCountMode = this.isCountMode;
// Preserve navigation metadata
newBuilder.isNavigate = this.isNavigate;
newBuilder.navigateRecordId = this.navigateRecordId;
newBuilder.navigateRelation = this.navigateRelation;
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
newBuilder.navigateBaseRelation = this.navigateBaseRelation;
return newBuilder;
}
maybeSingle(): QueryBuilder<T, Selected, "maybe", IsCount, Occ, Expands> {
const newBuilder = new QueryBuilder<
T,
Selected,
"maybe",
IsCount,
Occ,
Expands
>({
occurrence: this.occurrence,
tableName: this.tableName,
databaseName: this.databaseName,
context: this.context,
databaseUseEntityIds: this.databaseUseEntityIds,
});
newBuilder.queryOptions = { ...this.queryOptions };
newBuilder.expandConfigs = [...this.expandConfigs];
newBuilder.singleMode = "maybe";
newBuilder.isCountMode = this.isCountMode;
// Preserve navigation metadata
newBuilder.isNavigate = this.isNavigate;
newBuilder.navigateRecordId = this.navigateRecordId;
newBuilder.navigateRelation = this.navigateRelation;
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
newBuilder.navigateBaseRelation = this.navigateBaseRelation;
return newBuilder;
}
count(): QueryBuilder<T, Selected, SingleMode, true, Occ, Expands> {
const newBuilder = new QueryBuilder<
T,
Selected,
SingleMode,
true,
Occ,
Expands
>({
occurrence: this.occurrence,
tableName: this.tableName,
databaseName: this.databaseName,
context: this.context,
databaseUseEntityIds: this.databaseUseEntityIds,
});
newBuilder.queryOptions = { ...this.queryOptions, count: true };
newBuilder.expandConfigs = [...this.expandConfigs];
newBuilder.singleMode = this.singleMode;
newBuilder.isCountMode = true as true;
// Preserve navigation metadata
newBuilder.isNavigate = this.isNavigate;
newBuilder.navigateRecordId = this.navigateRecordId;
newBuilder.navigateRelation = this.navigateRelation;
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
newBuilder.navigateBaseRelation = this.navigateBaseRelation;
return newBuilder;
}
async execute<EO extends ExecuteOptions>(
options?: RequestInit & FFetchOptions & EO,
): Promise<
Result<
IsCount extends true
? number
: SingleMode extends "exact"
? ConditionallyWithODataAnnotations<
Pick<T, Selected> & {
[K in keyof Expands]: Pick<
Expands[K]["schema"],
Expands[K]["selected"]
>[];
},
EO["includeODataAnnotations"] extends true ? true : false
>
: SingleMode extends "maybe"
? ConditionallyWithODataAnnotations<
Pick<T, Selected> & {
[K in keyof Expands]: Pick<
Expands[K]["schema"],
Expands[K]["selected"]
>[];
},
EO["includeODataAnnotations"] extends true ? true : false
> | null
: ConditionallyWithODataAnnotations<
Pick<T, Selected> & {
[K in keyof Expands]: Pick<
Expands[K]["schema"],
Expands[K]["selected"]
>[];
},
EO["includeODataAnnotations"] extends true ? true : false
>[]
>
> {
// Build query without expand (we'll add it manually)
const queryOptionsWithoutExpand = { ...this.queryOptions };
delete queryOptionsWithoutExpand.expand;
const mergedOptions = this.mergeExecuteOptions(options);
// Format select fields before building query
if (queryOptionsWithoutExpand.select) {
queryOptionsWithoutExpand.select = this.formatSelectFields(
queryOptionsWithoutExpand.select,
this.occurrence?.baseTable,
) as any;
}
let queryString = buildQuery(queryOptionsWithoutExpand);
// Build custom expand string
const expandString = this.buildExpandString(this.expandConfigs);
if (expandString) {
const separator = queryString.includes("?") ? "&" : "?";
queryString = `${queryString}${separator}$expand=${expandString}`;
}
// Handle navigation from RecordBuilder
if (
this.isNavigate &&
this.navigateRecordId &&
this.navigateRelation &&
this.navigateSourceTableName
) {
let url: string;
if (this.navigateBaseRelation) {
// Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
} else {
// Normal navigation: /sourceTable('recordId')/relation
url = `/${this.databaseName}/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
}
const result = await this.context._makeRequest(url, mergedOptions);
if (result.error) {
return { data: undefined, error: result.error };
}
let response = result.data;
// 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) {
const expandValidationConfigs = this.buildExpandValidationConfigs(
this.expandConfigs,
);
response = transformResponseFields(
response,
this.occurrence.baseTable,
expandValidationConfigs,
);
}
// Skip validation if requested
if (options?.skipValidation === true) {
const resp = response as any;
if (this.singleMode !== false) {
const records = resp.value ?? [resp];
const count = Array.isArray(records) ? records.length : 1;
if (count > 1) {
return {
data: undefined,
error: new RecordCountMismatchError(
this.singleMode === "exact" ? "one" : "at-most-one",
count,
),
};
}
if (count === 0) {
if (this.singleMode === "exact") {
return {
data: undefined,
error: new RecordCountMismatchError("one", 0),
};
}
return { data: null as any, error: undefined };
}
const record = Array.isArray(records) ? records[0] : records;
const stripped = this.stripODataAnnotationsIfNeeded(record, options);
return { data: stripped as any, error: undefined };
} else {
const records = resp.value ?? [];
const stripped = records.map((record: any) =>
this.stripODataAnnotationsIfNeeded(record, options),
);
return { data: stripped as any, error: undefined };
}
}
// Get schema from occurrence if available
const schema = this.occurrence?.baseTable?.schema;
const selectedFields = this.queryOptions.select as
| (keyof T)[]
| undefined;
const expandValidationConfigs = this.buildExpandValidationConfigs(
this.expandConfigs,
);
if (this.singleMode !== false) {
const validation = await validateSingleResponse<T>(
response,
schema,
selectedFields,
expandValidationConfigs,
this.singleMode,
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
const stripped = validation.data
? this.stripODataAnnotationsIfNeeded(validation.data, options)
: null;
return { data: stripped as any, error: undefined };
} else {
const validation = await validateListResponse<T>(
response,
schema,
selectedFields,
expandValidationConfigs,
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
const stripped = validation.data.map((record) =>
this.stripODataAnnotationsIfNeeded(record, options),
);
return { data: stripped as any, error: undefined };
}
}
// Handle navigation from EntitySet (without record ID)
if (
this.isNavigate &&
!this.navigateRecordId &&
this.navigateRelation &&
this.navigateSourceTableName
) {
const result = await this.context._makeRequest(
`/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}${queryString}`,
mergedOptions,
);
if (result.error) {
return { data: undefined, error: result.error };
}
let response = result.data;
// 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) {
const expandValidationConfigs = this.buildExpandValidationConfigs(
this.expandConfigs,
);
response = transformResponseFields(
response,
this.occurrence.baseTable,
expandValidationConfigs,
);
}
// Skip validation if requested
if (options?.skipValidation === true) {
const resp = response as any;
if (this.singleMode !== false) {
const records = resp.value ?? [resp];
const count = Array.isArray(records) ? records.length : 1;
if (count > 1) {
return {
data: undefined,
error: new RecordCountMismatchError(
this.singleMode === "exact" ? "one" : "at-most-one",
count,
),
};
}
if (count === 0) {
if (this.singleMode === "exact") {
return {
data: undefined,
error: new RecordCountMismatchError("one", 0),
};
}
return { data: null as any, error: undefined };
}
const record = Array.isArray(records) ? records[0] : records;
const stripped = this.stripODataAnnotationsIfNeeded(record, options);
return { data: stripped as any, error: undefined };
} else {
const records = resp.value ?? [];
const stripped = records.map((record: any) =>
this.stripODataAnnotationsIfNeeded(record, options),
);
return { data: stripped as any, error: undefined };
}
}
// Get schema from occurrence if available
const schema = this.occurrence?.baseTable?.schema;
const selectedFields = this.queryOptions.select as
| (keyof T)[]
| undefined;
const expandValidationConfigs = this.buildExpandValidationConfigs(
this.expandConfigs,
);
if (this.singleMode !== false) {
const validation = await validateSingleResponse<T>(
response,
schema,
selectedFields,
expandValidationConfigs,
this.singleMode,
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
const stripped = validation.data
? this.stripODataAnnotationsIfNeeded(validation.data, options)
: null;
return { data: stripped as any, error: undefined };
} else {
const validation = await validateListResponse<T>(
response,
schema,
selectedFields,
expandValidationConfigs,
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
const stripped = validation.data.map((record) =>
this.stripODataAnnotationsIfNeeded(record, options),
);
return { data: stripped as any, error: undefined };
}
}
// Handle $count endpoint
if (this.isCountMode) {
const tableId = this.getTableId(mergedOptions.useEntityIds);
const result = await this.context._makeRequest(
`/${this.databaseName}/${tableId}/$count${queryString}`,
mergedOptions,
);
if (result.error) {
return { data: undefined, error: result.error };
}
// OData returns count as a string, convert to number
const count =
typeof result.data === "string" ? Number(result.data) : result.data;
return { data: count as number, error: undefined } as any;
}
const tableId = this.getTableId(mergedOptions.useEntityIds);
const result = await this.context._makeRequest(
`/${this.databaseName}/${tableId}${queryString}`,
mergedOptions,
);
if (result.error) {
return { data: undefined, error: result.error };
}
let response = result.data;
// 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) {
const expandValidationConfigs = this.buildExpandValidationConfigs(
this.expandConfigs,
);
response = transformResponseFields(
response,
this.occurrence.baseTable,
expandValidationConfigs,
);
}
// Skip validation if requested
if (options?.skipValidation === true) {
const resp = response as any;
if (this.singleMode !== false) {
const records = resp.value ?? [resp];
const count = Array.isArray(records) ? records.length : 1;
if (count > 1) {
return {
data: undefined,
error: new RecordCountMismatchError(
this.singleMode === "exact" ? "one" : "at-most-one",
count,
),
};
}
if (count === 0) {
if (this.singleMode === "exact") {
return {
data: undefined,
error: new RecordCountMismatchError("one", 0),
};
}
return { data: null as any, error: undefined };
}
const record = Array.isArray(records) ? records[0] : records;
const stripped = this.stripODataAnnotationsIfNeeded(record, options);
return { data: stripped as any, error: undefined };
} else {
// Handle list response structure
const records = resp.value ?? [];
const stripped = records.map((record: any) =>
this.stripODataAnnotationsIfNeeded(record, options),
);
return { data: stripped as any, error: undefined };
}
}
// Get schema from occurrence if available
const schema = this.occurrence?.baseTable?.schema;
const selectedFields = this.queryOptions.select as (keyof T)[] | undefined;
const expandValidationConfigs = this.buildExpandValidationConfigs(
this.expandConfigs,
);
if (this.singleMode !== false) {
const validation = await validateSingleResponse<T>(
response,
schema,
selectedFields,
expandValidationConfigs,
this.singleMode,
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
const stripped = validation.data
? this.stripODataAnnotationsIfNeeded(validation.data, options)
: null;
return {
data: stripped as any,
error: undefined,
};
} else {
const validation = await validateListResponse<T>(
response,
schema,
selectedFields,
expandValidationConfigs,
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
const stripped = validation.data.map((record) =>
this.stripODataAnnotationsIfNeeded(record, options),
);
return {
data: stripped as any,
error: undefined,
};
}
}
getQueryString(): string {
// Build query without expand (we'll add it manually)
const queryOptionsWithoutExpand = { ...this.queryOptions };
delete queryOptionsWithoutExpand.expand;
// Format select fields before building query - buildQuery treats & as separator,
// so we need to pre-encode special characters. buildQuery preserves encoded values.
if (queryOptionsWithoutExpand.select) {
queryOptionsWithoutExpand.select = this.formatSelectFields(
queryOptionsWithoutExpand.select,
this.occurrence?.baseTable,
) as any;
}
let queryParams = buildQuery(queryOptionsWithoutExpand);
// Post-process: buildQuery encodes spaces as %20, but we want to preserve spaces
// Replace %20 with spaces in the $select part
if (this.queryOptions.select) {
queryParams = queryParams.replace(
/\$select=([^&]*)/,
(match, selectValue) => {
return `$select=${selectValue.replace(/%20/g, " ")}`;
},
);
}
const expandString = this.buildExpandString(this.expandConfigs);
if (expandString) {
const separator = queryParams.includes("?") ? "&" : "?";
queryParams = `${queryParams}${separator}$expand=${expandString}`;
}
// Handle navigation from RecordBuilder (with record ID)
if (
this.isNavigate &&
this.navigateRecordId &&
this.navigateRelation &&
this.navigateSourceTableName
) {
let path: string;
if (this.navigateBaseRelation) {
// Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
path = `/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}`;
} else {
// Normal navigation: /sourceTableName('recordId')/relationName
path = `/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}`;
}
// Append query params if any exist
return queryParams ? `${path}${queryParams}` : path;
}
// Handle navigation from EntitySet (without record ID)
if (
this.isNavigate &&
!this.navigateRecordId &&
this.navigateRelation &&
this.navigateSourceTableName
) {
// Return the path portion: /sourceTableName/relationName
const path = `/${this.navigateSourceTableName}/${this.navigateRelation}`;
// Append query params if any exist
return queryParams ? `${path}${queryParams}` : path;
}
// Default case: return table name with query params
return `/${this.tableName}${queryParams}`;
}
getRequestConfig(): { method: string; url: string; body?: any } {
// Build query without expand (we'll add it manually)
const queryOptionsWithoutExpand = { ...this.queryOptions };
delete queryOptionsWithoutExpand.expand;
// Format select fields before building query
if (queryOptionsWithoutExpand.select) {
queryOptionsWithoutExpand.select = this.formatSelectFields(
queryOptionsWithoutExpand.select,
this.occurrence?.baseTable,
) as any;
}
let queryString = buildQuery(queryOptionsWithoutExpand);
// Build custom expand string
const expandString = this.buildExpandString(this.expandConfigs);
if (expandString) {
const separator = queryString.includes("?") ? "&" : "?";
queryString = `${queryString}${separator}$expand=${expandString}`;
}
let url: string;
// Handle navigation from RecordBuilder (with record ID)
if (
this.isNavigate &&
this.navigateRecordId &&
this.navigateRelation &&
this.navigateSourceTableName
) {
if (this.navigateBaseRelation) {
// Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
} else {
// Normal navigation: /sourceTable('recordId')/relation
url = `/${this.databaseName}/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
}
} else if (
this.isNavigate &&
!this.navigateRecordId &&
this.navigateRelation &&
this.navigateSourceTableName
) {
// Handle navigation from EntitySet (without record ID)
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}${queryString}`;
} else if (this.isCountMode) {
url = `/${this.databaseName}/${this.tableName}/$count${queryString}`;
} else {
url = `/${this.databaseName}/${this.tableName}${queryString}`;
}
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<QueryReturnType<T, Selected, SingleMode, IsCount, Expands>>
> {
// 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.singleMode !== false) {
if (this.singleMode === "maybe") {
return { data: null as any, error: undefined };
}
return {
data: undefined,
error: new RecordCountMismatchError("one", 0),
};
}
return { data: [] as any, error: undefined };
}
// Parse the response body
let rawData;
try {
rawData = await response.json();
} 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
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(),
} as any,
};
}
if (!rawData) {
return {
data: undefined,
error: {
name: "ResponseError",
message: "Response body was empty or null",
timestamp: new Date(),
} as any,
};
}
// 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 transformedData = rawData;
if (this.occurrence?.baseTable && shouldUseIds) {
const expandValidationConfigs = this.buildExpandValidationConfigs(
this.expandConfigs,
);
transformedData = transformResponseFields(
rawData,
this.occurrence.baseTable,
expandValidationConfigs,
);
}
// Skip validation if requested
if (options?.skipValidation === true) {
const resp = transformedData as any;
if (this.singleMode !== false) {
const records = resp.value ?? [resp];
const count = Array.isArray(records) ? records.length : 1;
if (count > 1) {
return {
data: undefined,
error: new RecordCountMismatchError(
this.singleMode === "exact" ? "one" : "at-most-one",
count,
),
};
}
if (count === 0) {
if (this.singleMode === "exact") {
return {
data: undefined,
error: new RecordCountMismatchError("one", 0),
};
}
return { data: null as any, error: undefined };
}
const record = Array.isArray(records) ? records[0] : records;
const stripped = this.stripODataAnnotationsIfNeeded(record, options);
return { data: stripped as any, error: undefined };
} else {
// Handle list response structure
const records = resp.value ?? [];
const stripped = records.map((record: any) =>
this.stripODataAnnotationsIfNeeded(record, options),
);
return { data: stripped as any, error: undefined };
}
}
// Get schema from occurrence if available
const schema = this.occurrence?.baseTable?.schema;
const selectedFields = this.queryOptions.select as (keyof T)[] | undefined;
const expandValidationConfigs = this.buildExpandValidationConfigs(
this.expandConfigs,
);
if (this.singleMode !== false) {
// Single mode (one() or oneOrNull())
const validation = await validateSingleResponse<T>(
transformedData,
schema,
selectedFields,
expandValidationConfigs,
this.singleMode,
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
if (validation.data === null) {
return { data: null as any, error: undefined };
}
const stripped = this.stripODataAnnotationsIfNeeded(
validation.data,
options,
);
return { data: stripped as any, error: undefined };
}
// List mode
const validation = await validateListResponse<T>(
transformedData,
schema,
selectedFields,
expandValidationConfigs,
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
const stripped = validation.data.map((record) =>
this.stripODataAnnotationsIfNeeded(record, options),
);
return { data: stripped as any, error: undefined };
}
}