joinfix 0.1.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,27 +1,67 @@
1
+ # Fixtures objects are essentially nested hashes:
2
+ #
3
+ # # A Fixtures is a hash of entry name keys and Fixture values
4
+ # entry_name:
5
+ # # A Fixture is a hash of entry fields and values
6
+ # field: value
7
+ #
8
+ # Fixtures also tracks data required for loading entries into the database,
9
+ # such as table_name and the connection to use. See 'active_record/fixtures.rb'
10
+ # for more details.
11
+ #
12
+ # JoinFix extends Fixtures to track additional information like the ActiveRecord
13
+ # class modeling the entry data. Using reflection, Fixtures can figure out which fields
14
+ # in an entry correspond to an association. Entries with association fields are
15
+ # extracted and turned into join entries. Consider and ActiveRecord class User, with
16
+ # 'name' as an attribute and a 'groups' association...
17
+ #
18
+ # [users.yml]
19
+ # # This entry will not be extracted as a template because it defines no 'groups'
20
+ # john:
21
+ # name: John
22
+ #
23
+ # # This entry will be extracted because it defines the 'groups' association
24
+ # jane:
25
+ # name: Jane
26
+ # groups:
27
+ # - admin_group
28
+ # - workers
29
+ #
30
+ # If you loaded users.yml into a Fixtures...
31
+ # users = Fixtures.new( ...arguments to load users.yml...)
32
+ # users.to_hash # => {'john' => ..., 'jane' => ...}
33
+ # users.templates # => nil
34
+ #
35
+ # users.extract_templates
36
+ # users.to_hash # => {'john' => ...}
37
+ # users.templates # => {'jane' => ...}
38
+ #
39
+ # The entry templates are extended with JoinFix and used to create the join entries.
1
40
  class Fixtures
2
- attr_reader :klass, :attributes, :templates, :fixture_path
41
+ attr_reader :klass, :association_names, :templates, :fixture_path
3
42
 
4
43
  alias join_fix_original_initialize initialize
5
- def initialize(*args)
44
+ def initialize(*args) # :nodoc:
6
45
  join_fix_original_initialize(*args)
7
46
  @klass = Object.const_get(@class_name)
8
-
9
- # default attributes, constructed from reflecting on the active record table
10
- @attributes = {}
11
- @klass.columns.each do |column|
12
- @attributes[column.name] = column.default
13
- end
14
-
47
+ @association_names = klass.reflect_on_all_associations.collect {|assoc| assoc.name.to_s}
15
48
  @join_configs = {}
16
49
  end
17
50
 
18
51
  alias join_fix_original_insert_fixtures insert_fixtures
52
+
53
+ # Extends the default method to provide a hook for making join fixtures.
54
+ # Now insert_fixtures calls Fixtures.make_join_fixtures before calling
55
+ # the original insert_fixtures method.
19
56
  def insert_fixtures
20
- Fixtures.template_all_loaded_fixtures if templates.nil?
57
+ Fixtures.make_join_fixtures if templates.nil?
21
58
  join_fix_original_insert_fixtures
22
59
  end
23
60
 
24
- def template_fixtures
61
+ # Extracts the child entry templates then makes the entries specified
62
+ # by each template. Does not execute if templates have already been
63
+ # extracted.
64
+ def make_child_entries
25
65
  return unless templates.nil?
26
66
 
27
67
  extract_templates
@@ -30,83 +70,88 @@ class Fixtures
30
70
  end
31
71
  end
32
72
 
33
- def offset
34
- 0
35
- # FUTURE!: is this even needed? I don't think so given that all rows are
36
- # deleted by delete_existing_fixtures... not sure.
37
- # @connection. QUERY FOR NEXT ID
38
- end
39
-
73
+ # Returns the join configuration (as per JoinFix.configure) for the given association.
40
74
  def join_config(assoc_name)
41
75
  @join_configs[assoc_name] ||= JoinFix.send("configure", klass, assoc_name)
