mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-02 16:07:15 +00:00
* Feature: schema v2 -- document_chunks/document_meta side tables, document_id INTEGER, point-delete Rewrites the sqlite-vec vector store's on-disk schema: document_id becomes an INTEGER vec0 metadata column (was TEXT), modified moves out of vec0 into a new document_meta side table, and a new document_chunks side table gives O(1) per-document chunk lookup for delete/upsert instead of a full vec0 scan. compact() now streams document_chunks and document_meta across the file-swap rebuild too (previously document_meta would have gone silently empty after the first compaction). drop_table() clears both side tables. Adds the single frozen m0001_v1_to_v2 migration, converting a real, historically-shaped v1 store (the shape shipped since v3.0.0) into the v2 shape in one streaming pass, with its own hardcoded DDL rather than delegating to any "current schema" helper. SCHEMA_VERSION bumps 1 -> 2. * Fix: strengthen two vacuous Task 4 regression tests test_migration_never_delegates_to_current_schema_helpers never actually ran the migration (missing check_and_run_migrations() call) and its source-text assertion was tautological (the "or DROP TABLE in source" clause was always true). Now runs the real migration and asserts spy call counts instead: DocumentChunksTable.create/DocumentMetaTable.create are each called exactly 3 times (construction, rebuild temp file, post-swap reopen -- all via _open_connection, never from inside apply()), and _create_vec_table is never called from the migration path. test_drop_table_clears_modified_times asserted via get_modified_times(), which short-circuits on table_exists() -- checking only the vec0 table that drop_table() drops first -- so the assertion held even if DocumentMetaTable.delete_all() were never called. Now asserts directly against document_meta and document_chunks row counts. * Perf: dedupe table_exists() lookups, atomic insert counter, fewer connections in update_llm_index() * Fix: guard compact() against unmigrated stores, apply final-review cleanups compact() had no migration guard: on a v1-schema store, document_chunks reads 0 (freshly created empty) while total_inserts reflects the real cumulative count, so the bloat check nearly always rebuilt -- silently losing document_meta (copy_all reads from the empty v1 table) while schema_version copied across unchanged, leaving the store permanently unmigratable. compact() now calls has_pending_migration() and no-ops with a warning instead. Also folds in five minor final-review findings: drop _rebuild_into's unused int return, hoist test-local imports to module level in test_vector_store.py, note in TestMigrations' docstring that its fake structural migrations only exercise dispatch (not full schema correctness), restore the comment explaining why _row() requires document_id, and tighten increment_total_inserts' docstring to not imply general concurrency safety beyond its single atomic statement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
341 lines
11 KiB
Python
341 lines
11 KiB
Python
import sqlite3
|
|
from collections.abc import Generator
|
|
|
|
import pytest
|
|
from pytest_mock import MockerFixture
|
|
|
|
from paperless_ai.tables import ChunkRow
|
|
from paperless_ai.tables import DocumentChunksTable
|
|
from paperless_ai.tables import DocumentMetaRow
|
|
from paperless_ai.tables import DocumentMetaTable
|
|
from paperless_ai.tables import IndexMetaTable
|
|
|
|
|
|
@pytest.fixture
|
|
def conn() -> Generator[sqlite3.Connection, None, None]:
|
|
connection = sqlite3.connect(":memory:")
|
|
connection.row_factory = sqlite3.Row
|
|
try:
|
|
yield connection
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
class TestDocumentChunksTable:
|
|
def test_create_is_idempotent(self, conn: sqlite3.Connection) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A bare sqlite3 connection
|
|
WHEN:
|
|
- create() is called, a row is inserted, then create() is called again
|
|
THEN:
|
|
- No error is raised and the row survives uncorrupted
|
|
"""
|
|
DocumentChunksTable.create(conn)
|
|
DocumentChunksTable.insert_many(conn, [ChunkRow("c1", 1)])
|
|
DocumentChunksTable.create(conn)
|
|
assert DocumentChunksTable.chunk_ids_for_document(conn, 1) == ["c1"]
|
|
|
|
def test_insert_many_then_lookup_by_document_id(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An empty document_chunks table
|
|
WHEN:
|
|
- Two chunks for document 1 and one for document 2 are inserted
|
|
THEN:
|
|
- chunk_ids_for_document returns exactly the matching chunk ids
|
|
"""
|
|
DocumentChunksTable.create(conn)
|
|
DocumentChunksTable.insert_many(
|
|
conn,
|
|
[ChunkRow("c1", 1), ChunkRow("c2", 1), ChunkRow("c3", 2)],
|
|
)
|
|
assert sorted(DocumentChunksTable.chunk_ids_for_document(conn, 1)) == [
|
|
"c1",
|
|
"c2",
|
|
]
|
|
assert DocumentChunksTable.chunk_ids_for_document(conn, 2) == ["c3"]
|
|
assert DocumentChunksTable.chunk_ids_for_document(conn, 999) == []
|
|
|
|
def test_delete_for_document_removes_only_that_document(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Chunks for two different documents
|
|
WHEN:
|
|
- delete_for_document() is called for one of them
|
|
THEN:
|
|
- Only that document's chunks are removed
|
|
"""
|
|
DocumentChunksTable.create(conn)
|
|
DocumentChunksTable.insert_many(
|
|
conn,
|
|
[ChunkRow("c1", 1), ChunkRow("c2", 2)],
|
|
)
|
|
DocumentChunksTable.delete_for_document(conn, 1)
|
|
assert DocumentChunksTable.chunk_ids_for_document(conn, 1) == []
|
|
assert DocumentChunksTable.chunk_ids_for_document(conn, 2) == ["c2"]
|
|
|
|
def test_delete_all_clears_every_row(self, conn: sqlite3.Connection) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Chunks for multiple documents
|
|
WHEN:
|
|
- delete_all() is called
|
|
THEN:
|
|
- count() returns 0
|
|
"""
|
|
DocumentChunksTable.create(conn)
|
|
DocumentChunksTable.insert_many(
|
|
conn,
|
|
[ChunkRow("c1", 1), ChunkRow("c2", 2)],
|
|
)
|
|
DocumentChunksTable.delete_all(conn)
|
|
assert DocumentChunksTable.count(conn) == 0
|
|
|
|
def test_count_reflects_live_rows(self, conn: sqlite3.Connection) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An empty document_chunks table
|
|
WHEN:
|
|
- Rows are inserted then one document's rows are deleted
|
|
THEN:
|
|
- count() reflects the remaining row count
|
|
"""
|
|
DocumentChunksTable.create(conn)
|
|
DocumentChunksTable.insert_many(
|
|
conn,
|
|
[ChunkRow("c1", 1), ChunkRow("c2", 1), ChunkRow("c3", 2)],
|
|
)
|
|
assert DocumentChunksTable.count(conn) == 3
|
|
DocumentChunksTable.delete_for_document(conn, 1)
|
|
assert DocumentChunksTable.count(conn) == 1
|
|
|
|
|
|
class TestDocumentMetaTable:
|
|
def test_upsert_many_then_all_modified_times(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An empty document_meta table
|
|
WHEN:
|
|
- Two documents' modified timestamps are upserted
|
|
THEN:
|
|
- all_modified_times() returns both, keyed by str(document_id)
|
|
"""
|
|
DocumentMetaTable.create(conn)
|
|
DocumentMetaTable.upsert_many(
|
|
conn,
|
|
[
|
|
DocumentMetaRow(1, "2026-01-01T00:00:00"),
|
|
DocumentMetaRow(2, "2026-02-02T00:00:00"),
|
|
],
|
|
)
|
|
assert DocumentMetaTable.all_modified_times(conn) == {
|
|
"1": "2026-01-01T00:00:00",
|
|
"2": "2026-02-02T00:00:00",
|
|
}
|
|
|
|
def test_upsert_many_overwrites_existing_value(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document_meta row for document 1
|
|
WHEN:
|
|
- upsert_many() is called again with a new modified value for
|
|
the same document_id
|
|
THEN:
|
|
- The stored value is replaced, not duplicated
|
|
"""
|
|
DocumentMetaTable.create(conn)
|
|
DocumentMetaTable.upsert_many(conn, [DocumentMetaRow(1, "old")])
|
|
DocumentMetaTable.upsert_many(conn, [DocumentMetaRow(1, "new")])
|
|
assert DocumentMetaTable.all_modified_times(conn) == {"1": "new"}
|
|
|
|
def test_delete_for_document_removes_only_that_row(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- document_meta rows for two documents
|
|
WHEN:
|
|
- delete_for_document() is called for one of them
|
|
THEN:
|
|
- Only that document's row is removed
|
|
"""
|
|
DocumentMetaTable.create(conn)
|
|
DocumentMetaTable.upsert_many(
|
|
conn,
|
|
[DocumentMetaRow(1, "a"), DocumentMetaRow(2, "b")],
|
|
)
|
|
DocumentMetaTable.delete_for_document(conn, 1)
|
|
assert DocumentMetaTable.all_modified_times(conn) == {"2": "b"}
|
|
|
|
def test_delete_all_clears_every_row(self, conn: sqlite3.Connection) -> None:
|
|
"""
|
|
GIVEN:
|
|
- document_meta rows for multiple documents
|
|
WHEN:
|
|
- delete_all() is called
|
|
THEN:
|
|
- all_modified_times() returns an empty dict
|
|
"""
|
|
DocumentMetaTable.create(conn)
|
|
DocumentMetaTable.upsert_many(
|
|
conn,
|
|
[DocumentMetaRow(1, "a"), DocumentMetaRow(2, "b")],
|
|
)
|
|
DocumentMetaTable.delete_all(conn)
|
|
assert DocumentMetaTable.all_modified_times(conn) == {}
|
|
|
|
def test_copy_all_streams_every_row_to_destination(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A source connection with document_meta rows for 5 documents
|
|
- A separate, empty destination connection
|
|
WHEN:
|
|
- copy_all() is called with a batch size smaller than the row
|
|
count, forcing multiple fetchmany() cycles
|
|
THEN:
|
|
- Every row is present on the destination connection
|
|
"""
|
|
DocumentMetaTable.create(conn)
|
|
DocumentMetaTable.upsert_many(
|
|
conn,
|
|
[DocumentMetaRow(i, f"modified-{i}") for i in range(5)],
|
|
)
|
|
dst_conn = sqlite3.connect(":memory:")
|
|
dst_conn.row_factory = sqlite3.Row
|
|
try:
|
|
DocumentMetaTable.create(dst_conn)
|
|
DocumentMetaTable.copy_all(conn, dst_conn, batch_size=2)
|
|
assert DocumentMetaTable.all_modified_times(dst_conn) == {
|
|
str(i): f"modified-{i}" for i in range(5)
|
|
}
|
|
finally:
|
|
dst_conn.close()
|
|
|
|
|
|
class TestIndexMetaTable:
|
|
@pytest.mark.parametrize(
|
|
("setter_name", "getter_name", "value"),
|
|
[
|
|
("set_dim", "get_dim", 384),
|
|
("set_embed_model", "get_embed_model", "model-a"),
|
|
("set_schema_version", "get_schema_version", 2),
|
|
],
|
|
)
|
|
def test_typed_accessor_roundtrip(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
setter_name: str,
|
|
getter_name: str,
|
|
value: int | str,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An empty index_meta table
|
|
WHEN:
|
|
- A typed accessor's setter is called then the getter is read back
|
|
THEN:
|
|
- The same value is returned, correctly typed (int or str)
|
|
"""
|
|
IndexMetaTable.create(conn)
|
|
getter = getattr(IndexMetaTable, getter_name)
|
|
setter = getattr(IndexMetaTable, setter_name)
|
|
assert getter(conn) is None
|
|
setter(conn, value)
|
|
assert getter(conn) == value
|
|
|
|
def test_total_inserts_starts_at_zero(self, conn: sqlite3.Connection) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An empty index_meta table
|
|
WHEN:
|
|
- get_total_inserts() is read before anything is set
|
|
THEN:
|
|
- 0 is returned
|
|
"""
|
|
IndexMetaTable.create(conn)
|
|
assert IndexMetaTable.get_total_inserts(conn) == 0
|
|
|
|
def test_increment_total_inserts_accumulates(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An empty index_meta table
|
|
WHEN:
|
|
- increment_total_inserts() is called twice
|
|
THEN:
|
|
- get_total_inserts() returns the running sum
|
|
"""
|
|
IndexMetaTable.create(conn)
|
|
IndexMetaTable.increment_total_inserts(conn, 5)
|
|
IndexMetaTable.increment_total_inserts(conn, 3)
|
|
assert IndexMetaTable.get_total_inserts(conn) == 8
|
|
|
|
def test_increment_total_inserts_is_a_single_statement(
|
|
self,
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An empty index_meta table
|
|
WHEN:
|
|
- increment_total_inserts() is called
|
|
THEN:
|
|
- Exactly one conn.execute() call is made (a single INSERT ...
|
|
ON CONFLICT DO UPDATE, not a separate read then write)
|
|
"""
|
|
|
|
# sqlite3.Connection is an immutable C extension type with no
|
|
# instance __dict__, so mocker.spy(conn, "execute") can't shadow
|
|
# "execute" on a plain connection ("attribute 'execute' is
|
|
# read-only"). A trivial Python subclass gets a normal instance
|
|
# __dict__, making the instance spyable while still being a real,
|
|
# usable sqlite3.Connection.
|
|
class _SpyableConnection(sqlite3.Connection):
|
|
pass
|
|
|
|
conn = sqlite3.connect(":memory:", factory=_SpyableConnection)
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
IndexMetaTable.create(conn)
|
|
execute_spy = mocker.spy(conn, "execute")
|
|
IndexMetaTable.increment_total_inserts(conn, 5)
|
|
assert execute_spy.call_count == 1
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_reset_total_inserts_sets_absolute_value(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A total_inserts counter already at a high value
|
|
WHEN:
|
|
- reset_total_inserts() is called with a lower value
|
|
THEN:
|
|
- get_total_inserts() returns exactly that value, not a sum
|
|
"""
|
|
IndexMetaTable.create(conn)
|
|
IndexMetaTable.increment_total_inserts(conn, 100)
|
|
IndexMetaTable.reset_total_inserts(conn, 7)
|
|
assert IndexMetaTable.get_total_inserts(conn) == 7
|