diff --git a/.rubocop.yml b/.rubocop.yml index f136fc0..a6a3c94 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,11 +4,15 @@ AllCops: - 'spec/**/*' - 'test/**/*' - 'bin/**/*' + - 'lib/simple/store.old/**/*' + - 'spec.old/**/*' - 'tasks/release.rake' - '*.gemspec' - 'Gemfile' - 'Rakefile' - + - 'scripts/*' + - 'config/*' + Metrics/LineLength: Max: 120 diff --git a/bin/console b/bin/console index 9393748..5a327c0 100755 --- a/bin/console +++ b/bin/console @@ -30,6 +30,8 @@ end Reload.load_file "config/console-init.rb" +require "benchmark" + require "irb" require "irb/completion" diff --git a/lib/simple-store.rb b/lib/simple-store.rb new file mode 100644 index 0000000..d0f36f2 --- /dev/null +++ b/lib/simple-store.rb @@ -0,0 +1,3 @@ +# rubocop:disable Naming/FileName + +require "simple/store" diff --git a/lib/simple/sql/helpers/immutable.rb b/lib/simple/sql/helpers/immutable.rb index c640dcc..256322a 100644 --- a/lib/simple/sql/helpers/immutable.rb +++ b/lib/simple/sql/helpers/immutable.rb @@ -50,6 +50,10 @@ def respond_to_missing?(method_name, include_private = false) super end + def to_hash + @hsh + end + def inspect "" end @@ -65,6 +69,8 @@ def ==(other) if $PROGRAM_NAME == __FILE__ + # rubocop:disable Metrics/AbcSize + require "test-unit" class Simple::SQL::Helpers::Immutable::TestCase < Test::Unit::TestCase diff --git a/lib/simple/sql/helpers/row_converter.rb b/lib/simple/sql/helpers/row_converter.rb index 5f024e8..43134ec 100644 --- a/lib/simple/sql/helpers/row_converter.rb +++ b/lib/simple/sql/helpers/row_converter.rb @@ -1,31 +1,99 @@ require_relative "./immutable" +require_relative "../reflection" module Simple::SQL::Helpers::RowConverter SELF = self + Reflection = ::Simple::SQL::Reflection + # returns an array of converted records def self.convert_row(records, into:, associations: nil, fq_table_name: nil) hsh = records.first return records unless hsh - converter = if into == :struct - StructConverter.for(attributes: hsh.keys, associations: associations) - elsif into == :immutable - ImmutableConverter.new(type: into, associations: associations) - elsif into.respond_to?(:new_from_row) - TypeConverter2.new(type: into, associations: associations, fq_table_name: fq_table_name) - else - TypeConverter.new(type: into, associations: associations) - end - + converter = build_converter(hsh, into: into, associations: associations, fq_table_name: fq_table_name) records.map { |record| converter.convert_row(record) } end + # This method builds and returns a converter object which provides a convert_row method. + # which in turn is able to convert a Hash (the row as read from the query) into the + # target type +into+ + # + # The into: parameter designates the target type. The following scenarios are supported: + # + # 1. Converting into Structs (into == :struct) + # + # Converts records into a dynamically created struct. This has the advantage of being + # really fast. + # + # 2. Converting into Immutables (into == :immutable) + # + # Converts records into a Immutable object. The resulting objects implement getters for all + # existing attributes, including embedded arrays and hashes, but do not offer setters - + # resulting in de-facto readonly objects. + # + # 3. Custom targets based on table name + # + # If an object is passed in that implements a "from_complete_row" method, and the query + # results are complete - i.e. all columns in the underlying table (via fq_table_name) + # are in the resulting records - this mode uses the +into+ object's +from_complete_row+ + # method to convert the records. This mode is used by Simple::Store. + # + # If the records are not complete, and contain more than a single value this mode behaves + # like :immutable. + # + # If the records are not complete and contain only a single value this mode returns that + # value for each record. + # + # 4. Other custom types + # + # If an object is passed in that does not implement "from_complete_row" the object's + # +new+ method is called on each row's hash. This allows to run queries against all classes + # that can be built from a Hash, for example OpenStructs. + def self.build_converter(hsh, into:, associations: nil, fq_table_name: nil) + case into + when :struct + StructConverter.for(attributes: hsh.keys, associations: associations) + when :immutable + ImmutableConverter.new(type: into, associations: associations) + else + build_custom_type_converter(hsh, into: into, associations: associations, fq_table_name: fq_table_name) + end + end + + def self.build_custom_type_converter(hsh, into:, associations: nil, fq_table_name: nil) + # If the into object does not provide a :from_complete_row method, we'll use + # a converter that creates objects via ".new" + unless into.respond_to?(:from_complete_row) + return TypeConverter.new(type: into, associations: associations) + end + + # If the query results are complete we'll use the into object + required_columns = Reflection.columns(fq_table_name) + actual_columns = hsh.keys.map(&:to_s) + + if (required_columns - actual_columns).empty? + return CompleteRowConverter.new(type: into, associations: associations, fq_table_name: fq_table_name) + end + + # If the query only has a single value we'll extract the value + return SingleValueConverter.new if hsh.count == 1 + + # Otherwise we'll fall back to :immutable + ImmutableConverter.new(type: into, associations: associations) + end + def self.convert(record, into:) # :nodoc: ary = convert_row([record], into: into) ary.first end + class SingleValueConverter + def convert_row(hsh) + hsh.values.first + end + end + class TypeConverter #:nodoc: def initialize(type:, associations:) @type = type @@ -64,7 +132,7 @@ def build_row_in_target_type(hsh) end end - class TypeConverter2 < TypeConverter #:nodoc: + class CompleteRowConverter < TypeConverter #:nodoc: def initialize(type:, associations:, fq_table_name:) super(type: type, associations: associations) @fq_table_name = fq_table_name @@ -72,7 +140,7 @@ def initialize(type:, associations:, fq_table_name:) def convert_row(hsh) hsh = convert_associations(hsh) if @associations - @type.new_from_row hsh, fq_table_name: @fq_table_name + @type.from_complete_row hsh, fq_table_name: @fq_table_name end end diff --git a/lib/simple/store.rb b/lib/simple/store.rb new file mode 100644 index 0000000..1e4cd0e --- /dev/null +++ b/lib/simple/store.rb @@ -0,0 +1,162 @@ +require "simple-sql" + +module Simple; end +module Simple::Store; end + +require_relative "store/errors" +require_relative "store/model" +require_relative "store/metamodel" +require_relative "store/registry" +require_relative "store/storage" +require_relative "store/create" +require_relative "store/delete" +require_relative "store/find" +require_relative "store/validation" +require_relative "store/update" + +# A simple document store +# +# The document store allows one to create, update, destroy records of specific +# types. In doing so validations are applied at the right times. +# +# A type has these attributes: +# +# - a table name. +# - a fixed list of attributes. A basic list is derived from the database layout. +# - a fixed list of associations. A basic list is derived from the database layout. +# +# Custom types provide their own list of attributes and associations. Both attributes +# and associations are registered with a name and a type. +# +# All attributes that do not exist as columns in the table are called "dynamic" +# attributes and read from a meta_data JSONB attribute. Type conversions are done to +# convert from and to types that are not supported by JSON (mostly dates and boolean.) +# +module Simple::Store + extend self + + # Runs a query and returns the first result row of a query. + # + # Examples: + # + # - Simple::Store.ask "SELECT * FROM users WHERE email=$? LIMIT 1", "foo@local" + def ask(*args, into: nil) + into ||= Storage + Simple::SQL.ask(*args, into: into) + end + + # Runs a query and returns all resulting row of a query, as Simple::Store::Model objects. + # + # Examples: + # + # - Simple::Store.all "SELECT * FROM users WHERE email=$?", "foo@local" + def all(*args, into: nil) + into ||= Storage + Simple::SQL.all(*args, into: into) + end + + # build one or more objects, verify, and save. + # + # Parameters: + # + # - metamodel: the type [Metamodel, String] + # - values: a Hash or an Array of Hashes + # + # Returns the newly created model or an array of newly created models. + # + # If validation fails for at least one of the objects no objects will be created. + def create!(metamodel, values, on_conflict: nil) + metamodel = Metamodel.resolve(metamodel) + + if values.is_a?(Array) + Create.create!(metamodel, values, on_conflict: on_conflict) + else + Create.create!(metamodel, [values], on_conflict: on_conflict).first + end + end + + # build one or more objects + # + # Parameters: + # + # - metamodel: the type [Metamodel, String] + # - values: a Hash or an Array of Hashes + # + # Returns the built model or an array of built models. + def build(metamodel, values) + metamodel = Metamodel.resolve(metamodel) + + if values.is_a?(Array) + Create.build(metamodel, values) + else + Create.build(metamodel, [values]).first + end + end + + # Validate the passed in models. + # + # Raises an error if any model is invalid. + def validate!(models) + Validation.validate!(Array(models)) + end + + # Delete one or more models from the database. + # + # def delete!(models) + # def delete!(metamodels, ids) + # + # Returns a copy of the deleted models + def delete!(*args) + Delete.delete args, force: true + end + + # def delete(models) + # def delete(metamodels, ids) + def delete(*args) + Delete.delete args, force: false + end + + # -- finding ---------------------------------------------------------------- + + # Reload a model fro the database + def reload(model) + raise ArgumentError, "Cannot reload a unsaved model (of type #{model.metamodel.name.inspect})" if model.new_record? + + find(model.metamodel, model.id) + end + + # Find a nuber of models in the database + # + # Parameters: + # + # - metamodels: a Metamodel, String, or an Array of Metamodels and/or Strings + # - ids: an ID value or an array of ids. + # + # Returns a array of newly built models. + def find(metamodels, ids) + expect! ids => [Array, Integer] + + metamodels = Metamodel.resolve(metamodels) + + if ids.is_a?(Integer) + Simple::Store::Find.find!(metamodels, [ids]).first + else + Simple::Store::Find.find!(metamodels, ids) + end + end + + # verify, and save one or more models. + def save!(models) + return save!([models]).first unless models.is_a?(Array) + + validate! models + + models.map do |model| + if model.new_record? + Create.create_model_during_save(model) + else + Update.update_model(model) + end + end + end +end diff --git a/lib/simple/store/create.rb b/lib/simple/store/create.rb new file mode 100644 index 0000000..d581601 --- /dev/null +++ b/lib/simple/store/create.rb @@ -0,0 +1,39 @@ +# require "active_support/core_ext/string/inflections" + +module Simple::Store::Create + Storage = Simple::Store::Storage + + extend self + + def create_model_during_save(model) + created_model = create!(model.metamodel, [model.to_hash], on_conflict: nil).first + model.send(:set_id_by_trusted_caller, created_model.id) + created_model + end + + def create!(metamodel, values, on_conflict:) + models = build(metamodel, values) + + validate_on_create! models + Simple::Store.validate! models + + records = Storage.convert_to_storage_representation(metamodel, models) + Simple::SQL.insert(metamodel.table_name, records, into: Storage, on_conflict: on_conflict) + end + + def build(metamodel, values) + values.map do |value| + metamodel.build({}).assign(value) + end + end + + private + + def validate_on_create!(models) + return validate_on_create!([models]) unless models.is_a?(Array) + + models.each do |model| + raise ArgumentError, "You cannot pass in an :id attribute" unless model.id.nil? + end + end +end diff --git a/lib/simple/store/delete.rb b/lib/simple/store/delete.rb new file mode 100644 index 0000000..644cdc4 --- /dev/null +++ b/lib/simple/store/delete.rb @@ -0,0 +1,60 @@ +# require "active_support/core_ext/string/inflections" + +require_relative "./helpers" + +module Simple::Store::Delete + extend self + + Store = ::Simple::Store + H = ::Simple::Store::Helpers + Metamodel = ::Simple::Store::Metamodel + + def delete(args, force:) + case args.length + when 1 + models = args.first + if models.is_a?(Array) + delete1(models, force: force) + else + delete1([models], force: force).first + end + when 2 + metamodels, ids = *args + metamodels = Metamodel.resolve metamodels + metamodels = Array(metamodels) + + if ids.is_a?(Array) + delete2(metamodels, ids, force: force) + else + delete2(metamodels, [ids], force: force).first + end + else + raise ArgumentError, "Invalid # of arguments to Store.delete!" + end + end + + private + + def delete1(models, force:) + # get all referenced metamodels. We need this to later pass it on + # to delete2, which also verifies that they refer to only one table. + metamodel_by_name = {} + models.each do |model| + metamodel_by_name[model.metamodel.name] ||= model.metamodel + end + metamodels = metamodel_by_name.values + + requested_ids = models.map(&:id) + delete2 metamodels, requested_ids, force: force + end + + def delete2(metamodels, requested_ids, force:) + table_name = H.table_name_for_metamodels(metamodels) + + SQL.transaction do + records = Store.all "DELETE FROM #{table_name} WHERE id = ANY ($1) RETURNING *", requested_ids + H.return_results_if_complete! metamodels, requested_ids, records if force + records + end + end +end diff --git a/lib/simple/store/errors.rb b/lib/simple/store/errors.rb new file mode 100644 index 0000000..9bfa0f5 --- /dev/null +++ b/lib/simple/store/errors.rb @@ -0,0 +1,37 @@ +module Simple::Store + class InvalidArguments < ArgumentError + def initialize(errors) + @errors = errors + super() + end + + attr_reader :errors + + def message + errors + .map { |key, error| "#{key}: #{error.map { |e| e.is_a?(String) ? e : e.inspect }.join(', ')}" } + .join(", ") + end + end + + class RecordNotFound < RuntimeError + def initialize(metamodels, missing_ids) + @metamodels = Array(metamodels) + @missing_ids = missing_ids + super() + end + + attr_reader :metamodels + attr_reader :missing_ids + + def message + types = metamodels.map(&:name).join(", ") + + if missing_ids.length > 1 + "#{types}: cannot find records with ids #{missing_ids.join(', ')}" + else + "#{types}: cannot find record with id #{missing_ids.first}" + end + end + end +end diff --git a/lib/simple/store/find.rb b/lib/simple/store/find.rb new file mode 100644 index 0000000..2f973e5 --- /dev/null +++ b/lib/simple/store/find.rb @@ -0,0 +1,47 @@ +# require "active_support/core_ext/string/inflections" + +require_relative "./helpers" + +module Simple::Store::Find + extend self + + Store = ::Simple::Store + H = ::Simple::Store::Helpers + + def find!(metamodels, requested_ids) + expect! requested_ids => Array + + return [] if requested_ids.empty? + + sql = find_sql(metamodels, requested_ids) + + rows = Store.all(sql, requested_ids) + + H.return_results_if_complete!(metamodels, requested_ids, rows) + end + + private + + def find_sql(metamodels, requested_ids) + table_name = H.table_name_for_metamodels metamodels + + # [TODO] check for types + + case requested_ids.length + when 1 + <<~SQL + SELECT * FROM #{table_name} __scope__ + WHERE __scope__.id = ANY ($1) + SQL + else + <<~SQL + SELECT * FROM #{table_name} __scope__ + LEFT JOIN ( + select * from unnest($1::bigint[]) with ordinality + ) AS __order__(id, ordinality) ON __order__.id=__scope__.id + WHERE __scope__.id= ANY($1) + ORDER BY __order__.ordinality + SQL + end + end +end diff --git a/lib/simple/store/helpers.rb b/lib/simple/store/helpers.rb new file mode 100644 index 0000000..49d3f38 --- /dev/null +++ b/lib/simple/store/helpers.rb @@ -0,0 +1,41 @@ +# rubocop:disable Metrics/AbcSize + +module Simple::Store::Helpers + extend self + + Store = ::Simple::Store + + def return_results_if_complete!(metamodels, requested_ids, records) + # we assume that no records will be passed in that haven't bee requested via + # requested_ids. + return records if records.count == requested_ids.count + + # If an id is listed twice in requested_ids the # of records will be + # less than the # of requested ids. We check for this one - one should + # never pass in duplicate ids. + raise ArgumentError, "requested_ids should not have duplicates" if requested_ids.length != requested_ids.uniq.length + + found_ids = records.map(&:id) + missing_ids = requested_ids - found_ids + + raise Store::RecordNotFound.new(metamodels, missing_ids) + end + + def table_name_for_metamodels(metamodels) + unless metamodels.is_a?(Array) + expect! metamodels => Store::Metamodel + return metamodels.table_name + end + + metamodels.each do |metamodel| + expect! metamodel => Store::Metamodel + end + + return metamodels.first.table_name if metamodels.length == 1 + + metamodels = metamodels.uniq(&:table_name) + return metamodels.first.table_name if metamodels.length == 1 + + raise ArgumentError, "Duplicate tables requested: #{metamodels.map(&:table_name).uniq.inspect}" + end +end diff --git a/lib/simple/store/metamodel.rb b/lib/simple/store/metamodel.rb new file mode 100644 index 0000000..593d4b2 --- /dev/null +++ b/lib/simple/store/metamodel.rb @@ -0,0 +1,167 @@ +class Simple::Store::Metamodel; end + +require_relative "metamodel/registry" +require_relative "storage" + +# rubocop:disable Metrics/ClassLength +class Simple::Store::Metamodel + SELF = self + NAME_REGEXP = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/ + + Model = ::Simple::Store::Model + Storage = ::Simple::Store::Storage + + def self.register(name, table_name:, &block) + expect! name => /^[A-Z]/ + expect! table_name => String + + metamodel = new(name: name, table_name: table_name, &block) + + Registry.register(metamodel) + end + + def self.register_virtual_attributes(name, &block) + resolve(name).register_virtual_attributes(&block) + end + + def self.unregister(name) + Registry.unregister(name) + end + + # Casts the \a name_or_metamodel parameter into a Metamodel object. + def self.resolve(name_or_metamodel) + if name_or_metamodel.is_a?(Array) + return name_or_metamodel.map { |m| resolve(m) } + end + + expect! name_or_metamodel => [String, self] + + if name_or_metamodel.is_a?(String) + Registry.lookup(name_or_metamodel) + else + name_or_metamodel + end + end + + # The name of the Metamodel + attr_reader :name + + # The name of the backing database table + attr_reader :table_name + + OPTIONS_DEFAULTS = { + readable: true, + writable: true, + kind: :dynamic + } + + # register an attribute + def attribute(name, type = nil, options = nil) + # -- apply type and options defaults -------------------------------------- + + if type.nil? && options.nil? + type, options = :string, {} + elsif options.nil? + if type.is_a?(Hash) + type, options = :string, type + else + type, options = type, {} + end + end + + expect! name => [Symbol, String] + expect! type => [Symbol, String] + + # -- define an attribute -------------------------------------------------- + + name = name.to_s + + options = options.dup + options[:type] = type + + current_options = @attributes[name] || OPTIONS_DEFAULTS + options = current_options.merge(options) + if options[:kind] == :virtual + options[:writable] = false + end + + @attributes[name] ||= {} + @attributes[name].merge!(options) + + @filtered_attributes&.clear # see attributes method + + self + end + + def register_virtual_attributes(&block) + @virtual_attributes ||= Module.new + @virtual_attributes.instance_eval(&block) + self + end + + # returns an object which has implementations for virtual attributes mixed in. + def virtual_implementations + @virtual_attributes + end + + def resolve_virtual_attribute(model, name) + @virtual_attributes.send(name, model) + end + + # A hash mapping attribute names to attribute specifications. + # attr_reader :attributes # Hash name -> {} + + def attributes(filter = nil) + return @attributes if filter.nil? + + @filtered_attributes ||= {} + @filtered_attributes[filter] ||= begin + expect! filter => { + dynamic: [true, false, nil], + static: [true, false, nil] + } + + @attributes.select do |_name, options| + filter.all? do |filter_name, filter_value| + next true if filter_value.nil? + next true if options[filter_name] == filter_value + false + end + end + end + end + + def attribute?(name) + attributes.key?(name) + end + + def writable_attribute?(name) + attributes(writable: true).key?(name) + end + + def build(hsh) + Model.new(self).assign(hsh) + end + + # Validate a model + def validate!(_model) + :nop + end + + def initialize(attrs, &block) + expect! attrs => { + name: [NAME_REGEXP, nil], + table_name: String + } + + name, table_name = attrs.values_at :name, :table_name + + @attributes = {} + @name = name || table_name.split(".").last.singularize.camelize + @table_name = table_name + + Storage.register_static_attributes(self, fq_table_name: table_name) + + instance_eval(&block) if block + end +end diff --git a/lib/simple/store/metamodel/registry.rb b/lib/simple/store/metamodel/registry.rb new file mode 100644 index 0000000..59a4fc6 --- /dev/null +++ b/lib/simple/store/metamodel/registry.rb @@ -0,0 +1,32 @@ +# require "active_support/core_ext/string/inflections" + +module Simple::Store::Metamodel::Registry + Metamodel = ::Simple::Store::Metamodel + + extend self + + @@registry = {} + + def register(metamodel) + expect! metamodel => Metamodel + @@registry[metamodel.name] = metamodel + end + + def unregister(name) + expect! name => String + @@registry.delete name + end + + # returns a Hash of Hashes; i.e. + # table_name => { "type1" => metamodel1, "type2" => metamodel2, ... } + def grouped_by_table_name + @@registry.values.each_with_object({}) do |metamodel, hsh| + hsh[metamodel.table_name] ||= {} + hsh[metamodel.table_name][metamodel.name] = metamodel + end + end + + def lookup(name) + @@registry[name] || raise(ArgumentError, "No suck Metamodel #{name.inspect}") + end +end diff --git a/lib/simple/store/model.rb b/lib/simple/store/model.rb new file mode 100644 index 0000000..d6575c9 --- /dev/null +++ b/lib/simple/store/model.rb @@ -0,0 +1,152 @@ +# rubocop:disable Metrics/CyclomaticComplexity +# rubocop:disable Metrics/AbcSize + +require "active_support/core_ext/string/inflections" +require "active_support/core_ext/hash/keys" + +# This class implements a Hash-like object, which will resolves attributes via +# method_missing, using the implementations as specified in the object's metamodel. +class Simple::Store::Model + def new_record? + id.nil? + end + + attr_reader :metamodel + attr_reader :to_hash + + def to_json(*args) + to_hash.to_json(*args) + end + + # Returns a hash of changed attributes indicating their original and new values + # like attr => [original value, new value]. + # + # person.diff # => {} + # person.name = 'bob' + # person.diff # => { "name" => ["bill", "bob"] } + # + # Note that no changes are returned if the record hasn't been saved. + # + # def diff + # return {} unless @original_hash + # + # @to_hash.each_with_object({}) do |(key, value), diff| + # original_value = @original_hash[key] + # next if !original_value.nil? && original_value == value + # next if original_value.nil? && !@original_hash.key?(key) + # + # diff[key] = [original_value, value] + # end + # end + + # Build a model of a given \a metamodel. + # + # The constructor assumes that all attributes in +trusted_data+ are valid and + # of the right type. No validation happens here. This is the case when reading + # from the storage. + + def initialize(metamodel, trusted_data: {}) + expect! trusted_data => { "type" => [nil, metamodel.name] } + + @metamodel = metamodel + @to_hash = trusted_data.stringify_keys + @to_hash["type"] = metamodel.name + @to_hash["id"] ||= nil + end + + private + + def set_id_by_trusted_caller(id) # rubocop:disable Naming/AccessorMethodName + expect! id => Integer + @to_hash["id"] = id + end + + public + + # Assign many attributes at once. + # + # This method ignores attributes that are not defined for models of this + # type. + # + # [TODO]: Attributes are converted as necessary. + def assign(attrs) + expect! attrs => Hash + + attrs = attrs.stringify_keys + + attrs.delete_if do |key, _value| + %w(id type created_at updated_at).include?(key) + end + + # -- apply write-protection + + write_protected_attributes = metamodel.attributes(writable: false) + + attrs.delete_if do |key, _value| + write_protected_attributes.key?(key) + end + + @to_hash.merge!(attrs) + self + end + + # compare this model with another model or Hash. + # + # This ignores Symbol/String differences. + def ==(other) + return false unless other.respond_to?(:to_hash) + + to_hash == other.to_hash.stringify_keys + end + + def inspect + hsh = to_hash.reject { |k, _v| %w(id type meta_data).include?(k) } + + hsh = hsh.reject do |k, v| + v.nil? && metamodel.attributes[k] && metamodel.attributes[k][:kind] == :dynamic + end + + inspected_values = hsh.map { |k, v| "#{k}: #{v.inspect}" }.sort + + identifier = "#{metamodel.name}##{id.inspect}" + inspected_values.empty? ? "<#{identifier}>" : "<#{identifier}: #{inspected_values.join(', ')}>" + end + + private + + GETTER_REGEXP = /\A([a-z_][a-z0-9_]*)\z/ + SETTER_REGEXP = /\A([a-z_][a-z0-9_]*)=\z/ + GETTER_OR_SETTER = /\A([a-z_][a-z0-9_]*)(=?)\z/ + + def respond_to_missing?(sym, _include_private = false) + (sym =~ GETTER_OR_SETTER) && metamodel.attribute?($1) + end + + # rubocop:disable Metrics/PerceivedComplexity + # rubocop:disable Metrics/BlockNesting + # rubocop:disable Style/GuardClause + + # ^^^ Please keep the code for method_missing as convoluted as it is. + # It needs a certain level of mental agility to read and adjust. + def method_missing(sym, *args) + case args.length + when 0 + if GETTER_REGEXP =~ sym + attr = metamodel.attributes[$1] + if attr + if attr[:kind] == :virtual + return metamodel.resolve_virtual_attribute(self, $1) + else + return @to_hash[$1] + end + end + end + when 1 + if SETTER_REGEXP =~ sym && metamodel.writable_attribute?($1) + return @to_hash[$1] = args[0] + end + end + + super + end +end diff --git a/lib/simple/store/registry.rb b/lib/simple/store/registry.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/simple/store/storage.rb b/lib/simple/store/storage.rb new file mode 100644 index 0000000..534e6b2 --- /dev/null +++ b/lib/simple/store/storage.rb @@ -0,0 +1,110 @@ +module Simple::Store::Storage + extend self + + Model = Simple::Store::Model + + METAMODEL_TYPE_BY_PG_DATA_TYPE = { + "character varying" => :string, + "timestamp without time zone" => :timestamp, + "USER-DEFINED" => :string # enums + } + + READONLY_ATTRIBUTES = %(created_at updated_at id type) + + def register_static_attributes(metamodel, fq_table_name:) + column_info = Simple::SQL::Reflection.column_info(fq_table_name) + raise ArgumentError, "No such table #{fq_table_name.inspect}" if column_info.empty? + + column_info.each do |name, ostruct| + next if name == "meta_data" + + data_type = ostruct.data_type + type = (METAMODEL_TYPE_BY_PG_DATA_TYPE[data_type] || data_type.to_sym) + + metamodel.attribute name, type, writable: !READONLY_ATTRIBUTES.include?(name), + readable: true, + kind: :static + end + end + + def convert_one_to_storage_representation(metamodel, model) + # Extract and merge static and dynamic attributes + record = extract_static_attributes(metamodel, model) + meta_data = extract_dynamic_attributes(metamodel, model) + unless meta_data.empty? + record["meta_data"] = meta_data + end + + # Remove type attribute if this table doesn't have a type column + # (but is statically typed instead.) + record.delete "type" unless metamodel.attribute?("type") + record + end + + def convert_to_storage_representation(metamodel, models) + models.map do |model| + convert_one_to_storage_representation metamodel, model + end + end + + private + + def extract_static_attributes(metamodel, model) + # copy all attributes from the model's internal Hash representation into + # either the dynamic_attributes or the record Hash. + metamodel.attributes(kind: :static).each_with_object({}) do |(k, _attribute), hsh| + next if k == "id" + next unless model.to_hash.key?(k) + + hsh[k] = model.to_hash[k] + end + end + + def extract_dynamic_attributes(metamodel, model) + metamodel.attributes(kind: :dynamic).each_with_object({}) do |(k, _attribute), hsh| + next unless model.to_hash.key?(k) + hsh[k] = model.to_hash[k] + end + end + + public + + Immutable = ::Simple::SQL::Helpers::Immutable + Reflection = ::Simple::SQL::Reflection + + def from_complete_row(hsh, fq_table_name:) + meta_data = hsh.delete :meta_data + if meta_data + hsh = meta_data.merge(hsh) + end + + # Note that we have to look up the metamodel for each row, since they can differ between + # rows. + metamodel = determine_metamodel(type: hsh[:type], fq_table_name: fq_table_name) + if metamodel + model = Model.new(metamodel, trusted_data: hsh) + else + STDERR.puts "Cannot find metamodel declaration for type #{hsh[:type].inspect} in table #{fq_table_name.inspect}" + Immutable.create(hsh) + end + end + + private + + Registry = Simple::Store::Metamodel::Registry + + def determine_metamodel(type:, fq_table_name:) + metamodels_by_table_name = Registry.grouped_by_table_name[fq_table_name] + return nil if metamodels_by_table_name.nil? + + unless type + metamodels = metamodels_by_table_name.values + return metamodels.first if metamodels.length == 1 + raise "No metamodels defined in table #{fq_table_name}" if metamodels.empty? + raise "Multiple metamodels defined in table #{fq_table_name}, but missing type" + end + + metamodels_by_table_name[type] || + raise("No metamodel definition #{type.inspect} in table #{fq_table_name}") + end +end diff --git a/lib/simple/store/update.rb b/lib/simple/store/update.rb new file mode 100644 index 0000000..bf39139 --- /dev/null +++ b/lib/simple/store/update.rb @@ -0,0 +1,25 @@ +# require "active_support/core_ext/string/inflections" + +module Simple::Store::Update + Storage = Simple::Store::Storage + Store = Simple::Store + + extend self + + def update_model(model) + expect! model.new_record? => false + + metamodel = model.metamodel + record = Storage.convert_one_to_storage_representation(metamodel, model) + + keys = record.keys + values = record.values_at(*keys) + + keys_w_placeholders = keys.each_with_index.map do |key, idx| + "#{key}=$#{idx + 2}" + end + + sql = "UPDATE #{metamodel.table_name} SET #{keys_w_placeholders.join(', ')} WHERE id=$1 RETURNING *" + Store.ask sql, model.id, *values + end +end diff --git a/lib/simple/store/validation.rb b/lib/simple/store/validation.rb new file mode 100644 index 0000000..315d9b5 --- /dev/null +++ b/lib/simple/store/validation.rb @@ -0,0 +1,12 @@ +# require "active_support/core_ext/string/inflections" + +module Simple::Store::Validation + extend self + + # Validate the passed in models. + def validate!(models) + models.each do |model| + model.metamodel.validate! model + end + end +end diff --git a/scripts/performance_test.rb b/scripts/performance_test.rb new file mode 100755 index 0000000..82ce541 --- /dev/null +++ b/scripts/performance_test.rb @@ -0,0 +1,97 @@ +#!/usr/bin/env ruby +require "bundler" +Bundler.require +require "simple-sql" +require "simple-store" + +require "benchmark" + +n = 1000 + +Simple::SQL.connect! +Simple::SQL.exec <<~SQL + DROP SCHEMA IF EXISTS performance CASCADE; + CREATE SCHEMA IF NOT EXISTS performance; + + CREATE TABLE performance.organizations ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + city VARCHAR + ); + + CREATE TABLE performance.users ( + id SERIAL PRIMARY KEY, + organization_id INTEGER REFERENCES performance.organizations (id), + role_id INTEGER, + first_name VARCHAR, + last_name VARCHAR, + meta_data JSONB, + type VARCHAR NOT NULL, + created_at timestamp, + updated_at timestamp + ); +SQL + +Simple::Store::Metamodel.register "Organization", table_name: "performance.organizations" do + attribute :city, writable: false +end + +Simple::Store::Metamodel.register "User", table_name: "performance.users" do + attribute :zip_code, type: :text +end + +$counter = 0 + +def user + $counter += 1 + { + first_name: "first#{$counter}", last_name: "last#{$counter}", zip_code: "BE#{$counter}" + } +end + +def organization + $counter += 1 + { + name: "name#{$counter}" + } +end + +def organizations(n) + 1.upto(n).map { organization } +end + +def users(n) + 1.upto(n).map { user } +end + +def clear + Simple::SQL.ask "TRUNCATE TABLE performance.users, performance.organizations RESTART IDENTITY CASCADE" +end + +N = 1000 + +if false + + Benchmark.bm(30) do |x| + x.report("build #{N} organizations") { clear; N.times { Simple::Store.build "Organization", organization; } } + x.report("build #{N} users") { clear; N.times { Simple::Store.build "User", user; } } + x.report("create #{N} organizations") { clear; N.times { Simple::Store.create! "Organization", organization; } } + x.report("create #{N} users") { clear; N.times { Simple::Store.create! "User", user; } } + x.report("create #{N} orgs/w transaction") { clear; Simple::SQL.transaction { N.times { Simple::Store.create! "Organization", organization; }; } } + x.report("create #{N} users/w transaction") { clear; Simple::SQL.transaction { N.times { Simple::Store.create! "User", user; }; } } + x.report("mass-create #{N} orgs") { clear; Simple::Store.create! "Organization", organizations(N) } + x.report("mass-create #{N} users") { clear; Simple::Store.create! "User", users(N) } + end + +end + +clear +Simple::Store.create! "Organization", organizations(N) +Simple::Store.create! "User", users(N) + +require "pp" + +Benchmark.bmbm(30) do |x| + x.report("load #{N} organizations") { Simple::Store.all "SELECT * FROM performance.organizations" } + x.report("load #{N} users") { Simple::Store.all "SELECT * FROM performance.users" } +end diff --git a/scripts/performance_test2.rb b/scripts/performance_test2.rb new file mode 100755 index 0000000..238767a --- /dev/null +++ b/scripts/performance_test2.rb @@ -0,0 +1,23 @@ +#!/usr/bin/env ruby +require "bundler" +Bundler.require +require "simple-sql" +require "simple-store" + +require "benchmark" + +module X + def s + "foo" + end +end + +class XX + include X +end + +N = 1_000_000 +Benchmark.bm(30) do |bm| + bm.report("extend") { N.times { x = Object.new; x.extend(X); x.s; } } + bm.report("custom class") { N.times { x = XX.new; x.s; } } +end diff --git a/simple-sql.gemspec b/simple-sql.gemspec index f5d58d6..9737ff5 100644 --- a/simple-sql.gemspec +++ b/simple-sql.gemspec @@ -31,6 +31,8 @@ Gem::Specification.new do |gem| gem.add_dependency 'pg', '~> 0.20' gem.add_dependency 'expectation', '~> 1' + gem.add_dependency 'json_schemer' + # optional gems (required by some of the parts) # development gems diff --git a/simple-store.md b/simple-store.md new file mode 100644 index 0000000..cdbc542 --- /dev/null +++ b/simple-store.md @@ -0,0 +1,178 @@ +## The Simple::Store interface + +The Simple::Store modules provides code to create, save, update, or delete objects of a given type. This module also manages a type registry, which is used to find out about various aspects of a type, for example which attributes are defined and whether or not these are dynamic or static attributes. + +Creating and saving objects also applies validation at the right time. + +```ruby +class Simple::Store + # build one or more, verify, and save. + + def self.create!(type, hsh_or_hshes) + end + + # verify, and save one or more. + + def self.save!(obj_or_objs) + end + + # update, verify, and save. + + def self.update!(type, id_or_ids, hsh) + end + + def self.update!(obj_or_objs, hsh) + end + + # delete one or more + + def self.delete!(obj_or_objs) + end + + def self.delete!(typename_or_typenames, id_or_ids) + # note: all type_or_types must refer to the same table. + end +end +``` + +## Registering a type + +The following code registers a type. + +A type registration defines attributes. Depending on the readability and writability settings of an attribute this code defines getter and setter methods. If an attribute is read or write protected these methods check that the current user can call the method. + +- static attributes are stored inside a table column. +- dynamic attributes are stored inside the "meta_data" JSONB column. +- virtual attributes are never stored; also, they are never writable. +- all attributes are readable by default; all non-virtual attributes are writable by default + +When using the getters and setters no typecasting occurs. + +```ruby +metamodel = Simple::Store.register_type "Klass::Name", + table_name: "postgres_table_name", + superclass: SuperKlass do + + # Note: the type of a static attribute can be inferred from the database table + static_attribute :static_name, type: :integer, writable: false + + dynamic_attribute :dynamic_name, type: :integer, writable: :protected + virtual_attribute :virtual_name, type: :integer +end + +metamodel.name # => "Klass::Name" +metamodel.table_name # => "postgres_table_name" +metamodel.is_a?(Class) # => true +metamodel.superclass == SuperKlass # => true +``` + +One can also auto-detect a type from the database. This infers attributes from the actual database table. + +```ruby +Simple::Store.register_type "Klass::Name", table_name: "postgres_table_name" +``` + +The generated class behaves as if it was implemented with an interface like this: + +```ruby +class Klass::Name < SuperKlass + # Adds in create!, update!, save!, etc. + # + include Simple::Store::MetamodelMethods + + def static_name + @data[:static_name] + end + + def dynamic_name + @data[:dynamic_name] + end + + def dynamic_name=(value) + Simple::Store.current_session.check_protection! self.class, :dynamic_name, :write + @data[:dynamic_name] = value + end +end +``` + +Virtual attributes must be implemented explicitely. This can be done in a base +klass implementation: + +```ruby +class Base::Organization + def self.table_name + "organizations" + end + + def users_count + sql = "SELECT COUNT(*) FROM user_organizations WHERE organization_id=$1" + SQL.ask sql, $1 + end +end +``` + +```ruby +Simple::Store.register_type "Mpx::Organization", superclass: ::Base::Organization do + + virtual_attribute :users_count, type: :integer +end + +metamodel.name # => "Klass::Name" +metamodel.table_name # => "postgres_table_name" +metamodel.is_a?(Class) # => true +metamodel.superclass == SuperKlass # => true +``` + + +### Mass assigning and type casting + +One can assign multiple attributes in one go. This is done via + + Type.mass_assign!(obj, hsh) + +When mass assigning String input values (and only String input values) +are converted into the specific type for the attribute. If the conversion +is not possible this will raise an ArgumentError. + +This is used to + +- a) quickly build objects from HTTP request parameters, and +- b) to convert data from JSON storage into our model representation. + +This is also the only place where mass assignment takes place. + +## Using a Metamodel klass + +Classes registered via `Simple::Store.register_type` can be looked up in the Metamodel +registry (or, of course, via their name), and then used to build and manage +objects of that class. + +```ruby +metamodel = Simple::Store.register_type 'Foo::Bar', ... +metamodel = Simple::Store.lookup_type 'Foo::Bar', ... + +# Building objects +attrs = { .. } +obj = metamodel.build(attrs) # => returns a object of type metamodel +obj.save! + +# shortcut to validate & save the object +metamodel.create!(attrs) # => returns the saved object of type metamodel + +# update & save the object +obj = { .. } # => update attributes +obj.validate! +obj.save! +obj.update!(hsh) # => update attributes, validate and save + +# Load objects + + "SELECT * FROM table", into: Simple::Store::Metamodel + +# loads data from the table; +# looks up types in Simple::Store.registry + # builds objects as requested, + # returns these objects +``` + + diff --git a/spec/simple/store/build_spec.rb b/spec/simple/store/build_spec.rb new file mode 100644 index 0000000..3fabdb6 --- /dev/null +++ b/spec/simple/store/build_spec.rb @@ -0,0 +1,40 @@ +require_relative "store_spec_helper" + +describe "Simple::Store.build" do + include StoreSpecHelper + + context "in a simple table" do + let!(:type_name) { "Organization" } + + let!(:model) do + Simple::Store.build "Organization", name: "orgname" + end + + it "returns a model" do + expected = { + id: nil, type: "Organization", name: "orgname" + } + expect(model.id).to be_nil + expect(model).to eq(expected) + end + + it "creates no entry in the database" do + expect(SQL.ask("SELECT count(*) FROM simple_store.organizations")).to eq(0) + end + + context "with unknown arguments" do + let!(:record) do + Simple::Store.create! "Organization", name: "orgname", foo: "Bar" + end + + it "ignores unknown attributes" do + expected = { + type: "Organization", + id: 1, name: "orgname", + city: nil + } + expect(record).to eq(expected) + end + end + end +end diff --git a/spec/simple/store/create_spec.rb b/spec/simple/store/create_spec.rb new file mode 100644 index 0000000..ff0477a --- /dev/null +++ b/spec/simple/store/create_spec.rb @@ -0,0 +1,164 @@ +require_relative "store_spec_helper" + +describe "Simple::Store.create!" do + include StoreSpecHelper + + shared_examples 'basic creation contract' do + it 'assigns an id' do + expect(record.id).to be_a(Integer) + end + + it 'assigns a metamodel' do + expect(record.metamodel.name).to eq(type_name) + end + + it "does not defines a meta_data gette" do + expect(record.respond_to?(:meta_data)).to eq(false) + end + + it "sets timestamps" do + now = Time.now + + expect(record.created_at).to be_within(0.01).of(now) if record.respond_to?(:created_at) + expect(record.updated_at).to be_within(0.01).of(now) if record.respond_to?(:updated_at) + end + end + + context "in a simple table" do + let!(:type_name) { "Organization" } + + let!(:record) do + Simple::Store.create! "Organization", name: "orgname" + end + + it_behaves_like 'basic creation contract' + + it "returns a record" do + expected = { + id: 1, type: "Organization", name: "orgname", city: nil + } + expect(record).to eq(expected) + end + + it "creates an entry in the database" do + expect(SQL.ask("SELECT count(*) FROM simple_store.organizations")).to eq(1) + actual = SQL.ask("SELECT * FROM simple_store.organizations", into: Hash) + expect(actual).to eq({ id: 1, name: "orgname", city: nil }) + end + + context "with unknown arguments" do + let!(:record) do + Simple::Store.create! "Organization", name: "orgname", foo: "Bar" + end + + it "ignores unknown attributes" do + expected = { + type: "Organization", + id: 1, name: "orgname", + city: nil + } + expect(record).to eq(expected) + end + end + end + + context "in a dynamic table" do + let!(:type_name) { "User" } + + it_behaves_like 'basic creation contract' + + let!(:record) do + Simple::Store.create! "User", first_name: "first" + end + + it "returns a record" do + expect(record.organization_id).to eq(nil) + expect(record.role_id).to eq(nil) + expect(record.first_name).to eq("first") + expect(record.last_name).to eq(nil) + expect(record.access_level).to eq(nil) + end + + it "creates an entry in the database" do + expect(SQL.ask("SELECT count(*) FROM simple_store.users")).to eq(1) + actual = SQL.ask("SELECT * FROM simple_store.users", into: Hash) + expected = { + id: 1, + organization_id: nil, + role_id: nil, + first_name: "first", + last_name: nil, + meta_data: nil, + access_level: nil, + type: "User" + } + expect(actual).to include(expected) + end + + context "with unknown arguments" do + let!(:record) do + Simple::Store.create! "Organization", name: "orgname", foo: "Bar" + end + + it "ignores unknown attributes" do + expected = { + id: 1, type: "Organization", name: "orgname", city: nil + } + expect(record).to eq(expected) + end + end + end + + describe "argument validation" do + shared_examples 'a validation error' do + it 'raises an ArgumentError' do + expect { + ::Simple::Store.create! *params + }.to raise_error { |e| + expect(e).to be_a(ArgumentError) + } + end + end + + context "when a 'type' attribute is passed in" do + it_behaves_like 'a validation error' do + let(:params) { [ "Organization", type: "Foo" ] } + end + end + + context "when a 'id' attribute is passed in" do + it_behaves_like 'a validation error' do + let(:params) { [ "Organization", id: 12 ] } + end + end + end + + describe "when passing in an array" do + it "it builds multiple models" do + params = [ + { name: "Foo" }, + { name: "Bar" } + ] + + organizations = ::Simple::Store.create! "Organization", params + expect(::Simple::SQL.ask "SELECT count(*) FROM simple_store.organizations").to eq(2) + expect(organizations.map(&:name)).to eq(["Foo", "Bar" ]) + end + + it "it either builds all or none" do + params = [ + { name: "Foo" }, + { name: nil }, + { name: "Foo2" } + ] + + organizations = nil + expect { + organizations = ::Simple::Store.create! "Organization", params + }.to raise_error { |e| + expect(e).to be_a(PG::NotNullViolation) + } + expect(::Simple::SQL.ask "SELECT count(*) FROM simple_store.organizations").to eq(0) + end + end +end diff --git a/spec/simple/store/delete_spec.rb b/spec/simple/store/delete_spec.rb new file mode 100644 index 0000000..d5d26d9 --- /dev/null +++ b/spec/simple/store/delete_spec.rb @@ -0,0 +1,140 @@ +require_relative "store_spec_helper" + +describe "Simple::Store.delete!" do + include StoreSpecHelper + + let!(:record1) { Simple::Store.create! "Organization", name: "org1" } + let!(:record2) { Simple::Store.create! "Organization", name: "org2" } + + context "Simple::Store.delete!(typenames, ids)" do + it "deletes a single record from the table" do + deleted_record = Simple::Store.delete! "Organization", 1 + expect(SQL.ask("SELECT COUNT(*) FROM simple_store.organizations")).to eq(1) + expect(deleted_record.name).to eq("org1") + end + + it "deletes multiple records from the table" do + deleted_records = Simple::Store.delete! [ "Organization" ], [1,2] + expect(SQL.ask("SELECT COUNT(*) FROM simple_store.organizations")).to eq(0) + expect(deleted_records.map(&:name)).to contain_exactly("org1", "org2") + end + + context "when deleting a record which no longer exists" do + before do + Simple::Store.delete! record1 + end + + it "raises a NotFound error if trying to delete a record which does not exist" do + expect { + Simple::Store.delete! [record1] + }.to raise_error { |e| + expect(e).to be_a(Simple::Store::RecordNotFound) + } + end + + it "Does not delete any records" do + expect { + Simple::Store.delete!([record1, record2]) rescue nil + }.to change { + SQL.ask("SELECT COUNT(*) FROM simple_store.organizations") + }.by(0) + end + end + end + + context "Simple::Store.delete!(models)" do + it "deletes a single record from the table" do + deleted_record = Simple::Store.delete! record1 + expect(SQL.ask("SELECT COUNT(*) FROM simple_store.organizations")).to eq(1) + expect(deleted_record.name).to eq("org1") + end + + it "deletes multiple records from the table" do + deleted_records = Simple::Store.delete! [record1, record2] + expect(SQL.ask("SELECT COUNT(*) FROM simple_store.organizations")).to eq(0) + expect(deleted_records.map(&:name)).to contain_exactly("org1", "org2") + end + + context "when deleting a record which no longer exists" do + before do + Simple::Store.delete! record1 + end + + it "raises a NotFound error if trying to delete a record which does not exist" do + expect { + Simple::Store.delete! [record1] + }.to raise_error { |e| + expect(e).to be_a(Simple::Store::RecordNotFound) + } + end + + it "Does not delete any records" do + expect { + Simple::Store.delete!([record1, record2]) rescue nil + }.to change { + SQL.ask("SELECT COUNT(*) FROM simple_store.organizations") + }.by(0) + end + end + end + + context "Simple::Store.delete(typenames, ids)" do + it "deletes a single record from the table" do + deleted_record = Simple::Store.delete "Organization", 1 + expect(SQL.ask("SELECT COUNT(*) FROM simple_store.organizations")).to eq(1) + expect(deleted_record.name).to eq("org1") + end + + it "deletes multiple records from the table" do + deleted_records = Simple::Store.delete [ "Organization" ], [1,2] + expect(SQL.ask("SELECT COUNT(*) FROM simple_store.organizations")).to eq(0) + expect(deleted_records.map(&:name)).to contain_exactly("org1", "org2") + end + + context "when deleting a record which no longer exists" do + before do + Simple::Store.delete record1 + end + + it "Deletes the requested records" do + Simple::Store.delete([record1, record2]) rescue nil + expect(SQL.ask("SELECT COUNT(*) FROM simple_store.organizations")).to eq(0) + end + end + end + + context "Simple::Store.delete(models)" do + it "deletes a single record from the table" do + deleted_record = Simple::Store.delete record1 + expect(SQL.ask("SELECT COUNT(*) FROM simple_store.organizations")).to eq(1) + expect(deleted_record.name).to eq("org1") + end + + it "deletes multiple records from the table" do + deleted_records = Simple::Store.delete [record1, record2] + expect(SQL.ask("SELECT COUNT(*) FROM simple_store.organizations")).to eq(0) + expect(deleted_records.map(&:name)).to contain_exactly("org1", "org2") + end + + context "when deleting a record which no longer exists" do + before do + Simple::Store.delete record1 + end + + it "Deletes the requested records" do + deleted_recs = Simple::Store.delete([record1, record2]) + expect(deleted_recs.map(&:name)).to contain_exactly("org2") + expect(SQL.ask("SELECT COUNT(*) FROM simple_store.organizations")).to eq(0) + end + end + end + + context "Simple::Store.delete!()" do + context "when called with wrong no of arguments" do + it "raises an ArgumentError" do + expect { Simple::Store.delete! }.to raise_error(ArgumentError) + expect { Simple::Store.delete! 1, 2, 3 }.to raise_error(ArgumentError) + end + end + end +end diff --git a/spec/simple/store/find_spec.rb b/spec/simple/store/find_spec.rb new file mode 100644 index 0000000..de97763 --- /dev/null +++ b/spec/simple/store/find_spec.rb @@ -0,0 +1,79 @@ +require_relative "store_spec_helper" + +describe "Simple::Store.find" do + include StoreSpecHelper + + before do + Simple::Store.create! "User", first_name: "first1" + Simple::Store.create! "User", first_name: "first2" + Simple::Store.create! "User", first_name: "first3" + end + + describe "reload(model)" do + it "reloads an object" do + user = Simple::Store.ask "SELECT * FROM simple_store.users LIMIT 1" + + reloaded_user = Simple::Store.reload user + expect(reloaded_user).to eq(reloaded_user) + end + end + + describe "find(type, id)" do + it "returns a record" do + rec = Simple::Store.find "User", 1 + end + + it "raises an error if the id cannot be found" do + expect { + Simple::Store.find "User", -1 + }.to raise_error { |e| + expect_records_not_found(e, "User", [-1]) + } + end + + it "raises an error if multiple ids cannot be found" do + expect { + Simple::Store.find "User", [-1, -2] + }.to raise_error { |e| + expect_records_not_found(e, "User", [-1, -2]) + } + end + end + + describe "find(type, ids)" do + it "returns an array of record" do + rec = Simple::Store.find "User", [1, 2] + expect(rec.map(&:id)).to eq([1,2]) + + rec = Simple::Store.find "User", [2, 1] + expect(rec.map(&:id)).to eq([2,1]) + end + + it "raises an error if the id cannot be found" do + expect { + Simple::Store.find "User", -1 + }.to raise_error { |e| + expect_records_not_found(e, "User", [-1]) + } + end + + it "raises an error if multiple ids cannot be found" do + expect { + Simple::Store.find "User", [-1, -2] + }.to raise_error { |e| + expect_records_not_found(e, "User", [-1, -2]) + } + end + end + + describe "find(types, ids)" do + it "raises an error if types refer to different tables" do + expect { + Simple::Store.find ["User", "Organization"], [-1, -2] + }.to raise_error { |e| + expect(e).to be_a(ArgumentError) + expect(e.message).to match(/Duplicate tables/) + } + end + end +end diff --git a/spec/simple/store/metamodel_spec.rb b/spec/simple/store/metamodel_spec.rb new file mode 100644 index 0000000..bfb30f5 --- /dev/null +++ b/spec/simple/store/metamodel_spec.rb @@ -0,0 +1,80 @@ +require_relative "store_spec_helper" + +describe "Simple::Store::Metamodel" do + include StoreSpecHelper + + before :all do + Simple::SQL.exec <<~SQL + CREATE TABLE IF NOT EXISTS simple_store.simple_types ( + id SERIAL PRIMARY KEY, + name VARCHAR + ); + SQL + Simple::SQL.ask <<~SQL + CREATE TABLE IF NOT EXISTS simple_store.dynamic_types ( + id SERIAL PRIMARY KEY, + type VARCHAR, + organization_id INTEGER REFERENCES simple_store.organizations (id), + role_id INTEGER, + first_name VARCHAR, + last_name VARCHAR, + meta_data JSONB, + access_level access_level, + created_at timestamp, + updated_at timestamp + ); + SQL + end + + describe "Types with only static attributes" do + + let!(:metamodel) { Simple::Store::Metamodel.new(name: "Simple", table_name: "simple_store.simple_types") } + + it "uses the passed in name" do + expect(metamodel.name).to eq("Simple") + end + + it "sets the table_name" do + expect(metamodel.table_name).to eq("simple_store.simple_types") + end + + it "returns the correct set of attributes" do + expect(metamodel.attributes).to eq( + "id" => { type: :integer, writable: false, readable: true, kind: :static }, + "name" => { type: :string, writable: true, readable: true, kind: :static } + ) + end + end + + describe "Types with dynamic attributes" do + let!(:metamodel) { Simple::Store::Metamodel.new(table_name: "simple_store.dynamic_types") } + + it "excludes the meta_data column" do + expect(metamodel.attributes.key?("meta_data")).to eq(false) + end + + it "automatically determines the name" do + expect(metamodel.name).to eq("DynamicType") + end + + it "sets the table_name" do + expect(metamodel.table_name).to eq("simple_store.dynamic_types") + end + + it "returns the correct set of attributes" do + expected_attributes = { + "id" => { type: :integer, writable: false, readable: true, kind: :static }, + "type" => { type: :string, writable: false, readable: true, kind: :static }, + "access_level" => { type: :string, writable: true, readable: true, kind: :static }, + "organization_id" => { type: :integer, writable: true, readable: true, kind: :static }, + "role_id" => { type: :integer, writable: true, readable: true, kind: :static }, + "first_name" => { type: :string, writable: true, readable: true, kind: :static }, + "last_name" => { type: :string, writable: true, readable: true, kind: :static }, + "created_at" => { type: :timestamp, writable: false, readable: true, kind: :static }, + "updated_at" => { type: :timestamp, writable: false, readable: true, kind: :static } + } + + expect(metamodel.attributes).to eq(expected_attributes) + end + end +end diff --git a/spec/simple/store/model_spec.rb b/spec/simple/store/model_spec.rb new file mode 100644 index 0000000..37b88c3 --- /dev/null +++ b/spec/simple/store/model_spec.rb @@ -0,0 +1,69 @@ +require_relative "store_spec_helper" + +describe "Simple::Store::Model" do + include StoreSpecHelper + + let(:user_klass) { Simple::Store::Metamodel.resolve "Organization" } + let(:user) { user_klass.build({}) } + + describe "to_json" do + it "returns a json string" do + expect(user.to_json).to eq("{\"type\":\"Organization\",\"id\":null}") + end + + it "returns a json string when created" do + user = Simple::Store.create! "Organization", { name: "foo"} + expect(user.to_json).to eq("{\"id\":1,\"name\":\"foo\",\"city\":null,\"type\":\"Organization\"}") + end + + it "returns a json string when loaded" do + user = Simple::Store.create! "Organization", { name: "foo"} + loaded = Simple::Store.ask "SELECT * FROM simple_store.organizations LIMIT 1" + expect(loaded.to_json).to eq("{\"id\":1,\"name\":\"foo\",\"city\":null,\"type\":\"Organization\"}") + end + end + + describe "method_missing getters" do + it "implements getters for readable attributes" do + expect(user.respond_to?(:name)).to eq(true) + expect(user.name).to be_nil + end + + it "raises a NoMethodError on unknown_attributes" do + expect(user.respond_to?(:unknown_attribute)).to eq(false) + expect { user.unknown_attribute }.to raise_error(NoMethodError) + end + end + + describe "method_missing setters" do + it "implements setters for writable attributes" do + expect(user.respond_to?(:name)).to eq(true) + user.name = "changed" + expect(user.name).to eq("changed") + end + + it "raises a NoMethodError on unknown_attributes" do + expect(user.respond_to?(:unknown_attribute=)).to eq(false) + expect { user.unknown_attribute = 1 }.to raise_error(NoMethodError) + end + + it "raises a NoMethodError on readonly attributes" do + expect(user.respond_to?(:unknown_attribute=)).to eq(false) + expect { user.unknown_attribute = 1 }.to raise_error(NoMethodError) + end + end + + describe "#inspect" do + let(:saved_user) { Simple::Store.create! "Organization", { name: "foo" } } + let(:return_value) { saved_user.inspect } + + it "returns a string" do + expect(user.inspect).to eq('') + end + + it "contains attributes" do + expect(user.inspect).to eq('') + expect(saved_user.inspect).to eq('') + end + end +end diff --git a/spec/simple/store/registry_spec.rb b/spec/simple/store/registry_spec.rb new file mode 100644 index 0000000..3f73da9 --- /dev/null +++ b/spec/simple/store/registry_spec.rb @@ -0,0 +1,53 @@ +require_relative "store_spec_helper" + +describe "Simple::Store::Metamodel.register" do + include StoreSpecHelper + + after do + Simple::Store::Metamodel.unregister("User2") + end + + context "when registering based on table_name" do + before do + @result = Simple::Store::Metamodel.register("User2", table_name: "simple_store.users") + end + + it "registers a metamodel" do + metamodel = Simple::Store::Metamodel.resolve "User2" + expect(metamodel).to be_a(Simple::Store::Metamodel) + expect(metamodel.name).to eq("User2") + end + + it "returns a metamodel" do + metamodel = @result + expect(metamodel).to be_a(Simple::Store::Metamodel) + expect(metamodel.name).to eq("User2") + end + end + + context "when adjusting a registration based on table_name" do + before do + Simple::Store::Metamodel.register("User2", table_name: "simple_store.users") do + attribute :last_name, writable: false + end + end + + it "replaces existing attribute options" do + metamodel = Simple::Store::Metamodel.resolve "User2" + expect(metamodel.attributes["last_name"]).to eq({:type=>:string, :writable=>false, :readable=>true, :kind=>:static}) + end + end + + context "when adjusting a registration based on table_name" do + before do + Simple::Store::Metamodel.register("User2", table_name: "simple_store.users") do + attribute :last_name, writable: false + end + end + + it "replaces existing attribute options" do + metamodel = Simple::Store::Metamodel.resolve "User2" + expect(metamodel.attributes["last_name"]).to eq({:type=>:string, :writable=>false, :readable=>true, :kind=>:static}) + end + end +end diff --git a/spec/simple/store/save_spec.rb b/spec/simple/store/save_spec.rb new file mode 100644 index 0000000..dabb320 --- /dev/null +++ b/spec/simple/store/save_spec.rb @@ -0,0 +1,91 @@ +require_relative "store_spec_helper" + +describe "Simple::Store.save!" do + include StoreSpecHelper + + before do + Simple::Store.create! "Organization", name: "orgname1" + Simple::Store.create! "Organization", name: "orgname2" + Simple::Store.create! "Organization", name: "orgname3" + end + + context "with an unsaved model" do + let!(:unsaved_model) { Simple::Store.build "Organization", name: "orgname" } + let!(:returned_model) { Simple::Store.save! unsaved_model } + + it "saves into the database" do + reloaded = Simple::Store.ask("SELECT * FROM simple_store.organizations WHERE id=4") + expected = { + id: 4, type: "Organization", name: "orgname", city: nil + } + expect(reloaded).to eq(expected) + end + + it "returns a saved model" do + expected = { + id: 4, type: "Organization", name: "orgname", city: nil + } + expect(returned_model).to eq(expected) + end + + it "sets the id in the original model" do + expect(unsaved_model.id).to eq(4) + end + + it "does not touch other objects" do + names = SQL.all "SELECT name FROM simple_store.organizations" + expect(names).to contain_exactly("orgname1", "orgname2", "orgname3", "orgname") + end + end + + context "with a saved model" do + let!(:saved_model) { Simple::Store.create! "Organization", name: "orgname" } + let!(:returned_model) { + saved_model.name = "changed" + Simple::Store.save! saved_model + } + + it "saves into the database" do + reloaded = Simple::Store.ask("SELECT * FROM simple_store.organizations WHERE id=4") + expected = { + id: 4, type: "Organization", name: "changed", city: nil + } + expect(reloaded).to eq(expected) + end + + it "returns a saved model" do + expected = { + id: 4, type: "Organization", name: "changed", city: nil + } + expect(returned_model).to eq(expected) + end + + it "sets the id in the original model" do + expect(saved_model.id).to eq(4) + end + + it "does not touch other objects" do + names = SQL.all "SELECT name FROM simple_store.organizations" + expect(names).to contain_exactly("orgname1", "orgname2", "orgname3", "changed") + end + end + + context "with an incomplete model" do + let!(:model) do + Simple::Store.create! "Organization", name: "orgname" + Simple::Store.ask "SELECT id, name FROM simple_store.organizations" + end + + it "cannot change any attributes" do + expect { + model.name = "changed_name" + }.to raise_error(NoMethodError) + end + + it "does not accept any mass assignment" do + expect { + model.assign(name: "changed_name") + }.to raise_error(NoMethodError) + end + end +end diff --git a/spec/simple/store/storage_spec.rb b/spec/simple/store/storage_spec.rb new file mode 100644 index 0000000..d78507c --- /dev/null +++ b/spec/simple/store/storage_spec.rb @@ -0,0 +1,22 @@ +require_relative "store_spec_helper" + +describe "Simple::Store::Storage" do + include StoreSpecHelper + + before do + Simple::Store.create! "User", first_name: "first1" + end + + describe "loading fro database" do + it "loads an object" do + now = Time.now + + record = Simple::Store.ask "SELECT * FROM simple_store.users LIMIT 1" + expect(record.id).to eq(1) + expect(record.type).to eq("User") + expect(record.created_at).to be_within(0.01).of(now) + expect(record.updated_at).to be_within(0.01).of(now) + expect(record.first_name).to eq("first1") + end + end +end diff --git a/spec/simple/store/store_migrations.rb b/spec/simple/store/store_migrations.rb new file mode 100644 index 0000000..0f754e3 --- /dev/null +++ b/spec/simple/store/store_migrations.rb @@ -0,0 +1,40 @@ +Simple::SQL.exec <<~SQL + DROP SCHEMA IF EXISTS simple_store CASCADE; +SQL + +Simple::SQL.exec <<~SQL + CREATE SCHEMA IF NOT EXISTS simple_store; + + CREATE TABLE simple_store.organizations ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + city VARCHAR + ); + + CREATE TABLE simple_store.users ( + id SERIAL PRIMARY KEY, + organization_id INTEGER REFERENCES organizations (id), + role_id INTEGER, + first_name VARCHAR, + last_name VARCHAR, + meta_data JSONB, + access_level access_level, + type VARCHAR NOT NULL, + created_at timestamp, + updated_at timestamp + ); +SQL + +Simple::Store::Metamodel.register "User", table_name: "simple_store.users" do + attribute :full_name, kind: :virtual +end + +Simple::Store::Metamodel.register_virtual_attributes "User" do + def full_name(user) + "#{user.first_name} #{user.last_name}" + end +end + +Simple::Store::Metamodel.register "Organization", table_name: "simple_store.organizations" do + attribute :city, writable: false +end diff --git a/spec/simple/store/store_spec_helper.rb b/spec/simple/store/store_spec_helper.rb new file mode 100644 index 0000000..6cba388 --- /dev/null +++ b/spec/simple/store/store_spec_helper.rb @@ -0,0 +1,32 @@ +require "spec_helper" +require "simple/store" + +require_relative "store_migrations" + +module StoreSpecHelper + def self.included(spec) + spec.before do + Simple::SQL.ask "TRUNCATE TABLE simple_store.users, simple_store.organizations RESTART IDENTITY CASCADE" + end + end + + # Helper to verify against InvalidArguments + def expect_invalid_arguments(e, errors, check_keys: true) + expect(e).to be_a(Simple::Store::InvalidArguments) + expect(e.errors).to be_a(Hash) + expect(e.message).to be_a(String) + + return unless errors + + expect(e.errors.keys).to contain_exactly(*errors.keys) if check_keys + expect(e.errors).to include(errors) + end + + # Helper to verify against RecordNotFound + def expect_records_not_found(e, type, missing_ids) + expect(e).to be_a(Simple::Store::RecordNotFound) + # expect(e.type).to eq(type) + expect(e.missing_ids).to contain_exactly(*missing_ids) + expect(e.message).to be_a(String) + end +end diff --git a/spec/simple/store/virtual_attribute_spec.rb b/spec/simple/store/virtual_attribute_spec.rb new file mode 100644 index 0000000..ea1e746 --- /dev/null +++ b/spec/simple/store/virtual_attribute_spec.rb @@ -0,0 +1,74 @@ +require_relative "store_spec_helper" + +describe "virtual_attributes" do + include StoreSpecHelper + + describe "registration" do + before :all do + Simple::Store::Metamodel.register "User3", table_name: "simple_store.users" do + attribute :full_name, kind: :virtual + end + + Simple::Store::Metamodel.register_virtual_attributes "User3" do + def full_name(user) + "#{user.first_name} #{user.last_name}" + end + end + end + + after :all do + Simple::Store::Metamodel.unregister("User3") + end + + let(:metamodel) { Simple::Store::Metamodel.resolve "User3" } + + it "registers a metamodel" do + expect(metamodel).to be_a(Simple::Store::Metamodel) + expect(metamodel.name).to eq("User3") + end + + it "makes the virtual attribute read only" do + expect(metamodel.attributes["full_name"][:writable]).to eq(false) + end + end + + describe "virtual attributes" do + before do + Simple::Store.create! "User", first_name: "foo", last_name: "bar" + end + + let!(:user) { Simple::Store.ask "SELECT * FROM simple_store.users LIMIT 1" } + + it "calculates value when requested" do + expect(user.full_name).to eq("foo bar") + end + + it "includes the virtual attribute in the full_hash result", pending: "needs full_hash" do + hsh = user.to_hash + + expect(hsh["full_name"]).to eq("foo bar") + end + + it "does not memoize the virtual attribute in the initial record" do + expect(user.full_name).to eq("foo bar") + user.first_name = "baz" + expect(user.full_name).to eq("baz bar") + end + + context "with an incomplete object" do + let!(:user) { Simple::Store.ask "SELECT id, first_name FROM simple_store.users LIMIT 1" } + + it "does not implement the getter" do + expect { + user.full_name + }.to raise_error(NameError) + end + + it "does not include the virtual attribute in the to_hash result" do + hsh = user.to_hash + + expect(hsh["full_name"]).to be_nil + end + end + end +end diff --git a/spec/support/004_simplecov.rb b/spec/support/004_simplecov.rb index 122a80c..3c77b65 100644 --- a/spec/support/004_simplecov.rb +++ b/spec/support/004_simplecov.rb @@ -9,6 +9,9 @@ add_filter do |src| src.filename =~ /\/spec\// end + add_filter do |src| + src.filename =~ /\/lib\/simple\/sql/ + end minimum_coverage 90 end