@proofkit/fmodata
Version:
FileMaker OData API client
584 lines (583 loc) • 22.3 kB
JavaScript
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