42
76
  end
43
77
 
44
- def each_pair(&block)
78
+ # Iterates through each entry
79
+ def each_pair(&block)
45
80
  map { |entry_name, fixture| yield(entry_name, fixture) }
46
81
  end
47
82
 
83
+ # Returns all entries as a hash of {entry_name => entry} pairs
48
84
  def to_hash
49
85
  hash = {}
50
- each_pair do |name, fixture|
51
- hash[name] = fixture.to_hash
86
+ each_pair do |entry_name, entry|
87
+ hash[entry_name] = entry.to_hash
52
88
  end
53
89
  hash
54
90
  end
91
+
92
+ # The initial offset for indexing (ie 0 so that indexing starts at 1).
93
+ # This method is provided as a hook for overrides if necessary.
94
+ def primary_key_offset
95
+ 0
96
+ end
55
97
 
56
98
  protected
57
99
 
58
- def extract_templates
59
- # fixture is a template if it contains an association
100
+ # Extracts template entries. Entries are Fixture objects, essentially a hash of {attribute => value} pairs.
101
+ # An entry is considered a template if it contains an association attribute. If this is true, then the entry
102
+ # is removed from self and stored as a template.
103
+ def extract_templates # :nodoc:
60
104
  @templates = {}
61
- assoc_names = klass.reflect_on_all_associations.collect {|assoc| assoc.name.to_s}
62
- each do |entry_name, fixture|
105
+
106
+ # iterate over all entries looking for templates
107
+ each do |entry_name, fixture|
63
108
  entry = fixture.to_hash
64
- next if (entry.keys & assoc_names).empty?
109
+
110
+ # fixture is a template if it contains an association
111
+ next if (entry.keys & association_names).empty?
65
112
  @templates[entry_name] = entry
66
113
  end
114
+
115
+ # remove template entries
67
116
  delete_if do |entry_name, fixture|
68
117
  @templates.has_key?(entry_name)
69
118
  end
70
119
  end
71
120
 
72
- def make_entry(entry_name, entry_template, add_entry_on_complete)
73
- raise NoEntryNameError.new(self, entry_name, entry_template) unless entry_template.kind_of?(Hash)
121
+ # Makes the specified entry and adds the entry to self if add_entry_on_complete is true.
122
+ def make_entry(entry_name, entry, add_entry_on_complete) # :nodoc:
123
+
124
+ # If the entry is not a hash, then no entry name was specified in the fixture file
125
+ unless entry.kind_of?(Hash)
126
+ raise NoEntryNameError.new(self, entry_name, entry)
127
+ end
74
128
 
75
- entry = attributes.merge(entry_template)
76
129
  entry.extend JoinFix
77
130
  entry.entry_name = entry_name
78
131
 
79
- # extract templates for entries that will be joined to the current entry
80
- # Associated entries are indicated by any key undeclared in attributes, that also does not
81
- # match one of the reseved key patterns... ie 'id' or keys ending in '_id'. Additionally includes
82
- # join references, which will all be arrays.
83
- assoc_entries = entry.extract_unless(attributes.keys) do |key, value|
84
- key == "id" || key.kind_of?(Array)
85
- end
86
-
87
- # also extract templates for entries that have an array value. These entries indicate a set of
88
- # associated entries that should each be joined to the current entry
89
- assoc_entries.merge( entry.extract_if { |key, value| value.kind_of?(Array) } )
90
- assoc_entries.each_pair do |assoc_name, assoc_array|
91
- # ensure that assoc_array is an array of template
92
- assoc_array = [assoc_array] unless assoc_array.kind_of?(Array)
132
+ # extract and construct associated entries
133
+ associated_entries = entry.extract_if(association_names)
134
+ associated_entries.each_pair do |assoc_name, associations|
135
+ # ensure that associations is an array
136
+ associations = [associations] unless associations.kind_of?(Array)
93
137
 
94
- # translate the association configuration into a join configuration
138
+ # get the join configuration for the association
95
139
  join_config = join_config(assoc_name)
