From 91338848fe1606210af1ac00c6fab2654d2b9bae Mon Sep 17 00:00:00 2001 From: eno Date: Tue, 27 Nov 2018 23:43:19 +0100 Subject: [PATCH 01/14] Rubocop fixes --- .rubocop.yml | 6 +++++- lib/simple/sql/helpers/immutable.rb | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) 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/lib/simple/sql/helpers/immutable.rb b/lib/simple/sql/helpers/immutable.rb index c640dcc..f76a30b 100644 --- a/lib/simple/sql/helpers/immutable.rb +++ b/lib/simple/sql/helpers/immutable.rb @@ -65,6 +65,8 @@ def ==(other) if $PROGRAM_NAME == __FILE__ + # rubocop:disable Metrics/AbcSize + require "test-unit" class Simple::SQL::Helpers::Immutable::TestCase < Test::Unit::TestCase From 5c6974684065e590e8f6a3a84eebda3ffc28511a Mon Sep 17 00:00:00 2001 From: eno Date: Wed, 28 Nov 2018 09:54:47 +0100 Subject: [PATCH 02/14] simple/sql Properly deal w/incomplete models, w/custom type converters 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, i.e. they are read only. If the records are not complete and contain only a single value this mode returns that value for each record. --- lib/simple/sql/helpers/row_converter.rb | 92 +++++++++++++++++++++---- 1 file changed, 80 insertions(+), 12 deletions(-) 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 From 02b244330e8d39b77303799e92ea6c83c6d49814 Mon Sep 17 00:00:00 2001 From: eno Date: Wed, 28 Nov 2018 12:12:14 +0100 Subject: [PATCH 03/14] Adds Immutable#to_hash --- lib/simple/sql/helpers/immutable.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/simple/sql/helpers/immutable.rb b/lib/simple/sql/helpers/immutable.rb index f76a30b..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 From 015b57e2ff76e62e0ffb6b7646a60d4ce7c3cb1b Mon Sep 17 00:00:00 2001 From: eno Date: Thu, 15 Nov 2018 21:03:13 +0100 Subject: [PATCH 04/14] Simple::Store initial import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simple::Store allows to easily define object “types”. A typical use case could look like this: ``` # This file is loaded when running bin/console. Simple::SQL.exec <<~SQL CREATE SCHEMA IF NOT EXISTS store; CREATE TABLE IF NOT EXISTS store.organizations ( id SERIAL PRIMARY KEY, name VARCHAR ); CREATE TABLE IF NOT EXISTS store.users ( id SERIAL PRIMARY KEY, organization_id INTEGER REFERENCES store.organizations (id), role_id INTEGER, first_name VARCHAR, last_name VARCHAR, metadata JSONB, created_at timestamp, updated_at timestamp ); SQL Simple::Store.ask "DELETE FROM store.users" Simple::Store.ask "DELETE FROM store.organizations" Simple::Store::Metamodel.register "User", table_name: "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: "store.organizations" do attribute :city, writable: false end user = Simple::Store.create! "User", first_name: "foo" p user # -> ``` --- lib/simple-store.rb | 3 + lib/simple/store.rb | 162 ++++++++++++++++++ lib/simple/store/create.rb | 39 +++++ lib/simple/store/delete.rb | 60 +++++++ lib/simple/store/errors.rb | 37 ++++ lib/simple/store/find.rb | 47 ++++++ lib/simple/store/helpers.rb | 41 +++++ lib/simple/store/metamodel.rb | 172 +++++++++++++++++++ lib/simple/store/metamodel/registry.rb | 32 ++++ lib/simple/store/model.rb | 177 +++++++++++++++++++ lib/simple/store/registry.rb | 0 lib/simple/store/storage.rb | 87 ++++++++++ lib/simple/store/update.rb | 25 +++ lib/simple/store/validation.rb | 12 ++ scripts/performance_test.rb | 97 +++++++++++ scripts/performance_test2.rb | 23 +++ simple-sql.gemspec | 2 + simple-store.md | 178 ++++++++++++++++++++ spec/simple/store/build_spec.rb | 40 +++++ spec/simple/store/create_spec.rb | 164 ++++++++++++++++++ spec/simple/store/delete_spec.rb | 140 +++++++++++++++ spec/simple/store/find_spec.rb | 79 +++++++++ spec/simple/store/metamodel_spec.rb | 80 +++++++++ spec/simple/store/model_spec.rb | 52 ++++++ spec/simple/store/registry_spec.rb | 53 ++++++ spec/simple/store/save_spec.rb | 72 ++++++++ spec/simple/store/storage_spec.rb | 22 +++ spec/simple/store/store_migrations.rb | 40 +++++ spec/simple/store/store_spec_helper.rb | 32 ++++ spec/simple/store/virtual_attribute_spec.rb | 54 ++++++ spec/support/004_simplecov.rb | 3 + 31 files changed, 2025 insertions(+) create mode 100644 lib/simple-store.rb create mode 100644 lib/simple/store.rb create mode 100644 lib/simple/store/create.rb create mode 100644 lib/simple/store/delete.rb create mode 100644 lib/simple/store/errors.rb create mode 100644 lib/simple/store/find.rb create mode 100644 lib/simple/store/helpers.rb create mode 100644 lib/simple/store/metamodel.rb create mode 100644 lib/simple/store/metamodel/registry.rb create mode 100644 lib/simple/store/model.rb create mode 100644 lib/simple/store/registry.rb create mode 100644 lib/simple/store/storage.rb create mode 100644 lib/simple/store/update.rb create mode 100644 lib/simple/store/validation.rb create mode 100755 scripts/performance_test.rb create mode 100755 scripts/performance_test2.rb create mode 100644 simple-store.md create mode 100644 spec/simple/store/build_spec.rb create mode 100644 spec/simple/store/create_spec.rb create mode 100644 spec/simple/store/delete_spec.rb create mode 100644 spec/simple/store/find_spec.rb create mode 100644 spec/simple/store/metamodel_spec.rb create mode 100644 spec/simple/store/model_spec.rb create mode 100644 spec/simple/store/registry_spec.rb create mode 100644 spec/simple/store/save_spec.rb create mode 100644 spec/simple/store/storage_spec.rb create mode 100644 spec/simple/store/store_migrations.rb create mode 100644 spec/simple/store/store_spec_helper.rb create mode 100644 spec/simple/store/virtual_attribute_spec.rb 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/store.rb b/lib/simple/store.rb new file mode 100644 index 0000000..0b70aa3 --- /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 metadata 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(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..142b39b --- /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(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..88e34f4 --- /dev/null +++ b/lib/simple/store/metamodel.rb @@ -0,0 +1,172 @@ +class Simple::Store::Metamodel; end + +require_relative "metamodel/registry" + +# 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 + + 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, + type: :text + } + + # register an attribute + def attribute(name, options) + name = name.to_s + + 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 + + # 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 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 + + read_attributes_from_table + + instance_eval(&block) if block + end + + private + + def column_info + column_info = Simple::SQL::Reflection.column_info(table_name) + raise ArgumentError, "No such table #{table_name.inspect}" if column_info.empty? + column_info + end + + public + + def column?(name) + column_info.key? name + end + + private + + TYPE_BY_PG_DATA_TYPE = { + "character varying" => :text, + "timestamp without time zone" => :timestamp, + "USER-DEFINED" => :string # enums + } + + READONLY_ATTRIBUTES = %(created_at updated_at id type) + + def read_attributes_from_table + column_info.each do |name, ostruct| + next if name == "metadata" + + data_type = ostruct.data_type + + attribute name, + type: (TYPE_BY_PG_DATA_TYPE[data_type] || data_type.to_sym), + writable: !READONLY_ATTRIBUTES.include?(name), + readable: true, + kind: :static + end + 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..53974a9 --- /dev/null +++ b/lib/simple/store/model.rb @@ -0,0 +1,177 @@ +# rubocop:disable Metrics/CyclomaticComplexity +# rubocop:disable Metrics/AbcSize + +require "active_support/core_ext/string/inflections" +require "active_support/core_ext/hash/keys" + +class Simple::Store::Model + def new_record? + id.nil? + end + + attr_reader :metamodel + attr_reader :to_hash + + # 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 + + private + + # 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 + # fro 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 + + metamodel.attributes(kind: :virtual).each_key do |name| + @to_hash[name] = metamodel.virtual_implementations.send(name, self) + end + + # puts base.public_methods(fal) + end + + 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. + # + # Attributes are converted as necessary. See #convert_types. + 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 + + # -- convert types fro String values into target type + + attrs = convert_types(attrs) + + @to_hash.merge!(attrs) + self + end + + private + + # stringify keys in attrs, and convert as necessary. + # + # Conversion only happens if an input value is a String, and the attribute + # is not of type :text. + # + # Also removes all unknown attributes, and internal attributes. + # + # Returns a new or a potentially changed Hash + def convert_types(attrs) + attrs + end + + public + + # compare this record with another record. + # + # This compares against a Hash representation of \a other. Consequently you + # can compare a Record against another Record, but also against a Hash. + 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 metadata).include?(k) } + + 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) && attribute?($1) + end + + def method_missing(sym, *args) + case args.length + when 0 + if GETTER_REGEXP =~ sym && attribute?($1) + return get_attribute($1) + end + when 1 + if SETTER_REGEXP =~ sym && writable_attribute?($1) + return set_attribute($1, args[0]) + end + end + + super + end + + def attribute?(name) + metamodel.attributes.key?(name) + end + + def writable_attribute?(name) + metamodel.attributes(writable: true).key?(name) + end + + def get_attribute(name) + @to_hash[name] + end + + def set_attribute(name, value) + # @original_hash ||= @to_hash.deep_dup + + @to_hash[name] = value + 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..ba46021 --- /dev/null +++ b/lib/simple/store/storage.rb @@ -0,0 +1,87 @@ +# rubocop:disable Metrics/AbcSize +# rubocop:disable Metrics/CyclomaticComplexity +# rubocop:disable Metrics/MethodLength +# rubocop:disable Metrics/BlockLength + +module Simple::Store::Storage + extend self + + Model = Simple::Store::Model + + def convert_one_to_storage_representation(metamodel, model) + convert_to_storage_representation(metamodel, [model]).first + end + + def convert_to_storage_representation(metamodel, models) + attributes = metamodel.attributes + dynamic_attributes = metamodel.attributes(kind: :dynamic) + + models.map do |model| + record = model.to_hash.dup + + metadata = {} + + # move all attributes from record into metadata. + record.reject! do |k, v| + attribute = attributes[k] + + case attribute && attribute[:kind] + when :static + false + when :dynamic + metadata[k] = v + true + when nil + # this is not a defined attribute (but maybe something internal, like "id" or "type") + true + else + expect! attribute => { kind: [:static, :dynamic, :virtual] } + true + end + end + + unless metadata.empty? + unless dynamic_attributes.empty? + raise "metadata on table without metadata column #{metadata.keys}" + end + record["metadata"] = metadata + end + record.delete "type" unless metamodel.column?("type") + record.delete "id" + record + end + end + + # + Immutable = ::Simple::SQL::Helpers::Immutable + + def new_from_row(hsh, fq_table_name:) + metamodel = determine_metamodel type: hsh[:type], fq_table_name: fq_table_name + if metamodel + Model.new(metamodel, trusted_data: hsh) + elsif hsh.count == 1 + hsh.values.first + else + 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..833b4be --- /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, + metadata 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..e7bb0f9 --- /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 "metadata" 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..dd7c80e --- /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 metadata gette" do + expect(record.respond_to?(:metadata)).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, + metadata: 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..2eef5b4 --- /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, + metadata 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: :text, 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 metadata column" do + expect(metamodel.attributes.key?("metadata")).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: :text, 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: :text, writable: true, readable: true, kind: :static }, + "last_name" => { type: :text, 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..05fb04d --- /dev/null +++ b/spec/simple/store/model_spec.rb @@ -0,0 +1,52 @@ +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 "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..e8a2eca --- /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=>:text, :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=>:text, :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..6b830bf --- /dev/null +++ b/spec/simple/store/save_spec.rb @@ -0,0 +1,72 @@ +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 +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..c4dda80 --- /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, + metadata 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..3ae1931 --- /dev/null +++ b/spec/simple/store/virtual_attribute_spec.rb @@ -0,0 +1,54 @@ +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 "reading virtual attributes" do + let!(:user) { Simple::Store.create! "User", first_name: "foo", last_name: "bar" } + + it "calculates the virtual attribute when requested" do + expect(user.full_name).to eq("foo bar") + end + + it "includes the virtual attribute" do + hsh = user.to_hash + + expect(hsh["full_name"]).to eq("foo bar") + end + + it "does not include the virtual attribute in the initial record", pending: "[TODO] Implement lazy virtual attributes" do + hsh = user.instance_variable_get :@to_hash + + expect(hsh["full_name"]).to be_nil + 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 From fece075776fb7ec0463bb50679b40e019eab91a0 Mon Sep 17 00:00:00 2001 From: eno Date: Wed, 28 Nov 2018 09:54:20 +0100 Subject: [PATCH 05/14] Refactor store/storage code for better readability --- lib/simple/store/storage.rb | 76 +++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 42 deletions(-) diff --git a/lib/simple/store/storage.rb b/lib/simple/store/storage.rb index ba46021..951ed9a 100644 --- a/lib/simple/store/storage.rb +++ b/lib/simple/store/storage.rb @@ -1,58 +1,50 @@ -# rubocop:disable Metrics/AbcSize -# rubocop:disable Metrics/CyclomaticComplexity -# rubocop:disable Metrics/MethodLength -# rubocop:disable Metrics/BlockLength - module Simple::Store::Storage extend self Model = Simple::Store::Model def convert_one_to_storage_representation(metamodel, model) - convert_to_storage_representation(metamodel, [model]).first + # Extract and merge static and dynamic attributes + record = extract_static_attributes(metamodel, model) + metadata = extract_dynamic_attributes(metamodel, model) + unless metadata.empty? + record["metadata"] = metadata + end + + # Reove type attribute if this table doesn't have a type column + # (but is statically typed instead.) + record.delete "type" unless metamodel.column?("type") + record end def convert_to_storage_representation(metamodel, models) - attributes = metamodel.attributes - dynamic_attributes = metamodel.attributes(kind: :dynamic) - models.map do |model| - record = model.to_hash.dup - - metadata = {} - - # move all attributes from record into metadata. - record.reject! do |k, v| - attribute = attributes[k] - - case attribute && attribute[:kind] - when :static - false - when :dynamic - metadata[k] = v - true - when nil - # this is not a defined attribute (but maybe something internal, like "id" or "type") - true - else - expect! attribute => { kind: [:static, :dynamic, :virtual] } - true - end - end - - unless metadata.empty? - unless dynamic_attributes.empty? - raise "metadata on table without metadata column #{metadata.keys}" - end - record["metadata"] = metadata - end - record.delete "type" unless metamodel.column?("type") - record.delete "id" - record + 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 def new_from_row(hsh, fq_table_name:) From b2a8a0c8523788a4e9e03f4df64e37ce20c81b4e Mon Sep 17 00:00:00 2001 From: eno Date: Wed, 28 Nov 2018 12:25:37 +0100 Subject: [PATCH 06/14] Rename function name for better readability --- lib/simple/store.rb | 2 +- lib/simple/store/create.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/simple/store.rb b/lib/simple/store.rb index 0b70aa3..376fd7e 100644 --- a/lib/simple/store.rb +++ b/lib/simple/store.rb @@ -153,7 +153,7 @@ def save!(models) models.map do |model| if model.new_record? - Create.create_model(model) + Create.create_model_during_save(model) else Update.update_model(model) end diff --git a/lib/simple/store/create.rb b/lib/simple/store/create.rb index 142b39b..d581601 100644 --- a/lib/simple/store/create.rb +++ b/lib/simple/store/create.rb @@ -5,7 +5,7 @@ module Simple::Store::Create extend self - def create_model(model) + 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 From 84f5fdbec3dea2cfe2126e96f8d8e68b550b5e41 Mon Sep 17 00:00:00 2001 From: eno Date: Wed, 28 Nov 2018 12:38:56 +0100 Subject: [PATCH 07/14] Fix a comment --- lib/simple/store/storage.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/simple/store/storage.rb b/lib/simple/store/storage.rb index 951ed9a..44777f7 100644 --- a/lib/simple/store/storage.rb +++ b/lib/simple/store/storage.rb @@ -11,7 +11,7 @@ def convert_one_to_storage_representation(metamodel, model) record["metadata"] = metadata end - # Reove type attribute if this table doesn't have a type column + # Remove type attribute if this table doesn't have a type column # (but is statically typed instead.) record.delete "type" unless metamodel.column?("type") record From 1d85050053d2c64d3a694c0a7b233b91109b7628 Mon Sep 17 00:00:00 2001 From: eno Date: Wed, 28 Nov 2018 12:39:55 +0100 Subject: [PATCH 08/14] Adjust the from_complete_row method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is according to the “simple/sql Properly deal w/incomplete models, w/custom type converters” commit in Simple::SQL --- lib/simple/store/storage.rb | 11 ++++++----- spec/simple/store/save_spec.rb | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/simple/store/storage.rb b/lib/simple/store/storage.rb index 44777f7..17732e3 100644 --- a/lib/simple/store/storage.rb +++ b/lib/simple/store/storage.rb @@ -45,14 +45,15 @@ def extract_dynamic_attributes(metamodel, model) public - Immutable = ::Simple::SQL::Helpers::Immutable + Immutable = ::Simple::SQL::Helpers::Immutable + Reflection = ::Simple::SQL::Reflection - def new_from_row(hsh, fq_table_name:) - metamodel = determine_metamodel type: hsh[:type], fq_table_name: fq_table_name + def from_complete_row(hsh, fq_table_name:) + # 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.new(metamodel, trusted_data: hsh) - elsif hsh.count == 1 - hsh.values.first else Immutable.create(hsh) end diff --git a/spec/simple/store/save_spec.rb b/spec/simple/store/save_spec.rb index 6b830bf..dabb320 100644 --- a/spec/simple/store/save_spec.rb +++ b/spec/simple/store/save_spec.rb @@ -69,4 +69,23 @@ 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 From aa9845870549d62d54c07ea3bb25351e9d2ee704 Mon Sep 17 00:00:00 2001 From: eno Date: Wed, 28 Nov 2018 12:40:47 +0100 Subject: [PATCH 09/14] Implement lazy, non-memoized virtual attributes Virtual attributes are resolved via method_missing. --- lib/simple/store/metamodel.rb | 12 ++++ lib/simple/store/model.rb | 63 +++++++++------------ spec/simple/store/virtual_attribute_spec.rb | 34 ++++++++--- 3 files changed, 66 insertions(+), 43 deletions(-) diff --git a/lib/simple/store/metamodel.rb b/lib/simple/store/metamodel.rb index 88e34f4..70a6094 100644 --- a/lib/simple/store/metamodel.rb +++ b/lib/simple/store/metamodel.rb @@ -83,6 +83,10 @@ 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 -> {} @@ -106,6 +110,14 @@ def attributes(filter = nil) 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 diff --git a/lib/simple/store/model.rb b/lib/simple/store/model.rb index 53974a9..1a175e4 100644 --- a/lib/simple/store/model.rb +++ b/lib/simple/store/model.rb @@ -4,6 +4,8 @@ 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? @@ -33,13 +35,12 @@ def new_record? # end # end - private - # 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 - # fro the storage. + # from the storage. + def initialize(metamodel, trusted_data: {}) expect! trusted_data => { "type" => [nil, metamodel.name] } @@ -47,14 +48,10 @@ def initialize(metamodel, trusted_data: {}) @to_hash = trusted_data.stringify_keys @to_hash["type"] = metamodel.name @to_hash["id"] ||= nil - - metamodel.attributes(kind: :virtual).each_key do |name| - @to_hash[name] = metamodel.virtual_implementations.send(name, self) - end - - # puts base.public_methods(fal) end + private + def set_id_by_trusted_caller(id) # rubocop:disable Naming/AccessorMethodName expect! id => Integer @to_hash["id"] = id @@ -109,10 +106,9 @@ def convert_types(attrs) public - # compare this record with another record. + # compare this model with another model or Hash. # - # This compares against a Hash representation of \a other. Consequently you - # can compare a Record against another Record, but also against a Hash. + # This ignores Symbol/String differences. def ==(other) return false unless other.respond_to?(:to_hash) @@ -122,7 +118,7 @@ def ==(other) def inspect hsh = to_hash.reject { |k, _v| %w(id type metadata).include?(k) } - hsh.reject! do |k, v| + hsh = hsh.reject do |k, v| v.nil? && metamodel.attributes[k] && metamodel.attributes[k][:kind] == :dynamic end @@ -139,39 +135,34 @@ def inspect GETTER_OR_SETTER = /\A([a-z_][a-z0-9_]*)(=?)\z/ def respond_to_missing?(sym, _include_private = false) - (sym =~ GETTER_OR_SETTER) && attribute?($1) + (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 && attribute?($1) - return get_attribute($1) + 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 && writable_attribute?($1) - return set_attribute($1, args[0]) + if SETTER_REGEXP =~ sym && metamodel.writable_attribute?($1) + return @to_hash[$1] = args[0] end end super end - - def attribute?(name) - metamodel.attributes.key?(name) - end - - def writable_attribute?(name) - metamodel.attributes(writable: true).key?(name) - end - - def get_attribute(name) - @to_hash[name] - end - - def set_attribute(name, value) - # @original_hash ||= @to_hash.deep_dup - - @to_hash[name] = value - end end diff --git a/spec/simple/store/virtual_attribute_spec.rb b/spec/simple/store/virtual_attribute_spec.rb index 3ae1931..ea1e746 100644 --- a/spec/simple/store/virtual_attribute_spec.rb +++ b/spec/simple/store/virtual_attribute_spec.rb @@ -32,23 +32,43 @@ def full_name(user) end end - describe "reading virtual attributes" do - let!(:user) { Simple::Store.create! "User", first_name: "foo", last_name: "bar" } + 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 the virtual attribute when requested" do + it "calculates value when requested" do expect(user.full_name).to eq("foo bar") end - it "includes the virtual attribute" do + 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 include the virtual attribute in the initial record", pending: "[TODO] Implement lazy virtual attributes" do - hsh = user.instance_variable_get :@to_hash + 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" } - expect(hsh["full_name"]).to be_nil + 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 From b07a28973661db68144913802ce7ec947701fb2e Mon Sep 17 00:00:00 2001 From: eno Date: Fri, 30 Nov 2018 18:23:28 +0100 Subject: [PATCH 10/14] =?UTF-8?q?Metamodel#attribute:=20changes=20interfac?= =?UTF-8?q?e=20to=20=E2=80=9Cattribute=20name=20[,=20type=20]=20[=20,=20op?= =?UTF-8?q?tions=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also changes the default text type from :text to :string --- lib/simple/store/metamodel.rb | 42 ++++++++++++++++++++++------- spec/simple/store/metamodel_spec.rb | 8 +++--- spec/simple/store/registry_spec.rb | 4 +-- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/lib/simple/store/metamodel.rb b/lib/simple/store/metamodel.rb index 70a6094..c2ab55f 100644 --- a/lib/simple/store/metamodel.rb +++ b/lib/simple/store/metamodel.rb @@ -50,14 +50,33 @@ def self.resolve(name_or_metamodel) OPTIONS_DEFAULTS = { readable: true, writable: true, - kind: :dynamic, - type: :text + kind: :dynamic } # register an attribute - def attribute(name, options) + 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 @@ -161,7 +180,7 @@ def column?(name) private TYPE_BY_PG_DATA_TYPE = { - "character varying" => :text, + "character varying" => :string, "timestamp without time zone" => :timestamp, "USER-DEFINED" => :string # enums } @@ -170,15 +189,18 @@ def column?(name) def read_attributes_from_table column_info.each do |name, ostruct| - next if name == "metadata" + next if name == "meta_data" data_type = ostruct.data_type + type = (TYPE_BY_PG_DATA_TYPE[data_type] || data_type.to_sym) + + options = { + writable: !READONLY_ATTRIBUTES.include?(name), + readable: true, + kind: :static + } - attribute name, - type: (TYPE_BY_PG_DATA_TYPE[data_type] || data_type.to_sym), - writable: !READONLY_ATTRIBUTES.include?(name), - readable: true, - kind: :static + attribute name, type, options end end end diff --git a/spec/simple/store/metamodel_spec.rb b/spec/simple/store/metamodel_spec.rb index 2eef5b4..2153741 100644 --- a/spec/simple/store/metamodel_spec.rb +++ b/spec/simple/store/metamodel_spec.rb @@ -41,7 +41,7 @@ it "returns the correct set of attributes" do expect(metamodel.attributes).to eq( "id" => { type: :integer, writable: false, readable: true, kind: :static }, - "name" => { type: :text, writable: true, readable: true, kind: :static } + "name" => { type: :string, writable: true, readable: true, kind: :static } ) end end @@ -64,12 +64,12 @@ it "returns the correct set of attributes" do expected_attributes = { "id" => { type: :integer, writable: false, readable: true, kind: :static }, - "type" => { type: :text, 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: :text, writable: true, readable: true, kind: :static }, - "last_name" => { type: :text, 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 } } diff --git a/spec/simple/store/registry_spec.rb b/spec/simple/store/registry_spec.rb index e8a2eca..3f73da9 100644 --- a/spec/simple/store/registry_spec.rb +++ b/spec/simple/store/registry_spec.rb @@ -34,7 +34,7 @@ it "replaces existing attribute options" do metamodel = Simple::Store::Metamodel.resolve "User2" - expect(metamodel.attributes["last_name"]).to eq({:type=>:text, :writable=>false, :readable=>true, :kind=>:static}) + expect(metamodel.attributes["last_name"]).to eq({:type=>:string, :writable=>false, :readable=>true, :kind=>:static}) end end @@ -47,7 +47,7 @@ it "replaces existing attribute options" do metamodel = Simple::Store::Metamodel.resolve "User2" - expect(metamodel.attributes["last_name"]).to eq({:type=>:text, :writable=>false, :readable=>true, :kind=>:static}) + expect(metamodel.attributes["last_name"]).to eq({:type=>:string, :writable=>false, :readable=>true, :kind=>:static}) end end end From 2b1882069775b9cda3bb8883439bc9634fbf428c Mon Sep 17 00:00:00 2001 From: eno Date: Fri, 30 Nov 2018 18:25:25 +0100 Subject: [PATCH 11/14] =?UTF-8?q?Changes=20name=20of=20meta=20data=20colum?= =?UTF-8?q?n=20to=20=E2=80=9Cmeta=5Fdata=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/simple/store.rb | 2 +- lib/simple/store/model.rb | 24 ++---------------------- lib/simple/store/storage.rb | 14 ++++++++++---- scripts/performance_test.rb | 2 +- simple-store.md | 2 +- spec/simple/store/create_spec.rb | 6 +++--- spec/simple/store/metamodel_spec.rb | 6 +++--- spec/simple/store/store_migrations.rb | 2 +- 8 files changed, 22 insertions(+), 36 deletions(-) diff --git a/lib/simple/store.rb b/lib/simple/store.rb index 376fd7e..1e4cd0e 100644 --- a/lib/simple/store.rb +++ b/lib/simple/store.rb @@ -29,7 +29,7 @@ module Simple::Store; end # 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 metadata JSONB attribute. Type conversions are done to +# 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 diff --git a/lib/simple/store/model.rb b/lib/simple/store/model.rb index 1a175e4..947c2b6 100644 --- a/lib/simple/store/model.rb +++ b/lib/simple/store/model.rb @@ -64,7 +64,7 @@ def set_id_by_trusted_caller(id) # rubocop:disable Naming/AccessorMethodName # This method ignores attributes that are not defined for models of this # type. # - # Attributes are converted as necessary. See #convert_types. + # [TODO]: Attributes are converted as necessary. def assign(attrs) expect! attrs => Hash @@ -82,30 +82,10 @@ def assign(attrs) write_protected_attributes.key?(key) end - # -- convert types fro String values into target type - - attrs = convert_types(attrs) - @to_hash.merge!(attrs) self end - private - - # stringify keys in attrs, and convert as necessary. - # - # Conversion only happens if an input value is a String, and the attribute - # is not of type :text. - # - # Also removes all unknown attributes, and internal attributes. - # - # Returns a new or a potentially changed Hash - def convert_types(attrs) - attrs - end - - public - # compare this model with another model or Hash. # # This ignores Symbol/String differences. @@ -116,7 +96,7 @@ def ==(other) end def inspect - hsh = to_hash.reject { |k, _v| %w(id type metadata).include?(k) } + 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 diff --git a/lib/simple/store/storage.rb b/lib/simple/store/storage.rb index 17732e3..73b3ea2 100644 --- a/lib/simple/store/storage.rb +++ b/lib/simple/store/storage.rb @@ -6,9 +6,9 @@ module Simple::Store::Storage def convert_one_to_storage_representation(metamodel, model) # Extract and merge static and dynamic attributes record = extract_static_attributes(metamodel, model) - metadata = extract_dynamic_attributes(metamodel, model) - unless metadata.empty? - record["metadata"] = metadata + 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 @@ -49,12 +49,18 @@ def extract_dynamic_attributes(metamodel, model) 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.new(metamodel, trusted_data: hsh) + 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 diff --git a/scripts/performance_test.rb b/scripts/performance_test.rb index 833b4be..82ce541 100755 --- a/scripts/performance_test.rb +++ b/scripts/performance_test.rb @@ -25,7 +25,7 @@ role_id INTEGER, first_name VARCHAR, last_name VARCHAR, - metadata JSONB, + meta_data JSONB, type VARCHAR NOT NULL, created_at timestamp, updated_at timestamp diff --git a/simple-store.md b/simple-store.md index e7bb0f9..cdbc542 100644 --- a/simple-store.md +++ b/simple-store.md @@ -42,7 +42,7 @@ 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 "metadata" JSONB 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 diff --git a/spec/simple/store/create_spec.rb b/spec/simple/store/create_spec.rb index dd7c80e..ff0477a 100644 --- a/spec/simple/store/create_spec.rb +++ b/spec/simple/store/create_spec.rb @@ -12,8 +12,8 @@ expect(record.metamodel.name).to eq(type_name) end - it "does not defines a metadata gette" do - expect(record.respond_to?(:metadata)).to eq(false) + it "does not defines a meta_data gette" do + expect(record.respond_to?(:meta_data)).to eq(false) end it "sets timestamps" do @@ -88,7 +88,7 @@ role_id: nil, first_name: "first", last_name: nil, - metadata: nil, + meta_data: nil, access_level: nil, type: "User" } diff --git a/spec/simple/store/metamodel_spec.rb b/spec/simple/store/metamodel_spec.rb index 2153741..bfb30f5 100644 --- a/spec/simple/store/metamodel_spec.rb +++ b/spec/simple/store/metamodel_spec.rb @@ -18,7 +18,7 @@ role_id INTEGER, first_name VARCHAR, last_name VARCHAR, - metadata JSONB, + meta_data JSONB, access_level access_level, created_at timestamp, updated_at timestamp @@ -49,8 +49,8 @@ describe "Types with dynamic attributes" do let!(:metamodel) { Simple::Store::Metamodel.new(table_name: "simple_store.dynamic_types") } - it "excludes the metadata column" do - expect(metamodel.attributes.key?("metadata")).to eq(false) + it "excludes the meta_data column" do + expect(metamodel.attributes.key?("meta_data")).to eq(false) end it "automatically determines the name" do diff --git a/spec/simple/store/store_migrations.rb b/spec/simple/store/store_migrations.rb index c4dda80..0f754e3 100644 --- a/spec/simple/store/store_migrations.rb +++ b/spec/simple/store/store_migrations.rb @@ -17,7 +17,7 @@ role_id INTEGER, first_name VARCHAR, last_name VARCHAR, - metadata JSONB, + meta_data JSONB, access_level access_level, type VARCHAR NOT NULL, created_at timestamp, From eac54fb9304b323204f0ff5ac2218c6d7b94fe6f Mon Sep 17 00:00:00 2001 From: eno Date: Fri, 30 Nov 2018 18:26:48 +0100 Subject: [PATCH 12/14] Adds specs for JSON generation --- lib/simple/store/model.rb | 4 ++++ spec/simple/store/model_spec.rb | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/lib/simple/store/model.rb b/lib/simple/store/model.rb index 947c2b6..d6575c9 100644 --- a/lib/simple/store/model.rb +++ b/lib/simple/store/model.rb @@ -14,6 +14,10 @@ def new_record? 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]. # diff --git a/spec/simple/store/model_spec.rb b/spec/simple/store/model_spec.rb index 05fb04d..37b88c3 100644 --- a/spec/simple/store/model_spec.rb +++ b/spec/simple/store/model_spec.rb @@ -6,6 +6,23 @@ 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) From 73f090f6fc85e5d858e09767b6bf24991c404985 Mon Sep 17 00:00:00 2001 From: eno Date: Sat, 1 Dec 2018 16:38:10 +0100 Subject: [PATCH 13/14] Move register_static_attributes method to Storage After all this method meeds knowledge of the Storage backend (i.e. the Database.) --- lib/simple/store/metamodel.rb | 47 +++-------------------------------- lib/simple/store/storage.rb | 26 ++++++++++++++++++- 2 files changed, 29 insertions(+), 44 deletions(-) diff --git a/lib/simple/store/metamodel.rb b/lib/simple/store/metamodel.rb index c2ab55f..593d4b2 100644 --- a/lib/simple/store/metamodel.rb +++ b/lib/simple/store/metamodel.rb @@ -1,6 +1,7 @@ class Simple::Store::Metamodel; end require_relative "metamodel/registry" +require_relative "storage" # rubocop:disable Metrics/ClassLength class Simple::Store::Metamodel @@ -8,6 +9,7 @@ class Simple::Store::Metamodel 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]/ @@ -155,52 +157,11 @@ def initialize(attrs, &block) name, table_name = attrs.values_at :name, :table_name @attributes = {} - @name = name || table_name.split(".").last.singularize.camelize + @name = name || table_name.split(".").last.singularize.camelize @table_name = table_name - read_attributes_from_table + Storage.register_static_attributes(self, fq_table_name: table_name) instance_eval(&block) if block end - - private - - def column_info - column_info = Simple::SQL::Reflection.column_info(table_name) - raise ArgumentError, "No such table #{table_name.inspect}" if column_info.empty? - column_info - end - - public - - def column?(name) - column_info.key? name - end - - private - - 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 read_attributes_from_table - column_info.each do |name, ostruct| - next if name == "meta_data" - - data_type = ostruct.data_type - type = (TYPE_BY_PG_DATA_TYPE[data_type] || data_type.to_sym) - - options = { - writable: !READONLY_ATTRIBUTES.include?(name), - readable: true, - kind: :static - } - - attribute name, type, options - end - end end diff --git a/lib/simple/store/storage.rb b/lib/simple/store/storage.rb index 73b3ea2..4027578 100644 --- a/lib/simple/store/storage.rb +++ b/lib/simple/store/storage.rb @@ -3,6 +3,30 @@ module Simple::Store::Storage 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) @@ -13,7 +37,7 @@ def convert_one_to_storage_representation(metamodel, model) # Remove type attribute if this table doesn't have a type column # (but is statically typed instead.) - record.delete "type" unless metamodel.column?("type") + record.delete "type" unless metamodel.attribute?("type") record end From 934db735ecf7c1410e8eaade7c50f532ffad4d58 Mon Sep 17 00:00:00 2001 From: eno Date: Fri, 21 Dec 2018 18:32:26 +0100 Subject: [PATCH 14/14] WIP --- bin/console | 2 ++ lib/simple/store/storage.rb | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) 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/storage.rb b/lib/simple/store/storage.rb index 4027578..534e6b2 100644 --- a/lib/simple/store/storage.rb +++ b/lib/simple/store/storage.rb @@ -29,7 +29,7 @@ def register_static_attributes(metamodel, fq_table_name:) def convert_one_to_storage_representation(metamodel, model) # Extract and merge static and dynamic attributes - record = extract_static_attributes(metamodel, model) + record = extract_static_attributes(metamodel, model) meta_data = extract_dynamic_attributes(metamodel, model) unless meta_data.empty? record["meta_data"] = meta_data