@flarequery/firebase 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -0
- package/dist/index.cjs +478 -236
- package/dist/index.d.cts +65 -31
- package/dist/index.d.ts +65 -31
- package/dist/index.js +476 -235
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -1,153 +1,72 @@
|
|
|
1
|
-
//
|
|
2
|
-
var
|
|
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
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
if (input[i] === "(") {
|
|
32
|
-
tokens.push({ kind: "lparen", value: "(", position: i });
|
|
33
|
-
i++;
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
if (input[i] === ")") {
|
|
37
|
-
tokens.push({ kind: "rparen", value: ")", position: i });
|
|
38
|
-
i++;
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
if (input[i] === ":") {
|
|
42
|
-
tokens.push({ kind: "colon", value: ":", position: i });
|
|
43
|
-
i++;
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
if (input[i] === '"') {
|
|
47
|
-
const start = i;
|
|
48
|
-
i++;
|
|
49
|
-
let str = "";
|
|
50
|
-
while (i < input.length && input[i] !== '"') {
|
|
51
|
-
str += input[i];
|
|
52
|
-
i++;
|
|
1
|
+
// src/adapter.ts
|
|
2
|
+
var FIRESTORE_GETALL_LIMIT = 300;
|
|
3
|
+
function createFirestoreAdapter(db) {
|
|
4
|
+
return {
|
|
5
|
+
async getOne(collection, id, fieldMask) {
|
|
6
|
+
const ref = db.collection(collection).doc(id);
|
|
7
|
+
const snapshot = await ref.get();
|
|
8
|
+
return adaptSnapshot(snapshot, fieldMask);
|
|
9
|
+
},
|
|
10
|
+
async getMany(collection, ids, fieldMask) {
|
|
11
|
+
if (ids.length === 0) return [];
|
|
12
|
+
const chunks = chunkArray(ids, FIRESTORE_GETALL_LIMIT);
|
|
13
|
+
const chunkResults = await Promise.all(
|
|
14
|
+
chunks.map((chunk) => {
|
|
15
|
+
const refs = chunk.map((id) => db.collection(collection).doc(id));
|
|
16
|
+
return db.getAll(...refs, { fieldMask });
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
return chunkResults.flat().map((snap) => adaptSnapshot(snap, fieldMask));
|
|
20
|
+
},
|
|
21
|
+
async getCollection(collection, filters, fieldMask, orderBy, limit) {
|
|
22
|
+
let query = db.collection(collection);
|
|
23
|
+
for (const filter of filters) {
|
|
24
|
+
query = query.where(
|
|
25
|
+
filter.field,
|
|
26
|
+
filter.operator,
|
|
27
|
+
filter.value
|
|
28
|
+
);
|
|
53
29
|
}
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
|
63
|
-
word += input[i];
|
|
64
|
-
i++;
|
|
30
|
+
if (orderBy !== void 0) {
|
|
31
|
+
query = query.orderBy(orderBy.field, orderBy.direction);
|
|
32
|
+
}
|
|
33
|
+
if (limit !== void 0) {
|
|
34
|
+
query = query.limit(limit);
|
|
35
|
+
}
|
|
36
|
+
if (fieldMask.length > 0) {
|
|
37
|
+
query = query.select(...fieldMask);
|
|
65
38
|
}
|
|
66
|
-
|
|
67
|
-
|
|
39
|
+
const snapshot = await query.get();
|
|
40
|
+
return snapshot.docs.map((snap) => adaptSnapshot(snap, fieldMask));
|
|
68
41
|
}
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
tokens.push({ kind: "eof", value: "", position: i });
|
|
72
|
-
return tokens;
|
|
42
|
+
};
|
|
73
43
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return token;
|
|
89
|
-
}
|
|
90
|
-
consumeWord(expectedVal) {
|
|
91
|
-
const token = this.consume("word");
|
|
92
|
-
if (expectedVal !== void 0 && token.value !== expectedVal) {
|
|
93
|
-
throw new ParseError(`expected ${expectedVal}, got ${token.value}`, token.position);
|
|
94
|
-
}
|
|
95
|
-
return token;
|
|
96
|
-
}
|
|
97
|
-
parseQuery() {
|
|
98
|
-
if (this.peek().kind === "word" && this.peek().value === "query") {
|
|
99
|
-
this.consume("word");
|
|
100
|
-
}
|
|
101
|
-
this.consume("lbrace");
|
|
102
|
-
const node = this.parseModelSelection();
|
|
103
|
-
this.consume("rbrace");
|
|
104
|
-
if (this.peek().kind !== "eof") {
|
|
105
|
-
throw new ParseError("expected end of query", this.peek().position);
|
|
106
|
-
}
|
|
107
|
-
return node;
|
|
108
|
-
}
|
|
109
|
-
parseModelSelection() {
|
|
110
|
-
const modelToken = this.consumeWord();
|
|
111
|
-
const model = modelToken.value;
|
|
112
|
-
this.consume("lparen");
|
|
113
|
-
this.consumeWord("id");
|
|
114
|
-
this.consume("colon");
|
|
115
|
-
const idToken = this.consume("string");
|
|
116
|
-
this.consume("rparen");
|
|
117
|
-
this.consume("lbrace");
|
|
118
|
-
const selections = this.parseFieldList();
|
|
119
|
-
this.consume("rbrace");
|
|
120
|
-
return { model, id: idToken.value, selections };
|
|
121
|
-
}
|
|
122
|
-
parseFieldList() {
|
|
123
|
-
const fields = [];
|
|
124
|
-
while (this.peek().kind !== "rbrace" && this.peek().kind !== "eof") {
|
|
125
|
-
fields.push(this.parseField());
|
|
126
|
-
}
|
|
127
|
-
if (fields.length === 0) {
|
|
128
|
-
throw new ParseError("selection set cannot be empty", this.peek().position);
|
|
129
|
-
}
|
|
130
|
-
return fields;
|
|
131
|
-
}
|
|
132
|
-
parseField() {
|
|
133
|
-
const nameToken = this.consumeWord();
|
|
134
|
-
const name = nameToken.value;
|
|
135
|
-
if (this.peek().kind === "lbrace") {
|
|
136
|
-
this.consume("lbrace");
|
|
137
|
-
const children = this.parseFieldList();
|
|
138
|
-
this.consume("rbrace");
|
|
139
|
-
return { name, children };
|
|
44
|
+
function adaptSnapshot(snap, fieldMask) {
|
|
45
|
+
return {
|
|
46
|
+
id: snap.id,
|
|
47
|
+
exists: snap.exists,
|
|
48
|
+
data() {
|
|
49
|
+
if (!snap.exists) return void 0;
|
|
50
|
+
const raw = snap.data() ?? {};
|
|
51
|
+
const masked = {};
|
|
52
|
+
for (const field of fieldMask) {
|
|
53
|
+
if (field in raw) {
|
|
54
|
+
masked[field] = raw[field];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return masked;
|
|
140
58
|
}
|
|
141
|
-
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function chunkArray(arr, size) {
|
|
62
|
+
const chunks = [];
|
|
63
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
64
|
+
chunks.push(arr.slice(i, i + size));
|
|
142
65
|
}
|
|
143
|
-
|
|
144
|
-
function parseQuery(input) {
|
|
145
|
-
const token = tokenize(input);
|
|
146
|
-
const parse = new Parser(token);
|
|
147
|
-
return parse.parseQuery();
|
|
66
|
+
return chunks;
|
|
148
67
|
}
|
|
149
68
|
|
|
150
|
-
// ../core/
|
|
69
|
+
// ../core/dist/index.js
|
|
151
70
|
var PlanError = class extends Error {
|
|
152
71
|
constructor(message) {
|
|
153
72
|
super(`PlanError: ${message}`);
|
|
@@ -173,13 +92,7 @@ function resolveSelections(selections, model, models, ctx, allOps) {
|
|
|
173
92
|
`relation field '${selection.name}' must have a selection set`
|
|
174
93
|
);
|
|
175
94
|
}
|
|
176
|
-
const dependent = planRelationField(
|
|
177
|
-
selection,
|
|
178
|
-
fieldDef,
|
|
179
|
-
models,
|
|
180
|
-
ctx,
|
|
181
|
-
allOps
|
|
182
|
-
);
|
|
95
|
+
const dependent = planRelationField(selection, fieldDef, models, ctx, allOps);
|
|
183
96
|
dependents.push(dependent);
|
|
184
97
|
} else {
|
|
185
98
|
scalarMask.push(selection.name);
|
|
@@ -281,8 +194,86 @@ function buildExecutionPlan(queryNode, models, ctx) {
|
|
|
281
194
|
);
|
|
282
195
|
return { root: rootOp, ops: allOps };
|
|
283
196
|
}
|
|
284
|
-
|
|
285
|
-
|
|
197
|
+
var INEQUALITY_OPS = /* @__PURE__ */ new Set(["!=", "<", "<=", ">", ">="]);
|
|
198
|
+
function validateFirestoreConstraints(filters, orderBy) {
|
|
199
|
+
const inequalityFields = [
|
|
200
|
+
...new Set(
|
|
201
|
+
filters.filter((f) => INEQUALITY_OPS.has(f.operator)).map((f) => f.field)
|
|
202
|
+
)
|
|
203
|
+
];
|
|
204
|
+
if (inequalityFields.length > 1) {
|
|
205
|
+
throw new PlanError(
|
|
206
|
+
`Firestore only allows inequality filters (!=, <, <=, >, >=) on ONE field per query.
|
|
207
|
+
You have inequality filters on: [${inequalityFields.join(", ")}].
|
|
208
|
+
Fix: keep one inequality field in .filter() and handle the rest in memory after .get().`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (inequalityFields.length === 1 && orderBy !== void 0 && orderBy.field !== inequalityFields[0]) {
|
|
212
|
+
throw new PlanError(
|
|
213
|
+
`Firestore requires orderBy to be on the same field as the inequality filter.`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function validateModelFields(filters, selections, model) {
|
|
218
|
+
const availableFields = Object.keys(model.fields).join(", ");
|
|
219
|
+
for (const filter of filters) {
|
|
220
|
+
const fieldDef = model.fields[filter.field];
|
|
221
|
+
if (fieldDef === void 0) {
|
|
222
|
+
throw new PlanError(
|
|
223
|
+
`filter field '${filter.field}' does not exist on model '${model.source.path}'.
|
|
224
|
+
Available fields: [${availableFields}]`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (typeof fieldDef === "object" && "relation" in fieldDef) {
|
|
228
|
+
throw new PlanError(
|
|
229
|
+
`Use the scalar foreign key field instead (e.g. '${fieldDef.relation.from}').`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const selection of selections) {
|
|
234
|
+
if (model.fields[selection.name] === void 0) {
|
|
235
|
+
throw new PlanError(
|
|
236
|
+
`selected field '${selection.name}' does not exist on model '${model.source.path}'.
|
|
237
|
+
`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function buildCollectionPlan(queryNode, models, ctx) {
|
|
243
|
+
const model = models.get(queryNode.model);
|
|
244
|
+
if (model === void 0) {
|
|
245
|
+
throw new PlanError(`model '${queryNode.model}' is not registered`);
|
|
246
|
+
}
|
|
247
|
+
if (model.auth !== void 0 && !model.auth(ctx)) {
|
|
248
|
+
throw new PlanError(`unauthorized access to model '${queryNode.model}'`);
|
|
249
|
+
}
|
|
250
|
+
validateModelFields(queryNode.filters, queryNode.selections, model);
|
|
251
|
+
validateFirestoreConstraints(queryNode.filters, queryNode.orderBy);
|
|
252
|
+
const allOps = [];
|
|
253
|
+
const { scalarMask, dependents } = resolveSelections(
|
|
254
|
+
queryNode.selections,
|
|
255
|
+
model,
|
|
256
|
+
models,
|
|
257
|
+
ctx,
|
|
258
|
+
allOps
|
|
259
|
+
);
|
|
260
|
+
const relationForeignKeys = dependents.map((d) => d.foreignKey);
|
|
261
|
+
const fieldMask = Array.from(/* @__PURE__ */ new Set([...scalarMask, ...relationForeignKeys]));
|
|
262
|
+
const plan = {
|
|
263
|
+
collection: model.source.path,
|
|
264
|
+
fieldMask,
|
|
265
|
+
filters: queryNode.filters,
|
|
266
|
+
dependents,
|
|
267
|
+
selections: queryNode.selections
|
|
268
|
+
};
|
|
269
|
+
if (queryNode.orderBy !== void 0) {
|
|
270
|
+
plan.orderBy = queryNode.orderBy;
|
|
271
|
+
}
|
|
272
|
+
if (queryNode.limit !== void 0) {
|
|
273
|
+
plan.limit = queryNode.limit;
|
|
274
|
+
}
|
|
275
|
+
return plan;
|
|
276
|
+
}
|
|
286
277
|
var ExecutionError = class extends Error {
|
|
287
278
|
constructor(message) {
|
|
288
279
|
super(`ExecutionError: ${message}`);
|
|
@@ -364,7 +355,7 @@ async function executeOp(op, parentDoc, adapter) {
|
|
|
364
355
|
}
|
|
365
356
|
return executeGetMany(op, parentDoc, adapter);
|
|
366
357
|
}
|
|
367
|
-
async function
|
|
358
|
+
async function executePlan(plan, adapter) {
|
|
368
359
|
try {
|
|
369
360
|
const data = await executeOp(plan.root, null, adapter);
|
|
370
361
|
return { data };
|
|
@@ -375,70 +366,276 @@ async function executeplan(plan, adapter) {
|
|
|
375
366
|
throw err;
|
|
376
367
|
}
|
|
377
368
|
}
|
|
369
|
+
async function resolveCollectionDoc(snap, plan, adapter) {
|
|
370
|
+
if (!snap.exists) return null;
|
|
371
|
+
const docData = snap.data();
|
|
372
|
+
if (docData === void 0) return null;
|
|
373
|
+
const result = await resolveDependents(plan.dependents, docData, adapter);
|
|
374
|
+
const foreignKeys = new Set(plan.dependents.map((d) => d.foreignKey));
|
|
375
|
+
for (const [key, value] of Object.entries(docData)) {
|
|
376
|
+
if (!foreignKeys.has(key) && plan.fieldMask.includes(key)) {
|
|
377
|
+
result[key] = value;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
result.__id = snap.id;
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
async function executeCollectionPlan(plan, adapter) {
|
|
384
|
+
try {
|
|
385
|
+
const snapshots = await adapter.getCollection(
|
|
386
|
+
plan.collection,
|
|
387
|
+
plan.filters,
|
|
388
|
+
plan.fieldMask,
|
|
389
|
+
plan.orderBy,
|
|
390
|
+
plan.limit
|
|
391
|
+
);
|
|
392
|
+
const results = await Promise.all(
|
|
393
|
+
snapshots.map((snap) => resolveCollectionDoc(snap, plan, adapter))
|
|
394
|
+
);
|
|
395
|
+
const data = results.filter((r) => r !== null);
|
|
396
|
+
return { data };
|
|
397
|
+
} catch (err) {
|
|
398
|
+
if (err instanceof ExecutionError) {
|
|
399
|
+
return { data: [], errors: [err.message] };
|
|
400
|
+
}
|
|
401
|
+
throw err;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
378
404
|
|
|
379
|
-
// src/
|
|
380
|
-
var
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
405
|
+
// src/builder.ts
|
|
406
|
+
var CollectionReferenceImpl = class {
|
|
407
|
+
constructor(modelName, models, adapter) {
|
|
408
|
+
this.modelName = modelName;
|
|
409
|
+
this.models = models;
|
|
410
|
+
this.adapter = adapter;
|
|
411
|
+
}
|
|
412
|
+
doc(id) {
|
|
413
|
+
return new DocumentReferenceImpl(
|
|
414
|
+
this.modelName,
|
|
415
|
+
id,
|
|
416
|
+
this.models,
|
|
417
|
+
this.adapter
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
filter(field) {
|
|
421
|
+
return new FilterBuilderImpl(
|
|
422
|
+
field,
|
|
423
|
+
this.modelName,
|
|
424
|
+
[],
|
|
425
|
+
[],
|
|
426
|
+
void 0,
|
|
427
|
+
void 0,
|
|
428
|
+
this.models,
|
|
429
|
+
this.adapter
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
var DocumentReferenceImpl = class {
|
|
434
|
+
constructor(modelName, docId, models, adapter) {
|
|
435
|
+
this.modelName = modelName;
|
|
436
|
+
this.docId = docId;
|
|
437
|
+
this.models = models;
|
|
438
|
+
this.adapter = adapter;
|
|
439
|
+
}
|
|
440
|
+
select(...fields) {
|
|
441
|
+
return new QueryImpl(
|
|
442
|
+
this.modelName,
|
|
443
|
+
this.docId,
|
|
444
|
+
fields,
|
|
445
|
+
this.models,
|
|
446
|
+
this.adapter
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
var QueryImpl = class {
|
|
451
|
+
constructor(modelName, docId, fields, models, adapter) {
|
|
452
|
+
this.modelName = modelName;
|
|
453
|
+
this.docId = docId;
|
|
454
|
+
this.fields = fields;
|
|
455
|
+
this.models = models;
|
|
456
|
+
this.adapter = adapter;
|
|
457
|
+
}
|
|
458
|
+
async get(ctx) {
|
|
459
|
+
const queryNode = buildQueryNode(this.modelName, this.docId, this.fields);
|
|
460
|
+
const plan = buildExecutionPlan(queryNode, this.models, ctx);
|
|
461
|
+
return executePlan(plan, this.adapter);
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
var FilterBuilderImpl = class {
|
|
465
|
+
constructor(field, modelName, existingFilters, selections, orderByClause, limitValue, models, adapter) {
|
|
466
|
+
this.field = field;
|
|
467
|
+
this.modelName = modelName;
|
|
468
|
+
this.existingFilters = existingFilters;
|
|
469
|
+
this.selections = selections;
|
|
470
|
+
this.orderByClause = orderByClause;
|
|
471
|
+
this.limitValue = limitValue;
|
|
472
|
+
this.models = models;
|
|
473
|
+
this.adapter = adapter;
|
|
474
|
+
}
|
|
475
|
+
next(operator, value) {
|
|
476
|
+
const newFilter = { field: this.field, operator, value };
|
|
477
|
+
return new CollectionQueryImpl(
|
|
478
|
+
this.modelName,
|
|
479
|
+
[...this.existingFilters, newFilter],
|
|
480
|
+
this.selections,
|
|
481
|
+
this.orderByClause,
|
|
482
|
+
this.limitValue,
|
|
483
|
+
this.models,
|
|
484
|
+
this.adapter
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
equals(value) {
|
|
488
|
+
return this.next("==", value);
|
|
489
|
+
}
|
|
490
|
+
not(value) {
|
|
491
|
+
return this.next("!=", value);
|
|
492
|
+
}
|
|
493
|
+
gt(value) {
|
|
494
|
+
return this.next(">", value);
|
|
495
|
+
}
|
|
496
|
+
gte(value) {
|
|
497
|
+
return this.next(">=", value);
|
|
498
|
+
}
|
|
499
|
+
lt(value) {
|
|
500
|
+
return this.next("<", value);
|
|
501
|
+
}
|
|
502
|
+
lte(value) {
|
|
503
|
+
return this.next("<=", value);
|
|
504
|
+
}
|
|
505
|
+
in(values) {
|
|
506
|
+
return this.next("in", values);
|
|
507
|
+
}
|
|
508
|
+
contains(value) {
|
|
509
|
+
return this.next("array-contains", value);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
var CollectionQueryImpl = class _CollectionQueryImpl {
|
|
513
|
+
constructor(modelName, filters, selectedFields, orderByClause, limitValue, models, adapter) {
|
|
514
|
+
this.modelName = modelName;
|
|
515
|
+
this.filters = filters;
|
|
516
|
+
this.selectedFields = selectedFields;
|
|
517
|
+
this.orderByClause = orderByClause;
|
|
518
|
+
this.limitValue = limitValue;
|
|
519
|
+
this.models = models;
|
|
520
|
+
this.adapter = adapter;
|
|
521
|
+
}
|
|
522
|
+
filter(field) {
|
|
523
|
+
return new FilterBuilderImpl(
|
|
524
|
+
field,
|
|
525
|
+
this.modelName,
|
|
526
|
+
this.filters,
|
|
527
|
+
this.selectedFields,
|
|
528
|
+
this.orderByClause,
|
|
529
|
+
this.limitValue,
|
|
530
|
+
this.models,
|
|
531
|
+
this.adapter
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
select(...fields) {
|
|
535
|
+
return new _CollectionQueryImpl(
|
|
536
|
+
this.modelName,
|
|
537
|
+
this.filters,
|
|
538
|
+
fields,
|
|
539
|
+
this.orderByClause,
|
|
540
|
+
this.limitValue,
|
|
541
|
+
this.models,
|
|
542
|
+
this.adapter
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
orderBy(field, direction) {
|
|
546
|
+
return new _CollectionQueryImpl(
|
|
547
|
+
this.modelName,
|
|
548
|
+
this.filters,
|
|
549
|
+
this.selectedFields,
|
|
550
|
+
{ field, direction },
|
|
551
|
+
this.limitValue,
|
|
552
|
+
this.models,
|
|
553
|
+
this.adapter
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
limit(n) {
|
|
557
|
+
return new _CollectionQueryImpl(
|
|
558
|
+
this.modelName,
|
|
559
|
+
this.filters,
|
|
560
|
+
this.selectedFields,
|
|
561
|
+
this.orderByClause,
|
|
562
|
+
n,
|
|
563
|
+
this.models,
|
|
564
|
+
this.adapter
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
async get(ctx) {
|
|
568
|
+
const queryNode = {
|
|
569
|
+
model: this.modelName,
|
|
570
|
+
filters: this.filters,
|
|
571
|
+
selections: parseFieldSelections(this.selectedFields)
|
|
572
|
+
};
|
|
573
|
+
if (this.orderByClause !== void 0) {
|
|
574
|
+
queryNode.orderBy = this.orderByClause;
|
|
398
575
|
}
|
|
399
|
-
|
|
576
|
+
if (this.limitValue !== void 0) {
|
|
577
|
+
queryNode.limit = this.limitValue;
|
|
578
|
+
}
|
|
579
|
+
const plan = buildCollectionPlan(queryNode, this.models, ctx);
|
|
580
|
+
return executeCollectionPlan(plan, this.adapter);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
function buildQueryNode(modelName, id, fields) {
|
|
584
|
+
const selections = parseFieldSelections(fields);
|
|
585
|
+
return { model: modelName, id, selections };
|
|
400
586
|
}
|
|
401
|
-
function
|
|
402
|
-
return
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
587
|
+
function addNestedField(parent, parts) {
|
|
588
|
+
if (parts.length === 0) return;
|
|
589
|
+
const [current, ...rest] = parts;
|
|
590
|
+
const name = current;
|
|
591
|
+
let child = parent.children.find((c) => c.name === name);
|
|
592
|
+
if (!child) {
|
|
593
|
+
child = { name, children: [] };
|
|
594
|
+
parent.children.push(child);
|
|
595
|
+
}
|
|
596
|
+
if (rest.length > 0) {
|
|
597
|
+
addNestedField(child, rest);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function parseFieldSelections(fields) {
|
|
601
|
+
const fieldMap = /* @__PURE__ */ new Map();
|
|
602
|
+
for (const field of fields) {
|
|
603
|
+
const parts = field.split(".");
|
|
604
|
+
if (parts.length === 1) {
|
|
605
|
+
const name = parts[0];
|
|
606
|
+
if (!fieldMap.has(name)) {
|
|
607
|
+
fieldMap.set(name, { name, children: [] });
|
|
413
608
|
}
|
|
414
|
-
|
|
609
|
+
} else {
|
|
610
|
+
const [root, ...rest] = parts;
|
|
611
|
+
const rootName = root;
|
|
612
|
+
if (!fieldMap.has(rootName)) {
|
|
613
|
+
fieldMap.set(rootName, { name: rootName, children: [] });
|
|
614
|
+
}
|
|
615
|
+
const rootField = fieldMap.get(rootName);
|
|
616
|
+
addNestedField(rootField, rest);
|
|
415
617
|
}
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
function chunkArray(arr, size) {
|
|
419
|
-
const chunks = [];
|
|
420
|
-
for (let i = 0; i < arr.length; i += size) {
|
|
421
|
-
chunks.push(arr.slice(i, i + size));
|
|
422
618
|
}
|
|
423
|
-
return
|
|
619
|
+
return Array.from(fieldMap.values());
|
|
620
|
+
}
|
|
621
|
+
function createCollectionRef(modelName, models, adapter) {
|
|
622
|
+
return new CollectionReferenceImpl(modelName, models, adapter);
|
|
424
623
|
}
|
|
425
624
|
|
|
426
625
|
// src/app.ts
|
|
427
626
|
function createServerlessApp(options) {
|
|
428
|
-
const { firestore
|
|
627
|
+
const { firestore } = options;
|
|
429
628
|
const models = /* @__PURE__ */ new Map();
|
|
430
629
|
const adapter = createFirestoreAdapter(firestore);
|
|
431
630
|
return {
|
|
432
631
|
model(name, definition) {
|
|
433
632
|
if (models.has(name)) {
|
|
434
|
-
|
|
633
|
+
console.warn(`[FlareQuery] model '${name}' is already registered \u2014 skipping duplicate registration`);
|
|
435
634
|
}
|
|
436
635
|
models.set(name, definition);
|
|
437
636
|
},
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const plan = buildExecutionPlan(queryNode, models, ctx);
|
|
441
|
-
return executeplan(plan, adapter);
|
|
637
|
+
collection(name) {
|
|
638
|
+
return createCollectionRef(name, models, adapter);
|
|
442
639
|
}
|
|
443
640
|
};
|
|
444
641
|
}
|
|
@@ -453,7 +650,11 @@ async function extractContext(authorization, auth) {
|
|
|
453
650
|
userId: decoded.uid,
|
|
454
651
|
token: decoded
|
|
455
652
|
};
|
|
456
|
-
} catch {
|
|
653
|
+
} catch (err) {
|
|
654
|
+
console.warn(
|
|
655
|
+
"[FlareQuery] extractContext: token verification failed \u2014",
|
|
656
|
+
err instanceof Error ? err.message : "unknown error"
|
|
657
|
+
);
|
|
457
658
|
return { userId: null, token: null };
|
|
458
659
|
}
|
|
459
660
|
}
|
|
@@ -461,65 +662,105 @@ async function extractContext(authorization, auth) {
|
|
|
461
662
|
// src/function.ts
|
|
462
663
|
import * as functionsV1 from "firebase-functions/v1";
|
|
463
664
|
import { onRequest } from "firebase-functions/v2/https";
|
|
665
|
+
var VALID_FIELD = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*$/;
|
|
666
|
+
function extractQueryRequest(body) {
|
|
667
|
+
if (typeof body !== "object" || body === null || !("model" in body) || !("id" in body) || !("select" in body)) return null;
|
|
668
|
+
const req = body;
|
|
669
|
+
if (typeof req.model !== "string" || typeof req.id !== "string" || !Array.isArray(req.select) || req.select.length === 0) return null;
|
|
670
|
+
const invalidField = req.select.find(
|
|
671
|
+
(f) => typeof f !== "string" || !VALID_FIELD.test(f)
|
|
672
|
+
);
|
|
673
|
+
if (invalidField !== void 0) {
|
|
674
|
+
return { error: `invalid field: "${String(invalidField)}"` };
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
model: req.model,
|
|
678
|
+
id: req.id,
|
|
679
|
+
select: req.select
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function handleCors(req, res, options) {
|
|
683
|
+
if (!options.cors) return false;
|
|
684
|
+
res.set("Access-Control-Allow-Origin", "*");
|
|
685
|
+
res.set("Access-Control-Allow-Methods", "POST");
|
|
686
|
+
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
687
|
+
if (req.method === "OPTIONS") {
|
|
688
|
+
res.status(204).send("");
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
464
693
|
function createFunction(app, auth, options = {}) {
|
|
465
694
|
return functionsV1.https.onRequest(async (req, res) => {
|
|
466
|
-
if (options
|
|
467
|
-
res.set("Access-Control-Allow-Origin", "*");
|
|
468
|
-
res.set("Access-Control-Allow-Methods", "POST");
|
|
469
|
-
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
470
|
-
if (req.method === "OPTIONS") {
|
|
471
|
-
res.status(204).send("");
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
695
|
+
if (handleCors(req, res, options)) return;
|
|
475
696
|
if (req.method !== "POST") {
|
|
476
697
|
res.status(405).json({ error: "method not allowed \u2014 use POST" });
|
|
477
698
|
return;
|
|
478
699
|
}
|
|
479
|
-
const
|
|
480
|
-
if (
|
|
481
|
-
res.status(400).json({
|
|
700
|
+
const queryReq = extractQueryRequest(req.body);
|
|
701
|
+
if (queryReq === null) {
|
|
702
|
+
res.status(400).json({
|
|
703
|
+
error: "request body must contain: { model, id, select }",
|
|
704
|
+
example: {
|
|
705
|
+
model: "Event",
|
|
706
|
+
id: "event_1",
|
|
707
|
+
select: ["title", "participants.name"]
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if ("error" in queryReq) {
|
|
713
|
+
res.status(400).json({ error: queryReq.error });
|
|
482
714
|
return;
|
|
483
715
|
}
|
|
484
716
|
const ctx = await extractContext(req.headers.authorization, auth);
|
|
485
|
-
|
|
486
|
-
|
|
717
|
+
try {
|
|
718
|
+
const response = await app.collection(queryReq.model).doc(queryReq.id).select(...queryReq.select).get(ctx);
|
|
719
|
+
res.status(200).json(response);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
res.status(500).json({
|
|
722
|
+
error: error instanceof Error ? error.message : "internal server error"
|
|
723
|
+
});
|
|
724
|
+
}
|
|
487
725
|
});
|
|
488
726
|
}
|
|
489
727
|
function createOnRequest(app, auth, options = {}) {
|
|
490
728
|
return onRequest(async (req, res) => {
|
|
491
|
-
if (options
|
|
492
|
-
res.set("Access-Control-Allow-Origin", "*");
|
|
493
|
-
res.set("Access-Control-Allow-Methods", "POST");
|
|
494
|
-
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
495
|
-
if (req.method === "OPTIONS") {
|
|
496
|
-
res.status(204).send("");
|
|
497
|
-
return;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
729
|
+
if (handleCors(req, res, options)) return;
|
|
500
730
|
if (req.method !== "POST") {
|
|
501
731
|
res.status(405).json({ error: "method not allowed \u2014 use POST" });
|
|
502
732
|
return;
|
|
503
733
|
}
|
|
504
|
-
const
|
|
505
|
-
if (
|
|
506
|
-
res.status(400).json({
|
|
734
|
+
const queryReq = extractQueryRequest(req.body);
|
|
735
|
+
if (queryReq === null) {
|
|
736
|
+
res.status(400).json({
|
|
737
|
+
error: "request body must contain: { model, id, select }",
|
|
738
|
+
example: {
|
|
739
|
+
model: "Event",
|
|
740
|
+
id: "event_1",
|
|
741
|
+
select: ["title", "participants.name"]
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if ("error" in queryReq) {
|
|
747
|
+
res.status(400).json({ error: queryReq.error });
|
|
507
748
|
return;
|
|
508
749
|
}
|
|
509
750
|
const ctx = await extractContext(req.headers.authorization, auth);
|
|
510
|
-
|
|
511
|
-
|
|
751
|
+
try {
|
|
752
|
+
const response = await app.collection(queryReq.model).doc(queryReq.id).select(...queryReq.select).get(ctx);
|
|
753
|
+
res.status(200).json(response);
|
|
754
|
+
} catch (error) {
|
|
755
|
+
res.status(500).json({
|
|
756
|
+
error: error instanceof Error ? error.message : "internal server error"
|
|
757
|
+
});
|
|
758
|
+
}
|
|
512
759
|
});
|
|
513
760
|
}
|
|
514
|
-
function extractQuery(body) {
|
|
515
|
-
if (typeof body === "string" && body.trim().length > 0) return body.trim();
|
|
516
|
-
if (typeof body === "object" && body !== null && "query" in body && typeof body["query"] === "string") {
|
|
517
|
-
return body["query"].trim();
|
|
518
|
-
}
|
|
519
|
-
return null;
|
|
520
|
-
}
|
|
521
761
|
export {
|
|
522
762
|
createFunction,
|
|
523
763
|
createOnRequest,
|
|
524
|
-
createServerlessApp
|
|
764
|
+
createServerlessApp,
|
|
765
|
+
extractContext
|
|
525
766
|
};
|