portable_model 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,20 +13,36 @@ module PortableModel
13
13
  # Export the record to a hash.
14
14
  #
15
15
  def export_to_hash
16
- # Export portable attributes.
17
- record_hash = self.class.portable_attributes.inject({}) do |hash, attr_name|
18
- hash[attr_name] = attributes[attr_name]
19
- hash
20
- end
16
+ self.class.start_exporting do |exported_records|
17
+ # If the record had already been exported during the current session, use
18
+ # the result of that previous export.
19
+ record_id = "#{self.class.table_name}_#{id}"
20
+ record_hash = exported_records[record_id]
21
+
22
+ unless record_hash
23
+ # Export portable attributes.
24
+ record_hash = self.class.portable_attributes.inject({}) do |hash, attr_name|
25
+ hash[attr_name] = if self.class.overridden_export_attrs.has_key?(attr_name)
26
+ overridden_value = self.class.overridden_export_attrs[attr_name]
27
+ overridden_value.is_a?(Proc) ? instance_eval(&overridden_value) : overridden_value
28
+ else
29
+ attributes[attr_name]
30
+ end
31
+ hash
32
+ end
21
33
 
22
- # Include the exported attributes of portable associations.
23
- self.class.portable_associations.inject(record_hash) do |hash, assoc_name|
24
- assoc = self.__send__(assoc_name)
25
- hash[assoc_name] = assoc.export_portable_association if assoc
26
- hash
27
- end
34
+ # Include the exported attributes of portable associations.
35
+ self.class.portable_associations.inject(record_hash) do |hash, assoc_name|
36
+ assoc = self.__send__(assoc_name)
37
+ hash[assoc_name] = assoc.export_portable_association if assoc
38
+ hash
39
+ end
28
40
 
29
- record_hash
41
+ exported_records[record_id] = record_hash
42
+ end
43
+
44
+ record_hash
45
+ end
30
46
  end
31
47
 
32
48
  # Export the record to a YAML file.
@@ -66,34 +82,44 @@ module PortableModel
66
82
  raise ArgumentError.new('specified argument is not a hash') unless record_hash.is_a?(Hash)
67
83
 
68
84
  # Override any necessary attributes before importing.
69
- record_hash = record_hash.merge(overridden_imported_attrs)
70
-
71
- transaction do
72
- if (columns_hash.include?(inheritance_column) &&
73
- (record_type_name = record_hash[inheritance_column.to_s]) &&
74
- !record_type_name.blank? &&
75
- record_type_name != sti_name)
76
- # The model implements STI and the record type points to a different
77
- # class; call the method in that class instead.
78
- return compute_type(record_type_name).import_from_hash(record_hash)
79
- end
80
-
81
- # First split out the attributes that correspond to portable
82
- # associations.
83
- assoc_attrs = portable_associations.inject({}) do |hash, assoc_name|
84
- hash[assoc_name] = record_hash.delete(assoc_name) if record_hash.has_key?(assoc_name)
85
- hash
86
- end
87
-
88
- # Create a new record.
89
- record = create!(record_hash)
90
-
91
- # Import each of the record's associations into the record.
92
- assoc_attrs.each do |assoc_name, assoc_value|
93
- record.import_into_association(assoc_name, assoc_value)
85
+ record_hash.merge!(overridden_import_attrs)
86
+
87
+ if (columns_hash.include?(inheritance_column) &&
88
+ (record_type_name = record_hash[inheritance_column.to_s]) &&
89
+ !record_type_name.blank? &&
90
+ record_type_name != sti_name)
91
+ # The model implements STI and the record type points to a different
92
+ # class; call the method in that class instead.
93
+ compute_type(record_type_name).import_from_hash(record_hash)
94
+ else
95
+ start_importing do |imported_records|
96
+ # If the hash had already been imported during the current session,
97
+ # use the result of that previous import.
98
+ record = imported_records[record_hash.object_id]
99
+
100
+ unless record
101
+ transaction do
102
+ # First split out the attributes that correspond to portable
103
+ # associations.
104
+ assoc_attrs = portable_associations.inject({}) do |hash, assoc_name|
105
+ hash[assoc_name] = record_hash.delete(assoc_name) if record_hash.has_key?(assoc_name)
106
+ hash
107
+ end
108
+
109
+ # Create a new record.
110
+ record = create!(record_hash)
111
+
112
+ # Import each of the record's associations into the record.
113
+ assoc_attrs.each do |assoc_name, assoc_value|
114
+ record.import_into_association(assoc_name, assoc_value)
115
+ end
116
+ end
117
+
118
+ imported_records[record_hash.object_id] = record
119
+ end
120
+
121
+ record
94
122
  end
