Skip to content

Commit 8882d75

Browse files
committed
Switch to Remix
1 parent 9b39a67 commit 8882d75

31 files changed

+2308
-675
lines changed

.editorconfig

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# EditorConfig is awesome: https://EditorConfig.org
2+
3+
# top-most EditorConfig file
4+
root = true
5+
6+
[*]
7+
indent_style = tab
8+
indent_size = 2
9+
end_of_line = lf
10+
charset = utf-8
11+
trim_trailing_whitespace = false
12+
insert_final_newline = false

.gitignore

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
1-
node_modules/
2-
dist/
1+
.env
2+
!.env.example
3+
.DS_Store
4+
.react-router
5+
build
6+
node_modules
7+
*.tsbuildinfo

.prettierrc

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2-
"singleQuote": true,
3-
"tabWidth": 2
2+
"singleQuote": true,
3+
"tabWidth": 2,
4+
"semi": false
45
}

.vscode/settings.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"editor.formatOnSave": true
2+
"editor.formatOnSave": true
33
}

renderer/index.css app/app.css

File renamed without changes.

app/components/main.tsx

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { observer } from 'mobx-react-lite'
2+
import { Fragment } from 'react'
3+
import {
4+
bindMobxInput,
5+
formatQueryAction,
6+
parseOne,
7+
removeQueryAction,
8+
state,
9+
type Query,
10+
} from './state'
11+
12+
export const Page = observer(function Page() {
13+
const parsed = state.parsed
14+
15+
return (
16+
<div className="flex flex-col gap-2">
17+
<div>SQLite JSON Query Tool</div>
18+
<div className="p-2 border rounded prose">
19+
<p>
20+
The <a href="https://www.sqlite.org/json1.html">JSON API of SQLite</a>{' '}
21+
allows new ways to combine multiple queries together. However, in
22+
order to create JSON objects, all of the column names must be known
23+
ahead of time. This can be a bit tedious to get right.
24+
</p>
25+
<p>
26+
This tool will try to auto-detect your column names and generate a
27+
wrapper query that is ready to go without any dependencies.
28+
</p>
29+
</div>
30+
31+
{state.queries.map((query, index) => (
32+
<Fragment key={index}>
33+
{index !== 0 ? <hr /> : null}
34+
<QueryEditor index={index} query={query} />
35+
</Fragment>
36+
))}
37+
38+
<hr />
39+
40+
<div>
41+
<button
42+
onClick={state.addQuery}
43+
className="p-1 border bg-gray-100 hover:bg-gray-200 active:bg-gray-200"
44+
>
45+
Add another query
46+
</button>
47+
</div>
48+
49+
<hr />
50+
51+
<div>Wrapped query:</div>
52+
53+
<div className="font-mono rounded bg-blue-50 form-textarea w-full whitespace-pre-wrap text-sm">
54+
{parsed}
55+
</div>
56+
</div>
57+
)
58+
})
59+
60+
const QueryEditor = observer(function QueryEditor({
61+
index,
62+
query,
63+
}: {
64+
index: number
65+
query: Query
66+
}) {
67+
const names = parseOne(query)
68+
69+
return (
70+
<>
71+
<label>Query {index + 1}</label>
72+
<input
73+
type="text"
74+
className="form-input rounded"
75+
{...bindMobxInput(query, 'name')}
76+
/>
77+
<textarea
78+
className="form-textarea w-full rounded"
79+
rows={10}
80+
{...bindMobxInput(query, 'sql')}
81+
/>
82+
<div className="flex gap-2">
83+
<button
84+
onClick={formatQueryAction(query)}
85+
className="p-1 border bg-gray-100 hover:bg-gray-200 active:bg-gray-200"
86+
>
87+
Format
88+
</button>
89+
<button
90+
onClick={removeQueryAction(index)}
91+
className="p-1 border bg-gray-100 hover:bg-gray-200 active:bg-gray-200"
92+
>
93+
Remove query
94+
</button>
95+
</div>
96+
<div>
97+
Detected columns: <span className="font-bold">{names?.join(', ')}</span>
98+
</div>
99+
</>
100+
)
101+
})

