Skip to content

Commit 8c6de3f

Browse files
committed
Add multi query support
1 parent 5fe06ca commit 8c6de3f

File tree

6 files changed

+214
-109
lines changed

6 files changed

+214
-109
lines changed

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"editor.formatOnSave": true
3+
}

package-lock.json

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"cross-env": "^7.0.3",
1818
"express": "^4.18.1",
1919
"indent-string": "^5.0.0",
20+
"mobx": "^6.9.0",
21+
"mobx-react-lite": "^3.4.3",
2022
"node-sql-parser": "^4.6.6",
2123
"react": "^18.2.0",
2224
"react-dom": "^18.2.0",

pages/index/index.page.tsx

Lines changed: 80 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,15 @@
1-
import indentString from 'indent-string';
2-
import type { Column } from 'node-sql-parser';
3-
import { Parser } from 'node-sql-parser';
4-
import { useMemo, useState } from 'react';
1+
import { action, remove } from 'mobx';
2+
import { observer } from 'mobx-react-lite';
53
import { formatDialect, sqlite } from 'sql-formatter';
4+
import { DEFAULT_QUERY, Query, parseOne, state } from './state';
5+
import { FC } from 'react';
66

7-
function getColumnNames(columns: Column[]) {
8-
const names: string[] = [];
9-
10-
for (const column of columns) {
11-
if (column.as) {
12-
names.push(column.as);
13-
} else if (column.expr.type === 'column_ref') {
14-
names.push(column.expr.column);
15-
} else {
16-
console.warn('Cannot parse column def', column);
17-
}
18-
}
19-
20-
return names;
21-
}
22-
23-
const DEFAULT_QUERY = `select
24-
id,
25-
employees.name,
26-
ranking as my_rank
27-
from
28-
employees
29-
limit
30-
2`;
31-
32-
function generateJsonQuery(queryName: string, columns: string[], sql: string) {
33-
const result = `select json_object(
34-
'${queryName}',
35-
(
36-
select
37-
json_group_array(
38-
json_object(${columns.map((col) => `'${col}', ${col}`).join(', ')})
39-
)
40-
from
41-
(
42-
${indentString(sql, 8)}
43-
)
44-
)
45-
) as json_result`;
46-
47-
return result;
48-
}
49-
50-
export function Page() {
51-
const [queryName, setQueryName] = useState('my_query_name');
52-
const [query, setQuery] = useState(DEFAULT_QUERY);
53-
54-
const parsed = useMemo(() => {
55-
try {
56-
const parser = new Parser();
57-
const ast = parser.astify(query, { database: 'sqlite' });
58-
console.log(ast);
59-
const first = Array.isArray(ast) ? ast[0] : ast;
60-
if (first.type !== 'select') return undefined;
61-
const columns = first.columns;
62-
if (!Array.isArray(columns)) return undefined;
63-
const names = getColumnNames(columns);
64-
65-
const jsonQuery = generateJsonQuery(queryName, names, query);
66-
67-
return { names, jsonQuery };
68-
} catch (err) {
69-
console.warn(err);
70-
return undefined;
71-
}
72-
}, [query, queryName]);
7+
export const Page = observer(function Page() {
8+
const parsed = state.parsed;
739

7410
return (
7511
<div className="flex flex-col gap-2">
76-
<div>SQLite Query Tool</div>
12+
<div>SQLite JSON Query Tool</div>
7713
<div className="p-2 border rounded prose">
7814
<p>
7915
The <a href="https://www.sqlite.org/json1.html">JSON API of SQLite</a>{' '}
@@ -85,54 +21,89 @@ export function Page() {
8521
This tool will try to auto-detect your column names and generate a
8622
wrapper query that is ready to go without any dependencies.
8723
</p>
88-
<p>
89-
Using the generated pattern below, you can also combine multiple
90-
unrelated queries. For example:
91-
</p>
92-
<div className="form-textarea whitespace-pre-wrap border-gray-200 font-mono text-sm">{`select json_object(
93-
'query_one',
94-
query_one,
95-
'query_two',
96-
query_two
97-
) as json_result`}</div>
9824
</div>
9925

100-
<label>Query name</label>
26+
{state.queries.map((query, index) => (
27+
<>
28+
{index !== 0 ? <hr /> : null}
29+
<QueryEditor key={index} index={index} query={query} />
30+
</>
31+
))}
32+
33+
<hr />
34+
35+
<div>
36+
<button
37+
onClick={action(() => {
38+
const newQuery = { ...DEFAULT_QUERY };
39+
newQuery.name += '_' + (state.queries.length + 1);
40+
state.queries.push(newQuery);
41+
})}
42+
className="p-1 border bg-gray-100 hover:bg-gray-200 active:bg-gray-200"
43+
>
44+
Add another query
45+
</button>
46+
</div>
47+
48+
<hr />
49+
50+
<div>Wrapped query:</div>
51+
52+
<div className="rounded bg-blue-50 form-textarea w-full whitespace-pre-wrap text-sm">
53+
{parsed}
54+
</div>
55+
</div>
56+
);
57+
});
58+
59+
const QueryEditor = observer(function QueryEditor({
60+
index,
61+
query,
62+
}: {
63+
index: number;
64+
query: Query;
65+
}) {
66+
const names = parseOne(query);
67+
68+
return (
69+
<>
70+
<label>Query {index + 1}</label>
10171
<input
10272
type="text"
103-
className="form-input"
104-
value={queryName}
105-
onChange={(event) => setQueryName(event.target.value)}
73+
className="form-input rounded"
74+
value={query.name}
75+
onChange={action((event) => (query.name = event.target.value))}
10676
/>
10777
<textarea
108-
className="form-textarea w-full"
78+
className="form-textarea w-full rounded"
10979
rows={10}
110-
value={query}
111-
onChange={(event) => setQuery(event.target.value)}
80+
value={query.sql}
81+
onChange={action((event) => (query.sql = event.target.value))}
11282
/>
113-
<button
114-
onClick={() => {
115-
setQuery(
116-
formatDialect(query, {
83+
<div className="flex gap-2">
84+
<button
85+
onClick={action(() => {
86+
query.sql = formatDialect(query.sql, {
11787
dialect: sqlite,
11888
tabWidth: 2,
119-
})
120-
);
121-
}}
122-
className="p-1 border bg-gray-100 hover:bg-gray-200 active:bg-gray-200"
123-
>
124-
Format
125-
</button>
126-
<div>
127-
Detected columns:{' '}
128-
<span className="font-bold">{parsed?.names.join(', ')}</span>
89+
});
90+
})}
91+
className="p-1 border bg-gray-100 hover:bg-gray-200 active:bg-gray-200"
92+
>
93+
Format
94+
</button>
95+
<button
96+
onClick={action(() => {
97+
remove(state.queries, index as any);
98+
})}
99+
className="p-1 border bg-gray-100 hover:bg-gray-200 active:bg-gray-200"
100+
>
101+
Remove query
102+
</button>
129103
</div>
130-
131-
<div>Wrapped query:</div>
132-
133-
<div className="form-textarea w-full whitespace-pre-wrap text-sm">
134-
{parsed?.jsonQuery}
104+
<div>
105+
Detected columns: <span className="font-bold">{names?.join(', ')}</span>
135106
</div>
136-
</div>
107+
</>
137108
);
138-
}
109+
});

pages/index/state.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import indentString from 'indent-string';
2+
import { observable } from 'mobx';
3+
import type { Column } from 'node-sql-parser';
4+
import { Parser } from 'node-sql-parser';
5+
6+
export type Query = {
7+
name: string;
8+
sql: string;
9+
};
10+
11+
export const DEFAULT_QUERY: Query = {
12+
name: 'my_query',
13+
sql: `select
14+
id,
15+
employees.name,
16+
ranking as my_rank
17+
from
18+
employees
19+
limit
20+
2`,
21+
};
22+
23+
export const state = observable({
24+
queries: [DEFAULT_QUERY] as Query[],
25+
26+
get parsed() {
27+
try {
28+
const jsonQuery = generateJsonQuery(this.queries);
29+
return jsonQuery;
30+
} catch (err) {
31+
console.warn(err);
32+
return undefined;
33+
}
34+
},
35+
});
36+
37+
function getColumnNames(columns: Column[]) {
38+
const names: string[] = [];
39+
40+
for (const column of columns) {
41+
if (column.as) {
42+
names.push(column.as);
43+
} else if (column.expr.type === 'column_ref') {
44+
names.push(column.expr.column);
45+
} else {
46+
console.warn('Cannot parse column def', column);
47+
}
48+
}
49+
50+
return names;
51+
}
52+
53+
export function parseOne(query: Query) {
54+
try {
55+
const parser = new Parser();
56+
const ast = parser.astify(query.sql, { database: 'sqlite' });
57+
console.log(ast);
58+
const first = Array.isArray(ast) ? ast[0] : ast;
59+
if (!first) return undefined;
60+
if (first.type !== 'select') return undefined;
61+
const columns = first.columns;
62+
if (!Array.isArray(columns)) return undefined;
63+
const names = getColumnNames(columns);
64+
return names;
65+
} catch (err) {
66+
console.warn(err);
67+
return undefined;
68+
}
69+
}
70+
71+
function generateJsonQuery(queries: Query[]) {
72+
const parts: string[] = [];
73+
74+
for (const query of queries) {
75+
const columns = parseOne(query);
76+
if (!columns || columns.length === 0) continue;
77+
parts.push(` '${query.name}',
78+
(
79+
select json_group_array(
80+
json_object(${columns.map((col) => `'${col}', ${col}`).join(', ')})
81+
)
82+
from
83+
(
84+
${indentString(query.sql, 8)}
85+
)
86+
)`);
87+
}
88+
89+
const result = `select json_object(
90+
${parts.join(',\n')}
91+
) as json_result`;
92+
93+
return result;
94+
}

vite.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const config: UserConfig = {
1010
}),
1111
],
1212
ssr: {
13+
// TODO: needs to be noExternal
14+
// for build mode
15+
// but external for dev mode
1316
noExternal: ['node-sql-parser'],
1417
},
1518
};

0 commit comments

Comments
 (0)