@adecore/database 0.39.0 → 0.40.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/console/QueryConsole.js +2 -10
- package/dist/console/SchemaPicker.js +0 -1
- package/dist/console/StatementResultView.js +1 -1
- package/dist/ddl/diff.js +1 -1
- package/dist/explorer/tree.js +0 -1
- package/dist/export-formats.d.ts +2 -0
- package/dist/export-formats.js +1 -0
- package/dist/grid/CopyAsMenu.js +0 -1
- package/dist/grid/DataGrid.js +10 -10
- package/dist/grid/column-layout.d.ts +0 -1
- package/dist/grid/column-layout.js +1 -2
- package/dist/grid/column-view.js +3 -2
- package/dist/grid/display.js +1 -2
- package/dist/host/host.js +1 -2
- package/dist/host/validate.d.ts +3 -0
- package/dist/host/validate.js +18 -15
- package/dist/same-list.d.ts +1 -0
- package/dist/same-list.js +1 -0
- package/dist/stored-size.d.ts +1 -0
- package/dist/stored-size.js +10 -0
- package/dist/table/TableToolbar.js +1 -1
- package/dist/table/TableView.js +2 -10
- package/package.json +15 -15
- package/src/DatabaseProvider.tsx +2 -0
- package/src/KeyIcon.tsx +1 -0
- package/src/client/client.ts +13 -0
- package/src/client-context.ts +2 -0
- package/src/connections/ConnectionForm.tsx +11 -0
- package/src/connections/ConnectionManager.tsx +9 -0
- package/src/connections/connection-config.ts +21 -0
- package/src/connections/docker.ts +2 -0
- package/src/connections/use-docker-containers.ts +3 -0
- package/src/console/DestructiveDialog.tsx +1 -0
- package/src/console/QueryConsole.tsx +38 -10
- package/src/console/SchemaPicker.tsx +1 -1
- package/src/console/SqlEditor.tsx +10 -0
- package/src/console/StatementResultView.tsx +7 -2
- package/src/console/TransactionControls.tsx +1 -0
- package/src/console/destructive.ts +11 -0
- package/src/console/editing.ts +10 -0
- package/src/console/history.ts +8 -0
- package/src/console/labels.ts +1 -0
- package/src/console/outcome.ts +1 -0
- package/src/console/result-keys.ts +16 -0
- package/src/console/shortcuts.ts +2 -0
- package/src/ddl/alter-mysql.ts +16 -0
- package/src/ddl/alter-sqlite.ts +21 -0
- package/src/ddl/alter.ts +2 -0
- package/src/ddl/column.ts +11 -0
- package/src/ddl/create.ts +15 -0
- package/src/ddl/dialect.ts +4 -0
- package/src/ddl/diff.ts +22 -2
- package/src/ddl/draft.ts +1 -0
- package/src/ddl/parse.ts +31 -0
- package/src/ddl/types.ts +3 -0
- package/src/ddl/validate.ts +12 -0
- package/src/designer/ColumnsEditor.tsx +3 -0
- package/src/designer/DesignerView.tsx +2 -0
- package/src/designer/ForeignKeysEditor.tsx +4 -0
- package/src/designer/IndexesEditor.tsx +1 -0
- package/src/designer/NamePicker.tsx +2 -0
- package/src/designer/OptionsEditor.tsx +4 -0
- package/src/designer/SqlPreview.tsx +2 -0
- package/src/designer/TableDesigner.tsx +14 -0
- package/src/designer/edit.ts +13 -0
- package/src/designer/plan.ts +3 -0
- package/src/designer/recover.ts +1 -0
- package/src/designer/useReferenceColumns.ts +5 -0
- package/src/explorer/DatabaseExplorer.tsx +40 -0
- package/src/explorer/RowMenu.tsx +2 -0
- package/src/explorer/TableDialog.tsx +6 -0
- package/src/explorer/expansion.ts +8 -0
- package/src/explorer/tree.ts +73 -2
- package/src/explorer/use-explorer-loads.ts +15 -0
- package/src/explorer/use-stored-expansion.ts +4 -0
- package/src/export-formats.ts +3 -0
- package/src/grid/CellEditor.tsx +1 -0
- package/src/grid/CopyAsMenu.tsx +1 -1
- package/src/grid/DataGrid.tsx +93 -23
- package/src/grid/EnumPicker.tsx +6 -0
- package/src/grid/GridHeaderCell.tsx +11 -0
- package/src/grid/GridRowView.tsx +3 -0
- package/src/grid/RecordView.tsx +19 -0
- package/src/grid/ValueDock.tsx +2 -0
- package/src/grid/aggregates.ts +12 -0
- package/src/grid/column-layout.ts +12 -4
- package/src/grid/column-selection.ts +9 -0
- package/src/grid/column-view.ts +6 -2
- package/src/grid/copy-formats.ts +13 -0
- package/src/grid/display.ts +17 -3
- package/src/grid/edit-value.ts +11 -0
- package/src/grid/enum-type.ts +15 -0
- package/src/grid/focused-value.ts +4 -0
- package/src/grid/layout.ts +12 -0
- package/src/grid/navigation.ts +5 -0
- package/src/grid/row-selection.ts +7 -0
- package/src/grid/sort.ts +1 -0
- package/src/grid/whole-cells.ts +8 -0
- package/src/host/host.ts +16 -3
- package/src/host/spawn.ts +2 -0
- package/src/host/validate.ts +37 -15
- package/src/same-list.ts +2 -0
- package/src/sql-split.ts +28 -0
- package/src/sql.ts +20 -0
- package/src/stored-size.ts +10 -0
- package/src/structure/StructureView.tsx +18 -0
- package/src/table/CommandField.tsx +11 -0
- package/src/table/ImportDialog.tsx +4 -0
- package/src/table/TableStatusBar.tsx +1 -0
- package/src/table/TableToolbar.tsx +2 -2
- package/src/table/TableView.tsx +50 -10
- package/src/table/command-field.ts +40 -0
- package/src/table/grid-rows.ts +13 -0
- package/src/table/import-mapping.ts +5 -0
- package/src/table/layout-store.ts +7 -0
- package/src/table/paging.ts +1 -0
- package/src/table/pending.ts +18 -0
- package/src/table/references.ts +4 -0
- package/src/table/useLoaded.ts +3 -0
- package/src/table/useStoredLayout.ts +2 -0
- package/src/table/useTableTransfer.ts +17 -1
- package/src/testing/fake.ts +24 -0
- package/src/use-database-resources.ts +1 -0
- package/src/use-popup-press.ts +3 -0
- package/src/value/ValueEditor.tsx +6 -0
- package/src/value/draft.ts +7 -0
- package/src/value/hex.ts +8 -0
- package/src/value/json.ts +16 -0
- package/src/value/views.ts +14 -0
- package/src/workbench/DatabaseWorkbench.tsx +22 -0
- package/src/workbench/tabs.ts +34 -0
|
@@ -46,10 +46,12 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
|
|
|
46
46
|
const client = useDatabaseClient();
|
|
47
47
|
const pickedId = selected === undefined ? own : selected;
|
|
48
48
|
const current = value.find((connection) => connection.id === pickedId) ?? value[0] ?? null;
|
|
49
|
+
|
|
49
50
|
// The answer of one connection is gone once another is picked, so coming back does not bring it back.
|
|
50
51
|
if (outcome !== null && outcome.connectionId !== current?.id) {
|
|
51
52
|
setOutcome(null);
|
|
52
53
|
}
|
|
54
|
+
|
|
53
55
|
// An answer to an older config is never shown for a newer one.
|
|
54
56
|
const shown = outcome !== null && current !== null && outcome.config === current.config ? outcome : null;
|
|
55
57
|
|
|
@@ -57,6 +59,7 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
|
|
|
57
59
|
if (selected === undefined) {
|
|
58
60
|
setOwn(id);
|
|
59
61
|
}
|
|
62
|
+
|
|
60
63
|
onSelectedChange?.(id);
|
|
61
64
|
};
|
|
62
65
|
|
|
@@ -192,6 +195,7 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
|
|
|
192
195
|
function DockerMenuItems({ onPick }: { onPick(container: DockerContainer): void }) {
|
|
193
196
|
const { t } = useTranslation('database');
|
|
194
197
|
const { state, reload } = useDockerContainers();
|
|
198
|
+
|
|
195
199
|
if (state.status === 'loading') {
|
|
196
200
|
return (
|
|
197
201
|
<Menu.Label className="flex items-center gap-2">
|
|
@@ -200,6 +204,7 @@ function DockerMenuItems({ onPick }: { onPick(container: DockerContainer): void
|
|
|
200
204
|
</Menu.Label>
|
|
201
205
|
);
|
|
202
206
|
}
|
|
207
|
+
|
|
203
208
|
if (state.status !== 'ready') {
|
|
204
209
|
return (
|
|
205
210
|
<>
|
|
@@ -212,10 +217,13 @@ function DockerMenuItems({ onPick }: { onPick(container: DockerContainer): void
|
|
|
212
217
|
</>
|
|
213
218
|
);
|
|
214
219
|
}
|
|
220
|
+
|
|
215
221
|
const containers = state.containers.filter(isMysqlContainer);
|
|
222
|
+
|
|
216
223
|
if (containers.length === 0) {
|
|
217
224
|
return <Menu.Label>{t('connections.fromDocker.empty')}</Menu.Label>;
|
|
218
225
|
}
|
|
226
|
+
|
|
219
227
|
return containers.map((container) => (
|
|
220
228
|
<Menu.Item key={container.id} onClick={() => onPick(container)}>
|
|
221
229
|
<span className="flex min-w-0 flex-col">
|
|
@@ -235,6 +243,7 @@ function ConnectionTest({ connection, onOutcome }: { connection: Connection; onO
|
|
|
235
243
|
const test = async (): Promise<void> => {
|
|
236
244
|
const config = connection.config;
|
|
237
245
|
setBusy(true);
|
|
246
|
+
|
|
238
247
|
try {
|
|
239
248
|
onOutcome({ connectionId: connection.id, config, info: await client.test(config) });
|
|
240
249
|
} catch (e) {
|
|
@@ -34,9 +34,11 @@ export const isAbsolutePath = (path: string): boolean => /^(?:\/|[A-Za-z]:[\\/]|
|
|
|
34
34
|
|
|
35
35
|
const sqliteProblems = (config: SqliteConnectionConfig): ConfigProblems => {
|
|
36
36
|
const path = config.path.trim();
|
|
37
|
+
|
|
37
38
|
if (path === '') {
|
|
38
39
|
return { path: 'path-required' };
|
|
39
40
|
}
|
|
41
|
+
|
|
40
42
|
return isAbsolutePath(path) ? {} : { path: 'path-relative' };
|
|
41
43
|
};
|
|
42
44
|
|
|
@@ -47,6 +49,7 @@ export const modeOf = (config: MysqlConnectionConfig): ConnectMode => {
|
|
|
47
49
|
if (config.tunnel !== undefined) {
|
|
48
50
|
return config.tunnel.kind;
|
|
49
51
|
}
|
|
52
|
+
|
|
50
53
|
return config.socket === undefined ? 'tcp' : 'socket';
|
|
51
54
|
};
|
|
52
55
|
|
|
@@ -54,31 +57,39 @@ const mysqlProblems = (config: MysqlConnectionConfig): ConfigProblems => {
|
|
|
54
57
|
const problems: { -readonly [K in keyof ConfigProblems]: ConfigProblems[K] } = {};
|
|
55
58
|
const tunnel = config.tunnel;
|
|
56
59
|
const mode = modeOf(config);
|
|
60
|
+
|
|
57
61
|
if (mode === 'socket' && (config.socket ?? '').trim() === '') {
|
|
58
62
|
problems.socket = 'socket-required';
|
|
59
63
|
}
|
|
64
|
+
|
|
60
65
|
if ((mode === 'tcp' || mode === 'ssh') && config.host.trim() === '') {
|
|
61
66
|
problems.host = 'host-required';
|
|
62
67
|
}
|
|
68
|
+
|
|
63
69
|
if ((mode === 'tcp' || mode === 'ssh') && config.port !== undefined && !isPort(config.port)) {
|
|
64
70
|
problems.port = 'port-range';
|
|
65
71
|
}
|
|
72
|
+
|
|
66
73
|
if (tunnel?.kind === 'ssh') {
|
|
67
74
|
if (tunnel.host.trim() === '') {
|
|
68
75
|
problems.sshHost = 'ssh-host-required';
|
|
69
76
|
}
|
|
77
|
+
|
|
70
78
|
if (tunnel.port !== undefined && !isPort(tunnel.port)) {
|
|
71
79
|
problems.sshPort = 'ssh-port-range';
|
|
72
80
|
}
|
|
73
81
|
}
|
|
82
|
+
|
|
74
83
|
if (tunnel?.kind === 'docker') {
|
|
75
84
|
if (tunnel.container.trim() === '') {
|
|
76
85
|
problems.container = 'container-required';
|
|
77
86
|
}
|
|
87
|
+
|
|
78
88
|
if (tunnel.port !== undefined && !isPort(tunnel.port)) {
|
|
79
89
|
problems.containerPort = 'container-port-range';
|
|
80
90
|
}
|
|
81
91
|
}
|
|
92
|
+
|
|
82
93
|
return problems;
|
|
83
94
|
};
|
|
84
95
|
|
|
@@ -90,30 +101,38 @@ export const isValidConfig = (config: ConnectionConfig): boolean => Object.keys(
|
|
|
90
101
|
|
|
91
102
|
const lastSegment = (path: string): string => {
|
|
92
103
|
const segments = path.split(/[\\/]/).filter((segment) => segment !== '');
|
|
104
|
+
|
|
93
105
|
return segments[segments.length - 1] ?? path;
|
|
94
106
|
};
|
|
95
107
|
|
|
96
108
|
const targetWhere = (config: MysqlConnectionConfig): string => {
|
|
97
109
|
const tunnel = config.tunnel;
|
|
110
|
+
|
|
98
111
|
if (tunnel?.kind === 'docker') {
|
|
99
112
|
return tunnel.container === '' ? 'Docker' : `${tunnel.container} (Docker)`;
|
|
100
113
|
}
|
|
114
|
+
|
|
101
115
|
if (tunnel?.kind === 'ssh') {
|
|
102
116
|
return tunnel.host === '' ? config.host : `${config.host} via ${tunnel.host}`;
|
|
103
117
|
}
|
|
118
|
+
|
|
104
119
|
if (config.socket !== undefined) {
|
|
105
120
|
return lastSegment(config.socket);
|
|
106
121
|
}
|
|
122
|
+
|
|
107
123
|
return `${config.host}:${config.port ?? DEFAULT_MYSQL_PORT}`;
|
|
108
124
|
};
|
|
109
125
|
|
|
110
126
|
/* The short line under a connection's name: the file name of a database file, or `user@host:port`, `user@host via <ssh host>` and `user@container (Docker)`. */
|
|
111
127
|
export const targetOf = (connection: Connection): string => {
|
|
112
128
|
const config = connection.config;
|
|
129
|
+
|
|
113
130
|
if (config.engine === 'sqlite') {
|
|
114
131
|
return lastSegment(config.path);
|
|
115
132
|
}
|
|
133
|
+
|
|
116
134
|
const where = targetWhere(config);
|
|
135
|
+
|
|
117
136
|
return config.user === '' || where === '' ? where : `${config.user}@${where}`;
|
|
118
137
|
};
|
|
119
138
|
|
|
@@ -122,12 +141,14 @@ export const withMode = (config: MysqlConnectionConfig, mode: ConnectMode): Mysq
|
|
|
122
141
|
if (modeOf(config) === mode) {
|
|
123
142
|
return config;
|
|
124
143
|
}
|
|
144
|
+
|
|
125
145
|
const base: MysqlConnectionConfig = {
|
|
126
146
|
...config,
|
|
127
147
|
host: config.host.trim() === '' ? DEFAULT_HOST : config.host,
|
|
128
148
|
socket: undefined,
|
|
129
149
|
tunnel: undefined
|
|
130
150
|
};
|
|
151
|
+
|
|
131
152
|
switch (mode) {
|
|
132
153
|
case 'tcp':
|
|
133
154
|
return base;
|
|
@@ -15,6 +15,7 @@ export const containerTitle = (container: DockerContainer): string => composeNam
|
|
|
15
15
|
/* The port to connect to inside the container: MySQL's own when the container lists it or lists nothing, else the first it lists. */
|
|
16
16
|
export const containerPortOf = (container: DockerContainer): number => {
|
|
17
17
|
const ports = container.ports.map((entry) => entry.container);
|
|
18
|
+
|
|
18
19
|
return ports.length === 0 || ports.includes(DEFAULT_MYSQL_PORT) ? DEFAULT_MYSQL_PORT : ports[0]!;
|
|
19
20
|
};
|
|
20
21
|
|
|
@@ -28,6 +29,7 @@ const isEmpty = (text: string | undefined): boolean => text === undefined || tex
|
|
|
28
29
|
export const withContainer = (config: MysqlConnectionConfig, container: DockerContainer): MysqlConnectionConfig => {
|
|
29
30
|
const docker = withMode(config, 'docker');
|
|
30
31
|
const { user, password, database } = container.suggested;
|
|
32
|
+
|
|
31
33
|
return {
|
|
32
34
|
...docker,
|
|
33
35
|
user: isEmpty(docker.user) && user !== undefined ? user : docker.user,
|
|
@@ -19,16 +19,19 @@ export function useDockerContainers(): { readonly state: DockerState; reload():
|
|
|
19
19
|
|
|
20
20
|
useEffect(() => {
|
|
21
21
|
const controller = new AbortController();
|
|
22
|
+
|
|
22
23
|
client.discover('docker', { signal: controller.signal }).then(
|
|
23
24
|
(containers) => setState({ status: 'ready', containers }),
|
|
24
25
|
(e: unknown) => {
|
|
25
26
|
if (controller.signal.aborted) {
|
|
26
27
|
return;
|
|
27
28
|
}
|
|
29
|
+
|
|
28
30
|
const message = messageOf(e);
|
|
29
31
|
setState({ status: e instanceof DatabaseRequestError && e.code === 'unsupported' ? 'unsupported' : 'error', message });
|
|
30
32
|
}
|
|
31
33
|
);
|
|
34
|
+
|
|
32
35
|
return () => controller.abort();
|
|
33
36
|
}, [client, attempt]);
|
|
34
37
|
|
|
@@ -15,6 +15,7 @@ export interface DestructiveDialogProps {
|
|
|
15
15
|
/* Lists the statements that cannot be taken back and asks before they run. */
|
|
16
16
|
export function DestructiveDialog({ statements, onConfirm, onCancel, ref }: DestructiveDialogProps) {
|
|
17
17
|
const { t } = useTranslation('database');
|
|
18
|
+
|
|
18
19
|
return (
|
|
19
20
|
<PromptDialog
|
|
20
21
|
ref={ref}
|
|
@@ -9,6 +9,7 @@ import type { DatabaseNotice } from '../actions.ts';
|
|
|
9
9
|
import { useDatabaseAction, useDatabaseClient, useDatabaseFiles, useDatabaseNotice, useDatabaseStorage } from '../client-context.ts';
|
|
10
10
|
import type { FileFormat, RowsResult, SchemaInfo, StatementResult } from '../protocol/index.ts';
|
|
11
11
|
import { splitStatements, statementAt } from '../sql-split.ts';
|
|
12
|
+
import { parseStoredSize } from '../stored-size.ts';
|
|
12
13
|
import { DestructiveDialog } from './DestructiveDialog.tsx';
|
|
13
14
|
import type { QueryConsoleEditorHandle, QueryConsoleEditorProps, QueryConsoleRunScope } from './editor-slot.ts';
|
|
14
15
|
import { findDestructive, type DestructiveStatement } from './destructive.ts';
|
|
@@ -72,15 +73,6 @@ export const RESULTS_HEIGHT_KEY = 'database:console-results';
|
|
|
72
73
|
const MIN_RESULTS_HEIGHT = 96;
|
|
73
74
|
const MIN_EDITOR_HEIGHT = 120;
|
|
74
75
|
|
|
75
|
-
const parseHeight = (raw: string | null | undefined): number | null => {
|
|
76
|
-
try {
|
|
77
|
-
const value: unknown = JSON.parse(raw ?? 'null');
|
|
78
|
-
return typeof value === 'number' && Number.isFinite(value) && value >= MIN_RESULTS_HEIGHT ? Math.round(value) : null;
|
|
79
|
-
} catch {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
76
|
/* The app's editor as an element of its own, so it gets its props the way `SqlEditor` does and not during the console's render. */
|
|
85
77
|
function AppEditor({ render, ...editor }: QueryConsoleEditorProps & { render(editor: QueryConsoleEditorProps): ReactNode }) {
|
|
86
78
|
return render(editor);
|
|
@@ -154,12 +146,14 @@ export function QueryConsole({
|
|
|
154
146
|
const channel = `console:${useId()}`;
|
|
155
147
|
const session = useMemo(() => (connection === undefined ? null : client.session(connection, channel)), [client, connection, channel]);
|
|
156
148
|
const engine = connection?.config.engine;
|
|
149
|
+
|
|
157
150
|
useEffect(
|
|
158
151
|
() => () => {
|
|
159
152
|
void session?.close();
|
|
160
153
|
},
|
|
161
154
|
[session]
|
|
162
155
|
);
|
|
156
|
+
|
|
163
157
|
// Nothing is recorded without a connection, so the key of none is never written.
|
|
164
158
|
const history = useConsoleHistory(connection?.id ?? '');
|
|
165
159
|
const running = useRef<AbortController | null>(null);
|
|
@@ -186,7 +180,8 @@ export function QueryConsole({
|
|
|
186
180
|
const [notice, setNotice] = useState<DatabaseNotice | null>(null);
|
|
187
181
|
// Closed until the first run, so the editor has the whole height before anything ran.
|
|
188
182
|
const [resultsOpen, setResultsOpen] = useState(false);
|
|
189
|
-
const [resultsHeight, setResultsHeight] = useState(() =>
|
|
183
|
+
const [resultsHeight, setResultsHeight] = useState(() => parseStoredSize(storage?.get(RESULTS_HEIGHT_KEY), MIN_RESULTS_HEIGHT));
|
|
184
|
+
|
|
190
185
|
const { startResize } = useColumnResize(resultsPane, {
|
|
191
186
|
size: resultsHeight ?? MIN_RESULTS_HEIGHT,
|
|
192
187
|
min: MIN_RESULTS_HEIGHT,
|
|
@@ -194,6 +189,7 @@ export function QueryConsole({
|
|
|
194
189
|
max: () => (resultsPane.current?.parentElement?.clientHeight ?? 0) - MIN_EDITOR_HEIGHT,
|
|
195
190
|
onSize: setResultsHeight
|
|
196
191
|
});
|
|
192
|
+
|
|
197
193
|
const sql = value ?? own;
|
|
198
194
|
const blank = sql.trim() === '';
|
|
199
195
|
const busy = run.status === 'running' || settling;
|
|
@@ -216,11 +212,14 @@ export function QueryConsole({
|
|
|
216
212
|
if (session === null || engine !== 'mysql') {
|
|
217
213
|
return undefined;
|
|
218
214
|
}
|
|
215
|
+
|
|
219
216
|
const controller = new AbortController();
|
|
217
|
+
|
|
220
218
|
session
|
|
221
219
|
.schemas({ signal: controller.signal })
|
|
222
220
|
.then((list) => setSchemas(orderSchemas(list)))
|
|
223
221
|
.catch(() => undefined);
|
|
222
|
+
|
|
224
223
|
return () => controller.abort();
|
|
225
224
|
}, [engine, session]);
|
|
226
225
|
|
|
@@ -236,6 +235,7 @@ export function QueryConsole({
|
|
|
236
235
|
if (value === undefined) {
|
|
237
236
|
setOwn(next);
|
|
238
237
|
}
|
|
238
|
+
|
|
239
239
|
onValueChange?.(next);
|
|
240
240
|
};
|
|
241
241
|
|
|
@@ -244,15 +244,20 @@ export function QueryConsole({
|
|
|
244
244
|
if (engine === undefined) {
|
|
245
245
|
return null;
|
|
246
246
|
}
|
|
247
|
+
|
|
247
248
|
const selection = editorHandle.current?.selection();
|
|
249
|
+
|
|
248
250
|
if (scope === 'all' || selection === undefined) {
|
|
249
251
|
return splitStatements(sql, engine).length === 0 ? null : sql;
|
|
250
252
|
}
|
|
253
|
+
|
|
251
254
|
const { start, end } = selection;
|
|
252
255
|
const selected = sql.slice(start, end);
|
|
256
|
+
|
|
253
257
|
if (splitStatements(selected, engine).length > 0) {
|
|
254
258
|
return selected;
|
|
255
259
|
}
|
|
260
|
+
|
|
256
261
|
return statementAt(sql, start, engine)?.text ?? null;
|
|
257
262
|
};
|
|
258
263
|
|
|
@@ -260,11 +265,15 @@ export function QueryConsole({
|
|
|
260
265
|
if (target === null || busy || engine === undefined) {
|
|
261
266
|
return;
|
|
262
267
|
}
|
|
268
|
+
|
|
263
269
|
const statements = findDestructive(target, engine);
|
|
270
|
+
|
|
264
271
|
if (statements.length > 0) {
|
|
265
272
|
setConfirming({ target, statements });
|
|
273
|
+
|
|
266
274
|
return;
|
|
267
275
|
}
|
|
276
|
+
|
|
268
277
|
void execute(target);
|
|
269
278
|
};
|
|
270
279
|
|
|
@@ -272,18 +281,22 @@ export function QueryConsole({
|
|
|
272
281
|
if (session === null || connection === undefined) {
|
|
273
282
|
return;
|
|
274
283
|
}
|
|
284
|
+
|
|
275
285
|
const controller = new AbortController();
|
|
276
286
|
const mine = ++generation.current;
|
|
277
287
|
running.current = controller;
|
|
278
288
|
setResultsOpen(true);
|
|
279
289
|
setRun({ status: 'running' });
|
|
280
290
|
setNotice(null);
|
|
291
|
+
|
|
281
292
|
const record = (outcome: Pick<HistoryEntry, 'ok' | 'rows'>): void =>
|
|
282
293
|
history.record({ sql: target, at: Date.now(), connection: connection.name, ...outcome });
|
|
294
|
+
|
|
283
295
|
try {
|
|
284
296
|
if (mode === 'manual' && !inTransaction) {
|
|
285
297
|
setInTransaction(await session.transaction('begin', { signal: controller.signal }));
|
|
286
298
|
}
|
|
299
|
+
|
|
287
300
|
// A cancel that stopped the run midway still answers what ran and whether a transaction stayed open.
|
|
288
301
|
const executed = await session.execute(target, { schema: activeSchema, limit: PAGE_SIZE, signal: controller.signal });
|
|
289
302
|
setInTransaction(executed.inTransaction);
|
|
@@ -307,6 +320,7 @@ export function QueryConsole({
|
|
|
307
320
|
|
|
308
321
|
const pickHistory = (entry: HistoryEntry, runIt: boolean): void => {
|
|
309
322
|
changeSql(entry.sql);
|
|
323
|
+
|
|
310
324
|
if (runIt) {
|
|
311
325
|
requestRun(entry.sql);
|
|
312
326
|
}
|
|
@@ -316,9 +330,12 @@ export function QueryConsole({
|
|
|
316
330
|
if (session === null) {
|
|
317
331
|
return false;
|
|
318
332
|
}
|
|
333
|
+
|
|
319
334
|
setSettling(true);
|
|
335
|
+
|
|
320
336
|
try {
|
|
321
337
|
setInTransaction(await session.transaction(action));
|
|
338
|
+
|
|
322
339
|
return true;
|
|
323
340
|
} catch (error) {
|
|
324
341
|
notify({
|
|
@@ -326,6 +343,7 @@ export function QueryConsole({
|
|
|
326
343
|
title: t(action === 'commit' ? 'console.transaction.commitFailed' : 'console.transaction.rollbackFailed'),
|
|
327
344
|
description: messageOf(error)
|
|
328
345
|
});
|
|
346
|
+
|
|
329
347
|
return false;
|
|
330
348
|
} finally {
|
|
331
349
|
setSettling(false);
|
|
@@ -336,6 +354,7 @@ export function QueryConsole({
|
|
|
336
354
|
if (await settle(action)) {
|
|
337
355
|
setMode('auto');
|
|
338
356
|
}
|
|
357
|
+
|
|
339
358
|
setEndingManual(false);
|
|
340
359
|
};
|
|
341
360
|
|
|
@@ -351,11 +370,14 @@ export function QueryConsole({
|
|
|
351
370
|
if (session === null) {
|
|
352
371
|
return;
|
|
353
372
|
}
|
|
373
|
+
|
|
354
374
|
const mine = generation.current;
|
|
355
375
|
setPaging(true);
|
|
356
376
|
setNotice(null);
|
|
377
|
+
|
|
357
378
|
try {
|
|
358
379
|
const rows = await session.page(result.sql, { schema: activeSchema, offset, limit: PAGE_SIZE });
|
|
380
|
+
|
|
359
381
|
if (generation.current === mine) {
|
|
360
382
|
setPages((current) => ({ ...current, [index]: { offset, rows, before } }));
|
|
361
383
|
}
|
|
@@ -370,9 +392,12 @@ export function QueryConsole({
|
|
|
370
392
|
if (files === undefined || session === null) {
|
|
371
393
|
return;
|
|
372
394
|
}
|
|
395
|
+
|
|
373
396
|
setExporting(true);
|
|
397
|
+
|
|
374
398
|
try {
|
|
375
399
|
const path = await files.save({ suggestedName: `result.${format}`, format });
|
|
400
|
+
|
|
376
401
|
if (path !== null) {
|
|
377
402
|
const exported = await session.export({ source: { kind: 'query', sql: result.sql, schema: activeSchema }, format, path });
|
|
378
403
|
notify({ tone: 'success', title: t('console.exported', { count: exported.rows, formatted: formatNumber(exported.rows) }) });
|
|
@@ -391,6 +416,7 @@ export function QueryConsole({
|
|
|
391
416
|
const shown: StatementResult | undefined = base?.kind === 'rows' && page !== undefined ? { kind: 'rows', sql: base.sql, ...page.rows } : base;
|
|
392
417
|
const offset = page?.offset ?? 0;
|
|
393
418
|
const before = page?.before ?? [];
|
|
419
|
+
|
|
394
420
|
const pager: ResultPager | undefined =
|
|
395
421
|
base?.kind === 'rows' && shown?.kind === 'rows' && engine !== undefined && isPageable(base.sql, engine) && (shown.hasMore || offset > 0)
|
|
396
422
|
? {
|
|
@@ -400,6 +426,7 @@ export function QueryConsole({
|
|
|
400
426
|
onNext: () => void loadPage(index, base, offset + shown.rows.length, [...before, offset])
|
|
401
427
|
}
|
|
402
428
|
: undefined;
|
|
429
|
+
|
|
403
430
|
const exportBase = files !== undefined && base?.kind === 'rows' ? base : undefined;
|
|
404
431
|
// Read for the statement, not for each page of it, so paging through a result asks for nothing again.
|
|
405
432
|
const keys = useResultKeys(session, engine, base);
|
|
@@ -538,6 +565,7 @@ export function QueryConsole({
|
|
|
538
565
|
onConfirm={() => {
|
|
539
566
|
const target = confirming?.target;
|
|
540
567
|
setConfirming(null);
|
|
568
|
+
|
|
541
569
|
if (target !== undefined) {
|
|
542
570
|
void execute(target);
|
|
543
571
|
}
|
|
@@ -13,9 +13,9 @@ export interface SchemaPickerProps {
|
|
|
13
13
|
ref?: Ref<HTMLButtonElement>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/* Picks the schema the console's statements run in. */
|
|
17
16
|
export function SchemaPicker({ schemas, value, onValueChange, disabled, className, ref }: SchemaPickerProps) {
|
|
18
17
|
const { t } = useTranslation('database');
|
|
18
|
+
|
|
19
19
|
return (
|
|
20
20
|
<Select
|
|
21
21
|
ref={ref}
|
|
@@ -26,6 +26,7 @@ export function SqlEditor({ ref, value, onValueChange, run, label, placeholder,
|
|
|
26
26
|
|
|
27
27
|
useEffect(() => {
|
|
28
28
|
const pending = pendingSelection.current;
|
|
29
|
+
|
|
29
30
|
if (pending !== null) {
|
|
30
31
|
pendingSelection.current = null;
|
|
31
32
|
pending.area.setSelectionRange(pending.start, pending.end);
|
|
@@ -37,29 +38,38 @@ export function SqlEditor({ ref, value, onValueChange, run, label, placeholder,
|
|
|
37
38
|
const before = target.value;
|
|
38
39
|
target.setSelectionRange(edit.from, edit.to);
|
|
39
40
|
const inserted = document.execCommand('insertText', false, edit.insert);
|
|
41
|
+
|
|
40
42
|
if (inserted && target.value === applyEdit(before, edit)) {
|
|
41
43
|
target.setSelectionRange(edit.selectionStart, edit.selectionEnd);
|
|
44
|
+
|
|
42
45
|
return;
|
|
43
46
|
}
|
|
47
|
+
|
|
44
48
|
pendingSelection.current = { area: target, start: edit.selectionStart, end: edit.selectionEnd };
|
|
45
49
|
onValueChange(applyEdit(before, edit));
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
49
53
|
const scope = runScopeOf(event, isApplePlatform());
|
|
54
|
+
|
|
50
55
|
if (scope !== null) {
|
|
51
56
|
event.preventDefault();
|
|
52
57
|
run(scope);
|
|
58
|
+
|
|
53
59
|
return;
|
|
54
60
|
}
|
|
61
|
+
|
|
55
62
|
if (event.nativeEvent.isComposing || event.metaKey || event.ctrlKey || event.altKey) {
|
|
56
63
|
return;
|
|
57
64
|
}
|
|
65
|
+
|
|
58
66
|
const target = event.currentTarget;
|
|
59
67
|
const { selectionStart, selectionEnd } = target;
|
|
68
|
+
|
|
60
69
|
if (event.key === 'Tab') {
|
|
61
70
|
event.preventDefault();
|
|
62
71
|
const edit = event.shiftKey ? outdentEdit(target.value, selectionStart, selectionEnd) : indentEdit(target.value, selectionStart, selectionEnd);
|
|
72
|
+
|
|
63
73
|
if (edit !== null) {
|
|
64
74
|
apply(target, edit);
|
|
65
75
|
}
|
|
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
4
4
|
import { Banner, ButtonGroup, ContextMenu, Icon, IconButton, Menu, Spinner } from '@adecore/ui';
|
|
5
5
|
import { formatDecimal, formatNumber } from '@adecore/ui/format';
|
|
6
6
|
import type { DatabaseNotice } from '../actions.ts';
|
|
7
|
+
import { EXPORT_FORMATS } from '../export-formats.ts';
|
|
7
8
|
import { DataGrid } from '../grid/DataGrid.tsx';
|
|
8
9
|
import { previewValueOf } from '../grid/focused-value.ts';
|
|
9
10
|
import type { FocusedCell, GridMenuContext, GridRow } from '../grid/types.ts';
|
|
@@ -17,8 +18,6 @@ import { ValuePanel } from '../value/ValuePanel.tsx';
|
|
|
17
18
|
/* What "Copy as SQL INSERT" names the table of a result that comes from no table. */
|
|
18
19
|
const RESULT_TABLE = 'result';
|
|
19
20
|
|
|
20
|
-
const EXPORT_FORMATS: readonly FileFormat[] = ['csv', 'tsv', 'json', 'sql'];
|
|
21
|
-
|
|
22
21
|
/* Paging through the rows of a statement that reads, one page at a time. */
|
|
23
22
|
export interface ResultPager {
|
|
24
23
|
/* Counted from 1. */
|
|
@@ -66,10 +65,12 @@ export function StatementResultView({
|
|
|
66
65
|
}: StatementResultViewProps) {
|
|
67
66
|
const { t } = useTranslation('database');
|
|
68
67
|
const [focus, setFocus] = useState<FocusedCell | null>(null);
|
|
68
|
+
|
|
69
69
|
const rows = useMemo(
|
|
70
70
|
() => (result.kind === 'rows' ? result.rows.map((cells, index): GridRow => ({ key: `row:${index}`, number: offset + index + 1, cells })) : []),
|
|
71
71
|
[result, offset]
|
|
72
72
|
);
|
|
73
|
+
|
|
73
74
|
const sqlTarget = useMemo((): SqlTarget | undefined => (engine === undefined ? undefined : { engine, table: RESULT_TABLE }), [engine]);
|
|
74
75
|
const elapsed = t('console.elapsed', { value: formatDecimal(result.elapsedMs) });
|
|
75
76
|
|
|
@@ -115,17 +116,21 @@ export function StatementResultView({
|
|
|
115
116
|
|
|
116
117
|
const referenceAt = (cell: FocusedCell): Reference | null => {
|
|
117
118
|
const row = rows.find((candidate) => candidate.key === cell.rowKey);
|
|
119
|
+
|
|
118
120
|
return keys === null || row === undefined ? null : keys.referenceAt(cell.column, row.cells);
|
|
119
121
|
};
|
|
122
|
+
|
|
120
123
|
const follow =
|
|
121
124
|
keys === null || onFollowReference === undefined
|
|
122
125
|
? undefined
|
|
123
126
|
: (cell: FocusedCell): void => {
|
|
124
127
|
const reference = referenceAt(cell);
|
|
128
|
+
|
|
125
129
|
if (reference !== null) {
|
|
126
130
|
onFollowReference(reference);
|
|
127
131
|
}
|
|
128
132
|
};
|
|
133
|
+
|
|
129
134
|
const referenceMenu = (context: GridMenuContext): ReactNode =>
|
|
130
135
|
follow === undefined || context.cell === null || keys?.isReference(context.cell.column) !== true ? null : (
|
|
131
136
|
<ContextMenu.Item disabled={referenceAt(context.cell) === null} onClick={() => context.cell !== null && follow(context.cell)}>
|
|
@@ -21,6 +21,7 @@ export interface TransactionControlsProps {
|
|
|
21
21
|
/* Auto-commit on or off, and while a transaction is open, the way to end it. */
|
|
22
22
|
export function TransactionControls({ mode, onModeChange, open, busy, onCommit, onRollback, className, ref }: TransactionControlsProps) {
|
|
23
23
|
const { t } = useTranslation('database');
|
|
24
|
+
|
|
24
25
|
return (
|
|
25
26
|
<div ref={ref} className={clsx('flex items-center gap-2', className)}>
|
|
26
27
|
<Tooltip label={t('console.transaction.hint')}>
|
|
@@ -18,8 +18,10 @@ interface Word {
|
|
|
18
18
|
const wordsOf = (sql: string, engine: Engine): Word[] => {
|
|
19
19
|
const words: Word[] = [];
|
|
20
20
|
let depth = 0;
|
|
21
|
+
|
|
21
22
|
for (const token of tokenize(sql, engine)) {
|
|
22
23
|
const text = sql.slice(token.start, token.end);
|
|
24
|
+
|
|
23
25
|
if (token.kind === 'word') {
|
|
24
26
|
words.push({ word: text.toUpperCase(), depth });
|
|
25
27
|
} else if (token.kind === 'punct' && text === '(') {
|
|
@@ -28,27 +30,35 @@ const wordsOf = (sql: string, engine: Engine): Word[] => {
|
|
|
28
30
|
depth = Math.max(0, depth - 1);
|
|
29
31
|
}
|
|
30
32
|
}
|
|
33
|
+
|
|
31
34
|
return words;
|
|
32
35
|
};
|
|
33
36
|
|
|
34
37
|
const kindOf = (sql: string, engine: Engine): DestructiveKind | null => {
|
|
35
38
|
const words = wordsOf(sql, engine);
|
|
36
39
|
const first = words[0]?.word;
|
|
40
|
+
|
|
37
41
|
if (first === 'DROP') {
|
|
38
42
|
return 'drop';
|
|
39
43
|
}
|
|
44
|
+
|
|
40
45
|
if (first === 'TRUNCATE') {
|
|
41
46
|
return 'truncate';
|
|
42
47
|
}
|
|
48
|
+
|
|
43
49
|
if (first === 'ALTER') {
|
|
44
50
|
return words.some(({ word }) => word === 'DROP') ? 'alterDrop' : null;
|
|
45
51
|
}
|
|
52
|
+
|
|
46
53
|
// A statement behind a `WITH` is the first DELETE or UPDATE outside any parentheses.
|
|
47
54
|
const writer = first === 'WITH' ? words.find(({ word, depth }) => depth === 0 && (word === 'DELETE' || word === 'UPDATE')) : words[0];
|
|
55
|
+
|
|
48
56
|
if (writer?.word !== 'DELETE' && writer?.word !== 'UPDATE') {
|
|
49
57
|
return null;
|
|
50
58
|
}
|
|
59
|
+
|
|
51
60
|
const filtered = words.some(({ word, depth }) => word === 'WHERE' && depth === 0);
|
|
61
|
+
|
|
52
62
|
return filtered ? null : writer.word === 'DELETE' ? 'delete' : 'update';
|
|
53
63
|
};
|
|
54
64
|
|
|
@@ -56,5 +66,6 @@ const kindOf = (sql: string, engine: Engine): DestructiveKind | null => {
|
|
|
56
66
|
export const findDestructive = (sql: string, engine: Engine): DestructiveStatement[] =>
|
|
57
67
|
splitStatements(sql, engine).flatMap((statement) => {
|
|
58
68
|
const kind = kindOf(statement.text, engine);
|
|
69
|
+
|
|
59
70
|
return kind === null ? [] : [{ sql: statement.text, kind }];
|
|
60
71
|
});
|
package/src/console/editing.ts
CHANGED
|
@@ -15,6 +15,7 @@ const lineStartOf = (value: string, at: number): number => value.lastIndexOf('\n
|
|
|
15
15
|
|
|
16
16
|
const lineEndOf = (value: string, at: number): number => {
|
|
17
17
|
const newline = value.indexOf('\n', at);
|
|
18
|
+
|
|
18
19
|
return newline === -1 ? value.length : newline;
|
|
19
20
|
};
|
|
20
21
|
|
|
@@ -24,6 +25,7 @@ const indentOf = (line: string): string => /^[ \t]*/.exec(line)![0];
|
|
|
24
25
|
const touchedLines = (value: string, start: number, end: number): { from: number; to: number } => {
|
|
25
26
|
const from = lineStartOf(value, start);
|
|
26
27
|
const last = end > start && value[end - 1] === '\n' ? end - 1 : end;
|
|
28
|
+
|
|
27
29
|
return { from, to: lineEndOf(value, last) };
|
|
28
30
|
};
|
|
29
31
|
|
|
@@ -32,8 +34,10 @@ export const indentEdit = (value: string, start: number, end: number): TextEdit
|
|
|
32
34
|
if (!value.slice(start, end).includes('\n')) {
|
|
33
35
|
return { from: start, to: end, insert: INDENT, selectionStart: start + INDENT.length, selectionEnd: start + INDENT.length };
|
|
34
36
|
}
|
|
37
|
+
|
|
35
38
|
const { from, to } = touchedLines(value, start, end);
|
|
36
39
|
const lines = value.slice(from, to).split('\n');
|
|
40
|
+
|
|
37
41
|
return {
|
|
38
42
|
from,
|
|
39
43
|
to,
|
|
@@ -47,16 +51,21 @@ export const indentEdit = (value: string, start: number, end: number): TextEdit
|
|
|
47
51
|
export const outdentEdit = (value: string, start: number, end: number): TextEdit | null => {
|
|
48
52
|
const { from, to } = touchedLines(value, start, end);
|
|
49
53
|
const lines = value.slice(from, to).split('\n');
|
|
54
|
+
|
|
50
55
|
const removed = lines.map((line) => {
|
|
51
56
|
if (line.startsWith('\t')) {
|
|
52
57
|
return 1;
|
|
53
58
|
}
|
|
59
|
+
|
|
54
60
|
return Math.min(INDENT.length, /^ */.exec(line)![0].length);
|
|
55
61
|
});
|
|
62
|
+
|
|
56
63
|
const total = removed.reduce((sum, count) => sum + count, 0);
|
|
64
|
+
|
|
57
65
|
if (total === 0) {
|
|
58
66
|
return null;
|
|
59
67
|
}
|
|
68
|
+
|
|
60
69
|
return {
|
|
61
70
|
from,
|
|
62
71
|
to,
|
|
@@ -71,5 +80,6 @@ export const newlineEdit = (value: string, start: number, end: number): TextEdit
|
|
|
71
80
|
const lineStart = lineStartOf(value, start);
|
|
72
81
|
const indent = indentOf(value.slice(lineStart, start));
|
|
73
82
|
const insert = `\n${indent}`;
|
|
83
|
+
|
|
74
84
|
return { from: start, to: end, insert, selectionStart: start + insert.length, selectionEnd: start + insert.length };
|
|
75
85
|
};
|