Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/split-fmodata-count-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@proofkit/fmodata": minor
---

Split the fmodata count API into 2 flows. `db.from(table).count()` now runs a count-only query against the `/$count` endpoint, while `db.from(table).list().count()` keeps the list query and returns `{ records, count }` from a single request. This improves pagination ergonomics and avoids forcing two requests when rows and total count are both needed.
3 changes: 2 additions & 1 deletion apps/docs/content/docs/fmodata/methods.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Quick reference for all available methods and operators in `@proofkit/fmodata`.
| Method | Description | Example |
|--------|-------------|---------|
| `list()` | Retrieve multiple records | `db.from(users).list().execute()` |
| `count()` | Count records without fetching rows | `db.from(users).count().where(eq(users.active, true)).execute()` |
| `get(id)` | Get a single record by ID | `db.from(users).get("user-123").execute()` |
| `getSingleField(column)` | Get a single field value | `db.from(users).get("user-123").getSingleField(users.email).execute()` |
| `single()` | Ensure exactly one record | `db.from(users).list().where(eq(...)).single().execute()` |
Expand All @@ -34,7 +35,7 @@ Quick reference for all available methods and operators in `@proofkit/fmodata`.
| `orderBy(...columns)` | Sort results | `db.from(users).list().orderBy(asc(users.name)).execute()` |
| `top(n)` | Limit results | `db.from(users).list().top(10).execute()` |
| `skip(n)` | Skip records (pagination) | `db.from(users).list().top(10).skip(20).execute()` |
| `count()` | Get total count | `db.from(users).list().count().execute()` |
| `count()` | Include total count with a list query | `db.from(users).list().top(10).skip(20).count().execute()` |
| `expand(table, builder?)` | Expand related records | `db.from(contacts).list().expand(users).execute()` |
| `navigate(table)` | Navigate to related table | `db.from(contacts).get("id").navigate(users).execute()` |

Expand Down
8 changes: 5 additions & 3 deletions apps/docs/content/docs/fmodata/queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,11 @@ const result = await db.from(users).list().top(10).execute();
// Skip records (pagination)
const result = await db.from(users).list().top(10).skip(20).execute();

// Count total records
const result = await db.from(users).list().count().execute();
// Count total records without fetching rows
const total = await db.from(users).count().execute();

// Fetch a page of rows and the total count in one request
const page = await db.from(users).list().top(10).skip(20).count().execute();
```

## Selecting Fields
Expand Down Expand Up @@ -244,4 +247,3 @@ const result = await db
.skip(0)
.execute();
```

7 changes: 5 additions & 2 deletions packages/fmodata/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,11 @@ const result = await db.from(users).list().top(10).execute();
// Skip records (pagination)
const result = await db.from(users).list().top(10).skip(20).execute();

// Count total records
const result = await db.from(users).list().count().execute();
// Count total records without fetching rows
const total = await db.from(users).count().execute();

