activeshepherd 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 6aedd38764552813b40c075d7215d87b588d37cf
4
+ data.tar.gz: 53fa8aa99d06d683c778a4b045a9f051e0c47c10
5
+ SHA512:
6
+ metadata.gz: d2c9ed3ee3217b7d9ebe2c0e153742e1b6f721bab5c929a19b4936809e49d13fbe1bfa6f60111f48a3c3bc883e8d964fda86313ac1006dc8c4d02815113c0c97
7
+ data.tar.gz: 43dbfc71a82519448d42a9d1abaf8ec1647d804498ff5bc65ff3f61a322d38e8bd70d34284db7537f392336f4e3af8c07c916fe5b4c2b19ab57e64d9ca1fc83e
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ tmp
4
+ Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.0.0-p0
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in activeshepherd.gemspec
4
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,16 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ guard 'minitest' do
5
+ # with Minitest::Unit
6
+ watch(%r|^test/(.*)\/?(.*)_test\.rb|)
7
+ watch(%r{^lib/active_shepherd/(.*/)?([^/]+)\.rb$}) { |m| "test/unit/#{m[1]}#{m[2]}_test.rb" }
8
+ watch(%r{^lib/active_shepherd.rb}) { "test" }
9
+ watch(%r|^test/test_helper\.rb|) { "test" }
10
+
11
+ watch(%r|^test/integration/project_todo_scenario/(.*)\.rb$|) do
12
+ "test/integration/project_todo_scenario_test.rb"
13
+ end
14
+
15
+ watch(%r{^lib/.*\.rb$}) { |m| "test/integration/project_todo_scenario_test.rb" }
16
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 ntl
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # WARNING: DANGER AHEAD!
2
+
3
+ *This repo is titled "eat-my-babies" for a reason. It's essentially scratch code at this point.*
4
+
5
+ # ActiveShepherd
6
+
7
+ Is your app/models directory growing unweildy? Do you find yourself desiring the notion of aggregates to help corral your less important models under the umbrella of more important "business entities?" That's the problem I had that led me to write this gem. I wanted to be able to reason about an entire namespace of models as one thing; or an "aggregate" in enterprisey development parlance.
8
+
9
+ My main goal was to be able to keep using ActiveRecord and intrude on it as little as possible. The result was an approach that requires you to wire up your models a bit more strictly -- you need to be setting options like `dependent: 'destroy'`, `autosave: true`, and `inverse_of` on all associations to the sub objects. The benefit you get from this gem is to be able to both query and manipulate the state of the entire aggregate all at once.
10
+
11
+ There are more requirements that are outlined by Eric Evans in his brilliant Domain Driven Design book, whose self titled concept is still very new to me.
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ gem 'activeshepherd'
18
+
19
+ And then execute:
20
+
21
+ $ bundle
22
+
23
+ Or install it yourself as:
24
+
25
+ $ gem install activeshepherd
26
+
27
+ In your `config/initializers` directory, add a tiny shim into ActiveRecord::Base:
28
+
29
+ ActiveShepherd.enable!(ActiveRecord::Base)
30
+
31
+ ## Usage
32
+
33
+ 1. Pick a model you'd like to make into an aggregate root
34
+ 2. Add `act_as_aggregate_root!` to the model, e.g.:
35
+ end
36
+ 3. Make sure it follows the rules (e.g. [see this blog post](http://lostechies.com/jimmybogard/2008/05/21/entities-value-objects-aggregates-and-roots/))
37
+ 4. ??
38
+ 5. Profit!
39
+
40
+ ## Examples:
41
+
42
+ See the test suite for more fleshed out examples. For now, say you have two models:
43
+
44
+ ```ruby
45
+ # app/models/my_model.rb
46
+ class MyModel < ActiveRecord::Base
47
+ act_as_aggregate_root!
48
+
49
+ has_many :bunnies, autosave: true, dependent: :destroy, inverse_of: :my_model,
50
+ validate: true
51
+ end
52
+
53
+ # app/models/my_model/bunny.rb
54
+ class MyModel::Bunny < ActiveRecord::Base
55
+ belongs_to :my_model, inverse_of: :bunnies, touch: true
56
+ end
57
+ ```
58
+ <!-- ` -->
59
+
60
+ Now add a test to make sure your models always meet the requirements for being an aggregate root:
61
+
62
+ ```ruby
63
+ # spec/models/my_model_spec.rb
64
+ describe MyModel do
65
+ it "is an aggregate root" do
66
+ MyModel.should be_able_to_act_as_aggregate_root
67
+ end
68
+ end
69
+
70
+ # test/unit/my_model_test.rb
71
+ class MyModel::TestCase < Minitest::Unit::TestCase
72
+ def test_should_be_aggregate_root
73
+ assert MyModel.able_to_act_as_aggregate_root?
74
+ end
75
+ end
76
+ ```
77
+ <!-- ` -->
78
+
79
+ You now get some new behavior on MyModel that will let you deal with the entire aggregate nicely:
80
+
81
+ ```ruby
82
+ >> @my_model = MyModel.new
83
+ >> @my_model.bunnies.build({ name: "Roger"})
84
+ >> @my_model.save
85
+
86
+ # Nothing new, right? wrong.
87
+
88
+ >> @my_model.aggregate_state
89
+ => {
90
+ bunnies: [
91
+ { name: "Roger" }
92
+ ]
93
+ }
94
+
95
+ # Sweet, what about changes?
96
+
97
+ >> @my_model.bunnies.first.name = "Roger Rabbit"
98
+ >> @my_model.bunnies.build({ name: "Energizer" })
99
+
100
+ # BAM!
101
+
102
+ >> @my_model.aggregate_changes
103
+ => {
104
+ bunnies: {
105
+ 0 => { name: ["Roger", "Roger Rabbit"] },
106
+ 1 => { name: [nil, "Energizer"] }
107
+ }
108
+ }
109
+ ```
110
+ <!-- ` -->
111
+
112
+ So `#aggregate_changes` is just like ActiveRecord's `#changes`, except it includes all of the nested changes within the aggregate.
113
+
114
+ That's a brief description of what this gem does. Here are the main methods that `acts_as_aggregate_root!` brings to your ActiveRecord models:
115
+
116
+ | Method name | Description |
117
+ |:---------------------:|:----------------------------------------------------------------------------------:|
118
+ | `#aggregate_state` | Serializes the entire state of the aggregate |
119
+ | `#aggregate_state=` | Takes a serialized blob and uses it to set the entire state of the aggregate |
120
+ | `#aggregate_changes` | Analagous to `#changes`; it tells you what all has changes in the entire aggregate |
121
+ | `#aggregate_changes=` | Takes an existing set of changes and applies it to the aggregate |
122
+
123
+ ## Todo
124
+
125
+ This project is way alpha right now, hence the "eat-my-babies" project name.
126
+
127
+ 1. Implement `ClassValidator` which will correctly tell you if a class can be an aggregate root (e.g. are your associations wired up correctly?)
128
+ 2. Implement `ChangeValidator` that adds a little more niceness around `#aggregate_changes=`
129
+
130
+ My main goal right now is to use the code as it exists for a while and deal with problems as they arise. Consider the entire gem incomplete for right now.
131
+
132
+ ## Contributing
133
+
134
+ 1. Fork it
135
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
136
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
137
+ 4. Push to the branch (`git push origin my-new-feature`)
138
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require "rake/testtask"
4
+
5
+ desc "Update ctags"
6
+ task :ctags do
7
+ `ctags -R lib test`
8
+ end
9
+
10
+ desc "Jump into a console with the test environment loaded"
11
+ task :console do
12
+ $:.push File.expand_path("../test", __FILE__)
13
+ require "test_helper"
14
+ require "irb"
15
+
16
+ binding.pry
17
+ end
18
+
19
+ Rake::TestTask.new do |t|
20
+ t.pattern = "test/**/*_test.rb"
21
+ t.libs << "test"
22
+ end
23
+
24
+ task default: "test"
@@ -0,0 +1,34 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'active_shepherd/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "activeshepherd"
8
+ gem.version = ActiveShepherd::VERSION
9
+ gem.authors = ["ntl"]
10
+ gem.email = ["nathanladd+github@gmail.com"]
11
+ gem.description = %q{Wrangle unweildy app/models directories by unobtrusively adding the aggregate pattern into ActiveRecord}
12
+ gem.summary = %q{Wrangle unweildy app/models directories with aggregates}
13
+ gem.homepage = "http://github.com/ntl/activeshepherd"
14
+ gem.license = "MIT"
15
+
16
+ gem.files = `git ls-files`.split($/)
17
+ gem.executables = gem.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ gem.test_files = gem.files.grep(%r{^(test|features)/})
19
+ gem.require_paths = ["lib"]
20
+
21
+ gem.add_dependency "activerecord", ">= 2.3.17"
22
+ gem.add_dependency "activesupport", ">= 2.3.17"
23
+
24
+ gem.add_development_dependency "activerecord", "4.0.0.beta1"
25
+ gem.add_development_dependency "guard"
26
+ gem.add_development_dependency "guard-minitest"
27
+ gem.add_development_dependency "hashie"
28
+ gem.add_development_dependency "minitest-reporters"
29
+ gem.add_development_dependency "pry"
30
+ gem.add_development_dependency "pry-debugger"
31
+ gem.add_development_dependency "rake"
32
+ gem.add_development_dependency "rb-fsevent"
33
+ gem.add_development_dependency "sqlite3"
34
+ end
@@ -0,0 +1,24 @@
1
+ module ActiveShepherd ; end
2
+
3
+ require 'active_shepherd/active_record_shim'
4
+ require 'active_shepherd/aggregate'
5
+ require 'active_shepherd/aggregate_root'
6
+ require 'active_shepherd/changes_validator'
7
+ require 'active_shepherd/deep_reverse_changes'
8
+ require 'active_shepherd/method'
9
+ require 'active_shepherd/methods/apply_changes'
10
+ require 'active_shepherd/methods/apply_state'
11
+ require 'active_shepherd/methods/query_changes'
12
+ require 'active_shepherd/methods/query_state'
13
+ require 'active_shepherd/traversal'
14
+ require 'active_shepherd/version'
15
+
16
+ module ActiveShepherd
17
+ AggregateMismatchError = Class.new(StandardError)
18
+ BadChangeError = Class.new(StandardError)
19
+ InvalidChangesError = Class.new(StandardError)
20
+
21
+ def self.deep_reverse_changes(changes)
22
+ DeepReverseChanges.new(changes).reverse
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ module ActiveShepherd
2
+ def self.enable!(activerecord_base)
3
+ class << activerecord_base
4
+ # FIXME: make this actually check the model to meet the criteria for being
5
+ # an Aggregate Root
6
+ def able_to_act_as_aggregate_root?
7
+ true
8
+ end
9
+
10
+ def act_as_aggregate_root!
11
+ include ::ActiveShepherd::AggregateRoot
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,69 @@
1
+ class ActiveShepherd::Aggregate
2
+ attr_reader :excluded_attributes
3
+ attr_reader :model
4
+
5
+ def initialize(model, excluded_attributes = [])
6
+ @model = model
7
+
8
+ @excluded_attributes = ["id", "created_at", "updated_at"]
9
+ @excluded_attributes.concat(Array.wrap(excluded_attributes).map(&:to_s))
10
+ end
11
+
12
+ def default_attributes
13
+ model.class.new.attributes
14
+ end
15
+
16
+ def raw_attributes
17
+ model.attributes_before_type_cast
18
+ end
19
+
20
+ def traversable_associations
21
+ associations.traversable
22
+ end
23
+
24
+ def untraversable_association_names
25
+ associations.untraversable.keys
26
+ end
27
+
28
+ def serialize_value(attribute_name, value)
29
+ run_through_serializer(attribute_name, value, :dump)
30
+ end
31
+
32
+ def deserialize_value(attribute_name, value)
33
+ run_through_serializer(attribute_name, value, :load)
34
+ end
35
+
36
+ private
37
+
38
+ def associations
39
+ @associations ||= begin
40
+ all_associations = model.class.reflect_on_all_associations
41
+ ostruct = OpenStruct.new untraversable: {}, traversable: {}
42
+ all_associations.each_with_object(ostruct) do |association_reflection, ostruct|
43
+ if traverse_association?(association_reflection)
44
+ key = :traversable
45
+ else
46
+ key = :untraversable
47
+ end
48
+ ostruct.send(key)[association_reflection.name] = association_reflection
49
+ end
50
+ end
51
+ end
52
+
53
+ def run_through_serializer(attribute_name, value, method)
54
+ serializer = model.class.serialized_attributes[attribute_name.to_s]
55
+ if serializer
56
+ serializer.send(method, value)
57
+ else
58
+ value
59
+ end
60
+ end
61
+
62
+ def traverse_association?(association)
63
+ return false if association.options[:readonly]
64
+ return false if association.macro == :belongs_to
65
+
66
+ true
67
+ end
68
+
69
+ end
@@ -0,0 +1,152 @@
1
+ module ActiveShepherd::AggregateRoot
2
+ def self.included(base)
3
+ base.extend(ClassMethods)
4
+ end
5
+
6
+ # Private: returns the behind the scenes object that does all the work
7
+ def aggregate
8
+ @aggregate ||= ActiveShepherd::Aggregate.new(self)
9
+ end
10
+ private :aggregate
11
+
12
+ # Public: Given a serializable blob of changes (Hash, Array, and String)
13
+ # objects, apply those changes to
14
+ #
15
+ # Examples:
16
+ #
17
+ # @project.aggregate_changes = { name: ["Clean House", "Clean My House"] }
18
+ #
19
+ # Returns nothing.
20
+ # Raises ActiveShepherd::InvalidChangesError if the changes supplied do not
21
+ # pass #valid_aggregate_changes? (see below)
22
+ # Raises ActiveShepherd::BadChangeError if a particular attribute change
23
+ # cannot be applied.
24
+ # Raises an ActiveShepherd::AggregateMismatchError if any objects in the
25
+ # aggregate are being asked to change attributes that do not exist.
26
+ def aggregate_changes=(changes)
27
+ changes_errors = ActiveShepherd::ChangesValidator.new(self).validate changes
28
+ unless changes_errors.empty?
29
+ raise ActiveShepherd::InvalidChangesError, "changes hash is invalid: "\
30
+ "#{changes_errors.join(', ')}"
31
+ end
32
+ # The validation process actually runs the changes internally. This means
33
+ # we don't have to explicitly invoke # #apply_changes here.
34
+ # XXX: remove when ChangesValidator does this
35
+ ActiveShepherd::Methods::ApplyChanges.apply_changes aggregate, changes
36
+ end
37
+
38
+ # Public: Reverses the effect of #aggregate_changes=
39
+ def reverse_aggregate_changes=(changes)
40
+ self.aggregate_changes = ActiveShepherd::DeepReverseChanges.new(changes).reverse
41
+ end
42
+
43
+ # Public: Returns the list of changes to the aggregate that would persist if
44
+ # #save were called on the aggregate root.
45
+ #
46
+ # Examples
47
+ #
48
+ # @project.aggregate_changes
49
+ # # => { name: ["Clean House", "Clean My House"], todos: [{ text: ["Take out trash", "Take out the trash" ...
50
+ #
51
+ # Returns all changes in the aggregate
52
+ def aggregate_changes
53
+ ActiveShepherd::Methods::QueryChanges.query_changes aggregate
54
+ end
55
+
56
+ # Public: Injects the entire state of the aggregate from a serializable blob.
57
+ #
58
+ # Examples:
59
+ #
60
+ # @project.aggregate_state = { name: "Clean House", todos: [{ text: "Take out trash" ...
61
+ #
62
+ # Returns nothing.
63
+ # Raises an AggregateMismatchError if the blob contains references to objects
64
+ # or attributes that do not exist in this aggregate.
65
+ def aggregate_state=(blob)
66
+ ActiveShepherd::Methods::ApplyState.apply_state aggregate, blob
67
+ end
68
+
69
+ # Public: Returns the entire state of the aggregate as a serializable blob.
70
+ # All id values (primary keys) are extracted.
71
+ #
72
+ # Examples
73
+ #
74
+ # @project.aggregate_state
75
+ # # => { name: "Clean House", todos: [{ text: "Take out trash" ...
76
+ #
77
+ # Returns serializable blob.
78
+ def aggregate_state
79
+ ActiveShepherd::Methods::QueryState.query_state aggregate
80
+ end
81
+
82
+ # Public: Reloads the entire aggregate by invoking #reload on each of the
83
+ # records in the aggregate.
84
+ def reload_aggregate
85
+ reload
86
+ raise "NYI"
87
+ end
88
+
89
+ # Public: Validates a set of changes for the aggregate root.
90
+ #
91
+ # * Does deep_reverse(deep_reverse(changes)) == changes?
92
+ # * Assuming the model is currently valid, if the changes were applied,
93
+ # would the aggregate be valid?
94
+ # * If I apply the changes, and then apply deep_reverse(changes), does
95
+ # #aggregate_state change?
96
+ #
97
+ # See ActiveShepherd.deep_reverse
98
+ #
99
+ # Examples:
100
+ #
101
+ # @project.valid_aggregate_changes?(@project.aggregate_changes)
102
+ # # => true
103
+ #
104
+ # Returns true if and only if the supplied changes pass muster.
105
+ def valid_aggregate_changes?(changes, emit_boolean = true)
106
+ validator = ActiveShepherd::ChangesValidator.new(self)
107
+ errors = validator.validate changes
108
+ emit_boolean ? errors.blank? : errors
109
+ ensure
110
+ reload_aggregate
111
+ end
112
+
113
+ module ClassMethods
114
+ # Public: Determines whether or not the including class can behave like an
115
+ # aggregate. Designed to be used by tests that want to make sure that any
116
+ # of the models that make up the aggregate never change in a way that would
117
+ # break the functionality of Aggregate::Root.
118
+ #
119
+ # In order for this method to return true, this model and its associated
120
+ # models are each checked rigorously to ensure they are wired up in a way
121
+ # that meets the requirements of ActiveShepherd. These requirements are:
122
+ #
123
+ # * All models in the namespace defined by the root itself are visible to
124
+ # associations that meet this criteria:
125
+ # * The root model autosaves all associated models in the aggregate.
126
+ # (:autosave is true on the association)
127
+ # * The root model validates all associated models in the aggregate.
128
+ # (:validate is true on the association)
129
+ # * Associated objects touch the root model when they are updated
130
+ # (:touch is true on the association)
131
+ # * When any root model is destroyed, all associated models in the
132
+ # aggregate boundary are also destroyed, or else their references are
133
+ # nullified. (:dependent => :destroy/:nullify)
134
+ # * The entire object constellation within the boundary can be traversed
135
+ # without accessing the persistence layer, providing they have all been
136
+ # eager loaded. (:inverse_of is set on the associations)
137
+ # * If the association references any external aggregate root, then
138
+ # when that root is deleted, then either the associations reference
139
+ # must be nullified, or else the associated model itself must be
140
+ # deleted.
141
+ # * All models within the aggregate are only referenced inside this
142
+ # aggregate boundary, with the exception of the root itself.
143
+ # * Any model in the namespace of the root that has its own sub namespace
144
+ # under it is recursively checked.
145
+ #
146
+ # Returns true if and only if this model is an aggregate root.
147
+ def behave_like_an_aggregate?(emit_boolean = true)
148
+ errors = ActiveShepherd::ClassValidator.new(self).validate
149
+ emit_boolean ? errors.blank? : errors
150
+ end
151
+ end
152
+ end