--- /dev/null
+module CompositePrimaryKeys\r
+ module ActiveRecord #:nodoc:\r
+ class CompositeKeyError < StandardError #:nodoc:\r
+ end\r
+\r
+ module Base #:nodoc:\r
+\r
+ INVALID_FOR_COMPOSITE_KEYS = 'Not appropriate for composite primary keys'\r
+ NOT_IMPLEMENTED_YET = 'Not implemented for composite primary keys yet'\r
+\r
+ def self.append_features(base)\r
+ super\r
+ base.send(:include, InstanceMethods)\r
+ base.extend(ClassMethods)\r
+ end\r
+\r
+ module ClassMethods\r
+ def set_primary_keys(*keys)\r
+ keys = keys.first if keys.first.is_a?(Array)\r
+ keys = keys.map { |k| k.to_sym }\r
+ cattr_accessor :primary_keys\r
+ self.primary_keys = keys.to_composite_keys\r
+\r
+ class_eval <<-EOV\r
+ extend CompositeClassMethods\r
+ include CompositeInstanceMethods\r
+\r
+ include CompositePrimaryKeys::ActiveRecord::Associations\r
+ include CompositePrimaryKeys::ActiveRecord::AssociationPreload\r
+ include CompositePrimaryKeys::ActiveRecord::Calculations\r
+ include CompositePrimaryKeys::ActiveRecord::AttributeMethods\r
+ EOV\r
+ end\r
+\r
+ def composite?\r
+ false\r
+ end\r
+ end\r
+\r
+ module InstanceMethods\r
+ def composite?; self.class.composite?; end\r
+ end\r
+\r
+ module CompositeInstanceMethods\r
+\r
+ # A model instance's primary keys is always available as model.ids\r
+ # whether you name it the default 'id' or set it to something else.\r
+ def id\r
+ attr_names = self.class.primary_keys\r
+ CompositeIds.new(attr_names.map { |attr_name| read_attribute(attr_name) })\r
+ end\r
+ alias_method :ids, :id\r
+\r
+ def to_param\r
+ id.to_s\r
+ end\r
+\r
+ def id_before_type_cast #:nodoc:\r
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::NOT_IMPLEMENTED_YET\r
+ end\r
+\r
+ def quoted_id #:nodoc:\r
+ [self.class.primary_keys, ids].\r
+ transpose.\r
+ map {|attr_name,id| quote_value(id, column_for_attribute(attr_name))}.\r
+ to_composite_ids\r
+ end\r
+\r
+ # Sets the primary ID.\r
+ def id=(ids)\r
+ ids = ids.split(ID_SEP) if ids.is_a?(String)\r
+ ids.flatten!\r
+ unless ids.is_a?(Array) and ids.length == self.class.primary_keys.length\r
+ raise "#{self.class}.id= requires #{self.class.primary_keys.length} ids"\r
+ end\r
+ [primary_keys, ids].transpose.each {|key, an_id| write_attribute(key , an_id)}\r
+ id\r
+ end\r
+\r
+ # Returns a clone of the record that hasn't been assigned an id yet and\r
+ # is treated as a new record. Note that this is a "shallow" clone:\r
+ # it copies the object's attributes only, not its associations.\r
+ # The extent of a "deep" clone is application-specific and is therefore\r
+ # left to the application to implement according to its need.\r
+ def clone\r
+ attrs = self.attributes_before_type_cast\r
+ self.class.primary_keys.each {|key| attrs.delete(key.to_s)}\r
+ self.class.new do |record|\r
+ record.send :instance_variable_set, '@attributes', attrs\r
+ end\r
+ end\r
+\r
+\r
+ private\r
+ # The xx_without_callbacks methods are overwritten as that is the end of the alias chain\r
+\r
+ # Creates a new record with values matching those of the instance attributes.\r
+ def create_without_callbacks\r
+ unless self.id\r
+ raise CompositeKeyError, "Composite keys do not generated ids from sequences, you must provide id values"\r
+ end\r
+ attributes_minus_pks = attributes_with_quotes(false)\r
+ quoted_pk_columns = self.class.primary_key.map { |col| connection.quote_column_name(col) }\r
+ cols = quoted_column_names(attributes_minus_pks) << quoted_pk_columns\r
+ vals = attributes_minus_pks.values << quoted_id\r
+ connection.insert(\r
+ "INSERT INTO #{self.class.quoted_table_name} " +\r
+ "(#{cols.join(', ')}) " +\r
+ "VALUES (#{vals.join(', ')})",\r
+ "#{self.class.name} Create",\r
+ self.class.primary_key,\r
+ self.id\r
+ )\r
+ @new_record = false\r
+ return true\r
+ end\r
+\r
+ # Updates the associated record with values matching those of the instance attributes.\r
+ def update_without_callbacks\r
+ where_clause_terms = [self.class.primary_key, quoted_id].transpose.map do |pair| \r
+ "(#{connection.quote_column_name(pair[0])} = #{pair[1]})"\r
+ end\r
+ where_clause = where_clause_terms.join(" AND ")\r
+ connection.update(\r
+ "UPDATE #{self.class.quoted_table_name} " +\r
+ "SET #{quoted_comma_pair_list(connection, attributes_with_quotes(false))} " +\r
+ "WHERE #{where_clause}",\r
+ "#{self.class.name} Update"\r
+ )\r
+ return true\r
+ end\r
+\r
+ # Deletes the record in the database and freezes this instance to reflect that no changes should\r
+ # be made (since they can't be persisted).\r
+ def destroy_without_callbacks\r
+ where_clause_terms = [self.class.primary_key, quoted_id].transpose.map do |pair| \r
+ "(#{connection.quote_column_name(pair[0])} = #{pair[1]})"\r
+ end\r
+ where_clause = where_clause_terms.join(" AND ")\r
+ unless new_record?\r
+ connection.delete(\r
+ "DELETE FROM #{self.class.quoted_table_name} " +\r
+ "WHERE #{where_clause}",\r
+ "#{self.class.name} Destroy"\r
+ )\r
+ end\r
+ freeze\r
+ end\r
+ end\r
+\r
+ module CompositeClassMethods\r
+ def primary_key; primary_keys; end\r
+ def primary_key=(keys); primary_keys = keys; end\r
+\r
+ def composite?\r
+ true\r
+ end\r
+\r
+ #ids_to_s([[1,2],[7,3]]) -> "(1,2),(7,3)"\r
+ #ids_to_s([[1,2],[7,3]], ',', ';') -> "1,2;7,3"\r
+ def ids_to_s(many_ids, id_sep = CompositePrimaryKeys::ID_SEP, list_sep = ',', left_bracket = '(', right_bracket = ')')\r
+ many_ids.map {|ids| "#{left_bracket}#{ids}#{right_bracket}"}.join(list_sep)\r
+ end\r
+ \r
+ # Creates WHERE condition from list of composited ids\r
+ # 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
+ # 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
+ def composite_where_clause(ids)\r
+ if ids.is_a?(String)\r
+ ids = [[ids]]\r
+ elsif not ids.first.is_a?(Array) # if single comp key passed, turn into an array of 1\r
+ ids = [ids.to_composite_ids]\r
+ end\r
+ \r
+ ids.map do |id_set|\r
+ [primary_keys, id_set].transpose.map do |key, id|\r
+ "#{table_name}.#{key.to_s}=#{sanitize(id)}"\r
+ end.join(" AND ")\r
+ end.join(") OR (") \r
+ end\r
+\r
+ # Returns true if the given +ids+ represents the primary keys of a record in the database, false otherwise.\r
+ # Example:\r
+ # Person.exists?(5,7)\r
+ def exists?(ids)\r
+ obj = find(ids) rescue false\r
+ !obj.nil? and obj.is_a?(self)\r
+ end\r
+\r
+ # Deletes the record with the given +ids+ without instantiating an object first, e.g. delete(1,2)\r
+ # If an array of ids is provided (e.g. delete([1,2], [3,4]), all of them\r
+ # are deleted.\r
+ def delete(*ids)\r
+ unless ids.is_a?(Array); raise "*ids must be an Array"; end\r
+ ids = [ids.to_composite_ids] if not ids.first.is_a?(Array)\r
+ where_clause = ids.map do |id_set|\r
+ [primary_keys, id_set].transpose.map do |key, id|\r
+ "#{quoted_table_name}.#{connection.quote_column_name(key.to_s)}=#{sanitize(id)}"\r
+ end.join(" AND ")\r
+ end.join(") OR (")\r
+ delete_all([ "(#{where_clause})" ])\r
+ end\r
+\r
+ # Destroys the record with the given +ids+ by instantiating the object and calling #destroy (all the callbacks are the triggered).\r
+ # If an array of ids is provided, all of them are destroyed.\r
+ def destroy(*ids)\r
+ unless ids.is_a?(Array); raise "*ids must be an Array"; end\r
+ if ids.first.is_a?(Array)\r
+ ids = ids.map{|compids| compids.to_composite_ids}\r
+ else\r
+ ids = ids.to_composite_ids\r
+ end\r
+ ids.first.is_a?(CompositeIds) ? ids.each { |id_set| find(id_set).destroy } : find(ids).destroy\r
+ end\r
+\r
+ # Returns an array of column objects for the table associated with this class.\r
+ # Each column that matches to one of the primary keys has its\r
+ # primary attribute set to true\r
+ def columns\r
+ unless @columns\r
+ @columns = connection.columns(table_name, "#{name} Columns")\r
+ @columns.each {|column| column.primary = primary_keys.include?(column.name.to_sym)}\r
+ end\r
+ @columns\r
+ end\r
+\r
+ ## DEACTIVATED METHODS ##\r
+ public\r
+ # Lazy-set the sequence name to the connection's default. This method\r
+ # is only ever called once since set_sequence_name overrides it.\r
+ def sequence_name #:nodoc:\r
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS\r
+ end\r
+\r
+ def reset_sequence_name #:nodoc:\r
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS\r
+ end\r
+\r
+ def set_primary_key(value = nil, &block)\r
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS\r
+ end\r
+\r
+ private\r
+ def find_one(id, options)\r
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS\r
+ end\r
+\r
+ def find_some(ids, options)\r
+ raise CompositeKeyError, CompositePrimaryKeys::ActiveRecord::Base::INVALID_FOR_COMPOSITE_KEYS\r
+ end\r
+\r
+ def find_from_ids(ids, options)\r
+ ids = ids.first if ids.last == nil\r
+ conditions = " AND (#{sanitize_sql(options[:conditions])})" if options[:conditions]\r
+ # if ids is just a flat list, then its size must = primary_key.length (one id per primary key, in order)\r
+ # if ids is list of lists, then each inner list must follow rule above\r
+ if ids.first.is_a? String\r
+ # find '2,1' -> ids = ['2,1']\r
+ # find '2,1;7,3' -> ids = ['2,1;7,3']\r
+ ids = ids.first.split(ID_SET_SEP).map {|id_set| id_set.split(ID_SEP).to_composite_ids}\r
+ # find '2,1;7,3' -> ids = [['2','1'],['7','3']], inner [] are CompositeIds\r
+ end\r
+ ids = [ids.to_composite_ids] if not ids.first.kind_of?(Array)\r
+ ids.each do |id_set|\r
+ unless id_set.is_a?(Array)\r
+ raise "Ids must be in an Array, instead received: #{id_set.inspect}"\r
+ end\r
+ unless id_set.length == primary_keys.length\r
+ raise "#{id_set.inspect}: Incorrect number of primary keys for #{class_name}: #{primary_keys.inspect}"\r
+ end\r
+ end\r
+\r
+ # Let keys = [:a, :b]\r
+ # If ids = [[10, 50], [11, 51]], then :conditions => \r
+ # "(#{quoted_table_name}.a, #{quoted_table_name}.b) IN ((10, 50), (11, 51))"\r
+\r
+ conditions = ids.map do |id_set|\r
+ [primary_keys, id_set].transpose.map do |key, id|\r
+ col = columns_hash[key.to_s]\r
+ val = quote_value(id, col)\r
+ "#{quoted_table_name}.#{connection.quote_column_name(key.to_s)}=#{val}"\r
+ end.join(" AND ")\r
+ end.join(") OR (")\r
+ \r
+ options.update :conditions => "(#{conditions})"\r
+\r
+ result = find_every(options)\r
+\r
+ if result.size == ids.size\r
+ ids.size == 1 ? result[0] : result\r
+ else\r
+ raise ::ActiveRecord::RecordNotFound, "Couldn't find all #{name.pluralize} with IDs (#{ids.inspect})#{conditions}"\r
+ end\r
+ end\r
+ end\r
+ end\r
+ end\r
+end\r
+\r
+\r
+module ActiveRecord\r
+ ID_SEP = ','\r
+ ID_SET_SEP = ';'\r
+\r
+ class Base\r
+ # Allows +attr_name+ to be the list of primary_keys, and returns the id\r
+ # of the object\r
+ # e.g. @object[@object.class.primary_key] => [1,1]\r
+ def [](attr_name)\r
+ if attr_name.is_a?(String) and attr_name != attr_name.split(ID_SEP).first\r
+ attr_name = attr_name.split(ID_SEP)\r
+ end\r
+ attr_name.is_a?(Array) ?\r
+ attr_name.map {|name| read_attribute(name)} :\r
+ read_attribute(attr_name)\r
+ end\r
+\r
+ # Updates the attribute identified by <tt>attr_name</tt> with the specified +value+.\r
+ # (Alias for the protected write_attribute method).\r
+ def []=(attr_name, value)\r
+ if attr_name.is_a?(String) and attr_name != attr_name.split(ID_SEP).first\r
+ attr_name = attr_name.split(ID_SEP)\r
+ end\r
+\r
+ if attr_name.is_a? Array\r
+ value = value.split(ID_SEP) if value.is_a? String\r
+ unless value.length == attr_name.length\r
+ raise "Number of attr_names and values do not match"\r
+ end\r
+ #breakpoint\r
+ [attr_name, value].transpose.map {|name,val| write_attribute(name.to_s, val)}\r
+ else\r
+ write_attribute(attr_name, value)\r
+ end\r
+ end\r
+ end\r
+end\r