forked from grahamearley/FirestoreGoogleAppsScript
-
Notifications
You must be signed in to change notification settings - Fork 1
/
Query.ts
269 lines (250 loc) · 8.73 KB
/
Query.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
interface QueryCallback {
(query: Query): Document[];
}
/**
* @see {@link https://firebase.google.com/docs/firestore/reference/rest/v1/StructuredQuery#Operator_1 FieldFilter Operator}
*/
enum FieldFilterOps_ {
'==' = 'EQUAL',
'===' = 'EQUAL',
'<' = 'LESS_THAN',
'<=' = 'LESS_THAN_OR_EQUAL',
'>' = 'GREATER_THAN',
'>=' = 'GREATER_THAN_OR_EQUAL',
'!=' = 'NOT_EQUAL',
'contains' = 'ARRAY_CONTAINS',
'array-contains' = 'ARRAY_CONTAINS',
'containsany' = 'ARRAY_CONTAINS_ANY',
'array-contains-any' = 'ARRAY_CONTAINS_ANY',
'in' = 'IN',
'not-in' = 'NOT_IN',
}
/**
* @see {@link https://firebase.google.com/docs/firestore/reference/rest/v1/StructuredQuery#Operator_2 UnaryFilter Operator}
*/
enum UnaryFilterOps_ {
'nan' = 'IS_NAN',
'is-nan' = 'IS_NAN',
'is-not-nan' = 'IS_NOT_NAN',
'null' = 'IS_NULL',
'is-null' = 'IS_NULL',
'is-not-null' = 'IS_NOT_NULL',
}
/**
* An internal object that acts as a Structured Query to be prepared before execution.
* Chain methods to update query. Must call .execute to send request.
*
* @see {@link https://firebase.google.com/docs/firestore/reference/rest/v1/StructuredQuery Firestore Structured Query}
*/
class Query implements FirestoreAPI.StructuredQuery {
select?: FirestoreAPI.Projection;
from?: FirestoreAPI.CollectionSelector[];
where?: FirestoreAPI.Filter;
orderBy?: FirestoreAPI.Order[];
startAt?: FirestoreAPI.Cursor;
endAt?: FirestoreAPI.Cursor;
offset?: number;
limit?: number;
callback: QueryCallback;
/**
* @param {string} from the base collection to query
* @param {QueryCallback} callback the function that is executed with the internally compiled query
*/
constructor(from: string, callback: QueryCallback) {
this.callback = callback;
if (from) {
this.from = [{ collectionId: from }];
}
}
// @see {@link https://firebase.google.com/docs/firestore/reference/rest/v1/StructuredQuery#FieldReference Field Reference}
fieldRef_(field: string): FirestoreAPI.FieldReference {
const escapedField = field
.split('.')
.map((f) => '`' + f.replace('`', '\\`') + '`')
.join('.');
return { fieldPath: escapedField };
}
// @see {@link https://firebase.google.com/docs/firestore/reference/rest/v1/StructuredQuery#FieldFilter Field Filter}
fieldFilter_(field: string, operator: string, value: any): FirestoreAPI.FieldFilter {
this.validateFieldFilter_(operator);
return {
field: this.fieldRef_(field),
op: (FieldFilterOps_ as any)[operator],
value: Document.wrapValue(value),
};
}
// @see {@link https://firebase.google.com/docs/firestore/reference/rest/v1/StructuredQuery#UnaryFilter Unary Filter}
unaryFilter_(operator: string, field: string): FirestoreAPI.UnaryFilter {
this.validateUnaryFilter_(operator);
return {
field: this.fieldRef_(field),
op: (UnaryFilterOps_ as any)[operator],
};
}
// @see {@link https://firebase.google.com/docs/firestore/reference/rest/v1/StructuredQuery#order Order}
order_(field: string, dir?: string): FirestoreAPI.Order {
const isDesc = !(dir && (dir.substr(0, 3).toUpperCase() === 'DEC' || dir.substr(0, 4).toUpperCase() === 'DESC'));
const direction: string = isDesc ? 'ASCENDING' : 'DESCENDING';
return {
direction: direction,
field: this.fieldRef_(field),
};
}
validateFieldFilter_(val: string): val is FieldFilterOps_ {
if (!(val in FieldFilterOps_)) {
throw new Error(`Operator '${val}' not within ${Object.keys(FieldFilterOps_)}`);
}
return true;
}
validateUnaryFilter_(val: string): val is UnaryFilterOps_ {
if (!(val in UnaryFilterOps_)) {
throw new Error(`Operator '${val}' not within ${Object.keys(UnaryFilterOps_)}`);
}
return true;
}
filter_(field: string, operator: string | number | null, value: any): FirestoreAPI.Filter {
if (typeof operator === 'string') {
operator = operator.toLowerCase().replace('_', '') as FilterOp;
} else if (value == null) {
// Covers null and undefined values
operator = 'null';
} else if (Util_.isNumberNaN(value)) {
// Covers NaN
operator = 'nan';
}
if ((operator as string) in FieldFilterOps_) {
return {
fieldFilter: this.fieldFilter_(field, operator as string, value),
};
}
if ((operator as string) in UnaryFilterOps_) {
return {
unaryFilter: this.unaryFilter_(operator as string, field),
};
}
throw new Error('Invalid Operator given: ' + operator);
}
/**
* Select Query which can narrow which fields to return.
* Can be repeated if multiple fields are needed in the response.
*
* @see {@link https://firebase.google.com/docs/firestore/reference/rest/v1/StructuredQuery#Projection Select}
* @param {string} field The field to narrow down (if empty, returns name of document)
* @return {this} this query object for chaining
*/
Select(field?: string): this {
if (!field || !field.trim()) {
// Catch undefined or blank strings and return document name
field = '__name__';
}
if (!this.select) {
this.select = { fields: [] };
}
this.select['fields']!.push(this.fieldRef_(field));
return this;
}
/**
* Filter Query by a given field and operator (or additionally a value).
* Can be repeated if multiple filters required.
* Results must satisfy all filters.
*
* @param {string} field The field to reference for filtering
* @param {string} operator The operator to filter by. {@link fieldOps} {@link unaryOps}
* @param {any} [value] Object to set the field value to. Null if using a unary operator.
* @return {this} this query object for chaining
*/
Where(field: string, operator: string | number | null, value?: any): this {
if (this.where) {
if (!this.where.compositeFilter) {
this.where = {
compositeFilter: {
op: 'AND', // Currently "OR" is unsupported
filters: [this.where],
},
};
}
this.where.compositeFilter!.filters!.push(this.filter_(field, operator, value));
} else {
this.where = this.filter_(field, operator, value);
}
return this;
}
/**
* Orders the Query results based on a field and specific direction.
* Can be repeated if additional ordering is needed.
*
* @see {@link https://firebase.google.com/docs/firestore/reference/rest/v1/StructuredQuery#Projection Select}
* @param {string} field The field to order by.
* @param {string} dir The direction to order the field by. Should be one of "asc" or "desc". Defaults to Ascending.
* @return {this} this query object for chaining
*/
OrderBy(field: string, dir?: string): this {
if (!this.orderBy) {
this.orderBy = [];
}
this.orderBy!.push(this.order_(field, dir));
return this;
}
/**
* Offsets the Query results by a given number of documents.
*
* @param {number} offset Number of results to skip
* @return {this} this query object for chaining
*/
Offset(offset: number): this {
if (!Util_.isNumeric(offset)) {
throw new TypeError('Offset is not a valid number!');
} else if (offset < 0) {
throw new RangeError('Offset must be >= 0!');
}
this.offset = offset;
return this;
}
/**
* Limits the amount Query results returned.
*
* @param {number} limit Number of results limit
* @return {this} this query object for chaining
*/
Limit(limit: number): this {
if (!Util_.isNumeric(limit)) {
throw new TypeError('Limit is not a valid number!');
} else if (limit < 0) {
throw new RangeError('Limit must be >= 0!');
}
this.limit = limit;
return this;
}
/**
* Sets the range of Query results returned.
*
* @param {number} start Start result number (inclusive)
* @param {number} end End result number (inclusive)
* @return {this} this query object for chaining
*/
Range(start: number, end: number): this {
if (!Util_.isNumeric(start)) {
throw new TypeError('Range start is not a valid number!');
} else if (!Util_.isNumeric(end)) {
throw new TypeError('Range end is not a valid number!');
} else if (start < 0) {
throw new RangeError('Range start must be >= 0!');
} else if (end < 0) {
throw new RangeError('Range end must be >= 0!');
} else if (start >= end) {
throw new RangeError('Range start must be less than range end!');
}
this.offset = start;
this.limit = end - start;
return this;
}
/**
* Executes the query with the given callback method and the generated query.
* Must be used at the end of any query for execution.
*
* @return {Document[]} The query results from the execution
*/
Execute(): Document[] {
return this.callback(this); // Not using callback.bind due to debugging limitations of GAS
}
}