//// An outrageously simple set of functions to interact with the BEAM //// DETS (Disk-based Erlang Term Storage) API. //// //// Good for small projects and POCs. //// //// //// This project DOES NOT intend to serve as direct bindings to the //// DETS API, but rather to interact with it in a gleamy way: //// 1. with a simple and concise interface; //// 2. type-safely; //// 3. no unexpected crashes, all errors are values. import gleam/bit_array import gleam/crypto import gleam/dynamic import gleam/dynamic/decode import gleam/erlang/atom.{type Atom} import gleam/erlang/charlist import gleam/option // BEAM interaction type Storage { Set } type TableAttributes { File(charlist.Charlist) Type(Storage) Keypos(Int) } type TableRef(a) /// A reference to an open table, required to interact with said table. /// Obtained through the `transaction(Table)` function pub opaque type Transaction(a) { Transaction(ref: TableRef(#(String, a)), decoder: decode.Decoder(a)) } @external(erlang, "dets", "open_file") fn dets_open_file( name: Atom, att: List(TableAttributes), ) -> Result(TableRef(a), reason) @external(erlang, "dets", "close") fn dets_close(tab: TableRef(a)) -> Result(b, c) @external(erlang, "dets", "insert") fn dets_insert(tab: TableRef(a), value: a) -> Result(b, c) @external(erlang, "dets", "delete") fn dets_delete(tab: TableRef(a), index: b) -> Result(b, c) @external(erlang, "dets", "lookup") fn dets_lookup(tab: TableRef(a), index: b) -> List(#(String, dynamic.Dynamic)) @external(erlang, "dets", "traverse") fn dets_traverse( tab: TableRef(a), select_fn: fn(#(String, dynamic.Dynamic)) -> SelectOption(b), ) -> List(b) @external(erlang, "file", "delete") fn file_delete(path: charlist.Charlist) -> a // Lying about the type ouput here is the only // way to ensure type-safety on the `select` function @external(erlang, "erlang", "binary_to_atom") fn erlang_binary_to_atom(value: String) -> SelectOption(a) // Type-safe API /// A collection of values used to access a DETS table. pub opaque type Table(a) { Table( tabname: Atom, attributes: List(TableAttributes), path: charlist.Charlist, decoder: decode.Decoder(a), ) } /// Possible error that may occur when using the library. pub type FileError { /// A problem occurred when trying to open and lock /// the .dets file. UnableToOpen /// A problem ocurred when trying to write into the /// .dets file and close it. UnableToClose } /// Creats a table. /// /// If no .dets file exists for the provided definition, creates one. /// Otherwise, just checks whether the file is accessible and not /// corrupted. /// /// # Example /// /// ```gleam /// pub fn start_database() { /// let decoder = { /// use name <- database.field(0, decode.string) /// use animal <- database.field(1, my_animal_decoder) /// decode.success(Pet(name:, animal:)) /// } /// database.create_table(name: "pets", decode_with: decoder) /// // -> Ok(Table(Pet)) /// } /// ``` /// pub fn create_table( name name: String, decode_with decoder: decode.Decoder(a), ) -> Result(Table(a), FileError) { let name_atom = atom.create(name) let path = charlist.from_string(name <> ".dets") let att = [File(path), Type(Set), Keypos(1)] case dets_open_file(name_atom, att) { Ok(tab) -> { case dets_close(tab) { Error(_) -> Error(UnableToClose) _ -> Ok(Table(name_atom, att, path, decoder)) } } Error(_) -> Error(UnableToOpen) } } /// Allows you to interact with the table. /// /// It opens and locks the .dets file, then execute your operations. /// Once the operations are done, it writes the changes into the file, /// closes and releases it. /// /// # Example /// /// ```gleam /// pub fn is_pet_registered(table: Table(Pet), pet_id: String) { /// use ref <- database.transaction(table) /// case database.find(ref, pet_id) { /// Some(_) -> True /// None -> False /// } /// } /// ``` /// pub fn transaction( table: Table(a), procedure: fn(Transaction(a)) -> b, ) -> Result(b, FileError) { case dets_open_file(table.tabname, table.attributes) { Error(_) -> Error(UnableToOpen) Ok(ref) -> { let resp = procedure(Transaction(ref, table.decoder)) case dets_close(ref) { Error(_) -> Error(UnableToClose) _ -> Ok(resp) } } } } /// Inserts a value into a table and return their generated id. /// /// DETS tables do not have support for update, only for upsert. /// So if you have to change a value, just insert a new value /// with the same index, and it will replace the previous value. /// /// # Example /// /// ```gleam /// pub fn new_pet(table: Table(Pet), animal: Animal, name: String) -> String { /// let pet = Pet(name, animal) /// use ref <- database.transaction(table) /// database.insert(ref, pet) /// } /// ``` /// pub fn insert(transac: Transaction(a), value: a) { let id = crypto.strong_random_bytes(16) |> bit_array.base64_url_encode(False) case dets_insert(transac.ref, #(id, value)) { Error(_) -> Error(Nil) _ -> Ok(id) } } /// Deletes a value from a table. /// /// # Example /// /// ```gleam /// pub fn delete_pet(table: Table(Pet) pet_id: String) { /// use ref <- database.transaction(table) /// database.delete(ref, pet_id) /// } /// ``` /// pub fn delete(transac: Transaction(a), id: String) { case dets_delete(transac.ref, id) { Error(_) -> Error(Nil) _ -> Ok(Nil) } } /// Finds a value by its index /// /// # Example /// /// ```gleam /// pub fn play_with_pluto(table: Table(Pet)) { /// use ref <- database.transaction(table) /// let resp = database.find(ref, known_pluto_id) /// case resp { /// None -> Error(PlutoNotFoundBlameTheAstronomers) /// Some(pluto) -> Ok(play_with(pluto)) /// } /// } /// ``` /// pub fn find(transac: Transaction(a), id: String) -> option.Option(a) { case dets_lookup(transac.ref, id) { [#(_, resp)] -> case decode.run(resp, transac.decoder) { Ok(val) -> option.Some(val) _ -> option.None } _ -> option.None } } /// Deletes the entire table file /// /// # Example /// /// ```gleam /// pub fn destroy_all_pets(table: Table(Pet), password: String) { /// case password { /// "Yes, I am evil." -> { /// database.drop_table(table) /// Ok(Nil) /// } /// _ -> Error(WrongPassword) /// } /// } /// ``` /// pub fn drop_table(table: Table(a)) { case file_delete(table.path) { Error(_) -> Error(Nil) _ -> Ok(Nil) } } /// Operations to perform on a select query. pub type SelectOption(value) { /// Ignores the current value. Skip /// Adds the value to the return list. Continue(value) /// Adds the value to the return list /// and immediately returns the query. Done(value) } /// Searches for somethig on the table. /// /// # Example /// /// ```gleam /// pub fn fetch_all_parrots(table: Table(Pet)) { /// use ref <- database.transaction(table) /// use value <- database.select(ref) /// case value { /// #(_id, Pet(_name, Parrot)) -> Continue(value) /// _ -> Skip /// } /// } /// ``` /// /// # IMPORTANT /// **DETS tables are not sorted in any deterministic way, so /// never assume that the last value inserted will be the last /// one on the table.** /// pub fn select( transac: Transaction(a), select_fn: fn(#(String, a)) -> SelectOption(b), ) -> List(b) { let continue = erlang_binary_to_atom("continue") let new_fn = fn(tab_value) { let #(id, dyn_value) = tab_value case decode.run(dyn_value, transac.decoder) { Ok(value) -> case select_fn(#(id, value)) { Skip -> continue res -> res } _ -> continue } } dets_traverse(transac.ref, new_fn) } /// Operations to perform on a migration. pub type MigrateOptions(a) { /// Replaces the previous value with the new one. Update(a) /// Maintains the value as it currently is. /// /// Keep in mind that if the value does not conform /// to the provided decoder, it will be unaccessible /// for both `find` and `select` functions. /// Only remaining stored for future migrations. Keep /// Removes the value from the table. Delete } /// Migrates the table to a new structure. /// When the type stored in the table changes, this function /// allows you to update the table to the new structure. /// /// # IMPORTANT /// **To avoid conflicts, this function will start its own /// transaction, so you should not use it inside another.** /// /// # Example /// /// ```gleam /// pub fn migrate_pets(table: Table(Pet)) { /// use value <- database.migrate(table) /// case decode.run(value, my_pet_decoder()) { /// Ok(pet) -> Update(pet) /// _ -> Delete /// } /// } /// ``` /// pub fn migrate( table: Table(a), migration: fn(dynamic.Dynamic) -> MigrateOptions(a), ) -> Result(Nil, FileError) { use transac <- transaction(table) let continue = erlang_binary_to_atom("continue") let func = fn(tab_value) { let #(id, dyn_value) = tab_value case migration(dyn_value) { Update(new_value) -> { let _ = dets_insert(transac.ref, #(id, new_value)) continue } Delete -> { let _ = dets_delete(transac.ref, id) continue } Keep -> continue } } let _ = dets_traverse(transac.ref, func) Nil } /// Field decoder /// /// # Example /// /// ```gleam /// let decoder = { /// use name <- database.field(0, decode.string) /// use animal <- database.field(1, my_animal_decoder) /// decode.success(Pet(name:, animal:)) /// } /// ``` /// pub fn field( field_index: Int, field_decoder: decode.Decoder(t), next: fn(t) -> decode.Decoder(final), ) { // +1 to avoid the atomic name at the start of the tuple decode.field(field_index + 1, field_decoder, next) } // Ad maiorem Dei gloriam