DataBase(K, V) becomes DataBase(V).

master
Luka Vandervelden 2019-12-18 03:43:09 +01:00
parent c4030d4179
commit 3f53034b5a
4 changed files with 186 additions and 89 deletions

View File

@ -13,11 +13,16 @@ class Ship
def_clone def_clone
property id : String property id : String
property class : String property klass : String
property name : String property name : String
property tags : Array(String) property tags : Array(String)
def initialize(@name, @class = "<unknown>", @id = UUID.random.to_s, @tags = [] of String) def initialize(@name, @klass = "<unknown>", @id = UUID.random.to_s, @tags = [] of String)
end
# Makes testing arrays of this class easier.
def <=>(other)
@name <=> other.name
end end
# Common, reusable test data. # Common, reusable test data.
@ -54,7 +59,7 @@ class Ship
# Equality is true if every property is identical. # Equality is true if every property is identical.
def ==(other) def ==(other)
@id == other.id && @class == other.class && @name == other.name && @id == other.id && @klass == other.klass && @name == other.name &&
@tags == other.tags @tags == other.tags
end end
end end
@ -99,42 +104,42 @@ describe "DODB::DataBase" do
db = DODB::SpecDataBase.new db = DODB::SpecDataBase.new
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
db[ship.id] = ship db << ship
end end
Ship.all_ships.each do |ship| db.to_a.sort.should eq(Ship.all_ships.sort)
db[ship.id].should eq(ship)
end
end end
it "rewrite already stored data" do it "rewrite already stored data" do
db = DODB::SpecDataBase.new db = DODB::SpecDataBase.new
ship = Ship.all_ships[0] ship = Ship.all_ships[0]
db[ship.id] = Ship.new "broken", id: ship.id key = db << ship
db[ship.id] = ship
db[ship.id].should eq(ship) db[key] = Ship.new "broken"
db[key] = ship
db[key].should eq(ship)
end end
it "properly remove data" do it "properly remove data" do
db = DODB::SpecDataBase.new db = DODB::SpecDataBase.new
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
db[ship.id] = ship db << ship
end end
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
db.delete ship.id db.pop
end end
Ship.all_ships.each do |ship| Ship.all_ships.each_with_index do |ship, i|
# FIXME: Should it raise a particular exception? # FIXME: Should it raise a particular exception?
expect_raises DODB::MissingEntry do expect_raises DODB::MissingEntry do
db[ship.id] db[i]
end end
db[ship.id]?.should be_nil db[i]?.should be_nil
end end
end end
end end
@ -146,10 +151,10 @@ describe "DODB::DataBase" do
db_ships_by_name = db.new_index "name", &.name db_ships_by_name = db.new_index "name", &.name
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
db[ship.id] = ship db << ship
end end
Ship.all_ships.each do |ship| Ship.all_ships.each_with_index do |ship|
db_ships_by_name.get?(ship.name).should eq(ship) db_ships_by_name.get?(ship.name).should eq(ship)
end end
end end
@ -159,14 +164,12 @@ describe "DODB::DataBase" do
db_ships_by_name = db.new_index "name", &.name db_ships_by_name = db.new_index "name", &.name
some_ship = Ship.kisaragi db << Ship.kisaragi
db[some_ship.id] = some_ship
# Should not be allowed to store an entry whose “name” field # Should not be allowed to store an entry whose “name” field
# already exists. # already exists.
expect_raises(DODB::IndexOverload) do expect_raises(DODB::IndexOverload) do
db["another id"] = some_ship db << Ship.kisaragi
end end
end end
@ -176,11 +179,11 @@ describe "DODB::DataBase" do
db_ships_by_name = db.new_index "name", &.name db_ships_by_name = db.new_index "name", &.name
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
db[ship.id] = ship db << ship
end end
Ship.all_ships.each do |ship| Ship.all_ships.each_with_index do |ship, i|
db.delete ship.id db.delete i
end end
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
@ -193,18 +196,15 @@ describe "DODB::DataBase" do
db_ships_by_name = db.new_index "name", &.name db_ships_by_name = db.new_index "name", &.name
some_ship = Ship.kisaragi key = db << Ship.kisaragi
db[some_ship.id] = some_ship
# We give the old id to the new ship, to get it replaced in # We give the old id to the new ship, to get it replaced in
# the database. # the database.
some_new_ship = Ship.all_ships[2].clone some_new_ship = Ship.all_ships[2].clone
some_new_ship.id = some_ship.id
db[some_new_ship.id] = some_new_ship db[key] = some_new_ship
db[some_new_ship.id].should eq(some_new_ship) db[key].should eq(some_new_ship)
db_ships_by_name.get?(some_new_ship.name).should eq(some_new_ship) db_ships_by_name.get?(some_new_ship.name).should eq(some_new_ship)
end end
@ -214,24 +214,24 @@ describe "DODB::DataBase" do
it "do basic partitioning" do it "do basic partitioning" do
db = DODB::SpecDataBase.new db = DODB::SpecDataBase.new
db_ships_by_class = db.new_partition "class", &.class db_ships_by_class = db.new_partition "class", &.klass
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
db[ship.id] = ship db << ship
end end
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
db_ships_by_class.get(ship.class).should contain(ship) db_ships_by_class.get(ship.klass).should contain(ship)
end end
# We extract the possible classes to do test on them. # We extract the possible classes to do test on them.
ship_classes = Ship.all_ships.map(&.class).uniq ship_classes = Ship.all_ships.map(&.klass).uniq
ship_classes.each do |klass| ship_classes.each do |klass|
partition = db_ships_by_class.get klass partition = db_ships_by_class.get klass
# A partition on “class” should contain entries that all # A partition on “class” should contain entries that all
# share the same value of “class”. # share the same value of “class”.
partition.map(&.class.==(klass)).reduce { |a, b| partition.map(&.klass.==(klass)).reduce { |a, b|
a && b a && b
}.should be_true }.should be_true
end end
@ -245,7 +245,7 @@ describe "DODB::DataBase" do
db_ships_by_tags = db.new_tags "tags", &.tags db_ships_by_tags = db.new_tags "tags", &.tags
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
db[ship.id] = ship db << ship
end end
db_ships_by_tags.get("flagship").should eq([Ship.flagship]) db_ships_by_tags.get("flagship").should eq([Ship.flagship])
@ -266,13 +266,20 @@ describe "DODB::DataBase" do
db_ships_by_tags = db.new_tags "tags", &.tags db_ships_by_tags = db.new_tags "tags", &.tags
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
db[ship.id] = ship db << ship
end end
# Removing the “flagship” tag, brace for impact. # Removing the “flagship” tag, brace for impact.
flagship = db_ships_by_tags.get("flagship")[0].clone flagship, index = db_ships_by_tags.get_with_indices("flagship")[0]
flagship.tags = [] of String flagship.tags = [] of String
db[flagship.id] = flagship db[index] = flagship
# ship, index = db_ships_by_tags.update(tag: "flagship") do |ship, index|
# ship.tags = [] of String
# db[index] = ship
# end
db_ships_by_tags.get("flagship").should eq([] of Ship) db_ships_by_tags.get("flagship").should eq([] of Ship)
end end
@ -283,18 +290,18 @@ describe "DODB::DataBase" do
db = DODB::SpecDataBase.new db = DODB::SpecDataBase.new
db_ships_by_name = db.new_index "name", &.name db_ships_by_name = db.new_index "name", &.name
db_ships_by_class = db.new_partition "class", &.class db_ships_by_class = db.new_partition "class", &.klass
db_ships_by_tags = db.new_tags "tags", &.tags db_ships_by_tags = db.new_tags "tags", &.tags
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
db[ship.id] = ship db << ship
end end
db.reindex_everything! db.reindex_everything!
Ship.all_ships.each do |ship| Ship.all_ships.each do |ship|
db_ships_by_name.get?(ship.name).should eq(ship) db_ships_by_name.get?(ship.name).should eq(ship)
db_ships_by_class.get(ship.class).should contain(ship) db_ships_by_class.get(ship.klass).should contain(ship)
end end
end end
@ -305,7 +312,7 @@ describe "DODB::DataBase" do
old_ships_by_class = old_db.new_partition "class", &.class_name old_ships_by_class = old_db.new_partition "class", &.class_name
PrimitiveShip.all_ships.each do |ship| PrimitiveShip.all_ships.each do |ship|
old_db[ship.id] = ship old_db << ship
end end
# At this point, the “old” DB is filled. Now we need to convert # At this point, the “old” DB is filled. Now we need to convert
@ -313,31 +320,31 @@ describe "DODB::DataBase" do
new_db = DODB::SpecDataBase.new "-migration-target" new_db = DODB::SpecDataBase.new "-migration-target"
new_ships_by_class = new_db.new_partition "class", &.class new_ships_by_name = new_db.new_index "name", &.name
new_ships_by_tags = new_db.new_tags "tags", &.tags new_ships_by_class = new_db.new_partition "class", &.klass
new_ships_by_tags = new_db.new_tags "tags", &.tags new_ships_by_tags = new_db.new_tags "tags", &.tags
old_db.each do |id, ship| old_db.each_with_index do |ship, index|
new_ship = Ship.new ship.name, new_ship = Ship.new ship.name,
class: ship.class_name, klass: ship.class_name,
id: ship.id, id: ship.id,
tags: Array(String).new.tap { |tags| tags: Array(String).new.tap { |tags|
tags << "name ship" if ship.name == ship.class_name tags << "name ship" if ship.name == ship.class_name
} }
new_db[new_ship.id] = new_ship new_db[index] = new_ship
end end
# At this point, the conversion is done, so… were making a few # At this point, the conversion is done, so… were making a few
# arbitrary tests on the new data. # arbitrary tests on the new data.
old_db.each do |old_id, old_ship| old_db.each_with_index do |old_ship, old_index|
ship = new_db[old_id] ship = new_db[old_index]
ship.id.should eq(old_ship.id) ship.id.should eq(old_ship.id)
ship.class.should eq(old_ship.class_name) ship.klass.should eq(old_ship.class_name)
ship.tags.any?(&.==("name ship")).should be_true if ship.name == ship.class ship.tags.any?(&.==("name ship")).should be_true if ship.name == ship.klass
end end
end end
end end

