1 module CompositePrimaryKeys
\r
2 module ActiveRecord #:nodoc:
\r
3 class CompositeKeyError < StandardError #:nodoc:
\r
8 INVALID_FOR_COMPOSITE_KEYS = 'Not appropriate for composite primary keys'
\r
9 NOT_IMPLEMENTED_YET = 'Not implemented for composite primary keys yet'
\r
11 def self.append_features(base)
\r
13 base.send(:include, InstanceMethods)
\r
14 base.extend(ClassMethods)
\r
18 def set_primary_keys(*keys)
\r
19 keys = keys.first if keys.first.is_a?(Array)
\r
20 keys = keys.map { |k| k.to_sym }
\r
21 cattr_accessor :primary_keys
\r
22 self.primary_keys = keys.to_composite_keys
\r
25 extend CompositeClassMethods
\r
26 include CompositeInstanceMethods
\r
28 include CompositePrimaryKeys::ActiveRecord::Associations
\r
29 include CompositePrimaryKeys::ActiveRecord::AssociationPreload
\r
30 include CompositePrimaryKeys::ActiveRecord::Calculations
\r
31 include CompositePrimaryKeys::ActiveRecord::AttributeMethods
\r
40 module InstanceMethods
\r
41 def composite?; self.class.composite?; end
\r
44 module CompositeInstanceMethods
\r
46 # A model instance's primary keys is always available as model.ids
\r
47 # whether you name it the default 'id' or set it to something else.
\r
49 attr_names = self.class.primary_keys
\r
50 CompositeIds.new(attr_names.map { |attr_name| read_attribute(attr_name) })
\r
52 alias_method :ids, :id
\r
58 def id_before_type_cast #:nodoc:
\r
59 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::NOT_IMPLEMENTED_YET
\r
62 def quoted_id #:nodoc:
\r
63 [self.class.primary_keys, ids].
\r
65 map {|attr_name,id| quote_value(id, column_for_attribute(attr_name))}.
\r
69 # Sets the primary ID.
\r
71 ids = ids.split(ID_SEP) if ids.is_a?(String)
\r
73 unless ids.is_a?(Array) and ids.length == self.class.primary_keys.length
\r
74 raise "#{self.class}.id= requires #{self.class.primary_keys.length} ids"
\r
76 [primary_keys, ids].transpose.each {|key, an_id| write_attribute(key , an_id)}
\r
80 # Returns a clone of the record that hasn't been assigned an id yet and
\r
81 # is treated as a new record. Note that this is a "shallow" clone:
\r
82 # it copies the object's attributes only, not its associations.
\r
83 # The extent of a "deep" clone is application-specific and is therefore
\r
84 # left to the application to implement according to its need.
\r
86 attrs = self.attributes_before_type_cast
\r
87 self.class.primary_keys.each {|key| attrs.delete(key.to_s)}
\r
88 self.class.new do |record|
\r
89 record.send :instance_variable_set, '@attributes', attrs
\r
95 # The xx_without_callbacks methods are overwritten as that is the end of the alias chain
\r
97 # Creates a new record with values matching those of the instance attributes.
\r
98 def create_without_callbacks
\r
100 raise CompositeKeyError, "Composite keys do not generated ids from sequences, you must provide id values"
\r
102 attributes_minus_pks = attributes_with_quotes(false)
\r
103 quoted_pk_columns = self.class.primary_key.map { |col| connection.quote_column_name(col) }
\r
104 cols = quoted_column_names(attributes_minus_pks) << quoted_pk_columns
\r
105 vals = attributes_minus_pks.values << quoted_id
\r
107 "INSERT INTO #{self.class.quoted_table_name} " +
\r
108 "(#{cols.join(', ')}) " +
\r
109 "VALUES (#{vals.join(', ')})",
\r
110 "#{self.class.name} Create",
\r
111 self.class.primary_key,
\r
114 @new_record = false
\r
118 # Updates the associated record with values matching those of the instance attributes.
\r
119 def update_without_callbacks
\r
120 where_clause_terms = [self.class.primary_key, quoted_id].transpose.map do |pair|
\r
121 "(#{connection.quote_column_name(pair[0])} = #{pair[1]})"
\r
123 where_clause = where_clause_terms.join(" AND ")
\r
125 "UPDATE #{self.class.quoted_table_name} " +
\r
126 "SET #{quoted_comma_pair_list(connection, attributes_with_quotes(false))} " +
\r
127 "WHERE #{where_clause}",
\r
128 "#{self.class.name} Update"
\r
133 # Deletes the record in the database and freezes this instance to reflect that no changes should
\r
134 # be made (since they can't be persisted).
\r
135 def destroy_without_callbacks
\r
136 where_clause_terms = [self.class.primary_key, quoted_id].transpose.map do |pair|
\r
137 "(#{connection.quote_column_name(pair[0])} = #{pair[1]})"
\r
139 where_clause = where_clause_terms.join(" AND ")
\r
142 "DELETE FROM #{self.class.quoted_table_name} " +
\r
143 "WHERE #{where_clause}",
\r
144 "#{self.class.name} Destroy"
\r
151 module CompositeClassMethods
\r
152 def primary_key; primary_keys; end
\r
153 def primary_key=(keys); primary_keys = keys; end
\r
159 #ids_to_s([[1,2],[7,3]]) -> "(1,2),(7,3)"
\r
160 #ids_to_s([[1,2],[7,3]], ',', ';') -> "1,2;7,3"
\r
161 def ids_to_s(many_ids, id_sep = CompositePrimaryKeys::ID_SEP, list_sep = ',', left_bracket = '(', right_bracket = ')')
\r
162 many_ids.map {|ids| "#{left_bracket}#{ids}#{right_bracket}"}.join(list_sep)
\r
165 # Creates WHERE condition from list of composited ids
\r
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)
\r
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)
\r
168 def composite_where_clause(ids)
\r
169 if ids.is_a?(String)
\r
171 elsif not ids.first.is_a?(Array) # if single comp key passed, turn into an array of 1
\r
172 ids = [ids.to_composite_ids]
\r
175 ids.map do |id_set|
\r
176 [primary_keys, id_set].transpose.map do |key, id|
\r
177 "#{table_name}.#{key.to_s}=#{sanitize(id)}"
\r
179 end.join(") OR (")
\r
182 # Returns true if the given +ids+ represents the primary keys of a record in the database, false otherwise.
\r
184 # Person.exists?(5,7)
\r
186 if ids.is_a?(Array) && ids.first.is_a?(String)
\r
187 count(:conditions => ids) > 0
\r
189 obj = find(ids) rescue false
\r
190 !obj.nil? and obj.is_a?(self)
\r
194 # Deletes the record with the given +ids+ without instantiating an object first, e.g. delete(1,2)
\r
195 # If an array of ids is provided (e.g. delete([1,2], [3,4]), all of them
\r
198 unless ids.is_a?(Array); raise "*ids must be an Array"; end
\r
199 ids = [ids.to_composite_ids] if not ids.first.is_a?(Array)
\r
200 where_clause = ids.map do |id_set|
\r
201 [primary_keys, id_set].transpose.map do |key, id|
\r
202 "#{quoted_table_name}.#{connection.quote_column_name(key.to_s)}=#{sanitize(id)}"
\r
205 delete_all([ "(#{where_clause})" ])
\r
208 # Destroys the record with the given +ids+ by instantiating the object and calling #destroy (all the callbacks are the triggered).
\r
209 # If an array of ids is provided, all of them are destroyed.
\r
211 unless ids.is_a?(Array); raise "*ids must be an Array"; end
\r
212 if ids.first.is_a?(Array)
\r
213 ids = ids.map{|compids| compids.to_composite_ids}
\r
215 ids = ids.to_composite_ids
\r
217 ids.first.is_a?(CompositeIds) ? ids.each { |id_set| find(id_set).destroy } : find(ids).destroy
\r
220 # Returns an array of column objects for the table associated with this class.
\r
221 # Each column that matches to one of the primary keys has its
\r
222 # primary attribute set to true
\r
225 @columns = connection.columns(table_name, "#{name} Columns")
\r
226 @columns.each {|column| column.primary = primary_keys.include?(column.name.to_sym)}
\r
231 ## DEACTIVATED METHODS ##
\r
233 # Lazy-set the sequence name to the connection's default. This method
\r
234 # is only ever called once since set_sequence_name overrides it.
\r
235 def sequence_name #:nodoc:
\r
236 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
\r
239 def reset_sequence_name #:nodoc:
\r
240 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
\r
243 def set_primary_key(value = nil, &block)
\r
244 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
\r
248 def find_one(id, options)
\r
249 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
\r
252 def find_some(ids, options)
\r
253 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
\r
256 def find_from_ids(ids, options)
\r
257 ids = ids.first if ids.last == nil
\r
258 conditions = " AND (#{sanitize_sql(options[:conditions])})" if options[:conditions]
\r
259 # if ids is just a flat list, then its size must = primary_key.length (one id per primary key, in order)
\r
260 # if ids is list of lists, then each inner list must follow rule above
\r
261 if ids.first.is_a? String
\r
262 # find '2,1' -> ids = ['2,1']
\r
263 # find '2,1;7,3' -> ids = ['2,1;7,3']
\r
264 ids = ids.first.split(ID_SET_SEP).map {|id_set| id_set.split(ID_SEP).to_composite_ids}
\r
265 # find '2,1;7,3' -> ids = [['2','1'],['7','3']], inner [] are CompositeIds
\r
267 ids = [ids.to_composite_ids] if not ids.first.kind_of?(Array)
\r
268 ids.each do |id_set|
\r
269 unless id_set.is_a?(Array)
\r
270 raise "Ids must be in an Array, instead received: #{id_set.inspect}"
\r
272 unless id_set.length == primary_keys.length
\r
273 raise "#{id_set.inspect}: Incorrect number of primary keys for #{class_name}: #{primary_keys.inspect}"
\r
277 # Let keys = [:a, :b]
\r
278 # If ids = [[10, 50], [11, 51]], then :conditions =>
\r
279 # "(#{quoted_table_name}.a, #{quoted_table_name}.b) IN ((10, 50), (11, 51))"
\r
281 conditions = ids.map do |id_set|
\r
282 [primary_keys, id_set].transpose.map do |key, id|
\r
283 col = columns_hash[key.to_s]
\r
284 val = quote_value(id, col)
\r
285 "#{quoted_table_name}.#{connection.quote_column_name(key.to_s)}=#{val}"
\r
289 options.update :conditions => "(#{conditions})"
\r
291 result = find_every(options)
\r
293 if result.size == ids.size
\r
294 ids.size == 1 ? result[0] : result
\r
296 raise ::ActiveRecord::RecordNotFound, "Couldn't find all #{name.pluralize} with IDs (#{ids.inspect})#{conditions}"
\r
305 module ActiveRecord
\r
310 # Allows +attr_name+ to be the list of primary_keys, and returns the id
\r
312 # e.g. @object[@object.class.primary_key] => [1,1]
\r
314 if attr_name.is_a?(String) and attr_name != attr_name.split(ID_SEP).first
\r
315 attr_name = attr_name.split(ID_SEP)
\r
317 attr_name.is_a?(Array) ?
\r
318 attr_name.map {|name| read_attribute(name)} :
\r
319 read_attribute(attr_name)
\r
322 # Updates the attribute identified by <tt>attr_name</tt> with the specified +value+.
\r
323 # (Alias for the protected write_attribute method).
\r
324 def []=(attr_name, value)
\r
325 if attr_name.is_a?(String) and attr_name != attr_name.split(ID_SEP).first
\r
326 attr_name = attr_name.split(ID_SEP)
\r
329 if attr_name.is_a? Array
\r
330 value = value.split(ID_SEP) if value.is_a? String
\r
331 unless value.length == attr_name.length
\r
332 raise "Number of attr_names and values do not match"
\r
335 [attr_name, value].transpose.map {|name,val| write_attribute(name.to_s, val)}
\r
337 write_attribute(attr_name, value)
\r