UNPKG

@proofkit/fmodata

Version:
1,456 lines (1,318 loc) 47.4 kB
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 }; } }