规范了update data的构建方式,统一使用build_update_request_data方法来处理更新数据的构建和过滤逻辑。这个方法会根据提供的原始数据、更新数据和对应的schema来生成最终的更新请求数据,同时会自动排除掉未变化的字段和空值字段,从而确保只发送必要的更新信息到后端接口。这种方式不仅简化了代码逻辑,还提高了代码的可维护性和一致性。

This commit is contained in:
strepsiades
2026-03-30 14:51:01 +08:00
parent f50dc1aed0
commit bdc7bd5069
28 changed files with 416 additions and 129 deletions
@@ -193,6 +193,30 @@ async def test_auto_binding_skip_on_ambiguous_migrated(setup_env):
assert await bm.get_local_id("test_project", "r1") is None
@pytest.mark.asyncio
async def test_auto_binding_many_to_one_can_bind_stable_pick_and_leave_rest_for_create(setup_env):
local, remote, bm, _ = setup_env
strategy = TestProjectStrategy("test_project", local, remote, bm)
strategy.update_config(
auto_bind=True,
auto_bind_fields=["code"],
depend_fields={},
local_orphan_action=OrphanAction.CREATE_REMOTE,
allow_many_to_one_auto_bind=True,
)
await local.add(build_project_node("l2", "P2", name="B", code="AMB-2"))
await local.add(build_project_node("l1", "P1", name="A", code="AMB-2"))
await remote.add(build_project_node("r1", "RP1", name="A", code="AMB-2"))
await strategy.bind()
assert await bm.get_remote_id("test_project", "l1") == "r1"
assert await bm.get_remote_id("test_project", "l2") is None
assert await bm.get_local_id("test_project", "r1") == "l1"
@pytest.mark.asyncio
async def test_pre_bind_data_id_works_when_auto_bind_disabled(setup_env):
local, remote, bm, _ = setup_env
@@ -0,0 +1,94 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel
from sync_state_machine.datasource.api.handler import BaseApiHandler
from sync_state_machine.datasource.task_result import TaskResult
class _UpdateSchema(BaseModel):
id: str
required_value: float
optional_text: str | None = None
class _DummyApiHandler(BaseApiHandler[BaseModel]):
_node_type = "dummy"
_node_class = object # type: ignore[assignment]
_schema = BaseModel
async def create_all(self, nodes):
return [TaskResult.success(node_id="dummy")]
async def update_all(self, nodes):
return [TaskResult.success(node_id="dummy")]
async def delete_all(self, nodes):
return [TaskResult.success(node_id="dummy")]
async def load(self):
return []
def test_get_update_schema_diff_falls_back_to_schema_fields_when_origin_is_incomplete() -> None:
handler = _DummyApiHandler()
diff = handler.get_update_schema_diff(
_UpdateSchema,
{"id": "node-1", "required_value": 4.0, "optional_text": "ok"},
{"id": "node-1"},
node_id="node-1",
)
assert diff == {"required_value": 4.0, "optional_text": "ok"}
def test_build_update_request_data_uses_same_schema_projection_as_diff() -> None:
handler = _DummyApiHandler()
payload = handler.build_update_request_data(
{"id": "node-1", "required_value": 4.0, "optional_text": "ok", "ignored": "x"},
{"id": "node-1"},
schema=_UpdateSchema,
)
assert payload == {"id": "node-1", "required_value": 4.0, "optional_text": "ok"}
def test_schema_changed_fields_matches_update_payload_comparison() -> None:
handler = _DummyApiHandler()
changed_fields = handler._schema_changed_fields(
_UpdateSchema,
{"id": "node-1", "required_value": 4.0, "optional_text": "ok"},
{"id": "node-1", "required_value": 4.0},
)
assert changed_fields == ["optional_text"]
def test_update_schema_payload_filters_empty_values() -> None:
handler = _DummyApiHandler()
payload = handler._get_update_schema_payload(
_UpdateSchema,
{"id": "node-1", "required_value": 4.0, "optional_text": ""},
require_valid=False,
)
assert payload == {"id": "node-1", "required_value": 4.0}
def test_get_update_schema_diff_skips_schema_when_new_data_is_still_incomplete() -> None:
handler = _DummyApiHandler()
diff = handler.get_update_schema_diff(
_UpdateSchema,
{"id": "node-1"},
{"id": "node-1", "required_value": 2.0},
node_id="node-1",
)
assert diff is None
@@ -31,6 +31,7 @@ class _StubStrategy:
def __init__(self, node_type: str):
self.node_type = node_type
self.skip_sync = False
self.skip_post_check = False
self.config = SimpleNamespace(
depend_fields={},
update_direction=None,
@@ -193,4 +194,55 @@ async def test_evaluate_consistency_merges_compare_and_post_check_ignore_fields(
full_sync_pipeline_module.evaluate_consistency_for_node_type = original
assert captured["ignore_fields"] == ["updated_at", "code", "audit_status"]
assert result["ignore_fields"] == ["updated_at", "code", "audit_status"]
assert result["ignore_fields"] == ["updated_at", "code", "audit_status"]
@pytest.mark.asyncio
async def test_phase_post_check_skips_configured_node_types(monkeypatch: pytest.MonkeyPatch) -> None:
supplier_strategy = _StubStrategy("supplier")
supplier_strategy.skip_post_check = True
project_strategy = _StubStrategy("project")
pipeline = FullSyncPipeline(
local_datasource=_FakeDataSource(),
remote_datasource=_FakeDataSource(),
strategies=[supplier_strategy, project_strategy],
persistence=_FakePersistence(),
local_collection=DataCollection("local"),
remote_collection=DataCollection("remote"),
binding_manager=BindingManager(_FakePersistence(), auto_persist=False),
node_types=["supplier", "project"],
load_order=["supplier", "project"],
sync_order=["supplier", "project"],
enable_persistence=False,
)
reload_calls: list[str] = []
evaluated: list[str] = []
async def fake_reload(node_type: str, *, reason: str) -> None:
del reason
reload_calls.append(node_type)
async def fake_evaluate(node_type: str):
evaluated.append(node_type)
return {"ok": True, "checked_pairs": 1, "mismatch_count": 0}
async def fake_print_summary() -> None:
return None
monkeypatch.setattr(pipeline, "_reload_node_type", fake_reload)
monkeypatch.setattr(pipeline, "_evaluate_consistency_for_node_type", fake_evaluate)
monkeypatch.setattr(pipeline, "print_summary", fake_print_summary)
result = await pipeline.phase_post_check()
assert result is True
assert reload_calls == ["project"]
assert evaluated == ["project"]
assert pipeline._consistency_by_type["supplier"] == {
"ok": True,
"checked_pairs": 0,
"mismatch_count": 0,
"skipped": True,
}
+6 -1
View File
@@ -8,6 +8,7 @@ def test_strategy_runtime_config_skip_sync_defaults_false() -> None:
runtime_config = StrategyRuntimeConfig(node_type="project")
assert runtime_config.skip_sync is False
assert runtime_config.skip_post_check is False
def test_apply_strategy_overrides_allows_skip_sync_override() -> None:
@@ -16,6 +17,7 @@ def test_apply_strategy_overrides_allows_skip_sync_override() -> None:
strategy_overrides={
"project": {
"skip_sync": True,
"skip_post_check": True,
}
},
)
@@ -23,6 +25,7 @@ def test_apply_strategy_overrides_allows_skip_sync_override() -> None:
assert len(resolved) == 1
assert resolved[0].node_type == "project"
assert resolved[0].skip_sync is True
assert resolved[0].skip_post_check is True
def test_strategy_config_supports_both_post_check_ignore_fields() -> None:
@@ -30,8 +33,10 @@ def test_strategy_config_supports_both_post_check_ignore_fields() -> None:
{
"post_check_ignore_fields": ["code"],
"post_check_ignore_list_item_fields": {"plan_node": ["is_audit"]},
"allow_many_to_one_auto_bind": True,
}
)
assert config.post_check_ignore_fields == ["code"]
assert config.post_check_ignore_list_item_fields == {"plan_node": ["is_audit"]}
assert config.post_check_ignore_list_item_fields == {"plan_node": ["is_audit"]}
assert config.allow_many_to_one_auto_bind is True