From 4362c1e79b9c90babad41bbe251a2580dd9f934e Mon Sep 17 00:00:00 2001 From: Sean Kirby Date: Mon, 23 Nov 2020 09:51:57 -0500 Subject: [PATCH 01/11] Explain why primary keys are eagerly set --- lib/vorpal/engine.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/vorpal/engine.rb b/lib/vorpal/engine.rb index 64a9823..1703c61 100644 --- a/lib/vorpal/engine.rb +++ b/lib/vorpal/engine.rb @@ -34,6 +34,9 @@ def persist(roots) serialize(all_owned_objects, mapping, loaded_db_objects) new_objects = get_unsaved_objects(mapping.keys) begin + # Primary keys are set eagerly (instead of waiting for them to be set by ActiveRecord upon create) + # because we want to support non-null FK constraints without needing to figure the correct + # order to save entities in. set_primary_keys(all_owned_objects, mapping) set_foreign_keys(all_owned_objects, mapping) remove_orphans(mapping, loaded_db_objects) From 15fe7c7efe846ca52e074a0c6d825d1139b2c3c2 Mon Sep 17 00:00:00 2001 From: Sean Kirby Date: Mon, 23 Nov 2020 09:53:10 -0500 Subject: [PATCH 02/11] Add test for UUID PK support --- .../vorpal/aggregate_mapper_spec.rb | 31 +++++++++++++++++++ spec/helpers/db_helpers.rb | 10 +++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/spec/acceptance/vorpal/aggregate_mapper_spec.rb b/spec/acceptance/vorpal/aggregate_mapper_spec.rb index 0423c86..d77911d 100644 --- a/spec/acceptance/vorpal/aggregate_mapper_spec.rb +++ b/spec/acceptance/vorpal/aggregate_mapper_spec.rb @@ -739,6 +739,37 @@ def initialize(id: nil, name: "", trunk: nil, environment: nil, fissures: [], br end end + class TreeUUID + attr_accessor :id + attr_accessor :name + + def initialize(id: nil, name: "") + @id = id + @name = name + end + end + + describe 'UUID support' do + before(:all) do + db_connection.enable_extension 'pgcrypto' + define_table('tree_uuids', { name: :text }, true, id: :uuid) + end + + it 'generates a UUID as a PK on create' do + engine = Vorpal.define do + map TreeUUID, id: :uuid do + attributes :name + end + end + mapper = engine.mapper_for(TreeUUID) + + tree = TreeUUID.new(name: 'new tree') + mapper.persist([tree]) + + expect(tree.id).to be_a(String) + end + end + private def db_class_for(clazz, mapper) diff --git a/spec/helpers/db_helpers.rb b/spec/helpers/db_helpers.rb index 15580e4..6f6989d 100644 --- a/spec/helpers/db_helpers.rb +++ b/spec/helpers/db_helpers.rb @@ -39,12 +39,10 @@ def establish_connection end # when you change a table's columns, set force to true to re-generate the table in the DB - def define_table(table_name, columns, force) - if table_name_is_free?(table_name) || force - db_connection.create_table(table_name, force: true) do |t| - columns.each do |name, type| - t.send(type, name) - end + def define_table(table_name, columns, force, create_options = {}) + create_table(table_name, force: force, **create_options) do |t| + columns.each do |name, type| + t.send(type, name) end end end From 678afeebcf55e35623488ff91b00ec0e7628cb4b Mon Sep 17 00:00:00 2001 From: Sean Kirby Date: Mon, 23 Nov 2020 16:05:26 -0500 Subject: [PATCH 03/11] Add option for setting the primary key type --- lib/vorpal/dsl/config_builder.rb | 1 + lib/vorpal/dsl/configuration.rb | 4 +++ spec/unit/vorpal/dsl/config_builder_spec.rb | 31 +++++++++++++++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/vorpal/dsl/config_builder.rb b/lib/vorpal/dsl/config_builder.rb index 56915ff..46a90f2 100644 --- a/lib/vorpal/dsl/config_builder.rb +++ b/lib/vorpal/dsl/config_builder.rb @@ -101,6 +101,7 @@ def build_class_config db_class: @class_options[:to] || @defaults_generator.build_db_class(@class_options[:table_name]), serializer: @class_options[:serializer] || @defaults_generator.serializer(attributes_with_id), deserializer: @class_options[:deserializer] || @defaults_generator.deserializer(attributes_with_id), + primary_key_type: @class_options[:primary_key_type] || @class_options[:id] || :serial, ) end diff --git a/lib/vorpal/dsl/configuration.rb b/lib/vorpal/dsl/configuration.rb index 0a76329..eb27927 100644 --- a/lib/vorpal/dsl/configuration.rb +++ b/lib/vorpal/dsl/configuration.rb @@ -35,6 +35,10 @@ def define(options={}, &block) # object. # # Must have a `(Object) deserialize(Object, Hash)` method. + # @option options [Symbol] :primary_key_type [:serial, :uuid] (:serial) + # The type of primary key for the class. :serial for auto-incrementing integer, :uuid for a UUID + # @option options [Symbol] :id + # Same as :primary_key_type. Exists for compatibility with the Rails API. def map(domain_class, options={}, &block) @class_configs << build_class_config(domain_class, options, &block) end diff --git a/spec/unit/vorpal/dsl/config_builder_spec.rb b/spec/unit/vorpal/dsl/config_builder_spec.rb index 9b851fe..074aa8a 100644 --- a/spec/unit/vorpal/dsl/config_builder_spec.rb +++ b/spec/unit/vorpal/dsl/config_builder_spec.rb @@ -1,18 +1,45 @@ require 'unit_spec_helper' require 'vorpal/dsl/config_builder' +require 'vorpal/driver/postgresql' describe Vorpal::Dsl::ConfigBuilder do class Tester; end - let(:builder) { Vorpal::Dsl::ConfigBuilder.new(Tester, {}, nil) } - describe 'mapping attributes' do it 'allows the \'attributes\' method to be called multiple times' do + builder = new_builder builder.attributes :first builder.attributes :second expect(builder.attributes_with_id).to eq([:id, :first, :second]) end end + + describe 'set primary key type' do + it 'has a default of :serial' do + builder = new_builder + + expect(builder.build.primary_key_type).to eq(:serial) + end + + it 'reads from the :primary_key_type option' do + builder = new_builder(primary_key_type: :uuid) + + expect(builder.build.primary_key_type).to eq(:uuid) + end + + it 'reads from the :id option' do + builder = new_builder(id: :uuid) + + expect(builder.build.primary_key_type).to eq(:uuid) + end + end + + private + + def new_builder(options = {}, db_driver = nil) + db_driver ||= instance_double(Vorpal::Driver::Postgresql, build_db_class: nil) + Vorpal::Dsl::ConfigBuilder.new(Tester, options, db_driver) + end end From 12f3d80fa81d0841385191628daf17d28785eff1 Mon Sep 17 00:00:00 2001 From: Sean Kirby Date: Mon, 23 Nov 2020 16:08:35 -0500 Subject: [PATCH 04/11] Extract ClassConfig into own file --- lib/vorpal/config/class_config.rb | 66 +++++++++++++++++++++++++ lib/vorpal/configs.rb | 62 +---------------------- lib/vorpal/dsl/config_builder.rb | 2 +- spec/unit/vorpal/configs_spec.rb | 4 +- spec/unit/vorpal/loaded_objects_spec.rb | 2 +- 5 files changed, 71 insertions(+), 65 deletions(-) create mode 100644 lib/vorpal/config/class_config.rb diff --git a/lib/vorpal/config/class_config.rb b/lib/vorpal/config/class_config.rb new file mode 100644 index 0000000..c30ed88 --- /dev/null +++ b/lib/vorpal/config/class_config.rb @@ -0,0 +1,66 @@ +require 'equalizer' + +module Vorpal + module Config + # @private + class ClassConfig + include Equalizer.new(:domain_class, :db_class) + attr_reader :serializer, :deserializer, :domain_class, :db_class, :local_association_configs, :primary_key_type + attr_accessor :has_manys, :belongs_tos, :has_ones + + def initialize(attrs) + @has_manys = [] + @belongs_tos = [] + @has_ones = [] + @local_association_configs = [] + + attrs.each do |k,v| + instance_variable_set("@#{k}", v) + end + end + + def build_db_object(attributes) + db_class.new(attributes) + end + + def set_db_object_attributes(db_object, attributes) + db_object.attributes = attributes + end + + def get_db_object_attributes(db_object) + symbolize_keys(db_object.attributes) + end + + def serialization_required? + domain_class.superclass.name != 'ActiveRecord::Base' + end + + def serialize(object) + serializer.serialize(object) + end + + def deserialize(db_object) + attributes = get_db_object_attributes(db_object) + serialization_required? ? deserializer.deserialize(domain_class.new, attributes) : db_object + end + + def set_attribute(db_object, attribute, value) + db_object.send("#{attribute}=", value) + end + + def get_attribute(db_object, attribute) + db_object.send(attribute) + end + + private + + def symbolize_keys(hash) + result = {} + hash.each_key do |key| + result[key.to_sym] = hash[key] + end + result + end + end + end +end diff --git a/lib/vorpal/configs.rb b/lib/vorpal/configs.rb index 63daefa..3cde47f 100644 --- a/lib/vorpal/configs.rb +++ b/lib/vorpal/configs.rb @@ -1,5 +1,6 @@ require 'vorpal/util/hash_initialization' require 'vorpal/exceptions' +require 'vorpal/config/class_config' require 'equalizer' module Vorpal @@ -126,67 +127,6 @@ def foreign_key_info(remote_class_config) end end - # @private - class ClassConfig - include Equalizer.new(:domain_class, :db_class) - attr_reader :serializer, :deserializer, :domain_class, :db_class, :local_association_configs - attr_accessor :has_manys, :belongs_tos, :has_ones - - def initialize(attrs) - @has_manys = [] - @belongs_tos = [] - @has_ones = [] - @local_association_configs = [] - - attrs.each do |k,v| - instance_variable_set("@#{k}", v) - end - end - - def build_db_object(attributes) - db_class.new(attributes) - end - - def set_db_object_attributes(db_object, attributes) - db_object.attributes = attributes - end - - def get_db_object_attributes(db_object) - symbolize_keys(db_object.attributes) - end - - def serialization_required? - domain_class.superclass.name != 'ActiveRecord::Base' - end - - def serialize(object) - serializer.serialize(object) - end - - def deserialize(db_object) - attributes = get_db_object_attributes(db_object) - serialization_required? ? deserializer.deserialize(domain_class.new, attributes) : db_object - end - - def set_attribute(db_object, attribute, value) - db_object.send("#{attribute}=", value) - end - - def get_attribute(db_object, attribute) - db_object.send(attribute) - end - - private - - def symbolize_keys(hash) - result = {} - hash.each_key do |key| - result[key.to_sym] = hash[key] - end - result - end - end - # @private class ForeignKeyInfo include Equalizer.new(:fk_column, :fk_type_column, :fk_type) diff --git a/lib/vorpal/dsl/config_builder.rb b/lib/vorpal/dsl/config_builder.rb index 46a90f2..99a439f 100644 --- a/lib/vorpal/dsl/config_builder.rb +++ b/lib/vorpal/dsl/config_builder.rb @@ -96,7 +96,7 @@ def attributes_with_id private def build_class_config - Vorpal::ClassConfig.new( + Vorpal::Config::ClassConfig.new( domain_class: @domain_class, db_class: @class_options[:to] || @defaults_generator.build_db_class(@class_options[:table_name]), serializer: @class_options[:serializer] || @defaults_generator.serializer(attributes_with_id), diff --git a/spec/unit/vorpal/configs_spec.rb b/spec/unit/vorpal/configs_spec.rb index 52a9692..29bcdd3 100644 --- a/spec/unit/vorpal/configs_spec.rb +++ b/spec/unit/vorpal/configs_spec.rb @@ -11,8 +11,8 @@ class Comment attr_accessor :post end - let(:post_config) { Vorpal::ClassConfig.new(domain_class: Post) } - let(:comment_config) { Vorpal::ClassConfig.new(domain_class: Comment) } + let(:post_config) { Vorpal::Config::ClassConfig.new(domain_class: Post) } + let(:comment_config) { Vorpal::Config::ClassConfig.new(domain_class: Comment) } let(:post_has_many_comments_config) { Vorpal::HasManyConfig.new(name: 'comments', fk: 'post_id', child_class: Comment) } let(:post_has_one_comment_config) { Vorpal::HasOneConfig.new(name: 'best_comment', fk: 'post_id', child_class: Comment) } let(:comment_belongs_to_post_config) { Vorpal::BelongsToConfig.new(name: 'post', fk: 'post_id', child_classes: [Post]) } diff --git a/spec/unit/vorpal/loaded_objects_spec.rb b/spec/unit/vorpal/loaded_objects_spec.rb index 131a53a..7387187 100644 --- a/spec/unit/vorpal/loaded_objects_spec.rb +++ b/spec/unit/vorpal/loaded_objects_spec.rb @@ -14,7 +14,7 @@ def initialize(id:) it 'does not accept duplicate objects' do object = TestObject.new(id: 22) - config = Vorpal::ClassConfig.new({domain_class: Object}) + config = Vorpal::Config::ClassConfig.new({domain_class: Object}) subject.add(config, [object, object]) subject.add(config, [object]) From b69551ae9f6cf90984f0870643d9cfab8c23f423 Mon Sep 17 00:00:00 2001 From: Sean Kirby Date: Tue, 24 Nov 2020 17:18:44 -0500 Subject: [PATCH 05/11] Consolidate DSL --- lib/vorpal/configs.rb | 8 +- lib/vorpal/dsl/config_builder.rb | 50 +-------- lib/vorpal/dsl/configuration.rb | 177 +++++++++++++++++++++++-------- 3 files changed, 141 insertions(+), 94 deletions(-) diff --git a/lib/vorpal/configs.rb b/lib/vorpal/configs.rb index 3cde47f..23627fe 100644 --- a/lib/vorpal/configs.rb +++ b/lib/vorpal/configs.rb @@ -6,7 +6,7 @@ module Vorpal # @private class MasterConfig - def initialize(class_configs) + def initialize(class_configs = []) @class_configs = class_configs initialize_association_configs end @@ -21,7 +21,9 @@ def config_for_db_object(db_object) @class_configs.detect { |conf| conf.db_class == db_object.class } end - private + def add_class_config(class_config) + @class_configs << class_config + end def initialize_association_configs association_configs = {} @@ -51,6 +53,8 @@ def initialize_association_configs end end + private + def build_association_config(association_configs, local_config, fk, fk_type) association_config = AssociationConfig.new(local_config, fk, fk_type) if association_configs[association_config] diff --git a/lib/vorpal/dsl/config_builder.rb b/lib/vorpal/dsl/config_builder.rb index 99a439f..c97437f 100644 --- a/lib/vorpal/dsl/config_builder.rb +++ b/lib/vorpal/dsl/config_builder.rb @@ -16,64 +16,22 @@ def initialize(clazz, options, db_driver) @defaults_generator = DefaultsGenerator.new(clazz, db_driver) end - # Maps the given attributes to and from the domain object and the DB. Not needed - # if a serializer and deserializer were provided. + # @private def attributes(*attributes) @attributes.concat(attributes) end - # Defines a one-to-many association to another type where the foreign key is stored on the child. - # - # In Object-Oriented programming, associations are *directed*. This means that they can only be - # traversed in one direction: from the type that defines the association (the one with the - # getter) to the type that is associated. They end that defines the association is called the - # 'Parent' and the end that is associated is called the 'Child'. - # - # @param name [String] Name of the association getter. - # @param options [Hash] - # @option options [Boolean] :owned (True) True if the child type belongs to the aggregate. Changes to any object belonging to the aggregate will be persisted when the aggregate is persisted. - # @option options [String] :fk (Parent class name converted to snakecase and appended with a '_id') The name of the DB column on the child that contains the foreign key reference to the parent. - # @option options [String] :fk_type The name of the DB column on the child that contains the parent class name. Only needed when there is an association from the child side that is polymorphic. - # @option options [Class] :child_class (name converted to a Class) The child class. + # @private def has_many(name, options={}) @has_manys << {name: name}.merge(options) end - # Defines a one-to-one association to another type where the foreign key - # is stored on the child. - # - # In Object-Oriented programming, associations are *directed*. This means that they can only be - # traversed in one direction: from the type that defines the association (the one with the - # getter) to the type that is associated. They end that defines the association is called the - # 'Parent' and the end that is associated is called the 'Child'. - # - # @param name [String] Name of the association getter. - # @param options [Hash] - # @option options [Boolean] :owned (True) True if the child type belongs to the aggregate. Changes to any object belonging to the aggregate will be persisted when the aggregate is persisted. - # @option options [String] :fk (Parent class name converted to snakecase and appended with a '_id') The name of the DB column on the child that contains the foreign key reference to the parent. - # @option options [String] :fk_type The name of the DB column on the child that contains the parent class name. Only needed when there is an association from the child side that is polymorphic. - # @option options [Class] :child_class (name converted to a Class) The child class. + # @private def has_one(name, options={}) @has_ones << {name: name}.merge(options) end - # Defines a one-to-one association with another type where the foreign key - # is stored on the parent. - # - # This association can be polymorphic. I.E. children can be of different types. - # - # In Object-Oriented programming, associations are *directed*. This means that they can only be - # traversed in one direction: from the type that defines the association (the one with the - # getter) to the type that is associated. They end that defines the association is called the - # 'Parent' and the end that is associated is called the 'Child'. - # - # @param name [String] Name of the association getter. - # @param options [Hash] - # @option options [Boolean] :owned (True) True if the child type belongs to the aggregate. Changes to any object belonging to the aggregate will be persisted when the aggregate is persisted. - # @option options [String] :fk (Child class name converted to snakecase and appended with a '_id') The name of the DB column on the parent that contains the foreign key reference to the child. - # @option options [String] :fk_type The name of the DB column on the parent that contains the child class name. Only needed when the association is polymorphic. - # @option options [Class] :child_class (name converted to a Class) The child class. - # @option options [[Class]] :child_classes The list of possible classes that can be children. This is for polymorphic associations. Takes precedence over `:child_class`. + # @private def belongs_to(name, options={}) @belongs_tos << {name: name}.merge(options) end diff --git a/lib/vorpal/dsl/configuration.rb b/lib/vorpal/dsl/configuration.rb index eb27927..204e1ea 100644 --- a/lib/vorpal/dsl/configuration.rb +++ b/lib/vorpal/dsl/configuration.rb @@ -4,58 +4,143 @@ module Vorpal module Dsl - module Configuration - - # Configures and creates a {Engine} instance. - # - # @param options [Hash] Global configuration options for the engine instance. - # @option options [Object] :db_driver (Object that will be used to interact with the DB.) - # Must be duck-type compatible with {Postgresql}. + # Implements the Vorpal DSL. # - # @return [Engine] Instance of the mapping engine. - def define(options={}, &block) - master_config = build_config(&block) - db_driver = options.fetch(:db_driver, Driver::Postgresql.new) - Engine.new(db_driver, master_config) - end - - # Maps a domain class to a relational table. + # ```ruby + # engine = Vorpal.define do + # map Tree do + # attributes :name + # belongs_to :trunk + # has_many :branches + # end # - # @param domain_class [Class] Type of the domain model to be mapped - # @param options [Hash] Configure how to map the domain model - # @option options [String] :to - # Class of the ActiveRecord object that will map this domain class to the DB. - # Optional, if one is not specified, it will be generated. - # @option options [Object] :serializer (map the {ConfigBuilder#attributes} directly) - # Object that will convert the domain objects into a hash. + # map Trunk do + # attributes :length + # has_one :tree + # end # - # Must have a `(Hash) serialize(Object)` method. - # @option options [Object] :deserializer (map the {ConfigBuilder#attributes} directly) - # Object that will set a hash of attribute_names->values onto a new domain - # object. + # map Branch do + # attributes :length + # belongs_to :tree + # end + # end # - # Must have a `(Object) deserialize(Object, Hash)` method. - # @option options [Symbol] :primary_key_type [:serial, :uuid] (:serial) - # The type of primary key for the class. :serial for auto-incrementing integer, :uuid for a UUID - # @option options [Symbol] :id - # Same as :primary_key_type. Exists for compatibility with the Rails API. - def map(domain_class, options={}, &block) - @class_configs << build_class_config(domain_class, options, &block) - end + # mapper = engine.mapper_for(Tree) + # ``` + module Configuration + # Configures and creates a {Engine} instance. + # + # @param options [Hash] Global configuration options for the engine instance. + # @option options [Object] :db_driver (Object that will be used to interact with the DB.) + # Must be duck-type compatible with {Postgresql}. + # + # @return [Engine] Instance of the mapping engine. + def define(options={}, &block) + @master_config = MasterConfig.new + instance_exec(&block) + @master_config.initialize_association_configs + db_driver = options.fetch(:db_driver, Driver::Postgresql.new) + engine = Engine.new(db_driver, @master_config) + @master_config = nil # make sure this MasterConfig is never re-used by accident. + engine + end - # @private - def build_class_config(domain_class, options={}, &block) - builder = ConfigBuilder.new(domain_class, options, Driver::Postgresql.new) - builder.instance_exec(&block) if block_given? - builder.build - end + # Maps a domain class to a relational table. + # + # @param domain_class [Class] Type of the domain model to be mapped + # @param options [Hash] Configure how to map the domain model + # @option options [String] :to + # Class of the ActiveRecord object that will map this domain class to the DB. + # Optional, if one is not specified, it will be generated. + # @option options [Object] :serializer (map the {ConfigBuilder#attributes} directly) + # Object that will convert the domain objects into a hash. + # + # Must have a `(Hash) serialize(Object)` method. + # @option options [Object] :deserializer (map the {ConfigBuilder#attributes} directly) + # Object that will set a hash of attribute_names->values onto a new domain + # object. + # + # Must have a `(Object) deserialize(Object, Hash)` method. + # @option options [Symbol] :primary_key_type [:serial, :uuid] (:serial) + # The type of primary key for the class. :serial for auto-incrementing integer, :uuid for a UUID + # @option options [Symbol] :id + # Same as :primary_key_type. Exists for compatibility with the Rails API. + def map(domain_class, options={}, &block) + class_config = build_class_config(domain_class, options, &block) + @master_config.add_class_config(class_config) + class_config + end - # @private - def build_config(&block) - @class_configs = [] - self.instance_exec(&block) - MasterConfig.new(@class_configs) + # @private + def build_class_config(domain_class, options, &block) + @builder = ConfigBuilder.new(domain_class, options, Driver::Postgresql.new) + instance_exec(&block) if block_given? + class_config = @builder.build + @builder = nil # make sure this ConfigBuilder is never re-used by accident. + class_config + end + + # Maps the given attributes to and from the domain object and the DB. Not needed + # if a serializer and deserializer were provided. + def attributes(*attributes) + @builder.attributes(*attributes) + end + + # Defines a one-to-many association to another type where the foreign key is stored on the child. + # + # In Object-Oriented programming, associations are *directed*. This means that they can only be + # traversed in one direction: from the type that defines the association (the one with the + # getter) to the type that is associated. They end that defines the association is called the + # 'Parent' and the end that is associated is called the 'Child'. + # + # @param name [String] Name of the association getter. + # @param options [Hash] + # @option options [Boolean] :owned (True) True if the child type belongs to the aggregate. Changes to any object belonging to the aggregate will be persisted when the aggregate is persisted. + # @option options [String] :fk (Parent class name converted to snakecase and appended with a '_id') The name of the DB column on the child that contains the foreign key reference to the parent. + # @option options [String] :fk_type The name of the DB column on the child that contains the parent class name. Only needed when there is an association from the child side that is polymorphic. + # @option options [Class] :child_class (name converted to a Class) The child class. + def has_many(name, options={}) + @builder.has_many(name, options) + end + + # Defines a one-to-one association to another type where the foreign key + # is stored on the child. + # + # In Object-Oriented programming, associations are *directed*. This means that they can only be + # traversed in one direction: from the type that defines the association (the one with the + # getter) to the type that is associated. They end that defines the association is called the + # 'Parent' and the end that is associated is called the 'Child'. + # + # @param name [String] Name of the association getter. + # @param options [Hash] + # @option options [Boolean] :owned (True) True if the child type belongs to the aggregate. Changes to any object belonging to the aggregate will be persisted when the aggregate is persisted. + # @option options [String] :fk (Parent class name converted to snakecase and appended with a '_id') The name of the DB column on the child that contains the foreign key reference to the parent. + # @option options [String] :fk_type The name of the DB column on the child that contains the parent class name. Only needed when there is an association from the child side that is polymorphic. + # @option options [Class] :child_class (name converted to a Class) The child class. + def has_one(name, options={}) + @builder.has_one(name, options) + end + + # Defines a one-to-one association with another type where the foreign key + # is stored on the parent. + # + # This association can be polymorphic. I.E. children can be of different types. + # + # In Object-Oriented programming, associations are *directed*. This means that they can only be + # traversed in one direction: from the type that defines the association (the one with the + # getter) to the type that is associated. They end that defines the association is called the + # 'Parent' and the end that is associated is called the 'Child'. + # + # @param name [String] Name of the association getter. + # @param options [Hash] + # @option options [Boolean] :owned (True) True if the child type belongs to the aggregate. Changes to any object belonging to the aggregate will be persisted when the aggregate is persisted. + # @option options [String] :fk (Child class name converted to snakecase and appended with a '_id') The name of the DB column on the parent that contains the foreign key reference to the child. + # @option options [String] :fk_type The name of the DB column on the parent that contains the child class name. Only needed when the association is polymorphic. + # @option options [Class] :child_class (name converted to a Class) The child class. + # @option options [[Class]] :child_classes The list of possible classes that can be children. This is for polymorphic associations. Takes precedence over `:child_class`. + def belongs_to(name, options={}) + @builder.belongs_to(name, options) + end end end - end end From 795d18d432d7ae1e5351ef772d74308fa5e22fdd Mon Sep 17 00:00:00 2001 From: Sean Kirby Date: Tue, 24 Nov 2020 17:31:38 -0500 Subject: [PATCH 06/11] Remove old method for initializing associations --- lib/vorpal/configs.rb | 5 ++--- lib/vorpal/engine.rb | 6 ++++++ spec/unit/vorpal/configs_spec.rb | 16 ++++++++++++---- spec/unit/vorpal/db_loader_spec.rb | 20 ++++++++++---------- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/lib/vorpal/configs.rb b/lib/vorpal/configs.rb index 23627fe..f5621be 100644 --- a/lib/vorpal/configs.rb +++ b/lib/vorpal/configs.rb @@ -6,9 +6,8 @@ module Vorpal # @private class MasterConfig - def initialize(class_configs = []) - @class_configs = class_configs - initialize_association_configs + def initialize + @class_configs = [] end def config_for(clazz) diff --git a/lib/vorpal/engine.rb b/lib/vorpal/engine.rb index 1703c61..712a86f 100644 --- a/lib/vorpal/engine.rb +++ b/lib/vorpal/engine.rb @@ -92,10 +92,16 @@ def db_class(domain_class) @configs.config_for(domain_class).db_class end + # Try to use {AggregateMapper#query} instead. def query(domain_class) @db_driver.query(@configs.config_for(domain_class).db_class, mapper_for(domain_class)) end + # @private + def class_config(domain_class) + @configs.config_for(domain_class) + end + private def wrap(collection_or_not) diff --git a/spec/unit/vorpal/configs_spec.rb b/spec/unit/vorpal/configs_spec.rb index 29bcdd3..954e56a 100644 --- a/spec/unit/vorpal/configs_spec.rb +++ b/spec/unit/vorpal/configs_spec.rb @@ -21,7 +21,7 @@ class Comment it 'builds an association_config for a belongs_to' do comment_config.belongs_tos << comment_belongs_to_post_config - Vorpal::MasterConfig.new([post_config, comment_config]) + initialize_association_configs([post_config, comment_config]) expect(comment_config.local_association_configs.size).to eq(1) expect(post_config.local_association_configs.size).to eq(0) @@ -31,7 +31,7 @@ class Comment comment_config.belongs_tos << comment_belongs_to_post_config post_config.has_manys << post_has_many_comments_config - Vorpal::MasterConfig.new([post_config, comment_config]) + initialize_association_configs([post_config, comment_config]) association_config = comment_config.local_association_configs.first @@ -42,16 +42,24 @@ class Comment it 'builds an association_config for a has_many' do post_config.has_manys << post_has_many_comments_config - Vorpal::MasterConfig.new([post_config, comment_config]) + initialize_association_configs([post_config, comment_config]) expect(comment_config.local_association_configs.size).to eq(1) expect(post_config.local_association_configs.size).to eq(0) end end + def initialize_association_configs(configs) + master_config = Vorpal::MasterConfig.new + configs.each do |config| + master_config.add_class_config(config) + end + master_config.initialize_association_configs + end + describe 'nice user feedback' do it 'lets the user know what the problem is when a configuration is missing' do - master_config = Vorpal::MasterConfig.new([]) + master_config = Vorpal::MasterConfig.new expect { master_config.config_for(String) diff --git a/spec/unit/vorpal/db_loader_spec.rb b/spec/unit/vorpal/db_loader_spec.rb index dd5d6f8..3698570 100644 --- a/spec/unit/vorpal/db_loader_spec.rb +++ b/spec/unit/vorpal/db_loader_spec.rb @@ -50,18 +50,18 @@ class Comment; end # end it 'loads an object once even when referred to by different associations of different types with stubs' do - post_config = Vorpal.build_class_config(Post, to: PostDB) do - attributes :name - belongs_to :best_comment, child_class: Comment - has_many :comments - end + engine = Vorpal.define do + map(Post, to: PostDB) do + attributes :name + belongs_to :best_comment, child_class: Comment + has_many :comments + end - comment_config = Vorpal.build_class_config(Comment, to: CommentDB) do - attributes :length + map(Comment, to: CommentDB) do + attributes :length + end end - Vorpal::MasterConfig.new([post_config, comment_config]) - best_comment_db = CommentDB.new best_comment_db.id = 99 post_db = PostDB.new(best_comment_id: best_comment_db.id) @@ -74,7 +74,7 @@ class Comment; end expect(driver).to receive(:load_by_foreign_key).and_return([best_comment_db]) loader = Vorpal::DbLoader.new(false, driver) - loaded_objects = loader.load_from_db([post_db.id], post_config) + loaded_objects = loader.load_from_db([post_db.id], engine.class_config(Post)) expect(loaded_objects.all_objects).to contain_exactly(post_db, best_comment_db) end From 9331a81ce362f21ca7e45c4fe81011dc383b6dac Mon Sep 17 00:00:00 2001 From: Sean Kirby Date: Tue, 24 Nov 2020 17:38:59 -0500 Subject: [PATCH 07/11] Remove lingering references to "master" --- README.md | 6 +++--- lib/vorpal/configs.rb | 2 +- lib/vorpal/dsl/configuration.rb | 10 +++++----- lib/vorpal/engine.rb | 4 ++-- spec/unit/vorpal/configs_spec.rb | 12 ++++++------ spec/unit/vorpal/db_loader_spec.rb | 6 +++--- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 2eb1407..0b80765 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Vorpal [![Build Status](https://travis-ci.com/nulogy/vorpal.svg?branch=master)](https://travis-ci.com/nulogy/vorpal) [![Code Climate](https://codeclimate.com/github/nulogy/vorpal/badges/gpa.svg)](https://codeclimate.com/github/nulogy/vorpal) [![Code Coverage](https://codecov.io/gh/nulogy/vorpal/branch/master/graph/badge.svg)](https://codecov.io/gh/nulogy/vorpal/branch/master) +# Vorpal [![Build Status](https://travis-ci.com/nulogy/vorpal.svg?branch=main)](https://travis-ci.com/nulogy/vorpal) [![Code Climate](https://codeclimate.com/github/nulogy/vorpal/badges/gpa.svg)](https://codeclimate.com/github/nulogy/vorpal) [![Code Coverage](https://codecov.io/gh/nulogy/vorpal/branch/main/graph/badge.svg)](https://codecov.io/gh/nulogy/vorpal/branch/main) Separate your domain model from your persistence mechanism. Some problems call for a really sharp tool. @@ -158,7 +158,7 @@ TreeRepository.destroy_by_id(dead_tree_id) ## API Documentation -http://rubydoc.info/github/nulogy/vorpal/master/frames +http://rubydoc.info/github/nulogy/vorpal/main/frames ## Caveats It also does not do some things that you might expect from other ORMs: @@ -195,7 +195,7 @@ It also does not do some things that you might expect from other ORMs: **A.** Create a method on a [Repository](http://martinfowler.com/eaaCatalog/repository.html)! They have full access to the DB/ORM so you can use [Arel](https://github.com/rails/arel) and go [crazy](http://asciicasts.com/episodes/239-activerecord-relation-walkthrough) or use direct SQL if you want. -For example, use the [#query](https://rubydoc.info/github/nulogy/vorpal/master/Vorpal/AggregateMapper#query-instance_method) method on the [AggregateMapper](https://rubydoc.info/github/nulogy/vorpal/master/Vorpal/AggregateMapper) to access the underyling [ActiveRecordRelation](https://api.rubyonrails.org/classes/ActiveRecord/Relation.html): +For example, use the [#query](https://rubydoc.info/github/nulogy/vorpal/main/Vorpal/AggregateMapper#query-instance_method) method on the [AggregateMapper](https://rubydoc.info/github/nulogy/vorpal/main/Vorpal/AggregateMapper) to access the underyling [ActiveRecordRelation](https://api.rubyonrails.org/classes/ActiveRecord/Relation.html): ```ruby def find_special_ones diff --git a/lib/vorpal/configs.rb b/lib/vorpal/configs.rb index f5621be..1395677 100644 --- a/lib/vorpal/configs.rb +++ b/lib/vorpal/configs.rb @@ -5,7 +5,7 @@ module Vorpal # @private - class MasterConfig + class MainConfig def initialize @class_configs = [] end diff --git a/lib/vorpal/dsl/configuration.rb b/lib/vorpal/dsl/configuration.rb index 204e1ea..44e7928 100644 --- a/lib/vorpal/dsl/configuration.rb +++ b/lib/vorpal/dsl/configuration.rb @@ -36,12 +36,12 @@ module Configuration # # @return [Engine] Instance of the mapping engine. def define(options={}, &block) - @master_config = MasterConfig.new + @main_config = MainConfig.new instance_exec(&block) - @master_config.initialize_association_configs + @main_config.initialize_association_configs db_driver = options.fetch(:db_driver, Driver::Postgresql.new) - engine = Engine.new(db_driver, @master_config) - @master_config = nil # make sure this MasterConfig is never re-used by accident. + engine = Engine.new(db_driver, @main_config) + @main_config = nil # make sure this MainConfig is never re-used by accident. engine end @@ -67,7 +67,7 @@ def define(options={}, &block) # Same as :primary_key_type. Exists for compatibility with the Rails API. def map(domain_class, options={}, &block) class_config = build_class_config(domain_class, options, &block) - @master_config.add_class_config(class_config) + @main_config.add_class_config(class_config) class_config end diff --git a/lib/vorpal/engine.rb b/lib/vorpal/engine.rb index 712a86f..b465807 100644 --- a/lib/vorpal/engine.rb +++ b/lib/vorpal/engine.rb @@ -7,9 +7,9 @@ module Vorpal class Engine # @private - def initialize(db_driver, master_config) + def initialize(db_driver, main_config) @db_driver = db_driver - @configs = master_config + @configs = main_config end # Creates a mapper for saving/updating/loading/destroying an aggregate to/from diff --git a/spec/unit/vorpal/configs_spec.rb b/spec/unit/vorpal/configs_spec.rb index 954e56a..bd6bf99 100644 --- a/spec/unit/vorpal/configs_spec.rb +++ b/spec/unit/vorpal/configs_spec.rb @@ -1,7 +1,7 @@ require 'unit_spec_helper' require 'vorpal/configs' -describe Vorpal::MasterConfig do +describe Vorpal::MainConfig do class Post attr_accessor :comments attr_accessor :best_comment @@ -50,19 +50,19 @@ class Comment end def initialize_association_configs(configs) - master_config = Vorpal::MasterConfig.new + main_config = Vorpal::MainConfig.new configs.each do |config| - master_config.add_class_config(config) + main_config.add_class_config(config) end - master_config.initialize_association_configs + main_config.initialize_association_configs end describe 'nice user feedback' do it 'lets the user know what the problem is when a configuration is missing' do - master_config = Vorpal::MasterConfig.new + main_config = Vorpal::MainConfig.new expect { - master_config.config_for(String) + main_config.config_for(String) }.to raise_error(Vorpal::ConfigurationNotFound, "No configuration found for String") end end diff --git a/spec/unit/vorpal/db_loader_spec.rb b/spec/unit/vorpal/db_loader_spec.rb index 3698570..397ae01 100644 --- a/spec/unit/vorpal/db_loader_spec.rb +++ b/spec/unit/vorpal/db_loader_spec.rb @@ -30,7 +30,7 @@ class Comment; end # attributes :length # end # - # master_config = Vorpal::MasterConfig.new([post_config, comment_config]) + # main_config = Vorpal::MainConfig.new([post_config, comment_config]) # # driver = Vorpal::Postgresql.new # @@ -39,11 +39,11 @@ class Comment; end # best_comment_db.update_attributes!(post_id: post_db.id) # # loader = Vorpal::DbLoader.new(false, driver) - # loaded_objects = loader.load_from_db([post_db.id], master_config.config_for(Post)) + # loaded_objects = loader.load_from_db([post_db.id], main_config.config_for(Post)) # p loaded_objects.all_objects # # expect(loaded_objects.all_objects.size).to eq(2) # - # repo = Vorpal::AggregateMapper.new(driver, master_config) + # repo = Vorpal::AggregateMapper.new(driver, main_config) # post = repo.load(post_db.id, Post) # p post # expect(post.comments.size).to eq(1) From 301e08e0ebb61975a7bbebb27bf1e6550432e206 Mon Sep 17 00:00:00 2001 From: Sean Kirby Date: Tue, 24 Nov 2020 18:16:40 -0500 Subject: [PATCH 08/11] Validate the primary_key_type options --- lib/vorpal/config/class_config.rb | 13 ++++++---- spec/unit/vorpal/config/class_config_spec.rb | 25 ++++++++++++++++++++ spec/unit/vorpal/configs_spec.rb | 4 ++-- spec/unit/vorpal/loaded_objects_spec.rb | 2 +- 4 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 spec/unit/vorpal/config/class_config_spec.rb diff --git a/lib/vorpal/config/class_config.rb b/lib/vorpal/config/class_config.rb index c30ed88..fe9d539 100644 --- a/lib/vorpal/config/class_config.rb +++ b/lib/vorpal/config/class_config.rb @@ -5,18 +5,23 @@ module Config # @private class ClassConfig include Equalizer.new(:domain_class, :db_class) - attr_reader :serializer, :deserializer, :domain_class, :db_class, :local_association_configs, :primary_key_type + attr_reader :serializer, :deserializer, :domain_class, :db_class, :primary_key_type, :local_association_configs attr_accessor :has_manys, :belongs_tos, :has_ones + ALLOWED_PRIMARY_KEY_TYPE_OPTIONS = [:serial, :uuid] + def initialize(attrs) @has_manys = [] @belongs_tos = [] @has_ones = [] @local_association_configs = [] - attrs.each do |k,v| - instance_variable_set("@#{k}", v) - end + @serializer = attrs[:serializer] + @deserializer = attrs[:deserializer] + @domain_class = attrs[:domain_class] + @db_class = attrs[:db_class] + @primary_key_type = attrs[:primary_key_type] + raise "Invalid primary_key_type: '#{@primary_key_type}'" unless ALLOWED_PRIMARY_KEY_TYPE_OPTIONS.include?(@primary_key_type) end def build_db_object(attributes) diff --git a/spec/unit/vorpal/config/class_config_spec.rb b/spec/unit/vorpal/config/class_config_spec.rb new file mode 100644 index 0000000..ae5c589 --- /dev/null +++ b/spec/unit/vorpal/config/class_config_spec.rb @@ -0,0 +1,25 @@ +require 'unit_spec_helper' + +require 'vorpal/config/class_config' + +describe Vorpal::Config::ClassConfig do + describe 'primary_key_type options' do + it 'allows :serial' do + class_config = described_class.new(primary_key_type: :serial) + + expect(class_config.primary_key_type).to eq(:serial) + end + + it 'allows :uuid' do + class_config = described_class.new(primary_key_type: :uuid) + + expect(class_config.primary_key_type).to eq(:uuid) + end + + it 'does not allow others' do + expect do + described_class.new(primary_key_type: :invalid) + end.to raise_exception("Invalid primary_key_type: 'invalid'") + end + end +end diff --git a/spec/unit/vorpal/configs_spec.rb b/spec/unit/vorpal/configs_spec.rb index bd6bf99..a1b0523 100644 --- a/spec/unit/vorpal/configs_spec.rb +++ b/spec/unit/vorpal/configs_spec.rb @@ -11,8 +11,8 @@ class Comment attr_accessor :post end - let(:post_config) { Vorpal::Config::ClassConfig.new(domain_class: Post) } - let(:comment_config) { Vorpal::Config::ClassConfig.new(domain_class: Comment) } + let(:post_config) { Vorpal::Config::ClassConfig.new(domain_class: Post, primary_key_type: :serial) } + let(:comment_config) { Vorpal::Config::ClassConfig.new(domain_class: Comment, primary_key_type: :serial) } let(:post_has_many_comments_config) { Vorpal::HasManyConfig.new(name: 'comments', fk: 'post_id', child_class: Comment) } let(:post_has_one_comment_config) { Vorpal::HasOneConfig.new(name: 'best_comment', fk: 'post_id', child_class: Comment) } let(:comment_belongs_to_post_config) { Vorpal::BelongsToConfig.new(name: 'post', fk: 'post_id', child_classes: [Post]) } diff --git a/spec/unit/vorpal/loaded_objects_spec.rb b/spec/unit/vorpal/loaded_objects_spec.rb index 7387187..baed468 100644 --- a/spec/unit/vorpal/loaded_objects_spec.rb +++ b/spec/unit/vorpal/loaded_objects_spec.rb @@ -14,7 +14,7 @@ def initialize(id:) it 'does not accept duplicate objects' do object = TestObject.new(id: 22) - config = Vorpal::Config::ClassConfig.new({domain_class: Object}) + config = Vorpal::Config::ClassConfig.new(domain_class: Object, primary_key_type: :serial) subject.add(config, [object, object]) subject.add(config, [object]) From 13288947bb4dd660c69059e01455ecc02c8cfc2b Mon Sep 17 00:00:00 2001 From: Sean Kirby Date: Tue, 24 Nov 2020 18:24:20 -0500 Subject: [PATCH 09/11] Remove unused methods --- lib/vorpal/dsl/config_builder.rb | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/lib/vorpal/dsl/config_builder.rb b/lib/vorpal/dsl/config_builder.rb index c97437f..23fc30f 100644 --- a/lib/vorpal/dsl/config_builder.rb +++ b/lib/vorpal/dsl/config_builder.rb @@ -23,25 +23,25 @@ def attributes(*attributes) # @private def has_many(name, options={}) - @has_manys << {name: name}.merge(options) + @has_manys << build_has_many({name: name}.merge(options)) end # @private def has_one(name, options={}) - @has_ones << {name: name}.merge(options) + @has_ones << build_has_one({name: name}.merge(options)) end # @private def belongs_to(name, options={}) - @belongs_tos << {name: name}.merge(options) + @belongs_tos << build_belongs_to({name: name}.merge(options)) end # @private def build class_config = build_class_config - class_config.has_manys = build_has_manys - class_config.has_ones = build_has_ones - class_config.belongs_tos = build_belongs_tos + class_config.has_manys = @has_manys + class_config.has_ones = @has_ones + class_config.belongs_tos = @belongs_tos class_config end @@ -63,10 +63,6 @@ def build_class_config ) end - def build_has_manys - @has_manys.map { |options| build_has_many(options) } - end - def build_has_many(options) options[:child_class] ||= @defaults_generator.child_class(options[:name]) options[:fk] ||= @defaults_generator.foreign_key(@domain_class.name) @@ -74,10 +70,6 @@ def build_has_many(options) Vorpal::HasManyConfig.new(options) end - def build_has_ones - @has_ones.map { |options| build_has_one(options) } - end - def build_has_one(options) options[:child_class] ||= @defaults_generator.child_class(options[:name]) options[:fk] ||= @defaults_generator.foreign_key(@domain_class.name) @@ -85,10 +77,6 @@ def build_has_one(options) Vorpal::HasOneConfig.new(options) end - def build_belongs_tos - @belongs_tos.map { |options| build_belongs_to(options) } - end - def build_belongs_to(options) child_class = options[:child_classes] || options[:child_class] || @defaults_generator.child_class(options[:name]) options[:child_classes] = Array(child_class) From 41b96cabbbf9265c4d084639b70fa6416315f8bd Mon Sep 17 00:00:00 2001 From: Sean Kirby Date: Tue, 24 Nov 2020 18:31:07 -0500 Subject: [PATCH 10/11] Support UUID v4 primary keys --- lib/vorpal/engine.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/vorpal/engine.rb b/lib/vorpal/engine.rb index b465807..74fdf42 100644 --- a/lib/vorpal/engine.rb +++ b/lib/vorpal/engine.rb @@ -174,7 +174,11 @@ def serialize_object(object, config, loaded_db_objects) def set_primary_keys(owned_objects, mapping) owned_objects.each do |config, objects| in_need_of_primary_keys = objects.find_all { |obj| obj.id.nil? } - primary_keys = @db_driver.get_primary_keys(config.db_class, in_need_of_primary_keys.length) + if config.primary_key_type == :uuid + primary_keys = Array.new(in_need_of_primary_keys.length) { SecureRandom.uuid } + elsif config.primary_key_type == :serial + primary_keys = @db_driver.get_primary_keys(config.db_class, in_need_of_primary_keys.length) + end in_need_of_primary_keys.zip(primary_keys).each do |object, primary_key| mapping[object].id = primary_key object.id = primary_key From 9a333e902c6a2fa62e01a21768b6415640e0500f Mon Sep 17 00:00:00 2001 From: Sean Kirby Date: Tue, 24 Nov 2020 18:42:06 -0500 Subject: [PATCH 11/11] Update docs --- README.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0b80765..f87e08c 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,32 @@ TreeRepository.destroy(dead_tree) TreeRepository.destroy_by_id(dead_tree_id) ``` +### Ids + +Vorpal by default will use auto-incrementing Integers from a DB sequence for ids. However, UUID v4 ids are also +supported: + +```ruby +Vorpal.define do + # UUID v4 id! + map Tree, primary_key_type: :uuid do + # .. + end + + # Also a UUID v4 id, the Rails Way! + map Trunk, id: :uuid do + # .. + end + + # If you feel the need to specify an auto-incrementing integer id. + map Branch, primary_key_type: :serial do + # .. + end +end +``` + +CAVEAT: Vorpal currently does NOT SUPPORT anyone but Vorpal setting the id of an entity! + ## API Documentation http://rubydoc.info/github/nulogy/vorpal/main/frames @@ -175,9 +201,8 @@ It also does not do some things that you might expect from other ORMs: 1. Only supports PostgreSQL. ## Future Enhancements -* Support for UUID primary keys. +* Support for clients to set UUID-based ids. * Nicer DSL for specifying attributes that have different names in the domain model than in the DB. -* Show how to implement POROs without using Virtus (it is unsupported and can be crazy slow) * Aggregate updated_at. * Better support for value objects.