jwulff-composite_primary_keys 1.0.9

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.
@@ -0,0 +1,84 @@
1
+ module CompositePrimaryKeys
2
+ module ActiveRecord
3
+ module AttributeMethods #:nodoc:
4
+ def self.append_features(base)
5
+ super
6
+ base.send(:extend, ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ # Define an attribute reader method. Cope with nil column.
11
+ def define_read_method(symbol, attr_name, column)
12
+ cast_code = column.type_cast_code('v') if column
13
+ cast_code = "::#{cast_code}" if cast_code && cast_code.match('ActiveRecord::.*')
14
+ access_code = cast_code ? "(v=@attributes['#{attr_name}']) && #{cast_code}" : "@attributes['#{attr_name}']"
15
+
16
+ unless self.primary_keys.include?(attr_name.to_sym)
17
+ access_code = access_code.insert(0, "missing_attribute('#{attr_name}', caller) unless @attributes.has_key?('#{attr_name}'); ")
18
+ end
19
+
20
+ if cache_attribute?(attr_name)
21
+ access_code = "@attributes_cache['#{attr_name}'] ||= (#{access_code})"
22
+ end
23
+
24
+ evaluate_attribute_method attr_name, "def #{symbol}; #{access_code}; end"
25
+ end
26
+
27
+ # Evaluate the definition for an attribute related method
28
+ def evaluate_attribute_method(attr_name, method_definition, method_name=attr_name)
29
+ unless primary_keys.include?(method_name.to_sym)
30
+ generated_methods << method_name
31
+ end
32
+
33
+ begin
34
+ class_eval(method_definition, __FILE__, __LINE__)
35
+ rescue SyntaxError => err
36
+ generated_methods.delete(attr_name)
37
+ if logger
38
+ logger.warn "Exception occurred during reader method compilation."
39
+ logger.warn "Maybe #{attr_name} is not a valid Ruby identifier?"
40
+ logger.warn "#{err.message}"
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ # Allows access to the object attributes, which are held in the @attributes hash, as though they
47
+ # were first-class methods. So a Person class with a name attribute can use Person#name and
48
+ # Person#name= and never directly use the attributes hash -- except for multiple assigns with
49
+ # ActiveRecord#attributes=. A Milestone class can also ask Milestone#completed? to test that
50
+ # the completed attribute is not nil or 0.
51
+ #
52
+ # It's also possible to instantiate related objects, so a Client class belonging to the clients
53
+ # table with a master_id foreign key can instantiate master through Client#master.
54
+ def method_missing(method_id, *args, &block)
55
+ method_name = method_id.to_s
56
+
57
+ # If we haven't generated any methods yet, generate them, then
58
+ # see if we've created the method we're looking for.
59
+ if !self.class.generated_methods?
60
+ self.class.define_attribute_methods
61
+
62
+ if self.class.generated_methods.include?(method_name)
63
+ return self.send(method_id, *args, &block)
64
+ end
65
+ end
66
+
67
+ if self.class.primary_keys.include?(method_name.to_sym)
68
+ ids[self.class.primary_keys.index(method_name.to_sym)]
69
+ elsif md = self.class.match_attribute_method?(method_name)
70
+ attribute_name, method_type = md.pre_match, md.to_s
71
+ if @attributes.include?(attribute_name)
72
+ __send__("attribute#{method_type}", attribute_name, *args, &block)
73
+ else
74
+ super
75
+ end
76
+ elsif @attributes.include?(method_name)
77
+ read_attribute(method_name)
78
+ else
79
+ super
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,320 @@
1
+ module CompositePrimaryKeys
2
+ module ActiveRecord #:nodoc:
3
+ class CompositeKeyError < StandardError #:nodoc:
4
+ end
5
+
6
+ module Base #:nodoc:
7
+
8
+ INVALID_FOR_COMPOSITE_KEYS = 'Not appropriate for composite primary keys'
9
+ NOT_IMPLEMENTED_YET = 'Not implemented for composite primary keys yet'
10
+
11
+ def self.append_features(base)
12
+ super
13
+ base.send(:include, InstanceMethods)
14
+ base.extend(ClassMethods)
15
+ end
16
+
17
+ module ClassMethods
18
+ def set_primary_keys(*keys)
19
+ keys = keys.first if keys.first.is_a?(Array)
20
+ keys = keys.map { |k| k.to_sym }
21
+ cattr_accessor :primary_keys
22
+ self.primary_keys = keys.to_composite_keys
23
+
24
+ class_eval <<-EOV
25
+ extend CompositeClassMethods
26
+ include CompositeInstanceMethods
27
+
28
+ include CompositePrimaryKeys::ActiveRecord::Associations
29
+ include CompositePrimaryKeys::ActiveRecord::AssociationPreload
30
+ include CompositePrimaryKeys::ActiveRecord::Calculations
31
+ include CompositePrimaryKeys::ActiveRecord::AttributeMethods
32
+ EOV
33
+ end
34
+
35
+ def composite?
36
+ false
37
+ end
38
+ end
39
+
40
+ module InstanceMethods
41
+ def composite?; self.class.composite?; end
42
+ end
43
+
44
+ module CompositeInstanceMethods
45
+
46
+ # A model instance's primary keys is always available as model.ids
47
+ # whether you name it the default 'id' or set it to something else.
48
+ def id
49
+ attr_names = self.class.primary_keys
50
+ CompositeIds.new(attr_names.map { |attr_name| read_attribute(attr_name) })
51
+ end
52
+ alias_method :ids, :id
53
+
54
+ def to_param
55
+ id.to_s
56
+ end
57
+
58
+ def id_before_type_cast #:nodoc:
59
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::NOT_IMPLEMENTED_YET
60
+ end
61
+
62
+ def quoted_id #:nodoc:
63
+ [self.class.primary_keys, ids].
64
+ transpose.
65
+ map {|attr_name,id| quote_value(id, column_for_attribute(attr_name))}.
66
+ to_composite_ids
67
+ end
68
+
69
+ # Sets the primary ID.
70
+ def id=(ids)
71
+ ids = ids.split(ID_SEP) if ids.is_a?(String)
72
+ ids.flatten!
73
+ unless ids.is_a?(Array) and ids.length == self.class.primary_keys.length
74
+ raise "#{self.class}.id= requires #{self.class.primary_keys.length} ids"
75
+ end
76
+ [primary_keys, ids].transpose.each {|key, an_id| write_attribute(key , an_id)}
77
+ id
78
+ end
79
+
80
+ # Returns a clone of the record that hasn't been assigned an id yet and
81
+ # is treated as a new record. Note that this is a "shallow" clone:
82
+ # it copies the object's attributes only, not its associations.
83
+ # The extent of a "deep" clone is application-specific and is therefore
84
+ # left to the application to implement according to its need.
85
+ def clone
86
+ attrs = self.attributes_before_type_cast
87
+ self.class.primary_keys.each {|key| attrs.delete(key.to_s)}
88
+ self.class.new do |record|
89
+ record.send :instance_variable_set, '@attributes', attrs
90
+ end
91
+ end
92
+
93
+
94
+ private
95
+ # The xx_without_callbacks methods are overwritten as that is the end of the alias chain
96
+
97
+ # Creates a new record with values matching those of the instance attributes.
98
+ def create_without_callbacks
99
+ unless self.id
100
+ raise CompositeKeyError, "Composite keys do not generated ids from sequences, you must provide id values"
101
+ end
102
+ attributes_minus_pks = attributes_with_quotes(false)
103
+ quoted_pk_columns = self.class.primary_key.map { |col| connection.quote_column_name(col) }
104
+ cols = quoted_column_names(attributes_minus_pks) << quoted_pk_columns
105
+ vals = attributes_minus_pks.values << quoted_id
106
+ connection.insert(
107
+ "INSERT INTO #{self.class.quoted_table_name} " +
108
+ "(#{cols.join(', ')}) " +
109
+ "VALUES (#{vals.join(', ')})",
110
+ "#{self.class.name} Create",
111
+ self.class.primary_key,
112
+ self.id
113
+ )
114
+ @new_record = false
115
+ return true
116
+ end
117
+
118
+ # Updates the associated record with values matching those of the instance attributes.
119
+ def update_without_callbacks
120
+ where_clause_terms = [self.class.primary_key, quoted_id].transpose.map do |pair|
121
+ "(#{connection.quote_column_name(pair[0])} = #{pair[1]})"
122
+ end
123
+ where_clause = where_clause_terms.join(" AND ")
124
+ connection.update(
125
+ "UPDATE #{self.class.quoted_table_name} " +
126
+ "SET #{quoted_comma_pair_list(connection, attributes_with_quotes(false))} " +
127
+ "WHERE #{where_clause}",
128
+ "#{self.class.name} Update"
129
+ )
130
+ return true
131
+ end
132
+
133
+ # Deletes the record in the database and freezes this instance to reflect that no changes should
134
+ # be made (since they can't be persisted).
135
+ def destroy_without_callbacks
136
+ where_clause_terms = [self.class.primary_key, quoted_id].transpose.map do |pair|
137
+ "(#{connection.quote_column_name(pair[0])} = #{pair[1]})"
138
+ end
139
+ where_clause = where_clause_terms.join(" AND ")
140
+ unless new_record?
141
+ connection.delete(
142
+ "DELETE FROM #{self.class.quoted_table_name} " +
143
+ "WHERE #{where_clause}",
144
+ "#{self.class.name} Destroy"
145
+ )
146
+ end
147
+ freeze
148
+ end
149
+ end
150
+
151
+ module CompositeClassMethods
152
+ def primary_key; primary_keys; end
153
+ def primary_key=(keys); primary_keys = keys; end
154
+
155
+ def composite?
156
+ true
157
+ end
158
+
159
+ #ids_to_s([[1,2],[7,3]]) -> "(1,2),(7,3)"
160
+ #ids_to_s([[1,2],[7,3]], ',', ';') -> "1,2;7,3"
161
+ def ids_to_s(many_ids, id_sep = CompositePrimaryKeys::ID_SEP, list_sep = ',', left_bracket = '(', right_bracket = ')')
162
+ many_ids.map {|ids| "#{left_bracket}#{ids}#{right_bracket}"}.join(list_sep)
163
+ end
164
+
165
+ # Returns true if the given +ids+ represents the primary keys of a record in the database, false otherwise.
166
+ # Example:
167
+ # Person.exists?(5,7)
168
+ def exists?(ids)
169
+ obj = find(ids) rescue false
170
+ !obj.nil? and obj.is_a?(self)
171
+ end
172
+
173
+ # Deletes the record with the given +ids+ without instantiating an object first, e.g. delete(1,2)
174
+ # If an array of ids is provided (e.g. delete([1,2], [3,4]), all of them
175
+ # are deleted.
176
+ def delete(*ids)
177
+ unless ids.is_a?(Array); raise "*ids must be an Array"; end
178
+ ids = [ids.to_composite_ids] if not ids.first.is_a?(Array)
179
+ where_clause = ids.map do |id_set|
180
+ [primary_keys, id_set].transpose.map do |key, id|
181
+ "#{quoted_table_name}.#{connection.quote_column_name(key.to_s)}=#{sanitize(id)}"
182
+ end.join(" AND ")
183
+ end.join(") OR (")
184
+ delete_all([ "(#{where_clause})" ])
185
+ end
186
+
187
+ # Destroys the record with the given +ids+ by instantiating the object and calling #destroy (all the callbacks are the triggered).
188
+ # If an array of ids is provided, all of them are destroyed.
189
+ def destroy(*ids)
190
+ unless ids.is_a?(Array); raise "*ids must be an Array"; end
191
+ if ids.first.is_a?(Array)
192
+ ids = ids.map{|compids| compids.to_composite_ids}
193
+ else
194
+ ids = ids.to_composite_ids
195
+ end
196
+ ids.first.is_a?(CompositeIds) ? ids.each { |id_set| find(id_set).destroy } : find(ids).destroy
197
+ end
198
+
199
+ # Returns an array of column objects for the table associated with this class.
200
+ # Each column that matches to one of the primary keys has its
201
+ # primary attribute set to true
202
+ def columns
203
+ unless @columns
204
+ @columns = connection.columns(table_name, "#{name} Columns")
205
+ @columns.each {|column| column.primary = primary_keys.include?(column.name.to_sym)}
206
+ end
207
+ @columns
208
+ end
209
+
210
+ ## DEACTIVATED METHODS ##
211
+ public
212
+ # Lazy-set the sequence name to the connection's default. This method
213
+ # is only ever called once since set_sequence_name overrides it.
214
+ def sequence_name #:nodoc:
215
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
216
+ end
217
+
218
+ def reset_sequence_name #:nodoc:
219
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
220
+ end
221
+
222
+ def set_primary_key(value = nil, &block)
223
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
224
+ end
225
+
226
+ private
227
+ def find_one(id, options)
228
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
229
+ end
230
+
231
+ def find_some(ids, options)
232
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
233
+ end
234
+
235
+ def find_from_ids(ids, options)
236
+ ids = ids.first if ids.last == nil
237
+ conditions = " AND (#{sanitize_sql(options[:conditions])})" if options[:conditions]
238
+ # if ids is just a flat list, then its size must = primary_key.length (one id per primary key, in order)
239
+ # if ids is list of lists, then each inner list must follow rule above
240
+ if ids.first.is_a? String
241
+ # find '2,1' -> ids = ['2,1']
242
+ # find '2,1;7,3' -> ids = ['2,1;7,3']
243
+ ids = ids.first.split(ID_SET_SEP).map {|id_set| id_set.split(ID_SEP).to_composite_ids}
244
+ # find '2,1;7,3' -> ids = [['2','1'],['7','3']], inner [] are CompositeIds
245
+ end
246
+ ids = [ids.to_composite_ids] if not ids.first.kind_of?(Array)
247
+ ids.each do |id_set|
248
+ unless id_set.is_a?(Array)
249
+ raise "Ids must be in an Array, instead received: #{id_set.inspect}"
250
+ end
251
+ unless id_set.length == primary_keys.length
252
+ raise "#{id_set.inspect}: Incorrect number of primary keys for #{class_name}: #{primary_keys.inspect}"
253
+ end
254
+ end
255
+
256
+ # Let keys = [:a, :b]
257
+ # If ids = [[10, 50], [11, 51]], then :conditions =>
258
+ # "(#{quoted_table_name}.a, #{quoted_table_name}.b) IN ((10, 50), (11, 51))"
259
+
260
+ conditions = ids.map do |id_set|
261
+ [primary_keys, id_set].transpose.map do |key, id|
262
+ col = columns_hash[key.to_s]
263
+ val = quote_value(id, col)
264
+ "#{quoted_table_name}.#{connection.quote_column_name(key.to_s)}=#{val}"
265
+ end.join(" AND ")
266
+ end.join(") OR (")
267
+
268
+ options.update :conditions => "(#{conditions})"
269
+
270
+ result = find_every(options)
271
+
272
+ if result.size == ids.size
273
+ ids.size == 1 ? result[0] : result
274
+ else
275
+ raise ::ActiveRecord::RecordNotFound, "Couldn't find all #{name.pluralize} with IDs (#{ids.inspect})#{conditions}"
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
282
+
283
+
284
+ module ActiveRecord
285
+ ID_SEP = ','
286
+ ID_SET_SEP = ';'
287
+
288
+ class Base
289
+ # Allows +attr_name+ to be the list of primary_keys, and returns the id
290
+ # of the object
291
+ # e.g. @object[@object.class.primary_key] => [1,1]
292
+ def [](attr_name)
293
+ if attr_name.is_a?(String) and attr_name != attr_name.split(ID_SEP).first
294
+ attr_name = attr_name.split(ID_SEP)
295
+ end
296
+ attr_name.is_a?(Array) ?
297
+ attr_name.map {|name| read_attribute(name)} :
298
+ read_attribute(attr_name)
299
+ end
300
+
301
+ # Updates the attribute identified by <tt>attr_name</tt> with the specified +value+.
302
+ # (Alias for the protected write_attribute method).
303
+ def []=(attr_name, value)
304
+ if attr_name.is_a?(String) and attr_name != attr_name.split(ID_SEP).first
305
+ attr_name = attr_name.split(ID_SEP)
306
+ end
307
+
308
+ if attr_name.is_a? Array
309
+ value = value.split(ID_SEP) if value.is_a? String
310
+ unless value.length == attr_name.length
311
+ raise "Number of attr_names and values do not match"
312
+ end
313
+ #breakpoint
314
+ [attr_name, value].transpose.map {|name,val| write_attribute(name.to_s, val)}
315
+ else
316
+ write_attribute(attr_name, value)
317
+ end
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,68 @@
1
+ module CompositePrimaryKeys
2
+ module ActiveRecord
3
+ module Calculations
4
+ def self.append_features(base)
5
+ super
6
+ base.send(:extend, ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def construct_calculation_sql(operation, column_name, options) #:nodoc:
11
+ operation = operation.to_s.downcase
12
+ options = options.symbolize_keys
13
+
14
+ scope = scope(:find)
15
+ merged_includes = merge_includes(scope ? scope[:include] : [], options[:include])
16
+ aggregate_alias = column_alias_for(operation, column_name)
17
+ use_workaround = !connection.supports_count_distinct? && options[:distinct] && operation.to_s.downcase == 'count'
18
+ join_dependency = nil
19
+
20
+ if merged_includes.any? && operation.to_s.downcase == 'count'
21
+ options[:distinct] = true
22
+ use_workaround = !connection.supports_count_distinct?
23
+ column_name = options[:select] || primary_key.map{ |part| "#{quoted_table_name}.#{connection.quote_column_name(part)}"}.join(',')
24
+ end
25
+
26
+ sql = "SELECT #{operation}(#{'DISTINCT ' if options[:distinct]}#{column_name}) AS #{aggregate_alias}"
27
+
28
+ # A (slower) workaround if we're using a backend, like sqlite, that doesn't support COUNT DISTINCT.
29
+ sql = "SELECT COUNT(*) AS #{aggregate_alias}" if use_workaround
30
+
31
+ sql << ", #{connection.quote_column_name(options[:group_field])} AS #{options[:group_alias]}" if options[:group]
32
+ sql << " FROM (SELECT DISTINCT #{column_name}" if use_workaround
33
+ sql << " FROM #{quoted_table_name} "
34
+ if merged_includes.any?
35
+ join_dependency = ::ActiveRecord::Associations::ClassMethods::JoinDependency.new(self, merged_includes, options[:joins])
36
+ sql << join_dependency.join_associations.collect{|join| join.association_join }.join
37
+ end
38
+ add_joins!(sql, options, scope)
39
+ add_conditions!(sql, options[:conditions], scope)
40
+ add_limited_ids_condition!(sql, options, join_dependency) if \
41
+ join_dependency &&
42
+ !using_limitable_reflections?(join_dependency.reflections) &&
43
+ ((scope && scope[:limit]) || options[:limit])
44
+
45
+ if options[:group]
46
+ group_key = connection.adapter_name == 'FrontBase' ? :group_alias : :group_field
47
+ sql << " GROUP BY #{connection.quote_column_name(options[group_key])} "
48
+ end
49
+
50
+ if options[:group] && options[:having]
51
+ # FrontBase requires identifiers in the HAVING clause and chokes on function calls
52
+ if connection.adapter_name == 'FrontBase'
53
+ options[:having].downcase!
54
+ options[:having].gsub!(/#{operation}\s*\(\s*#{column_name}\s*\)/, aggregate_alias)
55
+ end
56
+
57
+ sql << " HAVING #{options[:having]} "
58
+ end
59
+
60
+ sql << " ORDER BY #{options[:order]} " if options[:order]
61
+ add_limit!(sql, options, scope)
62
+ sql << ') w1' if use_workaround # assign a dummy table name as required for postgresql
63
+ sql
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end