96
140
  join_table = join_config[:join_table]
97
141
  macro = join_config[:macro]
98
142
 
99
- # pull the polymorphic association class out of the entry -- polymorphic joins cannot determine
100
- # beforehand what table they will join to. The associable_type MUST be specified in the fixture.
143
+ # pull the polymorphic join information out of the entry -- polymorphic joins cannot determine
144
+ # beforehand what table they will join to; the associable_type MUST be specified in the entry.
101
145
  if join_config[:polymorphic] == true
102
- join_config = join_config.dup
146
+ # be sure to preserve the base configuration...
147
+ join_config = join_config.dup
103
148
 
104
149
  associable_type = join_config[:associable_type]
105
150
  associable_class = entry[associable_type]
106
151
 
107
152
  unless associable_class
108
- raise MissingPolymorphicTypeError.new(self, entry_name, entry_template,
109
- "No <#{associable_type}> was specified.")
153
+ raise MissingPolymorphicTypeError.new(self, entry_name, entry.merge(associated_entries),
154
+ "No <#{associable_type}> was specified.")
110
155
  end
111
156
 
112
157
  join_config[:child_class] = associable_class
@@ -114,16 +159,16 @@ class Fixtures
114
159
  end
115
160
 
116
161
  # raise an error if the macro doesn't allow for multiple joins, but multiple associated entries are specified