View File

@ -8,6 +8,35 @@ class DODB::DataBase(K, V)
def initialize(@directory_name : String) def initialize(@directory_name : String)
Dir.mkdir_p data_path Dir.mkdir_p data_path
self.last_index = -1
end
private def index_file
"#{@directory_name}/last-index"
end
def last_index : Int32
File.read(index_file).to_i
end
def last_index=(x : Int32)
file = File.open(index_file, "w")
file << stringify_key(x)
file.close
x
rescue
raise Exception.new "could not update index file"
end
def stringify_key(key : Int32)
# Negative numbers give strange results with Crystals printf.
if key >= 0
"%010i" % key
else
key.to_s
end
end end
## ##
@ -51,48 +80,82 @@ class DODB::DataBase(K, V)
partition.not_nil!.as(DODB::Tags).get name, key partition.not_nil!.as(DODB::Tags).get name, key
end end
def []?(key : K) : V? def <<(item : V)
index = last_index + 1
self[index] = item
self.last_index = index
end
def []?(key : Int32) : V?
self[key] self[key]
rescue MissingEntry rescue MissingEntry
# FIXME: Only rescue JSON and “no such file” errors. # FIXME: Only rescue JSON and “no such file” errors.
return nil return nil
end end
def [](key : K) : V def [](key : Int32) : V
raise MissingEntry.new(key) unless ::File.exists? file_path key raise MissingEntry.new(key) unless ::File.exists? file_path key
read file_path key read file_path key
end end
def []=(key : K, value : V) def []=(index : Int32, value : V)
old_value = self.[key]? old_value = self.[index]?
check_collisions! key, value, old_value check_collisions! index, value, old_value
# Removes any old indices or partitions pointing to a value about # Removes any old indices or partitions pointing to a value about
# to be replaced. # to be replaced.
if old_value if old_value
remove_partitions key, old_value remove_partitions index, old_value
end end
# Avoids corruption in case the application crashes while writing. # Avoids corruption in case the application crashes while writing.
file_path(key).tap do |path| file_path(index).tap do |path|
::File.write "#{path}.new", value.to_json ::File.write "#{path}.new", value.to_json
::FileUtils.mv "#{path}.new", path ::FileUtils.mv "#{path}.new", path
end end
write_partitions key, value write_partitions index, value
if index > last_index
self.last_index = index
end
end end
def check_collisions!(key : K, value : V, old_value : V?) def check_collisions!(key : Int32, value : V, old_value : V?)
@indexers.each &.check!(key, value, old_value) @indexers.each &.check!(stringify_key(key), value, old_value)
end end
def write_partitions(key : K, value : V) def write_partitions(key : Int32, value : V)
@indexers.each &.index(key, value) @indexers.each &.index(stringify_key(key), value)
end end
def delete(key : K) def pop
index = last_index
# Some entries may have been removed. Well skip over those.
# Not the most efficient if a large number of indices are empty.
while index >= 0 && self[index]?.nil?
index = index - 1
end
if index < 0
return nil
end
poped = self[index]
self.delete index
last_index = index - 1
poped
end
def delete(key : Int32)
value = self[key]? value = self[key]?
return if value.nil? return if value.nil?
@ -108,14 +171,14 @@ class DODB::DataBase(K, V)
value value
end end
def remove_partitions(key : K, value : V) def remove_partitions(key : Int32, value : V)
@indexers.each &.deindex(key, value) @indexers.each &.deindex(stringify_key(key), value)
end end
## ##
# CAUTION: Very slow. Try not to use. # CAUTION: Very slow. Try not to use.
# Can be useful for making dumps or to restore a database, however. # Can be useful for making dumps or to restore a database, however.
def each def each_with_index
dirname = data_path dirname = data_path
Dir.each_child dirname do |child| Dir.each_child dirname do |child|
next if child.match /^\./ next if child.match /^\./
@ -129,20 +192,36 @@ class DODB::DataBase(K, V)
next next
end end
# FIXME: Will only work for String. :( key = child.gsub(/\.json$/, "").to_i
key = child.gsub /\.json$/, ""
yield key, field yield field, key
end
end
def each
each_with_index do |item, index|
yield item
end end
end end
## ##
# CAUTION: Very slow. Try not to use. # CAUTION: Very slow. Try not to use.
def to_h def to_a
hash = ::Hash(K, V).new array = ::Array(V).new
each do |key, value| each do |value|
hash[key] = value array << value
end
array
end
##
# CAUTION: Very slow. Try not to use.
def to_h
hash = ::Hash(Int32, V).new
each_with_index do |element, index|
hash[index] = element
end end
hash hash
@ -152,25 +231,29 @@ class DODB::DataBase(K, V)
"#{@directory_name}/data" "#{@directory_name}/data"
end end
private def file_path(key : K) private def file_path(key : Int32)
"#{data_path}/#{key.to_s}.json" "#{data_path}/%010i.json" % key
end end
private def read(file_path : String) private def read(file_path : String)
V.from_json ::File.read file_path V.from_json ::File.read file_path
end end
private def remove_data!
FileUtils.rm_rf data_path
Dir.mkdir_p data_path
end
# A very slow operation that removes all indices and then rewrites # A very slow operation that removes all indices and then rewrites
# them all. # them all.
# FIXME: Is this really useful in its current form? We should remove the
# index directories, not the indices based on our current (and
# possiblly different from whats stored) data.
def reindex_everything! def reindex_everything!
old_data = to_h old_data = to_h
old_data.each do |key, value| old_data.each do |index, item|
self.delete key self[index] = item
end
old_data.each do |key, value|
self[key] = value
end end
end end
end end

