✨ feat(vscode): 代码阅读支持库规则,内置 SQLAlchemy 模型类显示成数据表卡片

This commit is contained in:
cruldra committed 2026-09-26 18:29:40 +08:00
1 parent 32be7d54b6
commit 5ece24cef9
26 files changed
+1768 -81

No files matched your search

@@ -0,0 +1,21 @@
import { readdirSync, readFileSync } from 'node:fs'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
/** 库规则(这个目录下的非测试文件)import 的模块:只许 contract 和同目录的共用代码。 */
function importsOf(file: string): string[] {
const text = readFileSync(path.join(__dirname, file), 'utf8')
return [...text.matchAll(/^\s*(?:import|export)\s(?:[^'"]*?from\s+)?['"]([^'"]+)['"]/gm)].map(match => match[1])
}
describe('库规则只经 contract 拿信息', () => {
const files = readdirSync(__dirname).filter(file => file.endsWith('.ts') && !file.endsWith('.test.ts'))
it('目录里有规则', () => {
expect(files).toContain('sqlalchemy.ts')
})
it.each(files)('%s 不 import 语言插件、翻译插件和宿主', (file) => {
expect(importsOf(file).filter(source => source !== '../contract' && !source.startsWith('./'))).toEqual([])
})
})
@@ -0,0 +1,216 @@
import type { BlockView } from '../blockView'
import type { Inline, PropertyRow, Segment } from '../contract'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { sqlalchemyRule } from './sqlalchemy'
const py = createPythonAdapter(WASM_DIR)
const SNAPSHOT = readFixture('sqlalchemyModels.py')
async function views(source: string, reports: string[] = []): Promise<BlockView[]> {
return withBlocks(py, 'python', source, blocks =>
toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [sqlalchemyRule], report: message => reports.push(message) }))
}
function flatten(list: BlockView[]): BlockView[] {
return list.flatMap(view => [view, ...flatten(view.children)])
}
function properties(view: BlockView): PropertyRow[] {
if (!('properties' in view))
throw new Error(`块 ${view.id} 不是属性表`)
return view.properties
}
async function model(source: string, name: string): Promise<BlockView> {
const found = (await views(source)).find(view => 'properties' in view && view.properties[0]?.value && !('markdown' in view.properties[0].value)
&& view.properties[0].value.some(piece => 'text' in piece && piece.text === name))
if (!found)
throw new Error(`没有 ${name}`)
return found
}
/** 属性表里所有带下标的词(连表格里的格子)。 */
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
describe('sqlAlchemy 库规则', () => {
it('referralRelation:和原型一样的数据表卡片', async () => {
const table = await model(SNAPSHOT, 'ReferralRelation')
expect(table.kind).toBe('sqlalchemy.table')
expect(table.badge).toEqual({ label: '数据表', color: { dark: '#e8a860', light: '#a3590d' } })
expect(propertyLines(properties(table))).toEqual([
'名称: ReferralRelation',
'表名: referral_relations',
'说明: 邀请关系',
'继承: IntPK, TimestampMixin, Base',
'字段: 6 个',
' inviter_user_id | 整数 | 索引 · 外键 sys_user.id(删除时禁止) | 邀请人用户ID',
' invitee_user_id | 整数 | 唯一 · 外键 sys_user.id(删除时禁止) | 被邀请人用户ID(唯一, 一人只绑一个邀请人)',
' invite_code | 字符串(16) | | 绑定时使用的邀请码',
' bind_time | 日期时间(带时区) | | 绑定时间',
' expire_time | 日期时间(带时区) | | 归属过期时间',
' status | 枚举 RelationStatus | 默认 RelationStatus.ACTIVE | 关系状态',
'索引: 1 个',
' ix_referral_relations_inviter_bind_time | inviter_user_id, bind_time',
])
const rows = properties(table)
expect(rows.find(row => row.label === '字段')?.columns).toEqual(['字段', '类型', '约束', '说明'])
expect(rows.find(row => row.label === '索引')?.columns).toEqual(['名称', '列'])
expect(rows.find(row => row.label === '说明')?.value).toEqual({ markdown: '邀请关系' })
})
it('其余模型:Mapped 类型、可空、默认值、数据库默认、别名、关系、唯一约束', async () => {
expect(propertyLines(properties(await model(SNAPSHOT, 'ReferralRole'))).slice(4)).toEqual([
'字段: 4 个',
' role_code | 字符串(32) | 唯一 | 角色代码: bronze/silver/gold, 见 init_referral_roles.sql',
' role_name | 字符串(64) | | 角色显示名',
' is_active | 布尔 | 默认 真 | 是否启用',
' upgrade_min_invites | 整数 | 可空 | 自动升到本级所需累计邀请人数; 空=该条件不启用',
])
expect(propertyLines(properties(await model(SNAPSHOT, 'UserReferralProfile'))).slice(2)).toEqual([
'说明: 用户推广资料',
'继承: IntPK, TimestampMixin, Base',
'字段: 4 个',
' user_id | 整数 | 唯一 · 外键 sys_user.id(删除时禁止) | 平台用户ID',
' invite_code | 字符串(16) | 唯一 · 索引 | 用户唯一邀请码',
' custom_commission_rate_bps | 整数 | 可空 · 默认 空 | 单独佣金费率bps, 空则用角色默认',
' referral_status | 枚举 ReferralStatus | 默认 ReferralStatus.NORMAL | 推广状态',
'关系: 1 个',
' role | ReferralRole | ',
])
expect(propertyLines(properties(await model(SNAPSHOT, 'ReferralWithdrawal'))).slice(4)).toEqual([
'字段: 3 个',
' order_id | UUID | 唯一 · 外键 commerce_order.id(删除时禁止) | commerce 订单ID(唯一, 一单一佣金, 幂等)',
' tax_rate_bps | 整数 | 默认 0 · 数据库默认 text("0") | 申请时快照的代扣税点, 万分比 (后改配置不影响已有单)',
' auditor_id | 整数 | 可空 · 默认 空 · 外键 sys_user.id(删除时置空) | 审核人ID',
'索引: 1 个',
' ix_referral_withdrawals_user_status | user_id, status',
])
expect(propertyLines(properties(await model(SNAPSHOT, 'ReferralWithdrawalItem'))).slice(4)).toEqual([
'字段: 2 个',
' withdrawal_id | 整数 | 索引 · 外键 referral_withdrawals.id(级联删除) | 提现申请ID',
' commission_id | 整数 | 索引 · 外键 referral_commissions.id(删除时禁止) | 占用的佣金ID',
'唯一约束: 1 个',
' uq_referral_withdrawal_item_wd_commission | withdrawal_id, commission_id',
])
})
it('只认模型类:枚举类、函数照常显示;模型类的赋值都并进卡片,不再单独成块', async () => {
const list = await views(SNAPSHOT)
expect(list.map(view => view.kind)).toEqual([
'comment',
...Array.from({ length: 9 }, () => 'import'),
'function',
'class',
'class',
'sqlalchemy.table',
'sqlalchemy.table',
'sqlalchemy.table',
'sqlalchemy.table',
'sqlalchemy.table',
])
for (const view of list.filter(item => item.kind === 'sqlalchemy.table'))
expect(view.children, view.id).toEqual([])
})
it('并进来的行只在卡片里能编辑,卡片的编辑范围是整个类', async () => {
const lines = SNAPSHOT.split('\n')
const table = await model(SNAPSHOT, 'ReferralRelation')
expect(lines[table.startLine]).toBe('class ReferralRelation(IntPK, TimestampMixin, Base):')
expect(lines[table.endLine]).toBe(' )')
expect(lines[table.endLine + 1]).toBe('')
const owners = new Map<number, string[]>()
for (const view of flatten(await views(SNAPSHOT))) {
for (let line = view.startLine; line <= view.endLine; line++)
owners.set(line, [...owners.get(line) ?? [], view.id])
}
expect([...owners.values()].filter(ids => ids.length > 1)).toEqual([])
expect(owners.get(lines.indexOf(' __tablename__ = "referral_relations"'))).toEqual([table.id])
})
it('跳到定义、高亮出现处:卡片自己是类名的定义,字段行是字段名的定义,带下标的词都指着源码里的这个名字', async () => {
const table = await model(SNAPSHOT, 'ReferralRelation')
expect(SNAPSHOT.slice(table.defines?.startIndex, table.defines?.endIndex)).toBe('ReferralRelation')
const fields = properties(table).find(row => row.label === '字段')?.items ?? []
expect(fields.map(item => SNAPSHOT.slice(item.defines?.startIndex, item.defines?.endIndex))).toEqual([
'inviter_user_id',
'invitee_user_id',
'invite_code',
'bind_time',
'expire_time',
'status',
])
const [name] = fields[0].cells[0] as Segment[]
expect(name).toEqual({ text: 'inviter_user_id', role: 'variable', offset: fields[0].defines?.startIndex })
const linked = linkedSegments(properties(table))
expect(linked.map(segment => segment.text)).toEqual(expect.arrayContaining(['IntPK', 'RelationStatus', 'ACTIVE', 'status']))
for (const segment of linked)
expect(SNAPSHOT.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text)
})
it('筛选:种类是 sqlalchemy.table,访问级别同原来的类', async () => {
const table = await model(SNAPSHOT, 'ReferralRelation')
expect(table.attributes).toEqual({ kind: 'sqlalchemy.table', access: 'public' })
})
it('import sqlalchemy as sa:老写法 Column、类型全名、只写注解的字段、不认得的类型照常翻、docstring 当说明;方法照常是子块,夹在中间的注释并进来', async () => {
const source = [
'import sqlalchemy as sa',
'from sqlalchemy.orm import DeclarativeBase, relationship',
'',
'',
'class Base(DeclarativeBase):',
' pass',
'',
'',
'class User(Base):',
' """用户。"""',
'',
' __tablename__ = "users"',
' # 基本信息',
' id = sa.Column(sa.Integer, primary_key=True)',
' name = sa.Column("user_name", sa.String(50), sa.ForeignKey(Org.name, onupdate="cascade"))',
' orders = relationship("Order", back_populates="user", uselist=False)',
' price = sa.Column(sa.Numeric(10, 2), nullable=False)',
' nickname: Mapped[Optional[str]]',
' extra = sa.Column(CustomJson(3), comment=NOTE)',
'',
' def __repr__(self) -> str:',
' return self.name',
'',
].join('\n')
const list = await views(source)
expect(list.map(view => view.kind)).toEqual(['import', 'import', 'class', 'sqlalchemy.table'])
const user = list[3]
expect(propertyLines(properties(user))).toEqual([
'名称: User',
'表名: users',
'说明: 用户。',
'继承: Base',
'字段: 5 个',
' id | 整数 | 主键 | ',
' name | 字符串(50) | 可空 · 外键 Org.name(级联更新) | ',
' price | 定点数(10,2) | | ',
' nickname | 字符串 | 可空 | ',
' extra | CustomJson(3) | 可空 | NOTE',
'关系: 1 个',
' orders | Order | 对方字段 user · 单个对象',
])
expect([user.startLine, user.endLine]).toEqual([8, 18])
expect(user.children.map(child => [child.kind, child.startLine])).toEqual([['function', 20]])
})
it('没导入 SQLAlchemy 的文件不认', async () => {
const list = await views('class User(Base):\n __tablename__ = "users"\n')
expect(list.map(view => view.kind)).toEqual(['class'])
})
})
+557
View File
@@ -0,0 +1,557 @@
import type { ClassBlock, CodeBlock, ImportFacts, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, Segment, SourceRange, ThemeColor, TokenRole } from '../contract'
/**
* SQLAlchemy 库规则:声明式模型类(有 `__tablename__`,或主体里用 `mapped_column(...)` / `Column(...)` 定义字段)整块换成「数据表」卡片:
* 名称 / 表名 / 说明 / 继承 / 装饰器,字段、关系、索引、唯一约束各一张表,都是有才显示。
* `__tablename__`、`__table_args__`、字段和关系的赋值并进卡片,方法等别的成员照常是子块。
* 规则只经 contract 拿信息,直接读的是 Python 语法树(tree-sitter-python 的节点类型);用词都在词表里,按翻译插件的显示语言挑。
*/
/** 文件导入了这几个库之一才启用;Flask-SQLAlchemy 的模型写成 `db.Column(...)`,文件里可能只导入了它。 */
const LIBRARIES = ['sqlalchemy', 'flask_sqlalchemy']
const COLUMN_CALLS = new Set(['mapped_column', 'Column'])
/** 字段定义的位置参数里不是类型的那些(外键、序列、计算列……)。 */
const SCHEMA_ITEMS = new Set(['ForeignKey', 'Sequence', 'Identity', 'Computed', 'CheckConstraint', 'FetchedValue', 'DefaultClause'])
/** 写成关键字参数的类型大小:`String(length=16)`、`Numeric(precision=10, scale=2)`。 */
const SIZE_KEYWORDS = ['length', 'precision', 'scale']
const TABLE_KIND = 'sqlalchemy.table'
const TABLE_COLOR: ThemeColor = { dark: '#e8a860', light: '#a3590d' }
/** 一种显示语言的用词。 */
interface Words {
kind: string
labels: Record<'name' | 'table' | 'doc' | 'bases' | 'decorators' | 'columns' | 'relationships' | 'indexes' | 'uniques', string>
columnHeads: string[]
relationshipHeads: string[]
indexHeads: string[]
count: (n: number) => string
/** SQL 类型(SQLAlchemy 的类名;导入时起的别名已换回原名)→ 译名。 */
types: Record<string, string>
withTimezone: string
enumType: string
unannotated: string
constraints: Record<'primaryKey' | 'unique' | 'index' | 'nullable' | 'default' | 'serverDefault' | 'foreignKey', string>
/** 外键 ondelete / onupdate 的动作(大写)→ 译名;表里没有的用 otherOn… 拼。 */
onDelete: Record<string, string>
onUpdate: Record<string, string>
otherOnDelete: (action: string) => string
otherOnUpdate: (action: string) => string
/** 外键后面括起来的删除、更新规则。 */
actions: (texts: string[]) => string
unnamed: string
uniqueIndex: string
relationship: Record<'backPopulates' | 'single' | 'secondary', string>
}
const ZH: Words = {
kind: '数据表',
labels: { name: '名称', table: '表名', doc: '说明', bases: '继承', decorators: '装饰器', columns: '字段', relationships: '关系', indexes: '索引', uniques: '唯一约束' },
columnHeads: ['字段', '类型', '约束', '说明'],
relationshipHeads: ['名称', '目标', '说明'],
indexHeads: ['名称', '列'],
count: n => `${n} 个`,
types: {
Integer: '整数',
INTEGER: '整数',
INT: '整数',
BigInteger: '长整数',
BIGINT: '长整数',
SmallInteger: '短整数',
SMALLINT: '短整数',
String: '字符串',
Unicode: '字符串',
VARCHAR: '字符串',
NVARCHAR: '字符串',
CHAR: '字符串',
Text: '长文本',
UnicodeText: '长文本',
TEXT: '长文本',
Boolean: '布尔',
BOOLEAN: '布尔',
DateTime: '日期时间',
DATETIME: '日期时间',
TIMESTAMP: '日期时间',
Date: '日期',
DATE: '日期',
Time: '时间',
TIME: '时间',
Interval: '时间间隔',
Numeric: '定点数',
NUMERIC: '定点数',
DECIMAL: '定点数',
Float: '浮点数',
FLOAT: '浮点数',
REAL: '浮点数',
Double: '浮点数',
DOUBLE_PRECISION: '浮点数',
JSON: 'JSON',
JSONB: 'JSON',
UUID: 'UUID',
Uuid: 'UUID',
LargeBinary: '二进制',
BYTEA: '二进制',
ARRAY: '数组',
},
withTimezone: '(带时区)',
enumType: '枚举',
unannotated: '未标注',
constraints: { primaryKey: '主键', unique: '唯一', index: '索引', nullable: '可空', default: '默认', serverDefault: '数据库默认', foreignKey: '外键' },
onDelete: { 'CASCADE': '级联删除', 'RESTRICT': '删除时禁止', 'SET NULL': '删除时置空', 'SET DEFAULT': '删除时置默认值', 'NO ACTION': '删除时不处理' },
onUpdate: { 'CASCADE': '级联更新', 'RESTRICT': '更新时禁止', 'SET NULL': '更新时置空', 'SET DEFAULT': '更新时置默认值', 'NO ACTION': '更新时不处理' },
otherOnDelete: action => `删除时 ${action}`,
otherOnUpdate: action => `更新时 ${action}`,
actions: texts => `(${texts.join(',')})`,
unnamed: '未命名',
uniqueIndex: '(唯一)',
relationship: { backPopulates: '对方字段', single: '单个对象', secondary: '中间表' },
}
/** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */
const WORDS: Record<string, Words> = { zh: ZH }
/** 认一个模型类要用的:宿主给的 context、挑好的用词、别名表。 */
interface Env {
context: RuleContext
words: Words
aliases: Map<string, string>
}
/** 一个调用:函数名(最后一节,别名换回原名)、位置参数、关键字参数。 */
interface Call {
name: string | undefined
positional: Node[]
keywords: Map<string, Node>
}
/** 类主体里一条给名字赋值(或只写注解)的语句。 */
interface Member {
block: CodeBlock
name: Node
annotation?: Node
value?: Node
}
/** 一个字段:只写 `Mapped[...]` 注解的没有 call。 */
interface Column {
member: Member
call?: Call
}
interface Model {
table?: Member
args?: Member
columns: Column[]
relationships: { member: Member, call: Call }[]
}
// ─── 读语法树 ─────────────────────────────────────────────────────────────────
/** 具名子节点,注释不算。 */
function syntaxChildren(node: Node | null | undefined): Node[] {
return node ? node.namedChildren.filter((child): child is Node => child !== null && !child.isExtra) : []
}
/** 名字的最后一节:`sa.Column` → Column。 */
function lastName(node: Node | null | undefined): string | undefined {
if (node?.type === 'identifier')
return node.text
if (node?.type === 'attribute')
return node.childForFieldName('attribute')?.text
return undefined
}
function isTrue(node: Node | undefined): boolean {
return node?.type === 'true'
}
/** 字符串字面量的内容;f-string、拼接的字符串不算。 */
function stringValue(node: Node | null | undefined): string | undefined {
if (node?.type !== 'string')
return undefined
const parts = syntaxChildren(node)
if (parts.some(part => part.type === 'interpolation'))
return undefined
return parts.filter(part => part.type === 'string_content').map(part => part.text).join('')
}
function fromLibrary(source: Node): boolean {
return LIBRARIES.some(library => source.text === library || source.text.startsWith(`${library}.`))
}
/** 从 SQLAlchemy 导入时起的别名 → 原名:`from sqlalchemy import Enum as SQLEnum`。 */
function aliasesOf(imports: ImportFacts[]): Map<string, string> {
const aliases = new Map<string, string>()
for (const facts of imports) {
if (!facts.sources.some(fromLibrary))
continue
for (const item of facts.items) {
if (item.kind === 'named' && item.alias)
aliases.set(item.alias.text, item.name.text)
}
}
return aliases
}
/** 类型、函数的原名:直接写的名字按别名表换回,`sa.Integer` 这种取最后一节。 */
function canonicalName(node: Node | null | undefined, aliases: Map<string, string>): string | undefined {
const name = lastName(node)
return node?.type === 'identifier' && name ? aliases.get(name) ?? name : name
}
function callOf(node: Node | null | undefined, aliases: Map<string, string>): Call | undefined {
if (node?.type !== 'call')
return undefined
const args = node.childForFieldName('arguments')
const positional: Node[] = []
const keywords = new Map<string, Node>()
for (const arg of args?.type === 'argument_list' ? syntaxChildren(args) : []) {
if (arg.type === 'keyword_argument') {
const key = arg.childForFieldName('name')
const value = arg.childForFieldName('value')
if (key && value)
keywords.set(key.text, value)
}
else if (arg.type !== 'list_splat' && arg.type !== 'dictionary_splat') {
positional.push(arg)
}
}
return { name: canonicalName(node.childForFieldName('function'), aliases), positional, keywords }
}
function memberOf(block: CodeBlock): Member | undefined {
if (block.kind !== 'field' && block.kind !== 'variable' && block.kind !== 'const')
return undefined
const [assignment] = syntaxChildren(block.node)
const name = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null
if (!assignment || name?.type !== 'identifier')
return undefined
return { block, name, annotation: assignment.childForFieldName('type') ?? undefined, value: assignment.childForFieldName('right') ?? undefined }
}
/** 注解里 `type` 节点只包着一个类型时取里面那个。 */
function unwrapType(node: Node): Node {
const inner = node.type === 'type' ? syntaxChildren(node) : []
return inner.length === 1 ? inner[0] : node
}
/** `Mapped[X]`(`so.Mapped[X]`)里的 X;注解不是 Mapped[...] 时没有。 */
function mappedType(annotation: Node | undefined): Node | undefined {
const node = annotation && unwrapType(annotation)
if (node?.type === 'generic_type') {
const [head, parameters] = syntaxChildren(node)
return lastName(head) === 'Mapped' ? syntaxChildren(parameters)[0] : undefined
}
if (node?.type === 'subscript' && lastName(node.childForFieldName('value')) === 'Mapped')
return node.childForFieldName('subscript') ?? undefined
return undefined
}
function isNone(node: Node): boolean {
return unwrapType(node).type === 'none'
}
/** 类型去掉 None:`X | None`、`Optional[X]`、`Union[X, None]` → X,并说它可空;拆不出单个类型时留着整个。 */
function withoutNone(type: Node): { type: Node, optional: boolean } {
const node = unwrapType(type)
if (node.type === 'binary_operator' && node.childForFieldName('operator')?.type === '|') {
const left = node.childForFieldName('left')
const right = node.childForFieldName('right')
if (left && right && isNone(right))
return { type: left, optional: true }
if (left && right && isNone(left))
return { type: right, optional: true }
return { type, optional: [left, right].some(side => side && withoutNone(side).optional) }
}
if (node.type === 'generic_type') {
const [head, parameters] = syntaxChildren(node)
const args = syntaxChildren(parameters)
if (lastName(head) === 'Optional' && args.length === 1)
return { type: args[0], optional: true }
const rest = args.filter(arg => !isNone(arg))
if (lastName(head) === 'Union' && rest.length < args.length)
return { type: rest.length === 1 ? rest[0] : type, optional: true }
}
return { type, optional: false }
}
/** 类主体里的表名、表参数、字段和关系;既没有表名也没有 mapped_column / Column 字段的类不是模型。 */
function modelOf(block: ClassBlock, aliases: Map<string, string>): Model | undefined {
const model: Model = { columns: [], relationships: [] }
for (const child of block.children) {
const member = memberOf(child)
if (!member)
continue
if (member.name.text === '__tablename__') {
model.table = member
continue
}
if (member.name.text === '__table_args__') {
model.args = member
continue
}
const call = callOf(member.value, aliases)
if (call?.name && COLUMN_CALLS.has(call.name))
model.columns.push({ member, call })
else if (call?.name === 'relationship')
model.relationships.push({ member, call })
else if (!member.value && mappedType(member.annotation))
model.columns.push({ member })
}
return model.table || model.columns.some(column => column.call) ? model : undefined
}
/** `__table_args__`:元组 / 列表里的约束、索引,最后一项可以是选项字典;也可以只写一个字典。 */
function tableArgs(value: Node | undefined): { options?: Node, items: Node[] } {
let node = value
while (node?.type === 'parenthesized_expression')
node = syntaxChildren(node)[0]
if (node?.type === 'dictionary')
return { options: node, items: [] }
if (node?.type !== 'tuple' && node?.type !== 'list')
return { items: [] }
const items = syntaxChildren(node)
const last = items.at(-1)
return last?.type === 'dictionary' ? { options: last, items: items.slice(0, -1) } : { items }
}
function dictValue(dictionary: Node | undefined, key: string): Node | undefined {
for (const pair of syntaxChildren(dictionary)) {
if (pair.type === 'pair' && stringValue(pair.childForFieldName('key')) === key)
return pair.childForFieldName('value') ?? undefined
}
return undefined
}
// ─── 写成属性表 ───────────────────────────────────────────────────────────────
function range(node: Node): SourceRange {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
function plain(text: string): Segment {
return { text, role: 'plain' }
}
function hint(text: string): Segment {
return { text, role: 'hint' }
}
function word(text: string): Segment {
return { text, role: 'storage' }
}
/** 源码里的一个名字照抄,带源码下标(能跳到定义、高亮各处出现)。 */
function name(node: Node, role: TokenRole): Segment {
return { text: node.text, role, offset: node.startIndex }
}
function joined(parts: Inline[][], separator: Segment): Inline[] {
return parts.flatMap((part, i) => i > 0 ? [{ ...separator }, ...part] : part)
}
/** 字符串字面量写内容(不带引号)、按 role 着色;别的表达式交给翻译插件翻。 */
function textOr(node: Node, role: TokenRole, env: Env): Inline[] {
const text = stringValue(node)
return text === undefined ? env.context.translate(node) : [{ text, role }]
}
/** 类名、枚举类这种名字:是单个名字就按类型着色、带下标,别的写法交给翻译插件。 */
function typeName(node: Node, env: Env): Inline[] {
return node.type === 'identifier' ? [name(node, 'type')] : env.context.translate(node)
}
/** 枚举:`Enum(X)`、`SQLEnum(X)`、包着枚举类的辅助函数(`_enum(X, "…")`)写「枚举 X」;只列了取值的写「枚举 "a", "b"」。 */
function enumType(call: Call | undefined, env: Env): Inline[] {
const out: Inline[] = [{ text: env.words.enumType, role: 'type' }]
const [first] = call?.positional ?? []
const values = first && first.type !== 'string' ? [first] : (call?.positional ?? []).filter(arg => arg.type === 'string')
return values.length > 0 ? [...out, plain(' '), ...joined(values.map(value => typeName(value, env)), plain(', '))] : out
}
/** 字段的 SQL 类型:认得的换成译名,带上大小(`字符串(16)`、`定点数(10,2)`)和时区;不认得的照常翻。 */
function sqlType(node: Node, env: Env): Inline[] {
const call = callOf(node, env.aliases)
const original = call ? call.name : canonicalName(node, env.aliases)
if (original && /enum/i.test(original))
return enumType(call, env)
const translated = original && env.words.types[original]
if (!translated)
return env.context.translate(node)
const out: Inline[] = [{ text: translated, role: 'type' }]
if (!call)
return out
const sizes = [...call.positional, ...SIZE_KEYWORDS.flatMap(key => call.keywords.get(key) ?? [])]
if (sizes.length > 0)
out.push(plain('('), ...joined(sizes.map(size => sqlType(size, env)), plain(',')), plain(')'))
if (isTrue(call.keywords.get('timezone')))
out.push({ text: env.words.withTimezone, role: 'type' })
return out
}
function referentialAction(node: Node | undefined, known: Record<string, string>, other: (action: string) => string): string | undefined {
if (!node)
return undefined
const action = stringValue(node)?.trim().toUpperCase()
return action === undefined ? other(node.text) : known[action] ?? other(action)
}
/** 外键:「外键 sys_user.id(删除时禁止)」。 */
function foreignKey(call: Call, env: Env): Inline[] {
const { words } = env
const [target] = call.positional
const out: Inline[] = [word(words.constraints.foreignKey)]
if (target)
out.push(plain(' '), ...textOr(target, 'string', env))
const actions = [
referentialAction(call.keywords.get('ondelete'), words.onDelete, words.otherOnDelete),
referentialAction(call.keywords.get('onupdate'), words.onUpdate, words.otherOnUpdate),
].filter((text): text is string => text !== undefined)
if (actions.length > 0)
out.push(hint(words.actions(actions)))
return out
}
/**
* 可空:写了 nullable 按它;主键不可空;Column 默认可空;mapped_column 和只写注解的按 `Mapped[...]` 里有没有 None,没注解时同 Column。
* 非空是常态,不写。
*/
function isNullable(call: Call | undefined, optional: boolean | undefined): boolean {
const explicit = call?.keywords.get('nullable')
if (explicit)
return isTrue(explicit)
if (isTrue(call?.keywords.get('primary_key')))
return false
return call?.name === 'Column' || optional === undefined ? true : optional
}
/** 字段表的一行:字段 | 类型 | 约束 | 说明。讲的定义是字段名。 */
function columnItem({ member, call }: Column, env: Env): PropertyItem {
const { words, context } = env
const mapped = mappedType(member.annotation)
const annotated = mapped && withoutNone(mapped)
// 第一个位置参数是字符串时是库里的列名,不是类型
const args = (call?.positional ?? []).filter((arg, i) => !(i === 0 && arg.type === 'string'))
const calls = args.map(arg => callOf(arg, env.aliases))
const typeArg = args.find((_, i) => !SCHEMA_ITEMS.has(calls[i]?.name ?? ''))
const keywords = call?.keywords ?? new Map<string, Node>()
const constraints: Inline[][] = []
const flag = (key: string, text: string): void => {
if (isTrue(keywords.get(key)))
constraints.push([word(text)])
}
flag('primary_key', words.constraints.primaryKey)
flag('unique', words.constraints.unique)
flag('index', words.constraints.index)
if (isNullable(call, annotated ? annotated.optional : undefined))
constraints.push([word(words.constraints.nullable)])
for (const [key, text] of [['default', words.constraints.default], ['server_default', words.constraints.serverDefault]]) {
const value = keywords.get(key)
if (value)
constraints.push([word(text), plain(' '), ...context.translate(value)])
}
for (const foreign of calls.filter((found): found is Call => found?.name === 'ForeignKey'))
constraints.push(foreignKey(foreign, env))
const comment = keywords.get('comment')
return {
cells: [
[name(member.name, 'variable')],
typeArg ? sqlType(typeArg, env) : annotated ? context.translate(annotated.type) : [hint(words.unannotated)],
joined(constraints, hint(' · ')),
comment ? textOr(comment, 'comment', env) : [],
],
defines: range(member.name),
}
}
/** 关系表的一行:名称 | 目标 | 说明(对方字段、单个对象、中间表)。目标取第一个位置参数,没写时取 `Mapped[...]` 里的类型。 */
function relationshipItem({ member, call }: { member: Member, call: Call }, env: Env): PropertyItem {
const { words, context } = env
const [target] = call.positional
const mapped = mappedType(member.annotation)
const notes: Inline[][] = []
const back = call.keywords.get('back_populates') ?? call.keywords.get('backref')
if (back)
notes.push([word(words.relationship.backPopulates), plain(' '), ...textOr(back, 'variable', env)])
if (call.keywords.get('uselist')?.type === 'false')
notes.push([word(words.relationship.single)])
const secondary = call.keywords.get('secondary')
if (secondary)
notes.push([word(words.relationship.secondary), plain(' '), ...textOr(secondary, 'string', env)])
return {
cells: [
[name(member.name, 'variable')],
target ? textOr(target, 'type', env) : mapped ? context.translate(withoutNone(mapped).type) : [],
joined(notes, hint(' · ')),
],
defines: range(member.name),
}
}
/** 索引 / 唯一约束表的一行:名称 | 列。名称是字符串就写内容,没给写「未命名」;唯一索引的名称后面写「(唯一)」。 */
function constraintItem(nameNode: Node | undefined, columns: Node[], unique: boolean, env: Env): PropertyItem {
const title = nameNode && nameNode.type !== 'none' ? textOr(nameNode, 'string', env) : [hint(env.words.unnamed)]
return {
cells: [
unique ? [...title, hint(env.words.uniqueIndex)] : title,
joined(columns.map(column => textOr(column, 'variable', env)), plain(', ')),
],
}
}
function tableRow(label: string, heads: string[], items: PropertyItem[], env: Env): PropertyRow[] {
return items.length > 0 ? [{ label, value: [hint(env.words.count(items.length))], columns: heads, items }] : []
}
function tableBlock(block: ClassBlock, model: Model, env: Env): RuleBlock {
const { words, context } = env
const { facts } = block
const { labels } = words
const { options, items } = tableArgs(model.args?.value)
const comment = dictValue(options, 'comment')
const doc = (comment && context.doc(comment)) ?? (facts.doc && context.doc(facts.doc))
const indexes: PropertyItem[] = []
const uniques: PropertyItem[] = []
for (const item of items) {
const call = callOf(item, env.aliases)
if (call?.name === 'Index') {
const [indexName, ...columns] = call.positional
indexes.push(constraintItem(indexName, columns, isTrue(call.keywords.get('unique')), env))
}
else if (call?.name === 'UniqueConstraint') {
uniques.push(constraintItem(call.keywords.get('name'), call.positional, false, env))
}
}
const rows: PropertyRow[] = [
...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'type')] }] : []),
...(model.table?.value ? [{ label: labels.table, value: textOr(model.table.value, 'string', env) }] : []),
...(doc ? [{ label: labels.doc, value: doc }] : []),
...(facts.bases.length > 0 ? [{ label: labels.bases, value: joined(facts.bases.map(base => context.translate(base.value)), plain(', ')) }] : []),
...(facts.decorators.length > 0 ? [{ label: labels.decorators, value: joined(facts.decorators.map(decorator => context.translate(decorator)), plain('\n')) }] : []),
...tableRow(labels.columns, words.columnHeads, model.columns.map(column => columnItem(column, env)), env),
...tableRow(labels.relationships, words.relationshipHeads, model.relationships.map(relationship => relationshipItem(relationship, env)), env),
...tableRow(labels.indexes, words.indexHeads, indexes, env),
...tableRow(labels.uniques, words.indexHeads, uniques, env),
]
const members = [model.table, model.args, ...model.columns.map(column => column.member), ...model.relationships.map(relationship => relationship.member)]
return {
kind: TABLE_KIND,
badge: { label: words.kind, color: TABLE_COLOR },
rows,
...(facts.name && { defines: range(facts.name) }),
absorbs: members.filter((member): member is Member => member !== undefined).map(member => member.block),
}
}
export const sqlalchemyRule: LibraryRule = {
id: 'sqlalchemy',
label: 'SQLAlchemy 数据表',
languageIds: ['python'],
libraries: LIBRARIES,
claim: (block, context) => {
if (block.kind !== 'class')
return undefined
const aliases = aliasesOf(context.imports)
const model = modelOf(block, aliases)
return model && tableBlock(block, model, { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH, aliases })
},
}