117
- unless assoc_array.length == 1 || JoinFix.macro_allows_multiple(macro)
118
- raise MultipleChildrenError.new(self, entry_name, entry_template,
162
+ unless associations.length == 1 || JoinFix.macro_allows_multiple(macro)
163
+ raise MultipleChildrenError.new(self, entry_name, entry.merge(associated_entries),
119
164
  "Multiple joined entries specified for the single-join macro '#{macro}'.")
120
165
  end
121
166
 
122
- assoc_array.each do |template|
123
- # template is a reference to another entry
167
+ associations.each do |template|
168
+ # String templates are references to another entry. Make a dummy template with a dummy JoinFix.
124
169
  template = {template => JoinFix.new(template)} if template.kind_of?(String)
125
170
 
126
- # template is a fixture
171
+ # Make the entries specified in the template
127
172
  template.each_pair do |child_name, child|
128
173
  # generate the child entry
129
174
  child_fixture = Fixtures.fixture(join_config[:child_table])
@@ -131,12 +176,10 @@ class Fixtures
131
176
 
132
177
  # generate the join entry
133
178
  join = entry.send("join_#{macro}", child, join_config)
134
-
135
- # add entries
136
- # Note: join is sent through the make_entry machinery before adding... this ensures that the
137
- # join entry will be processed according to the wizard modules specified for the join table.
179
+ Fixtures.fixture(join_table).make_entry(join.entry_name, join, true) if join
180
+
181
+ # add the child entry
138
182
  child_fixture.add_entry(child_name, child)
139
- Fixtures.fixture(join_table).make_entry(join.entry_name, join, true) if join
140
183
  end
141
184
  end
142
185
  end
@@ -145,17 +188,11 @@ class Fixtures
145
188
  add_entry(entry_name, entry) if add_entry_on_complete
146
189
  entry
147
190
  end
148
-
149
- def add_entry(entry_name, entry)
150
- # FUTURE! bring back if you start running methods
151
- #return unless entry.addable?
152
-
153
- # remove any attributes that are equal to their default
154
- entry.delete_if do |attribute, value|
155
- default = attributes[attribute]
156
- value == default
157
- end
158
-
191
+
192
+ # Adds the specified entry to self, or merges entry attributes into the
193
+ # existing entry. Raises an error in the case of an entry attribute collision
194
+ # (ie an existing attribute does not equal the new attribute).
195
+ def add_entry(entry_name, entry) # :nodoc:
159
196
  # create a new fixture if one by the entry_name doesn't exist
160
197
  existing = self[entry_name] ||= Fixture.new({}, klass.class_name)
161
198
 
@@ -169,78 +206,3 @@ class Fixtures
169
206
  end
170
207
  end
171
208
  end
172
-
173
- class MakeEntryError < RuntimeError # :nodoc:
174
- attr_reader :fixtures, :entry_name, :entry, :msg
175
- attr_accessor :advice
176
-
177
- def initialize(fixtures, entry_name, entry, msg=nil)
178
- @fixtures = fixtures
179
- @entry_name = entry_name
180
- @entry = entry
181
- @msg =msg
182
- end
183
-
184
- def message
185
- "Error making <#{fixtures.klass.table_name}(:#{entry_name})> in <#{fixtures.fixture_path}>.\n" +
186
- {entry_name => entry}.to_yaml +
187
- (msg.nil? ? '' : "\n#{msg}\n") +
188
- (advice.nil? ? '' : "#{advice}\n")
189
- end
190
- end
191
-
192
- class EntryCollisionError < MakeEntryError # :nodoc:
193
- end
194
-
195
- class NoEntryNameError < MakeEntryError # :nodoc:
196
- def advice
197
- %Q{
198
- This error occurs when an entry is not named as in:
199
- ---
200
- dog:
201
- title: Dog
202
- author:
203
- # <an entry name like 'ferlinghetti' is missing here>
204
- full_name: Lawrence Ferlinghetti
205
- ...}
206
- end
207
- end
208
-
209
- class MultipleChildrenError < MakeEntryError # :nodoc:
210
- def advice
211
- %Q{
212
- Single entry joins should specify a single entry, not an array of entries.
213
- Use a different association if you need multiple joined entries.
214
- ---
215
- poem:
216
- title: Slave's Dream
217
- author:
218
- longfellow:
219
- full_name: Henry Wadsworth Longfellow}
220
- end
221
- end
222
-
223
- class MissingPolymorphicTypeError < MakeEntryError # :nodoc:
224
- def advice
225
- %Q{
226
- When specifying a belongs_to :polymorphic join, the type
227
- of the joined entry must be specified because it cannot be
228
- inferred from association itself. Use something like:
229
- --
230
- book_I_read:
231
- opinion: Great!
232
- readable_type: Book
233
- readable:
234
- the_jungle_books:
235
- author: Rudyard Kipling
236
- title: The Jungle Books
237
-
238
- poem_I_read:
239
- opinion: Essential!
240
- readable_type: Poem
241
- readable:
242
- sea_fever:
243
- poet: John Masefield
244
- title: Sea-Fever}
245
- end
246
- end
@@ -1,26 +1,45 @@
1
1
  class Fixtures
2
- # Class methods to tie fixture joining into the standard process for generating fixtures.
3
2
  class << self
4
3
 
5
- # Called by individual Fixtures instances just before they insert fixtures.
6
- # This allows templating to occur at the correct time (ie after all fixtures
7
- # have been loaded and configured) -- resolution of references naturally requires
8
- # that all join tables are available.
9
- def template_all_loaded_fixtures
4
+ # Makes join fixtures for all loaded fixtures. Called just prior to inserting
5
+ # fixtures, after all fixtures have been loaded.
6
+ #
7
+ # After make_join_fixtures completes, entries for tables matching ENV['joinfix_dump']
8
+ # are printed to STDOUT. You can print entries for each loaded fixture by setting
9
+ # ENV['joinfix_dump'] = 'true'. By default, no entries are printed.
10
+ #
11
+ # Note: printing does not affect the creation or loading of fixtures.
12
+ def make_join_fixtures
10
13
  all_loaded_fixtures.each_pair do |table_name, fixtures|
11
- fixtures.template_fixtures
14
+ fixtures.make_child_entries
12
15
  end
16
+
13
17
  # note indexing and reference resolution must execute after all
14
18
  # fixtures have been templated because you never know which fixture(s)
15
19
  # will recieve new entries. Additionally, indexing and resolution must
16
- # run separately, because reference resolution requires the target ids
17
- # to be set.
20
+ # run separately, because reference resolution requires the primary key
21
+ # be set for the referenced entry.
18
22
  all_loaded_fixtures.values.each {|fixtures| index_fixtures(fixtures) }
19
23
  all_loaded_fixtures.values.each {|fixtures| resolve_references(fixtures)}
24
+
25
+ # Print entries to STDOUT for review, if applicable
26
+ joinfix_dump = ENV['joinfix_dump']
27
+ if joinfix_dump
28
+ print_all = (joinfix_dump.to_s =~ /^true$/i)
29
+ regexp = Regexp.new(joinfix_dump.to_s)
30
+
31
+ all_loaded_fixtures.values.each do |fixtures|
32
+ table_name = fixtures.klass.table_name
33
+ next unless print_all || table_name =~ regexp
34
+
35
+ puts "------- #{fixtures.klass.table_name} --------"
36
+ puts fixtures.to_hash.to_yaml.gsub(/^---/, '') + "\n"
37
+ end
38
+ end
20
39
  end
21
40
 
22
- # Retreives the fixture by the given table name. Raises an error if the fixture has
23
- # not yet been loaded.
41
+ # Retreives the Fixtures for the given table name. Raises an error if this Fixtures
42
+ # has not yet been loaded.
24
43
  def fixture(table_name)
25
44
  fixture = all_loaded_fixtures[table_name.to_s]
26
45
  raise MissingFixtureError.new(table_name) unless fixture
@@ -29,26 +48,25 @@ class Fixtures
29
48
 
30
49
  protected
31
50
 
32
- # Sets the primary key in each of the input fixtures, beginning one-past fixtures.offset
33
- # (generally this is 1). If a fixture already has an id assigned, it will be skipped.
34
- def index_fixtures(fixtures)
51
+ # Sets the primary key for entries in each of the input Fixtures, beginning
52
+ # one past the Fixtures primary key offset (ie 1). Skips entries that already
53
+ # have a primary key assigned.
54
+ def index_fixtures(fixtures) # :nodoc:
35
55
  id = fixtures.klass.primary_key
36
56
 
37
- # first find entries with an id and record the ids so that they will be skipped
57
+ # find entries with the primary key and record these so that they will be skipped
38
58
  skip_indicies = []
39
59
  fixtures.each_pair do |name, fixture|
40
60
  skip_indicies << fixture[id].to_i if fixture.has_key?(id)
41
61
  end
42
62
 
43
- # next find and index entries that do not have an id defined
44
- index = fixtures.offset
63
+ # find and index entries that do not have a primary key defined
64
+ index = fixtures.primary_key_offset
45
65
  fixtures.each_pair do |name, fixture|
46
- # skip entries that already have an id defined
47
66
  next if fixture.has_key?(id)
48
67
 
49
- # find the next available index
50
- # note this must happen before the id assignment,
51
- # in case index 1 is marked for skipping
68
+ # find the next available index. Note this must happen before
69
+ # the assignment, in case '1' is marked for skipping
52
70
  while true
53
71
  index += 1
54
72
  break unless skip_indicies.include?(index)
@@ -58,24 +76,17 @@ class Fixtures
58
76
  end
59
77
  end
60
78
 
61
- # Resolves each join reference in the input fixtures
62
- def resolve_references(fixtures)
63
- fixtures.each_pair do |entry_name, fixture|
64
- # search the fixture for join references
65
- fixture.each_pair do |join_ref, join_name|
79
+ # Resolves join references in the input Fixtures.
80
+ def resolve_references(fixtures) # :nodoc:
81
+ fixtures.each_pair do |entry_name, entry|
82
+ entry.each_pair do |join_ref, join_name|
66
83
  # next if the key isn't a join reference
67
84
  next unless join_ref.kind_of?(Array)
68
85
 
69
86
  foreign_key = join_ref.first
70
87
  join_table_name = join_ref.last
71
-
72
- # raise an error if the join table isn't loaded; the reference cannot be resolved
73
- unless Fixtures.all_loaded_fixtures.has_key?(join_table_name)
74
- raise ResolveJoinReferenceError.new(fixtures, entry_name, join_table_name, join_name,
75
- "The join table '#{join_table_name}' has not been loaded.")
76
- end
77
-
78
- join_fixtures = Fixtures.all_loaded_fixtures[join_table_name]
88
+
89
+ join_fixtures = Fixtures.fixture(join_table_name)
79
90
  id = join_fixtures.klass.primary_key
80
91
 
81
92
  # raise an error if the join entry isn't in the join table; the reference cannot be resolved
@@ -86,82 +97,25 @@ class Fixtures
86
97
 
87
98
  join_entry = join_fixtures[join_name]
88
99
 
89
- # raise an exception if a join_id was not found
100
+ # raise an exception if the primary key for the join entry is not set
90
101
  unless join_entry.has_key?(id)
91
102
  raise ResolveJoinReferenceError.new(fixtures, entry_name, join_table_name, join_name,
92
103
  "No #{id} present in join entry '#{join_name}'.")
93
104
  end
94
105
 
95
106
  # raise an exception if the foreign key is already set
96
- unless fixture[foreign_key].nil?
107
+ unless entry[foreign_key].nil?
97
108
  raise ForeignKeySetError.new(fixtures, entry_name, join_table_name, join_name,
98
109
  "Foreign key <#{foreign_key}> is already set!")
99
110
  end
100
111
 
101
- # set the join id
102
- fixture[foreign_key] = join_entry[id]
112
+ # set the foreign key to the joined entry primary key
113
+ entry[foreign_key] = join_entry[id]
103
114
  end
104
115
 
105
116
  # delete the join references
106
- fixture.delete_if {|key,value| key.kind_of?(Array) }
117
+ entry.delete_if {|key,value| key.kind_of?(Array) }
107
118
  end
108
119
  end
109
120
  end
110
- end
111
-
112
- class MissingFixtureError < RuntimeError # :nodoc:
113
- attr_reader :table_name
114
- def initialize(table_name)
115
- @table_name = table_name
116
- end
117
-
118
- def message
119
- "No fixture loaded for <#{table_name}>\n" +
120
- (advice.nil? ? '' : "#{advice}\n")
121
- end
122
-
123
- def advice
124
- %Q{}
125
- end
126
- end
127
-
128
- class ResolveJoinReferenceError < RuntimeError # :nodoc:
129
- attr_reader :fixtures, :entry_name, :join_table_name, :join_name, :msg
130
- attr_accessor :advice
131
-
132
- def initialize(fixtures, entry_name, join_table_name, join_name, msg=nil)
133
- @fixtures = fixtures
134
- @entry_name = entry_name
135
- @join_table_name = join_table_name
136
- @join_name = join_name
137
- @msg =msg
138
- end
139
-
140
- def message
141
- "Cannot resolve reference to <#{join_table_name}(:#{join_name})> " +
142
- "for <#{fixtures.klass.table_name}(:#{entry_name})> " +
143
- "in <#{fixtures.fixture_path}>.\n" +
144
- (msg.nil? ? '' : "\n#{msg}\n") +
145
- (advice.nil? ? '' : "#{advice}\n")
146
- end
147
- end
148
-
149
- class ForeignKeySetError < ResolveJoinReferenceError # :nodoc:
150
- def advice
151
- %Q{
152
- This error occurs when you specifiy the foreign key, as well as a join entry.
153
- ---
154
- poem:
155
- title: Poetry of Departures
156
- author_id: 8
157
- author: larkin
158
-
159
- If you need to specify the foreign key, do so within the entry.
160
- ---
161
- poem:
162
- title: Poetry of Departures
163
- author:
164
- larkin:
165
- id: 8}
166
- end
167
- end
121
+ end