import type { BlockView } from '../blockView' import type { Inline, PropertyRow, Segment } from '../contract' import { describe, expect, it } from 'vitest' import { toBlockViews } from '../blockView' import { replaceLines } from '../editText' 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 { return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, 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 { 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' }, icon: 'database' }) 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() 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 source = [ 'from sqlalchemy.orm import Mapped, mapped_column', '', '', 'class Order(Base):', ' __tablename__ = "orders"', '', ' # 金额', ' amount: Mapped[int] = mapped_column(Integer)', '', ' # 算税', ' def tax(self) -> int:', ' return self.amount // 10', '', ' note: Mapped[str] = mapped_column(Text)', '', ].join('\n') const [, order] = await views(source) expect(propertyLines(properties(order)).slice(-3)).toEqual(['字段: 2 个', ' amount | 整数 | | ', ' note | 长文本 | | ']) expect(order.children.map(child => [child.kind, child.startLine])).toEqual([['comment', 9], ['function', 10]]) expect([order.startLine, order.endLine]).toEqual([3, 13]) expect(replaceLines(source, order.startLine, order.endLine, order.code)).toBe(source) expect(replaceLines(source, order.startLine, order.endLine, order.code.replace('// 10', '// 8'))).toBe(source.replace('// 10', '// 8')) }) 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, 21]) expect(user.children.map(child => [child.kind, child.startLine])).toEqual([['function', 20]]) }) it('字段的属性 docstring:没写 comment= 时当说明,写了以 comment= 为准;它并进卡片', async () => { const source = [ 'from sqlalchemy.orm import Mapped, mapped_column', '', '', 'class Case(Base):', ' __tablename__ = "case_library"', '', ' title: Mapped[str] = mapped_column(String(120))', ' """案例标题, 用户可编辑"""', ' summary: Mapped[str] = mapped_column(Text, comment="案例描述")', ' """不显示"""', ' status = mapped_column(String(16))', ' """案例状态"""', '', ' def label(self) -> str:', ' return self.title', ].join('\n') const [, table] = await views(source) expect(propertyLines(properties(table)).slice(-4)).toEqual([ '字段: 3 个', ' title | 字符串(120) | | 案例标题, 用户可编辑', ' summary | 长文本 | | 案例描述', ' status | 字符串(16) | 可空 | 案例状态', ]) expect([table.startLine, table.endLine]).toEqual([3, 14]) expect(table.children.map(child => [child.kind, child.startLine])).toEqual([['function', 13]]) }) it('没导入 SQLAlchemy 的文件不认', async () => { const list = await views('class User(Base):\n __tablename__ = "users"\n') expect(list.map(view => view.kind)).toEqual(['class']) }) })