Skip to content

Commit 026adc2

Browse files
committed
feat(sql-completion): add lazy metadata completion
1 parent 5dbb4de commit 026adc2

11 files changed

Lines changed: 616 additions & 92 deletions

File tree

ui/chen/api/index.ts

Lines changed: 97 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
1-
import type { ChenActionItem, ChenAuthResponse, ChenProfile, ChenSqlHints, ChenTreeNode } from "~/chen/types";
1+
import type {
2+
ChenActionItem,
3+
ChenAuthResponse,
4+
ChenProfile,
5+
ChenTreeNode
6+
} from "~/chen/types";
7+
import type {
8+
ChenQualifiedRelation,
9+
ChenRelationColumnsMetadata,
10+
ChenRelationMetadataPage,
11+
ChenSqlMetadataScope
12+
} from "~/chen/types/sqlMetadata";
213

314
const buildHeaders = (token?: string, init?: HeadersInit) => ({
415
...getWebApiHeaders(),
516
...(token ? { token } : {}),
617
...(init || {})
718
});
819

9-
export function chenPath(path: string, endpointUrl = window.location.origin) {
20+
export function chenPath(path: string, endpointUrl?: string) {
1021
const connectorPath = `/chen${path.startsWith("/") ? path : `/${path}`}`;
11-
const endpoint = new URL(endpointUrl || window.location.origin, window.location.origin);
22+
const currentOrigin = typeof window === "undefined" ? "http://localhost" : window.location.origin;
23+
const endpoint = new URL(endpointUrl || currentOrigin, currentOrigin);
1224

13-
if (endpoint.origin === window.location.origin) {
25+
if (endpoint.origin === currentOrigin) {
1426
return withWebSitePrefix(connectorPath);
1527
}
1628

@@ -76,35 +88,100 @@ export async function uploadChenSqlFile(
7688
return { path: result.path };
7789
}
7890

79-
export async function fetchChenSqlHints(
91+
export async function fetchChenSqlRelations(
8092
chenToken: string,
81-
nodeKey: string,
82-
context: string,
93+
scope: ChenSqlMetadataScope,
94+
prefix = "",
95+
limit = 100,
8396
fetchImpl: typeof fetch = fetch,
8497
endpointUrl?: string
85-
): Promise<ChenSqlHints> {
86-
const response = await fetchImpl(chenPath("/api/resources/hints", endpointUrl), {
98+
): Promise<ChenRelationMetadataPage> {
99+
const response = await fetchImpl(chenPath("/api/resources/metadata/relations", endpointUrl), {
87100
method: "POST",
88101
credentials: "include",
89102
headers: {
90103
...buildHeaders(chenToken, getWebApiMutationHeaders()),
91104
"Content-Type": "application/json"
92105
},
93-
body: JSON.stringify({ nodeKey, context })
106+
body: JSON.stringify({ ...scope, prefix, limit })
94107
});
95108
const result = await readJson<unknown>(response);
96-
if (!result || typeof result !== "object" || Array.isArray(result)) {
97-
throw new Error("Chen returned malformed SQL hints");
109+
if (!isRecord(result) || !Array.isArray(result.items) || typeof result.truncated !== "boolean") {
110+
throw new Error("Chen returned malformed SQL relation metadata");
98111
}
99112

100-
return Object.fromEntries(
101-
Object.entries(result)
102-
.filter(
103-
(entry): entry is [string, string[]] =>
104-
Array.isArray(entry[1]) && entry[1].every((column) => typeof column === "string")
105-
)
106-
.map(([table, columns]) => [table, [...columns]])
107-
);
113+
return {
114+
items: result.items.map(parseQualifiedRelation),
115+
truncated: result.truncated
116+
};
117+
}
118+
119+
export async function fetchChenSqlColumns(
120+
chenToken: string,
121+
scope: ChenSqlMetadataScope,
122+
relations: ChenQualifiedRelation[],
123+
fetchImpl: typeof fetch = fetch,
124+
endpointUrl?: string
125+
): Promise<ChenRelationColumnsMetadata[]> {
126+
const response = await fetchImpl(chenPath("/api/resources/metadata/columns", endpointUrl), {
127+
method: "POST",
128+
credentials: "include",
129+
headers: {
130+
...buildHeaders(chenToken, getWebApiMutationHeaders()),
131+
"Content-Type": "application/json"
132+
},
133+
body: JSON.stringify({ ...scope, relations })
134+
});
135+
const result = await readJson<unknown>(response);
136+
if (!isRecord(result) || !Array.isArray(result.items)) {
137+
throw new Error("Chen returned malformed SQL column metadata");
138+
}
139+
140+
return result.items.map((item) => {
141+
if (!isRecord(item) || !Array.isArray(item.columns)) {
142+
throw new Error("Chen returned malformed SQL column metadata");
143+
}
144+
return {
145+
relation: parseQualifiedRelation(item.relation),
146+
columns: item.columns.map((column) => {
147+
if (
148+
!isRecord(column) ||
149+
typeof column.name !== "string" ||
150+
!(typeof column.dataType === "string" || column.dataType === null) ||
151+
typeof column.nullable !== "boolean"
152+
) {
153+
throw new Error("Chen returned malformed SQL column metadata");
154+
}
155+
return {
156+
name: column.name,
157+
dataType: column.dataType,
158+
nullable: column.nullable
159+
};
160+
})
161+
};
162+
});
163+
}
164+
165+
function isRecord(value: unknown): value is Record<string, unknown> {
166+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
167+
}
168+
169+
function parseQualifiedRelation(value: unknown): ChenQualifiedRelation {
170+
if (
171+
!isRecord(value) ||
172+
!(typeof value.catalog === "string" || value.catalog === null) ||
173+
typeof value.schema !== "string" ||
174+
typeof value.name !== "string" ||
175+
!(value.kind === "table" || value.kind === "view")
176+
) {
177+
throw new Error("Chen returned malformed SQL relation metadata");
178+
}
179+
return {
180+
catalog: value.catalog,
181+
schema: value.schema,
182+
name: value.name,
183+
kind: value.kind
184+
};
108185
}
109186

110187
export function sanitizeChenExportFileName(value: string, fallback = "chen-export") {

ui/chen/components/QueryConsolePanel.vue

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
<script setup lang="ts">
22
import type { ChenSqlSnippet } from "~/chen/composables/useChenSqlSnippets";
33
import type { ChenDataViewAction, ChenDataViewActionData, ChenQueryConsoleTab, ChenQueryResultTab } from "~/chen/types";
4+
import type { ChenSqlMetadataStore } from "~/chen/utils/sqlMetadata";
45
56
import QueryResultTabs from "~/chen/components/QueryResultTabs.vue";
67
import ChenSqlEditor from "~/chen/components/SqlEditor.client.vue";
78
import SqlSnippetSaveDialog from "~/chen/components/SqlSnippetSaveDialog.vue";
89
import SqlSnippetSelectDialog from "~/chen/components/SqlSnippetSelectDialog.vue";
910
import { useChenSqlSnippets } from "~/chen/composables/useChenSqlSnippets";
11+
import { createChenCompletionSource } from "~/chen/utils/sqlCompletion";
12+
import { chenSqlDialect } from "~/chen/utils/sqlEditor";
1013
import { formatChenSql } from "~/chen/utils/sqlFormat";
1114
1215
const props = defineProps<{
1316
tab: ChenQueryConsoleTab;
1417
dbType: string;
1518
canCopy: boolean;
19+
metadataStore: ChenSqlMetadataStore;
1620
}>();
1721
1822
const emit = defineEmits<{
@@ -46,6 +50,14 @@ let messageCloseTimer: ReturnType<typeof setTimeout> | null = null;
4650
const toast = useToast();
4751
const { addErrorToast } = useErrorToast();
4852
const sqlSnippets = useChenSqlSnippets(() => props.dbType);
53+
const completionSource = createChenCompletionSource({
54+
store: props.metadataStore,
55+
scope: () => {
56+
const context = props.tab.state.currentContext || "";
57+
return context ? { nodeKey: props.tab.nodeKey, context } : null;
58+
},
59+
dialect: () => chenSqlDialect(props.dbType)
60+
});
4961
const queryBusy = computed(() => Boolean(props.tab.state.loading || props.tab.state.inQuery));
5062
const contextBusy = computed(() => Boolean(queryBusy.value || props.tab.state.editorLoading));
5163
const contextItems = computed(() =>
@@ -276,7 +288,7 @@ onBeforeUnmount(clearMessageTimer);
276288
v-model="statementValue"
277289
class="min-h-0 flex-1"
278290
:db-type="dbType"
279-
:hints="tab.sqlHints"
291+
:completion-source="completionSource"
280292
:read-only="Boolean(tab.state.loading || tab.state.editorLoading)"
281293
@selection-change="hasSelection = $event"
282294
@format="formatStatement"

ui/chen/components/SqlEditor.client.vue

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
<script setup lang="ts">
22
import type { Extension } from "@codemirror/state";
3-
import type { ChenSqlHints } from "~/chen/types";
4-
import { sql } from "@codemirror/lang-sql";
3+
import type { ChenSqlCompletionSource } from "~/chen/utils/sqlCompletion";
54
import { Compartment, EditorState, Prec } from "@codemirror/state";
65
import { EditorView, keymap } from "@codemirror/view";
76
import { basicSetup } from "codemirror";
8-
import { chenSqlConfig, replaceChenSqlDocument } from "~/chen/utils/sqlEditor";
7+
import { chenSqlExtensions, replaceChenSqlDocument } from "~/chen/utils/sqlEditor";
98
import { createCodeMirrorSyntaxTheme, createCodeMirrorTheme } from "~/shared/theme/adapters/codemirror";
109
1110
const props = withDefaults(
1211
defineProps<{
1312
modelValue: string;
1413
dbType?: string;
15-
hints?: ChenSqlHints;
14+
completionSource?: ChenSqlCompletionSource;
1615
readOnly?: boolean;
1716
}>(),
1817
{
1918
dbType: "",
20-
hints: () => ({}),
2119
readOnly: false
2220
}
2321
);
@@ -42,7 +40,7 @@ let applyingExternalValue = false;
4240
4341
const editorExtensions: Extension[] = [
4442
basicSetup,
45-
sqlLanguageSlot.of(sql(chenSqlConfig(props.dbType, props.hints))),
43+
sqlLanguageSlot.of(chenSqlExtensions(props.dbType, props.completionSource)),
4644
createCodeMirrorSyntaxTheme(),
4745
EditorView.lineWrapping,
4846
EditorState.tabSize.of(2),
@@ -144,9 +142,9 @@ watch(
144142
);
145143
146144
watch(
147-
() => [props.dbType, props.hints] as const,
148-
([dbType, hints]) => {
149-
editor?.dispatch({ effects: sqlLanguageSlot.reconfigure(sql(chenSqlConfig(dbType, hints))) });
145+
() => [props.dbType, props.completionSource] as const,
146+
([dbType, completionSource]) => {
147+
editor?.dispatch({ effects: sqlLanguageSlot.reconfigure(chenSqlExtensions(dbType, completionSource)) });
150148
}
151149
);
152150

ui/chen/composables/useChenSqlHints.ts

Lines changed: 0 additions & 26 deletions
This file was deleted.

ui/chen/composables/useChenWorkspaceTabs.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,6 @@ export function useChenWorkspaceTabs() {
5959
nodeKey,
6060
statement: "",
6161
uploadingSql: false,
62-
sqlHints: {},
63-
hintsContext: "",
64-
hintsLoading: false,
65-
hintsRequestGeneration: 0,
6662
state: {},
6763
logs: [],
6864
message: null,

ui/chen/types.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,6 @@ export interface ChenDataViewEditState {
169169

170170
export type ChenDataViewEditMode = "none" | "update" | "full";
171171

172-
export type ChenSqlHints = Record<string, string[]>;
173-
174172
export interface ChenConsoleState {
175173
loading?: boolean;
176174
inQuery?: boolean;
@@ -232,10 +230,6 @@ export interface ChenQueryConsoleTab extends ChenTabDefinition {
232230
kind: "query";
233231
statement: string;
234232
uploadingSql: boolean;
235-
sqlHints: ChenSqlHints;
236-
hintsContext: string;
237-
hintsLoading: boolean;
238-
hintsRequestGeneration: number;
239233
state: ChenConsoleState;
240234
logs: string[];
241235
message: ChenConsoleMessage | null;

ui/chen/types/sqlMetadata.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export interface ChenSqlMetadataScope {
2+
nodeKey: string;
3+
context: string;
4+
}
5+
6+
export interface ChenQualifiedRelation {
7+
catalog: string | null;
8+
schema: string;
9+
name: string;
10+
kind: "table" | "view";
11+
}
12+
13+
export interface ChenSqlColumnMetadata {
14+
name: string;
15+
dataType: string | null;
16+
nullable: boolean;
17+
}
18+
19+
export interface ChenRelationMetadataPage {
20+
items: ChenQualifiedRelation[];
21+
truncated: boolean;
22+
}
23+
24+
export interface ChenRelationColumnsMetadata {
25+
relation: ChenQualifiedRelation;
26+
columns: ChenSqlColumnMetadata[];
27+
}

0 commit comments

Comments
 (0)