Skip to content
Merged

Dev #229

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
1 change: 1 addition & 0 deletions packages/ducpy/src/ducpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
from .enums import *
from .parse import (DucData, get_external_file, list_external_files, parse_duc,
parse_duc_lazy)
from .search import *
from .serialize import DUC_SCHEMA_VERSION, serialize_duc
from .utils import *
61 changes: 61 additions & 0 deletions packages/ducpy/src/ducpy/builders/sql_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from __future__ import annotations

import os
import re
import sqlite3
import tempfile
from pathlib import Path
Expand All @@ -37,6 +38,9 @@
__all__ = ["DucSQL"]


_MIGRATION_RE = re.compile(r"^(\d+)_to_(\d+)$")


def _find_schema_dir() -> Optional[Path]:
current = Path(__file__).resolve()
for parent in current.parents:
Expand All @@ -46,6 +50,61 @@ def _find_schema_dir() -> Optional[Path]:
return None


def _get_current_schema_version() -> int:
"""Read the target user_version from duc.sql — mirrors Rust's CURRENT_VERSION."""
schema_dir = _find_schema_dir()
if schema_dir is None:
return 0
duc_sql = (schema_dir / "duc.sql").read_text(encoding="utf-8")
m = re.search(r"PRAGMA\s+user_version\s*=\s*(\d+)", duc_sql, re.IGNORECASE)
return int(m.group(1)) if m else 0


def _read_migrations() -> list[tuple[int, int, str]]:
"""Load all migration SQL files from schema/migrations/, sorted by from_version."""
schema_dir = _find_schema_dir()
if schema_dir is None:
return []
migrations_dir = schema_dir / "migrations"
if not migrations_dir.exists():
return []
result: list[tuple[int, int, str]] = []
for path in sorted(migrations_dir.glob("*.sql")):
m = _MIGRATION_RE.match(path.stem)
if m:
result.append((int(m.group(1)), int(m.group(2)), path.read_text(encoding="utf-8")))
result.sort(key=lambda x: x[0])
return result


def _apply_migrations(conn: sqlite3.Connection) -> None:
"""Walk the migration chain until user_version reaches the current schema version.

Mirrors the migration logic in Rust's ``bootstrap.rs``:
reads ``schema/migrations/{from}_to_{to}.sql`` files in order and executes
them until ``PRAGMA user_version`` matches the version declared in ``duc.sql``.
Safe to call on already-current or brand-new databases.
"""
user_version: int = conn.execute("PRAGMA user_version").fetchone()[0]
if user_version == 0:
return # unversioned / brand-new DB — schema applied by DucSQL.new()
current_version = _get_current_schema_version()
if user_version >= current_version:
return # already up to date
migrations = _read_migrations()
current = user_version
while current < current_version:
migration = next(((f, t, sql) for f, t, sql in migrations if f == current), None)
if migration is None:
raise RuntimeError(
f"No migration path from schema version {current} to {current_version}. "
"Upgrade the ducpy package."
)
_, to_v, sql = migration
conn.executescript(sql)
current = conn.execute("PRAGMA user_version").fetchone()[0]
Comment on lines +104 to +105
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_apply_migrations can get stuck in an infinite loop if a migration script fails to advance PRAGMA user_version (e.g., script error, missing final PRAGMA, or PRAGMA guarded by a conditional). Add a progress check after executescript (e.g., ensure the new user_version is > current and ideally equals the expected to_v) and raise a clear error if not.

Suggested change
conn.executescript(sql)
current = conn.execute("PRAGMA user_version").fetchone()[0]
prev_version = current
conn.executescript(sql)
new_version = conn.execute("PRAGMA user_version").fetchone()[0]
if new_version <= prev_version:
raise RuntimeError(
"Migration did not advance PRAGMA user_version: "
f"attempted to migrate from {prev_version} to {to_v}, "
f"but user_version is still {new_version}."
)
if new_version != to_v:
raise RuntimeError(
"Migration set unexpected PRAGMA user_version: "
f"attempted to migrate from {prev_version} to {to_v}, "
f"but user_version is {new_version}."
)
current = new_version

Copilot uses AI. Check for mistakes.


def _read_schema_sql() -> str:
schema_dir = _find_schema_dir()
if schema_dir is None:
Expand Down Expand Up @@ -83,6 +142,7 @@ def __init__(self, path: Union[str, Path]):
self.conn: sqlite3.Connection = sqlite3.connect(path)
self.conn.row_factory = sqlite3.Row
_apply_pragmas(self.conn)
_apply_migrations(self.conn)
self._path: Optional[str] = path
self._temp: Optional[str] = None
self._closed = False
Expand Down Expand Up @@ -114,6 +174,7 @@ def from_bytes(cls, data: bytes) -> DucSQL:
inst.conn = sqlite3.connect(tmp.name)
inst.conn.row_factory = sqlite3.Row
_apply_pragmas(inst.conn)
_apply_migrations(inst.conn)
inst._path = tmp.name
inst._temp = tmp.name
inst._closed = False
Expand Down
13 changes: 13 additions & 0 deletions packages/ducpy/src/ducpy/search/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Search helpers for DUC SQLite databases."""

from .search_elements import (DucElementSearchResult, DucFileSearchResult,
DucSearchResponse, DucSearchResult,
search_duc_elements)
Comment on lines +3 to +5
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import list formatting/indentation is inconsistent here, which makes the module harder to scan and is likely to fight auto-formatters. Reformat this into a standard multi-line import (one name per line or a single line if it fits) to keep style consistent.

Suggested change
from .search_elements import (DucElementSearchResult, DucFileSearchResult,
DucSearchResponse, DucSearchResult,
search_duc_elements)
from .search_elements import (
DucElementSearchResult,
DucFileSearchResult,
DucSearchResponse,
DucSearchResult,
search_duc_elements,
)

Copilot uses AI. Check for mistakes.

__all__ = [
"DucElementSearchResult",
"DucFileSearchResult",
"DucSearchResponse",
"DucSearchResult",
"search_duc_elements",
]
Loading
Loading