95
-
96
- record
97
123
  end
98
124
  end
99
125
 
@@ -104,13 +130,34 @@ module PortableModel
104
130
  import_from_hash(record_hash.merge(additional_attrs))
105
131
  end
106
132
 
133
+ # Starts an export session and yields a hash of currently exported records
134
+ # in the session to the specified block.
135
+ #
136
+ def start_exporting(&block)
137
+ start_porting(:exported_records, &block)
138
+ end
139
+
140
+ # Starts an import session and yields a hash of currently imported records
141
+ # in the session to the specified block.
142
+ #
143
+ def start_importing(&block)
144
+ start_porting(:imported_records, &block)
145
+ end
146
+
107
147
  # Returns the names of portable attributes, which are any attributes that
108
148
  # are not primary or foreign keys.
109
149
  #
110
150
  def portable_attributes
111
151
  columns.reject do |column|
112
152
  # TODO: Consider rejecting counter_cache columns as well; this will involve retrieving a has_many association's corresponding belongs_to association to retrieve its counter_cache_column.
113
- column.primary || column.name.in?(reflect_on_all_associations(:belongs_to).map(&:association_foreign_key))
153
+ (
154
+ column.primary ||
155
+ column.name.in?(excluded_export_attrs) && !overridden_export_attrs.has_key?(column.name) ||
156
+ (
157
+ column.name.in?(reflect_on_all_associations(:belongs_to).map(&:association_foreign_key)) &&
158
+ !column.name.in?(included_association_keys)
159
+ )
160
+ )
114
161
  end.map(&:name).map(&:to_s)
115
162
  end
116
163
 
@@ -129,12 +176,55 @@ module PortableModel
129
176
  end.map(&:name).map(&:to_s)
130
177
  end
131
178
 
179
+ def included_association_keys
180
+ @included_association_keys ||= Set.new
181
+ end
182
+
183
+ def excluded_export_attrs
184
+ @excluded_export_attrs ||= Set.new
185
+ end
186
+
187
+ def overridden_export_attrs
188
+ @overridden_export_attrs ||= {}
189
+ end
190
+
191
+ def overridden_import_attrs
192
+ @overridden_import_attrs ||= {}
193
+ end
194
+
132
195
  protected
133
196
 
197
+ # Includes the specified associations' foreign keys (which are normally
198
+ # excluded by default) whenever a record is exported.
199
+ #
200
+ def include_association_keys_on_export(*associations)
201
+ associations.inject(included_association_keys) do |included_keys, assoc|
202
+ assoc_reflection = reflect_on_association(assoc)
203
+ raise ArgumentError.new('can only include foreign keys of belongs_to associations') unless assoc_reflection.macro == :belongs_to
204
+ included_keys << assoc_reflection.association_foreign_key
205
+ end
206
+ end
207
+
208
+ # Excludes the specified attributes whenever a record is exported.
209
+ #
210
+ def exclude_attributes_on_export(*attrs)
211
+ excluded_export_attrs.merge(attrs.map(&:to_s))
212
+ end
213
+
214
+ # Overrides the specified attributes whenever a record is exported.
215
+ # Specified values can be procedures that dynamically generate the value.
216
+ #
217
+ def override_attributes_on_export(attrs)
218
+ attrs.inject(overridden_export_attrs) do |overridden_attrs, (attr_name, attr_value)|
219
+ overridden_attrs[attr_name.to_s] = attr_value
220
+ overridden_attrs
221
+ end
222
+ end
223
+
134
224
  # Overrides the specified attributes whenever a record is imported.
135
225
  #
136
226
  def override_attributes_on_import(attrs)
137
- attrs.inject(overridden_imported_attrs) do |overridden_attrs, (attr_name, attr_value)|
227
+ attrs.inject(overridden_import_attrs) do |overridden_attrs, (attr_name, attr_value)|
138
228
  overridden_attrs[attr_name.to_s] = attr_value
