@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,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV, RFC 4180-ish, with the delimiter sniffed rather than assumed.
|
|
3
|
+
*
|
|
4
|
+
* **Pure** — see the note at the top of `coerce.ts`. The client parses the file
|
|
5
|
+
* the user dropped and the server re-parses whatever it is sent, and they have
|
|
6
|
+
* to agree, so there is one parser.
|
|
7
|
+
*
|
|
8
|
+
* ## Why not `parseCSVRows` from `orm/adapters/base.ts:1071`
|
|
9
|
+
*
|
|
10
|
+
* It was read first, and reusing it was the intent. Four things stop it, and
|
|
11
|
+
* the first two are correctness rather than taste:
|
|
12
|
+
*
|
|
13
|
+
* 1. **A quote anywhere opens a quoted field.** `he said "hi", ok` parses as
|
|
14
|
+
* one field `he said hi, ok` rather than two, because the branch is
|
|
15
|
+
* `if (c === '"') inQuotes = true` with no check that the field is empty.
|
|
16
|
+
* RFC 4180 only gives `"` meaning at the start of a field.
|
|
17
|
+
* 2. **No BOM handling.** A file saved by Excel begins ``, so the first
|
|
18
|
+
* header comes back as `id` and matches no column — the single most
|
|
19
|
+
* common import failure there is.
|
|
20
|
+
* 3. Comma only. A European export is `;`-delimited and a database dump is
|
|
21
|
+
* often tab-delimited; both parse as one column per row and then fail with
|
|
22
|
+
* a message about the header.
|
|
23
|
+
* 4. It returns `string[][]` with a blank final row dropped by a heuristic
|
|
24
|
+
* (`row.length === 1 && row[0] === ''`), which also drops a legitimate
|
|
25
|
+
* single-column row whose value is empty.
|
|
26
|
+
*
|
|
27
|
+
* Fixing it in place would change `importCSV`'s behaviour for every existing
|
|
28
|
+
* caller of the ORM, which is a separate decision from adding an importer to
|
|
29
|
+
* the explorer. This parser is the explorer's; if the ORM's is ever replaced,
|
|
30
|
+
* this is the implementation to move.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The delimiters `sniffDelimiter` will consider, in preference order. */
|
|
34
|
+
export const CANDIDATE_DELIMITERS = [',', ';', '\t', '|'] as const
|
|
35
|
+
|
|
36
|
+
export type Delimiter = (typeof CANDIDATE_DELIMITERS)[number]
|
|
37
|
+
|
|
38
|
+
export interface CSVTable {
|
|
39
|
+
headers: string[]
|
|
40
|
+
rows: string[][]
|
|
41
|
+
delimiter: string
|
|
42
|
+
/**
|
|
43
|
+
* Rows whose field count differs from the header count, as
|
|
44
|
+
* `{row, fields}` — 1-based over the data rows, so it lines up with what a
|
|
45
|
+
* spreadsheet shows. Ragged rows are still returned in `rows`, padded with
|
|
46
|
+
* `''` or truncated; refusing the whole file for one short line is not the
|
|
47
|
+
* importer's call to make.
|
|
48
|
+
*/
|
|
49
|
+
ragged: { row: number; fields: number }[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Strip a UTF-8 BOM. Excel writes one; nothing downstream expects it. */
|
|
53
|
+
export function stripBOM(text: string): string {
|
|
54
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which delimiter this file uses.
|
|
59
|
+
*
|
|
60
|
+
* Counts occurrences **outside quotes** in the first few lines and takes the
|
|
61
|
+
* candidate whose count is both non-zero and most consistent line to line. A
|
|
62
|
+
* naive `indexOf` count picks `,` for a `;`-delimited file the moment one
|
|
63
|
+
* quoted address contains a comma, which is the common case rather than an
|
|
64
|
+
* exotic one.
|
|
65
|
+
*/
|
|
66
|
+
export function sniffDelimiter(text: string): Delimiter {
|
|
67
|
+
const sample = stripBOM(text)
|
|
68
|
+
let best: Delimiter = ','
|
|
69
|
+
let bestScore = -1
|
|
70
|
+
|
|
71
|
+
for (const candidate of CANDIDATE_DELIMITERS) {
|
|
72
|
+
const counts = countPerLine(sample, candidate, 20)
|
|
73
|
+
if (!counts.length || counts[0] === 0) continue
|
|
74
|
+
// Consistency first, frequency second: a delimiter that appears the same
|
|
75
|
+
// number of times on every line is a delimiter; one that appears a varying
|
|
76
|
+
// number of times is text.
|
|
77
|
+
const consistent = counts.every(n => n === counts[0])
|
|
78
|
+
const score = (consistent ? 1000 : 0) + counts[0]!
|
|
79
|
+
if (score > bestScore) {
|
|
80
|
+
bestScore = score
|
|
81
|
+
best = candidate
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return best
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Occurrences of `delimiter` per line, outside quotes, for the first `max` lines. */
|
|
88
|
+
function countPerLine(text: string, delimiter: string, max: number): number[] {
|
|
89
|
+
const counts: number[] = []
|
|
90
|
+
let inQuotes = false
|
|
91
|
+
let count = 0
|
|
92
|
+
let atFieldStart = true
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < text.length && counts.length < max; i++) {
|
|
95
|
+
const c = text[i]
|
|
96
|
+
if (inQuotes) {
|
|
97
|
+
if (c === '"' && text[i + 1] === '"') i++
|
|
98
|
+
else if (c === '"') inQuotes = false
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if (c === '"' && atFieldStart) {
|
|
102
|
+
inQuotes = true
|
|
103
|
+
atFieldStart = false
|
|
104
|
+
} else if (c === delimiter) {
|
|
105
|
+
count++
|
|
106
|
+
atFieldStart = true
|
|
107
|
+
} else if (c === '\n') {
|
|
108
|
+
counts.push(count)
|
|
109
|
+
count = 0
|
|
110
|
+
atFieldStart = true
|
|
111
|
+
} else if (c !== '\r') {
|
|
112
|
+
atFieldStart = false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (count > 0 || counts.length === 0) counts.push(count)
|
|
116
|
+
return counts
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Rows of fields, with nothing interpreted.
|
|
121
|
+
*
|
|
122
|
+
* No trimming and no type guessing: `"01"` and `01` are both the three-, then
|
|
123
|
+
* two-character strings they look like, and turning either into a number is
|
|
124
|
+
* `coerce.ts`'s job once a column is known. A field is only ever `''` when the
|
|
125
|
+
* file said so — this parser has no concept of NULL.
|
|
126
|
+
*/
|
|
127
|
+
export function parseCSVRows(text: string, delimiter = ','): string[][] {
|
|
128
|
+
const csv = stripBOM(text)
|
|
129
|
+
const rows: string[][] = []
|
|
130
|
+
let row: string[] = []
|
|
131
|
+
let field = ''
|
|
132
|
+
let inQuotes = false
|
|
133
|
+
let atFieldStart = true
|
|
134
|
+
/** Did any field of the current row open with a quote? */
|
|
135
|
+
let rowQuoted = false
|
|
136
|
+
/** Has anything at all been read since the last row ended? */
|
|
137
|
+
let pending = false
|
|
138
|
+
|
|
139
|
+
const endField = () => {
|
|
140
|
+
row.push(field)
|
|
141
|
+
field = ''
|
|
142
|
+
atFieldStart = true
|
|
143
|
+
pending = true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const endRow = () => {
|
|
147
|
+
endField()
|
|
148
|
+
// A blank line is not a row: it is what a trailing newline, or a stray one
|
|
149
|
+
// in the middle of a file, leaves behind. One field, empty, and never
|
|
150
|
+
// quoted is the only shape it can take — a line reading `""` is a real row
|
|
151
|
+
// holding one empty value, which is why `rowQuoted` is tracked.
|
|
152
|
+
const blank = row.length === 1 && row[0] === '' && !rowQuoted
|
|
153
|
+
if (!blank) rows.push(row)
|
|
154
|
+
row = []
|
|
155
|
+
rowQuoted = false
|
|
156
|
+
pending = false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (let i = 0; i < csv.length; i++) {
|
|
160
|
+
const c = csv[i]
|
|
161
|
+
if (inQuotes) {
|
|
162
|
+
if (c === '"' && csv[i + 1] === '"') {
|
|
163
|
+
field += '"'
|
|
164
|
+
i++
|
|
165
|
+
} else if (c === '"') {
|
|
166
|
+
inQuotes = false
|
|
167
|
+
} else {
|
|
168
|
+
// Includes newlines: a quoted field spans lines, which is the reason
|
|
169
|
+
// a line-splitting "parser" cannot be used for CSV at all.
|
|
170
|
+
field += c
|
|
171
|
+
}
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (c === '"' && atFieldStart) {
|
|
176
|
+
inQuotes = true
|
|
177
|
+
rowQuoted = true
|
|
178
|
+
atFieldStart = false
|
|
179
|
+
pending = true
|
|
180
|
+
} else if (c === delimiter) {
|
|
181
|
+
endField()
|
|
182
|
+
} else if (c === '\n' || c === '\r') {
|
|
183
|
+
if (c === '\r' && csv[i + 1] === '\n') i++
|
|
184
|
+
endRow()
|
|
185
|
+
} else {
|
|
186
|
+
field += c
|
|
187
|
+
atFieldStart = false
|
|
188
|
+
pending = true
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Anything still buffered is a final row with no line terminator.
|
|
193
|
+
if (pending) endRow()
|
|
194
|
+
|
|
195
|
+
return rows
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A whole file: headers, data rows squared off against them, and what the
|
|
200
|
+
* delimiter turned out to be.
|
|
201
|
+
*/
|
|
202
|
+
export function parseCSV(
|
|
203
|
+
text: string,
|
|
204
|
+
options: { delimiter?: string } = {},
|
|
205
|
+
): CSVTable {
|
|
206
|
+
const delimiter = options.delimiter ?? sniffDelimiter(text)
|
|
207
|
+
const all = parseCSVRows(text, delimiter)
|
|
208
|
+
if (!all.length) return { headers: [], rows: [], delimiter, ragged: [] }
|
|
209
|
+
|
|
210
|
+
// Headers are the one place trimming is right: a header is a name, and
|
|
211
|
+
// `" id "` naming the column `id` is what every spreadsheet means by it.
|
|
212
|
+
const headers = all[0]!.map(h => h.trim())
|
|
213
|
+
const ragged: { row: number; fields: number }[] = []
|
|
214
|
+
const rows = all.slice(1).map((fields, index) => {
|
|
215
|
+
if (fields.length !== headers.length) {
|
|
216
|
+
ragged.push({ row: index + 1, fields: fields.length })
|
|
217
|
+
}
|
|
218
|
+
const squared = fields.slice(0, headers.length)
|
|
219
|
+
while (squared.length < headers.length) squared.push('')
|
|
220
|
+
return squared
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
return { headers, rows, delimiter, ragged }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A parsed table as records, keyed by header. */
|
|
227
|
+
export function csvRecords(table: CSVTable): Record<string, string>[] {
|
|
228
|
+
return table.rows.map(fields => {
|
|
229
|
+
const record: Record<string, string> = {}
|
|
230
|
+
for (let i = 0; i < table.headers.length; i++) {
|
|
231
|
+
record[table.headers[i]!] = fields[i] ?? ''
|
|
232
|
+
}
|
|
233
|
+
return record
|
|
234
|
+
})
|
|
235
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filter vocabulary, written once and read by both halves.
|
|
3
|
+
*
|
|
4
|
+
* **Pure, and deliberately in `shared/`** — the client builds filters and the
|
|
5
|
+
* endpoint validates them, and a vocabulary that lived on only one side would
|
|
6
|
+
* drift. Like the rest of `shared/`, this is compiled into the browser bundle,
|
|
7
|
+
* so it imports nothing from `@bakery-framework/*` (see `client/safety.test.ts`).
|
|
8
|
+
*
|
|
9
|
+
* The list mirrors `filterClause` in `packages/orm/src/adapters/base.ts`. That
|
|
10
|
+
* method ends with:
|
|
11
|
+
*
|
|
12
|
+
* > An operator this dialect does not know is dropped rather than guessed at
|
|
13
|
+
* > … the caller validates the vocabulary before it gets here.
|
|
14
|
+
*
|
|
15
|
+
* This is that caller. Validating matters more than it looks: a *dropped*
|
|
16
|
+
* filter widens the result set, so a typo'd operator would silently show more
|
|
17
|
+
* rows than were asked for — and the explorer's Delete acts on a selection made
|
|
18
|
+
* from exactly that view. Failing the request is the only safe direction
|
|
19
|
+
* (convention 2).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type FilterOp =
|
|
23
|
+
| 'eq'
|
|
24
|
+
| 'ne'
|
|
25
|
+
| 'gt'
|
|
26
|
+
| 'gte'
|
|
27
|
+
| 'lt'
|
|
28
|
+
| 'lte'
|
|
29
|
+
| 'contains'
|
|
30
|
+
| 'starts'
|
|
31
|
+
| 'ends'
|
|
32
|
+
| 'null'
|
|
33
|
+
| 'notnull'
|
|
34
|
+
|
|
35
|
+
/** In menu order: equality, then ordering, then text, then the two nullary. */
|
|
36
|
+
export const FILTER_OPS: readonly FilterOp[] = [
|
|
37
|
+
'eq',
|
|
38
|
+
'ne',
|
|
39
|
+
'gt',
|
|
40
|
+
'gte',
|
|
41
|
+
'lt',
|
|
42
|
+
'lte',
|
|
43
|
+
'contains',
|
|
44
|
+
'starts',
|
|
45
|
+
'ends',
|
|
46
|
+
'null',
|
|
47
|
+
'notnull',
|
|
48
|
+
] as const
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The two operators that bind nothing.
|
|
52
|
+
*
|
|
53
|
+
* `IS NULL` has no parameter, which is the whole reason a filter cannot be
|
|
54
|
+
* modelled as a plain column/value pair — the value input is *hidden* for
|
|
55
|
+
* these rather than ignored, because an input whose contents do nothing is a
|
|
56
|
+
* lie about what the query will do.
|
|
57
|
+
*/
|
|
58
|
+
const NULLARY: ReadonlySet<string> = new Set<string>(['null', 'notnull'])
|
|
59
|
+
|
|
60
|
+
export function isFilterOp(value: unknown): value is FilterOp {
|
|
61
|
+
return (
|
|
62
|
+
typeof value === 'string' &&
|
|
63
|
+
(FILTER_OPS as readonly string[]).includes(value)
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function opTakesValue(op: FilterOp): boolean {
|
|
68
|
+
return !NULLARY.has(op)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** How a chip reads in the UI. Same order as `FILTER_OPS`. */
|
|
72
|
+
export const OP_LABELS: Readonly<Record<FilterOp, string>> = {
|
|
73
|
+
eq: '=',
|
|
74
|
+
ne: '≠',
|
|
75
|
+
gt: '>',
|
|
76
|
+
gte: '≥',
|
|
77
|
+
lt: '<',
|
|
78
|
+
lte: '≤',
|
|
79
|
+
contains: 'contains',
|
|
80
|
+
starts: 'starts with',
|
|
81
|
+
ends: 'ends with',
|
|
82
|
+
null: 'is NULL',
|
|
83
|
+
notnull: 'is not NULL',
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One filter as the user built it. `value` is unused when the op is nullary. */
|
|
87
|
+
export interface Filter {
|
|
88
|
+
column: string
|
|
89
|
+
op: FilterOp
|
|
90
|
+
value: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One filter as it crosses the wire. */
|
|
94
|
+
export interface WireFilter {
|
|
95
|
+
op: FilterOp
|
|
96
|
+
value?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function filter(
|
|
100
|
+
column: string,
|
|
101
|
+
op: FilterOp = 'eq',
|
|
102
|
+
value = '',
|
|
103
|
+
): Filter {
|
|
104
|
+
return { column, op, value }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Filters as `getData` wants them: keyed by column.
|
|
109
|
+
*
|
|
110
|
+
* **The wire shape is a record, so a column can carry only one filter** — the
|
|
111
|
+
* ORM iterates `Object.entries(options.filters)`. Two chips on one column
|
|
112
|
+
* therefore cannot both be sent, and the last one wins rather than the first,
|
|
113
|
+
* because the last is the one the user just touched. `duplicateColumns` exists
|
|
114
|
+
* so the builder can say so out loud instead of quietly dropping half of what
|
|
115
|
+
* is on screen.
|
|
116
|
+
*
|
|
117
|
+
* A value-taking op with an empty value is omitted entirely: that is a chip the
|
|
118
|
+
* user has added but not filled in, and sending `col LIKE '%%'` would match
|
|
119
|
+
* every row while looking like a filter.
|
|
120
|
+
*/
|
|
121
|
+
export function toWire(filters: readonly Filter[]): Record<string, WireFilter> {
|
|
122
|
+
const wire: Record<string, WireFilter> = {}
|
|
123
|
+
for (const entry of filters) {
|
|
124
|
+
if (!entry.column) continue
|
|
125
|
+
if (!opTakesValue(entry.op)) {
|
|
126
|
+
wire[entry.column] = { op: entry.op }
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if (entry.value === '') continue
|
|
130
|
+
wire[entry.column] = { op: entry.op, value: entry.value }
|
|
131
|
+
}
|
|
132
|
+
return wire
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Columns named by more than one filter — the ones the wire cannot carry. */
|
|
136
|
+
export function duplicateColumns(filters: readonly Filter[]): string[] {
|
|
137
|
+
const seen = new Set<string>()
|
|
138
|
+
const twice = new Set<string>()
|
|
139
|
+
for (const entry of filters) {
|
|
140
|
+
if (!entry.column) continue
|
|
141
|
+
if (seen.has(entry.column)) twice.add(entry.column)
|
|
142
|
+
seen.add(entry.column)
|
|
143
|
+
}
|
|
144
|
+
return [...twice]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type ParsedFilters =
|
|
148
|
+
| { ok: true; filters: Record<string, WireFilter | string> }
|
|
149
|
+
| { ok: false; error: string }
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validate what arrived on the query string.
|
|
153
|
+
*
|
|
154
|
+
* Both forms are accepted, because both are in use: a **bare scalar** means
|
|
155
|
+
* `contains` and is what every caller sent before operators existed, and
|
|
156
|
+
* `{op, value}` is the new one. An unknown operator is an error rather than a
|
|
157
|
+
* dropped clause, for the reason in this module's header.
|
|
158
|
+
*/
|
|
159
|
+
export function parseFilters(raw: unknown): ParsedFilters {
|
|
160
|
+
if (raw === null || raw === undefined) return { ok: true, filters: {} }
|
|
161
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
162
|
+
return { ok: false, error: 'filters must be an object' }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const filters: Record<string, WireFilter | string> = {}
|
|
166
|
+
for (const [column, value] of Object.entries(
|
|
167
|
+
raw as Record<string, unknown>,
|
|
168
|
+
)) {
|
|
169
|
+
const parsed = parseOne(column, value)
|
|
170
|
+
if ('error' in parsed) return { ok: false, error: parsed.error }
|
|
171
|
+
if (parsed.filter !== null) filters[column] = parsed.filter
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, filters }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseOne(
|
|
177
|
+
column: string,
|
|
178
|
+
value: unknown,
|
|
179
|
+
): { filter: WireFilter | string | null } | { error: string } {
|
|
180
|
+
// The pre-operator form. An empty string is "no filter", which is what a
|
|
181
|
+
// cleared text box has always sent.
|
|
182
|
+
if (value === null || typeof value !== 'object') {
|
|
183
|
+
if (value === null || value === undefined || value === '') {
|
|
184
|
+
return { filter: null }
|
|
185
|
+
}
|
|
186
|
+
return { filter: String(value) }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { op, value: operand } = value as { op?: unknown; value?: unknown }
|
|
190
|
+
if (!isFilterOp(op)) {
|
|
191
|
+
return {
|
|
192
|
+
error: `unknown filter operator ${JSON.stringify(op)} on ${column} — expected one of ${FILTER_OPS.join(', ')}`,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!opTakesValue(op)) return { filter: { op } }
|
|
196
|
+
if (operand === null || operand === undefined || operand === '') {
|
|
197
|
+
return { filter: null }
|
|
198
|
+
}
|
|
199
|
+
return { filter: { op, value: String(operand) } }
|
|
200
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a spreadsheet into statements: which CSV column feeds which database
|
|
3
|
+
* column, what stops an import before it starts, and what one row edit changes.
|
|
4
|
+
*
|
|
5
|
+
* **Pure** — see the note at the top of `coerce.ts`. The import dialog runs
|
|
6
|
+
* `autoMap` and `blockingIssues` in the browser to show the mapping before
|
|
7
|
+
* anything is sent, and the server runs the same two functions on what arrives,
|
|
8
|
+
* because a client-side check is a convenience and never a guarantee.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { type ColumnMeta, omittableOnInsert, sameValue } from './coerce'
|
|
12
|
+
|
|
13
|
+
/** A column as both sides refer to it: the raw database name, plus its meta. */
|
|
14
|
+
export interface PlanColumn {
|
|
15
|
+
name: string
|
|
16
|
+
meta: ColumnMeta
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** CSV header → database column name, or `null` for "do not import". */
|
|
20
|
+
export type ColumnMapping = Record<string, string | null>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Match headers to columns without asking the user first.
|
|
24
|
+
*
|
|
25
|
+
* Exact name wins, then a case- and separator-insensitive match, so
|
|
26
|
+
* `Courier Name`, `courier_name` and `courierName` all find `courier_name`.
|
|
27
|
+
* Nothing fuzzier: a near-miss that silently loads the wrong column is worse
|
|
28
|
+
* than an unmapped one the dialog can ask about.
|
|
29
|
+
*
|
|
30
|
+
* A database column is claimed at most once. Two headers that normalise to the
|
|
31
|
+
* same name would otherwise both map to it, and the second would quietly win.
|
|
32
|
+
*/
|
|
33
|
+
export function autoMap(
|
|
34
|
+
csvHeaders: readonly string[],
|
|
35
|
+
dbColumns: readonly string[],
|
|
36
|
+
): ColumnMapping {
|
|
37
|
+
const mapping: ColumnMapping = {}
|
|
38
|
+
const claimed = new Set<string>()
|
|
39
|
+
|
|
40
|
+
const byExact = new Map(dbColumns.map(c => [c, c]))
|
|
41
|
+
const byNormal = new Map<string, string>()
|
|
42
|
+
for (const c of dbColumns) {
|
|
43
|
+
const key = normalize(c)
|
|
44
|
+
// First declaration wins, so the mapping does not depend on column order
|
|
45
|
+
// among columns that normalise alike.
|
|
46
|
+
if (!byNormal.has(key)) byNormal.set(key, c)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const header of csvHeaders) {
|
|
50
|
+
const match = byExact.get(header) ?? byNormal.get(normalize(header)) ?? null
|
|
51
|
+
mapping[header] = match && !claimed.has(match) ? match : null
|
|
52
|
+
if (mapping[header]) claimed.add(match!)
|
|
53
|
+
}
|
|
54
|
+
return mapping
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalize(name: string): string {
|
|
58
|
+
return name.toLowerCase().replace(/[\s_-]+/g, '')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PlanIssue {
|
|
62
|
+
column: string
|
|
63
|
+
code: 'required_unmapped' | 'unknown_column' | 'duplicate_target'
|
|
64
|
+
message: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What makes this mapping unrunnable, as opposed to merely lossy.
|
|
69
|
+
*
|
|
70
|
+
* A column left out of the mapping is fine when the database can fill it in —
|
|
71
|
+
* an auto-increment key, a default, or a nullable column. A NOT NULL column
|
|
72
|
+
* with no default and no mapping is not: every row would fail at the database,
|
|
73
|
+
* one at a time, after the import had already started.
|
|
74
|
+
*/
|
|
75
|
+
export function blockingIssues(
|
|
76
|
+
mapping: ColumnMapping,
|
|
77
|
+
columns: readonly PlanColumn[],
|
|
78
|
+
): PlanIssue[] {
|
|
79
|
+
const issues: PlanIssue[] = []
|
|
80
|
+
const known = new Set(columns.map(c => c.name))
|
|
81
|
+
const targets = Object.values(mapping).filter(Boolean) as string[]
|
|
82
|
+
|
|
83
|
+
for (const target of new Set(targets)) {
|
|
84
|
+
if (!known.has(target)) {
|
|
85
|
+
issues.push({
|
|
86
|
+
column: target,
|
|
87
|
+
code: 'unknown_column',
|
|
88
|
+
message: `no column named ${target}`,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
if (targets.filter(t => t === target).length > 1) {
|
|
92
|
+
issues.push({
|
|
93
|
+
column: target,
|
|
94
|
+
code: 'duplicate_target',
|
|
95
|
+
message: `two headers are mapped to ${target}`,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const mapped = new Set(targets)
|
|
101
|
+
for (const column of columns) {
|
|
102
|
+
if (mapped.has(column.name)) continue
|
|
103
|
+
if (omittableOnInsert(column.meta)) continue
|
|
104
|
+
issues.push({
|
|
105
|
+
column: column.name,
|
|
106
|
+
code: 'required_unmapped',
|
|
107
|
+
message: `${column.name} cannot be null and has no default, so it must be mapped`,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return issues
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface UpdatePlanInput {
|
|
115
|
+
/** The row as the editor last saw it, keyed by raw column name. */
|
|
116
|
+
original: Record<string, unknown>
|
|
117
|
+
/** Edited values. **An absent key means unchanged** — see `coerce.ts`. */
|
|
118
|
+
edits: Record<string, unknown>
|
|
119
|
+
/** The identity columns, from `describeIdentity`. */
|
|
120
|
+
identity: readonly string[]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface UpdatePlanResult {
|
|
124
|
+
/** Columns whose value actually differs, and the value to write. */
|
|
125
|
+
set: Record<string, unknown>
|
|
126
|
+
/** The identity predicate, taken from `original`. */
|
|
127
|
+
where: Record<string, unknown>
|
|
128
|
+
/** Edited keys that matched what was already there. */
|
|
129
|
+
unchanged: string[]
|
|
130
|
+
/** Identity columns `original` does not carry — the plan is unusable. */
|
|
131
|
+
missingIdentity: string[]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One row edit, as the two halves of an UPDATE.
|
|
136
|
+
*
|
|
137
|
+
* `where` comes from `original` and never from `edits`, which is the point of
|
|
138
|
+
* separating them: editing a primary key has to find the row by its *old* value
|
|
139
|
+
* and set the new one, and a plan built from the edited row would look for a
|
|
140
|
+
* row that does not exist yet.
|
|
141
|
+
*
|
|
142
|
+
* Unchanged columns are dropped from `set`. That is not only economy — a `set`
|
|
143
|
+
* carrying every column makes two people editing different columns of the same
|
|
144
|
+
* row collide, and the whole reason to send a narrow statement is that they
|
|
145
|
+
* should not.
|
|
146
|
+
*/
|
|
147
|
+
export function updatePlan(input: UpdatePlanInput): UpdatePlanResult {
|
|
148
|
+
const set: Record<string, unknown> = {}
|
|
149
|
+
const unchanged: string[] = []
|
|
150
|
+
|
|
151
|
+
for (const [column, value] of Object.entries(input.edits)) {
|
|
152
|
+
if (sameValue(input.original[column], value)) unchanged.push(column)
|
|
153
|
+
else set[column] = value
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const where: Record<string, unknown> = {}
|
|
157
|
+
const missingIdentity: string[] = []
|
|
158
|
+
for (const column of input.identity) {
|
|
159
|
+
if (!(column in input.original)) missingIdentity.push(column)
|
|
160
|
+
else where[column] = input.original[column]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { set, where, unchanged, missingIdentity }
|
|
164
|
+
}
|