ulthar.xyz > Repos

leibowitz

Experimental Common Lisp object storage abstraction for Unix file systems
About Files Commits git clone https://ulthar.xyz/repos/leibowitz/leibowitz.git

leibowitz/core/backends/sqlite.lisp

Download raw file: core/backends/sqlite.lisp

;; A library backend to store the ontology in an SQLite database.

(in-package :leibowitz.core)

;; Helper macros; these need to be declared before they're called.

(defmacro with-sqlite-tx ((sqlite-library) &body body)
  "Run BODY as an atomic SQLite transaction."
  `(progn
     (sqlite-nq ,sqlite-library "begin transaction")
     (handler-case (progn ,@body)
       (T (c)
         (sqlite-nq ,sqlite-library "rollback")
         (error c))
       (:no-error (c)
         (declare (ignore c))
         (sqlite-nq ,sqlite-library "commit")))))

(defmacro ccat (&rest strings)
  "Concatenate some strings at compile-time.  Used internally to shorten
lines with really long SQL queries."
  (format NIL "~{~A~}" strings))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defclass sqlite-library (library)
  ((db-path
    :type (or string pathname)
    :initarg :db-path
    :initform (error "db-path required.")
    :documentation "The path to the SQLite database.")
   (handle
    :type sqlite:sqlite-handle
    :documentation "The handle through which database queries are made."))
  (:documentation "A library backend to store the ontology in a SQLite database."))

(defmethod print-object ((sqlite-library sqlite-library) stream)
  (print-unreadable-object (sqlite-library stream :type T)
    (format stream "~A" (slot-value sqlite-library 'db-path))))

(defmethod initialize-instance :after
    ((l sqlite-library) &rest initargs &key &allow-other-keys)
  (declare (ignore initargs))
  (ensure-directories-exist (directory-namestring (slot-value l 'db-path)))
  (setf (slot-value l 'handle) (sqlite:connect (slot-value l 'db-path)))
  (mapcar (lambda (tbl) (sqlite:execute-non-query (slot-value l 'handle) tbl))
          '("
create table if not exists 'data' (
  'id' text not null unique,
  'accesses' integer not null,
  'type' text not null,
  'birth' datetime not null,
  'modified' datetime not null,
  'terms' text
)" "
create table if not exists 'tags' (
  'name' text not null unique,
  'label' text,
  'count' integer
)" "
create table if not exists 'tag_datum_junctions' (
  'tag_name' text not null,
  'datum_id' text not null,
  unique(tag_name, datum_id) on conflict ignore
)" "
create table if not exists 'tag_predicates' (
  'iftag' text not null,
  'thentag' text not null,
  unique(iftag, thentag) on conflict ignore
)" "
create virtual table if not exists 'search' using fts5 (
  'terms',
  'id',
  content='data'
)" "
-- FIXME bruh why are we tracking this, just use a select count(*) instead
create trigger if not exists inc_tag_count after insert on tag_datum_junctions begin
  update tags set count = count + 1 where name = new.tag_name;
end" "
create trigger if not exists dec_tag_count after delete on tag_datum_junctions begin
  update tags set count = count - 1 where name = old.tag_name;
  delete from tags where name = old.tag_name and count = 0 and label is null;
end" "
create trigger if not exists data_into_fts after insert on data begin
  insert into search (id, terms) values (new.id, new.terms);
end" "
create trigger if not exists data_from_fts after delete on data begin
  insert into search (search, id, terms) values ('delete', old.id, old.terms);
end
" "
create trigger if not exists data_update_fts after update on data begin
  insert into search (search, id, terms) values ('delete', old.id, old.terms);
  insert into search (id, terms) values (new.id, new.terms);
end" "
create trigger if not exists update_junction_on_datum_rename after update on data begin
  update tag_datum_junctions set datum_id = new.id where datum_id = old.id;
end")))

(defmethod library-data-quantity ((l sqlite-library))
  (sqlite-row l "select count(*) from data"))

(defmethod library-tag-quantity ((l sqlite-library))
  (sqlite-row l "select count(*) from tags"))

;;; FIXME: get this into one statement so that we can sort by count
;;; and group by the major part.  This is apparently Very Hard in SQL.
;;; FIXME: is it worth writing tests for this?
(defmethod library-all-file-types ((l sqlite-library))
  (loop for type in (sqlite-rows l "select distinct type from data")
        collect (cons (car type)
                      (sqlite-row
                       l "select count(*) from data where type = ?"
                       (car type)))))

(defmethod library-print-info ((l sqlite-library))
  (format T "SQLite Library on ~A with ~A tags and ~A files.~%"
          (namestring (slot-value l 'db-path))
          (library-tag-quantity l)
          (library-data-quantity l)))

(defmethod datum-num-tags ((l sqlite-library) (datum datum))
  (datum-num-tags l (%need-datum-id datum)))
(defmethod datum-num-tags ((l sqlite-library) (datum pathname))
  (datum-num-tags l (%need-datum-id datum)))
(defmethod datum-num-tags ((l sqlite-library) (datum string))
  (sqlite-row l "select count(*) from tag_datum_junctions where datum_id = ? "
              datum))

;;; Reading and writing data

;; FIXME: Make indexing jobs run in parallel!
(defmethod index ((l sqlite-library) (path-or-paths list)
                  &key (log T) (promote-error NIL))
  (mapcar (lambda (path) (index l path :log log :promote-error promote-error))
          path-or-paths))
(defmethod index ((l sqlite-library) (path-or-paths string)
                  &key (log T) (promote-error NIL))
  (index l (uiop:parse-unix-namestring path-or-paths)
         :log log :promote-error promote-error))
(defmethod index ((l sqlite-library) (path-or-paths pathname)
                  &key (log T) (promote-error NIL))
  (let ((thumbnailer:*thumbnail-cache-dir* (library-thumbnail-cache-dir l))
        (indexed NIL))
    (labels ((index-worker (path)
               (when (library-path-indexable-p l path)
                 (when log
                   (format T "Indexing ~S..." (uiop:native-namestring path))
                   (finish-output))
                 (handler-case
                     (let ((d (collection-index l (library-get-datum-collection l path) path)))
                       (push d indexed)
                       (thumbnailer:get-thumbnail (datum-id d) (datum-kind d))
                       ;; For some reason known only to God and the
                       ;; spirit that dwells within the Steele Bank
                       ;; Common Lisp, we _need_ to `finish-output'
                       ;; here in order to avoid an "invalid number of
                       ;; arguments: 2" error
                       (when log (finish-output)))
                   ;; FIXME: `no-such-file', `file-not-readable', etc.
                   ;; The fact that `no-such-file' errors end up as
                   ;; `no-applicable-collection' is really annoying,
                   ;; where would be the proper place to correct this?
                   ;; Perhaps in `datum's `initialize-instance'?
                   ;; Really I should rip out collections altogether,
                   ;; they're feeling more and more like an
                   ;; overly-complex and inconvenient way of having
                   ;; custom indexing rules for given directories.
                   ;; (file-not-regular (c)
                   ;;   (if promote-error
                   ;;       (error c)
                   ;;       (when log (format T "failed!~%Error: ~A~%" c) (finish-output))))
                   (thumbnailer:unsupported-file-type (c)
                     (if promote-error
                         (error c)
                         (when log (format T "done~%~A~%" c))))
                   (thumbnailer:thumbnail-creation-failed (c)
                     (if promote-error
                         (error c)
                         (when log (format T "done~%~A~%" c))))
                   (:no-error (c)
                     (declare (ignore c))
                     (when log (format T "done~%")))))))
      (if (uiop:directory-exists-p path-or-paths)
          (cl-fad:walk-directory path-or-paths #'index-worker)
          (index-worker path-or-paths)))
    indexed))

