├── .document ├── .gitignore ├── .rspec ├── Gemfile ├── History.md ├── LICENSE.txt ├── README.md ├── Rakefile ├── VERSION ├── bricks.gemspec ├── lib ├── bricks.rb └── bricks │ ├── adapters │ └── active_record.rb │ ├── builder.rb │ ├── builder_set.rb │ └── dsl.rb ├── rails └── init.rb └── spec ├── bricks ├── adapters │ └── active_record_spec.rb └── builder_spec.rb ├── bricks_spec.rb ├── spec_helper.rb └── support └── active_record.rb /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | 4 | # rdoc generated 5 | rdoc 6 | 7 | # yard generated 8 | doc 9 | .yardoc 10 | 11 | # bundler 12 | .bundle 13 | 14 | # jeweler generated 15 | pkg 16 | 17 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 18 | # 19 | # * Create a file at ~/.gitignore 20 | # * Include files you want ignored 21 | # * Run: git config --global core.excludesfile ~/.gitignore 22 | # 23 | # After doing this, these files will be ignored in all your git projects, 24 | # saving you from having to 'pollute' every project you touch with them 25 | # 26 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 27 | # 28 | # For MacOS: 29 | # 30 | #.DS_Store 31 | 32 | # For TextMate 33 | #*.tmproj 34 | #tmtags 35 | 36 | # For emacs: 37 | #*~ 38 | #\#* 39 | #.\#* 40 | 41 | # For vim: 42 | #*.swp 43 | 44 | # For redcar: 45 | #.redcar 46 | 47 | # For rubinius: 48 | #*.rbc 49 | /Gemfile.lock 50 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | # Add dependencies required to use your gem here. 3 | # Example: 4 | # gem "activesupport", ">= 2.3.5" 5 | 6 | # Add dependencies to develop your gem here. 7 | # Include everything needed to run rake, tests, features, etc. 8 | group :development do 9 | gem "rspec", "~> 2.0" 10 | gem "bundler", "~> 1.0.0" 11 | gem "jeweler", "~> 1.6.2" 12 | gem "rcov", ">= 0" 13 | end 14 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | History 2 | ======= 3 | 4 | 0.5.0 5 | ----- 6 | 7 | * Now includes generalized hook framework, but only supports after(:clone) hook. 8 | * Ask for an attribute that hasn't been initialized yet, and it will be. 9 | 10 | 0.4.1 11 | ----- 12 | 13 | * You no longer need to place builders for classes used in associations before the builders for objects that declare those associations. 14 | * Fixed: you can now create builders for *-to-many associations using only the default attributes. 15 | 16 | 0.4.0 17 | ----- 18 | 19 | * Blocks passed to attributes now optionally take a second argument (the builder parent). 20 | * You can now use `?` like `!`, but it will search for an existing record first. 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Mojo Tech 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Bricks 2 | ====== 3 | 4 | *Bricks* is a hybrid Object Builder/Factory implementation. It aims to be a more flexible alternative to the existing Object Factory solutions while remaining as simple as possible. 5 | 6 | Usage 7 | ----- 8 | 9 | We'll use the following domain to describe *Brick's* features: 10 | 11 | # Only ActiveRecord objects are supported right now. 12 | 13 | # == Schema Information 14 | # 15 | # Table name: articles 16 | # 17 | # id :integer(4) not null, primary key 18 | # title :string(255) 19 | # author :string(255) 20 | # formatted_title :string(510) 21 | # popularity :integer(4) 22 | # publication_id :integer(4) 23 | # 24 | class Article < ActiveRecord::Base 25 | belongs_to :publication 26 | has_many :readers 27 | end 28 | 29 | # == Schema Information 30 | # 31 | # Table name: publications 32 | # 33 | # id :integer(4) not null, primary key 34 | # name :string(255) 35 | # type :string(255) 36 | # 37 | class Publication < ActiveRecord::Base 38 | end 39 | 40 | class Newspaper < Publication 41 | end 42 | 43 | # == Schema Information 44 | # 45 | # Table name: readers 46 | # 47 | # id :integer(4) not null, primary key 48 | # name :string(255) 49 | # birth_date :date 50 | # 51 | class Reader < ActiveRecord::Base 52 | end 53 | 54 | At its simplest, you can start using *Bricks* without declaring any builder (*note:* it gets less verbose). 55 | 56 | article_builder = build(Article) 57 | 58 | This will give you a builder for the Article class, which you can then use to build an Article 59 | 60 | article_builder. 61 | title("Why I hate Guybrush Threepwood"). 62 | author("Ghost Pirate LeChuck") 63 | 64 | Contrary to the original pattern, builders are stateful (i.e., you don't get a new builder every time you call a method on the current builder). 65 | 66 | You can get the underlying instance by calling `#generate`. 67 | 68 | article = article_builder.generate 69 | 70 | This will initialize an Article with the attributes you passed the builder. If, instead of initializing, you'd prefer the record to be created right away, use `#create` instead. 71 | 72 | If you don't really care about the builder and just want the underlying instance you can instead use. 73 | 74 | article = build(Article). 75 | title("Why I hate Guybrush Threepwood"). 76 | author!("Ghost Pirate LeChuck") # Note the "!" 77 | 78 | When you want to use the default builder, without customizing it any further, you can tack the "!" at the end of the builder method: 79 | 80 | build!(Article) 81 | create!(Article) 82 | 83 | ### Building builders 84 | 85 | Of course, using builders like described above isn't of much use. Let's create a builder for `Article`: 86 | 87 | Bricks do 88 | builder Article do 89 | title "Why I hate Guybrush Threepwood" 90 | author "Ghost Pirate LeChuck" 91 | end 92 | end 93 | 94 | You can then use it as you'd expect: 95 | 96 | # initializes an Article with default attributes set, and saves it 97 | article = create!(Article) 98 | 99 | ### Deferred initialization 100 | 101 | builder Article do 102 | # ... 103 | 104 | formatted_title { "The formatted title at #{Date.now}." } 105 | end 106 | 107 | You can get at the underlying instance from deferred blocks: 108 | 109 | builder Article do 110 | # ... 111 | 112 | formatted_title { |obj| obj.title + " by " + obj.author } 113 | end 114 | 115 | ### Associations 116 | 117 | *Bricks* supports setting association records. 118 | 119 | #### Many-to-one (belongs to) 120 | 121 | Bricks do 122 | builder Publication do 123 | name "The Caribbean Times" 124 | end 125 | 126 | builder Article do 127 | # ... 128 | 129 | publication # instantiate a publication with the default attributes set 130 | end 131 | end 132 | 133 | You can also customize the association builder instance: 134 | 135 | builder Article do 136 | # ... 137 | publication.name("The Caribeaneer") 138 | end 139 | 140 | If you prepend a "~" to the association declaration, the record will be initialized/created *only* if a record with the given attributes doesn't exist yet: 141 | 142 | builder Article do 143 | # ... 144 | ~publication # will search for a record with name "The Caribbean Times" 145 | end 146 | 147 | The same effect can be achieved in your tests using 148 | 149 | ~(build(Publication)).name!("The Daily Bugle") 150 | 151 | but since this is ugly, you can just use `?` instead of `!` and you'll get (almost) the same effect: 152 | 153 | build(Publication).name?("The Daily Bugle") 154 | 155 | There is a slight difference between using `~` and `?`. `~` will permanently change the builder, while `?` will enable searching only when it's used. 156 | 157 | #### One-to-many, Many-to-many (has many, has and belongs to many) 158 | 159 | You can create several objects for a *-to-many association by calling the method `#build` on the association: 160 | 161 | builder Article do 162 | # readers association will have 3 records 163 | 3.times { readers.build } 164 | end 165 | 166 | Upon generation, this will add 3 records with their attributes set to the defaults defined in the association class' builder. 167 | 168 | If you want to further customize each builder in the association, you can omit the `#build` method call: 169 | 170 | builder Article do 171 | # ... 172 | 173 | # readers association will have 3 records 174 | %w(Tom Dick Harry).each { |r| readers.name(r) } 175 | end 176 | 177 | Each call to the *-to-many association name will add a new builder, which you can then further customize: 178 | 179 | readers.name("Tom").birth_date(30.years.ago) 180 | 181 | (Note that you don't use "!" here. That's only when building the records in your tests.) 182 | 183 | ### Passing the parent to association builder blocks 184 | 185 | If you need access to the parent object when building an associated object, you'll find it as the second argument passed to a deferred block. 186 | 187 | builder Article do 188 | # ... 189 | 190 | publication.name { |_, article| "#{article.title}'s publication" } 191 | end 192 | 193 | ### Builder Inheritance 194 | 195 | Given the builder: 196 | 197 | builder Publication do 198 | name "The Caribbean Times" 199 | end 200 | 201 | you can do something like: 202 | 203 | np = build!(Newspaper) 204 | np.class # => Newspaper 205 | np.name # => "The Caribbean Times" 206 | 207 | ### Traits 208 | 209 | The real power of the Builder pattern comes from the use of traits. Instead of declaring name factories in a single-inheritance model, you instead declare traits, which you can then mix and match: 210 | 211 | builder Article 212 | # ... 213 | 214 | trait :alternative_publication do |name| 215 | publication.name(name) 216 | end 217 | 218 | trait :by_elaine do 219 | title "Why I love Guybrush Threepwood" 220 | author "Elaine Marley-Threepwood" 221 | end 222 | end 223 | 224 | Use it like this: 225 | 226 | article = build(Article).alternative_publication("The Caribeaneer").by_elaine! 227 | 228 | Note that if you want to override a *-to-many association inside a trait, you need to clear it first: 229 | 230 | builder Article 231 | # ... 232 | 233 | # this will reset the readers association 234 | trait :new_readers do 235 | readers.clear 236 | 237 | %(Charlotte Emily Anne).each { |r| readers.name(r) } 238 | end 239 | 240 | # this will add to the readers association 241 | trait :more_readers do 242 | readers.name("Groucho") 243 | end 244 | end 245 | 246 | For an executable version of this documentation, please see spec/bricks_spec.rb. 247 | 248 | ### Hooks 249 | 250 | *Bricks includes a simple, general hook framework. It allows you to do something like this: 251 | 252 | builder Article 253 | # ... 254 | 255 | trait :on_the_bugle do 256 | publication.name "The Daily Bugle" 257 | popularity 75 258 | end 259 | 260 | trait :on_the_planet do 261 | publication.name "The Daily Planet" 262 | popularity 85 263 | end 264 | 265 | after :clone do 266 | send %w(on_the_bugle on_the_planet)[rand(2)] 267 | end 268 | 269 | before :save do 270 | active true 271 | end 272 | end 273 | 274 | *Bricks* supports only two hooks right now: after(:clone) and before(:save). 275 | 276 | The after(:clone) hook will be executed whenever you use any of #build, #build!, #create or #create!, right before you start customizing the resulting builder on your test. 277 | 278 | On the other hand, the before(:save) hook will be executed only for #create and #create!. 279 | 280 | Installation 281 | ------------ 282 | 283 | ### Rails 2 284 | 285 | Add `config.gem "bricks"` to `environments/test.rb` or, as a rails plugin: 286 | 287 | $ script/plugin install git://github.com/mojotech/bricks.git # Rails 2 288 | 289 | ### Rails 3 290 | 291 | Add `gem "bricks"` to your `Gemfile`, or, as a rails plugin: 292 | 293 | $ rails plugin install git://github.com/mojotech/bricks.git # Rails 3 294 | 295 | ### RSpec [TODO: add instructions for other frameworks] 296 | 297 | # you only need to add the following line if you're using the gem 298 | require 'bricks/adapters/active_record' 299 | 300 | # put this inside RSpec's configure block to get access to 301 | # #build, #build!, #create and #create! in your specs 302 | config.include Bricks::DSL 303 | 304 | Finally, add a file to spec/support containing your builders. Call it whatever you'd like and make sure it gets loaded (rspec usually loads all .rb files under spec/support). 305 | 306 | Copyright 307 | --------- 308 | 309 | Copyright (c) 2011 Mojo Tech. See LICENSE.txt for further details. 310 | 311 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | begin 6 | Bundler.setup(:default, :development) 7 | rescue Bundler::BundlerError => e 8 | $stderr.puts e.message 9 | $stderr.puts "Run `bundle install` to install missing gems" 10 | exit e.status_code 11 | end 12 | require 'rake' 13 | 14 | require 'jeweler' 15 | Jeweler::Tasks.new do |gem| 16 | # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options 17 | gem.name = "bricks" 18 | gem.homepage = "http://github.com/mojotech/bricks" 19 | gem.license = "MIT" 20 | gem.summary = %Q{Hybrid object builder/factory.} 21 | gem.email = "david@mojotech.com" 22 | gem.author = "David Leal" 23 | # dependencies defined in Gemfile 24 | end 25 | Jeweler::RubygemsDotOrgTasks.new 26 | 27 | require 'rspec/core' 28 | require 'rspec/core/rake_task' 29 | RSpec::Core::RakeTask.new(:spec) do |spec| 30 | spec.pattern = FileList['spec/**/*_spec.rb'] 31 | end 32 | 33 | RSpec::Core::RakeTask.new(:rcov) do |spec| 34 | spec.pattern = 'spec/**/*_spec.rb' 35 | spec.rcov = true 36 | end 37 | 38 | task :default => :spec 39 | 40 | require 'rake/rdoctask' 41 | Rake::RDocTask.new do |rdoc| 42 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 43 | 44 | rdoc.rdoc_dir = 'rdoc' 45 | rdoc.title = "bricks #{version}" 46 | rdoc.rdoc_files.include('README*') 47 | rdoc.rdoc_files.include('lib/**/*.rb') 48 | end 49 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.6.0 -------------------------------------------------------------------------------- /bricks.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec' 4 | # -*- encoding: utf-8 -*- 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "bricks" 8 | s.version = "0.6.0" 9 | 10 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 11 | s.authors = ["David Leal"] 12 | s.date = "2011-11-24" 13 | s.email = "david@mojotech.com" 14 | s.extra_rdoc_files = [ 15 | "LICENSE.txt", 16 | "README.md" 17 | ] 18 | s.files = [ 19 | ".document", 20 | ".rspec", 21 | "Gemfile", 22 | "History.md", 23 | "LICENSE.txt", 24 | "README.md", 25 | "Rakefile", 26 | "VERSION", 27 | "bricks.gemspec", 28 | "lib/bricks.rb", 29 | "lib/bricks/adapters/active_record.rb", 30 | "lib/bricks/builder.rb", 31 | "lib/bricks/builder_set.rb", 32 | "lib/bricks/dsl.rb", 33 | "rails/init.rb", 34 | "spec/bricks/adapters/active_record_spec.rb", 35 | "spec/bricks/builder_spec.rb", 36 | "spec/bricks_spec.rb", 37 | "spec/spec_helper.rb", 38 | "spec/support/active_record.rb" 39 | ] 40 | s.homepage = "http://github.com/mojotech/bricks" 41 | s.licenses = ["MIT"] 42 | s.require_paths = ["lib"] 43 | s.rubygems_version = "1.8.10" 44 | s.summary = "Hybrid object builder/factory." 45 | 46 | if s.respond_to? :specification_version then 47 | s.specification_version = 3 48 | 49 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 50 | s.add_development_dependency(%q, ["~> 2.0"]) 51 | s.add_development_dependency(%q, ["~> 1.0.0"]) 52 | s.add_development_dependency(%q, ["~> 1.6.2"]) 53 | s.add_development_dependency(%q, [">= 0"]) 54 | else 55 | s.add_dependency(%q, ["~> 2.0"]) 56 | s.add_dependency(%q, ["~> 1.0.0"]) 57 | s.add_dependency(%q, ["~> 1.6.2"]) 58 | s.add_dependency(%q, [">= 0"]) 59 | end 60 | else 61 | s.add_dependency(%q, ["~> 2.0"]) 62 | s.add_dependency(%q, ["~> 1.0.0"]) 63 | s.add_dependency(%q, ["~> 1.6.2"]) 64 | s.add_dependency(%q, [">= 0"]) 65 | end 66 | end 67 | 68 | -------------------------------------------------------------------------------- /lib/bricks.rb: -------------------------------------------------------------------------------- 1 | require 'bricks/builder' 2 | require 'bricks/dsl' 3 | 4 | module Bricks 5 | class << self 6 | attr_writer :builders 7 | 8 | def builders 9 | @builders ||= BuilderHashSet.new 10 | end 11 | end 12 | 13 | class NoAttributeOrTrait < StandardError; end 14 | class BadSyntax < StandardError; end 15 | 16 | class BuilderHashSet 17 | def initialize(&block) 18 | @builders = {} 19 | end 20 | 21 | def [](key) 22 | if @builders[key] 23 | @builders[key] 24 | elsif Class === key 25 | @builders[key] = if builder = @builders[key.superclass] 26 | builder.derive(:class => key) 27 | else 28 | builder(key) 29 | end 30 | end 31 | end 32 | 33 | def builder(klass, &block) 34 | @builders[klass] = Bricks::Builder.new(klass, &block) 35 | end 36 | end 37 | end 38 | 39 | def Bricks(&block) 40 | Bricks::builders = Bricks::BuilderHashSet.new 41 | 42 | Bricks::builders.instance_eval(&block) 43 | end 44 | -------------------------------------------------------------------------------- /lib/bricks/adapters/active_record.rb: -------------------------------------------------------------------------------- 1 | require 'bricks' 2 | require 'active_record' 3 | 4 | module Bricks 5 | module Adapters 6 | class ActiveRecord 7 | class Association 8 | attr_reader :type 9 | 10 | def initialize(klass, kind) 11 | @class = klass 12 | @type = case kind 13 | when :belongs_to; :parent 14 | when :has_many, :has_and_belongs_to_many; :children 15 | else "Unknown AR association type: #{kind}." 16 | end 17 | end 18 | 19 | def klass 20 | @class 21 | end 22 | end 23 | 24 | def association?(klass, name, type = nil) 25 | association(klass, name, type) 26 | end 27 | 28 | def association(klass, name, type = nil) 29 | if ar = klass.reflect_on_association(name.to_sym) 30 | a = Association.new(ar.klass, ar.macro) 31 | 32 | a if type.nil? || a.type == type 33 | end 34 | end 35 | 36 | def find(klass, attrs) 37 | klass.find(:first, :conditions => attrs) 38 | end 39 | 40 | Bricks::Builder.adapter = self.new 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/bricks/builder.rb: -------------------------------------------------------------------------------- 1 | require 'bricks/dsl' 2 | require 'bricks/builder_set' 3 | 4 | module Bricks 5 | class Builder 6 | include Bricks::DSL 7 | 8 | def self.adapter 9 | @@adapter 10 | end 11 | 12 | def self.adapter=(adapter) 13 | @@adapter = adapter 14 | end 15 | 16 | def self.instances 17 | @@instances ||= {} 18 | end 19 | 20 | def ~@() 21 | @search = true 22 | 23 | self 24 | end 25 | 26 | def after(hook, &block) 27 | define_hook :after, hook, &block 28 | end 29 | 30 | def before(hook, &block) 31 | define_hook :before, hook, &block 32 | end 33 | 34 | def derive(args = {}) 35 | klass = args[:class] || @class 36 | save = args.has_key?(:save) ? args[:save] : @save 37 | search = args.has_key?(:search) ? args[:search] : @search 38 | 39 | Builder.new(klass, @attrs, @traits, save, search, &@block).tap { |b| 40 | b.send :build_attrs 41 | b.run_hook :after, :clone if ! args[:class] 42 | } 43 | end 44 | 45 | def initialize( 46 | klass, 47 | attrs = nil, 48 | traits = nil, 49 | save = false, 50 | search = false, 51 | &block) 52 | @class = klass 53 | @attrs = attrs ? deep_copy(attrs) : [] 54 | @traits = traits ? Module.new { include traits } : Module.new 55 | @save = save 56 | @search = search 57 | @block = block 58 | 59 | extend @traits 60 | end 61 | 62 | def generate(opts = {}) 63 | parent = opts[:parent] 64 | search = opts.has_key?(:search) ? opts[:search] : @search 65 | 66 | run_hook :before, :save if @save 67 | 68 | obj = initialize_object(parent) 69 | obj = adapter.find(@class, Hash[*@attrs.flatten]) || obj if search 70 | save_object(obj) if @save 71 | 72 | obj 73 | end 74 | 75 | def trait(name, &block) 76 | @traits.class_eval do 77 | define_method "__#{name}", &block 78 | 79 | define_method name do |*args| 80 | send "__#{name}", *args 81 | 82 | self 83 | end 84 | end 85 | end 86 | 87 | def method_missing(name, *args, &block) 88 | attr = (return_object = name.to_s =~ /[!?]$/) ? name.to_s.chop : name 89 | result = if respond_to?(attr) 90 | send(attr, *args) 91 | elsif settable?(attr) 92 | set attr, *args, &block 93 | else 94 | raise Bricks::NoAttributeOrTrait, 95 | "Can't find `#{name}' on builder for #{@class}." 96 | end 97 | 98 | if return_object 99 | opts = {:parent => @parent} 100 | opts[:search] = name.to_s =~ /\?$/ || @search 101 | 102 | generate opts 103 | else 104 | result 105 | end 106 | end 107 | 108 | protected 109 | 110 | def define_hook(position, name, &block) 111 | @traits.class_eval do 112 | define_method "__#{position}_#{name}", &block 113 | end 114 | end 115 | 116 | def run_hook(position, name) 117 | full_name = "__#{position}_#{name}" 118 | 119 | send full_name if respond_to?(full_name) 120 | end 121 | 122 | private 123 | 124 | def subject 125 | Builder.instances[@class] ||= @class.new 126 | end 127 | 128 | def adapter 129 | Builder.adapter 130 | end 131 | 132 | def deep_copy(attrs) 133 | attrs.inject([]) { |a, (k, v)| 134 | a.tap { a << [k, Builder === v ? v.derive : v] } 135 | } 136 | end 137 | 138 | def save_object(obj) 139 | obj.save! 140 | end 141 | 142 | class Proxy 143 | attr_reader :obj 144 | 145 | def initialize(obj, attrs, parent) 146 | @obj = obj 147 | @attrs_in = attrs.dup 148 | @attrs_out = {} 149 | @parent = parent 150 | end 151 | 152 | def method_missing(name, *args) 153 | name_y = name.to_sym 154 | 155 | if @attrs_in.assoc(name_y) && ! @attrs_out.has_key?(name_y) 156 | fix_attr name 157 | else 158 | @obj.send name, *args 159 | end 160 | end 161 | 162 | def build 163 | @attrs_in.each { |(k, _)| send k } 164 | 165 | @obj 166 | end 167 | 168 | def fix_attr(name) 169 | val = case v = @attrs_in.assoc(name).last 170 | when Proc 171 | case r = v.call(*[self, @parent].take([v.arity, 0].max)) 172 | when Proxy 173 | r.obj 174 | else 175 | r 176 | end 177 | when Builder, BuilderSet 178 | v.generate(:parent => self) 179 | else 180 | v 181 | end 182 | 183 | @attrs_out[name] = @obj.send("#{name}=", val) 184 | end 185 | end 186 | 187 | def initialize_object(parent) 188 | Proxy.new(@class.new, @attrs, parent).build 189 | end 190 | 191 | def settable?(name) 192 | subject.respond_to?("#{name}=") 193 | end 194 | 195 | def set(name, val = nil, &block) 196 | raise Bricks::BadSyntax, "Block and value given" if val && block_given? 197 | 198 | nsym = name.to_sym 199 | pair = @attrs.assoc(nsym) || (@attrs << [nsym, nil]).last 200 | 201 | if block_given? 202 | pair[-1] = block 203 | 204 | self 205 | elsif val 206 | pair[-1] = val 207 | 208 | self 209 | elsif adapter.association?(@class, nsym, :parent) 210 | pair[-1] = builder(adapter.association(@class, nsym).klass, @save) 211 | elsif adapter.association?(@class, nsym, :children) 212 | pair[-1] ||= BuilderSet.new(adapter.association(@class, nsym).klass) 213 | else 214 | raise Bricks::BadSyntax, 215 | "No value or block given and not an association: #{name}." 216 | end 217 | end 218 | 219 | def build_attrs 220 | instance_eval &@block if @block && @attrs.empty? 221 | end 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /lib/bricks/builder_set.rb: -------------------------------------------------------------------------------- 1 | require 'bricks/dsl' 2 | 3 | module Bricks 4 | class BuilderSet 5 | include Bricks::DSL 6 | 7 | def build(klass = @class) 8 | (@builders << super).last 9 | end 10 | 11 | def build!(klass = @class) 12 | (@builders << super).last 13 | end 14 | 15 | def clear 16 | @builders.clear 17 | end 18 | 19 | def create(klass = @class) 20 | (@builders << super).last 21 | end 22 | 23 | def create!(klass = @class) 24 | (@builders << super).last 25 | end 26 | 27 | def initialize(klass) 28 | @class = klass 29 | @builders = [] 30 | end 31 | 32 | def method_missing(name, *args) 33 | build(@class).send(name, *args) 34 | end 35 | 36 | def generate(parent = nil) 37 | @builders.map { |b| b.generate(parent) } 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/bricks/dsl.rb: -------------------------------------------------------------------------------- 1 | module Bricks 2 | module DSL 3 | def build(klass) 4 | builder(klass, false) 5 | end 6 | 7 | def build!(klass) 8 | build(klass).generate 9 | end 10 | 11 | def build?(klass) 12 | build(klass).generate(:search => true) 13 | end 14 | 15 | def create(klass) 16 | builder(klass, true) 17 | end 18 | 19 | def create!(klass) 20 | create(klass).generate 21 | end 22 | 23 | def create?(klass) 24 | create(klass).generate(:search => true) 25 | end 26 | 27 | def builder(klass, save) 28 | Bricks.builders[klass].derive(:save => save) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /rails/init.rb: -------------------------------------------------------------------------------- 1 | if defined?(ActiveRecord) 2 | require 'bricks/adapters/active_record' 3 | else 4 | Rails.logger.warn "No suitable Brick adapter found." 5 | end 6 | -------------------------------------------------------------------------------- /spec/bricks/adapters/active_record_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper') 2 | 3 | describe Bricks::Adapters::ActiveRecord do 4 | subject { Bricks::Adapters::ActiveRecord.new } 5 | 6 | it "gracefully handles a missing association" do 7 | subject.association?(Reader, :birth_date, :one).should be_nil 8 | end 9 | 10 | it "gracefully handles a missing association of the given type" do 11 | subject.association?(Article, :newspaper, :many).should be_nil 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/bricks/builder_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') 2 | 3 | describe Bricks::Builder do 4 | before :all do 5 | Bricks::Builder.adapter = Class.new { 6 | def association(*args) 7 | nil 8 | end 9 | 10 | alias_method :association?, :association 11 | }.new 12 | 13 | class Person 14 | attr_accessor :name, :first_name, :last_name 15 | end 16 | end 17 | 18 | it "fails if the model is missing the given attribute" do 19 | lambda { 20 | Bricks::Builder.new(Person).birth_date(Date.new(1978, 5, 3)) 21 | }.should raise_error(Bricks::NoAttributeOrTrait) 22 | end 23 | 24 | it "forbids passing a block and an initial value" do 25 | lambda { 26 | Bricks::Builder.new(Person).name("Jack") { "heh" } 27 | }.should raise_error(Bricks::BadSyntax) 28 | end 29 | 30 | it "forbids passing no value or block to a non-association attribute" do 31 | lambda { 32 | Bricks::Builder.new(Person).name 33 | }.should raise_error(Bricks::BadSyntax) 34 | end 35 | 36 | it "always generates a new object" do 37 | b = Bricks::Builder.new(Person) 38 | 39 | b.generate.object_id.should_not == b.generate.object_id 40 | end 41 | 42 | describe "attribute evaluation ordering" do 43 | before :all do 44 | end 45 | 46 | it "doesn't care which order the attributes are declared" do 47 | b = Bricks::Builder.new Person do 48 | name { |obj| obj.first_name + " " + obj.last_name } 49 | first_name { "Jack" } 50 | last_name { "Black" } 51 | end 52 | 53 | b.derive.generate.name.should == "Jack Black" 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/bricks_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper') 2 | 3 | describe Bricks do 4 | include Bricks::DSL 5 | 6 | before :all do 7 | Bricks::Builder.adapter = Bricks::Adapters::ActiveRecord.new 8 | 9 | Bricks do 10 | builder PrintMedium do 11 | start_date Date.new(1900, 1, 1) 12 | end 13 | 14 | builder Article do 15 | author 'Jack Jupiter' 16 | title 'a title' 17 | body 'the body' 18 | language 'Swahili' 19 | 20 | formatted_title { |obj| obj.title + " by " + obj.author } 21 | deferred { Time.now } 22 | newspaper.language { |_, article| article.language } 23 | 24 | %w(Socrates Plato Aristotle).each { |n| readers.name(n) } 25 | 26 | trait :in_english do 27 | language "English" 28 | end 29 | 30 | trait :by_jove do 31 | author "Jack Jupiter" 32 | end 33 | 34 | trait :maybe_bugle do 35 | ~newspaper.daily_bugle 36 | end 37 | 38 | trait :on_the_bugle do 39 | newspaper.daily_bugle 40 | end 41 | 42 | trait :with_alternative_readers do 43 | readers.clear 44 | 45 | %w(Tom Dick Harry).each { |n| readers.name(n) } 46 | end 47 | 48 | after :clone do 49 | popularity (1..100).to_a[rand(100)] 50 | end 51 | 52 | before :save do 53 | active true 54 | end 55 | end 56 | 57 | builder Newspaper do 58 | name "The Daily Planet" 59 | 60 | trait :daily_bugle do 61 | name "The Daily Bugle" 62 | end 63 | end 64 | end 65 | end 66 | 67 | after do 68 | Reader.delete_all 69 | Article.delete_all 70 | PrintMedium.delete_all 71 | Newspaper.delete_all 72 | end 73 | 74 | it "#build returns the constructor" do 75 | build(Article).should be_kind_of(Bricks::Builder) 76 | end 77 | 78 | it "#create returns the constructor" do 79 | create(Article).should be_kind_of(Bricks::Builder) 80 | end 81 | 82 | it "initializes a model" do 83 | a = build!(Article) 84 | 85 | a.should be_instance_of(Article) 86 | a.should be_new_record 87 | end 88 | 89 | it "creates a model" do 90 | a = create!(Article) 91 | 92 | a.should be_instance_of(Article) 93 | a.should be_saved 94 | end 95 | 96 | it "fetches an existing model instead of initializing it" do 97 | create(Newspaper).name!("The First in Line") 98 | 99 | create!(Newspaper).should == build?(Newspaper) 100 | end 101 | 102 | it "fetches an existing model instead of creating it" do 103 | create(Newspaper).name!("The First in Line") 104 | 105 | create!(Newspaper).should == create?(Newspaper) 106 | end 107 | 108 | describe "with simple fields" do 109 | it "initializes model fields" do 110 | a = build!(Article) 111 | 112 | a.title.should == 'a title' 113 | a.body.should == 'the body' 114 | end 115 | 116 | it "defers field initialization" do 117 | time = Time.now 118 | a = build!(Article) 119 | 120 | a.deferred.should > time 121 | end 122 | 123 | it "uses the object being built in deferred initialization" do 124 | build!(Article).formatted_title.should == "a title by Jack Jupiter" 125 | end 126 | 127 | it "fetches an existing model instead of creating it" do 128 | create!(Newspaper) 129 | 130 | n = create(Newspaper).name!("The Bugle Planet") 131 | 132 | create(Newspaper).name?("The Bugle Planet").should == n 133 | end 134 | end 135 | 136 | describe "with traits" do 137 | it "returns the builder after calling the trait" do 138 | build(Article).in_english.should be_kind_of(Bricks::Builder) 139 | end 140 | 141 | it "#build returns the object if the trait is called with a bang" do 142 | a = build(Article).in_english! 143 | 144 | a.should be_kind_of(Article) 145 | a.should be_new_record 146 | end 147 | 148 | it "#create creates the object if the trait is called with a bang" do 149 | a = create(Article).in_english! 150 | 151 | a.should be_kind_of(Article) 152 | a.should be_saved 153 | end 154 | 155 | it "initializes the model fields" do 156 | build(Article).in_english!.language.should == "English" 157 | end 158 | 159 | it "combines multiple traits" do 160 | a = build(Article).in_english.by_jove! 161 | 162 | a.language.should == "English" 163 | a.author.should == "Jack Jupiter" 164 | end 165 | end 166 | 167 | describe "with a many-to-one association" do 168 | it "initializes an association with the default values" do 169 | build!(Article).newspaper.name.should == 'The Daily Planet' 170 | end 171 | 172 | it "overrides the association" do 173 | build(Article).on_the_bugle!.newspaper.name. 174 | should == 'The Daily Bugle' 175 | end 176 | 177 | it "passes the parent into a deferred block" do 178 | build(Article).language!("Thai").newspaper.language.should == "Thai" 179 | end 180 | 181 | it "possibly looks for an existing record" do 182 | n = create(Newspaper).daily_bugle! 183 | a = create(Article).maybe_bugle! 184 | 185 | a.newspaper.should == n 186 | end 187 | 188 | it "possibly looks for an existing record (and finds none)" do 189 | a = create(Article).maybe_bugle! 190 | 191 | a.newspaper.should_not be_new_record 192 | a.newspaper.name.should == "The Daily Bugle" 193 | end 194 | end 195 | 196 | describe "with a one-to-many association" do 197 | it "initializes an association with the default values" do 198 | build!(Article).readers.map { |r| 199 | r.name 200 | }.should == %w(Socrates Plato Aristotle) 201 | end 202 | 203 | it "overrides the association" do 204 | build(Article).with_alternative_readers!.readers.map { |r| 205 | r.name 206 | }.should == %w(Tom Dick Harry) 207 | end 208 | 209 | it "creates records with default attributes" do 210 | a = create(Article).tap { |b| 2.times { b.readers.build } }.generate 211 | 212 | a.should have(5).readers 213 | end 214 | end 215 | 216 | describe "builder inheritance" do 217 | it "uses the parent's builder if the model has none" do 218 | mag = build!(Magazine) 219 | 220 | mag.should be_a(Magazine) 221 | mag.start_date.should == Date.new(1900, 1, 1) 222 | end 223 | 224 | it "creates a builder for models that don't have one" do 225 | build!(Reader).should be_a(Reader) 226 | end 227 | end 228 | 229 | describe "hooks" do 230 | it "executes the `generate' hook after a builder is cloned" do 231 | build!(Article).popularity.should_not == build!(Article).popularity 232 | end 233 | 234 | it "executes the `create' hook before an object is saved" do 235 | create!(Article).active.should be_true 236 | end 237 | 238 | it "it does not override values set after the builder is cloned" do 239 | build(Article).popularity!(50).popularity.should == 50 240 | end 241 | end 242 | end 243 | 244 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 2 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 3 | require 'rspec' 4 | require 'bricks' 5 | 6 | # Requires supporting files with custom matchers and macros, etc, 7 | # in ./support/ and its subdirectories. 8 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f} 9 | 10 | RSpec.configure do |config| 11 | 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/active_record.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/class/attribute_accessors' 2 | require 'active_support/core_ext/module/delegation' 3 | require 'benchmark' 4 | require 'bricks/adapters/active_record' 5 | 6 | ActiveRecord::Base.establish_connection( 7 | :adapter => "sqlite3", 8 | :database => ":memory:" 9 | ) 10 | 11 | ActiveRecord::Migration.verbose = false 12 | 13 | ActiveRecord::Schema.define(:version => 20110608204150) do 14 | create_table "articles", :force => true do |t| 15 | t.string "author" 16 | t.string "body" 17 | t.datetime "deferred" 18 | t.string "formatted_title" 19 | t.string "language" 20 | t.integer "newspaper_id" 21 | t.string "title" 22 | t.integer "popularity" 23 | t.boolean "active" 24 | end 25 | 26 | create_table "newspapers", :force => true do |t| 27 | t.string "language" 28 | t.string "name" 29 | end 30 | 31 | create_table "print_media", :force => true do |t| 32 | t.date "start_date" 33 | t.string "type" 34 | end 35 | 36 | create_table "readers", :force => true do |t| 37 | t.integer "article_id" 38 | t.string "name" 39 | end 40 | end 41 | 42 | class Article < ActiveRecord::Base 43 | belongs_to :newspaper 44 | has_many :readers 45 | 46 | validates_presence_of :newspaper_id 47 | 48 | def saved? 49 | ! new_record? 50 | end 51 | end 52 | 53 | class PrintMedium < ActiveRecord::Base; end 54 | 55 | class Magazine < PrintMedium; end 56 | 57 | class Newspaper < ActiveRecord::Base; end 58 | 59 | class Reader < ActiveRecord::Base; end 60 | --------------------------------------------------------------------------------