@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.7

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 (51) hide show
  1. package/package.json +4 -4
  2. package/src/access.ts +188 -0
  3. package/src/client/api.ts +279 -0
  4. package/src/client/bulk.ts +357 -0
  5. package/src/client/cell.ts +139 -0
  6. package/src/client/confirm.ts +203 -0
  7. package/src/client/csv-commit.ts +185 -0
  8. package/src/client/csv-map.ts +274 -0
  9. package/src/client/csv-model.ts +420 -0
  10. package/src/client/csv-pick.ts +54 -0
  11. package/src/client/csv-preview.ts +89 -0
  12. package/src/client/csv.ts +104 -0
  13. package/src/client/dom.ts +164 -0
  14. package/src/client/edit-session.ts +219 -0
  15. package/src/client/editors.ts +283 -0
  16. package/src/client/filter-builder.ts +198 -0
  17. package/src/client/fk.ts +269 -0
  18. package/src/client/grid-body.ts +106 -0
  19. package/src/client/grid-header.ts +65 -0
  20. package/src/client/grid-rowbar.ts +64 -0
  21. package/src/client/grid.ts +468 -0
  22. package/src/client/meta.ts +185 -0
  23. package/src/client/page.ts +332 -0
  24. package/src/client/panel.ts +296 -0
  25. package/src/client/relations.ts +205 -0
  26. package/src/client/save.ts +205 -0
  27. package/src/client/sidebar.ts +110 -0
  28. package/src/client/state.ts +218 -0
  29. package/src/client/statusbar.ts +130 -0
  30. package/src/client/structure.ts +234 -0
  31. package/src/client/tabs.ts +219 -0
  32. package/src/client/tabstrip.ts +127 -0
  33. package/src/client.ts +376 -160
  34. package/src/endpoints/common.ts +122 -0
  35. package/src/endpoints/graph.ts +148 -0
  36. package/src/endpoints/import.ts +89 -0
  37. package/src/endpoints/read.ts +173 -0
  38. package/src/endpoints/rows.ts +435 -0
  39. package/src/identity.ts +391 -0
  40. package/src/index.ts +40 -45
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +64 -81
  44. package/src/shared/coerce.ts +399 -0
  45. package/src/shared/csv.ts +186 -0
  46. package/src/shared/filters.ts +200 -0
  47. package/src/shared/plan.ts +173 -0
  48. package/src/shell.ts +187 -0
  49. package/src/validate.ts +295 -0
  50. package/src/credential.ts +0 -26
  51. package/src/endpoints.ts +0 -48
