@proofkit/fmodata
Version:
FileMaker OData API client
406 lines (372 loc) • 18.2 kB
text/typescript
import type { InternalLogger } from "../logger";
import type { FieldBuilder } from "../orm/field-builders";
import type {
ColumnMap,
FMTable,
InferSchemaOutputFromFMTable,
InsertDataFromFMTable,
UpdateDataFromFMTable,
ValidExpandTarget,
} from "../orm/table";
import {
FMTable as FMTableClass,
getDefaultSelect,
getTableColumns,
getTableName,
getTableSchema,
isUsingEntityIds,
} from "../orm/table";
import type { FMODataLayer, ODataConfig } from "../services";
import type { RowIdRecordLocator } from "./builders/mutation-helpers";
import { resolveTableId } from "./builders/table-utils";
import { CountBuilder } from "./count-builder";
import type { Database } from "./database";
import { DeleteBuilder } from "./delete-builder";
import { InsertBuilder } from "./insert-builder";
import { QueryBuilder } from "./query/index";
import { RecordBuilder } from "./record-builder";
import { createClientRuntime } from "./runtime";
import { UpdateBuilder } from "./update-builder";
// Helper type to extract defaultSelect from an FMTable
// Since TypeScript can't extract Symbol-indexed properties at the type level,
// we simplify to return keyof InferSchemaFromFMTable<O> when O is an FMTable.
// The actual defaultSelect logic is handled at runtime.
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
type _ExtractDefaultSelect<O> = O extends FMTable<any, any> ? keyof InferSchemaOutputFromFMTable<O> : never;
/**
* Helper type to extract properly-typed columns from an FMTable.
* This preserves the specific column types instead of widening to `any`.
*/
type ExtractColumnsFromOcc<T> =
// biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer
T extends FMTable<infer TFields, infer TName, any>
? // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any FieldBuilder configuration
TFields extends Record<string, FieldBuilder<any, any, any, any>>
? ColumnMap<TFields, TName>
: never
: never;
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
export class EntitySet<Occ extends FMTable<any, any>, DatabaseIncludeSpecialColumns extends boolean = false> {
private readonly occurrence: Occ;
private readonly layer: FMODataLayer;
private readonly config: ODataConfig;
private readonly logger: InternalLogger;
private readonly database: Database<DatabaseIncludeSpecialColumns>; // Database instance for accessing occurrences
private readonly isNavigateFromEntitySet?: boolean;
private readonly navigateRelation?: string;
private readonly navigateRelationEntityId?: string;
private readonly navigateSourceTableName?: string;
private readonly navigateSourceTableEntityId?: string;
private readonly navigateBasePath?: string; // Full base path for chained navigations
private readonly navigateBasePathEntityId?: string;
constructor(config: {
occurrence: Occ;
layer: FMODataLayer;
// biome-ignore lint/suspicious/noExplicitAny: Database type is optional and can be any Database instance
database?: any;
}) {
this.occurrence = config.occurrence;
this.database = config.database;
// Extract config and logger from the layer for sync access
const runtime = createClientRuntime(config.layer);
this.layer = runtime.layer;
this.config = runtime.config;
this.logger = runtime.logger;
}
// Type-only method to help TypeScript infer the schema from table
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
static create<Occ extends FMTable<any, any>, DatabaseIncludeSpecialColumns extends boolean = false>(config: {
occurrence: Occ;
layer: FMODataLayer;
database: Database<DatabaseIncludeSpecialColumns>;
}): EntitySet<Occ, DatabaseIncludeSpecialColumns> {
return new EntitySet<Occ, DatabaseIncludeSpecialColumns>({
occurrence: config.occurrence,
layer: config.layer,
database: config.database,
});
}
private applyNavigationContext<T>(builder: T): T {
if (this.isNavigateFromEntitySet && this.navigateRelation && this.navigateSourceTableName) {
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(builder as any).navigation = {
relation: this.navigateRelation,
relationEntityId: this.navigateRelationEntityId,
sourceTableName: this.navigateSourceTableName,
sourceTableEntityId: this.navigateSourceTableEntityId,
basePath: this.navigateBasePath,
basePathEntityId: this.navigateBasePathEntityId,
};
}
return builder;
}
list(): QueryBuilder<
Occ,
keyof InferSchemaOutputFromFMTable<Occ>,
false,
false,
// biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default
{},
false,
DatabaseIncludeSpecialColumns
> {
const builder = new QueryBuilder<
Occ,
keyof InferSchemaOutputFromFMTable<Occ>,
false,
false,
// biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default
{},
false,
DatabaseIncludeSpecialColumns
>({
occurrence: this.occurrence as Occ,
layer: this.layer,
});
// Apply defaultSelect if occurrence exists and select hasn't been called
if (this.occurrence) {
// FMTable - access via helper functions
const defaultSelectValue = getDefaultSelect(this.occurrence);
// Schema is stored directly as Partial<Record<keyof TFields, StandardSchemaV1>>
const _schema = getTableSchema(this.occurrence);
if (defaultSelectValue === "schema") {
// Use getTableColumns to get all columns and select them
// This is equivalent to select(getTableColumns(occurrence))
// Cast to the declared return type - runtime behavior handles the actual selection
const allColumns = getTableColumns(this.occurrence) as ExtractColumnsFromOcc<Occ>;
const selectedBuilder = this.applyNavigationContext(
this.config.includeSpecialColumns
? builder.select(allColumns, { ROWID: true, ROWMODID: true })
: builder.select(allColumns),
).top(1000);
return selectedBuilder as QueryBuilder<
Occ,
keyof InferSchemaOutputFromFMTable<Occ>,
false,
false,
// biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default
{},
false,
DatabaseIncludeSpecialColumns
>;
}
if (typeof defaultSelectValue === "object") {
// defaultSelectValue is a select object (Record<string, Column>)
// Cast to the declared return type - runtime behavior handles the actual selection
const selectedBuilder = this.applyNavigationContext(
builder.select(defaultSelectValue as ExtractColumnsFromOcc<Occ>),
).top(1000);
return selectedBuilder as QueryBuilder<
Occ,
keyof InferSchemaOutputFromFMTable<Occ>,
false,
false,
// biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default
{},
false,
DatabaseIncludeSpecialColumns
>;
}
// If defaultSelect is "all", no changes needed (current behavior)
}
// Apply default pagination limit of 1000 records to prevent stack overflow
// with large datasets. Users can override with .top() if needed.
return this.applyNavigationContext(builder).top(1000);
}
count(): CountBuilder<Occ, DatabaseIncludeSpecialColumns> {
const builder = new CountBuilder<Occ, DatabaseIncludeSpecialColumns>({
occurrence: this.occurrence,
layer: this.layer,
});
return this.applyNavigationContext(builder);
}
get(
locator: RowIdRecordLocator | string | number,
): RecordBuilder<
Occ,
false,
undefined,
keyof InferSchemaOutputFromFMTable<Occ>,
Record<string, never>,
DatabaseIncludeSpecialColumns
> {
const builder = new RecordBuilder<
Occ,
false,
undefined,
keyof InferSchemaOutputFromFMTable<Occ>,
// biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default
{},
DatabaseIncludeSpecialColumns
>({
occurrence: this.occurrence,
layer: this.layer,
recordLocator: locator,
});
// Apply defaultSelect if occurrence exists
if (this.occurrence) {
// FMTable - access via helper functions
const defaultSelectValue = getDefaultSelect(this.occurrence);
// Schema is stored directly as Partial<Record<keyof TFields, StandardSchemaV1>>
const _schema = getTableSchema(this.occurrence);
if (defaultSelectValue === "schema") {
// Use getTableColumns to get all columns and select them
// This is equivalent to select(getTableColumns(occurrence))
// Use ExtractColumnsFromOcc to preserve the properly-typed column types
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter
const allColumns = getTableColumns(this.occurrence as any) as ExtractColumnsFromOcc<Occ>;
const selectedBuilder = this.applyNavigationContext(
this.config.includeSpecialColumns
? builder.select(allColumns, { ROWID: true, ROWMODID: true })
: builder.select(allColumns),
);
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type
return selectedBuilder as any;
}
if (typeof defaultSelectValue === "object" && defaultSelectValue !== null && !Array.isArray(defaultSelectValue)) {
// defaultSelectValue is a select object (Record<string, Column>)
// Use it directly with select()
// Use ExtractColumnsFromOcc to preserve the properly-typed column types
const selectedBuilder = this.applyNavigationContext(
builder.select(defaultSelectValue as ExtractColumnsFromOcc<Occ>),
);
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type
return selectedBuilder as any;
}
// If defaultSelect is "all", no changes needed (current behavior)
}
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type
return this.applyNavigationContext(builder) as any;
}
// Overload: when returnFullRecord is false
insert(
data: InsertDataFromFMTable<Occ>,
options: { returnFullRecord: false },
): InsertBuilder<Occ, "minimal", DatabaseIncludeSpecialColumns>;
// Overload: when returnFullRecord is true or omitted (default)
insert(
data: InsertDataFromFMTable<Occ>,
options?: { returnFullRecord?: true },
): InsertBuilder<Occ, "representation", DatabaseIncludeSpecialColumns>;
// Implementation
insert(
data: InsertDataFromFMTable<Occ>,
options?: { returnFullRecord?: boolean },
): InsertBuilder<Occ, "minimal" | "representation", DatabaseIncludeSpecialColumns> {
const returnPreference = options?.returnFullRecord === false ? "minimal" : "representation";
return new InsertBuilder<Occ, typeof returnPreference, DatabaseIncludeSpecialColumns>({
occurrence: this.occurrence,
layer: this.layer,
// biome-ignore lint/suspicious/noExplicitAny: Input type is validated/transformed at runtime
data: data as any,
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter
returnPreference: returnPreference as any,
});
}
// Overload: when returnFullRecord is explicitly true
update(
data: UpdateDataFromFMTable<Occ>,
options: { returnFullRecord: true },
): UpdateBuilder<Occ, "representation", DatabaseIncludeSpecialColumns>;
// Overload: when returnFullRecord is false or omitted (default)
update(
data: UpdateDataFromFMTable<Occ>,
options?: { returnFullRecord?: false },
): UpdateBuilder<Occ, "minimal", DatabaseIncludeSpecialColumns>;
// Implementation
update(
data: UpdateDataFromFMTable<Occ>,
options?: { returnFullRecord?: boolean },
): UpdateBuilder<Occ, "minimal" | "representation", DatabaseIncludeSpecialColumns> {
const returnPreference = options?.returnFullRecord === true ? "representation" : "minimal";
return new UpdateBuilder<Occ, typeof returnPreference, DatabaseIncludeSpecialColumns>({
occurrence: this.occurrence,
layer: this.layer,
// biome-ignore lint/suspicious/noExplicitAny: Input type is validated/transformed at runtime
data: data as any,
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter
returnPreference: returnPreference as any,
});
}
delete(): DeleteBuilder<Occ> {
return new DeleteBuilder<Occ>({
occurrence: this.occurrence,
layer: this.layer,
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type
}) as any;
}
// Implementation
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
navigate<TargetTable extends FMTable<any, any>>(
targetTable: ValidExpandTarget<Occ, TargetTable>,
// biome-ignore lint/suspicious/noExplicitAny: Required for conditional type inference
): EntitySet<TargetTable extends FMTable<any, any> ? TargetTable : never, DatabaseIncludeSpecialColumns> {
// Check if it's an FMTable object or a string
let relationName: string;
// FMTable object - extract name and validate
relationName = getTableName(targetTable);
// Runtime validation: Check if relation name is in navigationPaths
if (this.occurrence && FMTableClass.Symbol.NavigationPaths in this.occurrence) {
// biome-ignore lint/suspicious/noExplicitAny: Symbol property access for internal property
const navigationPaths = (this.occurrence as any)[FMTableClass.Symbol.NavigationPaths] as readonly string[];
if (navigationPaths && !navigationPaths.includes(relationName)) {
this.logger.warn(
`Cannot navigate to "${relationName}". Valid navigation paths: ${navigationPaths.length > 0 ? navigationPaths.join(", ") : "none"}`,
);
}
}
// Create EntitySet with target table
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any FMTable configuration
const entitySet = new EntitySet<any, DatabaseIncludeSpecialColumns>({
occurrence: targetTable,
layer: this.layer,
database: this.database,
});
// Resolve entity IDs lazily at request time by storing both name and ID forms
const relationEntityId = isUsingEntityIds(targetTable)
? resolveTableId(targetTable, relationName, true)
: relationName;
const sourceTableName = getTableName(this.occurrence);
const sourceTableEntityId = isUsingEntityIds(this.occurrence)
? resolveTableId(this.occurrence, sourceTableName, true)
: sourceTableName;
// Store the navigation info in the EntitySet
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).isNavigateFromEntitySet = true;
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateRelation = relationName;
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateRelationEntityId = relationEntityId;
// Build the full base path for chained navigations
if (this.isNavigateFromEntitySet && this.navigateBasePath) {
// Already have a base path from previous navigation - extend it with current relation
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateBasePath = `${this.navigateBasePath}/${this.navigateRelation}`;
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateBasePathEntityId =
`${this.navigateBasePathEntityId ?? this.navigateBasePath}/${this.navigateRelationEntityId ?? this.navigateRelation}`;
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateSourceTableName = this.navigateSourceTableName;
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateSourceTableEntityId = this.navigateSourceTableEntityId;
} else if (this.isNavigateFromEntitySet && this.navigateRelation) {
// First chained navigation - create base path from source/relation
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateBasePath = `${this.navigateSourceTableName}/${this.navigateRelation}`;
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateBasePathEntityId =
`${this.navigateSourceTableEntityId ?? this.navigateSourceTableName}/${this.navigateRelationEntityId ?? this.navigateRelation}`;
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateSourceTableName = this.navigateSourceTableName;
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateSourceTableEntityId = this.navigateSourceTableEntityId;
} else {
// Initial navigation - source is just the table name (resolved to entity ID if needed)
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateSourceTableName = sourceTableName;
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(entitySet as any).navigateSourceTableEntityId = sourceTableEntityId;
}
return entitySet;
}
}