@svgrid/enterprise 1.2.0 → 2.0.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/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 +10 -3
- 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,187 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { EntitySchema } from '../schema'
|
|
3
|
+
import { resolveSchema, resolveSchemas, runStudioAdd, runStudioAddApp, type StudioIO } from './cli'
|
|
4
|
+
import { MANAGED_END, MANAGED_START } from './scaffold'
|
|
5
|
+
|
|
6
|
+
/** In-memory StudioIO for tests. */
|
|
7
|
+
function memIO(seed: Record<string, string> = {}) {
|
|
8
|
+
const files = new Map<string, string>(Object.entries(seed))
|
|
9
|
+
const io: StudioIO = {
|
|
10
|
+
readFile: async (p) => (files.has(p) ? files.get(p)! : null),
|
|
11
|
+
writeFile: async (p, c) => {
|
|
12
|
+
files.set(p, c)
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
return { io, files }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const drizzle = `
|
|
19
|
+
import { pgTable, serial, text, integer, boolean } from 'drizzle-orm/pg-core'
|
|
20
|
+
export const users = pgTable('users', {
|
|
21
|
+
id: serial('id').primaryKey(),
|
|
22
|
+
name: text('name').notNull(),
|
|
23
|
+
active: boolean('active'),
|
|
24
|
+
})
|
|
25
|
+
export const orders = pgTable('orders', {
|
|
26
|
+
id: serial('id').primaryKey(),
|
|
27
|
+
total: integer('total'),
|
|
28
|
+
})
|
|
29
|
+
`
|
|
30
|
+
|
|
31
|
+
describe('runStudioAdd', () => {
|
|
32
|
+
it('introspects a Drizzle file, writes the 3 files, and verifies', async () => {
|
|
33
|
+
const { io, files } = memIO({ 'src/lib/db/schema.ts': drizzle })
|
|
34
|
+
const res = await runStudioAdd({ from: 'src/lib/db/schema.ts' }, io)
|
|
35
|
+
|
|
36
|
+
expect(res.schema.name).toBe('users') // first table
|
|
37
|
+
expect(res.written).toEqual([
|
|
38
|
+
'src/lib/users.schema.ts',
|
|
39
|
+
'src/routes/api/users/+server.ts',
|
|
40
|
+
'src/routes/users/+page.svelte',
|
|
41
|
+
])
|
|
42
|
+
expect(res.verify.ok).toBe(true) // generated page compiles
|
|
43
|
+
expect(files.get('src/routes/users/+page.svelte')).toContain('<SvGrid')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('selects a specific table with `table`', async () => {
|
|
47
|
+
const { io } = memIO({ 'schema.ts': drizzle })
|
|
48
|
+
const res = await runStudioAdd({ from: 'schema.ts', table: 'orders' }, io)
|
|
49
|
+
expect(res.schema.name).toBe('orders')
|
|
50
|
+
expect(res.schema.fields.map((f) => f.field)).toEqual(['id', 'total'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('scaffolds a real SQL data source when dataSource:"sql"', async () => {
|
|
54
|
+
const { io, files } = memIO({ 'schema.ts': drizzle })
|
|
55
|
+
await runStudioAdd({ from: 'schema.ts', dataSource: 'sql' }, io)
|
|
56
|
+
const server = files.get('src/routes/api/users/+server.ts')!
|
|
57
|
+
expect(server).toContain('createSqlDataSource')
|
|
58
|
+
expect(server).toContain("table: \"users\"")
|
|
59
|
+
expect(server).toContain('execute:')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('regenerating preserves user edits outside the managed region', async () => {
|
|
63
|
+
const { io, files } = memIO({ 'schema.ts': drizzle })
|
|
64
|
+
await runStudioAdd({ from: 'schema.ts' }, io)
|
|
65
|
+
|
|
66
|
+
// User hand-edits the page outside the managed block.
|
|
67
|
+
const page = files.get('src/routes/users/+page.svelte')!
|
|
68
|
+
const edited = page.replace('<div class="page">', '<div class="page my-custom-class">')
|
|
69
|
+
files.set('src/routes/users/+page.svelte', edited)
|
|
70
|
+
|
|
71
|
+
// Regenerate.
|
|
72
|
+
await runStudioAdd({ from: 'schema.ts' }, io)
|
|
73
|
+
const after = files.get('src/routes/users/+page.svelte')!
|
|
74
|
+
expect(after).toContain('my-custom-class') // user edit survived
|
|
75
|
+
expect(after).toContain(MANAGED_START)
|
|
76
|
+
expect(after).toContain(MANAGED_END)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('throws a helpful error when no input is given', async () => {
|
|
80
|
+
const { io } = memIO()
|
|
81
|
+
await expect(runStudioAdd({}, io)).rejects.toThrow(/provide `from`/)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('accepts a ready EntitySchema directly', async () => {
|
|
85
|
+
const schema: EntitySchema = {
|
|
86
|
+
name: 'tags',
|
|
87
|
+
idField: 'id',
|
|
88
|
+
fields: [{ field: 'id', type: 'text', primaryKey: true }, { field: 'label', type: 'text' }],
|
|
89
|
+
}
|
|
90
|
+
const { io } = memIO()
|
|
91
|
+
const res = await runStudioAdd({ schema }, io)
|
|
92
|
+
expect(res.written).toHaveLength(3)
|
|
93
|
+
expect(res.verify.ok).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('runStudioAddApp', () => {
|
|
98
|
+
const companies: EntitySchema = {
|
|
99
|
+
name: 'companies', idField: 'id',
|
|
100
|
+
fields: [{ field: 'id', type: 'number', primaryKey: true }, { field: 'name', type: 'text' }],
|
|
101
|
+
}
|
|
102
|
+
const contacts: EntitySchema = {
|
|
103
|
+
name: 'contacts', idField: 'id',
|
|
104
|
+
fields: [
|
|
105
|
+
{ field: 'id', type: 'number', primaryKey: true },
|
|
106
|
+
{ field: 'name', type: 'text' },
|
|
107
|
+
{ field: 'company_id', type: 'relation', relation: { entity: 'companies', foreignKey: 'company_id', labelField: 'name' } },
|
|
108
|
+
],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it('writes every entity screen plus the app shell, and verifies', async () => {
|
|
112
|
+
const { io, files } = memIO()
|
|
113
|
+
const res = await runStudioAddApp([companies, contacts], {}, io)
|
|
114
|
+
expect(res.written).toContain('src/routes/+layout.svelte')
|
|
115
|
+
expect(res.written).toContain('src/routes/+page.svelte')
|
|
116
|
+
expect(res.written).toContain('src/routes/companies/+page.svelte')
|
|
117
|
+
expect(res.written).toContain('src/routes/contacts/+page.svelte')
|
|
118
|
+
expect(files.get('src/routes/+layout.svelte')).toContain('"href":"/contacts"')
|
|
119
|
+
expect(res.verify.ok).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// A linked Drizzle file (posts.authorId -> users) and the equivalent Prisma.
|
|
124
|
+
const linkedDrizzle = `
|
|
125
|
+
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core'
|
|
126
|
+
export const users = pgTable('users', {
|
|
127
|
+
id: serial('id').primaryKey(),
|
|
128
|
+
name: text('name').notNull(),
|
|
129
|
+
})
|
|
130
|
+
export const posts = pgTable('posts', {
|
|
131
|
+
id: serial('id').primaryKey(),
|
|
132
|
+
title: text('title').notNull(),
|
|
133
|
+
authorId: integer('author_id').references(() => users.id),
|
|
134
|
+
})
|
|
135
|
+
`
|
|
136
|
+
|
|
137
|
+
const linkedPrisma = `
|
|
138
|
+
model User {
|
|
139
|
+
id Int @id @default(autoincrement())
|
|
140
|
+
name String
|
|
141
|
+
posts Post[]
|
|
142
|
+
}
|
|
143
|
+
model Post {
|
|
144
|
+
id Int @id @default(autoincrement())
|
|
145
|
+
title String
|
|
146
|
+
author User @relation(fields: [authorId], references: [id])
|
|
147
|
+
authorId Int
|
|
148
|
+
}
|
|
149
|
+
`
|
|
150
|
+
|
|
151
|
+
describe('resolveSchema (single, from a file)', () => {
|
|
152
|
+
it('detects Prisma by content and turns @relation into a relation field', async () => {
|
|
153
|
+
const { io } = memIO({ 'schema.prisma': linkedPrisma })
|
|
154
|
+
const schema = await resolveSchema({ from: 'schema.prisma', table: 'Post' }, io)
|
|
155
|
+
const rel = schema.fields.find((f) => f.field === 'authorId')!
|
|
156
|
+
expect(rel.type).toBe('relation')
|
|
157
|
+
expect(rel.relation?.entity).toBe('User')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('parses a Drizzle .references() column as a relation', async () => {
|
|
161
|
+
const { io } = memIO({ 'schema.ts': linkedDrizzle })
|
|
162
|
+
const schema = await resolveSchema({ from: 'schema.ts', table: 'posts' }, io)
|
|
163
|
+
expect(schema.fields.find((f) => f.field === 'authorId')?.type).toBe('relation')
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('resolveSchemas + runStudioAddApp (--all --from a file)', () => {
|
|
168
|
+
it('scaffolds a linked app from a Drizzle schema and verifies', async () => {
|
|
169
|
+
const { io, files } = memIO({ 'schema.ts': linkedDrizzle })
|
|
170
|
+
const schemas = await resolveSchemas('schema.ts', io)
|
|
171
|
+
expect(schemas.map((s) => s.name)).toEqual(['users', 'posts'])
|
|
172
|
+
|
|
173
|
+
const res = await runStudioAddApp(schemas, {}, io)
|
|
174
|
+
expect(res.written).toContain('src/routes/users/+page.svelte')
|
|
175
|
+
expect(res.written).toContain('src/routes/posts/+page.svelte')
|
|
176
|
+
expect(files.get('src/routes/+layout.svelte')).toContain('"href":"/posts"')
|
|
177
|
+
expect(res.verify.ok).toBe(true)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('scaffolds a linked app from a Prisma schema and verifies', async () => {
|
|
181
|
+
const { io } = memIO({ 'schema.prisma': linkedPrisma })
|
|
182
|
+
const schemas = await resolveSchemas('schema.prisma', io)
|
|
183
|
+
expect(schemas.map((s) => s.name)).toEqual(['User', 'Post'])
|
|
184
|
+
const res = await runStudioAddApp(schemas, {}, io)
|
|
185
|
+
expect(res.verify.ok).toBe(true)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runStudioAdd - the one-command pipeline behind `@svgrid/studio add`: read a
|
|
3
|
+
* schema, introspect it, scaffold the files, merge-write them (preserving user
|
|
4
|
+
* edits outside managed regions), and verify the output compiles. This is the
|
|
5
|
+
* whole "CRUD screen in seconds" flow.
|
|
6
|
+
*
|
|
7
|
+
* Filesystem access is injected (`StudioIO`), so the orchestration is pure and
|
|
8
|
+
* unit-testable; the actual CLI bin supplies node:fs. No process/fs imports
|
|
9
|
+
* here, so it also runs in the browser (e.g. the designer's future
|
|
10
|
+
* write-to-project action).
|
|
11
|
+
*/
|
|
12
|
+
import type { EntitySchema } from '../schema.js'
|
|
13
|
+
import { introspectDrizzle, introspectDrizzleAll, introspectJson } from './introspect.js'
|
|
14
|
+
import { introspectPrisma, introspectPrismaAll } from './introspect-prisma.js'
|
|
15
|
+
import { mergeManaged, scaffold, type ScaffoldOptions } from './scaffold.js'
|
|
16
|
+
import { scaffoldApp, type ScaffoldAppOptions } from './scaffold-app.js'
|
|
17
|
+
import { verifyScaffold, type VerifyResult } from './verify.js'
|
|
18
|
+
|
|
19
|
+
/** A `.prisma` file (by extension or a `model X {` block) parses as Prisma; otherwise Drizzle. */
|
|
20
|
+
function isPrismaSource(path: string, source: string): boolean {
|
|
21
|
+
return /\.prisma$/i.test(path) || /\bmodel\s+\w+\s*\{/.test(source)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type StudioIO = {
|
|
25
|
+
/** Read a file; resolve to null when it doesn't exist. */
|
|
26
|
+
readFile: (path: string) => Promise<string | null>
|
|
27
|
+
/** Write a file, creating parent directories as needed. */
|
|
28
|
+
writeFile: (path: string, contents: string) => Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type AddOptions = ScaffoldOptions & {
|
|
32
|
+
/** Path to a Drizzle schema file to introspect. */
|
|
33
|
+
from?: string
|
|
34
|
+
/** Which table in the Drizzle file to use (defaults to the first). */
|
|
35
|
+
table?: string
|
|
36
|
+
/** Sample JSON rows to infer from, as an alternative to `from`. */
|
|
37
|
+
rows?: Array<Record<string, unknown>>
|
|
38
|
+
/** Entity name (required with `rows`, ignored with `from`). */
|
|
39
|
+
name?: string
|
|
40
|
+
/** A ready EntitySchema, bypassing introspection. */
|
|
41
|
+
schema?: EntitySchema
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type AddResult = {
|
|
45
|
+
schema: EntitySchema
|
|
46
|
+
/** Paths written, in order. */
|
|
47
|
+
written: string[]
|
|
48
|
+
verify: VerifyResult
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resolve an EntitySchema from whichever input was provided. */
|
|
52
|
+
export async function resolveSchema(options: AddOptions, io: StudioIO): Promise<EntitySchema> {
|
|
53
|
+
if (options.schema) return options.schema
|
|
54
|
+
if (options.from) {
|
|
55
|
+
const src = await io.readFile(options.from)
|
|
56
|
+
if (src == null) throw new Error(`runStudioAdd: schema file not found: ${options.from}`)
|
|
57
|
+
return isPrismaSource(options.from, src)
|
|
58
|
+
? introspectPrisma(src, options.table)
|
|
59
|
+
: introspectDrizzle(src, options.table)
|
|
60
|
+
}
|
|
61
|
+
if (options.rows) {
|
|
62
|
+
return introspectJson(options.name ?? options.route ?? 'entity', options.rows)
|
|
63
|
+
}
|
|
64
|
+
throw new Error('runStudioAdd: provide `from` (a Drizzle or Prisma schema file), `rows`, or `schema`')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve **every** entity from a Drizzle or Prisma schema file (the `--all
|
|
69
|
+
* --from` path), linked so foreign keys become working relation lookups.
|
|
70
|
+
*/
|
|
71
|
+
export async function resolveSchemas(from: string, io: StudioIO): Promise<EntitySchema[]> {
|
|
72
|
+
const src = await io.readFile(from)
|
|
73
|
+
if (src == null) throw new Error(`runStudioAddApp: schema file not found: ${from}`)
|
|
74
|
+
return isPrismaSource(from, src) ? introspectPrismaAll(src) : introspectDrizzleAll(src)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Merge-write a set of generated files, preserving user edits outside managed regions. */
|
|
78
|
+
async function writeAll(files: { path: string; contents: string }[], io: StudioIO): Promise<string[]> {
|
|
79
|
+
const written: string[] = []
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
const existing = await io.readFile(file.path)
|
|
82
|
+
// Regenerating? Replace only the managed region, keep the user's edits.
|
|
83
|
+
await io.writeFile(file.path, mergeManaged(existing, file.contents))
|
|
84
|
+
written.push(file.path)
|
|
85
|
+
}
|
|
86
|
+
return written
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runStudioAdd(options: AddOptions, io: StudioIO): Promise<AddResult> {
|
|
90
|
+
const schema = await resolveSchema(options, io)
|
|
91
|
+
const { files } = scaffold(schema, options)
|
|
92
|
+
const written = await writeAll(files, io)
|
|
93
|
+
const verify = await verifyScaffold(files)
|
|
94
|
+
return { schema, written, verify }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type AddAppResult = { written: string[]; verify: VerifyResult }
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Scaffold a whole multi-entity app - every entity's screen plus a nav layout
|
|
101
|
+
* and home page - and merge-write it. The `--all` path behind `svgrid-studio`.
|
|
102
|
+
*/
|
|
103
|
+
export async function runStudioAddApp(
|
|
104
|
+
schemas: EntitySchema[],
|
|
105
|
+
options: ScaffoldAppOptions,
|
|
106
|
+
io: StudioIO,
|
|
107
|
+
): Promise<AddAppResult> {
|
|
108
|
+
const { files } = scaffoldApp(schemas, options)
|
|
109
|
+
const written = await writeAll(files, io)
|
|
110
|
+
const verify = await verifyScaffold(files)
|
|
111
|
+
return { written, verify }
|
|
112
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseCsv, detectDelimiter, csvToEntity } from './csv.js'
|
|
3
|
+
|
|
4
|
+
describe('parseCsv', () => {
|
|
5
|
+
it('parses a simple grid', () => {
|
|
6
|
+
expect(parseCsv('a,b,c\n1,2,3\n4,5,6')).toEqual([
|
|
7
|
+
['a', 'b', 'c'],
|
|
8
|
+
['1', '2', '3'],
|
|
9
|
+
['4', '5', '6'],
|
|
10
|
+
])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('handles quoted fields, commas, escaped quotes, and embedded newlines', () => {
|
|
14
|
+
const text = 'name,note\n"Doe, John","said ""hi"""\n"Ada","line1\nline2"'
|
|
15
|
+
expect(parseCsv(text)).toEqual([
|
|
16
|
+
['name', 'note'],
|
|
17
|
+
['Doe, John', 'said "hi"'],
|
|
18
|
+
['Ada', 'line1\nline2'],
|
|
19
|
+
])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('auto-detects the delimiter (semicolon / tab / pipe), ignoring quoted separators', () => {
|
|
23
|
+
expect(detectDelimiter('a;b;c\n1;2;3')).toBe(';')
|
|
24
|
+
expect(detectDelimiter('a\tb\tc\n1\t2\t3')).toBe('\t')
|
|
25
|
+
expect(detectDelimiter('a|b|c')).toBe('|')
|
|
26
|
+
expect(detectDelimiter('a,b,c')).toBe(',')
|
|
27
|
+
// A comma inside a quoted header field must not sway a semicolon file.
|
|
28
|
+
expect(detectDelimiter('"a,x";b;c\n1;2;3')).toBe(';')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('parses a semicolon-delimited (European/Excel) file', () => {
|
|
32
|
+
expect(parseCsv('name;amount\n"Doe; John";1.200')).toEqual([
|
|
33
|
+
['name', 'amount'],
|
|
34
|
+
['Doe; John', '1.200'],
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('handles CRLF and a trailing newline, and strips a BOM', () => {
|
|
39
|
+
expect(parseCsv('a,b\r\n1,2\r\n')).toEqual([
|
|
40
|
+
['a', 'b'],
|
|
41
|
+
['1', '2'],
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('csvToEntity', () => {
|
|
47
|
+
it('infers types per column and coerces values', () => {
|
|
48
|
+
const csv = 'id,name,mrr,active,joined\n1,Ada,1200,true,2024-01-02\n2,Alan,240.5,no,2024-03-04'
|
|
49
|
+
const { schema, rows } = csvToEntity('customers', csv)
|
|
50
|
+
const t = Object.fromEntries(schema.fields.map((f) => [f.field, f.type]))
|
|
51
|
+
expect(t).toEqual({ id: 'number', name: 'text', mrr: 'number', active: 'boolean', joined: 'dateString' })
|
|
52
|
+
expect(rows[0]).toEqual({ id: 1, name: 'Ada', mrr: 1200, active: true, joined: '2024-01-02' })
|
|
53
|
+
expect(rows[1]!.mrr).toBe(240.5)
|
|
54
|
+
expect(rows[1]!.active).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('marks an existing id column as the primary key', () => {
|
|
58
|
+
const { schema } = csvToEntity('t', 'id,name\n1,a')
|
|
59
|
+
const id = schema.fields.find((f) => f.field === 'id')!
|
|
60
|
+
expect(id.primaryKey).toBe(true)
|
|
61
|
+
expect(id.readonly).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('synthesizes a string id when none is present', () => {
|
|
65
|
+
const { schema, rows } = csvToEntity('t', 'name,city\nAda,London\nAlan,Leeds')
|
|
66
|
+
const id = schema.fields.find((f) => f.field === 'id')!
|
|
67
|
+
expect(id.primaryKey).toBe(true)
|
|
68
|
+
expect(rows.map((r) => r.id)).toEqual(['1', '2'])
|
|
69
|
+
expect(rows[0]).toMatchObject({ id: '1', name: 'Ada', city: 'London' })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renames unsafe headers to safe keys and reports them', () => {
|
|
73
|
+
const { schema, renamed, rows } = csvToEntity('t', 'First Name,E-mail Address\nAda,ada@x.io')
|
|
74
|
+
const keys = schema.fields.map((f) => f.field)
|
|
75
|
+
expect(keys).toContain('first_name')
|
|
76
|
+
expect(keys).toContain('e_mail_address')
|
|
77
|
+
expect(renamed.map((r) => r.field)).toEqual(expect.arrayContaining(['first_name', 'e_mail_address']))
|
|
78
|
+
expect(rows[0]).toMatchObject({ first_name: 'Ada', e_mail_address: 'ada@x.io' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('treats empty cells as null and parses thousands separators', () => {
|
|
82
|
+
const { rows } = csvToEntity('t', 'id,amount\n1,"1,200"\n2,')
|
|
83
|
+
expect(rows[0]!.amount).toBe(1200)
|
|
84
|
+
expect(rows[1]!.amount).toBeNull()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns an empty entity for empty input', () => {
|
|
88
|
+
expect(csvToEntity('t', '').rows).toEqual([])
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV / spreadsheet import - turn a pasted or uploaded CSV into a runnable
|
|
3
|
+
* entity: infer an `EntitySchema` from the header + values, and return the
|
|
4
|
+
* parsed rows (coerced to real JS types) so they can seed a data source.
|
|
5
|
+
*
|
|
6
|
+
* Pure and dependency-free: a hand-rolled RFC-4180-ish reader (quoted fields,
|
|
7
|
+
* escaped `""`, embedded newlines, CRLF), then per-column type inference. The
|
|
8
|
+
* Studio generator and the visual designer both call `csvToEntity` to add a
|
|
9
|
+
* table from a file the user drops in - no backend, no parsing library.
|
|
10
|
+
*/
|
|
11
|
+
import type { EntityField, EntityFieldType, EntitySchema } from '../schema.js'
|
|
12
|
+
import { refineFields } from '../sources/field-inference.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sniff the field delimiter from the first (unquoted) line: whichever of
|
|
16
|
+
* `,` `;` `\t` `|` occurs most. Handles Excel/European exports (`;`) and TSV.
|
|
17
|
+
*/
|
|
18
|
+
export function detectDelimiter(text: string): ',' | ';' | '\t' | '|' {
|
|
19
|
+
const s = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
20
|
+
const firstLine = s.slice(0, (s.indexOf('\n') + 1 || s.length + 1) - 1)
|
|
21
|
+
const candidates: Array<',' | ';' | '\t' | '|'> = [',', ';', '\t', '|']
|
|
22
|
+
let best: ',' | ';' | '\t' | '|' = ','
|
|
23
|
+
let bestCount = -1
|
|
24
|
+
for (const d of candidates) {
|
|
25
|
+
// Count only outside quotes so a comma inside "a,b" doesn't sway the guess.
|
|
26
|
+
let count = 0, inQ = false
|
|
27
|
+
for (const ch of firstLine) {
|
|
28
|
+
if (ch === '"') inQ = !inQ
|
|
29
|
+
else if (ch === d && !inQ) count++
|
|
30
|
+
}
|
|
31
|
+
if (count > bestCount) { bestCount = count; best = d }
|
|
32
|
+
}
|
|
33
|
+
return best
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parse CSV/TSV text into a matrix of string cells. Handles quotes, `""`, embedded
|
|
37
|
+
* newlines, CRLF, and auto-detects the delimiter (comma / semicolon / tab / pipe). */
|
|
38
|
+
export function parseCsv(text: string, delimiter?: string): string[][] {
|
|
39
|
+
const delim = delimiter ?? detectDelimiter(text)
|
|
40
|
+
const rows: string[][] = []
|
|
41
|
+
let row: string[] = []
|
|
42
|
+
let field = ''
|
|
43
|
+
let inQuotes = false
|
|
44
|
+
// Strip a UTF-8 BOM if present so the first header isn't polluted.
|
|
45
|
+
const s = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
46
|
+
for (let i = 0; i < s.length; i++) {
|
|
47
|
+
const c = s[i]
|
|
48
|
+
if (inQuotes) {
|
|
49
|
+
if (c === '"') {
|
|
50
|
+
if (s[i + 1] === '"') { field += '"'; i++ } // escaped quote
|
|
51
|
+
else inQuotes = false
|
|
52
|
+
} else field += c
|
|
53
|
+
} else if (c === '"') {
|
|
54
|
+
inQuotes = true
|
|
55
|
+
} else if (c === delim) {
|
|
56
|
+
row.push(field); field = ''
|
|
57
|
+
} else if (c === '\n') {
|
|
58
|
+
row.push(field); field = ''; rows.push(row); row = []
|
|
59
|
+
} else if (c === '\r') {
|
|
60
|
+
// swallow; the paired \n (or EOF) ends the row
|
|
61
|
+
} else {
|
|
62
|
+
field += c
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Flush the trailing field/row (files without a final newline).
|
|
66
|
+
if (field !== '' || row.length > 0) { row.push(field); rows.push(row) }
|
|
67
|
+
// Drop fully-empty trailing rows (a blank final line).
|
|
68
|
+
return rows.filter((r) => !(r.length === 1 && r[0] === ''))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const TRUE_SET = new Set(['true', 'yes', 'y', '1'])
|
|
72
|
+
const FALSE_SET = new Set(['false', 'no', 'n', '0'])
|
|
73
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
74
|
+
const DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/
|
|
75
|
+
const NUMBER_RE = /^-?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?$/ // 1200, -3.5, 1,200
|
|
76
|
+
|
|
77
|
+
/** Coerce one raw cell to a JS value, given the column's inferred type. Empty -> null. */
|
|
78
|
+
function coerce(raw: string, type: EntityFieldType): unknown {
|
|
79
|
+
const v = raw.trim()
|
|
80
|
+
if (v === '') return null
|
|
81
|
+
switch (type) {
|
|
82
|
+
case 'number': return Number(v.replace(/,/g, ''))
|
|
83
|
+
case 'boolean': return TRUE_SET.has(v.toLowerCase())
|
|
84
|
+
default: return v // text / date / datetime / json stay strings (grid + schema handle them)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Infer a column's type from all its non-empty raw values. Defaults to text. */
|
|
89
|
+
function inferColumnType(values: string[]): EntityFieldType {
|
|
90
|
+
const nonEmpty = values.map((v) => v.trim()).filter((v) => v !== '')
|
|
91
|
+
if (nonEmpty.length === 0) return 'text'
|
|
92
|
+
const all = (pred: (v: string) => boolean) => nonEmpty.every(pred)
|
|
93
|
+
if (all((v) => NUMBER_RE.test(v))) return 'number'
|
|
94
|
+
if (all((v) => TRUE_SET.has(v.toLowerCase()) || FALSE_SET.has(v.toLowerCase()))) return 'boolean'
|
|
95
|
+
if (all((v) => DATETIME_RE.test(v))) return 'datetime'
|
|
96
|
+
if (all((v) => DATE_RE.test(v))) return 'dateString'
|
|
97
|
+
return 'text'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Turn a header string into a safe, camel-ish field key. */
|
|
101
|
+
function toFieldKey(header: string, index: number, taken: Set<string>): string {
|
|
102
|
+
let key = header.trim().replace(/[^\w]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase()
|
|
103
|
+
if (/^\d/.test(key)) key = 'col_' + key
|
|
104
|
+
if (!key) key = `col_${index + 1}`
|
|
105
|
+
let unique = key, n = 2
|
|
106
|
+
while (taken.has(unique)) unique = `${key}_${n++}`
|
|
107
|
+
taken.add(unique)
|
|
108
|
+
return unique
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type CsvEntity = {
|
|
112
|
+
schema: EntitySchema
|
|
113
|
+
rows: Array<Record<string, unknown>>
|
|
114
|
+
/** Fields whose header was renamed to a safe key (original -> key), for a UI note. */
|
|
115
|
+
renamed: Array<{ header: string; field: string }>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse CSV text into a ready-to-use entity: an `EntitySchema` (types inferred
|
|
120
|
+
* per column, an `id` primary key ensured) plus the coerced rows to seed with.
|
|
121
|
+
*
|
|
122
|
+
* If the file has no id-like column, a synthetic string `id` (`"1"`, `"2"`, ...)
|
|
123
|
+
* is added so the grid, edit form, and CRUD have a stable row key.
|
|
124
|
+
*/
|
|
125
|
+
export function csvToEntity(name: string, text: string): CsvEntity {
|
|
126
|
+
const matrix = parseCsv(text)
|
|
127
|
+
if (matrix.length === 0) return { schema: { name, fields: [] }, rows: [], renamed: [] }
|
|
128
|
+
|
|
129
|
+
const headers = matrix[0]!
|
|
130
|
+
const body = matrix.slice(1)
|
|
131
|
+
const taken = new Set<string>()
|
|
132
|
+
const renamed: Array<{ header: string; field: string }> = []
|
|
133
|
+
const cols = headers.map((h, i) => {
|
|
134
|
+
const field = toFieldKey(h, i, taken)
|
|
135
|
+
const values = body.map((r) => r[i] ?? '')
|
|
136
|
+
const type = inferColumnType(values)
|
|
137
|
+
if (field !== h.trim()) renamed.push({ header: h.trim() || `Column ${i + 1}`, field })
|
|
138
|
+
return { field, type, index: i }
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const rows = body.map((r) => {
|
|
142
|
+
const obj: Record<string, unknown> = {}
|
|
143
|
+
for (const c of cols) obj[c.field] = coerce(r[c.index] ?? '', c.type)
|
|
144
|
+
return obj
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Ensure a primary key: reuse an existing id-like column, else synthesize one.
|
|
148
|
+
const idCol = cols.find((c) => c.field === 'id') ?? cols.find((c) => /(^|_)id$/.test(c.field))
|
|
149
|
+
if (!idCol) {
|
|
150
|
+
rows.forEach((row, i) => { row.id = String(i + 1) })
|
|
151
|
+
cols.unshift({ field: 'id', type: 'text', index: -1 })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const fields: EntityField[] = cols.map((c) => {
|
|
155
|
+
const f: EntityField = { field: c.field, type: c.type }
|
|
156
|
+
const isId = idCol ? c.field === idCol.field : c.field === 'id'
|
|
157
|
+
if (isId) { f.primaryKey = true; f.readonly = true }
|
|
158
|
+
return f
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Let introspectJson refine any 'json'/edge types from the actual coerced values,
|
|
162
|
+
// but keep our column types + pk decisions (they saw every row, not just the first).
|
|
163
|
+
const schema: EntitySchema = refineFields({ name, fields })
|
|
164
|
+
return { schema, rows, renamed }
|
|
165
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildConnectionString,
|
|
4
|
+
parseConnectionString,
|
|
5
|
+
redactConnectionString,
|
|
6
|
+
isFileDialect,
|
|
7
|
+
DEFAULT_PORT,
|
|
8
|
+
DRIVER_PACKAGE,
|
|
9
|
+
} from './db-connect-string'
|
|
10
|
+
|
|
11
|
+
describe('buildConnectionString', () => {
|
|
12
|
+
it('builds a postgres URL with the default port', () => {
|
|
13
|
+
expect(buildConnectionString('postgres', { host: 'db.example.com', database: 'app', user: 'admin', password: 'secret' }))
|
|
14
|
+
.toBe('postgresql://admin:secret@db.example.com:5432/app')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('percent-encodes credentials with reserved characters', () => {
|
|
18
|
+
const url = buildConnectionString('postgres', { host: 'h', database: 'd', user: 'a@b', password: 'p:w/d?' })
|
|
19
|
+
expect(url).toBe('postgresql://a%40b:p%3Aw%2Fd%3F@h:5432/d')
|
|
20
|
+
// ...and round-trips back to the raw values.
|
|
21
|
+
const parts = parseConnectionString('postgres', url)
|
|
22
|
+
expect(parts.user).toBe('a@b')
|
|
23
|
+
expect(parts.password).toBe('p:w/d?')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('treats supabase as postgres', () => {
|
|
27
|
+
expect(buildConnectionString('supabase', { host: 'x', database: 'd', user: 'u', password: 'p' }))
|
|
28
|
+
.toBe('postgresql://u:p@x:5432/d')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('uses mysql / mssql schemes and default ports', () => {
|
|
32
|
+
expect(buildConnectionString('mysql', { host: 'h', database: 'd', user: 'u', password: 'p' }))
|
|
33
|
+
.toBe('mysql://u:p@h:3306/d')
|
|
34
|
+
expect(buildConnectionString('mssql', { host: 'h', database: 'd', user: 'u', password: 'p' }))
|
|
35
|
+
.toBe('mssql://u:p@h:1433/d')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('honours a custom port and omits empty auth / db', () => {
|
|
39
|
+
expect(buildConnectionString('postgres', { host: 'h', port: 6543 })).toBe('postgresql://h:6543')
|
|
40
|
+
expect(buildConnectionString('postgres', { host: 'h', port: '5432', user: 'u' })).toBe('postgresql://u@h:5432')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('adds the correct SSL param per dialect', () => {
|
|
44
|
+
expect(buildConnectionString('postgres', { host: 'h', database: 'd', ssl: true })).toContain('?sslmode=require')
|
|
45
|
+
expect(buildConnectionString('mssql', { host: 'h', database: 'd', ssl: true })).toContain('encrypt=true')
|
|
46
|
+
expect(buildConnectionString('mysql', { host: 'h', database: 'd', ssl: true })).toContain('ssl=')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns a bare file path for sqlite', () => {
|
|
50
|
+
expect(buildConnectionString('sqlite', { file: './data/app.db' })).toBe('./data/app.db')
|
|
51
|
+
expect(buildConnectionString('sqlite', { database: 'app.db' })).toBe('app.db')
|
|
52
|
+
expect(() => buildConnectionString('sqlite', {})).toThrow(/file path/)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('parseConnectionString', () => {
|
|
57
|
+
it('splits a URL back into form fields', () => {
|
|
58
|
+
expect(parseConnectionString('postgres', 'postgresql://u:p@host:5432/mydb?sslmode=require')).toEqual({
|
|
59
|
+
host: 'host', port: 5432, database: 'mydb', user: 'u', password: 'p', ssl: true,
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
it('treats a sqlite value as a file path', () => {
|
|
63
|
+
expect(parseConnectionString('sqlite', '/var/data/app.db')).toEqual({ file: '/var/data/app.db' })
|
|
64
|
+
})
|
|
65
|
+
it('returns {} for unparseable input', () => {
|
|
66
|
+
expect(parseConnectionString('postgres', 'not a url')).toEqual({})
|
|
67
|
+
expect(parseConnectionString('postgres', '')).toEqual({})
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('redactConnectionString', () => {
|
|
72
|
+
it('masks the password in a URL', () => {
|
|
73
|
+
expect(redactConnectionString('postgresql://u:secret@h:5432/d')).toBe('postgresql://u:***@h:5432/d')
|
|
74
|
+
})
|
|
75
|
+
it('masks password= tokens in key/value strings', () => {
|
|
76
|
+
expect(redactConnectionString('Server=h;Database=d;User Id=u;Password=secret;'))
|
|
77
|
+
.toBe('Server=h;Database=d;User Id=u;Password=***;')
|
|
78
|
+
})
|
|
79
|
+
it('leaves passwordless strings untouched', () => {
|
|
80
|
+
expect(redactConnectionString('postgresql://h:5432/d')).toBe('postgresql://h:5432/d')
|
|
81
|
+
expect(redactConnectionString('./app.db')).toBe('./app.db')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('metadata tables', () => {
|
|
86
|
+
it('maps every dialect to a driver + default port', () => {
|
|
87
|
+
expect(DRIVER_PACKAGE.postgres).toBe('pg')
|
|
88
|
+
expect(DRIVER_PACKAGE.sqlite).toBe('better-sqlite3')
|
|
89
|
+
expect(DEFAULT_PORT.mysql).toBe(3306)
|
|
90
|
+
expect(DEFAULT_PORT.sqlite).toBeNull()
|
|
91
|
+
expect(isFileDialect('sqlite')).toBe(true)
|
|
92
|
+
expect(isFileDialect('postgres')).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
})
|