@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.
@@ -0,0 +1,254 @@
1
+ import { and, arrayContains, arrayOverlaps, asc, desc, eq, gt, gte, ilike, inArray, isNotNull, isNull, like, lt, lte, ne, not, or } from 'drizzle-orm'
2
+ import type { Filter, FilterElement, FilterOperator, FilterValue, FindOptions } from '@modern-admin/core'
3
+ import type { DrizzleProperty } from './property.js'
4
+ import type { DrizzleColumn, DrizzleTable } from './types.js'
5
+
6
+ const isRangeValue = (
7
+ value: FilterValue,
8
+ ): value is { from?: string; to?: string } =>
9
+ typeof value === 'object' && value !== null && !Array.isArray(value)
10
+
11
+ const coerceScalar = (
12
+ value: FilterValue,
13
+ property: DrizzleProperty | null,
14
+ ): unknown => {
15
+ if (value == null || typeof value === 'boolean') return value
16
+ if (Array.isArray(value)) return value.map((v) => coerceScalar(v as FilterValue, property))
17
+ if (typeof value === 'number') return value
18
+ if (typeof value !== 'string') return value
19
+ if (!property) return value
20
+ switch (property.type()) {
21
+ case 'number':
22
+ case 'currency': {
23
+ const n = Number(value)
24
+ return Number.isFinite(n) ? n : value
25
+ }
26
+ case 'float': {
27
+ const n = parseFloat(value)
28
+ return Number.isFinite(n) ? n : value
29
+ }
30
+ case 'boolean':
31
+ return value === 'true' || value === '1'
32
+ case 'date':
33
+ case 'datetime': {
34
+ const d = new Date(value)
35
+ return Number.isNaN(d.getTime()) ? value : d
36
+ }
37
+ default:
38
+ return value
39
+ }
40
+ }
41
+
42
+ /** Return the case-insensitive `like` function appropriate for the dialect. */
43
+ const ciLike = (column: DrizzleColumn) =>
44
+ (column as DrizzleColumn).columnType?.startsWith('Pg') ? ilike : like
45
+
46
+ /**
47
+ * Wrap a value in `%...%` for a LIKE/ILIKE contains match.
48
+ * The `%` characters within the user value are escaped so they match literally.
49
+ */
50
+ const likeContains = (v: string) => `%${v}%`
51
+ const likeStartsWith = (v: string) => `${v}%`
52
+ const likeEndsWith = (v: string) => `%${v}`
53
+
54
+ /**
55
+ * Build a drizzle condition for a single filter element.
56
+ * When an explicit `operator` is set, it takes precedence over legacy implicit
57
+ * behaviour. All string comparisons use `ilike` (Postgres) / `like` (others)
58
+ * for case-insensitive matching.
59
+ */
60
+ const elementToCondition = (
61
+ element: FilterElement,
62
+ table: DrizzleTable,
63
+ ): unknown => {
64
+ const property = element.property as DrizzleProperty | null
65
+ if (!property) return null
66
+ const column = table[element.path] as DrizzleColumn | undefined
67
+ if (!column) return null
68
+ const { value, operator } = element
69
+
70
+ // ── Explicit operator ────────────────────────────────────────────────
71
+ if (operator) {
72
+ return buildOperatorCondition(operator, value, property, column)
73
+ }
74
+
75
+ // ── Legacy implicit behaviour (backward compat) ──────────────────────
76
+ if (Array.isArray(value)) {
77
+ const list = value.map((v) => coerceScalar(v as FilterValue, property)) as unknown[]
78
+ if (!list.length) return null
79
+ if (property.isArray()) return arrayOverlaps(column as never, list as never)
80
+ return inArray(column as never, list as never)
81
+ }
82
+
83
+ if (isRangeValue(value)) {
84
+ const conds: unknown[] = []
85
+ if (value.from !== undefined && value.from !== '') {
86
+ conds.push(gte(column as never, coerceScalar(value.from, property) as never))
87
+ }
88
+ if (value.to !== undefined && value.to !== '') {
89
+ conds.push(lte(column as never, coerceScalar(value.to, property) as never))
90
+ }
91
+ if (!conds.length) return null
92
+ return conds.length === 1 ? conds[0] : and(...(conds as never[]))
93
+ }
94
+
95
+ const coerced = coerceScalar(value, property)
96
+ if (property.isArray()) {
97
+ return arrayContains(column as never, [coerced] as never)
98
+ }
99
+ if (property.type() === 'string' && typeof coerced === 'string') {
100
+ const op = ciLike(column)
101
+ return op(column as never, likeContains(coerced) as never)
102
+ }
103
+ return eq(column as never, coerced as never)
104
+ }
105
+
106
+ /**
107
+ * Translate an explicit FilterOperator to a drizzle SQL condition.
108
+ * String comparisons use `ilike` (PG) / `like` (others).
109
+ */
110
+ const buildOperatorCondition = (
111
+ operator: FilterOperator,
112
+ value: FilterValue,
113
+ property: DrizzleProperty,
114
+ column: DrizzleColumn,
115
+ ): unknown => {
116
+ const isString = property.type() === 'string'
117
+
118
+ switch (operator) {
119
+ case 'eq': {
120
+ const coerced = coerceScalar(value, property)
121
+ if (isString && typeof coerced === 'string') {
122
+ const op = ciLike(column)
123
+ // Exact match via ILIKE without wildcards (case-insensitive equals)
124
+ return op(column as never, coerced as never)
125
+ }
126
+ return eq(column as never, coerced as never)
127
+ }
128
+ case 'neq': {
129
+ const coerced = coerceScalar(value, property)
130
+ if (isString && typeof coerced === 'string') {
131
+ const op = ciLike(column)
132
+ return not(op(column as never, coerced as never))
133
+ }
134
+ return ne(column as never, coerced as never)
135
+ }
136
+ case 'co': {
137
+ const coerced = coerceScalar(value, property)
138
+ if (typeof coerced === 'string') {
139
+ const op = ciLike(column)
140
+ return op(column as never, likeContains(coerced) as never)
141
+ }
142
+ return eq(column as never, coerced as never)
143
+ }
144
+ case 'nco': {
145
+ const coerced = coerceScalar(value, property)
146
+ if (typeof coerced === 'string') {
147
+ const op = ciLike(column)
148
+ return not(op(column as never, likeContains(coerced) as never))
149
+ }
150
+ return ne(column as never, coerced as never)
151
+ }
152
+ case 'sw': {
153
+ const coerced = coerceScalar(value, property)
154
+ if (typeof coerced === 'string') {
155
+ const op = ciLike(column)
156
+ return op(column as never, likeStartsWith(coerced) as never)
157
+ }
158
+ return eq(column as never, coerced as never)
159
+ }
160
+ case 'ew': {
161
+ const coerced = coerceScalar(value, property)
162
+ if (typeof coerced === 'string') {
163
+ const op = ciLike(column)
164
+ return op(column as never, likeEndsWith(coerced) as never)
165
+ }
166
+ return eq(column as never, coerced as never)
167
+ }
168
+ case 'empty':
169
+ return or(
170
+ isNull(column as never),
171
+ eq(column as never, '' as never),
172
+ )
173
+ case 'nempty':
174
+ return and(
175
+ isNotNull(column as never),
176
+ ne(column as never, '' as never),
177
+ )
178
+ case 'in': {
179
+ if (Array.isArray(value)) {
180
+ const list = value.map((v) => coerceScalar(v as FilterValue, property)) as unknown[]
181
+ if (!list.length) return null
182
+ if (property.isArray()) return arrayOverlaps(column as never, list as never)
183
+ return inArray(column as never, list as never)
184
+ }
185
+ const coerced = coerceScalar(value, property)
186
+ return eq(column as never, coerced as never)
187
+ }
188
+ case 'gt': {
189
+ const coerced = coerceScalar(value, property)
190
+ return gt(column as never, coerced as never)
191
+ }
192
+ case 'lt': {
193
+ const coerced = coerceScalar(value, property)
194
+ return lt(column as never, coerced as never)
195
+ }
196
+ case 'between': {
197
+ const str = typeof value === 'string' ? value : ''
198
+ const comma = str.indexOf(',')
199
+ const fromStr = comma >= 0 ? str.slice(0, comma) : str
200
+ const toStr = comma >= 0 ? str.slice(comma + 1) : ''
201
+ const conds: unknown[] = []
202
+ if (fromStr) conds.push(gte(column as never, coerceScalar(fromStr, property) as never))
203
+ if (toStr) conds.push(lte(column as never, coerceScalar(toStr, property) as never))
204
+ if (!conds.length) return null
205
+ return conds.length === 1 ? conds[0] : and(...(conds as never[]))
206
+ }
207
+ default:
208
+ return null
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Convert a core Filter into a drizzle `where` SQL condition. Returns
214
+ * `undefined` when no usable filter was provided so the caller can omit
215
+ * `.where()` entirely.
216
+ */
217
+ export const filterToWhere = (
218
+ filter: Filter,
219
+ table: DrizzleTable,
220
+ ): unknown => {
221
+ const conditions: unknown[] = []
222
+ filter.reduce<null>((_, element) => {
223
+ const cond = elementToCondition(element, table)
224
+ if (cond != null) conditions.push(cond)
225
+ return null
226
+ }, null)
227
+ if (conditions.length === 0) return undefined
228
+ if (conditions.length === 1) return conditions[0]
229
+ return and(...(conditions as never[]))
230
+ }
231
+
232
+ export interface DrizzleFindShape {
233
+ where: unknown
234
+ limit?: number
235
+ offset?: number
236
+ orderBy?: unknown
237
+ }
238
+
239
+ export const findOptionsToDrizzle = (
240
+ options: FindOptions,
241
+ table: DrizzleTable,
242
+ ): Pick<DrizzleFindShape, 'limit' | 'offset' | 'orderBy'> => {
243
+ const out: Pick<DrizzleFindShape, 'limit' | 'offset' | 'orderBy'> = {}
244
+ if (options.limit != null) out.limit = options.limit
245
+ if (options.offset != null) out.offset = options.offset
246
+ const sortBy = options.sort?.sortBy
247
+ if (sortBy) {
248
+ const column = table[sortBy] as DrizzleColumn | undefined
249
+ if (column) {
250
+ out.orderBy = (options.sort?.direction === 'desc' ? desc : asc)(column as never)
251
+ }
252
+ }
253
+ return out
254
+ }
@@ -0,0 +1,65 @@
1
+ import { BaseDatabase } from '@modern-admin/core'
2
+ import { DrizzleResource } from './resource.js'
3
+ import type { DrizzleDatabaseConfig, DrizzleTable } from './types.js'
4
+
5
+ const isDrizzleDatabaseConfig = (db: unknown): db is DrizzleDatabaseConfig =>
6
+ typeof db === 'object' &&
7
+ db !== null &&
8
+ 'client' in db &&
9
+ 'schema' in db &&
10
+ typeof (db as { schema?: object }).schema === 'object'
11
+
12
+ const looksLikeTable = (value: unknown): value is DrizzleTable => {
13
+ if (!value || typeof value !== 'object') return false
14
+ // Drizzle tables expose at least one column with `name` + `dataType`.
15
+ for (const key of Object.keys(value as Record<string, unknown>)) {
16
+ if (key === '_') continue
17
+ const col = (value as Record<string, unknown>)[key]
18
+ if (
19
+ col &&
20
+ typeof col === 'object' &&
21
+ typeof (col as { name?: unknown }).name === 'string' &&
22
+ typeof (col as { dataType?: unknown }).dataType === 'string'
23
+ ) {
24
+ return true
25
+ }
26
+ }
27
+ return false
28
+ }
29
+
30
+ export class DrizzleDatabase extends BaseDatabase {
31
+ public readonly config: DrizzleDatabaseConfig
32
+
33
+ constructor(config: unknown) {
34
+ super(config)
35
+ if (!isDrizzleDatabaseConfig(config)) {
36
+ throw new Error('DrizzleDatabase requires { client, schema } config')
37
+ }
38
+ this.config = config
39
+ }
40
+
41
+ static override isAdapterFor(db: unknown): boolean {
42
+ return isDrizzleDatabaseConfig(db)
43
+ }
44
+
45
+ override resources(): DrizzleResource[] {
46
+ const { client, schema, resources: overrides, dialect } = this.config
47
+ const out: DrizzleResource[] = []
48
+ for (const tableKey of Object.keys(schema)) {
49
+ const table = schema[tableKey]
50
+ if (!looksLikeTable(table)) continue
51
+ const cfg = overrides?.[tableKey] ?? {}
52
+ out.push(
53
+ new DrizzleResource({
54
+ client,
55
+ schema,
56
+ table,
57
+ tableKey,
58
+ dialect: dialect ?? 'pg',
59
+ ...cfg,
60
+ }),
61
+ )
62
+ }
63
+ return out
64
+ }
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ // @modern-admin/adapter-drizzle — Drizzle ORM adapter for @modern-admin/core.
2
+ //
3
+ // Usage:
4
+ // import { drizzle } from 'drizzle-orm/node-postgres'
5
+ // import * as schema from './schema'
6
+ // import { DrizzleDatabase, DrizzleResource } from '@modern-admin/adapter-drizzle'
7
+ //
8
+ // const client = drizzle(pool, { schema })
9
+ // const admin = new ModernAdmin({
10
+ // databases: [{ client, schema }],
11
+ // adapters: [{ Database: DrizzleDatabase, Resource: DrizzleResource }],
12
+ // })
13
+
14
+ export { DrizzleDatabase } from './database.js'
15
+ export { DrizzleResource } from './resource.js'
16
+ export { DrizzleProperty, extractForeignKeys, findPrimaryColumn } from './property.js'
17
+ export { filterToWhere, findOptionsToDrizzle } from './converters.js'
18
+ export type {
19
+ DrizzleClientLike,
20
+ DrizzleColumn,
21
+ DrizzleDatabaseConfig,
22
+ DrizzleDeleteBuilder,
23
+ DrizzleDialect,
24
+ DrizzleInsertBuilder,
25
+ DrizzleQueryBuilder,
26
+ DrizzleResourceConfig,
27
+ DrizzleSchema,
28
+ DrizzleSelectBuilder,
29
+ DrizzleTable,
30
+ DrizzleUpdateBuilder,
31
+ } from './types.js'
@@ -0,0 +1,125 @@
1
+ import { BaseProperty, type PropertyType } from '@modern-admin/core'
2
+ import type { DrizzleColumn, DrizzleTable } from './types.js'
3
+
4
+ const DATA_TYPE_TO_PROPERTY: Readonly<Record<string, PropertyType>> = {
5
+ string: 'string',
6
+ number: 'number',
7
+ bigint: 'number',
8
+ boolean: 'boolean',
9
+ date: 'datetime',
10
+ json: 'json',
11
+ buffer: 'string',
12
+ }
13
+
14
+ const isUuidColumn = (name: string, columnType?: string): boolean => {
15
+ if (columnType && /uuid/i.test(columnType)) return true
16
+ return /(^id$|Id$|_id$|uuid)/i.test(name)
17
+ }
18
+
19
+ /**
20
+ * Property descriptor backed by a Drizzle column. Maps drizzle's runtime
21
+ * dataType / columnType tags onto the core PropertyType taxonomy.
22
+ */
23
+ export class DrizzleProperty extends BaseProperty {
24
+ public readonly column: DrizzleColumn
25
+
26
+ constructor(column: DrizzleColumn, reference: string | null = null, position = 1) {
27
+ const isEnum = (column.enumValues?.length ?? 0) > 0
28
+ const isArray = column.dataType === 'array'
29
+ const type = DrizzleProperty.resolveType(column, isEnum, reference !== null)
30
+ super({
31
+ path: column.name,
32
+ type,
33
+ isId: column.primary === true,
34
+ isArray,
35
+ isSortable: type !== 'json' && type !== 'mixed' && !isArray,
36
+ isRequired:
37
+ column.notNull === true && column.hasDefault !== true && column.primary !== true,
38
+ position,
39
+ reference,
40
+ availableValues: isEnum ? Array.from(column.enumValues!) : null,
41
+ })
42
+ this.column = column
43
+ }
44
+
45
+ private static resolveType(
46
+ column: DrizzleColumn,
47
+ isEnum: boolean,
48
+ isReference: boolean,
49
+ ): PropertyType {
50
+ if (isReference) return 'reference'
51
+ if (isEnum) return 'enum'
52
+ // For PgArray columns, the *element* type lives on `baseColumn`. Surface
53
+ // that as the property type so adapters/UI render the inner kind (e.g.
54
+ // `text[]` shows as `string`).
55
+ const dataType =
56
+ column.dataType === 'array' && column.baseColumn?.dataType
57
+ ? column.baseColumn.dataType
58
+ : column.dataType
59
+ const mapped = DATA_TYPE_TO_PROPERTY[dataType]
60
+ if (mapped) {
61
+ if (mapped === 'string' && column.primary && isUuidColumn(column.name, column.columnType)) {
62
+ return 'uuid'
63
+ }
64
+ return mapped
65
+ }
66
+ return 'mixed'
67
+ }
68
+ }
69
+
70
+ /** Find the primary-key column in a drizzle table. Returns null when absent. */
71
+ export const findPrimaryColumn = (table: DrizzleTable): DrizzleColumn | null => {
72
+ for (const key of Object.keys(table)) {
73
+ if (key === '_') continue
74
+ const col = table[key] as DrizzleColumn | undefined
75
+ if (col && col.primary === true) return col
76
+ }
77
+ return null
78
+ }
79
+
80
+ const FK_SYMBOL_DESC = /InlineForeignKeys$/
81
+ const TABLE_NAME_SYMBOL_DESC = /BaseName$/
82
+
83
+ interface DrizzleFKShape {
84
+ reference: () => {
85
+ columns: Array<{ name: string }>
86
+ foreignTable: object
87
+ }
88
+ }
89
+
90
+ const tableBaseName = (table: object): string | null => {
91
+ for (const sym of Object.getOwnPropertySymbols(table)) {
92
+ if (TABLE_NAME_SYMBOL_DESC.test(sym.description ?? '')) {
93
+ const v = (table as Record<symbol, unknown>)[sym]
94
+ if (typeof v === 'string') return v
95
+ }
96
+ }
97
+ return null
98
+ }
99
+
100
+ /**
101
+ * Walk drizzle's hidden inline-FK symbol on a table and return a map of
102
+ * local column name → foreign table base name. Returns an empty map when
103
+ * the table has no FKs or when the structure differs (driver-specific).
104
+ */
105
+ export const extractForeignKeys = (table: DrizzleTable): Record<string, string> => {
106
+ const out: Record<string, string> = {}
107
+ for (const sym of Object.getOwnPropertySymbols(table)) {
108
+ if (!FK_SYMBOL_DESC.test(sym.description ?? '')) continue
109
+ const fks = (table as Record<symbol, unknown>)[sym]
110
+ if (!Array.isArray(fks)) continue
111
+ for (const fk of fks as DrizzleFKShape[]) {
112
+ try {
113
+ const ref = fk.reference()
114
+ const targetName = tableBaseName(ref.foreignTable)
115
+ if (!targetName) continue
116
+ for (const c of ref.columns) {
117
+ out[c.name] = targetName
118
+ }
119
+ } catch {
120
+ // ignore malformed FKs — keep adapter resilient across drivers.
121
+ }
122
+ }
123
+ }
124
+ return out
125
+ }