test_child_row_sync.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """子表按行同步:更新原行、新增缺行、只软删去掉的行。"""
  2. import unittest
  3. from datetime import datetime
  4. from sqlalchemy import Column, DateTime, Integer, SmallInteger, String, create_engine
  5. from sqlalchemy.orm import sessionmaker
  6. from app.core.database import Base
  7. from app.crud.child_row_sync import sync_child_rows
  8. class DemoChild(Base):
  9. __tablename__ = "demo_child_row"
  10. id = Column(String(64), primary_key=True)
  11. parent_id = Column(String(64), nullable=False)
  12. tenant_no = Column(String(64), nullable=False)
  13. item_code = Column(String(64))
  14. item_name = Column(String(128))
  15. order_num = Column(Integer)
  16. create_time = Column(DateTime)
  17. update_by = Column(String(64))
  18. update_time = Column(DateTime)
  19. del_flag = Column(SmallInteger, nullable=False, default=0)
  20. def _session():
  21. engine = create_engine("sqlite://")
  22. DemoChild.__table__.create(bind=engine)
  23. return sessionmaker(bind=engine)()
  24. def _row(**kwargs):
  25. now = datetime(2026, 10, 27, 9, 0, 0)
  26. data = dict(
  27. parent_id="parent-1",
  28. tenant_no="tenant-a",
  29. item_code="R-1",
  30. item_name="试剂",
  31. order_num=0,
  32. create_time=now,
  33. update_time=now,
  34. del_flag=0,
  35. )
  36. data.update(kwargs)
  37. return DemoChild(**data)
  38. def _sync(db, items):
  39. now = datetime(2026, 10, 27, 10, 0, 0)
  40. def build_row(row_id):
  41. return DemoChild(
  42. id=row_id,
  43. parent_id="parent-1",
  44. tenant_no="tenant-a",
  45. create_time=now,
  46. )
  47. def apply_row(row, item, idx, _now):
  48. row.item_code = item.get("item_code")
  49. row.item_name = item.get("item_name")
  50. row.order_num = idx
  51. return sync_child_rows(
  52. db,
  53. DemoChild,
  54. parent_attr="parent_id",
  55. parent_id="parent-1",
  56. tenant_no="tenant-a",
  57. items=items,
  58. user_id="user-1",
  59. now=now,
  60. build_row=build_row,
  61. apply_fields=apply_row,
  62. natural_key=lambda item: (item.get("item_code") or "").strip(),
  63. row_natural_key=lambda row: (row.item_code or "").strip(),
  64. )
  65. class TestChildRowSync(unittest.TestCase):
  66. def test_id_keeps_row_when_fields_change(self):
  67. db = _session()
  68. db.add(_row(id="row-keep", item_name="旧名称"))
  69. db.commit()
  70. saved = _sync(db, [{"id": "row-keep", "item_code": "R-2", "item_name": "新名称"}])
  71. self.assertEqual(saved[0].id, "row-keep")
  72. self.assertEqual(saved[0].item_name, "新名称")
  73. self.assertEqual(db.query(DemoChild).filter_by(del_flag=0).count(), 1)
  74. def test_natural_key_keeps_id_when_payload_omits_id(self):
  75. db = _session()
  76. db.add(_row(id="row-keep", item_code="R-1"))
  77. db.commit()
  78. saved = _sync(db, [{"item_code": "R-1", "item_name": "改名"}])
  79. self.assertEqual([row.id for row in saved], ["row-keep"])
  80. self.assertEqual(saved[0].item_name, "改名")
  81. def test_insert_update_and_soft_delete_removed(self):
  82. db = _session()
  83. db.add(_row(id="row-keep", item_code="R-1"))
  84. db.add(_row(id="row-drop", item_code="R-9", item_name="去掉"))
  85. db.commit()
  86. saved = _sync(
  87. db,
  88. [
  89. {"id": "row-keep", "item_code": "R-1", "item_name": "留下"},
  90. {"item_code": "R-NEW", "item_name": "新增"},
  91. ],
  92. )
  93. self.assertEqual(saved[0].id, "row-keep")
  94. self.assertNotEqual(saved[1].id, "row-drop")
  95. rows = {row.id: row for row in db.query(DemoChild).all()}
  96. self.assertEqual(rows["row-keep"].del_flag, 0)
  97. self.assertEqual(rows["row-drop"].del_flag, 1)
  98. self.assertEqual(rows[saved[1].id].del_flag, 0)
  99. def test_id_from_another_parent_is_rejected(self):
  100. db = _session()
  101. db.add(_row(id="row-other", parent_id="parent-2"))
  102. db.commit()
  103. with self.assertRaises(ValueError):
  104. _sync(db, [{"id": "row-other", "item_code": "R-1", "item_name": "占用"}])
  105. other = db.query(DemoChild).filter_by(id="row-other").one()
  106. self.assertEqual(other.parent_id, "parent-2")
  107. self.assertEqual(other.del_flag, 0)
  108. def test_readded_business_key_restores_deleted_row(self):
  109. db = _session()
  110. db.add(_row(id="row-old", item_code="R-1", del_flag=1))
  111. db.commit()
  112. now = datetime(2026, 10, 27, 10, 0, 0)
  113. def build_row(row_id):
  114. return DemoChild(
  115. id=row_id,
  116. parent_id="parent-1",
  117. tenant_no="tenant-a",
  118. create_time=now,
  119. )
  120. def apply_row(row, item, idx, _now):
  121. row.item_code = item.get("item_code")
  122. row.item_name = item.get("item_name")
  123. row.order_num = idx
  124. saved = sync_child_rows(
  125. db,
  126. DemoChild,
  127. parent_attr="parent_id",
  128. parent_id="parent-1",
  129. tenant_no="tenant-a",
  130. items=[{"item_code": "R-1", "item_name": "恢复"}],
  131. user_id="user-1",
  132. now=now,
  133. build_row=build_row,
  134. apply_fields=apply_row,
  135. natural_key=lambda item: (item.get("item_code") or "").strip(),
  136. row_natural_key=lambda row: (row.item_code or "").strip(),
  137. reuse_deleted=True,
  138. )
  139. db.commit()
  140. self.assertEqual(saved[0].id, "row-old")
  141. self.assertEqual(saved[0].del_flag, 0)
  142. self.assertEqual(db.query(DemoChild).count(), 1)
  143. def test_removing_live_row_clears_older_tombstone(self):
  144. db = _session()
  145. db.add(_row(id="row-live", item_code="R-1"))
  146. db.add(_row(id="row-tomb", item_code="R-1", del_flag=1))
  147. db.commit()
  148. now = datetime(2026, 10, 27, 10, 0, 0)
  149. sync_child_rows(
  150. db,
  151. DemoChild,
  152. parent_attr="parent_id",
  153. parent_id="parent-1",
  154. tenant_no="tenant-a",
  155. items=[],
  156. user_id="user-1",
  157. now=now,
  158. build_row=lambda row_id: DemoChild(
  159. id=row_id, parent_id="parent-1", tenant_no="tenant-a", create_time=now
  160. ),
  161. apply_fields=lambda row, item, idx, _now: None,
  162. natural_key=lambda item: (item.get("item_code") or "").strip(),
  163. row_natural_key=lambda row: (row.item_code or "").strip(),
  164. reuse_deleted=True,
  165. )
  166. db.commit()
  167. self.assertIsNone(db.query(DemoChild).filter_by(id="row-tomb").first())
  168. live = db.query(DemoChild).filter_by(id="row-live").one()
  169. self.assertEqual(live.del_flag, 1)
  170. if __name__ == "__main__":
  171. unittest.main()