Files
ShiErFeng/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py
T

2336 lines
99 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
import unittest
from collections import Counter
from collections.abc import Callable
from pathlib import Path
SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql'
INITIALIZERS = ('shierfeng-fastapi.sql', 'shierfeng-fastapi-pg.sql')
PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql'
BUSINESS_MENU_ICON_PATCH_PATH = SQL_ROOT / 'patches/20260728_add_business_menu_icons_mysql.sql'
TEST_BATCH_MYSQL_PATCH_PATH = SQL_ROOT / 'patches/20260922_test_data_batch_mysql.sql'
TEST_BATCH_PG_PATCH_PATH = SQL_ROOT / 'patches/20260922_test_data_batch_pg.sql'
SYS_MENU_VALUE_COUNT = 20
SYS_MENU_PERMS_INDEX = 13
SYS_MENU_ICON_INDEX = 14
REMOVED_AI_DICT_CODES = set(range(33, 70))
EXPECTED_TEST_BATCH_TABLES = {'biz_test_batch', 'biz_test_batch_user'}
TEST_BATCH_OWNED_TABLES = {
'biz_strain',
'biz_customer',
'biz_customer_site',
'biz_production_line',
'biz_warehouse',
'biz_pallet',
'biz_inbound_order',
'biz_inbound_item',
'biz_filling_record',
'biz_shipment_order',
'biz_shipment_item',
'biz_usage_record',
'biz_return_order',
'biz_return_item',
'biz_pallet_inventory',
'biz_pallet_event',
}
EXPECTED_TABLES = {
'biz_strain',
'biz_warehouse',
'biz_customer',
'biz_customer_site',
'biz_production_line',
'biz_pallet',
'biz_user_warehouse',
'biz_user_site',
'biz_inbound_order',
'biz_inbound_item',
'biz_filling_record',
'biz_shipment_order',
'biz_shipment_item',
'biz_usage_record',
'biz_return_order',
'biz_return_item',
'biz_pallet_inventory',
'biz_pallet_event',
}
AUDIT_COLUMNS = {'create_by', 'create_time', 'update_by', 'update_time', 'remark'}
CREATE_AUDIT_COLUMNS = {'create_by', 'create_time'}
EXPECTED_COLUMNS = {
'biz_strain': {'strain_id', 'strain_code', 'strain_name', 'status'} | AUDIT_COLUMNS,
'biz_warehouse': {'warehouse_id', 'warehouse_code', 'warehouse_name', 'address', 'status'} | AUDIT_COLUMNS,
'biz_customer': {'customer_id', 'customer_code', 'customer_name', 'status'} | AUDIT_COLUMNS,
'biz_customer_site': {'site_id', 'customer_id', 'site_code', 'site_name', 'address', 'status'} | AUDIT_COLUMNS,
'biz_production_line': {'line_id', 'site_id', 'line_code', 'line_name', 'status'} | AUDIT_COLUMNS,
'biz_pallet': {'pallet_id', 'pallet_code', 'qr_token', 'capacity_packages', 'asset_status'} | AUDIT_COLUMNS,
'biz_user_warehouse': {'user_id', 'warehouse_id'} | CREATE_AUDIT_COLUMNS,
'biz_user_site': {'user_id', 'site_id'} | CREATE_AUDIT_COLUMNS,
'biz_inbound_order': {
'inbound_id',
'inbound_no',
'warehouse_id',
'status',
'confirm_idempotency_key',
'confirm_request_hash',
'confirmed_by',
'confirmed_at',
}
| AUDIT_COLUMNS,
'biz_inbound_item': {'inbound_item_id', 'inbound_id', 'pallet_id'} | CREATE_AUDIT_COLUMNS,
'biz_filling_record': {
'filling_id',
'filling_no',
'pallet_id',
'warehouse_id',
'strain_id',
'strain_code_snapshot',
'strain_name_snapshot',
'batch_no',
'package_spec',
'initial_packages',
'active_slot',
'closed_at',
'idempotency_key',
'request_hash',
'operator_id',
'operated_at',
}
| AUDIT_COLUMNS,
'biz_shipment_order': {
'shipment_id',
'shipment_no',
'source_warehouse_id',
'target_site_id',
'status',
'confirm_idempotency_key',
'confirm_request_hash',
'confirmed_by',
'confirmed_at',
}
| AUDIT_COLUMNS,
'biz_shipment_item': {
'shipment_item_id',
'shipment_id',
'pallet_id',
'active_filling_id',
'package_qty',
}
| CREATE_AUDIT_COLUMNS,
'biz_usage_record': {
'usage_id',
'usage_no',
'pallet_id',
'active_filling_id',
'site_id',
'line_id',
'used_packages',
'remaining_packages_after',
'idempotency_key',
'request_hash',
'operator_id',
'occurred_at',
},
'biz_return_order': {
'return_id',
'return_no',
'source_site_id',
'target_warehouse_id',
'status',
'request_idempotency_key',
'request_hash',
'confirm_idempotency_key',
'confirm_request_hash',
'requested_by',
'requested_at',
'confirmed_by',
'confirmed_at',
}
| AUDIT_COLUMNS,
'biz_return_item': {
'return_item_id',
'return_id',
'pallet_id',
'active_filling_id_snapshot',
'remaining_packages_snapshot',
}
| CREATE_AUDIT_COLUMNS,
'biz_pallet_inventory': {
'pallet_id',
'active_filling_id',
'warehouse_id',
'site_id',
'current_line_id',
'flow_state',
'remaining_packages',
'last_event_id',
'version',
'updated_at',
},
'biz_pallet_event': {
'event_id',
'pallet_id',
'event_seq',
'event_type',
'source_type',
'source_id',
'before_state',
'after_state',
'before_warehouse_id',
'before_site_id',
'before_line_id',
'after_warehouse_id',
'after_site_id',
'after_line_id',
'before_remaining_packages',
'after_remaining_packages',
'operator_id',
'occurred_at',
'idempotency_key',
'request_hash',
},
}
EXPECTED_DATETIME_COLUMNS = {
'biz_strain': {'create_time', 'update_time'},
'biz_warehouse': {'create_time', 'update_time'},
'biz_customer': {'create_time', 'update_time'},
'biz_customer_site': {'create_time', 'update_time'},
'biz_production_line': {'create_time', 'update_time'},
'biz_pallet': {'create_time', 'update_time'},
'biz_user_warehouse': {'create_time'},
'biz_user_site': {'create_time'},
'biz_inbound_order': {'confirmed_at', 'create_time', 'update_time'},
'biz_inbound_item': {'create_time'},
'biz_filling_record': {'closed_at', 'operated_at', 'create_time', 'update_time'},
'biz_shipment_order': {'confirmed_at', 'create_time', 'update_time'},
'biz_shipment_item': {'create_time'},
'biz_usage_record': {'occurred_at'},
'biz_return_order': {'requested_at', 'confirmed_at', 'create_time', 'update_time'},
'biz_return_item': {'create_time'},
'biz_pallet_inventory': {'updated_at'},
'biz_pallet_event': {'occurred_at'},
}
EXPECTED_PRIMARY_KEYS = {
'biz_strain': ('strain_id',),
'biz_warehouse': ('warehouse_id',),
'biz_customer': ('customer_id',),
'biz_customer_site': ('site_id',),
'biz_production_line': ('line_id',),
'biz_pallet': ('pallet_id',),
'biz_user_warehouse': ('user_id', 'warehouse_id'),
'biz_user_site': ('user_id', 'site_id'),
'biz_inbound_order': ('inbound_id',),
'biz_inbound_item': ('inbound_item_id',),
'biz_filling_record': ('filling_id',),
'biz_shipment_order': ('shipment_id',),
'biz_shipment_item': ('shipment_item_id',),
'biz_usage_record': ('usage_id',),
'biz_return_order': ('return_id',),
'biz_return_item': ('return_item_id',),
'biz_pallet_inventory': ('pallet_id',),
'biz_pallet_event': ('event_id',),
}
EXPECTED_UNIQUE_KEYS = {
'biz_strain': {('strain_code',)},
'biz_warehouse': {('warehouse_code',)},
'biz_customer': {('customer_code',)},
'biz_customer_site': {('customer_id', 'site_code')},
'biz_production_line': {('site_id', 'line_code')},
'biz_pallet': {('pallet_code',), ('qr_token',)},
'biz_user_warehouse': set(),
'biz_user_site': set(),
'biz_inbound_order': {('inbound_no',), ('warehouse_id', 'confirm_idempotency_key')},
'biz_inbound_item': {('inbound_id', 'pallet_id')},
'biz_filling_record': {
('filling_no',),
('warehouse_id', 'idempotency_key'),
('pallet_id', 'active_slot'),
},
'biz_shipment_order': {
('shipment_no',),
('source_warehouse_id', 'target_site_id', 'confirm_idempotency_key'),
},
'biz_shipment_item': {('shipment_id', 'pallet_id')},
'biz_usage_record': {('usage_no',), ('site_id', 'idempotency_key')},
'biz_return_order': {
('return_no',),
('source_site_id', 'target_warehouse_id', 'request_idempotency_key'),
('source_site_id', 'target_warehouse_id', 'confirm_idempotency_key'),
},
'biz_return_item': {('return_id', 'pallet_id')},
'biz_pallet_inventory': set(),
'biz_pallet_event': {('pallet_id', 'event_seq'), ('pallet_id', 'idempotency_key')},
}
EXPECTED_FOREIGN_KEYS = {
'biz_strain': set(),
'biz_warehouse': set(),
'biz_customer': set(),
'biz_customer_site': {('customer_id', 'biz_customer.customer_id')},
'biz_production_line': {('site_id', 'biz_customer_site.site_id')},
'biz_pallet': set(),
'biz_user_warehouse': {('user_id', 'sys_user.user_id'), ('warehouse_id', 'biz_warehouse.warehouse_id')},
'biz_user_site': {('user_id', 'sys_user.user_id'), ('site_id', 'biz_customer_site.site_id')},
'biz_inbound_order': {
('warehouse_id', 'biz_warehouse.warehouse_id'),
('confirmed_by', 'sys_user.user_id'),
},
'biz_inbound_item': {
('inbound_id', 'biz_inbound_order.inbound_id'),
('pallet_id', 'biz_pallet.pallet_id'),
},
'biz_filling_record': {
('pallet_id', 'biz_pallet.pallet_id'),
('warehouse_id', 'biz_warehouse.warehouse_id'),
('strain_id', 'biz_strain.strain_id'),
('operator_id', 'sys_user.user_id'),
},
'biz_shipment_order': {
('source_warehouse_id', 'biz_warehouse.warehouse_id'),
('target_site_id', 'biz_customer_site.site_id'),
('confirmed_by', 'sys_user.user_id'),
},
'biz_shipment_item': {
('shipment_id', 'biz_shipment_order.shipment_id'),
('pallet_id', 'biz_pallet.pallet_id'),
('active_filling_id', 'biz_filling_record.filling_id'),
},
'biz_usage_record': {
('pallet_id', 'biz_pallet.pallet_id'),
('active_filling_id', 'biz_filling_record.filling_id'),
('site_id', 'biz_customer_site.site_id'),
('line_id', 'biz_production_line.line_id'),
('operator_id', 'sys_user.user_id'),
},
'biz_return_order': {
('source_site_id', 'biz_customer_site.site_id'),
('target_warehouse_id', 'biz_warehouse.warehouse_id'),
('requested_by', 'sys_user.user_id'),
('confirmed_by', 'sys_user.user_id'),
},
'biz_return_item': {
('return_id', 'biz_return_order.return_id'),
('pallet_id', 'biz_pallet.pallet_id'),
('active_filling_id_snapshot', 'biz_filling_record.filling_id'),
},
'biz_pallet_inventory': {
('pallet_id', 'biz_pallet.pallet_id'),
('active_filling_id', 'biz_filling_record.filling_id'),
('warehouse_id', 'biz_warehouse.warehouse_id'),
('site_id', 'biz_customer_site.site_id'),
('current_line_id', 'biz_production_line.line_id'),
('last_event_id', 'biz_pallet_event.event_id'),
},
'biz_pallet_event': {
('pallet_id', 'biz_pallet.pallet_id'),
('before_warehouse_id', 'biz_warehouse.warehouse_id'),
('before_site_id', 'biz_customer_site.site_id'),
('before_line_id', 'biz_production_line.line_id'),
('after_warehouse_id', 'biz_warehouse.warehouse_id'),
('after_site_id', 'biz_customer_site.site_id'),
('after_line_id', 'biz_production_line.line_id'),
('operator_id', 'sys_user.user_id'),
},
}
EXPECTED_CHECKS = {
'biz_strain': {"statusin('0','1')"},
'biz_warehouse': {"statusin('0','1')"},
'biz_customer': {"statusin('0','1')"},
'biz_customer_site': {"statusin('0','1')"},
'biz_production_line': {"statusin('0','1')"},
'biz_pallet': {'capacity_packages>0', "asset_statusin('0','1')"},
'biz_user_warehouse': set(),
'biz_user_site': set(),
'biz_inbound_order': {"statusin('draft','confirmed')"},
'biz_inbound_item': set(),
'biz_filling_record': {'initial_packages>0', "active_slotisnulloractive_slot='active'"},
'biz_shipment_order': {"statusin('draft','confirmed')"},
'biz_shipment_item': {'package_qtyisnullorpackage_qty>0'},
'biz_usage_record': {'used_packages>0', 'remaining_packages_after>=0'},
'biz_return_order': {"statusin('requested','confirmed')"},
'biz_return_item': {'remaining_packages_snapshot>=0'},
'biz_pallet_inventory': {
"flow_statein('unstocked','empty_in_warehouse','filled_in_warehouse','at_site_unused','at_site_partial',"
"'at_site_empty','returning')",
'remaining_packages>=0',
'version>=0',
'not(warehouse_idisnotnullandsite_idisnotnull)',
},
'biz_pallet_event': {
"event_typein('inbound_confirmed','filling_confirmed','shipment_confirmed','usage_recorded',"
"'return_requested','return_confirmed')"
},
}
LEGACY_EXPECTED_TABLES = set(EXPECTED_TABLES)
LEGACY_EXPECTED_COLUMNS = {table: set(columns) for table, columns in EXPECTED_COLUMNS.items()}
LEGACY_EXPECTED_PRIMARY_KEYS = dict(EXPECTED_PRIMARY_KEYS)
LEGACY_EXPECTED_UNIQUE_KEYS = {table: set(keys) for table, keys in EXPECTED_UNIQUE_KEYS.items()}
LEGACY_EXPECTED_FOREIGN_KEYS = {table: set(keys) for table, keys in EXPECTED_FOREIGN_KEYS.items()}
LEGACY_EXPECTED_CHECKS = {table: set(checks) for table, checks in EXPECTED_CHECKS.items()}
EXPECTED_TABLES |= EXPECTED_TEST_BATCH_TABLES
for table_name in TEST_BATCH_OWNED_TABLES:
EXPECTED_COLUMNS[table_name].add('test_batch_id')
EXPECTED_FOREIGN_KEYS[table_name].add(('test_batch_id', 'biz_test_batch.batch_id'))
EXPECTED_COLUMNS.update(
{
'biz_test_batch': {
'batch_id',
'batch_code',
'batch_name',
'status',
'create_by',
'create_time',
'update_by',
'update_time',
'closed_by',
'closed_at',
'purged_by',
'purged_at',
'purge_summary',
'preview_action',
'preview_token_hash',
'preview_scope_hash',
'preview_expires_at',
'remark',
},
'biz_test_batch_user': {'batch_id', 'user_id', 'create_by', 'create_time'},
}
)
EXPECTED_DATETIME_COLUMNS.update(
{
'biz_test_batch': {'create_time', 'update_time', 'closed_at', 'purged_at', 'preview_expires_at'},
'biz_test_batch_user': {'create_time'},
}
)
EXPECTED_PRIMARY_KEYS.update(
{
'biz_test_batch': ('batch_id',),
'biz_test_batch_user': ('batch_id', 'user_id'),
}
)
EXPECTED_UNIQUE_KEYS.update(
{
'biz_test_batch': {('batch_code',)},
'biz_test_batch_user': set(),
}
)
EXPECTED_FOREIGN_KEYS.update(
{
'biz_test_batch': {
('closed_by', 'sys_user.user_id'),
('purged_by', 'sys_user.user_id'),
},
'biz_test_batch_user': {
('batch_id', 'biz_test_batch.batch_id'),
('user_id', 'sys_user.user_id'),
},
}
)
EXPECTED_CHECKS.update(
{
'biz_test_batch': {
"statusin('active','closed','purged')",
"preview_actionisnullorpreview_actionin('adopt','purge')",
},
'biz_test_batch_user': set(),
}
)
EXPECTED_PAGES = {
502: ('菌种资料', 121, 1, 'strain', 'master/strain/index', 'master:strain:list'),
503: ('客户资料', 121, 2, 'customer', 'master/customer/index', 'master:customer:list'),
504: ('客户现场', 121, 3, 'site', 'master/site/index', 'master:site:list'),
505: ('产线资料', 121, 4, 'line', 'master/line/index', 'master:line:list'),
506: ('仓库资料', 121, 5, 'warehouse', 'master/warehouse/index', 'master:warehouse:list'),
507: ('托盘资料', 121, 6, 'pallet', 'master/pallet/index', 'master:pallet:list'),
508: ('仓库入库', 122, 1, 'inbound', 'business/inbound/index', 'business:inbound:list'),
509: ('工厂装填', 122, 2, 'filling', 'business/filling/index', 'business:filling:list'),
510: ('发货出库', 122, 3, 'shipment', 'business/shipment/index', 'business:shipment:list'),
511: ('客户现场使用', 122, 4, 'usage', 'business/usage/index', 'business:usage:list'),
512: ('退回仓库', 122, 5, 'return', 'business/return/index', 'business:return:list'),
513: (
'托盘库存',
123,
1,
'palletInventory',
'report/palletInventory/index',
'report:palletinventory:list',
),
514: ('装填记录', 123, 2, 'reportFilling', 'report/filling/index', 'report:filling:list'),
515: ('发货出库', 123, 3, 'reportShipment', 'report/shipment/index', 'report:shipment:list'),
516: ('产线消耗', 123, 4, 'consumption', 'report/consumption/index', 'report:consumption:list'),
517: ('退回仓库', 123, 5, 'reportReturn', 'report/return/index', 'report:return:list'),
518: ('托盘追溯', 123, 6, 'palletTrace', 'report/palletTrace/index', 'report:pallettrace:list'),
}
BUTTON_SPECS = """
1065 502 1 菌种查询 master:strain:query
1066 502 2 菌种新增 master:strain:add
1067 502 3 菌种修改 master:strain:edit
1068 502 4 菌种删除 master:strain:remove
1069 503 1 客户查询 master:customer:query
1070 503 2 客户新增 master:customer:add
1071 503 3 客户修改 master:customer:edit
1072 503 4 客户删除 master:customer:remove
1073 504 1 现场查询 master:site:query
1074 504 2 现场新增 master:site:add
1075 504 3 现场修改 master:site:edit
1076 504 4 现场删除 master:site:remove
1077 504 5 分配用户 master:site:assign
1078 505 1 产线查询 master:line:query
1079 505 2 产线新增 master:line:add
1080 505 3 产线修改 master:line:edit
1081 505 4 产线删除 master:line:remove
1082 506 1 仓库查询 master:warehouse:query
1083 506 2 仓库新增 master:warehouse:add
1084 506 3 仓库修改 master:warehouse:edit
1085 506 4 仓库删除 master:warehouse:remove
1086 506 5 分配用户 master:warehouse:assign
1087 507 1 托盘查询 master:pallet:query
1088 507 2 托盘新增 master:pallet:add
1089 507 3 托盘修改 master:pallet:edit
1090 507 4 托盘删除 master:pallet:remove
1091 507 5 托盘打印 master:pallet:print
1092 508 1 入库查询 business:inbound:query
1093 508 2 入库新增 business:inbound:add
1094 508 3 入库修改 business:inbound:edit
1095 508 4 入库删除 business:inbound:remove
1096 508 5 入库确认 business:inbound:confirm
1097 509 1 装填查询 business:filling:query
1098 509 2 装填新增 business:filling:add
1099 510 1 发货查询 business:shipment:query
1100 510 2 发货新增 business:shipment:add
1101 510 3 发货修改 business:shipment:edit
1102 510 4 发货删除 business:shipment:remove
1103 510 5 发货确认 business:shipment:confirm
1104 511 1 使用查询 business:usage:query
1105 511 2 使用新增 business:usage:add
1106 512 1 退回查询 business:return:query
1107 512 2 退回申请 business:return:request
1108 512 3 退回确认 business:return:confirm
"""
EXPECTED_BUTTONS = {
int(menu_id): (name, int(parent_id), int(order_num), permission)
for menu_id, parent_id, order_num, name, permission in (
line.split() for line in BUTTON_SPECS.splitlines() if line.strip()
)
}
EXPECTED_PAGE_PERMISSIONS = frozenset(
[
'master:strain:list',
'master:customer:list',
'master:site:list',
'master:line:list',
'master:warehouse:list',
'master:pallet:list',
'business:inbound:list',
'business:filling:list',
'business:shipment:list',
'business:usage:list',
'business:return:list',
'report:palletinventory:list',
'report:filling:list',
'report:shipment:list',
'report:consumption:list',
'report:return:list',
'report:pallettrace:list',
]
)
EXPECTED_BUTTON_PERMISSIONS = frozenset(
[
'master:strain:query',
'master:strain:add',
'master:strain:edit',
'master:strain:remove',
'master:customer:query',
'master:customer:add',
'master:customer:edit',
'master:customer:remove',
'master:site:query',
'master:site:add',
'master:site:edit',
'master:site:remove',
'master:site:assign',
'master:line:query',
'master:line:add',
'master:line:edit',
'master:line:remove',
'master:warehouse:query',
'master:warehouse:add',
'master:warehouse:edit',
'master:warehouse:remove',
'master:warehouse:assign',
'master:pallet:query',
'master:pallet:add',
'master:pallet:edit',
'master:pallet:remove',
'master:pallet:print',
'business:inbound:query',
'business:inbound:add',
'business:inbound:edit',
'business:inbound:remove',
'business:inbound:confirm',
'business:filling:query',
'business:filling:add',
'business:shipment:query',
'business:shipment:add',
'business:shipment:edit',
'business:shipment:remove',
'business:shipment:confirm',
'business:usage:query',
'business:usage:add',
'business:return:query',
'business:return:request',
'business:return:confirm',
]
)
EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS
BUSINESS_MENU_SPECS = """
121|0|基础资料维护|businessMaster||1||<NULL>
122|0|业务管理|businessOperation||2||<NULL>
123|0|报表查询|businessReport||3||<NULL>
502|121|菌种资料|strain||1|master:strain:list|master/strain/index
503|121|客户资料|customer||2|master:customer:list|master/customer/index
504|121|客户现场|site||3|master:site:list|master/site/index
505|121|产线资料|line||4|master:line:list|master/line/index
506|121|仓库资料|warehouse||5|master:warehouse:list|master/warehouse/index
507|121|托盘资料|pallet||6|master:pallet:list|master/pallet/index
508|122|仓库入库|inbound||1|business:inbound:list|business/inbound/index
509|122|工厂装填|filling||2|business:filling:list|business/filling/index
510|122|发货出库|shipment||3|business:shipment:list|business/shipment/index
511|122|客户现场使用|usage||4|business:usage:list|business/usage/index
512|122|退回仓库|return||5|business:return:list|business/return/index
513|123|托盘库存|palletInventory||1|report:palletinventory:list|report/palletInventory/index
514|123|装填记录|reportFilling||2|report:filling:list|report/filling/index
515|123|发货出库|reportShipment||3|report:shipment:list|report/shipment/index
516|123|产线消耗|consumption||4|report:consumption:list|report/consumption/index
517|123|退回仓库|reportReturn||5|report:return:list|report/return/index
518|123|托盘追溯|palletTrace||6|report:pallettrace:list|report/palletTrace/index
1065|502|菌种查询|||1|master:strain:query|
1066|502|菌种新增|||2|master:strain:add|
1067|502|菌种修改|||3|master:strain:edit|
1068|502|菌种删除|||4|master:strain:remove|
1069|503|客户查询|||1|master:customer:query|
1070|503|客户新增|||2|master:customer:add|
1071|503|客户修改|||3|master:customer:edit|
1072|503|客户删除|||4|master:customer:remove|
1073|504|现场查询|||1|master:site:query|
1074|504|现场新增|||2|master:site:add|
1075|504|现场修改|||3|master:site:edit|
1076|504|现场删除|||4|master:site:remove|
1077|504|分配用户|||5|master:site:assign|
1078|505|产线查询|||1|master:line:query|
1079|505|产线新增|||2|master:line:add|
1080|505|产线修改|||3|master:line:edit|
1081|505|产线删除|||4|master:line:remove|
1082|506|仓库查询|||1|master:warehouse:query|
1083|506|仓库新增|||2|master:warehouse:add|
1084|506|仓库修改|||3|master:warehouse:edit|
1085|506|仓库删除|||4|master:warehouse:remove|
1086|506|分配用户|||5|master:warehouse:assign|
1087|507|托盘查询|||1|master:pallet:query|
1088|507|托盘新增|||2|master:pallet:add|
1089|507|托盘修改|||3|master:pallet:edit|
1090|507|托盘删除|||4|master:pallet:remove|
1091|507|托盘打印|||5|master:pallet:print|
1092|508|入库查询|||1|business:inbound:query|
1093|508|入库新增|||2|business:inbound:add|
1094|508|入库修改|||3|business:inbound:edit|
1095|508|入库删除|||4|business:inbound:remove|
1096|508|入库确认|||5|business:inbound:confirm|
1097|509|装填查询|||1|business:filling:query|
1098|509|装填新增|||2|business:filling:add|
1099|510|发货查询|||1|business:shipment:query|
1100|510|发货新增|||2|business:shipment:add|
1101|510|发货修改|||3|business:shipment:edit|
1102|510|发货删除|||4|business:shipment:remove|
1103|510|发货确认|||5|business:shipment:confirm|
1104|511|使用查询|||1|business:usage:query|
1105|511|使用新增|||2|business:usage:add|
1106|512|退回查询|||1|business:return:query|
1107|512|退回申请|||2|business:return:request|
1108|512|退回确认|||3|business:return:confirm|
""".strip()
def _expected_business_menu_fields() -> dict[int, tuple]:
expected = {}
for spec in BUSINESS_MENU_SPECS.splitlines():
menu_id, parent_id, menu_name, path, route_name, order_num, perms, component = spec.split('|')
expected[int(menu_id)] = (
int(parent_id),
menu_name,
path,
route_name,
int(order_num),
perms,
None if component == '<NULL>' else component,
)
return expected
EXPECTED_BUSINESS_MENU_FIELDS = _expected_business_menu_fields()
LEGACY_PATCH_BUSINESS_MENU_FIELDS = EXPECTED_BUSINESS_MENU_FIELDS | {
121: (1, '基础资料维护', 'businessMaster', '', 10, '', None),
122: (1, '业务管理', 'businessOperation', '', 11, '', None),
123: (1, '报表查询', 'businessReport', '', 12, '', None),
}
EXPECTED_TOP_LEVEL_MENU_POSITIONS = {
121: ('基础资料维护', 0, 1),
122: ('业务管理', 0, 2),
123: ('报表查询', 0, 3),
1: ('系统管理', 0, 4),
2: ('系统监控', 0, 5),
3: ('系统工具', 0, 6),
}
EXPECTED_BUSINESS_ROOT_ICONS = {
121: 'documentation',
122: 'build',
123: 'chart',
}
EXPECTED_TOP_LEVEL_MENU_STABLE_FIELDS = {
121: ('基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', 'documentation'),
122: ('业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', 'build'),
123: ('报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', 'chart'),
1: ('系统管理', 'system', None, '', '', '1', '0', 'M', '0', '0', '', 'system'),
2: ('系统监控', 'monitor', None, '', '', '1', '0', 'M', '0', '0', '', 'monitor'),
3: ('系统工具', 'tool', None, '', '', '1', '0', 'M', '0', '0', '', 'tool'),
}
MENU_PROMOTION_IDS = set(EXPECTED_TOP_LEVEL_MENU_POSITIONS)
BUSINESS_MENU_IDS = set(range(121, 124)) | set(range(502, 519)) | set(range(1065, 1109))
MYSQL_MENU_STABLE_COLUMNS = (
'menu_name',
'parent_id',
'order_num',
'path',
'component',
'query',
'route_name',
'is_frame',
'is_cache',
'menu_type',
'visible',
'status',
'perms',
'icon',
)
MYSQL_MENU_SQL_MODE_SAVE = 'set @pallet_workflow_original_sql_mode = @@session.sql_mode'
MYSQL_MENU_SQL_MODE_ENFORCE = (
"set session sql_mode = concat_ws(',', nullif(@pallet_workflow_original_sql_mode, ''), "
"if(find_in_set('strict_all_tables', @pallet_workflow_original_sql_mode) = 0, "
"'strict_all_tables', null))"
)
MYSQL_MENU_SQL_MODE_RESTORE = 'set session sql_mode = @pallet_workflow_original_sql_mode'
def _strip_sql_comments(sql: str) -> str:
sql = re.sub(r'/\*.*?\*/', ' ', sql, flags=re.S)
return re.sub(r'(?m)^\s*(?:--|#).*$\n?', ' ', sql)
def _matching_parenthesis(text: str, opening: int) -> int:
depth = 0
quote = None
index = opening
while index < len(text):
char = text[index]
if quote:
if char == quote and index + 1 < len(text) and text[index + 1] == quote:
index += 2
continue
if char == quote:
quote = None
elif char in '\'"`':
quote = char
elif char == '(':
depth += 1
elif char == ')':
depth -= 1
if depth == 0:
return index
index += 1
raise AssertionError('unbalanced SQL parentheses')
def _split_values(values: str) -> list[str]:
parts = []
start = 0
depth = 0
quote = None
index = 0
while index < len(values):
char = values[index]
if quote:
if char == quote and index + 1 < len(values) and values[index + 1] == quote:
index += 2
continue
if char == quote:
quote = None
elif char in '\'"`':
quote = char
elif char == '(':
depth += 1
elif char == ')':
depth -= 1
elif char == ',' and depth == 0:
parts.append(values[start:index].strip())
start = index + 1
index += 1
parts.append(values[start:].strip())
return parts
def _decode_value(value: str) -> str | None:
value = value.strip()
if value.lower() == 'null':
return None
if value.startswith("'") and value.endswith("'"):
return value[1:-1].replace("''", "'")
return value
def _sql_statements(sql: str) -> list[str]:
return [statement.strip() for statement in _strip_sql_comments(sql).split(';') if statement.strip()]
def _normalized_sql_statement(statement: str) -> str:
return re.sub(r'\s+', ' ', statement.replace('`', '')).strip().lower()
def _mysql_menu_seed_has_safe_sql_mode_order(statements: list[str]) -> bool:
normalized = [_normalized_sql_statement(statement) for statement in statements]
predicates = (
lambda statement: statement == MYSQL_MENU_SQL_MODE_SAVE,
lambda statement: statement == MYSQL_MENU_SQL_MODE_ENFORCE,
lambda statement: (
statement.startswith('insert into sys_menu values ') and ' on duplicate key update ' in statement
),
lambda statement: statement == MYSQL_MENU_SQL_MODE_RESTORE,
lambda statement: (
re.fullmatch(
r'select\s+.+\s+from\s+sys_menu\s+where\s+menu_id\s+in\s*\([^)]+\)',
statement,
)
is not None
),
)
positions = []
for predicate in predicates:
matches = [index for index, statement in enumerate(normalized) if predicate(statement)]
if len(matches) != 1:
return False
positions.append(matches[0])
return positions == sorted(positions) and positions[-1] == len(statements) - 1
def _insert_rows(sql: str, table: str) -> list[list[str | None]]:
rows = []
pattern = re.compile(
rf'\binsert(?:\s+ignore)?\s+into\s+(?:[`"]?[a-z0-9_]+[`"]?\.)?[`"]?{table}[`"]?'
r'(?:\s*\([^;]*?\))?\s+values\s*',
re.I | re.S,
)
for statement in _sql_statements(sql):
match = pattern.search(statement)
if not match:
continue
index = match.end()
while index < len(statement):
while index < len(statement) and (statement[index].isspace() or statement[index] == ','):
index += 1
if index >= len(statement) or statement[index] != '(':
break
closing = _matching_parenthesis(statement, index)
rows.append([_decode_value(value) for value in _split_values(statement[index + 1 : closing])])
index = closing + 1
return rows
def _create_table_bodies(sql: str) -> dict[str, str]:
clean_sql = _strip_sql_comments(sql)
pattern = re.compile(
r'\bcreate\s+table\s+(?:if\s+not\s+exists\s+)?'
r'(?:[`"]?[a-z0-9_]+[`"]?\.)?[`"]?([a-z_][a-z0-9_]*)[`"]?\s*\(',
re.I,
)
tables = {}
for match in pattern.finditer(clean_sql):
opening = match.end() - 1
closing = _matching_parenthesis(clean_sql, opening)
tables[match.group(1).lower()] = clean_sql[opening + 1 : closing]
return tables
def _identifiers(value: str) -> tuple[str, ...]:
return tuple(part.strip().strip('`"').lower() for part in value.split(','))
def _normalized_check(value: str) -> str:
normalized = re.sub(r'[\s`"]+', '', value).lower()
while normalized.startswith('(') and normalized.endswith(')'):
try:
if _matching_parenthesis(normalized, 0) != len(normalized) - 1:
break
except AssertionError:
break
normalized = normalized[1:-1]
return normalized
def _table_contract(
body: str,
) -> tuple[set[str], tuple[str, ...], set[tuple[str, ...]], set[tuple[str, str]], set[str]]:
columns = set()
primary_key = ()
unique_keys = set()
foreign_keys = set()
checks = set()
reserved = {'constraint', 'primary', 'unique', 'foreign', 'check', 'key', 'index'}
for clause in _split_values(body):
clean = clause.strip()
identifier_match = re.match(r'[`"]?([a-z_][a-z0-9_]*)[`"]?', clean, flags=re.I)
first = identifier_match.group(1).lower() if identifier_match else ''
is_column = first and first not in reserved
if is_column:
columns.add(first)
if re.search(r'\bprimary\s+key\b', clean, flags=re.I):
primary_key = (first,)
if re.search(r'\bunique\b', clean, flags=re.I):
unique_keys.add((first,))
reference = re.search(
r'\breferences\s+[`"]?([a-z_][a-z0-9_]*)[`"]?\s*\(\s*[`"]?([a-z_][a-z0-9_]*)',
clean,
flags=re.I,
)
if reference:
foreign_keys.add((first, f'{reference.group(1).lower()}.{reference.group(2).lower()}'))
primary_match = re.search(r'\bprimary\s+key\s*\(', clean, flags=re.I)
if primary_match:
opening = primary_match.end() - 1
primary_key = _identifiers(clean[opening + 1 : _matching_parenthesis(clean, opening)])
unique_match = re.search(
r'\bunique(?:\s+(?:key|index))?(?:\s+[`"]?[a-z_][a-z0-9_]*[`"]?)?\s*\(',
clean,
flags=re.I,
)
if unique_match:
opening = unique_match.end() - 1
unique_keys.add(_identifiers(clean[opening + 1 : _matching_parenthesis(clean, opening)]))
foreign_match = re.search(r'\bforeign\s+key\s*\(', clean, flags=re.I)
if foreign_match:
opening = foreign_match.end() - 1
local_columns = _identifiers(clean[opening + 1 : _matching_parenthesis(clean, opening)])
reference = re.search(
r'\breferences\s+[`"]?([a-z_][a-z0-9_]*)[`"]?\s*\((.*?)\)',
clean,
flags=re.I | re.S,
)
if reference:
target_columns = _identifiers(reference.group(2))
for local_column, target_column in zip(local_columns, target_columns, strict=False):
foreign_keys.add((local_column, f'{reference.group(1).lower()}.{target_column}'))
check_match = re.search(r'\bcheck\s*\(', clean, flags=re.I)
if check_match:
opening = check_match.end() - 1
checks.add(_normalized_check(clean[opening + 1 : _matching_parenthesis(clean, opening)]))
return columns, primary_key, unique_keys, foreign_keys, checks
def _column_definitions(body: str) -> dict[str, str]:
definitions = {}
reserved = {'constraint', 'primary', 'unique', 'foreign', 'check', 'key', 'index'}
for clause in _split_values(body):
clean = clause.strip()
identifier_match = re.match(r'[`"]?([a-z_][a-z0-9_]*)[`"]?', clean, flags=re.I)
if identifier_match and identifier_match.group(1).lower() not in reserved:
definitions[identifier_match.group(1).lower()] = clean
return definitions
def _is_postgres_naive_timestamp_with_default_precision(definition: str) -> bool:
return (
re.fullmatch(
r'[`"]?[a-z_][a-z0-9_]*[`"]?\s+'
r'timestamp(?:\s+without\s+time\s+zone)?(?:\s+not\s+null)?',
definition.strip(),
flags=re.I,
)
is not None
)
def _assert_test_batch_sql_contract(
test_case: unittest.TestCase,
sql: str,
*,
dialect: str,
is_patch: bool,
) -> None:
clean_sql = _strip_sql_comments(sql).replace('`', '')
normalized = re.sub(r'\s+', ' ', clean_sql).strip().lower()
tables = _create_table_bodies(sql)
test_case.assertNotRegex(normalized, r'\bon\s+delete\s+cascade\b')
if is_patch:
test_case.assertNotRegex(normalized, r'\bupdate\b')
for table_name in EXPECTED_TEST_BATCH_TABLES:
test_case.assertIn(table_name, tables)
columns, primary_key, unique_keys, foreign_keys, checks = _table_contract(tables[table_name])
test_case.assertEqual(EXPECTED_COLUMNS[table_name], columns, table_name)
test_case.assertEqual(EXPECTED_PRIMARY_KEYS[table_name], primary_key, table_name)
test_case.assertEqual(EXPECTED_UNIQUE_KEYS[table_name], unique_keys, table_name)
test_case.assertEqual(EXPECTED_FOREIGN_KEYS[table_name], foreign_keys, table_name)
test_case.assertEqual(EXPECTED_CHECKS[table_name], checks, table_name)
create_batch = normalized.index('create table biz_test_batch (')
create_batch_user = normalized.index('create table biz_test_batch_user (')
owned_marker = 'alter table' if is_patch else 'create table'
first_owned = min(normalized.index(f'{owned_marker} {table_name} ') for table_name in TEST_BATCH_OWNED_TABLES)
test_case.assertLess(create_batch, create_batch_user)
test_case.assertLess(create_batch_user, first_owned)
for table_name in TEST_BATCH_OWNED_TABLES:
with test_case.subTest(dialect=dialect, patch=is_patch, table=table_name):
if is_patch:
test_case.assertRegex(
normalized,
rf'\balter table {table_name}\s+add column test_batch_id bigint null\b',
)
else:
definitions = _column_definitions(tables[table_name])
test_case.assertIn('test_batch_id', definitions)
test_case.assertRegex(definitions['test_batch_id'], r'\bbigint\b')
test_case.assertNotRegex(definitions['test_batch_id'], r'\bnot\s+null\b')
index_name = f'idx_{table_name}_test_batch_id'
if dialect == 'mysql':
index_pattern = rf'\b(?:add\s+)?(?:index|key)\s+{index_name}\s*\(\s*test_batch_id\s*\)'
test_case.assertRegex(normalized, index_pattern)
test_case.assertIn("comment '测试数据批次id;null表示正式数据'", normalized)
else:
index_pattern = (
rf'\bcreate\s+index\s+{index_name}\s+on\s+{table_name}\s*\(\s*test_batch_id\s*\)'
)
test_case.assertRegex(normalized, index_pattern)
test_case.assertRegex(
normalized,
rf"comment\s+on\s+column\s+{table_name}\.test_batch_id\s+is\s+"
r"'测试数据批次id;null表示正式数据'",
)
constraint_name = f'fk_{table_name}_test_batch_id'
test_case.assertRegex(
normalized,
rf'\bconstraint\s+{constraint_name}\s+foreign\s+key\s*\(\s*test_batch_id\s*\)\s*'
r'references\s+biz_test_batch\s*\(\s*batch_id\s*\)',
)
def _menu_rows(sql: str, menu_ids: set[int]) -> dict[int, list]:
rows = _insert_rows(sql, 'sys_menu')
selected = [row for row in rows if row and str(row[0]).isdigit() and int(row[0]) in menu_ids]
counts = {menu_id: sum(int(row[0]) == menu_id for row in selected) for menu_id in menu_ids}
if set(counts) != menu_ids or any(count != 1 for count in counts.values()):
raise AssertionError(f'menu row counts differ: {counts}')
return {int(row[0]): row for row in selected}
def _business_menu_rows(sql: str) -> dict[int, list]:
return _menu_rows(sql, BUSINESS_MENU_IDS)
def _assert_top_level_menu_fields(test_case: unittest.TestCase, sql: str) -> None:
rows = _menu_rows(sql, MENU_PROMOTION_IDS)
actual_positions = {menu_id: (row[1], int(row[2]), int(row[3])) for menu_id, row in rows.items()}
test_case.assertEqual(EXPECTED_TOP_LEVEL_MENU_POSITIONS, actual_positions)
stable_indexes = (1, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14)
actual_stable_fields = {menu_id: tuple(row[index] for index in stable_indexes) for menu_id, row in rows.items()}
test_case.assertEqual(EXPECTED_TOP_LEVEL_MENU_STABLE_FIELDS, actual_stable_fields)
def _assert_business_menu_fields(
test_case: unittest.TestCase,
sql: str,
expected_fields: dict[int, tuple] = EXPECTED_BUSINESS_MENU_FIELDS,
) -> None:
rows = _business_menu_rows(sql)
invalid_widths = {menu_id: len(row) for menu_id, row in rows.items() if len(row) != SYS_MENU_VALUE_COUNT}
test_case.assertEqual({}, invalid_widths)
actual = {
menu_id: (
int(row[2]),
row[1],
row[4],
row[7],
int(row[3]),
row[13],
row[5],
)
for menu_id, row in rows.items()
}
test_case.assertEqual(expected_fields, actual)
def _assert_business_permission_inventory(test_case: unittest.TestCase, sql: str) -> None:
permission_rows = [
row
for row in _insert_rows(sql, 'sys_menu')
if len(row) > SYS_MENU_PERMS_INDEX
and row[SYS_MENU_PERMS_INDEX]
and re.match(r'^(?:master|business|report):', row[SYS_MENU_PERMS_INDEX])
]
test_case.assertTrue(all(len(row) == SYS_MENU_VALUE_COUNT for row in permission_rows))
actual_permissions = Counter(row[SYS_MENU_PERMS_INDEX] for row in permission_rows)
test_case.assertEqual(Counter(EXPECTED_PERMISSIONS), actual_permissions)
MENU_PROMOTION_LOCK_STATEMENT = (
'select menu_id from sys_menu where menu_id in (1, 2, 3, 121, 122, 123) order by menu_id for update'
)
MENU_PROMOTION_EXPECTED_UPDATE = _normalized_sql_statement(
"""
update sys_menu
set parent_id = case
when menu_id in (121, 122, 123) then 0
else parent_id
end,
order_num = case menu_id
when 121 then 1
when 122 then 2
when 123 then 3
when 1 then 4
when 2 then 5
when 3 then 6
else order_num
end
where menu_id in (1, 2, 3, 121, 122, 123)
"""
)
def _assert_menu_promotion_guard_structure(test_case: unittest.TestCase, sql: str) -> tuple[str, str]:
normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(sql)]
guards = [
statement for statement in normalized if statement.startswith('insert into promote_business_menus_guard ')
]
test_case.assertEqual(2, len(guards))
pre_guard, post_guard = guards
identity_fields = (
('menu_name', True),
('path', True),
('component', True),
('query', True),
('route_name', True),
('is_frame', False),
('is_cache', False),
('menu_type', True),
('visible', True),
('status', True),
('perms', True),
('icon', True),
)
identity_comparisons = [
(
rf'\(binary\s+actual\.{field}\s*<=>\s*binary\s+expected\.{field}\)'
if binary
else rf'\(actual\.{field}\s*<=>\s*expected\.{field}\)'
)
for field, binary in identity_fields
]
identity_aggregate = r'sum\s*\(\s*' + r'\s+and\s+'.join(identity_comparisons) + r'\s*\)\s*=\s*6'
for guard, phase in ((pre_guard, 'precondition'), (post_guard, 'postcondition')):
conflict_token = f"'promote-business-menus-{phase}-conflict'"
test_case.assertEqual(2, guard.count(conflict_token))
test_case.assertIn(f"'promote-business-menus-{phase}-verified'", guard)
duplicate_key_structure = (
r'^insert into promote_business_menus_guard \(guard_token\) '
r'select guard_token from \( select '
rf'{re.escape(conflict_token)} as guard_token union all select if\('
)
test_case.assertRegex(guard, duplicate_key_structure)
test_case.assertEqual(1, len(re.findall(r'\bpromote_business_menus_expected\b', guard)))
expected_join = 'left join sys_menu as actual on actual.menu_id = expected.menu_id'
test_case.assertEqual(1, guard.count(expected_join))
test_case.assertIn('count(actual.menu_id) = 6', guard)
test_case.assertRegex(guard, identity_aggregate)
old_state = (
r'sum\(\s*\(actual\.parent_id <=> expected\.old_parent_id\)\s*'
r'and\s*\(actual\.order_num <=> expected\.old_order_num\)\s*\)\s*=\s*6'
)
new_state = (
r'sum\(\s*\(actual\.parent_id <=> expected\.new_parent_id\)\s*'
r'and\s*\(actual\.order_num <=> expected\.new_order_num\)\s*\)\s*=\s*6'
)
precondition_state_group = (
rf'and\s*\(\s*{old_state}\s+or\s+{new_state}\s*\)\s*,\s*'
r"'promote-business-menus-precondition-verified'\s*,\s*"
r"'promote-business-menus-precondition-conflict'\s*\)"
)
test_case.assertRegex(pre_guard, precondition_state_group)
test_case.assertNotIn('old_parent_id', post_guard)
test_case.assertNotIn('old_order_num', post_guard)
postcondition_state = (
rf'and\s*{new_state}\s*,\s*'
r"'promote-business-menus-postcondition-verified'\s*,\s*"
r"'promote-business-menus-postcondition-conflict'\s*\)"
)
test_case.assertRegex(post_guard, postcondition_state)
return pre_guard, post_guard
def _assert_menu_promotion_transaction_body(test_case: unittest.TestCase, sql: str) -> None:
normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(sql)]
pre_guard, post_guard = _assert_menu_promotion_guard_structure(test_case, sql)
def unique_index(statement: str) -> int:
matches = [index for index, actual in enumerate(normalized) if actual == statement]
test_case.assertEqual(1, len(matches), statement)
return matches[0]
start_index = unique_index('start transaction')
lock_index = unique_index(MENU_PROMOTION_LOCK_STATEMENT)
pre_guard_index = unique_index(pre_guard)
update_index = unique_index(MENU_PROMOTION_EXPECTED_UPDATE)
post_guard_index = unique_index(post_guard)
commit_index = unique_index('commit')
test_case.assertEqual(
sorted((start_index, lock_index, pre_guard_index, update_index, post_guard_index, commit_index)),
[start_index, lock_index, pre_guard_index, update_index, post_guard_index, commit_index],
)
test_case.assertEqual(
[MENU_PROMOTION_LOCK_STATEMENT, pre_guard, MENU_PROMOTION_EXPECTED_UPDATE, post_guard],
normalized[start_index + 1 : commit_index],
)
def _assert_business_menu_icon_guard_primary_key(test_case: unittest.TestCase, sql: str) -> None:
normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(sql)]
guard_creates = [
statement
for statement in normalized
if statement.startswith('create temporary table business_menu_icon_guard ')
]
test_case.assertEqual(1, len(guard_creates))
test_case.assertIn('primary key (guard_token)', guard_creates[0])
def _assert_business_menu_icon_guard_structure(test_case: unittest.TestCase, sql: str) -> tuple[str, str]:
normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(sql)]
guards = [statement for statement in normalized if statement.startswith('insert into business_menu_icon_guard ')]
test_case.assertEqual(2, len(guards))
pre_guard, post_guard = guards
identity_fields = (
('menu_name', True),
('path', True),
('component', True),
('query', True),
('route_name', True),
('is_frame', False),
('is_cache', False),
('menu_type', True),
('visible', True),
('status', True),
('perms', True),
('parent_id', False),
('order_num', False),
)
identity_comparisons = [
(
rf'\(binary\s+actual\.{field}\s*<=>\s*binary\s+expected\.{field}\)'
if binary
else rf'\(actual\.{field}\s*<=>\s*expected\.{field}\)'
)
for field, binary in identity_fields
]
identity_aggregate = r'sum\s*\(\s*' + r'\s+and\s+'.join(identity_comparisons) + r'\s*\)\s*=\s*3'
for guard, phase in ((pre_guard, 'precondition'), (post_guard, 'postcondition')):
conflict_token = f"'business-menu-icon-{phase}-conflict'"
test_case.assertEqual(2, guard.count(conflict_token))
test_case.assertIn(f"'business-menu-icon-{phase}-verified'", guard)
duplicate_key_structure = (
r'^insert into business_menu_icon_guard \(guard_token\) '
r'select guard_token from \( select '
rf'{re.escape(conflict_token)} as guard_token union all select if\('
)
test_case.assertRegex(guard, duplicate_key_structure)
test_case.assertEqual(1, len(re.findall(r'\bbusiness_menu_icon_expected\b', guard)))
test_case.assertIn('count(actual.menu_id) = 3', guard)
test_case.assertIn(
'left join sys_menu as actual on actual.menu_id = expected.menu_id',
guard,
)
test_case.assertRegex(guard, identity_aggregate)
for field in (
'menu_name',
'path',
'component',
'query',
'route_name',
'menu_type',
'visible',
'status',
'perms',
):
test_case.assertIn(f'binary actual.{field} <=> binary expected.{field}', guard)
for field in ('is_frame', 'is_cache', 'parent_id', 'order_num'):
test_case.assertIn(f'actual.{field} <=> expected.{field}', guard)
return pre_guard, post_guard
class SqlContractTest(unittest.TestCase):
def test_test_batch_initializers_and_patches_match_contract(self) -> None:
scripts = (
(SQL_ROOT / 'shierfeng-fastapi.sql', 'mysql', False),
(SQL_ROOT / 'shierfeng-fastapi-pg.sql', 'postgresql', False),
(TEST_BATCH_MYSQL_PATCH_PATH, 'mysql', True),
(TEST_BATCH_PG_PATCH_PATH, 'postgresql', True),
)
for path, dialect, is_patch in scripts:
with self.subTest(path=path.name):
self.assertTrue(path.is_file(), f'missing SQL: {path}')
_assert_test_batch_sql_contract(
self,
path.read_text(encoding='utf-8'),
dialect=dialect,
is_patch=is_patch,
)
def test_initializers_define_exact_business_table_set(self) -> None:
for filename in INITIALIZERS:
with self.subTest(filename=filename):
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
actual = {table for table in _create_table_bodies(sql) if table.startswith('biz_')}
self.assertEqual(EXPECTED_TABLES, actual)
def test_initializers_match_orm_table_contract(self) -> None:
for filename in INITIALIZERS:
tables = _create_table_bodies((SQL_ROOT / filename).read_text(encoding='utf-8'))
for table in sorted(EXPECTED_TABLES):
with self.subTest(filename=filename, table=table):
self.assertIn(table, tables)
columns, primary_key, unique_keys, foreign_keys, checks = _table_contract(tables[table])
self.assertEqual(EXPECTED_COLUMNS[table], columns)
self.assertEqual(EXPECTED_PRIMARY_KEYS[table], primary_key)
self.assertEqual(EXPECTED_UNIQUE_KEYS[table], unique_keys)
self.assertEqual(EXPECTED_FOREIGN_KEYS[table], foreign_keys)
self.assertEqual(EXPECTED_CHECKS[table], checks)
def test_postgres_business_datetimes_preserve_default_microsecond_precision(self) -> None:
self.assertEqual(40, sum(len(columns) for columns in EXPECTED_DATETIME_COLUMNS.values()))
tables = _create_table_bodies((SQL_ROOT / 'shierfeng-fastapi-pg.sql').read_text(encoding='utf-8'))
invalid_columns = []
for table, expected_columns in EXPECTED_DATETIME_COLUMNS.items():
definitions = _column_definitions(tables[table])
actual_columns = {
column
for column, definition in definitions.items()
if re.search(r'\btimestamp\b', definition, flags=re.I)
}
self.assertEqual(expected_columns, actual_columns, table)
for column in expected_columns:
definition = definitions[column]
if not _is_postgres_naive_timestamp_with_default_precision(definition):
invalid_columns.append(f'{table}.{column}')
self.assertEqual([], sorted(invalid_columns))
def test_postgres_datetime_type_validator_rejects_precision_and_timezone_mutations(self) -> None:
self.assertTrue(_is_postgres_naive_timestamp_with_default_precision('occurred_at timestamp'))
self.assertTrue(
_is_postgres_naive_timestamp_with_default_precision('occurred_at timestamp without time zone not null')
)
self.assertFalse(_is_postgres_naive_timestamp_with_default_precision('occurred_at timestamp(3)'))
self.assertFalse(_is_postgres_naive_timestamp_with_default_precision('occurred_at timestamp with time zone'))
def test_initializers_define_fixed_business_menus_and_permissions(self) -> None:
self.assertEqual(17, len(EXPECTED_PAGE_PERMISSIONS))
self.assertEqual(44, len(EXPECTED_BUTTON_PERMISSIONS))
self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS)
self.assertEqual(61, len(EXPECTED_PERMISSIONS))
self.assertEqual(64, len(EXPECTED_BUSINESS_MENU_FIELDS))
for filename in INITIALIZERS:
with self.subTest(filename=filename):
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
_assert_business_menu_fields(self, sql)
_assert_business_permission_inventory(self, sql)
rows = _business_menu_rows(sql)
self.assertTrue(all(len(row) == SYS_MENU_VALUE_COUNT for row in rows.values()))
self.assertEqual(
('基础资料维护', '0', '1', 'businessMaster', 'M', ''),
tuple(rows[121][index] for index in (1, 2, 3, 4, 10, 13)),
)
self.assertEqual(
('业务管理', '0', '2', 'businessOperation', 'M', ''),
tuple(rows[122][index] for index in (1, 2, 3, 4, 10, 13)),
)
self.assertEqual(
('报表查询', '0', '3', 'businessReport', 'M', ''),
tuple(rows[123][index] for index in (1, 2, 3, 4, 10, 13)),
)
for menu_id in (121, 122, 123):
self.assertEqual(
(None, '', '', '1', '0', '0', '0'), tuple(rows[menu_id][i] for i in (5, 6, 7, 8, 9, 11, 12))
)
for menu_id, expected in EXPECTED_PAGES.items():
row = rows[menu_id]
actual = (row[1], int(row[2]), int(row[3]), row[4], row[5], row[13])
self.assertEqual(expected, actual, menu_id)
self.assertEqual(
('', '', '1', '0', 'C', '0', '0'), tuple(row[index] for index in (6, 7, 8, 9, 10, 11, 12))
)
for menu_id, expected in EXPECTED_BUTTONS.items():
row = rows[menu_id]
actual = (row[1], int(row[2]), int(row[3]), row[13])
self.assertEqual(expected, actual, menu_id)
self.assertEqual(
('', '', '', '', '1', '0', 'F', '0', '0'),
tuple(row[index] for index in (4, 5, 6, 7, 8, 9, 10, 11, 12)),
)
def test_initializers_define_expected_top_level_menu_order(self) -> None:
for filename in INITIALIZERS:
with self.subTest(filename=filename):
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
_assert_top_level_menu_fields(self, sql)
def test_initializers_define_renderable_business_root_icons(self) -> None:
icon_root = SQL_ROOT.parent.parent / 'shierfeng-fastapi-frontend' / 'src' / 'assets' / 'icons' / 'svg'
for icon in EXPECTED_BUSINESS_ROOT_ICONS.values():
with self.subTest(icon=icon):
self.assertTrue((icon_root / f'{icon}.svg').is_file())
for filename in INITIALIZERS:
with self.subTest(filename=filename):
rows = _menu_rows((SQL_ROOT / filename).read_text(encoding='utf-8'), set(EXPECTED_BUSINESS_ROOT_ICONS))
actual = {menu_id: row[SYS_MENU_ICON_INDEX] for menu_id, row in rows.items()}
self.assertEqual(EXPECTED_BUSINESS_ROOT_ICONS, actual)
def test_business_menu_icon_patch_is_fail_closed_atomic_and_exact(self) -> None:
self.assertTrue(BUSINESS_MENU_ICON_PATCH_PATH.is_file(), f'missing patch: {BUSINESS_MENU_ICON_PATCH_PATH}')
raw = BUSINESS_MENU_ICON_PATCH_PATH.read_text(encoding='utf-8')
executable = _strip_sql_comments(raw).replace('`', '')
normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(raw)]
_assert_business_menu_icon_guard_primary_key(self, raw)
pre_guard, post_guard = _assert_business_menu_icon_guard_structure(self, raw)
for required_note in (
'未自动执行',
'执行前备份',
'可幂等重跑',
'禁止使用 mysql --force',
'独立连接',
'20260727_promote_business_menus_mysql.sql',
'禁止逆序重跑旧补丁',
):
self.assertIn(required_note, raw)
self.assertNotIn('--force', executable.lower())
self.assertNotRegex(executable.lower(), r'\bsys_role_menu\b')
expected_rows = [
(
'121',
'基础资料维护',
'businessMaster',
None,
'',
'',
'1',
'0',
'M',
'0',
'0',
'',
'0',
'1',
'#',
'documentation',
),
('122', '业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', '0', '2', '#', 'build'),
('123', '报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', '0', '3', '#', 'chart'),
]
self.assertEqual(
expected_rows,
[tuple(row) for row in _insert_rows(raw, 'business_menu_icon_expected')],
)
start_index = normalized.index('start transaction')
commit_index = normalized.index('commit')
self.assertEqual(1, normalized.count('start transaction'))
self.assertEqual(1, normalized.count('commit'))
transaction_body = normalized[start_index + 1 : commit_index]
lock_statement = 'select menu_id from sys_menu where menu_id in (121, 122, 123) order by menu_id for update'
expected_update = (
"update sys_menu set icon = case menu_id when 121 then 'documentation' when 122 then 'build' "
"when 123 then 'chart' else icon end where menu_id in (121, 122, 123)"
)
self.assertEqual(lock_statement, transaction_body[0])
self.assertEqual(expected_update, transaction_body[2])
self.assertEqual(4, len(transaction_body))
self.assertRegex(
pre_guard,
r'sum\(\(binary actual\.icon <=> binary expected\.old_icon\)\) = 3 or '
r'sum\(\(binary actual\.icon <=> binary expected\.new_icon\)\) = 3',
)
self.assertNotIn('old_icon', post_guard)
self.assertIn('sum((binary actual.icon <=> binary expected.new_icon)) = 3', post_guard)
permanent_dml = [
statement
for statement in normalized
if re.match(r'^(?:insert|update|delete|replace|truncate)\b', statement)
and not statement.startswith('insert into business_menu_icon_expected ')
and not statement.startswith('insert into business_menu_icon_guard ')
]
self.assertEqual([expected_update], permanent_dml)
self.assertEqual(
'select menu_id, menu_name, parent_id, order_num, path, icon from sys_menu '
'where menu_id in (121, 122, 123) order by order_num, menu_id',
normalized[-1],
)
def test_business_menu_icon_patch_rejects_guard_primary_key_removal(self) -> None:
raw = BUSINESS_MENU_ICON_PATCH_PATH.read_text(encoding='utf-8')
mutated, replacements = re.subn(
r',\s*primary\s+key\s*\(guard_token\)',
'',
raw,
count=1,
flags=re.I,
)
self.assertEqual(1, replacements)
with self.assertRaises(AssertionError):
_assert_business_menu_icon_guard_primary_key(self, mutated)
def test_business_menu_icon_guard_contract_rejects_structural_mutations(self) -> None:
raw = BUSINESS_MENU_ICON_PATCH_PATH.read_text(encoding='utf-8')
mutation_specs = (
('union_distinct', r'\bunion\s+all\b', 'union distinct'),
(
'identity_threshold',
r'(\(actual\.order_num\s*<=>\s*expected\.order_num\)\s*\)\s*)=\s*3',
r'\1>= 0',
),
)
for name, pattern, replacement in mutation_specs:
with self.subTest(mutation=name):
mutated, replacements = re.subn(pattern, replacement, raw, count=1, flags=re.I)
self.assertEqual(1, replacements)
with self.assertRaises(AssertionError):
_assert_business_menu_icon_guard_structure(self, mutated)
def test_top_level_menu_contract_rejects_same_stable_field_drift_in_both_initializers(self) -> None:
mutations = (
(1, '系统管理', 'system', 'driftSystemIcon'),
(121, '基础资料维护', 'documentation', 'driftBusinessIcon'),
)
for filename in INITIALIZERS:
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
for menu_id, menu_name, original_icon, drifted_icon in mutations:
with self.subTest(filename=filename, menu_id=menu_id):
mutated, replacements = re.subn(
rf"(\(?\s*'?{menu_id}'?\s*,\s*'{menu_name}'.*?,\s*)'{re.escape(original_icon)}'"
r"(\s*,\s*'admin')",
rf"\1'{drifted_icon}'\2",
sql,
count=1,
)
self.assertEqual(1, replacements)
with self.assertRaises(AssertionError):
_assert_top_level_menu_fields(self, mutated)
def test_business_permission_inventory_rejects_duplicate_permission(self) -> None:
sql = (SQL_ROOT / INITIALIZERS[0]).read_text(encoding='utf-8')
sql += """
insert into sys_menu values(
'1999', '重复权限', '1', '1', '', '', '', '', 1, 0,
'F', '0', '0', 'master:strain:list', '#', 'admin', sysdate(), '', null, ''
);
"""
with self.assertRaises(AssertionError):
_assert_business_permission_inventory(self, sql)
def test_business_menu_contract_rejects_same_field_drift_in_both_initializers(self) -> None:
for filename in INITIALIZERS:
with self.subTest(filename=filename):
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
mutated, replacements = re.subn(
r"(\(?\s*'?502'?\s*,\s*)'菌种资料'",
r"\1'漂移菌种资料'",
sql,
count=1,
)
self.assertEqual(1, replacements)
with self.assertRaises(AssertionError):
_assert_business_menu_fields(self, mutated)
def test_mysql_and_postgresql_business_menu_fields_are_identical(self) -> None:
mysql_rows = _business_menu_rows((SQL_ROOT / INITIALIZERS[0]).read_text(encoding='utf-8'))
postgres_rows = _business_menu_rows((SQL_ROOT / INITIALIZERS[1]).read_text(encoding='utf-8'))
compared_indexes = tuple(range(15))
mysql_contract = {
menu_id: tuple(row[index] for index in compared_indexes) for menu_id, row in mysql_rows.items()
}
postgres_contract = {
menu_id: tuple(row[index] for index in compared_indexes) for menu_id, row in postgres_rows.items()
}
self.assertEqual(mysql_contract, postgres_contract)
def test_initializers_do_not_grant_new_business_menus_to_roles(self) -> None:
for filename in INITIALIZERS:
with self.subTest(filename=filename):
rows = _insert_rows((SQL_ROOT / filename).read_text(encoding='utf-8'), 'sys_role_menu')
granted = {int(row[1]) for row in rows if len(row) > 1 and str(row[1]).isdigit()}
self.assertFalse(BUSINESS_MENU_IDS & granted)
self.assertFalse({4, 99, 118, 119, 1061, 1062, 1063, 1064} & granted)
def test_initializers_exclude_removed_ai_and_external_link_seeds(self) -> None:
removed_menu_ids = {4, 99, 118, 119, 1061, 1062, 1063, 1064}
for filename in INITIALIZERS:
with self.subTest(filename=filename):
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
menu_ids = {
int(row[0])
for row in _insert_rows(sql, 'sys_menu')
if row and row[0] is not None and str(row[0]).isdigit()
}
self.assertFalse(removed_menu_ids & menu_ids)
dict_types = _insert_rows(sql, 'sys_dict_type')
self.assertFalse(any(row[0] == '12' or row[2] == 'ai_provider_type' for row in dict_types))
dict_data = _insert_rows(sql, 'sys_dict_data')
self.assertFalse(
any(int(row[0]) in REMOVED_AI_DICT_CODES or row[4] == 'ai_provider_type' for row in dict_data)
)
def test_initializers_keep_menu_allocator_and_use_native_dialects(self) -> None:
mysql = (SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8').lower()
postgres = (SQL_ROOT / 'shierfeng-fastapi-pg.sql').read_text(encoding='utf-8').lower()
self.assertRegex(mysql, r'auto_increment\s*=\s*2000')
self.assertNotRegex(mysql, r'\bbigserial\b|\balter\s+sequence\b|\bcomment\s+on\b')
self.assertRegex(postgres, r'alter\s+sequence\s+sys_menu_menu_id_seq\s+restart\s+2000')
self.assertNotIn('`', postgres)
self.assertNotRegex(postgres, r'\bauto_increment\b|\bengine\s*=')
def test_initializers_create_event_before_inventory_foreign_key(self) -> None:
for filename in INITIALIZERS:
with self.subTest(filename=filename):
sql = _strip_sql_comments((SQL_ROOT / filename).read_text(encoding='utf-8')).lower()
self.assertIn('create table biz_pallet_event', sql)
self.assertIn('create table biz_pallet_inventory', sql)
self.assertLess(
sql.index('create table biz_pallet_event'), sql.index('create table biz_pallet_inventory')
)
def test_initializers_drop_business_tables_before_recreating_referenced_system_tables(self) -> None:
reverse_dependency_order = (
'biz_pallet_inventory',
'biz_pallet_event',
'biz_return_item',
'biz_return_order',
'biz_usage_record',
'biz_shipment_item',
'biz_shipment_order',
'biz_filling_record',
'biz_inbound_item',
'biz_inbound_order',
'biz_user_site',
'biz_user_warehouse',
'biz_pallet',
'biz_production_line',
'biz_customer_site',
'biz_customer',
'biz_warehouse',
'biz_strain',
'biz_test_batch_user',
'biz_test_batch',
)
for filename in INITIALIZERS:
with self.subTest(filename=filename):
sql = _strip_sql_comments((SQL_ROOT / filename).read_text(encoding='utf-8')).lower()
drop_positions = [sql.index(f'drop table if exists {table};') for table in reverse_dependency_order]
self.assertEqual(sorted(drop_positions), drop_positions)
self.assertLess(drop_positions[-1], sql.index('drop table if exists sys_user'))
def test_menu_promotion_patch_is_fail_closed_atomic_and_exact(self) -> None: # noqa: PLR0915
self.assertTrue(MENU_PROMOTION_PATCH_PATH.is_file(), f'missing patch: {MENU_PROMOTION_PATCH_PATH}')
raw = MENU_PROMOTION_PATCH_PATH.read_text(encoding='utf-8')
executable = _strip_sql_comments(raw).replace('`', '')
normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(raw)]
for required_note in ('未自动执行', '执行前备份', '可幂等重跑', '禁止使用 mysql --force', '独立连接'):
self.assertIn(required_note, raw)
self.assertNotIn('--force', executable.lower())
self.assertNotIn('continue-on-error', raw.lower())
expected_rows = [
('1', '系统管理', 'system', None, '', '', '1', '0', 'M', '0', '0', '', 'system', '0', '1', '0', '4'),
('2', '系统监控', 'monitor', None, '', '', '1', '0', 'M', '0', '0', '', 'monitor', '0', '2', '0', '5'),
('3', '系统工具', 'tool', None, '', '', '1', '0', 'M', '0', '0', '', 'tool', '0', '3', '0', '6'),
(
'121',
'基础资料维护',
'businessMaster',
None,
'',
'',
'1',
'0',
'M',
'0',
'0',
'',
'#',
'1',
'10',
'0',
'1',
),
(
'122',
'业务管理',
'businessOperation',
None,
'',
'',
'1',
'0',
'M',
'0',
'0',
'',
'#',
'1',
'11',
'0',
'2',
),
(
'123',
'报表查询',
'businessReport',
None,
'',
'',
'1',
'0',
'M',
'0',
'0',
'',
'#',
'1',
'12',
'0',
'3',
),
]
self.assertEqual(expected_rows, [tuple(row) for row in _insert_rows(raw, 'promote_business_menus_expected')])
def unique_index(predicate: Callable[[str], bool], label: str) -> int:
matches = [index for index, statement in enumerate(normalized) if predicate(statement)]
self.assertEqual(1, len(matches), label)
return matches[0]
expected_columns = (
'menu_id',
'menu_name',
'path',
'component',
'query',
'route_name',
'is_frame',
'is_cache',
'menu_type',
'visible',
'status',
'perms',
'icon',
'old_parent_id',
'old_order_num',
'new_parent_id',
'new_order_num',
)
expected_create_index = unique_index(
lambda statement: statement.startswith('create temporary table promote_business_menus_expected '),
'expected table creation',
)
expected_insert_index = unique_index(
lambda statement: statement.startswith('insert into promote_business_menus_expected '),
'expected row insertion',
)
expected_create = normalized[expected_create_index]
for column in expected_columns:
self.assertRegex(expected_create, rf'\b{column}\b')
guard_create_index = unique_index(
lambda statement: statement.startswith('create temporary table promote_business_menus_guard '),
'guard table creation',
)
self.assertIn('primary key (guard_token)', normalized[guard_create_index])
start_indexes = [index for index, statement in enumerate(normalized) if statement == 'start transaction']
commit_indexes = [index for index, statement in enumerate(normalized) if statement == 'commit']
self.assertEqual(1, len(start_indexes))
self.assertEqual(1, len(commit_indexes))
start_index = start_indexes[0]
commit_index = commit_indexes[0]
initial_cleanup_indexes = (
unique_index(
lambda statement: statement == 'drop temporary table if exists promote_business_menus_expected',
'expected table initial cleanup',
),
unique_index(
lambda statement: statement == 'drop temporary table if exists promote_business_menus_guard',
'guard table initial cleanup',
),
)
self.assertTrue(
all(
index < start_index
for index in (
*initial_cleanup_indexes,
expected_create_index,
expected_insert_index,
guard_create_index,
)
)
)
lock_index = unique_index(
lambda statement: statement == MENU_PROMOTION_LOCK_STATEMENT,
'six-row locking read',
)
pre_guard, post_guard = _assert_menu_promotion_guard_structure(self, raw)
guard_indexes = [
index
for index, statement in enumerate(normalized)
if statement.startswith('insert into promote_business_menus_guard ')
]
self.assertEqual(2, len(guard_indexes))
pre_guard_index, post_guard_index = guard_indexes
self.assertEqual((pre_guard, post_guard), tuple(normalized[index] for index in guard_indexes))
expected_update = MENU_PROMOTION_EXPECTED_UPDATE
_assert_menu_promotion_transaction_body(self, raw)
update_index = unique_index(
lambda statement: statement == expected_update,
'exact permanent update',
)
permanent_dml = []
for statement in normalized:
if not re.match(r'^(?:insert|update|delete|replace|truncate)\b', statement):
continue
if statement.startswith('insert into promote_business_menus_expected '):
continue
if statement.startswith('insert into promote_business_menus_guard '):
continue
permanent_dml.append(statement)
self.assertEqual([expected_update], permanent_dml)
self.assertNotRegex(executable.lower(), r'\bsys_role_menu\b')
self.assertLess(start_index, lock_index)
self.assertLess(lock_index, pre_guard_index)
self.assertLess(pre_guard_index, update_index)
self.assertLess(update_index, post_guard_index)
self.assertLess(post_guard_index, commit_index)
guard_drop_index = unique_index(
lambda statement: statement == 'drop temporary table promote_business_menus_guard',
'guard table final cleanup',
)
expected_drop_index = unique_index(
lambda statement: statement == 'drop temporary table promote_business_menus_expected',
'expected table final cleanup',
)
self.assertLess(commit_index, guard_drop_index)
self.assertLess(commit_index, expected_drop_index)
expected_verification = (
'select menu_id, menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms '
'from sys_menu where menu_id in (1, 2, 3, 121, 122, 123) order by order_num, menu_id'
)
self.assertEqual(expected_verification, normalized[-1])
self.assertLess(guard_drop_index, len(normalized) - 1)
self.assertLess(expected_drop_index, len(normalized) - 1)
def test_menu_promotion_guard_contract_rejects_structural_mutations(self) -> None:
raw = MENU_PROMOTION_PATCH_PATH.read_text(encoding='utf-8')
mutation_specs = (
('union_distinct', r'\bunion\s+all\b', 'union distinct'),
(
'old_and_new_state',
r'(old_order_num\)\s*\)\s*=\s*6\s*)or(\s+sum\s*\()',
r'\1and\2',
),
('inner_join_actual', r'\bleft\s+join\s+sys_menu\s+as\s+actual\b', 'inner join sys_menu as actual'),
(
'duplicate_expected_reference',
r'\bfrom\s+promote_business_menus_expected\s+as\s+expected\b',
'from promote_business_menus_expected as expected '
'cross join promote_business_menus_expected as duplicate_expected',
),
)
for name, pattern, replacement in mutation_specs:
with self.subTest(mutation=name):
mutated, replacements = re.subn(pattern, replacement, raw, count=1, flags=re.I)
self.assertEqual(1, replacements)
with self.assertRaises(AssertionError):
_assert_menu_promotion_guard_structure(self, mutated)
def test_menu_promotion_contract_rejects_identity_threshold_and_transaction_body_mutations(self) -> None:
raw = MENU_PROMOTION_PATCH_PATH.read_text(encoding='utf-8')
identity_threshold_mutation, replacements = re.subn(
r'(\(binary\s+actual\.icon\s+<=>\s+binary\s+expected\.icon\)\s*\)\s*)=\s*6',
r'\1>= 0',
raw,
count=1,
flags=re.I,
)
with self.subTest(mutation='identity_threshold'):
self.assertEqual(1, replacements)
with self.assertRaises(AssertionError):
_assert_menu_promotion_guard_structure(self, identity_threshold_mutation)
transaction_ddl_mutation, replacements = re.subn(
r'(start\s+transaction\s*;)',
r'\1\nalter table sys_menu engine=innodb;',
raw,
count=1,
flags=re.I,
)
with self.subTest(mutation='transaction_ddl'):
self.assertEqual(1, replacements)
with self.assertRaises(AssertionError):
_assert_menu_promotion_transaction_body(self, transaction_ddl_mutation)
def test_patch_exists_and_preserves_ai_business_tables(self) -> None:
self.assertTrue(PATCH_PATH.is_file(), f'missing patch: {PATCH_PATH}')
raw = PATCH_PATH.read_text(encoding='utf-8').lower()
sql = re.sub(r'/\*.*?\*/', '', raw, flags=re.S)
sql = re.sub(r'(?m)^\s*(?:--|#).*$\n?', '', sql).replace('`', '')
for table in (
'ai_models',
'ai_chat_config',
'ai_sessions',
'ai_memories',
'ai_metrics',
'ai_eval_runs',
'ai_knowledge',
'ai_culture',
'ai_traces',
'ai_spans',
'ai_schema_versions',
):
qualified = rf'(?:[a-z0-9_]+\.)?{table}'
patterns = (
rf'\bdrop\s+(?:temporary\s+)?table\s+(?:if\s+exists\s+)?{qualified}\b',
rf'\btruncate\s+(?:table\s+)?{qualified}\b',
rf'\bdelete\s+from\s+{qualified}\b',
rf'\balter\s+table\s+(?:if\s+exists\s+)?{qualified}\b',
)
for pattern in patterns:
self.assertIsNone(re.search(pattern, sql), f'dangerous AI table mutation: {table}')
self.assertNotRegex(sql, r'\b(?:drop\s+(?:temporary\s+)?table|truncate\s+(?:table\s+)?|delete\s+from)\s+biz_')
def test_patch_is_documented_idempotent_and_contains_full_contract(self) -> None:
self.assertTrue(PATCH_PATH.is_file(), f'missing patch: {PATCH_PATH}')
raw = PATCH_PATH.read_text(encoding='utf-8')
lower = raw.lower()
self.assertIn('隐式提交', raw)
self.assertIn('备份', raw)
self.assertIn('人工审查', raw)
self.assertIn('未自动执行', raw)
self.assertLess(lower.index('ddl'), lower.index('dml'))
self.assertEqual(18, len(re.findall(r'create\s+table\s+if\s+not\s+exists\s+`?biz_', lower)))
tables = _create_table_bodies(raw)
self.assertEqual(LEGACY_EXPECTED_TABLES, {table for table in tables if table.startswith('biz_')})
for table in LEGACY_EXPECTED_TABLES:
with self.subTest(table=table):
columns, primary_key, unique_keys, foreign_keys, checks = _table_contract(tables[table])
self.assertEqual(LEGACY_EXPECTED_COLUMNS[table], columns)
self.assertEqual(LEGACY_EXPECTED_PRIMARY_KEYS[table], primary_key)
self.assertEqual(LEGACY_EXPECTED_UNIQUE_KEYS[table], unique_keys)
self.assertEqual(LEGACY_EXPECTED_FOREIGN_KEYS[table], foreign_keys)
self.assertEqual(LEGACY_EXPECTED_CHECKS[table], checks)
_assert_business_menu_fields(self, raw, LEGACY_PATCH_BUSINESS_MENU_FIELDS)
_assert_business_permission_inventory(self, raw)
patch_rows = _business_menu_rows(raw)
mysql_rows = _business_menu_rows((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8'))
compared_indexes = (0, 1, *range(4, 15))
expected_legacy_rows = {
menu_id: tuple(
'#' if index == SYS_MENU_ICON_INDEX and menu_id in EXPECTED_BUSINESS_ROOT_ICONS else row[index]
for index in compared_indexes
)
for menu_id, row in mysql_rows.items()
}
self.assertEqual(
expected_legacy_rows,
{menu_id: tuple(row[index] for index in compared_indexes) for menu_id, row in patch_rows.items()},
)
self.assertRegex(lower, r'\bon\s+duplicate\s+key\s+update\b')
self.assertNotRegex(lower, r'\binsert\s+ignore\b')
self.assertNotRegex(_strip_sql_comments(lower), r'\binsert(?:\s+ignore)?\s+into\s+`?sys_role_menu`?\b')
self.assertIn('/getRouters', raw)
self.assertIn('用户信息', raw)
self.assertIn('sys_dict:ai_provider_type', raw)
executable = _strip_sql_comments(raw)
self.assertNotIn('/getRouters', executable)
self.assertNotIn('sys_dict:ai_provider_type', executable)
self.assertNotRegex(lower, r'\bbigserial\b|\balter\s+sequence\b|\bcomment\s+on\b')
def test_patch_menu_seed_fails_closed_on_fixed_id_conflicts(self) -> None:
raw = PATCH_PATH.read_text(encoding='utf-8')
executable = _strip_sql_comments(raw).replace('`', '').lower()
self.assertNotRegex(executable, r'\binsert\s+ignore\b')
statements = _sql_statements(raw)
self.assertTrue(_mysql_menu_seed_has_safe_sql_mode_order(statements))
menu_insert_statements = [
statement.replace('`', '').lower()
for statement in statements
if re.match(r'\s*insert(?:\s+ignore)?\s+into\s+`?sys_menu`?\b', statement, flags=re.I)
]
self.assertEqual(1, len(menu_insert_statements))
menu_insert_statement = menu_insert_statements[0]
menu_insert = re.fullmatch(
r'\s*insert\s+into\s+sys_menu\s+values\s*.*?'
r'\bon\s+duplicate\s+key\s+update\s+'
r'menu_name\s*=\s*if\s*\(\s*(?P<guard>.*?)\s*,\s*menu_name\s*,\s*null\s*\)\s*',
menu_insert_statement,
flags=re.S,
)
self.assertIsNotNone(menu_insert, 'sys_menu seed must use a fail-closed upsert')
self.assertEqual(len(BUSINESS_MENU_IDS), len(_insert_rows(menu_insert_statement, 'sys_menu')))
expected_guard = ' and '.join(f'{column} <=> values({column})' for column in MYSQL_MENU_STABLE_COLUMNS)
actual_guard = re.sub(r'\s+', ' ', menu_insert.group('guard')).strip()
self.assertEqual(expected_guard, actual_guard)
self.assertIn('任一稳定字段冲突', raw)
self.assertIn('menu_name NOT NULL', raw)
self.assertIn('中止整条语句', raw)
self.assertIn('人工解决冲突', raw)
self.assertIn('不会静默覆盖未知 ID', raw)
verification_statement = statements[-1].replace('`', '').lower()
verification = re.fullmatch(
r'\s*select\s+(?P<columns>.*?)\s+from\s+sys_menu\s+'
r'where\s+menu_id\s+in\s*\((?P<ids>[^)]+)\)\s*',
verification_statement,
flags=re.S,
)
self.assertIsNotNone(verification, 'patch must end with a manual business menu verification query')
verification_columns = tuple(column.strip() for column in verification.group('columns').split(','))
self.assertEqual(('menu_id', *MYSQL_MENU_STABLE_COLUMNS), verification_columns)
verification_ids = [int(value.strip()) for value in verification.group('ids').split(',')]
self.assertEqual(len(BUSINESS_MENU_IDS), len(verification_ids))
self.assertEqual(BUSINESS_MENU_IDS, set(verification_ids))
self.assertRegex(raw, r'查询结果必须为\s*64')
normalized = [_normalized_sql_statement(statement) for statement in statements]
marker_indexes = {
'save': normalized.index(MYSQL_MENU_SQL_MODE_SAVE),
'enforce': normalized.index(MYSQL_MENU_SQL_MODE_ENFORCE),
'insert': next(
index
for index, statement in enumerate(normalized)
if statement.startswith('insert into sys_menu values ')
),
'restore': normalized.index(MYSQL_MENU_SQL_MODE_RESTORE),
'verification': len(statements) - 1,
}
mutations = {
f'missing_{name}': [
statement for index, statement in enumerate(statements) if index != marker_indexes[name]
]
for name in ('save', 'enforce', 'restore')
}
strict_after_insert = statements.copy()
strict_after_insert[marker_indexes['enforce']], strict_after_insert[marker_indexes['insert']] = (
strict_after_insert[marker_indexes['insert']],
strict_after_insert[marker_indexes['enforce']],
)
mutations['strict_after_insert'] = strict_after_insert
restore_after_verification = statements.copy()
(
restore_after_verification[marker_indexes['restore']],
restore_after_verification[marker_indexes['verification']],
) = (
restore_after_verification[marker_indexes['verification']],
restore_after_verification[marker_indexes['restore']],
)
mutations['restore_after_verification'] = restore_after_verification
for name, mutation in mutations.items():
with self.subTest(mutation=name):
self.assertFalse(_mysql_menu_seed_has_safe_sql_mode_order(mutation))
def test_patch_preflights_new_menu_ids_before_atomic_permanent_dml(self) -> None:
raw = PATCH_PATH.read_text(encoding='utf-8')
statements = _sql_statements(raw)
normalized = [_normalized_sql_statement(statement) for statement in statements]
expected_table = 'pallet_workflow_menu_seed_expected'
conflict_guard = 'pallet_workflow_menu_seed_guard'
def statement_indexes(pattern: str) -> list[int]:
return [index for index, statement in enumerate(normalized) if re.fullmatch(pattern, statement, flags=re.S)]
expected_initial_drops = statement_indexes(rf'drop temporary table if exists {expected_table}')
expected_creates = statement_indexes(rf'create temporary table {expected_table}\s*\(.*\)')
expected_inserts = statement_indexes(rf'insert into {expected_table}\s*\(.*')
guard_initial_drops = statement_indexes(rf'drop temporary table if exists {conflict_guard}')
guard_creates = statement_indexes(rf'create temporary table {conflict_guard}\s*\(.*\)')
guard_inserts = statement_indexes(rf'insert into {conflict_guard}\s*\(.*')
guard_final_drops = statement_indexes(rf'drop temporary table {conflict_guard}')
expected_final_drops = statement_indexes(rf'drop temporary table {expected_table}')
markers = {
'expected table initial cleanup': expected_initial_drops,
'expected table creation': expected_creates,
'expected seed insertion': expected_inserts,
'conflict guard initial cleanup': guard_initial_drops,
'conflict guard creation': guard_creates,
'conflict guard check': guard_inserts,
'conflict guard success cleanup': guard_final_drops,
'expected table success cleanup': expected_final_drops,
}
for name, indexes in markers.items():
with self.subTest(marker=name):
self.assertEqual(1, len(indexes), f'missing or duplicate {name}')
legacy_guard_cleanup = normalized.index('drop temporary table pallet_workflow_legacy_seed_guard')
permanent_deletes = [
index
for index, statement in enumerate(normalized)
if re.match(r'delete from sys_(?:role_menu|menu|dict_data|dict_type)\b', statement)
]
self.assertTrue(permanent_deletes)
first_permanent_delete = permanent_deletes[0]
ordered_markers = (
legacy_guard_cleanup,
expected_initial_drops[0],
expected_creates[0],
expected_inserts[0],
guard_initial_drops[0],
guard_creates[0],
guard_inserts[0],
guard_final_drops[0],
expected_final_drops[0],
first_permanent_delete,
)
self.assertEqual(tuple(sorted(ordered_markers)), ordered_markers)
expected_insert = statements[expected_inserts[0]].replace('`', '').lower()
expected_insert_match = re.fullmatch(
rf'\s*insert\s+into\s+{expected_table}\s*\((?P<columns>[^)]+)\)\s+values\s+.*',
expected_insert,
flags=re.S,
)
self.assertIsNotNone(expected_insert_match)
expected_columns = tuple(column.strip() for column in expected_insert_match.group('columns').split(','))
self.assertEqual(('menu_id', *MYSQL_MENU_STABLE_COLUMNS), expected_columns)
expected_rows = _insert_rows(statements[expected_inserts[0]], expected_table)
self.assertEqual(len(BUSINESS_MENU_IDS), len(expected_rows))
expected_rows_by_id = {int(row[0]): tuple(row) for row in expected_rows}
self.assertEqual(BUSINESS_MENU_IDS, set(expected_rows_by_id))
seed_rows_by_id = {
menu_id: tuple(row[: len(expected_columns)]) for menu_id, row in _business_menu_rows(raw).items()
}
self.assertEqual(seed_rows_by_id, expected_rows_by_id)
guard_create = normalized[guard_creates[0]]
self.assertRegex(guard_create, r'primary key\s*\(\s*guard_token\s*\)')
guard_insert = normalized[guard_inserts[0]]
self.assertEqual(2, guard_insert.count("select 'business-menu-seed-conflict'"))
self.assertIn(
"union all select 'business-menu-seed-conflict' as guard_token where exists (",
guard_insert,
)
self.assertNotIn(' on duplicate key update ', guard_insert)
expected_comparisons = ' and '.join(
f'actual.{column} <=> expected.{column}' for column in MYSQL_MENU_STABLE_COLUMNS
)
guard_insert = re.sub(r'\(\s+', '(', guard_insert)
guard_insert = re.sub(r'\s+\)', ')', guard_insert)
self.assertIn(
f'from sys_menu as actual join {expected_table} as expected '
f'on actual.menu_id = expected.menu_id where not ({expected_comparisons})',
guard_insert,
)
preflight_sql = '; '.join(normalized[expected_creates[0] : first_permanent_delete])
self.assertNotRegex(
preflight_sql,
r'\b(?:insert\s+into|replace\s+into|update|delete\s+from)\s+sys_menu\b',
)
def test_patch_wraps_permanent_seed_dml_in_explicit_transaction(self) -> None:
statements = _sql_statements(PATCH_PATH.read_text(encoding='utf-8'))
normalized = [_normalized_sql_statement(statement) for statement in statements]
start_transactions = [index for index, statement in enumerate(normalized) if statement == 'start transaction']
commits = [index for index, statement in enumerate(normalized) if statement == 'commit']
self.assertEqual(1, len(start_transactions), 'permanent seed DML must start one explicit transaction')
self.assertEqual(1, len(commits), 'permanent seed DML must commit one explicit transaction')
start_transaction = start_transactions[0]
commit = commits[0]
permanent_deletes = [
index
for index, statement in enumerate(normalized)
if re.match(r'delete from sys_(?:role_menu|menu|dict_data|dict_type)\b', statement)
]
self.assertTrue(permanent_deletes)
menu_seed_inserts = [
index for index, statement in enumerate(normalized) if statement.startswith('insert into sys_menu values ')
]
self.assertEqual(1, len(menu_seed_inserts))
menu_seed_insert = menu_seed_inserts[0]
ordered_markers = (
normalized.index(MYSQL_MENU_SQL_MODE_SAVE),
normalized.index(MYSQL_MENU_SQL_MODE_ENFORCE),
start_transaction,
permanent_deletes[0],
permanent_deletes[-1],
menu_seed_insert,
commit,
normalized.index(MYSQL_MENU_SQL_MODE_RESTORE),
)
self.assertEqual(tuple(sorted(ordered_markers)), ordered_markers)
permanent_mutations = [*permanent_deletes, menu_seed_insert]
self.assertTrue(all(start_transaction < index < commit for index in permanent_mutations))
self.assertTrue(
all(
index < start_transaction
for index, statement in enumerate(normalized)
if re.match(r'(?:create|drop) temporary table\b', statement)
)
)
transaction_sql = '; '.join(normalized[start_transaction + 1 : commit])
self.assertNotRegex(transaction_sql, r'\b(?:create|drop|alter|truncate)\b')
def test_patch_cleanup_is_explicit_and_ordered(self) -> None:
self.assertTrue(PATCH_PATH.is_file(), f'missing patch: {PATCH_PATH}')
sql = _strip_sql_comments(PATCH_PATH.read_text(encoding='utf-8')).replace('`', '').lower()
role_cleanup = re.search(r'delete\s+from\s+sys_role_menu\s+where\s+menu_id\s+in\s*\([^;]+\)', sql)
ai_menu_cleanup = re.search(r'delete\s+from\s+sys_menu\s+where\s+menu_id\s+in\s*\([^;]+\)', sql)
dict_data_cleanup = re.search(r'delete\s+from\s+sys_dict_data\s+where\s+[^;]+', sql)
dict_type_cleanup = re.search(r'delete\s+from\s+sys_dict_type\s+where\s+[^;]+', sql)
external_link_cleanup = re.search(r'delete\s+from\s+sys_menu\s+where\s+menu_id\s*=\s*99\b', sql)
for cleanup in (
role_cleanup,
ai_menu_cleanup,
dict_data_cleanup,
dict_type_cleanup,
external_link_cleanup,
):
self.assertIsNotNone(cleanup)
self.assertRegex(role_cleanup.group(), r'\b4\b.*\b99\b.*\b118\b.*\b119\b.*\b1061\b.*\b1064\b')
self.assertRegex(ai_menu_cleanup.group(), r'\b4\b.*\b118\b.*\b119\b.*\b1061\b.*\b1064\b')
self.assertRegex(dict_data_cleanup.group(), r'dict_code\s+between\s+33\s+and\s+69')
self.assertIn('ai_provider_type', dict_data_cleanup.group())
self.assertRegex(dict_type_cleanup.group(), r'dict_id\s*=\s*12')
self.assertIn('ai_provider_type', dict_type_cleanup.group())
self.assertLess(role_cleanup.start(), ai_menu_cleanup.start())
self.assertLess(ai_menu_cleanup.start(), dict_data_cleanup.start())
self.assertLess(dict_data_cleanup.start(), dict_type_cleanup.start())
self.assertLess(dict_type_cleanup.start(), external_link_cleanup.start())
self.assertNotRegex(sql, r'\bdelete\s+from\s+(?:[a-z0-9_]+\.)?(?:biz|ai)_[a-z0-9_]+\b')
def test_patch_cleanup_fails_closed_before_deleting_legacy_seed_ids(self) -> None:
sql = _strip_sql_comments(PATCH_PATH.read_text(encoding='utf-8')).replace('`', '').lower()
guard_create = re.search(
r'create\s+temporary\s+table\s+pallet_workflow_legacy_seed_guard\b',
sql,
)
guard_insert = re.search(
r'insert\s+into\s+pallet_workflow_legacy_seed_guard\b[\s\S]+?;',
sql,
)
guard_drops = list(
re.finditer(
r'drop\s+temporary\s+table(?:\s+if\s+exists)?\s+pallet_workflow_legacy_seed_guard\b',
sql,
)
)
guard_drop = guard_drops[-1] if guard_drops else None
role_cleanup = re.search(r'delete\s+from\s+sys_role_menu\b', sql)
for statement in (guard_create, guard_insert, guard_drop, role_cleanup):
self.assertIsNotNone(statement)
self.assertLess(guard_create.start(), guard_insert.start())
self.assertLess(guard_insert.start(), guard_drop.start())
self.assertLess(guard_drop.start(), role_cleanup.start())
guard_sql = guard_insert.group()
expected_menu_identities = (
(4, 'ai 管理', 0, 'ai', None, '', 'm'),
(99, '施尔丰仓库', 0, 'https://git.yujiangrubber.cn/niuniu/shierfeng', None, '', 'm'),
(118, '模型管理', 4, 'model', 'ai/model/index', 'ai:model:list', 'c'),
(119, 'ai 对话', 4, 'chat', 'ai/chat/index', 'ai:chat:list', 'c'),
(1061, '模型查询', 118, '#', '', 'ai:model:query', 'f'),
(1062, '模型新增', 118, '#', '', 'ai:model:add', 'f'),
(1063, '模型修改', 118, '#', '', 'ai:model:edit', 'f'),
(1064, '模型删除', 118, '#', '', 'ai:model:remove', 'f'),
)
for menu_id, name, parent_id, path, component, permission, menu_type in expected_menu_identities:
with self.subTest(menu_id=menu_id):
component_pattern = (
r'component\s*<=>\s*null' if component is None else rf"component\s*<=>\s*'{re.escape(component)}'"
)
identity_pattern = (
rf'menu_id\s*=\s*{menu_id}\b[\s\S]+?'
rf"menu_name\s*<=>\s*'{re.escape(name)}'[\s\S]+?"
rf'parent_id\s*<=>\s*{parent_id}\b[\s\S]+?'
rf"path\s*<=>\s*'{re.escape(path)}'[\s\S]+?"
rf'{component_pattern}[\s\S]+?'
rf"perms\s*<=>\s*'{re.escape(permission)}'[\s\S]+?"
rf"menu_type\s*<=>\s*'{menu_type}'"
)
self.assertRegex(guard_sql, identity_pattern)
self.assertRegex(
guard_sql,
r'from\s+sys_dict_data[\s\S]+?dict_code\s+between\s+33\s+and\s+69'
r"[\s\S]+?dict_type\s*<=>\s*'ai_provider_type'",
)
self.assertRegex(
guard_sql,
r'from\s+sys_dict_type[\s\S]+?dict_id\s*=\s*12\b'
r"[\s\S]+?dict_type\s*<=>\s*'ai_provider_type'",
)
dict_data_cleanup = re.search(r'delete\s+from\s+sys_dict_data\s+where\s+[^;]+', sql)
dict_type_cleanup = re.search(r'delete\s+from\s+sys_dict_type\s+where\s+[^;]+', sql)
self.assertRegex(
dict_data_cleanup.group(),
r'dict_code\s+between\s+33\s+and\s+69\s+and\s+dict_type\s*=\s*\'ai_provider_type\'',
)
self.assertRegex(
dict_type_cleanup.group(),
r'dict_id\s*=\s*12\s+and\s+dict_type\s*=\s*\'ai_provider_type\'',
)
if __name__ == '__main__':
unittest.main()