Switch to UUID for sqlite db

This commit is contained in:
Andrew Mulbrook 2026-03-24 21:22:24 -05:00
parent 90b72ed678
commit 82c272990c
3 changed files with 59 additions and 55 deletions

View file

@ -9,21 +9,22 @@ similar ideas to git storage.
import argparse
import sqlite3
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
NOTE_ROOT_ID = 1
NOTE_ROOT_ID = uuid.UUID(int=0)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS objects (
id INTEGER PRIMARY KEY,
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('blob', 'tree')),
data BLOB,
modified TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS metadata (
id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value BLOB,
PRIMARY KEY (id, key)
@ -32,14 +33,21 @@ CREATE TABLE IF NOT EXISTS metadata (
CREATE TABLE IF NOT EXISTS labels
(
label TEXT PRIMARY KEY,
id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE
id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE
);
"""
type SqlObjectId = str | uuid.UUID
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _sql_id(id: SqlObjectId | None) -> str | None:
if id is None:
return None
return (
id if isinstance(id, str) else str(id)
)
def _initialize_db(con: sqlite3.Connection):
con.executescript(_SCHEMA)
@ -71,7 +79,7 @@ class Sqlite3Trove:
con.commit()
obj = cls(con)
if initialize:
obj.write_blob(b"", NOTE_ROOT_ID)
obj.write_tree(b"", NOTE_ROOT_ID)
return obj
def close(self):
@ -87,95 +95,88 @@ class Sqlite3Trove:
# CRUD operations
# ------------------------------------------------------------------
def get_object_type(self, object_id: int) -> str | None:
def get_object_type(self, object_id: SqlObjectId) -> str | None:
"""Return the type column for an object, or None if not found."""
row = self._con.execute(
"SELECT type FROM objects WHERE id = ?", (object_id,)
"SELECT type FROM objects WHERE id = ?", (_sql_id(object_id),)
).fetchone()
return row["type"] if row else None
def read_object(self, object_id: int) -> bytes | None:
def read_object(self, object_id: SqlObjectId) -> bytes | None:
"""Return raw data for a blob object, or None if not found."""
row = self._con.execute(
"SELECT data, type FROM objects WHERE id = ?", (object_id,)
"SELECT data, type FROM objects WHERE id = ?", (_sql_id(object_id),)
).fetchone()
if row is None:
return None
return bytes(row["data"]) if row["data"] is not None else b""
def get_mtime(self, object_id: int) -> datetime | None:
def get_mtime(self, object_id: SqlObjectId) -> datetime | None:
"""Return the modified timestamp for an object, or None if not found."""
row = self._con.execute(
"SELECT modified FROM objects WHERE id = ?", (object_id,)
"SELECT modified FROM objects WHERE id = ?", (_sql_id(object_id),)
).fetchone()
if row is None:
return None
return datetime.fromisoformat(row["modified"])
def read_metadata(self, object_id: int, key: str) -> bytes | None:
def read_metadata(self, object_id: SqlObjectId, key: str) -> bytes | None:
"""Return raw metadata value for (uuid, key), or None if not found."""
row = self._con.execute(
"SELECT value FROM metadata WHERE id = ? AND key = ?", (object_id, key)
"SELECT value FROM metadata WHERE id = ? AND key = ?", (_sql_id(object_id), key)
).fetchone()
if row is None:
return None
return bytes(row["value"]) if row["value"] is not None else b""
def write_metadata(self, object_id: int, key: str, value: bytes) -> None:
def write_metadata(self, object_id: SqlObjectId, key: str, value: bytes) -> None:
"""Upsert a metadata row. db.py has no write_metadata, so we go direct."""
self._con.execute(
"INSERT OR REPLACE INTO metadata (id, key, value) VALUES (?, ?, ?)",
(object_id, key, value),
(_sql_id(object_id), key, value),
)
self._con.commit()
def _write_object(self, data: bytes, dtype: str, object_id: int | None = None) -> int:
def _write_object(self, data: bytes, dtype: str, object_id: str | uuid.UUID | None = None) -> str:
"""
Insert or replace an object. Returns the id.
If object_id is None, creates a new object with auto-assigned ID.
If object_id is None, creates a new object with a new UUID.
If object_id is provided, updates or creates the object with that ID.
"""
modified = _now()
if object_id is None:
cur = self._con.execute(
"INSERT INTO objects (type, data, modified) VALUES (?, ?, ?)",
(dtype, data, modified)
)
self._con.commit()
assert cur.lastrowid is not None
return cur.lastrowid
else:
self._con.execute(
"INSERT OR REPLACE INTO objects (id, type, data, modified) VALUES (?, ?, ?, ?)",
(object_id, dtype, data, modified)
)
self._con.commit()
return object_id
object_id = uuid.uuid4()
self._con.execute(
"INSERT OR REPLACE INTO objects (id, type, data, modified) VALUES (?, ?, ?, ?)",
(_sql_id(object_id), dtype, data, modified)
)
self._con.commit()
return _sql_id(object_id)
def write_blob(self, data: bytes, object_id: int | None = None) -> int:
def write_blob(self, data: bytes, object_id: SqlObjectId | None = None) -> str:
"""
Insert or replace a blob. Returns the id.
Pass object_id to update an existing object.
"""
return self._write_object(data, "blob", object_id)
return self._write_object(data, "blob", _sql_id(object_id))
def write_tree(self, data: bytes, object_id: int | None = None) -> int:
def write_tree(self, data: bytes, object_id: SqlObjectId | None = None) -> str:
"""Write a tree-typed object. Returns the assigned id."""
return self._write_object(data, "tree", object_id)
return self._write_object(data, "tree", _sql_id(object_id))
def delete_object(self, object_id: int) -> bool:
def delete_object(self, object_id: SqlObjectId) -> bool:
"""
Delete a blob and all its metadata rows.
Returns True if an object was deleted, False if id not found.
Foreign key cascade handles the metadata rows.
"""
cur = self._con.execute(
"DELETE FROM objects WHERE id = ?", (object_id,)
"DELETE FROM objects WHERE id = ?", (_sql_id(object_id),)
)
self._con.commit()
return cur.rowcount > 0
def get_label(self, label: str) -> int | None:
def get_label(self, label: str) -> SqlObjectId | None:
"""
Return the ID associated with a label, or None if not found.
"""
@ -184,16 +185,16 @@ class Sqlite3Trove:
).fetchone()
if row is None:
return None
return row["id"]
return uuid.UUID(row["id"])
def set_label(self, label: str, object_id: int) -> None:
def set_label(self, label: str, object_id: SqlObjectId) -> None:
"""
Set a label to point to an ID. Creates or updates the label.
The ID must exist in the objects table.
"""
self._con.execute(
"INSERT OR REPLACE INTO labels (label, id) VALUES (?, ?)",
(label, object_id),
(label, _sql_id(object_id)),
)
self._con.commit()
@ -207,7 +208,7 @@ class Sqlite3Trove:
self._con.commit()
return cur.rowcount > 0
def list_labels(self) -> list[tuple[str, int]]:
def list_labels(self) -> list[tuple[str, str]]:
"""
Return all labels as a list of (label, id) tuples.
"""
@ -229,21 +230,21 @@ def main():
# Get blob
get_parser = subparsers.add_parser("get", help="Get a blob object by ID")
get_parser.add_argument("id", type=int, help="ID of the blob to retrieve")
get_parser.add_argument("id", help="ID of the blob to retrieve")
# Write blob
write_parser = subparsers.add_parser("write", help="Write data to a blob")
write_parser.add_argument("data", help="Data to write (as string, will be encoded as UTF-8)")
write_parser.add_argument("--id", type=int, help="ID of existing blob to update (optional)")
write_parser.add_argument("--id", help="ID of existing blob to update (optional)")
# Delete blob
delete_parser = subparsers.add_parser("delete", help="Delete a blob by ID")
delete_parser.add_argument("id", type=int, help="ID of the blob to delete")
delete_parser.add_argument("id", help="ID of the blob to delete")
# Set label
setlabel_parser = subparsers.add_parser("setlabel", help="Create or update a label to point to an ID")
setlabel_parser.add_argument("label", help="Label name")
setlabel_parser.add_argument("id", type=int, help="ID to associate with the label")
setlabel_parser.add_argument("id", help="ID to associate with the label")
# Remove label
rmlabel_parser = subparsers.add_parser("rmlabel", help="Delete a label")

View file

@ -9,6 +9,9 @@ only the data field — storage is the caller's concern.
import json
from .trove import ObjectId
class Tree:
def __init__(self, data: bytes | None = None):
"""
@ -16,7 +19,7 @@ class Tree:
An empty Tree is created if data is None.
"""
if not data:
self._entries: dict[str, int] = {}
self._entries: dict[str, ObjectId] = {}
else:
self._entries = json.loads(data.decode("utf-8"))
@ -24,11 +27,11 @@ class Tree:
"""Serialize the tree to UTF-8 JSON bytes."""
return json.dumps(self._entries).encode("utf-8")
def set_entry(self, name: str, object_id: int) -> None:
def set_entry(self, name: str, object_id: ObjectId) -> None:
"""Add or update an entry mapping name -> uuid."""
self._entries[name] = object_id
def get_entry(self, name: str) -> int:
def get_entry(self, name: str) -> ObjectId:
"""Get the uuid associated with a name, or raise KeyError if not found."""
return self._entries[name]
@ -36,6 +39,6 @@ class Tree:
"""Remove an entry by name. Raises KeyError if not found."""
del self._entries[name]
def list(self) -> dict[str, int]:
def list(self) -> dict[str, ObjectId]:
"""Return a shallow copy of all entries as {name: uuid}."""
return dict(self._entries)

View file

@ -15,20 +15,20 @@ from .tree import Tree as TreeData
from . import trove as tr
from .trove import Note, Trove, TreeNote, BlobNote, TreeEntry, NoteNotFound
from .trove import Note, Trove, TreeNote, BlobNote, TreeEntry, NoteNotFound, ObjectId
class NoteImpl(Note):
"""Concrete not implementation"""
def __init__(self, parent: 'TroveImpl', object_id: int):
def __init__(self, parent: 'TroveImpl', object_id: ObjectId):
self._parent = parent
self._db = parent.db
self._object_id = object_id
# Note protocol
@property
def object_id(self) -> int:
def object_id(self) -> ObjectId:
return self._object_id
@property
@ -123,7 +123,7 @@ class TreeNoteImpl(NoteImpl, TreeNote):
for name, object_id in tree.list().items():
yield TreeEntry(name, object_id)
def list(self) -> dict[str, int]:
def list(self) -> dict[str, ObjectId]:
"""Return all entries as {name: object_id}."""
return self._read_tree().list()
@ -168,7 +168,7 @@ class TroveImpl:
self.close()
# Trove protocol
def get_raw_note(self, note_id: int) -> Note:
def get_raw_note(self, note_id: ObjectId) -> Note:
"""Return a BlobNote or TreeNote for the given id, or None if not found."""
ot = self._db.get_object_type(note_id)
if ot is None: