Repository navigation
Expand file tree
/
Copy pathuse-query-execution.test.ts
More file actions
6419 lines (5476 loc) · 257 KB
/
Copy pathuse-query-execution.test.ts
File metadata and controls
6419 lines (5476 loc) · 257 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import "../setup-dom";
import { mockToastSuccess, mockToastError, mockToastDefault, mockToastLoading } from "../helpers/mock-sonner";
import "../helpers/mock-navigation";
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { renderHook, act, waitFor } from "@testing-library/react";
import { mockGlobalFetch, restoreGlobalFetch, type MockFetchResponse } from "../helpers/mock-fetch";
import { installStandInVocabulary, STAND_IN_TYPE } from "../helpers/stand-in-vocabulary";
import qdrantDocs from "../fixtures/vector/corpus/qdrant-docs.json";
import { storage } from "@/lib/storage";
import { logger } from "@/lib/logger";
// ── Mock QuerySafetyDialog ──────────────────────────────────────────────────
// The stub is deliberately more permissive than the real predicate - it answers for
// DROP/DELETE/TRUNCATE only - which is what lets the UPDATE and DDL tests further
// down execute without a confirmation. It is a spy so the gate test can assert WHICH
// text the hook asks about; what the real predicate ANSWERS for that text is pinned
// in tests/components/QuerySafetyDialog.test.tsx.
const isDangerousQueryMock = mock(
(q: string) =>
q.toUpperCase().includes("DROP") || q.toUpperCase().includes("DELETE") || q.toUpperCase().includes("TRUNCATE"),
);
mock.module("@/components/QuerySafetyDialog", () => ({
isDangerousQuery: isDangerousQueryMock,
}));
import { useQueryExecution } from "@/hooks/use-query-execution";
import { onSessionEnded } from "@/lib/config/base-path";
import { oxiaRefusal } from "@/lib/db/providers/keyvalue/oxia/guard";
import { milvusRefusal } from "@/lib/db/providers/vector/milvus/guard";
import { qdrantRefusal } from "@/lib/db/providers/vector/qdrant/guard";
import { statementRefusal } from "@/lib/db/destructive-commands";
import { DATABEND_EXPLAIN_DECLINES } from "@/lib/explain/databend-text";
import { SNOWFLAKE_EXPLAIN_GATED_DECLINE } from "@/lib/explain/snowflake-json";
import { resolveSqlGrammar } from "@/lib/sql/grammar";
import { isMultiStatement } from "@/lib/sql/statement-splitter";
import type { DatabaseConnection, QueryTab } from "@/lib/types";
import type { ProviderMetadata } from "@/hooks/use-provider-metadata";
import * as explainModule from "@/lib/explain";
import type { ExplainStrategy } from "@/lib/explain";
/** What the hook says when the server did not confirm a cancel (#1364). */
const CANCEL_NOT_CONFIRMED_TEXT =
"The database did not confirm the cancel, so the statement may still be running there. It may also have finished just before the cancel arrived.";
// =============================================================================
// Test Data
// =============================================================================
const mockConnection: DatabaseConnection = {
id: "qe-pg-1",
name: "Test PostgreSQL",
type: "postgres",
host: "localhost",
port: 5432,
user: "testuser",
password: "testpass",
database: "testdb",
createdAt: new Date("2025-01-01T00:00:00Z"),
environment: "development",
};
const mockMetadata: ProviderMetadata = {
capabilities: {
queryLanguage: "sql" as const,
supportsExplain: true,
explainFormat: "postgres-json" as const,
supportsExternalQueryLimiting: true,
supportsInlineRowEdit: true,
supportsMaintenance: true,
maintenanceOperations: ["vacuum", "analyze"],
supportsConnectionString: true,
defaultPort: 5432,
schemaRefreshPattern: "^(CREATE|DROP|ALTER)\\b",
},
labels: {
entityName: "Table",
entityNamePlural: "Tables",
rowName: "Row",
rowNamePlural: "Rows",
selectAction: "SELECT * FROM",
generateAction: "Generate SELECT",
analyzeAction: "Analyze",
vacuumAction: "Vacuum",
searchPlaceholder: "Search tables...",
analyzeGlobalLabel: "Analyze All",
analyzeGlobalTitle: "Analyze All Tables",
analyzeGlobalDesc: "Analyze all tables in the database",
vacuumGlobalLabel: "Vacuum All",
vacuumGlobalTitle: "Vacuum All Tables",
vacuumGlobalDesc: "Vacuum all tables in the database",
},
};
const createTab = (overrides?: Partial<QueryTab>): QueryTab => ({
id: "tab-1",
name: "Query 1",
query: "SELECT * FROM users",
result: null,
isExecuting: false,
type: "sql",
...overrides,
});
const mockQueryResult = {
rows: [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
fields: ["id", "name"],
rowCount: 2,
executionTime: 15,
pagination: { limit: 500, offset: 0, hasMore: false, totalReturned: 2, wasLimited: false },
};
function createDefaultParams(overrides?: Record<string, unknown>) {
const tab = createTab();
const setTabsMock = mock((fn: unknown) => {
// Apply function if it's a function (for state updater pattern)
if (typeof fn === "function") {
fn([tab]);
}
});
return {
activeConnection: mockConnection,
metadata: mockMetadata,
tabs: [tab],
activeTabId: "tab-1",
currentTab: tab,
setTabs: setTabsMock,
transactionActive: false,
playgroundMode: false,
fetchSchema: mock(async () => {}),
queryEditorRef: { current: null },
...overrides,
};
}
// =============================================================================
// useQueryExecution Tests
// =============================================================================
let addToHistorySpy: ReturnType<typeof spyOn>;
describe("useQueryExecution", () => {
beforeEach(() => {
mockToastSuccess.mockClear();
mockToastError.mockClear();
addToHistorySpy = spyOn(storage, "addToHistory").mockImplementation(() => {});
});
afterEach(() => {
addToHistorySpy.mockRestore();
restoreGlobalFetch();
});
// ── Initially bottomPanelMode is 'results' ────────────────────────────────
test("initially bottomPanelMode is results", () => {
mockGlobalFetch({});
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
expect(result.current.bottomPanelMode).toBe("results");
});
// ── executeQuery shows toast when no connection ────────────────────────────
test("executeQuery shows toast when no connection", async () => {
mockGlobalFetch({});
const params = createDefaultParams({ activeConnection: null });
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("SELECT 1");
});
// useToast wraps sonnerToast.error for destructive variant
expect(mockToastError).toHaveBeenCalled();
});
// ── executeQuery calls /api/db/query POST with correct body ────────────────
test("executeQuery calls /api/db/query POST with correct body", async () => {
const fetchMock = mockGlobalFetch({
"/api/db/query": { ok: true, json: mockQueryResult },
});
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("SELECT * FROM users");
});
const queryCall = fetchMock.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("/api/db/query"),
);
expect(queryCall).toBeDefined();
expect(queryCall![1]).toMatchObject({ method: "POST" });
const body = JSON.parse(queryCall![1]!.body as string);
expect(body.sql).toBe("SELECT * FROM users");
expect(body.connection).toBeDefined();
expect(body.connection.id).toBe("qe-pg-1");
});
// ── the tab's own numbered database (the #1095 review) ─────────────────────
/**
* A key browser activation opens its tab against ONE numbered database, and Redis has no
* database-qualified key syntax: `GET report:daily` cannot name it, so the number travels with the
* run and this is where the tab's own fact becomes a request field.
*
* A FIELD BESIDE THE CONNECTION, NOT INSIDE IT, and that is the whole of this case: a managed
* connection travels as an id and the server discards any connection field the caller attached
* (GHSA-3wh2-8x78), so a database merged into the connection object is silently dropped for every
* zero-config deployment - and the read runs in the SESSION's database while the tab claims it read
* another. Beside the connection, `POST /api/db/query` applies it after resolving the id.
*/
test("a run on a tab opened against a numbered database sends that database", async () => {
const fetchMock = mockGlobalFetch({
"/api/db/query": { ok: true, json: mockQueryResult },
});
const tab = createTab({ databaseOverride: 3 });
const params = createDefaultParams({ tabs: [tab], currentTab: tab });
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("GET report:daily");
});
const mainCall = fetchMock.mock.calls.find((call) => {
const body = JSON.parse(call[1]!.body as string);
// The background EXPLAIN of the same statement is a second request to the same route; the
// run itself is the one without a plan asked of it.
return body.sql === "GET report:daily" && body.explain === undefined;
});
expect(mainCall).toBeDefined();
const body = JSON.parse(mainCall![1]!.body as string);
expect(body.database).toBe(3);
// The control: the CONNECTION does not move at all - its saved database is still its saved one,
// so nothing a stored connection pins is rewritten by a tab's own walk. Only the field beside it
// names the run's database.
expect(body.connection.id).toBe("qe-pg-1");
expect(body.connection.host).toBe("localhost");
expect(body.connection.database).toBe(mockConnection.database);
});
test("a run on an ordinary tab keeps the connection's own database", async () => {
const fetchMock = mockGlobalFetch({
"/api/db/query": { ok: true, json: mockQueryResult },
});
const params = createDefaultParams();
expect("databaseOverride" in params.currentTab).toBe(false);
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("SELECT * FROM users");
});
const mainCall = fetchMock.mock.calls.find((call) => {
const body = JSON.parse(call[1]!.body as string);
return body.sql === "SELECT * FROM users" && body.explain === undefined;
});
const body = JSON.parse(mainCall![1]!.body as string);
expect(body.connection.database).toBe("testdb");
// ABSENT is not 0 and not "the session's number": a tab with no override sends no field, so the
// body is byte for byte what it was before this existed.
expect("database" in body).toBe(false);
});
// ── executeQuery updates tab result on success ─────────────────────────────
test("executeQuery updates tab result on success", async () => {
mockGlobalFetch({
"/api/db/query": { ok: true, json: mockQueryResult },
});
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("SELECT * FROM users");
});
// setTabs should have been called (state updater function)
expect(params.setTabs).toHaveBeenCalled();
});
// ── An Explain run is recorded as a plan (#1447) ───────────────────────────
test("an Explain run is recorded as a plan with no row count", async () => {
mockGlobalFetch({
"/api/db/query": {
ok: true,
json: { rows: [{ "QUERY PLAN": { plan: "Seq Scan" } }], fields: ["QUERY PLAN"], rowCount: 1, executionTime: 5 },
},
});
const { result } = renderHook(() => useQueryExecution(createDefaultParams()));
await act(async () => {
await result.current.executeQuery("SELECT * FROM users", undefined, true);
});
const historyArg = (storage.addToHistory as ReturnType<typeof mock>).mock.calls[0][0] as Record<string, unknown>;
expect(historyArg.query).toBe("SELECT * FROM users");
expect(historyArg.kind).toBe("explain");
expect("rowCount" in historyArg).toBe(false);
});
test("a failed Explain run is recorded as a plan too", async () => {
mockGlobalFetch({ "/api/db/query": { ok: false, status: 400, json: { error: "EXPLAIN is not allowed here" } } });
const { result } = renderHook(() => useQueryExecution(createDefaultParams()));
await act(async () => {
await result.current.executeQuery("SELECT * FROM users", undefined, true);
});
const historyArg = (storage.addToHistory as ReturnType<typeof mock>).mock.calls[0][0] as Record<string, unknown>;
expect(historyArg.status).toBe("error");
expect(historyArg.kind).toBe("explain");
});
test("an ordinary run keeps its row count and carries no kind", async () => {
mockGlobalFetch({ "/api/db/query": { ok: true, json: mockQueryResult } });
const { result } = renderHook(() => useQueryExecution(createDefaultParams()));
await act(async () => {
await result.current.executeQuery("SELECT * FROM users");
});
const historyArg = (storage.addToHistory as ReturnType<typeof mock>).mock.calls[0][0] as Record<string, unknown>;
expect(historyArg.rowCount).toBe(mockQueryResult.rowCount);
expect("kind" in historyArg).toBe(false);
});
// ── executeQuery adds to history on success ────────────────────────────────
test("executeQuery adds to history on success", async () => {
mockGlobalFetch({
"/api/db/query": { ok: true, json: mockQueryResult },
});
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("SELECT * FROM users");
});
expect(storage.addToHistory).toHaveBeenCalled();
const historyArg = (storage.addToHistory as ReturnType<typeof mock>).mock.calls[0][0] as Record<string, unknown>;
expect(historyArg.query).toBe("SELECT * FROM users");
expect(historyArg.connectionId).toBe("qe-pg-1");
expect(historyArg.status).toBe("success");
});
// ── executeQuery shows toast on error ──────────────────────────────────────
test("executeQuery shows toast on error", async () => {
mockGlobalFetch({
"/api/db/query": { ok: false, status: 400, json: { error: "syntax error at position 1" } },
});
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("SELEC * FROM users");
});
expect(mockToastError).toHaveBeenCalled();
});
// ── executeQuery sets safetyCheckQuery for dangerous queries ───────────────
test("executeQuery sets safetyCheckQuery for dangerous queries (DROP/DELETE)", async () => {
mockGlobalFetch({});
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
const onFailure = mock((_message: string) => {});
let ran: boolean | undefined;
await act(async () => {
ran = await result.current.executeQuery("DROP TABLE users", undefined, false, { onFailure });
});
expect(result.current.safetyCheckQuery).toBe("DROP TABLE users");
// Handed to the safety dialog, not failed: no message, so the import dialog closes and the
// statement the safety dialog runs on Proceed is not offered a second time (#1396).
expect(ran).toBe(false);
expect(onFailure).not.toHaveBeenCalled();
});
/**
* The standalone path's half of #294: the hook must ask the gate about the query
* text AS WRITTEN, comments included, and open the dialog on a positive answer.
*
* The predicate itself is stubbed in this file (see the top), so this asserts the
* call site's contract - no trimming, no normalising, no re-derived SELECT test
* before the gate - while the predicate's comment tolerance is pinned in
* tests/components/QuerySafetyDialog.test.tsx against the real export.
*/
test("executeQuery asks the safety gate about the query text as written", async () => {
mockGlobalFetch({});
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
const annotated = "-- cleanup\nDROP TABLE users";
await act(async () => {
await result.current.executeQuery(annotated);
});
// The active connection's type travels with the text: the predicate reads a
// statement per dialect (#292), and this call site is one of the two places
// that knows which dialect the statement is about to run on.
expect(isDangerousQueryMock).toHaveBeenCalledWith(annotated, "postgres");
expect(result.current.safetyCheckQuery).toBe(annotated);
});
/**
* The standalone path's half of #297, in this file's division of labour: a script
* whose reading cannot be resolved is handed to the gate whole - the unresolvable
* literal included, on the line where the user wrote it - and a positive answer
* opens the dialog instead of executing.
*
* What the REAL predicate answers for this exact text is pinned in
* tests/components/QuerySafetyDialog.test.tsx (it asks); the embedded adapter
* exercises the real predicate end to end in tests/hooks/use-query-adapter.test.ts.
* Here the stub answers on the DELETE, so what is provable is the call site: the
* gate sees the script as written and its answer decides whether anything runs.
*/
test("asks the safety gate about a script whose literal cannot be resolved", async () => {
mockGlobalFetch({});
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
const hidden = "SELECT '\\';\nDELETE FROM t WHERE id = 1";
await act(async () => {
await result.current.executeQuery(hidden);
});
expect(isDangerousQueryMock).toHaveBeenCalledWith(hidden, "postgres");
expect(result.current.safetyCheckQuery).toBe(hidden);
});
test("executeQuery sets safetyCheckQuery for DELETE queries", async () => {
mockGlobalFetch({});
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("DELETE FROM users WHERE id = 1");
});
expect(result.current.safetyCheckQuery).toBe("DELETE FROM users WHERE id = 1");
});
// ── executeQuery skips safety check when skipSafety is true ────────────────
test("executeQuery skips safety check when skipSafety is true", async () => {
const fetchMock = mockGlobalFetch({
"/api/db/query": { ok: true, json: { ...mockQueryResult, rows: [], rowCount: 0 } },
});
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("DROP TABLE users", undefined, false, { skipSafety: true });
});
// Should NOT set safetyCheckQuery, should proceed to execute
expect(result.current.safetyCheckQuery).toBeNull();
const queryCall = fetchMock.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("/api/db/query"),
);
expect(queryCall).toBeDefined();
});
// ── forceExecuteQuery clears safetyCheckQuery and calls executeQuery ───────
test("forceExecuteQuery clears safetyCheckQuery and calls with skipSafety", async () => {
const fetchMock = mockGlobalFetch({
"/api/db/query": { ok: true, json: { ...mockQueryResult, rows: [], rowCount: 0 } },
});
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
// First trigger safety check
await act(async () => {
await result.current.executeQuery("DROP TABLE users");
});
expect(result.current.safetyCheckQuery).toBe("DROP TABLE users");
// Now force execute
await act(async () => {
result.current.forceExecuteQuery("DROP TABLE users");
});
// safetyCheckQuery should be cleared
expect(result.current.safetyCheckQuery).toBeNull();
// Query should have been sent to server
const queryCall = fetchMock.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("/api/db/query"),
);
expect(queryCall).toBeDefined();
});
// ── cancelQuery aborts the fetch controller ────────────────────────────────
test("cancelQuery aborts the fetch controller", async () => {
// We intercept fetch to track AbortSignal usage
let abortSignalUsed = false;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/api/db/query") && init?.signal) {
// Track that signal was provided
abortSignalUsed = true;
// Return a delayed promise that respects abort
return new Promise<Response>((resolve, reject) => {
if (init.signal!.aborted) {
reject(new DOMException("The operation was aborted.", "AbortError"));
return;
}
init.signal!.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted.", "AbortError"));
});
// Never resolve naturally — test will cancel
});
}
if (url.includes("/api/db/cancel")) {
return new Response(JSON.stringify({ cancelled: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Not found" }), { status: 404 });
}) as typeof fetch;
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
// Start a query (don't await it — it will hang until cancelled)
const queryPromise = act(async () => {
await result.current.executeQuery("SELECT * FROM users");
});
// Cancel it
await act(async () => {
await result.current.cancelQuery();
});
await queryPromise;
expect(abortSignalUsed).toBe(true);
// The server confirmed the cancel, so the toast says it happened, once.
expect(mockToastSuccess).toHaveBeenCalledTimes(1);
expect(mockToastSuccess).toHaveBeenCalledWith("Query Cancelled", {
description: "Query execution was cancelled.",
id: "loading-toast",
});
globalThis.fetch = originalFetch;
});
// ── cancelQuery calls /api/db/cancel on server ─────────────────────────────
test("cancelQuery calls /api/db/cancel on server", async () => {
let cancelCalled = false;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/api/db/query")) {
return new Promise<Response>((resolve, reject) => {
if (init?.signal?.aborted) {
reject(new DOMException("The operation was aborted.", "AbortError"));
return;
}
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted.", "AbortError"));
});
});
}
if (url.includes("/api/db/cancel")) {
cancelCalled = true;
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Not found" }), { status: 404 });
}) as typeof fetch;
const params = createDefaultParams();
const { result } = renderHook(() => useQueryExecution(params));
// Start query
const queryPromise = act(async () => {
await result.current.executeQuery("SELECT * FROM users");
});
// Cancel
await act(async () => {
await result.current.cancelQuery();
});
await queryPromise;
expect(cancelCalled).toBe(true);
globalThis.fetch = originalFetch;
});
// ── handleLoadMore calls executeQuery with offset ──────────────────────────
test("handleLoadMore calls executeQuery with offset", async () => {
const tabWithResults = createTab({
result: {
...mockQueryResult,
pagination: { limit: 500, offset: 0, hasMore: true, totalReturned: 500, wasLimited: true },
},
currentOffset: 500,
});
const fetchMock = mockGlobalFetch({
"/api/db/query": { ok: true, json: { ...mockQueryResult, rows: [{ id: 3, name: "Charlie" }], rowCount: 1 } },
});
const params = createDefaultParams({
tabs: [tabWithResults],
currentTab: tabWithResults,
});
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
result.current.handleLoadMore();
});
await waitFor(() => {
const queryCall = fetchMock.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("/api/db/query"),
);
expect(queryCall).toBeDefined();
const body = JSON.parse(queryCall![1]!.body as string);
expect(body.options.offset).toBe(500);
});
});
/**
* The next page of a key's tab is still that key's database.
*
* Pagination is a SECOND run of the same tab, and the tab is what carries the numbered database
* (`QueryTab.databaseOverride`) - so a page reached with Next has to be sent on a connection naming
* it, exactly as the first one was. Everything else about the page is unchanged: it is the same
* statement, at the offset the grid asked for.
*/
test("handleLoadMore keeps the tab's own database", async () => {
const tabWithResults = createTab({
databaseOverride: 3,
result: {
...mockQueryResult,
pagination: { limit: 500, offset: 0, hasMore: true, totalReturned: 500, wasLimited: true },
},
currentOffset: 500,
});
const fetchMock = mockGlobalFetch({
"/api/db/query": { ok: true, json: { ...mockQueryResult, rows: [{ id: 3, name: "Charlie" }], rowCount: 1 } },
});
const params = createDefaultParams({ tabs: [tabWithResults], currentTab: tabWithResults });
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
result.current.handleLoadMore();
});
await waitFor(() => {
const queryCall = fetchMock.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("/api/db/query"),
);
expect(queryCall).toBeDefined();
const body = JSON.parse(queryCall![1]!.body as string);
expect(body.options.offset).toBe(500);
expect(body.database).toBe(3);
});
});
// ── result pagination (#816) ───────────────────────────────────────────────
/**
* A tabs array the hook can really write to, so a test can read the state BACK.
*
* The shared `createDefaultParams` mock applies the updater and throws the result
* away, which is enough for "setTabs was called" and cannot see what was written.
* Criterion 8 is entirely about what was written — the rows and `currentOffset` after
* a failure — so it needs this.
*/
function mutableTabs(initial: QueryTab[]) {
const tabs = [...initial];
const setTabs = mock((fn: unknown) => {
if (typeof fn === "function") {
tabs.splice(0, tabs.length, ...(fn as (prev: QueryTab[]) => QueryTab[])(tabs));
}
});
return { tabs, setTabs };
}
/**
* Page two is the size of page one, in BOTH shells.
*
* `limit: 500` was hardcoded here. A tree click now asks for 50 rows, so a hardcoded
* 500 made the second page ten times the first. The size to reuse is the one the
* result reports, which is the one the route applied.
*/
test("handleLoadMore asks for the page size the first page came back with", async () => {
const tabWithResults = createTab({
result: {
...mockQueryResult,
pagination: { limit: 50, offset: 0, hasMore: true, totalReturned: 50, wasLimited: true },
},
currentOffset: 50,
});
const fetchMock = mockGlobalFetch({
"/api/db/query": { ok: true, json: { ...mockQueryResult, rows: [{ id: 3 }], rowCount: 1 } },
});
const params = createDefaultParams({ tabs: [tabWithResults], currentTab: tabWithResults });
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
result.current.handleLoadMore();
});
await waitFor(() => {
const queryCall = fetchMock.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("/api/db/query"),
);
expect(queryCall).toBeDefined();
const body = JSON.parse(queryCall![1]!.body as string);
expect(body.options.limit).toBe(50);
expect(body.options.offset).toBe(50);
});
});
/**
* Criterion 8, in the standalone shell: a failed page must leave the rows and the
* offset exactly as they were, so a retry asks for the same page rather than skipping
* one. `use-query-adapter.test.ts` holds the mirror of this test, because the two
* shells render in different products and are only kept in step by being asserted
* separately.
*/
test("a failed page keeps the loaded rows and does not advance currentOffset", async () => {
const existingRows = [{ id: 1 }, { id: 2 }];
const tabWithResults = createTab({
result: {
...mockQueryResult,
rows: existingRows,
rowCount: 2,
pagination: { limit: 50, offset: 0, hasMore: true, totalReturned: 2, wasLimited: true },
},
allRows: existingRows,
currentOffset: 50,
});
const { tabs, setTabs } = mutableTabs([tabWithResults]);
mockGlobalFetch({ "/api/db/query": { ok: false, status: 500, json: { error: "connection reset" } } });
const params = createDefaultParams({ tabs, currentTab: tabWithResults, setTabs });
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
result.current.handleLoadMore();
});
await waitFor(() => expect(tabs[0].isLoadingMore).toBe(false));
// NAMED FOR WHAT FAILED, and named the same in both products (#816 review item 7).
// A lost page is not a lost query: the rows on screen are intact and only the next
// page did not arrive. Under the generic "Query Error" the user reads their own
// statement as having failed. `use-query-adapter.ts` already says "Load More Error";
// the two hooks render in different products and only stay in step by being asked
// the same question.
expect(mockToastError).toHaveBeenCalledWith("Load More Error", { description: "connection reset" });
expect(tabs[0].result!.rows).toHaveLength(2);
expect(tabs[0].allRows).toHaveLength(2);
expect(tabs[0].currentOffset).toBe(50);
// The page is what failed, not the statement, so the panel keeps its rows and says
// nothing inline.
expect(tabs[0].runError).toBeUndefined();
});
/**
* A failed NEW run is the opposite case of the page above: the statement on screen is
* no longer the one that last ran, so the rows it fetched may not stay under it.
* Measured before this: a good run, then `SELEC * FROM x`, left the first run's rows,
* header and `resultQuery` in the tab, and the only signal was a toast that fades, so
* the grid, export and inline edit all acted on the previous statement's rows.
*/
test("a failed run replaces the previous result with its error, and the next success clears it", async () => {
const { tabs, setTabs } = mutableTabs([createTab()]);
mockGlobalFetch({
"/api/db/query": async (req) => {
const body = (await req.json()) as { sql: string };
return body.sql.startsWith("SELEC ")
? { ok: false, status: 400, json: { error: 'near "SELEC": syntax error' } }
: { ok: true, json: mockQueryResult };
},
});
const params = createDefaultParams({ tabs, currentTab: tabs[0], setTabs });
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("SELECT * FROM users");
});
expect(tabs[0].result?.rows).toHaveLength(2);
const onFailure = mock((_message: string) => {});
await act(async () => {
await result.current.executeQuery("SELEC * FROM x", undefined, false, { onFailure });
});
expect(tabs[0].isExecuting).toBe(false);
expect(tabs[0].runError).toBe('near "SELEC": syntax error');
// A caller that asked for the failure gets the same message (#1396).
expect(onFailure).toHaveBeenCalledWith('near "SELEC": syntax error');
expect(tabs[0].result).toBeNull();
expect(tabs[0].resultQuery).toBeUndefined();
expect(tabs[0].allRows).toBeUndefined();
expect(tabs[0].currentOffset).toBe(0);
// The toast stays: the inline block is the lasting signal, not the only one.
expect(mockToastError).toHaveBeenCalledWith("Query Error", { description: 'near "SELEC": syntax error' });
await act(async () => {
await result.current.executeQuery("SELECT * FROM users");
});
expect(tabs[0].runError).toBeUndefined();
expect(tabs[0].result?.rows).toHaveLength(2);
expect(tabs[0].resultQuery).toBe("SELECT * FROM users");
});
// A privilege the credential lacks is the run's error like any other refusal, never a session problem: the
// browser signs in again only for a 401 carrying AUTH_REQUIRED (`appFetch` in src/lib/config/base-path.ts).
test("a refused privilege (403 PERMISSION_DENIED) is the run's error and leaves the session alone", async () => {
const { tabs, setTabs } = mutableTabs([createTab()]);
const sentence = "Permission denied: permission denied for table orders";
mockGlobalFetch({
"/api/db/query": {
ok: false,
status: 403,
json: { error: sentence, code: "PERMISSION_DENIED", statusCode: 403 },
},
});
const sessionEnded = mock(() => {});
const unregister = onSessionEnded(sessionEnded);
try {
const params = createDefaultParams({ tabs, currentTab: tabs[0], setTabs });
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("INSERT INTO orders VALUES (1)");
});
expect(tabs[0].runError).toBe(sentence);
expect(mockToastError).toHaveBeenCalledWith("Query Error", { description: sentence });
expect(sessionEnded).not.toHaveBeenCalled();
} finally {
unregister();
}
});
/**
* A superseded run's refusal belongs to a statement the tab no longer shows, so its
* error must not replace the rows of the run that took the tab over.
*/
test("a superseded run's failure writes no error over the run that replaced it", async () => {
const { tabs, setTabs } = mutableTabs([createTab()]);
let releaseFirst: () => void = () => {};
const firstHeld = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
mockGlobalFetch({
"/api/db/query": async (req) => {
const body = (await req.json()) as { sql: string; explain?: unknown };
if (body.sql !== "SELEC * FROM x" || body.explain !== undefined) return { ok: true, json: mockQueryResult };
await firstHeld;
return { ok: false, status: 400, json: { error: 'near "SELEC": syntax error' } };
},
});
const params = createDefaultParams({ tabs, currentTab: tabs[0], setTabs });
const { result } = renderHook(() => useQueryExecution(params));
let first: Promise<boolean> | undefined;
act(() => {
first = result.current.executeQuery("SELEC * FROM x");
});
await act(async () => {
await result.current.executeQuery("SELECT * FROM users");
});
releaseFirst();
await act(async () => {
await first;
});
expect(tabs[0].runError).toBeUndefined();
expect(tabs[0].resultQuery).toBe("SELECT * FROM users");
expect(tabs[0].result?.rows).toHaveLength(2);
});
/** An EXPLAIN never owned the results panel, so landing one does not answer its error. */
test("an EXPLAIN that succeeds leaves the tab's run error in place", async () => {
const { tabs, setTabs } = mutableTabs([createTab({ runError: 'near "SELEC": syntax error' })]);
mockGlobalFetch({
"/api/db/query": {
ok: true,
json: { rows: [{ "QUERY PLAN": { plan: "Seq Scan" } }], fields: ["QUERY PLAN"], rowCount: 1, executionTime: 5 },
},
});
const params = createDefaultParams({ tabs, currentTab: tabs[0], setTabs });
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("SELECT * FROM users", undefined, true);
});
expect(tabs[0].explainPlan).toBeDefined();
expect(tabs[0].runError).toBe('near "SELEC": syntax error');
});
/** The same exemption from the other side: a failed EXPLAIN takes no rows off the panel. */
test("an EXPLAIN that fails leaves the previous result and sets no run error", async () => {
const tab = createTab({
result: mockQueryResult,
resultQuery: "SELECT * FROM users",
allRows: mockQueryResult.rows,
});
const { tabs, setTabs } = mutableTabs([tab]);
mockGlobalFetch({ "/api/db/query": { ok: false, status: 400, json: { error: "EXPLAIN is not allowed here" } } });
const params = createDefaultParams({ tabs, currentTab: tab, setTabs });
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("SELECT * FROM users", undefined, true);
});
expect(mockToastError).toHaveBeenCalledWith("Query Error", { description: "EXPLAIN is not allowed here" });
expect(tabs[0].runError).toBeUndefined();
expect(tabs[0].result?.rows).toHaveLength(2);
expect(tabs[0].resultQuery).toBe("SELECT * FROM users");
});
/** A thrown request, not a refused one, is the same failure to the reader. */
test("a request that throws replaces the previous result too", async () => {
const tab = createTab({
result: mockQueryResult,
resultQuery: "SELECT * FROM users",
allRows: mockQueryResult.rows,
});
const { tabs, setTabs } = mutableTabs([tab]);
globalThis.fetch = mock(async () => {
throw new TypeError("Failed to fetch");
}) as unknown as typeof fetch;
const params = createDefaultParams({ tabs, currentTab: tab, setTabs });
const { result } = renderHook(() => useQueryExecution(params));
await act(async () => {
await result.current.executeQuery("SELECT * FROM users");
});
expect(tabs[0].runError).toBe("Failed to fetch");
expect(tabs[0].result).toBeNull();
});
/**
* The owner's scope: a cancellation keeps today's behaviour and is not an error. This is
* the shape a server-side cancel arrives in, `createErrorResponse`'s 499 and its code.
*/
test("a cancelled run leaves the previous result and sets no error", async () => {
const tab = createTab({
result: mockQueryResult,
resultQuery: "SELECT * FROM users",
allRows: mockQueryResult.rows,
});
const { tabs, setTabs } = mutableTabs([tab]);
mockGlobalFetch({
"/api/db/query": {