实现一个智能的SQL编辑器

前言

目前我司的多个产品中都支持在线编辑 SQL 来生成对应的任务。为了优化用户体验,在使用 MonacoEditor 为编辑器的基础上,我们还支持了如下几个重要功能:

  • 多种 SQL 的语法高亮
  • 多种 SQL 的报错提示(错误位置飘红)
  • 多种 SQL 的自动补全(智能提示)

本文旨在讲解上述功能的实现思路,对于技术细节,由于篇幅原因不会阐述的太详细。

Monaco Languages

Monaco Editor 内置的 languages

Monaco Editor 内置了相当多的 languages,比如 javaScriptCSSShell 等。

Monaco Editor 依赖包的 ESM 入口文件为 ./esm/vs/editor/editor.main.ts

而在入口文件中,Monaco Editor 引入了所有内置的 Languages。

这里 languages 文件可以分为两类,一类是../language文件夹下的,支持自动补全和飘红提示等高级功能;另一类则是../basic-languages文件夹下的,只支持一些基本功能。

使用内置的 Language 功能

以使用 typescript language 为例:

ts 复制代码
import { editor } from 'monaco-editor';

const container = document.getElementById('container');

editor.create(container, {
    language: 'typescript'
})

此时我们会发现,编辑器已经有语法高亮的功能了,但是浏览器控制台会抛异常,另外也没有自动补全功能和飘红提示功能,

这其实是因为,Monaco Editor 无法加载到 language 对应的 worker,对应的解决办法看这里: Monaco integrate-esm

这里我们使用 Using plain webpack的方式,首先将对应的 worker 文件设置为 webpack entry:

js 复制代码
module.exports = {
    entry: {
        index: path.resolve( __dirname, './src/index.ts'),
        'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js',
        'ts.worker': 'monaco-editor/esm/vs/language/typescript/ts.worker.js'
    },
}

另外还需要设置 Monaco Editor 的全局环境变量,这主要是为了告诉 Monaco Editor 对应的 worker 文件的路径

ts 复制代码
import { editor } from 'monaco-editor';

(window as any).MonacoEnvironment = {
    getWorkerUrl: function (_moduleId, label) {
        switch (label) {
            case 'flink': {
                return './flink.worker.js';
            }
            case 'typescript': {
                return './ts.worker.js'
            }
            default: {
                return './editor.worker.js';
            }
        }
    }
};

const container = document.getElementById('container');

editor.create(container, {
    language: 'typescript'
})

这样一个具有语法高亮自动补全飘红提示 功能的 typescript 编辑器就设置好了

小结分析

首先上文中提到了当我们直接从 Monaco Editor 的入口文件中导入时,会自动的引入所有内置的 Languages,但是实际上这其中绝大都是我们不需要的,而由于其导入方式,很显然我们不需要的 languages 也无法被 treeShaking。要解决这个问题我们可以选择从 monaco-editor/esm/vs/editor/editor.api 文件中导入Monaco Editor 核心 API,然后通过 monaco-editor-webpack-plugin 来按需导入所需要的功能。另外这个插件也可以自动处理Monaco Editor 内置的 worker 文件的打包问题,以及自动注入 MonacoEnvironment全局环境变量。

自定义 Language

注册Language

Monaco Editor 提供了 monaco.languages.register方法,用来自定义 language

ts 复制代码
/**
 * Register information about a new language.
 */
export function register(language: ILanguageExtensionPoint): void;

export interface ILanguageExtensionPoint {
    id: string;
    extensions?: string[];
    filenames?: string[];
    filenamePatterns?: string[];
    firstLine?: string;
    aliases?: string[];
    mimetypes?: string[];
    configuration?: Uri;
}

第一步,我们需要注册一个 language, 配置项中 id 对应的就是语言名称(其他配置项可以暂时不填),这里自定义的 language 名为 myLang

ts 复制代码
import { editor, languages } from 'monaco-editor';

languages.register({
    id: "myLang"
});

const container = document.getElementById('container');

editor.create(container, {
    language: 'myLang'
})

此时可以发现,页面上的编辑器没有任何其他附加功能,就是普通的文本编辑器。

设置 Language

通过 monaco.languages.setLanguageConfiguration,可以对 language 进行配置

ts 复制代码
/**
 * Set the editing configuration for a language.
 */
export function setLanguageConfiguration(
    languageId: string,
    configuration: LanguageConfiguration
): IDisposable;

