@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.6
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/package.json +4 -4
- package/src/access.ts +188 -0
- package/src/client/api.ts +261 -0
- package/src/client/bulk.ts +361 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +201 -0
- package/src/client/csv-commit.ts +185 -0
- package/src/client/csv-map.ts +274 -0
- package/src/client/csv-model.ts +420 -0
- package/src/client/csv-pick.ts +54 -0
- package/src/client/csv-preview.ts +91 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +144 -0
- package/src/client/edit-session.ts +219 -0
- package/src/client/editors.ts +283 -0
- package/src/client/filter-builder.ts +198 -0
- package/src/client/fk.ts +242 -0
- package/src/client/grid-body.ts +103 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +466 -0
- package/src/client/meta.ts +188 -0
- package/src/client/page.ts +332 -0
- package/src/client/panel.ts +296 -0
- package/src/client/relations.ts +205 -0
- package/src/client/save.ts +209 -0
- package/src/client/sidebar.ts +110 -0
- package/src/client/state.ts +218 -0
- package/src/client/statusbar.ts +130 -0
- package/src/client/structure.ts +231 -0
- package/src/client/tabs.ts +224 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +374 -160
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +0 -0
- package/src/endpoints/import.ts +89 -0
- package/src/endpoints/read.ts +173 -0
- package/src/endpoints/rows.ts +435 -0
- package/src/identity.ts +391 -0
- package/src/index.ts +40 -45
- package/src/policy.ts +45 -0
- package/src/preview.ts +53 -0
- package/src/setup.ts +64 -81
- package/src/shared/coerce.ts +399 -0
- package/src/shared/csv.ts +235 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +164 -0
- package/src/shell.ts +187 -0
- package/src/validate.ts +295 -0
- package/src/credential.ts +0 -26
- package/src/endpoints.ts +0 -48
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the explorer is looking at, and how that fits in a URL.
|
|
3
|
+
*
|
|
4
|
+
* **Pure.** `encodeView`/`decodeView` are a round trip over a string and are
|
|
5
|
+
* tested as one. The store below is a plain mutable object with no DOM in it;
|
|
6
|
+
* the hash is written by the entry module, which is the only place that knows
|
|
7
|
+
* about `location`.
|
|
8
|
+
*
|
|
9
|
+
* The view lives in the **hash** rather than the query string on purpose. The
|
|
10
|
+
* explorer is reached with `?db-key=…` on a first visit, the client scrubs that
|
|
11
|
+
* parameter out of the URL immediately, and a view state sharing the query
|
|
12
|
+
* string would be scrubbed along with it — or worse, would have to be
|
|
13
|
+
* reconstructed by an edit to the same `URLSearchParams` the credential was
|
|
14
|
+
* just removed from. A hash also never reaches the server, which is the right
|
|
15
|
+
* place for "which row am I on".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Filter } from '../shared/filters'
|
|
19
|
+
import { isFilterOp } from '../shared/filters'
|
|
20
|
+
import type { SchemaGraph, SchemaReport, SchemaTable } from './meta'
|
|
21
|
+
|
|
22
|
+
export const PAGE_SIZE = 50
|
|
23
|
+
|
|
24
|
+
/** The three views a table has. One level of nesting, and only one. */
|
|
25
|
+
export type TableView = 'data' | 'structure' | 'relations'
|
|
26
|
+
|
|
27
|
+
const TABLE_VIEWS: readonly TableView[] = ['data', 'structure', 'relations']
|
|
28
|
+
|
|
29
|
+
export interface ViewState {
|
|
30
|
+
table: string
|
|
31
|
+
/** Which of Data / Structure / Relations is showing. */
|
|
32
|
+
view: TableView
|
|
33
|
+
page: number
|
|
34
|
+
sortBy: string | null
|
|
35
|
+
sortOrder: 'ASC' | 'DESC'
|
|
36
|
+
/**
|
|
37
|
+
* Column, operator and operand — several, each removable.
|
|
38
|
+
*
|
|
39
|
+
* A **list** rather than a record, because the builder lets a filter exist
|
|
40
|
+
* before it has a column or a value and a record cannot hold a half-built
|
|
41
|
+
* one. `toWire` is what collapses it to the record `getData` takes.
|
|
42
|
+
*/
|
|
43
|
+
filters: Filter[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function defaultView(table = ''): ViewState {
|
|
47
|
+
return {
|
|
48
|
+
table,
|
|
49
|
+
view: 'data',
|
|
50
|
+
page: 1,
|
|
51
|
+
sortBy: null,
|
|
52
|
+
sortOrder: 'ASC',
|
|
53
|
+
filters: [],
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `t` table, `v` view, `p` page, `s` sort column, `o` order, `f` filters.
|
|
59
|
+
*
|
|
60
|
+
* Short keys because several of these are encoded per open tab and the whole
|
|
61
|
+
* thing has to stay something a person can paste into chat. Defaults are
|
|
62
|
+
* omitted rather than written, so the common case is `t=parcels`.
|
|
63
|
+
*
|
|
64
|
+
* There is no `r` any more. It carried a row identity because a filter could
|
|
65
|
+
* not name a row — `id=1` matched `11` under a substring `LIKE` — and with `eq`
|
|
66
|
+
* available a foreign-key jump is just a filter. An old link with `r=` decodes
|
|
67
|
+
* to the same page it always did, minus the highlight.
|
|
68
|
+
*/
|
|
69
|
+
export function encodeView(view: ViewState): string {
|
|
70
|
+
const params = new URLSearchParams()
|
|
71
|
+
if (view.table) params.set('t', view.table)
|
|
72
|
+
if (view.view !== 'data') params.set('v', view.view)
|
|
73
|
+
if (view.page > 1) params.set('p', String(view.page))
|
|
74
|
+
if (view.sortBy) {
|
|
75
|
+
params.set('s', view.sortBy)
|
|
76
|
+
if (view.sortOrder === 'DESC') params.set('o', 'DESC')
|
|
77
|
+
}
|
|
78
|
+
if (view.filters.length) params.set('f', JSON.stringify(view.filters))
|
|
79
|
+
return params.toString()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function decodeView(hash: string): ViewState {
|
|
83
|
+
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
|
84
|
+
const view = defaultView(params.get('t') ?? '')
|
|
85
|
+
view.view = readTableView(params.get('v'))
|
|
86
|
+
const page = Number.parseInt(params.get('p') ?? '1', 10)
|
|
87
|
+
view.page = Number.isFinite(page) && page > 0 ? page : 1
|
|
88
|
+
view.sortBy = params.get('s')
|
|
89
|
+
view.sortOrder = params.get('o') === 'DESC' ? 'DESC' : 'ASC'
|
|
90
|
+
view.filters = readFilters(params.get('f'))
|
|
91
|
+
return view
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readTableView(raw: string | null): TableView {
|
|
95
|
+
return TABLE_VIEWS.find(name => name === raw) ?? 'data'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Filters out of a URL, or none.
|
|
100
|
+
*
|
|
101
|
+
* Nothing here is trusted: the hash is user-supplied, and a filter carrying an
|
|
102
|
+
* operator the ORM does not know would be *dropped* server-side and quietly
|
|
103
|
+
* widen the result. Anything that does not typecheck as a filter is discarded
|
|
104
|
+
* here, before it can be sent.
|
|
105
|
+
*/
|
|
106
|
+
function readFilters(raw: string | null): Filter[] {
|
|
107
|
+
if (!raw) return []
|
|
108
|
+
let parsed: unknown
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(raw)
|
|
111
|
+
} catch {
|
|
112
|
+
// A truncated or hand-edited link. Falling back to "no filters" renders
|
|
113
|
+
// the table the link asked for, which beats an error page.
|
|
114
|
+
return []
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(parsed)) return []
|
|
117
|
+
return parsed.filter(isFilter)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isFilter(value: unknown): value is Filter {
|
|
121
|
+
if (typeof value !== 'object' || value === null) return false
|
|
122
|
+
const entry = value as Record<string, unknown>
|
|
123
|
+
return (
|
|
124
|
+
typeof entry.column === 'string' &&
|
|
125
|
+
entry.column !== '' &&
|
|
126
|
+
isFilterOp(entry.op) &&
|
|
127
|
+
(entry.value === undefined || typeof entry.value === 'string')
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Everything the running client holds. One object, mutated in place. */
|
|
132
|
+
export interface AppState {
|
|
133
|
+
report: SchemaReport | null
|
|
134
|
+
graph: SchemaGraph | null
|
|
135
|
+
/** Views to return to, pushed by a foreign-key jump. */
|
|
136
|
+
trail: ViewState[]
|
|
137
|
+
/** Whether the sidebar lists the framework's own tables. Off by default. */
|
|
138
|
+
showSystem: boolean
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createState(): AppState {
|
|
142
|
+
return { report: null, graph: null, trail: [], showSystem: false }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function tableOf(
|
|
146
|
+
state: AppState,
|
|
147
|
+
name: string,
|
|
148
|
+
): SchemaTable | undefined {
|
|
149
|
+
return state.report?.tables.find(table => table.name === name)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The ORM's own bookkeeping, which is not the user's data.
|
|
154
|
+
*
|
|
155
|
+
* `__bakery_schema` is the sync ledger (`orm/src/sync/ledger.ts`), and
|
|
156
|
+
* `__bakery` is the reserved prefix convention 10 names. Matching the prefix
|
|
157
|
+
* rather than the one literal name means a second ledger table would be hidden
|
|
158
|
+
* on the day it lands rather than on the day someone notices.
|
|
159
|
+
*
|
|
160
|
+
* Matched against the **raw** database name, which is what the schema report
|
|
161
|
+
* carries: `introspect()` builds from `getSchema()`, and that speaks raw names.
|
|
162
|
+
* (`getConstraints()` camel-cases, so the same table is `bakerySchema` there —
|
|
163
|
+
* that spelling never reaches this client, and matching it here would risk
|
|
164
|
+
* hiding a user table that happens to be called `bakerySchema`.)
|
|
165
|
+
*/
|
|
166
|
+
const SYSTEM_PREFIX = '__bakery'
|
|
167
|
+
|
|
168
|
+
export function isSystemTable(name: string): boolean {
|
|
169
|
+
return name.startsWith(SYSTEM_PREFIX)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The tables the sidebar shows.
|
|
174
|
+
*
|
|
175
|
+
* Hidden behind a checkbox rather than removed outright, because every real
|
|
176
|
+
* client offers the toggle: a ledger row is occasionally exactly what someone
|
|
177
|
+
* needs to look at, and a table that cannot be reached at all is a support
|
|
178
|
+
* question.
|
|
179
|
+
*/
|
|
180
|
+
export function visibleTables(
|
|
181
|
+
tables: readonly SchemaTable[],
|
|
182
|
+
showSystem: boolean,
|
|
183
|
+
): SchemaTable[] {
|
|
184
|
+
return showSystem ? [...tables] : tables.filter(t => !isSystemTable(t.name))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** How many are being hidden, for the checkbox's own label. */
|
|
188
|
+
export function systemTableCount(tables: readonly SchemaTable[]): number {
|
|
189
|
+
return tables.filter(table => isSystemTable(table.name)).length
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether this table may be edited, decided **once, before first paint**.
|
|
194
|
+
*
|
|
195
|
+
* Two independent reasons it may not be, and the client is told both by
|
|
196
|
+
* `/api/_db/schema` rather than discovering them at save time: the session may
|
|
197
|
+
* be `read`, or the table may have no identity. Drawing an editable grid and
|
|
198
|
+
* then refusing the save is the failure this answers early.
|
|
199
|
+
*/
|
|
200
|
+
export function editableTable(
|
|
201
|
+
state: AppState,
|
|
202
|
+
table: SchemaTable | undefined,
|
|
203
|
+
): boolean {
|
|
204
|
+
if (!table) return false
|
|
205
|
+
return state.report?.access === 'write' && table.writable
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Why not, in the words the server used. Never invented here. */
|
|
209
|
+
export function readOnlyReason(
|
|
210
|
+
state: AppState,
|
|
211
|
+
table: SchemaTable | undefined,
|
|
212
|
+
): string {
|
|
213
|
+
if (!table) return 'no table selected'
|
|
214
|
+
if (state.report?.access !== 'write') {
|
|
215
|
+
return table.reason ?? 'this session may read but not write'
|
|
216
|
+
}
|
|
217
|
+
return table.reason ?? table.identity.reason ?? 'this table is read-only'
|
|
218
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bar along the bottom: what is on screen, what it cost, what may be done
|
|
3
|
+
* to it.
|
|
4
|
+
*
|
|
5
|
+
* Every database client has one, and it earns its space by answering three
|
|
6
|
+
* questions that otherwise need a round trip through the developer tools: how
|
|
7
|
+
* many rows there really are, how long the server took, and whether this
|
|
8
|
+
* session can write at all. The last one is the reason it exists here — the
|
|
9
|
+
* access level used to be a line of sidebar text that scrolled away.
|
|
10
|
+
*
|
|
11
|
+
* `statusParts` is **pure** and is where the wording lives, so the segments are
|
|
12
|
+
* asserted rather than read off a screenshot.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { box, el } from './dom'
|
|
16
|
+
|
|
17
|
+
export interface StatusFacts {
|
|
18
|
+
table: string | null
|
|
19
|
+
totalRows?: number
|
|
20
|
+
page: number
|
|
21
|
+
totalPages?: number
|
|
22
|
+
/** Milliseconds the server reported for the last table-data call. */
|
|
23
|
+
ms: number | null
|
|
24
|
+
access: 'read' | 'write' | false
|
|
25
|
+
dirtyRows: number
|
|
26
|
+
/** Filters currently applied, so a surprising row count has an explanation. */
|
|
27
|
+
filterCount: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The segments, in order, already worded.
|
|
32
|
+
*
|
|
33
|
+
* Absent facts are **omitted rather than rendered as a placeholder** — a
|
|
34
|
+
* `page 1 / ?` teaches nobody anything, and `getData` genuinely does not always
|
|
35
|
+
* return a total. An empty array is the honest answer for "nothing is open".
|
|
36
|
+
*/
|
|
37
|
+
export function statusParts(facts: StatusFacts): string[] {
|
|
38
|
+
if (!facts.table) return []
|
|
39
|
+
const parts: string[] = []
|
|
40
|
+
|
|
41
|
+
if (facts.totalRows !== undefined) {
|
|
42
|
+
parts.push(`${formatCount(facts.totalRows)} row${plural(facts.totalRows)}`)
|
|
43
|
+
}
|
|
44
|
+
parts.push(
|
|
45
|
+
facts.totalPages !== undefined
|
|
46
|
+
? `page ${facts.page} / ${facts.totalPages}`
|
|
47
|
+
: `page ${facts.page}`,
|
|
48
|
+
)
|
|
49
|
+
if (facts.filterCount > 0) {
|
|
50
|
+
parts.push(`${facts.filterCount} filter${plural(facts.filterCount)}`)
|
|
51
|
+
}
|
|
52
|
+
if (facts.ms !== null) parts.push(`${formatMs(facts.ms)} ms`)
|
|
53
|
+
parts.push(accessLabel(facts.access))
|
|
54
|
+
if (facts.dirtyRows > 0) {
|
|
55
|
+
parts.push(`${facts.dirtyRows} unsaved row${plural(facts.dirtyRows)}`)
|
|
56
|
+
}
|
|
57
|
+
return parts
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function plural(count: number): string {
|
|
61
|
+
return count === 1 ? '' : 's'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Thousands separated, because a six-figure row count is unreadable without. */
|
|
65
|
+
function formatCount(count: number): string {
|
|
66
|
+
return count.toLocaleString('en-US')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sub-millisecond timings keep one decimal.
|
|
71
|
+
*
|
|
72
|
+
* `getElapsed` returns a float, and a fast query rounding to `0 ms` reads as
|
|
73
|
+
* "not measured" rather than "fast" — which is the opposite of what it means.
|
|
74
|
+
*/
|
|
75
|
+
function formatMs(ms: number): string {
|
|
76
|
+
return ms < 10 ? ms.toFixed(1) : String(Math.round(ms))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function accessLabel(access: 'read' | 'write' | false): string {
|
|
80
|
+
if (access === 'write') return 'read · write'
|
|
81
|
+
if (access === 'read') return 'read-only'
|
|
82
|
+
return 'no access'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class StatusBar {
|
|
86
|
+
readonly node: HTMLElement
|
|
87
|
+
private readonly line: HTMLElement
|
|
88
|
+
/**
|
|
89
|
+
* The last facts painted.
|
|
90
|
+
*
|
|
91
|
+
* Kept so `bumpDirty` can repaint one segment without the caller having to
|
|
92
|
+
* reconstruct the row count, the page and the timing — which it cannot,
|
|
93
|
+
* since a staged edit does not re-fetch and the alternative would be showing
|
|
94
|
+
* a stale count or none.
|
|
95
|
+
*/
|
|
96
|
+
private facts: StatusFacts = {
|
|
97
|
+
table: null,
|
|
98
|
+
page: 1,
|
|
99
|
+
ms: null,
|
|
100
|
+
access: false,
|
|
101
|
+
dirtyRows: 0,
|
|
102
|
+
filterCount: 0,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
constructor() {
|
|
106
|
+
this.line = el('span', { class: 'status-parts' })
|
|
107
|
+
this.node = box('statusbar', this.line)
|
|
108
|
+
this.node.setAttribute('role', 'status')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
paint(facts: StatusFacts): void {
|
|
112
|
+
this.facts = facts
|
|
113
|
+
this.render()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A staged or saved edit. Everything else on the bar is unchanged. */
|
|
117
|
+
bumpDirty(dirtyRows: number): void {
|
|
118
|
+
if (this.facts.dirtyRows === dirtyRows) return
|
|
119
|
+
this.facts = { ...this.facts, dirtyRows }
|
|
120
|
+
this.render()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private render(): void {
|
|
124
|
+
this.line.textContent = statusParts(this.facts).join(' · ')
|
|
125
|
+
// The dirty count is the one segment worth colouring, and it is always
|
|
126
|
+
// last — so the class goes on the bar rather than on a span nobody can see
|
|
127
|
+
// when the bar is empty.
|
|
128
|
+
this.node.classList.toggle('dirty', this.facts.dirtyRows > 0)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Structure view: what the table *is*, as opposed to what is in it.
|
|
3
|
+
*
|
|
4
|
+
* Every serious client — DBeaver, DataGrip, Devart — splits a table into Data
|
|
5
|
+
* and its structure, and keeps them apart. This one was the single biggest
|
|
6
|
+
* omission: `GET /api/_db/schema` has always returned the type, nullability,
|
|
7
|
+
* default, enum values, auto-increment flag and resolved row identity of every
|
|
8
|
+
* column, and the client used all of it *only* to pick which editor widget to
|
|
9
|
+
* open. None of it was ever on screen. The reason a table is read-only was the
|
|
10
|
+
* worst of it — it existed, in the server's own words, and appeared nowhere
|
|
11
|
+
* except a tooltip on a padlock.
|
|
12
|
+
*
|
|
13
|
+
* `structureRows` is **pure** and is the whole derivation, so what each column
|
|
14
|
+
* is said to be is asserted rather than eyeballed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { box, el } from './dom'
|
|
18
|
+
import type { SchemaColumn, SchemaIndex, SchemaTable } from './meta'
|
|
19
|
+
|
|
20
|
+
/** One row of the columns table, already worded. */
|
|
21
|
+
export interface StructureRow {
|
|
22
|
+
name: string
|
|
23
|
+
type: string
|
|
24
|
+
nullable: string
|
|
25
|
+
/** `AUTO`, a literal `DEFAULT`, or empty — never a guess. */
|
|
26
|
+
default: string
|
|
27
|
+
/** `PK`, `unique`, or empty. */
|
|
28
|
+
key: string
|
|
29
|
+
/** The enum members, comma-joined, or empty. */
|
|
30
|
+
values: string
|
|
31
|
+
/** Whether this column is part of the row identity. */
|
|
32
|
+
identity: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A column as the Structure table shows it.
|
|
37
|
+
*
|
|
38
|
+
* Two decisions worth naming, because both are places a plausible shortcut is
|
|
39
|
+
* wrong:
|
|
40
|
+
*
|
|
41
|
+
* - **`hasDefault` and `autoIncrement` are different facts**, and an
|
|
42
|
+
* auto-increment column is reported as such rather than as "has a default".
|
|
43
|
+
* They differ in the one place it matters — `omittableOnInsert` — and
|
|
44
|
+
* conflating them on screen would teach the reader the wrong model.
|
|
45
|
+
* - **`notnull` is not the negation of `nullable` here.** The server sends
|
|
46
|
+
* both, from two different introspection calls (`getConstraints()` when it
|
|
47
|
+
* says anything, `getSchema()`'s flag otherwise), and `nullable` is the
|
|
48
|
+
* richer one. It is the one the editors obey, so it is the one shown.
|
|
49
|
+
*/
|
|
50
|
+
export function structureRow(
|
|
51
|
+
column: SchemaColumn,
|
|
52
|
+
identityCols: readonly string[],
|
|
53
|
+
indexes: readonly SchemaIndex[] = [],
|
|
54
|
+
): StructureRow {
|
|
55
|
+
return {
|
|
56
|
+
name: column.name,
|
|
57
|
+
// The length is appended only when the database's own type string does not
|
|
58
|
+
// already carry it. `getSchema()` reports `VARCHAR(255)` on every dialect
|
|
59
|
+
// that has sized text, so appending unconditionally rendered
|
|
60
|
+
// `VARCHAR(255)(255)`. SQLite's `TEXT` with a declared length is the case
|
|
61
|
+
// that still needs the suffix.
|
|
62
|
+
type:
|
|
63
|
+
column.length && !column.type.includes('(')
|
|
64
|
+
? `${column.type}(${column.length})`
|
|
65
|
+
: column.type,
|
|
66
|
+
nullable: column.nullable ? 'NULL' : 'NOT NULL',
|
|
67
|
+
default: defaultOf(column),
|
|
68
|
+
key: keyOf(column, indexes),
|
|
69
|
+
values: column.enum?.length ? column.enum.join(', ') : '',
|
|
70
|
+
identity: identityCols.includes(column.name),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function defaultOf(column: SchemaColumn): string {
|
|
75
|
+
if (column.autoIncrement) return 'AUTO'
|
|
76
|
+
return column.hasDefault ? 'DEFAULT' : ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Primary key first, then unique-index membership.
|
|
81
|
+
*
|
|
82
|
+
* Only `unique` indexes are reported as a key. A plain index makes a column
|
|
83
|
+
* faster to look up and does nothing to make a row nameable, and calling both
|
|
84
|
+
* "key" is how someone concludes a table is addressable when it is not.
|
|
85
|
+
*/
|
|
86
|
+
function keyOf(column: SchemaColumn, indexes: readonly SchemaIndex[]): string {
|
|
87
|
+
if (column.pk) return 'PK'
|
|
88
|
+
const unique = indexes.some(
|
|
89
|
+
index => index.type === 'unique' && index.cols.includes(column.name),
|
|
90
|
+
)
|
|
91
|
+
return unique ? 'unique' : ''
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function structureRows(table: SchemaTable): StructureRow[] {
|
|
95
|
+
return table.columns.map(column =>
|
|
96
|
+
structureRow(column, table.identity.cols, table.indexes ?? []),
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --------------------------------------------------------------------- render
|
|
101
|
+
|
|
102
|
+
const HEADINGS = [
|
|
103
|
+
'column',
|
|
104
|
+
'type',
|
|
105
|
+
'null',
|
|
106
|
+
'default',
|
|
107
|
+
'key',
|
|
108
|
+
'values',
|
|
109
|
+
'identity',
|
|
110
|
+
] as const
|
|
111
|
+
|
|
112
|
+
export interface StructureContext {
|
|
113
|
+
table: SchemaTable
|
|
114
|
+
editable: boolean
|
|
115
|
+
/** Why not, in the server's words. Shown prominently when not editable. */
|
|
116
|
+
reason: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function renderStructure(ctx: StructureContext): HTMLElement {
|
|
120
|
+
const node = box('structure')
|
|
121
|
+
const parts: (HTMLElement | null)[] = [
|
|
122
|
+
identitySection(ctx),
|
|
123
|
+
columnsSection(ctx.table),
|
|
124
|
+
indexesSection(ctx.table),
|
|
125
|
+
]
|
|
126
|
+
for (const part of parts) if (part) node.appendChild(part)
|
|
127
|
+
return node
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* How a row of this table is named — and, when it cannot be, why.
|
|
132
|
+
*
|
|
133
|
+
* First rather than last, and a banner rather than a footnote. "This table is
|
|
134
|
+
* read-only" with no reason is the message that generates support questions;
|
|
135
|
+
* the reason has always existed and is the server's own sentence.
|
|
136
|
+
*/
|
|
137
|
+
function identitySection(ctx: StructureContext): HTMLElement {
|
|
138
|
+
const section = box('structure-section')
|
|
139
|
+
section.appendChild(el('h3', { text: 'Row identity' }))
|
|
140
|
+
|
|
141
|
+
const identity = ctx.table.identity
|
|
142
|
+
if (identity.mode === 'none') {
|
|
143
|
+
section.appendChild(
|
|
144
|
+
el('p', {
|
|
145
|
+
class: 'banner warn',
|
|
146
|
+
text: `no row of this table can be named — ${ctx.reason}`,
|
|
147
|
+
}),
|
|
148
|
+
)
|
|
149
|
+
return section
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
section.appendChild(
|
|
153
|
+
el('p', {
|
|
154
|
+
class: 'note',
|
|
155
|
+
text:
|
|
156
|
+
`a row is named by its ${identity.mode === 'pk' ? 'primary key' : 'unique index'}: ` +
|
|
157
|
+
identity.cols.join(', '),
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
if (!ctx.editable) {
|
|
161
|
+
section.appendChild(el('p', { class: 'banner warn', text: ctx.reason }))
|
|
162
|
+
}
|
|
163
|
+
return section
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function columnsSection(table: SchemaTable): HTMLElement {
|
|
167
|
+
const section = box('structure-section')
|
|
168
|
+
section.appendChild(el('h3', { text: `Columns (${table.columns.length})` }))
|
|
169
|
+
|
|
170
|
+
const grid = el('table', { class: 'grid structure-grid' })
|
|
171
|
+
const head = el('tr')
|
|
172
|
+
for (const heading of HEADINGS) head.appendChild(el('th', { text: heading }))
|
|
173
|
+
grid.appendChild(head)
|
|
174
|
+
for (const row of structureRows(table)) grid.appendChild(columnRow(row))
|
|
175
|
+
|
|
176
|
+
section.appendChild(box('scroll', grid))
|
|
177
|
+
return section
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function columnRow(row: StructureRow): HTMLElement {
|
|
181
|
+
const tr = el('tr')
|
|
182
|
+
const cells = [
|
|
183
|
+
row.name,
|
|
184
|
+
row.type,
|
|
185
|
+
row.nullable,
|
|
186
|
+
row.default,
|
|
187
|
+
row.key,
|
|
188
|
+
row.values,
|
|
189
|
+
row.identity ? '✓' : '',
|
|
190
|
+
]
|
|
191
|
+
for (const text of cells) tr.appendChild(el('td', { class: 'cell', text }))
|
|
192
|
+
return tr
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Indexes, or an explicit statement that there are none.
|
|
197
|
+
*
|
|
198
|
+
* An empty section beats an absent one: "this table has no indexes" is an
|
|
199
|
+
* answer, and a missing heading reads as a client that failed to load them.
|
|
200
|
+
*/
|
|
201
|
+
function indexesSection(table: SchemaTable): HTMLElement {
|
|
202
|
+
const section = box('structure-section')
|
|
203
|
+
const indexes = table.indexes ?? []
|
|
204
|
+
section.appendChild(el('h3', { text: `Indexes (${indexes.length})` }))
|
|
205
|
+
|
|
206
|
+
if (!indexes.length) {
|
|
207
|
+
section.appendChild(
|
|
208
|
+
el('p', { class: 'note', text: 'no indexes are declared on this table' }),
|
|
209
|
+
)
|
|
210
|
+
return section
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const grid = el('table', { class: 'grid structure-grid' })
|
|
214
|
+
const head = el('tr')
|
|
215
|
+
for (const heading of ['index', 'type', 'columns']) {
|
|
216
|
+
head.appendChild(el('th', { text: heading }))
|
|
217
|
+
}
|
|
218
|
+
grid.appendChild(head)
|
|
219
|
+
for (const index of indexes) grid.appendChild(indexRow(index))
|
|
220
|
+
|
|
221
|
+
section.appendChild(box('scroll', grid))
|
|
222
|
+
return section
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function indexRow(index: SchemaIndex): HTMLElement {
|
|
226
|
+
const tr = el('tr')
|
|
227
|
+
for (const text of [index.name, index.type, index.cols.join(', ')]) {
|
|
228
|
+
tr.appendChild(el('td', { class: 'cell', text }))
|
|
229
|
+
}
|
|
230
|
+
return tr
|
|
231
|
+
}
|