diff --git a/src/common/datasource/pg/__tests__/index.test.ts b/src/common/datasource/pg/__tests__/index.test.ts new file mode 100644 index 000000000..8f16699e2 --- /dev/null +++ b/src/common/datasource/pg/__tests__/index.test.ts @@ -0,0 +1,212 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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. + */ + +/** + * Unit tests for src/common/datasource/pg/index.tsx + * + * 覆盖目标(design.md §3.5 / §4.2 / §10.3,compat-RISK-3): + * 1. PG `features.task` 经收敛后等于 [TaskType.ASYNC] + * 2. PG 其它 features 字段保持原状(sessionManage / sessionParams / sqlExplain / + * plRun / groupResourceTree / sqlconsole = true;obclient / recycleBin = false) + * 3. 横向回归:MySQL / Oracle / OceanBase 各自 features.task 数组未受波及 + * + * 测试形式:map case(数组定义所有用例 + forEach 循环生成 it, + * 等价于 it.each;选 forEach 是因为 odc-client 的 @types/jest@22 不识别 it.each 签名)。 + * Refs: dms-ee#850, compat-RISK-3 + */ + +// haveOCP() 依赖 webpack DefinePlugin 注入的 HAVEOCP 常量,jest 运行时未注入, +// 因此显式 mock 让 haveOCP() 返回 false,避免触发 pg/index.tsx 末尾的 delete 分支。 +jest.mock('@/util/env', () => ({ + haveOCP: () => false, + isClient: () => false, + hasEventTrackingPermission: () => false +})); + +// pg/index.tsx 静态 import MySQLColumnExtra(来自 oceanbase),而该组件的依赖链 +// 引入了 src/util/intl.tsx -> @umijs/max -> esbuild 运行时检查,在 jsdom 测试环境 +// 下会失败。本测试只断言 PG datasource 静态导出,不渲染 React 组件,因此 mock 之。 +jest.mock('@/common/datasource/oceanbase/MySQLColumnExtra', () => ({ + __esModule: true, + default: () => null +})); + +// mysql/oracle/oceanbase 三套 lateral 基线引用 ColumnExtra 同链路,全部以最小 mock 代替。 +jest.mock('@/common/datasource/oceanbase/OracleColumnExtra', () => ({ + __esModule: true, + default: () => null +})); + +import { ConnectType, TaskType } from '@/d.ts'; +import pgItems from '@/common/datasource/pg'; +import mysqlItems from '@/common/datasource/mysql'; +import oracleItems from '@/common/datasource/oracle'; +import obItems from '@/common/datasource/oceanbase/obmysql'; + +const pgConfig = pgItems[ConnectType.PG]; + +describe('datasource/pg/index features.task collapse (compat-RISK-3)', () => { + it('exports a config keyed by ConnectType.PG', () => { + expect(pgConfig).toBeDefined(); + }); + + it('features.task equals [TaskType.ASYNC] (only ASYNC kept)', () => { + expect(pgConfig.features.task).toEqual([TaskType.ASYNC]); + expect(pgConfig.features.task).toHaveLength(1); + expect(pgConfig.features.task[0]).toBe(TaskType.ASYNC); + }); + + // map case · 旧任务类型必须全部移除 + const removedTaskCases: Array<{ name: string; taskType: TaskType }> = [ + { name: 'SQL_PLAN removed', taskType: TaskType.SQL_PLAN }, + { name: 'DATA_ARCHIVE removed', taskType: TaskType.DATA_ARCHIVE }, + { name: 'DATA_DELETE removed', taskType: TaskType.DATA_DELETE }, + { name: 'IMPORT removed', taskType: TaskType.IMPORT }, + { name: 'EXPORT removed', taskType: TaskType.EXPORT }, + { name: 'EXPORT_RESULT_SET removed', taskType: TaskType.EXPORT_RESULT_SET }, + { + name: 'STRUCTURE_COMPARISON removed', + taskType: TaskType.STRUCTURE_COMPARISON + }, + { name: 'MULTIPLE_ASYNC removed', taskType: TaskType.MULTIPLE_ASYNC } + ]; + + removedTaskCases.forEach(({ name, taskType }) => { + it(`features.task should NOT contain ${name}`, () => { + expect(pgConfig.features.task).not.toContain(taskType); + }); + }); +}); + +describe('datasource/pg/index other features unchanged (compat-RISK-3)', () => { + // map case · 其它 features 字段保持现状(与 product.md §5 / design.md §3.5 一致) + const featuresCases: Array<{ name: string; key: string; expected: boolean }> = + [ + { + name: 'sessionManage stays true', + key: 'sessionManage', + expected: true + }, + { + name: 'sessionParams stays true', + key: 'sessionParams', + expected: true + }, + { name: 'sqlExplain stays true', key: 'sqlExplain', expected: true }, + { name: 'plRun stays true', key: 'plRun', expected: true }, + { + name: 'groupResourceTree stays true', + key: 'groupResourceTree', + expected: true + }, + { name: 'sqlconsole stays true', key: 'sqlconsole', expected: true }, + { name: 'obclient stays false', key: 'obclient', expected: false }, + { name: 'recycleBin stays false', key: 'recycleBin', expected: false } + ]; + + featuresCases.forEach(({ name, key, expected }) => { + it(`${name} (features.${key} === ${expected})`, () => { + expect(pgConfig.features[key]).toBe(expected); + }); + }); + + it('features.export object preserves PG defaults (fileLimit/snapshot=false)', () => { + expect(pgConfig.features.export).toEqual({ + fileLimit: false, + snapshot: false + }); + }); + + it('schema.innerSchema preserves PG built-in schema list', () => { + expect(pgConfig.schema.innerSchema).toEqual([ + 'postgres', + 'information_schema', + 'pg_catalog' + ]); + }); + + it('sql.language stays "pg"', () => { + expect(pgConfig.sql.language).toBe('pg'); + }); +}); + +describe('datasource/pg/index lateral regression baseline (compat-RISK-3)', () => { + // 横向回归 · MySQL / Oracle / OceanBase 的 features.task 必须与 support-pg 基线一致: + // - MySQL:12 项(含 LOGICAL_DATABASE_CHANGE / DATAMOCK) + // - Oracle:8 项(含 IMPORT / EXPORT / EXPORT_RESULT_SET / SQL_PLAN / ASYNC / + // DATA_DELETE / DATA_ARCHIVE / MULTIPLE_ASYNC) + // - OB-MySQL:Object.values(TaskType) 全集 + const lateralCases: Array<{ + name: string; + actual: TaskType[]; + expected: TaskType[]; + }> = [ + { + name: 'MySQL features.task baseline (12 items, contains DATAMOCK)', + actual: mysqlItems[ConnectType.MYSQL].features.task, + expected: [ + TaskType.ASYNC, + TaskType.DATAMOCK, + TaskType.SQL_PLAN, + TaskType.DATA_ARCHIVE, + TaskType.DATA_DELETE, + TaskType.IMPORT, + TaskType.EXPORT, + TaskType.EXPORT_RESULT_SET, + TaskType.STRUCTURE_COMPARISON, + TaskType.MULTIPLE_ASYNC, + TaskType.LOGICAL_DATABASE_CHANGE + ] + }, + { + name: 'Oracle features.task baseline (8 items, no STRUCTURE_COMPARISON)', + actual: oracleItems[ConnectType.ORACLE].features.task, + expected: [ + TaskType.IMPORT, + TaskType.EXPORT, + TaskType.EXPORT_RESULT_SET, + TaskType.SQL_PLAN, + TaskType.ASYNC, + TaskType.DATA_DELETE, + TaskType.DATA_ARCHIVE, + TaskType.MULTIPLE_ASYNC + ] + }, + { + name: 'OB-MySQL features.task baseline (Object.values(TaskType) 全集)', + actual: obItems[ConnectType.OB_MYSQL].features.task, + expected: Object.values(TaskType) + } + ]; + + lateralCases.forEach(({ name, actual, expected }) => { + it(name, () => { + expect(actual).toEqual(expected); + }); + }); + + it('MySQL features.task NOT polluted by PG collapse (length unchanged)', () => { + expect(mysqlItems[ConnectType.MYSQL].features.task.length).toBeGreaterThan( + 1 + ); + }); + + it('Oracle features.task NOT polluted by PG collapse (length unchanged)', () => { + expect( + oracleItems[ConnectType.ORACLE].features.task.length + ).toBeGreaterThan(1); + }); +}); diff --git a/src/common/datasource/pg/index.tsx b/src/common/datasource/pg/index.tsx index 7fb1ca1a6..7a615b293 100644 --- a/src/common/datasource/pg/index.tsx +++ b/src/common/datasource/pg/index.tsx @@ -20,16 +20,16 @@ import MySQLColumnExtra from '../oceanbase/MySQLColumnExtra'; import { haveOCP } from '@/util/env'; const tableConfig = { - enableTableCharsetsAndCollations: true, + enableTableCharsetsAndCollations: false, // PG 字符集在数据库级别设置 enableConstraintOnUpdate: true, ColumnExtraComponent: MySQLColumnExtra, paritionNameCaseSensitivity: true, enableIndexesFullTextType: true, - enableAutoIncrement: true, + enableAutoIncrement: false, // PG 使用 SERIAL/IDENTITY type2ColumnType: { id: 'int', name: 'varchar', - date: 'datetime', + date: 'timestamp', time: 'timestamp' } }; @@ -67,14 +67,20 @@ const items: Record = { disableURLParse: true }, features: { - task: [TaskType.DATA_ARCHIVE, TaskType.DATA_DELETE], + // Align with ODC backend PostgreSQLFeatures + odc_version_diff_config: + // PG 后端在本期仅承诺 ASYNC(数据库变更)任务;SQL_PLAN / DATA_ARCHIVE / + // DATA_DELETE / IMPORT / EXPORT / EXPORT_RESULT_SET / STRUCTURE_COMPARISON / + // MULTIPLE_ASYNC 均未在后端开放,前端入口同步收敛以避免点击后 unsupported。 + // Refs: dms-ee#850, compat-RISK-3 + task: [TaskType.ASYNC], obclient: false, - recycleBin: false, - sessionManage: false, - sessionParams: false, - sqlExplain: false, - groupResourceTree: false, - sqlconsole: false, + recycleBin: false, // PG 无回收站 + sessionManage: true, + sessionParams: true, + sqlExplain: true, + plRun: true, + groupResourceTree: true, + sqlconsole: true, export: { fileLimit: false, snapshot: false @@ -84,10 +90,10 @@ const items: Record = { table: tableConfig, func: functionConfig, proc: procedureConfig, - innerSchema: ['postgres'] + innerSchema: ['postgres', 'information_schema', 'pg_catalog'] }, sql: { - language: 'mysql', + language: 'pg', escapeChar: '"', caseSensitivity: true } diff --git a/src/component/MonacoEditor/index.tsx b/src/component/MonacoEditor/index.tsx index 7cfc850bd..93bae1319 100644 --- a/src/component/MonacoEditor/index.tsx +++ b/src/component/MonacoEditor/index.tsx @@ -159,6 +159,10 @@ const MonacoEditor: React.FC = function (props) { './plugins/sqlserver-language/service' ); getModelServiceFunc = serviceModule.getModelService; + } else if (language === 'pg') { + pluginModule = await import('./plugins/pg-language/index'); + const serviceModule = await import('./plugins/pg-language/service'); + getModelServiceFunc = serviceModule.getModelService; } else { pluginModule = await import('./plugins/ob-language/index'); getModelServiceFunc = getModelService; @@ -187,8 +191,8 @@ const MonacoEditor: React.FC = function (props) { } async function initEditor() { - // SQL Server 使用自定义的 'sqlserver' 语言支持 - const monacoLanguage = language === 'sqlserver' ? 'sqlserver' : language; + // SQL Server 使用自定义的 'sqlserver' 语言支持, PostgreSQL 使用 'pg' + const monacoLanguage = language === 'sqlserver' ? 'sqlserver' : language === 'pg' ? 'pg' : language; const currentTheme = theme || settingTheme; editorRef.current = monaco.editor?.create(domRef.current, { @@ -308,8 +312,8 @@ const MonacoEditor: React.FC = function (props) { language && language !== editorRef.current?.getModel().getLanguageId() ) { - // SQL Server 使用自定义的 'sqlserver' 语言支持 - const monacoLanguage = language === 'sqlserver' ? 'sqlserver' : language; + // SQL Server 使用自定义的 'sqlserver' 语言支持, PostgreSQL 使用 'pg' + const monacoLanguage = language === 'sqlserver' ? 'sqlserver' : language === 'pg' ? 'pg' : language; monaco.editor.setModelLanguage( editorRef.current?.getModel(), monacoLanguage @@ -338,6 +342,28 @@ const MonacoEditor: React.FC = function (props) { ); } }); + } else if (language === 'pg') { + Promise.all([ + import('./plugins/pg-language/index'), + import('./plugins/pg-language/service') + ]).then(([pluginModule, serviceModule]) => { + const plugin = pluginModule.register(language); + // 重新设置模型选项 + if (editorRef.current?.getModel()) { + plugin.setModelOptions( + editorRef.current.getModel().id, + serviceModule.getModelService( + { + modelId: editorRef.current.getModel().id, + delimiter() { + return sessionRef.current?.params?.delimiter; + } + }, + () => sessionRef.current + ) + ); + } + }); } else { import('./plugins/ob-language/index').then((module) => module.register(language) diff --git a/src/component/MonacoEditor/plugins/pg-language/autoComplete/completionItem.ts b/src/component/MonacoEditor/plugins/pg-language/autoComplete/completionItem.ts new file mode 100644 index 000000000..aed03b4ae --- /dev/null +++ b/src/component/MonacoEditor/plugins/pg-language/autoComplete/completionItem.ts @@ -0,0 +1,187 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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 * as monaco from 'monaco-editor'; +import { IFunction } from '../functions'; + +export interface ISnippet { + label: string; + documentation: string; + insertText: string; +} + +export enum CompletionItemSort { + Star = '37', + Keyword = '50', + Table = '39', + Column = '38', + Function = '51', + Schema = '40', + Snippet = '52' +} + +export function keywordItem( + keyword: string, + range: monaco.languages.CompletionItemRanges | monaco.IRange, + autoNext: boolean +): monaco.languages.CompletionItem { + return { + label: keyword, + range, + insertText: keyword + ' ', + kind: monaco.languages.CompletionItemKind.Keyword, + command: autoNext + ? { id: 'editor.action.triggerSuggest', title: '' } + : undefined, + sortText: + keyword === '*' ? CompletionItemSort.Star : CompletionItemSort.Keyword + }; +} + +export function tableItem( + tableName: string, + schemaName: string = '', + insertSchema: boolean = false, + range: monaco.languages.CompletionItemRanges | monaco.IRange, + type: string = 'TABLE' +): monaco.languages.CompletionItem { + const name = !insertSchema + ? tableName + : [schemaName, tableName].filter(Boolean).join('.'); + + // 根据类型显示不同的描述 + let description = 'Table'; + if (type === 'VIEW') { + description = 'View'; + } else if (type === 'MATERIALIZED_VIEW') { + description = 'Materialized View'; + } else if (type === 'FOREIGN_TABLE') { + description = 'Foreign Table'; + } + + return { + label: { label: name, description, detail: ' ' + schemaName }, + range, + insertText: name, + kind: monaco.languages.CompletionItemKind.Class, + sortText: CompletionItemSort.Table + }; +} + +export function tableColumnItem( + columnName: string, + tableName: string, + schemaName: string = '', + range: monaco.languages.CompletionItemRanges | monaco.IRange, + autoNext: boolean = true +): monaco.languages.CompletionItem { + const tableFullName = [schemaName, tableName].filter(Boolean).join('.'); + return { + label: { + label: columnName, + description: 'Column', + detail: ' ' + tableFullName + }, + range, + insertText: columnName + ' ', + kind: monaco.languages.CompletionItemKind.Field, + command: autoNext + ? { id: 'editor.action.triggerSuggest', title: '' } + : undefined, + sortText: CompletionItemSort.Column + }; +} + +export function functionItem( + func: IFunction, + range: monaco.languages.CompletionItemRanges | monaco.IRange +): monaco.languages.CompletionItem { + if (func.body) { + return { + label: { + label: func.name, + description: 'Function', + detail: ' ' + func.desc + }, + kind: monaco.languages.CompletionItemKind.Function, + documentation: func.desc, + insertText: func.body, + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + sortText: CompletionItemSort.Function + }; + } + + const params = + func.params + ?.map( + (param, index) => + `${'$'}{${index + 1}:${ + typeof param === 'string' ? param : param.name + }}` + ) + .join(', ') || ''; + const paramsDocument = + func.params + ?.map((param) => `${typeof param === 'string' ? param : param.name}`) + .join(', ') || ''; + + return { + label: { + label: func.name, + description: 'Function', + detail: ' ' + func.desc + }, + kind: monaco.languages.CompletionItemKind.Function, + documentation: `${func.name}(${paramsDocument})`, + insertText: `${func.name}(${params}) `, + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + sortText: CompletionItemSort.Function + }; +} + +export function snippetItem( + s: ISnippet, + range: monaco.languages.CompletionItemRanges | monaco.IRange +): monaco.languages.CompletionItem { + return { + label: s.label, + kind: monaco.languages.CompletionItemKind.Snippet, + documentation: s.documentation, + insertText: s.insertText, + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + sortText: CompletionItemSort.Snippet + }; +} + +export function schemaItem( + schemaName: string, + range: monaco.languages.CompletionItemRanges | monaco.IRange +): monaco.languages.CompletionItem { + return { + label: schemaName, + kind: monaco.languages.CompletionItemKind.Module, + detail: 'Schema', + insertText: schemaName, + range, + sortText: CompletionItemSort.Schema + }; +} diff --git a/src/component/MonacoEditor/plugins/pg-language/autoComplete/index.ts b/src/component/MonacoEditor/plugins/pg-language/autoComplete/index.ts new file mode 100644 index 000000000..be07fafc5 --- /dev/null +++ b/src/component/MonacoEditor/plugins/pg-language/autoComplete/index.ts @@ -0,0 +1,293 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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 * as monaco from 'monaco-editor'; +import { + functionItem, + keywordItem, + schemaItem, + snippetItem, + tableColumnItem, + tableItem +} from './completionItem'; +import { IPGModelOptions } from '../service'; +import functions from '../functions'; +import { keywords } from '../keywords'; +import simpleParser from '../parser/simpleParser'; + +class PGAutoComplete implements monaco.languages.CompletionItemProvider { + triggerCharacters?: string[] | undefined = ['.']; + private modelOptionsMap: Map = + new Map(); + + // constructor intentionally empty + + public setModelOptions( + modelId: string, + options: IPGModelOptions | null + ) { + this.modelOptionsMap.set(modelId, options); + } + + private getModelOptions(modelId: string): IPGModelOptions | null { + let options = this.modelOptionsMap.get(modelId); + // 如果找不到匹配的 modelId,尝试使用第一个可用的 modelOptions + // 这可以处理模型 ID 变化的情况 + if (!options && this.modelOptionsMap.size > 0) { + const firstEntry = this.modelOptionsMap.values().next(); + if (firstEntry.value) { + options = firstEntry.value; + } + } + return options || null; + } + + async getColumnList( + model: monaco.editor.ITextModel, + item: { tableName: string; schemaName?: string }, + range: monaco.IRange + ): Promise { + const modelOptions = this.getModelOptions(model.id); + const suggestions: monaco.languages.CompletionItem[] = []; + const autoNext = modelOptions?.autoNext ?? true; + const columns = await modelOptions?.getTableColumns?.( + item.tableName, + item.schemaName + ); + if (columns && Array.isArray(columns) && columns.length > 0) { + columns.forEach((column) => { + suggestions.push( + tableColumnItem( + column.columnName, + item.tableName, + item.schemaName || '', + range, + autoNext + ) + ); + }); + } + return suggestions; + } + + async getSchemaList( + model: monaco.editor.ITextModel, + range: monaco.IRange + ): Promise { + const modelOptions = this.getModelOptions(model.id); + const suggestions: monaco.languages.CompletionItem[] = []; + const schemaList = await modelOptions?.getSchemaList?.(); + if (schemaList && Array.isArray(schemaList) && schemaList.length > 0) { + schemaList.forEach((schema) => { + suggestions.push(schemaItem(schema, range)); + }); + } + return suggestions; + } + + async getTableList( + model: monaco.editor.ITextModel, + schema: string | undefined, + range: monaco.IRange + ): Promise { + const modelOptions = this.getModelOptions(model.id); + const suggestions: monaco.languages.CompletionItem[] = []; + const tables = await modelOptions?.getTableList?.(schema || ''); + if (tables && Array.isArray(tables) && tables.length > 0) { + tables.forEach((table) => { + // table 现在是 { name: string, type: string } 格式 + suggestions.push( + tableItem(table.name, schema || '', false, range, table.type) + ); + }); + } + return suggestions; + } + + public provideCompletionItems( + model: monaco.editor.ITextModel, + position: monaco.Position, + context: monaco.languages.CompletionContext, + _token: monaco.CancellationToken + ): monaco.languages.ProviderResult { + const triggerCharacter = context.triggerCharacter; + const modelOptions = this.getModelOptions(model.id); + const delimiter = modelOptions?.delimiter || ';'; + const word = model.getWordUntilPosition(position); + const range: monaco.IRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn + }; + + /** + * 自动触发补全,需要补一个字符来让解析顺利 + * 增加一个字符并不会对补全造成正确性的问题 + * 关键字补全:会自动去除当前的token + * 表名补全:不会访问当前的token + */ + let value = model.getValue(); + const offset = model.getOffsetAt(position); + if (word?.word?.trim() === '' && !triggerCharacter) { + value = value.substring(0, offset) + 's' + value.substring(offset); + } + + // 获取补全上下文 + const contexts = simpleParser.getCompletionContext( + value, + offset, + delimiter + ); + + // 如果没有上下文,返回关键字补全 + if (!contexts || contexts.length === 0) { + return this.getCompletionItems( + [{ type: 'keyword' }], + model, + range, + triggerCharacter + ); + } + + return this.getCompletionItems(contexts, model, range, triggerCharacter); + } + + private async getCompletionItems( + contexts: Array<{ + type: string; + schemaName?: string; + tableName?: string; + objectName?: string; + }>, + model: monaco.editor.ITextModel, + range: monaco.IRange, + _triggerCharacter?: string + ): Promise { + const suggestions: monaco.languages.CompletionItem[] = []; + const modelOptions = this.getModelOptions(model.id); + const autoNext = modelOptions?.autoNext ?? true; + for (const context of contexts) { + switch (context.type) { + case 'keyword': { + // 添加关键字 + keywords.forEach((keyword) => { + suggestions.push(keywordItem(keyword, range, autoNext)); + }); + // 添加函数 + functions.forEach((func) => { + suggestions.push(functionItem(func, range)); + }); + // 添加 snippets + const snippets = await modelOptions?.getSnippets?.(); + if (snippets) { + snippets.forEach((snippet) => { + suggestions.push(snippetItem(snippet, range)); + }); + } + break; + } + case 'allTables': { + // 获取表列表 + // PostgreSQL 使用 schema.table 格式 + const tableSuggestions = await this.getTableList( + model, + context.schemaName, + range + ); + suggestions.push(...tableSuggestions); + break; + } + case 'allSchemas': { + // 获取 schema 列表 + const schemaSuggestions = await this.getSchemaList(model, range); + suggestions.push(...schemaSuggestions); + break; + } + case 'column': { + // 获取列列表 + const tableName = context.tableName; + const schemaName = context.schemaName; + if (tableName) { + const columnSuggestions = await this.getColumnList( + model, + { tableName, schemaName }, + range + ); + suggestions.push(...columnSuggestions); + } + break; + } + case 'objectAccess': { + // 对象访问,可能是 schema.table 或 table.column + // PostgreSQL 使用 schema.table 格式 + const objectName = context.objectName; + if (objectName) { + const schemaList = await modelOptions?.getSchemaList?.(); + + // 检查是否是 schema + const isSchema = schemaList?.includes(objectName); + + if (isSchema) { + // 是 schema,获取该 schema 下的表列表 + const tableSuggestions = await this.getTableList( + model, + objectName, + range + ); + suggestions.push(...tableSuggestions); + } else { + // 可能是 table,尝试获取字段 + const columnSuggestions = await this.getColumnList( + model, + { tableName: objectName }, + range + ); + if (columnSuggestions && columnSuggestions.length > 0) { + suggestions.push(...columnSuggestions); + } else { + // 如果没有字段,返回该表的 schema 名作为备选 + const tableSuggestions = await this.getTableList( + model, + objectName, + range + ); + suggestions.push(...tableSuggestions); + } + } + } + break; + } + case 'allFunctions': { + // 添加函数 + const udf = await modelOptions?.getFunctions?.(); + const allFunctions = (udf || []).concat(functions); + allFunctions.forEach((func) => { + suggestions.push(functionItem(func, range)); + }); + break; + } + } + } + + return { + suggestions, + incomplete: false + }; + } +} + +export default PGAutoComplete; diff --git a/src/component/MonacoEditor/plugins/pg-language/config.ts b/src/component/MonacoEditor/plugins/pg-language/config.ts new file mode 100644 index 000000000..4801add28 --- /dev/null +++ b/src/component/MonacoEditor/plugins/pg-language/config.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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 { conf } from './monarch/pg'; + +export { conf }; diff --git a/src/component/MonacoEditor/plugins/pg-language/functions/index.ts b/src/component/MonacoEditor/plugins/pg-language/functions/index.ts new file mode 100644 index 000000000..2fcb27289 --- /dev/null +++ b/src/component/MonacoEditor/plugins/pg-language/functions/index.ts @@ -0,0 +1,1195 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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. + */ + +export interface IFunction { + name: string; + desc: string; + params?: IFunctionParam[]; + isNotSupport?: boolean; + body?: string; +} + +export interface IFunctionParamRich { + name: string; + desc?: string; + dataType?: string; +} + +export type IFunctionParam = IFunctionParamRich | string; + +/** + * 字符串函数 + */ +const stringFunctions: IFunction[] = [ + { + name: 'LENGTH', + params: [{ name: 'string' }], + desc: '返回字符串中的字符数。' + }, + { + name: 'CHAR_LENGTH', + params: [{ name: 'string' }], + desc: '返回字符串中的字符数。' + }, + { + name: 'SUBSTRING', + params: [{ name: 'string' }, { name: 'start' }, { name: 'length' }], + desc: '提取子字符串。' + }, + { + name: 'SUBSTR', + params: [{ name: 'string' }, { name: 'start' }, { name: 'length' }], + desc: '提取子字符串。' + }, + { + name: 'UPPER', + params: [{ name: 'string' }], + desc: '将字符串转换为大写。' + }, + { + name: 'LOWER', + params: [{ name: 'string' }], + desc: '将字符串转换为小写。' + }, + { + name: 'INITCAP', + params: [{ name: 'string' }], + desc: '将字符串中每个单词的首字母转换为大写。' + }, + { + name: 'TRIM', + params: [{ name: 'string' }], + desc: '删除字符串开头和结尾的空白字符。' + }, + { + name: 'LTRIM', + params: [{ name: 'string' }], + desc: '删除字符串开头的空白字符。' + }, + { + name: 'RTRIM', + params: [{ name: 'string' }], + desc: '删除字符串结尾的空白字符。' + }, + { + name: 'LPAD', + params: [{ name: 'string' }, { name: 'length' }, { name: 'fill' }], + desc: '将字符串填充到指定长度,在左侧添加填充字符。' + }, + { + name: 'RPAD', + params: [{ name: 'string' }, { name: 'length' }, { name: 'fill' }], + desc: '将字符串填充到指定长度,在右侧添加填充字符。' + }, + { + name: 'REPLACE', + params: [{ name: 'string' }, { name: 'from' }, { name: 'to' }], + desc: '替换字符串中所有匹配的子字符串。' + }, + { + name: 'OVERLAY', + params: [{ name: 'string' }, { name: 'placing' }, { name: 'from' }, { name: 'for' }], + desc: '覆盖子字符串。' + }, + { + name: 'POSITION', + params: [{ name: 'substring' }, { name: 'IN' }, { name: 'string' }], + desc: '返回子字符串在字符串中的位置。' + }, + { + name: 'STRPOS', + params: [{ name: 'string' }, { name: 'substring' }], + desc: '返回子字符串在字符串中的位置。' + }, + { + name: 'SPLIT_PART', + params: [{ name: 'string' }, { name: 'delimiter' }, { name: 'field' }], + desc: '按分隔符分割字符串并返回指定字段。' + }, + { + name: 'CONCAT', + params: [{ name: 'str1' }, { name: 'str2' }], + desc: '连接两个或更多字符串。' + }, + { + name: 'CONCAT_WS', + params: [{ name: 'separator' }, { name: 'str1' }, { name: 'str2' }], + desc: '使用分隔符连接字符串。' + }, + { + name: 'REVERSE', + params: [{ name: 'string' }], + desc: '返回反转的字符串。' + }, + { + name: 'REPEAT', + params: [{ name: 'string' }, { name: 'number' }], + desc: '重复字符串指定次数。' + }, + { + name: 'LEFT', + params: [{ name: 'string' }, { name: 'n' }], + desc: '返回字符串左侧前 n 个字符。' + }, + { + name: 'RIGHT', + params: [{ name: 'string' }, { name: 'n' }], + desc: '返回字符串右侧后 n 个字符。' + }, + { + name: 'MD5', + params: [{ name: 'string' }], + desc: '计算字符串的 MD5 哈希值。' + }, + { + name: 'ENCODE', + params: [{ name: 'data' }, { name: 'format' }], + desc: '将二进制数据编码为文本表示。' + }, + { + name: 'DECODE', + params: [{ name: 'string' }, { name: 'format' }], + desc: '将文本解码为二进制数据。' + }, + { + name: 'QUOTE_IDENT', + params: [{ name: 'string' }], + desc: '返回适当地引用的字符串作为 SQL 标识符。' + }, + { + name: 'QUOTE_LITERAL', + params: [{ name: 'string' }], + desc: '返回适当地引用的字符串作为 SQL 字符串字面量。' + }, + { + name: 'QUOTE_NULLABLE', + params: [{ name: 'value' }], + desc: '返回适当地引用的值作为 SQL 字符串字面量,可以为空。' + }, + { + name: 'REGEXP_MATCHES', + params: [{ name: 'string' }, { name: 'pattern' }, { name: 'flags' }], + desc: '返回匹配 POSIX 正则表达式的所有子串。' + }, + { + name: 'REGEXP_REPLACE', + params: [{ name: 'string' }, { name: 'pattern' }, { name: 'replacement' }, { name: 'flags' }], + desc: '替换匹配 POSIX 正则表达式的子串。' + }, + { + name: 'REGEXP_SPLIT_TO_ARRAY', + params: [{ name: 'string' }, { name: 'pattern' }, { name: 'flags' }], + desc: '使用 POSIX 正则表达式作为分隔符分割字符串为数组。' + }, + { + name: 'REGEXP_SPLIT_TO_TABLE', + params: [{ name: 'string' }, { name: 'pattern' }, { name: 'flags' }], + desc: '使用 POSIX 正则表达式作为分隔符分割字符串为表行。' + } +]; + +/** + * 日期和时间函数 + */ +const dateTimeFunctions: IFunction[] = [ + { + name: 'CURRENT_DATE', + params: [], + desc: '返回当前日期。' + }, + { + name: 'CURRENT_TIME', + params: [{ name: 'precision' }], + desc: '返回当前时间。' + }, + { + name: 'CURRENT_TIMESTAMP', + params: [{ name: 'precision' }], + desc: '返回当前日期和时间。' + }, + { + name: 'LOCALTIME', + params: [{ name: 'precision' }], + desc: '返回当前时间(不含时区)。' + }, + { + name: 'LOCALTIMESTAMP', + params: [{ name: 'precision' }], + desc: '返回当前日期和时间(不含时区)。' + }, + { + name: 'NOW', + params: [], + desc: '返回当前日期和时间。' + }, + { + name: 'TRANSACTION_TIMESTAMP', + params: [], + desc: '返回当前事务开始时的日期和时间。' + }, + { + name: 'STATEMENT_TIMESTAMP', + params: [], + desc: '返回当前语句开始时的日期和时间。' + }, + { + name: 'CLOCK_TIMESTAMP', + params: [], + desc: '返回当前实际日期和时间。' + }, + { + name: 'TIMEOFDAY', + params: [], + desc: '返回当前日期和时间作为文本字符串。' + }, + { + name: 'TODAY', + params: [], + desc: '返回当前日期。' + }, + { + name: 'TOMORROW', + params: [], + desc: '返回明天的日期。' + }, + { + name: 'YESTERDAY', + params: [], + desc: '返回昨天的日期。' + }, + { + name: 'AGE', + params: [{ name: 'timestamp' }, { name: 'timestamp' }], + desc: '计算两个时间戳之间的时间间隔。' + }, + { + name: 'DATE_PART', + params: [{ name: 'field' }, { name: 'timestamp' }], + desc: '提取时间戳的指定部分。' + }, + { + name: 'EXTRACT', + body: 'EXTRACT(${1:field} FROM ${2:timestamp})', + desc: '从时间戳中提取指定部分。' + }, + { + name: 'DATE_TRUNC', + params: [{ name: 'field' }, { name: 'timestamp' }], + desc: '将时间戳截断到指定精度。' + }, + { + name: 'DATE_BIN', + params: [{ name: 'stride' }, { name: 'source' }, { name: 'origin' }], + desc: '将时间戳对齐到指定间隔。' + }, + { + name: 'TO_DATE', + params: [{ name: 'string' }, { name: 'format' }], + desc: '将字符串转换为日期。' + }, + { + name: 'TO_TIMESTAMP', + params: [{ name: 'string' }, { name: 'format' }], + desc: '将字符串转换为时间戳。支持多态:TO_TIMESTAMP(string, format) 将字符串按格式转换;TO_TIMESTAMP(epoch) 将 Unix 纪元时间(double precision)转换为时间戳。' + }, + { + name: 'MAKE_DATE', + params: [{ name: 'year' }, { name: 'month' }, { name: 'day' }], + desc: '从年、月、日创建日期。' + }, + { + name: 'MAKE_TIME', + params: [{ name: 'hour' }, { name: 'min' }, { name: 'sec' }], + desc: '从时、分、秒创建时间。' + }, + { + name: 'MAKE_TIMESTAMP', + params: [{ name: 'year' }, { name: 'month' }, { name: 'day' }, { name: 'hour' }, { name: 'min' }, { name: 'sec' }], + desc: '从各部分创建时间戳。' + }, + { + name: 'MAKE_TIMESTAMPTZ', + params: [{ name: 'year' }, { name: 'month' }, { name: 'day' }, { name: 'hour' }, { name: 'min' }, { name: 'sec' }, { name: 'timezone' }], + desc: '从各部分创建带时区的时间戳。' + }, + { + name: 'ISFINITE', + params: [{ name: 'date' }], + desc: '检查日期是否为有限值(不是无限或无效)。' + }, + { + name: 'JUSTIFY_DAYS', + params: [{ name: 'interval' }], + desc: '调整间隔中的天数部分为月和日。' + }, + { + name: 'JUSTIFY_HOURS', + params: [{ name: 'interval' }], + desc: '调整间隔中的小时部分为天。' + }, + { + name: 'JUSTIFY_INTERVAL', + params: [{ name: 'interval' }], + desc: '调整间隔使用 justify_days 和 justify_hours。' + }, + { + name: 'PG_SLEEP', + params: [{ name: 'seconds' }], + desc: '使服务器进程休眠指定的秒数。' + }, + { + name: 'PG_SLEEP_FOR', + params: [{ name: 'interval' }], + desc: '使服务器进程休眠指定的时间间隔。' + }, + { + name: 'PG_SLEEP_UNTIL', + params: [{ name: 'timestamp' }], + desc: '使服务器进程休眠直到指定的时间戳。' + } +]; + +/** + * 数学函数 + */ +const mathFunctions: IFunction[] = [ + { + name: 'ABS', + params: [{ name: 'number' }], + desc: '返回绝对值。' + }, + { + name: 'CEIL', + params: [{ name: 'number' }], + desc: '返回大于或等于参数的最小整数。' + }, + { + name: 'CEILING', + params: [{ name: 'number' }], + desc: '返回大于或等于参数的最小整数。' + }, + { + name: 'FLOOR', + params: [{ name: 'number' }], + desc: '返回小于或等于参数的最大整数。' + }, + { + name: 'ROUND', + params: [{ name: 'number' }, { name: 'decimals' }], + desc: '将数字四舍五入到指定的小数位数。' + }, + { + name: 'TRUNC', + params: [{ name: 'number' }, { name: 'decimals' }], + desc: '将数字截断到指定的小数位数。' + }, + { + name: 'DIV', + params: [{ name: 'a' }, { name: 'b' }], + desc: '返回整数除法的整数商。' + }, + { + name: 'MOD', + params: [{ name: 'a' }, { name: 'b' }], + desc: '返回整数除法的余数。' + }, + { + name: 'POWER', + params: [{ name: 'a' }, { name: 'b' }], + desc: '返回 a 的 b 次幂。' + }, + { + name: 'SQRT', + params: [{ name: 'number' }], + desc: '返回平方根。' + }, + { + name: 'CBRT', + params: [{ name: 'number' }], + desc: '返回立方根。' + }, + { + name: 'SIGN', + params: [{ name: 'number' }], + desc: '返回数字的符号(-1, 0, 1)。' + }, + { + name: 'EXP', + params: [{ name: 'number' }], + desc: '返回 e 的指定次幂。' + }, + { + name: 'LN', + params: [{ name: 'number' }], + desc: '返回自然对数。' + }, + { + name: 'LOG', + params: [{ name: 'number' }], + desc: '返回以 10 为底的对数。' + }, + { + name: 'LOG', + params: [{ name: 'base' }, { name: 'number' }], + desc: '返回指定底数的对数。' + }, + { + name: 'PI', + params: [], + desc: '返回 π 的值。' + }, + { + name: 'DEGREES', + params: [{ name: 'radians' }], + desc: '将弧度转换为角度。' + }, + { + name: 'RADIANS', + params: [{ name: 'degrees' }], + desc: '将角度转换为弧度。' + }, + { + name: 'SIN', + params: [{ name: 'radians' }], + desc: '返回正弦值。' + }, + { + name: 'COS', + params: [{ name: 'radians' }], + desc: '返回余弦值。' + }, + { + name: 'TAN', + params: [{ name: 'radians' }], + desc: '返回正切值。' + }, + { + name: 'COT', + params: [{ name: 'radians' }], + desc: '返回余切值。' + }, + { + name: 'ASIN', + params: [{ name: 'number' }], + desc: '返回反正弦值。' + }, + { + name: 'ACOS', + params: [{ name: 'number' }], + desc: '返回反余弦值。' + }, + { + name: 'ATAN', + params: [{ name: 'number' }], + desc: '返回反正切值。' + }, + { + name: 'ATAN2', + params: [{ name: 'y' }, { name: 'x' }], + desc: '返回 y/x 的反正切值。' + }, + { + name: 'RANDOM', + params: [], + desc: '返回 0.0 到 1.0 之间的随机数。' + }, + { + name: 'SETSEED', + params: [{ name: 'seed' }], + desc: '设置随机数生成器的种子。' + }, + { + name: 'WIDTH_BUCKET', + params: [{ name: 'operand' }, { name: 'b1' }, { name: 'b2' }, { name: 'count' }], + desc: '返回操作数所在的桶编号。' + } +]; + +/** + * 聚合函数 + */ +const aggregateFunctions: IFunction[] = [ + { + name: 'AVG', + params: [{ name: 'expression' }], + desc: '计算平均值。' + }, + { + name: 'COUNT', + params: [{ name: 'expression' }], + desc: '返回非空值的数量。' + }, + { + name: 'MAX', + params: [{ name: 'expression' }], + desc: '返回最大值。' + }, + { + name: 'MIN', + params: [{ name: 'expression' }], + desc: '返回最小值。' + }, + { + name: 'SUM', + params: [{ name: 'expression' }], + desc: '计算总和。' + }, + { + name: 'BIT_AND', + params: [{ name: 'expression' }], + desc: '计算位与聚合。' + }, + { + name: 'BIT_OR', + params: [{ name: 'expression' }], + desc: '计算位或聚合。' + }, + { + name: 'BOOL_AND', + params: [{ name: 'expression' }], + desc: '如果所有输入都为真,则返回真。' + }, + { + name: 'BOOL_OR', + params: [{ name: 'expression' }], + desc: '如果至少有一个输入为真,则返回真。' + }, + { + name: 'EVERY', + params: [{ name: 'expression' }], + desc: '等同于 BOOL_AND。' + }, + { + name: 'ARRAY_AGG', + params: [{ name: 'expression' }], + desc: '将值聚合为数组。' + }, + { + name: 'STRING_AGG', + params: [{ name: 'expression' }, { name: 'delimiter' }], + desc: '将值聚合并用分隔符连接。' + }, + { + name: 'XMLAGG', + params: [{ name: 'expression' }], + desc: '将 XML 值聚合为单个 XML 值。' + }, + { + name: 'JSON_AGG', + params: [{ name: 'expression' }], + desc: '将值聚合为 JSON 数组。' + }, + { + name: 'JSONB_AGG', + params: [{ name: 'expression' }], + desc: '将值聚合为 JSONB 数组。' + }, + { + name: 'JSON_OBJECT_AGG', + params: [{ name: 'key' }, { name: 'value' }], + desc: '将键值对聚合为 JSON 对象。' + }, + { + name: 'JSONB_OBJECT_AGG', + params: [{ name: 'key' }, { name: 'value' }], + desc: '将键值对聚合为 JSONB 对象。' + }, + { + name: 'STDDEV', + params: [{ name: 'expression' }], + desc: '计算样本标准差。' + }, + { + name: 'STDDEV_SAMP', + params: [{ name: 'expression' }], + desc: '计算样本标准差。' + }, + { + name: 'STDDEV_POP', + params: [{ name: 'expression' }], + desc: '计算总体标准差。' + }, + { + name: 'VARIANCE', + params: [{ name: 'expression' }], + desc: '计算样本方差。' + }, + { + name: 'VAR_SAMP', + params: [{ name: 'expression' }], + desc: '计算样本方差。' + }, + { + name: 'VAR_POP', + params: [{ name: 'expression' }], + desc: '计算总体方差。' + }, + { + name: 'CORR', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算相关系数。' + }, + { + name: 'COVAR_POP', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算总体协方差。' + }, + { + name: 'COVAR_SAMP', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算样本协方差。' + }, + { + name: 'REGR_AVGX', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算自变量的平均值。' + }, + { + name: 'REGR_AVGY', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算因变量的平均值。' + }, + { + name: 'REGR_COUNT', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算非空行数。' + }, + { + name: 'REGR_INTERCEPT', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算线性回归的 y 截距。' + }, + { + name: 'REGR_R2', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算决定系数 R²。' + }, + { + name: 'REGR_SLOPE', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算线性回归的斜率。' + }, + { + name: 'REGR_SXX', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算 sum(X²) - sum(X)²/N。' + }, + { + name: 'REGR_SXY', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算 sum(X*Y) - sum(X)sum(Y)/N。' + }, + { + name: 'REGR_SYY', + params: [{ name: 'y' }, { name: 'x' }], + desc: '计算 sum(Y²) - sum(Y)²/N。' + } +]; + +/** + * 窗口函数 + */ +const windowFunctions: IFunction[] = [ + { + name: 'ROW_NUMBER', + params: [], + desc: '为分区中的行分配唯一序号。' + }, + { + name: 'RANK', + params: [], + desc: '返回当前行的排名,带间隙。' + }, + { + name: 'DENSE_RANK', + params: [], + desc: '返回当前行的排名,不带间隙。' + }, + { + name: 'PERCENT_RANK', + params: [], + desc: '返回当前行的相对排名。' + }, + { + name: 'CUME_DIST', + params: [], + desc: '返回累积分布。' + }, + { + name: 'NTILE', + params: [{ name: 'num_buckets' }], + desc: '将行分配到指定数量的桶中。' + }, + { + name: 'LAG', + params: [{ name: 'value' }, { name: 'offset' }, { name: 'default' }], + desc: '返回分区中前一个行的值。' + }, + { + name: 'LEAD', + params: [{ name: 'value' }, { name: 'offset' }, { name: 'default' }], + desc: '返回分区中后一个行的值。' + }, + { + name: 'FIRST_VALUE', + params: [{ name: 'value' }], + desc: '返回窗口中第一行的值。' + }, + { + name: 'LAST_VALUE', + params: [{ name: 'value' }], + desc: '返回窗口中最后一行的值。' + }, + { + name: 'NTH_VALUE', + params: [{ name: 'value' }, { name: 'nth' }], + desc: '返回窗口中第 n 行的值。' + } +]; + +/** + * JSON 函数 + */ +const jsonFunctions: IFunction[] = [ + { + name: 'JSON_ARRAY_LENGTH', + params: [{ name: 'json' }], + desc: '返回 JSON 数组的长度。' + }, + { + name: 'JSONB_ARRAY_LENGTH', + params: [{ name: 'jsonb' }], + desc: '返回 JSONB 数组的长度。' + }, + { + name: 'JSON_EACH', + params: [{ name: 'json' }], + desc: '将 JSON 对象扩展为键值对集合。' + }, + { + name: 'JSONB_EACH', + params: [{ name: 'jsonb' }], + desc: '将 JSONB 对象扩展为键值对集合。' + }, + { + name: 'JSON_EXTRACT_PATH', + params: [{ name: 'json' }, { name: 'path' }], + desc: '从 JSON 值中提取指定路径的值。' + }, + { + name: 'JSONB_EXTRACT_PATH', + params: [{ name: 'jsonb' }, { name: 'path' }], + desc: '从 JSONB 值中提取指定路径的值。' + }, + { + name: 'JSON_EXTRACT_PATH_TEXT', + params: [{ name: 'json' }, { name: 'path' }], + desc: '从 JSON 值中提取指定路径的值作为文本。' + }, + { + name: 'JSONB_EXTRACT_PATH_TEXT', + params: [{ name: 'jsonb' }, { name: 'path' }], + desc: '从 JSONB 值中提取指定路径的值作为文本。' + }, + { + name: 'JSON_OBJECT_KEYS', + params: [{ name: 'json' }], + desc: '返回 JSON 对象的键集合。' + }, + { + name: 'JSONB_OBJECT_KEYS', + params: [{ name: 'jsonb' }], + desc: '返回 JSONB 对象的键集合。' + }, + { + name: 'JSON_POPULATE_RECORD', + params: [{ name: 'base' }, { name: 'json' }], + desc: '从 JSON 对象创建记录。' + }, + { + name: 'JSONB_POPULATE_RECORD', + params: [{ name: 'base' }, { name: 'jsonb' }], + desc: '从 JSONB 对象创建记录。' + }, + { + name: 'JSON_STRIP_NULLS', + params: [{ name: 'json' }], + desc: '删除 JSON 值中的空值字段。' + }, + { + name: 'JSONB_STRIP_NULLS', + params: [{ name: 'jsonb' }], + desc: '删除 JSONB 值中的空值字段。' + }, + { + name: 'JSON_TYPEOF', + params: [{ name: 'json' }], + desc: '返回 JSON 值的类型。' + }, + { + name: 'JSONB_TYPEOF', + params: [{ name: 'jsonb' }], + desc: '返回 JSONB 值的类型。' + }, + { + name: 'JSON_TO_RECORD', + params: [{ name: 'json' }], + desc: '将 JSON 对象转换为记录。' + }, + { + name: 'JSONB_TO_RECORD', + params: [{ name: 'jsonb' }], + desc: '将 JSONB 对象转换为记录。' + }, + { + name: 'TO_JSON', + params: [{ name: 'value' }], + desc: '将 SQL 值转换为 JSON。' + }, + { + name: 'TO_JSONB', + params: [{ name: 'value' }], + desc: '将 SQL 值转换为 JSONB。' + }, + { + name: 'JSON_BUILD_ARRAY', + params: [{ name: 'elements' }], + desc: '从元素构建 JSON 数组。' + }, + { + name: 'JSONB_BUILD_ARRAY', + params: [{ name: 'elements' }], + desc: '从元素构建 JSONB 数组。' + }, + { + name: 'JSON_BUILD_OBJECT', + params: [{ name: 'keys_and_values' }], + desc: '从键值对构建 JSON 对象。' + }, + { + name: 'JSONB_BUILD_OBJECT', + params: [{ name: 'keys_and_values' }], + desc: '从键值对构建 JSONB 对象。' + }, + { + name: 'JSONB_PRETTY', + params: [{ name: 'jsonb' }], + desc: '返回格式化的 JSONB。' + }, + { + name: 'JSONB_PATH_QUERY', + params: [{ name: 'target' }, { name: 'path' }], + desc: '从 JSONB 中查询匹配 SQL/JSON 路径的项。' + }, + { + name: 'JSONB_PATH_QUERY_ARRAY', + params: [{ name: 'target' }, { name: 'path' }], + desc: '从 JSONB 中查询匹配 SQL/JSON 路径的项作为数组。' + }, + { + name: 'JSONB_PATH_QUERY_FIRST', + params: [{ name: 'target' }, { name: 'path' }], + desc: '从 JSONB 中查询第一个匹配 SQL/JSON 路径的项。' + } +]; + +/** + * 系统函数 + */ +const systemFunctions: IFunction[] = [ + { + name: 'CAST', + body: 'CAST(${1:expression} AS ${2:type})', + desc: '类型转换。' + }, + { + name: 'COALESCE', + params: [{ name: 'value1' }, { name: 'value2' }], + desc: '返回第一个非空参数。' + }, + { + name: 'NULLIF', + params: [{ name: 'value1' }, { name: 'value2' }], + desc: '如果两个值相等,返回 NULL。' + }, + { + name: 'GREATEST', + params: [{ name: 'value1' }, { name: 'value2' }], + desc: '返回参数中的最大值。' + }, + { + name: 'LEAST', + params: [{ name: 'value1' }, { name: 'value2' }], + desc: '返回参数中的最小值。' + }, + { + name: 'GENERATE_SERIES', + params: [{ name: 'start' }, { name: 'stop' }, { name: 'step' }], + desc: '生成一个整数序列。' + }, + { + name: 'GENERATE_SUBSCRIPTS', + params: [{ name: 'array' }, { name: 'dimension' }], + desc: '生成数组下标序列。' + }, + { + name: 'CURRENT_DATABASE', + params: [], + desc: '返回当前数据库名。' + }, + { + name: 'CURRENT_SCHEMA', + params: [], + desc: '返回当前 schema 名。' + }, + { + name: 'CURRENT_SCHEMAS', + params: [{ name: 'include_implicit' }], + desc: '返回有效 schema 搜索路径。' + }, + { + name: 'CURRENT_USER', + params: [], + desc: '返回当前用户名。' + }, + { + name: 'SESSION_USER', + params: [], + desc: '返回会话用户名。' + }, + { + name: 'CURRENT_SETTING', + params: [{ name: 'setting_name' }], + desc: '返回当前配置参数值。' + }, + { + name: 'SET_CONFIG', + params: [{ name: 'setting_name' }, { name: 'new_value' }, { name: 'is_local' }], + desc: '设置配置参数。' + }, + { + name: 'PG_GET_INDEXDEF', + params: [{ name: 'index_oid' }], + desc: '返回索引的定义语句。' + }, + { + name: 'PG_GET_FUNCTIONDEF', + params: [{ name: 'func_oid' }], + desc: '返回函数的定义语句。' + }, + { + name: 'PG_GET_TABLEDEF', + params: [{ name: 'table_oid' }], + desc: '返回表的定义语句。' + }, + { + name: 'PG_GET_CONSTRAINTDEF', + params: [{ name: 'constraint_oid' }], + desc: '返回约束的定义语句。' + }, + { + name: 'PG_GET_EXPR', + params: [{ name: 'expr' }, { name: 'relation_oid' }], + desc: '反编译表达式的内部形式。' + }, + { + name: 'PG_GET_SERIAL_SEQUENCE', + params: [{ name: 'table' }, { name: 'column' }], + desc: '返回序列列关联的序列名。' + }, + { + name: 'PG_GET_USERBYID', + params: [{ name: 'role_oid' }], + desc: '根据 OID 获取角色名。' + }, + { + name: 'PG_GET_VIEWDEF', + params: [{ name: 'view_oid' }], + desc: '返回视图的定义语句。' + }, + { + name: 'VERSION', + params: [], + desc: '返回 PostgreSQL 版本信息。' + }, + { + name: 'PG_BACKEND_PID', + params: [], + desc: '返回当前会话的服务器进程 ID。' + }, + { + name: 'PG_CANCEL_BACKEND', + params: [{ name: 'pid' }], + desc: '取消指定后端进程的当前查询。' + }, + { + name: 'PG_TERMINATE_BACKEND', + params: [{ name: 'pid' }], + desc: '终止指定后端进程。' + }, + { + name: 'PG_IS_IN_RECOVERY', + params: [], + desc: '检查数据库是否处于恢复模式。' + }, + { + name: 'PG_CONTROL_CHECKPOINT', + params: [], + desc: '返回检查点信息。' + }, + { + name: 'PG_CONTROL_SYSTEM', + params: [], + desc: '返回系统控制信息。' + }, + { + name: 'PG_STAT_FILE', + params: [{ name: 'filename' }], + desc: '返回文件状态信息。' + }, + { + name: 'PG_READ_FILE', + params: [{ name: 'filename' }, { name: 'offset' }, { name: 'length' }], + desc: '读取文件内容。' + }, + { + name: 'PG_READ_BINARY_FILE', + params: [{ name: 'filename' }, { name: 'offset' }, { name: 'length' }], + desc: '读取二进制文件内容。' + }, + { + name: 'PG_LS_DIR', + params: [{ name: 'dirname' }], + desc: '列出目录内容。' + }, + { + name: 'FORMAT', + params: [{ name: 'format_string' }, { name: 'arguments' }], + desc: '格式化字符串。' + }, + { + name: 'BTRIM', + params: [{ name: 'string' }, { name: 'characters' }], + desc: '删除字符串两端指定的字符。' + }, + { + name: 'CHR', + params: [{ name: 'code' }], + desc: '返回指定 Unicode 码点的字符。' + }, + { + name: 'ASCII', + params: [{ name: 'char' }], + desc: '返回字符的 ASCII 码。' + }, + { + name: 'PG_CLIENT_ENCODING', + params: [], + desc: '返回客户端编码名称。' + } +]; + +/** + * 数组函数 + */ +const arrayFunctions: IFunction[] = [ + { + name: 'ARRAY_APPEND', + params: [{ name: 'array' }, { name: 'element' }], + desc: '向数组末尾添加元素。' + }, + { + name: 'ARRAY_CAT', + params: [{ name: 'array1' }, { name: 'array2' }], + desc: '连接两个数组。' + }, + { + name: 'ARRAY_NDIMS', + params: [{ name: 'array' }], + desc: '返回数组的维度数。' + }, + { + name: 'ARRAY_DIMS', + params: [{ name: 'array' }], + desc: '返回数组维度的文本表示。' + }, + { + name: 'ARRAY_FILL', + params: [{ name: 'value' }, { name: 'dimensions' }], + desc: '创建填充指定值的数组。' + }, + { + name: 'ARRAY_LENGTH', + params: [{ name: 'array' }, { name: 'dimension' }], + desc: '返回数组指定维度的长度。' + }, + { + name: 'ARRAY_LOWER', + params: [{ name: 'array' }, { name: 'dimension' }], + desc: '返回数组指定维度的下界。' + }, + { + name: 'ARRAY_PREPEND', + params: [{ name: 'element' }, { name: 'array' }], + desc: '向数组开头添加元素。' + }, + { + name: 'ARRAY_REMOVE', + params: [{ name: 'array' }, { name: 'element' }], + desc: '删除数组中所有等于指定值的元素。' + }, + { + name: 'ARRAY_REPLACE', + params: [{ name: 'array' }, { name: 'old' }, { name: 'new' }], + desc: '替换数组中的元素。' + }, + { + name: 'ARRAY_TO_STRING', + params: [{ name: 'array' }, { name: 'delimiter' }, { name: 'null_string' }], + desc: '将数组转换为字符串。' + }, + { + name: 'ARRAY_UPPER', + params: [{ name: 'array' }, { name: 'dimension' }], + desc: '返回数组指定维度的上界。' + }, + { + name: 'CARDINALITY', + params: [{ name: 'array' }], + desc: '返回数组的总元素数。' + }, + { + name: 'STRING_TO_ARRAY', + params: [{ name: 'string' }, { name: 'delimiter' }, { name: 'null_string' }], + desc: '将字符串分割为数组。' + }, + { + name: 'UNNEST', + params: [{ name: 'array' }], + desc: '将数组扩展为一组行。' + } +]; + +const functions: IFunction[] = stringFunctions + .concat(dateTimeFunctions) + .concat(mathFunctions) + .concat(aggregateFunctions) + .concat(windowFunctions) + .concat(jsonFunctions) + .concat(systemFunctions) + .concat(arrayFunctions); + +export default functions; diff --git a/src/component/MonacoEditor/plugins/pg-language/hover/index.ts b/src/component/MonacoEditor/plugins/pg-language/hover/index.ts new file mode 100644 index 000000000..8458f95c6 --- /dev/null +++ b/src/component/MonacoEditor/plugins/pg-language/hover/index.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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 * as monaco from 'monaco-editor'; +import { IPGModelOptions } from '../service'; +import simpleParser from '../parser/simpleParser'; + +class PGHover implements monaco.languages.HoverProvider { + private modelOptionsMap: Map = + new Map(); + + constructor() {} + + public setModelOptions( + modelId: string, + options: IPGModelOptions | null + ) { + this.modelOptionsMap.set(modelId, options); + } + + private getModelOptions(modelId: string): IPGModelOptions | null { + return this.modelOptionsMap.get(modelId) || null; + } + + provideHover( + model: monaco.editor.ITextModel, + position: monaco.Position, + token: monaco.CancellationToken + ): monaco.languages.ProviderResult { + const modelOptions = this.getModelOptions(model.id); + if (!modelOptions?.getTableDDL) { + return; + } + + return new Promise(async (resolve) => { + const word = model.getWordAtPosition(position); + if (!word) { + return resolve(null); + } + + const delimiter = modelOptions.delimiter || ';'; + const offset = model.getOffsetAt(position); + const value = model.getValue(); + + // 使用简单解析器获取表信息 + const tableInfo = simpleParser.getTableAtOffset(value, offset, delimiter); + if (!tableInfo || !tableInfo.name) { + resolve(null); + return; + } + + const ddl = await modelOptions.getTableDDL( + tableInfo.name, + tableInfo.schema + ); + if (!ddl) { + resolve(null); + return; + } + + resolve({ + contents: [ + { + value: '```sql\n' + ddl + '\n```' + } + ] + }); + }); + } +} + +export default PGHover; diff --git a/src/component/MonacoEditor/plugins/pg-language/index.tsx b/src/component/MonacoEditor/plugins/pg-language/index.tsx new file mode 100644 index 000000000..c5152ad41 --- /dev/null +++ b/src/component/MonacoEditor/plugins/pg-language/index.tsx @@ -0,0 +1,108 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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 * as monaco from 'monaco-editor'; +import { conf, language } from './monarch/pg'; +import { IPGModelOptions } from './service'; +import PGAutoComplete from './autoComplete'; +import PGHover from './hover'; + +// PostgreSQL 插件接口,提供与 ob-language 插件类似的 API +export interface IPGPlugin { + setup(languages: string[]): void; + setModelOptions( + modelId: string, + options: IPGModelOptions | null + ): void; +} + +const LANGUAGE_ID = 'pg'; + +class PGPlugin implements IPGPlugin { + private registeredLanguages: string[] = []; + private autoCompleteProvider: PGAutoComplete; + private hoverProvider: PGHover; + private isLanguageRegistered = false; + + constructor() { + this.autoCompleteProvider = new PGAutoComplete(); + this.hoverProvider = new PGHover(); + } + + setup(languages: string[]): void { + languages.forEach((lang) => { + if (!this.registeredLanguages.includes(lang)) { + this.registeredLanguages.push(lang); + } + }); + + // 只注册一次语言 + if (!this.isLanguageRegistered) { + // 注册 PostgreSQL 语言 + monaco.languages.register({ + id: LANGUAGE_ID + }); + + // 设置 Monarch 语法高亮 + monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, language); + + // 设置语言配置 + monaco.languages.setLanguageConfiguration(LANGUAGE_ID, conf); + + // 注册代码补全 Provider + monaco.languages.registerCompletionItemProvider( + LANGUAGE_ID, + this.autoCompleteProvider + ); + + // 注册悬停提示 Provider + monaco.languages.registerHoverProvider(LANGUAGE_ID, this.hoverProvider); + + this.isLanguageRegistered = true; + } + } + + setModelOptions( + modelId: string, + options: IPGModelOptions | null + ): void { + this.autoCompleteProvider.setModelOptions(modelId, options); + this.hoverProvider.setModelOptions(modelId, options); + } + + getRegisteredLanguages(): string[] { + return [...this.registeredLanguages]; + } +} + +let plugin: PGPlugin | null = null; + +export function register(language: string): IPGPlugin { + language = language || 'pg'; + + if (!plugin) { + plugin = new PGPlugin(); + plugin.setup([language]); + return plugin; + } + + const registeredLanguages = (plugin as any).getRegisteredLanguages?.() || []; + if (language && !registeredLanguages.includes(language)) { + plugin.setup([language]); + } + + return plugin; +} diff --git a/src/component/MonacoEditor/plugins/pg-language/keywords/index.ts b/src/component/MonacoEditor/plugins/pg-language/keywords/index.ts new file mode 100644 index 000000000..dfaf6bbdd --- /dev/null +++ b/src/component/MonacoEditor/plugins/pg-language/keywords/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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 reservedKeywords from './reserved'; + +export const keywords = reservedKeywords; diff --git a/src/component/MonacoEditor/plugins/pg-language/keywords/reserved.ts b/src/component/MonacoEditor/plugins/pg-language/keywords/reserved.ts new file mode 100644 index 000000000..fd78a18d9 --- /dev/null +++ b/src/component/MonacoEditor/plugins/pg-language/keywords/reserved.ts @@ -0,0 +1,412 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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. + */ + +// PostgreSQL 保留关键字列表 (基于 PostgreSQL 16 文档) +const words: string[] = [ + // SQL 标准保留关键字 + 'ALL', + 'ANALYSE', + 'ANALYZE', + 'AND', + 'ANY', + 'ARRAY', + 'AS', + 'ASC', + 'ASYMMETRIC', + 'BOTH', + 'CASE', + 'CAST', + 'CHECK', + 'COLLATE', + 'COLUMN', + 'CONSTRAINT', + 'CREATE', + 'CURRENT_CATALOG', + 'CURRENT_DATE', + 'CURRENT_ROLE', + 'CURRENT_TIME', + 'CURRENT_TIMESTAMP', + 'CURRENT_USER', + 'DEFAULT', + 'DEFERRABLE', + 'DESC', + 'DISTINCT', + 'DO', + 'ELSE', + 'END', + 'EXCEPT', + 'FALSE', + 'FETCH', + 'FOR', + 'FOREIGN', + 'FROM', + 'GRANT', + 'GROUP', + 'HAVING', + 'IN', + 'INITIALLY', + 'INTERSECT', + 'INTO', + 'LATERAL', + 'LEADING', + 'LIMIT', + 'LOCALTIME', + 'LOCALTIMESTAMP', + 'NOT', + 'NULL', + 'OFFSET', + 'ON', + 'ONLY', + 'OR', + 'ORDER', + 'PLACING', + 'PRIMARY', + 'REFERENCES', + 'RETURNING', + 'SELECT', + 'SESSION_USER', + 'SOME', + 'SYMMETRIC', + 'TABLE', + 'THEN', + 'TO', + 'TRAILING', + 'TRUE', + 'UNION', + 'UNIQUE', + 'USER', + 'USING', + 'VARIADIC', + 'WHEN', + 'WHERE', + 'WINDOW', + 'WITH', + + // PostgreSQL 特有关键字 + 'AUTHORIZATION', + 'BINARY', + 'CONCURRENTLY', + 'CROSS', + 'CURRENT_SCHEMA', + 'FREEZE', + 'FULL', + 'ILIKE', + 'INNER', + 'IS', + 'ISNULL', + 'JOIN', + 'LEFT', + 'LIKE', + 'NATURAL', + 'NOTNULL', + 'OUTER', + 'OVER', + 'OVERLAPS', + 'RIGHT', + 'SIMILAR', + 'TABLESAMPLE', + 'VERBOSE', + + // 类型关键字 + 'BIGINT', + 'BIT', + 'BOOLEAN', + 'CHAR', + 'CHARACTER', + 'DATE', + 'DECIMAL', + 'DOUBLE', + 'FLOAT', + 'INTEGER', + 'INTERVAL', + 'NUMERIC', + 'REAL', + 'SMALLINT', + 'TEXT', + 'TIME', + 'TIMESTAMP', + 'VARCHAR', + 'XML', + + // DDL 关键字 + 'ADD', + 'ALTER', + 'DROP', + 'MODIFY', + 'RENAME', + 'TRUNCATE', + + // DML 关键字 + 'DELETE', + 'INSERT', + 'UPDATE', + 'VALUES', + + // 索引相关 + 'INDEX', + 'UNIQUE', + 'USING', + 'BTREE', + 'HASH', + 'GIN', + 'GIST', + 'SPGIST', + 'BRIN', + + // 约束相关 + 'EXCLUDE', + 'DEFERRABLE', + 'IMMEDIATE', + + // 事务相关 + 'ABORT', + 'BEGIN', + 'COMMIT', + 'ROLLBACK', + 'SAVEPOINT', + 'RELEASE', + 'PREPARE', + 'EXECUTE', + 'DEALLOCATE', + + // 视图/函数/存储过程 + 'VIEW', + 'MATERIALIZED', + 'FUNCTION', + 'PROCEDURE', + 'RETURNS', + 'LANGUAGE', + 'VOLATILE', + 'STABLE', + 'IMMUTABLE', + 'STRICT', + 'CALLED', + 'SECURITY', + 'DEFINER', + 'INVOKER', + 'COST', + 'ROWS', + 'SET', + 'DECLARE', + 'RAISE', + 'RETURN', + 'PERFORM', + 'GET', + 'DIAGNOSTICS', + 'FOUND', + 'ROW_COUNT', + 'IF', + 'THEN', + 'ELSIF', + 'WHILE', + 'LOOP', + 'EXIT', + 'CONTINUE', + 'FOR', + 'FOREACH', + 'IN', + 'REVERSE', + 'BY', + + // 游标相关 + 'CURSOR', + 'FETCH', + 'MOVE', + 'CLOSE', + 'OPEN', + + // 触发器相关 + 'TRIGGER', + 'BEFORE', + 'AFTER', + 'INSTEAD', + 'EACH', + 'ROW', + 'STATEMENT', + 'EXECUTE', + 'REFERENCING', + 'OLD', + 'NEW', + + // 规则相关 + 'RULE', + 'INSTEAD', + 'NOTHING', + + // 序列相关 + 'SEQUENCE', + 'INCREMENT', + 'MINVALUE', + 'MAXVALUE', + 'START', + 'RESTART', + 'CACHE', + 'CYCLE', + 'OWNED', + 'NO', + 'NONE', + + // 表空间/数据库 + 'TABLESPACE', + 'DATABASE', + 'SCHEMA', + 'ROLE', + 'USER', + 'GROUP', + 'PASSWORD', + 'ENCRYPTED', + 'UNENCRYPTED', + 'VALID', + 'UNTIL', + 'CONNECTION', + 'LIMIT', + 'INHERIT', + 'NOINHERIT', + 'CREATEROLE', + 'NOCREATEROLE', + 'CREATEDB', + 'NOCREATEDB', + 'SUPERUSER', + 'NOSUPERUSER', + 'REPLICATION', + 'NOREPLICATION', + 'BYPASSRLS', + 'NOBYPASSRLS', + 'LOGIN', + 'NOLOGIN', + + // 权限相关 + 'GRANT', + 'REVOKE', + 'PRIVILEGES', + 'PUBLIC', + 'ALL', + 'SELECT', + 'INSERT', + 'UPDATE', + 'DELETE', + 'TRUNCATE', + 'REFERENCES', + 'TRIGGER', + 'CREATE', + 'CONNECT', + 'TEMPORARY', + 'TEMP', + 'EXECUTE', + 'USAGE', + + // 分区 + 'PARTITION', + 'PARTITIONED', + 'RANGE', + 'LIST', + 'HASH', + 'DEFAULT', + + // 注释 + 'COMMENT', + + // EXPLAIN + 'EXPLAIN', + 'ANALYZE', + 'BUFFERS', + 'TIMING', + 'FORMAT', + 'JSON', + 'TEXT', + 'XML', + 'YAML', + + // COPY + 'COPY', + 'STDIN', + 'STDOUT', + 'TO', + 'DELIMITER', + 'NULL', + 'HEADER', + 'QUOTE', + 'ESCAPE', + 'FORCE', + 'QUOTE', + 'NOT', + 'ENCODING', + + // VACUUM + 'VACUUM', + 'FULL', + 'FREEZE', + 'ANALYZE', + 'VERBOSE', + + // 其他常用 + 'AS', + 'BETWEEN', + 'BINARY', + 'CONFLICT', + 'CURRENT', + 'DATABASE', + 'DAY', + 'EXCLUDED', + 'EXISTS', + 'EXTENSION', + 'EXTERNAL', + 'EXTRACT', + 'FILTER', + 'GLOBAL', + 'HOUR', + 'IMPORT', + 'LOG', + 'MINUTE', + 'MONTH', + 'NATURAL', + 'NULLS', + 'OVER', + 'PARTITION', + 'PARTITIONS', + 'PRESERVE', + 'RECORD', + 'REFRESH', + 'RELATIVE', + 'RETAIN', + 'RETURNING', + 'SECOND', + 'SEPARATOR', + 'SUBSCRIPTION', + 'TYPE', + 'WEEK', + 'WORK', + 'WRAPPER', + 'YEAR', + 'ZONE', + + // PostgreSQL 特性 + 'ATTACH', + 'DETACH', + 'CONFLICT', + 'ON', + 'CONFIRM', + 'SKIP', + 'LOCKED', + 'NOWAIT', + 'SHARE', + 'MODE', + 'NULLS', + 'FIRST', + 'LAST', +]; + +export default words; diff --git a/src/component/MonacoEditor/plugins/pg-language/monarch/pg.ts b/src/component/MonacoEditor/plugins/pg-language/monarch/pg.ts new file mode 100644 index 000000000..9aefc0499 --- /dev/null +++ b/src/component/MonacoEditor/plugins/pg-language/monarch/pg.ts @@ -0,0 +1,213 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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 * as monaco from 'monaco-editor'; +import { IFunction } from '../functions'; +import functions from '../functions'; +import { keywords } from '../keywords'; + +export const conf: monaco.languages.LanguageConfiguration = { + comments: { + lineComment: '-- ', + blockComment: ['/*', '*/'] + }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'] + ], + wordPattern: /[\w$]+/i, + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" } + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" } + ] +}; + +//@ts-ignore +export const language: monaco.languages.IMonarchLanguage = { + defaultToken: '', + tokenPostfix: '.sql', + ignoreCase: true, + brackets: [ + { open: '[', close: ']', token: 'delimiter.square' }, + { open: '(', close: ')', token: 'delimiter.parenthesis' } + ], + //@ts-ignore + keywords: Array.from(new Set(keywords)), + operators: [ + '=', + '>', + '<', + '<=', + '>=', + '<>', + '!=', + '+', + '-', + '*', + '/', + '%', + '^', + '||', + '~~', + '~~*', + '!~~', + '!~~*', + '@>', + '<@', + '->', + '->>', + '#>', + '#>>', + '&&', + '|/', + '||/', + '<<', + '>>', + '!!' + ], + builtinVariables: [], + builtinFunctions: functions.map((func: IFunction) => { + return func.name; + }), + pseudoColumns: [], + escapes: + /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/, + tokenizer: { + root: [ + { include: '@comments' }, + { include: '@whitespace' }, + { include: '@dollarQuotedStrings' }, + { include: '@numbers' }, + { include: '@strings' }, + { include: '@complexIdentifiers' }, + { include: '@scopes' }, + [/[;,.]/, 'delimiter'], + [/[()]/, '@brackets'], + [ + /[\w$]+/, + { + cases: { + '@keywords': 'keyword', + '@operators': 'operator', + '@builtinVariables': 'string', + '@builtinFunctions': 'type.identifier', + '@default': 'identifier' + } + } + ], + [/[<>=!%&+\-*/|~^]+/, 'operator'], + // Dollar-quoted string start (如 $$ 或 $tag$) + [/\$[\w$]*\$/, { token: 'string', next: '@dollarQuoted' }] + ], + whitespace: [[/\s+/, 'white']], + comments: [ + [/--+\s.*/, 'comment'], + [/\/\*/, { token: 'comment.quote', next: '@comment' }] + ], + comment: [ + [/[^*/]+/, 'comment'], + [/\*\//, { token: 'comment.quote', next: '@pop' }], + [/./, 'comment'] + ], + numbers: [ + [/0[xX][0-9a-fA-F]*/, 'number'], + [/0[oO][0-7]*/, 'number'], + [/0[bB][01]*/, 'number'], + [/[$][+-]*\d*(\.\d*)?/, 'number'], + [/((\d+(\.\d*)?)|(\.\d+))([eE][\-+]?\d+)?/, 'number'] + ], + strings: [ + // E-string (PostgreSQL 扩展字符串) + [/E'/, { token: 'string', next: '@eString' }], + // 普通单引号字符串 + [/'/, { token: 'string', next: '@string' }] + ], + string: [ + [/[^\\']+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/''/, 'string'], + [/'/, { token: 'string', next: '@pop' }] + ], + eString: [ + // E-string 支持反斜杠转义 + [/[^\\']+/, 'string'], + [/\\./, 'string.escape'], + [/''/, 'string'], + [/'/, { token: 'string', next: '@pop' }] + ], + dollarQuotedStrings: [ + // Dollar-quoted strings 用于 PL/pgSQL 等情况 + // 由主 tokenizer 中的规则处理 + ], + dollarQuoted: [ + // 匹配结束标记 + [/\$[\w$]*\$/, { token: 'string', next: '@pop' }], + // 匹配内容(不包含 $ 符号) + [/[^$]+/, 'string'], + // 单个 $ 不是结束标记 + [/\$/, 'string'] + ], + complexIdentifiers: [ + // PostgreSQL 双引号标识符 + [/"/, { token: 'identifier.quote', next: '@quotedIdentifier' }], + // 方括号(用于数组访问) + [/\[/, { token: 'delimiter.square', next: '@bracketedIdentifier' }] + ], + quotedIdentifier: [ + [/[^"]+/, 'identifier'], + [/""/, 'identifier'], + [/"/, { token: 'identifier.quote', next: '@pop' }] + ], + bracketedIdentifier: [ + [/[^\]]+/, 'identifier'], + [/\]/, { token: 'delimiter.square', next: '@pop' }] + ], + scopes: [ + // BEGIN ... END 块 + [/BEGIN\b/i, { token: 'keyword.block' }], + [/END\b/i, { token: 'keyword.block' }], + // CASE ... END + [/CASE\b/i, { token: 'keyword.block' }], + // WHEN ... THEN + [/WHEN\b/i, { token: 'keyword.choice' }], + [/THEN\b/i, { token: 'keyword.choice' }], + [/ELSE\b/i, { token: 'keyword.choice' }], + // IF ... THEN ... END IF + [/IF\b/i, { token: 'keyword.block' }], + [/ELSIF\b/i, { token: 'keyword.block' }], + // LOOP ... END LOOP + [/LOOP\b/i, { token: 'keyword.block' }], + // WHILE ... LOOP + [/WHILE\b/i, { token: 'keyword.block' }], + // FOR ... LOOP + [/FOR\b/i, { token: 'keyword.block' }], + // EXCEPTION + [/EXCEPTION\b/i, { token: 'keyword.exception' }] + ] + } +}; diff --git a/src/component/MonacoEditor/plugins/pg-language/parser/simpleParser.ts b/src/component/MonacoEditor/plugins/pg-language/parser/simpleParser.ts new file mode 100644 index 000000000..b30069739 --- /dev/null +++ b/src/component/MonacoEditor/plugins/pg-language/parser/simpleParser.ts @@ -0,0 +1,456 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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. + */ + +export interface CompletionContext { + type: + | 'keyword' + | 'table' + | 'column' + | 'schema' + | 'function' + | 'objectAccess' + | 'allTables' + | 'allSchemas' + | 'allFunctions'; + schemaName?: string; + tableName?: string; + objectName?: string; +} + +/** + * 简化的 SQL 解析器,用于判断代码补全的上下文 + * PostgreSQL 版本 + */ +export class SimpleSQLParser { + /** + * 获取代码补全的上下文 + * @param sql SQL 文本 + * @param offset 光标位置 + * @param delimiter SQL 分隔符 + */ + getCompletionContext( + sql: string, + offset: number, + delimiter: string = ';' + ): CompletionContext[] { + const contexts: CompletionContext[] = []; + + // 获取当前语句 + const currentStatement = this.getCurrentStatement(sql, offset, delimiter); + if (!currentStatement) { + return [{ type: 'keyword' }]; + } + + const textBeforeCursor = currentStatement.text.substring( + 0, + currentStatement.offset + ); + + // 检查是否在对象访问符(.)之后 + // PostgreSQL 使用 schema.table.column 格式 + const dotMatch = textBeforeCursor.match( + /((?:[\w"]+\s*\.\s*)*[\w"]+)\s*\.\s*$/ + ); + if (dotMatch) { + const objectName = dotMatch[1]; + // 去除每个部分的引号,但保留点号 + const parts = objectName + .split('.') + .map((part) => this.unquoteIdentifier(part.trim())); + + if (parts.length === 2) { + // schema.table. → 提示字段 + return [ + { + type: 'column', + schemaName: parts[0], + tableName: parts[1] + } + ]; + } else if (parts.length === 1) { + // schema. → 提示表名 + // 或 table. → 提示字段(需要在 objectAccess 中判断) + return [ + { + type: 'objectAccess', + objectName: parts[0] + } + ]; + } + } + + // 检查是否在 FROM 子句中 + const fromMatch = textBeforeCursor.match(/\bFROM\s+([\w".\s,]*)$/i); + if (fromMatch) { + contexts.push({ type: 'allTables' }); + contexts.push({ type: 'allSchemas' }); + return contexts; + } + + // 检查是否在 JOIN 子句中 + const joinMatch = textBeforeCursor.match( + /\b(INNER|LEFT|RIGHT|FULL|CROSS)?\s*JOIN\s+([\w".\s,]*)$/i + ); + if (joinMatch) { + contexts.push({ type: 'allTables' }); + contexts.push({ type: 'allSchemas' }); + return contexts; + } + + // 检查是否在 SELECT 列表中 + const selectMatch = textBeforeCursor.match(/\bSELECT\s+([\w".\s,]*)$/i); + if (selectMatch) { + contexts.push({ type: 'allFunctions' }); + // 如果前面有 FROM,可以提示表和列 + const fromIndex = currentStatement.text + .toUpperCase() + .lastIndexOf('FROM', currentStatement.offset); + if (fromIndex > 0) { + contexts.push({ type: 'allTables' }); + // 尝试提取表名 + const fromClause = currentStatement.text.substring( + fromIndex + 4, + currentStatement.offset + ); + const tableMatch = fromClause.match( + /([\w"]+)(?:\s+AS\s+([\w"]+))?/i + ); + if (tableMatch) { + const tableName = this.unquoteIdentifier(tableMatch[1]); + contexts.push({ + type: 'column', + tableName: tableName + }); + } + } + return contexts; + } + + // 检查是否在 WHERE 子句中 + const whereMatch = textBeforeCursor.match(/\bWHERE\s+([\w".\s,]*)$/i); + if (whereMatch) { + contexts.push({ type: 'allFunctions' }); + // 尝试从 FROM 子句提取表名 + const fromIndex = currentStatement.text.toUpperCase().indexOf('FROM'); + if (fromIndex > 0) { + const fromClause = currentStatement.text.substring(fromIndex + 4); + const whereIndex = fromClause.toUpperCase().indexOf('WHERE'); + const fromPart = + whereIndex > 0 ? fromClause.substring(0, whereIndex) : fromClause; + const tableMatch = fromPart.match( + /([\w"]+)(?:\s+AS\s+([\w"]+))?/i + ); + if (tableMatch) { + const tableName = this.unquoteIdentifier(tableMatch[1]); + contexts.push({ + type: 'column', + tableName: tableName + }); + } + } + return contexts; + } + + // 检查是否在 INSERT INTO 中 + const insertMatch = textBeforeCursor.match( + /\bINSERT\s+INTO\s+([\w".\s,]*)$/i + ); + if (insertMatch) { + contexts.push({ type: 'allTables' }); + contexts.push({ type: 'allSchemas' }); + return contexts; + } + + // 检查是否在 UPDATE 中 + const updateMatch = textBeforeCursor.match(/\bUPDATE\s+([\w".\s,]*)$/i); + if (updateMatch) { + contexts.push({ type: 'allTables' }); + contexts.push({ type: 'allSchemas' }); + return contexts; + } + + // 检查是否在 DELETE FROM 中 + const deleteMatch = textBeforeCursor.match( + /\bDELETE\s+FROM\s+([\w".\s,]*)$/i + ); + if (deleteMatch) { + contexts.push({ type: 'allTables' }); + contexts.push({ type: 'allSchemas' }); + return contexts; + } + + // 如果没有任何匹配,检查是否在语句开始位置 + // 如果光标前只有空白或注释,返回关键字和表名 + const trimmedBefore = textBeforeCursor.trim(); + if ( + trimmedBefore === '' || + /^--/.test(trimmedBefore) || + /^\/\*/.test(trimmedBefore) + ) { + contexts.push({ type: 'keyword' }); + contexts.push({ type: 'allTables' }); + contexts.push({ type: 'allSchemas' }); + return contexts; + } + + // 默认返回关键字 + return [{ type: 'keyword' }]; + } + + /** + * 获取当前语句 + */ + private getCurrentStatement(sql: string, offset: number, delimiter: string) { + // 按分隔符分割 SQL + const statements: Array<{ start: number; end: number; text: string }> = []; + let start = 0; + let inString = false; + let stringChar = ''; + let inDollarQuote = false; + let dollarTag = ''; + let inComment = false; + let commentType: 'line' | 'block' | null = null; + + for (let i = 0; i < sql.length; i++) { + const char = sql[i]; + const nextChar = sql[i + 1]; + + // 处理 dollar-quoted strings (PostgreSQL 特有) + if (!inComment && !inString && !inDollarQuote && char === '$') { + // 寻找结束的 $ + let tagEnd = i + 1; + while (tagEnd < sql.length && /[\w$]/.test(sql[tagEnd])) { + tagEnd++; + } + if (tagEnd < sql.length && sql[tagEnd] === '$') { + dollarTag = sql.substring(i, tagEnd + 1); + inDollarQuote = true; + i = tagEnd; + continue; + } + } + + // 处理 dollar-quoted strings 结束 + if (inDollarQuote && sql.substring(i).startsWith(dollarTag)) { + inDollarQuote = false; + dollarTag = ''; + i += dollarTag.length - 1; + continue; + } + + if (inDollarQuote) { + continue; + } + + // 处理字符串 + if (!inComment && (char === "'" || char === '"')) { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar) { + // 检查是否是转义的引号 + if (char === "'" && nextChar === "'") { + i++; // 跳过转义的引号 + continue; + } + inString = false; + stringChar = ''; + } + continue; + } + + if (inString) { + continue; + } + + // 处理注释 + if (char === '-' && nextChar === '-') { + inComment = true; + commentType = 'line'; + i++; + continue; + } + + if (char === '/' && nextChar === '*') { + inComment = true; + commentType = 'block'; + i++; + continue; + } + + if ( + inComment && + commentType === 'block' && + char === '*' && + nextChar === '/' + ) { + inComment = false; + commentType = null; + i++; + continue; + } + + if (inComment && commentType === 'line' && char === '\n') { + inComment = false; + commentType = null; + continue; + } + + if (inComment) { + continue; + } + + // 检查分隔符 + if (delimiter.length === 1 && char === delimiter) { + statements.push({ + start, + end: i, + text: sql.substring(start, i).trim() + }); + start = i + 1; + } else if ( + delimiter.length > 1 && + sql.substring(i, i + delimiter.length) === delimiter + ) { + statements.push({ + start, + end: i, + text: sql.substring(start, i).trim() + }); + start = i + delimiter.length; + i += delimiter.length - 1; + } + } + + // 添加最后一个语句 + if (start < sql.length) { + statements.push({ + start, + end: sql.length, + text: sql.substring(start).trim() + }); + } + + // 找到包含 offset 的语句 + for (const stmt of statements) { + if (offset >= stmt.start && offset <= stmt.end) { + return { + text: stmt.text, + offset: offset - stmt.start + }; + } + } + + // 如果没找到,返回最后一个语句 + if (statements.length > 0) { + const lastStmt = statements[statements.length - 1]; + return { + text: lastStmt.text, + offset: Math.min(offset - lastStmt.start, lastStmt.text.length) + }; + } + + return null; + } + + /** + * 去除标识符的引号 + */ + private unquoteIdentifier(identifier: string): string { + if (!identifier) { + return ''; + } + // 去除双引号 "identifier" + if (identifier.startsWith('"') && identifier.endsWith('"')) { + return identifier.slice(1, -1); + } + return identifier; + } + + /** + * 获取表名和 schema 名(用于悬停提示) + */ + getTableAtOffset( + sql: string, + offset: number, + delimiter: string = ';' + ): { name: string; schema?: string } | null { + const currentStatement = this.getCurrentStatement(sql, offset, delimiter); + if (!currentStatement) { + return null; + } + + const textBeforeCursor = currentStatement.text.substring( + 0, + currentStatement.offset + ); + + // 尝试从 FROM 子句提取表名 + const fromMatch = textBeforeCursor.match(/\bFROM\s+([\w".]+)/i); + if (fromMatch) { + const tableRef = this.unquoteIdentifier(fromMatch[1]); + const parts = tableRef.split('.'); + if (parts.length === 2) { + return { + schema: parts[0], + name: parts[1] + }; + } + return { + name: tableRef + }; + } + + // 尝试从 UPDATE 提取表名 + const updateMatch = textBeforeCursor.match(/\bUPDATE\s+([\w".]+)/i); + if (updateMatch) { + const tableRef = this.unquoteIdentifier(updateMatch[1]); + const parts = tableRef.split('.'); + if (parts.length === 2) { + return { + schema: parts[0], + name: parts[1] + }; + } + return { + name: tableRef + }; + } + + // 尝试从 INSERT INTO 提取表名 + const insertMatch = textBeforeCursor.match( + /\bINSERT\s+INTO\s+([\w".]+)/i + ); + if (insertMatch) { + const tableRef = this.unquoteIdentifier(insertMatch[1]); + const parts = tableRef.split('.'); + if (parts.length === 2) { + return { + schema: parts[0], + name: parts[1] + }; + } + return { + name: tableRef + }; + } + + return null; + } +} + +const simpleParser = new SimpleSQLParser(); +export default simpleParser; diff --git a/src/component/MonacoEditor/plugins/pg-language/service.ts b/src/component/MonacoEditor/plugins/pg-language/service.ts new file mode 100644 index 000000000..cea2d5872 --- /dev/null +++ b/src/component/MonacoEditor/plugins/pg-language/service.ts @@ -0,0 +1,213 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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 { getTableColumnList, getTableInfo } from '@/common/network/table'; +import { getView } from '@/common/network/view'; +import { ITableColumn } from '@/d.ts'; +import { TableColumn } from '@/page/Workspace/components/CreateTable/interface'; +import SessionStore from '@/store/sessionManager/session'; + +// PostgreSQL 数据服务接口 +export interface IPGModelOptions { + delimiter: string; + /** + * 自动提示下一个token,默认为true + */ + autoNext?: boolean; + getTableList( + schemaName: string + ): Promise | undefined>; + getTableColumns( + tableName: string, + schemaName?: string + ): Promise | undefined>; + getSchemaList(): Promise; + getFunctions(): Promise | undefined>; + getSnippets(): Promise< + | Array<{ + label: string; + documentation: string; + insertText: string; + }> + | undefined + >; + getTableDDL(tableName: string, schemaName?: string): Promise; +} + +function hasConnect(session: SessionStore) { + return session?.sessionId && session?.database?.dbName; +} + +export function getModelService( + { modelId: _modelId, delimiter }, + sessionFunc: () => SessionStore +): IPGModelOptions { + return { + get delimiter() { + return delimiter(); + }, + autoNext: true, + async getTableList(schemaName: string) { + const dbName = schemaName || sessionFunc()?.database?.dbName; + if (!hasConnect(sessionFunc())) { + return; + } + /** + * 保证200ms内返回,不返回就用上一次的值 + */ + await Promise.race([ + new Promise((resolve) => { + setTimeout(() => { + resolve([]); + }, 300); + }), + sessionFunc()?.queryIdentities() + ]); + + const dbObj = + sessionFunc()?.allIdentities[dbName] || + sessionFunc()?.allIdentities[dbName?.toLowerCase()]; + if (!dbObj) { + return []; + } + // PostgreSQL 支持表、视图、物化视图、外部表 + return [ + ...(dbObj.tables || []).map((name) => ({ name, type: 'TABLE' })), + ...(dbObj.views || []).map((name) => ({ name, type: 'VIEW' })), + ...(dbObj.materialized_view || []).map((name) => ({ + name, + type: 'MATERIALIZED_VIEW' + })), + ...(dbObj.external_table || []).map((name) => ({ + name, + type: 'FOREIGN_TABLE' + })) + ]; + }, + async getTableColumns(tableName: string, schemaName?: string) { + // PostgreSQL 使用小写标识符 + const realTableName = tableName.toLowerCase(); + if (!hasConnect(sessionFunc())) { + return; + } + const dbName = schemaName || sessionFunc()?.database?.dbName; + if ( + /[\u4e00-\u9fa5\w]+/.test(realTableName) && + realTableName?.length < 500 + ) { + await sessionFunc()?.queryIdentities(realTableName); + const db = + sessionFunc()?.allIdentities[dbName] || + sessionFunc()?.allIdentities[dbName?.toLowerCase()]; + const isTable = db?.tables?.includes(realTableName); + const isView = db?.views?.includes(realTableName); + if (isTable) { + const columns = await getTableColumnList( + realTableName, + dbName, + sessionFunc()?.sessionId + ); + // 表 + return columns?.map((column: TableColumn) => ({ + columnName: column.name, + columnType: column.type + })); + } + if (isView) { + // 视图 + const view = await getView( + realTableName, + sessionFunc()?.sessionId, + dbName + ); + return view?.columns?.map((column: ITableColumn) => ({ + columnName: column.columnName, + columnType: column.dataType + })); + } + } + return []; + }, + async getSchemaList() { + if (!Object.keys(sessionFunc()?.allIdentities || {}).length) { + sessionFunc()?.queryIdentities(); + } + return Object.keys(sessionFunc()?.allIdentities || {}); + }, + async getFunctions() { + if (!sessionFunc()?.database?.functions) { + await sessionFunc()?.database?.getFunctionList(); + } + return sessionFunc()?.database?.functions?.map((func) => ({ + name: func.funName, + desc: func.status + })); + }, + async getSnippets() { + const session = sessionFunc(); + if (session) { + return session.snippets?.map((item) => { + return { + label: item.prefix || '', + documentation: item.description || '', + insertText: item.body || '' + }; + }); + } + }, + async getTableDDL(tableName: string, schemaName?: string) { + // PostgreSQL 使用小写标识符 + const realTableName = tableName.toLowerCase(); + if (!hasConnect(sessionFunc())) { + return; + } + const dbName = schemaName || sessionFunc()?.database?.dbName; + if ( + /[\u4e00-\u9fa5\w]+/.test(realTableName) && + realTableName?.length < 500 + ) { + /** + * schemaStore.queryIdentities(); 不能是阻塞的,编辑器对于函数的超时时间有严格的要求,不能超过 300ms,调用这个接口肯定会超过这个时间。 + */ + await sessionFunc()?.queryIdentities(realTableName); + const db = + sessionFunc()?.allIdentities[dbName] || + sessionFunc()?.allIdentities[dbName?.toLowerCase()]; + const isTable = db?.tables?.includes(realTableName); + const isView = db?.views?.includes(realTableName); + if (isTable) { + const table = await getTableInfo( + realTableName, + dbName, + sessionFunc()?.sessionId + ); + // 返回 PostgreSQL 表的 DDL + return table?.info?.DDL || ''; + } + if (isView) { + // 视图 + const view = await getView( + realTableName, + sessionFunc()?.sessionId, + dbName + ); + return view?.ddl || ''; + } + } + return ''; + } + }; +} diff --git a/src/page/Workspace/SideBar/ResourceTree/DatabaseSearchModal/__tests__/constant.test.ts b/src/page/Workspace/SideBar/ResourceTree/DatabaseSearchModal/__tests__/constant.test.ts new file mode 100644 index 000000000..d3b2fe051 --- /dev/null +++ b/src/page/Workspace/SideBar/ResourceTree/DatabaseSearchModal/__tests__/constant.test.ts @@ -0,0 +1,237 @@ +/* + * Copyright 2023 OceanBase + * + * Licensed 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. + */ + +/** + * Unit tests for src/page/Workspace/SideBar/ResourceTree/DatabaseSearchModal/constant.ts + * + * 覆盖目标(design.md §3.5 / §4.2 / §8.2 / §10.3,compat-RISK-3): + * 1. PG 对象类型数组经收敛后等于 ['table','column','function','view'] + * (顺序、长度、内容三维度对齐;不含 'trigger') + * 2. 横向回归:MySQL / OB-Mysql / Doris / Oracle / OB-Oracle / SQL Server + * / SEARCH_OBJECT_FROM_ALL_DATABASE 等数组在 PG 收敛之后保持原状 + * + * 测试形式:map case(数组定义所有用例 + forEach 循环生成 it, + * 等价于 it.each;选 forEach 是因为 odc-client 的 @types/jest@22 不识别 it.each 签名)。 + * Refs: dms-ee#850, compat-RISK-3 + */ + +// constant.ts 引入了 @/store/helper/page、Workspace/components/TablePage、 +// Workspace/components/PackagePage 等重组件链路。本测试仅断言纯静态数组导出, +// 因此 mock 这些模块为最小空对象,避免 jest 拉入 monaco / store / redux 等运行时依赖。 +jest.mock('@/store/helper/page', () => ({ + openTableViewPage: jest.fn(), + openPackageViewPage: jest.fn(), + openFunctionViewPage: jest.fn(), + openProcedureViewPage: jest.fn(), + openTriggerViewPage: jest.fn(), + openTypeViewPage: jest.fn(), + openSequenceViewPage: jest.fn(), + openSynonymViewPage: jest.fn(), + openViewViewPage: jest.fn(), + openExternalTableTableViewPage: jest.fn(), + openMaterializedViewViewPage: jest.fn() +})); + +jest.mock('@/page/Workspace/components/TablePage', () => ({ + PropsTab: { DDL: 'DDL' }, + TopTab: { PROPS: 'PROPS' } +})); + +jest.mock('@/page/Workspace/components/PackagePage', () => ({ + TopTab: { HEAD: 'HEAD' } +})); + +jest.mock('@/constant/label', () => ({ + DbObjectTypeTextMap: jest.fn(() => '') +})); + +jest.mock('@/util/intl', () => ({ + formatMessage: jest.fn(({ defaultMessage }) => defaultMessage) +})); + +import { ConnectType, DbObjectType } from '@/d.ts'; +import { objectTypeConfig } from '@/page/Workspace/SideBar/ResourceTree/DatabaseSearchModal/constant'; + +describe('DatabaseSearchModal/constant PG object type collapse (compat-RISK-3)', () => { + const pgTypes = objectTypeConfig[ConnectType.PG]; + + it('PG object type list equals [table, column, function, view] strictly', () => { + expect(pgTypes).toEqual([ + DbObjectType.table, + DbObjectType.column, + DbObjectType.function, + DbObjectType.view + ]); + expect(pgTypes).toHaveLength(4); + }); + + it('PG object type list does NOT contain trigger', () => { + expect(pgTypes).not.toContain(DbObjectType.trigger); + }); + + // map case · PG 资源搜索弹窗在本期不暴露的扩展对象类型 + const forbiddenOnPg: Array<{ name: string; objType: DbObjectType }> = [ + { name: 'trigger forbidden on PG', objType: DbObjectType.trigger }, + { name: 'sequence forbidden on PG', objType: DbObjectType.sequence }, + { name: 'type forbidden on PG', objType: DbObjectType.type }, + { name: 'synonym forbidden on PG', objType: DbObjectType.synonym }, + { name: 'package forbidden on PG', objType: DbObjectType.package }, + { name: 'procedure forbidden on PG', objType: DbObjectType.procedure }, + { + name: 'materialized_view forbidden on PG', + objType: DbObjectType.materialized_view + }, + { + name: 'external_table forbidden on PG', + objType: DbObjectType.external_table + }, + { + name: 'database forbidden on PG (PG 搜索数组不含 database)', + objType: DbObjectType.database + } + ]; + + forbiddenOnPg.forEach(({ name, objType }) => { + it(name, () => { + expect(pgTypes).not.toContain(objType); + }); + }); + + // map case · PG 资源搜索弹窗本期保留的对象类型 + const keptOnPg: Array<{ name: string; objType: DbObjectType }> = [ + { name: 'table kept on PG', objType: DbObjectType.table }, + { name: 'column kept on PG', objType: DbObjectType.column }, + { name: 'function kept on PG', objType: DbObjectType.function }, + { name: 'view kept on PG', objType: DbObjectType.view } + ]; + + keptOnPg.forEach(({ name, objType }) => { + it(name, () => { + expect(pgTypes).toContain(objType); + }); + }); +}); + +describe('DatabaseSearchModal/constant lateral regression baseline (compat-RISK-3)', () => { + // 横向基线快照:本次只动 PG 分支,其它 DBType 分支必须保持现状。 + // 数组值与 support-pg 基线(odc-client@f93589b8)一致。 + const mysqlBaseline = [ + DbObjectType.database, + DbObjectType.table, + DbObjectType.external_table, + DbObjectType.column, + DbObjectType.function, + DbObjectType.view, + DbObjectType.procedure, + DbObjectType.materialized_view + ]; + + const oracleBaseline = [ + DbObjectType.database, + DbObjectType.table, + DbObjectType.external_table, + DbObjectType.column, + DbObjectType.function, + DbObjectType.view, + DbObjectType.procedure, + DbObjectType.package, + DbObjectType.trigger, + DbObjectType.type, + DbObjectType.sequence, + DbObjectType.synonym, + DbObjectType.materialized_view + ]; + + const sqlServerBaseline = [ + DbObjectType.database, + DbObjectType.table, + DbObjectType.external_table, + DbObjectType.column, + DbObjectType.function, + DbObjectType.view, + DbObjectType.procedure + ]; + + const lateralCases: Array<{ + name: string; + actual: DbObjectType[]; + expected: DbObjectType[]; + }> = [ + { + name: 'MYSQL object type baseline unchanged', + actual: objectTypeConfig[ConnectType.MYSQL], + expected: mysqlBaseline + }, + { + name: 'OB_MYSQL object type baseline unchanged', + actual: objectTypeConfig[ConnectType.OB_MYSQL], + expected: mysqlBaseline + }, + { + name: 'DORIS object type baseline unchanged', + actual: objectTypeConfig[ConnectType.DORIS], + expected: mysqlBaseline + }, + { + name: 'ORACLE object type baseline unchanged (trigger 仍存在)', + actual: objectTypeConfig[ConnectType.ORACLE], + expected: oracleBaseline + }, + { + name: 'OB_ORACLE object type baseline unchanged (trigger 仍存在)', + actual: objectTypeConfig[ConnectType.OB_ORACLE], + expected: oracleBaseline + }, + { + name: 'SQL_SERVER object type baseline unchanged', + actual: objectTypeConfig[ConnectType.SQL_SERVER], + expected: sqlServerBaseline + }, + { + name: 'SEARCH_OBJECT_FROM_ALL_DATABASE 仍复用 oracle 全集', + actual: objectTypeConfig.SEARCH_OBJECT_FROM_ALL_DATABASE, + expected: oracleBaseline + } + ]; + + lateralCases.forEach(({ name, actual, expected }) => { + it(name, () => { + expect(actual).toEqual(expected); + }); + }); + + // 横向 trigger 维度:MYSQL 体系不含 trigger(基线即如此);Oracle 体系仍含 trigger。 + it('MYSQL/OB_MYSQL/DORIS object types still do NOT include trigger (baseline)', () => { + expect(objectTypeConfig[ConnectType.MYSQL]).not.toContain( + DbObjectType.trigger + ); + expect(objectTypeConfig[ConnectType.OB_MYSQL]).not.toContain( + DbObjectType.trigger + ); + expect(objectTypeConfig[ConnectType.DORIS]).not.toContain( + DbObjectType.trigger + ); + }); + + it('ORACLE/OB_ORACLE object types still include trigger (baseline)', () => { + expect(objectTypeConfig[ConnectType.ORACLE]).toContain( + DbObjectType.trigger + ); + expect(objectTypeConfig[ConnectType.OB_ORACLE]).toContain( + DbObjectType.trigger + ); + }); +}); diff --git a/src/page/Workspace/SideBar/ResourceTree/DatabaseSearchModal/constant.ts b/src/page/Workspace/SideBar/ResourceTree/DatabaseSearchModal/constant.ts index 33e969d1e..c4285f7e3 100644 --- a/src/page/Workspace/SideBar/ResourceTree/DatabaseSearchModal/constant.ts +++ b/src/page/Workspace/SideBar/ResourceTree/DatabaseSearchModal/constant.ts @@ -28,12 +28,14 @@ const mysqlObjectType = [ DbObjectType.materialized_view ]; +// PG 资源搜索对象类型与 ODC 后端 odc_version_diff_config 中 PG 的能力对齐: +// support_trigger=false,因此前端资源搜索不再暴露 trigger 入口。 +// Refs: dms-ee#850, compat-RISK-3 const pgObjectType = [ DbObjectType.table, DbObjectType.column, DbObjectType.function, - DbObjectType.view, - DbObjectType.trigger + DbObjectType.view ]; const oracleObjectType = [