From 5f4885eda8f8dc57cf83b8fe7e75b2d97282874b Mon Sep 17 00:00:00 2001 From: Tony Burns Date: Fri, 2 Jan 2026 01:35:13 -0500 Subject: [PATCH 1/2] docs: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a73d724..6e00e38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [PEP 440](https://peps.python.org/pep-0440/). ### Added +- `Table.from_records()` and `Table.from_file()` factory methods for convenient table initialization - Dictionary-like access for `Table` and `Transaction` (`table[key]`, `table[key] = record`, `del table[key]`, `pop`, `popitem`, `setdefault`, `update`) ## [0.1.0] - 2025-12-31 From ae3537a29f24c1fdf5298881b8ee38c0f52a9f89 Mon Sep 17 00:00:00 2001 From: Tony Burns Date: Fri, 2 Jan 2026 15:14:00 -0500 Subject: [PATCH 2/2] feat: add equality and hash methods for Table and Transaction Implement __eq__ and __hash__ for both Table and Transaction classes: - Table: value-based equality comparing resolved path, key_specifier, and record state. Auto-reloads both tables before comparison. - Transaction: equality based on parent table identity (is), finalized status, and snapshot state. - Both classes raise TypeError on hash (mutable objects). Includes comprehensive tests and documentation updates. --- CHANGELOG.md | 1 + README.md | 17 +++++ src/jsonlt/_table.py | 30 ++++++++ src/jsonlt/_transaction.py | 28 ++++++++ tests/unit/test_table.py | 78 +++++++++++++++++++++ tests/unit/test_transaction.py | 122 +++++++++++++++++++++++++++++++++ 6 files changed, 276 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e00e38..e77c6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [PEP 440](https://peps.python.org/pep-0440/). - `Table.from_records()` and `Table.from_file()` factory methods for convenient table initialization - Dictionary-like access for `Table` and `Transaction` (`table[key]`, `table[key] = record`, `del table[key]`, `pop`, `popitem`, `setdefault`, `update`) +- Value-based equality for `Table` and `Transaction` (`==` compares path, key specifier, and records) ## [0.1.0] - 2025-12-31 diff --git a/README.md b/README.md index 9b9658f..3f09e67 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,23 @@ print(len(table)) Methods like `pop()`, `setdefault()`, and `update()` also work. The `keys()`, `values()`, and `items()` methods return sorted lists rather than views to maintain JSONLT's deterministic key ordering. +## Equality + +Tables support value-based equality comparison: + +```python +table1 = Table("users.jsonlt", key="id") +table2 = Table("users.jsonlt", key="id") + +# Equal if same path, key specifier, and records +if table1 == table2: + print("Tables have identical content") +``` + +Two tables are equal when they have the same resolved path, key specifier, and record state. Transactions are equal when they reference the same parent table instance and have identical snapshot state. + +Tables and transactions are mutable and therefore not hashable (cannot be used as dictionary keys or in sets). + ## Finding records ```python diff --git a/src/jsonlt/_table.py b/src/jsonlt/_table.py index f9a0eeb..c3b2011 100644 --- a/src/jsonlt/_table.py +++ b/src/jsonlt/_table.py @@ -976,3 +976,33 @@ def _apply_buffer_updates( def __repr__(self) -> str: """Return a string representation of the table.""" return f"Table({self._path!r}, key={self._key_specifier!r})" + + @override + def __eq__(self, other: object) -> bool: + """Value equality based on path, key_specifier, and current state. + + Two tables are equal if they have the same resolved path, key specifier, + and identical record state. Triggers auto-reload on self before comparison + to ensure current disk state is reflected. + + Args: + other: Object to compare with. + + Returns: + True if equal, False otherwise. Returns NotImplemented for non-Tables. + """ + if not isinstance(other, Table): + return NotImplemented + self._maybe_reload() + other._maybe_reload() + return ( + self._path.resolve() == other._path.resolve() + and self._key_specifier == other._key_specifier + and self._state == other._state + ) + + @override + def __hash__(self) -> int: + """Table is mutable and not hashable.""" + msg = f"unhashable type: '{type(self).__name__}'" + raise TypeError(msg) diff --git a/src/jsonlt/_transaction.py b/src/jsonlt/_transaction.py index 859a2bc..5f55928 100644 --- a/src/jsonlt/_transaction.py +++ b/src/jsonlt/_transaction.py @@ -317,3 +317,31 @@ def __repr__(self) -> str: return ( f"Transaction({self._table._path!r}, key={self._key_specifier!r}, {status})" # pyright: ignore[reportPrivateUsage] # noqa: SLF001 ) + + @override + def __eq__(self, other: object) -> bool: + """Equality based on parent table identity, finalized status, snapshot. + + Two transactions are equal if they reference the same parent Table + instance, have the same finalized status, and have identical snapshot + state (which includes any buffered writes). + + Args: + other: Object to compare with. + + Returns: + True if equal, False otherwise. Returns NotImplemented for others. + """ + if not isinstance(other, Transaction): + return NotImplemented + return ( + self._table is other._table + and self._finalized == other._finalized + and self._snapshot == other._snapshot + ) + + @override + def __hash__(self) -> int: + """Transaction is mutable and not hashable.""" + msg = f"unhashable type: '{type(self).__name__}'" + raise TypeError(msg) diff --git a/tests/unit/test_table.py b/tests/unit/test_table.py index fe9b42b..9579c0b 100644 --- a/tests/unit/test_table.py +++ b/tests/unit/test_table.py @@ -1449,3 +1449,81 @@ def test_update_with_none_is_noop(self, make_table: "Callable[..., Table]") -> N table.update(None) assert table.count() == 0 + + +class TestTableEquality: + def test_equal_tables_same_path_and_state(self, tmp_path: "Path") -> None: + table_path = tmp_path / "test.jsonlt" + _ = table_path.write_text('{"id": "alice", "v": 1}\n') + + table1 = Table(table_path, key="id") + table2 = Table(table_path, key="id") + + assert table1 == table2 + + def test_not_equal_different_paths(self, tmp_path: "Path") -> None: + path1 = tmp_path / "test1.jsonlt" + path2 = tmp_path / "test2.jsonlt" + _ = path1.write_text('{"id": "alice", "v": 1}\n') + _ = path2.write_text('{"id": "alice", "v": 1}\n') + + table1 = Table(path1, key="id") + table2 = Table(path2, key="id") + + assert table1 != table2 + + def test_not_equal_different_key_specifier(self, tmp_path: "Path") -> None: + table_path = tmp_path / "test.jsonlt" + _ = table_path.write_text('{"id": "alice", "name": "Alice"}\n') + + table1 = Table(table_path, key="id") + table2 = Table(table_path, key="name") + + assert table1 != table2 + + def test_not_equal_different_state(self, tmp_path: "Path") -> None: + path1 = tmp_path / "test1.jsonlt" + path2 = tmp_path / "test2.jsonlt" + _ = path1.write_text('{"id": "alice", "v": 1}\n') + _ = path2.write_text('{"id": "alice", "v": 2}\n') + + table1 = Table(path1, key="id") + table2 = Table(path2, key="id") + + assert table1 != table2 + + def test_equal_empty_tables(self, tmp_path: "Path") -> None: + table_path = tmp_path / "test.jsonlt" + + table1 = Table(table_path, key="id") + table2 = Table(table_path, key="id") + + assert table1 == table2 + + def test_eq_with_non_table_returns_false( + self, make_table: "Callable[..., Table]" + ) -> None: + table = make_table() + + result = table == "string" + + assert result is False + + def test_equal_relative_vs_absolute_path( + self, tmp_path: "Path", monkeypatch: "pytest.MonkeyPatch" + ) -> None: + table_path = tmp_path / "test.jsonlt" + _ = table_path.write_text('{"id": "alice", "v": 1}\n') + + # Change to tmp_path directory to create a relative path + monkeypatch.chdir(tmp_path) + table1 = Table(table_path, key="id") + table2 = Table("test.jsonlt", key="id") + + assert table1 == table2 + + def test_table_is_not_hashable(self, make_table: "Callable[..., Table]") -> None: + table = make_table() + + with pytest.raises(TypeError, match="unhashable type"): + _ = hash(table) diff --git a/tests/unit/test_transaction.py b/tests/unit/test_transaction.py index f7a9a7f..56af721 100644 --- a/tests/unit/test_transaction.py +++ b/tests/unit/test_transaction.py @@ -1302,3 +1302,125 @@ def test_update_with_none_is_noop(self, make_table: "Callable[..., Table]") -> N with table.transaction() as tx: tx.update(None) assert tx.count() == 0 + + +class TestTransactionEquality: + def test_equal_transactions_same_table_same_snapshot( + self, make_table: "Callable[..., Table]" + ) -> None: + table = make_table() + + # Since only one transaction can be active at a time per table, + # we verify equality by comparing a transaction to itself using + # a reference. This ensures __eq__ returns True for same instance. + tx = table.transaction() + try: + tx_ref = tx + assert tx == tx_ref + finally: + tx.abort() + + def test_not_equal_different_parent_tables(self, tmp_path: "Path") -> None: + path1 = tmp_path / "test1.jsonlt" + path2 = tmp_path / "test2.jsonlt" + _ = path1.write_text('{"id": "alice", "v": 1}\n') + _ = path2.write_text('{"id": "alice", "v": 1}\n') + + table1 = Table(path1, key="id") + table2 = Table(path2, key="id") + + tx1 = table1.transaction() + tx2 = table2.transaction() + try: + # Different table instances, so not equal + assert tx1 != tx2 + finally: + tx1.abort() + tx2.abort() + + def test_not_equal_different_buffered_writes(self, tmp_path: "Path") -> None: + path1 = tmp_path / "test1.jsonlt" + path2 = tmp_path / "test2.jsonlt" + _ = path1.write_text('{"id": "alice", "v": 1}\n') + _ = path2.write_text('{"id": "alice", "v": 1}\n') + + table1 = Table(path1, key="id") + table2 = Table(path2, key="id") + + tx1 = table1.transaction() + tx2 = table2.transaction() + try: + # Make different writes + tx1.put({"id": "bob", "v": 1}) + tx2.put({"id": "carol", "v": 1}) + + assert tx1 != tx2 + finally: + tx1.abort() + tx2.abort() + + def test_equal_with_same_buffered_writes(self, tmp_path: "Path") -> None: + path1 = tmp_path / "test1.jsonlt" + path2 = tmp_path / "test2.jsonlt" + _ = path1.write_text('{"id": "alice", "v": 1}\n') + _ = path2.write_text('{"id": "alice", "v": 1}\n') + + table1 = Table(path1, key="id") + table2 = Table(path2, key="id") + + tx1 = table1.transaction() + tx2 = table2.transaction() + try: + # Make identical writes + tx1.put({"id": "bob", "v": 2}) + tx2.put({"id": "bob", "v": 2}) + + # They still differ because they have different parent tables + assert tx1 != tx2 + finally: + tx1.abort() + tx2.abort() + + def test_eq_with_non_transaction_returns_false( + self, make_table: "Callable[..., Table]" + ) -> None: + table = make_table() + + tx = table.transaction() + try: + result = tx == "string" + assert result is False + finally: + tx.abort() + + def test_finalized_vs_active_not_equal(self, tmp_path: "Path") -> None: + path1 = tmp_path / "test1.jsonlt" + path2 = tmp_path / "test2.jsonlt" + _ = path1.write_text('{"id": "alice", "v": 1}\n') + _ = path2.write_text('{"id": "alice", "v": 1}\n') + + table1 = Table(path1, key="id") + table2 = Table(path2, key="id") + + tx1 = table1.transaction() + tx2 = table2.transaction() + + # Commit one, leave the other active + tx1.commit() + try: + # finalized vs active should not be equal + assert tx1 != tx2 + finally: + tx2.abort() + + def test_transaction_is_not_hashable( + self, make_table: "Callable[..., Table]" + ) -> None: + table = make_table() + + tx = table.transaction() + try: + with pytest.raises(TypeError, match="unhashable type"): + _ = hash(tx) + finally: + tx.abort()