@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.
Files changed (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ServerRequest } from '@svgrid/grid'
3
+ import type { EntitySchema } from '../schema'
4
+ import { createSqlDataSource, type SqlExecutor } from './sql-source'
5
+
6
+ type Customer = { id: string; name: string; age: number }
7
+
8
+ const schema: EntitySchema<Customer> = {
9
+ name: 'customers',
10
+ idField: 'id',
11
+ fields: [
12
+ { field: 'id', type: 'text', primaryKey: true },
13
+ { field: 'name', type: 'text' },
14
+ { field: 'age', type: 'number' },
15
+ ],
16
+ }
17
+
18
+ /** Records every (sql, params) and returns queued results per call. */
19
+ function recorder(results: Array<ReadonlyArray<Record<string, unknown>>>) {
20
+ const calls: Array<{ sql: string; params: unknown[] }> = []
21
+ let i = 0
22
+ const execute: SqlExecutor = async (sql, params) => {
23
+ calls.push({ sql, params })
24
+ return results[i++] ?? []
25
+ }
26
+ return { execute, calls }
27
+ }
28
+
29
+ const req = (p: Partial<ServerRequest>): ServerRequest => ({
30
+ startRow: 0,
31
+ endRow: 50,
32
+ pageIndex: 0,
33
+ pageSize: 50,
34
+ sortModel: [],
35
+ filterModel: {},
36
+ ...p,
37
+ })
38
+
39
+ describe('createSqlDataSource reads', () => {
40
+ it('builds a parameterized SELECT + COUNT and maps the result', async () => {
41
+ const { execute, calls } = recorder([
42
+ [{ id: '1', name: 'Ann', age: 30 }],
43
+ [{ count: 7 }],
44
+ ])
45
+ const src = createSqlDataSource({ schema, table: 'customers', execute })
46
+ const res = await src.getRows(
47
+ req({
48
+ filterModel: { columns: { age: { operator: 'greaterThan', value: '20' } } },
49
+ sortModel: [{ id: 'name', desc: false }],
50
+ startRow: 50,
51
+ pageSize: 25,
52
+ }),
53
+ )
54
+
55
+ expect(res.rowCount).toBe(7)
56
+ expect(res.rows[0]?.name).toBe('Ann')
57
+
58
+ expect(calls[0]!.sql).toBe(
59
+ 'SELECT * FROM "customers" WHERE "age" > ? ORDER BY "name" ASC LIMIT 25 OFFSET 50',
60
+ )
61
+ expect(calls[0]!.params).toEqual([20])
62
+ expect(calls[1]!.sql).toBe('SELECT COUNT(*) AS count FROM "customers" WHERE "age" > ?')
63
+ expect(calls[1]!.params).toEqual([20])
64
+ })
65
+
66
+ it('honors the Postgres dialect ($ placeholders)', async () => {
67
+ const { execute, calls } = recorder([[], [{ count: 0 }]])
68
+ const src = createSqlDataSource({
69
+ schema,
70
+ table: 'customers',
71
+ execute,
72
+ dialect: { placeholders: '$', ilike: true },
73
+ })
74
+ await src.getRows(req({ filterModel: { columns: { name: { operator: 'contains', value: 'a' } } } }))
75
+ expect(calls[0]!.sql).toContain('"name" ILIKE $1')
76
+ })
77
+ })
78
+
79
+ describe('createSqlDataSource writes', () => {
80
+ it('creates with a parameterized INSERT ... RETURNING', async () => {
81
+ const { execute, calls } = recorder([[{ id: '9', name: 'Zoe', age: 41 }]])
82
+ const src = createSqlDataSource({ schema, table: 'customers', execute })
83
+ const created = await src.createRow({ id: '9', name: 'Zoe', age: 41 })
84
+ expect(created).toEqual({ id: '9', name: 'Zoe', age: 41 })
85
+ expect(calls[0]!.sql).toBe(
86
+ 'INSERT INTO "customers" ("id", "name", "age") VALUES (?, ?, ?) RETURNING *',
87
+ )
88
+ expect(calls[0]!.params).toEqual(['9', 'Zoe', 41])
89
+ })
90
+
91
+ it('updates by id with SET + WHERE', async () => {
92
+ const { execute, calls } = recorder([[{ id: '9', name: 'Zoe', age: 42 }]])
93
+ const src = createSqlDataSource({ schema, table: 'customers', execute })
94
+ await src.updateRow('9', { age: 42 })
95
+ expect(calls[0]!.sql).toBe('UPDATE "customers" SET "age" = ? WHERE "id" = ? RETURNING *')
96
+ expect(calls[0]!.params).toEqual([42, '9'])
97
+ })
98
+
99
+ it('deletes by id', async () => {
100
+ const { execute, calls } = recorder([[]])
101
+ const src = createSqlDataSource({ schema, table: 'customers', execute })
102
+ await src.deleteRow('9')
103
+ expect(calls[0]!.sql).toBe('DELETE FROM "customers" WHERE "id" = ?')
104
+ expect(calls[0]!.params).toEqual(['9'])
105
+ })
106
+
107
+ it('omits RETURNING and echoes input when returning:false (MySQL)', async () => {
108
+ const { execute, calls } = recorder([[]])
109
+ const src = createSqlDataSource({ schema, table: 'customers', execute, returning: false })
110
+ const created = await src.createRow({ id: '1', name: 'A', age: 1 })
111
+ expect(calls[0]!.sql).not.toContain('RETURNING')
112
+ expect(created).toEqual({ id: '1', name: 'A', age: 1 })
113
+ })
114
+
115
+ it('getAggregate emits a GROUP BY with quoted identifiers', async () => {
116
+ const { execute, calls } = recorder([[{ category: 'x', value: 10 }, { category: 'y', value: 5 }]])
117
+ const src = createSqlDataSource({ schema, table: 'customers', execute })
118
+ const out = await src.getAggregate({ dimension: 'name', measure: 'age', reduce: 'sum' })
119
+ expect(calls[0]!.sql).toBe('SELECT "name" AS category, SUM("age") AS value FROM "customers" GROUP BY "name" ORDER BY value DESC')
120
+ expect(out).toEqual([{ category: 'x', value: 10 }, { category: 'y', value: 5 }])
121
+ })
122
+
123
+ it('getAggregate uses COUNT(*) for reduce:count', async () => {
124
+ const { execute, calls } = recorder([[]])
125
+ const src = createSqlDataSource({ schema, table: 'customers', execute })
126
+ await src.getAggregate({ dimension: 'name', reduce: 'count' })
127
+ expect(calls[0]!.sql).toContain('COUNT(*) AS value')
128
+ expect(calls[0]!.sql).toContain('GROUP BY "name"')
129
+ })
130
+
131
+ it('getAggregate with no dimension emits a single total (no GROUP BY) - a KPI', async () => {
132
+ const { execute, calls } = recorder([[{ value: 42 }]])
133
+ const src = createSqlDataSource({ schema, table: 'customers', execute })
134
+ const out = await src.getAggregate({ measure: 'age', reduce: 'sum' })
135
+ expect(calls[0]!.sql).toBe('SELECT SUM("age") AS value FROM "customers"')
136
+ expect(calls[0]!.sql).not.toContain('GROUP BY')
137
+ expect(out).toEqual([{ category: '', value: 42 }])
138
+ })
139
+ })
140
+
141
+ // A schema whose fields are renamed relative to their DB columns (the Drizzle
142
+ // `createdAt: timestamp('created_at')` case).
143
+ type Ev = { id: string; startsAt: string }
144
+ const renamedSchema: EntitySchema<Ev> = {
145
+ name: 'events',
146
+ idField: 'id',
147
+ fields: [
148
+ { field: 'id', dbColumn: 'event_id', type: 'text', primaryKey: true },
149
+ { field: 'startsAt', dbColumn: 'starts_at', type: 'datetime' },
150
+ ],
151
+ }
152
+
153
+ describe('createSqlDataSource honors dbColumn (renamed columns)', () => {
154
+ it('SELECTs the real columns aliased back to the field names', async () => {
155
+ const { execute, calls } = recorder([[{ id: '1', startsAt: 'x' }], [{ count: 1 }]])
156
+ const src = createSqlDataSource({ schema: renamedSchema, table: 'events', execute })
157
+ await src.getRows(req({ sortModel: [{ id: 'startsAt', desc: true }] }))
158
+ expect(calls[0]!.sql).toContain('SELECT "event_id" AS "id", "starts_at" AS "startsAt" FROM "events"')
159
+ expect(calls[0]!.sql).toContain('ORDER BY "starts_at" DESC') // sort uses the DB column
160
+ })
161
+
162
+ it('filters on the real column', async () => {
163
+ const { execute, calls } = recorder([[], [{ count: 0 }]])
164
+ const src = createSqlDataSource({ schema: renamedSchema, table: 'events', execute })
165
+ await src.getRows(req({ filterModel: { columns: { startsAt: { operator: 'contains', value: '2026' } } } }))
166
+ // The WHERE predicate targets the real DB column, not the field name.
167
+ expect(calls[0]!.sql).toContain('LOWER("starts_at") LIKE')
168
+ expect(calls[0]!.sql).not.toContain('LOWER("startsAt")')
169
+ })
170
+
171
+ it('INSERT / UPDATE write the real columns and RETURNING aliases back', async () => {
172
+ const { execute, calls } = recorder([[{ id: '9', startsAt: 'y' }]])
173
+ const src = createSqlDataSource({ schema: renamedSchema, table: 'events', execute })
174
+ await src.createRow({ id: '9', startsAt: 'y' })
175
+ expect(calls[0]!.sql).toContain('INSERT INTO "events" ("event_id", "starts_at")')
176
+ expect(calls[0]!.sql).toContain('RETURNING "event_id" AS "id", "starts_at" AS "startsAt"')
177
+ })
178
+
179
+ it('getAggregate reduces the real measure column', async () => {
180
+ const { execute, calls } = recorder([[{ value: 3 }]])
181
+ const src = createSqlDataSource({ schema: renamedSchema, table: 'events', execute })
182
+ await src.getAggregate({ dimension: 'startsAt', reduce: 'count' })
183
+ expect(calls[0]!.sql).toContain('GROUP BY "starts_at"')
184
+ })
185
+ })
@@ -0,0 +1,166 @@
1
+ /**
2
+ * createSqlDataSource - a production `ServerDataSource` backed by any SQL
3
+ * database. It plans the read with `planQuery` + `planToSql`, and builds
4
+ * parameterized INSERT / UPDATE / DELETE for writes. You supply one thing: an
5
+ * `execute(sql, params)` that runs a query and returns rows. Every SQL client
6
+ * SvelteKit teams use provides that - Drizzle (`db.execute`), postgres.js,
7
+ * better-sqlite3, mysql2 - so this is the turnkey "point at your database"
8
+ * adapter, the sibling of `createInMemoryDataSource`.
9
+ *
10
+ * All values are parameterized; identifiers come from the schema-whitelisted
11
+ * plan and are quoted. No string interpolation of user input.
12
+ *
13
+ * Drizzle (Postgres) example:
14
+ *
15
+ * import { sql } from 'drizzle-orm'
16
+ * const source = createSqlDataSource({
17
+ * schema: customerSchema,
18
+ * table: 'customers',
19
+ * dialect: { placeholders: '$', ilike: true },
20
+ * execute: (text, params) =>
21
+ * db.execute(sql.raw(text, params)).then((r) => r.rows),
22
+ * })
23
+ */
24
+ import type { RowData, ServerRequest, ServerResult } from '@svgrid/grid'
25
+ import { resolveIdField, type EntitySchema } from '../schema'
26
+ import { nudgeEnterprise } from '../license'
27
+ import { planQuery } from './query-plan'
28
+ import { planToSql, type SqlDialect } from './sql'
29
+ import type { WritableDataSource } from './types'
30
+ import type { AggregateBucket, AggregateRequest, AggregateSource } from '../sources/aggregate'
31
+
32
+ const SQL_AGG: Record<'sum' | 'avg' | 'min' | 'max', string> = { sum: 'SUM', avg: 'AVG', min: 'MIN', max: 'MAX' }
33
+
34
+ /** Run a parameterized query, resolving to the result rows. */
35
+ export type SqlExecutor = (
36
+ sql: string,
37
+ params: unknown[],
38
+ ) => Promise<ReadonlyArray<Record<string, unknown>>>
39
+
40
+ export type SqlDataSourceConfig<TData extends RowData> = {
41
+ schema: EntitySchema<TData>
42
+ /** Table (or view) name. Quoted per the dialect; not interpolated raw. */
43
+ table: string
44
+ execute: SqlExecutor
45
+ dialect?: SqlDialect
46
+ /**
47
+ * Append `RETURNING *` to writes and return the row the database echoes back
48
+ * (Postgres / SQLite). Set false for MySQL, where the input is returned as-is.
49
+ * Default true.
50
+ */
51
+ returning?: boolean
52
+ }
53
+
54
+ function quoter(dialect: SqlDialect) {
55
+ const q = dialect.quote ?? '"'
56
+ return (name: string) => `${q}${name.replace(new RegExp(q, 'g'), q + q)}${q}`
57
+ }
58
+
59
+ function binder(dialect: SqlDialect, params: unknown[]) {
60
+ return (value: unknown) => {
61
+ params.push(value)
62
+ if (dialect.placeholders === '$') return `$${params.length}`
63
+ if (dialect.placeholders === '@') return `@p${params.length}`
64
+ return '?'
65
+ }
66
+ }
67
+
68
+ const squish = (sql: string) => sql.replace(/\s+/g, ' ').trim()
69
+
70
+ export function createSqlDataSource<TData extends RowData>(
71
+ config: SqlDataSourceConfig<TData>,
72
+ ): WritableDataSource<TData> & AggregateSource {
73
+ nudgeEnterprise('Studio') // soft-gate; never blocks, safe on the server
74
+ const { schema, table, execute } = config
75
+ const dialect = config.dialect ?? {}
76
+ const returning = config.returning ?? true
77
+ const id = quoter(dialect)
78
+ const idField = resolveIdField(schema)
79
+
80
+ // Column mapping: when a field has a `dbColumn` (e.g. Drizzle `created_at` vs
81
+ // key `createdAt`), read/write the real column and alias it back to the field.
82
+ // With no renames, everything is identity - the emitted SQL is byte-identical.
83
+ const dbCol = new Map(schema.fields.filter((f) => f.dbColumn).map((f) => [f.field, f.dbColumn!]))
84
+ const hasRenames = dbCol.size > 0
85
+ const columnOf = (field: string): string => dbCol.get(field) ?? field
86
+ const col = (field: string): string => id(columnOf(field))
87
+ const projection = hasRenames
88
+ ? schema.fields.map((f) => `${id(columnOf(f.field))} AS ${id(f.field)}`).join(', ')
89
+ : '*'
90
+ const planDialect: SqlDialect = hasRenames ? { ...dialect, column: columnOf } : dialect
91
+ const RET = returning ? ` RETURNING ${projection}` : ''
92
+
93
+ return {
94
+ async getRows(request: ServerRequest): Promise<ServerResult<TData>> {
95
+ const plan = planQuery(schema, request)
96
+ const sql = planToSql(plan, planDialect)
97
+ const t = id(table)
98
+ const rowsSql = squish(
99
+ `SELECT ${projection} FROM ${t} ${sql.whereText} ${sql.orderByText} LIMIT ${sql.limit} OFFSET ${sql.offset}`,
100
+ )
101
+ const countSql = squish(`SELECT COUNT(*) AS count FROM ${t} ${sql.whereText}`)
102
+ const rows = await execute(rowsSql, sql.params)
103
+ const countRows = await execute(countSql, sql.params)
104
+ const rowCount = Number(countRows[0]?.count ?? rows.length)
105
+ return { rows: rows as ReadonlyArray<TData>, rowCount }
106
+ },
107
+
108
+ async createRow(input: Partial<TData>): Promise<TData> {
109
+ const cols = Object.keys(input)
110
+ if (cols.length === 0) throw new Error('createSqlDataSource: createRow needs at least one field')
111
+ const params: unknown[] = []
112
+ const bind = binder(dialect, params)
113
+ const values = cols.map((c) => bind((input as RowData)[c]))
114
+ const sql = squish(
115
+ `INSERT INTO ${id(table)} (${cols.map(col).join(', ')}) VALUES (${values.join(', ')})${RET}`,
116
+ )
117
+ const rows = await execute(sql, params)
118
+ return (rows[0] ?? input) as TData
119
+ },
120
+
121
+ async updateRow(rowIdValue: string, patch: Partial<TData>): Promise<TData> {
122
+ const cols = Object.keys(patch)
123
+ if (cols.length === 0) throw new Error('createSqlDataSource: updateRow needs at least one field')
124
+ const params: unknown[] = []
125
+ const bind = binder(dialect, params)
126
+ const sets = cols.map((c) => `${col(c)} = ${bind((patch as RowData)[c])}`)
127
+ const where = `${col(idField)} = ${bind(rowIdValue)}`
128
+ const sql = squish(`UPDATE ${id(table)} SET ${sets.join(', ')} WHERE ${where}${RET}`)
129
+ const rows = await execute(sql, params)
130
+ return (rows[0] ?? { ...patch, [idField]: rowIdValue }) as TData
131
+ },
132
+
133
+ async deleteRow(rowIdValue: string): Promise<void> {
134
+ const params: unknown[] = []
135
+ const bind = binder(dialect, params)
136
+ const sql = squish(`DELETE FROM ${id(table)} WHERE ${col(idField)} = ${bind(rowIdValue)}`)
137
+ await execute(sql, params)
138
+ },
139
+
140
+ async getAggregate(request: AggregateRequest): Promise<AggregateBucket[]> {
141
+ // Server-side GROUP BY. Identifiers are schema-quoted (no interpolation of
142
+ // user input); the WHERE + params come from the shared query plan.
143
+ const plan = planQuery(schema, {
144
+ startRow: 0, endRow: 0, pageIndex: 0, pageSize: 0, sortModel: [],
145
+ filterModel: request.filterModel ?? {},
146
+ })
147
+ const sql = planToSql(plan, planDialect)
148
+ const fn =
149
+ request.reduce === 'count' || !request.measure
150
+ ? 'COUNT(*)'
151
+ : `${SQL_AGG[request.reduce]}(${col(request.measure)})`
152
+ // No dimension -> a single grand total (a KPI), no GROUP BY.
153
+ if (!request.dimension) {
154
+ const text = squish(`SELECT ${fn} AS value FROM ${id(table)} ${sql.whereText}`)
155
+ const rows = await execute(text, sql.params)
156
+ return [{ category: '', value: Number(rows[0]?.value ?? 0) }]
157
+ }
158
+ const dim = col(request.dimension)
159
+ const text = squish(
160
+ `SELECT ${dim} AS category, ${fn} AS value FROM ${id(table)} ${sql.whereText} GROUP BY ${dim} ORDER BY value DESC`,
161
+ )
162
+ const rows = await execute(text, sql.params)
163
+ return rows.map((r) => ({ category: String(r.category ?? ''), value: Number(r.value ?? 0) }))
164
+ },
165
+ }
166
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { QueryPlan } from './query-plan'
3
+ import { planToSql } from './sql'
4
+
5
+ const base: QueryPlan = { where: [], search: null, orderBy: [], limit: 50, offset: 0 }
6
+
7
+ describe('planToSql', () => {
8
+ it('emits parameterized predicates with ANSI defaults', () => {
9
+ const plan: QueryPlan = {
10
+ ...base,
11
+ where: [
12
+ { field: 'age', op: 'gt', value: 30 },
13
+ { field: 'name', op: 'contains', value: 'ann' },
14
+ ],
15
+ }
16
+ const sql = planToSql(plan)
17
+ expect(sql.whereText).toBe('WHERE "age" > ? AND LOWER("name") LIKE LOWER(?)')
18
+ expect(sql.params).toEqual([30, '%ann%'])
19
+ })
20
+
21
+ it('supports the Postgres dialect: $ placeholders and ILIKE', () => {
22
+ const plan: QueryPlan = { ...base, where: [{ field: 'name', op: 'startsWith', value: 'A' }] }
23
+ const sql = planToSql(plan, { placeholders: '$', ilike: true })
24
+ expect(sql.whereText).toBe('WHERE "name" ILIKE $1')
25
+ expect(sql.params).toEqual(['A%'])
26
+ })
27
+
28
+ it('supports SQL Server @p placeholders', () => {
29
+ const plan: QueryPlan = { ...base, where: [{ field: 'age', op: 'gt', value: 30 }] }
30
+ const sql = planToSql(plan, { placeholders: '@' })
31
+ expect(sql.whereText).toBe('WHERE "age" > @p1')
32
+ expect(sql.params).toEqual([30])
33
+ })
34
+
35
+ it('renders between, isBlank, and in', () => {
36
+ const plan: QueryPlan = {
37
+ ...base,
38
+ where: [
39
+ { field: 'age', op: 'between', value: 20, valueTo: 40 },
40
+ { field: 'note', op: 'isBlank' },
41
+ { field: 'tier', op: 'in', values: ['free', 'pro'] },
42
+ ],
43
+ }
44
+ const sql = planToSql(plan)
45
+ expect(sql.where).toBe(
46
+ '"age" BETWEEN ? AND ? AND ("note" IS NULL OR "note" = \'\') AND "tier" IN (?, ?)',
47
+ )
48
+ expect(sql.params).toEqual([20, 40, 'free', 'pro'])
49
+ })
50
+
51
+ it('an empty `in` matches nothing', () => {
52
+ const sql = planToSql({ ...base, where: [{ field: 'tier', op: 'in', values: [] }] })
53
+ expect(sql.where).toBe('1 = 0')
54
+ expect(sql.params).toEqual([])
55
+ })
56
+
57
+ it('appends a global search as an OR group after the column predicates', () => {
58
+ const plan: QueryPlan = {
59
+ ...base,
60
+ where: [{ field: 'age', op: 'gt', value: 1 }],
61
+ search: { term: 'acme', fields: ['name', 'tier'] },
62
+ }
63
+ const sql = planToSql(plan)
64
+ expect(sql.where).toBe(
65
+ '"age" > ? AND (LOWER("name") LIKE LOWER(?) OR LOWER("tier") LIKE LOWER(?))',
66
+ )
67
+ expect(sql.params).toEqual([1, '%acme%', '%acme%'])
68
+ })
69
+
70
+ it('builds order by and leaves where empty when there are no predicates', () => {
71
+ const sql = planToSql({ ...base, orderBy: [{ field: 'name', desc: false }, { field: 'age', desc: true }] })
72
+ expect(sql.whereText).toBe('')
73
+ expect(sql.orderByText).toBe('ORDER BY "name" ASC, "age" DESC')
74
+ })
75
+
76
+ it('escapes identifier quote chars', () => {
77
+ const sql = planToSql({ ...base, where: [{ field: 'we"ird', op: 'eq', value: 1 }] })
78
+ expect(sql.where).toBe('"we""ird" = ?')
79
+ })
80
+ })
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `planToSql` - turn a `QueryPlan` into a portable, parameterized SQL fragment.
3
+ * This is the backend seam for every SQL database: Drizzle, postgres.js,
4
+ * better-sqlite3, mysql2. It emits only a WHERE body, an ORDER BY body, and a
5
+ * params array - never a full statement - so you keep control of the SELECT,
6
+ * the table, joins, and execution.
7
+ *
8
+ * All values are parameterized (never interpolated) and every identifier comes
9
+ * from the plan, which `planQuery` already whitelisted against the schema - so
10
+ * there is no SQL-injection surface here.
11
+ *
12
+ * Drizzle usage (Postgres):
13
+ *
14
+ * import { sql } from 'drizzle-orm'
15
+ * const { whereText, orderByText, params, limit, offset } =
16
+ * planToSql(plan, { placeholders: '$', ilike: true })
17
+ * const rows = await db.execute(
18
+ * sql.raw(`select * from customers ${whereText} ${orderByText} limit ${limit} offset ${offset}`, params)
19
+ * )
20
+ *
21
+ * (Or map `plan.where` to Drizzle operators directly - the plan is the seam
22
+ * either way.)
23
+ */
24
+ import type { PlanPredicate, QueryPlan } from './query-plan'
25
+
26
+ export type SqlDialect = {
27
+ /** Identifier quote char. Default `"` (ANSI / Postgres / SQLite). MySQL: `` ` ``. */
28
+ quote?: string
29
+ /** Placeholder style: `'?'` (SQLite / MySQL), `'$'` for `$1, $2` (Postgres), or `'@'` for `@p1, @p2` (SQL Server). Default `'?'`. */
30
+ placeholders?: '?' | '$' | '@'
31
+ /** Use `ILIKE` for case-insensitive matching (Postgres) instead of `LOWER(col) LIKE LOWER(?)`. */
32
+ ilike?: boolean
33
+ /** Map a schema `field` to its real DB column. Default: identity. Lets renamed columns (Drizzle `created_at` vs key `createdAt`) plan correctly. */
34
+ column?: (field: string) => string
35
+ }
36
+
37
+ export type SqlPlan = {
38
+ /** WHERE body without the keyword, e.g. `"age" > ? AND "name" ILIKE ?`. Empty string when no predicates. */
39
+ where: string
40
+ /** WHERE body with the leading `WHERE ` keyword, or `''`. */
41
+ whereText: string
42
+ /** ORDER BY body without the keyword, e.g. `"name" ASC, "age" DESC`. */
43
+ orderBy: string
44
+ /** ORDER BY body with the leading `ORDER BY ` keyword, or `''`. */
45
+ orderByText: string
46
+ params: unknown[]
47
+ limit: number
48
+ offset: number
49
+ }
50
+
51
+ export function planToSql(plan: QueryPlan, dialect: SqlDialect = {}): SqlPlan {
52
+ const q = dialect.quote ?? '"'
53
+ const useIlike = dialect.ilike ?? false
54
+ const params: unknown[] = []
55
+
56
+ const ph = () => {
57
+ if (dialect.placeholders === '$') return `$${params.length}`
58
+ if (dialect.placeholders === '@') return `@p${params.length}`
59
+ return '?'
60
+ }
61
+ const bind = (v: unknown): string => {
62
+ params.push(v)
63
+ return ph()
64
+ }
65
+ const columnFor = dialect.column ?? ((f: string) => f)
66
+ const id = (field: string) => {
67
+ const c = columnFor(field)
68
+ return `${q}${c.replace(new RegExp(q, 'g'), q + q)}${q}`
69
+ }
70
+
71
+ const like = (field: string, pattern: string): string => {
72
+ const p = bind(pattern)
73
+ return useIlike ? `${id(field)} ILIKE ${p}` : `LOWER(${id(field)}) LIKE LOWER(${p})`
74
+ }
75
+
76
+ const clauses: string[] = []
77
+
78
+ for (const pred of plan.where) {
79
+ clauses.push(predicateSql(pred, { id, bind, like }))
80
+ }
81
+
82
+ if (plan.search && plan.search.fields.length > 0) {
83
+ const term = `%${plan.search.term}%`
84
+ const ors = plan.search.fields.map((f) => like(f, term))
85
+ clauses.push(`(${ors.join(' OR ')})`)
86
+ }
87
+
88
+ const where = clauses.join(' AND ')
89
+ const orderBy = plan.orderBy.map((o) => `${id(o.field)} ${o.desc ? 'DESC' : 'ASC'}`).join(', ')
90
+
91
+ return {
92
+ where,
93
+ whereText: where ? `WHERE ${where}` : '',
94
+ orderBy,
95
+ orderByText: orderBy ? `ORDER BY ${orderBy}` : '',
96
+ params,
97
+ limit: plan.limit,
98
+ offset: plan.offset,
99
+ }
100
+ }
101
+
102
+ function predicateSql(
103
+ pred: PlanPredicate,
104
+ ctx: { id: (f: string) => string; bind: (v: unknown) => string; like: (f: string, p: string) => string },
105
+ ): string {
106
+ const { id, bind, like } = ctx
107
+ const col = id(pred.field)
108
+ switch (pred.op) {
109
+ case 'eq':
110
+ return `${col} = ${bind(pred.value)}`
111
+ case 'contains':
112
+ return like(pred.field, `%${pred.value}%`)
113
+ case 'startsWith':
114
+ return like(pred.field, `${pred.value}%`)
115
+ case 'gt':
116
+ return `${col} > ${bind(pred.value)}`
117
+ case 'lt':
118
+ return `${col} < ${bind(pred.value)}`
119
+ case 'between':
120
+ return `${col} BETWEEN ${bind(pred.value)} AND ${bind(pred.valueTo)}`
121
+ case 'isBlank':
122
+ return `(${col} IS NULL OR ${col} = '')`
123
+ case 'in': {
124
+ const vals = pred.values ?? []
125
+ if (vals.length === 0) return '1 = 0' // empty IN () matches nothing
126
+ return `${col} IN (${vals.map((v) => bind(v)).join(', ')})`
127
+ }
128
+ default:
129
+ return '1 = 1'
130
+ }
131
+ }