@modern-admin/adapter-drizzle 0.1.0
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/dist/converters.d.ts +16 -0
- package/dist/converters.d.ts.map +1 -0
- package/dist/converters.js +225 -0
- package/dist/converters.js.map +1 -0
- package/dist/database.d.ts +10 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +56 -0
- package/dist/database.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/property.d.ts +20 -0
- package/dist/property.d.ts.map +1 -0
- package/dist/property.js +112 -0
- package/dist/property.js.map +1 -0
- package/dist/resource.d.ts +41 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +444 -0
- package/dist/resource.js.map +1 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +48 -0
- package/src/converters.ts +254 -0
- package/src/database.ts +65 -0
- package/src/index.ts +31 -0
- package/src/property.ts +125 -0
- package/src/resource.ts +527 -0
- package/src/types.ts +90 -0
package/src/resource.ts
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { and, asc, count as countFn, eq, gte, ilike, inArray, isNotNull, like, lte, ne, or, sql } from 'drizzle-orm'
|
|
2
|
+
import {
|
|
3
|
+
BaseRecord,
|
|
4
|
+
BaseResource,
|
|
5
|
+
type Filter,
|
|
6
|
+
type FindOptions,
|
|
7
|
+
type ParamsType,
|
|
8
|
+
type TimeSeriesQuery,
|
|
9
|
+
type TimeSeriesResult,
|
|
10
|
+
type TimeSeriesSeries,
|
|
11
|
+
type TimeSeriesStep,
|
|
12
|
+
} from '@modern-admin/core'
|
|
13
|
+
import { DrizzleProperty, extractForeignKeys, findPrimaryColumn } from './property.js'
|
|
14
|
+
import { filterToWhere, findOptionsToDrizzle } from './converters.js'
|
|
15
|
+
import type {
|
|
16
|
+
DrizzleClientLike,
|
|
17
|
+
DrizzleColumn,
|
|
18
|
+
DrizzleDialect,
|
|
19
|
+
DrizzleResourceConfig,
|
|
20
|
+
DrizzleTable,
|
|
21
|
+
} from './types.js'
|
|
22
|
+
|
|
23
|
+
interface DrizzleResourceInit extends DrizzleResourceConfig {
|
|
24
|
+
client: DrizzleClientLike
|
|
25
|
+
table: DrizzleTable
|
|
26
|
+
tableKey: string
|
|
27
|
+
dialect: DrizzleDialect
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isInit = (raw: unknown): raw is DrizzleResourceInit =>
|
|
31
|
+
typeof raw === 'object' &&
|
|
32
|
+
raw !== null &&
|
|
33
|
+
'client' in raw &&
|
|
34
|
+
'table' in raw &&
|
|
35
|
+
typeof (raw as { table?: object }).table === 'object'
|
|
36
|
+
|
|
37
|
+
export class DrizzleResource extends BaseResource {
|
|
38
|
+
public readonly client: DrizzleClientLike
|
|
39
|
+
public readonly table: DrizzleTable
|
|
40
|
+
public readonly tableKey: string
|
|
41
|
+
public readonly dialect: DrizzleDialect
|
|
42
|
+
private readonly _id: string
|
|
43
|
+
private readonly _properties: DrizzleProperty[]
|
|
44
|
+
private readonly idColumn: DrizzleColumn
|
|
45
|
+
|
|
46
|
+
constructor(raw: unknown) {
|
|
47
|
+
super()
|
|
48
|
+
if (!isInit(raw)) {
|
|
49
|
+
throw new Error('DrizzleResource requires { client, table, tableKey } config')
|
|
50
|
+
}
|
|
51
|
+
this.client = raw.client
|
|
52
|
+
this.table = raw.table
|
|
53
|
+
this.tableKey = raw.tableKey
|
|
54
|
+
this.dialect = raw.dialect ?? 'pg'
|
|
55
|
+
this._id = raw.id ?? raw.table._?.name ?? raw.tableKey
|
|
56
|
+
|
|
57
|
+
const idColumn = findPrimaryColumn(raw.table)
|
|
58
|
+
if (!idColumn) {
|
|
59
|
+
throw new Error(`Drizzle table "${this._id}" has no primary-key column`)
|
|
60
|
+
}
|
|
61
|
+
this.idColumn = idColumn
|
|
62
|
+
|
|
63
|
+
const fks = extractForeignKeys(raw.table)
|
|
64
|
+
let position = 1
|
|
65
|
+
this._properties = []
|
|
66
|
+
for (const key of Object.keys(raw.table)) {
|
|
67
|
+
if (key === '_') continue
|
|
68
|
+
const col = raw.table[key] as DrizzleColumn | undefined
|
|
69
|
+
if (!col || typeof col.name !== 'string') continue
|
|
70
|
+
this._properties.push(new DrizzleProperty(col, fks[col.name] ?? null, position++))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static override isAdapterFor(raw: unknown): boolean {
|
|
75
|
+
return isInit(raw)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
override id(): string {
|
|
79
|
+
return this._id
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
override databaseName(): string {
|
|
83
|
+
return this.table._?.name ?? this._id
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override databaseType(): string {
|
|
87
|
+
return 'drizzle'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override properties(): DrizzleProperty[] {
|
|
91
|
+
return this._properties
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
override property(path: string): DrizzleProperty | null {
|
|
95
|
+
return this._properties.find((p) => p.path() === path) ?? null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private castId(id: string | number): unknown {
|
|
99
|
+
if (typeof id === 'number') return id
|
|
100
|
+
if (this.idColumn.dataType === 'number' || this.idColumn.dataType === 'bigint') {
|
|
101
|
+
const n = Number(id)
|
|
102
|
+
return Number.isFinite(n) ? n : id
|
|
103
|
+
}
|
|
104
|
+
return id
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Drop unknown keys so we never write to columns that don't exist. */
|
|
108
|
+
private writableData(params: ParamsType): Record<string, unknown> {
|
|
109
|
+
const out: Record<string, unknown> = {}
|
|
110
|
+
for (const prop of this._properties) {
|
|
111
|
+
const path = prop.path()
|
|
112
|
+
if (path in params) out[path] = params[path]
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
override async distinct(
|
|
118
|
+
field: string,
|
|
119
|
+
options?: { limit?: number; search?: string },
|
|
120
|
+
): Promise<string[]> {
|
|
121
|
+
const column = this.table[field] as DrizzleColumn | undefined
|
|
122
|
+
if (!column) return []
|
|
123
|
+
const prop = this.property(field)
|
|
124
|
+
if (!prop || prop.type() !== 'string') return []
|
|
125
|
+
|
|
126
|
+
const limit = options?.limit ?? 100
|
|
127
|
+
const conds: unknown[] = [
|
|
128
|
+
isNotNull(column as never),
|
|
129
|
+
ne(column as never, '' as never),
|
|
130
|
+
]
|
|
131
|
+
if (options?.search) {
|
|
132
|
+
const op = (column as DrizzleColumn).columnType?.startsWith('Pg') ? ilike : like
|
|
133
|
+
conds.push(op(column as never, `%${options.search}%` as never))
|
|
134
|
+
}
|
|
135
|
+
const where = and(...(conds as never[]))
|
|
136
|
+
|
|
137
|
+
// Use select + groupBy for DISTINCT — avoids requiring selectDistinct
|
|
138
|
+
// on the client interface while producing the same SQL result.
|
|
139
|
+
const rows = (await this.client
|
|
140
|
+
.select({ value: column })
|
|
141
|
+
.from(this.table)
|
|
142
|
+
.where(where as unknown)
|
|
143
|
+
.groupBy(column as never)
|
|
144
|
+
.orderBy(asc(column as never))
|
|
145
|
+
.limit(limit)) as Array<{ value: unknown }>
|
|
146
|
+
|
|
147
|
+
return rows
|
|
148
|
+
.map((r) => r.value)
|
|
149
|
+
.filter((v): v is string => typeof v === 'string')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
override async count(filter: Filter): Promise<number> {
|
|
153
|
+
const where = filterToWhere(filter, this.table)
|
|
154
|
+
let qb = this.client.select({ value: countFn() }).from(this.table)
|
|
155
|
+
if (where !== undefined) qb = qb.where(where)
|
|
156
|
+
const rows = (await qb) as Array<{ value: number | string }>
|
|
157
|
+
const v = rows[0]?.value ?? 0
|
|
158
|
+
return typeof v === 'number' ? v : Number(v)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
override async find(filter: Filter, options: FindOptions): Promise<BaseRecord[]> {
|
|
162
|
+
const where = filterToWhere(filter, this.table)
|
|
163
|
+
const { limit, offset, orderBy } = findOptionsToDrizzle(options, this.table)
|
|
164
|
+
let qb = this.client.select().from(this.table)
|
|
165
|
+
if (where !== undefined) qb = qb.where(where)
|
|
166
|
+
if (orderBy !== undefined) qb = qb.orderBy(orderBy)
|
|
167
|
+
if (limit !== undefined) qb = qb.limit(limit)
|
|
168
|
+
if (offset !== undefined) qb = qb.offset(offset)
|
|
169
|
+
const rows = (await qb) as ParamsType[]
|
|
170
|
+
return rows.map((row) => new BaseRecord(row, this))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
override async search(
|
|
174
|
+
query: string,
|
|
175
|
+
fields: string[],
|
|
176
|
+
options?: { limit?: number },
|
|
177
|
+
): Promise<BaseRecord[]> {
|
|
178
|
+
const limit = options?.limit ?? 50
|
|
179
|
+
if (!query || fields.length === 0) return []
|
|
180
|
+
// Build per-field substring conditions. Only string-typed columns are
|
|
181
|
+
// valid for `ilike`/`like`; skip the rest defensively so a stray
|
|
182
|
+
// property path can't crash the query.
|
|
183
|
+
const isPg = this.dialect === 'pg'
|
|
184
|
+
const conds: unknown[] = []
|
|
185
|
+
for (const field of fields) {
|
|
186
|
+
const col = this.table[field] as DrizzleColumn | undefined
|
|
187
|
+
if (!col) continue
|
|
188
|
+
const prop = this.property(field)
|
|
189
|
+
if (!prop || prop.type() !== 'string') continue
|
|
190
|
+
const op = isPg ? ilike : like
|
|
191
|
+
conds.push(op(col as never, `%${query}%` as never))
|
|
192
|
+
}
|
|
193
|
+
if (conds.length === 0) return []
|
|
194
|
+
const where =
|
|
195
|
+
conds.length === 1 ? conds[0] : or(...(conds as never[]))
|
|
196
|
+
const rows = (await this.client
|
|
197
|
+
.select()
|
|
198
|
+
.from(this.table)
|
|
199
|
+
.where(where as unknown)
|
|
200
|
+
.limit(limit)) as ParamsType[]
|
|
201
|
+
return rows.map((row) => new BaseRecord(row, this))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
override async findOne(id: string): Promise<BaseRecord | null> {
|
|
205
|
+
const rows = (await this.client
|
|
206
|
+
.select()
|
|
207
|
+
.from(this.table)
|
|
208
|
+
.where(eq(this.idColumn as never, this.castId(id) as never))
|
|
209
|
+
.limit(1)) as ParamsType[]
|
|
210
|
+
const row = rows[0]
|
|
211
|
+
return row ? new BaseRecord(row, this) : null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
override async findMany(ids: Array<string | number>): Promise<BaseRecord[]> {
|
|
215
|
+
if (ids.length === 0) return []
|
|
216
|
+
const cast = ids.map((id) => this.castId(id))
|
|
217
|
+
const rows = (await this.client
|
|
218
|
+
.select()
|
|
219
|
+
.from(this.table)
|
|
220
|
+
.where(inArray(this.idColumn as never, cast as never))) as ParamsType[]
|
|
221
|
+
return rows.map((row) => new BaseRecord(row, this))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
override async create(params: ParamsType): Promise<ParamsType> {
|
|
225
|
+
const rows = await this.client
|
|
226
|
+
.insert(this.table)
|
|
227
|
+
.values(this.writableData(params))
|
|
228
|
+
.returning()
|
|
229
|
+
return (rows[0] ?? {}) as ParamsType
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
override async update(id: string, params: ParamsType): Promise<ParamsType> {
|
|
233
|
+
const rows = await this.client
|
|
234
|
+
.update(this.table)
|
|
235
|
+
.set(this.writableData(params))
|
|
236
|
+
.where(eq(this.idColumn as never, this.castId(id) as never))
|
|
237
|
+
.returning()
|
|
238
|
+
return (rows[0] ?? {}) as ParamsType
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
override async delete(id: string): Promise<void> {
|
|
242
|
+
await this.client
|
|
243
|
+
.delete(this.table)
|
|
244
|
+
.where(eq(this.idColumn as never, this.castId(id) as never))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
override supportsTimeSeries(): boolean {
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
override async aggregateTimeSeries(
|
|
252
|
+
filter: Filter,
|
|
253
|
+
query: TimeSeriesQuery,
|
|
254
|
+
): Promise<TimeSeriesResult> {
|
|
255
|
+
const series = await this.runTimeSeriesQuery(filter, query)
|
|
256
|
+
let previous: TimeSeriesSeries[] | undefined
|
|
257
|
+
if (query.comparePrevious) {
|
|
258
|
+
const span = query.to.getTime() - query.from.getTime()
|
|
259
|
+
const prevTo = new Date(query.from.getTime())
|
|
260
|
+
const prevFrom = new Date(query.from.getTime() - span)
|
|
261
|
+
previous = (
|
|
262
|
+
await this.runTimeSeriesQuery(filter, { ...query, from: prevFrom, to: prevTo })
|
|
263
|
+
).series
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
series: series.series,
|
|
267
|
+
...(previous ? { previous } : {}),
|
|
268
|
+
sql: series.sql,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async runTimeSeriesQuery(
|
|
273
|
+
filter: Filter,
|
|
274
|
+
query: TimeSeriesQuery,
|
|
275
|
+
): Promise<{ series: TimeSeriesSeries[]; sql: string }> {
|
|
276
|
+
const dateCol = this.table[query.dateField] as DrizzleColumn | undefined
|
|
277
|
+
if (!dateCol) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`aggregateTimeSeries: dateField "${query.dateField}" not found on resource "${this._id}"`,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
let fieldCol: DrizzleColumn | undefined
|
|
283
|
+
if (query.metric !== 'count') {
|
|
284
|
+
if (!query.field) {
|
|
285
|
+
throw new Error(`aggregateTimeSeries: metric "${query.metric}" requires "field"`)
|
|
286
|
+
}
|
|
287
|
+
fieldCol = this.table[query.field] as DrizzleColumn | undefined
|
|
288
|
+
if (!fieldCol) {
|
|
289
|
+
throw new Error(`aggregateTimeSeries: field "${query.field}" not found`)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
let groupCol: DrizzleColumn | undefined
|
|
293
|
+
if (query.groupBy) {
|
|
294
|
+
groupCol = this.table[query.groupBy] as DrizzleColumn | undefined
|
|
295
|
+
if (!groupCol) {
|
|
296
|
+
throw new Error(`aggregateTimeSeries: groupBy "${query.groupBy}" not found`)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const bucketSql = bucketExpr(this.dialect, query.step, dateCol)
|
|
301
|
+
const metricSql = metricExpr(query.metric, fieldCol)
|
|
302
|
+
|
|
303
|
+
// WHERE = date range + user filters.
|
|
304
|
+
const conds: unknown[] = [
|
|
305
|
+
gte(dateCol as never, query.from as never),
|
|
306
|
+
lte(dateCol as never, query.to as never),
|
|
307
|
+
]
|
|
308
|
+
const filterWhere = filterToWhere(filter, this.table)
|
|
309
|
+
if (filterWhere !== undefined) conds.push(filterWhere)
|
|
310
|
+
const where = conds.length === 1 ? conds[0] : and(...(conds as never[]))
|
|
311
|
+
|
|
312
|
+
type Row = { bucket?: unknown; value: unknown; series_key?: unknown }
|
|
313
|
+
const select: Record<string, unknown> = { value: metricSql }
|
|
314
|
+
if (query.step !== 'all') select.bucket = bucketSql
|
|
315
|
+
if (groupCol) select.series_key = groupCol
|
|
316
|
+
|
|
317
|
+
let qb = this.client.select(select).from(this.table).where(where as unknown)
|
|
318
|
+
const groupKeys: unknown[] = []
|
|
319
|
+
if (query.step !== 'all') groupKeys.push(bucketSql)
|
|
320
|
+
if (groupCol) groupKeys.push(groupCol)
|
|
321
|
+
if (groupKeys.length) qb = qb.groupBy(...groupKeys)
|
|
322
|
+
if (query.step !== 'all') qb = qb.orderBy(asc(bucketSql as never))
|
|
323
|
+
|
|
324
|
+
const rows = (await qb) as Row[]
|
|
325
|
+
|
|
326
|
+
// Bucket → series_key → numeric value.
|
|
327
|
+
const fromIso = isoDate(query.from)
|
|
328
|
+
const seriesMap = new Map<string, Map<string, number>>()
|
|
329
|
+
for (const row of rows) {
|
|
330
|
+
const seriesKey = groupCol ? stringifyKey(row.series_key) : '__total__'
|
|
331
|
+
const bucketKey =
|
|
332
|
+
query.step === 'all' ? fromIso : isoDate(toDate(row.bucket))
|
|
333
|
+
const num = toNumber(row.value)
|
|
334
|
+
let inner = seriesMap.get(seriesKey)
|
|
335
|
+
if (!inner) {
|
|
336
|
+
inner = new Map()
|
|
337
|
+
seriesMap.set(seriesKey, inner)
|
|
338
|
+
}
|
|
339
|
+
inner.set(bucketKey, num)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Top-N truncation: rank series by total, keep top N, collapse rest into '__other__'.
|
|
343
|
+
const topN = query.topN ?? 10
|
|
344
|
+
const totals = Array.from(seriesMap.entries()).map(
|
|
345
|
+
([k, m]) => [k, sumValues(m)] as const,
|
|
346
|
+
)
|
|
347
|
+
totals.sort((a, b) => b[1] - a[1])
|
|
348
|
+
const keep = new Set(totals.slice(0, topN).map(([k]) => k))
|
|
349
|
+
const otherInner = new Map<string, number>()
|
|
350
|
+
for (const [key, inner] of seriesMap) {
|
|
351
|
+
if (keep.has(key)) continue
|
|
352
|
+
for (const [bucket, val] of inner) {
|
|
353
|
+
otherInner.set(bucket, (otherInner.get(bucket) ?? 0) + val)
|
|
354
|
+
}
|
|
355
|
+
seriesMap.delete(key)
|
|
356
|
+
}
|
|
357
|
+
if (otherInner.size > 0) {
|
|
358
|
+
seriesMap.set('__other__', otherInner)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const seriesOut: TimeSeriesSeries[] = []
|
|
362
|
+
for (const [key, inner] of seriesMap) {
|
|
363
|
+
const points = Array.from(inner.entries())
|
|
364
|
+
.map(([date, value]) => ({ date, value }))
|
|
365
|
+
.sort((a, b) => a.date.localeCompare(b.date))
|
|
366
|
+
seriesOut.push({ key, points })
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
series: seriesOut,
|
|
371
|
+
sql: buildDisplaySql(this.dialect, this.databaseName(), query, filter),
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
override async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
376
|
+
if (typeof this.client.transaction !== 'function') return fn()
|
|
377
|
+
return this.client.transaction(async () => fn())
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ─── Time-series helpers ─────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
const bucketExpr = (
|
|
384
|
+
dialect: DrizzleDialect,
|
|
385
|
+
step: TimeSeriesStep,
|
|
386
|
+
dateCol: DrizzleColumn,
|
|
387
|
+
): unknown => {
|
|
388
|
+
if (step === 'all') return sql`MIN(${dateCol})`
|
|
389
|
+
if (dialect === 'pg') {
|
|
390
|
+
return sql`DATE_TRUNC(${step}, ${dateCol})`
|
|
391
|
+
}
|
|
392
|
+
if (dialect === 'mysql') {
|
|
393
|
+
const fmt =
|
|
394
|
+
step === 'day'
|
|
395
|
+
? '%Y-%m-%d'
|
|
396
|
+
: step === 'week'
|
|
397
|
+
? '%x-W%v'
|
|
398
|
+
: step === 'month'
|
|
399
|
+
? '%Y-%m-01'
|
|
400
|
+
: '%Y-01-01'
|
|
401
|
+
return sql`DATE_FORMAT(${dateCol}, ${fmt})`
|
|
402
|
+
}
|
|
403
|
+
// sqlite
|
|
404
|
+
const fmt =
|
|
405
|
+
step === 'day'
|
|
406
|
+
? '%Y-%m-%d'
|
|
407
|
+
: step === 'week'
|
|
408
|
+
? '%Y-W%W'
|
|
409
|
+
: step === 'month'
|
|
410
|
+
? '%Y-%m-01'
|
|
411
|
+
: '%Y-01-01'
|
|
412
|
+
return sql`STRFTIME(${fmt}, ${dateCol})`
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const metricExpr = (
|
|
416
|
+
op: TimeSeriesQuery['metric'],
|
|
417
|
+
fieldCol: DrizzleColumn | undefined,
|
|
418
|
+
): unknown => {
|
|
419
|
+
if (op === 'count') return sql`COUNT(*)`
|
|
420
|
+
if (!fieldCol) {
|
|
421
|
+
throw new Error(`metric "${op}" requires field`)
|
|
422
|
+
}
|
|
423
|
+
switch (op) {
|
|
424
|
+
case 'sum':
|
|
425
|
+
return sql`SUM(${fieldCol})`
|
|
426
|
+
case 'avg':
|
|
427
|
+
return sql`AVG(${fieldCol})`
|
|
428
|
+
case 'min':
|
|
429
|
+
return sql`MIN(${fieldCol})`
|
|
430
|
+
case 'max':
|
|
431
|
+
return sql`MAX(${fieldCol})`
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const isoDate = (d: Date): string => d.toISOString().slice(0, 10)
|
|
436
|
+
|
|
437
|
+
const toDate = (v: unknown): Date => {
|
|
438
|
+
if (v instanceof Date) return v
|
|
439
|
+
if (typeof v === 'string' || typeof v === 'number') return new Date(v)
|
|
440
|
+
return new Date(String(v))
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const toNumber = (v: unknown): number => {
|
|
444
|
+
if (typeof v === 'number') return v
|
|
445
|
+
if (v == null) return 0
|
|
446
|
+
const n = Number(v)
|
|
447
|
+
return Number.isFinite(n) ? n : 0
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const stringifyKey = (v: unknown): string => {
|
|
451
|
+
if (v == null) return '__null__'
|
|
452
|
+
if (typeof v === 'string') return v
|
|
453
|
+
return String(v)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const sumValues = (m: Map<string, number>): number => {
|
|
457
|
+
let s = 0
|
|
458
|
+
for (const v of m.values()) s += v
|
|
459
|
+
return s
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const buildDisplaySql = (
|
|
463
|
+
dialect: DrizzleDialect,
|
|
464
|
+
tableName: string,
|
|
465
|
+
query: TimeSeriesQuery,
|
|
466
|
+
filter: Filter,
|
|
467
|
+
): string => {
|
|
468
|
+
const ident = (s: string) => (dialect === 'mysql' ? `\`${s}\`` : `"${s}"`)
|
|
469
|
+
const t = ident(tableName)
|
|
470
|
+
const dateCol = ident(query.dateField)
|
|
471
|
+
const bucket =
|
|
472
|
+
query.step === 'all'
|
|
473
|
+
? `MIN(${dateCol})`
|
|
474
|
+
: dialect === 'pg'
|
|
475
|
+
? `DATE_TRUNC('${query.step}', ${dateCol})`
|
|
476
|
+
: dialect === 'mysql'
|
|
477
|
+
? `DATE_FORMAT(${dateCol}, ${mysqlFmt(query.step)})`
|
|
478
|
+
: `STRFTIME(${sqliteFmt(query.step)}, ${dateCol})`
|
|
479
|
+
const metric =
|
|
480
|
+
query.metric === 'count'
|
|
481
|
+
? 'COUNT(*)'
|
|
482
|
+
: `${query.metric.toUpperCase()}(${ident(query.field as string)})`
|
|
483
|
+
const cols: string[] = []
|
|
484
|
+
if (query.step !== 'all') cols.push(`${bucket} AS bucket`)
|
|
485
|
+
cols.push(`${metric} AS value`)
|
|
486
|
+
if (query.groupBy) cols.push(`${ident(query.groupBy)} AS series_key`)
|
|
487
|
+
|
|
488
|
+
const where: string[] = [
|
|
489
|
+
`${dateCol} >= '${query.from.toISOString()}'`,
|
|
490
|
+
`${dateCol} <= '${query.to.toISOString()}'`,
|
|
491
|
+
]
|
|
492
|
+
filter.reduce<null>((_, el) => {
|
|
493
|
+
where.push(`${ident(el.path)} = '${String(el.value)}'`)
|
|
494
|
+
return null
|
|
495
|
+
}, null)
|
|
496
|
+
|
|
497
|
+
const groupBy: string[] = []
|
|
498
|
+
if (query.step !== 'all') groupBy.push('bucket')
|
|
499
|
+
if (query.groupBy) groupBy.push('series_key')
|
|
500
|
+
|
|
501
|
+
const lines = [
|
|
502
|
+
`SELECT ${cols.join(', ')}`,
|
|
503
|
+
`FROM ${t}`,
|
|
504
|
+
`WHERE ${where.join(' AND ')}`,
|
|
505
|
+
]
|
|
506
|
+
if (groupBy.length) lines.push(`GROUP BY ${groupBy.join(', ')}`)
|
|
507
|
+
if (query.step !== 'all') lines.push('ORDER BY bucket ASC')
|
|
508
|
+
return lines.join('\n')
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const mysqlFmt = (step: TimeSeriesStep): string =>
|
|
512
|
+
step === 'day'
|
|
513
|
+
? "'%Y-%m-%d'"
|
|
514
|
+
: step === 'week'
|
|
515
|
+
? "'%x-W%v'"
|
|
516
|
+
: step === 'month'
|
|
517
|
+
? "'%Y-%m-01'"
|
|
518
|
+
: "'%Y-01-01'"
|
|
519
|
+
|
|
520
|
+
const sqliteFmt = (step: TimeSeriesStep): string =>
|
|
521
|
+
step === 'day'
|
|
522
|
+
? "'%Y-%m-%d'"
|
|
523
|
+
: step === 'week'
|
|
524
|
+
? "'%Y-W%W'"
|
|
525
|
+
: step === 'month'
|
|
526
|
+
? "'%Y-%m-01'"
|
|
527
|
+
: "'%Y-01-01'"
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Structural types matching the surface of `drizzle-orm` we depend on.
|
|
2
|
+
// Importing the real types breaks for consumers who use a different DB driver
|
|
3
|
+
// (pg, mysql, sqlite) since each has its own column/table classes — keeping
|
|
4
|
+
// this duck-typed lets the adapter work uniformly across drivers.
|
|
5
|
+
|
|
6
|
+
export interface DrizzleColumn {
|
|
7
|
+
name: string
|
|
8
|
+
/** drizzle's runtime data tag — 'string', 'number', 'boolean', 'date', 'json', 'bigint', 'buffer', 'array'. */
|
|
9
|
+
dataType: string
|
|
10
|
+
/** specific column kind such as 'PgUUID', 'PgEnum', 'PgText', 'PgArray'. Optional. */
|
|
11
|
+
columnType?: string
|
|
12
|
+
primary?: boolean
|
|
13
|
+
notNull?: boolean
|
|
14
|
+
hasDefault?: boolean
|
|
15
|
+
enumValues?: readonly string[]
|
|
16
|
+
/** For PgArray columns — points to the inner element column. */
|
|
17
|
+
baseColumn?: DrizzleColumn
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type DrizzleTable = Record<string, DrizzleColumn> & {
|
|
21
|
+
/** drizzle's hidden table metadata bag. */
|
|
22
|
+
_?: { name?: string }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DrizzleQueryBuilder<T> {
|
|
26
|
+
where(condition: unknown): DrizzleQueryBuilder<T>
|
|
27
|
+
orderBy(...columns: unknown[]): DrizzleQueryBuilder<T>
|
|
28
|
+
groupBy(...columns: unknown[]): DrizzleQueryBuilder<T>
|
|
29
|
+
limit(n: number): DrizzleQueryBuilder<T>
|
|
30
|
+
offset(n: number): DrizzleQueryBuilder<T>
|
|
31
|
+
// The library returns a thenable at the end of the chain.
|
|
32
|
+
then<U>(onfulfilled?: (value: T[]) => U | PromiseLike<U>): Promise<U>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DrizzleSelectBuilder {
|
|
36
|
+
from(table: DrizzleTable): DrizzleQueryBuilder<Record<string, unknown>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DrizzleInsertBuilder {
|
|
40
|
+
values(value: Record<string, unknown>): {
|
|
41
|
+
returning(): Promise<Array<Record<string, unknown>>>
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DrizzleUpdateBuilder {
|
|
46
|
+
set(values: Record<string, unknown>): {
|
|
47
|
+
where(condition: unknown): {
|
|
48
|
+
returning(): Promise<Array<Record<string, unknown>>>
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DrizzleDeleteBuilder {
|
|
54
|
+
where(condition: unknown): Promise<unknown>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DrizzleClientLike {
|
|
58
|
+
select(): DrizzleSelectBuilder
|
|
59
|
+
select(fields: Record<string, unknown>): DrizzleSelectBuilder
|
|
60
|
+
insert(table: DrizzleTable): DrizzleInsertBuilder
|
|
61
|
+
update(table: DrizzleTable): DrizzleUpdateBuilder
|
|
62
|
+
delete(table: DrizzleTable): DrizzleDeleteBuilder
|
|
63
|
+
transaction?<T>(fn: (tx: DrizzleClientLike) => Promise<T>): Promise<T>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DrizzleSchema {
|
|
67
|
+
[tableName: string]: DrizzleTable
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type DrizzleDialect = 'pg' | 'mysql' | 'sqlite'
|
|
71
|
+
|
|
72
|
+
export interface DrizzleResourceConfig {
|
|
73
|
+
/** Override the resource id (defaults to drizzle's table name). */
|
|
74
|
+
id?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DrizzleDatabaseConfig {
|
|
78
|
+
/** drizzle client, e.g. `drizzle(pool, { schema })`. */
|
|
79
|
+
client: DrizzleClientLike
|
|
80
|
+
/** drizzle schema (object exporting tables). */
|
|
81
|
+
schema: DrizzleSchema
|
|
82
|
+
/**
|
|
83
|
+
* Database dialect. Required to build dialect-specific SQL for
|
|
84
|
+
* `aggregateTimeSeries` (DATE_TRUNC vs DATE_FORMAT vs strftime).
|
|
85
|
+
* Defaults to `'pg'` when omitted.
|
|
86
|
+
*/
|
|
87
|
+
dialect?: DrizzleDialect
|
|
88
|
+
/** Optional per-table overrides. */
|
|
89
|
+
resources?: Record<string, DrizzleResourceConfig>
|
|
90
|
+
}
|