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 obj = find(ids) rescue false
\r
187 !obj.nil? and obj.is_a?(self)
\r
190 # Deletes the record with the given +ids+ without instantiating an object first, e.g. delete(1,2)
\r
191 # If an array of ids is provided (e.g. delete([1,2], [3,4]), all of them
\r
194 unless ids.is_a?(Array); raise "*ids must be an Array"; end
\r
195 ids = [ids.to_composite_ids] if not ids.first.is_a?(Array)
\r
196 where_clause = ids.map do |id_set|
\r
197 [primary_keys, id_set].transpose.map do |key, id|
\r
198 "#{quoted_table_name}.#{connection.quote_column_name(key.to_s)}=#{sanitize(id)}"
\r
201 delete_all([ "(#{where_clause})" ])
\r
204 # Destroys the record with the given +ids+ by instantiating the object and calling #destroy (all the callbacks are the triggered).
\r
205 # If an array of ids is provided, all of them are destroyed.
\r
207 unless ids.is_a?(Array); raise "*ids must be an Array"; end
\r
208 if ids.first.is_a?(Array)
\r
209 ids = ids.map{|compids| compids.to_composite_ids}
\r
211 ids = ids.to_composite_ids
\r
213 ids.first.is_a?(CompositeIds) ? ids.each { |id_set| find(id_set).destroy } : find(ids).destroy
\r
216 # Returns an array of column objects for the table associated with this class.
\r
217 # Each column that matches to one of the primary keys has its
\r
218 # primary attribute set to true
\r
221 @columns = connection.columns(table_name, "#{name} Columns")
\r
222 @columns.each {|column| column.primary = primary_keys.include?(column.name.to_sym)}
\r
227 ## DEACTIVATED METHODS ##
\r
229 # Lazy-set the sequence name to the connection's default. This method
\r
230 # is only ever called once since set_sequence_name overrides it.
\r
231 def sequence_name #:nodoc:
\r
232 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
\r
235 def reset_sequence_name #:nodoc:
\r
236 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
\r
239 def set_primary_key(value = nil, &block)
\r
240 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
\r
244 def find_one(id, options)
\r
245 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
\r
248 def find_some(ids, options)
\r
249 raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS
\r
252 def find_from_ids(ids, options)
\r
253 ids = ids.first if ids.last == nil
\r
254 conditions = " AND (#{sanitize_sql(options[:conditions])})" if options[:conditions]
\r
255 # if ids is just a flat list, then its size must = primary_key.length (one id per primary key, in order)
\r
256 # if ids is list of lists, then each inner list must follow rule above
\r
257 if ids.first.is_a? String
\r
258 # find '2,1' -> ids = ['2,1']
\r
259 # find '2,1;7,3' -> ids = ['2,1;7,3']
\r
260 ids = ids.first.split(ID_SET_SEP).map {|id_set| id_set.split(ID_SEP).to_composite_ids}
\r
261 # find '2,1;7,3' -> ids = [['2','1'],['7','3']], inner [] are CompositeIds
\r
263 ids = [ids.to_composite_ids] if not ids.first.kind_of?(Array)
\r
264 ids.each do |id_set|
\r
265 unless id_set.is_a?(Array)
\r
266 raise "Ids must be in an Array, instead received: #{id_set.inspect}"
\r
268 unless id_set.length == primary_keys.length
\r
269 raise "#{id_set.inspect}: Incorrect number of primary keys for #{class_name}: #{primary_keys.inspect}"
\r
273 # Let keys = [:a, :b]
\r
274 # If ids = [[10, 50], [11, 51]], then :conditions =>
\r
275 # "(#{quoted_table_name}.a, #{quoted_table_name}.b) IN ((10, 50), (11, 51))"
\r
277 conditions = ids.map do |id_set|
\r
278 [primary_keys, id_set].transpose.map do |key, id|
\r
279 col = columns_hash[key.to_s]
\r
280 val = quote_value(id, col)
\r
281 "#{quoted_table_name}.#{connection.quote_column_name(key.to_s)}=#{val}"
\r
285 options.update :conditions => "(#{conditions})"
\r
287 result = find_every(options)
\r
289 if result.size == ids.size
\r
290 ids.size == 1 ? result[0] : result
\r
292 raise ::ActiveRecord::RecordNotFound, "Couldn't find all #{name.pluralize} with IDs (#{ids.inspect})#{conditions}"
\r
301 module ActiveRecord
\r
306 # Allows +attr_name+ to be the list of primary_keys, and returns the id
\r
308 # e.g. @object[@object.class.primary_key] => [1,1]
\r
310 if attr_name.is_a?(String) and attr_name != attr_name.split(ID_SEP).first
\r
311 attr_name = attr_name.split(ID_SEP)
\r
313 attr_name.is_a?(Array) ?
\r
314 attr_name.map {|name| read_attribute(name)} :
\r
315 read_attribute(attr_name)
\r
318 # Updates the attribute identified by <tt>attr_name</tt> with the specified +value+.
\r
319 # (Alias for the protected write_attribute method).
\r
320 def []=(attr_name, value)
\r
321 if attr_name.is_a?(String) and attr_name != attr_name.split(ID_SEP).first
\r
322 attr_name = attr_name.split(ID_SEP)
\r
325 if attr_name.is_a? Array
\r
326 value = value.split(ID_SEP) if value.is_a? String
\r
327 unless value.length == attr_name.length
\r
328 raise "Number of attr_names and values do not match"
\r
331 [attr_name, value].transpose.map {|name,val| write_attribute(name.to_s, val)}
\r
333 write_attribute(attr_name, value)
\r