app/components/state.ts

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import indentString from 'indent-string'
2+
import { once } from 'lodash-es'
3+
import { action, makeAutoObservable, remove, runInAction } from 'mobx'
4+
import type { Column } from 'node-sql-parser'
5+
import nodeSqlParser from 'node-sql-parser' // https://vitejs.dev/guide/migration#ssr-externalized-modules-value-now-matches-production
6+
import type { ChangeEvent } from 'react'
7+
8+
const getParser = once(() => {
9+
const { Parser } = nodeSqlParser
10+
return new Parser()
11+
})
12+
13+
export type Query = {
14+
name: string
15+
sql: string
16+
}
17+
18+
export const DEFAULT_QUERY: Query = {
19+
name: 'my_query',
20+
sql: `select
21+
id,
22+
employees.name,
23+
ranking as my_rank
24+
from
25+
employees
26+
limit
27+
2`,
28+
}
29+
30+
export const state = makeAutoObservable(
31+
{
32+
queries: [DEFAULT_QUERY] as Query[],
33+
34+
get parsed() {
35+
try {
36+
const jsonQuery = generateJsonQuery(this.queries)
37+
return jsonQuery
38+
} catch (err) {
39+
console.warn(err)
40+
return undefined
41+
}
42+
},
43+
44+
addQuery() {
45+
const newQuery = { ...DEFAULT_QUERY }
46+
newQuery.name += '_' + (this.queries.length + 1)
47+
this.queries.push(newQuery)
48+
},
49+
},
50+
{},
51+
{
52+
autoBind: true,
53+
},
54+
)
55+
56+
// TODO: is there a way to define the action more
57+
// efficiently?
58+
export const formatQueryAction = (query: Query) => async () => {
59+
const { formatDialect, sqlite } = await import('sql-formatter')
60+
61+
const sql = formatDialect(query.sql, {
62+
dialect: sqlite,
63+
tabWidth: 2,
64+
})
65+
66+
runInAction(() => {
67+
query.sql = sql
68+
})
69+
}
70+
71+
// TODO: is there a way to define the action more
72+
// efficiently?
73+
export const removeQueryAction = (index: number) =>
74+
action(() => {
75+
remove(state.queries, index as any)
76+
})
77+
78+
export function bindMobxInput<T, K extends keyof T>(model: T, field: K) {
79+
return {
80+
value: model[field],
81+
onChange: action(
82+
(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
83+
;(model as any)[field] = event.target.value
84+
},
85+
),
86+
}
87+
}
88+
89+
function getColumnNames(columns: Column[]) {
90+
const names: string[] = []
91+
92+
for (const column of columns) {
93+
if (column.as) {
94+
names.push(column.as)
95+
} else if (column.expr.type === 'column_ref') {
96+
names.push(column.expr.column)
97+
} else {
98+
console.warn('Cannot parse column def', column)
99+
}
100+
}
101+
102+
return names
103+
}
104+
105+
export function parseOne(query: Query) {
106+
try {
107+
const parser = getParser()
108+
const ast = parser.astify(query.sql, { database: 'sqlite' })
109+
console.log(ast)
110+
const first = Array.isArray(ast) ? ast[0] : ast
111+
if (!first) return undefined
112+
if (first.type !== 'select') return undefined
113+
const columns = first.columns
114+
if (!Array.isArray(columns)) return undefined
115+
const names = getColumnNames(columns)
116+
return names
117+
} catch (err) {
118+
console.warn(err)
119+
return undefined
120+
}
121+
}
122+
123+
function generateJsonQuery(queries: Query[]) {
124+
const parts: string[] = []
125+
126+
for (const query of queries) {
127+
const columns = parseOne(query)
128+
if (!columns || columns.length === 0) continue
129+
parts.push(` '${query.name}',
130+
(
131+
select json_group_array(
132+
json_object(${columns.map((col) => `'${col}', ${col}`).join(', ')})
133+
)
134+
from (
135+
${indentString(query.sql, 6)}
136+
)
137+
)`)
138+
}
139+
140+
const result = `select json_object(
141+
${parts.join(',\n')}
142+
) as json_result`
143+
144+
return result
145+
}

app/root.tsx

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
isRouteErrorResponse,
3+
Links,
4+
Meta,
5+
Outlet,
6+
Scripts,
7+
ScrollRestoration,
8+
} from 'react-router'
9+
10+
import type { Route } from './+types/root'
11+
import stylesheet from './app.css?url'
12+
13+
export const links: Route.LinksFunction = () => [
14+
{ rel: 'stylesheet', href: stylesheet },
15+
]
16+
17+
export function Layout({ children }: { children: React.ReactNode }) {
18+
return (
19+
<html lang="en">
20+
<head>
21+
<meta charSet="utf-8" />
22+
<meta name="viewport" content="width=device-width, initial-scale=1" />
23+
<Meta />
24+
<Links />
25+
</head>
26+
<body className="max-w-xl p-4 mx-auto">
27+
{children}
28+
<ScrollRestoration />
29+
<Scripts />
30+
</body>
31+
</html>
32+
)
33+
}
34+
35+
export default function App() {
36+
return <Outlet />
37+
}
38+
39+
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
40+
let message = 'Oops!'
41+
let details = 'An unexpected error occurred.'
42+
let stack: string | undefined
43+
44+
if (isRouteErrorResponse(error)) {
45+
message = error.status === 404 ? '404' : 'Error'
46+
details =
47+
error.status === 404
48+
? 'The requested page could not be found.'
49+
: error.statusText || details
50+
} else if (import.meta.env.DEV && error && error instanceof Error) {
51+
details = error.message
52+
stack = error.stack
53+
}
54+
55+
return (
56+
<main className="pt-16 p-4 container mx-auto">
57+
<h1>{message}</h1>
58+
<p>{details}</p>
59+
{stack && (
60+
<pre className="w-full p-4 overflow-x-auto">
61+
<code>{stack}</code>
62+
</pre>
63+
)}
64+
</main>
65+
)
66+
}

app/routes.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { type RouteConfig, index } from '@react-router/dev/routes'
2+
3+
export default [index('routes/home.tsx')] satisfies RouteConfig

app/routes/home.tsx

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { Route } from './+types/home'
2+
import { Page } from '~/components/main'
3+
4+
export function meta({}: Route.MetaArgs) {
5+
return [
6+
{ title: 'SQLite Query Tool' },
7+
{
8+
name: 'description',
9+
content: 'Tools for experimenting with SQLite in the browser.',
10+
},
11+
]
12+
}
13+
14+
export default function Home() {
15+
return <Page />
16+
}

0 commit comments

Comments
 (0)