This is an automated email from the ASF dual-hosted git repository.
qiuxiafan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
The following commit(s) were added to refs/heads/main by this push:
new 9c1e29c7 feat(ui): implement the prompt and highlight for BydbQL
(#840)
9c1e29c7 is described below
commit 9c1e29c7c52e13abf48e05e8e6de072de361a07a
Author: Fine0830 <[email protected]>
AuthorDate: Mon Nov 10 11:24:51 2025 +0800
feat(ui): implement the prompt and highlight for BydbQL (#840)
---
ui/src/components/BydbQL/Index.vue | 298 ++++++++++++++-
ui/src/components/CodeMirror/bydbql-hint.js | 557 ++++++++++++++++++++++++++++
ui/src/components/CodeMirror/bydbql-mode.js | 139 +++++++
ui/src/components/CodeMirror/index.vue | 123 +++++-
4 files changed, 1083 insertions(+), 34 deletions(-)
diff --git a/ui/src/components/BydbQL/Index.vue
b/ui/src/components/BydbQL/Index.vue
index 69d4dc0b..09f1588f 100644
--- a/ui/src/components/BydbQL/Index.vue
+++ b/ui/src/components/BydbQL/Index.vue
@@ -17,15 +17,23 @@
~ under the License.
-->
<script setup>
- import { ref, computed, onMounted, nextTick } from 'vue';
+ import { ref, computed, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
- import { executeBydbQLQuery } from '@/api/index';
+ import {
+ executeBydbQLQuery,
+ getGroupList,
+ getAllTypesOfResourceList,
+ getTopNAggregationList,
+ getindexRuleList,
+ getResourceOfAllType,
+ } from '@/api/index';
import CodeMirror from '@/components/CodeMirror/index.vue';
import TopNTable from '@/components/common/TopNTable.vue';
import MeasureAndStreamTable from
'@/components/common/MeasureAndStreamTable.vue';
import PropertyTable from '@/components/common/PropertyTable.vue';
import TraceTable from '@/components/common/TraceTable.vue';
- import { CatalogToGroupType } from '@/components/common/data';
+ import { CatalogToGroupType, GroupTypeToCatalog, SupportedIndexRuleTypes }
from '@/components/common/data';
+ import { updateSchemasAndGroups } from
'@/components/CodeMirror/bydbql-hint.js';
// Default query text with example queries as comments
const queryText = ref(`-- Example queries:
@@ -39,6 +47,7 @@ SELECT * FROM STREAM log in sw_recordsLog TIME > '-30m'`);
const loading = ref(false);
const error = ref(null);
const executionTime = ref(0);
+ const codeMirrorInstance = ref(null);
const hasResult = computed(() => queryResult.value !== null);
const resultType = computed(() => {
@@ -55,7 +64,6 @@ SELECT * FROM STREAM log in sw_recordsLog TIME > '-30m'`);
if (!queryResult.value) return [];
try {
- // Handle TopN results differently
if (queryResult.value.topnResult?.lists) {
const topnLists = queryResult.value.topnResult.lists;
const rows = topnLists
@@ -273,19 +281,273 @@ SELECT * FROM STREAM log in sw_recordsLog TIME >
'-30m'`);
error.value = null;
executionTime.value = 0;
}
- // Setup keyboard shortcuts for CodeMirror
- onMounted(() => {
- nextTick(() => {
- // Find the CodeMirror instance and add keyboard shortcuts
- const codeMirrorElement = document.querySelector('.query-input
.CodeMirror');
- if (codeMirrorElement && codeMirrorElement.CodeMirror) {
- const cm = codeMirrorElement.CodeMirror;
- cm.setOption('extraKeys', {
- 'Ctrl-Enter': executeQuery,
- 'Cmd-Enter': executeQuery,
- });
+
+ function onCodeMirrorReady(cm) {
+ codeMirrorInstance.value = cm;
+ const currentExtraKeys = cm.getOption('extraKeys') || {};
+ const mergedExtraKeys = {
+ ...currentExtraKeys,
+ 'Ctrl-Enter': executeQuery,
+ 'Cmd-Enter': executeQuery,
+ };
+ cm.setOption('extraKeys', mergedExtraKeys);
+ }
+
+ // Fetch groups and schemas for autocomplete
+ async function fetchSchemaData() {
+ try {
+ const groupResponse = await getGroupList();
+ if (groupResponse.error) {
+ console.error('Failed to fetch groups:', groupResponse.error);
+ return;
}
- });
+
+ // Only include groups with valid catalog types (property, stream,
trace, measure, topn)
+ const groups = (groupResponse.group || [])
+ .filter((g) => CatalogToGroupType[g.catalog])
+ .map((g) => g.metadata.name);
+ const schemaSets = {
+ stream: new Set(),
+ measure: new Set(),
+ trace: new Set(),
+ property: new Set(),
+ topn: new Set(),
+ };
+ const schemaGroupMap = {
+ stream: {},
+ measure: {},
+ trace: {},
+ property: {},
+ topn: {},
+ };
+ const schemaDetailSets = {
+ stream: {},
+ measure: {},
+ trace: {},
+ property: {},
+ topn: {},
+ };
+ const indexRuleSets = {};
+ const indexRuleGroupMap = {};
+ const indexRuleNameLookup = {};
+
+ const groupsData = groupResponse.group || [];
+
+ const collectTagNames = (schemaLike) => {
+ const tags = new Set();
+ if (!schemaLike) {
+ return tags;
+ }
+ const addTagName = (tagName) => {
+ if (typeof tagName === 'string' && tagName.trim().length > 0) {
+ tags.add(tagName);
+ }
+ };
+
+ const tagFamilies = schemaLike.tagFamilies || schemaLike.tag_families
|| [];
+ tagFamilies.forEach((family) => {
+ (family?.tags || []).forEach((tag) => {
+ addTagName(tag?.name || tag?.key || tag?.metadata?.name);
+ });
+ });
+
+ const explicitTags = schemaLike.tags || schemaLike.tagNames || [];
+ explicitTags.forEach((tag) => {
+ if (typeof tag === 'string') {
+ addTagName(tag);
+ } else {
+ addTagName(tag?.name || tag?.key || tag?.metadata?.name);
+ }
+ });
+
+ const entityTagNames = schemaLike.entity?.tagNames ||
schemaLike.entity?.tag_names || [];
+ entityTagNames.forEach(addTagName);
+
+ const shardingKeyTagNames = schemaLike.shardingKey?.tagNames ||
schemaLike.shardingKey?.tag_names || [];
+ shardingKeyTagNames.forEach(addTagName);
+
+ addTagName(schemaLike.traceIdTagName || schemaLike.trace_id_tag_name);
+ addTagName(schemaLike.spanIdTagName || schemaLike.span_id_tag_name);
+ addTagName(schemaLike.timestampTagName ||
schemaLike.timestamp_tag_name);
+
+ return tags;
+ };
+
+ const emitUpdate = () => {
+ const schemas =
Object.fromEntries(Object.entries(schemaSets).map(([key, value]) => [key,
[...value]]));
+ const schemaToGroups = Object.fromEntries(
+ Object.entries(schemaGroupMap).map(([type, map]) => [
+ type,
+ Object.fromEntries(
+ Object.entries(map).map(([schema, groupSet]) => [
+ schema,
+ [...groupSet].sort((a, b) => a.localeCompare(b)),
+ ]),
+ ),
+ ]),
+ );
+ const schemaDetails = Object.fromEntries(
+ Object.entries(schemaDetailSets).map(([type, schemaMap]) => [
+ type,
+ Object.fromEntries(
+ Object.entries(schemaMap).map(([schemaName, detailSets]) => [
+ schemaName,
+ {
+ tags: [...detailSets.tags].sort((a, b) =>
a.localeCompare(b)),
+ },
+ ]),
+ ),
+ ]),
+ );
+
+ const indexRuleSchemas = Object.fromEntries(
+ Object.entries(indexRuleSets).map(([type, set]) => [type,
[...set].sort((a, b) => a.localeCompare(b))]),
+ );
+ const indexRuleGroups = Object.fromEntries(
+ Object.entries(indexRuleGroupMap).map(([type, ruleMap]) => [
+ type,
+ Object.fromEntries(
+ Object.entries(ruleMap).map(([rule, groupSet]) => [
+ rule,
+ [...groupSet].sort((a, b) => a.localeCompare(b)),
+ ]),
+ ),
+ ]),
+ );
+
+ updateSchemasAndGroups(groups, schemas, schemaToGroups, {
+ indexRuleSchemas,
+ indexRuleGroups,
+ indexRuleNameLookup,
+ schemaDetails,
+ });
+ };
+
+ const processGroup = async (group) => {
+ const groupName = group.metadata.name;
+ const catalog = group.catalog;
+ const type = CatalogToGroupType[catalog];
+
+ if (!type) {
+ return;
+ }
+
+ try {
+ const schemaResponse = await getAllTypesOfResourceList(type,
groupName);
+ if (!schemaResponse.error) {
+ const schemaList = schemaResponse[type ===
CatalogToGroupType.CATALOG_PROPERTY ? 'properties' : type] || [];
+ for (const schema of schemaList) {
+ const name = schema?.metadata?.name;
+ if (!name) {
+ continue;
+ }
+ const lowerName = name.toLowerCase();
+ schemaSets[type].add(name);
+ if (!schemaGroupMap[type][lowerName]) {
+ schemaGroupMap[type][lowerName] = new Set();
+ }
+ schemaGroupMap[type][lowerName].add(groupName);
+
+ if (!schemaDetailSets[type][lowerName]) {
+ schemaDetailSets[type][lowerName] = {
+ tags: new Set(),
+ };
+ }
+
+ const detailEntry = schemaDetailSets[type][lowerName];
+ collectTagNames(schema).forEach((tag) =>
detailEntry.tags.add(tag));
+
+ if (
+ (type === CatalogToGroupType.CATALOG_PROPERTY || type ===
CatalogToGroupType.CATALOG_TRACE) &&
+ detailEntry.tags.size === 0
+ ) {
+ try {
+ const detailResponse = await getResourceOfAllType(type,
groupName, name);
+ const detailSchema =
+ (detailResponse && typeof detailResponse === 'object' ?
detailResponse?.[type] : null) || null;
+ collectTagNames(detailSchema).forEach((tag) =>
detailEntry.tags.add(tag));
+ } catch (err) {
+ console.error(`Failed to fetch ${type} schema detail for
${name} in group ${groupName}:`, err);
+ }
+ }
+ }
+ }
+ } catch (e) {
+ console.error(`Failed to fetch ${type} schemas for group
${groupName}:`, e);
+ }
+ const normalizedType = typeof type === 'string' ? type.toLowerCase() :
type;
+ if (SupportedIndexRuleTypes.includes(normalizedType)) {
+ if (!indexRuleSets[normalizedType]) {
+ indexRuleSets[normalizedType] = new Set();
+ }
+ if (!indexRuleGroupMap[normalizedType]) {
+ indexRuleGroupMap[normalizedType] = {};
+ }
+ if (!indexRuleNameLookup[normalizedType]) {
+ indexRuleNameLookup[normalizedType] = {};
+ }
+
+ try {
+ const indexRuleResponse = await getindexRuleList(groupName);
+ if (!indexRuleResponse.error) {
+ (indexRuleResponse.indexRule || [])
+ .map((s) => s.metadata?.name)
+ .filter((s) => Boolean(s) && !s.noSort)
+ .forEach((name) => {
+ const lowerName = name.toLowerCase();
+ indexRuleSets[normalizedType].add(name);
+ indexRuleNameLookup[normalizedType][lowerName] = name;
+ if (!indexRuleGroupMap[normalizedType][lowerName]) {
+ indexRuleGroupMap[normalizedType][lowerName] = new Set();
+ }
+ indexRuleGroupMap[normalizedType][lowerName].add(groupName);
+ });
+ }
+ } catch (e) {
+ console.error(`Failed to fetch index rule schemas for group
${groupName}:`, e);
+ }
+ }
+
+ if (catalog === GroupTypeToCatalog.measure) {
+ try {
+ const topnResponse = await getTopNAggregationList(groupName);
+ if (!topnResponse.error) {
+ (topnResponse.topNAggregation || [])
+ .map((s) => s.metadata?.name)
+ .filter(Boolean)
+ .forEach((name) => {
+ const lowerName = name.toLowerCase();
+ schemaSets.topn.add(name);
+ if (!schemaGroupMap.topn[lowerName]) {
+ schemaGroupMap.topn[lowerName] = new Set();
+ }
+ schemaGroupMap.topn[lowerName].add(groupName);
+ });
+ }
+ } catch (e) {
+ console.error(`Failed to fetch topn schemas for group
${groupName}:`, e);
+ }
+ }
+
+ emitUpdate();
+ };
+
+ emitUpdate();
+
+ groupsData.forEach((group) => {
+ processGroup(group).catch((e) => {
+ const groupName = group?.metadata?.name || 'unknown';
+ console.error(`Failed to process group ${groupName}:`, e);
+ });
+ });
+ } catch (e) {
+ console.error('Failed to fetch schema data:', e);
+ }
+ }
+
+ // Setup on mount
+ onMounted(() => {
+ fetchSchemaData();
});
</script>
@@ -304,12 +566,14 @@ SELECT * FROM STREAM log in sw_recordsLog TIME > '-30m'`);
<div class="query-input-container">
<CodeMirror
v-model="queryText"
- :mode="'sql'"
+ :mode="'bydbql'"
:lint="false"
:readonly="false"
:style-active-line="true"
:auto-refresh="true"
+ :enable-hint="true"
class="query-input"
+ @ready="onCodeMirrorReady"
/>
</div>
</el-card>
diff --git a/ui/src/components/CodeMirror/bydbql-hint.js
b/ui/src/components/CodeMirror/bydbql-hint.js
new file mode 100644
index 00000000..a33e8d7a
--- /dev/null
+++ b/ui/src/components/CodeMirror/bydbql-hint.js
@@ -0,0 +1,557 @@
+/*
+ * Licensed to Apache Software Foundation (ASF) under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Apache Software Foundation (ASF) licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import CodeMirror from 'codemirror';
+import { SupportedIndexRuleTypes } from '../common/data';
+
+// BydbQL keywords
+const BYDBQL_KEYWORDS = [
+ 'SELECT',
+ 'FROM',
+ 'WHERE',
+ 'ORDER BY',
+ 'LIMIT',
+ 'OFFSET',
+ 'AND',
+ 'OR',
+ 'NOT',
+ 'IN',
+ 'LIKE',
+ 'BETWEEN',
+ 'IS',
+ 'NULL',
+ 'TRUE',
+ 'FALSE',
+ 'AS',
+ 'DISTINCT',
+ 'GROUP BY',
+ 'HAVING',
+ 'TIME',
+ 'ASC',
+ 'DESC',
+ 'SHOW',
+ 'TOP',
+ 'UNION',
+ 'INTERSECT',
+ 'EXCEPT',
+ 'AGGREGATE BY',
+ 'WITH QUERY_TRACE',
+ 'ON',
+ 'STAGES',
+ 'GROUP',
+ 'BY',
+ 'MATCH',
+ 'SUM',
+ 'MEAN',
+ 'AVG',
+ 'COUNT',
+ 'MAX',
+ 'MIN',
+ 'TAG',
+];
+
+// BydbQL entity types
+const ENTITY_TYPES = ['STREAM', 'MEASURE', 'TRACE', 'PROPERTY', 'TOPN'];
+let schemasAndGroups = {
+ groups: [],
+ schemas: {},
+ schemaToGroups: {},
+ indexRulesByType: {},
+ indexRulesByGroup: {},
+ schemaDetails: {},
+ typeProjections: {},
+ globalProjections: { tags: [] },
+};
+
+export function updateSchemasAndGroups(groups, schemas, schemaToGroups,
indexRuleData = {}) {
+ schemasAndGroups.groups = groups || [];
+ schemasAndGroups.schemas = schemas || {};
+ schemasAndGroups.schemaToGroups = schemaToGroups || {};
+
+ const { indexRuleSchemas = {}, indexRuleGroups = {}, indexRuleNameLookup =
{}, schemaDetails = {} } = indexRuleData;
+
+ const indexRulesByType = {};
+ for (const [type, rules] of Object.entries(indexRuleSchemas)) {
+ const normalizedType = typeof type === 'string' ? type.toLowerCase() :
type;
+ const uniqueRules = Array.from(new Set((rules || []).filter((rule) =>
typeof rule === 'string')));
+ indexRulesByType[normalizedType] = uniqueRules.sort((a, b) =>
a.localeCompare(b));
+ }
+
+ const indexRulesByGroup = {};
+ for (const [type, ruleMap] of Object.entries(indexRuleGroups)) {
+ const normalizedType = typeof type === 'string' ? type.toLowerCase() :
type;
+ const typeNameLookup = indexRuleNameLookup?.[type] ||
indexRuleNameLookup?.[normalizedType] || {};
+ const groupRuleSets = {};
+
+ for (const [ruleKey, groupList] of Object.entries(ruleMap || {})) {
+ const displayName = typeNameLookup[ruleKey] || ruleKey;
+ (groupList || []).forEach((group) => {
+ const normalizedGroup = typeof group === 'string' ?
group.toLowerCase() : group;
+ if (!normalizedGroup) {
+ return;
+ }
+ if (!groupRuleSets[normalizedGroup]) {
+ groupRuleSets[normalizedGroup] = new Set();
+ }
+ groupRuleSets[normalizedGroup].add(displayName);
+ });
+ }
+
+ indexRulesByGroup[normalizedType] = Object.fromEntries(
+ Object.entries(groupRuleSets).map(([groupKey, ruleSet]) => [
+ groupKey,
+ [...ruleSet].sort((a, b) => a.localeCompare(b)),
+ ]),
+ );
+ }
+
+ schemasAndGroups.indexRulesByType = indexRulesByType;
+ schemasAndGroups.indexRulesByGroup = indexRulesByGroup;
+
+ const normalizedSchemaDetails = {};
+ const typeProjections = {};
+ const globalTagSet = new Set();
+
+ for (const [typeKey, schemaMap] of Object.entries(schemaDetails || {})) {
+ const normalizedType = typeof typeKey === 'string' ? typeKey.toLowerCase()
: typeKey;
+ const normalizedSchemaMap = {};
+ const typeTagSet = new Set();
+
+ for (const [schemaName, detail] of Object.entries(schemaMap || {})) {
+ const normalizedSchemaName = typeof schemaName === 'string' ?
schemaName.toLowerCase() : schemaName;
+ const tagList = Array.from(
+ new Set((detail?.tags || []).filter((tag) => typeof tag === 'string'
&& tag.trim().length > 0)),
+ ).sort((a, b) => a.localeCompare(b));
+ normalizedSchemaMap[normalizedSchemaName] = {
+ tags: tagList,
+ };
+
+ tagList.forEach((tag) => {
+ typeTagSet.add(tag);
+ globalTagSet.add(tag);
+ });
+ }
+
+ normalizedSchemaDetails[normalizedType] = normalizedSchemaMap;
+ typeProjections[normalizedType] = {
+ tags: Array.from(typeTagSet).sort((a, b) => a.localeCompare(b)),
+ };
+ }
+
+ schemasAndGroups.schemaDetails = normalizedSchemaDetails;
+ schemasAndGroups.typeProjections = typeProjections;
+ schemasAndGroups.globalProjections = {
+ tags: Array.from(globalTagSet).sort((a, b) => a.localeCompare(b)),
+ };
+}
+
+function getWordAt(cm, pos) {
+ const line = cm.getLine(pos.line);
+
+ let wordStart = pos.ch;
+ let wordEnd = pos.ch;
+ // Find word boundaries
+ while (wordStart > 0 && /\w/.test(line.charAt(wordStart - 1))) {
+ wordStart--;
+ }
+ while (wordEnd < line.length && /\w/.test(line.charAt(wordEnd))) {
+ wordEnd++;
+ }
+
+ return {
+ word: line.slice(wordStart, wordEnd),
+ start: wordStart,
+ end: wordEnd,
+ };
+}
+
+function extractFromClauseContext(text) {
+ const fromClauseRegex =
+
/\bFROM\s+(STREAM|MEASURE|TRACE|PROPERTY|TOPN)\s+(\w+)(?:\s+IN\s+([\s\S]*?)(?=\bWHERE\b|\bORDER\b|\bGROUP\b|\bTIME\b|\bLIMIT\b|\bOFFSET\b|\bWITH\b|\bHAVING\b|\bON\b|$))?/gi;
+ let match;
+ let lastContext = null;
+
+ while ((match = fromClauseRegex.exec(text)) !== null) {
+ const entityType = match[1]?.toLowerCase();
+ const schemaName = match[2];
+ const rawGroups = match[3] || '';
+
+ const groups = rawGroups
+ .replace(/[()]/g, ' ')
+ .split(',')
+ .map((group) => group.trim())
+ .filter(Boolean);
+
+ lastContext = {
+ entityType,
+ schemaName,
+ groups,
+ };
+ }
+
+ return lastContext;
+}
+
+function findLastKeywordMatch(text, keyword) {
+ if (!text) {
+ return null;
+ }
+ const regex = new RegExp(`\\b${keyword}\\b`, 'gi');
+ let match;
+ let lastMatch = null;
+ while ((match = regex.exec(text)) !== null) {
+ lastMatch = match;
+ }
+ return lastMatch;
+}
+
+function detectSelectClauseContext(cm, textBeforeCursor) {
+ const selectMatch = findLastKeywordMatch(textBeforeCursor, 'SELECT');
+ if (!selectMatch) {
+ return null;
+ }
+
+ const betweenSelectAndCursor = textBeforeCursor.slice(selectMatch.index +
selectMatch[0].length);
+ if (/\bFROM\b/i.test(betweenSelectAndCursor)) {
+ return null;
+ }
+
+ const fullText = cm.getValue();
+ const textFromSelect = fullText.slice(selectMatch.index);
+ const fromClauseRegex =
+
/\bFROM\s+(STREAM|MEASURE|TRACE|PROPERTY|TOPN)\s+(\w+)(?:\s+IN\s+([\s\S]*?)(?=\bWHERE\b|\bORDER\b|\bGROUP\b|\bTIME\b|\bLIMIT\b|\bOFFSET\b|\bWITH\b|\bHAVING\b|\bON\b|$))?/i;
+ const fromMatch = fromClauseRegex.exec(textFromSelect);
+
+ if (!fromMatch) {
+ return { type: 'select_projection' };
+ }
+
+ const entityType = fromMatch[1]?.toLowerCase() || null;
+ const schemaName = fromMatch[2] || null;
+ const rawGroups = fromMatch[3] || '';
+ const groups = rawGroups
+ .replace(/[()]/g, ' ')
+ .split(',')
+ .map((group) => group.trim())
+ .filter(Boolean);
+
+ return {
+ type: 'select_projection',
+ entityType,
+ schemaName,
+ groups,
+ };
+}
+
+function getQueryContext(cm, cursor) {
+ const textBeforeCursor = cm.getRange({ line: 0, ch: 0 }, cursor);
+
+ const selectClauseContext = detectSelectClauseContext(cm, textBeforeCursor);
+ if (selectClauseContext) {
+ return selectClauseContext;
+ }
+
+ if (!textBeforeCursor.trim()) {
+ return { type: 'empty_line' };
+ }
+
+ const lastNewlineIndex = textBeforeCursor.lastIndexOf('\n');
+ if (lastNewlineIndex !== -1) {
+ const lastLine = textBeforeCursor.slice(lastNewlineIndex + 1);
+ if (!lastLine.trim()) {
+ return { type: 'empty_line' };
+ }
+ }
+
+ const orderByRegex = /\bORDER\s+BY\b[\s\w.,-]*$/i;
+ const orderByMatch = orderByRegex.exec(textBeforeCursor);
+ if (orderByMatch) {
+ const textBeforeOrderBy = textBeforeCursor.slice(0, orderByMatch.index);
+ const fromContext = extractFromClauseContext(textBeforeOrderBy);
+ if (fromContext &&
SupportedIndexRuleTypes.includes(fromContext.entityType)) {
+ return {
+ type: 'entity_order_by',
+ entityType: fromContext.entityType,
+ groupNames: fromContext.groups || [],
+ };
+ }
+ return { type: 'order_by' };
+ }
+
+ // Check if we're typing after 'in' (group name context)
+ const inMatch =
textBeforeCursor.match(/\bFROM\s+(STREAM|MEASURE|TRACE|PROPERTY|TOPN)\s+(\w+)\s+in\s+(\w*)$/i);
+ if (inMatch) {
+ return { type: 'group', entityType: inMatch[1].toLowerCase(), schemaName:
inMatch[2] };
+ }
+
+ // Check if we're typing after schema name (expecting 'in')
+ const schemaMatch =
textBeforeCursor.match(/\bFROM\s+(STREAM|MEASURE|TRACE|PROPERTY|TOPN)\s+(\w+)\s+(\w*)$/i);
+ if (schemaMatch) {
+ return { type: 'in_keyword' };
+ }
+
+ // Check if we're typing after entity type (schema name context)
+ const entityMatch =
textBeforeCursor.match(/\bFROM\s+(STREAM|MEASURE|TRACE|PROPERTY|TOPN)\s+(\w*)$/i);
+ if (entityMatch) {
+ return { type: 'schema', entityType: entityMatch[1].toLowerCase() };
+ }
+
+ // Check if we're typing after FROM (entity type context)
+ if (/\bFROM\s+(\w*)$/i.test(textBeforeCursor)) {
+ return { type: 'entity_type' };
+ }
+
+ // Default: suggest keywords
+ return { type: 'keyword' };
+}
+
+// Generate hint list based on context
+function generateHints(context, word) {
+ const hints = [];
+ const lowerWord = word ? word.toLowerCase() : '';
+
+ switch (context.type) {
+ case 'empty_line':
+ for (const keyword of BYDBQL_KEYWORDS) {
+ if (!lowerWord || keyword.toLowerCase().startsWith(lowerWord)) {
+ hints.push({
+ text: keyword,
+ displayText: keyword,
+ className: 'bydbql-hint-keyword',
+ });
+ }
+ }
+ break;
+
+ case 'select_projection': {
+ const seen = new Set();
+ let projectionMatches = 0;
+ const pushHint = (text, displayText, className, track = false) => {
+ if (!text) {
+ return;
+ }
+ const normalized = text.toLowerCase();
+ if (lowerWord && !normalized.startsWith(lowerWord)) {
+ return;
+ }
+ if (seen.has(normalized)) {
+ return;
+ }
+ seen.add(normalized);
+ hints.push({
+ text,
+ displayText: displayText || text,
+ className,
+ });
+ if (track) {
+ projectionMatches += 1;
+ }
+ };
+
+ pushHint('*', '*', 'bydbql-hint-keyword');
+
+ const normalizedType = context.entityType ?
context.entityType.toLowerCase() : null;
+ const normalizedSchema = context.schemaName ?
context.schemaName.toLowerCase() : null;
+ const schemaMeta =
+ normalizedType && normalizedSchema
+ ?
schemasAndGroups.schemaDetails?.[normalizedType]?.[normalizedSchema]
+ : null;
+ const typeMeta = normalizedType ?
schemasAndGroups.typeProjections?.[normalizedType] : null;
+ const globalMeta = schemasAndGroups.globalProjections || { tags: [] };
+
+ const tagCandidates = schemaMeta?.tags?.length
+ ? schemaMeta.tags
+ : typeMeta?.tags?.length
+ ? typeMeta.tags
+ : globalMeta.tags;
+ tagCandidates.forEach((tag) => pushHint(tag, `${tag}`,
'bydbql-hint-tag', true));
+
+ if (projectionMatches === 0 && hints.length === 0) {
+ for (const keyword of BYDBQL_KEYWORDS) {
+ if (!lowerWord || keyword.toLowerCase().startsWith(lowerWord)) {
+ hints.push({
+ text: keyword,
+ displayText: keyword,
+ className: 'bydbql-hint-keyword',
+ });
+ }
+ }
+ }
+
+ break;
+ }
+
+ case 'entity_type':
+ // Suggest entity types (STREAM, MEASURE, etc.)
+ for (const type of ENTITY_TYPES) {
+ if (!lowerWord || type.toLowerCase().startsWith(lowerWord)) {
+ hints.push({
+ text: type,
+ displayText: type,
+ className: 'bydbql-hint-entity-type',
+ });
+ }
+ }
+ break;
+
+ case 'schema':
+ // Suggest schema names for the given entity type
+ const schemas = schemasAndGroups.schemas[context.entityType] || [];
+ for (const schema of schemas) {
+ if (!lowerWord || schema.toLowerCase().startsWith(lowerWord)) {
+ hints.push({
+ text: schema,
+ displayText: schema,
+ className: 'bydbql-hint-schema',
+ });
+ }
+ }
+ break;
+
+ case 'in_keyword':
+ // Suggest 'in' keyword
+ if (!lowerWord || 'in'.startsWith(lowerWord)) {
+ hints.push({
+ text: 'in',
+ displayText: 'in',
+ className: 'bydbql-hint-keyword',
+ });
+ }
+ break;
+
+ case 'group': {
+ const schemaKey = context.schemaName ? context.schemaName.toLowerCase()
: '';
+ const schemaGroupsMap =
schemasAndGroups.schemaToGroups[context.entityType] || {};
+ const relatedGroups = schemaKey ? schemaGroupsMap[schemaKey] || [] : [];
+ const targetGroups = relatedGroups.length > 0 ? relatedGroups :
schemasAndGroups.groups;
+
+ for (const group of targetGroups) {
+ if (!lowerWord || group.toLowerCase().startsWith(lowerWord)) {
+ hints.push({
+ text: group,
+ displayText: group,
+ className: 'bydbql-hint-group',
+ });
+ }
+ }
+ break;
+ }
+
+ case 'entity_order_by': {
+ const entityType = context.entityType || '';
+ const normalizedGroups = (context.groupNames || []).map((group) =>
group.toLowerCase());
+ const indexRulesByGroup =
schemasAndGroups.indexRulesByGroup?.[entityType] || {};
+ const aggregatedRuleSet = new Set();
+
+ for (const group of normalizedGroups) {
+ const rules = indexRulesByGroup[group] || [];
+ for (const rule of rules) {
+ aggregatedRuleSet.add(rule);
+ }
+ }
+
+ const fallbackRules = schemasAndGroups.indexRulesByType?.[entityType] ||
[];
+ const candidates =
+ aggregatedRuleSet.size > 0 ? [...aggregatedRuleSet].sort((a, b) =>
a.localeCompare(b)) : fallbackRules.slice();
+
+ for (const rule of candidates) {
+ if (!lowerWord || rule.toLowerCase().startsWith(lowerWord)) {
+ hints.push({
+ text: rule,
+ displayText: rule,
+ className: 'bydbql-hint-index-rule',
+ });
+ }
+ }
+
+ if (!lowerWord || 'ASC'.toLowerCase().startsWith(lowerWord)) {
+ hints.push({
+ text: 'ASC',
+ displayText: 'ASC',
+ className: 'bydbql-hint-keyword',
+ });
+ }
+ if (!lowerWord || 'DESC'.toLowerCase().startsWith(lowerWord)) {
+ hints.push({
+ text: 'DESC',
+ displayText: 'DESC',
+ className: 'bydbql-hint-keyword',
+ });
+ }
+ break;
+ }
+
+ case 'order_by': {
+ const orderKeywords = ['ASC', 'DESC'];
+ for (const keyword of orderKeywords) {
+ if (!lowerWord || keyword.toLowerCase().startsWith(lowerWord)) {
+ hints.push({
+ text: keyword,
+ displayText: keyword,
+ className: 'bydbql-hint-keyword',
+ });
+ }
+ }
+ break;
+ }
+
+ case 'keyword':
+ default:
+ for (const keyword of BYDBQL_KEYWORDS) {
+ if (!lowerWord || keyword.toLowerCase().startsWith(lowerWord)) {
+ hints.push({
+ text: keyword,
+ displayText: keyword,
+ className: 'bydbql-hint-keyword',
+ });
+ }
+ }
+
+ for (const type of ENTITY_TYPES) {
+ if (!lowerWord || type.toLowerCase().startsWith(lowerWord)) {
+ hints.push({
+ text: type,
+ displayText: type,
+ className: 'bydbql-hint-entity-type',
+ });
+ }
+ }
+ break;
+ }
+
+ return hints;
+}
+
+// Main hint function
+CodeMirror.registerHelper('hint', 'bydbql', function (cm) {
+ const cursor = cm.getCursor();
+ const { word, start, end } = getWordAt(cm, cursor);
+ const context = getQueryContext(cm, cursor);
+ const hints = generateHints(context, word);
+
+ if (hints.length === 0) {
+ return null;
+ }
+
+ return {
+ list: hints,
+ from: CodeMirror.Pos(cursor.line, start),
+ to: CodeMirror.Pos(cursor.line, end),
+ };
+});
diff --git a/ui/src/components/CodeMirror/bydbql-mode.js
b/ui/src/components/CodeMirror/bydbql-mode.js
new file mode 100644
index 00000000..eb125d3d
--- /dev/null
+++ b/ui/src/components/CodeMirror/bydbql-mode.js
@@ -0,0 +1,139 @@
+/*
+ * Licensed to Apache Software Foundation (ASF) under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Apache Software Foundation (ASF) licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import CodeMirror from 'codemirror';
+
+// Define BydbQL mode extending SQL
+CodeMirror.defineMode('bydbql', function (config) {
+ const sqlMode = CodeMirror.getMode(config, 'text/x-sql');
+ if (!sqlMode || typeof sqlMode.token !== 'function') {
+ throw new Error(
+ "CodeMirror SQL mode ('text/x-sql') must be loaded before defining the
BydbQL mode. Please ensure the SQL mode is imported first.",
+ );
+ }
+
+ const entityTypes = {
+ STREAM: true,
+ MEASURE: true,
+ TRACE: true,
+ PROPERTY: true,
+ TOPN: true,
+ };
+
+ // BydbQL-specific keywords
+ const bydbqlKeywords = {
+ SELECT: true,
+ FROM: true,
+ WHERE: true,
+ ORDER: true,
+ BY: true,
+ ASC: true,
+ DESC: true,
+ LIMIT: true,
+ OFFSET: true,
+ AND: true,
+ OR: true,
+ NOT: true,
+ IN: true,
+ LIKE: true,
+ BETWEEN: true,
+ IS: true,
+ NULL: true,
+ TRUE: true,
+ FALSE: true,
+ AS: true,
+ DISTINCT: true,
+ ALL: true,
+ ANY: true,
+ SOME: true,
+ EXISTS: true,
+ CASE: true,
+ WHEN: true,
+ THEN: true,
+ ELSE: true,
+ END: true,
+ UNION: true,
+ INTERSECT: true,
+ EXCEPT: true,
+ GROUP: true,
+ HAVING: true,
+ TIME: true,
+ SHOW: true,
+ TOP: true,
+ AGGREGATE: true,
+ MATCH: true,
+ SUM: true,
+ MEAN: true,
+ AVG: true,
+ COUNT: true,
+ MAX: true,
+ MIN: true,
+ TAG: true,
+ };
+
+ const isWord = (value) => /^[A-Za-z_]\w*$/.test(value);
+
+ return {
+ startState: function () {
+ return {
+ sqlState: CodeMirror.startState(sqlMode),
+ };
+ },
+
+ copyState: function (state) {
+ return {
+ sqlState: CodeMirror.copyState(sqlMode, state.sqlState),
+ };
+ },
+
+ token: function (stream, state) {
+ const style = sqlMode.token(stream, state.sqlState);
+ if (style === 'comment' || style === 'string') {
+ return style;
+ }
+
+ const current = stream.current();
+ if (!isWord(current)) {
+ return style;
+ }
+
+ const upperWord = current.toUpperCase();
+ if (entityTypes[upperWord]) {
+ return 'entity-type';
+ }
+ if (bydbqlKeywords[upperWord]) {
+ return 'keyword';
+ }
+
+ return 'variable-2';
+ },
+
+ indent: function (state, textAfter) {
+ return sqlMode.indent ? sqlMode.indent(state.sqlState, textAfter) :
CodeMirror.Pass;
+ },
+
+ electricChars: sqlMode.electricChars,
+ blockCommentStart: '/*',
+ blockCommentEnd: '*/',
+ lineComment: '--',
+ };
+});
+
+// Set MIME type for BydbQL
+CodeMirror.defineMIME('text/x-bydbql', 'bydbql');
diff --git a/ui/src/components/CodeMirror/index.vue
b/ui/src/components/CodeMirror/index.vue
index 48403d9a..f06d0229 100644
--- a/ui/src/components/CodeMirror/index.vue
+++ b/ui/src/components/CodeMirror/index.vue
@@ -24,7 +24,7 @@
</template>
<script>
- import { onMounted, ref, watch } from 'vue';
+ import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import CodeMirror from 'codemirror';
import 'codemirror/lib/codemirror.css';
import 'codemirror/mode/yaml/yaml.js';
@@ -32,6 +32,8 @@
import 'codemirror/mode/css/css.js';
import 'codemirror/addon/lint/yaml-lint.js';
import 'codemirror/theme/dracula.css';
+ import './bydbql-mode.js';
+ import './bydbql-hint.js';
import jsYaml from 'js-yaml';
window.jsyaml = jsYaml;
export default {
@@ -65,61 +67,150 @@
type: Boolean,
default: true,
},
+ enableHint: {
+ type: Boolean,
+ default: false,
+ },
+ extraKeys: {
+ type: Object,
+ default: () => ({}),
+ },
},
- emits: ['update:modelValue'],
+ emits: ['update:modelValue', 'ready'],
setup(props, { emit }) {
const textarea = ref(null);
const code = ref(props.modelValue);
let coder;
+ let autocompleteTimeout;
+ let hintAddonLoaded = false;
+ const AUTOCOMPLETE_DELAY = 200;
+
watch(
() => props.modelValue,
(val) => {
- coder?.setValue(val);
+ const currentValue = coder?.getValue();
+ if (val !== currentValue) {
+ coder?.setValue(val);
+ }
},
);
+
+ // Get mode based on prop
+ const getModeString = () => {
+ if (props.mode === 'bydbql') {
+ return 'text/x-bydbql';
+ }
+ if (props.mode === 'yaml') {
+ return 'text/x-yaml';
+ }
+ if (props.mode === 'css') {
+ return 'text/css';
+ }
+ return 'text/x-yaml';
+ };
+
const options = {
- mode: 'text/x-yaml',
+ mode: getModeString(),
tabSize: 2,
theme: props.theme,
lineNumbers: true,
line: true,
readOnly: props.readonly,
lint: props.lint,
- gutters: ['CodeMirror-lint-markers'],
+ gutters: props.lint ? ['CodeMirror-lint-markers'] : [],
styleActiveLine: props.styleActiveLine,
autoRefresh: props.autoRefresh,
height: '500px',
+ extraKeys: props.extraKeys,
};
+
const initialize = async () => {
try {
- /* let theme = `codemirror/theme/${props.theme}.css`
- await import(theme) */
if (props.lint) {
await import('codemirror/addon/lint/lint.js');
await import('codemirror/addon/lint/lint.css');
}
- /* if (props.mode) {
- await import(`codemirror/mode/${props.mode}/${props.mode}.js`)
- } */
if (props.autoRefresh) {
await import('codemirror/addon/display/autorefresh');
}
if (props.styleActiveLine) {
await import('codemirror/addon/selection/active-line');
}
- } catch (e) {}
+ if (props.enableHint) {
+ await import('codemirror/addon/hint/show-hint.js');
+ await import('codemirror/addon/hint/show-hint.css');
+ hintAddonLoaded = true;
+ }
+ } catch (e) {
+ console.error('Error loading CodeMirror addons:', e);
+ }
+
coder = CodeMirror.fromTextArea(textarea.value, options);
+
coder.on('blur', (coder) => {
const newValue = coder.getValue();
emit('update:modelValue', newValue);
});
+
+ // Enable automatic autocomplete when typing (on keyup events)
+ if (props.enableHint && hintAddonLoaded) {
+ coder.on('keyup', (cm, event) => {
+ // Don't show hints for special keys
+ const excludedKeys = [
+ 8, // Backspace
+ 9, // Tab
+ 13, // Enter
+ 16,
+ 17,
+ 18, // Shift, Ctrl, Alt
+ 20, // Caps Lock
+ 27, // Escape
+ 33,
+ 34,
+ 35,
+ 36,
+ 37,
+ 38,
+ 39,
+ 40, // Page/Arrow keys
+ ];
+
+ if (excludedKeys.includes(event.keyCode)) {
+ return;
+ }
+
+ if (autocompleteTimeout) {
+ clearTimeout(autocompleteTimeout);
+ }
+
+ autocompleteTimeout = setTimeout(() => {
+ if (!cm.state.completionActive) {
+ CodeMirror.commands.autocomplete(cm, CodeMirror.hint.bydbql, {
completeSingle: false });
+ }
+ }, AUTOCOMPLETE_DELAY);
+ });
+ } else if (props.enableHint && !hintAddonLoaded) {
+ console.warn('CodeMirror hint addon failed to load; autocomplete is
disabled.');
+ }
+
+ // Emit ready event with coder instance
+ emit('ready', coder);
};
+
onMounted(() => {
initialize();
});
+
+ onBeforeUnmount(() => {
+ if (autocompleteTimeout) {
+ clearTimeout(autocompleteTimeout);
+ }
+ });
+
const checkYaml = async (val) => {
jsYaml.load(val);
};
+
return {
code,
options,
@@ -139,13 +230,11 @@
height: 100%;
width: 100%;
.CodeMirror-code {
- line-height: 19px;
+ line-height: 20px;
}
}
- }
-</style>
-<style>
- .CodeMirror-lint-tooltip {
- z-index: 10000 !important;
+ :deep(.cm-entity-type) {
+ color: #bd93f9;
+ }
}
</style>