;; modules/links_db.fnl ;; Provides a simple interface for storing and retrieving shortened links with SQLite ;; -------------------------------------------------------------------------- ;; ;; Dependencies (local hashids (require :hashids)) (local lume (require :lume)) (local sqlite (require :sqlite)) ;; -------------------------------------------------------------------------- ;; ;; Local modules (local fs_utils (require :modules.fs_utils)) ;; -------------------------------------------------------------------------- ;; ;; Constants (local sql_glob_links_query " SELECT link_id, link_dest, visit_count FROM links WHERE (link_id GLOB :glob_pattern OR link_dest GLOB :glob_pattern) AND is_custom BETWEEN :custom AND 1 ORDER BY created DESC") ;; -------------------------------------------------------------------------- ;; ;; Helper functions (fn random_whole_number [num_digits] "Return a random whole number with `num_digits` digits." (math.floor (* (math.random) (^ 10 num_digits)))) (fn generate_link_id [] "Return a random 6 character base62 link ID." (let [seed_length 10 id_length 6 encoder (hashids.new (tostring (random_whole_number seed_length)) id_length)] (encoder:encode (random_whole_number id_length)))) (fn encode_glob_string [string] "Return `string` encoded as a case-insensitive glob string." (let [encode_char_fn (fn [char] (.. "[" (string.lower char) (string.upper char) "]"))] (string.gsub string "%a" encode_char_fn))) ;; -------------------------------------------------------------------------- ;; ;; Initialize database ;; ---------------------------------- ;; ;; Ensure 'db/' folder exists (when (not (fs_utils.dir_exists? "db")) (print "🚨 ERROR: No 'db/' folder to place SQLite database files in.\n❓ Did you forget to mount a volume at '/simple-shortener/db'?") (os.exit -1)) ;; ---------------------------------- ;; ;; Connect to SQLite database ;; Tables: ;; links | Stores information about shortened links ;; link_id - The unique base62 identifying string for the shortened link ;; link_dest - The fully qualified URI that the shortened link points at ;; is_custom - Whether `link_id` was a custom value provided by the user ;; created - When the link was created (stored as Unix time) ;; deleted - When the link was deleted (stored as Unix time) ;; (if zero, the link has not been deleted) (local db (sqlite {:uri "db/links.db" :links {:link_id {:type "text" :primary true} :link_dest {:type "text" :required true} :is_custom {:type "number" :default 0} :visit_count {:type "number" :default 0} :created {:type "number" :default (sqlite.lib.strftime "%s" "now")} :deleted {:type "number" :default 0}}})) ;; ---------------------------------- ;; ;; Database method overrides (fn db.links.id_available? [self link_id] "Return true if a row with primary key `link_id` is not yet in the database, otherwise return false." (= (length (self:__get {:where {: link_id}})) 0)) (fn db.links.where [self where] "Return first match against `where` that has not been flagged as deleted. Returns a table containing the row data on success, otherwise nil." (let [where (lume.merge (or where {}) {:deleted 0})] (self:__where where))) (fn db.links.get [self query] "Return rows matching `query` that have not been flagged as deleted. Returns a sequential table of rows." (let [where (lume.merge (or query.where {}) {:deleted 0}) query (lume.merge query {:where where})] (self:__get query))) ;; -------------------------------------------------------------------------- ;; ;; Module definition (local links_db {}) (fn links_db.insert_link [?link_id link_dest] "Insert a link to `link_dest` and with optional custom ID `link_id` into the database. Returns the ID of the link after insertion, or nil if the operation failed." (let [link_id (or ?link_id (generate_link_id)) is_custom (if ?link_id 1 0)] (if (and (db.links:id_available? link_id) (db.links:insert {: link_id : link_dest : is_custom})) link_id nil))) (fn links_db.link_id_available? [link_id] "Return true if a link with ID `link_id` is not yet in the database, otherwise return false." (db.links:id_available? link_id)) (fn links_db.redirect_link [link_id link_dest] "Redirect the link with ID `link_id` to new destination `link_dest` in the database. Returns true on success, otherwise returns false." (and (db.links:where {: link_id}) (db.links:update {:where {: link_id} :set {: link_dest}}))) (fn links_db.delete_link [link_id] "Flag the link with ID `link_id` as deleted in the database. If the link has a non-zero is_custom column, then the row will deleted so that the ID may be reused. Returns true on success, otherwise returns false." (let [current_time (os.time) link (db.links:where {: link_id})] (if (= link.is_custom 0) (db.links:update {:where {: link_id} :set {:deleted current_time}}) (db.links:remove {: link_id})))) (fn links_db.increment_link_visit_count [link_id] "Increment the recorded visit count of the link with ID `link_id` in the database. Returns true on success, otherwise returns false." (let [link (db.links:where {: link_id})] (db.links:update {:where {: link_id} :set {:visit_count (+ link.visit_count 1)}}))) (fn links_db.fetch_link_dest [link_id] "Return the destination of the link with ID `link_id` from the database. Returns a URI string on success, otherwise returns nil." (let [link (db.links:where {: link_id})] (?. link :link_dest))) (fn links_db.fetch_links [limit ?custom] "Return up to `limit` of the most recent link_id/link_dest pairs from the database. If `?custom` is non-nil, return only links with custom IDs. Returns a sequential table of links." (db.links:get {:select [:link_id :link_dest :visit_count] :where (when ?custom {:is_custom 1}) :order_by {:desc :created} : limit})) (fn links_db.search_links [query limit ?custom] "Return up to `limit` of the most recent link_id/link_dest pairs from the database that contain `query`. Both the ID and the destination URI of each link are searched for `query`. If `?custom` is non-nil, return only links with custom IDs. Returns a sequential table of links." (let [glob_pattern (.. "*" (encode_glob_string query) "*") custom (if ?custom 1 0) search_fn #(db:eval sql_glob_links_query {: glob_pattern : custom}) results (db:with_open search_fn)] (if (= (type results) "table") results []))) ;; -------------------------------------------------------------------------- ;; ;; Return module links_db