Skip to content

Commit e78762a

Browse files
committed
Fix counted query handling and typing
- preserve query errors before counted result shaping - keep typed execute/processResponse returns - fix navigated /$count URL generation
1 parent ac7c9f4 commit e78762a

7 files changed

Lines changed: 205 additions & 77 deletions

File tree

packages/fmodata/src/client/builders/response-processor.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,13 @@ export async function processQueryResponse<T>(
278278
}
279279

280280
if (includeCount) {
281+
if (processedResponse.error) {
282+
return {
283+
data: undefined,
284+
error: processedResponse.error,
285+
};
286+
}
287+
281288
if (singleMode !== false) {
282289
return {
283290
data: undefined,

packages/fmodata/src/client/query/query-builder.ts

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,84 @@ function normalizeQueryBuildError(error: unknown): FMODataErrorType {
6565
return new BuilderInvariantError("QueryBuilder.execute", String(error));
6666
}
6767

68+
type QueryBuilderHasSelect<
69+
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
70+
Occ extends FMTable<any, any>,
71+
Selected,
72+
> = Selected extends Record<string, Column<any, any, any>> // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
73+
? true
74+
: Selected extends keyof InferSchemaOutputFromFMTable<Occ>
75+
? false
76+
: true;
77+
78+
type BaseQueryBuilderReturn<
79+
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
80+
Occ extends FMTable<any, any>,
81+
Selected extends
82+
| keyof InferSchemaOutputFromFMTable<Occ>
83+
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
84+
| Record<string, Column<any, any, ExtractTableName<Occ>>>,
85+
SingleMode extends "exact" | "maybe" | false,
86+
IsCount extends boolean,
87+
Expands extends ExpandedRelations,
88+
IncludeCount extends boolean,
89+
SystemCols extends SystemColumnsOption | undefined,
90+
> = QueryReturnType<
91+
InferSchemaOutputFromFMTable<Occ>,
92+
Selected,
93+
SingleMode,
94+
IsCount,
95+
Expands,
96+
IncludeCount,
97+
SystemCols
98+
>;
99+
100+
type ExecutableQueryBuilderReturn<
101+
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
102+
Occ extends FMTable<any, any>,
103+
Selected extends
104+
| keyof InferSchemaOutputFromFMTable<Occ>
105+
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
106+
| Record<string, Column<any, any, ExtractTableName<Occ>>>,
107+
SingleMode extends "exact" | "maybe" | false,
108+
IsCount extends boolean,
109+
Expands extends ExpandedRelations,
110+
IncludeCount extends boolean,
111+
SystemCols extends SystemColumnsOption | undefined,
112+
> =
113+
| ConditionallyWithODataAnnotations<
114+
ConditionallyWithSpecialColumns<
115+
BaseQueryBuilderReturn<Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols>,
116+
true,
117+
QueryBuilderHasSelect<Occ, Selected>
118+
>,
119+
true
120+
>
121+
| ConditionallyWithODataAnnotations<
122+
ConditionallyWithSpecialColumns<
123+
BaseQueryBuilderReturn<Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols>,
124+
true,
125+
QueryBuilderHasSelect<Occ, Selected>
126+
>,
127+
false
128+
>
129+
| ConditionallyWithODataAnnotations<
130+
ConditionallyWithSpecialColumns<
131+
BaseQueryBuilderReturn<Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols>,
132+
false,
133+
QueryBuilderHasSelect<Occ, Selected>
134+
>,
135+
true
136+
>
137+
| ConditionallyWithODataAnnotations<
138+
ConditionallyWithSpecialColumns<
139+
BaseQueryBuilderReturn<Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols>,
140+
false,
141+
QueryBuilderHasSelect<Occ, Selected>
142+
>,
143+
false
144+
>;
145+
68146
export class QueryBuilder<
69147
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
70148
Occ extends FMTable<any, any>,
@@ -81,15 +159,7 @@ export class QueryBuilder<
81159
SystemCols extends SystemColumnsOption | undefined = undefined,
82160
> implements
83161
ExecutableBuilder<
84-
QueryReturnType<
85-
InferSchemaOutputFromFMTable<Occ>,
86-
Selected,
87-
SingleMode,
88-
IsCount,
89-
Expands,
90-
IncludeCount,
91-
SystemCols
92-
>
162+
ExecutableQueryBuilderReturn<Occ, Selected, SingleMode, IsCount, Expands, IncludeCount, SystemCols>
93163
>
94164
{
95165
private readState = createInitialQueryReadBuilderState<InferSchemaOutputFromFMTable<Occ>>();

packages/fmodata/src/client/query/url-builder.ts

Lines changed: 1 addition & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -52,61 +52,7 @@ export class QueryUrlBuilder {
5252
navigation?: NavigationConfig;
5353
},
5454
): string {
55-
const effectiveUseEntityIds = options.useEntityIds ?? this.useEntityIds;
56-
const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), effectiveUseEntityIds);
57-
58-
const navigation = options.navigation;
59-
if (navigation?.recordId && navigation?.relation) {
60-
return this.buildRecordNavigation(queryString, tableId, navigation, effectiveUseEntityIds);
61-
}
62-
if (navigation?.relation) {
63-
return this.buildEntitySetNavigation(queryString, tableId, navigation, effectiveUseEntityIds);
64-
}
65-
if (options.isCount) {
66-
return `/${this.databaseName}/${tableId}/$count${queryString}`;
67-
}
68-
return `/${this.databaseName}/${tableId}${queryString}`;
69-
}
70-
71-
/**
72-
* Builds URL for record navigation: /database/sourceTable('recordId')/relation
73-
* or /database/sourceTable/baseRelation('recordId')/relation for chained navigations
74-
*/
75-
private buildRecordNavigation(
76-
queryString: string,
77-
_tableId: string,
78-
navigation: NavigationConfig,
79-
useEntityIds: boolean,
80-
): string {
81-
const sourceTable = useEntityIds
82-
? (navigation.sourceTableEntityId ?? navigation.sourceTableName)
83-
: navigation.sourceTableName;
84-
const baseRelation = useEntityIds
85-
? (navigation.baseRelationEntityId ?? navigation.baseRelation)
86-
: navigation.baseRelation;
87-
const relation = useEntityIds ? (navigation.relationEntityId ?? navigation.relation) : navigation.relation;
88-
const { recordId } = navigation;
89-
const base = baseRelation ? `${sourceTable}/${baseRelation}('${recordId}')` : `${sourceTable}('${recordId}')`;
90-
return `/${this.databaseName}/${base}/${relation}${queryString}`;
91-
}
92-
93-
/**
94-
* Builds URL for entity set navigation: /database/sourceTable/relation
95-
* or /database/basePath/relation for chained navigations
96-
*/
97-
private buildEntitySetNavigation(
98-
queryString: string,
99-
_tableId: string,
100-
navigation: NavigationConfig,
101-
useEntityIds: boolean,
102-
): string {
103-
const sourceTable = useEntityIds
104-
? (navigation.sourceTableEntityId ?? navigation.sourceTableName)
105-
: navigation.sourceTableName;
106-
const basePath = useEntityIds ? (navigation.basePathEntityId ?? navigation.basePath) : navigation.basePath;
107-
const relation = useEntityIds ? (navigation.relationEntityId ?? navigation.relation) : navigation.relation;
108-
const base = basePath || sourceTable;
109-
return `/${this.databaseName}/${base}/${relation}${queryString}`;
55+
return `/${this.databaseName}${this.buildPath(queryString, options)}`;
11056
}
11157

11258
/**

packages/fmodata/src/client/record-builder.ts

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,94 @@ export type RecordReturnType<
8282
? Pick<Schema, Selected> & ResolveExpandedRelations<Expands> & SystemColumnsFromOption<SystemCols>
8383
: never;
8484

85+
type RecordBuilderHasSelect<
86+
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
87+
Occ extends FMTable<any, any>,
88+
IsSingleField extends boolean,
89+
Selected,
90+
> = IsSingleField extends true
91+
? false
92+
: // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
93+
Selected extends Record<string, Column<any, any, any>>
94+
? true
95+
: Selected extends keyof InferSchemaOutputFromFMTable<NonNullable<Occ>>
96+
? false
97+
: true;
98+
99+
type ExecutableRecordBuilderReturn<
100+
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
101+
Occ extends FMTable<any, any>,
102+
IsSingleField extends boolean,
103+
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
104+
FieldColumn extends Column<any, any, any, any> | undefined,
105+
Selected extends
106+
| keyof InferSchemaOutputFromFMTable<NonNullable<Occ>>
107+
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
108+
| Record<string, Column<any, any, ExtractTableName<NonNullable<Occ>>>>,
109+
Expands extends ExpandedRelations,
110+
SystemCols extends SystemColumnsOption | undefined,
111+
> =
112+
| ConditionallyWithODataAnnotations<
113+
ConditionallyWithSpecialColumns<
114+
RecordReturnType<
115+
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
116+
IsSingleField,
117+
FieldColumn,
118+
Selected,
119+
Expands,
120+
SystemCols
121+
>,
122+
true,
123+
RecordBuilderHasSelect<Occ, IsSingleField, Selected>
124+
>,
125+
true
126+
>
127+
| ConditionallyWithODataAnnotations<
128+
ConditionallyWithSpecialColumns<
129+
RecordReturnType<
130+
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
131+
IsSingleField,
132+
FieldColumn,
133+
Selected,
134+
Expands,
135+
SystemCols
136+
>,
137+
true,
138+
RecordBuilderHasSelect<Occ, IsSingleField, Selected>
139+
>,
140+
false
141+
>
142+
| ConditionallyWithODataAnnotations<
143+
ConditionallyWithSpecialColumns<
144+
RecordReturnType<
145+
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
146+
IsSingleField,
147+
FieldColumn,
148+
Selected,
149+
Expands,
150+
SystemCols
151+
>,
152+
false,
153+
RecordBuilderHasSelect<Occ, IsSingleField, Selected>
154+
>,
155+
true
156+
>
157+
| ConditionallyWithODataAnnotations<
158+
ConditionallyWithSpecialColumns<
159+
RecordReturnType<
160+
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
161+
IsSingleField,
162+
FieldColumn,
163+
Selected,
164+
Expands,
165+
SystemCols
166+
>,
167+
false,
168+
RecordBuilderHasSelect<Occ, IsSingleField, Selected>
169+
>,
170+
false
171+
>;
172+
85173
export class RecordBuilder<
86174
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration, default allows untyped tables
87175
Occ extends FMTable<any, any> = FMTable<any, any>,
@@ -98,16 +186,7 @@ export class RecordBuilder<
98186
DatabaseIncludeSpecialColumns extends boolean = false,
99187
SystemCols extends SystemColumnsOption | undefined = undefined,
100188
> implements
101-
ExecutableBuilder<
102-
RecordReturnType<
103-
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
104-
IsSingleField,
105-
FieldColumn,
106-
Selected,
107-
Expands,
108-
SystemCols
109-
>
110-
>
189+
ExecutableBuilder<ExecutableRecordBuilderReturn<Occ, IsSingleField, FieldColumn, Selected, Expands, SystemCols>>
111190
{
112191
private readonly table: Occ;
113192
private readonly recordId: string | number;

packages/fmodata/src/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ export interface CountedListResult<T> {
88
count: number;
99
}
1010

11-
export interface ExecutableBuilder<_T> {
12-
execute(options?: ExecuteOptions): Promise<Result<unknown>>;
11+
export interface ExecutableBuilder<T> {
12+
execute(options?: ExecuteOptions): Promise<Result<T>>;
1313
// biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value
1414
getRequestConfig(): { method: string; url: string; body?: any };
1515

@@ -28,7 +28,7 @@ export interface ExecutableBuilder<_T> {
2828
* @param options - Optional execution options (e.g., skipValidation, includeODataAnnotations)
2929
* @returns A typed Result with the builder's expected return type
3030
*/
31-
processResponse(response: Response, options?: ExecuteOptions): Promise<Result<unknown>>;
31+
processResponse(response: Response, options?: ExecuteOptions): Promise<Result<T>>;
3232
}
3333