/**
 * The language configuration interface defines the contract between extensions and
 * various editor features, like automatic bracket insertion, automatic indentation etc.
 */
export interface LanguageConfiguration {
    comments?: CommentRule;
    brackets?: CharacterPair[];
    wordPattern?: RegExp;
    indentationRules?: IndentationRule;
    onEnterRules?: OnEnterRule[];
    autoClosingPairs?: IAutoClosingPairConditional[];
    surroundingPairs?: IAutoClosingPair[];
    colorizedBracketPairs?: CharacterPair[];
    autoCloseBefore?: string;
    folding?: FoldingRules;
}

这些配置会影响 Monaco Editor 的一些默认行为,比如设置 autoClosingPairs中有一项为一对圆括号,那么当输入左圆括号后,会自动补全右圆括号。

ts 复制代码
import { languages } from "monaco-editor";
const conf: languages.LanguageConfiguration = {
    comments: {
        lineComment: "--",
        blockComment: ["/*", "*/"],
    },
    brackets: [
        ["(", ")"],
    ],
    autoClosingPairs: [
        { open: "(", close: ")" },
        { open: '"', close: '"' },
        { open: "'", close: "'" },
    ],
    surroundingPairs: [
        { open: "(", close: ")" },
        { open: '"', close: '"' },
        { open: "'", close: "'" },
    ],
};

languages.setLanguageConfiguration('myLang', conf)

高亮功能

Monarch

Moanco Editor 内置了 Monarch,用于实现语法高亮功能,它本质上是一个有限状态机,我们可以通过JSON的形式来配置其状态流转逻辑,并通过monaco.languages.setMonarchTokensProvider API 应用该配置。关于Monarch 的具体用法可以看一下这篇文章 以及Monarch Document

配置中最重要的是 tokenizer属性,意思是分词器,分词器会自动对编辑器内部的文本进行分词处理,每个分词器都有一个 root state,在 root state 中可以有多条规则,规则内部可以引用其他 state。

下面是一个简单的配置示例

