diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 9f9270fb..c0036945 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -4,11 +4,11 @@ reviews: # Enable automated review for pull requests auto_review: - enabled: true + enabled: false base_branches: - ".*" # Matches all branches using regex review_status: false poem: false - -path_filters: - - "!apps/demo/**" # exclude the demo app from reivews + high_level_summary: false + path_filters: + - "!apps/demo/**" # exclude the demo app from reivews diff --git a/packages/fmodata/README.md b/packages/fmodata/README.md index 8aa965bb..2d742318 100644 --- a/packages/fmodata/README.md +++ b/packages/fmodata/README.md @@ -808,7 +808,7 @@ const result = await db.webhook.add({ tableName: contactsTable, headers: { "X-Custom-Header": "value", - "Authorization": "Bearer token", + Authorization: "Bearer token", }, notifySchemaChanges: true, // Notify when schema changes }); @@ -1442,6 +1442,37 @@ const users = fmTableOccurrence( ); ``` +### Special Columns (ROWID and ROWMODID) + +FileMaker provides special columns `ROWID` and `ROWMODID` that uniquely identify records and track modifications. These can be included in query responses when enabled. + +Enable special columns at the database level: + +```typescript +const db = connection.database("MyDatabase", { + includeSpecialColumns: true, +}); + +const result = await db.from(users).list().execute(); +// result.data[0] will have ROWID and ROWMODID properties +``` + +Override at the request level: + +```typescript +// Enable for this request only +const result = await db.from(users).list().execute({ + includeSpecialColumns: true, +}); + +// Disable for this request +const result = await db.from(users).list().execute({ + includeSpecialColumns: false, +}); +``` + +**Important:** Special columns are only included when no `$select` query is applied (per OData specification). When using `.select()`, special columns are excluded even if `includeSpecialColumns` is enabled. + ### Error Handling All operations return a `Result` type with either `data` or `error`. The library provides rich error types that help you handle different error scenarios appropriately. diff --git a/packages/fmodata/src/client/builders/default-select.ts b/packages/fmodata/src/client/builders/default-select.ts index 0256db05..21bb1e4d 100644 --- a/packages/fmodata/src/client/builders/default-select.ts +++ b/packages/fmodata/src/client/builders/default-select.ts @@ -20,9 +20,13 @@ function getContainerFieldNames(table: FMTable): string[] { * Gets default select fields from a table definition. * Returns undefined if defaultSelect is "all". * Automatically filters out container fields since they cannot be selected via $select. + * + * @param table - The table occurrence + * @param includeSpecialColumns - If true, includes ROWID and ROWMODID when defaultSelect is "schema" */ export function getDefaultSelectFields( table: FMTable | undefined, + includeSpecialColumns?: boolean, ): string[] | undefined { if (!table) return undefined; @@ -33,7 +37,14 @@ export function getDefaultSelectFields( const baseTableConfig = getBaseTableConfig(table); const allFields = Object.keys(baseTableConfig.schema); // Filter out container fields - return [...new Set(allFields.filter((f) => !containerFields.includes(f)))]; + const fields = [...new Set(allFields.filter((f) => !containerFields.includes(f)))]; + + // Add special columns if requested + if (includeSpecialColumns) { + fields.push("ROWID", "ROWMODID"); + } + + return fields; } if (Array.isArray(defaultSelect)) { diff --git a/packages/fmodata/src/client/builders/query-string-builder.ts b/packages/fmodata/src/client/builders/query-string-builder.ts index a9fb68df..ee3694dd 100644 --- a/packages/fmodata/src/client/builders/query-string-builder.ts +++ b/packages/fmodata/src/client/builders/query-string-builder.ts @@ -17,12 +17,18 @@ export function buildSelectExpandQueryString(config: { table?: FMTable; useEntityIds: boolean; logger: InternalLogger; + includeSpecialColumns?: boolean; }): string { const parts: string[] = []; const expandBuilder = new ExpandBuilder(config.useEntityIds, config.logger); // Build $select if (config.selectedFields && config.selectedFields.length > 0) { + // Important: do NOT implicitly add system columns (ROWID/ROWMODID) here. + // - `includeSpecialColumns` controls the Prefer header + response parsing, but should not + // mutate/expand an explicit `$select` (e.g. when the user calls `.select({ ... })`). + // - If system columns are desired with `.select()`, they must be explicitly included via + // the `systemColumns` argument, which will already have added them to `selectedFields`. const selectString = formatSelectFields( config.selectedFields, config.table, diff --git a/packages/fmodata/src/client/builders/response-processor.ts b/packages/fmodata/src/client/builders/response-processor.ts index 783b1a72..9f171d15 100644 --- a/packages/fmodata/src/client/builders/response-processor.ts +++ b/packages/fmodata/src/client/builders/response-processor.ts @@ -17,6 +17,7 @@ export interface ProcessResponseConfig { expandValidationConfigs?: ExpandValidationConfig[]; skipValidation?: boolean; useEntityIds?: boolean; + includeSpecialColumns?: boolean; // Mapping from field names to output keys (for renamed fields in select) fieldMapping?: Record; } @@ -37,6 +38,7 @@ export async function processODataResponse( expandValidationConfigs, skipValidation, useEntityIds, + includeSpecialColumns, fieldMapping, } = config; @@ -67,6 +69,9 @@ export async function processODataResponse( } // Validation path + // Note: Special columns are excluded when using QueryBuilder.single() method, + // but included for RecordBuilder.get() method (both use singleMode: "exact") + // The exclusion is handled in QueryBuilder's processQueryResponse, not here if (singleMode !== false) { const validation = await validateSingleResponse( response, @@ -74,6 +79,7 @@ export async function processODataResponse( selectedFields as any, expandValidationConfigs, singleMode, + includeSpecialColumns, ); if (!validation.valid) { @@ -96,6 +102,7 @@ export async function processODataResponse( schema, selectedFields as any, expandValidationConfigs, + includeSpecialColumns, ); if (!validation.valid) { @@ -223,6 +230,7 @@ export async function processQueryResponse( expandConfigs: ExpandConfig[]; skipValidation?: boolean; useEntityIds?: boolean; + includeSpecialColumns?: boolean; // Mapping from field names to output keys (for renamed fields in select) fieldMapping?: Record; logger: InternalLogger; @@ -235,6 +243,7 @@ export async function processQueryResponse( expandConfigs, skipValidation, useEntityIds, + includeSpecialColumns, fieldMapping, logger, } = config; @@ -258,6 +267,7 @@ export async function processQueryResponse( expandValidationConfigs, skipValidation, useEntityIds, + includeSpecialColumns, }); // Rename fields if field mapping is provided (for renamed fields in select) diff --git a/packages/fmodata/src/client/database.ts b/packages/fmodata/src/client/database.ts index 26950c92..5b350fb2 100644 --- a/packages/fmodata/src/client/database.ts +++ b/packages/fmodata/src/client/database.ts @@ -6,8 +6,9 @@ import { SchemaManager } from "./schema-manager"; import { FMTable } from "../orm/table"; import { WebhookManager } from "./webhook-builder"; -export class Database { +export class Database { private _useEntityIds: boolean = false; + private _includeSpecialColumns: IncludeSpecialColumns; public readonly schema: SchemaManager; public readonly webhook: WebhookManager; @@ -21,15 +22,24 @@ export class Database { * If set to false but some occurrences do not use entity IDs, an error will be thrown */ useEntityIds?: boolean; + /** + * Whether to include special columns (ROWID and ROWMODID) in responses. + * Note: Special columns are only included when there is no $select query. + */ + includeSpecialColumns?: IncludeSpecialColumns; }, ) { // Initialize schema manager this.schema = new SchemaManager(this.databaseName, this.context); this.webhook = new WebhookManager(this.databaseName, this.context); this._useEntityIds = config?.useEntityIds ?? false; + this._includeSpecialColumns = (config?.includeSpecialColumns ?? + false) as IncludeSpecialColumns; } - from>(table: T): EntitySet { + from>( + table: T, + ): EntitySet { // Only override database-level useEntityIds if table explicitly sets it // (not if it's undefined, which would override the database setting) if ( @@ -40,7 +50,7 @@ export class Database { this._useEntityIds = tableUseEntityIds; } } - return new EntitySet({ + return new EntitySet({ occurrence: table as T, databaseName: this.databaseName, context: this.context, diff --git a/packages/fmodata/src/client/delete-builder.ts b/packages/fmodata/src/client/delete-builder.ts index 0df96248..fd742f2a 100644 --- a/packages/fmodata/src/client/delete-builder.ts +++ b/packages/fmodata/src/client/delete-builder.ts @@ -2,7 +2,7 @@ import type { ExecutionContext, ExecutableBuilder, Result, - WithSystemFields, + WithSpecialColumns, ExecuteOptions, ExecuteMethodOptions, } from "../types"; @@ -26,17 +26,21 @@ export class DeleteBuilder> { private context: ExecutionContext; private table: Occ; private databaseUseEntityIds: boolean; + private databaseIncludeSpecialColumns: boolean; constructor(config: { occurrence: Occ; databaseName: string; context: ExecutionContext; databaseUseEntityIds?: boolean; + databaseIncludeSpecialColumns?: boolean; }) { this.table = config.occurrence; this.databaseName = config.databaseName; this.context = config.context; this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; + this.databaseIncludeSpecialColumns = + config.databaseIncludeSpecialColumns ?? false; } /** diff --git a/packages/fmodata/src/client/entity-set.ts b/packages/fmodata/src/client/entity-set.ts index fb03d177..a2612ba1 100644 --- a/packages/fmodata/src/client/entity-set.ts +++ b/packages/fmodata/src/client/entity-set.ts @@ -41,16 +41,20 @@ type ExtractColumnsFromOcc = : never : never; -export class EntitySet> { +export class EntitySet< + Occ extends FMTable, + DatabaseIncludeSpecialColumns extends boolean = false, +> { private occurrence: Occ; private databaseName: string; private context: ExecutionContext; - private database: Database; // Database instance for accessing occurrences + private database: Database; // Database instance for accessing occurrences private isNavigateFromEntitySet?: boolean; private navigateRelation?: string; private navigateSourceTableName?: string; private navigateBasePath?: string; // Full base path for chained navigations private databaseUseEntityIds: boolean; + private databaseIncludeSpecialColumns: DatabaseIncludeSpecialColumns; private logger: InternalLogger; constructor(config: { @@ -66,17 +70,23 @@ export class EntitySet> { // Get useEntityIds from database if available, otherwise default to false this.databaseUseEntityIds = (config.database as any)?._useEntityIds ?? false; + // Get includeSpecialColumns from database if available, otherwise default to false + this.databaseIncludeSpecialColumns = + (config.database as any)?._includeSpecialColumns ?? false; this.logger = config.context?._getLogger?.() ?? createLogger(); } // Type-only method to help TypeScript infer the schema from table - static create>(config: { + static create< + Occ extends FMTable, + DatabaseIncludeSpecialColumns extends boolean = false, + >(config: { occurrence: Occ; databaseName: string; context: ExecutionContext; - database: Database; - }): EntitySet { - return new EntitySet({ + database: Database; + }): EntitySet { + return new EntitySet({ occurrence: config.occurrence, databaseName: config.databaseName, context: config.context, @@ -89,13 +99,22 @@ export class EntitySet> { keyof InferSchemaOutputFromFMTable, false, false, - {} + {}, + DatabaseIncludeSpecialColumns > { - const builder = new QueryBuilder({ + const builder = new QueryBuilder< + Occ, + keyof InferSchemaOutputFromFMTable, + false, + false, + {}, + DatabaseIncludeSpecialColumns + >({ occurrence: this.occurrence as Occ, databaseName: this.databaseName, context: this.context, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); // Apply defaultSelect if occurrence exists and select hasn't been called @@ -124,12 +143,22 @@ export class EntitySet> { const allColumns = getTableColumns( this.occurrence, ) as ExtractColumnsFromOcc; - return builder.select(allColumns).top(1000) as QueryBuilder< + + // Include special columns if enabled at database level + const systemColumns = this.databaseIncludeSpecialColumns + ? { ROWID: true, ROWMODID: true } + : undefined; + + return builder + .select(allColumns, systemColumns) + .top(1000) as QueryBuilder< Occ, keyof InferSchemaOutputFromFMTable, false, false, - {} + {}, + DatabaseIncludeSpecialColumns, + typeof systemColumns >; } else if (typeof defaultSelectValue === "object") { // defaultSelectValue is a select object (Record) @@ -141,7 +170,8 @@ export class EntitySet> { keyof InferSchemaOutputFromFMTable, false, false, - {} + {}, + DatabaseIncludeSpecialColumns >; } // If defaultSelect is "all", no changes needed (current behavior) @@ -173,14 +203,23 @@ export class EntitySet> { false, undefined, keyof InferSchemaOutputFromFMTable, - {} + {}, + DatabaseIncludeSpecialColumns > { - const builder = new RecordBuilder({ + const builder = new RecordBuilder< + Occ, + false, + undefined, + keyof InferSchemaOutputFromFMTable, + {}, + DatabaseIncludeSpecialColumns + >({ occurrence: this.occurrence, databaseName: this.databaseName, context: this.context, recordId: id, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); // Apply defaultSelect if occurrence exists @@ -209,7 +248,13 @@ export class EntitySet> { const allColumns = getTableColumns( this.occurrence as any, ) as ExtractColumnsFromOcc; - const selectedBuilder = builder.select(allColumns); + + // Include special columns if enabled at database level + const systemColumns = this.databaseIncludeSpecialColumns + ? { ROWID: true, ROWMODID: true } + : undefined; + + const selectedBuilder = builder.select(allColumns, systemColumns); // Propagate navigation context if present if ( this.isNavigateFromEntitySet && @@ -293,6 +338,7 @@ export class EntitySet> { data: data as any, // Input type is validated/transformed at runtime returnPreference: returnPreference as any, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); } @@ -323,6 +369,7 @@ export class EntitySet> { data: data as any, // Input type is validated/transformed at runtime returnPreference: returnPreference as any, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); } @@ -332,13 +379,17 @@ export class EntitySet> { databaseName: this.databaseName, context: this.context, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }) as any; } // Implementation navigate>( targetTable: ValidExpandTarget, - ): EntitySet ? TargetTable : never> { + ): EntitySet< + TargetTable extends FMTable ? TargetTable : never, + DatabaseIncludeSpecialColumns + > { // Check if it's an FMTable object or a string let relationName: string; @@ -361,7 +412,7 @@ export class EntitySet> { } // Create EntitySet with target table - const entitySet = new EntitySet({ + const entitySet = new EntitySet({ occurrence: targetTable, databaseName: this.databaseName, context: this.context, diff --git a/packages/fmodata/src/client/filemaker-odata.ts b/packages/fmodata/src/client/filemaker-odata.ts index a47c5eb4..df31d3db 100644 --- a/packages/fmodata/src/client/filemaker-odata.ts +++ b/packages/fmodata/src/client/filemaker-odata.ts @@ -24,6 +24,7 @@ export class FMServerConnection implements ExecutionContext { private serverUrl: string; private auth: Auth; private useEntityIds: boolean = false; + private includeSpecialColumns: boolean = false; private logger: InternalLogger; constructor(config: { serverUrl: string; @@ -63,6 +64,22 @@ export class FMServerConnection implements ExecutionContext { return this.useEntityIds; } + /** + * @internal + * Sets whether to include special columns (ROWID and ROWMODID) in requests + */ + _setIncludeSpecialColumns(includeSpecialColumns: boolean): void { + this.includeSpecialColumns = includeSpecialColumns; + } + + /** + * @internal + * Gets whether to include special columns (ROWID and ROWMODID) in requests + */ + _getIncludeSpecialColumns(): boolean { + return this.includeSpecialColumns; + } + /** * @internal * Gets the base URL for OData requests @@ -84,7 +101,11 @@ export class FMServerConnection implements ExecutionContext { */ async _makeRequest( url: string, - options?: RequestInit & FFetchOptions & { useEntityIds?: boolean }, + options?: RequestInit & + FFetchOptions & { + useEntityIds?: boolean; + includeSpecialColumns?: boolean; + }, ): Promise> { const logger = this._getLogger(); const baseUrl = `${this.serverUrl}${"apiKey" in this.auth ? `/otto` : ""}/fmi/odata/v4`; @@ -92,10 +113,21 @@ export class FMServerConnection implements ExecutionContext { // Use per-request override if provided, otherwise use the database-level setting const useEntityIds = options?.useEntityIds ?? this.useEntityIds; + const includeSpecialColumns = + options?.includeSpecialColumns ?? this.includeSpecialColumns; // Get includeODataAnnotations from options (it's passed through from execute options) const includeODataAnnotations = (options as any)?.includeODataAnnotations; + // Build Prefer header as comma-separated list when multiple preferences are set + const preferValues: string[] = []; + if (useEntityIds) { + preferValues.push("fmodata.entity-ids"); + } + if (includeSpecialColumns) { + preferValues.push("fmodata.include-specialcolumns"); + } + const headers = { Authorization: "apiKey" in this.auth @@ -103,7 +135,7 @@ export class FMServerConnection implements ExecutionContext { : `Basic ${btoa(`${this.auth.username}:${this.auth.password}`)}`, "Content-Type": "application/json", Accept: getAcceptHeader(includeODataAnnotations), - ...(useEntityIds ? { Prefer: "fmodata.entity-ids" } : {}), + ...(preferValues.length > 0 ? { Prefer: preferValues.join(", ") } : {}), ...(options?.headers || {}), }; @@ -271,13 +303,14 @@ export class FMServerConnection implements ExecutionContext { } } - database( + database( name: string, config?: { useEntityIds?: boolean; + includeSpecialColumns?: IncludeSpecialColumns; }, - ): Database { - return new Database(name, this, config); + ): Database { + return new Database(name, this, config); } /** @@ -285,14 +318,9 @@ export class FMServerConnection implements ExecutionContext { * @returns Promise resolving to an array of database names */ async listDatabaseNames(): Promise { - if ("apiKey" in this.auth) { - this.logger.error( - "listDatabaseNames not supported with API key authentication. OttoFMS requires that the API only be used with the database for which it was created", - ); - } const result = await this._makeRequest<{ value?: Array<{ name: string }>; - }>("/"); + }>("/$metadata", { headers: { Accept: "application/json" } }); if (result.error) { throw result.error; } diff --git a/packages/fmodata/src/client/insert-builder.ts b/packages/fmodata/src/client/insert-builder.ts index 01b74113..0294c9ec 100644 --- a/packages/fmodata/src/client/insert-builder.ts +++ b/packages/fmodata/src/client/insert-builder.ts @@ -52,6 +52,7 @@ export class InsertBuilder< private returnPreference: ReturnPreference; private databaseUseEntityIds: boolean; + private databaseIncludeSpecialColumns: boolean; constructor(config: { occurrence?: Occ; @@ -60,6 +61,7 @@ export class InsertBuilder< data: Partial>>; returnPreference?: ReturnPreference; databaseUseEntityIds?: boolean; + databaseIncludeSpecialColumns?: boolean; }) { this.table = config.occurrence; this.databaseName = config.databaseName; @@ -68,6 +70,8 @@ export class InsertBuilder< this.returnPreference = (config.returnPreference || "representation") as ReturnPreference; this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; + this.databaseIncludeSpecialColumns = + config.databaseIncludeSpecialColumns ?? false; } /** diff --git a/packages/fmodata/src/client/query/query-builder.ts b/packages/fmodata/src/client/query/query-builder.ts index 90b12d61..f6989ac2 100644 --- a/packages/fmodata/src/client/query/query-builder.ts +++ b/packages/fmodata/src/client/query/query-builder.ts @@ -6,15 +6,13 @@ import type { Result, ExecuteOptions, ConditionallyWithODataAnnotations, - ExtractSchemaFromOccurrence, + ConditionallyWithSpecialColumns, + NormalizeIncludeSpecialColumns, ExecuteMethodOptions, } from "../../types"; import { RecordCountMismatchError } from "../../errors"; import { type FFetchOptions } from "@fetchkit/ffetch"; -import { - transformFieldNamesArray, - transformOrderByField, -} from "../../transform"; +import { transformOrderByField } from "../../transform"; import { safeJsonParse } from "../sanitize-json"; import { parseErrorResponse } from "../error-parser"; import { isColumn, type Column } from "../../orm/column"; @@ -28,7 +26,6 @@ import { type InferSchemaOutputFromFMTable, type ValidExpandTarget, type ExtractTableName, - type ValidateNoContainerFields, getTableName, } from "../../orm/table"; import { @@ -37,14 +34,17 @@ import { type ExpandedRelations, resolveTableId, mergeExecuteOptions, - formatSelectFields, processQueryResponse, processSelectWithRenames, buildSelectExpandQueryString, createODataRequest, } from "../builders/index"; import { QueryUrlBuilder, type NavigationConfig } from "./url-builder"; -import type { TypeSafeOrderBy, QueryReturnType } from "./types"; +import type { + TypeSafeOrderBy, + QueryReturnType, + SystemColumnsOption, +} from "./types"; import { createLogger, InternalLogger } from "../../logger"; // Re-export QueryReturnType for backward compatibility @@ -70,6 +70,8 @@ export class QueryBuilder< SingleMode extends "exact" | "maybe" | false = false, IsCount extends boolean = false, Expands extends ExpandedRelations = {}, + DatabaseIncludeSpecialColumns extends boolean = false, + SystemCols extends SystemColumnsOption | undefined = undefined, > implements ExecutableBuilder< QueryReturnType< @@ -77,7 +79,8 @@ export class QueryBuilder< Selected, SingleMode, IsCount, - Expands + Expands, + SystemCols > > { @@ -92,10 +95,13 @@ export class QueryBuilder< private context: ExecutionContext; private navigation?: NavigationConfig; private databaseUseEntityIds: boolean; + private databaseIncludeSpecialColumns: boolean; private expandBuilder: ExpandBuilder; private urlBuilder: QueryUrlBuilder; // Mapping from field names to output keys (for renamed fields in select) private fieldMapping?: Record; + // System columns requested via select() second argument + private systemColumns?: SystemColumnsOption; private logger: InternalLogger; constructor(config: { @@ -103,12 +109,15 @@ export class QueryBuilder< databaseName: string; context: ExecutionContext; databaseUseEntityIds?: boolean; + databaseIncludeSpecialColumns?: boolean; }) { this.occurrence = config.occurrence; this.databaseName = config.databaseName; this.context = config.context; this.logger = config.context?._getLogger?.() ?? createLogger(); this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; + this.databaseIncludeSpecialColumns = + config.databaseIncludeSpecialColumns ?? false; this.expandBuilder = new ExpandBuilder( this.databaseUseEntityIds, this.logger, @@ -121,12 +130,21 @@ export class QueryBuilder< } /** - * Helper to merge database-level useEntityIds with per-request options + * Helper to merge database-level useEntityIds and includeSpecialColumns with per-request options */ private mergeExecuteOptions( options?: RequestInit & FFetchOptions & ExecuteOptions, - ): RequestInit & FFetchOptions & { useEntityIds?: boolean } { - return mergeExecuteOptions(options, this.databaseUseEntityIds); + ): RequestInit & + FFetchOptions & { + useEntityIds?: boolean; + includeSpecialColumns?: boolean; + } { + const merged = mergeExecuteOptions(options, this.databaseUseEntityIds); + return { + ...merged, + includeSpecialColumns: + options?.includeSpecialColumns ?? this.databaseIncludeSpecialColumns, + }; } /** @@ -159,24 +177,37 @@ export class QueryBuilder< | Record>> = Selected, NewSingle extends "exact" | "maybe" | false = SingleMode, NewCount extends boolean = IsCount, + NewSystemCols extends SystemColumnsOption | undefined = SystemCols, >(changes: { selectedFields?: NewSelected; singleMode?: NewSingle; isCountMode?: NewCount; queryOptions?: Partial>>; fieldMapping?: Record; - }): QueryBuilder { + systemColumns?: NewSystemCols; + }): QueryBuilder< + Occ, + NewSelected, + NewSingle, + NewCount, + Expands, + DatabaseIncludeSpecialColumns, + NewSystemCols + > { const newBuilder = new QueryBuilder< Occ, NewSelected, NewSingle, NewCount, - Expands + Expands, + DatabaseIncludeSpecialColumns, + NewSystemCols >({ occurrence: this.occurrence, databaseName: this.databaseName, context: this.context, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); newBuilder.queryOptions = { ...this.queryOptions, @@ -186,6 +217,10 @@ export class QueryBuilder< newBuilder.singleMode = (changes.singleMode ?? this.singleMode) as any; newBuilder.isCountMode = (changes.isCountMode ?? this.isCountMode) as any; newBuilder.fieldMapping = changes.fieldMapping ?? this.fieldMapping; + newBuilder.systemColumns = + changes.systemColumns !== undefined + ? changes.systemColumns + : this.systemColumns; // Copy navigation metadata newBuilder.navigation = this.navigation; newBuilder.urlBuilder = new QueryUrlBuilder( @@ -207,7 +242,15 @@ export class QueryBuilder< * userEmail: users.email // renamed! * }) * + * @example + * // Include system columns (ROWID, ROWMODID) when using select() + * db.from(users).list().select( + * { name: users.name }, + * { ROWID: true, ROWMODID: true } + * ) + * * @param fields - Object mapping output keys to column references (container fields excluded) + * @param systemColumns - Optional object to request system columns (ROWID, ROWMODID) * @returns QueryBuilder with updated selected fields */ select< @@ -215,7 +258,19 @@ export class QueryBuilder< string, Column, false> >, - >(fields: TSelect): QueryBuilder { + TSystemCols extends SystemColumnsOption = {}, + >( + fields: TSelect, + systemColumns?: TSystemCols, + ): QueryBuilder< + Occ, + TSelect, + SingleMode, + IsCount, + Expands, + DatabaseIncludeSpecialColumns, + TSystemCols + > { const tableName = getTableName(this.occurrence); const { selectedFields, fieldMapping } = processSelectWithRenames( fields, @@ -223,13 +278,23 @@ export class QueryBuilder< this.logger, ); + // Add system columns to selectedFields if requested + const finalSelectedFields = [...selectedFields]; + if (systemColumns?.ROWID) { + finalSelectedFields.push("ROWID"); + } + if (systemColumns?.ROWMODID) { + finalSelectedFields.push("ROWMODID"); + } + return this.cloneWithChanges({ selectedFields: fields as any, queryOptions: { - select: selectedFields, + select: finalSelectedFields, }, fieldMapping: Object.keys(fieldMapping).length > 0 ? fieldMapping : undefined, + systemColumns: systemColumns as any, }); } @@ -245,7 +310,15 @@ export class QueryBuilder< */ where( expression: FilterExpression | string, - ): QueryBuilder { + ): QueryBuilder< + Occ, + Selected, + SingleMode, + IsCount, + Expands, + DatabaseIncludeSpecialColumns, + SystemCols + > { // Handle raw string filters (escape hatch) if (typeof expression === "string") { this.queryOptions.filter = expression; @@ -295,7 +368,15 @@ export class QueryBuilder< | OrderByExpression> >, ] - ): QueryBuilder { + ): QueryBuilder< + Occ, + Selected, + SingleMode, + IsCount, + Expands, + DatabaseIncludeSpecialColumns, + SystemCols + > { const tableName = getTableName(this.occurrence); // Handle variadic arguments (multiple fields) @@ -440,14 +521,30 @@ export class QueryBuilder< top( count: number, - ): QueryBuilder { + ): QueryBuilder< + Occ, + Selected, + SingleMode, + IsCount, + Expands, + DatabaseIncludeSpecialColumns, + SystemCols + > { this.queryOptions.top = count; return this; } skip( count: number, - ): QueryBuilder { + ): QueryBuilder< + Occ, + Selected, + SingleMode, + IsCount, + Expands, + DatabaseIncludeSpecialColumns, + SystemCols + > { this.queryOptions.skip = count; return this; } @@ -483,7 +580,9 @@ export class QueryBuilder< selected: TSelected; nested: TNestedExpands; }; - } + }, + DatabaseIncludeSpecialColumns, + SystemCols > { // Use ExpandBuilder.processExpand to handle the expand logic type TargetBuilder = QueryBuilder< @@ -491,7 +590,8 @@ export class QueryBuilder< keyof InferSchemaOutputFromFMTable, false, false, - {} + {}, + DatabaseIncludeSpecialColumns >; const expandConfig = this.expandBuilder.processExpand< TargetTable, @@ -501,11 +601,20 @@ export class QueryBuilder< this.occurrence, callback as ((builder: TargetBuilder) => TargetBuilder) | undefined, () => - new QueryBuilder({ + new QueryBuilder< + TargetTable, + any, + any, + any, + any, + DatabaseIncludeSpecialColumns, + undefined + >({ occurrence: targetTable, databaseName: this.databaseName, context: this.context, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }), ); @@ -513,15 +622,39 @@ export class QueryBuilder< return this as any; } - single(): QueryBuilder { + single(): QueryBuilder< + Occ, + Selected, + "exact", + IsCount, + Expands, + DatabaseIncludeSpecialColumns, + SystemCols + > { return this.cloneWithChanges({ singleMode: "exact" as const }); } - maybeSingle(): QueryBuilder { + maybeSingle(): QueryBuilder< + Occ, + Selected, + "maybe", + IsCount, + Expands, + DatabaseIncludeSpecialColumns, + SystemCols + > { return this.cloneWithChanges({ singleMode: "maybe" as const }); } - count(): QueryBuilder { + count(): QueryBuilder< + Occ, + Selected, + SingleMode, + true, + Expands, + DatabaseIncludeSpecialColumns, + SystemCols + > { return this.cloneWithChanges({ isCountMode: true as const, queryOptions: { count: true }, @@ -531,7 +664,7 @@ export class QueryBuilder< /** * Builds the OData query string from current query options and expand configs. */ - private buildQueryString(): string { + private buildQueryString(includeSpecialColumns?: boolean): string { // Build query without expand and select (we'll add them manually if using entity IDs) const queryOptionsWithoutExpandAndSelect = { ...this.queryOptions }; const originalSelect = queryOptionsWithoutExpandAndSelect.select; @@ -547,12 +680,17 @@ export class QueryBuilder< : [String(originalSelect)] : undefined; + // Use merged includeSpecialColumns if provided, otherwise use database-level default + const finalIncludeSpecialColumns = + includeSpecialColumns ?? this.databaseIncludeSpecialColumns; + const selectExpandString = buildSelectExpandQueryString({ selectedFields: selectArray, expandConfigs: this.expandConfigs, table: this.occurrence, useEntityIds: this.databaseUseEntityIds, logger: this.logger, + includeSpecialColumns: finalIncludeSpecialColumns, }); // Append select/expand to existing query string @@ -573,19 +711,35 @@ export class QueryBuilder< ): Promise< Result< ConditionallyWithODataAnnotations< - QueryReturnType< - InferSchemaOutputFromFMTable, - Selected, - SingleMode, - IsCount, - Expands + ConditionallyWithSpecialColumns< + QueryReturnType< + InferSchemaOutputFromFMTable, + Selected, + SingleMode, + IsCount, + Expands, + SystemCols + >, + // Use the merged value: if explicitly provided in options, use that; otherwise use database default + NormalizeIncludeSpecialColumns< + EO["includeSpecialColumns"], + DatabaseIncludeSpecialColumns + >, + // Check if select was applied: if Selected is Record (object select) or a subset of keys, select was applied + Selected extends Record> + ? true + : Selected extends keyof InferSchemaOutputFromFMTable + ? false + : true >, EO["includeODataAnnotations"] extends true ? true : false > > > { const mergedOptions = this.mergeExecuteOptions(options); - const queryString = this.buildQueryString(); + const queryString = this.buildQueryString( + mergedOptions.includeSpecialColumns, + ); // Handle $count endpoint if (this.isCountMode) { @@ -618,6 +772,9 @@ export class QueryBuilder< return { data: undefined, error: result.error }; } + // Check if select was applied (runtime check) + const hasSelect = this.queryOptions.select !== undefined; + return processQueryResponse(result.data, { occurrence: this.occurrence, singleMode: this.singleMode, @@ -625,6 +782,7 @@ export class QueryBuilder< expandConfigs: this.expandConfigs, skipValidation: options?.skipValidation, useEntityIds: mergedOptions.useEntityIds, + includeSpecialColumns: mergedOptions.includeSpecialColumns, fieldMapping: this.fieldMapping, logger: this.logger, }); @@ -667,7 +825,8 @@ export class QueryBuilder< Selected, SingleMode, IsCount, - Expands + Expands, + SystemCols > > > { @@ -728,6 +887,9 @@ export class QueryBuilder< } const mergedOptions = this.mergeExecuteOptions(options); + // Check if select was applied (runtime check) + const hasSelect = this.queryOptions.select !== undefined; + return processQueryResponse(rawData, { occurrence: this.occurrence, singleMode: this.singleMode, @@ -735,6 +897,7 @@ export class QueryBuilder< expandConfigs: this.expandConfigs, skipValidation: options?.skipValidation, useEntityIds: mergedOptions.useEntityIds, + includeSpecialColumns: mergedOptions.includeSpecialColumns, fieldMapping: this.fieldMapping, logger: this.logger, }); diff --git a/packages/fmodata/src/client/query/response-processor.ts b/packages/fmodata/src/client/query/response-processor.ts index c3140601..ba99121b 100644 --- a/packages/fmodata/src/client/query/response-processor.ts +++ b/packages/fmodata/src/client/query/response-processor.ts @@ -20,6 +20,7 @@ export interface ProcessQueryResponseConfig { expandConfigs: ExpandConfig[]; skipValidation?: boolean; useEntityIds?: boolean; + includeSpecialColumns?: boolean; // Mapping from field names to output keys (for renamed fields in select) fieldMapping?: Record; logger: InternalLogger; @@ -214,6 +215,9 @@ export async function processQueryResponse( ); // Validate with original field names + // Special columns are excluded when using single() method (per OData spec behavior) + const shouldIncludeSpecialColumns = + singleMode === false ? config.includeSpecialColumns : false; const validationResult = singleMode !== false ? await validateSingleResponse( @@ -222,12 +226,14 @@ export async function processQueryResponse( selectedFields as string[] | undefined, expandValidationConfigs, singleMode, + shouldIncludeSpecialColumns, ) : await validateListResponse( data, schema, selectedFields as string[] | undefined, expandValidationConfigs, + shouldIncludeSpecialColumns, ); if (!validationResult.valid) { diff --git a/packages/fmodata/src/client/query/types.ts b/packages/fmodata/src/client/query/types.ts index a3b81441..9aae8637 100644 --- a/packages/fmodata/src/client/query/types.ts +++ b/packages/fmodata/src/client/query/types.ts @@ -70,30 +70,59 @@ export type ResolveExpandedRelations = { [K in keyof Exps]: ResolveExpandType[]; }; +/** + * System columns option for select() method. + * Allows explicitly requesting ROWID and/or ROWMODID when using select(). + */ +export type SystemColumnsOption = { + ROWID?: boolean; + ROWMODID?: boolean; +}; + +/** + * Extract system columns type from SystemColumnsOption. + * Returns an object type with ROWID and/or ROWMODID properties when set to true. + */ +export type SystemColumnsFromOption< + T extends SystemColumnsOption | undefined, +> = (T extends { ROWID: true } ? { ROWID: number } : {}) & + (T extends { ROWMODID: true } ? { ROWMODID: number } : {}); + export type QueryReturnType< T extends Record, Selected extends keyof T | Record>, SingleMode extends "exact" | "maybe" | false, IsCount extends boolean, Expands extends ExpandedRelations, + SystemCols extends SystemColumnsOption | undefined = undefined, > = IsCount extends true ? number : // Use tuple wrapping [Selected] extends [...] to prevent distribution over unions [Selected] extends [Record>] ? SingleMode extends "exact" - ? MapSelectToReturnType & ResolveExpandedRelations + ? MapSelectToReturnType & + ResolveExpandedRelations & + SystemColumnsFromOption : SingleMode extends "maybe" ? | (MapSelectToReturnType & - ResolveExpandedRelations) + ResolveExpandedRelations & + SystemColumnsFromOption) | null : (MapSelectToReturnType & - ResolveExpandedRelations)[] + ResolveExpandedRelations & + SystemColumnsFromOption)[] : // Use tuple wrapping to prevent distribution over union of keys [Selected] extends [keyof T] ? SingleMode extends "exact" - ? Pick & ResolveExpandedRelations + ? Pick & + ResolveExpandedRelations & + SystemColumnsFromOption : SingleMode extends "maybe" - ? (Pick & ResolveExpandedRelations) | null - : (Pick & ResolveExpandedRelations)[] + ? (Pick & + ResolveExpandedRelations & + SystemColumnsFromOption) | null + : (Pick & + ResolveExpandedRelations & + SystemColumnsFromOption)[] : never; diff --git a/packages/fmodata/src/client/record-builder.ts b/packages/fmodata/src/client/record-builder.ts index 48f66b8f..484c49c7 100644 --- a/packages/fmodata/src/client/record-builder.ts +++ b/packages/fmodata/src/client/record-builder.ts @@ -5,6 +5,8 @@ import type { ODataFieldResponse, ExecuteOptions, ConditionallyWithODataAnnotations, + ConditionallyWithSpecialColumns, + NormalizeIncludeSpecialColumns, ExecuteMethodOptions, } from "../types"; import type { @@ -35,6 +37,8 @@ import { import { type ResolveExpandedRelations, type ResolveExpandType, + type SystemColumnsOption, + type SystemColumnsFromOption, } from "./query/types"; import { createLogger, InternalLogger, Logger } from "../logger"; @@ -64,6 +68,7 @@ export type RecordReturnType< | keyof Schema | Record>>>, Expands extends ExpandedRelations, + SystemCols extends SystemColumnsOption | undefined = undefined, > = IsSingleField extends true ? FieldColumn extends Column ? TOutput @@ -71,10 +76,13 @@ export type RecordReturnType< : // Use tuple wrapping [Selected] extends [...] to prevent distribution over unions [Selected] extends [Record>] ? MapSelectToReturnType & - ResolveExpandedRelations + ResolveExpandedRelations & + SystemColumnsFromOption : // Use tuple wrapping to prevent distribution over union of keys [Selected] extends [keyof Schema] - ? Pick & ResolveExpandedRelations + ? Pick & + ResolveExpandedRelations & + SystemColumnsFromOption : never; export class RecordBuilder< @@ -88,6 +96,8 @@ export class RecordBuilder< Column>> > = keyof InferSchemaOutputFromFMTable>, Expands extends ExpandedRelations = {}, + DatabaseIncludeSpecialColumns extends boolean = false, + SystemCols extends SystemColumnsOption | undefined = undefined, > implements ExecutableBuilder< RecordReturnType< @@ -95,7 +105,8 @@ export class RecordBuilder< IsSingleField, FieldColumn, Selected, - Expands + Expands, + SystemCols > > { @@ -111,12 +122,15 @@ export class RecordBuilder< private navigateSourceTableName?: string; private databaseUseEntityIds: boolean; + private databaseIncludeSpecialColumns: boolean; // Properties for select/expand support private selectedFields?: string[]; private expandConfigs: ExpandConfig[] = []; // Mapping from field names to output keys (for renamed fields in select) private fieldMapping?: Record; + // System columns requested via select() second argument + private systemColumns?: SystemColumnsOption; private logger: InternalLogger; @@ -126,22 +140,34 @@ export class RecordBuilder< context: ExecutionContext; recordId: string | number; databaseUseEntityIds?: boolean; + databaseIncludeSpecialColumns?: boolean; }) { this.table = config.occurrence; this.databaseName = config.databaseName; this.context = config.context; this.recordId = config.recordId; this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; + this.databaseIncludeSpecialColumns = + config.databaseIncludeSpecialColumns ?? false; this.logger = config.context?._getLogger?.() ?? createLogger(); } /** - * Helper to merge database-level useEntityIds with per-request options + * Helper to merge database-level useEntityIds and includeSpecialColumns with per-request options */ private mergeExecuteOptions( options?: RequestInit & FFetchOptions & ExecuteOptions, - ): RequestInit & FFetchOptions & { useEntityIds?: boolean } { - return mergeExecuteOptions(options, this.databaseUseEntityIds); + ): RequestInit & + FFetchOptions & { + useEntityIds?: boolean; + includeSpecialColumns?: boolean; + } { + const merged = mergeExecuteOptions(options, this.databaseUseEntityIds); + return { + ...merged, + includeSpecialColumns: + options?.includeSpecialColumns ?? this.databaseIncludeSpecialColumns, + }; } /** @@ -171,25 +197,42 @@ export class RecordBuilder< string, Column>> > = Selected, + NewSystemCols extends SystemColumnsOption | undefined = SystemCols, >(changes: { selectedFields?: string[]; fieldMapping?: Record; - }): RecordBuilder { + systemColumns?: NewSystemCols; + }): RecordBuilder< + Occ, + false, + FieldColumn, + NewSelected, + Expands, + DatabaseIncludeSpecialColumns, + NewSystemCols + > { const newBuilder = new RecordBuilder< Occ, false, FieldColumn, NewSelected, - Expands + Expands, + DatabaseIncludeSpecialColumns, + NewSystemCols >({ occurrence: this.table, databaseName: this.databaseName, context: this.context, recordId: this.recordId, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); newBuilder.selectedFields = changes.selectedFields ?? this.selectedFields; newBuilder.fieldMapping = changes.fieldMapping ?? this.fieldMapping; + newBuilder.systemColumns = + changes.systemColumns !== undefined + ? changes.systemColumns + : this.systemColumns; newBuilder.expandConfigs = [...this.expandConfigs]; // Preserve navigation context newBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet; @@ -208,7 +251,8 @@ export class RecordBuilder< true, TColumn, keyof InferSchemaOutputFromFMTable>, - {} + {}, + DatabaseIncludeSpecialColumns > { // Runtime validation: ensure column is from the correct table const tableName = getTableName(this.table); @@ -223,13 +267,15 @@ export class RecordBuilder< true, TColumn, keyof InferSchemaOutputFromFMTable>, - {} + {}, + DatabaseIncludeSpecialColumns >({ occurrence: this.table, databaseName: this.databaseName, context: this.context, recordId: this.recordId, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); newBuilder.operation = "getSingleField"; newBuilder.operationColumn = column; @@ -254,7 +300,15 @@ export class RecordBuilder< * userEmail: contacts.email // renamed! * }) * + * @example + * // Include system columns (ROWID, ROWMODID) when using select() + * db.from(contacts).get("uuid").select( + * { name: contacts.name }, + * { ROWID: true, ROWMODID: true } + * ) + * * @param fields - Object mapping output keys to column references (container fields excluded) + * @param systemColumns - Optional object to request system columns (ROWID, ROWMODID) * @returns RecordBuilder with updated selected fields */ select< @@ -262,7 +316,19 @@ export class RecordBuilder< string, Column, false> >, - >(fields: TSelect): RecordBuilder { + TSystemCols extends SystemColumnsOption = {}, + >( + fields: TSelect, + systemColumns?: TSystemCols, + ): RecordBuilder< + Occ, + false, + FieldColumn, + TSelect, + Expands, + DatabaseIncludeSpecialColumns, + TSystemCols + > { const tableName = getTableName(this.table); const { selectedFields, fieldMapping } = processSelectWithRenames( fields, @@ -270,10 +336,20 @@ export class RecordBuilder< this.logger, ); + // Add system columns to selectedFields if requested + const finalSelectedFields = [...selectedFields]; + if (systemColumns?.ROWID) { + finalSelectedFields.push("ROWID"); + } + if (systemColumns?.ROWMODID) { + finalSelectedFields.push("ROWMODID"); + } + return this.cloneWithChanges({ - selectedFields, + selectedFields: finalSelectedFields, fieldMapping: Object.keys(fieldMapping).length > 0 ? fieldMapping : undefined, + systemColumns: systemColumns as any, }) as any; } @@ -323,7 +399,9 @@ export class RecordBuilder< selected: TSelected; nested: TNestedExpands; }; - } + }, + DatabaseIncludeSpecialColumns, + SystemCols > { // Create new builder with updated types const newBuilder = new RecordBuilder< @@ -331,18 +409,21 @@ export class RecordBuilder< false, FieldColumn, Selected, - any + any, + DatabaseIncludeSpecialColumns >({ occurrence: this.table, databaseName: this.databaseName, context: this.context, recordId: this.recordId, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); // Copy existing state newBuilder.selectedFields = this.selectedFields; newBuilder.fieldMapping = this.fieldMapping; + newBuilder.systemColumns = this.systemColumns; newBuilder.expandConfigs = [...this.expandConfigs]; newBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet; newBuilder.navigateRelation = this.navigateRelation; @@ -369,11 +450,20 @@ export class RecordBuilder< this.table ?? undefined, callback as ((builder: TargetBuilder) => TargetBuilder) | undefined, () => - new QueryBuilder({ + new QueryBuilder< + TargetTable, + any, + any, + any, + any, + DatabaseIncludeSpecialColumns, + undefined + >({ occurrence: targetTable, databaseName: this.databaseName, context: this.context, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }), ); @@ -387,7 +477,10 @@ export class RecordBuilder< TargetTable, keyof InferSchemaOutputFromFMTable, false, - false + false, + {}, + DatabaseIncludeSpecialColumns, + undefined > { // Extract name and validate const relationName = getTableName(targetTable); @@ -403,11 +496,20 @@ export class RecordBuilder< } // Create QueryBuilder with target table - const builder = new QueryBuilder({ + const builder = new QueryBuilder< + TargetTable, + any, + any, + any, + any, + DatabaseIncludeSpecialColumns, + undefined + >({ occurrence: targetTable, databaseName: this.databaseName, context: this.context, databaseUseEntityIds: this.databaseUseEntityIds, + databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); // Store the navigation info - we'll use it in execute @@ -452,13 +554,18 @@ export class RecordBuilder< /** * Builds the complete query string including $select and $expand parameters. */ - private buildQueryString(): string { + private buildQueryString(includeSpecialColumns?: boolean): string { + // Use merged includeSpecialColumns if provided, otherwise use database-level default + const finalIncludeSpecialColumns = + includeSpecialColumns ?? this.databaseIncludeSpecialColumns; + return buildSelectExpandQueryString({ selectedFields: this.selectedFields, expandConfigs: this.expandConfigs, table: this.table, useEntityIds: this.databaseUseEntityIds, logger: this.logger, + includeSpecialColumns: finalIncludeSpecialColumns, }); } @@ -467,12 +574,30 @@ export class RecordBuilder< ): Promise< Result< ConditionallyWithODataAnnotations< - RecordReturnType< - InferSchemaOutputFromFMTable>, - IsSingleField, - FieldColumn, - Selected, - Expands + ConditionallyWithSpecialColumns< + RecordReturnType< + InferSchemaOutputFromFMTable>, + IsSingleField, + FieldColumn, + Selected, + Expands, + SystemCols + >, + // Use the merged value: if explicitly provided in options, use that; otherwise use database default + NormalizeIncludeSpecialColumns< + EO["includeSpecialColumns"], + DatabaseIncludeSpecialColumns + >, + // Check if select was applied: if Selected is Record (object select) or a subset of keys, select was applied + IsSingleField extends true + ? false // Single field operations don't include special columns + : Selected extends Record> + ? true + : Selected extends keyof InferSchemaOutputFromFMTable< + NonNullable + > + ? false + : true >, EO["includeODataAnnotations"] extends true ? true : false > @@ -496,15 +621,17 @@ export class RecordBuilder< url = `/${this.databaseName}/${tableId}('${this.recordId}')`; } + const mergedOptions = this.mergeExecuteOptions(options); + if (this.operation === "getSingleField" && this.operationParam) { url += `/${this.operationParam}`; } else { // Add query string for select/expand (only when not getting a single field) - const queryString = this.buildQueryString(); + const queryString = this.buildQueryString( + mergedOptions.includeSpecialColumns, + ); url += queryString; } - - const mergedOptions = this.mergeExecuteOptions(options); const result = await this.context._makeRequest(url, mergedOptions); if (result.error) { @@ -538,6 +665,7 @@ export class RecordBuilder< expandValidationConfigs, skipValidation: options?.skipValidation, useEntityIds: mergedOptions.useEntityIds, + includeSpecialColumns: mergedOptions.includeSpecialColumns, fieldMapping: this.fieldMapping, }); } @@ -626,7 +754,8 @@ export class RecordBuilder< IsSingleField, FieldColumn, Selected, - Expands + Expands, + SystemCols > > > { @@ -652,10 +781,7 @@ export class RecordBuilder< } // Use shared response processor - const mergedOptions = mergeExecuteOptions( - options, - this.databaseUseEntityIds, - ); + const mergedOptions = this.mergeExecuteOptions(options); const expandBuilder = new ExpandBuilder( mergedOptions.useEntityIds ?? false, this.logger, @@ -672,6 +798,7 @@ export class RecordBuilder< expandValidationConfigs, skipValidation: options?.skipValidation, useEntityIds: mergedOptions.useEntityIds, + includeSpecialColumns: mergedOptions.includeSpecialColumns, fieldMapping: this.fieldMapping, }); } diff --git a/packages/fmodata/src/client/update-builder.ts b/packages/fmodata/src/client/update-builder.ts index a2b2292b..adb540ac 100644 --- a/packages/fmodata/src/client/update-builder.ts +++ b/packages/fmodata/src/client/update-builder.ts @@ -2,7 +2,6 @@ import type { ExecutionContext, ExecutableBuilder, Result, - WithSystemFields, ExecuteOptions, ExecuteMethodOptions, } from "../types"; @@ -35,6 +34,7 @@ export class UpdateBuilder< private returnPreference: ReturnPreference; private databaseUseEntityIds: boolean; + private databaseIncludeSpecialColumns: boolean; constructor(config: { occurrence: Occ; @@ -43,6 +43,7 @@ export class UpdateBuilder< data: Partial>; returnPreference: ReturnPreference; databaseUseEntityIds?: boolean; + databaseIncludeSpecialColumns?: boolean; }) { this.table = config.occurrence; this.databaseName = config.databaseName; @@ -50,6 +51,8 @@ export class UpdateBuilder< this.data = config.data; this.returnPreference = config.returnPreference; this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; + this.databaseIncludeSpecialColumns = + config.databaseIncludeSpecialColumns ?? false; } /** diff --git a/packages/fmodata/src/orm/field-builders.ts b/packages/fmodata/src/orm/field-builders.ts index d7acbaa7..f099dc02 100644 --- a/packages/fmodata/src/orm/field-builders.ts +++ b/packages/fmodata/src/orm/field-builders.ts @@ -36,11 +36,17 @@ export class FieldBuilder< /** * Mark this field as the primary key for the table. - * Primary keys are automatically read-only. + * Primary keys are automatically read-only and non-nullable. */ - primaryKey(): FieldBuilder { + primaryKey(): FieldBuilder< + NonNullable, + NonNullable, + NonNullable, + true + > { const builder = this._clone() as any; builder._primaryKey = true; + builder._notNull = true; // Primary keys are automatically non-nullable builder._readOnly = true; // Primary keys are automatically read-only return builder; } diff --git a/packages/fmodata/src/types.ts b/packages/fmodata/src/types.ts index 4c310d0d..820fe982 100644 --- a/packages/fmodata/src/types.ts +++ b/packages/fmodata/src/types.ts @@ -32,10 +32,16 @@ export interface ExecutableBuilder { export interface ExecutionContext { _makeRequest( url: string, - options?: RequestInit & FFetchOptions & { useEntityIds?: boolean }, + options?: RequestInit & + FFetchOptions & { + useEntityIds?: boolean; + includeSpecialColumns?: boolean; + }, ): Promise>; _setUseEntityIds?(useEntityIds: boolean): void; _getUseEntityIds?(): boolean; + _setIncludeSpecialColumns?(includeSpecialColumns: boolean): void; + _getIncludeSpecialColumns?(): boolean; _getBaseUrl?(): string; _getLogger?(): InternalLogger; } @@ -46,7 +52,7 @@ export type InferSchemaType> = { : never; }; -export type WithSystemFields = +export type WithSpecialColumns = T extends Record ? T & { ROWID: number; @@ -54,15 +60,12 @@ export type WithSystemFields = } : never; -// Helper type to exclude system fields from a union of keys +// Helper type to exclude special columns from a union of keys export type ExcludeSystemFields = Exclude< T, "ROWID" | "ROWMODID" >; -// Helper type to omit system fields from an object type -export type OmitSystemFields = Omit; - // OData record metadata fields (present on each record) export type ODataRecordMetadata = { "@id": string; @@ -158,6 +161,11 @@ export type ExecuteOptions = { * Overrides the default behavior of the database to use entity IDs (rather than field names) in THIS REQUEST ONLY */ useEntityIds?: boolean; + /** + * Overrides the default behavior of the database to include special columns (ROWID and ROWMODID) in THIS REQUEST ONLY. + * Note: Special columns are only included when there is no $select query. + */ + includeSpecialColumns?: boolean; }; /** @@ -213,6 +221,54 @@ export type ConditionallyWithODataAnnotations< } : T; +/** + * Normalizes includeSpecialColumns with a database-level default. + * Uses distributive conditional types to handle unions correctly. + * @template IncludeSpecialColumns - The includeSpecialColumns value from execute options + * @template DatabaseDefault - The database-level includeSpecialColumns setting (defaults to false) + */ +export type NormalizeIncludeSpecialColumns< + IncludeSpecialColumns extends boolean | undefined, + DatabaseDefault extends boolean = false, +> = [IncludeSpecialColumns] extends [true] + ? true + : [IncludeSpecialColumns] extends [false] + ? false + : DatabaseDefault; // When undefined, use database-level default + +/** + * Conditionally adds ROWID and ROWMODID special columns to a type. + * Special columns are only included when: + * - includeSpecialColumns is true AND + * - hasSelect is false (no $select query was applied) AND + * - T is an object type (not a primitive like string or number) + * + * Handles both single objects and arrays of objects. + */ +export type ConditionallyWithSpecialColumns< + T, + IncludeSpecialColumns extends boolean, + HasSelect extends boolean, +> = IncludeSpecialColumns extends true + ? HasSelect extends false + ? // Handle array types + T extends readonly (infer U)[] + ? U extends Record + ? (U & { + ROWID: number; + ROWMODID: number; + })[] + : T + : // Handle single object types + T extends Record + ? T & { + ROWID: number; + ROWMODID: number; + } + : T // Don't add special columns to primitives (e.g., single field queries) + : T + : T; + // Helper type to extract schema from a FMTable export type ExtractSchemaFromOccurrence = Occ extends { baseTable: { schema: infer S }; diff --git a/packages/fmodata/src/validation.ts b/packages/fmodata/src/validation.ts index 116ba375..f13b7530 100644 --- a/packages/fmodata/src/validation.ts +++ b/packages/fmodata/src/validation.ts @@ -99,6 +99,7 @@ export async function validateRecord>( schema: Record | undefined, selectedFields?: (keyof T)[], expandConfigs?: ExpandValidationConfig[], + includeSpecialColumns?: boolean, ): Promise< | { valid: true; data: T & ODataRecordMetadata } | { valid: false; error: ValidationError } @@ -112,15 +113,33 @@ export async function validateRecord>( if (editLink) metadata["@editLink"] = editLink; // If no schema, just return the data with metadata + // Exclude special columns if includeSpecialColumns is false if (!schema) { + const { ROWID, ROWMODID, ...restWithoutSystemFields } = rest; + const specialColumns: { ROWID?: number; ROWMODID?: number } = {}; + if (includeSpecialColumns) { + if (ROWID !== undefined) specialColumns.ROWID = ROWID; + if (ROWMODID !== undefined) specialColumns.ROWMODID = ROWMODID; + } return { valid: true, - data: { ...rest, ...metadata } as T & ODataRecordMetadata, + data: { + ...restWithoutSystemFields, + ...specialColumns, + ...metadata, + } as T & ODataRecordMetadata, }; } - // Filter out FileMaker system fields that shouldn't be in responses by default + // Extract FileMaker special columns - preserve them if includeSpecialColumns is enabled + // Note: Special columns are excluded when using single() method (per OData spec behavior) const { ROWID, ROWMODID, ...restWithoutSystemFields } = rest; + const specialColumns: { ROWID?: number; ROWMODID?: number } = {}; + // Only include special columns if explicitly enabled (they're excluded for single() by design) + if (includeSpecialColumns) { + if (ROWID !== undefined) specialColumns.ROWID = ROWID; + if (ROWMODID !== undefined) specialColumns.ROWMODID = ROWMODID; + } // If selected fields are specified, validate only those fields if (selectedFields && selectedFields.length > 0) { @@ -170,8 +189,18 @@ export async function validateRecord>( } } else { // For fields not in schema (like when explicitly selecting ROWID/ROWMODID) - // include them from the original response - validatedRecord[fieldName] = rest[fieldName]; + // Check if it's a special column that was destructured earlier + if (fieldName === "ROWID" || fieldName === "ROWMODID") { + // Use the destructured value since it was removed from rest + if (fieldName === "ROWID" && ROWID !== undefined) { + validatedRecord[fieldName] = ROWID; + } else if (fieldName === "ROWMODID" && ROWMODID !== undefined) { + validatedRecord[fieldName] = ROWMODID; + } + } else { + // For other fields not in schema, include them from the original response + validatedRecord[fieldName] = rest[fieldName]; + } } } @@ -229,6 +258,7 @@ export async function validateRecord>( expandConfig.targetSchema, expandConfig.selectedFields as string[] | undefined, expandConfig.nestedExpands, + includeSpecialColumns, ); if (!itemValidation.valid) { return { @@ -253,6 +283,7 @@ export async function validateRecord>( expandConfig.targetSchema, expandConfig.selectedFields as string[] | undefined, expandConfig.nestedExpands, + includeSpecialColumns, ); if (!itemValidation.valid) { return { @@ -273,14 +304,15 @@ export async function validateRecord>( } } - // Merge validated data with metadata + // Merge validated data with metadata and special columns return { valid: true, - data: { ...validatedRecord, ...metadata } as T & ODataRecordMetadata, + data: { ...validatedRecord, ...specialColumns, ...metadata } as T & + ODataRecordMetadata, }; } - // Validate all fields in schema, but exclude ROWID/ROWMODID by default + // Validate all fields in schema, but exclude ROWID/ROWMODID by default (unless includeSpecialColumns is enabled) const validatedRecord: Record = { ...restWithoutSystemFields }; for (const [fieldName, fieldSchema] of Object.entries(schema)) { @@ -378,6 +410,7 @@ export async function validateRecord>( expandConfig.targetSchema, expandConfig.selectedFields as string[] | undefined, expandConfig.nestedExpands, + includeSpecialColumns, ); if (!itemValidation.valid) { return { @@ -402,6 +435,7 @@ export async function validateRecord>( expandConfig.targetSchema, expandConfig.selectedFields as string[] | undefined, expandConfig.nestedExpands, + includeSpecialColumns, ); if (!itemValidation.valid) { return { @@ -424,7 +458,8 @@ export async function validateRecord>( return { valid: true, - data: { ...validatedRecord, ...metadata } as T & ODataRecordMetadata, + data: { ...validatedRecord, ...specialColumns, ...metadata } as T & + ODataRecordMetadata, }; } @@ -436,6 +471,7 @@ export async function validateListResponse>( schema: Record | undefined, selectedFields?: (keyof T)[], expandConfigs?: ExpandValidationConfig[], + includeSpecialColumns?: boolean, ): Promise< | { valid: true; data: (T & ODataRecordMetadata)[] } | { valid: false; error: ResponseStructureError | ValidationError } @@ -471,6 +507,7 @@ export async function validateListResponse>( schema, selectedFields, expandConfigs, + includeSpecialColumns, ); if (!validation.valid) { @@ -498,6 +535,7 @@ export async function validateSingleResponse>( selectedFields?: (keyof T)[], expandConfigs?: ExpandValidationConfig[], mode: "exact" | "maybe" = "maybe", + includeSpecialColumns?: boolean, ): Promise< | { valid: true; data: (T & ODataRecordMetadata) | null } | { valid: false; error: RecordCountMismatchError | ValidationError } @@ -539,6 +577,7 @@ export async function validateSingleResponse>( schema, selectedFields, expandConfigs, + includeSpecialColumns, ); if (!validation.valid) { diff --git a/packages/fmodata/tests/include-special-columns.test.ts b/packages/fmodata/tests/include-special-columns.test.ts new file mode 100644 index 00000000..2084eb36 --- /dev/null +++ b/packages/fmodata/tests/include-special-columns.test.ts @@ -0,0 +1,568 @@ +/** + * Tests for includeSpecialColumns feature + * + * These tests verify that the includeSpecialColumns option can be set at the database level + * and overridden at the request level, and that special columns (ROWID and ROWMODID) are + * included in responses when the header is set and no $select query is applied. + */ + +import { describe, it, expect, expectTypeOf, assert } from "vitest"; +import { fmTableOccurrence, textField } from "@proofkit/fmodata"; +import { simpleMock } from "./utils/mock-fetch"; +import { createMockClient } from "./utils/test-setup"; +import { first } from "es-toolkit/compat"; + +// Create a simple table occurrence for testing +const contactsTO = fmTableOccurrence("contacts", { + id: textField().primaryKey(), + name: textField(), +}); + +const connection = createMockClient(); + +describe("includeSpecialColumns feature", () => { + it("should include special columns header when enabled at database level", async () => { + const db = connection.database("TestDB", { + includeSpecialColumns: true, + }); + + let preferHeader: string | null = null; + let reqUrl: string | null = null; + const { data } = await db + .from(contactsTO) + .list() + .execute({ + hooks: { + before: async (req) => { + const headers = req.headers; + reqUrl = req.url; + preferHeader = headers.get("Prefer"); + return; + }, + }, + fetchHandler: simpleMock({ + body: { + value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], + }, + status: 200, + }), + }); + expect(preferHeader).toBe("fmodata.include-specialcolumns"); + const parsedUrl = new URL(reqUrl!); + const selectParam = parsedUrl.searchParams.get("$select"); + // since we're automatically adding a $select parameter (defaultSelect: "schema"), we need to include the special columns in the select parameter + expect(selectParam).toContain("ROWID"); + expect(selectParam).toContain("ROWMODID"); + + const firstRecord = data![0]!; + + // type checks + expectTypeOf(firstRecord).toHaveProperty("ROWID"); + expectTypeOf(firstRecord).toHaveProperty("ROWMODID"); + firstRecord.ROWID; + firstRecord.ROWMODID; + + // runtime check + expect(firstRecord).toHaveProperty("ROWID"); + expect(firstRecord).toHaveProperty("ROWMODID"); + }); + + it("should not add $select parameter when defaultSelect is not 'schema'", async () => { + const db = connection.database("TestDB", { includeSpecialColumns: true }); + + const contactsAll = fmTableOccurrence( + "contacts", + { + id: textField().primaryKey(), + name: textField(), + }, + { defaultSelect: "all" }, + ); + + let preferHeader: string | null = null; + let reqUrl: string | null = null; + const { data } = await db + .from(contactsAll) + .list() + .execute({ + hooks: { + before: async (req) => { + const headers = req.headers; + preferHeader = headers.get("Prefer"); + reqUrl = req.url; + return; + }, + }, + fetchHandler: simpleMock({ + body: { + value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], + }, + status: 200, + }), + }); + const parsedUrl = new URL(reqUrl!); + const selectParam = parsedUrl.searchParams.get("$select"); + // don't add $select parameter when defaultSelect is not 'schema' + expect(selectParam).toBeNull(); + + const firstRecord = data![0]!; + + // type checks + expectTypeOf(firstRecord).toHaveProperty("ROWID"); + expectTypeOf(firstRecord).toHaveProperty("ROWMODID"); + firstRecord.ROWID; + firstRecord.ROWMODID; + + // runtime check + expect(firstRecord).toHaveProperty("ROWID"); + expect(firstRecord).toHaveProperty("ROWMODID"); + }); + + it("should not include special columns header when disabled at database level", async () => { + const db = connection.database("TestDB", { + includeSpecialColumns: false, + }); + + let preferHeader: string | null = null; + const { data } = await db + .from(contactsTO) + .list() + .execute({ + hooks: { + before: async (req) => { + const headers = req.headers; + preferHeader = headers.get("Prefer"); + return; + }, + }, + fetchHandler: simpleMock({ + body: { value: [{ id: "1", name: "John" }] }, + status: 200, + }), + }); + expect(preferHeader).toBeNull(); + + const firstRecord = data![0]!; + + // type checks + expectTypeOf(firstRecord).not.toHaveProperty("ROWID"); + expectTypeOf(firstRecord).not.toHaveProperty("ROWMODID"); + // @ts-expect-error + firstRecord.ROWID; + // @ts-expect-error + firstRecord.ROWMODID; + + // runtime check + expect(firstRecord).not.toHaveProperty("ROWID"); + expect(firstRecord).not.toHaveProperty("ROWMODID"); + }); + + it("should be disabled by default at database level", async () => { + const db = connection.database("TestDB"); + + let preferHeader: string | null = null; + const { data } = await db + .from(contactsTO) + .list() + .execute({ + hooks: { + before: async (req) => { + const headers = req.headers; + preferHeader = headers.get("Prefer"); + return; + }, + }, + fetchHandler: simpleMock({ + body: { value: [{ id: "1", name: "John" }] }, + status: 200, + }), + }); + expect(preferHeader).toBeNull(); + + const firstRecord = data![0]!; + + // type checks + expectTypeOf(firstRecord).not.toHaveProperty("ROWID"); + expectTypeOf(firstRecord).not.toHaveProperty("ROWMODID"); + // @ts-expect-error + firstRecord.ROWID; + // @ts-expect-error + firstRecord.ROWMODID; + + // runtime check + expect(firstRecord).not.toHaveProperty("ROWID"); + expect(firstRecord).not.toHaveProperty("ROWMODID"); + }); + + it("should allow overriding includeSpecialColumns at request level", async () => { + const db = connection.database("TestDB", { + includeSpecialColumns: false, + }); + + // First request: use default (should NOT have header) + let preferHeader1: string | null = null; + const { data: data1 } = await db + .from(contactsTO) + .list() + .execute({ + hooks: { + before: async (req) => { + const headers = req.headers; + preferHeader1 = headers.get("Prefer"); + return; + }, + }, + fetchHandler: simpleMock({ + body: { value: [{ id: "1", name: "John" }] }, + status: 200, + }), + }); + + const firstRecord1 = data1![0]!; + + // type checks + expectTypeOf(firstRecord1).not.toHaveProperty("ROWID"); + expectTypeOf(firstRecord1).not.toHaveProperty("ROWMODID"); + // @ts-expect-error + firstRecord1.ROWID; + // @ts-expect-error + firstRecord1.ROWMODID; + + // runtime check + expect(firstRecord1).not.toHaveProperty("ROWID"); + expect(firstRecord1).not.toHaveProperty("ROWMODID"); + + // Second request: explicitly enable for this request only + let preferHeader2: string | null = null; + const { data: data2 } = await db + .from(contactsTO) + .list() + .execute({ + includeSpecialColumns: true, + hooks: { + before: async (req) => { + const headers = req.headers; + preferHeader2 = headers.get("Prefer"); + return; + }, + }, + fetchHandler: simpleMock({ + body: { + value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], + }, + status: 200, + }), + }); + + const firstRecord2 = data2![0]!; + + // type checks + expectTypeOf(firstRecord2).toHaveProperty("ROWID"); + expectTypeOf(firstRecord2).toHaveProperty("ROWMODID"); + firstRecord2.ROWID; + firstRecord2.ROWMODID; + + // runtime check + expect(firstRecord2).toHaveProperty("ROWID"); + expect(firstRecord2).toHaveProperty("ROWMODID"); + + // Third request: explicitly disable for this request + let preferHeader3: string | null = null; + await db + .from(contactsTO) + .list() + .execute({ + includeSpecialColumns: false, + hooks: { + before: async (req) => { + const headers = req.headers; + preferHeader3 = headers.get("Prefer"); + return; + }, + }, + fetchHandler: simpleMock({ body: { value: [] }, status: 200 }), + }); + + expect(preferHeader1).toBeNull(); + expect(preferHeader2).toBe("fmodata.include-specialcolumns"); + expect(preferHeader3).toBeNull(); + }); + + it("should combine includeSpecialColumns with useEntityIds in Prefer header", async () => { + const contactsTOWithEntityIds = fmTableOccurrence( + "contacts", + { + id: textField().primaryKey().entityId("FMFID:1"), + name: textField().entityId("FMFID:2"), + }, + { + entityId: "FMTID:100", + }, + ); + + const db = connection.database("TestDB", { + useEntityIds: true, + includeSpecialColumns: true, + }); + + let preferHeader: string | null = null; + const { data } = await db + .from(contactsTOWithEntityIds) + .list() + .execute({ + hooks: { + before: async (req) => { + const headers = req.headers; + preferHeader = headers.get("Prefer"); + return; + }, + }, + fetchHandler: simpleMock({ + body: { + value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], + }, + status: 200, + }), + }); + expect(preferHeader).toContain("fmodata.entity-ids"); + expect(preferHeader).toContain("fmodata.include-specialcolumns"); + // Should be comma-separated + expect(preferHeader).not.toBeNull(); + const preferValues = preferHeader!.split(", "); + expect(preferValues.length).toBe(2); + expect(preferValues).toContain("fmodata.entity-ids"); + expect(preferValues).toContain("fmodata.include-specialcolumns"); + + const firstRecord = data![0]!; + + // type checks + expectTypeOf(firstRecord).toHaveProperty("ROWID"); + expectTypeOf(firstRecord).toHaveProperty("ROWMODID"); + firstRecord.ROWID; + firstRecord.ROWMODID; + + // runtime check + expect(firstRecord).toHaveProperty("ROWID"); + expect(firstRecord).toHaveProperty("ROWMODID"); + }); + + it("should work with get() method for single records", async () => { + const db = connection.database("TestDB", { + includeSpecialColumns: true, + }); + + let preferHeader: string | null = null; + const { data } = await db + .from(contactsTO) + .get("123") + .execute({ + hooks: { + before: async (req) => { + const headers = req.headers; + preferHeader = headers.get("Prefer"); + return; + }, + }, + fetchHandler: simpleMock({ + body: { + id: "123", + name: "John", + ROWID: 123, + ROWMODID: 456, + }, + status: 200, + }), + }); + expect(preferHeader).toBe("fmodata.include-specialcolumns"); + + assert(data, "data is undefined"); + + // type checks + expectTypeOf(data).toHaveProperty("ROWID"); + expectTypeOf(data).toHaveProperty("ROWMODID"); + data.ROWID; + data.ROWMODID; + + // runtime check + expect(data).toHaveProperty("ROWID"); + expect(data).toHaveProperty("ROWMODID"); + }); + + it("should not include special columns when $select is applied", async () => { + const db = connection.database("TestDB", { + includeSpecialColumns: true, + }); + + // FileMaker OData requires ROWID/ROWMODID to be explicitly listed in $select + // to be returned (they are only included when explicitly requested or when header is set and no $select is applied) + let preferHeader: string | null = null; + const { data } = await db + .from(contactsTO) + .list() + .select({ name: contactsTO.name }) + .execute({ + hooks: { + before: async (req) => { + const headers = req.headers; + // Header should still be sent, but server won't return special columns + preferHeader = headers.get("Prefer"); + return; + }, + }, + fetchHandler: simpleMock({ + body: { + value: [{ name: "John" }], // No ROWID or ROWMODID + }, + status: 200, + }), + }); + expect(preferHeader).toBe("fmodata.include-specialcolumns"); + + const firstRecord = data![0]!; + + // type checks + expectTypeOf(firstRecord).not.toHaveProperty("ROWID"); + expectTypeOf(firstRecord).not.toHaveProperty("ROWMODID"); + // @ts-expect-error + firstRecord.ROWID; + // @ts-expect-error + firstRecord.ROWMODID; + + // runtime check + expect(firstRecord).not.toHaveProperty("ROWID"); + expect(firstRecord).not.toHaveProperty("ROWMODID"); + }); + + it("should not append ROWID/ROWMODID to explicit $select unless requested via systemColumns", () => { + const db = connection.database("TestDB", { + includeSpecialColumns: true, + }); + + // Explicit select() should remain exact (no implicit system columns) + const queryString = db + .from(contactsTO) + .list() + .select({ name: contactsTO.name }) + .getQueryString(); + + expect(queryString).toContain("$select="); + expect(queryString).toContain("name"); + expect(queryString).not.toContain("ROWID"); + expect(queryString).not.toContain("ROWMODID"); + + // But system columns should still be selectable when explicitly requested + const queryStringWithSystemCols = db + .from(contactsTO) + .list() + .select({ name: contactsTO.name }, { ROWID: true, ROWMODID: true }) + .getQueryString(); + + expect(queryStringWithSystemCols).toContain("ROWID"); + expect(queryStringWithSystemCols).toContain("ROWMODID"); + }); + + it("should work with single() method", async () => { + const db = connection.database("TestDB", { + includeSpecialColumns: true, + }); + + let preferHeader: string | null = null; + const { data } = await db + .from(contactsTO) + .list() + .single() + .execute({ + hooks: { + before: async (req) => { + const headers = req.headers; + preferHeader = headers.get("Prefer"); + return; + }, + }, + fetchHandler: simpleMock({ + body: { + id: "123", + name: "John", + ROWID: 123, + ROWMODID: 456, + }, + status: 200, + }), + }); + expect(preferHeader).toBe("fmodata.include-specialcolumns"); + + assert(data, "data is undefined"); + + // type checks + expectTypeOf(data).toHaveProperty("ROWID"); + expectTypeOf(data).toHaveProperty("ROWMODID"); + data.ROWID; + data.ROWMODID; + + // runtime check + expect(data).toHaveProperty("ROWID"); + expect(data).toHaveProperty("ROWMODID"); + }); + + it("should not include special columns if getSingleField() is used", async () => { + const db = connection.database("TestDB", { + includeSpecialColumns: true, + }); + + let preferHeader: string | null = null; + const { data } = await db + .from(contactsTO) + .get("123") + .getSingleField(contactsTO.name) + .execute({ + hooks: { + before: async (req) => { + const headers = req.headers; + preferHeader = headers.get("Prefer"); + return; + }, + }, + fetchHandler: simpleMock({ body: { value: "John" }, status: 200 }), + }); + expect(preferHeader).toBe("fmodata.include-specialcolumns"); + + expectTypeOf(data).not.toHaveProperty("ROWID"); + expectTypeOf(data).not.toHaveProperty("ROWMODID"); + // @ts-expect-error + data.ROWID; + // @ts-expect-error + data.ROWMODID; + }); + + it("should still allow you to select ROWID or ROWMODID in select()", async () => { + const db = connection.database("TestDB"); + + const { data } = await db + .from(contactsTO) + .list() + .select( + { + id: contactsTO.id, + }, + { ROWID: true, ROWMODID: true }, + ) + .execute({ + fetchHandler: simpleMock({ + body: { + value: [{ id: "1", ROWID: 123, ROWMODID: 456 }], + }, + status: 200, + }), + }); + const firstRecord = data![0]!; + + expectTypeOf(firstRecord).toHaveProperty("ROWID"); + expectTypeOf(firstRecord).toHaveProperty("ROWMODID"); + firstRecord.ROWID; + firstRecord.ROWMODID; + + // runtime check + expect(firstRecord).toHaveProperty("ROWID"); + expect(firstRecord).toHaveProperty("ROWMODID"); + }); +}); diff --git a/packages/fmodata/tests/typescript.test.ts b/packages/fmodata/tests/typescript.test.ts index 5b286569..ddb735f3 100644 --- a/packages/fmodata/tests/typescript.test.ts +++ b/packages/fmodata/tests/typescript.test.ts @@ -18,7 +18,7 @@ * helping ensure the API remains ergonomic and type-safe as the library evolves. */ -import { describe, expect, it, expectTypeOf, beforeEach } from "vitest"; +import { describe, expect, it, expectTypeOf } from "vitest"; import { z } from "zod/v4"; import { fmTableOccurrence, @@ -28,6 +28,7 @@ import { FMTable, getTableColumns, eq, + type InferTableSchema, } from "@proofkit/fmodata"; import { createMockFetch } from "./utils/mock-fetch"; import { createMockClient, contacts, users } from "./utils/test-setup"; @@ -554,4 +555,20 @@ describe("fmodata", () => { void _typeChecks; }); }); + + describe("InferSchemaType", () => { + it("Primary key fields should not be nullable in the inferred schema", () => { + const specialUsers = fmTableOccurrence("specialUsers", { + id: textField().primaryKey(), + name: textField(), + }); + type SpecialUserSchema = InferTableSchema; + type IdField = SpecialUserSchema["id"]; + + const controlTest: string | null = null; + + // @ts-expect-error - id should not be nullable + const idData: IdField = null; + }); + }); });