139
229
  overridden_attrs
140
230
  end
@@ -142,8 +232,19 @@ module PortableModel
142
232
 
143
233
  private
144
234
 
145
- def overridden_imported_attrs
146
- @overridden_imported_attrs ||= {}
235
+ def start_porting(storage_identifier)
236
+ # Use thread-local storage to keep track of records that have been
237
+ # ported in the current session. This way, records that are encountered
238
+ # multiple times are represented using the same resulting object.
239
+ is_new_session = Thread.current[storage_identifier].nil?
240
+ Thread.current[storage_identifier] = {} if is_new_session
241
+
242
+ begin
243
+ # Yield the hash of records in the current session to the specified block.
244
+ yield(Thread.current[storage_identifier])
245
+ ensure
246
+ Thread.current[storage_identifier] = nil if is_new_session
247
+ end
147
248
  end
148
249
 
149
250
  end
@@ -55,7 +55,7 @@ module ActiveRecord::Associations
55
55
  #
56
56
  def export_portable_association
57
57
  NotPortableError.raise_on_not_portable(self)
58
- export_to_hash
58
+ proxy_reflection.klass.start_exporting { export_to_hash }
59
59
  end
60
60
 
61
61
  # Import the association from a hash.
@@ -63,13 +63,13 @@ module ActiveRecord::Associations
63
63
  def import_portable_association(record_hash)
64
64
  NotPortableError.raise_on_not_portable(self)
65
65
  raise ArgumentError.new('specified argument is not a hash') unless record_hash.is_a?(Hash)
66
+ raise 'cannot replace existing association record' unless target.nil?
66
67
 
67
- proxy_owner.transaction do
68
- if target.nil?
69
- assoc_record = proxy_reflection.klass.import_from_hash(record_hash.merge(primary_key_hash))
68
+ proxy_reflection.klass.start_importing do
69
+ proxy_owner.transaction do
70
+ record_hash.merge!(primary_key_hash)
71
+ assoc_record = proxy_reflection.klass.import_from_hash(record_hash)
70
72
  replace(assoc_record)
71
- else
72
- raise 'cannot replace existing association record'
73
73
  end
74
74
  end
75
75
  end
@@ -82,7 +82,7 @@ module ActiveRecord::Associations
82
82
  #
83
83
  def export_portable_association
84
84
  NotPortableError.raise_on_not_portable(self)
85
- map(&:export_to_hash)
85
+ proxy_reflection.klass.start_exporting { map(&:export_to_hash) }
86
86
  end
87
87
 
88
88
  # Import the association from an array of hashes.
@@ -91,9 +91,15 @@ module ActiveRecord::Associations
91
91
  NotPortableError.raise_on_not_portable(self)
92
92
  raise ArgumentError.new('specified argument is not an array of hashes') unless record_hashes.is_a?(Array) && record_hashes.all? { |record_hash| record_hash.is_a?(Hash) }
93
93
 
94
- proxy_owner.transaction do
95
- assoc_records = record_hashes.map { |record_hash| proxy_reflection.klass.import_from_hash(record_hash.merge(primary_key_hash)) }
96
- concat(*assoc_records)
94
+ proxy_reflection.klass.start_importing do
95
+ proxy_owner.transaction do
96
+ delete_all
97
+ assoc_records = record_hashes.map do |record_hash|
98
+ record_hash.merge!(primary_key_hash)
99
+ proxy_reflection.klass.import_from_hash(record_hash)
100
+ end
101
+ replace(assoc_records)
102
+ end
97
103
  end
98
104
  end
99
105
 
@@ -1,3 +1,3 @@
1
1
  module PortableModel
2
- VERSION = "0.1.0"
2
+ VERSION = "1.0.0"
3
3
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portable_model
3
3
  version: !ruby/object:Gem::Version
4
- hash: 27
4
+ hash: 23
5
5
  prerelease:
6
6
  segments:
7
- - 0
8
7
  - 1
9
8
  - 0
10
- version: 0.1.0
9
+ - 0
10
+ version: 1.0.0
11
11
  platform: ruby
12
12
  authors:
13
13
  - Clyde Law
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2012-04-12 00:00:00 -07:00
18
+ date: 2012-04-13 00:00:00 -07:00
19
19
  default_executable:
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency