Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: duckdb-wasm query UI [DRAFT] #39

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
15 changes: 10 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"serve": "vite preview"
},
"dependencies": {
"@codemirror/lang-sql": "^0.20.0",
"@duckdb/duckdb-wasm": "^1.14.1",
"@githubocto/flat-ui": "^0.13.5",
"@octokit/rest": "^18.3.5",
"@popperjs/core": "^2.9.1",
Expand All @@ -15,6 +17,7 @@
"@types/lodash": "^4.14.168",
"@types/lodash.debounce": "^4.0.6",
"@types/lodash.truncate": "^4.0.6",
"@uiw/react-codemirror": "^4.7.0",
"classcat": "^5.0.3",
"d3-dsv": "^2.0.0",
"date-fns": "^2.19.0",
Expand All @@ -27,15 +30,17 @@
"query-string": "^6.14.1",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-error-boundary": "^3.1.4",
"react-head": "^3.4.0",
"react-hot-toast": "^1.0.2",
"react-icons": "^4.2.0",
"react-popper": "^2.2.4",
"react-portal": "^4.2.1",
"react-query": "^3.12.1",
"react-query": "^3.39.0",
"react-router-dom": "^5.2.0",
"reakit": "^1.3.8",
"store2": "^2.12.0",
"use-debounce": "^8.0.1",
"use-query-params": "^1.2.2",
"vite-plugin-rewrite-all": "^0.1.2",
"wretch": "^1.7.4",
Expand All @@ -44,17 +49,17 @@
},
"devDependencies": {
"@octokit/types": "^6.12.2",
"@tailwindcss/forms": "^0.2.1",
"@tailwindcss/forms": "^0.5.1",
"@types/nprogress": "^0.2.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-portal": "^4.0.2",
"@types/react-router-dom": "^5.1.7",
"@vitejs/plugin-react-refresh": "^1.3.1",
"@vitejs/plugin-react": "^1.3.2",
"autoprefixer": "^10.2.5",
"postcss": "^8.2.8",
"tailwindcss": "^2.1.2",
"tailwindcss": "^3.0.24",
"typescript": "^4.1.2",
"vite": "^2.0.5"
"vite": "^2.9.9"
}
}
22 changes: 11 additions & 11 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import wretch from "wretch";
import { Endpoints } from "@octokit/types";
import { csvParse, tsvParse } from "d3-dsv";
import store from "store2";
import wretch from "wretch";
import YAML from "yaml";

import { Repo, Repository } from "../types";
import { csvParse, tsvParse } from "d3-dsv";
import { Repo } from "../types";

export type listCommitsResponse =
Endpoints["GET /repos/{owner}/{repo}/commits"]["response"];
Expand Down Expand Up @@ -153,13 +152,7 @@ export async function fetchDataFile(params: FileParamsWithSHA) {
"yaml",
];
if (!validTypes.includes(fileType)) return [];
// const githubWretch = cachedPat
// ? wretch(
// `https://raw.githubusercontent.com/${owner}/${name}/${sha}/${filename}`
// ).auth(`token ${cachedPat}`)
// :

