"""Tests for bitable service layer (U2): upsert, field deletion, view filtering. Requires PostgreSQL — marked ``postgres``. """ from __future__ import annotations import pytest from agentkit.bitable.models import FieldOwner, FieldType from agentkit.bitable.service import FieldDependencyError pytestmark = pytest.mark.postgres # --------------------------------------------------------------------------- # Upsert (KTD8: jsonb_set preserves user columns) # --------------------------------------------------------------------------- async def test_upsert_inserts_new_records(bitable_service) -> None: """First upsert inserts all records.""" table = await bitable_service.create_table(name="T") pk_field = await bitable_service.create_field( table_id=table.id, name="id", field_type=FieldType.text, owner=FieldOwner.agent ) data_field = await bitable_service.create_field( table_id=table.id, name="data", field_type=FieldType.text, owner=FieldOwner.agent ) await bitable_service.update_table(table.id, primary_key_field_id=pk_field.id) result = await bitable_service.upsert_records( table.id, [ {pk_field.id: "row1", data_field.id: "hello"}, {pk_field.id: "row2", data_field.id: "world"}, ], pk_field.id, ) assert result == {"inserted": 2, "updated": 0, "skipped": 0} records, _ = await bitable_service.list_records(table.id) assert len(records) == 2 async def test_upsert_updates_existing_preserves_user_columns(bitable_service) -> None: """KTD8: upsert updates agent columns via jsonb_set, user columns untouched.""" table = await bitable_service.create_table(name="T") pk_field = await bitable_service.create_field( table_id=table.id, name="id", field_type=FieldType.text, owner=FieldOwner.agent ) agent_field = await bitable_service.create_field( table_id=table.id, name="agent_data", field_type=FieldType.text, owner=FieldOwner.agent ) user_field = await bitable_service.create_field( table_id=table.id, name="user_data", field_type=FieldType.text, owner=FieldOwner.user ) await bitable_service.update_table(table.id, primary_key_field_id=pk_field.id) # First: insert with both agent and user values await bitable_service.upsert_records( table.id, [{pk_field.id: "row1", agent_field.id: "agent_v1", user_field.id: "user_v1"}], pk_field.id, ) # Manually set user column (simulating user edit) records, _ = await bitable_service.list_records(table.id) assert len(records) == 1 rec = records[0] await bitable_service.update_record_values(rec.id, {**rec.values, user_field.id: "USER_EDITED"}) # Second upsert: only agent column changes result = await bitable_service.upsert_records( table.id, [{pk_field.id: "row1", agent_field.id: "agent_v2", user_field.id: "SHOULD_NOT_APPLY"}], pk_field.id, ) assert result == {"inserted": 0, "updated": 1, "skipped": 0} # Verify: agent column updated, user column preserved records, _ = await bitable_service.list_records(table.id) assert len(records) == 1 rec = records[0] assert rec.values[agent_field.id] == "agent_v2" # updated assert rec.values[user_field.id] == "USER_EDITED" # preserved (NOT "SHOULD_NOT_APPLY") async def test_upsert_skips_records_without_pk(bitable_service) -> None: """Records without PK value are skipped.""" table = await bitable_service.create_table(name="T") pk_field = await bitable_service.create_field( table_id=table.id, name="id", field_type=FieldType.text, owner=FieldOwner.agent ) await bitable_service.update_table(table.id, primary_key_field_id=pk_field.id) result = await bitable_service.upsert_records( table.id, [{pk_field.id: "row1"}, {}], # second has no PK pk_field.id, ) assert result == {"inserted": 1, "updated": 0, "skipped": 1} async def test_upsert_empty_batch(bitable_service) -> None: """Empty batch returns all zeros.""" table = await bitable_service.create_table(name="T") pk_field = await bitable_service.create_field( table_id=table.id, name="id", field_type=FieldType.text, owner=FieldOwner.agent ) result = await bitable_service.upsert_records(table.id, [], pk_field.id) assert result == {"inserted": 0, "updated": 0, "skipped": 0} async def test_upsert_without_pk_field_raises(bitable_service) -> None: """Upsert without primary_key_field_id raises ValueError.""" table = await bitable_service.create_table(name="T") with pytest.raises(ValueError, match="primary_key_field_id"): await bitable_service.upsert_records(table.id, [{}], "") # --------------------------------------------------------------------------- # Field deletion with dependency checking # --------------------------------------------------------------------------- async def test_delete_field_no_dependencies(bitable_service) -> None: """Deleting a field with no dependencies succeeds.""" table = await bitable_service.create_table(name="T") field = await bitable_service.create_field( table_id=table.id, name="f", field_type=FieldType.text ) deleted = await bitable_service.delete_field(field.id) assert deleted is True assert await bitable_service.get_field(field.id) is None async def test_delete_field_referenced_by_formula_returns_deps(bitable_service) -> None: """Deleting a field referenced by a formula raises FieldDependencyError.""" table = await bitable_service.create_table(name="T") source_field = await bitable_service.create_field( table_id=table.id, name="source", field_type=FieldType.number ) formula_field = await bitable_service.create_field( table_id=table.id, name="calc", field_type=FieldType.formula, config={"formula_expr": f"={source_field.id} * 2"}, ) with pytest.raises(FieldDependencyError) as exc_info: await bitable_service.delete_field(source_field.id) deps = exc_info.value.dependencies assert "formula_fields" in deps assert any(f["id"] == formula_field.id for f in deps["formula_fields"]) async def test_delete_primary_key_field_returns_deps(bitable_service) -> None: """Deleting the primary key field raises FieldDependencyError.""" table = await bitable_service.create_table(name="T") pk_field = await bitable_service.create_field( table_id=table.id, name="id", field_type=FieldType.text ) await bitable_service.update_table(table.id, primary_key_field_id=pk_field.id) with pytest.raises(FieldDependencyError) as exc_info: await bitable_service.delete_field(pk_field.id) assert exc_info.value.dependencies.get("is_primary_key") is True async def test_delete_field_force_casces_cleanup(bitable_service) -> None: """Force delete cascades: removes field from records, marks formula as error.""" table = await bitable_service.create_table(name="T") source_field = await bitable_service.create_field( table_id=table.id, name="source", field_type=FieldType.number, owner=FieldOwner.agent ) formula_field = await bitable_service.create_field( table_id=table.id, name="calc", field_type=FieldType.formula, config={"formula_expr": f"={source_field.id} * 2"}, ) # Create a record with the source field value record = await bitable_service.create_record(table_id=table.id, values={source_field.id: 42}) # Force delete deleted = await bitable_service.delete_field(source_field.id, force=True) assert deleted is True # Record should no longer have the source field key rec = await bitable_service.get_record(record.id) assert rec is not None assert source_field.id not in rec.values # Formula field should have error in config formula = await bitable_service.get_field(formula_field.id) assert formula is not None assert "error" in formula.config # --------------------------------------------------------------------------- # View-filtered record listing # --------------------------------------------------------------------------- async def test_list_records_filtered_by_number_gt(bitable_service) -> None: """View filter with gt op on number field correctly filters (CAST NUMERIC).""" table = await bitable_service.create_table(name="T") num_field = await bitable_service.create_field( table_id=table.id, name="amount", field_type=FieldType.number, owner=FieldOwner.agent ) # Create records with various amounts for amt in [10, 50, 100, 200]: await bitable_service.create_record(table_id=table.id, values={num_field.id: amt}) # Filter: amount > 50 records, _ = await bitable_service.list_records_filtered( table.id, filters=[{"field_id": num_field.id, "op": "gt", "value": 50}], ) amounts = [r.values[num_field.id] for r in records] assert all(a > 50 for a in amounts) assert len(records) == 2 # 100 and 200 async def test_list_records_filtered_by_text_eq(bitable_service) -> None: """View filter with eq op on text field.""" table = await bitable_service.create_table(name="T") text_field = await bitable_service.create_field( table_id=table.id, name="status", field_type=FieldType.text, owner=FieldOwner.agent ) for status in ["open", "closed", "open", "pending"]: await bitable_service.create_record(table_id=table.id, values={text_field.id: status}) records, _ = await bitable_service.list_records_filtered( table.id, filters=[{"field_id": text_field.id, "op": "eq", "value": "open"}], ) assert len(records) == 2 assert all(r.values[text_field.id] == "open" for r in records) async def test_list_records_filtered_with_sort(bitable_service) -> None: """View sort by number field descending.""" table = await bitable_service.create_table(name="T") num_field = await bitable_service.create_field( table_id=table.id, name="score", field_type=FieldType.number, owner=FieldOwner.agent ) for score in [30, 10, 50, 20]: await bitable_service.create_record(table_id=table.id, values={num_field.id: score}) records, _ = await bitable_service.list_records_filtered( table.id, sorts=[{"field_id": num_field.id, "direction": "desc"}], ) # Records should be sorted by score descending (as text, but single/double digit sorts OK) assert len(records) == 4 async def test_list_records_filtered_cursor_pagination(bitable_service) -> None: """Cursor pagination with filters.""" table = await bitable_service.create_table(name="T") text_field = await bitable_service.create_field( table_id=table.id, name="name", field_type=FieldType.text, owner=FieldOwner.agent ) for i in range(5): await bitable_service.create_record(table_id=table.id, values={text_field.id: f"item_{i}"}) # First page records, next_cursor = await bitable_service.list_records_filtered(table.id, limit=2) assert len(records) == 2 assert next_cursor is not None # Second page records2, next_cursor2 = await bitable_service.list_records_filtered( table.id, cursor=next_cursor, limit=2 ) assert len(records2) == 2 assert next_cursor2 is not None # Third page records3, next_cursor3 = await bitable_service.list_records_filtered( table.id, cursor=next_cursor2, limit=2 ) assert len(records3) == 1 assert next_cursor3 is None # All records unique all_ids = {r.id for r in [records, records2, records3] for r in r} assert len(all_ids) == 5