✨ feat(vscode): 代码阅读支持库规则,内置 SQLAlchemy 模型类显示成数据表卡片
This commit is contained in:
1 parent
32be7d54b6
commit
5ece24cef9
26 files changed
+1768
-81
No files matched your search
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user