View File

@ -1,7 +1,7 @@
class DODB::MissingEntry < Exception class DODB::MissingEntry < Exception
getter index : String? getter index : String?
getter key : String getter key : String | Int32
def initialize(@index, @key) def initialize(@index, @key)
super "no entry in index '#{@index}' for key '#{@key}''" super "no entry in index '#{@index}' for key '#{@key}''"

View File

@ -14,7 +14,7 @@ class DODB::Tags(V) < DODB::Indexer(V)
indices = key_proc.call value indices = key_proc.call value
indices.each do |index| indices.each do |index|
symlink = get_tagged_entry_path(key.to_s, index) symlink = get_tagged_entry_path(key, index)
Dir.mkdir_p ::File.dirname symlink Dir.mkdir_p ::File.dirname symlink
@ -38,20 +38,27 @@ class DODB::Tags(V) < DODB::Indexer(V)
return true # Tags dont have collisions or overloads. return true # Tags dont have collisions or overloads.
end end
def get(key) : Array(V) def get_with_indices(key) : Array(Tuple(V, Int32))
r_value = Array(V).new r_value = Array(Tuple(V, Int32)).new
partition_directory = "#{get_tag_directory}/#{key}" partition_directory = "#{get_tag_directory}/#{key}"
return r_value unless Dir.exists? partition_directory return r_value unless Dir.exists? partition_directory
Dir.each_child partition_directory do |child| Dir.each_child partition_directory do |child|
r_value << V.from_json ::File.read "#{partition_directory}/#{child}" r_value << {
V.from_json(::File.read("#{partition_directory}/#{child}")),
File.basename(child).gsub(/\.json$/, "").to_i
}
end end
r_value r_value
end end
def get(key) : Array(V)
get_with_indices(key).map &.[0]
end
private def get_tag_directory private def get_tag_directory
"#{@storage_root}/by_tags/by_#{@name}" "#{@storage_root}/by_tags/by_#{@name}"
end end
@ -60,7 +67,7 @@ class DODB::Tags(V) < DODB::Indexer(V)
"#{get_tag_directory}/#{index_key}/#{key}.json" "#{get_tag_directory}/#{index_key}/#{key}.json"
end end
private def get_data_symlink(key) private def get_data_symlink(key : String)
"../../../data/#{key}.json" "../../../data/#{key}.json"
end end
end end