// Fetch a page of rows and the total count in one request
const page = await db.from(users).list().top(10).skip(20).count().execute();
```

### Selecting Fields
Expand Down
6 changes: 5 additions & 1 deletion packages/fmodata/src/client/batch-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import { createClientRuntime } from "./runtime";
*/
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any ExecutableBuilder result type
type ExtractTupleTypes<T extends readonly ExecutableBuilder<any>[]> = {
[K in keyof T]: T[K] extends ExecutableBuilder<infer U> ? U : never;
[K in keyof T]: T[K] extends {
processResponse(response: Response, options?: ExecuteOptions): Promise<Result<infer U>>;
}
? U
: never;
};

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/fmodata/src/client/builders/read-builder-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface QueryReadBuilderState<TSchema> {
expandConfigs: ExpandConfig[];
singleMode: "exact" | "maybe" | false;
isCountMode: boolean;
includeCountMode: boolean;
fieldMapping?: Record<string, string>;
systemColumns?: SystemColumnsOption;
navigation?: NavigationConfig;
Expand All @@ -21,6 +22,7 @@ export function createInitialQueryReadBuilderState<TSchema>(): QueryReadBuilderS
expandConfigs: [],
singleMode: false,
isCountMode: false,
includeCountMode: false,
};
}

Expand Down
45 changes: 43 additions & 2 deletions packages/fmodata/src/client/builders/response-processor.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { RecordCountMismatchError } from "../../errors";
import { RecordCountMismatchError, ResponseStructureError } from "../../errors";
import type { InternalLogger } from "../../logger";
import type { FMTable } from "../../orm/table";
import { getBaseTableConfig } from "../../orm/table";
import { transformResponseFields } from "../../transform";
import type { Result } from "../../types";
import type { CountedListResult, Result } from "../../types";
import type { ExpandValidationConfig } from "../../validation";
import { validateListResponse, validateSingleResponse } from "../../validation";
import { ExpandBuilder } from "./expand-builder";
Expand Down Expand Up @@ -227,6 +227,7 @@ export async function processQueryResponse<T>(
skipValidation?: boolean;
useEntityIds?: boolean;
includeSpecialColumns?: boolean;
includeCount?: boolean;
// Mapping from field names to output keys (for renamed fields in select)
fieldMapping?: Record<string, string>;
logger: InternalLogger;
Expand All @@ -241,6 +242,7 @@ export async function processQueryResponse<T>(
skipValidation,
useEntityIds,
includeSpecialColumns,
includeCount,
fieldMapping,
logger,
} = config;
Expand Down Expand Up @@ -275,6 +277,45 @@ export async function processQueryResponse<T>(
};
}

if (includeCount) {
if (processedResponse.error) {
return {
data: undefined,
error: processedResponse.error,
};
}

if (singleMode !== false) {
return {
data: undefined,
error: new ResponseStructureError("list response for count-enabled query", response),
};
}

const rawCount = response?.["@odata.count"];
let parsedCount = Number.NaN;
if (typeof rawCount === "number") {
parsedCount = rawCount;
} else if (typeof rawCount === "string" && rawCount.trim() !== "") {
parsedCount = Number(rawCount);
}

if (!Number.isFinite(parsedCount)) {
return {
data: undefined,
error: new ResponseStructureError("response with valid @odata.count", response),
};
}

return {
data: {
records: processedResponse.data as T[],
count: parsedCount,
} as CountedListResult<T>,
error: undefined,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return processedResponse;
}

Expand Down
172 changes: 172 additions & 0 deletions packages/fmodata/src/client/count-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { Effect } from "effect";
import buildQuery, { type QueryOptions } from "odata-query";
import { requestFromService, runLayerResult } from "../effect";
import type { FMODataErrorType } from "../errors";
import { BuilderInvariantError, isFMODataError, ResponseStructureError } from "../errors";
import type { FilterExpression } from "../orm/operators";
import { type FMTable, getTableName, type InferSchemaOutputFromFMTable } from "../orm/table";
import type { FMODataLayer, ODataConfig } from "../services";
import type { ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, Result } from "../types";
import { createODataRequest, mergeExecuteOptions } from "./builders/index";
import { parseErrorResponse } from "./error-parser";
import { type NavigationConfig, QueryUrlBuilder } from "./query/url-builder";
import { createClientRuntime } from "./runtime";

function normalizeCountBuildError(error: unknown): FMODataErrorType {
if (isFMODataError(error)) {
return error;
}
if (error instanceof Error) {
return new BuilderInvariantError("CountBuilder.execute", error.message, { cause: error });
}
return new BuilderInvariantError("CountBuilder.execute", String(error));
}

export class CountBuilder<
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
Occ extends FMTable<any, any>,
DatabaseIncludeSpecialColumns extends boolean = false,
> implements ExecutableBuilder<number>
{
private readonly occurrence: Occ;
private readonly layer: FMODataLayer;
private readonly config: ODataConfig;
private readonly urlBuilder: QueryUrlBuilder;
private filterExpression?: FilterExpression;
private readonly queryOptions: Partial<QueryOptions<InferSchemaOutputFromFMTable<Occ>>> = {};
private navigationConfig?: NavigationConfig;

constructor(config: {
occurrence: Occ;
layer: FMODataLayer;
}) {
this.occurrence = config.occurrence;
const runtime = createClientRuntime(config.layer);
this.layer = runtime.layer;
this.config = runtime.config;
this.urlBuilder = new QueryUrlBuilder(this.config.databaseName, this.occurrence, this.config.useEntityIds);
}

set navigation(navigation: NavigationConfig | undefined) {
this.navigationConfig = navigation;
}

where(expression: FilterExpression | string): CountBuilder<Occ, DatabaseIncludeSpecialColumns> {
if (typeof expression === "string") {
this.filterExpression = undefined;
this.queryOptions.filter = expression;
return this;
}

this.filterExpression = expression;
this.queryOptions.filter = undefined;
return this;
}

private buildQueryString(useEntityIds?: boolean): string {
const finalUseEntityIds = useEntityIds ?? this.config.useEntityIds;
const queryOptions = { ...this.queryOptions };

if (this.filterExpression) {
queryOptions.filter = this.filterExpression.toODataFilter(finalUseEntityIds);
}

queryOptions.count = undefined;
queryOptions.select = undefined;
queryOptions.expand = undefined;
queryOptions.top = undefined;
queryOptions.skip = undefined;
queryOptions.orderBy = undefined;

return buildQuery(queryOptions);
}

private parseCountValue(raw: unknown): number | ResponseStructureError {
let count = Number.NaN;
if (typeof raw === "number") {
count = raw;
} else if (typeof raw === "string" && raw.trim() !== "") {
count = Number(raw);
}
return Number.isFinite(count) ? count : new ResponseStructureError("numeric count response", raw);
}

execute<EO extends ExecuteOptions>(options?: ExecuteMethodOptions<EO>): Promise<Result<number>> {
const mergedOptions = mergeExecuteOptions(options, this.config.useEntityIds);
let queryString: string;
let url: string;

try {
queryString = this.buildQueryString(mergedOptions.useEntityIds);
url = this.urlBuilder.build(queryString, {
isCount: true,
useEntityIds: mergedOptions.useEntityIds,
navigation: this.navigationConfig,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (error) {
return Promise.resolve({
data: undefined,
error: normalizeCountBuildError(error),
});
}

const pipeline = requestFromService(url, mergedOptions).pipe(
Effect.flatMap((data) => {
const parsed = this.parseCountValue(data);
return parsed instanceof ResponseStructureError ? Effect.fail(parsed) : Effect.succeed(parsed);
}),
);

return runLayerResult(this.layer, pipeline, "fmodata.query.count", {
"fmodata.table": getTableName(this.occurrence),
});
}

getQueryString(options?: { useEntityIds?: boolean }): string {
const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds;
const queryString = this.buildQueryString(useEntityIds);
return this.urlBuilder.buildPath(queryString, {
isCount: true,
useEntityIds,
navigation: this.navigationConfig,
});
}

// biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value
getRequestConfig(): { method: string; url: string; body?: any } {
const queryString = this.buildQueryString(this.config.useEntityIds);
const url = this.urlBuilder.build(queryString, {
isCount: true,
useEntityIds: this.config.useEntityIds,
navigation: this.navigationConfig,
});

return {
method: "GET",
url,
};
}

toRequest(baseUrl: string, options?: ExecuteOptions): Request {
const config = this.getRequestConfig();
return createODataRequest(baseUrl, config, options);
}

async processResponse(response: Response, _options?: ExecuteOptions): Promise<Result<number>> {
if (!response.ok) {
const error = await parseErrorResponse(
response,
response.url || `/${this.config.databaseName}/${getTableName(this.occurrence)}/$count`,
);
return { data: undefined, error };
}

const raw = await response.text();
const parsed = this.parseCountValue(raw);
if (parsed instanceof ResponseStructureError) {
return { data: undefined, error: parsed };
}

return { data: parsed, error: undefined };
}
}
25 changes: 23 additions & 2 deletions packages/fmodata/src/client/entity-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../orm/table";
import type { FMODataLayer, ODataConfig } from "../services";
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";
Expand Down Expand Up @@ -105,15 +106,24 @@ export class EntitySet<Occ extends FMTable<any, any>, DatabaseIncludeSpecialColu
return builder;
}

// biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default
list(): QueryBuilder<Occ, keyof InferSchemaOutputFromFMTable<Occ>, false, false, {}, DatabaseIncludeSpecialColumns> {
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,
Expand Down Expand Up @@ -145,6 +155,7 @@ export class EntitySet<Occ extends FMTable<any, any>, DatabaseIncludeSpecialColu
false,
// biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default
{},
false,
DatabaseIncludeSpecialColumns
>;
}
Expand All @@ -161,6 +172,7 @@ export class EntitySet<Occ extends FMTable<any, any>, DatabaseIncludeSpecialColu
false,
// biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default
{},
false,
DatabaseIncludeSpecialColumns
>;
}
Expand All @@ -172,6 +184,15 @@ export class EntitySet<Occ extends FMTable<any, any>, DatabaseIncludeSpecialColu
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(
id: string | number,
// biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default
Expand Down
Loading
Loading