1 module CompositePrimaryKeys
2 module ActiveRecord #:nodoc:
3 class CompositeKeyError < StandardError #:nodoc:
8 INVALID_FOR_COMPOSITE_KEYS = 'Not appropriate for composite primary keys'
9 NOT_IMPLEMENTED_YET = 'Not implemented for composite primary keys yet'
11 def self.append_features(base)
13 base.send(:include, InstanceMethods)
14 base.extend(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
25 extend CompositeClassMethods
26 include CompositeInstanceMethods
28 include CompositePrimaryKeys::ActiveRecord::Associations
29 include CompositePrimaryKeys::ActiveRecord::AssociationPreload
30 include CompositePrimaryKeys::ActiveRecord::Calculations
31 include CompositePrimaryKeys::ActiveRecord::AttributeMethods
40 module InstanceMethods
41 def composite?; self.class.composite?; end
44 module CompositeInstanceMethods
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.
49 attr_names = self.class.primary_keys
50 CompositeIds.new(attr_names.map { |attr_name| read_attribute(attr_name) })
52 alias_method :ids, :id
58 def id_before_type_cast #:nodoc:
59 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::NOT_IMPLEMENTED_YET
62 def quoted_id #:nodoc:
63 [self.class.primary_keys, ids].
65 map {|attr_name,id| quote_value(id, column_for_attribute(attr_name))}.
69 # Sets the primary ID.
71 ids = ids.split(ID_SEP) if ids.is_a?(String)
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"
76 [primary_keys, ids].transpose.each {|key, an_id| write_attribute(key , an_id)}
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.
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
95 # The xx_without_callbacks methods are overwritten as that is the end of the alias chain
97 # Creates a new record with values matching those of the instance attributes.
98 def create_without_callbacks
100 raise CompositeKeyError, "Composite keys do not generated ids from sequences, you must provide id values"
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
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,
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]})"
123 where_clause = where_clause_terms.join(" AND ")
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"
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]})"
139 where_clause = where_clause_terms.join(" AND ")
142 "DELETE FROM #{self.class.quoted_table_name} " +
143 "WHERE #{where_clause}",
144 "#{self.class.name} Destroy"
151 module CompositeClassMethods
152 def primary_key; primary_keys; end
153 def primary_key=(keys); primary_keys = keys; end
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)
165 # Creates WHERE condition from list of composited ids
166 # User.update_all({:role => 'admin'}, :conditions => composite_where_clause([[1, 2], [2, 2]])) #=> UPDATE admins SET admin.role='admin' WHERE (admin.type=1 AND admin.type2=2) OR (admin.type=2 AND admin.type2=2)
167 # User.find(:all, :conditions => composite_where_clause([[1, 2], [2, 2]])) #=> SELECT * FROM admins WHERE (admin.type=1 AND admin.type2=2) OR (admin.type=2 AND admin.type2=2)
168 def composite_where_clause(ids)
171 elsif not ids.first.is_a?(Array) # if single comp key passed, turn into an array of 1
172 ids = [ids.to_composite_ids]
176 [primary_keys, id_set].transpose.map do |key, id|
177 "#{table_name}.#{key.to_s}=#{sanitize(id)}"
182 # Returns true if the given +ids+ represents the primary keys of a record in the database, false otherwise.
184 # Person.exists?(5,7)
186 if ids.is_a?(Array) && ids.first.is_a?(String)
187 count(:conditions => ids) > 0
189 obj = find(ids) rescue false
190 !obj.nil? and obj.is_a?(self)
194 # Deletes the record with the given +ids+ without instantiating an object first, e.g. delete(1,2)
195 # If an array of ids is provided (e.g. delete([1,2], [3,4]), all of them
198 unless ids.is_a?(Array); raise "*ids must be an Array"; end
199 ids = [ids.to_composite_ids] if not ids.first.is_a?(Array)
200 where_clause = ids.map do |id_set|
201 [primary_keys, id_set].transpose.map do |key, id|
202 "#{quoted_table_name}.#{connection.quote_column_name(key.to_s)}=#{sanitize(id)}"
205 delete_all([ "(#{where_clause})" ])
208 # Destroys the record with the given +ids+ by instantiating the object and calling #destroy (all the callbacks are the triggered).
209 # If an array of ids is provided, all of them are destroyed.
211 unless ids.is_a?(Array); raise "*ids must be an Array"; end
212 if ids.first.is_a?(Array)
213 ids = ids.map{|compids| compids.to_composite_ids}
215 ids = ids.to_composite_ids
217 ids.first.is_a?(CompositeIds) ? ids.each { |id_set| find(id_set).destroy } : find(ids).destroy
220 # Returns an array of column objects for the table associated with this class.
221 # Each column that matches to one of the primary keys has its
222 # primary attribute set to true
225 @columns = connection.columns(table_name, "#{name} Columns")
226 @columns.each {|column| column.primary = primary_keys.include?(column.name.to_sym)}
231 ## DEACTIVATED METHODS ##
233 # Lazy-set the sequence name to the connection's default. This method
234 # is only ever called once since set_sequence_name overrides it.
235 def sequence_name #:nodoc:
236 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
239 def reset_sequence_name #:nodoc:
240 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
243 def set_primary_key(value = nil, &block)
244 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
248 def find_one(id, options)
249 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
252 def find_some(ids, options)
253 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
256 def find_from_ids(ids, options)
257 ids = ids.first if ids.last == nil
258 conditions = " AND (#{sanitize_sql(options[:conditions])})" if options[:conditions]
259 # if ids is just a flat list, then its size must = primary_key.length (one id per primary key, in order)
260 # if ids is list of lists, then each inner list must follow rule above
261 if ids.first.is_a? String
262 # find '2,1' -> ids = ['2,1']
263 # find '2,1;7,3' -> ids = ['2,1;7,3']
264 ids = ids.first.split(ID_SET_SEP).map {|id_set| id_set.split(ID_SEP).to_composite_ids}
265 # find '2,1;7,3' -> ids = [['2','1'],['7','3']], inner [] are CompositeIds
267 ids = [ids.to_composite_ids] if not ids.first.kind_of?(Array)
269 unless id_set.is_a?(Array)
270 raise "Ids must be in an Array, instead received: #{id_set.inspect}"
272 unless id_set.length == primary_keys.length
273 raise "#{id_set.inspect}: Incorrect number of primary keys for #{class_name}: #{primary_keys.inspect}"
277 # Let keys = [:a, :b]
278 # If ids = [[10, 50], [11, 51]], then :conditions =>
279 # "(#{quoted_table_name}.a, #{quoted_table_name}.b) IN ((10, 50), (11, 51))"
281 conditions = ids.map do |id_set|
282 [primary_keys, id_set].transpose.map do |key, id|
283 col = columns_hash[key.to_s]
284 val = quote_value(id, col)
285 "#{quoted_table_name}.#{connection.quote_column_name(key.to_s)}=#{val}"
289 options.update :conditions => "(#{conditions})"
291 result = find_every(options)
293 if result.size == ids.size
294 ids.size == 1 ? result[0] : result
296 raise ::ActiveRecord::RecordNotFound, "Couldn't find all #{name.pluralize} with IDs (#{ids.inspect})#{conditions}"
310 # Allows +attr_name+ to be the list of primary_keys, and returns the id
312 # e.g. @object[@object.class.primary_key] => [1,1]
314 if attr_name.is_a?(String) and attr_name != attr_name.split(ID_SEP).first
315 attr_name = attr_name.split(ID_SEP)
317 attr_name.is_a?(Array) ?
318 attr_name.map {|name| read_attribute(name)} :
319 read_attribute(attr_name)
322 # Updates the attribute identified by <tt>attr_name</tt> with the specified +value+.
323 # (Alias for the protected write_attribute method).
324 def []=(attr_name, value)
325 if attr_name.is_a?(String) and attr_name != attr_name.split(ID_SEP).first
326 attr_name = attr_name.split(ID_SEP)
329 if attr_name.is_a? Array
330 value = value.split(ID_SEP) if value.is_a? String
331 unless value.length == attr_name.length
332 raise "Number of attr_names and values do not match"
335 [attr_name, value].transpose.map {|name,val| write_attribute(name.to_s, val)}
337 write_attribute(attr_name, value)