From 39256d6e6d406e946cd78805bd7b272b418f5608 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Thu, 24 Aug 2017 03:36:27 +0300 Subject: [PATCH] Add jennifer association support --- README.md | 16 ++++ spec/factory/jennifer/base_spec.cr | 53 ++++++++++++ spec/support/factories.cr | 15 +++- .../20170822160720829_add_author.cr | 18 ++++ spec/support/models.cr | 23 +++++- src/factory.cr | 8 +- src/factory/base.cr | 19 +++-- src/factory/jennifer.cr | 3 +- src/factory/jennifer/base.cr | 82 +++++++++++++++++-- src/factory/jennifer/trait.cr | 19 +++++ src/factory/trait.cr | 12 +++ 11 files changed, 247 insertions(+), 21 deletions(-) create mode 100644 spec/support/migrations/20170822160720829_add_author.cr create mode 100644 src/factory/jennifer/trait.cr diff --git a/README.md b/README.md index 162c36b..3cf8f6c 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,22 @@ It provides direct creating methods same as for building: FilmFactory.create([:bad], {:name => "Atilla"}) ``` +Also any association could be described on the factory or trait level: + +```crystal +class FilmFactory < Factory::Jennifer::Base + association :author + association :actor, UserFactory, options: {name: "Artemius Fault"} +end +``` + +Allowed arguments: + +- `:name` - first argument - represent model association name (mandatory) +- `:factory` - represents factory class (optional); is defaulted from association name +- `:strategy` - represents creation strategy; optional; default is "create" (also "build" is allowed) +- `:options` - represents extra arguments to association factory; optional + ## Development For development postgres is required because of testing integration with Jennifer. diff --git a/spec/factory/jennifer/base_spec.cr b/spec/factory/jennifer/base_spec.cr index 09ca6f4..6d62715 100644 --- a/spec/factory/jennifer/base_spec.cr +++ b/spec/factory/jennifer/base_spec.cr @@ -11,6 +11,52 @@ describe Factory::Jennifer::Base do ::Jennifer::Adapter.adapter.rollback_transaction end + describe "%association" do + it "uses factory's defined association" do + film = Factory.create_custom_film([:bad, :hit]) + expect(film.author.nil?).wont_equal(true) + expect(film.author!.name).must_match(/Author \d*$/) + end + + it "uses trait's author if it is given" do + film = Factory.create_fiction_film([:with_special_author]) + expect(film.author.nil?).wont_equal(true) + expect(film.author!.name).must_equal("Special Author") + end + + it "uses given overrides for factory" do + film = Factory.create_fiction_film([:with_special_author]) + expect(film.author!.name).must_equal("Special Author") + end + + it "uses parent association if current factory has no" do + film = Factory.create_fiction_film + expect(film.author.nil?).must_equal(false) + end + + it "creates object without association if there is no one" do + film = Factory.create_film + expect(film.author.nil?).must_equal(true) + end + end + + describe "%factory_creators" do + it "defines all create methods on module level" do + expect(Factory.create_custom_film.new_record?).must_equal(false) + expect(Factory.create_custom_film(name: "New film").new_record?).must_equal(false) + expect(Factory.create_custom_film({:name => "New"}).new_record?).must_equal(false) + expect(Factory.create_custom_film([:bad]).new_record?).must_equal(false) + expect(Factory.create_custom_film([:bad], name: "new").new_record?).must_equal(false) + expect(Factory.create_custom_film([:bad], {:name => "new"}).new_record?).must_equal(false) + expect(Factory.create_custom_film(1)[0].new_record?).must_equal(false) + expect(Factory.create_custom_film(1, name: "asd")[0].new_record?).must_equal(false) + expect(Factory.create_custom_film(1, [:bad])[0].new_record?).must_equal(false) + expect(Factory.create_custom_film(1, {:name => "asd"})[0].new_record?).must_equal(false) + expect(Factory.create_custom_film(1, [:bad], name: "asd")[0].new_record?).must_equal(false) + expect(Factory.create_custom_film(1, [:bad], {:name => "asd"})[0].new_record?).must_equal(false) + end + end + describe "%before_create" do it "calls before create" do film = CustomFilmFactory.create @@ -51,6 +97,13 @@ describe Factory::Jennifer::Base do expect(film.new_record?).must_equal(false) end + it "all model callbacks during creating" do + film = FilmFactory.create + expect(film.before_create).must_equal(true) + expect(film.before_save).must_equal(true) + expect(film.after_initialize).must_equal(true) + end + describe "ancestor factory" do it "accepts no arguments" do film = CustomFilmFactory.create diff --git a/spec/support/factories.cr b/spec/support/factories.cr index 180aca0..efd11e7 100644 --- a/spec/support/factories.cr +++ b/spec/support/factories.cr @@ -68,7 +68,6 @@ class TestFactory < Factory::Base end trait :addon do - attr :f1, "addon1" end end @@ -77,7 +76,6 @@ class SecondTestFactory < TestFactory assign :f3, 0.64 trait :nested do - attr :f1, "nested" assign :f2, -2 assign :f4, "nestedaddon" end @@ -120,6 +118,9 @@ end class CustomFilmFactory < FilmFactory sequence(:name) { |i| "Custom Film #{i}" } + + association :author, AuthorFactory + after_create do |obj| obj.name = obj.name! + "after" end @@ -128,3 +129,13 @@ class CustomFilmFactory < FilmFactory obj.name = obj.name! + "before" end end + +class FictionFilmFactory < CustomFilmFactory + trait :with_special_author do + association :author, options: {name: "Special Author"} + end +end + +class AuthorFactory < Factory::Jennifer::Base + sequence(:name) { |i| "Author #{i}" } +end diff --git a/spec/support/migrations/20170822160720829_add_author.cr b/spec/support/migrations/20170822160720829_add_author.cr new file mode 100644 index 0000000..f7e06b7 --- /dev/null +++ b/spec/support/migrations/20170822160720829_add_author.cr @@ -0,0 +1,18 @@ +class AddAuthor20170822160720829 < Jennifer::Migration::Base + def up + create_table :authors do |t| + t.string :name + t.string :last_name + end + change_table :films do |t| + t.add_column :author_id, :integer + end + end + + def down + drop_table :authors + change_table :films do |t| + t.drop_column :author_id + end + end +end diff --git a/spec/support/models.cr b/spec/support/models.cr index c98f818..1659d7c 100644 --- a/spec/support/models.cr +++ b/spec/support/models.cr @@ -3,6 +3,27 @@ class Film < Jennifer::Model::Base id: {type: Int32, primary: true}, name: String?, rating: Int32, - budget: Float32? + budget: Float32?, + author_id: Int32? ) + + belongs_to :author, Author + + {% for callback in %i(before_save after_initialize before_create) %} + getter {{callback.id}} = false + + def set_{{callback.id}} + @{{callback.id}} = true + end + + {{callback.id}} :set_{{callback.id}} + {% end %} +end + +class Author < Jennifer::Model::Base + mapping( + id: {type: Int32, primary: true}, + name: String? + ) + has_many :films, Film end diff --git a/src/factory.cr b/src/factory.cr index 14f4ddc..7f8ac5e 100644 --- a/src/factory.cr +++ b/src/factory.cr @@ -48,7 +48,7 @@ module Factory \{{@type}}.build(**attrs) end - def self.build_\{{factory_name}}(attrs : Hash) + def self.build_\{{factory_name}}(attrs : Hash | NamedTuple) \{{@type}}.build(attrs) end @@ -60,7 +60,7 @@ module Factory \{{@type}}.build(traits, **attrs) end - def self.build_\{{factory_name}}(traits : Array, attrs : Hash) + def self.build_\{{factory_name}}(traits : Array, attrs : Hash | NamedTuple) \{{@type}}.build(traits, attrs) end @@ -76,7 +76,7 @@ module Factory arr end - def self.build_\{{factory_name}}(count : Int32, attrs : Hash) + def self.build_\{{factory_name}}(count : Int32, attrs : Hash | NamedTuple) arr = [] of \{{CLASS_NAME.last.id}} count.times { arr << \{{@type}}.build(attrs) } arr @@ -94,7 +94,7 @@ module Factory arr end - def self.build_\{{factory_name}}(count : Int32, traits : Array, attrs : Hash) + def self.build_\{{factory_name}}(count : Int32, traits : Array, attrs : Hash | NamedTuple) arr = [] of \{{CLASS_NAME.last.id}} count.times { arr << \{{@type}}.build(traits, attrs) } arr diff --git a/src/factory/base.cr b/src/factory/base.cr index 81da6ae..7e1f091 100644 --- a/src/factory/base.cr +++ b/src/factory/base.cr @@ -10,7 +10,11 @@ module Factory def self.build end - def self.get_trait(name) + def self.get_trait(name : String, go_deep) + end + + def self.get_trait(name : Symbol) + get_trait(name.to_s) end def self.after_initialize(obj) @@ -109,12 +113,11 @@ module Factory \{{trait.id}} \{% end %} - def self.get_trait(name : String) - t = super - return t if t + def self.get_trait(name : String, go_deep : Bool = true) \{% for k, v in TRAITS %} return \{{v.id}} if \{{k}} == name \{% end %} + super if go_deep end \{% if ARGUMENT_TYPE.size == 0 && @type.superclass.constant("IS_FACTORY")[-1] == "true" && @type.superclass.constant("ARGUMENT_TYPE").size != 0 %} @@ -158,13 +161,13 @@ module Factory obj end - def self.build(attrs : Hash) + def self.build(attrs : Hash | NamedTuple) obj = initialize_with(build_attributes(attrs), [] of String) after_initialize(obj) obj end - def self.build(traits : Array, attrs : Hash) + def self.build(traits : Array, attrs : Hash | NamedTuple) obj = initialize_with(build_attributes(attrs, traits), traits) after_initialize(obj) obj @@ -194,7 +197,7 @@ module Factory attrs = attributes traits.each do |name| trait = get_trait(name.to_s) - raise "Unknown trait" if trait.nil? + raise "Unknown trait \"#{name.to_s}\"" if trait.nil? trait.not_nil!.add_attributes(attrs) end opts.each do |k, v| @@ -205,7 +208,7 @@ module Factory def self.make_assigns(obj, traits) \{% for k, v in ASSIGNS %} - obj.\{{k.id}} = \{% if v =~ /->/ %} \{{v.id}}.call \{% else %} @@assign_\{{k.id}} \{% end %} + obj.\{{k.id}} = \{% if v =~ /->/ %} \{{v.id}}.call \{% else %} @@assign_\{{k.id}} \{% end %} \{% end %} traits.each do |name| trait = get_trait(name.to_s) diff --git a/src/factory/jennifer.cr b/src/factory/jennifer.cr index a8d850c..70e0827 100644 --- a/src/factory/jennifer.cr +++ b/src/factory/jennifer.cr @@ -1 +1,2 @@ -require "./jennifer/*" +require "./jennifer/base" +require "./jennifer/trait" diff --git a/src/factory/jennifer/base.cr b/src/factory/jennifer/base.cr index 2b69ed3..801630e 100644 --- a/src/factory/jennifer/base.cr +++ b/src/factory/jennifer/base.cr @@ -1,5 +1,28 @@ module Factory module Jennifer + macro association_macro + macro association(name, factory = nil, strategy = :create, options = nil) + \{% ASSOCIATIONS << name.id.stringify %} + + \{% if factory %} + \{% klass = factory %} + \{% else %} + \{% klass = (name.id.stringify.camelcase + "Factory" ).id%} + \{% end %} + + def self.__process_association_\{{name.id}}(obj) + aobj = \{{klass}}.build(\{% if options %}\{{options}} \{% end %}) + \{% if strategy.id.stringify == "build" %} + obj.append_\{{name.id}}(aobj) + \{% elsif strategy.id.stringify == "create" %} + obj.add_\{{name.id}}(aobj) + \{% else %} + \{% raise "Strategy #{strategy.id} of #{@type} is not valid"} + \{% end %} + end + end + end + class Base < ::Factory::Base not_a_factory @@ -9,6 +32,9 @@ module Factory def self.before_create(obj) end + def self.process_association(obj, klasses, assoc) + end + macro before_create(&block) def self.before_create({{block.args[0].id}}) super @@ -23,7 +49,19 @@ module Factory end end + macro inherited + ASSOCIATIONS = [] of String + + ::Factory::Jennifer.association_macro + end + macro after_finished_hook + {% if @type.superclass != ::Factory::Jennifer::Base && @type != ::Factory::Jennifer::Base %} + {% for assoc in @type.superclass.constant("ASSOCIATIONS") %} + {% ASSOCIATIONS << assoc %} + {% end %} + {% end %} + {% if IS_FACTORY[-1] == "true" %} {% factory_name = @type.stringify.gsub(/Factory$/, "").underscore %} factory_creators({{factory_name.id}}) @@ -72,6 +110,40 @@ module Factory after_create(obj) obj end + + def self.make_assigns(obj, traits : Array) + \{% for k, v in ASSIGNS %} + obj.\{{k.id}} = \{% if v =~ /->/ %} \{{v.id}}.call \{% else %} @@assign_\{{k.id}} \{% end %} + \{% end %} + traits.each do |name| + trait = get_trait(name.to_s) + raise "Unknown trait" if trait.nil? + trait.not_nil!.make_assignes(obj) + end + add_associations(obj, traits) + end + + def self.add_associations(obj, traits : Array) + \{% if !ASSOCIATIONS.empty? %} + trait_classes = traits.map { |e| get_trait(e) }.compact + \{% for assoc in ASSOCIATIONS %} + process_association(obj, trait_classes, \{{assoc}}) + \{% end %} + \{% end %} + end + + def self.process_association(obj, trait_classes : Array, assoc) + trait_classes.each do |t| + if t.associations.includes?(assoc) + t.process_association(obj, assoc) + return + end + end + \{% for assoc in ASSOCIATIONS %} + __process_association_\{{assoc.id}}(obj) if \{{assoc}} == assoc + \{% end %} + super(obj, trait_classes[0...1], assoc) + end {% end %} end @@ -83,27 +155,27 @@ module Factory end def self.create_{{factory_name}}(**attrs) - obj = {{@type}}.build(**attrs) + obj = {{@type}}.create(**attrs) obj end def self.create_{{factory_name}}(attrs : Hash) - obj = {{@type}}.build(attrs) + obj = {{@type}}.create(attrs) obj end def self.create_{{factory_name}}(traits : Array) - obj = {{@type}}.build(traits) + obj = {{@type}}.create(traits) obj end def self.create_{{factory_name}}(traits : Array, **attrs) - obj = {{@type}}.build(traits, **attrs) + obj = {{@type}}.create(traits, **attrs) obj end def self.create_{{factory_name}}(traits : Array, attrs : Hash) - obj = {{@type}}.build(traits, attrs) + obj = {{@type}}.create(traits, attrs) obj end diff --git a/src/factory/jennifer/trait.cr b/src/factory/jennifer/trait.cr new file mode 100644 index 0000000..a9b7a95 --- /dev/null +++ b/src/factory/jennifer/trait.cr @@ -0,0 +1,19 @@ +module Factory + class Trait(T) + macro inherited + ASSOCIATIONS = [] of String + + ::Factory::Jennifer.association_macro + + def self.associations + ASSOCIATIONS + end + + def self.process_association(obj, assoc : String) + \{% for assoc in ASSOCIATIONS %} + __process_association_\{{assoc.id}}(obj) if assoc == \{{assoc}} + \{% end %} + end + end + end +end diff --git a/src/factory/trait.cr b/src/factory/trait.cr index 37905a3..c443455 100644 --- a/src/factory/trait.cr +++ b/src/factory/trait.cr @@ -2,12 +2,24 @@ module Factory class Trait(T) Factory.default_methods + # TODO: find way to suport trit attributes + macro attr(name, value, klass = nil) + {% raise "Now traits can't maintain attributes." %} + end + def self.add_attributes(hash) : Void end def self.make_assignes(obj : T) : Void end + def self.process_association(obj, assoc) + end + + def self.associations + [] of String + end + macro inherited SEQUENCES = {} of String => String CLASS_NAME = [] of String