diff --git a/trovedb/db.py b/trovedb/db.py index 3156f15..4769037 100644 --- a/trovedb/db.py +++ b/trovedb/db.py @@ -14,6 +14,8 @@ from typing import NamedTuple from datetime import datetime, timezone from pathlib import Path +from . import trove as tr + NOTE_ROOT_ID = uuid.UUID(int=0) class ObjectInfo(NamedTuple): @@ -288,13 +290,21 @@ class Sqlite3Trove: # Tree entry operations # ------------------------------------------------------------------ - def link(self, parent_id: SqlObjectId, name: str, child_id: SqlObjectId) -> None: + def link(self, parent_id: SqlObjectId, name: str, child_id: SqlObjectId, overwrite: bool = True) -> None: """ Link a child object into a tree under the given name. - Replaces any existing entry with the same name in this tree. + Replaces any existing entry with the same name in this tree if overwrite=True. Both parent_id and child_id must exist in the objects table (enforced by FK constraints). """ + if not overwrite: + existing = self._con.execute( + "SELECT 1 FROM tree_entries WHERE parent_id = ? AND name = ?", + (_sql_id(parent_id), name), + ).fetchone() + if existing: + raise tr.ErrorExists(f"Entry '{name}' already exists in tree {parent_id}") + self._con.execute( "INSERT OR REPLACE INTO tree_entries (parent_id, name, child_id) " "VALUES (?, ?, ?)", diff --git a/trovedb/fs.py b/trovedb/fs.py index 3b97e1b..8dc29fe 100644 --- a/trovedb/fs.py +++ b/trovedb/fs.py @@ -1,12 +1,9 @@ import os -import sqlite3 -import tempfile import datetime as dt -from pathlib import Path, PurePosixPath -from typing import Optional, Dict, List, Self, Iterable, Iterator +from pathlib import Path +from typing import Optional, Iterable, Iterator, override from .trove import Note, Trove, TreeNote, BadNoteType, TreeEntry, NoteNotFound, ObjectId -from . import fs_util as fsu from . import trove as tr class FSNote(Note): @@ -78,7 +75,7 @@ class FSNote(Note): content = b"" if mime == 'inode/directory': - if content is not None: + if content: raise NotImplementedError("FSNote does not support children") return FSTreeNote(self._trove, self._new_child_subdir(name, False)) @@ -104,27 +101,25 @@ class FSNote(Note): target_path.unlink() # TODO: remove meta directory! - - def children(self) -> Iterator[TreeEntry]: - """Get all children of this note.""" - if not self._fs_path.is_dir(): + def unlink_(self, name: str): + target_path = self._fs_path / name + if not target_path.exists(): return - for item in self._fs_path.iterdir(): - if item.name == ".trove": - continue - yield TreeEntry(name=item.name, object_id=item.stat().st_ino) + if target_path.is_dir(): + target_path.rmdir() + else: + target_path.unlink() -class FSTreeNote(FSNote, TreeNote): - def link(self, name: str, note: Note): + def link_(self, name: str, note: Note): if not isinstance(note, FSNote): raise BadNoteType("Only blob notes can be linked") target_path = self._fs_path / name if target_path.exists(): - self.unlink(name) - + self.unlink_(name) + note_path = note._fs_path - + # If the note is in .working, move it to the new location. if self._trove.working in note_path.parents: os.rename(note_path, target_path) @@ -137,19 +132,20 @@ class FSTreeNote(FSNote, TreeNote): # Fallback to rename if link fails (e.g. cross-device, though we assume single FS) os.rename(note_path, target_path) else: - # Directories cannot be hardlinked. + # Directories cannot be hardlinked. # We move it to the new location. os.rename(note_path, target_path) - def unlink(self, name: str): - target_path = self._fs_path / name - if not target_path.exists(): + def children(self) -> Iterator[TreeEntry]: + """Get all children of this note.""" + if not self._fs_path.is_dir(): return - if target_path.is_dir(): - target_path.rmdir() - else: - target_path.unlink() + for item in self._fs_path.iterdir(): + if item.name == ".trove": + continue + yield TreeEntry(name=item.name, object_id=item.stat().st_ino) +class FSTreeNote(FSNote, TreeNote): def mkdir(self, name: str) -> 'FSTreeNote': target_path = self._fs_path / name target_path.mkdir(exist_ok=True) @@ -164,6 +160,11 @@ class FSTreeNote(FSNote, TreeNote): except OSError: pass + def unlink(self, name: str): + return self.unlink_(name) + + def link(self, name: str, note: Note): + return self.link_(name, note) @@ -198,6 +199,25 @@ class FSTrove(Trove): raise NoteNotFound(note_id) return self.get_raw_note_by_path(p) + @override + def move(self, src_parent: Note, src_name: str, dst_parent: Note, dst_name: str, overwrite: bool): + """Move a child note to a new location.""" + src_note = src_parent.child(src_name) + if not isinstance(src_note, FSNote): + raise tr.ErrorBadType("not a valid DB note") + if not isinstance(dst_parent, FSNote): + raise tr.ErrorBadType("not a valid DB note") + if not dst_parent._fs_path.is_dir(): + raise NotImplementedError("FSTrove promoting during move") + + # Remove existing target + if overwrite: + dst_parent.unlink_(dst_name) + + # Link to new parent, unlink from old + dst_parent.link_(dst_name, src_note) + src_parent.rm_child(src_name, True) + def create_blob(self, data: bytes | None = None) -> Note: raise NotImplementedError("FSTrove does not support blobs") diff --git a/trovedb/fuse/server.py b/trovedb/fuse/server.py index fea7c92..67fef3c 100644 --- a/trovedb/fuse/server.py +++ b/trovedb/fuse/server.py @@ -378,12 +378,15 @@ class TroveFuseOps(pyfuse3.Operations): self._close_handle(handle) async def unlink(self, parent_inode: InodeT, name: bytes, ctx) -> None: - parent_note = self._get_inode_note(parent_inode) - name_str = name.decode() - parent_note.rm_child(name_str, False) + try: + parent_note = self._get_inode_note(parent_inode) + name_str = name.decode() + parent_note.rm_child(name_str, False) + except tr.ErrorWithErrno as e: + raise pyfuse3.FUSEError(e.errno) from None async def rename(self, parent_inode_old: InodeT, name_old: bytes, parent_inode_new: InodeT, name_new: bytes, flags, ctx): - # Decode / validate names + # # Decode / validate names name_new_str = name_new.decode() name_old_str = name_old.decode() @@ -395,16 +398,8 @@ class TroveFuseOps(pyfuse3.Operations): if not isinstance(old_parent, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) - # We want to maintain the inode - find the note via the internal entity - ent, note = self._lookup_child(parent_inode_old, name_old) - - # Remove existing target - new_parent.unlink(name_new_str) - - # Link to new parent, unlink from old - new_parent.link(name_new_str, note) - old_parent.unlink(name_old_str) - + # Move! + self._trove.move(old_parent, name_old_str, new_parent, name_new_str, overwrite=True) # ------------------------------------------------------------------ # Serve diff --git a/trovedb/trove.py b/trovedb/trove.py index 06ee331..1bf6182 100644 --- a/trovedb/trove.py +++ b/trovedb/trove.py @@ -33,6 +33,10 @@ class ErrorNotEmpty(ErrorWithErrno): def __init__(self, *args): super().__init__(errno.ENOTEMPTY, *args) +class ErrorBadType(TypeError): + """Raised when an invalid type is encountered.""" + ... + class BadNoteType(TypeError): """Raised when an invalid note type is encountered.""" @@ -159,6 +163,10 @@ class Trove(Protocol): """Retrieve a note by a object id""" ... + def move(self, src_parent: Note, src_name: str, dst_parent: Note, dst_name: str, overwrite: bool): + """Move a child note to a new location.""" + ... + def create_blob(self, data: bytes | None = None) -> Note: """Create a new blob node at the given path with content""" ... diff --git a/trovedb/trovedb.py b/trovedb/trovedb.py index 30dfa6d..b45807a 100644 --- a/trovedb/trovedb.py +++ b/trovedb/trovedb.py @@ -5,7 +5,7 @@ Implements BlobNote, TreeNote, and Trove protocols defined in trove.py. Depends on db.py (Sqlite3Trove) for storage. """ -from typing import Optional, Iterator +from typing import Optional, Iterator, override from pathlib import Path import datetime as dt import uuid @@ -78,6 +78,7 @@ class NoteImpl(Note): """Create a new child note.""" content = content if content is not None else b"" object_id = self._db.write_blob(data=content, object_id=None, dtype=mime, executable=executable, hidden=hidden) + self._db.link(self._object_id, name, object_id) # TODO fix this if mime == 'inode/directory': return TreeNoteImpl(self._parent, object_id) @@ -139,7 +140,7 @@ class TreeNoteImpl(NoteImpl, TreeNote): # Trove # --------------------------------------------------------------------------- -class TroveImpl: +class TroveImpl(Trove): """ Concrete Trove: top-level API backed by a Sqlite3Trove database. @@ -179,6 +180,17 @@ class TroveImpl: return TreeNoteImpl(self, note_id) return NoteImpl(self, note_id) + @override + def move(self, src_parent: Note, src_name: str, dst_parent: Note, dst_name: str, overwrite: bool): + """Move a child note to a new location.""" + src_note = src_parent.child(src_name) + if not isinstance(src_note, NoteImpl): + raise tr.ErrorBadType("not a valid DB note") + if not isinstance(dst_parent, NoteImpl): + raise tr.ErrorBadType("not a valid DB note") + self._db.link(dst_parent.object_id, dst_name, src_note.object_id) + self._db.unlink(src_parent.object_id, src_name) + def create_blob(self, data: bytes | None = None, dtype: str = "application/octet-stream") -> Note: """Create a new blob object and return a BlobNote for it."""