require "file_utils" # Basic indexes for 1-to-1 relations. # Uncached version. # # ``` # cars_by_name = car_database.new_uncached_index "name", &.name # ``` # # This index provides a file-system representation, enabling the administrators to # select a value based on its index. The following example presents an index named "id" # with some data indexed by an UUID attribute. # # ```plain # storage # ├── data # │   ├── 0000000000 # │   ├── 0000000001 # │   └── 0000000002 # ├── indices # │   └── by_id <- this is an example of index named "id" # │   ├── 6e109b82-25de-4250-9c67-e7e8415ad5a7 -> ../../data/0000000000 # │   ├── 2080131b-97d7-4300-afa9-55b93cdfd124 -> ../../data/0000000001 # │   └── 8b4e83e3-ef95-40dc-a6e5-e6e697ce6323 -> ../../data/0000000002 # ``` # # NOTE: no cache, thus considered as *slow* for creation, deletion **and retrieval**. # NOTE: see `IndexCached` for a cached version, faster for retrieval. # NOTE: for fast operations without fs representation, see `IndexRAMOnly`. class DODB::Trigger::Index(V) < DODB::Trigger(V) # Name of the index, such as *id* or *color* for example. # This is an arbitrary value, mostly to create the index directory. # # NOTE: used for internal operations. property name : String # Procedure to retrieve the index attribute from the value, used for **internal operations**. property key_proc : Proc(V, String | NoIndex) # Root database directory, used for **internal operations**. getter storage_root : String # Reference to the database instance, used for **internal operations**. @storage : DODB::Storage(V) # To create an index from a database, use `DODB::Storage#new_index` to create # a cached index, `DODB::Storage#new_uncached_index` for an uncached index or # `DODB::Storage#new_RAM_index` for a RAM-only index. # # WARNING: this is an internal operation, do not instanciate an index by hand. def initialize(@storage : DODB::Storage(V), @storage_root : String, @name : String, @key_proc : Proc(V, String | NoIndex)) Dir.mkdir_p trigger_directory end def check!(key : String, value : V, old_value : V?) index_key = key_proc.call value return if index_key.is_a? NoIndex symlink = file_path_index index_key.to_s if ::File.symlink? symlink # In case both old and new values are pointing to the same key, # this is not considered a collision. if old_value old_key = key_proc.call old_value return if index_key == old_key end raise IndexOverload.new "index '#{@name}' is overloaded for key '#{key}', file #{symlink} exists" end end # :inherit: def index(key : String, value : V) index_key = key_proc.call value return if index_key.is_a? NoIndex symlink = file_path_index index_key Dir.mkdir_p ::File.dirname symlink ::File.symlink get_data_symlink_index(key), symlink end # :inherit: def deindex (key : String, value : V) index_key = key_proc.call value return if index_key.is_a? NoIndex symlink = file_path_index index_key begin ::File.delete symlink rescue File::NotFoundError end end # Gets the key (ex: 343) for an entry in the DB from an indexed value, used for **internal operations**. # # Reads the link in `db/indices/by_#{name}/`. # # Useful for internal purposes, to retrieve a value use `#get`. # # ``` # internal_database_key_for_the_corvet = cars_by_name.get_key "Corvet" # ``` # # NOTE: used for internal operations. def get_key(index : String) : Int32 get_key_on_fs index end # Gets data from an indexed value (throws an exception on a missing entry). # # ``` # corvet = cars_by_name.get "Corvet" # ``` # # WARNING: throws an exception if the value isn't found. # NOTE: for a safe version, use `#get?`. def get(index : String) : V @storage[get_key index] end # Gets data from an indexed value without throwing an exception on a missing entry. # # ``` # corvet = cars_by_name.get? "Corvet" # ``` # # NOTE: safe version of `#get`, returns a *nil* value in case of a missing entry instead of an exception. def get?(index : String) : V? get index rescue MissingEntry nil end # Gets data from an indexed value (thread-safe via two file locks) and gives it to a provided block of code. # # WARNING: should be thread-safe only between other `#safe_get` and `#safe_get?` calls, # index creations and deletions do not use the same locks! # NOTE: on exception, releases all locks. def safe_get(index : String) : Nil @storage.request_lock @name, index internal_key = get_key(index).to_s @storage.request_lock internal_key begin yield get index rescue e # On exception, returns the exception after releasing locks. @storage.release_lock internal_key @storage.release_lock @name, index raise e end @storage.release_lock internal_key @storage.release_lock @name, index end # Same as `#safe_get` but doesn't throw an exception on a missing value # (provided block of code receives a *nil* value). # # WARNING: should be thread-safe only between other `#safe_get` and `#safe_get?` calls, # index creations and deletions do not use the same locks! # NOTE: on exception, releases all locks. def safe_get?(index : String, &block : Proc(V | Nil, Nil)) : Nil safe_get index, &block rescue MissingEntry yield nil end # Reads the indexed symlink to find its related key, used for **internal operations**. # # For example, for a car indexed by its name: # # ``` # storage # ├── data # │   └── 0000000343 # └── indices #    └── by_name #    └── Corvet -> ../../data/0000000343 # ``` # # `#get_key_on_fs` reads the *storage/indices/by_name/Corvet* symlink and gets # the name of the data file ("000000343") and converts it in an integer, # which is the key in the database. # # NOTE: used for internal operations. def get_key_on_fs(index : String) : Int32 file_path = file_path_index index raise MissingEntry.new(@name, index) unless ::File.symlink? file_path ::File.readlink(file_path).sub(/^.*\//, "").to_i end # Updates a value based on its indexed attribute (which must not have changed). # # ``` # # Update the car "corvet" in the database. # car_by_name.update corvet # ``` # WARNING: in case the indexed attribute has changed, use `#update(index, value)`. def update(new_value : V) index = key_proc.call new_value raise Exception.new "new value is not indexable" if index.is_a? NoIndex update index, new_value end # Updates a value based on its indexed attribute (which may have changed). # # ``` # # Update the car "corvet" in the database. # car_by_name.update "Corvet", corvet # ``` # NOTE: in case the indexed attribute hasn't changed, you may prefer `#update(value)`. def update(index : String, new_value : V) key = get_key index @storage[key] = new_value end # Updates a value. Creates it if necessary. # # ``` # # Update or create the car "corvet" in the database. # car_by_name.update_or_create corvet # ``` # WARNING: use `#update_or_create(index, value)` if the indexed value may have changed. def update_or_create(new_value : V) update new_value rescue MissingEntry @storage << new_value end # Same as `#update_or_create(value)` but handles changed indexes. # # ``` # # Update or create the car named "Corvet" in the database. # # Its name may have changed in the object "corvet". # car_by_name.update_or_create "Corvet", corvet # ``` # NOTE: safe version in case the index has changed. def update_or_create(index : String, new_value : V) update index, new_value rescue MissingEntry @storage << new_value end # Deletes a value based on its index. # # ``` # # Deletes the car named "Corvet". # car_by_name.delete "Corvet" # ``` # WARNING: may throw a MissingEntry exception. def delete(index : String) key = get_key index @storage.delete key end # Deletes a value based on its index, but do ignores a MissingEntry error. # # ``` # # Deletes the car named "Corvet" (no MissingEntry exception if the car doesn't exist). # car_by_name.delete? "Corvet" # ``` def delete?(v : String) delete v rescue MissingEntry nil end # :inherit: def trigger_directory : String "#{@storage_root}/indices/by_#{@name}" end # FIXME: Now that it’s being used outside of this class, name it properly. def file_path_index(index_key : String) "#{trigger_directory}/#{index_key}" end # Creates the relative path to the data from the indexing directory. private def get_data_symlink_index(key : String) "../../data/#{key}" end end # Basic indexes for 1-to-1 relations. # Cached version. # # ``` # cars_by_name = car_database.new_index "name", &.name # ``` # # The cache makes this index fast and since the index doesn't contain # the full value but just an attribute and a key, memory usage is still reasonable. # # A few file-system operations are required on index creation and deletion, # thus this version still is slow for both these operations. # # ```plain # storage # ├── data # │   ├── 0000000000 # │   ├── 0000000001 # │   └── 0000000002 # ├── indices # │   └── by_id <- this is an example of index named "id" # │   ├── 6e109b82-25de-4250-9c67-e7e8415ad5a7 -> ../../data/0000000000 # │   ├── 2080131b-97d7-4300-afa9-55b93cdfd124 -> ../../data/0000000001 # │   └── 8b4e83e3-ef95-40dc-a6e5-e6e697ce6323 -> ../../data/0000000002 # ``` # # NOTE: cached, reasonable amount of memory used since it's just an index. # NOTE: fast for retrieval, slow for index creation and deletion (fs operations). # NOTE: see `DODB::Trigger::Index` for an uncached version, even less memory-hungry. # NOTE: for fast operations without fs representation, see `IndexRAMOnly`. class DODB::Trigger::IndexCached(V) < DODB::Trigger::Index(V) # This hash contains the relation between the index key and the data key, used for # **internal operations**. # # WARNING: used for internal operations, do not change its content or access it directly. property data = Hash(String, Int32).new def check!(key : String, value : V, old_value : V?) index_key = key_proc.call value return if index_key.is_a? NoIndex if data[index_key]? # In case both old and new values are pointing to the same key, # this is not considered a collision. if old_value old_key = key_proc.call old_value return if index_key == old_key end raise IndexOverload.new "index '#{@name}' is overloaded for key '#{key}'" end end def clear_cache! data.clear end # Clears the cache and removes the `#trigger_directory`. def nuke_trigger super clear_cache! end # Indexes the value on the file-system as `DODB::Trigger::Index#index` but also puts the index in a cache. # # NOTE: used for internal operations. def index(key, value) index_key = key_proc.call value return if index_key.is_a? NoIndex super(key, value) @data[index_key] = key.to_i end # Removes the index of a value on the file-system as `DODB::Trigger::Index#deindex` but also from # the cache, used for **internal operations**. # # NOTE: used for internal operations. def deindex(key, value) index_key = key_proc.call value return if index_key.is_a? NoIndex super(key, value) @data.delete index_key end # Gets the key (ex: 343) for an entry in the DB. # With caching, the key is probably stored in a hash, or we'll search in the FS. # # NOTE: used for internal operations. def get_key(index : String) : Int32 if k = @data[index]? k elsif k = get_key_on_fs(index) @data[index] = k k else raise MissingEntry.new(@name, index) end end end # Basic indexes for 1-to-1 relations. # RAM-only version, no file-system representation. # # ``` # cars_by_name = car_database.new_RAM_index "name", &.name # ``` # # Since there is no file-system operations, all the operations are fast. # `DODB::Trigger::IndexRAMOnly` enables the flexibility of indexes without a file-system representation # for absolute efficiency. # Exactly as easy to use as the other index implementations. # # NOTE: reasonable amount of memory used since it's just an index. # NOTE: fast for all operations, but no file-system representation. class DODB::Trigger::IndexRAMOnly(V) < DODB::Trigger::IndexCached(V) # Indexes a value in RAM, no file-system operation. # # NOTE: used for internal operations. def index(key, value) index_key = key_proc.call value return if index_key.is_a? NoIndex @data[index_key] = key.to_i end # Removes the index of a value in RAM, no file-system operation. # # NOTE: used for internal operations. def deindex(key, value) index_key = key_proc.call value return if index_key.is_a? NoIndex @data.delete index_key end # Gets the key (ex: 343) for an entry in the DB. # With a RAM-only index, the key is necessarily stored in the hash. # # NOTE: used for internal operations. def get_key(index : String) : Int32 if k = @data[index]? k else raise MissingEntry.new(@name, index) end end # Clears the index. def nuke_trigger clear_cache! end end