UNPKG

@proofkit/fmodata

Version:

FileMaker OData API client

584 lines (583 loc) 22.3 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import { Effect } from "effect"; import buildQuery from "odata-query"; import { requestFromService, runLayerResult } from "../../effect.js"; import { BuilderInvariantError, RecordCountMismatchError, isFMODataError } from "../../errors.js"; import { isColumn } from "../../orm/column.js"; import { isOrderByExpression } from "../../orm/operators.js"; import { getTableName } from "../../orm/table.js"; import { transformOrderByField } from "../../transform.js"; import { ExpandBuilder } from "../builders/expand-builder.js"; import { mergeExecuteOptions, createODataRequest } from "../builders/table-utils.js"; import { buildSelectExpandQueryString } from "../builders/query-string-builder.js"; import { createInitialQueryReadBuilderState, cloneQueryReadBuilderState } from "../builders/read-builder-state.js"; import { processQueryResponse } from "../builders/response-processor.js"; import { processSelectWithRenames } from "../builders/select-mixin.js"; import { parseErrorResponse } from "../error-parser.js"; import { createClientRuntime } from "../runtime.js"; import { safeJsonParse } from "../sanitize-json.js"; import { QueryUrlBuilder } from "./url-builder.js"; function normalizeQueryBuildError(error) { 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)); } class QueryBuilder { constructor(config) { __publicField(this, "readState", createInitialQueryReadBuilderState()); __publicField(this, "occurrence"); __publicField(this, "expandBuilder"); __publicField(this, "urlBuilder"); __publicField(this, "layer"); __publicField(this, "config"); __publicField(this, "logger"); 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); } // Compatibility accessors for internal modules that inspect builder internals via `as any`. get queryOptions() { return this.readState.queryOptions; } set queryOptions(queryOptions) { this.readState = cloneQueryReadBuilderState(this.readState, { queryOptions }); } get expandConfigs() { return this.readState.expandConfigs; } set expandConfigs(expandConfigs) { this.readState = cloneQueryReadBuilderState(this.readState, { expandConfigs }); } get singleMode() { return this.readState.singleMode; } set singleMode(singleMode) { this.readState = cloneQueryReadBuilderState(this.readState, { singleMode }); } get isCountMode() { return this.readState.isCountMode; } set isCountMode(isCountMode) { this.readState = cloneQueryReadBuilderState(this.readState, { isCountMode }); } get includeCountMode() { return this.readState.includeCountMode; } set includeCountMode(includeCountMode) { this.readState = cloneQueryReadBuilderState(this.readState, { includeCountMode }); } get fieldMapping() { return this.readState.fieldMapping; } set fieldMapping(fieldMapping) { this.readState = cloneQueryReadBuilderState(this.readState, { fieldMapping }); } get systemColumns() { return this.readState.systemColumns; } set systemColumns(systemColumns) { this.readState = cloneQueryReadBuilderState(this.readState, { systemColumns }); } get navigation() { return this.readState.navigation; } set navigation(navigation) { this.setNavigation(navigation); } /** * Helper to merge database-level useEntityIds and includeSpecialColumns with per-request options */ mergeExecuteOptions(options) { const merged = mergeExecuteOptions(options, this.config.useEntityIds); return { ...merged, includeSpecialColumns: (options == null ? void 0 : options.includeSpecialColumns) ?? this.config.includeSpecialColumns }; } patchQueryOptions(patch) { this.readState = cloneQueryReadBuilderState(this.readState, { queryOptions: patch }); } setFilterExpression(expression) { this.readState = cloneQueryReadBuilderState(this.readState, { filterExpression: expression }); } setNavigation(navigation) { this.readState = cloneQueryReadBuilderState(this.readState, { navigation }); } /** * Creates a new QueryBuilder with modified configuration. * Used by single(), maybeSingle(), count(), and select() to create new instances. */ cloneWithChanges(changes) { const newBuilder = new QueryBuilder({ 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, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter isCountMode: changes.isCountMode ?? this.readState.isCountMode, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter includeCountMode: changes.includeCountMode ?? this.readState.includeCountMode, fieldMapping: "fieldMapping" in changes ? changes.fieldMapping : this.readState.fieldMapping, systemColumns: changes.systemColumns !== void 0 ? changes.systemColumns : this.readState.systemColumns, navigation: this.readState.navigation }); newBuilder.urlBuilder = new QueryUrlBuilder(this.config.databaseName, this.occurrence, this.config.useEntityIds); return newBuilder; } // biome-ignore lint/suspicious/noExplicitAny: Implementation signature hidden from callers select(fields, systemColumns) { if (fields === "all") { return this.cloneWithChanges({ queryOptions: { select: void 0 }, fieldMapping: void 0, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter systemColumns: void 0 }); } const tableName = getTableName(this.occurrence); const { selectedFields, fieldMapping } = processSelectWithRenames(fields, tableName, this.logger); const finalSelectedFields = [...selectedFields]; if (systemColumns == null ? void 0 : systemColumns.ROWID) { finalSelectedFields.push("ROWID"); } if (systemColumns == null ? void 0 : systemColumns.ROWMODID) { finalSelectedFields.push("ROWMODID"); } return this.cloneWithChanges({ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter selectedFields: fields, queryOptions: { select: finalSelectedFields }, fieldMapping: Object.keys(fieldMapping).length > 0 ? fieldMapping : void 0, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter systemColumns }); } /** * 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) { if (typeof expression === "string") { this.setFilterExpression(void 0); this.patchQueryOptions({ filter: expression }); return this; } this.setFilterExpression(expression); this.patchQueryOptions({ filter: void 0 }); 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) { const tableName = getTableName(this.occurrence); if (orderByArgs.length > 1) { const orderByParts = orderByArgs.map((arg) => { if (isOrderByExpression(arg)) { 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)) { 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; } throw new Error("Variadic orderBy() only accepts Column or OrderByExpression arguments"); }); this.patchQueryOptions({ orderBy: orderByParts }); return this; } const orderBy = orderByArgs[0]; if (isOrderByExpression(orderBy)) { 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; } if (isColumn(orderBy)) { if (orderBy.tableName !== tableName) { this.logger.warn( `Column ${orderBy.toString()} is from table "${orderBy.tableName}", but query is for table "${tableName}"` ); } const fieldName = orderBy.fieldName; this.patchQueryOptions({ orderBy: this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName }); return this; } if (this.occurrence && orderBy) { if (Array.isArray(orderBy)) { if (orderBy.length === 2 && (typeof orderBy[0] === "string" || isColumn(orderBy[0])) && (orderBy[1] === "asc" || orderBy[1] === "desc")) { const field = isColumn(orderBy[0]) ? orderBy[0].fieldName : orderBy[0]; const direction = orderBy[1]; this.patchQueryOptions({ orderBy: `${transformOrderByField(field, this.occurrence)} ${direction}` }); } else { this.patchQueryOptions({ orderBy: orderBy.map(([fieldOrCol, direction]) => { const field = isColumn(fieldOrCol) ? fieldOrCol.fieldName : String(fieldOrCol); const transformedField = this.occurrence ? transformOrderByField(field, this.occurrence) : field; return `${transformedField} ${direction}`; }) }); } } else { 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")) { const field = isColumn(orderBy[0]) ? orderBy[0].fieldName : orderBy[0]; const direction = orderBy[1]; this.patchQueryOptions({ orderBy: `${field} ${direction}` }); } else { this.patchQueryOptions({ orderBy: orderBy.map(([fieldOrCol, direction]) => { const field = isColumn(fieldOrCol) ? fieldOrCol.fieldName : String(fieldOrCol); return `${field} ${direction}`; }) }); } } else { this.patchQueryOptions({ orderBy }); } return this; } top(count) { this.patchQueryOptions({ top: count }); return this; } skip(count) { this.patchQueryOptions({ skip: count }); return this; } expand(targetTable, callback) { const expandConfig = this.expandBuilder.processExpand( targetTable, this.occurrence, callback, () => ( // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryBuilder configuration new QueryBuilder({ occurrence: targetTable, layer: this.layer }) ) ); this.readState = cloneQueryReadBuilderState(this.readState, { expandConfigs: [...this.readState.expandConfigs, expandConfig] }); return this; } single() { if (this.readState.includeCountMode) { throw new BuilderInvariantError("QueryBuilder.single", "count-enabled list queries cannot use single()"); } return this.cloneWithChanges({ singleMode: "exact" }); } maybeSingle() { if (this.readState.includeCountMode) { throw new BuilderInvariantError( "QueryBuilder.maybeSingle", "count-enabled list queries cannot use maybeSingle()" ); } return this.cloneWithChanges({ singleMode: "maybe" }); } count() { if (this.readState.singleMode !== false) { throw new BuilderInvariantError( "QueryBuilder.count", "single() and maybeSingle() cannot be combined with count()" ); } return this.cloneWithChanges({ includeCountMode: true, queryOptions: { count: true } }); } /** * Builds the OData query string from current query options and expand configs. */ buildQueryString(includeSpecialColumns, useEntityIds) { const finalUseEntityIds = useEntityIds ?? this.config.useEntityIds; const queryOptionsWithoutExpandAndSelect = { ...this.readState.queryOptions }; if (this.readState.filterExpression) { queryOptionsWithoutExpandAndSelect.filter = this.readState.filterExpression.toODataFilter(finalUseEntityIds); } const originalSelect = queryOptionsWithoutExpandAndSelect.select; queryOptionsWithoutExpandAndSelect.expand = void 0; queryOptionsWithoutExpandAndSelect.select = void 0; let queryString = buildQuery(queryOptionsWithoutExpandAndSelect); let selectArray; if (originalSelect) { selectArray = Array.isArray(originalSelect) ? originalSelect.map(String) : [String(originalSelect)]; } includeSpecialColumns ?? this.config.includeSpecialColumns; const selectExpandString = buildSelectExpandQueryString({ selectedFields: selectArray, expandConfigs: this.readState.expandConfigs, table: this.occurrence, useEntityIds: finalUseEntityIds, logger: this.logger }); if (selectExpandString) { const params = selectExpandString.startsWith("?") ? selectExpandString.slice(1) : selectExpandString; const separator = queryString.includes("?") ? "&" : "?"; queryString = `${queryString}${separator}${params}`; } return queryString; } execute(options) { const mergedOptions = this.mergeExecuteOptions(options); let queryString; try { queryString = this.buildQueryString(mergedOptions.includeSpecialColumns, mergedOptions.useEntityIds); } catch (error) { return Promise.resolve({ data: void 0, error: normalizeQueryBuildError(error) }); } if (this.readState.isCountMode) { let url2; try { url2 = this.urlBuilder.build(queryString, { isCount: true, useEntityIds: mergedOptions.useEntityIds, navigation: this.readState.navigation }); } catch (error) { return Promise.resolve({ data: void 0, error: normalizeQueryBuildError(error) }); } const pipeline2 = requestFromService(url2, mergedOptions).pipe( Effect.map((data) => { const count = typeof data === "string" ? Number(data) : data; return count; }) ); return runLayerResult(this.layer, pipeline2, "fmodata.query.count", { "fmodata.table": getTableName(this.occurrence) }); } let url; try { url = this.urlBuilder.build(queryString, { isCount: this.readState.isCountMode, useEntityIds: mergedOptions.useEntityIds, navigation: this.readState.navigation }); } catch (error) { return Promise.resolve({ data: void 0, error: normalizeQueryBuildError(error) }); } const pipeline = requestFromService(url, mergedOptions).pipe( Effect.flatMap( (data) => Effect.tryPromise({ try: () => processQueryResponse(data, { occurrence: this.occurrence, singleMode: this.readState.singleMode, queryOptions: this.readState.queryOptions, expandConfigs: this.readState.expandConfigs, skipValidation: options == null ? void 0 : 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) } ); } getQueryString(options) { const useEntityIds = (options == null ? void 0 : options.useEntityIds) ?? this.config.useEntityIds; const queryString = this.buildQueryString(void 0, 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() { 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, options) { const config = this.getRequestConfig(); return createODataRequest(baseUrl, config, { ...options, normalizeDatabaseName: (options == null ? void 0 : options.normalizeDatabaseName) ?? this.config.normalizeDatabaseName }); } async processResponse(response, options) { if (!response.ok) { const error = await parseErrorResponse( response, response.url || `/${this.config.databaseName}/${getTableName(this.occurrence)}` ); return { data: void 0, error }; } if (response.status === 204) { if (this.readState.singleMode !== false) { if (this.readState.singleMode === "maybe") { return { data: null, error: void 0 }; } return { data: void 0, error: new RecordCountMismatchError("one", 0) }; } return { data: [], error: void 0 }; } let rawData; try { rawData = await safeJsonParse(response); } catch (err) { if (err instanceof SyntaxError && response.status === 204) { return { data: [], error: void 0 }; } return { data: void 0, error: { name: "ResponseParseError", message: `Failed to parse response JSON: ${err instanceof Error ? err.message : "Unknown error"}`, timestamp: /* @__PURE__ */ new Date() // biome-ignore lint/suspicious/noExplicitAny: Type assertion for error object } }; } if (!rawData) { return { data: void 0, error: { name: "ResponseError", message: "Response body was empty or null", timestamp: /* @__PURE__ */ new Date() // biome-ignore lint/suspicious/noExplicitAny: Type assertion for error object } }; } const mergedOptions = this.mergeExecuteOptions(options); this.readState.queryOptions.select !== void 0; return processQueryResponse(rawData, { occurrence: this.occurrence, singleMode: this.readState.singleMode, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter queryOptions: this.readState.queryOptions, expandConfigs: this.readState.expandConfigs, skipValidation: options == null ? void 0 : options.skipValidation, useEntityIds: mergedOptions.useEntityIds, includeSpecialColumns: mergedOptions.includeSpecialColumns, includeCount: this.readState.includeCountMode, fieldMapping: this.readState.fieldMapping, logger: this.logger }); } } export { QueryBuilder }; //# sourceMappingURL=query-builder.js.map