Skip to content

Commit f3941a0

Browse files
fix(firestore): add explicit _apply to QueryConstraint subclasses (#8928)
* fix(firestore): add QueryConstraint interface so constraint arrays type-check * fix(firestore): add explicit _apply declarations to QueryConstraint subclasses * fix: match js-sdk * fix: match 'and' and 'or' to match js-sdk implementation * fix: remove unused * fix: remove extra references * format: run js format * fix: throw on nexted OR filters * fix: adhere to try and make abstract class non abstract * fix: fix QueryConstraint._apply inheritance in type definitions
1 parent 895279f commit f3941a0

File tree

2 files changed

+130
-34
lines changed

2 files changed

+130
-34
lines changed

packages/firestore/lib/modular/query.ts

Lines changed: 116 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import type {
3030
import type {
3131
DocumentReferenceDeleteInternal,
3232
DocumentReferenceGetInternal,
33-
QueryConstraintWithApplyInternal,
3433
QueryFilterConstraintWithFilterInternal,
3534
QueryInternal,
3635
QueryWithMethodInternal,
@@ -39,11 +38,63 @@ import type {
3938
} from '../types/internal';
4039
import type { FieldPath } from './FieldPath';
4140

42-
export abstract class QueryConstraint {
41+
/**
42+
* An `AppliableConstraint` is an abstraction of a constraint that can be applied
43+
* to a Firestore query.
44+
*/
45+
export abstract class AppliableConstraint {
46+
/**
47+
* Takes the provided {@link Query} and returns a copy of the {@link Query} with this
48+
* {@link AppliableConstraint} applied.
49+
*/
50+
abstract _apply<AppModelType = DocumentData, DbModelType extends DocumentData = DocumentData>(
51+
query: Query<AppModelType, DbModelType>,
52+
): Query<AppModelType, DbModelType>;
53+
}
54+
55+
/**
56+
* A `QueryConstraint` is used to narrow the set of documents returned by a
57+
* Firestore query. `QueryConstraint`s are created by invoking {@link where},
58+
* {@link orderBy}, {@link startAt}, {@link startAfter}, {@link endBefore},
59+
* {@link endAt}, {@link limit}, {@link limitToLast} and can then be passed to
60+
* {@link query} to create a new query instance that also contains this `QueryConstraint`.
61+
*
62+
* @remarks
63+
* Matches Firebase JS SDK API. We use QueryConstraintBase for shared implementation
64+
* because Query objects are native module wrappers that expose methods matching
65+
* constraint types (orderBy, limit, etc.), allowing dynamic dispatch via `query[type]()`.
66+
*/
67+
export abstract class QueryConstraint extends AppliableConstraint {
68+
/** The type of this query constraint */
69+
abstract readonly type: QueryConstraintType;
70+
71+
/**
72+
* Takes the provided {@link Query} and returns a copy of the {@link Query} with this
73+
* {@link QueryConstraint} applied.
74+
*/
75+
_apply<AppModelType = DocumentData, DbModelType extends DocumentData = DocumentData>(
76+
_queryRef: Query<AppModelType, DbModelType>,
77+
): Query<AppModelType, DbModelType> {
78+
// This method is implemented in subclasses (QueryConstraintBase provides the default implementation)
79+
// Making it non-abstract ensures it appears in .d.ts so subclasses inherit it
80+
throw new Error('_apply must be implemented by subclass');
81+
}
82+
}
83+
84+
/**
85+
* Base implementation for orderBy/limit/startAt/endAt/where constraints.
86+
*
87+
* @remarks
88+
* Differs from JS SDK (where each constraint implements its own _apply) because
89+
* our Query objects are native wrappers. All constraints use the same pattern:
90+
* `query[this.type](...args)`, so we share implementation via this base class.
91+
*/
92+
export abstract class QueryConstraintBase extends QueryConstraint {
4393
abstract readonly type: QueryConstraintType;
4494
private readonly _args: unknown[];
4595

4696
protected constructor(...args: unknown[]) {
97+
super();
4798
this._args = args;
4899
}
49100

@@ -60,32 +111,70 @@ export abstract class QueryConstraint {
60111
}
61112
}
62113

63-
export class QueryCompositeFilterConstraint {
64-
readonly type: 'or' | 'and';
65-
readonly _filter: _Filter;
114+
export class QueryCompositeFilterConstraint extends AppliableConstraint {
115+
/**
116+
* @internal
117+
*/
118+
protected constructor(
119+
/** The type of this query constraint */
120+
readonly type: 'or' | 'and',
121+
private readonly _queryConstraints: QueryFilterConstraint[],
122+
) {
123+
super();
124+
}
66125

67-
constructor(type: 'or' | 'and', filters: _Filter[]) {
68-
this.type = type;
69-
this._filter = type === 'or' ? Filter.or(...filters) : Filter.and(...filters);
126+
static _create(
127+
type: 'or' | 'and',
128+
_queryConstraints: QueryFilterConstraint[],
129+
): QueryCompositeFilterConstraint {
130+
// Validate nested OR filters when creating the constraint
131+
if (type === 'or') {
132+
const filters = _queryConstraints.map(constraint => {
133+
if (constraint instanceof QueryCompositeFilterConstraint) {
134+
return constraint._filter;
135+
}
136+
return (constraint as unknown as QueryFilterConstraintWithFilterInternal)._filter;
137+
});
138+
// This will throw if nested OR filters are detected
139+
Filter.or(...filters);
140+
}
141+
return new QueryCompositeFilterConstraint(type, _queryConstraints);
70142
}
71143

72144
_apply<AppModelType = DocumentData, DbModelType extends DocumentData = DocumentData>(
73145
query: Query<AppModelType, DbModelType>,
74146
): Query<AppModelType, DbModelType> {
147+
const filters = this._queryConstraints.map(constraint => {
148+
if (constraint instanceof QueryCompositeFilterConstraint) {
149+
return constraint._filter;
150+
}
151+
return (constraint as unknown as QueryFilterConstraintWithFilterInternal)._filter;
152+
});
153+
const _filter = this.type === 'or' ? Filter.or(...filters) : Filter.and(...filters);
75154
const where = (query as unknown as QueryWithWhereInternal<AppModelType, DbModelType>).where;
76-
return where.call(query, this._filter, MODULAR_DEPRECATION_ARG);
155+
return where.call(query, _filter, MODULAR_DEPRECATION_ARG);
156+
}
157+
158+
get _filter(): _Filter {
159+
const filters = this._queryConstraints.map(constraint => {
160+
if (constraint instanceof QueryCompositeFilterConstraint) {
161+
return constraint._filter;
162+
}
163+
return (constraint as unknown as QueryFilterConstraintWithFilterInternal)._filter;
164+
});
165+
return this.type === 'or' ? Filter.or(...filters) : Filter.and(...filters);
77166
}
78167
}
79168

80-
export class QueryOrderByConstraint extends QueryConstraint {
169+
export class QueryOrderByConstraint extends QueryConstraintBase {
81170
readonly type = 'orderBy';
82171

83172
constructor(fieldPath: string | FieldPath, directionStr?: OrderByDirection) {
84173
super(fieldPath, directionStr);
85174
}
86175
}
87176

88-
export class QueryLimitConstraint extends QueryConstraint {
177+
export class QueryLimitConstraint extends QueryConstraintBase {
89178
readonly type: 'limit' | 'limitToLast';
90179

91180
constructor(type: 'limit' | 'limitToLast', limitValue: number) {
@@ -94,7 +183,7 @@ export class QueryLimitConstraint extends QueryConstraint {
94183
}
95184
}
96185

97-
export class QueryStartAtConstraint extends QueryConstraint {
186+
export class QueryStartAtConstraint extends QueryConstraintBase {
98187
readonly type: 'startAt' | 'startAfter';
99188

100189
constructor(type: 'startAt' | 'startAfter', ...docOrFields: Array<unknown | DocumentSnapshot>) {
@@ -103,7 +192,7 @@ export class QueryStartAtConstraint extends QueryConstraint {
103192
}
104193
}
105194

106-
export class QueryEndAtConstraint extends QueryConstraint {
195+
export class QueryEndAtConstraint extends QueryConstraintBase {
107196
readonly type: 'endAt' | 'endBefore';
108197

109198
constructor(type: 'endAt' | 'endBefore', ...fieldValues: unknown[]) {
@@ -112,7 +201,7 @@ export class QueryEndAtConstraint extends QueryConstraint {
112201
}
113202
}
114203

115-
export class QueryFieldFilterConstraint extends QueryConstraint {
204+
export class QueryFieldFilterConstraint extends QueryConstraintBase {
116205
readonly type = 'where';
117206
readonly _filter: _Filter;
118207

@@ -140,20 +229,23 @@ export function query<AppModelType = DocumentData, DbModelType extends DocumentD
140229
): Query<AppModelType, DbModelType>;
141230
export function query<AppModelType = DocumentData, DbModelType extends DocumentData = DocumentData>(
142231
queryRef: Query<AppModelType, DbModelType>,
143-
...queryConstraints: Array<QueryCompositeFilterConstraint | QueryConstraint>
232+
queryConstraint: QueryCompositeFilterConstraint | QueryConstraint | undefined,
233+
...additionalQueryConstraints: Array<QueryConstraint | QueryNonFilterConstraint>
144234
): Query<AppModelType, DbModelType> {
235+
let queryConstraints: AppliableConstraint[] = [];
236+
237+
if (queryConstraint instanceof AppliableConstraint) {
238+
queryConstraints.push(queryConstraint);
239+
}
240+
241+
queryConstraints = queryConstraints.concat(additionalQueryConstraints);
242+
145243
let constrainedQuery = queryRef;
146244
for (const constraint of queryConstraints) {
147245
if (!constraint) {
148246
continue;
149247
}
150-
const apply = (
151-
constraint as unknown as QueryConstraintWithApplyInternal<AppModelType, DbModelType>
152-
)._apply;
153-
if (!apply) {
154-
continue;
155-
}
156-
constrainedQuery = apply.call(constraint, constrainedQuery);
248+
constrainedQuery = constraint._apply(constrainedQuery);
157249
}
158250
return constrainedQuery;
159251
}
@@ -166,22 +258,12 @@ export function where(
166258
return new QueryFieldFilterConstraint(fieldPath, opStr, value);
167259
}
168260

169-
function toFilter(queryConstraint: QueryFilterConstraint): _Filter {
170-
if (queryConstraint instanceof QueryCompositeFilterConstraint) {
171-
return (queryConstraint as unknown as QueryFilterConstraintWithFilterInternal)._filter;
172-
}
173-
if (queryConstraint instanceof QueryFieldFilterConstraint) {
174-
return (queryConstraint as unknown as QueryFilterConstraintWithFilterInternal)._filter;
175-
}
176-
throw new Error('Invalid query constraint: expected filter constraint');
177-
}
178-
179261
export function or(...queryConstraints: QueryFilterConstraint[]): QueryCompositeFilterConstraint {
180-
return new QueryCompositeFilterConstraint('or', queryConstraints.map(toFilter));
262+
return QueryCompositeFilterConstraint._create('or', queryConstraints);
181263
}
182264

183265
export function and(...queryConstraints: QueryFilterConstraint[]): QueryCompositeFilterConstraint {
184-
return new QueryCompositeFilterConstraint('and', queryConstraints.map(toFilter));
266+
return QueryCompositeFilterConstraint._create('and', queryConstraints);
185267
}
186268

187269
export function orderBy(

packages/firestore/type-test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ import {
271271
endBefore,
272272
limit,
273273
limitToLast,
274+
type QueryConstraint,
274275
getDoc,
275276
getDocFromCache,
276277
getDocFromServer,
@@ -396,6 +397,19 @@ const modQuery4 = query(
396397
);
397398
void modQuery4;
398399

400+
// ----- QueryConstraint[] array test (reproducer for #8923) -----
401+
// This test verifies that QueryConstraint subclasses can be assigned to QueryConstraint[]
402+
// without TypeScript errors about missing _apply property
403+
const constraints: QueryConstraint[] = [];
404+
405+
constraints.push(where('status', '==', 'active'));
406+
constraints.push(orderBy('createdAt', 'desc'));
407+
constraints.push(limit(10));
408+
409+
// Verify we can use the constraints array
410+
const testQuery = query(modColl, ...constraints);
411+
void testQuery;
412+
399413
// ----- getCountFromServer -----
400414
getCountFromServer(modQuery1).then(
401415
(snap: AggregateQuerySnapshot<{ count: AggregateField<number> }>) => {

0 commit comments

Comments
 (0)