@antelopejs/dms-database 0.0.14 → 0.1.0
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/README.md +30 -5
- package/dist/db/models/query_history.model.d.ts +1 -0
- package/dist/db/models/query_history.model.js +6 -0
- package/dist/db/models/query_history.model.js.map +1 -1
- package/dist/db/tables/query_history.table.d.ts +3 -0
- package/dist/db/tables/query_history.table.js +12 -0
- package/dist/db/tables/query_history.table.js.map +1 -1
- package/dist/db/tables/saved_queries.table.d.ts +1 -0
- package/dist/db/tables/saved_queries.table.js +4 -0
- package/dist/db/tables/saved_queries.table.js.map +1 -1
- package/dist/i18n/messages.d.ts +39 -0
- package/dist/i18n/messages.js +64 -0
- package/dist/i18n/messages.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -7
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +11 -0
- package/dist/module.js +55 -0
- package/dist/module.js.map +1 -0
- package/dist/pages/data.d.ts +1 -1
- package/dist/pages/data.js +9 -6
- package/dist/pages/data.js.map +1 -1
- package/dist/pages/diagram.d.ts +9 -0
- package/dist/pages/diagram.js +45 -0
- package/dist/pages/diagram.js.map +1 -0
- package/dist/pages/index.d.ts +1 -0
- package/dist/pages/index.js +1 -0
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/overview.d.ts +4 -1
- package/dist/pages/overview.js +113 -6
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/query.d.ts +1 -1
- package/dist/pages/query.js +22 -6
- package/dist/pages/query.js.map +1 -1
- package/dist/pages/schemas.d.ts +2 -1
- package/dist/pages/schemas.js +143 -6
- package/dist/pages/schemas.js.map +1 -1
- package/dist/routes/data.d.ts +8 -0
- package/dist/routes/data.js +84 -21
- package/dist/routes/data.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/overview.d.ts +12 -0
- package/dist/routes/overview.js +55 -0
- package/dist/routes/overview.js.map +1 -0
- package/dist/routes/query.d.ts +5 -1
- package/dist/routes/query.js +162 -27
- package/dist/routes/query.js.map +1 -1
- package/dist/routes/tables.d.ts +15 -12
- package/dist/routes/tables.js +95 -55
- package/dist/routes/tables.js.map +1 -1
- package/dist/service/catalog.d.ts +3 -0
- package/dist/service/catalog.js +26 -0
- package/dist/service/catalog.js.map +1 -0
- package/dist/service/health.d.ts +3 -3
- package/dist/service/health.js +17 -14
- package/dist/service/health.js.map +1 -1
- package/dist/service/introspect.d.ts +3 -1
- package/dist/service/introspect.js +131 -43
- package/dist/service/introspect.js.map +1 -1
- package/dist/service/overview.d.ts +9 -0
- package/dist/service/overview.js +113 -0
- package/dist/service/overview.js.map +1 -0
- package/dist/service/queryInspection.d.ts +15 -0
- package/dist/service/queryInspection.js +107 -0
- package/dist/service/queryInspection.js.map +1 -0
- package/dist/service/references.d.ts +9 -0
- package/dist/service/references.js +52 -0
- package/dist/service/references.js.map +1 -0
- package/dist/service/schemaFilters.d.ts +26 -0
- package/dist/service/schemaFilters.js +42 -0
- package/dist/service/schemaFilters.js.map +1 -0
- package/dist/service/stagedSerialization.d.ts +2 -0
- package/dist/service/stagedSerialization.js +38 -10
- package/dist/service/stagedSerialization.js.map +1 -1
- package/dist/service/types.d.ts +11 -1
- package/dist/types/constants.d.ts +2 -0
- package/dist/types/constants.js +3 -1
- package/dist/types/constants.js.map +1 -1
- package/dist/utils/query.d.ts +0 -2
- package/dist/utils/query.js +0 -20
- package/dist/utils/query.js.map +1 -1
- package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
- package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
- package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
- package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
- package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
- package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
- package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
- package/frontend-vue/app/build/data/cellValues.ts +132 -0
- package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
- package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
- package/frontend-vue/app/build/data/recentTables.ts +48 -0
- package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
- package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
- package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
- package/frontend-vue/app/build/diagram/autosave.ts +72 -0
- package/frontend-vue/app/build/diagram/graph.ts +585 -0
- package/frontend-vue/app/build/diagram/history.ts +178 -0
- package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
- package/frontend-vue/app/build/diagram/sync.ts +137 -0
- package/frontend-vue/app/build/query/completion.ts +100 -0
- package/frontend-vue/app/build/query/csv.ts +32 -0
- package/frontend-vue/app/build/query/examples.ts +38 -0
- package/frontend-vue/app/build/query/runs.ts +168 -0
- package/frontend-vue/app/build/query/tableColumns.ts +35 -0
- package/frontend-vue/app/build/query/target.ts +143 -0
- package/frontend-vue/app/build/schemas/filterState.ts +128 -0
- package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
- package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
- package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
- package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
- package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
- package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
- package/frontend-vue/app/components/DataBrowser.vue +126 -0
- package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
- package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
- package/frontend-vue/app/components/DataFilterBar.vue +163 -0
- package/frontend-vue/app/components/DataGrid.vue +737 -499
- package/frontend-vue/app/components/DataGridCell.vue +125 -77
- package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
- package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
- package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
- package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
- package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
- package/frontend-vue/app/components/InstancePicker.vue +195 -0
- package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
- package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
- package/frontend-vue/app/components/QueryConsole.vue +726 -0
- package/frontend-vue/app/components/QueryLibrary.vue +314 -0
- package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
- package/frontend-vue/app/components/RowDrawer.vue +520 -0
- package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
- package/frontend-vue/app/components/SchemaPicker.vue +86 -0
- package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
- package/frontend-vue/app/components/TableInspector.vue +741 -0
- package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
- package/frontend-vue/dms.frontend.ts +5 -14
- package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
- package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
- package/frontend-vue/package.json +2 -1
- package/frontend-vue/pnpm-lock.yaml +2601 -858
- package/frontend-vue/pnpm-workspace.yaml +1 -1
- package/frontend-vue/tests/dataBrowser.test.ts +206 -0
- package/frontend-vue/tests/diagram.test.ts +516 -0
- package/frontend-vue/tests/helpers.test.ts +205 -0
- package/frontend-vue/tests/instanceOptions.test.ts +53 -0
- package/frontend-vue/tests/locales.test.ts +56 -0
- package/frontend-vue/tests/persistence.test.ts +106 -5
- package/frontend-vue/tests/pickerMenus.test.ts +254 -0
- package/frontend-vue/tests/queryConsole.test.ts +282 -0
- package/frontend-vue/tests/queryRuntime.test.ts +2 -2
- package/frontend-vue/tests/registration.test.ts +36 -25
- package/frontend-vue/tests/schemasFilters.test.ts +115 -0
- package/package.json +7 -7
- package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
- package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
- package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
- package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
- package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
- package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
- package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
- package/frontend-vue/app/composables/useQueryStore.ts +0 -117
- package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
- package/frontend-vue/app/custom-pages/database/data.vue +0 -41
- package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
- package/frontend-vue/app/custom-pages/database/query.vue +0 -436
- package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
- package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
- package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
- package/frontend-vue/app/utils/dmsTableView.ts +0 -83
- package/frontend-vue/app/utils/relativeTime.ts +0 -18
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useQueryStore } from '../build/composables/useQueryStore'
|
|
3
|
+
import { useDatabaseSchemas } from '../build/composables/useDatabaseSchemas'
|
|
4
|
+
import { useSessionStorage } from '@vueuse/core'
|
|
5
|
+
import { h } from 'vue'
|
|
6
|
+
import { replaceName, type UnknownKind } from '../build/query/runs'
|
|
7
|
+
import {
|
|
8
|
+
describeTarget,
|
|
9
|
+
instanceArgument,
|
|
10
|
+
readQueryTarget,
|
|
11
|
+
targetFromSource,
|
|
12
|
+
withInstance,
|
|
13
|
+
type QueryTarget,
|
|
14
|
+
} from '../build/query/target'
|
|
15
|
+
import {
|
|
16
|
+
prepareQueryPayload,
|
|
17
|
+
QueryInputError,
|
|
18
|
+
} from '../build/composables/useQueryRuntime'
|
|
19
|
+
import type {
|
|
20
|
+
DryRunResult,
|
|
21
|
+
HistoryEntry,
|
|
22
|
+
SavedQuery,
|
|
23
|
+
} from '../build/composables/useQueryStore'
|
|
24
|
+
import { schemaAccess } from '../build/utils/databaseLinks'
|
|
25
|
+
import QueryBuilderPanel from './QueryBuilderPanel.vue'
|
|
26
|
+
import QueryLibrary from './QueryLibrary.vue'
|
|
27
|
+
import QueryResultPanel, {
|
|
28
|
+
type InstanceRows,
|
|
29
|
+
type QueryFailure,
|
|
30
|
+
} from './QueryResultPanel.vue'
|
|
31
|
+
|
|
32
|
+
// The query console: the library rail, the editor and the result. A query
|
|
33
|
+
// that changes data runs only after a dry run told what it touches (D-03):
|
|
34
|
+
// deletes and multi-row writes ask for the table name; a single-row update
|
|
35
|
+
// can be let through without asking until the browser tab is closed.
|
|
36
|
+
|
|
37
|
+
const BROWSE_COUNT = '/api/database/browse/count'
|
|
38
|
+
// The query being written, kept for the browser tab: a reload, or a row link
|
|
39
|
+
// followed by Back, brings it back (Q-9). A link that names a query wins.
|
|
40
|
+
const DRAFT_KEY = 'dms-database:query:draft'
|
|
41
|
+
const SKIP_SINGLE_UPDATES_KEY = 'dms-database:query:skip-single-row-updates'
|
|
42
|
+
const PREFILL_ROWS = 50
|
|
43
|
+
const OPERATION_ICONS: Record<string, string> = {
|
|
44
|
+
insert: 'i-ph-plus-circle',
|
|
45
|
+
update: 'i-ph-pencil-simple',
|
|
46
|
+
replace: 'i-ph-swap',
|
|
47
|
+
delete: 'i-ph-trash',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { t, n } = useI18n()
|
|
51
|
+
const route = useDmsRoute()
|
|
52
|
+
const router = useDmsRouter()
|
|
53
|
+
const { $authFetch } = useAuthFetch()
|
|
54
|
+
const { confirm } = useConfirm()
|
|
55
|
+
const toast = useToast()
|
|
56
|
+
const { user } = useUserSession()
|
|
57
|
+
const store = useQueryStore()
|
|
58
|
+
const { schemas } = useDatabaseSchemas()
|
|
59
|
+
|
|
60
|
+
const source = ref('')
|
|
61
|
+
const executing = ref(false)
|
|
62
|
+
const result = ref<Awaited<ReturnType<typeof store.executeQuery>> | null>(null)
|
|
63
|
+
const failure = ref<QueryFailure | null>(null)
|
|
64
|
+
const target = ref<QueryTarget>({})
|
|
65
|
+
const loadedSaved = ref<SavedQuery | null>(null)
|
|
66
|
+
const activeHistoryId = ref<string | null>(null)
|
|
67
|
+
const libraryLoading = ref(true)
|
|
68
|
+
// Named instances holding rows of a table the last read found empty in the
|
|
69
|
+
// default instance.
|
|
70
|
+
const elsewhere = ref<InstanceRows[]>([])
|
|
71
|
+
const team = ref<SavedQuery[]>([])
|
|
72
|
+
const editor = useTemplateRef<InstanceType<typeof QueryBuilderPanel>>('editor')
|
|
73
|
+
|
|
74
|
+
const edited = computed(
|
|
75
|
+
() =>
|
|
76
|
+
Boolean(loadedSaved.value) && loadedSaved.value?.source !== source.value,
|
|
77
|
+
)
|
|
78
|
+
const ownId = computed(
|
|
79
|
+
() => (user.value as { _id?: string } | null)?._id ?? null,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// --- library ---
|
|
83
|
+
async function refreshHistory() {
|
|
84
|
+
await store.fetchHistory().catch(() => undefined)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function refreshSaved() {
|
|
88
|
+
await store.fetchSaved('me').catch(() => undefined)
|
|
89
|
+
try {
|
|
90
|
+
const res = await $authFetch<{ items: SavedQuery[] }>(
|
|
91
|
+
'/api/database/query/saved',
|
|
92
|
+
{
|
|
93
|
+
query: { scope: 'shared' },
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
team.value = res.items.filter((query) => query.userId !== ownId.value)
|
|
97
|
+
} catch {
|
|
98
|
+
team.value = []
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function openSaved(query: SavedQuery) {
|
|
103
|
+
loadedSaved.value = query
|
|
104
|
+
activeHistoryId.value = null
|
|
105
|
+
source.value = query.source
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function openHistory(entry: HistoryEntry) {
|
|
109
|
+
loadedSaved.value = null
|
|
110
|
+
activeHistoryId.value = entry.id
|
|
111
|
+
source.value = entry.source
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function newQuery() {
|
|
115
|
+
loadedSaved.value = null
|
|
116
|
+
activeHistoryId.value = null
|
|
117
|
+
source.value = ''
|
|
118
|
+
result.value = null
|
|
119
|
+
failure.value = null
|
|
120
|
+
nextTick(() => editor.value?.focus())
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const draft = useSessionStorage<{ source: string; savedId: string | null }>(
|
|
124
|
+
DRAFT_KEY,
|
|
125
|
+
{ source: '', savedId: null },
|
|
126
|
+
)
|
|
127
|
+
watch([source, () => loadedSaved.value?.id ?? null], ([text, savedId]) => {
|
|
128
|
+
draft.value = { source: text, savedId }
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
function restoreDraft() {
|
|
132
|
+
const { source: text, savedId } = draft.value
|
|
133
|
+
if (!text) return
|
|
134
|
+
const saved = savedId
|
|
135
|
+
? store.savedQueries.value.find((query) => query.id === savedId)
|
|
136
|
+
: undefined
|
|
137
|
+
loadedSaved.value = saved ?? null
|
|
138
|
+
source.value = text
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function deleteSaved(query: SavedQuery) {
|
|
142
|
+
const confirmed = await confirm({
|
|
143
|
+
title: t('dms_database.query.delete.title', { name: query.name }),
|
|
144
|
+
description: query.shared
|
|
145
|
+
? t('dms_database.query.delete.shared')
|
|
146
|
+
: undefined,
|
|
147
|
+
confirmLabel: t('dms_database.query.delete.confirm'),
|
|
148
|
+
color: 'error',
|
|
149
|
+
icon: 'i-ph-trash',
|
|
150
|
+
})
|
|
151
|
+
if (!confirmed) return
|
|
152
|
+
await store.deleteSaved(query.id).catch(() => undefined)
|
|
153
|
+
if (loadedSaved.value?.id === query.id) loadedSaved.value = null
|
|
154
|
+
await refreshSaved()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function clearHistory() {
|
|
158
|
+
const confirmed = await confirm({
|
|
159
|
+
title: t('dms_database.query.clear.title'),
|
|
160
|
+
description: t('dms_database.query.clear.description'),
|
|
161
|
+
confirmLabel: t('dms_database.query.clear.confirm'),
|
|
162
|
+
color: 'warning',
|
|
163
|
+
})
|
|
164
|
+
if (confirmed) await store.clearHistory().catch(() => undefined)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- running ---
|
|
168
|
+
function fetchMessage(error: unknown): string {
|
|
169
|
+
const { data, message } = (error ?? {}) as {
|
|
170
|
+
data?: unknown
|
|
171
|
+
message?: unknown
|
|
172
|
+
}
|
|
173
|
+
const inner = (data as { message?: unknown } | null)?.message
|
|
174
|
+
if (typeof inner === 'string' && inner) return inner
|
|
175
|
+
if (typeof data === 'string' && data) return data
|
|
176
|
+
return typeof message === 'string' && message
|
|
177
|
+
? message
|
|
178
|
+
: t('dms_database.query.result.failed_title')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** How long the server ran a failed query, as its error says. */
|
|
182
|
+
function fetchDuration(error: unknown): number | null {
|
|
183
|
+
const data = (error as { data?: unknown } | null)?.data
|
|
184
|
+
const duration = (data as { durationMs?: unknown } | null)?.durationMs
|
|
185
|
+
return typeof duration === 'number' ? duration : null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The named instances of a table that hold rows, each with its count: asked
|
|
190
|
+
* only when a read found the table empty in the default instance (decision
|
|
191
|
+
* 11). An instance whose count fails is left out.
|
|
192
|
+
*/
|
|
193
|
+
async function instancesWithRows(target: QueryTarget): Promise<InstanceRows[]> {
|
|
194
|
+
const instances =
|
|
195
|
+
schemas.value.find((schema) => schema.id === target.schema)?.instances ?? []
|
|
196
|
+
const counts = await Promise.all(
|
|
197
|
+
instances.map((id) =>
|
|
198
|
+
$authFetch<{ total: number }>(BROWSE_COUNT, {
|
|
199
|
+
query: {
|
|
200
|
+
filter_schema: `is:${target.schema}`,
|
|
201
|
+
filter_table: `is:${target.table}`,
|
|
202
|
+
filter_instance: `is:${id}`,
|
|
203
|
+
},
|
|
204
|
+
}).then(
|
|
205
|
+
(res) => ({ id, count: res.total }),
|
|
206
|
+
() => ({ id, count: 0 }),
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
return counts.filter((entry) => entry.count > 0)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Each run drops the instances found for the previous one; a slow answer
|
|
214
|
+
// for an older run is ignored.
|
|
215
|
+
let runId = 0
|
|
216
|
+
|
|
217
|
+
async function findElsewhere(
|
|
218
|
+
answer: Awaited<ReturnType<typeof store.executeQuery>>,
|
|
219
|
+
next: QueryTarget,
|
|
220
|
+
) {
|
|
221
|
+
const id = ++runId
|
|
222
|
+
elsewhere.value = []
|
|
223
|
+
if (
|
|
224
|
+
answer.mutation ||
|
|
225
|
+
answer.rows.length > 0 ||
|
|
226
|
+
next.instance !== '' ||
|
|
227
|
+
!next.schema ||
|
|
228
|
+
!next.table
|
|
229
|
+
)
|
|
230
|
+
return
|
|
231
|
+
const found = await instancesWithRows(next)
|
|
232
|
+
if (id === runId) elsewhere.value = found
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The result keeps naming the previous run's target until the new one lands.
|
|
236
|
+
async function execute(query: Record<string, unknown>, text: string) {
|
|
237
|
+
executing.value = true
|
|
238
|
+
const next = readQueryTarget(query)
|
|
239
|
+
try {
|
|
240
|
+
const answer = await store.executeQuery(query, text, 'aql')
|
|
241
|
+
failure.value = null
|
|
242
|
+
result.value = answer
|
|
243
|
+
void findElsewhere(answer, next)
|
|
244
|
+
} catch (error) {
|
|
245
|
+
result.value = null
|
|
246
|
+
failure.value = {
|
|
247
|
+
message: fetchMessage(error),
|
|
248
|
+
durationMs: fetchDuration(error),
|
|
249
|
+
}
|
|
250
|
+
} finally {
|
|
251
|
+
target.value = next
|
|
252
|
+
executing.value = false
|
|
253
|
+
activeHistoryId.value = null
|
|
254
|
+
await refreshHistory()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A run that failed before the server ran it, worded for the reader. */
|
|
259
|
+
function describeInputError(error: unknown): QueryFailure {
|
|
260
|
+
if (!(error instanceof QueryInputError))
|
|
261
|
+
return {
|
|
262
|
+
message:
|
|
263
|
+
error instanceof Error && error.message
|
|
264
|
+
? error.message
|
|
265
|
+
: t('dms_database.query.result.failed_title'),
|
|
266
|
+
durationMs: null,
|
|
267
|
+
}
|
|
268
|
+
const { problem } = error
|
|
269
|
+
if (problem.kind === 'unknown_schema')
|
|
270
|
+
return {
|
|
271
|
+
message: t('dms_database.query.errors.unknown_schema', {
|
|
272
|
+
schema: problem.schema,
|
|
273
|
+
}),
|
|
274
|
+
durationMs: null,
|
|
275
|
+
unknown: { kind: 'schema', name: problem.schema },
|
|
276
|
+
}
|
|
277
|
+
const { place, detail } = problem
|
|
278
|
+
return {
|
|
279
|
+
message: place
|
|
280
|
+
? t('dms_database.query.errors.syntax_at', { ...place })
|
|
281
|
+
: t('dms_database.query.errors.syntax', { detail }),
|
|
282
|
+
durationMs: null,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// A run that never reached the server is not kept in History (decision 9).
|
|
287
|
+
function failBeforeRun(text: string, problem: QueryFailure) {
|
|
288
|
+
result.value = null
|
|
289
|
+
elsewhere.value = []
|
|
290
|
+
failure.value = problem
|
|
291
|
+
target.value = targetFromSource(text)
|
|
292
|
+
activeHistoryId.value = null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function skipsSingleUpdates(): boolean {
|
|
296
|
+
return (
|
|
297
|
+
typeof window !== 'undefined' &&
|
|
298
|
+
window.sessionStorage.getItem(SKIP_SINGLE_UPDATES_KEY) === 'true'
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isSingleRowUpdate(dry: DryRunResult): boolean {
|
|
303
|
+
return (
|
|
304
|
+
(dry.operation === 'update' || dry.operation === 'replace') &&
|
|
305
|
+
dry.rows === 1
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function guardTexts(dry: DryRunResult) {
|
|
310
|
+
const rows = dry.rows ?? 0
|
|
311
|
+
// The whole target, schema and instance included: `demo.tags @eu`.
|
|
312
|
+
const table = describeTarget(dry) || (dry.table ?? '')
|
|
313
|
+
const unknown = dry.rows === null
|
|
314
|
+
return {
|
|
315
|
+
title: unknown
|
|
316
|
+
? t('dms_database.query.guard.title_unknown', { table })
|
|
317
|
+
: t(
|
|
318
|
+
`dms_database.query.guard.title_${dry.operation}`,
|
|
319
|
+
{ table, count: n(rows) },
|
|
320
|
+
rows,
|
|
321
|
+
),
|
|
322
|
+
confirmLabel: unknown
|
|
323
|
+
? t('dms_database.query.guard.confirm_unknown')
|
|
324
|
+
: t(
|
|
325
|
+
`dms_database.query.guard.confirm_${dry.operation}`,
|
|
326
|
+
{ count: n(rows) },
|
|
327
|
+
rows,
|
|
328
|
+
),
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function guard(query: Record<string, unknown>, text: string) {
|
|
333
|
+
let dry: DryRunResult
|
|
334
|
+
try {
|
|
335
|
+
dry = await store.dryRun(query)
|
|
336
|
+
} catch (error) {
|
|
337
|
+
return failBeforeRun(text, {
|
|
338
|
+
message: fetchMessage(error),
|
|
339
|
+
durationMs: null,
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
if (dry.operation === 'read') return execute(query, text)
|
|
343
|
+
if (isSingleRowUpdate(dry) && skipsSingleUpdates())
|
|
344
|
+
return execute(query, text)
|
|
345
|
+
const skipNextTime = ref(false)
|
|
346
|
+
const typed =
|
|
347
|
+
dry.operation === 'delete' || dry.rows === null || (dry.rows ?? 0) > 1
|
|
348
|
+
const targetLabel = describeTarget(dry)
|
|
349
|
+
const texts = guardTexts(dry)
|
|
350
|
+
await confirm({
|
|
351
|
+
...texts,
|
|
352
|
+
description: t('dms_database.query.guard.description'),
|
|
353
|
+
color: dry.operation === 'delete' ? 'error' : 'warning',
|
|
354
|
+
icon: OPERATION_ICONS[dry.operation] ?? 'i-ph-warning',
|
|
355
|
+
initialFocus: 'cancel',
|
|
356
|
+
confirmText: typed ? (dry.table ?? dry.schema) : undefined,
|
|
357
|
+
impact: [
|
|
358
|
+
{
|
|
359
|
+
icon: OPERATION_ICONS[dry.operation] ?? 'i-ph-lightning',
|
|
360
|
+
label: t('dms_database.query.guard.operation'),
|
|
361
|
+
count: t(`dms_database.query.guard.op_${dry.operation}`),
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
icon: 'i-ph-table',
|
|
365
|
+
label: t('dms_database.query.guard.target'),
|
|
366
|
+
count: targetLabel,
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
icon: 'i-ph-rows',
|
|
370
|
+
label: t('dms_database.query.guard.rows'),
|
|
371
|
+
count: dry.rows === null ? '?' : n(dry.rows),
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
body: () =>
|
|
375
|
+
h('div', { class: 'grid gap-3' }, [
|
|
376
|
+
h(
|
|
377
|
+
'pre',
|
|
378
|
+
{
|
|
379
|
+
class:
|
|
380
|
+
'border-default bg-elevated/50 max-h-40 overflow-auto rounded-md border p-3 font-mono text-xs whitespace-pre-wrap [overflow-wrap:anywhere]',
|
|
381
|
+
},
|
|
382
|
+
text,
|
|
383
|
+
),
|
|
384
|
+
isSingleRowUpdate(dry)
|
|
385
|
+
? h('label', { class: 'flex items-start gap-2 text-sm' }, [
|
|
386
|
+
h('input', {
|
|
387
|
+
type: 'checkbox',
|
|
388
|
+
class: 'mt-1',
|
|
389
|
+
checked: skipNextTime.value,
|
|
390
|
+
onChange: (event: Event) => {
|
|
391
|
+
skipNextTime.value = (
|
|
392
|
+
event.target as HTMLInputElement
|
|
393
|
+
).checked
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
h('span', [
|
|
397
|
+
h(
|
|
398
|
+
'span',
|
|
399
|
+
{ class: 'text-toned' },
|
|
400
|
+
t('dms_database.query.guard.skip'),
|
|
401
|
+
),
|
|
402
|
+
h(
|
|
403
|
+
'span',
|
|
404
|
+
{ class: 'text-dimmed block text-xs' },
|
|
405
|
+
t('dms_database.query.guard.skip_detail'),
|
|
406
|
+
),
|
|
407
|
+
]),
|
|
408
|
+
])
|
|
409
|
+
: null,
|
|
410
|
+
]),
|
|
411
|
+
onConfirm: async () => {
|
|
412
|
+
if (skipNextTime.value)
|
|
413
|
+
window.sessionStorage.setItem(SKIP_SINGLE_UPDATES_KEY, 'true')
|
|
414
|
+
await execute(query, text)
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function run() {
|
|
420
|
+
if (!source.value.trim() || executing.value) return
|
|
421
|
+
let prepared: ReturnType<typeof prepareQueryPayload>
|
|
422
|
+
try {
|
|
423
|
+
prepared = prepareQueryPayload(source.value, schemas.value)
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return failBeforeRun(source.value.trim(), describeInputError(error))
|
|
426
|
+
}
|
|
427
|
+
if (prepared.hasMutations) await guard(prepared.query, prepared.source)
|
|
428
|
+
else await execute(prepared.query, prepared.source)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function replaceUnknown(kind: UnknownKind, from: string, to: string) {
|
|
432
|
+
source.value = replaceName(source.value, kind, from, to)
|
|
433
|
+
failure.value = null
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// The empty read again, in a named instance that has rows.
|
|
437
|
+
async function runInInstance(instance: string) {
|
|
438
|
+
if (!target.value.schema) return
|
|
439
|
+
const next = withInstance(source.value, target.value.schema, instance)
|
|
440
|
+
if (next === source.value) return
|
|
441
|
+
source.value = next
|
|
442
|
+
await run()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// --- saving ---
|
|
446
|
+
const saveOpen = ref(false)
|
|
447
|
+
const saveName = ref('')
|
|
448
|
+
const saveDescription = ref('')
|
|
449
|
+
const saveShared = ref(false)
|
|
450
|
+
const saveError = ref<string | null>(null)
|
|
451
|
+
// The name says it is missing once left empty, as a DMS form's required field.
|
|
452
|
+
const nameTouched = ref(false)
|
|
453
|
+
const nameError = computed(() =>
|
|
454
|
+
nameTouched.value && !saveName.value.trim()
|
|
455
|
+
? t('dms_database.query.save.name_required')
|
|
456
|
+
: null,
|
|
457
|
+
)
|
|
458
|
+
// A DMS form's rows in a modal: label column, hairline between rows.
|
|
459
|
+
const SAVE_ROW = { layout: 'form', inset: false, spacing: 'row' } as const
|
|
460
|
+
const saving = ref(false)
|
|
461
|
+
const editing = ref<SavedQuery | null>(null)
|
|
462
|
+
// A past run being saved: the editor takes its text once it is saved.
|
|
463
|
+
const savingRun = ref<HistoryEntry | null>(null)
|
|
464
|
+
|
|
465
|
+
function openSaveModal(from?: SavedQuery | HistoryEntry) {
|
|
466
|
+
const saved = from && 'name' in from ? from : null
|
|
467
|
+
editing.value = saved
|
|
468
|
+
savingRun.value = from && !saved ? (from as HistoryEntry) : null
|
|
469
|
+
saveName.value = saved?.name ?? ''
|
|
470
|
+
saveDescription.value = saved?.description ?? ''
|
|
471
|
+
saveShared.value = saved?.shared ?? false
|
|
472
|
+
saveError.value = null
|
|
473
|
+
nameTouched.value = false
|
|
474
|
+
saveOpen.value = true
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function persist(text: string) {
|
|
478
|
+
const payload = prepareQueryPayload(text, schemas.value)
|
|
479
|
+
const input = {
|
|
480
|
+
name: saveName.value.trim(),
|
|
481
|
+
description: saveDescription.value.trim(),
|
|
482
|
+
query: payload.query,
|
|
483
|
+
source: payload.source,
|
|
484
|
+
language: 'aql' as const,
|
|
485
|
+
shared: saveShared.value,
|
|
486
|
+
}
|
|
487
|
+
return editing.value
|
|
488
|
+
? store.updateSaved(editing.value.id, input)
|
|
489
|
+
: store.saveQuery(input)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function confirmSave() {
|
|
493
|
+
if (!saveName.value.trim()) {
|
|
494
|
+
nameTouched.value = true
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
saving.value = true
|
|
498
|
+
saveError.value = null
|
|
499
|
+
try {
|
|
500
|
+
const run = savingRun.value
|
|
501
|
+
const text = run
|
|
502
|
+
? run.source
|
|
503
|
+
: editing.value && editing.value.id !== loadedSaved.value?.id
|
|
504
|
+
? editing.value.source
|
|
505
|
+
: source.value
|
|
506
|
+
const saved = await persist(text)
|
|
507
|
+
if (run || !editing.value || editing.value.id === loadedSaved.value?.id) {
|
|
508
|
+
loadedSaved.value = saved
|
|
509
|
+
activeHistoryId.value = null
|
|
510
|
+
source.value = saved.source
|
|
511
|
+
}
|
|
512
|
+
savingRun.value = null
|
|
513
|
+
saveOpen.value = false
|
|
514
|
+
toast.add({
|
|
515
|
+
title: t('dms_database.query.save.saved', { name: saved.name }),
|
|
516
|
+
color: 'success',
|
|
517
|
+
icon: 'i-ph-check',
|
|
518
|
+
})
|
|
519
|
+
await refreshSaved()
|
|
520
|
+
} catch (error) {
|
|
521
|
+
saveError.value =
|
|
522
|
+
error instanceof Error && !('data' in error)
|
|
523
|
+
? error.message
|
|
524
|
+
: fetchMessage(error)
|
|
525
|
+
} finally {
|
|
526
|
+
saving.value = false
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ⌘S on a query of the caller's own saves it in place; anything else asks a name.
|
|
531
|
+
async function save() {
|
|
532
|
+
const own = loadedSaved.value
|
|
533
|
+
if (!own || own.userId !== ownId.value) return openSaveModal()
|
|
534
|
+
editing.value = own
|
|
535
|
+
savingRun.value = null
|
|
536
|
+
saveName.value = own.name
|
|
537
|
+
saveDescription.value = own.description
|
|
538
|
+
saveShared.value = own.shared
|
|
539
|
+
await confirmSave()
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// --- links into the console ---
|
|
543
|
+
/** Opens what the URL names; false when it names nothing to open. */
|
|
544
|
+
async function openFromRoute(): Promise<boolean> {
|
|
545
|
+
const { q, source: linked, history, schema, table, instance } = route.query
|
|
546
|
+
let opened = false
|
|
547
|
+
if (typeof q === 'string') {
|
|
548
|
+
const match =
|
|
549
|
+
store.savedQueries.value.find((query) => query.id === q) ??
|
|
550
|
+
team.value.find((query) => query.id === q)
|
|
551
|
+
if (match) openSaved(match)
|
|
552
|
+
opened = Boolean(match)
|
|
553
|
+
} else if (typeof history === 'string') {
|
|
554
|
+
const entry = await store.fetchHistoryEntry(history).catch(() => null)
|
|
555
|
+
if (entry) openHistory(entry)
|
|
556
|
+
opened = Boolean(entry)
|
|
557
|
+
} else if (typeof linked === 'string') {
|
|
558
|
+
newQuery()
|
|
559
|
+
source.value = linked
|
|
560
|
+
opened = true
|
|
561
|
+
} else if (typeof schema === 'string' && typeof table === 'string') {
|
|
562
|
+
newQuery()
|
|
563
|
+
// The instance the link names: a named one, the default, or every one.
|
|
564
|
+
source.value = `${schemaAccess(schema)}.instance(${instanceArgument(instance)}).table(${JSON.stringify(table)}).slice(0, ${PREFILL_ROWS})`
|
|
565
|
+
opened = true
|
|
566
|
+
} else {
|
|
567
|
+
return false
|
|
568
|
+
}
|
|
569
|
+
router.replace({ query: {} })
|
|
570
|
+
return opened
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
onMounted(async () => {
|
|
574
|
+
await Promise.all([refreshHistory(), refreshSaved()])
|
|
575
|
+
libraryLoading.value = false
|
|
576
|
+
if (!(await openFromRoute())) restoreDraft()
|
|
577
|
+
})
|
|
578
|
+
</script>
|
|
579
|
+
|
|
580
|
+
<template>
|
|
581
|
+
<!-- Below lg the cards stack at their own height and the page scrolls; a
|
|
582
|
+
card never shrinks under its content. -->
|
|
583
|
+
<div
|
|
584
|
+
class="flex min-h-0 flex-1 flex-col gap-4 max-lg:overflow-y-auto lg:grid lg:grid-cols-[19rem_minmax(0,1fr)]"
|
|
585
|
+
>
|
|
586
|
+
<QueryLibrary
|
|
587
|
+
class="max-lg:max-h-80 max-lg:shrink-0 lg:min-h-0"
|
|
588
|
+
:history="store.historyItems.value"
|
|
589
|
+
:saved="store.savedQueries.value"
|
|
590
|
+
:team="team"
|
|
591
|
+
:active-saved-id="loadedSaved?.id ?? null"
|
|
592
|
+
:active-history-id="activeHistoryId"
|
|
593
|
+
:loading="libraryLoading"
|
|
594
|
+
@open-history="openHistory"
|
|
595
|
+
@save-history="openSaveModal"
|
|
596
|
+
@open-saved="openSaved"
|
|
597
|
+
@edit-saved="openSaveModal"
|
|
598
|
+
@delete-saved="deleteSaved"
|
|
599
|
+
@clear-history="clearHistory"
|
|
600
|
+
/>
|
|
601
|
+
|
|
602
|
+
<div
|
|
603
|
+
class="flex min-w-0 flex-col gap-4 max-lg:shrink-0 lg:min-h-0 lg:overflow-y-auto"
|
|
604
|
+
>
|
|
605
|
+
<QueryBuilderPanel
|
|
606
|
+
ref="editor"
|
|
607
|
+
v-model="source"
|
|
608
|
+
class="shrink-0"
|
|
609
|
+
:executing="executing"
|
|
610
|
+
:schemas="schemas"
|
|
611
|
+
:saved-name="loadedSaved?.name"
|
|
612
|
+
:saved-shared="loadedSaved?.shared"
|
|
613
|
+
:edited="edited"
|
|
614
|
+
@execute="run"
|
|
615
|
+
@save="save"
|
|
616
|
+
@new="newQuery"
|
|
617
|
+
/>
|
|
618
|
+
<QueryResultPanel
|
|
619
|
+
class="shrink-0"
|
|
620
|
+
:result="result"
|
|
621
|
+
:failure="failure"
|
|
622
|
+
:target="target"
|
|
623
|
+
:schemas="schemas"
|
|
624
|
+
:running="executing"
|
|
625
|
+
:elsewhere="elsewhere"
|
|
626
|
+
@use-source="source = $event"
|
|
627
|
+
@replace-name="replaceUnknown"
|
|
628
|
+
@use-instance="runInInstance"
|
|
629
|
+
/>
|
|
630
|
+
</div>
|
|
631
|
+
|
|
632
|
+
<UModal
|
|
633
|
+
v-model:open="saveOpen"
|
|
634
|
+
:title="
|
|
635
|
+
editing
|
|
636
|
+
? t('dms_database.query.save.edit_title')
|
|
637
|
+
: t('dms_database.query.save.title')
|
|
638
|
+
"
|
|
639
|
+
:description="t('dms_database.query.save.description')"
|
|
640
|
+
>
|
|
641
|
+
<template #body>
|
|
642
|
+
<!-- Drawn as a DMS form in a modal: FieldRow rows, errors under
|
|
643
|
+
their control, the required legend under the rows. -->
|
|
644
|
+
<form
|
|
645
|
+
id="dms-database-save-query"
|
|
646
|
+
class="flex flex-col py-1"
|
|
647
|
+
novalidate
|
|
648
|
+
@submit.prevent="confirmSave"
|
|
649
|
+
>
|
|
650
|
+
<DmsFieldRow
|
|
651
|
+
v-bind="SAVE_ROW"
|
|
652
|
+
:label="t('dms_database.query.save.name')"
|
|
653
|
+
label-for="dms-database-save-query-name"
|
|
654
|
+
required
|
|
655
|
+
>
|
|
656
|
+
<div class="grid min-w-0 gap-1.5">
|
|
657
|
+
<UFormField name="name" :error="nameError ?? false">
|
|
658
|
+
<DmsInputText
|
|
659
|
+
id="dms-database-save-query-name"
|
|
660
|
+
v-model="saveName"
|
|
661
|
+
autofocus
|
|
662
|
+
class="w-full"
|
|
663
|
+
:placeholder="t('dms_database.query.save.name_placeholder')"
|
|
664
|
+
@blur="nameTouched = true"
|
|
665
|
+
/>
|
|
666
|
+
<template v-if="nameError" #error="{ error }">
|
|
667
|
+
<DmsFormErrorText :error />
|
|
668
|
+
</template>
|
|
669
|
+
</UFormField>
|
|
670
|
+
</div>
|
|
671
|
+
</DmsFieldRow>
|
|
672
|
+
<DmsFieldRow
|
|
673
|
+
v-bind="SAVE_ROW"
|
|
674
|
+
:label="t('dms_database.query.save.notes')"
|
|
675
|
+
label-for="dms-database-save-query-description"
|
|
676
|
+
>
|
|
677
|
+
<DmsTextarea
|
|
678
|
+
id="dms-database-save-query-description"
|
|
679
|
+
v-model="saveDescription"
|
|
680
|
+
class="w-full"
|
|
681
|
+
:rows="3"
|
|
682
|
+
/>
|
|
683
|
+
</DmsFieldRow>
|
|
684
|
+
<DmsFieldRow
|
|
685
|
+
v-bind="SAVE_ROW"
|
|
686
|
+
:label="t('dms_database.query.save.share')"
|
|
687
|
+
:description="t('dms_database.query.save.share_description')"
|
|
688
|
+
label-for="dms-database-save-query-shared"
|
|
689
|
+
>
|
|
690
|
+
<DmsSwitch
|
|
691
|
+
id="dms-database-save-query-shared"
|
|
692
|
+
v-model="saveShared"
|
|
693
|
+
/>
|
|
694
|
+
</DmsFieldRow>
|
|
695
|
+
<DmsFormRequiredLegend class="pb-2" />
|
|
696
|
+
<UAlert
|
|
697
|
+
v-if="saveError"
|
|
698
|
+
color="error"
|
|
699
|
+
variant="subtle"
|
|
700
|
+
icon="i-ph-warning"
|
|
701
|
+
:title="t('dms_database.query.save.failed')"
|
|
702
|
+
:description="saveError"
|
|
703
|
+
/>
|
|
704
|
+
</form>
|
|
705
|
+
</template>
|
|
706
|
+
<template #footer>
|
|
707
|
+
<div class="flex w-full justify-end gap-2">
|
|
708
|
+
<UButton
|
|
709
|
+
color="neutral"
|
|
710
|
+
variant="outline"
|
|
711
|
+
:disabled="saving"
|
|
712
|
+
:label="t('dms_database.common.cancel')"
|
|
713
|
+
@click="saveOpen = false"
|
|
714
|
+
/>
|
|
715
|
+
<UButton
|
|
716
|
+
type="submit"
|
|
717
|
+
form="dms-database-save-query"
|
|
718
|
+
:loading="saving"
|
|
719
|
+
:disabled="!saveName.trim()"
|
|
720
|
+
:label="t('dms_database.query.save.confirm')"
|
|
721
|
+
/>
|
|
722
|
+
</div>
|
|
723
|
+
</template>
|
|
724
|
+
</UModal>
|
|
725
|
+
</div>
|
|
726
|
+
</template>
|