@@ -0,0 +1,186 @@
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` — and, unlike the rest of
5
+ * `shared/`, **browser-only**. It was written when the server re-parsed the
6
+ * text it was sent; `endpoints/import.ts` takes records that are already mapped
7
+ * and coerced, so no CSV text ever reaches it. `client/csv-model.ts` is the
8
+ * only caller. The file stays here because `client/safety.test.ts` holds
9
+ * `shared/` to the same no-framework-imports rule as `client/`, and because a
10
+ * server-side importer would want exactly this parser back.
11
+ *
12
+ * ## Why not `parseCSVRows` from `orm/adapters/base.ts:1071`
13
+ *
14
+ * It was read first, and reusing it was the intent. Four things stop it, and
15
+ * the first two are correctness rather than taste:
16
+ *
17
+ * 1. **A quote anywhere opens a quoted field.** `he said "hi", ok` parses as
18
+ * one field `he said hi, ok` rather than two, because the branch is
19
+ * `if (c === '"') inQuotes = true` with no check that the field is empty.
20
+ * RFC 4180 only gives `"` meaning at the start of a field.
21
+ * 2. **No BOM handling.** A file saved by Excel begins ``, so the first
22
+ * header comes back as `id` and matches no column — the single most
23
+ * common import failure there is.
24
+ * 3. Comma only. A European export is `;`-delimited and a database dump is
25
+ * often tab-delimited; both parse as one column per row and then fail with
26
+ * a message about the header.
27
+ * 4. It returns `string[][]` with a blank final row dropped by a heuristic
28
+ * (`row.length === 1 && row[0] === ''`), which also drops a legitimate
29
+ * single-column row whose value is empty.
30
+ *
31
+ * Fixing it in place would change `importCSV`'s behaviour for every existing
32
+ * caller of the ORM, which is a separate decision from adding an importer to
33
+ * the explorer. This parser is the explorer's; if the ORM's is ever replaced,
34
+ * this is the implementation to move.
35
+ */
36
+
37
+ /** The delimiters `sniffDelimiter` will consider, in preference order. */
38
+ const CANDIDATE_DELIMITERS = [',', ';', '\t', '|'] as const
39
+
40
+ export type Delimiter = (typeof CANDIDATE_DELIMITERS)[number]
41
+
42
+ /** Strip a UTF-8 BOM. Excel writes one; nothing downstream expects it. */
43
+ export function stripBOM(text: string): string {
44
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
45
+ }
46
+
47
+ /**
48
+ * Which delimiter this file uses.
49
+ *
50
+ * Counts occurrences **outside quotes** in the first few lines and takes the
51
+ * candidate whose count is both non-zero and most consistent line to line. A
52
+ * naive `indexOf` count picks `,` for a `;`-delimited file the moment one
53
+ * quoted address contains a comma, which is the common case rather than an
54
+ * exotic one.
55
+ */
56
+ export function sniffDelimiter(text: string): Delimiter {
57
+ const sample = stripBOM(text)
58
+ let best: Delimiter = ','
59
+ let bestScore = -1
60
+
61
+ for (const candidate of CANDIDATE_DELIMITERS) {
62
+ const counts = countPerLine(sample, candidate, 20)
63
+ if (!counts.length || counts[0] === 0) continue
64
+ // Consistency first, frequency second: a delimiter that appears the same
65
+ // number of times on every line is a delimiter; one that appears a varying
66
+ // number of times is text.
67
+ const consistent = counts.every(n => n === counts[0])
68
+ const score = (consistent ? 1000 : 0) + counts[0]!
69
+ if (score > bestScore) {
70
+ bestScore = score
71
+ best = candidate
72
+ }
73
+ }
74
+ return best
75
+ }
76
+
77
+ /** Occurrences of `delimiter` per line, outside quotes, for the first `max` lines. */
78
+ function countPerLine(text: string, delimiter: string, max: number): number[] {
79
+ const counts: number[] = []
80
+ let inQuotes = false
81
+ let count = 0
82
+ let atFieldStart = true
83
+
84
+ for (let i = 0; i < text.length && counts.length < max; i++) {
85
+ const c = text[i]
86
+ if (inQuotes) {
87
+ if (c === '"' && text[i + 1] === '"') i++
88
+ else if (c === '"') inQuotes = false
89
+ continue
90
+ }
91
+ if (c === '"' && atFieldStart) {
92
+ inQuotes = true
93
+ atFieldStart = false
94
+ } else if (c === delimiter) {
95
+ count++
96
+ atFieldStart = true
97
+ } else if (c === '\n') {
98
+ counts.push(count)
99
+ count = 0
100
+ atFieldStart = true
101
+ } else if (c !== '\r') {
102
+ atFieldStart = false
103
+ }
104
+ }
105
+ if (count > 0 || counts.length === 0) counts.push(count)
106
+ return counts
107
+ }
108
+
109
+ /**
110
+ * Rows of fields, with nothing interpreted.
111
+ *
112
+ * No trimming and no type guessing: `"01"` and `01` are both the three-, then
113
+ * two-character strings they look like, and turning either into a number is
114
+ * `coerce.ts`'s job once a column is known. A field is only ever `''` when the
115
+ * file said so — this parser has no concept of NULL.
116
+ */
117
+ export function parseCSVRows(text: string, delimiter = ','): string[][] {
118
+ const csv = stripBOM(text)
119
+ const rows: string[][] = []
120
+ let row: string[] = []
121
+ let field = ''
122
+ let inQuotes = false
123
+ let atFieldStart = true
124
+ /** Did any field of the current row open with a quote? */
125
+ let rowQuoted = false
126
+ /** Has anything at all been read since the last row ended? */
127
+ let pending = false
128
+
129
+ const endField = () => {
130
+ row.push(field)
131
+ field = ''
132
+ atFieldStart = true
133
+ pending = true
134
+ }
135
+
136
+ const endRow = () => {
137
+ endField()
138
+ // A blank line is not a row: it is what a trailing newline, or a stray one
139
+ // in the middle of a file, leaves behind. One field, empty, and never
140
+ // quoted is the only shape it can take — a line reading `""` is a real row
141
+ // holding one empty value, which is why `rowQuoted` is tracked.
142
+ const blank = row.length === 1 && row[0] === '' && !rowQuoted
143
+ if (!blank) rows.push(row)
144
+ row = []
145
+ rowQuoted = false
146
+ pending = false
147
+ }
148
+
149
+ for (let i = 0; i < csv.length; i++) {
150
+ const c = csv[i]
151
+ if (inQuotes) {
152
+ if (c === '"' && csv[i + 1] === '"') {
153
+ field += '"'
154
+ i++
155
+ } else if (c === '"') {
156
+ inQuotes = false
157
+ } else {
158
+ // Includes newlines: a quoted field spans lines, which is the reason
159
+ // a line-splitting "parser" cannot be used for CSV at all.
160
+ field += c
161
+ }
162
+ continue
163
+ }
164
+
165
+ if (c === '"' && atFieldStart) {
166
+ inQuotes = true
167
+ rowQuoted = true
168
+ atFieldStart = false
169
+ pending = true
170
+ } else if (c === delimiter) {
171
+ endField()
172
+ } else if (c === '\n' || c === '\r') {
173
+ if (c === '\r' && csv[i + 1] === '\n') i++
174
+ endRow()
175
+ } else {
176
+ field += c
177
+ atFieldStart = false
178
+ pending = true
179
+ }
180
+ }
181
+
182
+ // Anything still buffered is a final row with no line terminator.
183
+ if (pending) endRow()
184
+
185
+ return rows
186
+ }
@@ -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,173 @@
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
+ /**
58
+ * A name with case and separators removed.
59
+ *
60
+ * Exported because `client/meta.ts` compares *table* names the same way and
61
+ * held a byte-identical private copy called `flatten`. Both are in the browser
62
+ * bundle, so it was one rule written twice: a change to what counts as "the
63
+ * same name" would have fixed CSV auto-mapping and left foreign-key visibility
64
+ * on the old one.
65
+ */
66
+ export function normalize(name: string): string {
67
+ return name.toLowerCase().replace(/[\s_-]+/g, '')
68
+ }
69
+
70
+ export interface PlanIssue {
71
+ column: string
72
+ code: 'required_unmapped' | 'unknown_column' | 'duplicate_target'
73
+ message: string
74
+ }
75
+
76
+ /**
77
+ * What makes this mapping unrunnable, as opposed to merely lossy.
78
+ *
79
+ * A column left out of the mapping is fine when the database can fill it in —
80
+ * an auto-increment key, a default, or a nullable column. A NOT NULL column
81
+ * with no default and no mapping is not: every row would fail at the database,
82
+ * one at a time, after the import had already started.
83
+ */
84
+ export function blockingIssues(
85
+ mapping: ColumnMapping,
86
+ columns: readonly PlanColumn[],
87
+ ): PlanIssue[] {
88
+ const issues: PlanIssue[] = []
89
+ const known = new Set(columns.map(c => c.name))
90
+ const targets = Object.values(mapping).filter(Boolean) as string[]
91
+
92
+ for (const target of new Set(targets)) {
93
+ if (!known.has(target)) {
94
+ issues.push({
95
+ column: target,
96
+ code: 'unknown_column',
97
+ message: `no column named ${target}`,
98
+ })
99
+ }
100
+ if (targets.filter(t => t === target).length > 1) {
101
+ issues.push({
102
+ column: target,
103
+ code: 'duplicate_target',
104
+ message: `two headers are mapped to ${target}`,
105
+ })
106
+ }
107
+ }
108
+
109
+ const mapped = new Set(targets)
110
+ for (const column of columns) {
111
+ if (mapped.has(column.name)) continue
112
+ if (omittableOnInsert(column.meta)) continue
113
+ issues.push({
114
+ column: column.name,
115
+ code: 'required_unmapped',
116
+ message: `${column.name} cannot be null and has no default, so it must be mapped`,
117
+ })
118
+ }
119
+
120
+ return issues
121
+ }
122
+
123
+ export interface UpdatePlanInput {
124
+ /** The row as the editor last saw it, keyed by raw column name. */
125
+ original: Record<string, unknown>
126
+ /** Edited values. **An absent key means unchanged** — see `coerce.ts`. */
127
+ edits: Record<string, unknown>
128
+ /** The identity columns, from `describeIdentity`. */
129
+ identity: readonly string[]
130
+ }
131
+
132
+ export interface UpdatePlanResult {
133
+ /** Columns whose value actually differs, and the value to write. */
134
+ set: Record<string, unknown>
135
+ /** The identity predicate, taken from `original`. */
136
+ where: Record<string, unknown>
137
+ /** Edited keys that matched what was already there. */
138
+ unchanged: string[]
139
+ /** Identity columns `original` does not carry — the plan is unusable. */
140
+ missingIdentity: string[]
141
+ }
142
+
143
+ /**
144
+ * One row edit, as the two halves of an UPDATE.
145
+ *
146
+ * `where` comes from `original` and never from `edits`, which is the point of
147
+ * separating them: editing a primary key has to find the row by its *old* value
148
+ * and set the new one, and a plan built from the edited row would look for a
149
+ * row that does not exist yet.
150
+ *
151
+ * Unchanged columns are dropped from `set`. That is not only economy — a `set`
152
+ * carrying every column makes two people editing different columns of the same
153
+ * row collide, and the whole reason to send a narrow statement is that they
154
+ * should not.
155
+ */
156
+ export function updatePlan(input: UpdatePlanInput): UpdatePlanResult {
157
+ const set: Record<string, unknown> = {}
158
+ const unchanged: string[] = []
159
+
160
+ for (const [column, value] of Object.entries(input.edits)) {
161
+ if (sameValue(input.original[column], value)) unchanged.push(column)
162
+ else set[column] = value
163
+ }
164
+
165
+ const where: Record<string, unknown> = {}
166
+ const missingIdentity: string[] = []
167
+ for (const column of input.identity) {
168
+ if (!(column in input.original)) missingIdentity.push(column)
169
+ else where[column] = input.original[column]
170
+ }
171
+
172
+ return { set, where, unchanged, missingIdentity }
173
+ }