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 argparse
import sqlite3 import sqlite3
import sys import sys
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
NOTE_ROOT_ID = 1 NOTE_ROOT_ID = uuid.UUID(int=0)
_SCHEMA = """ _SCHEMA = """
CREATE TABLE IF NOT EXISTS objects ( CREATE TABLE IF NOT EXISTS objects (
id INTEGER PRIMARY KEY, id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('blob', 'tree')), type TEXT NOT NULL CHECK(type IN ('blob', 'tree')),
data BLOB, data BLOB,
modified TEXT NOT NULL modified TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS metadata ( 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, key TEXT NOT NULL,
value BLOB, value BLOB,
PRIMARY KEY (id, key) PRIMARY KEY (id, key)
@ -32,14 +33,21 @@ CREATE TABLE IF NOT EXISTS metadata (
CREATE TABLE IF NOT EXISTS labels CREATE TABLE IF NOT EXISTS labels
( (
label TEXT PRIMARY KEY, 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: def _now() -> str:
return datetime.now(timezone.utc).isoformat() 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): def _initialize_db(con: sqlite3.Connection):
con.executescript(_SCHEMA) con.executescript(_SCHEMA)
@ -71,7 +79,7 @@ class Sqlite3Trove:
con.commit() con.commit()
obj = cls(con) obj = cls(con)
if initialize: if initialize:
obj.write_blob(b"", NOTE_ROOT_ID) obj.write_tree(b"", NOTE_ROOT_ID)
return obj return obj
def close(self): def close(self):
@ -87,95 +95,88 @@ class Sqlite3Trove:
# CRUD operations # 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.""" """Return the type column for an object, or None if not found."""
row = self._con.execute( row = self._con.execute(
"SELECT type FROM objects WHERE id = ?", (object_id,) "SELECT type FROM objects WHERE id = ?", (_sql_id(object_id),)
).fetchone() ).fetchone()
return row["type"] if row else None 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.""" """Return raw data for a blob object, or None if not found."""
row = self._con.execute( 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() ).fetchone()
if row is None: if row is None:
return None return None
return bytes(row["data"]) if row["data"] is not None else b"" 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.""" """Return the modified timestamp for an object, or None if not found."""
row = self._con.execute( row = self._con.execute(
"SELECT modified FROM objects WHERE id = ?", (object_id,) "SELECT modified FROM objects WHERE id = ?", (_sql_id(object_id),)
).fetchone() ).fetchone()
if row is None: if row is None:
return None return None
return datetime.fromisoformat(row["modified"]) 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.""" """Return raw metadata value for (uuid, key), or None if not found."""
row = self._con.execute( 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() ).fetchone()
if row is None: if row is None:
return None return None
return bytes(row["value"]) if row["value"] is not None else b"" 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.""" """Upsert a metadata row. db.py has no write_metadata, so we go direct."""
self._con.execute( self._con.execute(
"INSERT OR REPLACE INTO metadata (id, key, value) VALUES (?, ?, ?)", "INSERT OR REPLACE INTO metadata (id, key, value) VALUES (?, ?, ?)",
(object_id, key, value), (_sql_id(object_id), key, value),
) )
self._con.commit() 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. 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. If object_id is provided, updates or creates the object with that ID.
""" """
modified = _now() modified = _now()
if object_id is None: if object_id is None:
cur = self._con.execute( object_id = uuid.uuid4()
"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( self._con.execute(
"INSERT OR REPLACE INTO objects (id, type, data, modified) VALUES (?, ?, ?, ?)", "INSERT OR REPLACE INTO objects (id, type, data, modified) VALUES (?, ?, ?, ?)",
(object_id, dtype, data, modified) (_sql_id(object_id), dtype, data, modified)
) )
self._con.commit() self._con.commit()
return object_id 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. Insert or replace a blob. Returns the id.
Pass object_id to update an existing object. 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.""" """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. Delete a blob and all its metadata rows.
Returns True if an object was deleted, False if id not found. Returns True if an object was deleted, False if id not found.
Foreign key cascade handles the metadata rows. Foreign key cascade handles the metadata rows.
""" """
cur = self._con.execute( cur = self._con.execute(
"DELETE FROM objects WHERE id = ?", (object_id,) "DELETE FROM objects WHERE id = ?", (_sql_id(object_id),)
) )
self._con.commit() self._con.commit()
return cur.rowcount > 0 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. Return the ID associated with a label, or None if not found.
""" """
@ -184,16 +185,16 @@ class Sqlite3Trove:
).fetchone() ).fetchone()
if row is None: if row is None:
return 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. Set a label to point to an ID. Creates or updates the label.
The ID must exist in the objects table. The ID must exist in the objects table.
""" """
self._con.execute( self._con.execute(
"INSERT OR REPLACE INTO labels (label, id) VALUES (?, ?)", "INSERT OR REPLACE INTO labels (label, id) VALUES (?, ?)",
(label, object_id), (label, _sql_id(object_id)),
) )
self._con.commit() self._con.commit()
@ -207,7 +208,7 @@ class Sqlite3Trove:
self._con.commit() self._con.commit()
return cur.rowcount > 0 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. Return all labels as a list of (label, id) tuples.
""" """
@ -229,21 +230,21 @@ def main():
# Get blob # Get blob
get_parser = subparsers.add_parser("get", help="Get a blob object by ID") 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 blob
write_parser = subparsers.add_parser("write", help="Write data to a 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("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 blob
delete_parser = subparsers.add_parser("delete", help="Delete a blob by ID") 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 # Set label
setlabel_parser = subparsers.add_parser("setlabel", help="Create or update a label to point to an ID") 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("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 # Remove label
rmlabel_parser = subparsers.add_parser("rmlabel", help="Delete a 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 import json
from .trove import ObjectId
class Tree: class Tree:
def __init__(self, data: bytes | None = None): def __init__(self, data: bytes | None = None):
""" """
@ -16,7 +19,7 @@ class Tree:
An empty Tree is created if data is None. An empty Tree is created if data is None.
""" """
if not data: if not data:
self._entries: dict[str, int] = {} self._entries: dict[str, ObjectId] = {}
else: else:
self._entries = json.loads(data.decode("utf-8")) self._entries = json.loads(data.decode("utf-8"))
@ -24,11 +27,11 @@ class Tree:
"""Serialize the tree to UTF-8 JSON bytes.""" """Serialize the tree to UTF-8 JSON bytes."""
return json.dumps(self._entries).encode("utf-8") 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.""" """Add or update an entry mapping name -> uuid."""
self._entries[name] = object_id 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.""" """Get the uuid associated with a name, or raise KeyError if not found."""
return self._entries[name] return self._entries[name]
@ -36,6 +39,6 @@ class Tree:
"""Remove an entry by name. Raises KeyError if not found.""" """Remove an entry by name. Raises KeyError if not found."""
del self._entries[name] 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 a shallow copy of all entries as {name: uuid}."""
return dict(self._entries) return dict(self._entries)

View file

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