let res;
const text = await wretch(
`https://raw.githubusercontent.com/${owner}/${name}/${sha}/${filename}`
)
Expand Down Expand Up @@ -213,14 +206,15 @@ export async function fetchDataFile(params: FileParamsWithSHA) {
} else {
return [
{
content: text,
invalidValue: stringifyValue(text),
},
];
}
} catch (e) {
console.log(e);
return [
{
content: text,
invalidValue: stringifyValue(text),
},
];
Expand All @@ -229,6 +223,7 @@ export async function fetchDataFile(params: FileParamsWithSHA) {
if (typeof data !== "object") {
return [
{
content: text,
invalidValue: stringifyValue(data),
},
];
Expand All @@ -238,6 +233,7 @@ export async function fetchDataFile(params: FileParamsWithSHA) {
if (isArray) {
return [
{
content: text,
value: data,
},
];
Expand All @@ -255,19 +251,22 @@ export async function fetchDataFile(params: FileParamsWithSHA) {
if (!Array.isArray(value)) {
return {
key,
content: text,
invalidValue: stringifyValue(value),
};
}

if (typeof value[0] === "string") {
return {
key,
content: text,
value: value.map((d) => ({ value: d })),
};
}

return {
key,
content: text,
value,
};
});
Expand All @@ -278,6 +277,7 @@ export async function fetchDataFile(params: FileParamsWithSHA) {
});
return [
{
content: text,
value: parsedData,
},
];
Expand Down
249 changes: 249 additions & 0 deletions src/components/db-explorer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { sql } from "@codemirror/lang-sql";
import * as duckdb from "@duckdb/duckdb-wasm";
import eh_worker from "@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js?url";
import duckdb_wasm_next from "@duckdb/duckdb-wasm/dist/duckdb-eh.wasm?url";
import duckdb_wasm from "@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm?url";
import { Grid } from "@githubocto/flat-ui";
import CodeMirror from "@uiw/react-codemirror";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { ErrorBoundary, FallbackProps } from "react-error-boundary";
import { useQuery } from "react-query";
import { useDebounce } from "use-debounce";
import Bug from "../bug.svg";
import { useDataFile } from "../hooks";
import { ErrorState } from "./error-state";
import { LoadingState } from "./loading-state";
import { Spinner } from "./spinner";

interface Props {
sha: string;
filename: string;
owner: string;
name: string;
}

interface DBExplorerInnerProps {
content: string;
filename: string;
extension: string;
sha: string;
}

const VALID_EXTENSIONS = ["csv", "json"];

function ErrorFallback(props: FallbackProps) {
const { error, resetErrorBoundary } = props;
return (
<ErrorState img={Bug} alt={error.message}>
<p>{error?.message}</p>
<div className="mt-4">
<button
className="inline-flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-gray-900 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
onClick={resetErrorBoundary}
>
Reset Query
</button>
</div>
</ErrorState>
);
}

function DBExplorerInner(props: DBExplorerInnerProps) {
const { content, extension, filename, sha } = props;
const filenameWithoutExtension = filename.split(".").slice(0, -1).join(".");
const connectionRef = useRef<duckdb.AsyncDuckDBConnection | null>(null);
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebounce(query, 500);
const [dbStatus, setDbStatus] = useState<"error" | "idle" | "success">(
"idle"
);

const execQuery = async (query: string) => {
if (!connectionRef.current) return;
const queryRes = await connectionRef.current.query(query);
const asArray = queryRes.toArray();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to convert the result to an array. Arrow tables already behave like arrays of objects if you iterate over them for for.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@domoritz Fantastic! This felt kludgy and I suspected there was a better way.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

map and forEach won't work so you may have to refactor your code a bit but it would be worth it since you avoid a lot of upfront conversion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@domoritz Good call. We're ultimately passing this data down to our flat-ui component , so it needs to be in a shape that this component understands (or we need to refactor the component)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may need to refactor the component. Shouldn't be a big deal, though, and would be great for interoperability anyway.


return {
numRows: queryRes.numRows,
numCols: queryRes.numCols,
results: asArray.map((row) => {
return row.toJSON();
mattrothenberg marked this conversation as resolved.
Show resolved Hide resolved
}),
};
};

const { data, status, error } = useQuery(
["query-results", filename, sha, debouncedQuery],
() => execQuery(debouncedQuery),
{
refetchOnWindowFocus: false,
retry: false,
enabled: dbStatus === "success",
}
);

useEffect(() => {
const initDuckDb = async () => {
const MANUAL_BUNDLES: duckdb.DuckDBBundles = {
mvp: {
mainModule: duckdb_wasm,
mainWorker: eh_worker,
},
eh: {
mainModule: duckdb_wasm_next,
mainWorker: eh_worker,
},
};
const bundle = await duckdb.selectBundle(MANUAL_BUNDLES);
const worker = new Worker(bundle.mainWorker!);
const logger = new duckdb.ConsoleLogger();
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);

const c = await db.connect();
connectionRef.current = c;

try {
await db.registerFileText(filename, content);
extension === "csv"
mattrothenberg marked this conversation as resolved.
Show resolved Hide resolved
? await c.insertCSVFromPath(filename, {
name: filenameWithoutExtension,
})
: await c.insertJSONFromPath(filename, {
name: filenameWithoutExtension,
});
setDbStatus("success");
setQuery(`select * from '${filenameWithoutExtension}'`);
mattrothenberg marked this conversation as resolved.
Show resolved Hide resolved
} catch (e) {
console.error(e);
setDbStatus("error");
}
};

initDuckDb();

return () => {
if (connectionRef.current) {
connectionRef.current.close();
connectionRef.current = null;
setDbStatus("idle");
}
};
}, [content, sha, filename]);

const sqlSchema = useMemo(() => {
if (!content) return [];

if (extension === "csv") {
const names = content.split("\n")[0].split(",");
return names.map((name) => name.replace(/"/g, ""));
} else if (extension === "json") {
try {
return Object.keys(JSON.parse(content)[0]);
} catch {
return [];
}
} else {
return [];
}
}, [content]);

return (
<div className="flex-1 flex-shrink-0 overflow-hidden flex flex-col z-0">
{dbStatus === "idle" && <LoadingState text="Initializing DuckDB 🦆" />}
{dbStatus === "error" && (
<ErrorState img={Bug} alt="Database initialization error">
Couldn't initialize DuckDB 😕
</ErrorState>
)}
{dbStatus === "success" && (
<>
<div className="border-b bg-gray-50 sticky top-0 z-20">
<CodeMirror
value={query}
height={"120px"}
className="w-full"
extensions={[
sql({
defaultTable: filenameWithoutExtension,
schema: {
[filenameWithoutExtension]: sqlSchema,
},
}),
]}
onChange={(value) => {
setQuery(value);
}}
/>
</div>
<div className="flex-1 flex flex-col h-full overflow-scroll">
{status === "error" && error && (
<div className="bg-red-50 border-b border-red-600 p-2 text-sm text-red-600">
{(error as Error)?.message || "An unexpected error occurred."}
</div>
)}
<div className="relative flex-1 h-full">
{status === "loading" && (
<div className="absolute top-4 right-4 z-20">
<Spinner />
</div>
)}
{data && (
<ErrorBoundary
FallbackComponent={ErrorFallback}
resetKeys={[query, debouncedQuery]}
onReset={() => {
setQuery(`select * from '${filenameWithoutExtension}'`);
}}
>
<Grid
data={data.results}
diffData={undefined}
defaultSort={undefined}
defaultStickyColumnName={undefined}
defaultFilters={{}}
downloadFilename={filename}
onChange={() => {}}
/>
</ErrorBoundary>
)}
</div>
</div>
</>
)}
</div>
);
}

export function DBExplorer(props: Props) {
const { sha, filename, owner, name } = props;
const { data, status } = useDataFile(
{
sha,
filename,
owner,
name,
},
{
refetchOnWindowFocus: false,
retry: false,
}
);

const extension = filename.split(".").pop() || "";
const content = data ? data[0].content : "";

return (
<>
{status === "loading" && <LoadingState />}
{status === "success" && data && VALID_EXTENSIONS.includes(extension) && (
<DBExplorerInner
sha={sha}
filename={filename}
extension={extension}
content={content}
/>
)}
</>
);
}
Loading