diff --git a/src/jsonlt/_keys.py b/src/jsonlt/_keys.py index b9c53ca..534bd54 100644 --- a/src/jsonlt/_keys.py +++ b/src/jsonlt/_keys.py @@ -8,8 +8,13 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, TypeAlias -from ._constants import MAX_INTEGER_KEY, MAX_TUPLE_ELEMENTS, MIN_INTEGER_KEY -from ._exceptions import InvalidKeyError +from ._constants import ( + MAX_INTEGER_KEY, + MAX_KEY_LENGTH, + MAX_TUPLE_ELEMENTS, + MIN_INTEGER_KEY, +) +from ._exceptions import InvalidKeyError, LimitError from ._json import utf8_byte_length if TYPE_CHECKING: @@ -343,3 +348,18 @@ def key_from_json(value: object) -> Key: return tuple(elements) msg = f"Cannot convert {type(value).__name__} to key" raise TypeError(msg) + + +def validate_key_length(key: Key) -> None: + """Validate that key length does not exceed the maximum. + + Args: + key: The key to validate. + + Raises: + LimitError: If key length exceeds MAX_KEY_LENGTH (1024 bytes). + """ + key_len = key_length(key) + if key_len > MAX_KEY_LENGTH: + msg = f"key length {key_len} bytes exceeds maximum {MAX_KEY_LENGTH}" + raise LimitError(msg) diff --git a/src/jsonlt/_table.py b/src/jsonlt/_table.py index e8a952d..cb9f13d 100644 --- a/src/jsonlt/_table.py +++ b/src/jsonlt/_table.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, ClassVar from typing_extensions import override -from ._constants import MAX_KEY_LENGTH, MAX_RECORD_SIZE +from ._constants import MAX_RECORD_SIZE from ._encoding import validate_no_surrogates from ._exceptions import ( ConflictError, @@ -25,10 +25,10 @@ from ._keys import ( Key, KeySpecifier, - key_length, key_specifiers_match, normalize_key_specifier, validate_key_arity, + validate_key_length, ) from ._readable import ReadableMixin from ._reader import parse_table_content, read_table_file @@ -431,10 +431,7 @@ def put(self, record: "JSONObject") -> None: # Extract and validate key key = extract_key(record, key_specifier) - key_len = key_length(key) - if key_len > MAX_KEY_LENGTH: - msg = f"key length {key_len} bytes exceeds maximum {MAX_KEY_LENGTH}" - raise LimitError(msg) + validate_key_length(key) # Serialize record serialized = serialize_json(record) @@ -528,6 +525,9 @@ def delete(self, key: Key) -> bool: # Validate key arity matches specifier validate_key_arity(key, key_specifier) + # Validate key length + validate_key_length(key) + # Build tombstone tombstone = build_tombstone(key, key_specifier) serialized = serialize_json(tombstone) @@ -666,13 +666,12 @@ def _end_transaction(self) -> None: """ self._active_transaction = None - def _commit_transaction_buffer( # noqa: PLR0913 + def _commit_transaction_buffer( self, lines: list[str], start_state: "dict[Key, JSONObject]", written_keys: set[Key], buffer_updates: "dict[Key, JSONObject | None]", - start_mtime: float, start_size: int, *, _retries: int = 0, @@ -687,7 +686,6 @@ def _commit_transaction_buffer( # noqa: PLR0913 start_state: Snapshot of table state when transaction started. written_keys: Keys that were modified in the transaction. buffer_updates: Map of key -> record (or None for delete). - start_mtime: File mtime when transaction started. start_size: File size when transaction started. _retries: Internal retry counter (do not pass externally). @@ -743,7 +741,6 @@ def _commit_transaction_buffer( # noqa: PLR0913 start_state, written_keys, buffer_updates, - start_mtime, start_size, _retries=_retries + 1, ) diff --git a/src/jsonlt/_transaction.py b/src/jsonlt/_transaction.py index 100e289..0620628 100644 --- a/src/jsonlt/_transaction.py +++ b/src/jsonlt/_transaction.py @@ -9,11 +9,11 @@ from typing import TYPE_CHECKING, ClassVar from typing_extensions import override -from ._constants import MAX_KEY_LENGTH, MAX_RECORD_SIZE +from ._constants import MAX_RECORD_SIZE from ._encoding import validate_no_surrogates from ._exceptions import LimitError, TransactionError from ._json import serialize_json, utf8_byte_length -from ._keys import Key, KeySpecifier, key_length, validate_key_arity +from ._keys import Key, KeySpecifier, validate_key_arity, validate_key_length from ._readable import ReadableMixin from ._records import build_tombstone, extract_key, validate_record @@ -52,6 +52,7 @@ class Transaction(ReadableMixin): """ __slots__: ClassVar[tuple[str, ...]] = ( + "_buffer_serialized", "_buffer_updates", "_cached_sorted_keys", "_file_mtime", @@ -69,6 +70,7 @@ class Transaction(ReadableMixin): _snapshot: "dict[Key, JSONObject]" _start_state: "dict[Key, JSONObject]" _buffer_updates: "dict[Key, JSONObject | None]" + _buffer_serialized: "dict[Key, str]" _written_keys: set[Key] _finalized: bool _file_mtime: float @@ -99,6 +101,7 @@ def __init__( # reloaded state. Safe because _start_state values are never modified. self._start_state = state.copy() self._buffer_updates = {} + self._buffer_serialized = {} self._written_keys = set() self._finalized = False # Cache file stats for skip-reload optimization at commit time @@ -154,18 +157,18 @@ def put(self, record: "JSONObject") -> None: # Extract and validate key key = extract_key(record, self._key_specifier) - key_len = key_length(key) - if key_len > MAX_KEY_LENGTH: - msg = f"key length {key_len} bytes exceeds maximum {MAX_KEY_LENGTH}" - raise LimitError(msg) + validate_key_length(key) - # Serialize record to check size limit (we don't store the serialized form) + # Serialize record to check size limit and cache for commit serialized = serialize_json(record) record_bytes = utf8_byte_length(serialized) if record_bytes > MAX_RECORD_SIZE: msg = f"record size {record_bytes} bytes exceeds maximum {MAX_RECORD_SIZE}" raise LimitError(msg) + # Cache serialized form before deep copy (record hasn't been modified) + self._buffer_serialized[key] = serialized + # Buffer the update (only keep latest value per key) record_copy = copy.deepcopy(record) self._buffer_updates[key] = record_copy @@ -196,11 +199,15 @@ def delete(self, key: Key) -> bool: # Validate key arity matches specifier validate_key_arity(key, self._key_specifier) + # Validate key length + validate_key_length(key) + # Check if key exists in snapshot existed = key in self._snapshot # Buffer the delete (only keep latest state per key) self._buffer_updates[key] = None + _ = self._buffer_serialized.pop(key, None) self._written_keys.add(key) # Update snapshot @@ -239,8 +246,8 @@ def commit(self) -> None: tombstone = build_tombstone(key, self._key_specifier) lines.append(serialize_json(tombstone)) else: - # Record (put) - lines.append(serialize_json(value)) + # Record (put) - use cached serialization from put() + lines.append(self._buffer_serialized[key]) # Commit via table (handles locking and conflict detection) # Transaction is a friend class of Table - protected access is intentional @@ -249,7 +256,6 @@ def commit(self) -> None: self._start_state, self._written_keys, self._buffer_updates, - self._file_mtime, self._file_size, ) finally: diff --git a/tests/unit/test_table.py b/tests/unit/test_table.py index 4b5cd51..dea366a 100644 --- a/tests/unit/test_table.py +++ b/tests/unit/test_table.py @@ -591,6 +591,17 @@ def test_delete_tuple_key_arity_mismatch_raises(self, tmp_path: "Path") -> None: with pytest.raises(InvalidKeyError, match="key arity mismatch"): _ = table.delete(("acme", 1, "extra")) # 3 elements, specifier has 2 + def test_delete_key_length_limit_raises(self, tmp_path: "Path") -> None: + """Delete with key exceeding 1024 bytes raises LimitError.""" + table_path = tmp_path / "test.jsonlt" + table = Table(table_path, key="id") + + # 1030 characters + quotes = 1032 bytes > 1024 + long_key = "x" * 1030 + + with pytest.raises(LimitError, match="key length"): + _ = table.delete(long_key) + class TestTableClear: def test_clear_removes_all_records(self, tmp_path: "Path") -> None: diff --git a/tests/unit/test_transaction.py b/tests/unit/test_transaction.py index 0505947..878ca00 100644 --- a/tests/unit/test_transaction.py +++ b/tests/unit/test_transaction.py @@ -276,6 +276,20 @@ def test_put_record_size_limit_raises(self, tmp_path: "Path") -> None: ): tx.put({"id": "test", "data": large_data}) + def test_delete_key_length_limit_raises(self, tmp_path: "Path") -> None: + """Delete with key exceeding 1024 bytes raises LimitError.""" + table_path = tmp_path / "test.jsonlt" + table = Table(table_path, key="id") + + # 1030 characters + quotes = 1032 bytes > 1024 + long_key = "x" * 1030 + + with ( + table.transaction() as tx, + pytest.raises(LimitError, match="key length"), + ): + _ = tx.delete(long_key) + class TestTransactionCommit: def test_commit_persists_writes(self, tmp_path: "Path") -> None: