appflare 0.0.9 → 0.0.11
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/cli/generators/generate-db-handlers/templates.ts +51 -1
- package/cli/schema/schema-static-types.ts +230 -6
- package/cli/schema/schema.ts +6 -13
- package/cli/utils/zod-utils.ts +6 -0
- package/index.ts +1 -0
- package/lib/README.md +7 -0
- package/lib/location.ts +110 -0
- package/lib/values.ts +3 -0
- package/package.json +4 -3
- package/server/database/context.ts +89 -3
- package/server/database/populate.ts +104 -30
- package/server/database/query-builder.ts +58 -4
- package/server/types/types.ts +166 -2
|
@@ -13,6 +13,8 @@ export const buildHandlerFileContent = (
|
|
|
13
13
|
): string => {
|
|
14
14
|
const findFn = `find${params.pascalName}`;
|
|
15
15
|
const findOneFn = `findOne${params.pascalName}`;
|
|
16
|
+
const sumFn = `sum${params.pascalName}`;
|
|
17
|
+
const avgFn = `avg${params.pascalName}`;
|
|
16
18
|
const insertFn = `insert${params.pascalName}`;
|
|
17
19
|
const updateFn = `update${params.pascalName}`;
|
|
18
20
|
const deleteFn = `delete${params.pascalName}`;
|
|
@@ -26,7 +28,9 @@ import { z } from "zod";
|
|
|
26
28
|
import {
|
|
27
29
|
internalMutation,
|
|
28
30
|
internalQuery,
|
|
31
|
+
type AppflareInclude,
|
|
29
32
|
type EditableDoc,
|
|
33
|
+
type Doc,
|
|
30
34
|
type Id,
|
|
31
35
|
type QuerySort,
|
|
32
36
|
type QueryWhere,
|
|
@@ -40,6 +44,8 @@ export const ${findFn} = internalQuery({
|
|
|
40
44
|
.optional(),
|
|
41
45
|
limit: z.number().int().nonnegative().optional(),
|
|
42
46
|
offset: z.number().int().nonnegative().optional(),
|
|
47
|
+
include: z.custom<AppflareInclude<Doc<${JSON.stringify(params.tableName)}>>>()
|
|
48
|
+
.optional(),
|
|
43
49
|
},
|
|
44
50
|
handler: async (ctx, args) => {
|
|
45
51
|
return ctx.db[${JSON.stringify(params.tableName)} as any].findMany({
|
|
@@ -47,6 +53,7 @@ export const ${findFn} = internalQuery({
|
|
|
47
53
|
orderBy: args.sort as any,
|
|
48
54
|
skip: args.offset,
|
|
49
55
|
take: args.limit,
|
|
56
|
+
include: args.include as any,
|
|
50
57
|
});
|
|
51
58
|
},
|
|
52
59
|
});
|
|
@@ -58,6 +65,8 @@ export const ${findOneFn} = internalQuery({
|
|
|
58
65
|
sort: z.custom<QuerySort<${JSON.stringify(params.tableName)}>>()
|
|
59
66
|
.optional(),
|
|
60
67
|
offset: z.number().int().nonnegative().optional(),
|
|
68
|
+
include: z.custom<AppflareInclude<Doc<${JSON.stringify(params.tableName)}>>>()
|
|
69
|
+
.optional(),
|
|
61
70
|
},
|
|
62
71
|
handler: async (ctx, args) => {
|
|
63
72
|
return ctx.db[${JSON.stringify(params.tableName)} as any].findFirst({
|
|
@@ -65,6 +74,45 @@ export const ${findOneFn} = internalQuery({
|
|
|
65
74
|
orderBy: args.sort as any,
|
|
66
75
|
skip: args.offset,
|
|
67
76
|
take: 1,
|
|
77
|
+
include: args.include as any,
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const ${sumFn} = internalQuery({
|
|
83
|
+
args: {
|
|
84
|
+
fields: z.array(z.string()).min(1),
|
|
85
|
+
where: z.custom<QueryWhere<${JSON.stringify(params.tableName)}>>()
|
|
86
|
+
.optional(),
|
|
87
|
+
groupBy: z.union([z.string(), z.array(z.string())]).optional(),
|
|
88
|
+
populate: z.custom<AppflareInclude<Doc<${JSON.stringify(params.tableName)}>>>()
|
|
89
|
+
.optional(),
|
|
90
|
+
},
|
|
91
|
+
handler: async (ctx, args) => {
|
|
92
|
+
return ctx.db[${JSON.stringify(params.tableName)} as any].aggregate({
|
|
93
|
+
where: args.where as any,
|
|
94
|
+
groupBy: args.groupBy as any,
|
|
95
|
+
sum: args.fields as any,
|
|
96
|
+
populate: args.populate as any,
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const ${avgFn} = internalQuery({
|
|
102
|
+
args: {
|
|
103
|
+
fields: z.array(z.string()).min(1),
|
|
104
|
+
where: z.custom<QueryWhere<${JSON.stringify(params.tableName)}>>()
|
|
105
|
+
.optional(),
|
|
106
|
+
groupBy: z.union([z.string(), z.array(z.string())]).optional(),
|
|
107
|
+
populate: z.custom<AppflareInclude<Doc<${JSON.stringify(params.tableName)}>>>()
|
|
108
|
+
.optional(),
|
|
109
|
+
},
|
|
110
|
+
handler: async (ctx, args) => {
|
|
111
|
+
return ctx.db[${JSON.stringify(params.tableName)} as any].aggregate({
|
|
112
|
+
where: args.where as any,
|
|
113
|
+
groupBy: args.groupBy as any,
|
|
114
|
+
avg: args.fields as any,
|
|
115
|
+
populate: args.populate as any,
|
|
68
116
|
});
|
|
69
117
|
},
|
|
70
118
|
});
|
|
@@ -121,11 +169,13 @@ export const ${deleteFn} = internalMutation({
|
|
|
121
169
|
export const buildExportLine = (params: ExportLineParams): string => {
|
|
122
170
|
const findFn = `find${params.pascalName}`;
|
|
123
171
|
const findOneFn = `findOne${params.pascalName}`;
|
|
172
|
+
const sumFn = `sum${params.pascalName}`;
|
|
173
|
+
const avgFn = `avg${params.pascalName}`;
|
|
124
174
|
const insertFn = `insert${params.pascalName}`;
|
|
125
175
|
const updateFn = `update${params.pascalName}`;
|
|
126
176
|
const deleteFn = `delete${params.pascalName}`;
|
|
127
177
|
|
|
128
|
-
return `export { ${findFn}, ${findOneFn}, ${insertFn}, ${updateFn}, ${deleteFn} } from "./${params.tableName}";`;
|
|
178
|
+
return `export { ${findFn}, ${findOneFn}, ${sumFn}, ${avgFn}, ${insertFn}, ${updateFn}, ${deleteFn} } from "./${params.tableName}";`;
|
|
129
179
|
};
|
|
130
180
|
|
|
131
181
|
export const buildIndexFileContent = (
|
|
@@ -9,6 +9,11 @@ type ExtractIdTableName<T> = NonNil<T> extends Id<infer TTable>
|
|
|
9
9
|
? ExtractIdTableName<TItem>
|
|
10
10
|
: never;
|
|
11
11
|
|
|
12
|
+
type NumericKeys<T> =
|
|
13
|
+
{
|
|
14
|
+
[K in keyof T]: NonNil<T[K]> extends number | bigint ? K : never;
|
|
15
|
+
}[keyof T] & string;
|
|
16
|
+
|
|
12
17
|
type PopulateValue<T> = T extends Id<infer TTable>
|
|
13
18
|
? (TTable extends TableNames ? Doc<TTable> : never)
|
|
14
19
|
: T extends Array<infer TItem>
|
|
@@ -52,6 +57,100 @@ export type QuerySort<TableName extends TableNames> =
|
|
|
52
57
|
| Record<string, SortDirection>
|
|
53
58
|
| Array<[string, SortDirection]>;
|
|
54
59
|
|
|
60
|
+
type GeoCoordinates = readonly [number, number];
|
|
61
|
+
|
|
62
|
+
export type GeoPoint = {
|
|
63
|
+
type: "Point";
|
|
64
|
+
coordinates: GeoCoordinates;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type GeoPointInput = GeoPoint | GeoCoordinates;
|
|
68
|
+
|
|
69
|
+
const GEO_EARTH_RADIUS_METERS = 6_378_100;
|
|
70
|
+
|
|
71
|
+
const __geoNormalizePoint = (point: GeoPointInput): GeoPoint =>
|
|
72
|
+
Array.isArray(point)
|
|
73
|
+
? { type: "Point", coordinates: [point[0], point[1]] }
|
|
74
|
+
: point;
|
|
75
|
+
|
|
76
|
+
export const geo = {
|
|
77
|
+
point(lng: number, lat: number): GeoPoint {
|
|
78
|
+
return { type: "Point", coordinates: [lng, lat] };
|
|
79
|
+
},
|
|
80
|
+
near(
|
|
81
|
+
field: string,
|
|
82
|
+
point: GeoPointInput,
|
|
83
|
+
options: { maxDistanceMeters?: number; minDistanceMeters?: number } = {}
|
|
84
|
+
): Record<string, unknown> {
|
|
85
|
+
const $near: Record<string, unknown> = {
|
|
86
|
+
$geometry: __geoNormalizePoint(point),
|
|
87
|
+
};
|
|
88
|
+
if (options.maxDistanceMeters !== undefined)
|
|
89
|
+
$near.$maxDistance = options.maxDistanceMeters;
|
|
90
|
+
if (options.minDistanceMeters !== undefined)
|
|
91
|
+
$near.$minDistance = options.minDistanceMeters;
|
|
92
|
+
return { [field]: { $near } };
|
|
93
|
+
},
|
|
94
|
+
withinRadius(
|
|
95
|
+
field: string,
|
|
96
|
+
center: GeoPointInput,
|
|
97
|
+
radiusMeters: number
|
|
98
|
+
): Record<string, unknown> {
|
|
99
|
+
return {
|
|
100
|
+
[field]: {
|
|
101
|
+
$geoWithin: {
|
|
102
|
+
$centerSphere: [
|
|
103
|
+
__geoNormalizePoint(center).coordinates,
|
|
104
|
+
radiusMeters / GEO_EARTH_RADIUS_METERS,
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
withinBox(
|
|
111
|
+
field: string,
|
|
112
|
+
southwest: GeoPointInput,
|
|
113
|
+
northeast: GeoPointInput
|
|
114
|
+
): Record<string, unknown> {
|
|
115
|
+
return {
|
|
116
|
+
[field]: {
|
|
117
|
+
$geoWithin: {
|
|
118
|
+
$box: [
|
|
119
|
+
__geoNormalizePoint(southwest).coordinates,
|
|
120
|
+
__geoNormalizePoint(northeast).coordinates,
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
withinPolygon(
|
|
127
|
+
field: string,
|
|
128
|
+
polygon: ReadonlyArray<GeoPointInput>
|
|
129
|
+
): Record<string, unknown> {
|
|
130
|
+
return {
|
|
131
|
+
[field]: {
|
|
132
|
+
$geoWithin: {
|
|
133
|
+
$polygon: polygon.map((p) =>
|
|
134
|
+
__geoNormalizePoint(p).coordinates
|
|
135
|
+
),
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
intersects(
|
|
141
|
+
field: string,
|
|
142
|
+
geometry: { type: string; coordinates: unknown }
|
|
143
|
+
): Record<string, unknown> {
|
|
144
|
+
return {
|
|
145
|
+
[field]: {
|
|
146
|
+
$geoIntersects: {
|
|
147
|
+
$geometry: geometry,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
55
154
|
type SelectedKeys<TDoc, TSelect> =
|
|
56
155
|
TSelect extends readonly (infer TKey)[]
|
|
57
156
|
? Extract<TKey, Keys<TDoc>>
|
|
@@ -70,14 +169,70 @@ export type AppflareSelect<TDoc> =
|
|
|
70
169
|
| ReadonlyArray<Keys<TDoc>>
|
|
71
170
|
| Partial<Record<Keys<TDoc>, boolean>>;
|
|
72
171
|
|
|
172
|
+
type PopulatedDocForKey<TDoc, TKey extends Keys<TDoc>> =
|
|
173
|
+
NonNil<PopulateValue<TDoc[TKey]>> extends Array<infer TItem>
|
|
174
|
+
? NonNil<TItem>
|
|
175
|
+
: NonNil<PopulateValue<TDoc[TKey]>>;
|
|
176
|
+
|
|
177
|
+
type AggregateSpec<TDoc> = {
|
|
178
|
+
count?: boolean;
|
|
179
|
+
sum?: ReadonlyArray<NumericKeys<TDoc>>;
|
|
180
|
+
avg?: ReadonlyArray<NumericKeys<TDoc>>;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
type AppflareIncludeRecord<TDoc> = {
|
|
184
|
+
[K in PopulatableKeys<TDoc>]?:
|
|
185
|
+
| boolean
|
|
186
|
+
| {
|
|
187
|
+
aggregate?: AggregateSpec<PopulatedDocForKey<TDoc, K>>;
|
|
188
|
+
includeDocs?: boolean;
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
73
192
|
export type AppflareInclude<TDoc> =
|
|
74
193
|
| ReadonlyArray<PopulatableKeys<TDoc>>
|
|
75
|
-
|
|
|
76
|
-
|
|
77
|
-
type
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
194
|
+
| AppflareIncludeRecord<TDoc>;
|
|
195
|
+
|
|
196
|
+
type ExtractAggregateSpec<T> = T extends { aggregate?: infer TSpec } ? TSpec : never;
|
|
197
|
+
|
|
198
|
+
type AggregateResultFromSpec<TDoc, TSpec> = (TSpec extends { count: true }
|
|
199
|
+
? { count: number }
|
|
200
|
+
: {}) &
|
|
201
|
+
(TSpec extends { sum: infer TSum }
|
|
202
|
+
? TSum extends ReadonlyArray<infer K>
|
|
203
|
+
? { [P in Extract<K, NumericKeys<TDoc>> as \`sum_\${P}\`]: number }
|
|
204
|
+
: {}
|
|
205
|
+
: {}) &
|
|
206
|
+
(TSpec extends { avg: infer TAvg }
|
|
207
|
+
? TAvg extends ReadonlyArray<infer K>
|
|
208
|
+
? { [P in Extract<K, NumericKeys<TDoc>> as \`avg_\${P}\`]: number }
|
|
209
|
+
: {}
|
|
210
|
+
: {});
|
|
211
|
+
|
|
212
|
+
type AggregateMapForInclude<TDoc, TInclude> = TInclude extends ReadonlyArray<any>
|
|
213
|
+
? {}
|
|
214
|
+
: {
|
|
215
|
+
[K in keyof TInclude as ExtractAggregateSpec<TInclude[K]> extends never
|
|
216
|
+
? never
|
|
217
|
+
: K]: AggregateResultFromSpec<
|
|
218
|
+
PopulatedDocForKey<TDoc, Extract<K, Keys<TDoc>>>,
|
|
219
|
+
ExtractAggregateSpec<TInclude[K]>
|
|
220
|
+
>;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
type WithAggregatesForInclude<TDoc, TInclude> = keyof AggregateMapForInclude<
|
|
224
|
+
TDoc,
|
|
225
|
+
TInclude
|
|
226
|
+
> extends never
|
|
227
|
+
? {}
|
|
228
|
+
: { _aggregates: AggregateMapForInclude<TDoc, TInclude> };
|
|
229
|
+
|
|
230
|
+
type AppflareResultDoc<TDoc, TSelect, TInclude> =
|
|
231
|
+
WithSelected<
|
|
232
|
+
WithPopulatedMany<TDoc, IncludedKeys<TDoc, TInclude>>,
|
|
233
|
+
SelectedKeys<TDoc, TSelect>
|
|
234
|
+
> &
|
|
235
|
+
WithAggregatesForInclude<TDoc, TInclude>;
|
|
81
236
|
|
|
82
237
|
export type AppflareFindManyArgs<TableName extends TableNames> = {
|
|
83
238
|
where?: QueryWhere<TableName>;
|
|
@@ -130,6 +285,55 @@ export type AppflareCountArgs<TableName extends TableNames> = {
|
|
|
130
285
|
where?: QueryWhere<TableName>;
|
|
131
286
|
};
|
|
132
287
|
|
|
288
|
+
type AggregateGroupInput<TDoc> =
|
|
289
|
+
| ReadonlyArray<keyof TDoc & string>
|
|
290
|
+
| (keyof TDoc & string);
|
|
291
|
+
|
|
292
|
+
type NormalizeGroupInput<TDoc, TGroup> =
|
|
293
|
+
TGroup extends readonly (infer K)[]
|
|
294
|
+
? ReadonlyArray<Extract<K, keyof TDoc & string>>
|
|
295
|
+
: TGroup extends string
|
|
296
|
+
? ReadonlyArray<Extract<TGroup, keyof TDoc & string>>
|
|
297
|
+
: ReadonlyArray<never>;
|
|
298
|
+
|
|
299
|
+
type AggregateId<TDoc, TGroup extends ReadonlyArray<keyof TDoc & string>> =
|
|
300
|
+
TGroup extends readonly []
|
|
301
|
+
? null
|
|
302
|
+
: TGroup extends readonly [infer K]
|
|
303
|
+
? TDoc[Extract<K, keyof TDoc>]
|
|
304
|
+
: { [K in TGroup[number]]: TDoc[K] };
|
|
305
|
+
|
|
306
|
+
type AggregateResult<
|
|
307
|
+
TDoc,
|
|
308
|
+
TGroup extends ReadonlyArray<keyof TDoc & string>,
|
|
309
|
+
TSum extends ReadonlyArray<NumericKeys<TDoc>>,
|
|
310
|
+
TAvg extends ReadonlyArray<NumericKeys<TDoc>>,
|
|
311
|
+
> = {
|
|
312
|
+
_id: AggregateId<TDoc, TGroup>;
|
|
313
|
+
} & (TSum[number] extends never ? {} : { [K in TSum[number] as \`sum_\${K}\`]: number }) &
|
|
314
|
+
(TAvg[number] extends never ? {} : { [K in TAvg[number] as \`avg_\${K}\`]: number });
|
|
315
|
+
|
|
316
|
+
export type AppflareAggregateArgs<
|
|
317
|
+
TableName extends TableNames,
|
|
318
|
+
TGroup = AggregateGroupInput<TableDocMap[TableName]>,
|
|
319
|
+
TSum extends ReadonlyArray<NumericKeys<TableDocMap[TableName]>> = ReadonlyArray<
|
|
320
|
+
NumericKeys<TableDocMap[TableName]>
|
|
321
|
+
>,
|
|
322
|
+
TAvg extends ReadonlyArray<NumericKeys<TableDocMap[TableName]>> = ReadonlyArray<
|
|
323
|
+
NumericKeys<TableDocMap[TableName]>
|
|
324
|
+
>,
|
|
325
|
+
> = {
|
|
326
|
+
where?: QueryWhere<TableName>;
|
|
327
|
+
groupBy?: TGroup;
|
|
328
|
+
sum?: TSum;
|
|
329
|
+
avg?: TAvg;
|
|
330
|
+
/**
|
|
331
|
+
* Populate aggregated group keys that are references to other tables.
|
|
332
|
+
* Only keys present in groupBy are eligible for populate.
|
|
333
|
+
*/
|
|
334
|
+
populate?: AppflareInclude<TableDocMap[TableName]>;
|
|
335
|
+
};
|
|
336
|
+
|
|
133
337
|
export type AppflareTableClient<TableName extends TableNames> = {
|
|
134
338
|
findMany<TSelect = AppflareSelect<TableDocMap[TableName]>, TInclude = never>(
|
|
135
339
|
args?: AppflareFindManyArgs<TableName> & {
|
|
@@ -197,6 +401,26 @@ export type AppflareTableClient<TableName extends TableNames> = {
|
|
|
197
401
|
>;
|
|
198
402
|
deleteMany(args?: AppflareDeleteManyArgs<TableName>): Promise<{ count: number }>;
|
|
199
403
|
count(args?: AppflareCountArgs<TableName>): Promise<number>;
|
|
404
|
+
aggregate<
|
|
405
|
+
TGroup = AggregateGroupInput<TableDocMap[TableName]>,
|
|
406
|
+
TSum extends ReadonlyArray<NumericKeys<TableDocMap[TableName]>> = ReadonlyArray<
|
|
407
|
+
NumericKeys<TableDocMap[TableName]>
|
|
408
|
+
>,
|
|
409
|
+
TAvg extends ReadonlyArray<NumericKeys<TableDocMap[TableName]>> = ReadonlyArray<
|
|
410
|
+
NumericKeys<TableDocMap[TableName]>
|
|
411
|
+
>,
|
|
412
|
+
>(
|
|
413
|
+
args: AppflareAggregateArgs<TableName, TGroup, TSum, TAvg>
|
|
414
|
+
): Promise<
|
|
415
|
+
Array<
|
|
416
|
+
AggregateResult<
|
|
417
|
+
TableDocMap[TableName],
|
|
418
|
+
NormalizeGroupInput<TableDocMap[TableName], TGroup>,
|
|
419
|
+
TSum,
|
|
420
|
+
TAvg
|
|
421
|
+
>
|
|
422
|
+
>
|
|
423
|
+
>;
|
|
200
424
|
};
|
|
201
425
|
|
|
202
426
|
export type AppflareModelMap = {
|
package/cli/schema/schema.ts
CHANGED
|
@@ -101,22 +101,15 @@ type __AppflareAuthSessionResult = __AppflareAuthOptions extends BetterAuthOptio
|
|
|
101
101
|
>
|
|
102
102
|
: null;
|
|
103
103
|
|
|
104
|
-
export type AppflareAuthSession = __AppflareAuthSessionResult extends {
|
|
105
|
-
session: infer TSession;
|
|
106
|
-
}
|
|
107
|
-
? TSession | null
|
|
108
|
-
: __AppflareAuthSessionResult;
|
|
109
104
|
|
|
110
|
-
export type
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
? TUser | null
|
|
114
|
-
: null;
|
|
105
|
+
export type AppflareAuthSession = Session | null
|
|
106
|
+
export type AppflareAuthUser = User | null
|
|
115
107
|
|
|
116
108
|
export type AppflareAuthContext = {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
109
|
+
session: AppflareAuthSession
|
|
110
|
+
user: AppflareAuthUser
|
|
111
|
+
}
|
|
112
|
+
|
|
120
113
|
`
|
|
121
114
|
: `export type AppflareAuthSession = null;
|
|
122
115
|
export type AppflareAuthUser = null;
|
package/cli/utils/zod-utils.ts
CHANGED
|
@@ -37,6 +37,12 @@ function renderField(fieldName: string, schema: any): string {
|
|
|
37
37
|
function renderType(schema: any): { tsType: string; optional: boolean } {
|
|
38
38
|
const def = schema?._def;
|
|
39
39
|
const typeName: string | undefined = def?.typeName ?? def?.type;
|
|
40
|
+
const description: string | undefined =
|
|
41
|
+
schema?.description ?? def?.description;
|
|
42
|
+
|
|
43
|
+
if (description === "geo:point") {
|
|
44
|
+
return { tsType: "GeoPoint", optional: false };
|
|
45
|
+
}
|
|
40
46
|
|
|
41
47
|
if (typeName === "ZodOptional" || typeName === "optional") {
|
|
42
48
|
const inner = def?.innerType ?? def?.schema ?? schema?._def?.innerType;
|
package/index.ts
CHANGED
package/lib/README.md
CHANGED
|
@@ -19,10 +19,16 @@ Docs for the shared library helpers in `lib/`
|
|
|
19
19
|
|
|
20
20
|
- Primitives: `v.string()`, `v.number()`, `v.boolean()`, `v.date()`.
|
|
21
21
|
- Relations/ids: `v.id(table)` creates a string with an ObjectId regex and a `ref:<table>` description for downstream typing.
|
|
22
|
+
- Location: `v.point()`/`v.location()` yields a GeoJSON Point schema for geospatial queries.
|
|
22
23
|
- Collections/objects: `v.array(item)`, `v.object(shape)`.
|
|
23
24
|
- Modifiers: `v.optional(schema)`, `v.nullable(schema)`, `v.union(...schemas)`, `v.literal(value)`.
|
|
24
25
|
- Misc: `v.buffer()` (string placeholder), `v.any()`, `v.unknown()`.
|
|
25
26
|
|
|
27
|
+
**Location helpers** ([packages/appflare/lib/location.ts](packages/appflare/lib/location.ts))
|
|
28
|
+
|
|
29
|
+
- Builders: `point(lng, lat)` returns a GeoJSON Point; `geo.point` is the same alias.
|
|
30
|
+
- Queries: `geo.near("location", pointLngLat, { maxDistanceMeters })`, `geo.withinRadius("location", center, radiusMeters)`, `geo.withinBox(...)`, `geo.withinPolygon(...)`, `geo.intersects(...)` produce Mongo-ready filters you can pass to `where`.
|
|
31
|
+
|
|
26
32
|
**Example: define a schema**
|
|
27
33
|
|
|
28
34
|
```ts
|
|
@@ -35,6 +41,7 @@ export default defineSchema({
|
|
|
35
41
|
email: v.string(),
|
|
36
42
|
age: v.number().optional(),
|
|
37
43
|
orgId: v.id("orgs"),
|
|
44
|
+
location: v.point(),
|
|
38
45
|
}),
|
|
39
46
|
orgs: defineTable({
|
|
40
47
|
name: v.string(),
|
package/lib/location.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export type GeoCoordinates = readonly [number, number];
|
|
4
|
+
|
|
5
|
+
export type GeoPoint = {
|
|
6
|
+
type: "Point";
|
|
7
|
+
coordinates: GeoCoordinates;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const geoPointSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
type: z.literal("Point"),
|
|
13
|
+
coordinates: z.tuple([z.number(), z.number()]),
|
|
14
|
+
})
|
|
15
|
+
.describe("geo:point");
|
|
16
|
+
|
|
17
|
+
export const EARTH_RADIUS_METERS = 6_378_100;
|
|
18
|
+
|
|
19
|
+
const normalizePoint = (point: GeoPoint | GeoCoordinates): GeoPoint =>
|
|
20
|
+
Array.isArray(point)
|
|
21
|
+
? { type: "Point", coordinates: [point[0], point[1]] }
|
|
22
|
+
: (point as GeoPoint);
|
|
23
|
+
|
|
24
|
+
export const point = (lng: number, lat: number): GeoPoint => ({
|
|
25
|
+
type: "Point",
|
|
26
|
+
coordinates: [lng, lat],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export type NearQueryOptions = {
|
|
30
|
+
maxDistanceMeters?: number;
|
|
31
|
+
minDistanceMeters?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const near = (
|
|
35
|
+
field: string,
|
|
36
|
+
geometry: GeoPoint | GeoCoordinates,
|
|
37
|
+
options: NearQueryOptions = {}
|
|
38
|
+
): Record<string, unknown> => {
|
|
39
|
+
const $near: Record<string, unknown> = {
|
|
40
|
+
$geometry: normalizePoint(geometry),
|
|
41
|
+
};
|
|
42
|
+
if (options.maxDistanceMeters !== undefined) {
|
|
43
|
+
$near.$maxDistance = options.maxDistanceMeters;
|
|
44
|
+
}
|
|
45
|
+
if (options.minDistanceMeters !== undefined) {
|
|
46
|
+
$near.$minDistance = options.minDistanceMeters;
|
|
47
|
+
}
|
|
48
|
+
return { [field]: { $near } };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const withinRadius = (
|
|
52
|
+
field: string,
|
|
53
|
+
center: GeoPoint | GeoCoordinates,
|
|
54
|
+
radiusMeters: number
|
|
55
|
+
): Record<string, unknown> => ({
|
|
56
|
+
[field]: {
|
|
57
|
+
$geoWithin: {
|
|
58
|
+
$centerSphere: [
|
|
59
|
+
normalizePoint(center).coordinates,
|
|
60
|
+
radiusMeters / EARTH_RADIUS_METERS,
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const withinBox = (
|
|
67
|
+
field: string,
|
|
68
|
+
southwest: GeoPoint | GeoCoordinates,
|
|
69
|
+
northeast: GeoPoint | GeoCoordinates
|
|
70
|
+
): Record<string, unknown> => ({
|
|
71
|
+
[field]: {
|
|
72
|
+
$geoWithin: {
|
|
73
|
+
$box: [
|
|
74
|
+
normalizePoint(southwest).coordinates,
|
|
75
|
+
normalizePoint(northeast).coordinates,
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const withinPolygon = (
|
|
82
|
+
field: string,
|
|
83
|
+
polygon: ReadonlyArray<GeoPoint | GeoCoordinates>
|
|
84
|
+
): Record<string, unknown> => ({
|
|
85
|
+
[field]: {
|
|
86
|
+
$geoWithin: {
|
|
87
|
+
$polygon: polygon.map((p) => normalizePoint(p).coordinates),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const intersects = (
|
|
93
|
+
field: string,
|
|
94
|
+
geometry: { type: string; coordinates: unknown }
|
|
95
|
+
): Record<string, unknown> => ({
|
|
96
|
+
[field]: {
|
|
97
|
+
$geoIntersects: {
|
|
98
|
+
$geometry: geometry,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export const geo = {
|
|
104
|
+
point,
|
|
105
|
+
near,
|
|
106
|
+
withinRadius,
|
|
107
|
+
withinBox,
|
|
108
|
+
withinPolygon,
|
|
109
|
+
intersects,
|
|
110
|
+
};
|
package/lib/values.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { geoPointSchema } from "./location";
|
|
2
3
|
|
|
3
4
|
export const v = {
|
|
4
5
|
string: () => z.string(),
|
|
5
6
|
number: () => z.number(),
|
|
6
7
|
boolean: () => z.boolean(),
|
|
7
8
|
date: () => z.date(),
|
|
9
|
+
point: () => geoPointSchema,
|
|
10
|
+
location: () => geoPointSchema,
|
|
8
11
|
id: (table: string) =>
|
|
9
12
|
z
|
|
10
13
|
.string()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "appflare",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"bin": {
|
|
5
5
|
"appflare": "./cli/index.ts"
|
|
6
6
|
},
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
".": "./index.ts",
|
|
9
9
|
"./db": "./lib/db.ts",
|
|
10
10
|
"./values": "./lib/values.ts",
|
|
11
|
+
"./location": "./lib/location.ts",
|
|
11
12
|
"./*": "./*.ts",
|
|
12
13
|
"./react/*": "./react/*.ts",
|
|
13
14
|
"./react": "./react/index.ts",
|
|
@@ -24,12 +25,12 @@
|
|
|
24
25
|
"react": "^18.3.1"
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
|
-
"@hono/standard-validator": "^0.2.1",
|
|
28
28
|
"better-auth": "^1.4.9",
|
|
29
29
|
"better-fetch": "^1.1.2",
|
|
30
30
|
"chokidar": "^5.0.0",
|
|
31
|
-
"cloudflare-do-mongo": "^0.1.2",
|
|
32
31
|
"commander": "^14.0.1",
|
|
32
|
+
"@hono/standard-validator": "^0.2.1",
|
|
33
|
+
"cloudflare-do-mongo": "^0.1.2",
|
|
33
34
|
"hono": "^4.6.8",
|
|
34
35
|
"mongodb": "^7.0.0",
|
|
35
36
|
"zod": "^4.1.13"
|
|
@@ -110,6 +110,7 @@ export function createMongoDbContext<
|
|
|
110
110
|
|
|
111
111
|
function toKeyList(arg: unknown): string[] | undefined {
|
|
112
112
|
if (!arg) return undefined;
|
|
113
|
+
if (typeof arg === "string" || typeof arg === "number") return [String(arg)];
|
|
113
114
|
if (Array.isArray(arg)) return arg.map((k) => String(k));
|
|
114
115
|
if (typeof arg === "object") {
|
|
115
116
|
return Object.entries(arg as Record<string, unknown>)
|
|
@@ -130,7 +131,26 @@ function createAppflareTableClient<
|
|
|
130
131
|
getCollection: (table: string) => Collection<Document>;
|
|
131
132
|
}): AppflareTableClient<TableName, TTableDocMap> {
|
|
132
133
|
const selectKeys = (select: unknown) => toKeyList(select);
|
|
133
|
-
|
|
134
|
+
|
|
135
|
+
const toArray = <T>(value: T | T[] | undefined): T[] => {
|
|
136
|
+
if (value === undefined) return [];
|
|
137
|
+
return Array.isArray(value) ? value : [value];
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const stringifyAggregateValue = (value: unknown): unknown => {
|
|
141
|
+
if (value instanceof ObjectId) return value.toHexString();
|
|
142
|
+
if (Array.isArray(value)) return value.map(stringifyAggregateValue);
|
|
143
|
+
if (value && typeof value === "object") {
|
|
144
|
+
const out: Record<string, unknown> = {};
|
|
145
|
+
for (const [key, val] of Object.entries(
|
|
146
|
+
value as Record<string, unknown>
|
|
147
|
+
)) {
|
|
148
|
+
out[key] = stringifyAggregateValue(val);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
};
|
|
134
154
|
|
|
135
155
|
const buildQuery = (args?: {
|
|
136
156
|
where?: any;
|
|
@@ -149,8 +169,7 @@ function createAppflareTableClient<
|
|
|
149
169
|
if (args?.take !== undefined) q = q.limit(args.take);
|
|
150
170
|
const s = selectKeys(args?.select);
|
|
151
171
|
if (s?.length) q = q.select(s as any);
|
|
152
|
-
|
|
153
|
-
if (i?.length) q = q.populate(i as any);
|
|
172
|
+
if (args?.include) q = q.populate(args.include as any);
|
|
154
173
|
return q;
|
|
155
174
|
};
|
|
156
175
|
|
|
@@ -235,5 +254,72 @@ function createAppflareTableClient<
|
|
|
235
254
|
const filter = normalizeIdFilter(toMongoFilter(args?.where ?? {}));
|
|
236
255
|
return coll.countDocuments(filter ?? {});
|
|
237
256
|
},
|
|
257
|
+
aggregate: async (args) => {
|
|
258
|
+
const coll = params.getCollection(params.table as string);
|
|
259
|
+
const pipeline: Document[] = [];
|
|
260
|
+
|
|
261
|
+
const normalizedWhere = args.where
|
|
262
|
+
? normalizeIdFilter(
|
|
263
|
+
normalizeRefFields(
|
|
264
|
+
params.table as string,
|
|
265
|
+
args.where as Record<string, unknown>,
|
|
266
|
+
params.refs
|
|
267
|
+
) as any
|
|
268
|
+
)
|
|
269
|
+
: undefined;
|
|
270
|
+
if (normalizedWhere && Object.keys(normalizedWhere).length > 0) {
|
|
271
|
+
pipeline.push({ $match: normalizedWhere });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const sumFields = toArray(args.sum).filter(Boolean) as string[];
|
|
275
|
+
const avgFields = toArray(args.avg).filter(Boolean) as string[];
|
|
276
|
+
if (sumFields.length === 0 && avgFields.length === 0) {
|
|
277
|
+
throw new Error("aggregate requires at least one sum or avg field");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const groupKeys = toArray(args.groupBy).filter(Boolean) as string[];
|
|
281
|
+
const groupId =
|
|
282
|
+
groupKeys.length === 0
|
|
283
|
+
? null
|
|
284
|
+
: groupKeys.length === 1
|
|
285
|
+
? `$${groupKeys[0]}`
|
|
286
|
+
: Object.fromEntries(groupKeys.map((k) => [k, `$${k}`]));
|
|
287
|
+
|
|
288
|
+
const groupStage: Record<string, unknown> = { _id: groupId };
|
|
289
|
+
for (const field of sumFields) {
|
|
290
|
+
groupStage[`sum_${field}`] = { $sum: `$${field}` };
|
|
291
|
+
}
|
|
292
|
+
for (const field of avgFields) {
|
|
293
|
+
groupStage[`avg_${field}`] = { $avg: `$${field}` };
|
|
294
|
+
}
|
|
295
|
+
pipeline.push({ $group: groupStage });
|
|
296
|
+
|
|
297
|
+
const tableRefs = params.refs.get(params.table as string) ?? new Map();
|
|
298
|
+
const populateKeys = toKeyList(args.populate) ?? [];
|
|
299
|
+
for (const key of populateKeys) {
|
|
300
|
+
if (!groupKeys.includes(key)) continue;
|
|
301
|
+
const target = tableRefs.get(key);
|
|
302
|
+
if (!target) continue;
|
|
303
|
+
const useRootId = groupKeys.length === 1 && groupKeys[0] === key;
|
|
304
|
+
const localField = useRootId ? "_id" : `_id.${key}`;
|
|
305
|
+
pipeline.push({
|
|
306
|
+
$lookup: {
|
|
307
|
+
from: target,
|
|
308
|
+
localField,
|
|
309
|
+
foreignField: "_id",
|
|
310
|
+
as: key,
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
pipeline.push({
|
|
314
|
+
$unwind: {
|
|
315
|
+
path: `$${key}`,
|
|
316
|
+
preserveNullAndEmptyArrays: true,
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const results = await coll.aggregate(pipeline as any).toArray();
|
|
322
|
+
return results.map((doc) => stringifyAggregateValue(doc)) as any;
|
|
323
|
+
},
|
|
238
324
|
};
|
|
239
325
|
}
|
|
@@ -7,11 +7,18 @@ export async function applyPopulate(params: {
|
|
|
7
7
|
docs: Array<Record<string, unknown>>;
|
|
8
8
|
currentTable: string;
|
|
9
9
|
populateKeys: string[];
|
|
10
|
+
aggregate?: Record<
|
|
11
|
+
string,
|
|
12
|
+
{ count?: boolean; sum?: string[]; avg?: string[] }
|
|
13
|
+
>;
|
|
14
|
+
includeDocs?: Record<string, boolean>;
|
|
10
15
|
selectedKeys: string[] | undefined;
|
|
11
16
|
refs: SchemaRefMap;
|
|
12
17
|
getCollection: (table: string) => Collection<Document>;
|
|
13
18
|
}) {
|
|
14
19
|
const tableRefs = params.refs.get(params.currentTable) ?? new Map();
|
|
20
|
+
const aggregateConfig = params.aggregate ?? {};
|
|
21
|
+
const includeDocsConfig = params.includeDocs ?? {};
|
|
15
22
|
|
|
16
23
|
const ensureStringId = (val: unknown): string | null => {
|
|
17
24
|
if (!val) return null;
|
|
@@ -60,10 +67,53 @@ export async function applyPopulate(params: {
|
|
|
60
67
|
return byId;
|
|
61
68
|
};
|
|
62
69
|
|
|
70
|
+
const computeAggregates = (
|
|
71
|
+
value: unknown,
|
|
72
|
+
spec: { count?: boolean; sum?: string[]; avg?: string[] }
|
|
73
|
+
): Record<string, number> | null => {
|
|
74
|
+
const items = Array.isArray(value)
|
|
75
|
+
? value
|
|
76
|
+
: value === undefined || value === null
|
|
77
|
+
? []
|
|
78
|
+
: [value];
|
|
79
|
+
|
|
80
|
+
const out: Record<string, number> = {};
|
|
81
|
+
const numericSum = (field: string) => {
|
|
82
|
+
let total = 0;
|
|
83
|
+
let found = 0;
|
|
84
|
+
for (const item of items) {
|
|
85
|
+
if (!item || typeof item !== "object") continue;
|
|
86
|
+
const raw = (item as Record<string, unknown>)[field];
|
|
87
|
+
if (typeof raw === "number" || typeof raw === "bigint") {
|
|
88
|
+
total += Number(raw);
|
|
89
|
+
found += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { total, found };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (spec.count) out.count = items.length;
|
|
96
|
+
|
|
97
|
+
for (const field of spec.sum ?? []) {
|
|
98
|
+
const { total, found } = numericSum(String(field));
|
|
99
|
+
if (found > 0) out[`sum_${String(field)}`] = total;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const field of spec.avg ?? []) {
|
|
103
|
+
const { total, found } = numericSum(String(field));
|
|
104
|
+
if (found > 0) out[`avg_${String(field)}`] = total / found;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return Object.keys(out).length ? out : null;
|
|
108
|
+
};
|
|
109
|
+
|
|
63
110
|
for (const key of params.populateKeys) {
|
|
64
111
|
const forwardTarget = tableRefs.get(key);
|
|
65
112
|
const backwardCandidates = reverseRefs.get(params.currentTable) ?? [];
|
|
66
113
|
const backward = backwardCandidates.find((c) => c.table === key);
|
|
114
|
+
const includeDocs = includeDocsConfig[key] ?? true;
|
|
115
|
+
let valueById: Map<string, Record<string, unknown>[]> | undefined;
|
|
116
|
+
let handled = false;
|
|
67
117
|
|
|
68
118
|
// Prefer forward populate via $lookup when the table carries the ref.
|
|
69
119
|
if (forwardTarget) {
|
|
@@ -94,33 +144,37 @@ export async function applyPopulate(params: {
|
|
|
94
144
|
.toArray()) as Array<Record<string, unknown>>;
|
|
95
145
|
|
|
96
146
|
const byId = buildLookupMap(populated);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
147
|
+
valueById = byId;
|
|
148
|
+
handled = true;
|
|
149
|
+
|
|
150
|
+
if (includeDocs) {
|
|
151
|
+
for (const doc of params.docs) {
|
|
152
|
+
const docId = ensureStringId(doc._id);
|
|
153
|
+
if (!docId) continue;
|
|
154
|
+
const populatedDocs = byId.get(docId) ?? [];
|
|
155
|
+
const currentValue = doc[key];
|
|
156
|
+
|
|
157
|
+
if (Array.isArray(currentValue)) {
|
|
158
|
+
const byRelId = new Map<string, Record<string, unknown>>();
|
|
159
|
+
for (const rel of populatedDocs) {
|
|
160
|
+
const relId = ensureStringId(rel._id);
|
|
161
|
+
if (relId) byRelId.set(relId, rel);
|
|
162
|
+
}
|
|
163
|
+
doc[key] = currentValue
|
|
164
|
+
.map((v) => {
|
|
165
|
+
const relId = ensureStringId(v);
|
|
166
|
+
return relId ? (byRelId.get(relId) ?? null) : null;
|
|
167
|
+
})
|
|
168
|
+
.filter((v): v is Record<string, unknown> => Boolean(v));
|
|
169
|
+
continue;
|
|
109
170
|
}
|
|
110
|
-
doc[key] = currentValue
|
|
111
|
-
.map((v) => {
|
|
112
|
-
const relId = ensureStringId(v);
|
|
113
|
-
return relId ? (byRelId.get(relId) ?? null) : null;
|
|
114
|
-
})
|
|
115
|
-
.filter((v): v is Record<string, unknown> => Boolean(v));
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
171
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
172
|
+
const relId = ensureStringId(currentValue);
|
|
173
|
+
doc[key] = relId
|
|
174
|
+
? (populatedDocs.find((v) => ensureStringId(v._id) === relId) ??
|
|
175
|
+
currentValue)
|
|
176
|
+
: (populatedDocs[0] ?? currentValue);
|
|
177
|
+
}
|
|
124
178
|
}
|
|
125
179
|
|
|
126
180
|
continue;
|
|
@@ -144,16 +198,36 @@ export async function applyPopulate(params: {
|
|
|
144
198
|
},
|
|
145
199
|
{ $project: { _id: 1, __pop: 1 } },
|
|
146
200
|
];
|
|
147
|
-
|
|
148
201
|
const populated = (await coll
|
|
149
202
|
.aggregate(pipeline as any)
|
|
150
203
|
.toArray()) as Array<Record<string, unknown>>;
|
|
204
|
+
if (!handled && backward) {
|
|
205
|
+
const grouped = buildLookupMap(populated);
|
|
206
|
+
valueById = grouped;
|
|
207
|
+
|
|
208
|
+
if (includeDocs) {
|
|
209
|
+
for (const doc of params.docs) {
|
|
210
|
+
const id = ensureStringId(doc._id);
|
|
211
|
+
if (!id) continue;
|
|
212
|
+
doc[key] = grouped.get(id) ?? [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
151
217
|
|
|
152
|
-
|
|
218
|
+
const aggregateSpec = aggregateConfig[key];
|
|
219
|
+
if (aggregateSpec) {
|
|
153
220
|
for (const doc of params.docs) {
|
|
154
|
-
const
|
|
155
|
-
if (!
|
|
156
|
-
|
|
221
|
+
const docId = ensureStringId(doc._id);
|
|
222
|
+
if (!docId) continue;
|
|
223
|
+
const aggregateSource = valueById?.get(docId) ?? doc[key];
|
|
224
|
+
const aggregated = computeAggregates(aggregateSource, aggregateSpec);
|
|
225
|
+
if (!aggregated) continue;
|
|
226
|
+
const existing = (doc as any)._aggregates as
|
|
227
|
+
| Record<string, unknown>
|
|
228
|
+
| undefined;
|
|
229
|
+
const target = existing ?? ((doc as any)._aggregates = {});
|
|
230
|
+
target[key] = aggregated;
|
|
157
231
|
}
|
|
158
232
|
}
|
|
159
233
|
}
|
|
@@ -19,6 +19,41 @@ export function createQueryBuilder<
|
|
|
19
19
|
let offset: number | undefined;
|
|
20
20
|
let selectedKeys: string[] | undefined;
|
|
21
21
|
let populateKeys: string[] = [];
|
|
22
|
+
let populateAggregates: Record<
|
|
23
|
+
string,
|
|
24
|
+
{
|
|
25
|
+
count?: boolean;
|
|
26
|
+
sum?: string[];
|
|
27
|
+
avg?: string[];
|
|
28
|
+
}
|
|
29
|
+
> = {};
|
|
30
|
+
let populateIncludeDocs: Record<string, boolean> = {};
|
|
31
|
+
|
|
32
|
+
const normalizeAggregate = (
|
|
33
|
+
value: unknown
|
|
34
|
+
): { count?: boolean; sum?: string[]; avg?: string[] } | undefined => {
|
|
35
|
+
if (!value || typeof value !== "object") return undefined;
|
|
36
|
+
const spec = value as Record<string, unknown>;
|
|
37
|
+
const out: { count?: boolean; sum?: string[]; avg?: string[] } = {};
|
|
38
|
+
if (spec.count) out.count = true;
|
|
39
|
+
if (Array.isArray(spec.sum)) out.sum = spec.sum.map((k) => String(k));
|
|
40
|
+
if (Array.isArray(spec.avg)) out.avg = spec.avg.map((k) => String(k));
|
|
41
|
+
return Object.keys(out).length ? out : undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const addPopulateKey = (
|
|
45
|
+
key: string,
|
|
46
|
+
opts?: {
|
|
47
|
+
aggregate?: { count?: boolean; sum?: string[]; avg?: string[] };
|
|
48
|
+
includeDocs?: boolean;
|
|
49
|
+
}
|
|
50
|
+
) => {
|
|
51
|
+
const ks = String(key);
|
|
52
|
+
if (!populateKeys.includes(ks)) populateKeys.push(ks);
|
|
53
|
+
if (opts?.aggregate) populateAggregates[ks] = opts.aggregate;
|
|
54
|
+
if (opts?.includeDocs !== undefined)
|
|
55
|
+
populateIncludeDocs[ks] = opts.includeDocs;
|
|
56
|
+
};
|
|
22
57
|
|
|
23
58
|
const api: MongoDbQuery<any, any, any> = {
|
|
24
59
|
where(next) {
|
|
@@ -50,11 +85,28 @@ export function createQueryBuilder<
|
|
|
50
85
|
return api;
|
|
51
86
|
},
|
|
52
87
|
populate(arg: any) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!populateKeys.includes(ks)) populateKeys.push(ks);
|
|
88
|
+
if (Array.isArray(arg)) {
|
|
89
|
+
for (const k of arg) addPopulateKey(k as any);
|
|
90
|
+
return api;
|
|
57
91
|
}
|
|
92
|
+
|
|
93
|
+
if (arg && typeof arg === "object") {
|
|
94
|
+
for (const [key, value] of Object.entries(
|
|
95
|
+
arg as Record<string, unknown>
|
|
96
|
+
)) {
|
|
97
|
+
if (!value) continue;
|
|
98
|
+
const includeObj =
|
|
99
|
+
typeof value === "object" && !Array.isArray(value)
|
|
100
|
+
? (value as Record<string, unknown>)
|
|
101
|
+
: undefined;
|
|
102
|
+
const aggregate = normalizeAggregate(includeObj?.aggregate);
|
|
103
|
+
const includeDocs = includeObj?.includeDocs;
|
|
104
|
+
addPopulateKey(key, { aggregate, includeDocs });
|
|
105
|
+
}
|
|
106
|
+
return api;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (arg !== undefined) addPopulateKey(arg as any);
|
|
58
110
|
return api;
|
|
59
111
|
},
|
|
60
112
|
async find() {
|
|
@@ -80,6 +132,8 @@ export function createQueryBuilder<
|
|
|
80
132
|
docs,
|
|
81
133
|
currentTable: params.table as string,
|
|
82
134
|
populateKeys,
|
|
135
|
+
aggregate: populateAggregates,
|
|
136
|
+
includeDocs: populateIncludeDocs,
|
|
83
137
|
selectedKeys,
|
|
84
138
|
refs: params.refs,
|
|
85
139
|
getCollection: params.getCollection,
|
package/server/types/types.ts
CHANGED
|
@@ -35,12 +35,87 @@ export type AppflareSelect<TDoc> =
|
|
|
35
35
|
| ReadonlyArray<Keys<TDoc>>
|
|
36
36
|
| Partial<Record<Keys<TDoc>, boolean>>;
|
|
37
37
|
|
|
38
|
+
type PopulatedDocForKey<
|
|
39
|
+
TDoc,
|
|
40
|
+
TTableDocMap extends Record<string, TableDocBase>,
|
|
41
|
+
TKey extends Keys<TDoc>,
|
|
42
|
+
> =
|
|
43
|
+
NonNil<PopulateValue<TDoc[TKey], TTableDocMap>> extends Array<infer TItem>
|
|
44
|
+
? NonNil<TItem>
|
|
45
|
+
: NonNil<PopulateValue<TDoc[TKey], TTableDocMap>>;
|
|
46
|
+
|
|
47
|
+
type AggregateSpec<TDoc> = {
|
|
48
|
+
count?: boolean;
|
|
49
|
+
sum?: ReadonlyArray<NumericKeys<TDoc>>;
|
|
50
|
+
avg?: ReadonlyArray<NumericKeys<TDoc>>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type AggregateSpecForKey<
|
|
54
|
+
TDoc,
|
|
55
|
+
TTableDocMap extends Record<string, TableDocBase>,
|
|
56
|
+
TKey extends Keys<TDoc>,
|
|
57
|
+
> = AggregateSpec<PopulatedDocForKey<TDoc, TTableDocMap, TKey>>;
|
|
58
|
+
|
|
59
|
+
type AppflareIncludeRecord<
|
|
60
|
+
TDoc,
|
|
61
|
+
TTableDocMap extends Record<string, TableDocBase>,
|
|
62
|
+
> = {
|
|
63
|
+
[K in PopulatableKeys<TDoc, TTableDocMap>]?:
|
|
64
|
+
| boolean
|
|
65
|
+
| {
|
|
66
|
+
aggregate?: AggregateSpecForKey<TDoc, TTableDocMap, K>;
|
|
67
|
+
includeDocs?: boolean;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
38
71
|
export type AppflareInclude<
|
|
39
72
|
TDoc,
|
|
40
73
|
TTableDocMap extends Record<string, TableDocBase>,
|
|
41
74
|
> =
|
|
42
75
|
| ReadonlyArray<PopulatableKeys<TDoc, TTableDocMap>>
|
|
43
|
-
|
|
|
76
|
+
| AppflareIncludeRecord<TDoc, TTableDocMap>;
|
|
77
|
+
|
|
78
|
+
type ExtractAggregateSpec<T> = T extends { aggregate?: infer TSpec }
|
|
79
|
+
? TSpec
|
|
80
|
+
: never;
|
|
81
|
+
|
|
82
|
+
type AggregateResultFromSpec<TDoc, TSpec> = (TSpec extends { count: true }
|
|
83
|
+
? { count: number }
|
|
84
|
+
: {}) &
|
|
85
|
+
(TSpec extends { sum: infer TSum }
|
|
86
|
+
? TSum extends ReadonlyArray<infer K>
|
|
87
|
+
? { [P in Extract<K, NumericKeys<TDoc>> as `sum_${P}`]: number }
|
|
88
|
+
: {}
|
|
89
|
+
: {}) &
|
|
90
|
+
(TSpec extends { avg: infer TAvg }
|
|
91
|
+
? TAvg extends ReadonlyArray<infer K>
|
|
92
|
+
? { [P in Extract<K, NumericKeys<TDoc>> as `avg_${P}`]: number }
|
|
93
|
+
: {}
|
|
94
|
+
: {});
|
|
95
|
+
|
|
96
|
+
type AggregateMapForInclude<
|
|
97
|
+
TDoc,
|
|
98
|
+
TTableDocMap extends Record<string, TableDocBase>,
|
|
99
|
+
TInclude,
|
|
100
|
+
> =
|
|
101
|
+
TInclude extends ReadonlyArray<any>
|
|
102
|
+
? {}
|
|
103
|
+
: {
|
|
104
|
+
[K in keyof TInclude as ExtractAggregateSpec<TInclude[K]> extends never
|
|
105
|
+
? never
|
|
106
|
+
: K]: AggregateResultFromSpec<
|
|
107
|
+
PopulatedDocForKey<TDoc, TTableDocMap, Extract<K, Keys<TDoc>>>,
|
|
108
|
+
ExtractAggregateSpec<TInclude[K]>
|
|
109
|
+
>;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
type WithAggregatesForInclude<
|
|
113
|
+
TDoc,
|
|
114
|
+
TTableDocMap extends Record<string, TableDocBase>,
|
|
115
|
+
TInclude,
|
|
116
|
+
> = keyof AggregateMapForInclude<TDoc, TTableDocMap, TInclude> extends never
|
|
117
|
+
? {}
|
|
118
|
+
: { _aggregates: AggregateMapForInclude<TDoc, TTableDocMap, TInclude> };
|
|
44
119
|
|
|
45
120
|
export type AppflareResultDoc<
|
|
46
121
|
TDoc,
|
|
@@ -54,7 +129,8 @@ export type AppflareResultDoc<
|
|
|
54
129
|
TTableDocMap
|
|
55
130
|
>,
|
|
56
131
|
SelectedKeys<TDoc, TSelect>
|
|
57
|
-
|
|
132
|
+
> &
|
|
133
|
+
WithAggregatesForInclude<TDoc, TTableDocMap, TInclude>;
|
|
58
134
|
|
|
59
135
|
export type SortDirection = "asc" | "desc";
|
|
60
136
|
|
|
@@ -78,6 +154,11 @@ export type ExtractIdTableName<T> =
|
|
|
78
154
|
? ExtractIdTableName<TItem>
|
|
79
155
|
: never;
|
|
80
156
|
|
|
157
|
+
export type NumericKeys<T> = {
|
|
158
|
+
[K in keyof T]: NonNil<T[K]> extends number | bigint ? K : never;
|
|
159
|
+
}[keyof T] &
|
|
160
|
+
string;
|
|
161
|
+
|
|
81
162
|
export type PopulateValue<
|
|
82
163
|
T,
|
|
83
164
|
TTableDocMap extends Record<string, TableDocBase>,
|
|
@@ -166,6 +247,18 @@ export type MongoDbQuery<
|
|
|
166
247
|
TTableDocMap,
|
|
167
248
|
WithPopulatedMany<TResultDoc, TKeys[number], TTableDocMap>
|
|
168
249
|
>;
|
|
250
|
+
populate<TInclude extends AppflareInclude<TResultDoc, TTableDocMap>>(
|
|
251
|
+
include: TInclude
|
|
252
|
+
): MongoDbQuery<
|
|
253
|
+
TableName,
|
|
254
|
+
TTableDocMap,
|
|
255
|
+
WithPopulatedMany<
|
|
256
|
+
TResultDoc,
|
|
257
|
+
IncludedKeys<TResultDoc, TInclude, TTableDocMap>,
|
|
258
|
+
TTableDocMap
|
|
259
|
+
> &
|
|
260
|
+
WithAggregatesForInclude<TResultDoc, TTableDocMap, TInclude>
|
|
261
|
+
>;
|
|
169
262
|
|
|
170
263
|
find(): Promise<Array<TResultDoc>>;
|
|
171
264
|
findOne(): Promise<TResultDoc | null>;
|
|
@@ -310,6 +403,59 @@ export type AppflareCountArgs<
|
|
|
310
403
|
where?: QueryWhere<TTableDocMap[TableName]>;
|
|
311
404
|
};
|
|
312
405
|
|
|
406
|
+
type AggregateGroupInput<TDoc> =
|
|
407
|
+
| ReadonlyArray<keyof TDoc & string>
|
|
408
|
+
| (keyof TDoc & string);
|
|
409
|
+
|
|
410
|
+
type NormalizeGroupInput<TDoc, TGroup> = TGroup extends readonly (infer K)[]
|
|
411
|
+
? ReadonlyArray<Extract<K, keyof TDoc & string>>
|
|
412
|
+
: TGroup extends string
|
|
413
|
+
? ReadonlyArray<Extract<TGroup, keyof TDoc & string>>
|
|
414
|
+
: ReadonlyArray<never>;
|
|
415
|
+
|
|
416
|
+
type AggregateId<
|
|
417
|
+
TDoc,
|
|
418
|
+
TGroup extends ReadonlyArray<keyof TDoc & string>,
|
|
419
|
+
> = TGroup extends readonly []
|
|
420
|
+
? null
|
|
421
|
+
: TGroup extends readonly [infer K]
|
|
422
|
+
? TDoc[Extract<K, keyof TDoc>]
|
|
423
|
+
: { [K in TGroup[number]]: TDoc[K] };
|
|
424
|
+
|
|
425
|
+
type AggregateResult<
|
|
426
|
+
TDoc,
|
|
427
|
+
TGroup extends ReadonlyArray<keyof TDoc & string>,
|
|
428
|
+
TSum extends ReadonlyArray<NumericKeys<TDoc>>,
|
|
429
|
+
TAvg extends ReadonlyArray<NumericKeys<TDoc>>,
|
|
430
|
+
> = {
|
|
431
|
+
_id: AggregateId<TDoc, TGroup>;
|
|
432
|
+
} & (TSum[number] extends never
|
|
433
|
+
? {}
|
|
434
|
+
: { [K in TSum[number] as `sum_${K}`]: number }) &
|
|
435
|
+
(TAvg[number] extends never
|
|
436
|
+
? {}
|
|
437
|
+
: { [K in TAvg[number] as `avg_${K}`]: number });
|
|
438
|
+
|
|
439
|
+
export type AppflareAggregateArgs<
|
|
440
|
+
TableName extends string,
|
|
441
|
+
TTableDocMap extends Record<TableName, TableDocBase>,
|
|
442
|
+
TGroup = AggregateGroupInput<TTableDocMap[TableName]>,
|
|
443
|
+
TSum extends ReadonlyArray<NumericKeys<TTableDocMap[TableName]>> =
|
|
444
|
+
ReadonlyArray<NumericKeys<TTableDocMap[TableName]>>,
|
|
445
|
+
TAvg extends ReadonlyArray<NumericKeys<TTableDocMap[TableName]>> =
|
|
446
|
+
ReadonlyArray<NumericKeys<TTableDocMap[TableName]>>,
|
|
447
|
+
> = {
|
|
448
|
+
where?: QueryWhere<TTableDocMap[TableName]>;
|
|
449
|
+
groupBy?: TGroup;
|
|
450
|
+
sum?: TSum;
|
|
451
|
+
avg?: TAvg;
|
|
452
|
+
/**
|
|
453
|
+
* Populate aggregated group keys that are references to other tables.
|
|
454
|
+
* Only keys present in groupBy are eligible for populate.
|
|
455
|
+
*/
|
|
456
|
+
populate?: AppflareInclude<TTableDocMap[TableName], TTableDocMap>;
|
|
457
|
+
};
|
|
458
|
+
|
|
313
459
|
export type AppflareTableClient<
|
|
314
460
|
TableName extends string,
|
|
315
461
|
TTableDocMap extends Record<TableName, TableDocBase>,
|
|
@@ -394,6 +540,24 @@ export type AppflareTableClient<
|
|
|
394
540
|
args?: AppflareDeleteManyArgs<TableName, TTableDocMap>
|
|
395
541
|
): Promise<{ count: number }>;
|
|
396
542
|
count(args?: AppflareCountArgs<TableName, TTableDocMap>): Promise<number>;
|
|
543
|
+
aggregate<
|
|
544
|
+
TGroup = AggregateGroupInput<TTableDocMap[TableName]>,
|
|
545
|
+
TSum extends ReadonlyArray<NumericKeys<TTableDocMap[TableName]>> =
|
|
546
|
+
ReadonlyArray<NumericKeys<TTableDocMap[TableName]>>,
|
|
547
|
+
TAvg extends ReadonlyArray<NumericKeys<TTableDocMap[TableName]>> =
|
|
548
|
+
ReadonlyArray<NumericKeys<TTableDocMap[TableName]>>,
|
|
549
|
+
>(
|
|
550
|
+
args: AppflareAggregateArgs<TableName, TTableDocMap, TGroup, TSum, TAvg>
|
|
551
|
+
): Promise<
|
|
552
|
+
Array<
|
|
553
|
+
AggregateResult<
|
|
554
|
+
TTableDocMap[TableName],
|
|
555
|
+
NormalizeGroupInput<TTableDocMap[TableName], TGroup>,
|
|
556
|
+
TSum,
|
|
557
|
+
TAvg
|
|
558
|
+
>
|
|
559
|
+
>
|
|
560
|
+
>;
|
|
397
561
|
};
|
|
398
562
|
|
|
399
563
|
export type AppflareModelMap<
|