@marimo-team/islands 0.23.16-dev51 → 0.23.16-dev53
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/{chat-ui-1e7PcW56.js → chat-ui-9neL0bYw.js} +2 -2
- package/dist/{common-CD6phlRu.js → common-BxtRKijk.js} +2 -2
- package/dist/{html-to-image-3w0AakWW.js → html-to-image-DzQw9y4M.js} +2113 -2103
- package/dist/main.js +7 -5
- package/dist/{process-output-BRwR4QGs.js → process-output-tYmEI9FZ.js} +1 -1
- package/dist/{reveal-component-DmGGKS8z.js → reveal-component-DuXe-jZN.js} +2 -2
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +1 -0
- package/src/components/editor/connections/__tests__/quick-add-data-sources.test.tsx +113 -0
- package/src/components/editor/connections/add-connection-dialog.tsx +2 -0
- package/src/components/editor/connections/quick-add-data-sources.tsx +106 -0
- package/src/core/codemirror/language/__tests__/sql.test.ts +191 -7
- package/src/core/codemirror/language/languages/sql/sql.ts +41 -13
- package/src/core/datasets/data-source-discovery.ts +5 -0
- package/src/core/datasets/request-registry.ts +11 -0
- package/src/core/islands/bootstrap.ts +1 -0
- package/src/core/islands/bridge.ts +1 -0
- package/src/core/kernel/messages.ts +2 -0
- package/src/core/network/__tests__/requests-lazy.test.ts +15 -0
- package/src/core/network/__tests__/requests-network.test.ts +14 -0
- package/src/core/network/requests-lazy.ts +1 -0
- package/src/core/network/requests-network.ts +8 -0
- package/src/core/network/requests-static.ts +1 -0
- package/src/core/network/requests-toasting.tsx +1 -0
- package/src/core/network/types.ts +2 -0
- package/src/core/wasm/bridge.ts +10 -0
- package/src/core/websocket/useMarimoKernelConnection.tsx +4 -0
- package/src/hooks/__tests__/useDataSourceDiscovery.test.ts +80 -0
- package/src/hooks/useDataSourceDiscovery.ts +18 -0
package/package.json
CHANGED
|
@@ -41,6 +41,7 @@ export const MockRequestClient = {
|
|
|
41
41
|
previewSQLTableList: vi.fn().mockResolvedValue({ tables: [] }),
|
|
42
42
|
previewSQLSchemaList: vi.fn().mockResolvedValue({ schemas: [] }),
|
|
43
43
|
previewDataSourceConnection: vi.fn().mockResolvedValue({}),
|
|
44
|
+
discoverDataSources: vi.fn().mockResolvedValue({}),
|
|
44
45
|
validateSQL: vi.fn().mockResolvedValue({}),
|
|
45
46
|
openFile: vi.fn().mockResolvedValue({}),
|
|
46
47
|
getUsageStats: vi.fn().mockResolvedValue({}),
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DetectedDataSource } from "@/core/datasets/data-source-discovery";
|
|
6
|
+
import { QuickAddDataSources } from "../quick-add-data-sources";
|
|
7
|
+
|
|
8
|
+
const sources: DetectedDataSource[] = [
|
|
9
|
+
{
|
|
10
|
+
id: "postgres-libpq-environment",
|
|
11
|
+
integration: "postgres",
|
|
12
|
+
category: "database",
|
|
13
|
+
displayName: "PostgreSQL",
|
|
14
|
+
confidence: "high",
|
|
15
|
+
origins: [{ type: "environment", label: "Kernel environment" }],
|
|
16
|
+
configuration: [
|
|
17
|
+
{
|
|
18
|
+
field: "Host",
|
|
19
|
+
value: { kind: "environment-variable", name: "PGHOST" },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
field: "Username",
|
|
23
|
+
value: { kind: "environment-variable", name: "PGUSER" },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
field: "Database",
|
|
27
|
+
value: { kind: "environment-variable", name: "PGDATABASE" },
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
code: "engine = create_engine()",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "pyiceberg-prod",
|
|
34
|
+
integration: "pyiceberg",
|
|
35
|
+
category: "catalog",
|
|
36
|
+
displayName: "PyIceberg (prod)",
|
|
37
|
+
confidence: "high",
|
|
38
|
+
origins: [
|
|
39
|
+
{
|
|
40
|
+
type: "configuration",
|
|
41
|
+
label: "Resolved PyIceberg configuration",
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
configuration: [
|
|
45
|
+
{
|
|
46
|
+
field: "Catalog",
|
|
47
|
+
value: { kind: "safe-literal", value: "prod" },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
field: "Type",
|
|
51
|
+
value: { kind: "safe-literal", value: "REST" },
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
code: 'catalog = load_catalog("prod")',
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
describe("QuickAddDataSources", () => {
|
|
59
|
+
it("does not render an empty section", () => {
|
|
60
|
+
const { container } = render(
|
|
61
|
+
<QuickAddDataSources sources={[]} onAdd={vi.fn()} />,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(container).toBeEmptyDOMElement();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("renders detected sources as clickable tags", () => {
|
|
68
|
+
const onAdd = vi.fn();
|
|
69
|
+
|
|
70
|
+
render(<QuickAddDataSources sources={sources} onAdd={onAdd} />);
|
|
71
|
+
fireEvent.click(
|
|
72
|
+
screen.getByRole("button", {
|
|
73
|
+
name: "Add PostgreSQL connection",
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(screen.getByText("Quick add")).toBeInTheDocument();
|
|
78
|
+
expect(onAdd).toHaveBeenCalledWith(sources[0]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("shows environment references on hover", async () => {
|
|
82
|
+
render(<QuickAddDataSources sources={sources} onAdd={vi.fn()} />);
|
|
83
|
+
const tag = screen.getByRole("button", {
|
|
84
|
+
name: "Add PostgreSQL connection",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
fireEvent.pointerMove(tag);
|
|
88
|
+
fireEvent.mouseOver(tag);
|
|
89
|
+
|
|
90
|
+
await waitFor(() => {
|
|
91
|
+
expect(screen.getAllByText('os.environ["PGHOST"]')).not.toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
expect(screen.getAllByText('os.environ["PGUSER"]')).not.toHaveLength(0);
|
|
94
|
+
expect(screen.getAllByText('os.environ["PGDATABASE"]')).not.toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("shows safe configuration metadata on hover", async () => {
|
|
98
|
+
render(<QuickAddDataSources sources={sources} onAdd={vi.fn()} />);
|
|
99
|
+
const tag = screen.getByRole("button", {
|
|
100
|
+
name: "Add PyIceberg (prod) connection",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
fireEvent.pointerMove(tag);
|
|
104
|
+
fireEvent.mouseOver(tag);
|
|
105
|
+
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(
|
|
108
|
+
screen.getAllByText("Detected from Resolved PyIceberg configuration"),
|
|
109
|
+
).not.toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
expect(screen.getAllByText("REST")).not.toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { ExternalLink } from "@/components/ui/links";
|
|
13
13
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
14
14
|
import { AddDatabaseForm } from "./database/add-database-form";
|
|
15
|
+
import { AutoDiscoveredDataSources } from "./quick-add-data-sources";
|
|
15
16
|
import { AddStorageForm } from "./storage/add-storage-form";
|
|
16
17
|
|
|
17
18
|
type ConnectionTab = "databases" | "storage";
|
|
@@ -86,6 +87,7 @@ export const AddConnectionDialogContent: React.FC<{
|
|
|
86
87
|
<span className="block">{codeSnippetHint}</span>
|
|
87
88
|
</DialogDescription>
|
|
88
89
|
</DialogHeader>
|
|
90
|
+
<AutoDiscoveredDataSources onSubmit={onClose} />
|
|
89
91
|
<Tabs
|
|
90
92
|
value={activeTab}
|
|
91
93
|
onValueChange={(v) => setActiveTab(v as ConnectionTab)}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { PlusIcon, SparklesIcon } from "lucide-react";
|
|
4
|
+
import { Tooltip, TooltipProvider } from "@/components/ui/tooltip";
|
|
5
|
+
import type { DetectedDataSource } from "@/core/datasets/data-source-discovery";
|
|
6
|
+
import { useDataSourceDiscovery } from "@/hooks/useDataSourceDiscovery";
|
|
7
|
+
import { useInsertCode } from "./components";
|
|
8
|
+
|
|
9
|
+
export const QuickAddDataSources: React.FC<{
|
|
10
|
+
sources: DetectedDataSource[];
|
|
11
|
+
onAdd: (source: DetectedDataSource) => void;
|
|
12
|
+
}> = ({ sources, onAdd }) => {
|
|
13
|
+
if (sources.length === 0) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<section
|
|
19
|
+
aria-labelledby="quick-add-data-sources-title"
|
|
20
|
+
className="rounded-md border bg-muted/30 px-3 py-2"
|
|
21
|
+
>
|
|
22
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
23
|
+
<div className="mr-1 flex items-center gap-1.5">
|
|
24
|
+
<SparklesIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
25
|
+
<h3 id="quick-add-data-sources-title" className="text-sm font-medium">
|
|
26
|
+
Quick add
|
|
27
|
+
</h3>
|
|
28
|
+
</div>
|
|
29
|
+
<TooltipProvider delayDuration={200}>
|
|
30
|
+
{sources.map((source) => (
|
|
31
|
+
<Tooltip
|
|
32
|
+
key={source.id}
|
|
33
|
+
side="bottom"
|
|
34
|
+
content={<DetectedDataSourceDetails source={source} />}
|
|
35
|
+
>
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
aria-label={`Add ${source.displayName} connection`}
|
|
39
|
+
className="inline-flex items-center gap-1 rounded-full border border-(--blue-8) bg-(--blue-2) px-2.5 py-1 text-xs font-semibold text-(--blue-11) transition-colors hover:bg-(--blue-3) focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
40
|
+
onClick={() => onAdd(source)}
|
|
41
|
+
>
|
|
42
|
+
<PlusIcon className="h-3 w-3" />
|
|
43
|
+
{source.displayName}
|
|
44
|
+
</button>
|
|
45
|
+
</Tooltip>
|
|
46
|
+
))}
|
|
47
|
+
</TooltipProvider>
|
|
48
|
+
</div>
|
|
49
|
+
</section>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const DetectedDataSourceDetails: React.FC<{
|
|
54
|
+
source: DetectedDataSource;
|
|
55
|
+
}> = ({ source }) => (
|
|
56
|
+
<div className="min-w-64 space-y-2 py-1">
|
|
57
|
+
<div>
|
|
58
|
+
<div className="font-medium">{source.displayName}</div>
|
|
59
|
+
<div className="text-xs text-muted-foreground">
|
|
60
|
+
Detected from{" "}
|
|
61
|
+
{source.origins.map((origin) => origin.label).join(" and ")}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
|
65
|
+
{source.configuration.map((item) => (
|
|
66
|
+
<div
|
|
67
|
+
className="contents"
|
|
68
|
+
key={
|
|
69
|
+
item.value.kind === "environment-variable"
|
|
70
|
+
? item.value.name
|
|
71
|
+
: `${item.field}:${item.value.value}`
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
<dt className="text-muted-foreground">{item.field}</dt>
|
|
75
|
+
<dd>
|
|
76
|
+
<code>
|
|
77
|
+
{item.value.kind === "environment-variable"
|
|
78
|
+
? `os.environ["${item.value.name}"]`
|
|
79
|
+
: item.value.value}
|
|
80
|
+
</code>
|
|
81
|
+
</dd>
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</dl>
|
|
85
|
+
<div className="text-xs text-muted-foreground">
|
|
86
|
+
Click to add a configured cell.
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
export const AutoDiscoveredDataSources: React.FC<{
|
|
92
|
+
onSubmit: () => void;
|
|
93
|
+
}> = ({ onSubmit }) => {
|
|
94
|
+
const insertCode = useInsertCode();
|
|
95
|
+
const { data } = useDataSourceDiscovery();
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<QuickAddDataSources
|
|
99
|
+
sources={data ?? []}
|
|
100
|
+
onAdd={(source) => {
|
|
101
|
+
insertCode(source.code);
|
|
102
|
+
onSubmit();
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
};
|
|
@@ -27,6 +27,7 @@ import type { HotkeyProvider } from "@/core/hotkeys/hotkeys";
|
|
|
27
27
|
import { store } from "@/core/state/jotai";
|
|
28
28
|
import type { PlaceholderType } from "../../config/types";
|
|
29
29
|
import { TestSQLCompletionStore } from "../languages/sql/completion-store";
|
|
30
|
+
import * as sqlMode from "../languages/sql/sql-mode";
|
|
30
31
|
import {
|
|
31
32
|
exportedForTesting,
|
|
32
33
|
SQLLanguageAdapter,
|
|
@@ -971,13 +972,63 @@ describe("SQL analysis features", () => {
|
|
|
971
972
|
});
|
|
972
973
|
|
|
973
974
|
describe("CustomSqlParser", () => {
|
|
975
|
+
beforeEach(() => {
|
|
976
|
+
store.set(dataSourceConnectionsAtom, {
|
|
977
|
+
connectionsMap: new Map(),
|
|
978
|
+
latestEngineSelected: DUCKDB_ENGINE,
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
974
982
|
afterEach(() => {
|
|
975
983
|
vi.useRealTimers();
|
|
976
984
|
vi.restoreAllMocks();
|
|
985
|
+
store.set(dataSourceConnectionsAtom, {
|
|
986
|
+
connectionsMap: new Map(),
|
|
987
|
+
latestEngineSelected: DUCKDB_ENGINE,
|
|
988
|
+
});
|
|
977
989
|
});
|
|
978
990
|
|
|
991
|
+
const createNamedConnectionState = ({
|
|
992
|
+
doc,
|
|
993
|
+
engine,
|
|
994
|
+
dialect,
|
|
995
|
+
}: {
|
|
996
|
+
doc: string;
|
|
997
|
+
engine: ConnectionName;
|
|
998
|
+
dialect: string;
|
|
999
|
+
}) => {
|
|
1000
|
+
store.set(dataSourceConnectionsAtom, {
|
|
1001
|
+
connectionsMap: new Map([
|
|
1002
|
+
[
|
|
1003
|
+
engine,
|
|
1004
|
+
{
|
|
1005
|
+
name: engine,
|
|
1006
|
+
dialect,
|
|
1007
|
+
display_name: engine,
|
|
1008
|
+
source: dialect,
|
|
1009
|
+
databases: [],
|
|
1010
|
+
},
|
|
1011
|
+
],
|
|
1012
|
+
]),
|
|
1013
|
+
latestEngineSelected: engine,
|
|
1014
|
+
});
|
|
1015
|
+
return EditorState.create({
|
|
1016
|
+
doc,
|
|
1017
|
+
extensions: [
|
|
1018
|
+
languageMetadataField.init(() => ({
|
|
1019
|
+
dataframeName: "_df",
|
|
1020
|
+
quotePrefix: "f",
|
|
1021
|
+
commentLines: [],
|
|
1022
|
+
showOutput: true,
|
|
1023
|
+
engine,
|
|
1024
|
+
})),
|
|
1025
|
+
],
|
|
1026
|
+
});
|
|
1027
|
+
};
|
|
1028
|
+
|
|
979
1029
|
it("uses backend DuckDB validation", async () => {
|
|
980
1030
|
vi.useFakeTimers();
|
|
1031
|
+
vi.spyOn(sqlMode, "getSQLMode").mockReturnValue("validate");
|
|
981
1032
|
const error = {
|
|
982
1033
|
message: "Backend syntax error",
|
|
983
1034
|
line: 1,
|
|
@@ -1009,13 +1060,146 @@ describe("CustomSqlParser", () => {
|
|
|
1009
1060
|
await vi.runAllTimersAsync();
|
|
1010
1061
|
|
|
1011
1062
|
await expect(result).resolves.toEqual([error]);
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1063
|
+
// No connection is registered for the internal engine, so the dialect
|
|
1064
|
+
// falls back to DEFAULT_PARSER_DIALECT ("DuckDB").
|
|
1065
|
+
expect(request).toHaveBeenCalledWith({
|
|
1066
|
+
dialect: "DuckDB",
|
|
1067
|
+
engine: DUCKDB_ENGINE,
|
|
1068
|
+
onlyParse: false,
|
|
1069
|
+
query: "SELECT",
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it("resolves superseded validation requests instead of leaving them pending", async () => {
|
|
1074
|
+
vi.useFakeTimers();
|
|
1075
|
+
vi.spyOn(sqlMode, "getSQLMode").mockReturnValue("validate");
|
|
1076
|
+
const error = {
|
|
1077
|
+
message: "Backend syntax error",
|
|
1078
|
+
line: 1,
|
|
1079
|
+
column: 1,
|
|
1080
|
+
severity: "error" as const,
|
|
1081
|
+
};
|
|
1082
|
+
vi.spyOn(ValidateSQL, "request").mockResolvedValue({
|
|
1083
|
+
error: null,
|
|
1084
|
+
parse_result: { success: false, errors: [error] },
|
|
1085
|
+
request_id: "request-id",
|
|
1086
|
+
validate_result: null,
|
|
1087
|
+
});
|
|
1088
|
+
const state = EditorState.create({
|
|
1089
|
+
doc: "SELECT",
|
|
1090
|
+
extensions: [
|
|
1091
|
+
languageMetadataField.init(() => ({
|
|
1092
|
+
dataframeName: "_df",
|
|
1093
|
+
quotePrefix: "f",
|
|
1094
|
+
commentLines: [],
|
|
1095
|
+
showOutput: true,
|
|
1096
|
+
engine: DUCKDB_ENGINE,
|
|
1097
|
+
})),
|
|
1098
|
+
],
|
|
1099
|
+
});
|
|
1100
|
+
const parser = new exportedForTesting.CustomSqlParser();
|
|
1101
|
+
parser.setFocusState(true);
|
|
1102
|
+
|
|
1103
|
+
// Simulate a rapid edit: a second validation call arrives before the
|
|
1104
|
+
// first call's internal debounce timer has fired. The first call should
|
|
1105
|
+
// resolve (with no errors) rather than hang forever.
|
|
1106
|
+
const first = parser.validateSql("SELECT 1", { state });
|
|
1107
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
1108
|
+
const second = parser.validateSql("SELECT 2", { state });
|
|
1109
|
+
await vi.runAllTimersAsync();
|
|
1110
|
+
|
|
1111
|
+
await expect(first).resolves.toEqual([]);
|
|
1112
|
+
await expect(second).resolves.toEqual([error]);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it("uses backend validation for a named DuckDB connection", async () => {
|
|
1116
|
+
vi.useFakeTimers();
|
|
1117
|
+
vi.spyOn(sqlMode, "getSQLMode").mockReturnValue("validate");
|
|
1118
|
+
const query =
|
|
1119
|
+
'INSERT OR IGNORE INTO "rebalance-dates" (Timestamp) VALUES (CURRENT_TIMESTAMP);';
|
|
1120
|
+
const error = {
|
|
1121
|
+
message: "Named DuckDB backend result",
|
|
1122
|
+
line: 1,
|
|
1123
|
+
column: 1,
|
|
1124
|
+
severity: "error" as const,
|
|
1125
|
+
};
|
|
1126
|
+
const request = vi.spyOn(ValidateSQL, "request").mockResolvedValue({
|
|
1127
|
+
error: null,
|
|
1128
|
+
parse_result: { success: false, errors: [error] },
|
|
1129
|
+
request_id: "request-id",
|
|
1130
|
+
validate_result: null,
|
|
1131
|
+
});
|
|
1132
|
+
const state = createNamedConnectionState({
|
|
1133
|
+
doc: query,
|
|
1134
|
+
engine: "con" as ConnectionName,
|
|
1135
|
+
dialect: "duckdb",
|
|
1136
|
+
});
|
|
1137
|
+
const parser = new exportedForTesting.CustomSqlParser();
|
|
1138
|
+
parser.setFocusState(true);
|
|
1139
|
+
|
|
1140
|
+
const result = parser.validateSql(query, { state });
|
|
1141
|
+
await vi.runAllTimersAsync();
|
|
1142
|
+
|
|
1143
|
+
await expect(result).resolves.toEqual([error]);
|
|
1144
|
+
expect(request).toHaveBeenCalledWith({
|
|
1145
|
+
dialect: "DuckDB",
|
|
1146
|
+
engine: "con",
|
|
1147
|
+
onlyParse: true,
|
|
1148
|
+
query,
|
|
1149
|
+
});
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
it("skips client-side parsing for named DuckDB SQL", async () => {
|
|
1153
|
+
const query = "CHECKPOINT;";
|
|
1154
|
+
const state = createNamedConnectionState({
|
|
1155
|
+
doc: query,
|
|
1156
|
+
engine: "con" as ConnectionName,
|
|
1157
|
+
dialect: "duckdb",
|
|
1158
|
+
});
|
|
1159
|
+
const parser = new exportedForTesting.CustomSqlParser();
|
|
1160
|
+
|
|
1161
|
+
await expect(parser.parse(query, { state })).resolves.toEqual({
|
|
1162
|
+
success: true,
|
|
1163
|
+
errors: [],
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
it("preserves client-side parsing for non-DuckDB connections", async () => {
|
|
1168
|
+
const query = "SELECT TOP 1 * FROM users";
|
|
1169
|
+
const state = createNamedConnectionState({
|
|
1170
|
+
doc: query,
|
|
1171
|
+
engine: "postgres_con" as ConnectionName,
|
|
1172
|
+
dialect: "postgres",
|
|
1173
|
+
});
|
|
1174
|
+
const parser = new exportedForTesting.CustomSqlParser();
|
|
1175
|
+
|
|
1176
|
+
await expect(parser.parse(query, { state })).resolves.toMatchObject({
|
|
1177
|
+
success: false,
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it("preserves client-side parsing for an unregistered connection", async () => {
|
|
1182
|
+
// No entry is registered in the data source connections store for this
|
|
1183
|
+
// engine, so its dialect cannot be resolved (unlike a known non-DuckDB
|
|
1184
|
+
// dialect, which resolves to a non-null, non-DuckDB value).
|
|
1185
|
+
const query = "SELECT TOP 1 * FROM users";
|
|
1186
|
+
const state = EditorState.create({
|
|
1187
|
+
doc: query,
|
|
1188
|
+
extensions: [
|
|
1189
|
+
languageMetadataField.init(() => ({
|
|
1190
|
+
dataframeName: "_df",
|
|
1191
|
+
quotePrefix: "f",
|
|
1192
|
+
commentLines: [],
|
|
1193
|
+
showOutput: true,
|
|
1194
|
+
engine: "unregistered_con" as ConnectionName,
|
|
1195
|
+
})),
|
|
1196
|
+
],
|
|
1197
|
+
});
|
|
1198
|
+
const parser = new exportedForTesting.CustomSqlParser();
|
|
1199
|
+
|
|
1200
|
+
await expect(parser.parse(query, { state })).resolves.toMatchObject({
|
|
1201
|
+
success: false,
|
|
1202
|
+
});
|
|
1019
1203
|
});
|
|
1020
1204
|
});
|
|
1021
1205
|
|
|
@@ -66,7 +66,7 @@ import { getSQLMode, type SQLMode } from "./sql-mode";
|
|
|
66
66
|
import { isKnownDialect } from "./utils";
|
|
67
67
|
|
|
68
68
|
const DEFAULT_DIALECT = DuckDBDialect;
|
|
69
|
-
const DEFAULT_PARSER_DIALECT = "DuckDB";
|
|
69
|
+
const DEFAULT_PARSER_DIALECT: ParserDialects = "DuckDB";
|
|
70
70
|
|
|
71
71
|
// A compartment for the SQL config, so we can update the config of codemirror
|
|
72
72
|
const sqlConfigCompartment = new Compartment();
|
|
@@ -372,6 +372,8 @@ class DialectAwareSqlStructureAnalyzer extends SqlStructureAnalyzer {
|
|
|
372
372
|
|
|
373
373
|
class CustomSqlParser extends NodeSqlParser {
|
|
374
374
|
private validationTimeout: number | null = null;
|
|
375
|
+
private pendingValidationResolve: ((errors: SqlParseError[]) => void) | null =
|
|
376
|
+
null;
|
|
375
377
|
private readonly VALIDATION_DELAY_MS = 300; // Wait 300ms after user stops typing
|
|
376
378
|
private isFocused = false; // Only validate if the editor is focused
|
|
377
379
|
|
|
@@ -379,37 +381,51 @@ class CustomSqlParser extends NodeSqlParser {
|
|
|
379
381
|
this.isFocused = focused;
|
|
380
382
|
}
|
|
381
383
|
|
|
384
|
+
private resolvePendingValidation(errors: SqlParseError[]): void {
|
|
385
|
+
this.pendingValidationResolve?.(errors);
|
|
386
|
+
this.pendingValidationResolve = null;
|
|
387
|
+
}
|
|
388
|
+
|
|
382
389
|
private async validateWithDelay(
|
|
383
390
|
sql: string,
|
|
384
|
-
engine:
|
|
391
|
+
engine: ConnectionName,
|
|
385
392
|
dialect: ParserDialects | null,
|
|
386
393
|
): Promise<SqlParseError[]> {
|
|
387
|
-
// Clear any existing delay call
|
|
394
|
+
// Clear any existing delay call, resolving its promise so a superseded
|
|
395
|
+
// request doesn't hang forever.
|
|
388
396
|
if (this.validationTimeout) {
|
|
389
397
|
window.clearTimeout(this.validationTimeout);
|
|
398
|
+
this.resolvePendingValidation([]);
|
|
390
399
|
}
|
|
391
400
|
|
|
392
401
|
// Set up a new request to be called after the delay
|
|
393
402
|
return new Promise((resolve) => {
|
|
403
|
+
this.pendingValidationResolve = resolve;
|
|
394
404
|
this.validationTimeout = window.setTimeout(async () => {
|
|
405
|
+
this.validationTimeout = null;
|
|
406
|
+
|
|
395
407
|
// Only validate if the editor is still focused
|
|
396
408
|
if (!this.isFocused) {
|
|
397
|
-
|
|
409
|
+
this.resolvePendingValidation([]);
|
|
398
410
|
return;
|
|
399
411
|
}
|
|
400
412
|
|
|
401
413
|
try {
|
|
402
|
-
|
|
414
|
+
// For validate mode, we run EXPLAIN queries on the engine, which can be
|
|
415
|
+
// expensive for remote databases. So, we only run for internal engines.
|
|
416
|
+
const sqlMode = INTERNAL_SQL_ENGINES.has(engine)
|
|
417
|
+
? getSQLMode()
|
|
418
|
+
: "default";
|
|
403
419
|
const result = await validateSQL(sql, engine, dialect, sqlMode);
|
|
404
420
|
if (result.error) {
|
|
405
421
|
Logger.error("Failed to validate SQL", { error: result.error });
|
|
406
|
-
|
|
422
|
+
this.resolvePendingValidation([]);
|
|
407
423
|
return;
|
|
408
424
|
}
|
|
409
|
-
|
|
425
|
+
this.resolvePendingValidation(result.parse_result?.errors ?? []);
|
|
410
426
|
} catch (error) {
|
|
411
427
|
Logger.error("Failed to validate SQL", { error });
|
|
412
|
-
|
|
428
|
+
this.resolvePendingValidation([]);
|
|
413
429
|
}
|
|
414
430
|
}, this.VALIDATION_DELAY_MS);
|
|
415
431
|
});
|
|
@@ -420,19 +436,23 @@ class CustomSqlParser extends NodeSqlParser {
|
|
|
420
436
|
opts: { state: EditorState },
|
|
421
437
|
): Promise<SqlParseError[]> {
|
|
422
438
|
const metadata = getSQLMetadata(opts.state);
|
|
439
|
+
const dialect = connectionNameToParserDialect(metadata.engine);
|
|
423
440
|
|
|
424
441
|
// Only validate if the editor is focused
|
|
425
442
|
if (!this.isFocused) {
|
|
426
443
|
return [];
|
|
427
444
|
}
|
|
428
445
|
|
|
429
|
-
// Only perform custom validation for DuckDB
|
|
430
|
-
if (!
|
|
446
|
+
// Only perform custom validation for DuckDB as we have a custom validation endpoint for it.
|
|
447
|
+
if (!isDuckDBConnection(metadata.engine, dialect)) {
|
|
431
448
|
return super.validateSql(sql, opts);
|
|
432
449
|
}
|
|
433
450
|
|
|
434
|
-
|
|
435
|
-
|
|
451
|
+
return this.validateWithDelay(
|
|
452
|
+
sql,
|
|
453
|
+
metadata.engine,
|
|
454
|
+
dialect ?? DEFAULT_PARSER_DIALECT,
|
|
455
|
+
);
|
|
436
456
|
}
|
|
437
457
|
|
|
438
458
|
override async parse(
|
|
@@ -441,9 +461,10 @@ class CustomSqlParser extends NodeSqlParser {
|
|
|
441
461
|
): Promise<NodeSqlParserResult> {
|
|
442
462
|
const metadata = getSQLMetadata(opts.state);
|
|
443
463
|
const engine = metadata.engine;
|
|
464
|
+
const dialect = connectionNameToParserDialect(engine);
|
|
444
465
|
|
|
445
466
|
// For now, always return success for DuckDB
|
|
446
|
-
if (engine
|
|
467
|
+
if (isDuckDBConnection(engine, dialect)) {
|
|
447
468
|
return { success: true, errors: [] };
|
|
448
469
|
}
|
|
449
470
|
|
|
@@ -451,6 +472,13 @@ class CustomSqlParser extends NodeSqlParser {
|
|
|
451
472
|
}
|
|
452
473
|
}
|
|
453
474
|
|
|
475
|
+
function isDuckDBConnection(
|
|
476
|
+
engine: ConnectionName,
|
|
477
|
+
dialect: ParserDialects | null,
|
|
478
|
+
): boolean {
|
|
479
|
+
return engine === DUCKDB_ENGINE || dialect === "DuckDB";
|
|
480
|
+
}
|
|
481
|
+
|
|
454
482
|
/**
|
|
455
483
|
* Update the SQL dialect in the editor view.
|
|
456
484
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import type {
|
|
3
|
+
DataSourceDiscoveryResult,
|
|
3
4
|
SQLSchemaListPreview,
|
|
4
5
|
SQLTableListPreview,
|
|
5
6
|
SQLTablePreview,
|
|
@@ -19,6 +20,16 @@ import type {
|
|
|
19
20
|
// The backend returns data tables, which could also exist in other engines, dbs, schemas
|
|
20
21
|
// Thus, we use the request ID pattern to match the response to the request
|
|
21
22
|
|
|
23
|
+
export const DiscoverDataSources = new DeferredRequestRegistry<
|
|
24
|
+
{},
|
|
25
|
+
DataSourceDiscoveryResult
|
|
26
|
+
>("data-source-discovery-result", async (requestId) => {
|
|
27
|
+
const client = getRequestClient();
|
|
28
|
+
await client.discoverDataSources({
|
|
29
|
+
requestId,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
22
33
|
export const PreviewSQLTable = new DeferredRequestRegistry<
|
|
23
34
|
Omit<PreviewSQLTableRequest, "requestId">,
|
|
24
35
|
SQLTablePreview
|
|
@@ -341,6 +341,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
341
341
|
previewSQLTableList = throwNotImplemented;
|
|
342
342
|
previewSQLSchemaList = throwNotImplemented;
|
|
343
343
|
previewDataSourceConnection = throwNotImplemented;
|
|
344
|
+
discoverDataSources = throwNotImplemented;
|
|
344
345
|
validateSQL = throwNotImplemented;
|
|
345
346
|
openFile = throwNotImplemented;
|
|
346
347
|
sendListFiles = throwNotImplemented;
|
|
@@ -44,6 +44,8 @@ export type SQLTableListPreview =
|
|
|
44
44
|
export type SQLSchemaListPreview =
|
|
45
45
|
NotificationMessageData<"sql-schema-list-preview">;
|
|
46
46
|
export type ValidateSQLResult = NotificationMessageData<"validate-sql-result">;
|
|
47
|
+
export type DataSourceDiscoveryResult =
|
|
48
|
+
NotificationMessageData<"data-source-discovery-result">;
|
|
47
49
|
export type SecretKeysResult = NotificationMessageData<"secret-keys-result">;
|
|
48
50
|
export type StartupLogs = NotificationMessageData<"startup-logs">;
|
|
49
51
|
export type CellMessage = NotificationMessageData<"cell-op">;
|