js 复制代码
import { languages } from "monaco-editor";
export const language: languages.IMonarchLanguage = {
    ignoreCase: true,
    tokenizer: {
        root: [
            { include: '@comments' }, // 引用下面的 comments 规则
            { include: '@whitespace' }, // 引用下面的 whiteSpace 规则
            { include: '@strings' },// 引用下面的 strings 规则
        ],
        whitespace: [[/\s+/, 'white']],
        comments: [
            [/--+.*/, 'comment'],
            [//*/, { token: 'comment.quote', next: '@comment' }]
        ],
        comment: [
            [/[^*/]+/, 'comment'],
            [/*//, { token: 'comment.quote', next: '@pop' }],
            [/./, 'comment']
        ],
        strings: [
            [/'/, { token: 'string', next: '@string' }]
        ],
        string: [
            [/[^']+/, 'string'],
            [/''/, 'string'],
            [/'/, { token: 'string', next: '@pop' }]
        ],
    }
};

languages.setMonarchTokensProvider("myLang", language);

上面的配置中 root 下面有三条规则分别匹配 注释(comments)字符串(strings) 以及空白字符(whiteSpace), 每条规则可以大体分为两部分:

  • 匹配方式,比如说正则
  • 对应的 token 类型(任意字符串)

比如上述配置中 tokenizer.comments 规则

js 复制代码
comments: [
    [/--+.*/, 'comment'], // 左边是正则表达式用来匹配文本,右边是该规则对应的 token 名称
    [//*/, { token: 'comment.quote', next: '@comment' }] // 左边是正则表达式用来匹配文本,右边显示声明对应的 token 名称
],

配置了如上 Monarch 之后,在编辑器内部输入注释或者字符串,那么Monaco editor 就会根据输入的内容进行分词处理

可以看到目前字符串和注释已经被高亮了。这里有一个新的问题,不同类型的分词的颜色是怎么设置的

Monaco Theme

从上图中右侧的 Elements 面板中可以看到,不同类型的分词,对应的标签的 className 不同,它们是由 Monarch 配置中的 token 映射而来的。MonacoEditor 内置了一些 Theme,默认的 Theme 是 vs,而默认的 theme 中已经设置了上述 Monarch 中的 token 对应的颜色,所以我们应用上述配置后,对应的分词直接就有了高亮颜色。

我们可以通过 monaco.editor.defineTheme 来定义一种新的 theme,如下例所示:

js 复制代码
editor.defineTheme('myTheme', {
    base: 'vs',
    inherit: true,
    rules: [
        { token: 'comment', foreground: 'ff4400' },
        { token: 'string', foreground: '0000ff' }
    ],
    colors: {
    },
});

// xxxx

editor.create(container, {
  language: "myLang",
  theme: "myTheme"
});

这里将注释设置为红色,字符串设置为蓝色,显示效果如下图所示

飘红提示

飘红提示的功能就是在代码错误的位置打上标记(一般是红色波浪线),可以通过 monaco.editor.setModelMarkers API 来实现。比如我们想为 第1行的第1个字符到第2行的第2个字符 之间打上错误标记:

js 复制代码
const editorIns = editor.create(container, {
  language: "myLang",
  theme: "myTheme",
  value: `hello
world`
});

const model = editorIns.getModel();

editor.setModelMarkers(model, 'myLang', [
    {
        startLineNumber: 1,
        startColumn: 1,
        endLineNumber: 2,
        endColumn: 2,
        message: "语法错误",
        severity: MarkerSeverity.Error
    }
])

severity 是标记类型,message 是提示信息,效果如下所示。

到此为止,实现了飘红的功能,但是没有实现在语法错误处飘红的功能,这需要额外的语法解析器支持,会在下文中讲到。

自动补全功能

Monaco Editor 提供了 monaco.languages.registerCompletionItemProvider API 来实现自动补全功能

ts 复制代码
import { editor, languages, MarkerSeverity, Position, CancellationToken, Range  } from "monaco-editor";

languages.registerCompletionItemProvider('myLang', {
    triggerCharacters: ['.', '*'],
    provideCompletionItems(
        model: editor.IReadOnlyModel,
        position: Position,
        context: languages.CompletionContext,
        token: CancellationToken
    ){
        const wordInfo = model.getWordUntilPosition(position);
        const wordRange = new Range(
            position.lineNumber,
            wordInfo.startColumn,
            position.lineNumber,
            wordInfo.endColumn
        );
        return new Promise((resolve) => {
            resolve({
                suggestions: [
                    {
                            label: "SELECT",
                            kind: languages.CompletionItemKind.Keyword,
                            insertText: "SELECT",
                            range: wordRange,
                            detail: '关键字',
                    },
                    {
                            label: "SET",
                            kind: languages.CompletionItemKind.Keyword,
                            insertText: "SET",
                            range: wordRange,
                            detail: '关键字',
                    },
                    {
                            label: "SHOW",
                            kind: languages.CompletionItemKind.Keyword,
                            insertText: "SHOW",
                            range: wordRange,
                            detail: '关键字',
                    },
                ]
            })
        })
    }
})

registerCompletionItemProvider 接受两个参数,第一个参数是 languageId 也就是 language 名称,

第二个参数是一个 CompletionItemProviderCompletionItemProvidertriggerCharacters用来配置触发自动补全的字符有哪些,而 provideCompletionItems则是一个函数,它接收 Monaco Editor 提供的当前的上下文信息,返回自动补全项列表。如上例中返回了三个自动补全项,那么当我们在编辑器中输入 S时,就会出现配置的自动补全项候选菜单。

通过这个 API 我们可以实现一种语言的关键字自动补全,只需要在CompletionItemProvider中返回该语言所有的关键字对应的自动补全项即可。

但是registerCompletionItemProvider目前做不到根据语义进行自动补全。

比如用户写一段 flinkSQL,当用户输入完 CREATE 关键字并按下空格后,应该出现的自动补全项应该是只有TABLECATALOGDATABASEFUNCTIONVIEW

再比如当用户输入 SELECT * FROM 时,后面应该提示表名而不是其他无关的关键字。与上文中的飘红提示一样,这些语义信息需要单独的语法解析器来分析。

小结分析

到此为止,在自定义 language 这一节中,我们已经了解了,在 Monaco Editor 中如何实现自定义语言的 语法高亮错误处飘红提示自动补全

在数栈产品中,本节讲到的功能都通过引入 monaco-sql-languages 依赖来实现,这是我们数栈 UED 团队自研的开源项目,目前已经支持多种 SQL Languages。

由于目前为止没有实现自定义 language 的语义分析功能,导致目前实现的编辑器不够智能。 另外,对于第一节中提到的 web worker ,在第二节中也没有有提到,实际上 Monaco Editor 自带的 web worker,也都是为了实现 language 的语义分析功能,下一节将阐述这一部分内容。

SQL Parser

要实现语义分析功能,很显然我们需要一个语法解析器。除了基本的语法解析的基础功能以外,我们还需要

  • 语法错误收集,收集编辑器中文本的语法错误信息,用于错误飘红提示功能。
  • 推断文本中指定位置的候选项列表,对于编辑器来说,指定位置一般就是光标所在位置。候选项是指在光标所在的位置应该要写什么。比如 SQL 中 SELECT 关键字后面可以跟字段或者函数,那么我们所要实现的 sql parser 就应该提示出在 SELECT 关键字后面的候选项应该是字段或者函数。

实现基础的 SQL Parser

Antlr4 语法文件

我们使用 Antlr4 来实现一个基本的 SQL Parser。Antlr4 是一个强大的解析器生成器,它能根据用户自定义的语法文件来生成对应的解析器。Antlr4 的语法文件为 .g4文件,内部可以包含多条规则,规则可以分为词法规则和语法规则,词法规则用于生成词法分析器,语法规则用于生成语法解析器。

例,我们现在写一份语法规则,匹配最简单的 SELECT 语句(不包括子查询、别名等规则),比如

sql 复制代码
SELECT * FROM table1;  -- eg1

SELECT table2.name, age FROM schema2.table2; -- eg2

那么在antlr4中这份语法文件应该这样写:

g4 复制代码
grammar SelectStatement;

/** 语法规则 begin */
program: selectStatement? EOF;

// 声明 语句的匹配规则
selectStatement: KW_SELECT columnGroup KW_FROM tablePath SEMICOLON?;

// 声明 语句中字段部分的匹配规则,字段部分可能为 col1, col2 的形式
columnGroup: columnPath (COMMA columnPath)*;

// 声明 字段名匹配规则,字段名有可能为 db.table.col 或者 * 的形式
columnPath: dot_id | OP_STAR; 

// 声明 表名匹配规则,表名有可能为 db.table 的形式
tablePath: dot_id; 

// 匹配 id.id 形式的标识符号
dot_id: IDENTIFIER_LITERAL (DOT IDENTIFIER_LITERAL)*; 
/** 语法规则 end */ 


/** 词法规则 begin */
KW_SELECT:          'SELECT'; // 匹配 SELECT 关键字
KW_FROM:            'FROM'; // 匹配 FROM 关键字
OP_STAR:            '*'; // 匹配 * 
DOT:                '.'; // 匹配 .
COMMA:              ','; // 匹配 ,
SEMICOLON:          ';'; // 匹配 ;
IDENTIFIER_LITERAL: [A-Z_a-z][A-Z_0-9a-z]*; // 匹配标识符

WS:                 [ \t\n\r]+ -> skip ; // 忽略空格换行等空白字符
/** 词法规则 end */

语法规则的编写格式类似于EBNF

然后运行 antlr4 命令,根据所写的语法文件生成对应的解析器。可以直接使用官方文档中提供的方式 antlr4 typescript-target doc ,或者直接使用社区提供的 antlr4ts包,这里以使用 antlr4ts 为例。

生成的文件结果如下所示:

使用 Antlr4 生成的 Parser

在使用Antlr4 的生成的 Parser 之前我们需要安装,Antlr4 的运行时包。你可以将 Antlr4 的运行时包通过语法文件生成的parser文件之间的关系,类比为 react 和 react-dom之间的关系。这里以使用 antlr4ts 为运行时

ts 复制代码
import { CommonTokenStream, CharStreams } from 'antlr4ts';
import { SelectStatementLexer } from '../lib/selectStatement/SelectStatementLexer';
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';

class SelectParser {
    private createLexer(input: string) {
        const inputStream = CharStreams.fromString(input);
        const lexer = new SelectStatementLexer(inputStream);
        return lexer
    }

    private createParser (input: string) {
        const lexer = this.createLexer(input);
        const tokens = new CommonTokenStream(lexer);
        const parser = new SelectStatementParser(tokens);
        return parser
    }

    parse (sql: string) {
        const parser = this.createParser(sql)
        const parseTree = parser.selectStatement();
        return parseTree;
    }
}
// 试一下效果
const selectParser = new SelectParser();
const parseTree = selectParser.parse('SELECT * FROM table1');

获取文本中的错误信息

当解析一个含有错误的文本时,Antlr4 会输出错误信息,例如输入

js 复制代码
selectParser.parse('SELECT id FRO');

控制台打印

可以看到错误信息中包含了文本中的错误所处的位置,我们可以通过使用 Antlr4 ParserErrorListener 来获取错误信息。

声明一个 ParserErrorListener

ts 复制代码
import { ParserErrorListener } from 'antlr4ts';

export class SelectErrorListener implements ParserErrorListener {
    private _parserErrorSet: Set<any> = new Set();

    syntaxError(_rec, _ofSym, line, charPosInLine, msg) {
        let endCol = charPosInLine + 1;
        this._parserErrorSet.add({
            startLine: line,
            endLine: line,
            startCol: charPosInLine,
            endCol: endCol,
            message: msg,
        })
    }

    clear () {
        this._parserErrorSet.clear();
    }

    get parserErrors () {
        return Array.from(this._parserErrorSet) 
    }
}

使用 ParserErrorListener 收集错误信息

ts 复制代码
import { CommonTokenStream, CharStreams } from 'antlr4ts';
import { SelectStatementLexer } from '../lib/selectStatement/SelectStatementLexer';
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';


class SelectParser {
    private _errorListener = new SelectErrorListener();

    createLexer(input: string) {
        const inputStream = CharStreams.fromString(input);
        const lexer = new SelectStatementLexer(inputStream);
        this._errorListener.clear();
        lexer.removeErrorListeners(); // 移除 Antlr4 内置的 ErrorListener
        lexer.addErrorListener(this._errorListener)
        return lexer
    }

    createParser (input: string) {
        const lexer = this.createLexer(input);
        const tokens = new CommonTokenStream(lexer);
        const parser = new SelectStatementParser(tokens);
        parser.removeErrorListeners(); // 移除 Antlr4 内置的 ErrorListener
        parser.addErrorListener(this._errorListener);
        return parser
    }

    parse (sql: string) {
        const parser = this.createParser(sql)
        const parseTree = parser.selectStatement();
        console.log(this._errorListener.parserErrors);
        return {
          parseTree,
          errors: this._errorListener.parserErrors,
        };
    }
}
// 试一下效果
const selectParser = new SelectParser();
const { errors } = selectParser.parse('SELECT id FRO');
console.log(errors);

打印结果

这样我们就获取到了文本中的语法错误出现的位置,以及错误信息。

到此为止上文中遗留的第一个问题就已经差不多解决了,我们只需要在合适的时机将编辑器的内容进行解析,拿到错误信息并且通过 editor.setModelMarkers这个 API 让错误的位置飘红就大功告成了。

自动补全功能

对于自动补全功能,Antlr4 并没有直接提供,但是社区已经有了比较优秀的解决方案 -antlr-c3 。它的作用是根据Antlr4 Parser 的解析结果,分析指定位置填哪些词法/语法规则是合法的

antlr4-c3 的使用方式比较简单。

ts 复制代码
import { CodeCompletionCore } from "antlr4-c3";

// 这里 parser 是 parser 实例
let core = new CodeCompletionCore(parser); 
// tokenIndex 是想要自动补全的位置,对应由编辑器的光标位置转换而来
// parserContext 则是解析完之后的返回的 ParserTree 或者 ParserTree 的子节点(传入子节点可以更高效)
let candidates = core.collectCandidates(tokenIndex, parserContext);

那么结合上文中写的 SelectParser,代码应该是这样

js 复制代码
import { CodeCompletionCore } from "antlr4-c3";
import { SelectParser } from "./selectParser";

/**
 * input 源文本
 * caretPosition 编辑器光标位置
 */
function getSuggestions(input: string, caretPosition) {
    const selectParser = new SelectParser();
    const parserIns = selectParser.createParser(input)
    let core = new CodeCompletionCore(parserIns);

    const parserContext = parserIns.selectStatement();
    // 伪代码
    const tokenIndex = caretPosition2TokenIndex(caretPosition)

    let candidates = core.collectCandidates(tokenIndex, parserContext);
}

core.collectCandidates 的返回值的数据类型如下:

ts 复制代码
interface CandidatesCollection {
    tokens: Map<number, TokenList>;
    rules: Map<number, CandidateRule>;
}

tokens 对应的是词法规则提示,比如关键字等,rules 对应的是语法规则,比如上述语法文件中的 columnPathtablePath等。

需要注意的是,antlr4-c3 默认不收集语法规则,需要我们手动设置需要收集的语法规则

ts 复制代码
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';


let core = new CodeCompletionCore(parserIns);

core.preferredRules= new Set([
    SelectStatementParser.RULE_tablePath,
    SelectStatementParser.RULE_columnPath
])
// 设置需要收集 tablePath 和 columnPath

这样我们就收集到了在指定位置的可以填什么。接下来我们需要将结果进行转换成我们需要的数据结果

ts 复制代码
import { CodeCompletionCore } from "antlr4-c3";
import { SelectParser } from "./selectParser";
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';

/**
 * input 源文本
 * caretPosition 编辑器光标位置
 */
export function getSuggestions(input: string, caretPosition?: any) {
    const selectParser = new SelectParser();
    const parserIns = selectParser.createParser(input)
    let core = new CodeCompletionCore(parserIns);

    core.preferredRules= new Set([
        SelectStatementParser.RULE_tablePath,
        SelectStatementParser.RULE_columnPath
    ])

    const parserContext = parserIns.selectStatement();
    const tokenIndex = caretPosition2TokenIndex(caretPosition);

    let candidates = core.collectCandidates(tokenIndex, parserContext);

    const rule = [];
    const keywords = []

    for (let candidate of candidates.rules) {
        const [ruleType] = candidate;
        let syntaxContextType;
        switch (ruleType) {
            case SelectStatementParser.RULE_tablePath: {
                syntaxContextType = 'table';
                break;
            }
            case SelectStatementParser.RULE_columnPath: {
                syntaxContextType = 'column';
                break;
            }
            default:
                break;
        }
        if (syntaxContextType) {
            rule.push(syntaxContextType)
        }
    }

    for (let candidate of candidates.tokens) {
        const symbolicName = parserIns.vocabulary.getSymbolicName(candidate[0]);
        const displayName = parserIns.vocabulary.getDisplayName(candidate[0]);
        if(symbolicName && symbolicName.startsWith('KW_')) {
            const keyword = displayName.startsWith("'") && displayName.endsWith("'")
                ? displayName.slice(1, -1)
                : displayName
            keywords.push(keyword);
        }
    }

    console.log('===== suggest keywords: ',keywords);
    console.log('===== suggest rules:', rule);
}

这样我们就拿到了要提示的关键字和语法规则。关键字可以直接用于生成自动补全项,语法规则可以用于提示表名、字段名等。

小结分析

在这一节中,我们已经了解了,如何使用 Antlr4 和 antlr4-c3 来实现更加智能的飘红提示以及自动补全功能。

这一部分功能,在 monaco-sql-languages 中通过引入数栈前端团队自研的开源项目 dt-sql-parser 实现。

前文中提到的 worker 文件也正是用于运行 sql parser,因为dt-sql-parser 的解析可能会比较耗时,为了避免阻塞用户交互,将 sql parser 放到 web worker 中运行显然是更明智的选择。

总结

总的来说

  • 多种 SQL 的语法高亮
  • 多种 SQL 的报错提示(错误位置飘红)
  • 多种 SQL 的自动补全(智能提示)

三个功能大部分都可以通过 MonacoEditor 内置的 API 来实现,只是关键的语法解析功能需要使用 Antlr4 实现。因此实际上大部分的工作在编写 Antlr4 的语法文件以及方案整合上面。

Github 链接

相关推荐
知识分享小能手2 分钟前
mysql学习教程,从入门到精通,SQL DISTINCT 子句 (16)
大数据·开发语言·sql·学习·mysql·数据分析·数据库开发
Rense12 小时前
开源RK3588 AI Module7,并与Jetson Nano生态兼容的低功耗AI模块
人工智能·开源
程序员皮皮林6 小时前
开源PDF工具 Apache PDFBox 认识及使用(知识点+案例)
java·pdf·开源·apache
NaZiMeKiY6 小时前
SQLServer数据分页
数据库·sql·sqlserver
码爸6 小时前
java 执行es中的sql
java·sql·elasticsearch
中文很快乐7 小时前
springboot结合p6spy进行SQL监控
java·数据库·sql
666786667 小时前
Mysql高级篇(中)—— SQL优化
linux·运维·服务器·数据库·sql·mysql
知识分享小能手8 小时前
mysql学习教程,从入门到精通,SQL 删除数据(DELETE 语句)(19)
大数据·开发语言·数据库·sql·学习·mysql·数据开发
冰镇毛衣8 小时前
2.4 数据库表字段约束
数据库·sql·mysql
冰镇毛衣8 小时前
数据库简介
开发语言·数据库·sql·oracle