@antelopejs/dms-database 0.0.13 → 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 +9 -6
- 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,168 @@
|
|
|
1
|
+
import type { HistoryEntry } from '../composables/useQueryStore'
|
|
2
|
+
|
|
3
|
+
// How the library tells runs apart (D-11): a dot per run — fast, slow,
|
|
4
|
+
// failed or changed data — and day headings.
|
|
5
|
+
|
|
6
|
+
export type RunKind = 'fast' | 'slow' | 'failed' | 'changed'
|
|
7
|
+
|
|
8
|
+
// A run slower than this, in milliseconds, is slow; the server flags the
|
|
9
|
+
// recent queries of the overview with the same threshold.
|
|
10
|
+
export const SLOW_QUERY_MS = 100
|
|
11
|
+
|
|
12
|
+
export function runKind(entry: HistoryEntry): RunKind {
|
|
13
|
+
if (entry.status === 'error') return 'failed'
|
|
14
|
+
if (entry.mutation) return 'changed'
|
|
15
|
+
return entry.durationMs > SLOW_QUERY_MS ? 'slow' : 'fast'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A run that changed data reads apart from a fast one by its shape too: a
|
|
19
|
+
// ring, as the info and success tones sit close in some themes.
|
|
20
|
+
export const RUN_DOT_CLASSES: Record<RunKind, string> = {
|
|
21
|
+
fast: 'bg-success',
|
|
22
|
+
slow: 'bg-warning',
|
|
23
|
+
failed: 'bg-error',
|
|
24
|
+
changed: 'bg-default ring-2 ring-inset ring-info',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Runs the server keeps per user (MAX_HISTORY_PER_USER). */
|
|
28
|
+
export const HISTORY_KEPT = 500
|
|
29
|
+
|
|
30
|
+
export interface DayGroup<T> {
|
|
31
|
+
key: string
|
|
32
|
+
date: Date
|
|
33
|
+
items: T[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function dayKey(date: Date): string {
|
|
37
|
+
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Entries by local calendar day, newest day first, in their given order. */
|
|
41
|
+
export function groupByDay<T>(
|
|
42
|
+
entries: T[],
|
|
43
|
+
dateOf: (entry: T) => string,
|
|
44
|
+
): DayGroup<T>[] {
|
|
45
|
+
const groups = new Map<string, DayGroup<T>>()
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const date = new Date(dateOf(entry))
|
|
48
|
+
const key = dayKey(date)
|
|
49
|
+
const group = groups.get(key) ?? { key, date, items: [] }
|
|
50
|
+
group.items.push(entry)
|
|
51
|
+
groups.set(key, group)
|
|
52
|
+
}
|
|
53
|
+
return [...groups.values()]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DAY_MS = 86_400_000
|
|
57
|
+
|
|
58
|
+
/** "Today", "Yesterday" or the date, in the interface language. */
|
|
59
|
+
export function dayLabel(
|
|
60
|
+
date: Date,
|
|
61
|
+
locale: string,
|
|
62
|
+
words: { today: string; yesterday: string },
|
|
63
|
+
): string {
|
|
64
|
+
const today = new Date()
|
|
65
|
+
today.setHours(0, 0, 0, 0)
|
|
66
|
+
const day = new Date(date)
|
|
67
|
+
day.setHours(0, 0, 0, 0)
|
|
68
|
+
const days = Math.round((today.getTime() - day.getTime()) / DAY_MS)
|
|
69
|
+
if (days === 0) return words.today
|
|
70
|
+
if (days === 1) return words.yesterday
|
|
71
|
+
return new Intl.DateTimeFormat(locale, {
|
|
72
|
+
day: 'numeric',
|
|
73
|
+
month: 'short',
|
|
74
|
+
}).format(date)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type UnknownKind = 'schema' | 'instance' | 'table'
|
|
78
|
+
|
|
79
|
+
/** A name a query used that its schema does not know. */
|
|
80
|
+
export interface UnknownName {
|
|
81
|
+
kind: UnknownKind
|
|
82
|
+
name: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What an "Unknown schema / instance / table" error names, if it names one. */
|
|
86
|
+
export function unknownName(message: string): UnknownName | null {
|
|
87
|
+
const match = /unknown (schema|instance|table):?\s*["“'«]?\s*([\w$-]+)/i.exec(
|
|
88
|
+
message,
|
|
89
|
+
)
|
|
90
|
+
if (!match) return null
|
|
91
|
+
return {
|
|
92
|
+
kind: (match[1] as string).toLowerCase() as UnknownKind,
|
|
93
|
+
name: match[2] as string,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function distance(left: string, right: string): number {
|
|
98
|
+
const previous = Array.from({ length: right.length + 1 }, (_, index) => index)
|
|
99
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
100
|
+
let diagonal = previous[0] as number
|
|
101
|
+
previous[0] = i
|
|
102
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
103
|
+
const above = previous[j] as number
|
|
104
|
+
const cost = left[i - 1] === right[j - 1] ? 0 : 1
|
|
105
|
+
previous[j] = Math.min(
|
|
106
|
+
above + 1,
|
|
107
|
+
(previous[j - 1] as number) + 1,
|
|
108
|
+
diagonal + cost,
|
|
109
|
+
)
|
|
110
|
+
diagonal = above
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return previous[right.length] as number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Two edits away at most: "invoice" for "invoices", "oders" for "orders".
|
|
117
|
+
const MAX_SUGGESTION_DISTANCE = 2
|
|
118
|
+
|
|
119
|
+
/** The name closest to a mistyped one, when one is close enough. */
|
|
120
|
+
export function closestName(name: string, candidates: string[]): string | null {
|
|
121
|
+
let best: string | null = null
|
|
122
|
+
let bestDistance = MAX_SUGGESTION_DISTANCE + 1
|
|
123
|
+
for (const candidate of candidates) {
|
|
124
|
+
if (candidate === name) continue
|
|
125
|
+
const score = distance(name.toLowerCase(), candidate.toLowerCase())
|
|
126
|
+
if (score < bestDistance) {
|
|
127
|
+
best = candidate
|
|
128
|
+
bestDistance = score
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return best
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function escapeRegExp(text: string): string {
|
|
135
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const IDENTIFIER = /^[A-Z_$][\w$]*$/i
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The query with a mistyped schema, instance or table name replaced, in the
|
|
142
|
+
* call or access that names it: `table("oders")`, `instance("zz")`,
|
|
143
|
+
* `schemas.shoop` or `schemas["shoop"]`.
|
|
144
|
+
*/
|
|
145
|
+
export function replaceName(
|
|
146
|
+
source: string,
|
|
147
|
+
kind: UnknownKind,
|
|
148
|
+
from: string,
|
|
149
|
+
to: string,
|
|
150
|
+
): string {
|
|
151
|
+
const name = escapeRegExp(from)
|
|
152
|
+
if (kind === 'schema') {
|
|
153
|
+
const access = IDENTIFIER.test(to)
|
|
154
|
+
? `schemas.${to}`
|
|
155
|
+
: `schemas[${JSON.stringify(to)}]`
|
|
156
|
+
return source.replace(
|
|
157
|
+
new RegExp(
|
|
158
|
+
`schemas\\s*(?:\\.\\s*${name}(?![\\w$])|\\[\\s*(["'\`])${name}\\1\\s*\\])`,
|
|
159
|
+
'g',
|
|
160
|
+
),
|
|
161
|
+
access,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return source.replace(
|
|
165
|
+
new RegExp(`${kind}\\(\\s*(["'\`])${name}\\1\\s*\\)`, 'g'),
|
|
166
|
+
(_, quote: string) => `${kind}(${quote}${to}${quote})`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// How the result table lays out its columns: which ones it shows and which
|
|
2
|
+
// it right-aligns. JSON and the CSV export keep every column as it is.
|
|
3
|
+
|
|
4
|
+
function isEmpty(value: unknown): boolean {
|
|
5
|
+
return value === null || value === undefined
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The columns the result table shows: a column null in every row says
|
|
10
|
+
* nothing (`_instance` on a single-instance read) and is left out, unless
|
|
11
|
+
* every column is.
|
|
12
|
+
*/
|
|
13
|
+
export function filledColumns(
|
|
14
|
+
rows: Record<string, unknown>[],
|
|
15
|
+
columns: string[],
|
|
16
|
+
): string[] {
|
|
17
|
+
const filled = columns.filter((column) =>
|
|
18
|
+
rows.some((row) => !isEmpty(row[column])),
|
|
19
|
+
)
|
|
20
|
+
return filled.length ? filled : columns
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Columns holding numbers and nothing else but nulls: right-aligned. */
|
|
24
|
+
export function numericColumns(
|
|
25
|
+
rows: Record<string, unknown>[],
|
|
26
|
+
columns: string[],
|
|
27
|
+
): string[] {
|
|
28
|
+
return columns.filter(
|
|
29
|
+
(column) =>
|
|
30
|
+
rows.some((row) => typeof row[column] === 'number') &&
|
|
31
|
+
rows.every(
|
|
32
|
+
(row) => isEmpty(row[column]) || typeof row[column] === 'number',
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The schema, instance and table an encoded query starts from, read from its
|
|
2
|
+
// stages the way the server reads them: what the result header names and the
|
|
3
|
+
// data browser link opens.
|
|
4
|
+
|
|
5
|
+
import { instanceFromUrl } from '../utils/databaseLinks'
|
|
6
|
+
import { CROSS_INSTANCE_SENTINEL } from '../utils/stagedSerialization'
|
|
7
|
+
import { escapeRegExp } from './runs'
|
|
8
|
+
|
|
9
|
+
export interface QueryTarget {
|
|
10
|
+
schema?: string
|
|
11
|
+
/** `""` for the default instance, `"*"` across every instance. */
|
|
12
|
+
instance?: string
|
|
13
|
+
table?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface EncodedStage {
|
|
17
|
+
stage?: unknown
|
|
18
|
+
options?: { id?: unknown }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function stageId(stages: EncodedStage[], name: string): unknown {
|
|
22
|
+
return stages.find((stage) => stage.stage === name)?.options?.id
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readQueryTarget(
|
|
26
|
+
encoded: Record<string, unknown> | null,
|
|
27
|
+
): QueryTarget {
|
|
28
|
+
const stages = Array.isArray(encoded?.stages)
|
|
29
|
+
? (encoded.stages as EncodedStage[])
|
|
30
|
+
: []
|
|
31
|
+
const schema = stageId(stages, 'schema')
|
|
32
|
+
const table = stageId(stages, 'table')
|
|
33
|
+
const hasInstance = stages.some((stage) => stage.stage === 'instance')
|
|
34
|
+
const instance = stageId(stages, 'instance')
|
|
35
|
+
return {
|
|
36
|
+
schema: typeof schema === 'string' ? schema : undefined,
|
|
37
|
+
table: typeof table === 'string' ? table : undefined,
|
|
38
|
+
instance: !hasInstance
|
|
39
|
+
? undefined
|
|
40
|
+
: instance === CROSS_INSTANCE_SENTINEL || typeof instance === 'symbol'
|
|
41
|
+
? '*'
|
|
42
|
+
: typeof instance === 'string'
|
|
43
|
+
? instance
|
|
44
|
+
: '',
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `shop.orders @eu`, or as much of it as the query names. */
|
|
49
|
+
export function describeTarget(target: QueryTarget): string {
|
|
50
|
+
if (!target.schema) return ''
|
|
51
|
+
const table = target.table
|
|
52
|
+
? `${target.schema}.${target.table}`
|
|
53
|
+
: target.schema
|
|
54
|
+
return target.instance ? `${table} @${target.instance}` : table
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SCHEMA_ACCESS =
|
|
58
|
+
/schemas\s*(?:\.\s*([A-Za-z_$][\w$]*)|\[\s*["'`]([\w$-]+)["'`]\s*\])/g
|
|
59
|
+
const INSTANCE_CALL =
|
|
60
|
+
/\.\s*instance\(\s*(?:(["'`])([^"'`]*)\1|(CROSS_INSTANCE))?\s*\)/g
|
|
61
|
+
const TABLE_CALL = /\.\s*table\(\s*(["'`])([^"'`]+)\1\s*\)/g
|
|
62
|
+
|
|
63
|
+
function lastMatch(pattern: RegExp, text: string): RegExpExecArray | null {
|
|
64
|
+
let last: RegExpExecArray | null = null
|
|
65
|
+
pattern.lastIndex = 0
|
|
66
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text))
|
|
67
|
+
last = match
|
|
68
|
+
return last
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The schema, instance and table the last chain of a query text names, read
|
|
73
|
+
* from the text itself: for a query that never reached the server, or the
|
|
74
|
+
* chain the cursor is in.
|
|
75
|
+
*/
|
|
76
|
+
export function targetFromSource(source: string): QueryTarget {
|
|
77
|
+
const schema = lastMatch(SCHEMA_ACCESS, source)
|
|
78
|
+
if (!schema) return {}
|
|
79
|
+
const chain = source.slice(schema.index)
|
|
80
|
+
const instance = lastMatch(INSTANCE_CALL, chain)
|
|
81
|
+
const table = lastMatch(TABLE_CALL, chain)
|
|
82
|
+
return {
|
|
83
|
+
schema: schema[1] ?? schema[2],
|
|
84
|
+
instance: !instance ? undefined : instance[3] ? '*' : (instance[2] ?? ''),
|
|
85
|
+
table: table?.[2],
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Where a past run pointed: its encoded query, else its text. */
|
|
90
|
+
export function runTarget(run: {
|
|
91
|
+
query?: Record<string, unknown> | null
|
|
92
|
+
source: string
|
|
93
|
+
}): QueryTarget {
|
|
94
|
+
const encoded = readQueryTarget(run.query ?? null)
|
|
95
|
+
return encoded.schema ? encoded : targetFromSource(run.source)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The instance a result row lives in, for its data browser link: the
|
|
100
|
+
* query's, or the row's own `_instance` when the query read every instance.
|
|
101
|
+
* `''` is the default instance; null when the row does not say.
|
|
102
|
+
*/
|
|
103
|
+
export function rowInstance(
|
|
104
|
+
target: QueryTarget,
|
|
105
|
+
row: Record<string, unknown>,
|
|
106
|
+
): string | null {
|
|
107
|
+
if (target.instance !== '*') return target.instance ?? ''
|
|
108
|
+
if (!('_instance' in row)) return null
|
|
109
|
+
const instance = row._instance
|
|
110
|
+
if (instance === null || instance === undefined || instance === '') return ''
|
|
111
|
+
return typeof instance === 'string' ? instance : null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The `instance(…)` argument for the instance the URL's `instance` names:
|
|
116
|
+
* none for the default one, CROSS_INSTANCE for every instance.
|
|
117
|
+
*/
|
|
118
|
+
export function instanceArgument(raw: unknown): string {
|
|
119
|
+
const choice = instanceFromUrl(raw)
|
|
120
|
+
if (choice.kind === 'default') return ''
|
|
121
|
+
if (choice.kind === 'all') return 'CROSS_INSTANCE'
|
|
122
|
+
return JSON.stringify(choice.id)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The query text with the default instance of a schema, `instance()`, read
|
|
127
|
+
* in a named instance instead: `instance("eu")`.
|
|
128
|
+
*/
|
|
129
|
+
export function withInstance(
|
|
130
|
+
source: string,
|
|
131
|
+
schema: string,
|
|
132
|
+
instance: string,
|
|
133
|
+
): string {
|
|
134
|
+
const name = escapeRegExp(schema)
|
|
135
|
+
const access = new RegExp(
|
|
136
|
+
`(schemas\\s*(?:\\.\\s*${name}(?![\\w$])|\\[\\s*(["'\`])${name}\\2\\s*\\])\\s*\\.\\s*instance\\()\\s*\\)`,
|
|
137
|
+
'g',
|
|
138
|
+
)
|
|
139
|
+
return source.replace(
|
|
140
|
+
access,
|
|
141
|
+
(_, call: string) => `${call}${JSON.stringify(instance)})`,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { InstanceChoice } from '../data/instanceOptions'
|
|
2
|
+
import { instanceFromUrl, instanceToUrl } from '../utils/databaseLinks'
|
|
3
|
+
|
|
4
|
+
// The Schemas page's filter bar keeps its state in the URL, so a filtered
|
|
5
|
+
// list can be shared, reloaded and gone back to: `scope` (a schema),
|
|
6
|
+
// `instance`, `q` (a table or column name) and `has` (relations, modifiers,
|
|
7
|
+
// empty). The table view reads the same keys as hidden filters and sends
|
|
8
|
+
// them to its source route, which reads them back (src/service/schemaFilters.ts
|
|
9
|
+
// holds the same rules).
|
|
10
|
+
// `instance` follows the module's convention (see instanceFromUrl): absent
|
|
11
|
+
// for the default instance, `all` for every instance.
|
|
12
|
+
|
|
13
|
+
const FILTER_KEYS = ['scope', 'instance', 'q', 'has'] as const
|
|
14
|
+
type FilterKey = (typeof FILTER_KEYS)[number]
|
|
15
|
+
|
|
16
|
+
export const HAS_FLAGS = ['relations', 'modifiers', 'empty'] as const
|
|
17
|
+
export type HasFlag = (typeof HAS_FLAGS)[number]
|
|
18
|
+
const HAS_SEPARATOR = ','
|
|
19
|
+
|
|
20
|
+
export type { InstanceChoice }
|
|
21
|
+
|
|
22
|
+
export interface SchemasFilterState {
|
|
23
|
+
/** The schema listed; null for every schema. */
|
|
24
|
+
scope: string | null
|
|
25
|
+
instance: InstanceChoice
|
|
26
|
+
/** A part of a table or column name, trimmed; empty for none. */
|
|
27
|
+
q: string
|
|
28
|
+
/** Structure the tables must have, all of them, in a fixed order. */
|
|
29
|
+
has: HasFlag[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const EMPTY_FILTER_STATE: SchemasFilterState = {
|
|
33
|
+
scope: null,
|
|
34
|
+
instance: { kind: 'default' },
|
|
35
|
+
q: '',
|
|
36
|
+
has: [],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Query = Record<string, unknown>
|
|
40
|
+
|
|
41
|
+
function text(value: unknown): string {
|
|
42
|
+
const first = Array.isArray(value) ? value[0] : value
|
|
43
|
+
return typeof first === 'string' ? first.trim() : ''
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Known flags only, once each, in their fixed order. */
|
|
47
|
+
export function parseHas(param: string | undefined): HasFlag[] {
|
|
48
|
+
if (!param) return []
|
|
49
|
+
const asked = new Set(param.split(HAS_SEPARATOR).map((flag) => flag.trim()))
|
|
50
|
+
return HAS_FLAGS.filter((flag) => asked.has(flag))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The bar's state from a route query; an empty value reads as unset. */
|
|
54
|
+
export function readFilterState(query: Query): SchemasFilterState {
|
|
55
|
+
return {
|
|
56
|
+
scope: text(query.scope) || null,
|
|
57
|
+
instance: instanceFromUrl(query.instance),
|
|
58
|
+
q: text(query.q),
|
|
59
|
+
has: parseHas(text(query.has)),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The URL values of a state; undefined for what is unset. */
|
|
64
|
+
export function filterParams(
|
|
65
|
+
state: SchemasFilterState,
|
|
66
|
+
): Record<FilterKey, string | undefined> {
|
|
67
|
+
const has = parseHas(state.has.join(HAS_SEPARATOR))
|
|
68
|
+
return {
|
|
69
|
+
scope: state.scope || undefined,
|
|
70
|
+
instance: instanceToUrl(state.instance),
|
|
71
|
+
q: state.q.trim() || undefined,
|
|
72
|
+
has: has.length > 0 ? has.join(HAS_SEPARATOR) : undefined,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A route query carrying this state, the rest of it kept: an unset key is
|
|
78
|
+
* dropped, empty ones included.
|
|
79
|
+
*/
|
|
80
|
+
export function withFilterState(
|
|
81
|
+
query: Query,
|
|
82
|
+
state: SchemasFilterState,
|
|
83
|
+
): Query {
|
|
84
|
+
const next: Query = { ...query }
|
|
85
|
+
for (const [key, value] of Object.entries(filterParams(state))) {
|
|
86
|
+
if (value === undefined) delete next[key]
|
|
87
|
+
else next[key] = value
|
|
88
|
+
}
|
|
89
|
+
return next
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether the bar narrows the list at all. */
|
|
93
|
+
export function isFiltered(state: SchemasFilterState): boolean {
|
|
94
|
+
return Object.values(filterParams(state)).some((value) => value !== undefined)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The source route's query for a state, as the table view sends it. */
|
|
98
|
+
export function sourceQuery(state: SchemasFilterState): Record<string, string> {
|
|
99
|
+
const query: Record<string, string> = {}
|
|
100
|
+
for (const [key, value] of Object.entries(filterParams(state))) {
|
|
101
|
+
if (value !== undefined) query[`filter_${key}`] = `is:${value}`
|
|
102
|
+
}
|
|
103
|
+
return query
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Another schema: a named instance belongs to the one left. */
|
|
107
|
+
export function withScope(
|
|
108
|
+
state: SchemasFilterState,
|
|
109
|
+
scope: string | null,
|
|
110
|
+
): SchemasFilterState {
|
|
111
|
+
const keepsInstance =
|
|
112
|
+
state.instance.kind !== 'named' || (scope !== null && scope === state.scope)
|
|
113
|
+
return {
|
|
114
|
+
...state,
|
|
115
|
+
scope,
|
|
116
|
+
instance: keepsInstance ? state.instance : { kind: 'default' },
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function toggleHas(
|
|
121
|
+
state: SchemasFilterState,
|
|
122
|
+
flag: HasFlag,
|
|
123
|
+
): SchemasFilterState {
|
|
124
|
+
const has = state.has.includes(flag)
|
|
125
|
+
? state.has.filter((entry) => entry !== flag)
|
|
126
|
+
: [...state.has, flag]
|
|
127
|
+
return { ...state, has: HAS_FLAGS.filter((entry) => has.includes(entry)) }
|
|
128
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { computed, ref, watch } from 'vue'
|
|
2
|
+
import {
|
|
3
|
+
readFilterState,
|
|
4
|
+
sourceQuery,
|
|
5
|
+
type SchemasFilterState,
|
|
6
|
+
} from './filterState'
|
|
7
|
+
|
|
8
|
+
// What the Schemas page's filter bar counts: the tables its state (read from
|
|
9
|
+
// the URL) lists, through the table view's own source route, and the tables
|
|
10
|
+
// registered before any filter ("12 of 40").
|
|
11
|
+
|
|
12
|
+
const TABLES_SOURCE = '/api/database/tables/source'
|
|
13
|
+
|
|
14
|
+
interface SourceResponse {
|
|
15
|
+
total: number
|
|
16
|
+
all?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type ListingStatus = 'idle' | 'pending' | 'success' | 'error'
|
|
20
|
+
|
|
21
|
+
export function useSchemasListing() {
|
|
22
|
+
const route = useDmsRoute()
|
|
23
|
+
const { $authFetch } = useAuthFetch()
|
|
24
|
+
const state = computed<SchemasFilterState>(() => readFilterState(route.query))
|
|
25
|
+
const query = computed(() => sourceQuery(state.value))
|
|
26
|
+
|
|
27
|
+
// The counts of the last answer stay while the next one loads.
|
|
28
|
+
const total = ref(0)
|
|
29
|
+
const all = ref<number | null>(null)
|
|
30
|
+
const status = ref<ListingStatus>('idle')
|
|
31
|
+
let latest = 0
|
|
32
|
+
|
|
33
|
+
async function load() {
|
|
34
|
+
const request = ++latest
|
|
35
|
+
status.value = 'pending'
|
|
36
|
+
try {
|
|
37
|
+
const response = await $authFetch<SourceResponse>(TABLES_SOURCE, {
|
|
38
|
+
query: query.value,
|
|
39
|
+
})
|
|
40
|
+
if (request !== latest) return
|
|
41
|
+
total.value = response.total
|
|
42
|
+
all.value = response.all ?? null
|
|
43
|
+
status.value = 'success'
|
|
44
|
+
} catch {
|
|
45
|
+
if (request === latest) status.value = 'error'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// In the browser only: the server renders no count.
|
|
50
|
+
if (typeof window !== 'undefined') {
|
|
51
|
+
watch(() => JSON.stringify(query.value), load, { immediate: true })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
state,
|
|
56
|
+
/** Tables the bar's filters list. */
|
|
57
|
+
total,
|
|
58
|
+
/** Registered tables before any filter; null until known. */
|
|
59
|
+
all,
|
|
60
|
+
status,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { InstanceChoice } from '../data/instanceOptions'
|
|
2
|
+
|
|
3
|
+
export type { InstanceChoice }
|
|
4
|
+
|
|
5
|
+
// Where the module's pages live. Module pages resolve under
|
|
6
|
+
// /modules/<module id>; a link without that prefix 404s.
|
|
7
|
+
const MODULE_ROOT = '/modules/database'
|
|
8
|
+
|
|
9
|
+
export const DATABASE_PAGES = {
|
|
10
|
+
overview: `${MODULE_ROOT}/overview`,
|
|
11
|
+
schemas: `${MODULE_ROOT}/schemas`,
|
|
12
|
+
diagram: `${MODULE_ROOT}/diagram`,
|
|
13
|
+
data: `${MODULE_ROOT}/data`,
|
|
14
|
+
query: `${MODULE_ROOT}/query`,
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
type DatabasePage = keyof typeof DATABASE_PAGES
|
|
18
|
+
|
|
19
|
+
/** A column value the data browser filters the table on when it opens. */
|
|
20
|
+
interface TableMatch {
|
|
21
|
+
field: string
|
|
22
|
+
value: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TableAddress {
|
|
26
|
+
schema: string
|
|
27
|
+
table: string
|
|
28
|
+
/**
|
|
29
|
+
* A named instance, none for the default one; a page link also takes
|
|
30
|
+
* ALL_INSTANCES_PARAM for every instance.
|
|
31
|
+
*/
|
|
32
|
+
instance?: string
|
|
33
|
+
match?: TableMatch
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MATCH_SEPARATOR = ':'
|
|
37
|
+
|
|
38
|
+
// The URL's `instance`, the same on every page of the module: absent for the
|
|
39
|
+
// default instance, `all` for every instance at once, any other value for
|
|
40
|
+
// the instance of that name. No escaping: an instance named "all" cannot be
|
|
41
|
+
// linked to.
|
|
42
|
+
|
|
43
|
+
/** The URL's `instance` for every instance at once. */
|
|
44
|
+
export const ALL_INSTANCES_PARAM = 'all'
|
|
45
|
+
|
|
46
|
+
/** The instance an `instance` URL parameter names; a missing one is the default. */
|
|
47
|
+
export function instanceFromUrl(param: unknown): InstanceChoice {
|
|
48
|
+
const first = Array.isArray(param) ? param[0] : param
|
|
49
|
+
const value = typeof first === 'string' ? first.trim() : ''
|
|
50
|
+
if (!value) return { kind: 'default' }
|
|
51
|
+
if (value === ALL_INSTANCES_PARAM) return { kind: 'all' }
|
|
52
|
+
return { kind: 'named', id: value }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The `instance` URL parameter of a choice; undefined (left out) for the default. */
|
|
56
|
+
export function instanceToUrl(choice: InstanceChoice): string | undefined {
|
|
57
|
+
if (choice.kind === 'default') return undefined
|
|
58
|
+
if (choice.kind === 'all') return ALL_INSTANCES_PARAM
|
|
59
|
+
return choice.id
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function encodeMatch(match: TableMatch): string {
|
|
63
|
+
return `${match.field}${MATCH_SEPARATOR}${match.value}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `field:value` back to its parts; the value may hold separators itself. */
|
|
67
|
+
export function decodeMatch(raw: unknown): TableMatch | null {
|
|
68
|
+
if (typeof raw !== 'string') return null
|
|
69
|
+
const index = raw.indexOf(MATCH_SEPARATOR)
|
|
70
|
+
if (index <= 0) return null
|
|
71
|
+
return { field: raw.slice(0, index), value: raw.slice(index + 1) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A page of the module opened on one table, e.g. the data browser on it. */
|
|
75
|
+
export function tableLink(page: DatabasePage, address: TableAddress): string {
|
|
76
|
+
const query = new URLSearchParams({
|
|
77
|
+
schema: address.schema,
|
|
78
|
+
table: address.table,
|
|
79
|
+
})
|
|
80
|
+
if (address.instance) query.set('instance', address.instance)
|
|
81
|
+
if (address.match) query.set('match', encodeMatch(address.match))
|
|
82
|
+
return `${DATABASE_PAGES[page]}?${query.toString()}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const IDENTIFIER = /^[A-Z_$][\w$]*$/i
|
|
86
|
+
|
|
87
|
+
/** `schemas.shop` or `schemas["dms-core"]`, as the query DSL reaches a schema. */
|
|
88
|
+
export function schemaAccess(schemaId: string): string {
|
|
89
|
+
return IDENTIFIER.test(schemaId)
|
|
90
|
+
? `schemas.${schemaId}`
|
|
91
|
+
: `schemas[${JSON.stringify(schemaId)}]`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The DSL reaching a table, for the query console's prefilled snippets. */
|
|
95
|
+
export function tableAccess(address: TableAddress): string {
|
|
96
|
+
const instance = address.instance ? JSON.stringify(address.instance) : ''
|
|
97
|
+
return `${schemaAccess(address.schema)}.instance(${instance}).table(${JSON.stringify(address.table)})`
|
|
98
|
+
}
|