@adecore/database 0.21.1 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assign-ref.d.ts +2 -0
- package/dist/assign-ref.js +9 -0
- package/dist/client/client.js +14 -15
- package/dist/client/types.d.ts +1 -0
- package/dist/client/types.js +2 -0
- package/dist/connections/ConnectionForm.js +7 -2
- package/dist/connections/ConnectionManager.js +5 -5
- package/dist/connections/EngineIcon.js +0 -1
- package/dist/console/QueryConsole.js +12 -4
- package/dist/console/editing.js +2 -4
- package/dist/console/useConsoleHistory.js +3 -2
- package/dist/ddl/alter-mysql.js +2 -8
- package/dist/ddl/alter-sqlite.js +1 -1
- package/dist/ddl/column.js +5 -8
- package/dist/ddl/create.d.ts +1 -0
- package/dist/ddl/create.js +1 -1
- package/dist/ddl/diff.js +25 -23
- package/dist/designer/ColumnsEditor.js +8 -6
- package/dist/designer/ForeignKeysEditor.js +5 -1
- package/dist/designer/OptionsEditor.js +12 -3
- package/dist/designer/SqlPreview.d.ts +1 -5
- package/dist/designer/SqlPreview.js +1 -1
- package/dist/designer/TableDesigner.js +7 -6
- package/dist/explorer/DatabaseExplorer.js +12 -13
- package/dist/explorer/TableDialog.js +1 -1
- package/dist/explorer/tree.js +6 -16
- package/dist/grid/DataGrid.js +11 -14
- package/dist/grid/EnumPicker.js +4 -3
- package/dist/grid/GridHeaderCell.d.ts +1 -1
- package/dist/grid/GridHeaderCell.js +2 -2
- package/dist/grid/RecordView.js +2 -1
- package/dist/grid/ValueDock.js +2 -6
- package/dist/grid/aggregates.js +2 -3
- package/dist/grid/column-layout.js +3 -2
- package/dist/grid/copy-formats.js +2 -3
- package/dist/grid/display.d.ts +0 -1
- package/dist/grid/display.js +1 -3
- package/dist/grid/layout.js +6 -11
- package/dist/host/helper-path.js +8 -0
- package/dist/host/host.js +11 -16
- package/dist/host/validate.js +44 -43
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +5 -2
- package/dist/structure/StructureView.js +5 -6
- package/dist/table/TableView.js +19 -12
- package/dist/table/command-field.js +8 -12
- package/dist/table/grid-rows.js +2 -1
- package/dist/table/layout-store.js +5 -2
- package/dist/table/pending.js +11 -3
- package/dist/table/useTableTransfer.js +6 -5
- package/dist/testing/fake.js +7 -9
- package/dist/value/ValueEditor.js +1 -1
- package/dist/value/draft.js +1 -6
- package/dist/value/json.js +4 -4
- package/dist/workbench/DatabaseWorkbench.js +12 -5
- package/dist/workbench/tabs.js +3 -4
- package/package.json +6 -7
- package/src/assign-ref.ts +10 -0
- package/src/client/client.ts +17 -18
- package/src/client/types.ts +3 -0
- package/src/connections/ConnectionForm.tsx +62 -47
- package/src/connections/ConnectionManager.tsx +6 -5
- package/src/connections/EngineIcon.tsx +0 -2
- package/src/console/QueryConsole.tsx +52 -31
- package/src/console/editing.ts +2 -4
- package/src/console/useConsoleHistory.ts +5 -2
- package/src/ddl/alter-mysql.ts +3 -11
- package/src/ddl/alter-sqlite.ts +1 -1
- package/src/ddl/column.ts +5 -7
- package/src/ddl/create.ts +1 -1
- package/src/ddl/diff.ts +30 -25
- package/src/designer/ColumnsEditor.tsx +145 -125
- package/src/designer/ForeignKeysEditor.tsx +44 -27
- package/src/designer/OptionsEditor.tsx +95 -75
- package/src/designer/SqlPreview.tsx +2 -3
- package/src/designer/TableDesigner.tsx +7 -6
- package/src/explorer/DatabaseExplorer.tsx +26 -31
- package/src/explorer/TableDialog.tsx +1 -1
- package/src/explorer/tree.ts +8 -19
- package/src/grid/DataGrid.tsx +11 -14
- package/src/grid/EnumPicker.tsx +4 -3
- package/src/grid/GridHeaderCell.tsx +2 -3
- package/src/grid/RecordView.tsx +2 -1
- package/src/grid/ValueDock.tsx +2 -5
- package/src/grid/aggregates.ts +2 -4
- package/src/grid/column-layout.ts +3 -2
- package/src/grid/copy-formats.ts +2 -3
- package/src/grid/display.ts +1 -5
- package/src/grid/layout.ts +10 -11
- package/src/host/helper-path.ts +8 -8
- package/src/host/host.ts +11 -16
- package/src/host/validate.ts +45 -43
- package/src/protocol/messages.ts +0 -1
- package/src/sql.ts +7 -2
- package/src/structure/StructureView.tsx +7 -8
- package/src/table/CommandField.tsx +0 -1
- package/src/table/TableView.tsx +48 -34
- package/src/table/command-field.ts +9 -12
- package/src/table/grid-rows.ts +2 -7
- package/src/table/layout-store.ts +6 -3
- package/src/table/pending.ts +12 -7
- package/src/table/useTableTransfer.ts +7 -5
- package/src/testing/fake.ts +8 -12
- package/src/value/ValueEditor.tsx +1 -1
- package/src/value/draft.ts +1 -6
- package/src/value/json.ts +4 -4
- package/src/workbench/DatabaseWorkbench.tsx +14 -6
- package/src/workbench/DesignerTab.tsx +1 -1
- package/src/workbench/tabs.ts +5 -8
package/src/grid/display.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { formatBytes, formatNumeral } from '@adecore/ui/format';
|
|
2
2
|
import type { BinaryValue, Cell, EditValue, ValueKind } from '../protocol/index.ts';
|
|
3
|
+
import { isNumericKind } from '../sql.ts';
|
|
3
4
|
|
|
4
5
|
/* What a cell shows: a cell of a result, or the column's default for a value an edit set to `DEFAULT`. */
|
|
5
6
|
export type Shown = Cell | { readonly kind: 'default' };
|
|
@@ -22,11 +23,6 @@ export const DISPLAY_LIMIT = 200;
|
|
|
22
23
|
/* How many bytes of a binary value the cell spells out. */
|
|
23
24
|
export const BINARY_PREVIEW_BYTES = 8;
|
|
24
25
|
|
|
25
|
-
const NUMERIC_KINDS: ReadonlySet<ValueKind> = new Set(['integer', 'decimal', 'float']);
|
|
26
|
-
|
|
27
|
-
/* Whether the cells of a column of this kind sit against the end of their cell, as numbers do. */
|
|
28
|
-
export const isNumericKind = (kind: ValueKind): boolean => NUMERIC_KINDS.has(kind);
|
|
29
|
-
|
|
30
26
|
const ELLIPSIS = '…';
|
|
31
27
|
|
|
32
28
|
const isObject = (cell: Shown): cell is Exclude<Shown, null | boolean | number | string> => cell !== null && typeof cell === 'object';
|
package/src/grid/layout.ts
CHANGED
|
@@ -78,19 +78,18 @@ export const gutterWidth = (rowCount: number): number => Math.max(GUTTER_MIN_WID
|
|
|
78
78
|
|
|
79
79
|
export const clampColumnWidth = (width: number): number => Math.max(MIN_COLUMN_WIDTH, Math.min(Math.ceil(width), MAX_COLUMN_WIDTH));
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
const bodyWidth = (texts: readonly string[]): number => texts.reduce((longest, text) => Math.max(longest, text.length), 0) * CHARACTER_WIDTH;
|
|
82
|
+
|
|
83
|
+
/* `keyed` makes room for the key icon in front of the name. */
|
|
84
|
+
const headerWidth = (header: string, keyed: boolean): number => header.length * CHARACTER_WIDTH + (keyed ? KEY_ICON_ROOM : 0) + SORT_ROOM;
|
|
85
|
+
|
|
86
|
+
/* From the longest text among the header and the first rows. */
|
|
87
|
+
export const estimateColumnWidth = (header: string, samples: readonly string[], keyed: boolean): number =>
|
|
88
|
+
clampColumnWidth(Math.max(bodyWidth(samples.slice(0, SAMPLE_ROWS)), headerWidth(header, keyed)) + CELL_PADDING);
|
|
87
89
|
|
|
88
90
|
/* Wide enough for the header and every text of the column, up to `MAX_FIT_WIDTH`. */
|
|
89
|
-
export const fitColumnWidth = (header: string, texts: readonly string[], keyed: boolean): number =>
|
|
90
|
-
|
|
91
|
-
const named = header.length * CHARACTER_WIDTH + (keyed ? KEY_ICON_ROOM : 0) + SORT_ROOM;
|
|
92
|
-
return Math.max(MIN_COLUMN_WIDTH, Math.min(Math.ceil(Math.max(body, named) + CELL_PADDING), MAX_FIT_WIDTH));
|
|
93
|
-
};
|
|
91
|
+
export const fitColumnWidth = (header: string, texts: readonly string[], keyed: boolean): number =>
|
|
92
|
+
Math.max(MIN_COLUMN_WIDTH, Math.min(Math.ceil(Math.max(bodyWidth(texts), headerWidth(header, keyed)) + CELL_PADDING), MAX_FIT_WIDTH));
|
|
94
93
|
|
|
95
94
|
export const isDoubleClick = (previous: number | null, now: number): boolean => previous !== null && now - previous <= DOUBLE_CLICK_MS;
|
|
96
95
|
|
package/src/host/helper-path.ts
CHANGED
|
@@ -2,14 +2,6 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { dirname, join, sep } from 'node:path';
|
|
4
4
|
|
|
5
|
-
/*
|
|
6
|
-
* Finds the prebuilt helper that npm installed for this machine, in the `@adecore/database-<platform>-<arch>`
|
|
7
|
-
* package, or null when there is none: an unsupported platform, or an install with optional dependencies off.
|
|
8
|
-
*
|
|
9
|
-
* A binary cannot run from inside an Electron archive. The path is mapped from `app.asar` to
|
|
10
|
-
* `app.asar.unpacked`, so the app must unpack the packages (electron-builder: `asarUnpack` with
|
|
11
|
-
* `node_modules/@adecore/database-*` followed by `/**`).
|
|
12
|
-
*/
|
|
13
5
|
export interface HelperPathOptions {
|
|
14
6
|
/* `process.platform` when left out. */
|
|
15
7
|
readonly platform?: string;
|
|
@@ -20,6 +12,14 @@ export interface HelperPathOptions {
|
|
|
20
12
|
readonly exists?: (path: string) => boolean;
|
|
21
13
|
}
|
|
22
14
|
|
|
15
|
+
/*
|
|
16
|
+
* Finds the prebuilt helper that npm installed for this machine, in the `@adecore/database-<platform>-<arch>`
|
|
17
|
+
* package, or null when there is none: an unsupported platform, or an install with optional dependencies off.
|
|
18
|
+
*
|
|
19
|
+
* A binary cannot run from inside an Electron archive. The path is mapped from `app.asar` to
|
|
20
|
+
* `app.asar.unpacked`, so the app must unpack the packages (electron-builder: `asarUnpack` with
|
|
21
|
+
* `node_modules/@adecore/database-*` followed by `/**`).
|
|
22
|
+
*/
|
|
23
23
|
export const helperPath = ({
|
|
24
24
|
platform = process.platform,
|
|
25
25
|
arch = process.arch,
|
package/src/host/host.ts
CHANGED
|
@@ -7,7 +7,6 @@ const DEFAULT_READY_TIMEOUT_MS = 10000;
|
|
|
7
7
|
/* How long a release or a dispose waits for a helper to answer its `close` requests. */
|
|
8
8
|
const CLOSE_GRACE_MS = 2000;
|
|
9
9
|
|
|
10
|
-
/* A failure with the code the caller gets. */
|
|
11
10
|
class Failure extends Error {
|
|
12
11
|
readonly code: DatabaseErrorCode;
|
|
13
12
|
|
|
@@ -41,6 +40,15 @@ const failure = (id: string, code: DatabaseErrorCode, message: string): Database
|
|
|
41
40
|
|
|
42
41
|
const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
43
42
|
|
|
43
|
+
/* `undefined` for a line that is not JSON. */
|
|
44
|
+
const parseLine = (line: string): unknown => {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(line);
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
44
52
|
const isDict = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
45
53
|
|
|
46
54
|
/* Settles when every promise has, or after the grace period; a helper that hangs must not hold up an app that quits. */
|
|
@@ -115,14 +123,7 @@ export const createDatabaseHost = (options: DatabaseHostOptions): DatabaseHost =
|
|
|
115
123
|
};
|
|
116
124
|
|
|
117
125
|
const onHandshake = (run: Run, line: string): void => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
try {
|
|
121
|
-
message = JSON.parse(line);
|
|
122
|
-
} catch {
|
|
123
|
-
abort(run, 'The helper did not start with a ready line.');
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
+
const message = parseLine(line);
|
|
126
127
|
|
|
127
128
|
if (!isDict(message) || message.event !== 'ready') {
|
|
128
129
|
abort(run, 'The helper did not start with a ready line.');
|
|
@@ -140,13 +141,7 @@ export const createDatabaseHost = (options: DatabaseHostOptions): DatabaseHost =
|
|
|
140
141
|
};
|
|
141
142
|
|
|
142
143
|
const onResponse = (run: Run, line: string): void => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
message = JSON.parse(line);
|
|
147
|
-
} catch {
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
144
|
+
const message = parseLine(line);
|
|
150
145
|
|
|
151
146
|
if (!isDict(message) || typeof message.id !== 'string') {
|
|
152
147
|
return;
|
package/src/host/validate.ts
CHANGED
|
@@ -24,6 +24,8 @@ const fail = (message: string): never => {
|
|
|
24
24
|
|
|
25
25
|
const isDict = (value: unknown): value is Dict => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
26
26
|
|
|
27
|
+
const isRequestId = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= MAX_ID_LENGTH;
|
|
28
|
+
|
|
27
29
|
/* Rejects a key nobody asked for, so a typo cannot pass for an option the helper ignores. */
|
|
28
30
|
const shape = (value: unknown, label: string, required: readonly string[], optional: readonly string[] = []): Dict => {
|
|
29
31
|
if (!isDict(value)) {
|
|
@@ -45,13 +47,13 @@ const shape = (value: unknown, label: string, required: readonly string[], optio
|
|
|
45
47
|
return value;
|
|
46
48
|
};
|
|
47
49
|
|
|
48
|
-
const
|
|
50
|
+
const filledText = (value: unknown, label: string): void => {
|
|
49
51
|
if (typeof value !== 'string' || value.length === 0) {
|
|
50
52
|
fail(`"${label}" must be a non-empty string.`);
|
|
51
53
|
}
|
|
52
54
|
};
|
|
53
55
|
|
|
54
|
-
const
|
|
56
|
+
const anyText = (value: unknown, label: string): void => {
|
|
55
57
|
if (typeof value !== 'string') {
|
|
56
58
|
fail(`"${label}" must be a string.`);
|
|
57
59
|
}
|
|
@@ -76,7 +78,7 @@ const oneOf = (allowed: readonly string[]) => (value: unknown, label: string) =>
|
|
|
76
78
|
};
|
|
77
79
|
|
|
78
80
|
const absolutePath = (value: unknown, label: string): void => {
|
|
79
|
-
|
|
81
|
+
filledText(value, label);
|
|
80
82
|
|
|
81
83
|
if (!ABSOLUTE_PATH.test(value as string)) {
|
|
82
84
|
fail(`"${label}" must be an absolute path.`);
|
|
@@ -148,18 +150,18 @@ const tunnel = (input: unknown, label: string): void => {
|
|
|
148
150
|
|
|
149
151
|
if (input.kind === 'ssh') {
|
|
150
152
|
const config = shape(input, label, ['kind', 'host'], ['port', 'user', 'identityFile']);
|
|
151
|
-
|
|
153
|
+
filledText(config.host, `${label}.host`);
|
|
152
154
|
optional(config, 'port', label, port);
|
|
153
|
-
optional(config, 'user', label,
|
|
154
|
-
optional(config, 'identityFile', label,
|
|
155
|
+
optional(config, 'user', label, filledText);
|
|
156
|
+
optional(config, 'identityFile', label, filledText);
|
|
155
157
|
return;
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
if (input.kind === 'docker') {
|
|
159
161
|
const config = shape(input, label, ['kind', 'container'], ['port', 'context']);
|
|
160
|
-
|
|
162
|
+
filledText(config.container, `${label}.container`);
|
|
161
163
|
optional(config, 'port', label, port);
|
|
162
|
-
optional(config, 'context', label,
|
|
164
|
+
optional(config, 'context', label, filledText);
|
|
163
165
|
return;
|
|
164
166
|
}
|
|
165
167
|
|
|
@@ -186,12 +188,12 @@ const connection = (input: unknown, label: string): void => {
|
|
|
186
188
|
|
|
187
189
|
// A Docker tunnel finds the server by its container, so the host is left empty.
|
|
188
190
|
const viaDocker = isDict(config.tunnel) && config.tunnel.kind === 'docker';
|
|
189
|
-
(viaDocker ?
|
|
190
|
-
|
|
191
|
+
(viaDocker ? anyText : filledText)(config.host, `${label}.host`);
|
|
192
|
+
filledText(config.user, `${label}.user`);
|
|
191
193
|
optional(config, 'port', label, port);
|
|
192
|
-
optional(config, 'socket', label,
|
|
193
|
-
optional(config, 'password', label,
|
|
194
|
-
optional(config, 'database', label,
|
|
194
|
+
optional(config, 'socket', label, filledText);
|
|
195
|
+
optional(config, 'password', label, anyText);
|
|
196
|
+
optional(config, 'database', label, anyText);
|
|
195
197
|
optional(config, 'tls', label, oneOf(TLS_MODES));
|
|
196
198
|
optional(config, 'readOnly', label, flag);
|
|
197
199
|
return;
|
|
@@ -229,17 +231,17 @@ const exportSource = (input: unknown, label: string): void => {
|
|
|
229
231
|
|
|
230
232
|
if (input.kind === 'table') {
|
|
231
233
|
const source = shape(input, label, ['kind', 'schema', 'table'], ['where', 'orderBy']);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
optional(source, 'where', label,
|
|
235
|
-
optional(source, 'orderBy', label,
|
|
234
|
+
filledText(source.schema, `${label}.schema`);
|
|
235
|
+
filledText(source.table, `${label}.table`);
|
|
236
|
+
optional(source, 'where', label, anyText);
|
|
237
|
+
optional(source, 'orderBy', label, anyText);
|
|
236
238
|
return;
|
|
237
239
|
}
|
|
238
240
|
|
|
239
241
|
if (input.kind === 'query') {
|
|
240
242
|
const source = shape(input, label, ['kind', 'sql'], ['schema']);
|
|
241
|
-
|
|
242
|
-
optional(source, 'schema', label,
|
|
243
|
+
filledText(source.sql, `${label}.sql`);
|
|
244
|
+
optional(source, 'schema', label, filledText);
|
|
243
245
|
return;
|
|
244
246
|
}
|
|
245
247
|
|
|
@@ -249,9 +251,9 @@ const exportSource = (input: unknown, label: string): void => {
|
|
|
249
251
|
const cellLimit = (params: Dict): void => optional(params, 'cellLimit', 'params', (limit, name) => integer(limit, name, 1, MAX_CELL_LIMIT));
|
|
250
252
|
|
|
251
253
|
const table = (params: Dict): void => {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
254
|
+
filledText(params.session, 'params.session');
|
|
255
|
+
filledText(params.schema, 'params.schema');
|
|
256
|
+
filledText(params.table, 'params.table');
|
|
255
257
|
};
|
|
256
258
|
|
|
257
259
|
const TARGET = ['session', 'schema', 'table'];
|
|
@@ -259,19 +261,19 @@ const TARGET = ['session', 'schema', 'table'];
|
|
|
259
261
|
const PARAMS: { readonly [M in DatabaseMethod]: (input: unknown) => void } = {
|
|
260
262
|
open: (input) => connection(shape(input, 'params', ['connection']).connection, 'params.connection'),
|
|
261
263
|
test: (input) => connection(shape(input, 'params', ['connection']).connection, 'params.connection'),
|
|
262
|
-
close: (input) =>
|
|
263
|
-
schemas: (input) =>
|
|
264
|
+
close: (input) => filledText(shape(input, 'params', ['session']).session, 'params.session'),
|
|
265
|
+
schemas: (input) => filledText(shape(input, 'params', ['session']).session, 'params.session'),
|
|
264
266
|
tables: (input) => {
|
|
265
267
|
const params = shape(input, 'params', ['session', 'schema']);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
+
filledText(params.session, 'params.session');
|
|
269
|
+
filledText(params.schema, 'params.schema');
|
|
268
270
|
},
|
|
269
271
|
structure: (input) => table(shape(input, 'params', TARGET)),
|
|
270
272
|
rows: (input) => {
|
|
271
273
|
const params = shape(input, 'params', [...TARGET, 'offset', 'limit'], ['where', 'orderBy', 'cellLimit']);
|
|
272
274
|
table(params);
|
|
273
|
-
optional(params, 'where', 'params',
|
|
274
|
-
optional(params, 'orderBy', 'params',
|
|
275
|
+
optional(params, 'where', 'params', anyText);
|
|
276
|
+
optional(params, 'orderBy', 'params', anyText);
|
|
275
277
|
integer(params.offset, 'params.offset', 0, Number.MAX_SAFE_INTEGER);
|
|
276
278
|
integer(params.limit, 'params.limit', 1, MAX_LIMIT);
|
|
277
279
|
cellLimit(params);
|
|
@@ -279,13 +281,13 @@ const PARAMS: { readonly [M in DatabaseMethod]: (input: unknown) => void } = {
|
|
|
279
281
|
count: (input) => {
|
|
280
282
|
const params = shape(input, 'params', TARGET, ['where']);
|
|
281
283
|
table(params);
|
|
282
|
-
optional(params, 'where', 'params',
|
|
284
|
+
optional(params, 'where', 'params', anyText);
|
|
283
285
|
},
|
|
284
286
|
cell: (input) => {
|
|
285
287
|
const params = shape(input, 'params', [...TARGET, 'key', 'column']);
|
|
286
288
|
table(params);
|
|
287
289
|
values(params.key, 'params.key', value, true);
|
|
288
|
-
|
|
290
|
+
filledText(params.column, 'params.column');
|
|
289
291
|
},
|
|
290
292
|
apply: (input) => {
|
|
291
293
|
const params = shape(input, 'params', [...TARGET, 'changes']);
|
|
@@ -300,34 +302,34 @@ const PARAMS: { readonly [M in DatabaseMethod]: (input: unknown) => void } = {
|
|
|
300
302
|
},
|
|
301
303
|
execute: (input) => {
|
|
302
304
|
const params = shape(input, 'params', ['session', 'sql'], ['schema', 'limit', 'cellLimit']);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
optional(params, 'schema', 'params',
|
|
305
|
+
filledText(params.session, 'params.session');
|
|
306
|
+
filledText(params.sql, 'params.sql');
|
|
307
|
+
optional(params, 'schema', 'params', filledText);
|
|
306
308
|
optional(params, 'limit', 'params', (limit, name) => integer(limit, name, 1, MAX_LIMIT));
|
|
307
309
|
cellLimit(params);
|
|
308
310
|
},
|
|
309
311
|
page: (input) => {
|
|
310
312
|
const params = shape(input, 'params', ['session', 'sql', 'offset', 'limit'], ['schema', 'cellLimit']);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
optional(params, 'schema', 'params',
|
|
313
|
+
filledText(params.session, 'params.session');
|
|
314
|
+
filledText(params.sql, 'params.sql');
|
|
315
|
+
optional(params, 'schema', 'params', filledText);
|
|
314
316
|
integer(params.offset, 'params.offset', 0, Number.MAX_SAFE_INTEGER);
|
|
315
317
|
integer(params.limit, 'params.limit', 1, MAX_LIMIT);
|
|
316
318
|
cellLimit(params);
|
|
317
319
|
},
|
|
318
320
|
transaction: (input) => {
|
|
319
321
|
const params = shape(input, 'params', ['session', 'action']);
|
|
320
|
-
|
|
322
|
+
filledText(params.session, 'params.session');
|
|
321
323
|
oneOf(TRANSACTION_ACTIONS)(params.action, 'params.action');
|
|
322
324
|
},
|
|
323
325
|
export: (input) => {
|
|
324
326
|
const params = shape(input, 'params', ['session', 'source', 'format', 'path'], ['header', 'tableName']);
|
|
325
|
-
|
|
327
|
+
filledText(params.session, 'params.session');
|
|
326
328
|
exportSource(params.source, 'params.source');
|
|
327
329
|
oneOf(EXPORT_FORMATS)(params.format, 'params.format');
|
|
328
330
|
absolutePath(params.path, 'params.path');
|
|
329
331
|
optional(params, 'header', 'params', flag);
|
|
330
|
-
optional(params, 'tableName', 'params',
|
|
332
|
+
optional(params, 'tableName', 'params', filledText);
|
|
331
333
|
},
|
|
332
334
|
sample: (input) => {
|
|
333
335
|
const params = shape(input, 'params', ['path', 'format', 'header'], ['limit']);
|
|
@@ -350,19 +352,19 @@ const PARAMS: { readonly [M in DatabaseMethod]: (input: unknown) => void } = {
|
|
|
350
352
|
|
|
351
353
|
params.columns.forEach((column, i) => {
|
|
352
354
|
if (column !== null) {
|
|
353
|
-
|
|
355
|
+
filledText(column, `params.columns[${i}]`);
|
|
354
356
|
}
|
|
355
357
|
});
|
|
356
358
|
},
|
|
357
359
|
discover: (input) => {
|
|
358
360
|
const params = shape(input, 'params', ['kind'], ['context']);
|
|
359
361
|
oneOf(['docker'])(params.kind, 'params.kind');
|
|
360
|
-
optional(params, 'context', 'params',
|
|
362
|
+
optional(params, 'context', 'params', filledText);
|
|
361
363
|
},
|
|
362
364
|
cancel: (input) => {
|
|
363
365
|
const params = shape(input, 'params', ['request']);
|
|
364
366
|
|
|
365
|
-
if (
|
|
367
|
+
if (!isRequestId(params.request)) {
|
|
366
368
|
fail(`"params.request" must be a string of 1 to ${MAX_ID_LENGTH} characters.`);
|
|
367
369
|
}
|
|
368
370
|
}
|
|
@@ -370,7 +372,7 @@ const PARAMS: { readonly [M in DatabaseMethod]: (input: unknown) => void } = {
|
|
|
370
372
|
|
|
371
373
|
/* Checks the whole request before anything reaches the helper, which then never has to defend itself against a page. */
|
|
372
374
|
export const parseRequest = (input: unknown): ParsedRequest => {
|
|
373
|
-
const id = isDict(input) &&
|
|
375
|
+
const id = isDict(input) && isRequestId(input.id) ? input.id : '';
|
|
374
376
|
|
|
375
377
|
try {
|
|
376
378
|
const envelope = shape(input, 'request', ['id', 'method', 'params']);
|
package/src/protocol/messages.ts
CHANGED
|
@@ -6,7 +6,6 @@ import type { Cell, EditValue, Value } from './values.ts';
|
|
|
6
6
|
/* Raised whenever a message changes shape, so a host refuses a helper from another release. */
|
|
7
7
|
export const PROTOCOL_VERSION = 3;
|
|
8
8
|
|
|
9
|
-
/* The columns of the row key and their values. */
|
|
10
9
|
export type RowKey = Readonly<Record<string, Value>>;
|
|
11
10
|
|
|
12
11
|
export type RowChange =
|
package/src/sql.ts
CHANGED
|
@@ -18,7 +18,12 @@ export interface SqlTarget {
|
|
|
18
18
|
export type LiteralValue = Cell | Value | { readonly kind: 'default' };
|
|
19
19
|
|
|
20
20
|
const NUMERIC_KINDS: ReadonlySet<ValueKind> = new Set(['integer', 'decimal', 'float']);
|
|
21
|
-
|
|
21
|
+
|
|
22
|
+
/* A number as SQL and most people write it: a sign, digits with an optional fraction, and an exponent. */
|
|
23
|
+
export const NUMERIC_TEXT = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
24
|
+
|
|
25
|
+
/* Whether a value of this kind is a number, which a grid aligns against the end of its cell. */
|
|
26
|
+
export const isNumericKind = (kind: ValueKind): boolean => NUMERIC_KINDS.has(kind);
|
|
22
27
|
const ORDER_ITEM = /\s*(`(?:[^`]|``)+`|"(?:[^"]|"")+"|[A-Za-z_][\w$]*)(?:\s+(asc|desc))?\s*(,|$)/iy;
|
|
23
28
|
|
|
24
29
|
const markOf = (engine: Engine): string => (engine === 'mysql' ? '`' : '"');
|
|
@@ -54,7 +59,7 @@ export const sqlLiteral = (engine: Engine, value: LiteralValue, kind?: ValueKind
|
|
|
54
59
|
return Number.isFinite(value) ? String(value) : 'NULL';
|
|
55
60
|
case 'string': {
|
|
56
61
|
const trimmed = value.trim();
|
|
57
|
-
return kind !== undefined &&
|
|
62
|
+
return kind !== undefined && isNumericKind(kind) && NUMERIC_TEXT.test(trimmed) ? trimmed : stringLiteral(engine, value);
|
|
58
63
|
}
|
|
59
64
|
}
|
|
60
65
|
switch (value.kind) {
|
|
@@ -36,7 +36,6 @@ interface Answer {
|
|
|
36
36
|
|
|
37
37
|
const TH = 'sticky top-0 h-7 bg-surface px-3 text-left text-xs font-medium whitespace-nowrap text-text-faint';
|
|
38
38
|
const TD = 'h-7 border-t border-border-soft px-3 text-xs whitespace-nowrap text-text select-text';
|
|
39
|
-
const MONO = CODE_TEXT;
|
|
40
39
|
|
|
41
40
|
/* The columns, indexes, foreign keys and DDL of one table or view. */
|
|
42
41
|
export function StructureView({ connection, schema, table, toolbarStart, className, ref }: StructureViewProps) {
|
|
@@ -44,7 +43,7 @@ export function StructureView({ connection, schema, table, toolbarStart, classNa
|
|
|
44
43
|
const client = useDatabaseClient();
|
|
45
44
|
const [answer, setAnswer] = useState<Answer | null>(null);
|
|
46
45
|
const [attempt, setAttempt] = useState(0);
|
|
47
|
-
// A schema change reloads
|
|
46
|
+
// A schema change reloads in place: the old answer stays on screen until the new one arrives.
|
|
48
47
|
const [reloads, setReloads] = useState(0);
|
|
49
48
|
const answered = answer !== null && answer.connection === connection && answer.schema === schema && answer.table === table && answer.attempt === attempt;
|
|
50
49
|
const load: Load = answered ? answer.load : { status: 'loading' };
|
|
@@ -194,11 +193,11 @@ function ColumnRow({ column, primary, foreign }: { column: ColumnInfo; primary:
|
|
|
194
193
|
{column.name}
|
|
195
194
|
</span>
|
|
196
195
|
</td>
|
|
197
|
-
<td className={clsx(TD,
|
|
196
|
+
<td className={clsx(TD, CODE_TEXT)}>{column.type}</td>
|
|
198
197
|
<td className={clsx(TD, 'text-text-muted')}>
|
|
199
198
|
<YesNo value={column.nullable} />
|
|
200
199
|
</td>
|
|
201
|
-
<td className={clsx(TD,
|
|
200
|
+
<td className={clsx(TD, CODE_TEXT)}>{column.defaultValue}</td>
|
|
202
201
|
<td className={TD}>
|
|
203
202
|
<span className="flex gap-1">
|
|
204
203
|
{column.autoIncrement && <Pill>{t('structure.autoIncrement')}</Pill>}
|
|
@@ -220,7 +219,7 @@ function IndexesTable({ indexes }: { indexes: readonly IndexInfo[] }) {
|
|
|
220
219
|
{indexes.map((index) => (
|
|
221
220
|
<tr key={index.name}>
|
|
222
221
|
<td className={TD}>{index.name}</td>
|
|
223
|
-
<td className={clsx(TD,
|
|
222
|
+
<td className={clsx(TD, CODE_TEXT)}>{index.columns.join(', ')}</td>
|
|
224
223
|
<td className={TD}>
|
|
225
224
|
<span className="flex gap-1">
|
|
226
225
|
{index.primary && <Pill tone="accent">{t('structure.primary')}</Pill>}
|
|
@@ -252,8 +251,8 @@ function ForeignKeysTable({ foreignKeys }: { foreignKeys: readonly ForeignKeyInf
|
|
|
252
251
|
{foreignKeys.map((key, i) => (
|
|
253
252
|
<tr key={key.name ?? i}>
|
|
254
253
|
<td className={TD}>{key.name}</td>
|
|
255
|
-
<td className={clsx(TD,
|
|
256
|
-
<td className={clsx(TD,
|
|
254
|
+
<td className={clsx(TD, CODE_TEXT)}>{key.columns.join(', ')}</td>
|
|
255
|
+
<td className={clsx(TD, CODE_TEXT)}>{referenceOf(key)}</td>
|
|
257
256
|
<td className={clsx(TD, 'text-text-muted')}>{action(key.onUpdate)}</td>
|
|
258
257
|
<td className={clsx(TD, 'text-text-muted')}>{action(key.onDelete)}</td>
|
|
259
258
|
</tr>
|
|
@@ -273,7 +272,7 @@ function Ddl({ ddl }: { ddl: string | null }) {
|
|
|
273
272
|
<Icon icon={Copy} size={12} />
|
|
274
273
|
{t('structure.copy')}
|
|
275
274
|
</Button>
|
|
276
|
-
<pre className={clsx(
|
|
275
|
+
<pre className={clsx(CODE_TEXT, 'overflow-auto p-3 pr-24 whitespace-pre text-text select-text')}>{ddl}</pre>
|
|
277
276
|
</div>
|
|
278
277
|
);
|
|
279
278
|
}
|
|
@@ -25,7 +25,6 @@ export interface CommandFieldProps {
|
|
|
25
25
|
engine: Engine;
|
|
26
26
|
/* Every change of the chips applies at once, so this is told the whole list each time. */
|
|
27
27
|
onChipsChange(next: Chip[]): void;
|
|
28
|
-
/* "Jump to column" was picked. */
|
|
29
28
|
onJumpToColumn(name: string): void;
|
|
30
29
|
/* The text input, for the owner to focus on Mod+F. */
|
|
31
30
|
inputRef?: Ref<HTMLInputElement>;
|
package/src/table/TableView.tsx
CHANGED
|
@@ -27,7 +27,7 @@ import type { GridSort } from '../grid/sort.ts';
|
|
|
27
27
|
import type { ColumnRequest, FocusedCell, FocusRequest, GridMenuContext } from '../grid/types.ts';
|
|
28
28
|
import { RecordView } from '../grid/RecordView.tsx';
|
|
29
29
|
import { ValueDock } from '../grid/ValueDock.tsx';
|
|
30
|
-
import { valueOfCell, type EditValue, type RowChange, type Value } from '../protocol/index.ts';
|
|
30
|
+
import { valueOfCell, type EditValue, type RowChange, type TableStructure, type Value } from '../protocol/index.ts';
|
|
31
31
|
import type { SqlTarget } from '../sql.ts';
|
|
32
32
|
import { useSchemaChange } from '../use-schema-change.ts';
|
|
33
33
|
import { useStableCallback } from '../use-stable-callback.ts';
|
|
@@ -109,6 +109,19 @@ interface Filters {
|
|
|
109
109
|
|
|
110
110
|
const NO_KEYS: ReadonlySet<string> = new Set();
|
|
111
111
|
|
|
112
|
+
const readOnlyReasonOf = (connection: Connection, structure: TableStructure | null, t: TFunction<'database'>): string | null => {
|
|
113
|
+
if (connection.config.readOnly === true) {
|
|
114
|
+
return t('table.readOnly.connection');
|
|
115
|
+
}
|
|
116
|
+
if (structure?.kind === 'view') {
|
|
117
|
+
return t('table.readOnly.view');
|
|
118
|
+
}
|
|
119
|
+
if (structure !== null && structure.rowKey === null) {
|
|
120
|
+
return t('table.readOnly.noKey');
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
124
|
+
|
|
112
125
|
/* What the strip over the table, or the app's notice, says of an export or an import. */
|
|
113
126
|
const transferText = (found: TransferNotice, t: TFunction<'database'>): string => {
|
|
114
127
|
switch (found.kind) {
|
|
@@ -123,7 +136,6 @@ const transferText = (found: TransferNotice, t: TFunction<'database'>): string =
|
|
|
123
136
|
}
|
|
124
137
|
};
|
|
125
138
|
|
|
126
|
-
/* How much of a value a menu item spells out before it cuts it. */
|
|
127
139
|
const MENU_VALUE_LENGTH = 40;
|
|
128
140
|
|
|
129
141
|
const FOCUS_COMMAND = shortcut('Mod+F');
|
|
@@ -210,16 +222,7 @@ function TableBody({ connection, schema, table, toolbarStart, defaultWhere, defa
|
|
|
210
222
|
const loaded = rowsLoad.value?.result ?? null;
|
|
211
223
|
const loadedOffset = rowsLoad.value?.offset ?? 0;
|
|
212
224
|
|
|
213
|
-
const readOnlyReason =
|
|
214
|
-
connection.config.readOnly === true
|
|
215
|
-
? t('table.readOnly.connection')
|
|
216
|
-
: structure === null
|
|
217
|
-
? null
|
|
218
|
-
: structure.kind === 'view'
|
|
219
|
-
? t('table.readOnly.view')
|
|
220
|
-
: structure.rowKey === null
|
|
221
|
-
? t('table.readOnly.noKey')
|
|
222
|
-
: null;
|
|
225
|
+
const readOnlyReason = readOnlyReasonOf(connection, structure, t);
|
|
223
226
|
const editable = readOnlyReason === null && structure !== null && loaded !== null && !rowsLoad.loading;
|
|
224
227
|
|
|
225
228
|
const columns = useMemo(() => (loaded === null ? [] : buildGridColumns(loaded, structure)), [loaded, structure]);
|
|
@@ -342,7 +345,7 @@ function TableBody({ connection, schema, table, toolbarStart, defaultWhere, defa
|
|
|
342
345
|
}
|
|
343
346
|
};
|
|
344
347
|
|
|
345
|
-
/*
|
|
348
|
+
/* A change that leaves the SQL alone only remembers the filters and keeps the page. */
|
|
346
349
|
const applyChips = (next: readonly Chip[]): void => {
|
|
347
350
|
const where = chipsToWhere(next);
|
|
348
351
|
const orderBy = chipsToOrderBy(engine, next);
|
|
@@ -713,27 +716,7 @@ function TableBody({ connection, schema, table, toolbarStart, defaultWhere, defa
|
|
|
713
716
|
</Button>
|
|
714
717
|
</Banner>
|
|
715
718
|
)}
|
|
716
|
-
{transfer.notice !== null &&
|
|
717
|
-
<Banner
|
|
718
|
-
icon={transfer.notice.kind === 'failed' ? CircleAlert : transfer.notice.kind === 'exporting' ? Download : Check}
|
|
719
|
-
tone={transfer.notice.kind === 'failed' ? 'error' : 'neutral'}
|
|
720
|
-
message={transferText(transfer.notice, t)}
|
|
721
|
-
className="shrink-0 pt-2"
|
|
722
|
-
>
|
|
723
|
-
{transfer.notice.kind === 'exporting' ? (
|
|
724
|
-
<>
|
|
725
|
-
<Spinner size={12} label={t('table.export.running', { format: transfer.notice.format.toUpperCase() })} />
|
|
726
|
-
<Button size="xs" onClick={transfer.cancelExport}>
|
|
727
|
-
{t('table.export.cancel')}
|
|
728
|
-
</Button>
|
|
729
|
-
</>
|
|
730
|
-
) : (
|
|
731
|
-
<Button size="xs" onClick={transfer.dismiss}>
|
|
732
|
-
{t('table.dismiss')}
|
|
733
|
-
</Button>
|
|
734
|
-
)}
|
|
735
|
-
</Banner>
|
|
736
|
-
)}
|
|
719
|
+
{transfer.notice !== null && <TransferBanner notice={transfer.notice} onCancel={transfer.cancelExport} onDismiss={transfer.dismiss} />}
|
|
737
720
|
{loaded === null ? (
|
|
738
721
|
<PanelEmpty busy={notice === null}>{notice === null ? t('table.loading') : t('table.notLoaded')}</PanelEmpty>
|
|
739
722
|
) : (
|
|
@@ -841,3 +824,34 @@ function TableBody({ connection, schema, table, toolbarStart, defaultWhere, defa
|
|
|
841
824
|
</div>
|
|
842
825
|
);
|
|
843
826
|
}
|
|
827
|
+
|
|
828
|
+
interface TransferBannerProps {
|
|
829
|
+
notice: TransferNotice;
|
|
830
|
+
onCancel(): void;
|
|
831
|
+
onDismiss(): void;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function TransferBanner({ notice, onCancel, onDismiss }: TransferBannerProps) {
|
|
835
|
+
const { t } = useTranslation('database');
|
|
836
|
+
return (
|
|
837
|
+
<Banner
|
|
838
|
+
icon={notice.kind === 'failed' ? CircleAlert : notice.kind === 'exporting' ? Download : Check}
|
|
839
|
+
tone={notice.kind === 'failed' ? 'error' : 'neutral'}
|
|
840
|
+
message={transferText(notice, t)}
|
|
841
|
+
className="shrink-0 pt-2"
|
|
842
|
+
>
|
|
843
|
+
{notice.kind === 'exporting' ? (
|
|
844
|
+
<>
|
|
845
|
+
<Spinner size={12} label={t('table.export.running', { format: notice.format.toUpperCase() })} />
|
|
846
|
+
<Button size="xs" onClick={onCancel}>
|
|
847
|
+
{t('table.export.cancel')}
|
|
848
|
+
</Button>
|
|
849
|
+
</>
|
|
850
|
+
) : (
|
|
851
|
+
<Button size="xs" onClick={onDismiss}>
|
|
852
|
+
{t('table.dismiss')}
|
|
853
|
+
</Button>
|
|
854
|
+
)}
|
|
855
|
+
</Banner>
|
|
856
|
+
);
|
|
857
|
+
}
|
|
@@ -65,6 +65,8 @@ const isFilter = (chip: Chip): chip is FilterChip => chip.kind === 'filter';
|
|
|
65
65
|
|
|
66
66
|
const isSort = (chip: Chip): chip is SortChip => chip.kind === 'sort';
|
|
67
67
|
|
|
68
|
+
const sortChip = (sort: ColumnSort): SortChip => ({ kind: 'sort', column: sort.column, direction: sort.direction });
|
|
69
|
+
|
|
68
70
|
/* A name as typed in SQL: bare when it can be, else in double quotes. The quotes are only for the text of a chip; `sql` quotes per engine. */
|
|
69
71
|
export const identifierText = (name: string): string => (BARE_NAME.test(name) ? name : `"${name.replaceAll('"', '""')}"`);
|
|
70
72
|
|
|
@@ -95,10 +97,7 @@ export const chipsToOrderBy = (engine: Engine, chips: readonly Chip[]): string =
|
|
|
95
97
|
export const sortsOf = (chips: readonly Chip[]): ColumnSort[] => chips.filter(isSort).map(({ column, direction }) => ({ column, direction }));
|
|
96
98
|
|
|
97
99
|
/* The sorts of a header menu replace every sort and raw ORDER BY, keeping the filters where they are. */
|
|
98
|
-
export const withSorts = (chips: readonly Chip[], sorts: readonly ColumnSort[]): Chip[] => [
|
|
99
|
-
...chips.filter(isFilter),
|
|
100
|
-
...sorts.map((sort): Chip => ({ kind: 'sort', column: sort.column, direction: sort.direction }))
|
|
101
|
-
];
|
|
100
|
+
export const withSorts = (chips: readonly Chip[], sorts: readonly ColumnSort[]): Chip[] => [...chips.filter(isFilter), ...sorts.map(sortChip)];
|
|
102
101
|
|
|
103
102
|
/* A column that is not sorted joins as ascending; one that is flips its direction where it stands. */
|
|
104
103
|
export const toggleSort = (chips: readonly Chip[], column: string): Chip[] => {
|
|
@@ -139,7 +138,7 @@ export const sortChipsOf = (engine: Engine, orderBy: string): Chip[] => {
|
|
|
139
138
|
return [];
|
|
140
139
|
}
|
|
141
140
|
const sorts = parseOrderByLoose(engine, text);
|
|
142
|
-
return sorts === null ? [{ kind: 'order', text }] : sorts.map(
|
|
141
|
+
return sorts === null ? [{ kind: 'order', text }] : sorts.map(sortChip);
|
|
143
142
|
};
|
|
144
143
|
|
|
145
144
|
export interface ChipSource {
|
|
@@ -183,13 +182,11 @@ const readOperation = (rest: string): string | null => {
|
|
|
183
182
|
const operator = compare[1] === '==' ? '=' : compare[1] === '!=' ? '<>' : compare[1];
|
|
184
183
|
return isClosed(compare[2]!) ? `${operator} ${compare[2]}` : null;
|
|
185
184
|
}
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (member !== null) {
|
|
192
|
-
return isClosed(member[2]!) ? `${upper(member[1]!)} ${member[2]}` : null;
|
|
185
|
+
for (const form of [PATTERN, MEMBER]) {
|
|
186
|
+
const found = form.exec(rest);
|
|
187
|
+
if (found !== null) {
|
|
188
|
+
return isClosed(found[2]!) ? `${upper(found[1]!)} ${found[2]}` : null;
|
|
189
|
+
}
|
|
193
190
|
}
|
|
194
191
|
const between = BETWEEN.exec(rest);
|
|
195
192
|
if (between !== null) {
|