(defmethod add-datum ((l sqlite-library) (d datum))
  (sqlite-nq l (ccat "insert or replace into data "
                     "(id, accesses, type, birth, modified, terms) "
                     "values (?, ?, ?, ?, ?, ?)")
             (datum-id d) (datum-accesses d) (datum-kind d) (datum-birth d)
             (datum-modified d) (datum-terms d))
  d)

(defmethod get-datum ((l sqlite-library) (path string) &key (error NIL))
  (multiple-value-bind
        (id accesses kind birth modified terms)
      (sqlite-row l "select * from data where id = ?"
                  (%need-datum-id path))
    (if id
        (make-instance 'datum :id id :accesses accesses :kind kind :birth birth
                              :modified modified :terms terms
                              :collection (library-get-datum-collection l id))
        (if error
            (error 'datum-not-indexed :lib l :id path)
            NIL))))
(defmethod get-datum ((l sqlite-library) (path pathname) &key (error NIL))
  (get-datum l (%need-datum-id path)))

(defun %del-datum-inner-transaction (lib id)
  (loop for tag in (get-datum-tags lib id)
        do (sqlite-nq lib (ccat "delete from tags where name = ? and "
                              "count = 0 and label is null")
                      (tag-name tag)))
  (sqlite-nq lib "delete from data where id = ?" id)
  (sqlite-nq lib "delete from tag_datum_junctions where datum_id = ?" id))

(defmethod del-datum ((l sqlite-library) datum-or-id &key (error NIL) (disk T))
  (check-type datum-or-id (or datum string pathname))
  (let ((id (%need-datum-id datum-or-id)))
    (when error (get-datum l id :error T))
    (with-sqlite-tx (l)
      (%del-datum-inner-transaction l id))
    ;; FIXME: this should be mediated by this datum's collection.
    ;; Placing this last means that if this datum is in db but not in
    ;; disk, the orphaned entries will be removed before an error is
    ;; signaled.
    (when disk (if (probe-file (uiop:parse-native-namestring id))
                   (delete-file (uiop:parse-native-namestring id))
                   (when error
                     (error 'datum-is-orphaned :id id))))))

(defmethod move-datum ((l sqlite-library) old-datum-or-id new-datum-or-id
                             &key (overwrite NIL))
  (check-type old-datum-or-id (or string pathname datum))
  (check-type new-datum-or-id (or string pathname datum))
  (let* ((old (%need-datum-id old-datum-or-id))
         (new (%need-datum-id new-datum-or-id))
         (oldpath (uiop:parse-unix-namestring old))
         (newpath (uiop:parse-unix-namestring new))
         ;; FIXME: aren't we appending an image file extension?  I
         ;; think there'll be a bug here!
         (oldth (merge-pathnames oldpath (library-thumbnail-cache-dir l)))
         (newth (merge-pathnames newpath (library-thumbnail-cache-dir l))))
    (when (equal old new) (error 'cannot-mv-or-cp-to-itself :d new))
    (unless (get-datum l old) (error 'datum-not-indexed :lib l :id old))
    (when (and (or (get-datum l new) (probe-file newpath))
               (not overwrite))
      (error 'datum-already-exists :d new))
    (when (and (not (probe-file oldpath))
               (not (probe-file newpath)))
      (error 'datum-is-orphaned :id old))
    ;; It's okay if old doesn't exist on disk as long as the entry is
    ;; still in the DB; the user might have moved it and want to
    ;; update the ID.
    (when (probe-file oldpath)
      (rename-file oldpath newpath)
      ;; The user probably passed a relative path for new, and we want
      ;; the absolute for all ids.
      (setf new (uiop:native-namestring (truename newpath))))
    (when (probe-file oldth)
      (rename-file oldth newth))
    ;; Triggers do the hard lifting of keeping everything up to date.
    (with-sqlite-tx (l)
      (%del-datum-inner-transaction l new)
      (sqlite-nq l "update data set id = ? where id = ?" new old))
    (uiop:native-namestring new)))

(defmethod copy-datum ((l sqlite-library) old-datum-or-id new-datum-or-id
                       &key (overwrite NIL))
  (check-type old-datum-or-id (or string pathname datum))
  (check-type new-datum-or-id (or string pathname datum))
  (let* ((old (%need-datum-id old-datum-or-id))
         (new (%need-datum-id new-datum-or-id))
         (oldth (merge-pathnames old (library-thumbnail-cache-dir l)))
         (newth (merge-pathnames new (library-thumbnail-cache-dir l))))
    (when (equal old new) (error 'cannot-mv-or-cp-to-itself :d new))
    (unless (get-datum l old) (error 'datum-not-indexed :lib l :id old))
    (unless (probe-file old) (error 'datum-is-orphaned :lib l :id old))
    (when (and (or (get-datum l new) (probe-file new))
               (not overwrite))
      (error 'datum-already-exists :d new))
    (uiop:copy-file old new)
    (uiop:copy-file oldth newth)
    (with-sqlite-tx (l)
      (let* ((new (namestring (truename new)))
             (datum (get-datum l old))
             (tags (get-datum-tags l datum)))
        (setf (datum-id datum) new)
        (add-datum l datum)
        (if overwrite
            (%add-datum-tags-inner-transaction l datum tags :replace T)
            (%add-datum-tags-inner-transaction l datum tags))))
    ;; FIXME: with-sqlite-tx clobbers return values
    (get-datum l new)))

;;; Reading and writing tags

;; FIXME: again, tag-count could easily be found by a method that does
;; a select count(*) query on the junction table so we wouldn't have
;; to use multiple queries to update the label.
(defun %add-tag-inner-transaction (l tag)
  (let* ((tag   (if (stringp tag) (make-instance 'tag :name tag) tag))
         (prev-label (sqlite-row l "select label from tags where name = ?" (tag-name tag)))
         (curr-label (if (slot-boundp tag 'label) (tag-label tag) NIL)))
    ;; We insert-or-ignore here in order to not clobber count.
    (sqlite-nq l "insert or ignore into tags (name, label, count) values (?, ?, ?)"
               (tag-name tag) curr-label (tag-count tag))
    (when curr-label
      (unless (equal prev-label curr-label)
        (sqlite-nq l "update tags set label = ? where name = ?"
                   curr-label (tag-name tag))))
    tag))

(defmethod add-tag ((l sqlite-library) tag-or-name)
  (check-type tag-or-name (or tag string))
  (with-sqlite-tx (l)
    (%add-tag-inner-transaction l tag-or-name))
  ;; fixme with-sqlite-tx clobbers return value
  (get-tag l (%need-tag-name tag-or-name)))

(defmethod get-tag ((l sqlite-library) tag-name &key (error NIL))
  (check-type tag-name string)
  (multiple-value-bind
        (name label count)
      (sqlite-row l "select * from tags where name = ?" tag-name)
    (if name
        (make-instance 'tag :name name :label label :count count)
        (if error
            (error 'no-such-tag :name tag-name :lib l)
            NIL))))

(defun %del-tag-inner-transaction (l name)
  (check-type l sqlite-library)
  (check-type name string)
  (sqlite-nq l "delete from tags where name = ?" name)
  (sqlite-nq l "delete from tag_datum_junctions where tag_name = ?" name)
  (sqlite-nq l "delete from tag_predicates where iftag = ? or thentag = ?"
             name name))

(defmethod del-tag ((l sqlite-library) tag-or-name)
  (check-type tag-or-name (or datum string))
  (with-sqlite-tx (l)
    (%del-tag-inner-transaction l (%need-tag-name tag-or-name))))

(defmethod move-tag ((l sqlite-library) old-tag-or-name new-tag-or-name
                     &key (overwrite NIL) (merge NIL))
  (check-type old-tag-or-name (or string tag))
  (check-type new-tag-or-name (or string tag))
  (check-type overwrite boolean)
  (check-type merge boolean)
  (assert (not (and merge overwrite)))
  (let ((old (%need-tag-name old-tag-or-name))
        (new (%need-tag-name new-tag-or-name)))
    (when (equal old new) (error 'cannot-mv-or-cp-to-itself :d new))
    (unless (get-tag l old) (error 'no-such-tag :name old :lib l))
    (when (and (get-tag l new)
               (not (or overwrite merge)))
      (error 'tag-already-exists :name new))
    (with-sqlite-tx (l)
      (when overwrite
        (%del-tag-inner-transaction l new))
      (if merge
          (let ((old-tag (get-tag l old)))
            (sqlite-nq l "update tags set label = ? where name = ?" (tag-label old-tag) new)
            (sqlite-nq l "update tags set count = count + ? where name = ?" (tag-count old-tag) new)
            (sqlite-nq l "delete from tags where name = ?" old)
            ;; Since we're merging, the data of old all need to be
            ;; updated to the existing new's predicate tags.  Worst
            ;; case runtime of O(fuck-my-life-I-hate-graphs).
            (let* ((predicates (%cascade-down-predicate-tree l new))
                   (tags (loop for tag being each hash-key of predicates
                               collect tag)))
              (loop for datum in (list-data l :tags (list old))
                    do (%add-datum-tags-inner-transaction l datum tags))))
          (sqlite-nq l "update tags set name = ? where name = ?" new old))
      (sqlite-nq l "update tag_datum_junctions set tag_name = ? where tag_name = ?" new old)
      (sqlite-nq l "update tag_predicates set iftag = ? where iftag = ?" new old)
      (sqlite-nq l "update tag_predicates set thentag = ? where thentag = ?" new old))
    ;; FIXME: with-sqlite-tx clobbers return values
    (get-tag l new)))

(defmethod copy-tag ((l sqlite-library) old-tag-or-name new-tag-or-name
                     &key (overwrite NIL) (merge NIL))
  (check-type old-tag-or-name (or string tag))
  (check-type new-tag-or-name (or string tag))
  (check-type overwrite boolean)
  (check-type merge boolean)
  (assert (not (and merge overwrite)))
  (let ((old (%need-tag-name old-tag-or-name))
        (new (%need-tag-name new-tag-or-name)))
    (when (equal old new) (error 'cannot-mv-or-cp-to-itself :d new))
    (unless (get-tag l old) (error 'no-such-tag :name old :lib l))
    (when (and (get-tag l new)
               (not (or overwrite merge)))
      (error 'tag-already-exists :name new))
    (with-sqlite-tx (l)
      (when overwrite (%del-tag-inner-transaction l new))
      (let ((new-tag (make-instance 'tag :name new
                                         :label (tag-label (get-tag l old)))))
        (%add-tag-inner-transaction l new-tag))
      (loop for predicate in (get-tag-predicates l old)
            do (%add-tag-predicate-inner-transaction l new (list predicate)))
      (loop for datum in (list-data l :tags (list old))
            do (%add-datum-tags-inner-transaction l datum (list new))))
    ;; FIXME: with-sqlite-tx clobbers return values
    (get-tag l new)))

;;; Reading and writing datum-tag relationships

;; FIXME: if tags is a list of tag instances, their labels are
;; discarded!
(defun %add-datum-tags-inner-transaction (lib datum-or-id tags &key replace)
  "Same deal as `%del-datum-tags-inner-transaction'."
  (check-type datum-or-id (or datum pathname string))
  (check-type tags list)
  (labels ((add-assoc (l name id)
             (%add-tag-inner-transaction l name)
             (sqlite-nq l (ccat "insert into tag_datum_junctions "
                                "(tag_name, datum_id) values (?, ?)")
                        name id)))
    (when replace
      (%del-datum-tags-inner-transaction
       lib datum-or-id (get-datum-tags lib datum-or-id)))
    (loop for tag in tags
          for id = (%need-datum-id datum-or-id)
          for name = (%need-tag-name tag)
          for required-tags = (%cascade-down-predicate-tree lib name)
          do (loop for required-tag being each hash-key of required-tags
                   do (add-assoc lib required-tag id)))))

(defmethod add-datum-tags ((l sqlite-library) datum-or-id tags &key (replace NIL))
  (unless (get-datum l (%need-datum-id datum-or-id))
    (error 'datum-not-indexed :lib l :id (%need-datum-id datum-or-id)))
  (with-sqlite-tx (l)
    (%add-datum-tags-inner-transaction l datum-or-id tags :replace replace)))

(defmethod get-datum-tags ((l sqlite-library) datum-or-id)
  (check-type datum-or-id (or datum pathname string))
  (loop for row in (sqlite-rows l (ccat "select tags.* from tags "
                                        "inner join tag_datum_junctions "
                                        "on tag_name = name where datum_id = ?")
                                (%need-datum-id datum-or-id))
        collect (destructuring-bind (name label count) row
                  (make-instance 'tag :name name :count count :label label))))

(defun %del-datum-tags-inner-transaction (lib datum-or-id tags &key cascade)
  "All the tag operations require multiple update/insert/delete queries,
which we want to run atomically in a single transaction in order to
avoid corrupting the db.  Unfortunately, transactions cannot be nested
and `add-datum-tags' needs to call `del-datum-tags' within its own
transaction, hence this little helper function."
  (labels ((del-assoc (l name id)
             (sqlite-nq l (ccat "delete from tag_datum_junctions "
                                "where tag_name = ? and datum_id = ?")
                        name id)
             (sqlite-nq l (ccat "delete from tags where name = ? and "
                                "count = 0 and label is null")
                        name)))
    (loop for tag in tags
          for name = (%need-tag-name tag)
          for id = (%need-datum-id datum-or-id)
          do (del-assoc lib name id)
             (when cascade
               (loop for req being each hash-key of (%cascade-down-predicate-tree lib name)
                     do (del-assoc lib req id))))))

(defmethod del-datum-tags ((l sqlite-library) (datum-or-id datum) (tags list)
                           &key (cascade NIL))
  (del-datum-tags l (datum-id datum-or-id) tags :cascade cascade))
(defmethod del-datum-tags ((l sqlite-library) (datum-or-id pathname) (tags list)
                           &key (cascade NIL))
  (del-datum-tags l (%need-datum-id datum-or-id) tags :cascade cascade))
(defmethod del-datum-tags ((l sqlite-library) (datum-or-id string) (tags list)
                           &key (cascade NIL))
  (get-datum l datum-or-id :error T)
  (with-sqlite-tx (l)
    (%del-datum-tags-inner-transaction l datum-or-id tags :cascade cascade)))

;;; Reading and writing tag hierarchies

(defun %add-tag-predicate-inner-transaction (l iftag-or-name thentags-or-names
                                             &key (retroactive T) (replace NIL))
  (labels ((add-assoc (ifname thenname)
             (let ((ifname (%need-tag-name ifname))
                   (thenname (%need-tag-name thenname)))
               ;; FIXME: if this methods arguments are tags we'll
               ;; lose information like labels!  In general the core
               ;; library's handling of different argument types is
               ;; abysmally convoluted.
               (unless (get-tag l ifname)
                 (%add-tag-inner-transaction l ifname))
               (unless (get-tag l thenname)
                 (%add-tag-inner-transaction l thenname))
               ;; FIXME: give del-tag-predicate a retroactive option
               ;; and pass it whatever value we were passed here
               (sqlite-nq l (ccat "insert or ignore into tag_predicates "
                                  "(iftag, thentag) values (?, ?)")
                          ifname thenname)
               (when retroactive
                 ;; Big O of deez nuts
                 (let ((predicates (%cascade-down-predicate-tree l iftag-or-name)))
                   (loop for tag being each hash-key of predicates
                         do (loop for datum in (list-data l :tags (list tag))
                                  do (%add-datum-tags-inner-transaction
                                      l datum (list thenname)))))))))
    ;; FIXME: refactor del-tag-predicate to optionally take a list
    (when replace
      (loop for tag in (get-tag-predicates l iftag-or-name)
            do (del-tag-predicate l iftag-or-name tag)))
    (etypecase thentags-or-names
      ((or string tag) (add-assoc iftag-or-name thentags-or-names))
      (list (loop for thentag-or-name in thentags-or-names
                  do (add-assoc iftag-or-name thentag-or-name))))))

(defmethod add-tag-predicate ((l sqlite-library) iftag-or-name thentags-or-names
                              &key (retroactive T) (replace NIL))
  (check-type iftag-or-name (or tag string))
  (with-sqlite-tx (l)
    (%add-tag-predicate-inner-transaction
     l iftag-or-name thentags-or-names :replace replace :retroactive retroactive)))

(defmethod get-tag-predicates ((l sqlite-library) tag-or-name)
  (check-type tag-or-name (or tag string))
  (loop for row in (sqlite-rows l (ccat "select tags.* from tags "
                                        "inner join tag_predicates "
                                        "on thentag = name where iftag = ?")
                                (%need-tag-name tag-or-name))
        collect (destructuring-bind (name label count) row
                  (make-instance 'tag :name name :count count :label label))))

(defmethod get-tag-predicands ((l sqlite-library) tag-or-name)
  (check-type tag-or-name (or tag string))
  (loop for row in (sqlite-rows l (ccat "select tags.* from tags "
                                        "inner join tag_predicates "
                                        "on iftag = name where thentag = ?")
                                (%need-tag-name tag-or-name))
        collect (destructuring-bind (name label count) row
                  (make-instance 'tag :name name :count count :label label))))

(defmethod del-tag-predicate ((l sqlite-library) iftag-or-name thentag-or-name)
  (check-type iftag-or-name (or tag string))
  (check-type thentag-or-name (or tag string))
  (let ((ifname (%need-tag-name iftag-or-name))
        (thenname (%need-tag-name thentag-or-name)))
    (sqlite-nq l "delete from tag_predicates where iftag = ? and thentag = ?"
               ifname thenname)))

(defmethod tag-num-parents ((l sqlite-library) (tag-or-name tag))
  (tag-num-parents l (tag-name tag-or-name)))
(defmethod tag-num-parents ((l sqlite-library) (tag-or-name string))
  (sqlite-row l "select count(*) from tag_predicates where iftag = ?" tag-or-name))

(defmethod tag-num-children ((l sqlite-library) (tag-or-name tag))
  (tag-num-children l (tag-name tag-or-name)))
(defmethod tag-num-children ((l sqlite-library) (tag-or-name string))
  (sqlite-row l "select count(*) from tag_predicates where thentag = ?" tag-or-name))

;;; Searching and Listing

;; FIXME: Improve search!  We should be able to search in different or
;; all fields (id, body, tags) and filter for tags at the same time as
;; searching for text.

;; FIXME: add support for tag filtering
;; FIXME: the search terms must follow FTS's search syntax and we will
;; receive an error if they contain certain stray characters.  This
;; may be fixed by double-quoting the entire string, but then we lose
;; the search syntax.  Figure out a policy here!
(defmethod query ((l sqlite-library) terms &key (sort-by :rank) (direction :ascending)
                                             (limit NIL) (offset NIL))
  (assert (member sort-by '(:rank :modified :birth :accesses)))
  (assert (member direction '(:descending :ascending)))
  (check-type terms string)
  (check-type offset (or null integer))
  (check-type limit (or null integer))
  (when (or offset limit) (assert (and offset limit)))
  (loop for row in (sqlite-rows
                    l (format NIL (ccat "select data.* from search "
                                        "left join data on data.id = search.id "
                                        "where search match ? order by ~A ~A ~A")
                              (cond ((eql sort-by :rank) "rank")
                                    ((eql sort-by :modified) "modified")
                                    ((eql sort-by :birth) "birth")
                                    ((eql sort-by :accesses) "accesses"))
                              (cond ((eql direction :descending) "desc")
                                    ((eql direction :ascending) "asc"))
                              (if (and limit offset)
                                  (format NIL "limit ~A offset ~A" limit offset)
                                  ""))
                    terms)
        collect (destructuring-bind (id accesses kind birth modified terms) row
                  (make-instance 'datum :id id :accesses accesses :kind kind
                                        :birth birth :modified modified
                                        :terms terms
                                        :collection (library-get-datum-collection l id)))))

(defmethod list-tags ((l sqlite-library) &key (limit NIL))
  (check-type limit (or null integer))
  (loop for row in (sqlite-rows
                    l (format NIL "select * from tags order by count desc ~A"
                              (if limit  (format NIL "limit ~A" limit) "")))
        collect (destructuring-bind (name label count) row
                  (make-instance 'tag :name name :count count :label label))))

;; FIXME: Track datum tag-count so we can sort by them
(defmethod list-data ((l sqlite-library) &key (sort-by :modified)
                                           (direction :descending)
                                           (limit NIL) (offset NIL)
                                           (tags NIL) (type NIL) (dir NIL))
  (assert (member sort-by '(:modified :birth :accesses)))
  (assert (member direction '(:descending :ascending)))
  (check-type limit (or null integer))
  (check-type offset (or null integer))
  (check-type tags list)
  (check-type type (or null string))
  (check-type dir (or null string pathname))
  (setf dir (etypecase dir
              (string (uiop:parse-unix-namestring dir))
              ((or pathname null) dir)))
  (when (or limit offset) (assert (and limit offset)))
  (let ((query (format NIL "~A select * from ~A ~A order by ~A ~A ~A"
                       (if tags
                           (format NIL "with tagged as ~
                                        (select data.* from data ~
                                         inner join tag_datum_junctions ~
                                         on datum_id = data.id where ~
                                         ~v{tag_name = ?~*~^ or ~})"
                                   (length tags) tags)
                           "")
                       (if tags "tagged" "data")
                       (if type "where type = ?" "")
                       (case sort-by
                         (:modified "modified")
                         (:birth "birth")
                         (:accesses "accesses"))
                       (case direction
                         (:descending "desc")
                         (:ascending "asc"))
                       (if (and limit offset)
                           (format NIL "limit ~A offset ~A" limit offset)
                           ""))))
    (loop for row in (apply #'sqlite-rows (nconc (list l query)
                                                 (mapcar #'%need-tag-name tags)
                                                 (when type (list type))))
          ;; FIXME: will this filtering approach break if the database
          ;; and filesystem are out of sync?  I'd rather do it in SQL
          when (let* ((id (uiop:parse-unix-namestring (car row)))
                      (base (make-pathname :name (pathname-name id)
                                           :type (pathname-type id))))
                 (if dir (equal id (merge-pathnames base dir)) T))
            collect (destructuring-bind (id accesses kind birth modified terms) row
                      (make-instance 'datum :id id :accesses accesses :kind kind
                                            :birth birth :modified modified
                                            :terms terms
                                            :collection (library-get-datum-collection l id))))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Additional Methods

(defmethod sqlite-nq ((l sqlite-library) &rest args)
  "Run a non-query on this database."
  (apply #'sqlite:execute-non-query (nconc (list (slot-value l 'handle)) args)))

(defmethod sqlite-row ((l sqlite-library) &rest args)
  "Run a query on this database that returns a single row."
  (apply #'sqlite:execute-one-row-m-v (nconc (list (slot-value l 'handle)) args)))

(defmethod sqlite-rows ((l sqlite-library) &rest args)
  "Run a query on this database that returns multiple rows."
  (apply #'sqlite:execute-to-list (nconc (list (slot-value l 'handle)) args)))

(defun %cascade-down-predicate-tree (lib root &optional (tbl NIL))
  "Given ROOT as the root tag of a tag hierarchy, traverse down it and
return a hash table of all tags that should be added.  If we encounter
a tag that is already in the table of tags to add, simply skip it; ie,
cycles in the tag hierarchy are just ignored."
  ;; Create the hash table the first time this function is called
  (unless tbl (setf tbl (make-hash-table :test #'equal)))
  ;; Do a recursive breadth-first search of the graph, skipping
  ;; whenever we find a tag that was previously encountered in order
  ;; to avoid recursing infinitely.
  (setf (gethash root tbl) root)
  (loop for tag in (get-tag-predicates lib root)
        for name = (tag-name tag)
        unless (gethash (tag-name tag) tbl)
          do (setf (gethash (tag-name tag) tbl) (tag-name tag))
             (%cascade-down-predicate-tree lib (tag-name tag) tbl))
  tbl)

Generated 2024-06-10 19:24:14 -0700 by RepoRat