@svgrid/enterprise 1.2.0 → 2.0.1
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/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +92 -83
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- package/src/upgrade-prompt.ts +149 -148
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { introspectPrisma, introspectPrismaAll } from './introspect-prisma'
|
|
3
|
+
|
|
4
|
+
const SCHEMA = `
|
|
5
|
+
datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
|
|
6
|
+
generator client { provider = "prisma-client-js" }
|
|
7
|
+
|
|
8
|
+
enum Role {
|
|
9
|
+
ADMIN
|
|
10
|
+
USER
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
model User {
|
|
14
|
+
id Int @id @default(autoincrement())
|
|
15
|
+
email String @unique
|
|
16
|
+
name String?
|
|
17
|
+
role Role @default(USER)
|
|
18
|
+
createdAt DateTime @default(now())
|
|
19
|
+
posts Post[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
model Post {
|
|
23
|
+
id Int @id @default(autoincrement())
|
|
24
|
+
title String
|
|
25
|
+
views Int @default(0)
|
|
26
|
+
author User @relation(fields: [authorId], references: [id])
|
|
27
|
+
authorId Int
|
|
28
|
+
}
|
|
29
|
+
`
|
|
30
|
+
|
|
31
|
+
describe('introspectPrisma', () => {
|
|
32
|
+
it('maps scalar types and marks the @id as the read-only key', () => {
|
|
33
|
+
const s = introspectPrisma(SCHEMA, 'User')
|
|
34
|
+
const byField = Object.fromEntries(s.fields.map((f) => [f.field, f]))
|
|
35
|
+
expect(s.name).toBe('User')
|
|
36
|
+
expect(byField.id!.primaryKey).toBe(true)
|
|
37
|
+
expect(byField.id!.readonly).toBe(true)
|
|
38
|
+
expect(byField.email!.type).toBe('text')
|
|
39
|
+
expect(byField.createdAt!.type).toBe('datetime')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('treats a missing `?` as required and `?` as optional', () => {
|
|
43
|
+
const byField = Object.fromEntries(introspectPrisma(SCHEMA, 'User').fields.map((f) => [f.field, f]))
|
|
44
|
+
expect(byField.email!.required).toBe(true)
|
|
45
|
+
expect(byField.name!.required).toBeUndefined()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('reads an enum field with its options', () => {
|
|
49
|
+
const role = introspectPrisma(SCHEMA, 'User').fields.find((f) => f.field === 'role')!
|
|
50
|
+
expect(role.type).toBe('enum')
|
|
51
|
+
expect(role.options?.map((o) => o.value)).toEqual(['ADMIN', 'USER'])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('turns a @relation FK into a relation field and skips the navigation field', () => {
|
|
55
|
+
const s = introspectPrisma(SCHEMA, 'Post')
|
|
56
|
+
const fieldNames = s.fields.map((f) => f.field)
|
|
57
|
+
expect(fieldNames).toContain('authorId')
|
|
58
|
+
expect(fieldNames).not.toContain('author') // navigation field is virtual
|
|
59
|
+
const rel = s.fields.find((f) => f.field === 'authorId')!
|
|
60
|
+
expect(rel.type).toBe('relation')
|
|
61
|
+
expect(rel.relation?.entity).toBe('User')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('defaults to the first model when none is named', () => {
|
|
65
|
+
expect(introspectPrisma(SCHEMA).name).toBe('User')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('throws when there is no model', () => {
|
|
69
|
+
expect(() => introspectPrisma('generator client {}')).toThrow(/no .*model/)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('composite primary key (join table)', () => {
|
|
74
|
+
const JOIN = `
|
|
75
|
+
model PostTag {
|
|
76
|
+
postId Int
|
|
77
|
+
tagId Int
|
|
78
|
+
post Post @relation(fields: [postId], references: [id])
|
|
79
|
+
tag Tag @relation(fields: [tagId], references: [id])
|
|
80
|
+
@@id([postId, tagId])
|
|
81
|
+
}
|
|
82
|
+
model Post { id Int @id }
|
|
83
|
+
model Tag { id Int @id }
|
|
84
|
+
`
|
|
85
|
+
it('keys off the first @@id member and keeps the other as a relation', () => {
|
|
86
|
+
const s = introspectPrisma(JOIN, 'PostTag')
|
|
87
|
+
expect(s.idField).toBe('postId')
|
|
88
|
+
expect(s.fields.find((f) => f.field === 'tagId')?.type).toBe('relation')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('introspectPrismaAll', () => {
|
|
93
|
+
it('returns every model, linked', () => {
|
|
94
|
+
const all = introspectPrismaAll(SCHEMA)
|
|
95
|
+
expect(all.map((s) => s.name)).toEqual(['User', 'Post'])
|
|
96
|
+
const rel = all.find((s) => s.name === 'Post')!.fields.find((f) => f.field === 'authorId')!
|
|
97
|
+
expect(rel.relation?.entity).toBe('User')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma introspection - turn a `schema.prisma` into `EntitySchema`s, the peer
|
|
3
|
+
* of the Drizzle reader in `introspect.ts`. Same contract: a pure, best-effort
|
|
4
|
+
* text parse (no `@prisma/*` dependency, no code execution), funnelled through
|
|
5
|
+
* the shared `buildEntitySchema` so grid + form come out identical to every
|
|
6
|
+
* other introspection path.
|
|
7
|
+
*
|
|
8
|
+
* - `introspectPrisma(source, model?)` -> one model's schema.
|
|
9
|
+
* - `introspectPrismaAll(source)` -> every model, linked (relations
|
|
10
|
+
* resolve their display field from the related model).
|
|
11
|
+
*
|
|
12
|
+
* Scalar types map to entity field types; `@id` marks the key; a missing `?`
|
|
13
|
+
* means required; `enum` blocks become enum fields; and a `@relation(fields:
|
|
14
|
+
* [fk], references: [id])` navigation turns the scalar `fk` column into a
|
|
15
|
+
* relation field with a searchable lookup.
|
|
16
|
+
*/
|
|
17
|
+
import { linkRelationLabels, type EntityFieldType, type EntitySchema } from '../schema.js'
|
|
18
|
+
import { buildEntitySchema, type IntrospectedColumn } from '../sources/schema-from-columns.js'
|
|
19
|
+
|
|
20
|
+
/** Prisma scalar type -> entity field type. Unknown scalars fall back to text. */
|
|
21
|
+
const PRISMA_TYPE_MAP: Record<string, EntityFieldType> = {
|
|
22
|
+
Int: 'number',
|
|
23
|
+
BigInt: 'number',
|
|
24
|
+
Float: 'number',
|
|
25
|
+
Decimal: 'number',
|
|
26
|
+
String: 'text',
|
|
27
|
+
Boolean: 'boolean',
|
|
28
|
+
DateTime: 'datetime',
|
|
29
|
+
Json: 'json',
|
|
30
|
+
Bytes: 'text',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type PrismaModel = { name: string; body: string }
|
|
34
|
+
type Relations = Map<string, { table: string; column: string }>
|
|
35
|
+
|
|
36
|
+
/** Find each `model X { ... }` / `enum X { ... }` block and return its inner body. */
|
|
37
|
+
function blocks(source: string, keyword: 'model' | 'enum'): Array<{ name: string; body: string }> {
|
|
38
|
+
const out: Array<{ name: string; body: string }> = []
|
|
39
|
+
const re = new RegExp(`${keyword}\\s+(\\w+)\\s*\\{`, 'g')
|
|
40
|
+
let m: RegExpExecArray | null
|
|
41
|
+
while ((m = re.exec(source)) !== null) {
|
|
42
|
+
const open = m.index + m[0].length - 1
|
|
43
|
+
let depth = 0
|
|
44
|
+
for (let i = open; i < source.length; i++) {
|
|
45
|
+
const ch = source[i]
|
|
46
|
+
if (ch === '{') depth++
|
|
47
|
+
else if (ch === '}' && --depth === 0) {
|
|
48
|
+
out.push({ name: m[1]!, body: source.slice(open + 1, i) })
|
|
49
|
+
re.lastIndex = i
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Strip comments and blank lines; return meaningful field/attribute lines. */
|
|
58
|
+
function lines(body: string): string[] {
|
|
59
|
+
return body
|
|
60
|
+
.split('\n')
|
|
61
|
+
.map((l) => l.replace(/\/\/.*$/, '').trim())
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Enum block -> its member names (one identifier per line, `@map` ignored). */
|
|
66
|
+
function enumValues(body: string): string[] {
|
|
67
|
+
return lines(body)
|
|
68
|
+
.map((l) => l.match(/^(\w+)/)?.[1])
|
|
69
|
+
.filter((v): v is string => Boolean(v))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Collect `@relation(fields: [fk], references: [ref])` FKs declared in a model. */
|
|
73
|
+
function relationsOf(modelLines: string[]): Relations {
|
|
74
|
+
const rels: Relations = new Map()
|
|
75
|
+
for (const line of modelLines) {
|
|
76
|
+
const rel = line.match(/@relation\([^)]*fields:\s*\[(\w+)\][^)]*references:\s*\[(\w+)\]/)
|
|
77
|
+
if (!rel) continue
|
|
78
|
+
const type = line.match(/^\w+\s+(\w+)/)?.[1]
|
|
79
|
+
if (type) rels.set(rel[1]!, { table: type, column: rel[2]! })
|
|
80
|
+
}
|
|
81
|
+
return rels
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Field names of a composite key (`@@id([a, b])`), else empty. */
|
|
85
|
+
function compositeKey(modelLines: string[]): string[] {
|
|
86
|
+
for (const line of modelLines) {
|
|
87
|
+
const m = line.match(/^@@id\(\s*\[([^\]]*)\]/)
|
|
88
|
+
if (m) return m[1]!.split(',').map((s) => s.trim()).filter(Boolean)
|
|
89
|
+
}
|
|
90
|
+
return []
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Parse one model body into neutral columns, skipping navigation fields. */
|
|
94
|
+
function columnsFor(model: PrismaModel, modelNames: Set<string>, enums: Map<string, string[]>): IntrospectedColumn[] {
|
|
95
|
+
const modelLines = lines(model.body)
|
|
96
|
+
const relations = relationsOf(modelLines)
|
|
97
|
+
const columns: IntrospectedColumn[] = []
|
|
98
|
+
|
|
99
|
+
for (const line of modelLines) {
|
|
100
|
+
if (line.startsWith('@@')) continue // block attribute (@@id, @@index, @@map, ...)
|
|
101
|
+
const field = line.match(/^(\w+)\s+(\w+)(\[\])?(\?)?(.*)$/)
|
|
102
|
+
if (!field) continue
|
|
103
|
+
const [, name, baseType, list, optional, attrs = ''] = field
|
|
104
|
+
|
|
105
|
+
// A navigation field (points at another model) is virtual - the stored FK
|
|
106
|
+
// scalar column carries the relation. Skip it (its @relation was captured).
|
|
107
|
+
if (modelNames.has(baseType!)) continue
|
|
108
|
+
|
|
109
|
+
const col: IntrospectedColumn = { name: name! }
|
|
110
|
+
if (enums.has(baseType!)) col.enumValues = enums.get(baseType!)
|
|
111
|
+
else col.type = PRISMA_TYPE_MAP[baseType!] ?? 'text'
|
|
112
|
+
|
|
113
|
+
if (/@id\b/.test(attrs)) col.primaryKey = true
|
|
114
|
+
// Required = not optional and not a list.
|
|
115
|
+
if (!optional && !list) col.required = true
|
|
116
|
+
|
|
117
|
+
const ref = relations.get(name!)
|
|
118
|
+
if (ref) col.references = { table: ref.table, column: ref.column }
|
|
119
|
+
|
|
120
|
+
columns.push(col)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Composite key (`@@id([a, b])`): flag the members. buildEntitySchema keys off
|
|
124
|
+
// the first (a join table's screen targets its first key column).
|
|
125
|
+
const composite = compositeKey(modelLines)
|
|
126
|
+
if (composite.length && !columns.some((c) => c.primaryKey)) {
|
|
127
|
+
for (const c of columns) if (composite.includes(c.name)) c.primaryKey = true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return columns
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseModels(source: string): {
|
|
134
|
+
models: PrismaModel[]
|
|
135
|
+
modelNames: Set<string>
|
|
136
|
+
enums: Map<string, string[]>
|
|
137
|
+
} {
|
|
138
|
+
const models = blocks(source, 'model')
|
|
139
|
+
const enums = new Map(blocks(source, 'enum').map((e) => [e.name, enumValues(e.body)]))
|
|
140
|
+
const modelNames = new Set(models.map((m) => m.name))
|
|
141
|
+
return { models, modelNames, enums }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Parse one Prisma model into an `EntitySchema`. Defaults to the first model. */
|
|
145
|
+
export function introspectPrisma(source: string, model?: string): EntitySchema {
|
|
146
|
+
const { models, modelNames, enums } = parseModels(source)
|
|
147
|
+
if (models.length === 0) {
|
|
148
|
+
throw new Error('introspectPrisma: no `model X { ... }` definition found in source')
|
|
149
|
+
}
|
|
150
|
+
const picked = model ? models.find((m) => m.name === model) : models[0]
|
|
151
|
+
if (!picked) throw new Error(`introspectPrisma: model "${model}" not found in source`)
|
|
152
|
+
|
|
153
|
+
const columns = columnsFor(picked, modelNames, enums)
|
|
154
|
+
if (columns.length === 0) {
|
|
155
|
+
throw new Error(`introspectPrisma: parsed model "${picked.name}" but found no fields`)
|
|
156
|
+
}
|
|
157
|
+
return buildEntitySchema(picked.name, columns)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse **every** model in a `schema.prisma` into a linked set of schemas.
|
|
162
|
+
* Foreign keys become relation fields and `linkRelationLabels` resolves each
|
|
163
|
+
* lookup's display field from the related model.
|
|
164
|
+
*/
|
|
165
|
+
export function introspectPrismaAll(source: string): EntitySchema[] {
|
|
166
|
+
const { models, modelNames, enums } = parseModels(source)
|
|
167
|
+
if (models.length === 0) {
|
|
168
|
+
throw new Error('introspectPrisma: no `model X { ... }` definition found in source')
|
|
169
|
+
}
|
|
170
|
+
const schemas = models
|
|
171
|
+
.map((m) => ({ m, columns: columnsFor(m, modelNames, enums) }))
|
|
172
|
+
.filter(({ columns }) => columns.length > 0)
|
|
173
|
+
.map(({ m, columns }) => buildEntitySchema(m.name, columns))
|
|
174
|
+
return linkRelationLabels(schemas)
|
|
175
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { inferType, introspectDrizzle, introspectDrizzleAll, introspectJson } from './introspect'
|
|
3
|
+
|
|
4
|
+
describe('inferType', () => {
|
|
5
|
+
it('classifies runtime values', () => {
|
|
6
|
+
expect(inferType(true)).toBe('boolean')
|
|
7
|
+
expect(inferType(42)).toBe('number')
|
|
8
|
+
expect(inferType('hello')).toBe('text')
|
|
9
|
+
expect(inferType('2026-06-21')).toBe('dateString')
|
|
10
|
+
expect(inferType('2026-06-21T09:30:00Z')).toBe('datetime')
|
|
11
|
+
expect(inferType({ a: 1 })).toBe('json')
|
|
12
|
+
expect(inferType(null)).toBe('text')
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('introspectJson', () => {
|
|
17
|
+
it('unions keys and infers types from the first non-null value', () => {
|
|
18
|
+
const schema = introspectJson('customers', [
|
|
19
|
+
{ id: '1', name: 'Ann', age: 30, active: true },
|
|
20
|
+
{ id: '2', name: 'Bob', age: null, joined: '2026-01-02' },
|
|
21
|
+
])
|
|
22
|
+
const byField = Object.fromEntries(schema.fields.map((f) => [f.field, f]))
|
|
23
|
+
expect(schema.name).toBe('customers')
|
|
24
|
+
expect(byField.name!.type).toBe('text')
|
|
25
|
+
expect(byField.age!.type).toBe('number')
|
|
26
|
+
expect(byField.active!.type).toBe('boolean')
|
|
27
|
+
expect(byField.joined!.type).toBe('dateString')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('marks an `id` field as the read-only primary key', () => {
|
|
31
|
+
const schema = introspectJson('t', [{ id: 'x', v: 1 }])
|
|
32
|
+
const id = schema.fields.find((f) => f.field === 'id')!
|
|
33
|
+
expect(id.primaryKey).toBe(true)
|
|
34
|
+
expect(id.readonly).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('introspectDrizzle', () => {
|
|
39
|
+
const source = `
|
|
40
|
+
import { pgTable, serial, text, integer, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core'
|
|
41
|
+
|
|
42
|
+
export const users = pgTable('users', {
|
|
43
|
+
id: serial('id').primaryKey(),
|
|
44
|
+
name: text('name').notNull(),
|
|
45
|
+
age: integer('age'),
|
|
46
|
+
active: boolean('active').default(false),
|
|
47
|
+
createdAt: timestamp('created_at').defaultNow(),
|
|
48
|
+
meta: jsonb('meta'),
|
|
49
|
+
})
|
|
50
|
+
`
|
|
51
|
+
|
|
52
|
+
it('parses the table name and fields with types', () => {
|
|
53
|
+
const schema = introspectDrizzle(source)
|
|
54
|
+
expect(schema.name).toBe('users')
|
|
55
|
+
const byField = Object.fromEntries(schema.fields.map((f) => [f.field, f]))
|
|
56
|
+
expect(byField.id!.type).toBe('number')
|
|
57
|
+
expect(byField.name!.type).toBe('text')
|
|
58
|
+
expect(byField.age!.type).toBe('number')
|
|
59
|
+
expect(byField.active!.type).toBe('boolean')
|
|
60
|
+
expect(byField.createdAt!.type).toBe('datetime')
|
|
61
|
+
expect(byField.meta!.type).toBe('json')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('reads primaryKey and notNull modifiers', () => {
|
|
65
|
+
const schema = introspectDrizzle(source)
|
|
66
|
+
const byField = Object.fromEntries(schema.fields.map((f) => [f.field, f]))
|
|
67
|
+
expect(byField.id!.primaryKey).toBe(true)
|
|
68
|
+
expect(byField.id!.readonly).toBe(true)
|
|
69
|
+
expect(byField.name!.required).toBe(true)
|
|
70
|
+
expect(byField.age!.required).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('works for sqlite/mysql table builders too', () => {
|
|
74
|
+
const s = introspectDrizzle(`export const t = sqliteTable('todos', { id: integer('id').primaryKey(), title: text('title') })`)
|
|
75
|
+
expect(s.name).toBe('todos')
|
|
76
|
+
expect(s.fields.map((f) => f.field)).toEqual(['id', 'title'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('throws when there is no table definition', () => {
|
|
80
|
+
expect(() => introspectDrizzle('const x = 1')).toThrow(/no .*Table/)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('reads a composite primary key from the table config (join table)', () => {
|
|
84
|
+
const s = introspectDrizzle(
|
|
85
|
+
`
|
|
86
|
+
export const postsToTags = pgTable('posts_to_tags', {
|
|
87
|
+
postId: integer('post_id').references(() => posts.id).notNull(),
|
|
88
|
+
tagId: integer('tag_id').references(() => tags.id).notNull(),
|
|
89
|
+
}, (t) => ({ pk: primaryKey({ columns: [t.postId, t.tagId] }) }))
|
|
90
|
+
`,
|
|
91
|
+
'posts_to_tags',
|
|
92
|
+
)
|
|
93
|
+
expect(s.idField).toBe('postId') // first composite member is the key
|
|
94
|
+
// The other member stays a relation lookup.
|
|
95
|
+
expect(s.fields.find((f) => f.field === 'tagId')?.type).toBe('relation')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('reads an enum column (pgEnum + inline) as an enum field with options', () => {
|
|
99
|
+
const s = introspectDrizzle(`
|
|
100
|
+
export const roleEnum = pgEnum('role', ['admin', 'user'])
|
|
101
|
+
export const members = pgTable('members', {
|
|
102
|
+
id: serial('id').primaryKey(),
|
|
103
|
+
role: roleEnum('role').notNull(),
|
|
104
|
+
status: text('status', { enum: ['active', 'paused'] }),
|
|
105
|
+
})
|
|
106
|
+
`)
|
|
107
|
+
const byField = Object.fromEntries(s.fields.map((f) => [f.field, f]))
|
|
108
|
+
expect(byField.role!.type).toBe('enum')
|
|
109
|
+
expect(byField.role!.options?.map((o) => o.value)).toEqual(['admin', 'user'])
|
|
110
|
+
expect(byField.status!.type).toBe('enum')
|
|
111
|
+
expect(byField.status!.options?.map((o) => o.value)).toEqual(['active', 'paused'])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('captures the DB column name when it differs from the property key', () => {
|
|
115
|
+
const s = introspectDrizzle(`
|
|
116
|
+
export const users = pgTable('users', {
|
|
117
|
+
id: serial('id').primaryKey(),
|
|
118
|
+
createdAt: timestamp('created_at').defaultNow(),
|
|
119
|
+
name: text('name'),
|
|
120
|
+
})
|
|
121
|
+
`)
|
|
122
|
+
const byField = Object.fromEntries(s.fields.map((f) => [f.field, f]))
|
|
123
|
+
expect(byField.createdAt!.dbColumn).toBe('created_at') // renamed -> captured
|
|
124
|
+
expect(byField.name!.dbColumn).toBeUndefined() // same -> omitted
|
|
125
|
+
expect(byField.id!.dbColumn).toBeUndefined()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('turns a `.references()` column into a relation field', () => {
|
|
129
|
+
const s = introspectDrizzle(
|
|
130
|
+
`
|
|
131
|
+
export const users = pgTable('users', { id: serial('id').primaryKey(), name: text('name') })
|
|
132
|
+
export const posts = pgTable('posts', {
|
|
133
|
+
id: serial('id').primaryKey(),
|
|
134
|
+
authorId: integer('author_id').references(() => users.id).notNull(),
|
|
135
|
+
})
|
|
136
|
+
`,
|
|
137
|
+
'posts',
|
|
138
|
+
)
|
|
139
|
+
const author = s.fields.find((f) => f.field === 'authorId')!
|
|
140
|
+
expect(author.type).toBe('relation')
|
|
141
|
+
expect(author.relation?.entity).toBe('users')
|
|
142
|
+
expect(author.required).toBe(true)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('introspectDrizzleAll', () => {
|
|
147
|
+
const source = `
|
|
148
|
+
export const users = pgTable('users', {
|
|
149
|
+
id: serial('id').primaryKey(),
|
|
150
|
+
name: text('name').notNull(),
|
|
151
|
+
})
|
|
152
|
+
export const posts = pgTable('posts', {
|
|
153
|
+
id: serial('id').primaryKey(),
|
|
154
|
+
title: text('title').notNull(),
|
|
155
|
+
authorId: integer('author_id').references(() => users.id),
|
|
156
|
+
})
|
|
157
|
+
`
|
|
158
|
+
|
|
159
|
+
it('returns every table as a schema', () => {
|
|
160
|
+
const all = introspectDrizzleAll(source)
|
|
161
|
+
expect(all.map((s) => s.name)).toEqual(['users', 'posts'])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('links the relation label field from the related table', () => {
|
|
165
|
+
const posts = introspectDrizzleAll(source).find((s) => s.name === 'posts')!
|
|
166
|
+
const rel = posts.fields.find((f) => f.field === 'authorId')!
|
|
167
|
+
expect(rel.type).toBe('relation')
|
|
168
|
+
expect(rel.relation?.entity).toBe('users')
|
|
169
|
+
// `users` has a `name` column, so the lookup shows it.
|
|
170
|
+
expect(rel.relation?.labelField).toBe('name')
|
|
171
|
+
})
|
|
172
|
+
})
|