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
6 changes: 5 additions & 1 deletion src/did/did_matlab_python_bridge_implementations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ classes:
- name: sqlitedb
type: class
matlab_path: "+did/+implementations/sqlitedb.m"
matlab_last_sync_hash: "83a2bda"
matlab_last_sync_hash: "5b7220b"
status: ported
python_path: "did/implementations/sqlitedb.py"
python_class: "SQLiteDB"
Expand All @@ -55,6 +55,10 @@ classes:
applies negation as a complement in Python rather than in SQL, so a
negated term with candidates is the complement within the candidates
rather than within the branch -- the same set once intersected.
DID-matlab f35bbb4..5b7220b (2026-10-24): ensure_search_indexes announces each
index before building it (with the approximate doc_data row count),
reports the time it took, and reports a build that failed; ported to
_ensure_indexes, which prints the same lines to stderr.
sync_notes: >
MATLAB 14439fb (2026-06-12, audit 6.1-3) added a private
escapeSqlLiteral() helper and applied it at the 14 sites where
Expand Down
58 changes: 47 additions & 11 deletions src/did/implementations/sqlitedb.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,15 @@ def _ensure_indexes(self):
by another writer) is left as it is, as DID-matlab leaves it: the
indexes only speed searches up, and without them searches run as they
did before.

Building them is a one-time cost, but not a small one:
doc_data_field_value holds a copy of every field value, so for a large
dataset (100,000 documents can be tens of millions of doc_data rows)
on a slow or synced drive it can take minutes. Each index is announced
on stderr before it is built, with the approximate number of rows, and
timed -- otherwise the open just appears to hang. A database that
already has them opens without printing anything. Mirrors DID-matlab's
sqlitedb/ensure_search_indexes.
"""
self._search_indexes_ready = False
try:
Expand All @@ -398,18 +407,45 @@ def _ensure_indexes(self):
"SELECT name FROM sqlite_master WHERE type = 'index'"
)
}
missing = [idx for idx in _SEARCH_INDEXES if idx[0] not in existing]
if missing:
cursor = self.dbid.cursor()
for name, columns in missing:
cursor.execute(
f'CREATE INDEX IF NOT EXISTS "{name}" ON doc_data({columns})'
)
self.dbid.commit()
except sqlite3.Error:
with contextlib.suppress(sqlite3.Error):
self.dbid.rollback()
return
return # cannot even read the schema - search without the indexes
for name, columns in _SEARCH_INDEXES:
if name in existing:
continue
# max(rowid) is read straight from the end of the table's b-tree,
# so this estimate costs nothing (count(*) would scan)
try:
num_rows = self.dbid.execute(
"SELECT max(rowid) FROM doc_data"
).fetchone()[0]
except sqlite3.Error:
num_rows = None
print(
f"DID: adding search index {name} to {self.connection} "
f"(about {num_rows or 0:,} rows). This happens once per database "
"and can take several minutes for a large one...",
file=sys.stderr,
)
started = time.perf_counter()
try:
self.dbid.execute(
f'CREATE INDEX IF NOT EXISTS "{name}" ON doc_data({columns})'
)
self.dbid.commit()
except sqlite3.Error as err:
with contextlib.suppress(sqlite3.Error):
self.dbid.rollback()
print(
f"DID: could not add search index {name} ({err}); "
"searches will run without it.",
file=sys.stderr,
)
return
print(
f"DID: added search index {name} in "
f"{time.perf_counter() - started:.1f} s.",
file=sys.stderr,
)
self._search_indexes_ready = True

def _close_db(self):
Expand Down
17 changes: 16 additions & 1 deletion tests/test_search_indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
path however quickly it happens to run on a small test database.
"""

import contextlib
import io
import os
import sqlite3
import statistics
Expand Down Expand Up @@ -162,17 +164,30 @@ def test_indexes_added_when_older_database_opened(self):
older.dbid.commit()
older._close_db()

reopened = SQLiteDB(path)
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
reopened = SQLiteDB(path)
try:
names = self.index_names(reopened)
for name in SEARCH_INDEXES:
self.assertIn(name, names, f"opening must add {name}")
# building an index on a large database can take minutes,
# so each one is announced and timed, not built silently
self.assertIn(f"adding search index {name}", stderr.getvalue())
self.assertIn(f"added search index {name} in ", stderr.getvalue())
self.assertTrue(reopened._search_indexes_ready)
q = _id_query(doc.id()) & _session_query(self.session)
self.assertEqual(reopened.search(q, branch_id="a"), [doc.id()])
finally:
reopened._close_db()

# ...and only once: a database that has them opens quietly
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
again = SQLiteDB(path)
again._close_db()
self.assertNotIn("search index", stderr.getvalue())

def test_read_only_database_without_indexes_still_searches(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "readonly.sqlite")
Expand Down
Loading