Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ 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`)
- Value-based equality for `Table` and `Transaction` (`==` compares path, key specifier, and records)

## [0.1.0] - 2025-12-31

Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions src/jsonlt/_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
28 changes: 28 additions & 0 deletions src/jsonlt/_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
78 changes: 78 additions & 0 deletions tests/unit/test_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
122 changes: 122 additions & 0 deletions tests/unit/test_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()