diff --git a/package.json b/package.json index ee3ccfe1..2937460e 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,6 @@ ] }, "dependencies": { - "dt-sql-parser": "4.4.2" + "dt-sql-parser": "^4.5.0-beta.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ce50c9d..782b685f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: dt-sql-parser: - specifier: 4.4.2 - version: 4.4.2(antlr4ng-cli@1.0.7) + specifier: ^4.5.0-beta.1 + version: 4.5.0-beta.1(antlr4ng-cli@1.0.7) devDependencies: '@commitlint/cli': specifier: ^17.7.2 @@ -714,8 +714,8 @@ packages: resolution: {integrity: sha512-sCm11ak2oY6DglEPpCB8TixLjWAxd3kJTs6UIcSasNYxXdFPV+YKlye92c8H4kKFqV5qYMIh7d+cYecEg0dIkA==} engines: {node: '>=6'} - dt-sql-parser@4.4.2: - resolution: {integrity: sha512-NpfSeCetxDICs5NNOHKO/e/PTQ/EFnGW0QBo11e5nmuWqVRACZkArcBH9/LKnoe1Mdtql45U5h0aU9GOqhBLWg==} + dt-sql-parser@4.5.0-beta.1: + resolution: {integrity: sha512-1y7lLOF3SJJGh5LonzKwkIcWHxdWBNuhDT45rx5ZIT3KUyYkFDvRqJ/20sKeksQWs/r2/bIawW/4ICFOVluBAg==} engines: {node: '>=18'} eastasianwidth@0.2.0: @@ -2729,7 +2729,7 @@ snapshots: find-up: 3.0.0 minimatch: 3.1.2 - dt-sql-parser@4.4.2(antlr4ng-cli@1.0.7): + dt-sql-parser@4.5.0-beta.1(antlr4ng-cli@1.0.7): dependencies: antlr4-c3: 3.3.7(antlr4ng-cli@1.0.7) antlr4ng: 2.0.11(antlr4ng-cli@1.0.7) diff --git a/src/languageFeatures.ts b/src/languageFeatures.ts index d19a1960..d3877b37 100644 --- a/src/languageFeatures.ts +++ b/src/languageFeatures.ts @@ -255,7 +255,9 @@ export class DefinitionAdapter implements languages.Def startIndex: -1, endIndex: -1, startColumn: -1, - endColumn: -1 + endColumn: -1, + startTokenIndex: -1, + endTokenIndex: -1 }; const curEntity = entities?.find((entity: EntityContext) => { const entityPosition = entity.position; diff --git a/website/src/extensions/main/index.tsx b/website/src/extensions/main/index.tsx index 83682bf3..a554ea4e 100644 --- a/website/src/extensions/main/index.tsx +++ b/website/src/extensions/main/index.tsx @@ -1,37 +1,36 @@ -import { - IContributeType, - IEditorTab, - IExtension, - IMoleculeContext, - TabGroup -} from '@dtinsight/molecule'; - +import { ParseError } from 'dt-sql-parser'; import * as monaco from 'monaco-editor'; import { vsPlusTheme } from 'monaco-sql-languages/esm/main'; +import { LanguageService, type SerializedTreeNode } from 'monaco-sql-languages/esm/languageService'; -import Welcome from '@/workbench/welcome'; +import TreeVisualizerPanel from '@/components/treeVisualizerPanel'; import { - FILE_PATH, - QUICK_GITHUB, - PARSE_LANGUAGE, + ACTIVITY_API, ACTIVITY_FOLDER, ACTIVITY_SQL, - ACTIVITY_API, - SQL_LANGUAGES, - PARSE_TREE + FILE_PATH, + PARSE_LANGUAGE, + PARSE_TREE, + QUICK_GITHUB, + SQL_LANGUAGES } from '@/consts'; -import QuickGithub from '@/workbench/quickGithub'; -import SourceSpace from '@/workbench/sourceSpace'; -import UnitTest from '@/workbench/unitTest'; -import ApiDocPage from '@/workbench/apiDocPage'; import { debounce } from '@/utils/tool'; -import { LanguageService } from '../../../../esm/languageService'; -import { ParseError } from 'dt-sql-parser'; +import ApiDocPage from '@/workbench/apiDocPage'; import { ProblemsPaneView } from '@/workbench/problems'; import ProblemStore from '@/workbench/problems/clients/problemStore'; -import { ProblemsService } from '@/workbench/problems/services'; import { ProblemsController } from '@/workbench/problems/controllers'; -import TreeVisualizerPanel from '@/components/treeVisualizerPanel'; +import { ProblemsService } from '@/workbench/problems/services'; +import QuickGithub from '@/workbench/quickGithub'; +import SourceSpace from '@/workbench/sourceSpace'; +import UnitTest from '@/workbench/unitTest'; +import Welcome from '@/workbench/welcome'; +import { + IContributeType, + IEditorTab, + IExtension, + IMoleculeContext, + TabGroup +} from '@dtinsight/molecule'; const problemsService = new ProblemsService(); @@ -499,12 +498,14 @@ const updateParseTree = (molecule: IMoleculeContext, languageService: LanguageSe sql = ''; } - languageService.getSerializedParseTree(language, sql).then((tree) => { - molecule.panel.update({ - id: PARSE_TREE, - data: tree + languageService + .getSerializedParseTree(language, sql) + .then((tree: SerializedTreeNode | null) => { + molecule.panel.update({ + id: PARSE_TREE, + data: tree + }); }); - }); }; const debounceUpdateParseTree = debounce(updateParseTree, 400); diff --git a/website/src/languages/helpers/completionService.ts b/website/src/languages/helpers/completionService.ts index c060536c..e9922823 100644 --- a/website/src/languages/helpers/completionService.ts +++ b/website/src/languages/helpers/completionService.ts @@ -1,8 +1,31 @@ +import type { CommonEntityContext, Suggestions, WordRange } from 'dt-sql-parser'; +import { + AttrName, + ColumnDeclareType, + EntityContext, + TableDeclareType +} from 'dt-sql-parser/dist/parser/common/entityCollector'; import { languages } from 'monaco-editor/esm/vs/editor/editor.api'; -import { CompletionService, ICompletionItem } from 'monaco-sql-languages/esm/languageService'; -import { EntityContextType } from 'monaco-sql-languages/esm/main'; +import { EntityContextType, StmtContextType } from 'monaco-sql-languages/esm/main'; +import type { + CompletionService, + ICompletionItem +} from 'monaco-sql-languages/esm/monaco.contribution'; -import { getCatalogs, getDataBases, getSchemas, getTables, getViews } from './dbMetaProvider'; +import { + getCatalogs, + getColumns, + getDataBases, + getSchemas, + getTables, + getViews +} from './dbMetaProvider'; + +// Custom completion item interface, extending ICompletionItem to support additional properties +interface EnhancedCompletionItem extends ICompletionItem { + _tableName?: string; + _columnText?: string; +} const haveCatalogSQLType = (languageId: string) => { return ['flinksql', 'trinosql'].includes(languageId.toLowerCase()); @@ -12,180 +35,738 @@ const namedSchemaSQLType = (languageId: string) => { return ['trinosql', 'hivesql', 'sparksql'].includes(languageId); }; -export const completionService: CompletionService = async function ( - model, - _position, - _completionContext, - suggestions, - _entities, - snippets -) { - if (!suggestions) { - return Promise.resolve([]); +const isWordRangesEndWithWhiteSpace = (wordRanges: WordRange[]) => { + return wordRanges.length > 1 && wordRanges.at(-1)?.text === ' '; +}; + +// Completion tracker class, used to track already added completion types +class CompletionTracker { + private completionTypes = new Set(); + + hasCompletionType(type: string): boolean { + return this.completionTypes.has(type); } - const languageId = model.getLanguageId(); + + markAsCompleted(type: string): void { + this.completionTypes.add(type); + } +} + +/** + * Get database object completion items (catalog, database, table, etc.) + */ +const getDatabaseObjectCompletions = async ( + tracker: CompletionTracker, + languageId: string, + contextType: EntityContextType | StmtContextType, + words: string[] +): Promise => { const haveCatalog = haveCatalogSQLType(languageId); const getDBOrSchema = namedSchemaSQLType(languageId) ? getSchemas : getDataBases; + const wordCount = words.length; + const result: ICompletionItem[] = []; - const { keywords, syntax } = suggestions; + // Complete Catalog + if (wordCount <= 1 && haveCatalog && !tracker.hasCompletionType('catalog')) { + if ( + [EntityContextType.CATALOG, EntityContextType.DATABASE_CREATE].includes( + contextType as EntityContextType + ) + ) { + result.push(...(await getCatalogs(languageId))); + tracker.markAsCompleted('catalog'); + } + } - const keywordsCompletionItems: ICompletionItem[] = keywords.map((kw) => ({ - label: kw, - kind: languages.CompletionItemKind.Keyword, - detail: '关键字', - sortText: '2' + kw - })); + // Complete Database + if (wordCount <= 1 && !tracker.hasCompletionType('database')) { + if ( + [ + EntityContextType.DATABASE, + EntityContextType.TABLE, + EntityContextType.TABLE_CREATE, + EntityContextType.VIEW, + EntityContextType.VIEW_CREATE + ].includes(contextType as EntityContextType) + ) { + result.push(...(await getDBOrSchema(languageId))); + tracker.markAsCompleted('database'); + } + } - let syntaxCompletionItems: ICompletionItem[] = []; + // Complete Database under Catalog + if ( + wordCount >= 2 && + wordCount <= 3 && + haveCatalog && + !tracker.hasCompletionType('database_in_catalog') + ) { + if ( + [ + EntityContextType.DATABASE, + EntityContextType.TABLE, + EntityContextType.TABLE_CREATE, + EntityContextType.VIEW, + EntityContextType.VIEW_CREATE + ].includes(contextType as EntityContextType) + ) { + result.push(...(await getDBOrSchema(languageId, words[0]))); + tracker.markAsCompleted('database_in_catalog'); + } + } - /** 是否已经存在 catalog 补全项 */ - let existCatalogCompletions = false; - /** 是否已经存在 database 补全项 tmpDatabase */ - let existDatabaseCompletions = false; - /** 是否已经存在 database 补全项 */ - let existDatabaseInCatCompletions = false; - /** 是否已经存在 table 补全项 tmpTable */ - let existTableCompletions = false; - /** 是否已经存在 tableInDb 补全项 (cat.db.table) */ - let existTableInDbCompletions = false; - /** 是否已经存在 view 补全项 tmpDb */ - let existViewCompletions = false; - /** 是否已经存在 viewInDb 补全项 */ - let existViewInDbCompletions = false; + // Complete Table + if ( + contextType === EntityContextType.TABLE && + wordCount <= 1 && + !tracker.hasCompletionType('table') + ) { + result.push(...(await getTables(languageId))); + tracker.markAsCompleted('table'); + } - for (let i = 0; i < syntax.length; i++) { - const { syntaxContextType, wordRanges } = syntax[i]; + // Complete Tables under Database + if ( + contextType === EntityContextType.TABLE && + wordCount >= 2 && + wordCount <= 3 && + !tracker.hasCompletionType('table_in_database') + ) { + result.push(...(await getTables(languageId, undefined, words[0]))); + tracker.markAsCompleted('table_in_database'); + } - // e.g. words -> ['cat', '.', 'database', '.', 'table'] - const words = wordRanges.map((wr) => wr.text); - const wordCount = words.length; + // Complete Tables under Catalog.Database + if ( + contextType === EntityContextType.TABLE && + wordCount >= 4 && + wordCount <= 5 && + haveCatalog && + !tracker.hasCompletionType('table_in_catalog_database') + ) { + result.push(...(await getTables(languageId, words[0], words[2]))); + tracker.markAsCompleted('table_in_catalog_database'); + } - if ( - syntaxContextType === EntityContextType.CATALOG || - syntaxContextType === EntityContextType.DATABASE_CREATE - ) { - if (!existCatalogCompletions && wordCount <= 1) { - syntaxCompletionItems = syntaxCompletionItems.concat(await getCatalogs(languageId)); - existCatalogCompletions = true; + // Complete View + if ( + contextType === EntityContextType.VIEW && + wordCount <= 1 && + !tracker.hasCompletionType('view') + ) { + result.push(...(await getViews(languageId))); + tracker.markAsCompleted('view'); + } + + // Complete Views under Database + if ( + contextType === EntityContextType.VIEW && + wordCount >= 2 && + wordCount <= 3 && + !tracker.hasCompletionType('view_in_database') + ) { + result.push(...(await getViews(languageId, undefined, words[0]))); + tracker.markAsCompleted('view_in_database'); + } + + // Complete Views under Catalog.Database + if ( + contextType === EntityContextType.VIEW && + wordCount >= 4 && + wordCount <= 5 && + !tracker.hasCompletionType('view_in_catalog_database') + ) { + result.push(...(await getViews(languageId, words[0], words[2]))); + tracker.markAsCompleted('view_in_catalog_database'); + } + + return result; +}; + +/** + * Parse entity text and extract different parts + * @param originEntityText - The origin entity text + * @returns Parsed entity information + * @example + * parseEntityText('catalog.database.table') => { catalog: 'catalog', schema: 'database', table: 'table', fullPath: 'catalog.database.table' } + * parseEntityText('schema.table') => { catalog: null, schema: 'schema', table: 'table', fullPath: 'schema.table' } + * parseEntityText('table') => { catalog: null, schema: null, table: 'table', fullPath: 'table' } + */ +const parseEntityText = (originEntityText: string) => { + // Use regex to split correctly, keeping backtick-wrapped parts as a whole. + // Match: backtick-wrapped content (including internal dots) or regular non-dot characters. + // '`xx.xx`' should be treated as a whole word `xx.xx`. + const regex = /`[^`]+`|[^.]+/g; + const matches = originEntityText.match(regex) || []; + + const words = matches.map((word) => { + if (word.startsWith('`') && word.endsWith('`') && word.length >= 3) { + const content = word.slice(1, -1); + // Only remove backticks when content contains only letters, numbers, and underscores + if (/^[a-zA-Z0-9_]+$/.test(content)) { + return content; } } + return word; + }); - if ( - syntaxContextType === EntityContextType.DATABASE || - syntaxContextType === EntityContextType.TABLE_CREATE || - syntaxContextType === EntityContextType.VIEW_CREATE - ) { - if (!existCatalogCompletions && haveCatalog && wordCount <= 1) { - syntaxCompletionItems = syntaxCompletionItems.concat(await getCatalogs(languageId)); - existCatalogCompletions = true; + const length = words.length; + if (length >= 3) { + // catalog.schema.table format + return { + catalog: words[0], + schema: words[1], + table: words[2], + fullPath: words.join('.'), + pureEntityText: words[2] + }; + } else if (length === 2) { + // schema.table format + return { + catalog: null, + schema: words[0], + table: words[1], + fullPath: words.join('.'), + pureEntityText: words[1] + }; + } else { + // table format + return { + catalog: null, + schema: null, + table: words[0], + fullPath: words[0], + pureEntityText: words[0] + }; + } +}; + +/** + * Get the pure entity text from the origin entity text + * @param originEntityText - The origin entity text + * @returns The pure entity text + * @example + * getPureEntityText('catalog.database.table') => 'table' + * getPureEntityText('tb.id') => 'id' + * getPureEntityText('`a1`') => 'a1' + */ +const getPureEntityText = (originEntityText: string) => { + return parseEntityText(originEntityText).pureEntityText; +}; + +/** + * Remove backticks from text for filter matching + * @param text - The text that may contain backticks + * @returns The text without backticks + */ +const removeBackticks = (text: string): string => { + return text.replace(/`/g, ''); +}; + +/** + * Check if two entity paths match, considering schema information + * @param createTablePath - The path from CREATE TABLE statement + * @param referenceTablePath - The path from table reference + * @returns Whether the paths match + */ +const isEntityPathMatch = (createTablePath: string, referenceTablePath: string): boolean => { + const createInfo = parseEntityText(createTablePath); + const refInfo = parseEntityText(referenceTablePath); + + // Exact match + if (createInfo.fullPath === refInfo.fullPath) { + return true; + } + + // If reference has no schema but table name matches + if (!refInfo.schema && createInfo.table === refInfo.table) { + return true; + } + + // If both have schema and table, they must match exactly + if (createInfo.schema && refInfo.schema) { + return createInfo.schema === refInfo.schema && createInfo.table === refInfo.table; + } + + return false; +}; + +/** + * Process column completions, including regular columns and table.column format + */ +const getColumnCompletions = async ( + languageId: string, + wordRanges: WordRange[], + entities: EntityContext[] | null +): Promise => { + if (!entities) return []; + + const words = wordRanges.map((wr) => wr.text); + const result: ICompletionItem[] = []; + + // Extract already selected columns from QUERY_RESULT to filter them out + const queryResultEntity = entities.find( + (entity) => entity.entityContextType === EntityContextType.QUERY_RESULT + ) as CommonEntityContext | undefined; + + const selectedColumns = new Set(); + queryResultEntity?.columns?.forEach((col) => { + const columnName = col[AttrName.alias]?.text || getPureEntityText(col.text); + if (columnName) { + selectedColumns.add(columnName); + } + }); + + // All tables defined in the context + const allTableDefinitionEntities = + (entities?.filter( + (entity) => entity.entityContextType === EntityContextType.TABLE_CREATE + ) as CommonEntityContext[]) || []; + + // Source tables in the SELECT statement + const sourceTables = + (entities?.filter( + (entity) => entity.entityContextType === EntityContextType.TABLE && entity.isAccessible + ) as CommonEntityContext[]) || []; + + // Find table definitions from source tables (regular CREATE TABLE with explicit columns) + const sourceTableDefinitionEntities = allTableDefinitionEntities.filter((createTable) => + sourceTables?.some( + (sourceTable) => + sourceTable.declareType === TableDeclareType.LITERAL && + isEntityPathMatch(createTable.text, sourceTable.text) + ) + ); + + // Find CTAS table definitions from source tables (CREATE TABLE AS SELECT) + const ctasTableDefinitionEntities = allTableDefinitionEntities.filter((createTable) => + sourceTables?.some( + (sourceTable) => + sourceTable.declareType === TableDeclareType.LITERAL && + // Check if the CREATE TABLE has relatedEntities with QUERY_RESULT (indicates CTAS) + createTable.relatedEntities?.some( + (relatedEntity) => + relatedEntity.entityContextType === EntityContextType.QUERY_RESULT + ) && + isEntityPathMatch(createTable.text, sourceTable.text) + ) + ); + + const derivedTableEntities = + sourceTables?.filter((entity) => entity.declareType === TableDeclareType.EXPRESSION) || []; + + const tableNameAliasMap: Record = sourceTables.reduce( + (acc: Record, tb) => { + const alias = tb[AttrName.alias]?.text; + if (alias) { + acc[tb.text] = alias; } + return acc; + }, + {} + ); - if (!existDatabaseCompletions && wordCount <= 1) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getDBOrSchema(languageId) - ); - existDatabaseCompletions = true; + // alias to full table path + const aliasToTableMap: Record = Object.fromEntries( + Object.entries(tableNameAliasMap).map(([tablePath, alias]) => [alias, tablePath]) + ); + + // When not typing a dot, suggest all source tables and columns (if source tables are directly created in local context) + // Handle the case where input is like 't1.' -> wordRanges could be ['t1', '.'] or ['t1.'] + const currentText = words[words.length - 1] || ''; + const isDotContext = + (wordRanges.length === 2 && words[1] === '.') || + (wordRanges.length === 1 && currentText.endsWith('.')); + const tbNameOrAliasForDotContext = isDotContext + ? wordRanges.length === 2 + ? words[0] + : currentText.slice(0, -1) + : null; + + if (wordRanges.length <= 1 && !currentText.endsWith('.')) { + const columnRepeatCountMap = new Map(); + + // Get columns from local tables + let sourceTableColumns: EnhancedCompletionItem[] = []; + + sourceTables.forEach((sourceTable) => { + const realTablePath = sourceTable.text; + const displayAlias = tableNameAliasMap[sourceTable.text]; + + const tableColumns = [ + ...getSpecificTableColumns( + sourceTableDefinitionEntities, + realTablePath, + displayAlias + ), + ...getSpecificDerivedTableColumns(derivedTableEntities, displayAlias), + ...getSpecificCTASTableColumns( + ctasTableDefinitionEntities, + realTablePath, + displayAlias + ) + ]; + + sourceTableColumns.push(...tableColumns); + }); + + // Count duplicate column names + sourceTableColumns.forEach((col) => { + if (col._columnText) { + const repeatCount = columnRepeatCountMap.get(col._columnText) || 0; + columnRepeatCountMap.set(col._columnText, repeatCount + 1); + } + }); + + // If there are columns with the same name, automatically include table name in inserted text + sourceTableColumns = sourceTableColumns.map((column) => { + const columnRepeatCount = columnRepeatCountMap.get(column._columnText as string) || 0; + const isIncludeInMultipleTables = sourceTables.length > 1; + if (columnRepeatCount > 1 && isIncludeInMultipleTables) { + const newLabel = `${column._tableName}.${column.label}`; + return { + ...column, + label: newLabel, + filterText: removeBackticks(newLabel), + insertText: `${column._tableName}.${column._columnText}` + }; } - if (!existDatabaseInCatCompletions && haveCatalog && wordCount >= 2 && wordCount <= 3) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getDBOrSchema(languageId, words[0]) + return column; + }); + + result.push(...sourceTableColumns); + + // Also suggest tables when inputting column + const tableCompletionItems = + sourceTables.length > 1 + ? sourceTables.map((tb) => { + const tableName = tb[AttrName.alias]?.text ?? getPureEntityText(tb.text); + return { + label: tableName, + filterText: removeBackticks(tableName), + kind: languages.CompletionItemKind.Field, + detail: + tb.declareType === TableDeclareType.LITERAL + ? 'table' + : 'derived table', + sortText: '1' + tableName + }; + }) + : []; + + result.push(...tableCompletionItems); + } else if (isDotContext && tbNameOrAliasForDotContext) { + // Table.column format completion when input is like 't1.' + // wordRanges could be ['t1', '.'] or ['t1.'] + const tbNameOrAlias = tbNameOrAliasForDotContext; + + let realTablePath = tbNameOrAlias; + + // Check if the input is an alias and resolve to full table path + if (aliasToTableMap[tbNameOrAlias]) { + realTablePath = aliasToTableMap[tbNameOrAlias]; + } else { + // Try to find matching table in source tables + const matchingTable = sourceTables.find((tb) => { + const parsedTable = parseEntityText(tb.text); + return ( + parsedTable.table === tbNameOrAlias || + parsedTable.fullPath === tbNameOrAlias || + tb.text === tbNameOrAlias ); - existDatabaseInCatCompletions = true; + }); + + if (matchingTable) { + realTablePath = matchingTable.text; } } - if (syntaxContextType === EntityContextType.TABLE) { - if (wordCount <= 1) { - if (!existCatalogCompletions && haveCatalog) { - const ctas = await getCatalogs(languageId); - syntaxCompletionItems = syntaxCompletionItems.concat(ctas); - existCatalogCompletions = true; - } - - if (!existDatabaseCompletions) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getDBOrSchema(languageId) - ); - existDatabaseCompletions = true; - } - - if (!existTableCompletions) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getTables(languageId) - ); - existTableCompletions = true; - } - } else if (wordCount >= 2 && wordCount <= 3) { - if (!existDatabaseInCatCompletions && haveCatalog) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getDBOrSchema(languageId, words[0]) - ); - existDatabaseInCatCompletions = true; - } - - if (!existTableInDbCompletions) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getTables(languageId, undefined, words[0]) - ); - existTableInDbCompletions = true; - } - } else if (wordCount >= 4 && wordCount <= 5) { - if (!existTableInDbCompletions) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getTables(languageId, words[0], words[2]) - ); - existTableInDbCompletions = true; - } + // Find columns in local table definitions + const displayAlias = aliasToTableMap[tbNameOrAlias] ? tbNameOrAlias : undefined; + + const localTableColumns = [ + ...getSpecificTableColumns(sourceTableDefinitionEntities, realTablePath, displayAlias), + ...getSpecificDerivedTableColumns(derivedTableEntities, displayAlias), + ...getSpecificCTASTableColumns(ctasTableDefinitionEntities, realTablePath, displayAlias) + ]; + + result.push(...localTableColumns); + + // If no local table columns found, try to fetch from cloud + if (localTableColumns.length === 0) { + const isLocallyCreatedTable = allTableDefinitionEntities.some((createTable) => { + return isEntityPathMatch(createTable.text, realTablePath); + }); + + const isLiteralTable = sourceTables.some( + (tb) => + tb.declareType === TableDeclareType.LITERAL && + (tb.text === realTablePath || isEntityPathMatch(tb.text, realTablePath)) + ); + + if (!isLocallyCreatedTable && isLiteralTable) { + const remoteColumns = await getColumns(languageId, realTablePath); + result.push(...remoteColumns); } } + } - if (syntaxContextType === EntityContextType.VIEW) { - if (wordCount <= 1) { - if (!existCatalogCompletions && haveCatalog) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getCatalogs(languageId) - ); - existCatalogCompletions = true; - } - - if (!existDatabaseCompletions) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getDBOrSchema(languageId) - ); - existDatabaseCompletions = true; - } - - if (!existViewCompletions) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getViews(languageId) - ); - existViewCompletions = true; - } - } else if (wordCount >= 2 && wordCount <= 3) { - if (!existDatabaseInCatCompletions && haveCatalog) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getDBOrSchema(languageId, words[0]) - ); - existDatabaseInCatCompletions = true; - } - - if (!existViewInDbCompletions) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getViews(languageId, undefined, words[0]) - ); - existViewInDbCompletions = true; - } - } else if (wordCount >= 4 && wordCount <= 5) { - if (!existViewInDbCompletions) { - syntaxCompletionItems = syntaxCompletionItems.concat( - await getViews(languageId, words[0], words[2]) - ); - existViewInDbCompletions = true; - } - } + // Filter out already selected columns in QUERY_RESULT + if (selectedColumns.size > 0) { + return result.filter((item) => { + const columnName = + (item as any)._columnText || + (typeof item.label === 'string' ? item.label : (item.label as any).label); + return !selectedColumns.has(columnName); + }); + } + + return result; +}; + +/** + * Get columns from a specific table + */ +const getSpecificTableColumns = ( + sourceTableDefinitionEntities: CommonEntityContext[], + realTablePath: string, + displayAlias?: string +): any[] => { + return sourceTableDefinitionEntities + .filter((tb) => { + return ( + tb.text === realTablePath || + isEntityPathMatch(tb.text, realTablePath) || + getPureEntityText(tb.text) === getPureEntityText(realTablePath) + ); + }) + .map((tb) => { + const tableName = displayAlias || getPureEntityText(tb.text); + return ( + tb.columns?.map((column) => { + const columnName = + column[AttrName.alias]?.text || getPureEntityText(column.text); + if (!columnName) return null; + const label = + columnName + + (column[AttrName.colType]?.text + ? `(${column[AttrName.colType].text})` + : ''); + return { + label, + filterText: removeBackticks(label), + insertText: columnName, + kind: languages.CompletionItemKind.EnumMember, + detail: `\`${tableName}\`'s column`, + sortText: '0' + tableName + columnName, + _columnText: columnName, + _tableName: tableName + }; + }) || [] + ); + }) + .flat() + .filter(Boolean); +}; + +/** + * Get columns from a specific derived table (subquery) + */ +const getSpecificDerivedTableColumns = ( + derivedTableEntities: CommonEntityContext[], + displayAlias?: string +): any[] => { + return derivedTableEntities + .filter((tb) => { + return displayAlias ? tb[AttrName.alias]?.text === displayAlias : false; + }) + .map((tb) => { + const derivedTableQueryResult = tb.relatedEntities?.find( + (entity) => entity.entityContextType === EntityContextType.QUERY_RESULT + ) as CommonEntityContext | undefined; + + const tableName = + displayAlias || tb[AttrName.alias]?.text || getPureEntityText(tb.text); + + return ( + derivedTableQueryResult?.columns + ?.filter((column) => column.declareType !== ColumnDeclareType.ALL) + .map((column) => { + const columnName = + column[AttrName.alias]?.text || getPureEntityText(column.text); + if (!columnName) return null; + return { + label: columnName, + filterText: removeBackticks(columnName), + insertText: columnName, + kind: languages.CompletionItemKind.EnumMember, + detail: `\`${tableName}\`'s column`, + sortText: '0' + tableName + columnName, + _columnText: columnName, + _tableName: tableName + }; + }) || [] + ); + }) + .flat() + .filter(Boolean); +}; + +/** + * Get columns from a specific CTAS table + */ +const getSpecificCTASTableColumns = ( + ctasTableEntities: CommonEntityContext[], + realTablePath: string, + displayAlias?: string +): any[] => { + return ctasTableEntities + .filter((tb) => { + return ( + tb.text === realTablePath || + isEntityPathMatch(tb.text, realTablePath) || + getPureEntityText(tb.text) === getPureEntityText(realTablePath) + ); + }) + .map((tb) => { + const ctasQueryResult = tb.relatedEntities?.find( + (entity) => entity.entityContextType === EntityContextType.QUERY_RESULT + ) as CommonEntityContext | undefined; + + const tableName = displayAlias || getPureEntityText(tb.text); + + return ( + ctasQueryResult?.columns + ?.filter((column) => column.declareType !== ColumnDeclareType.ALL) + .map((column) => { + const columnName = + column[AttrName.alias]?.text || getPureEntityText(column.text); + if (!columnName) return null; + const label = + columnName + + (column[AttrName.colType]?.text + ? `(${column[AttrName.colType].text})` + : ''); + return { + label, + filterText: removeBackticks(label), + insertText: columnName, + kind: languages.CompletionItemKind.EnumMember, + detail: `\`${tableName}\`'s column`, + sortText: '0' + tableName + columnName, + _columnText: columnName, + _tableName: tableName + }; + }) || [] + ); + }) + .flat() + .filter(Boolean); +}; + +const getSyntaxCompletionItems = async ( + languageId: string, + syntax: Suggestions['syntax'], + entities: EntityContext[] | null +): Promise => { + const tracker = new CompletionTracker(); + let syntaxCompletionItems: ICompletionItem[] = []; + + for (let i = 0; i < syntax.length; i++) { + const { syntaxContextType, wordRanges } = syntax[i]; + const words = wordRanges.map((wr) => wr.text); + + // If already typed a space, we've left that context + if (isWordRangesEndWithWhiteSpace(wordRanges)) continue; + + if ( + [ + EntityContextType.CATALOG, + EntityContextType.DATABASE, + EntityContextType.DATABASE_CREATE, + EntityContextType.TABLE, + EntityContextType.TABLE_CREATE, + EntityContextType.VIEW, + EntityContextType.VIEW_CREATE + ].includes(syntaxContextType as EntityContextType) && + !tracker.hasCompletionType('db_objects') + ) { + // Get database object completions (catalog, database, table, etc.) + const dbObjectCompletions = await getDatabaseObjectCompletions( + tracker, + languageId, + syntaxContextType, + words + ); + + syntaxCompletionItems = syntaxCompletionItems.concat(dbObjectCompletions); + tracker.markAsCompleted('db_objects'); + } + + // Add table completions from table entities created in context + if ( + syntaxContextType === EntityContextType.TABLE && + words.length <= 1 && + !tracker.hasCompletionType('created_tables') + ) { + const createTables = + entities + ?.filter( + (entity) => entity.entityContextType === EntityContextType.TABLE_CREATE + ) + .map((tb) => { + const tableName = getPureEntityText(tb.text); + return { + label: tableName, + filterText: removeBackticks(tableName), + kind: languages.CompletionItemKind.Field, + detail: 'table', + sortText: '1' + tableName + }; + }) || []; + + syntaxCompletionItems = syntaxCompletionItems.concat(createTables); + tracker.markAsCompleted('created_tables'); + } + + // Process column completions + if ( + syntaxContextType === EntityContextType.COLUMN && + !tracker.hasCompletionType('columns') + ) { + const columnCompletions = await getColumnCompletions(languageId, wordRanges, entities); + syntaxCompletionItems = syntaxCompletionItems.concat(columnCompletions); + tracker.markAsCompleted('columns'); } } + return syntaxCompletionItems; +}; + +export const completionService: CompletionService = async function ( + model, + _position, + _completionContext, + suggestions, + entities, + snippets +) { + if (!suggestions) { + return Promise.resolve([]); + } + const languageId = model.getLanguageId(); + + const { keywords, syntax } = suggestions; + + const keywordsCompletionItems: ICompletionItem[] = keywords.map((kw) => ({ + label: kw, + kind: languages.CompletionItemKind.Keyword, + detail: 'keyword', + sortText: '2' + kw + })); + + const syntaxCompletionItems = await getSyntaxCompletionItems(languageId, syntax, entities); + const snippetCompletionItems: ICompletionItem[] = snippets?.map((item) => ({ label: item.label || item.prefix, diff --git a/website/src/languages/helpers/dbMetaProvider.ts b/website/src/languages/helpers/dbMetaProvider.ts index 881dfd23..fe06b4fb 100644 --- a/website/src/languages/helpers/dbMetaProvider.ts +++ b/website/src/languages/helpers/dbMetaProvider.ts @@ -1,4 +1,5 @@ import { languages } from 'monaco-editor/esm/vs/editor/editor.api'; +import type { ICompletionItem } from 'monaco-sql-languages/esm/monaco.contribution'; const catalogList = ['mock_catalog_1', 'mock_catalog_2', 'mock_catalog_3']; const schemaList = ['mock_schema_1', 'mock_schema_2', 'mock_schema_3']; @@ -21,78 +22,136 @@ const prefixLabel = (languageId: string, text: string) => { }; /** - * 获取所有的 catalog + * Remove backticks from text for filter matching + */ +const removeBackticks = (text: string): string => { + return text.replace(/`/g, ''); +}; + +/** + * Get all catalogs */ export function getCatalogs(languageId: string) { - const catCompletions = catalogList.map((cat) => ({ - label: prefixLabel(languageId, cat), - kind: languages.CompletionItemKind.Field, - detail: 'catalog', - sortText: '1' + prefixLabel(languageId, cat) - })); + const catCompletions = catalogList.map((cat) => { + const label = prefixLabel(languageId, cat); + return { + label, + filterText: removeBackticks(label), + kind: languages.CompletionItemKind.Field, + detail: 'Remote: catalog', + sortText: '1' + label + }; + }); return Promise.resolve(catCompletions); } /** - * 根据catalog 获取 database + * Get databases based on catalog */ export function getDataBases(languageId: string, catalog?: string) { const databases = catalog ? databaseList : tmpDatabaseList; - const databaseCompletions = databases.map((db) => ({ - label: prefixLabel(languageId, db), - kind: languages.CompletionItemKind.Field, - detail: 'database', - sortText: '1' + prefixLabel(languageId, db) - })); + const databaseCompletions = databases.map((db) => { + const label = prefixLabel(languageId, db); + return { + label, + filterText: removeBackticks(label), + kind: languages.CompletionItemKind.Field, + detail: 'Remote: database', + sortText: '1' + label + }; + }); return Promise.resolve(databaseCompletions); } /** - * 根据catalog 获取 schema + * Get schemas based on catalog */ export function getSchemas(languageId: string, catalog?: string) { const schemas = catalog ? schemaList : tmpSchemaList; - const schemaCompletions = schemas.map((sc) => ({ - label: prefixLabel(languageId, sc), - kind: languages.CompletionItemKind.Field, - detail: 'schema', - sortText: '1' + prefixLabel(languageId, sc) - })); + const schemaCompletions = schemas.map((sc) => { + const label = prefixLabel(languageId, sc); + return { + label, + filterText: removeBackticks(label), + kind: languages.CompletionItemKind.Field, + detail: 'Remote: schema', + sortText: '1' + label + }; + }); return Promise.resolve(schemaCompletions); } /** - * 根据 catalog 和 database 获取 table + * Get tables based on catalog and database */ export function getTables(languageId: string, catalog?: string, database?: string) { const tables = catalog && database ? tableList : tmpTableList; - const tableCompletions = tables.map((tb) => ({ - label: prefixLabel(languageId, tb), - kind: languages.CompletionItemKind.Field, - detail: 'table', - sortText: '1' + prefixLabel(languageId, tb) - })); + const tableCompletions = tables.map((tb) => { + const label = prefixLabel(languageId, tb); + return { + label, + filterText: removeBackticks(label), + kind: languages.CompletionItemKind.Field, + detail: 'Remote: table', + sortText: '1' + label + }; + }); return Promise.resolve(tableCompletions); } /** - * 根据 catalog 和 database 获取 view + * Get views based on catalog and database */ export function getViews(languageId: string, catalog?: string, database?: string) { const views = catalog && database ? viewList : tmpViewList; - const viewCompletions = views.map((v) => ({ - label: prefixLabel(languageId, v), - kind: languages.CompletionItemKind.Field, - detail: 'view', - sortText: '1' + prefixLabel(languageId, v) - })); + const viewCompletions = views.map((v) => { + const label = prefixLabel(languageId, v); + return { + label, + filterText: removeBackticks(label), + kind: languages.CompletionItemKind.Field, + detail: 'Remote: view', + sortText: '1' + label + }; + }); return Promise.resolve(viewCompletions); } + +/** + * Get column information for a specific table + * @param languageId Language ID + * @param tableName Table name + * @returns Column completion items + */ +export function getColumns(languageId: string, tableName: string): Promise { + // Mock column data, should fetch from cloud in real environment + const mockColumns = [ + { name: 'id', type: 'INT' }, + { name: 'name', type: 'VARCHAR' }, + { name: 'age', type: 'INT' }, + { name: 'created_at', type: 'TIMESTAMP' }, + { name: 'updated_at', type: 'TIMESTAMP' } + ]; + + const columnCompletions = mockColumns.map((col) => { + const label = `${col.name}(${col.type})`; + return { + label, + filterText: removeBackticks(label), + insertText: col.name, + kind: languages.CompletionItemKind.EnumMember, + detail: `Remote: \`${tableName}\`'s column`, + sortText: '0' + tableName + col.name + }; + }); + + return Promise.resolve(columnCompletions); +} diff --git a/website/src/types/monaco-sql-languages.d.ts b/website/src/types/monaco-sql-languages.d.ts new file mode 100644 index 00000000..14616ffb --- /dev/null +++ b/website/src/types/monaco-sql-languages.d.ts @@ -0,0 +1,64 @@ +// Type declarations for monaco-sql-languages +// This overrides the old types in esm directory + +import { languages } from 'monaco-editor'; +import { Position } from 'monaco-editor'; + +declare module 'monaco-sql-languages/esm/languageService' { + export interface SerializedTreeNode { + ruleName: string; + text?: string; + children: SerializedTreeNode[]; + } + + export class LanguageService { + valid(language: string, model: any): Promise; + getSerializedParseTree(language: string, model: any): Promise; + /** @deprecated Use getSerializedParseTree instead */ + parserTreeToString(language: string, model: any): Promise; + getAllEntities(language: string, model: any, position?: Position): Promise; + dispose(language?: string): void; + } +} + +declare module 'monaco-sql-languages/esm/monaco.contribution' { + export interface ICompletionItem + extends Omit { + label: string; + } + + export interface ICompletionList { + suggestions: ICompletionItem[]; + incomplete?: boolean; + dispose?: () => void; + } + + export type CompletionService = ( + model: any, + position: Position, + triggerCharacter?: string + ) => Promise; +} + +declare module 'monaco-sql-languages/esm/main' { + export * from 'monaco-sql-languages/esm/languageService'; + export * from 'monaco-sql-languages/esm/monaco.contribution'; + export * from 'monaco-sql-languages/esm/setupLanguageFeatures'; + export * from 'monaco-sql-languages/esm/common/constants'; + export * from 'monaco-sql-languages/esm/theme'; + + export { EntityContextType, StmtContextType } from 'dt-sql-parser'; + + export type { + WordRange, + SyntaxSuggestion, + Suggestions, + TextSlice, + ParseError, + StmtContext, + EntityContext, + CommonEntityContext, + ColumnEntityContext, + FuncEntityContext + } from 'dt-sql-parser'; +}