Skip to content

Commit cbc7746

Browse files
committed
feat(apps/deditor): support sqlite
1 parent ae335a6 commit cbc7746

9 files changed

Lines changed: 573 additions & 41 deletions

File tree

apps/deditor/src/main/ipc/databases/local/pglite-fs.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ export function registerPGLiteDatabaseDialect(window: BrowserWindow) {
6868
.handle(async (_, { dsn }) => {
6969
try {
7070
const parsedDSN = new URL(dsn)
71+
if (!parsedDSN.searchParams.get('dataDir')) {
72+
throw new Error('Missing "dataDir" parameter in DSN.')
73+
}
7174

7275
const pgliteClient = new PGlite(
7376
decodeURIComponent(String(parsedDSN.searchParams.get('dataDir'))),

apps/deditor/src/main/ipc/databases/local/sqlite-fs.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,39 @@ import { nanoid } from '@deditor-app/shared'
77
import * as schema from '@deditor-app/shared-schemas'
88
import { useLogg } from '@guiiai/logg'
99
import { createClient } from '@libsql/client'
10+
import { sql } from 'drizzle-orm'
1011
import { drizzle } from 'drizzle-orm/libsql'
1112

1213
import { defineIPCHandler } from '../../define-ipc-handler'
1314

1415
const databaseSessions = new Map<string, { drizzle: LibSQLDatabase<typeof schema>, client: Client }>()
1516

17+
// https://github.com/drizzle-team/drizzle-orm/blob/33f0374e29014677c29f4b1f1dd1ab8fb68ac516/drizzle-kit/src/serializer/sqliteSerializer.ts#L498C1-L508C2
18+
function filterIgnoredTablesByField(fieldName: string) {
19+
// _cf_ is a prefix for internal Cloudflare D1 tables (e.g. _cf_KV, _cf_METADATA)
20+
// _litestream_ is a prefix for internal Litestream tables (e.g. _litestream_seq, _litestream_lock)
21+
// libsql_ is a prefix for internal libSQL tables (e.g. libsql_wasm_func_table)
22+
// sqlite_ is a prefix for internal SQLite tables (e.g. sqlite_sequence, sqlite_stat1)
23+
return `${fieldName} != '__drizzle_migrations'
24+
AND ${fieldName} NOT LIKE '\\_cf\\_%' ESCAPE '\\'
25+
AND ${fieldName} NOT LIKE '\\_litestream\\_%' ESCAPE '\\'
26+
AND ${fieldName} NOT LIKE 'libsql\\_%' ESCAPE '\\'
27+
AND ${fieldName} NOT LIKE 'sqlite\\_%' ESCAPE '\\'`
28+
}
29+
1630
export function registerSQLiteDatabaseDialect(window: BrowserWindow) {
1731
const log = useLogg('sqlite-database-dialect').useGlobalConfig()
1832

1933
defineIPCHandler<SQLiteMethods>(window, 'databaseLocalSQLite', 'connect')
2034
.handle(async (_, { dsn }) => {
2135
try {
2236
const parsedDSN = new URL(dsn)
37+
if (!parsedDSN.searchParams.get('dbFilePath')) {
38+
throw new Error('Missing "dbFilePath" parameter in DSN.')
39+
}
2340

2441
const sqliteClient = createClient({
25-
url: `file://${parsedDSN.searchParams.get('dbFilePath') || parsedDSN.pathname}`,
42+
url: `file://${parsedDSN.searchParams.get('dbFilePath')}`,
2643
})
2744

2845
const sqliteDrizzle = drizzle(sqliteClient, { schema })
@@ -70,4 +87,36 @@ export function registerSQLiteDatabaseDialect(window: BrowserWindow) {
7087
throw err
7188
}
7289
})
90+
91+
defineIPCHandler<SQLiteMethods>(window, 'databaseLocalSQLite', 'listColumns')
92+
.handle(async (_, { databaseSessionId, tableName }) => {
93+
if (!databaseSessions.has(databaseSessionId)) {
94+
throw new Error('Database session ID not found in session map, please connect to the database first.')
95+
}
96+
97+
try {
98+
const dbSession = databaseSessions.get(databaseSessionId)!
99+
const res = await dbSession.drizzle.run(sql`
100+
SELECT
101+
m.name as "tableName",
102+
p.name as "columnName",
103+
p.type as "columnType",
104+
p."notnull" as "notNull",
105+
p.dflt_value as "defaultValue",
106+
p.pk as pk,
107+
p.hidden as hidden,
108+
m.sql,
109+
m.type as type
110+
FROM sqlite_master AS m
111+
JOIN pragma_table_xinfo(m.name) AS p
112+
WHERE (m.type = 'table' OR m.type = 'view')
113+
AND ${filterIgnoredTablesByField('m.tbl_name')};
114+
`)
115+
return { databaseSessionId, results: res.rows }
116+
}
117+
catch (err) {
118+
log.withError(err).withFields({ databaseSessionId, tableName }).error('failed to query local SQLite database to list columns')
119+
throw err
120+
}
121+
})
73122
}

apps/deditor/src/renderer/src/composables/ipc/databases/local/sqlite-fs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,13 @@ export function useLocalSQLite() {
3636
const res = await methods('listTables').call({ databaseSessionId: databaseSessionId.value! })
3737
return res.results
3838
},
39+
listColumns: async (tableName: string) => {
40+
if (!databaseSessionId.value) {
41+
throw new Error('Database session ID is not set. Please connect to a database first.')
42+
}
43+
44+
const res = await methods('listColumns').call({ databaseSessionId: databaseSessionId.value!, tableName })
45+
return res.results
46+
},
3947
}
4048
}

apps/deditor/src/renderer/src/pages/datasources/pglite/inspect/[id]/index.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ const datasourceTables = computedAsync(async () => {
4141
4242
return tables
4343
.map<DatasourceTable>(t => ({
44-
schema: t.table_schema,
45-
table: t.table_name,
44+
table: t.tableName,
45+
schema: t.schema,
4646
}))
4747
.filter((t) => {
4848
if (!t.schema) {

apps/deditor/src/renderer/src/pages/datasources/postgres/inspect/[id]/index.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ const datasourceTables = computedAsync(async () => {
4141
4242
return tables
4343
.map<DatasourceTable>(t => ({
44-
schema: t.table_schema,
45-
table: t.table_name,
44+
table: t.tableName,
45+
schema: t.schema,
4646
}))
4747
.filter((t) => {
4848
if (!t.schema) {
Lines changed: 234 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,236 @@
1+
<script setup lang="ts">
2+
import type { ConnectionThroughParameters, DatasourceDriver } from '../../../../../libs/datasources'
3+
4+
import { useClipboard, useRefHistory } from '@vueuse/core'
5+
import { computed, onMounted, ref, watch } from 'vue'
6+
import { RouterLink, useRoute } from 'vue-router'
7+
8+
import Button from '../../../../../components/basic/Button.vue'
9+
import Editable from '../../../../../components/basic/Editable.vue'
10+
import { Input } from '../../../../../components/ui/input'
11+
import { useLocalPGLite } from '../../../../../composables/ipc/databases/local'
12+
import { dialog } from '../../../../../composables/ipc/electron'
13+
import { DatasourceDriverEnum, defaultParamsFromDriver, fromDSN, toDSN } from '../../../../../libs/datasources'
14+
import { useDatasourcesStore } from '../../../../../stores/datasources'
15+
16+
const route = useRoute('/datasources/sqlite/edit/[id]/')
17+
18+
const id = computed(() => route.params.id)
19+
const driver = computed(() => DatasourceDriverEnum.SQLite)
20+
21+
const testConnectionConnecting = ref(false)
22+
const testConnectionSucceeded = ref(false)
23+
const testConnectionErrored = ref(false)
24+
const testConnectionErrorMessage = ref('')
25+
26+
const datasourcesStore = useDatasourcesStore()
27+
28+
function datasourceFromId() {
29+
const datasource = datasourcesStore.datasources.find(ds => ds.id === id.value)
30+
if (typeof datasource === 'undefined') {
31+
const newDatasource = datasourcesStore.createDatasource(driver.value as DatasourceDriver)
32+
datasourcesStore.datasources.push(newDatasource)
33+
return newDatasource
34+
}
35+
36+
return datasource
37+
}
38+
39+
const datasource = computed({
40+
get: () => datasourceFromId(),
41+
set: (value) => {
42+
const datasourceIndex = datasourcesStore.datasources.findIndex(ds => ds.id === id.value)
43+
if (datasourceIndex !== -1) {
44+
datasourcesStore.datasources[datasourceIndex] = value
45+
}
46+
else {
47+
console.error(`Datasource with id ${id.value} not found in store.`)
48+
}
49+
},
50+
})
51+
52+
const DSN = computed({
53+
get: () => {
54+
return toDSN(
55+
driver.value,
56+
datasource.value as ConnectionThroughParameters,
57+
defaultParamsFromDriver(driver.value),
58+
)
59+
},
60+
set: (value) => {
61+
if (!datasource.value)
62+
return
63+
64+
const params = datasource.value as ConnectionThroughParameters
65+
const paramsFromDSN = fromDSN(
66+
value,
67+
defaultParamsFromDriver(driver.value),
68+
)
69+
70+
params.host = paramsFromDSN.host
71+
params.port = paramsFromDSN.port
72+
params.user = paramsFromDSN.user
73+
params.password = paramsFromDSN.password
74+
params.database = paramsFromDSN.database
75+
params.extraOptions = paramsFromDSN.extraOptions || { sslmode: false }
76+
},
77+
})
78+
79+
const dbFilePath = computed({
80+
get: () => {
81+
const paramsFromDSN = fromDSN(
82+
DSN.value,
83+
defaultParamsFromDriver(driver.value),
84+
)
85+
86+
return paramsFromDSN.extraOptions?.dbFilePath as string
87+
},
88+
set: (val) => {
89+
const dsn = toDSN(
90+
driver.value,
91+
{
92+
...datasource.value,
93+
extraOptions: { dbFilePath: val },
94+
} as ConnectionThroughParameters,
95+
defaultParamsFromDriver(driver.value),
96+
)
97+
98+
DSN.value = dsn
99+
},
100+
})
101+
102+
const datasourceName = computed({
103+
get: () => datasource.value.name || 'New Datasource',
104+
set: (value) => {
105+
datasource.value.name = value
106+
},
107+
})
108+
109+
const { undo, clear } = useRefHistory(datasourceName)
110+
111+
// TODO: ?
112+
onMounted(() => {
113+
DSN.value = toDSN(
114+
driver.value,
115+
datasource.value as ConnectionThroughParameters,
116+
defaultParamsFromDriver(driver.value),
117+
)
118+
})
119+
120+
watch([id, driver], () => {
121+
clear()
122+
datasource.value = datasourceFromId()
123+
})
124+
125+
function handleBlur() {
126+
if (!datasourceName.value) {
127+
undo()
128+
}
129+
}
130+
131+
async function handleTestConnection() {
132+
const { connect, execute } = useLocalPGLite()
133+
let dsn = ''
134+
135+
if ('connectionString' in datasource.value && !!datasource.value.connectionString) {
136+
dsn = datasource.value.connectionString
137+
}
138+
else {
139+
const params = datasource.value as ConnectionThroughParameters
140+
dsn = toDSN(driver.value, params, defaultParamsFromDriver(driver.value))
141+
}
142+
143+
try {
144+
testConnectionSucceeded.value = false
145+
testConnectionConnecting.value = true
146+
147+
await connect(dsn)
148+
// eslint-disable-next-line no-console
149+
console.debug(await execute('SELECT 1'))
150+
151+
testConnectionSucceeded.value = true
152+
}
153+
catch (err) {
154+
testConnectionErrored.value = true
155+
156+
const e = err as Error
157+
testConnectionErrorMessage.value = e.message || 'Unknown error occurred while testing connection.'
158+
if (e.cause != null) {
159+
testConnectionErrorMessage.value += ` Cause: ${String(e.cause)}`
160+
}
161+
162+
console.error('Error testing connection:', testConnectionErrorMessage.value)
163+
}
164+
finally {
165+
testConnectionConnecting.value = false
166+
}
167+
}
168+
169+
async function handlePick() {
170+
const res = await dialog('showOpenDialog').call({
171+
properties: ['openFile', 'createDirectory'],
172+
})
173+
if (!res.filePaths) {
174+
return
175+
}
176+
177+
dbFilePath.value = res.filePaths[0] || ''
178+
}
179+
180+
async function handlePasteDSN() {
181+
const { text } = useClipboard()
182+
DSN.value = text.value || ''
183+
}
184+
</script>
185+
1186
<template>
2-
<slot />
187+
<div h-full flex flex-col>
188+
<div flex>
189+
<h2 text="neutral-300/80" mb-1 flex flex-1>
190+
Edit SQLite Datasource
191+
</h2>
192+
<RouterLink to="/datasources">
193+
<div i-ph:x-bold text="neutral-300/80" />
194+
</RouterLink>
195+
</div>
196+
<div h-full flex flex-col>
197+
<div mt-3 flex flex-1 flex-col gap-2>
198+
<Editable v-model="datasourceName" mb-3 font-bold @blur="handleBlur">
199+
{{ driver }}
200+
</Editable>
201+
<div>
202+
<label flex="~ col gap-2">
203+
<div>
204+
<div class="flex items-center gap-1 text-sm font-medium">
205+
Database File
206+
</div>
207+
<div class="text-xs text-neutral-500 dark:text-neutral-400">
208+
Database file path of the SQLite database.
209+
</div>
210+
</div>
211+
<div flex items-center gap-2>
212+
<Input v-model="dbFilePath" flex-1 />
213+
<Button @click="handlePick">
214+
Pick
215+
</Button>
216+
<Button @click="handlePasteDSN">
217+
Paste
218+
</Button>
219+
</div>
220+
</label>
221+
</div>
222+
</div>
223+
<div flex flex-col gap-3>
224+
<div v-if="testConnectionErrored" class="mt-2 text-sm text-red-500" border="2 solid red-800/50" bg="red-900/50" flex items-center gap-1 rounded-lg px-3 py-2 text-lg>
225+
<div i-ph:warning-circle-bold mr-1 inline-block />
226+
{{ testConnectionErrorMessage }}
227+
</div>
228+
<button bg="green-800/50" flex items-center justify-center gap-2 rounded-lg px-3 py-2 @click="handleTestConnection">
229+
Test
230+
<div v-if="testConnectionConnecting" i-svg-spinners:270-ring />
231+
<div v-else-if="testConnectionSucceeded" i-ph:check-bold />
232+
</button>
233+
</div>
234+
</div>
235+
</div>
3236
</template>

0 commit comments

Comments
 (0)