1
mirror of https://gitlab.com/jessieh/simple-shortener.git synced 2025-06-02 03:32:33 +00:00
simple-shortener/src/modules/links_db.fnl

176 lines
7.0 KiB
Fennel

;; 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