3434
export interface ExecutionContext {

packages/fmodata/tests/mock.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,24 @@ describe("Mock Fetch Tests", () => {
140140
expect(isResponseStructureError(result.error)).toBe(true);
141141
});
142142

143+
it("should preserve list validation errors before building counted results", async () => {
144+
const mock = new MockFMServerConnection();
145+
mock.addRoute({
146+
urlPattern: "/fmdapi_test.fmp12/contacts",
147+
response: {
148+
"@context": "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/$metadata#contacts",
149+
"@odata.count": "1",
150+
},
151+
status: 200,
152+
});
153+
const db = mock.database("fmdapi_test.fmp12");
154+
155+
const result = await db.from(contacts).list().count().execute();
156+
157+
expect(result.data).toBeUndefined();
158+
expect(isResponseStructureError(result.error)).toBe(true);
159+
});
160+
143161
it("should reject single() after list().count()", () => {
144162
const mock = new MockFMServerConnection();
145163
const db = mock.database("fmdapi_test.fmp12");

packages/fmodata/tests/query-strings.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,14 @@ describe("OData Query String Generation", () => {
208208
});
209209
});
210210

211+
describe("$count", () => {
212+
it("should include /$count for navigated top-level count queries", () => {
213+
const queryString = db.from(users).navigate(contacts).count().where(eq(contacts.name, "Alice")).getQueryString();
214+
215+
expect(queryString).toBe(`/users/contacts/$count?$filter=name eq 'Alice'`);
216+
});
217+
});
218+
211219
describe("$orderby", () => {
212220
it("should generate $orderby for ascending order", () => {
213221
const queryString = db.from(users).list().orderBy(asc(users.name)).getQueryString();

0 commit comments

Comments
 (0)