查询窗口加自动补全:T-SQL 关键字、函数、数据类型(中文说明,只列服务器能执行的),当前数据库的表名和列名;FROM / JOIN 等后面敲空格弹出表名,"表名."或"别名."只列这张表的列,USE 后面列数据库;字符串和注释里不弹出
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-27 15:01:57 +08:00
1 parent d6e891cdb0
commit b292d38ba7
6 files changed
+520 -6

No files matched your search

+13 -4
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
// 查询窗口(仿 SSMS):选数据库、写 T-SQL、F5 执行(选中一部分时只执行选中的),下面是结果和消息。
// USE 语句会切换当前数据库,和 SSMS 一样执行后下拉框跟着变。
// USE 语句会切换当前数据库,和 SSMS 一样执行后下拉框跟着变。自动补全见 completion.ts。
import { NButton, NSelect, NTab, NTabs } from 'naive-ui'
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
import { monaco } from '../editor/monaco'
import ResultGrid from './ResultGrid.vue'
import { messages, runQuery, type QueryReply } from './sql'
import { setSqlSchema } from './sql-language'
import { messages, runQuery, type ExplorerDatabase, type QueryReply } from './sql'
const props = defineProps<{ databases: string[]; initialSql?: string; runOnOpen?: boolean }>()
const props = defineProps<{ databases: ExplorerDatabase[]; initialSql?: string; runOnOpen?: boolean }>()
const database = defineModel<string>('database', { required: true })
const emit = defineEmits<{ changed: [] }>()
@@ -16,8 +17,10 @@ const editor = shallowRef<monaco.editor.IStandaloneCodeEditor>()
const reply = ref<QueryReply | null>(null)
const running = ref(false)
const view = ref<'results' | 'messages'>('results')
let removeSchema: (() => void) | undefined
const options = computed(() => ['master', ...props.databases].map((name) => ({ label: name, value: name })))
const names = computed(() => ['master', ...props.databases.map((d) => d.name)])
const options = computed(() => names.value.map((name) => ({ label: name, value: name })))
const messageLines = computed(() => (reply.value ? messages(reply.value) : []))
async function execute() {
@@ -64,12 +67,18 @@ onMounted(() => {
tabSize: 4,
})
editor.value.addCommand(monaco.KeyCode.F5, () => void execute())
// 自动补全:当前数据库(下拉框选的)的表和列
removeSchema = setSqlSchema(editor.value.getModel()!, () => ({
databases: names.value,
tables: props.databases.find((d) => d.name === database.value)?.tables ?? [],
}))
editor.value.focus()
if (props.runOnOpen) void execute()
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', f5)
removeSchema?.()
editor.value?.getModel()?.dispose()
editor.value?.dispose()
})
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, test } from 'bun:test'
import { sqlSuggestions, type SqlSchema } from './completion'
import type { ExplorerTable } from './sql'
const column = (name: string, type = 'varchar(50)', primaryKey = false) => ({ name, type, nullable: !primaryKey, identity: primaryKey, primaryKey })
const environment: ExplorerTable = { name: 'CampusEnvironment', columns: [column('ID', 'int', true), column('light'), column('addtime', 'datetime')] }
const users: ExplorerTable = { name: '用户表', columns: [column('ID', 'int', true), column('姓名')] }
const spaced: ExplorerTable = { name: 'my table', columns: [column('Order')] }
const schema: SqlSchema = { databases: ['master', '环境数据库'], tables: [environment, users, spaced] }
/** | 是光标位置 */
function suggest(sql: string, trigger?: string) {
const offset = sql.indexOf('|')
return sqlSuggestions(sql.replace('|', ''), offset, schema, trigger)
}
const labels = (sql: string, trigger?: string) => suggest(sql, trigger).map((s) => s.label)
const sorted = (sql: string, trigger?: string) =>
suggest(sql, trigger)
.sort((a, b) => a.sort.localeCompare(b.sort))
.map((s) => s.label)
describe('查询窗口的自动补全', () => {
test('一般位置:关键字、函数、数据类型、表、列都有', () => {
const items = suggest('SEL|')
expect(items.find((s) => s.label === 'SELECT')?.detail).toBe('查询')
expect(items.find((s) => s.label === 'GETDATE()')).toMatchObject({ kind: 'function', insert: 'GETDATE($0)', snippet: true })
expect(items.find((s) => s.label === 'COUNT(*)')?.insert).toBe('COUNT(*)')
expect(items.find((s) => s.label === 'nvarchar(50)')?.kind).toBe('type')
expect(items.find((s) => s.label === 'CampusEnvironment')?.kind).toBe('table')
expect(items.find((s) => s.label === 'light')?.detail).toBe('列 · varchar(50)(CampusEnvironment)')
})
test('同名的列只列一次,说明里写出所有表', () => {
const ids = suggest('|').filter((s) => s.label === 'ID')
expect(ids).toHaveLength(1)
expect(ids[0].detail).toBe('列 · int · 主键 · 自增 · 不允许空(CampusEnvironment、用户表)')
})
test('FROM / JOIN / UPDATE / INTO 后面表名排在最前', () => {
expect(sorted('SELECT * FROM |').slice(0, 3)).toEqual(['CampusEnvironment', 'my table', '用户表'])
expect(sorted('UPDATE |')[0]).toBe('CampusEnvironment')
expect(sorted('INSERT INTO C|')[0]).toBe('CampusEnvironment')
expect(sorted('SELECT * FROM 用户表, |')[0]).toBe('CampusEnvironment')
})
test('语句里用到的表的列排在其他列前面', () => {
const columns = sorted('SELECT | FROM 用户表').filter((label) => ['姓名', 'light'].includes(label))
expect(columns).toEqual(['姓名', 'light'])
})
test('空格触发:只在要写表名的地方弹出', () => {
expect(labels('SELECT * FROM |', ' ')).toContain('CampusEnvironment')
expect(labels('SELECT * FROM 用户表 |', ' ')).toEqual([])
expect(labels('SELECT |', ' ')).toEqual([])
})
test('表名. 列出这张表的列(按表里的顺序)', () => {
expect(labels('SELECT CampusEnvironment.| FROM CampusEnvironment', '.')).toEqual(['ID', 'light', 'addtime'])
expect(labels('SELECT campusenvironment.|', '.')).toEqual(['ID', 'light', 'addtime'])
})
test('别名. 列出别名对应的表的列,支持 AS、JOIN、逗号分隔', () => {
expect(labels('SELECT e.| FROM CampusEnvironment e', '.')).toEqual(['ID', 'light', 'addtime'])
expect(labels('SELECT u.| FROM CampusEnvironment AS e INNER JOIN 用户表 AS u ON e.ID = u.ID', '.')).toEqual(['ID', '姓名'])
expect(labels('SELECT u.| FROM CampusEnvironment e, dbo.用户表 u', '.')).toEqual(['ID', '姓名'])
expect(labels('SELECT t.| FROM [my table] t', '.')).toEqual(['Order'])
})
test('几条语句用同一个别名时,取离光标最近的', () => {
const sql = 'SELECT * FROM CampusEnvironment t\nSELECT t.| FROM 用户表 t'
expect(labels(sql, '.')).toEqual(['ID', '姓名'])
})
test('表名后面的关键字不当别名', () => {
expect(labels('SELECT CampusEnvironment.| FROM CampusEnvironment WHERE ID = 1', '.')).toEqual(['ID', 'light', 'addtime'])
})
test('不认识的前缀不弹出;dbo. 列出表', () => {
expect(labels('SELECT x.| FROM CampusEnvironment', '.')).toEqual([])
expect(labels('SELECT 3.|', '.')).toEqual([])
expect(labels('SELECT * FROM dbo.|', '.')).toEqual(['CampusEnvironment', '用户表', 'my table'])
})
test('名字需要时加方括号;已经输入了 [ 时不加', () => {
expect(suggest('SELECT * FROM |').find((s) => s.label === 'my table')?.insert).toBe('[my table]')
expect(suggest('SELECT t.| FROM [my table] t', '.')[0].insert).toBe('[Order]')
expect(suggest('SELECT * FROM [m|').find((s) => s.label === 'my table')?.insert).toBe('my table')
expect(suggest('SELECT * FROM [m|').every((s) => s.kind === 'table' || s.kind === 'column')).toBe(true)
expect(suggest('SELECT t.[|] FROM [my table] t')[0].insert).toBe('Order')
expect(suggest('SELECT * FROM |').find((s) => s.label === '用户表')?.insert).toBe('用户表')
})
test('USE 后面列数据库', () => {
expect(labels('USE |', ' ')).toEqual(['master', '环境数据库'])
})
test('字符串和注释里不补全', () => {
expect(labels("SELECT * FROM 用户表 WHERE 姓名 = '张|")).toEqual([])
expect(labels("SELECT * FROM 用户表 WHERE 姓名 = 'a.|", '.')).toEqual([])
expect(labels('-- 查询 FROM |', ' ')).toEqual([])
expect(labels('/* e.| */', '.')).toEqual([])
expect(labels("SELECT 'it''s' AS a, |")).toContain('SELECT')
expect(labels('-- 注释\nSEL|')).toContain('SELECT')
})
})
+347
View File
@@ -0,0 +1,347 @@
// 查询窗口的自动补全(仿 SSMS 的 IntelliSense):T-SQL 关键字、函数、数据类型,当前数据库的表名和列名;
// 输入"表名."或"别名."时只列这张表的列。只做简单的词法分析(找 FROM / JOIN / UPDATE / INTO 后面的表和别名),够学生用就行。
// 这里不依赖 Monaco,方便单独测试;注册到编辑器见 sql-language.ts。
// 关键字、函数、数据类型只列服务器能执行的(见 runtime/Sim.Data/Engine/Translator.cs)。
import type { ExplorerTable } from './sql'
export type SuggestionKind = 'keyword' | 'function' | 'type' | 'table' | 'column' | 'database'
export interface SqlSuggestion {
label: string
kind: SuggestionKind
/** 插入的文字;snippet 为 true 时是 Monaco 的代码片段格式($0 是插入后光标的位置) */
insert: string
snippet?: boolean
/** 显示在右边的中文说明 */
detail: string
sort: string
}
export interface SqlSchema {
databases: string[]
tables: ExplorerTable[]
}
const KEYWORDS: [string, string][] = [
['SELECT', '查询'],
['FROM', '从哪张表'],
['WHERE', '筛选条件'],
['INSERT INTO', '插入数据'],
['VALUES', '插入的值'],
['UPDATE', '修改数据'],
['SET', '要修改的列和新值'],
['DELETE FROM', '删除数据'],
['CREATE DATABASE', '新建数据库'],
['CREATE TABLE', '新建表'],
['ALTER TABLE', '修改表结构'],
['ADD', '添加列'],
['DROP TABLE', '删除表'],
['DROP DATABASE', '删除数据库'],
['TRUNCATE TABLE', '清空表里的数据'],
['USE', '切换数据库'],
['ORDER BY', '排序'],
['GROUP BY', '分组'],
['HAVING', '分组后的筛选条件'],
['ASC', '升序(从小到大)'],
['DESC', '降序(从大到小)'],
['TOP', '只取前几行'],
['DISTINCT', '去掉重复的行'],
['AS', '起别名'],
['AND', '并且'],
['OR', '或者'],
['NOT', '不'],
['NULL', '空值'],
['IS NULL', '是空值'],
['IS NOT NULL', '不是空值'],
['IN', '在列表里'],
['LIKE', '模糊匹配(% 任意多个字符,_ 一个字符)'],
['BETWEEN', '在两个值之间(BETWEEN 小 AND 大)'],
['JOIN', '连接另一张表'],
['INNER JOIN', '内连接'],
['LEFT JOIN', '左连接'],
['RIGHT JOIN', '右连接'],
['CROSS JOIN', '交叉连接'],
['ON', '连接条件'],
['UNION', '合并两个查询的结果(去重)'],
['UNION ALL', '合并两个查询的结果'],
['CASE', '条件取值(CASE WHEN ... THEN ... END)'],
['WHEN', '条件'],
['THEN', '条件成立时的值'],
['ELSE', '都不成立时的值'],
['END', '结束 CASE'],
['PRIMARY KEY', '主键'],
['IDENTITY(1,1)', '自增列(标识列)'],
['NOT NULL', '不允许空值'],
['DEFAULT', '默认值'],
]
const FUNCTIONS: [string, string, string][] = [
['COUNT', '*', '统计行数'],
['SUM', '列', '求和'],
['AVG', '列', '平均值'],
['MAX', '列', '最大值'],
['MIN', '列', '最小值'],
['GETDATE', '', '当前日期和时间'],
['YEAR', '日期', '取年份'],
['MONTH', '日期', '取月份'],
['DAY', '日期', '取日'],
['DATEPART', '部分, 日期', '取日期的一部分,部分如 year、month、day、hour'],
['DATENAME', '部分, 日期', '取日期的一部分'],
['DATEADD', '部分, 数量, 日期', '日期加上一段时间'],
['DATEDIFF', '部分, 开始, 结束', '两个日期相差多少'],
['CAST', '值 AS 类型', '转换类型'],
['CONVERT', '类型, 值', '转换类型'],
['LEN', '字符串', '字符串长度'],
['LEFT', '字符串, 个数', '取左边几个字符'],
['RIGHT', '字符串, 个数', '取右边几个字符'],
['SUBSTRING', '字符串, 开始, 长度', '取一部分字符'],
['CHARINDEX', '要找的, 字符串', '查找位置(从 1 开始,找不到是 0)'],
['REPLACE', '字符串, 旧, 新', '替换'],
['UPPER', '字符串', '转大写'],
['LOWER', '字符串', '转小写'],
['LTRIM', '字符串', '去掉左边的空格'],
['RTRIM', '字符串', '去掉右边的空格'],
['ABS', '数', '绝对值'],
['ROUND', '数, 小数位数', '四舍五入'],
['FLOOR', '数', '向下取整'],
['CEILING', '数', '向上取整'],
['POWER', '数, 次方', '乘方'],
['SQRT', '数', '平方根'],
['ISNULL', '值, 替代值', '是空值时用替代值'],
['COALESCE', '值1, 值2, ...', '第一个不是空值的'],
['NEWID', '', '新的 GUID'],
['RAND', '', '0 到 1 之间的随机数'],
['SCOPE_IDENTITY', '', '刚插入的自增值'],
]
const TYPES: [string, string][] = [
['int', '整数'],
['bigint', '大整数'],
['smallint', '小整数'],
['tinyint', '0 到 255 的整数'],
['bit', '0 或 1'],
['decimal(18, 2)', '小数(总位数, 小数位数)'],
['float', '浮点数'],
['real', '单精度浮点数'],
['money', '金额'],
['char(10)', '定长字符串'],
['varchar(50)', '字符串'],
['varchar(MAX)', '长字符串'],
['nchar(10)', '定长字符串(Unicode)'],
['nvarchar(50)', '字符串(Unicode,存中文用)'],
['nvarchar(MAX)', '长字符串(Unicode)'],
['text', '长文本'],
['ntext', '长文本(Unicode)'],
['date', '日期'],
['datetime', '日期和时间'],
['datetime2', '日期和时间(精度更高)'],
['time', '时间'],
['varbinary(MAX)', '二进制数据(图片等)'],
['image', '二进制数据(图片等)'],
['uniqueidentifier', 'GUID'],
]
/** 后面跟表名的关键字 */
const TABLE_CONTEXT = new Set(['FROM', 'JOIN', 'UPDATE', 'INTO', 'TABLE'])
/** 跟在表名后面时不是别名的词 */
const NOT_ALIAS = new Set(
'WHERE ON JOIN INNER LEFT RIGHT FULL CROSS OUTER ORDER GROUP HAVING UNION EXCEPT SET VALUES SELECT DEFAULT WITH AS FROM INTO UPDATE DELETE INSERT CREATE DROP ALTER USE TRUNCATE ADD'.split(' '),
)
/** 不加方括号就不能当名字用的词(关键字里的单词) */
const RESERVED = new Set(KEYWORDS.flatMap(([k]) => k.split(/[\s(]/)).concat(['TABLE', 'DATABASE', 'KEY', 'INTO', 'BY']))
const IDENTIFIER = /^[\p{L}_][\p{L}\p{N}_@$#]*$/u
interface Token {
text: string
/** 名字(去掉方括号);关键字比较用大写 */
name: string
start: number
end: number
word: boolean
}
/**
* 分词,跳过字符串和注释。光标在字符串或注释里时 inLiteral 为 true(这时不补全)。
* 只扫描 [0, end),未闭合的方括号名字算作一个词。
*/
function tokenize(text: string, end: number) {
const tokens: Token[] = []
let i = 0
let inLiteral = false
while (i < end) {
const c = text[i]
if (/\s/.test(c)) {
i++
} else if (c === "'" || (c === '-' && text[i + 1] === '-') || (c === '/' && text[i + 1] === '*')) {
const close = c === "'" ? text.indexOf("'", i + 1) : c === '-' ? text.indexOf('\n', i) : text.indexOf('*/', i + 2)
if (close === -1 || close >= end) {
inLiteral = true
break
}
// '' 是字符串里的单引号:下一轮从它开始又是一个字符串,相当于接着跳过
i = close + (c === '/' ? 2 : 1)
} else if (c === '[') {
const close = text.indexOf(']', i)
const stop = close === -1 || close >= end ? end : close + 1
tokens.push({ text: text.slice(i, stop), name: text.slice(i + 1, close === -1 || close >= end ? end : close), start: i, end: stop, word: true })
i = stop
} else if (/[\p{L}\p{N}_@$#]/u.test(c)) {
const start = i
while (i < end && /[\p{L}\p{N}_@$#]/u.test(text[i])) i++
const word = text.slice(start, i)
tokens.push({ text: word, name: word, start, end: i, word: true })
} else {
tokens.push({ text: c, name: c, start: i, end: i + 1, word: false })
i++
}
}
return { tokens, inLiteral }
}
const upper = (token: Token | undefined) => (token?.word && !token.text.startsWith('[') ? token.text.toUpperCase() : '')
const same = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0
interface TableRef {
table: ExplorerTable
alias: string | null
offset: number
}
/** 找出语句里用到的表和别名:FROM / JOIN / UPDATE / INTO / TABLE 后面的表名(可带 dbo.),后面可以跟 [AS] 别名,FROM 后面可以用逗号列多张表 */
function tableRefs(tokens: Token[], tables: ExplorerTable[]) {
const refs: TableRef[] = []
for (let i = 0; i < tokens.length; i++) {
const keyword = upper(tokens[i])
if (!TABLE_CONTEXT.has(keyword)) continue
let j = i + 1
for (;;) {
if (upper(tokens[j]) === 'DBO' && tokens[j + 1]?.text === '.') j += 2
const name = tokens[j]
const table = name?.word ? tables.find((t) => same(t.name, name.name)) : undefined
if (!table) break
j++
if (upper(tokens[j]) === 'AS') j++
let alias: string | null = null
if (tokens[j]?.word && !NOT_ALIAS.has(upper(tokens[j]))) alias = tokens[j++].name
refs.push({ table, alias, offset: name.start })
if (keyword !== 'FROM' || tokens[j]?.text !== ',') break
j++
}
}
return refs
}
/** 名字需要时加方括号;光标前已经有 [ 时不加 */
function nameText(name: string, bracketOpen: boolean) {
if (bracketOpen) return name
return IDENTIFIER.test(name) && !RESERVED.has(name.toUpperCase()) ? name : `[${name.replace(/]/g, ']]')}]`
}
function columnDetail(column: ExplorerTable['columns'][number]) {
return ['列', column.type, column.primaryKey ? '主键' : '', column.identity ? '自增' : '', column.nullable ? '' : '不允许空'].filter(Boolean).join(' · ')
}
const pad = (n: number) => String(n).padStart(4, '0')
/**
* 光标处(offset)的补全项。trigger 是触发补全的字符:输入"."时列表或表;输入空格时只在 FROM / JOIN 等后面列表名,其他地方不弹出。
*/
export function sqlSuggestions(text: string, offset: number, schema: SqlSchema, trigger?: string): SqlSuggestion[] {
// 光标所在的词(正在输入的部分)不算在分析里
let wordStart = offset
while (wordStart > 0 && /[\p{L}\p{N}_@$#]/u.test(text[wordStart - 1])) wordStart--
const before = tokenize(text, wordStart)
if (before.inLiteral) return []
const all = tokenize(text, text.length).tokens
const bracketOpen = text[wordStart - 1] === '['
// 正在输入的词前面是 "[" 时,往前再看一个
const previous = before.tokens.filter((t) => !(bracketOpen && t.end === wordStart))
const last = previous.at(-1)
const refs = tableRefs(all, schema.tables)
// 表名. 或 别名.
if (last?.text === '.' && last.end === (bracketOpen ? wordStart - 1 : wordStart)) {
const qualifier = previous.at(-2)
if (!qualifier?.word) return []
if (upper(qualifier) === 'DBO') return tableSuggestions(schema.tables, bracketOpen, '0')
const matches = refs.filter((r) => (r.alias ? same(r.alias, qualifier.name) : false) || same(r.table.name, qualifier.name))
const nearest = matches.sort((a, b) => Math.abs(a.offset - offset) - Math.abs(b.offset - offset))[0]
const table = nearest?.table ?? schema.tables.find((t) => same(t.name, qualifier.name))
if (!table) return []
return table.columns.map((column, i) => ({
label: column.name,
kind: 'column',
insert: nameText(column.name, bracketOpen),
detail: columnDetail(column),
sort: pad(i),
}))
}
if (trigger === '.') return []
const context = upper(last)
if (context === 'USE' || (context === 'DATABASE' && upper(previous.at(-2)) === 'DROP')) {
return schema.databases.map((name) => ({ label: name, kind: 'database', insert: nameText(name, bracketOpen), detail: '数据库', sort: name }))
}
const tableContext = TABLE_CONTEXT.has(context) || (last?.text === ',' && lastKeyword(previous) === 'FROM')
if (trigger === ' ' && !tableContext) return []
if (bracketOpen) {
// [ 后面只会是名字
return [...tableSuggestions(schema.tables, true, '0'), ...columnSuggestions(schema.tables, refs, true)]
}
const keywords: SqlSuggestion[] = KEYWORDS.map(([label, detail]) => ({ label, kind: 'keyword', insert: label, detail, sort: `3${label}` }))
const functions: SqlSuggestion[] = FUNCTIONS.map(([name, args, detail]) => ({
label: `${name}(${args})`,
kind: 'function',
insert: args === '*' ? `${name}(*)` : `${name}($0)`,
snippet: args !== '*',
detail,
sort: `3${name}`,
}))
const types: SqlSuggestion[] = TYPES.map(([label, detail]) => ({ label, kind: 'type', insert: label, detail: `数据类型:${detail}`, sort: `4${label}` }))
return [
...tableSuggestions(schema.tables, false, tableContext ? '0' : '2'),
...(tableContext ? [] : columnSuggestions(schema.tables, refs, false)),
...keywords,
...functions,
...types,
]
}
/** 往前找最近的一个子句关键字(判断逗号是不是在 FROM 的表列表里) */
function lastKeyword(tokens: Token[]) {
for (let i = tokens.length - 1; i >= 0; i--) {
const word = upper(tokens[i])
if (['SELECT', 'FROM', 'WHERE', 'SET', 'VALUES', 'ORDER', 'GROUP', 'ON', 'JOIN', 'HAVING'].includes(word)) return word
}
return ''
}
function tableSuggestions(tables: ExplorerTable[], bracketOpen: boolean, rank: string): SqlSuggestion[] {
return tables.map((table) => ({ label: table.name, kind: 'table', insert: nameText(table.name, bracketOpen), detail: '表', sort: rank + table.name }))
}
/** 所有表的列(同名的列只列一次);语句里用到的表的列排在前面 */
function columnSuggestions(tables: ExplorerTable[], refs: TableRef[], bracketOpen: boolean): SqlSuggestion[] {
const used = new Set(refs.map((r) => r.table))
const ordered = [...tables.filter((t) => used.has(t)), ...tables.filter((t) => !used.has(t))]
const columns = new Map<string, { column: ExplorerTable['columns'][number]; tables: string[]; sort: string }>()
for (const table of ordered) {
for (const column of table.columns) {
const key = column.name.toUpperCase()
const existing = columns.get(key)
if (existing) existing.tables.push(table.name)
else columns.set(key, { column, tables: [table.name], sort: `${used.has(table) ? 1 : 2}${pad(columns.size)}` })
}
}
return [...columns.values()].map(({ column, tables, sort }) => ({
label: column.name,
kind: 'column',
insert: nameText(column.name, bracketOpen),
detail: `${columnDetail(column)}(${tables.join('、')})`,
sort,
}))
}
+52
View File
@@ -0,0 +1,52 @@
// 查询窗口的自动补全注册到 Monaco。每个查询窗口(模型)有自己的当前数据库,由 QueryTab 设置取表结构的函数。
import { monaco } from '../editor/monaco'
import { sqlSuggestions, type SqlSchema, type SuggestionKind } from './completion'
const schemas = new Map<string, () => SqlSchema>()
const Kind = monaco.languages.CompletionItemKind
const KINDS: Record<SuggestionKind, monaco.languages.CompletionItemKind> = {
keyword: Kind.Keyword,
function: Kind.Function,
type: Kind.TypeParameter,
table: Kind.Class,
column: Kind.Field,
database: Kind.Module,
}
let registered = false
/** 设置模型的表结构(取当前数据库的表);返回取消函数,关闭查询窗口时调用 */
export function setSqlSchema(model: monaco.editor.ITextModel, schema: () => SqlSchema) {
register()
const key = model.uri.toString()
schemas.set(key, schema)
return () => schemas.delete(key)
}
function register() {
if (registered) return
registered = true
monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['.', ' '],
provideCompletionItems(model, position, context) {
const schema = schemas.get(model.uri.toString())
if (!schema) return { suggestions: [] }
const word = model.getWordUntilPosition(position)
const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, position.column)
const items = sqlSuggestions(model.getValue(), model.getOffsetAt(position), schema(), context.triggerCharacter)
return {
suggestions: items.map((item) => ({
label: { label: item.label, description: item.detail },
kind: KINDS[item.kind],
insertText: item.insert,
insertTextRules: item.snippet ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet : undefined,
sortText: item.sort,
filterText: item.label,
detail: item.detail,
range,
})),
}
},
})
}
+1 -1
View File
@@ -190,7 +190,7 @@ onMounted(refresh)
<QueryTab
v-if="tab.kind === 'query'"
v-model:database="tab.database"
:databases="databaseNames"
:databases="databases"
:initial-sql="tab.sql"
:run-on-open="tab.run"
@changed="refresh"
+1 -1
View File
@@ -141,7 +141,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<h3>7. 数据库(项目 6、8)</h3>
<ul>
<li>点右上角的<b>数据库</b>(在新标签页打开),代替书上的 SSMS:右键新建数据库、新建表,<b>新建查询</b>写 SQL,按 F5 执行。</li>
<li>点右上角的<b>数据库</b>(在新标签页打开),代替书上的 SSMS:右键新建数据库、新建表,<b>新建查询</b>写 SQL(输入时会提示关键字、表名和列名,写"表名."或"别名."列出这张表的列),按 F5 执行。</li>
<li>每个人有自己的一套数据库,只有自己能看到,一直保留。程序里的连接字符串只看数据库名,服务器名、账号密码照书上写就行。</li>
<li>数据弄乱了:点<b>恢复实验初始数据</b>,选实验(默认是刚才做的实验),把这个实验用到的库换回初始数据(其他库不动)。</li>
<li>点<b>← 返回实验</b>回到刚才做的实验。</li>