We build Web & Mobile Applications.

< All Articles

Unsigned integers for MySQL on Rails

Ok, so I’m starting a new Rails application, I’ve spent some time refining my database, I’ve considered storage requirements and performance when choosing the data types for my fields and now I’m ready to create some migrations to implement my design.

And then I’m reminded that, lovely though Rails migrations are, there are two things that really bug me about them: they create signed primary keys and they don’t allow me to easily create unsigned integer columns.

It’s been discussed before, dismissed as an ‘uncommon requirement’ (probably because MySQL is, as far as I know, the only database that uses unsigned integers) and, as far as I can tell, not a lot more has happened. So, while sat out in the garden, enjoying a sunny Saturday afternoon, I grabbed edge Rails from GitHub and decided I’d see if I could do something about it (how’s that for geekyness?!)

Unsigned primary keys

Thanks to a snippet on DZone this was the easy bit:

module ActiveRecord
  module ConnectionAdapters

    class MysqlAdapter
      def native_database_types_with_unsigned_primary_key
        returning native_database_types_without_unsigned_primary_key do |types|
          types[:primary_key] = 'int(11) UNSIGNED DEFAULT NULL auto_increment PRIMARY KEY'
        end
      end
      alias_method_chain :native_database_types, :unsigned_primary_key

  end
end

As you can see this extends the native_database_types method of the MysqlAdapter class which means that all other database adapters will be unaffected by this change.

Dealing with references

If you use the ‘sexy’ references or belongs_to syntax in your migration then changing the primary key to unsigned can cause problems. For example if you try and create a foreign key constraint you might see an error like this:

ERROR 1005 (HY000): Can't create table 'test_development.#sql-160_31' (errno: 150)

This unhelpful error message occurs because the primary key is unsigned but the foreign key field created by references is signed. To fix this I subclassed the Table and TableDefinition classes to provide MySQL specific behaviour that defaults to creating unsigned foreign key columns. This also involved overriding the create_table and change_table methods of the MysqlAdapter.

Of course if you really want an signed reference you can simply pass :unsigned => false in your call to references.

module ActiveRecord
  module ConnectionAdapters
    class MysqlTable < Table
      def references(*args)
        options = args.extract_options!
        polymorphic = options.delete(:polymorphic)
        args.each do |col|
          @base.add_column(@table_name, "#{col}_id", :integer, options.reverse_merge(:unsigned => true))
          @base.add_column(@table_name, "#{col}_type", :string, polymorphic.is_a?(Hash) ? polymorphic : options) unless polymorphic.nil?
        end
      end
      alias :belongs_to :references
    end

    class MysqlTableDefinition < TableDefinition
      def references(*args)
        options = args.extract_options!
        polymorphic = options.delete(:polymorphic)
        args.each do |col|
          column("#{col}_id", :integer, options.reverse_merge(:unsigned => true))
          column("#{col}_type", :string, polymorphic.is_a?(Hash) ? polymorphic : options) unless polymorphic.nil?
        end
      end
      alias :belongs_to :references
    end

    class MysqlAdapter
      def create_table(table_name, options = {}) #:nodoc:
        options = options.reverse_merge(:options => "ENGINE=InnoDB")
        table_definition = MysqlTableDefinition.new(self)
        table_definition.primary_key(options[:primary_key] || Base.get_primary_key(table_name)) unless options[:id] == false

        yield table_definition

        if options[:force] && table_exists?(table_name)
          drop_table(table_name, options)
        end

        create_sql = "CREATE#{' TEMPORARY' if options[:temporary]} TABLE "
        create_sql << "#{quote_table_name(table_name)} ("
        create_sql << table_definition.to_sql
        create_sql << ") #{options[:options]}"
        execute create_sql
      end

      def change_table(table_name)
        yield MysqlTable.new(table_name, self)
      end
    end
  end
end

Adding an :unsigned => true option

This was the tricky bit, here’s the code:

module ActiveRecord
  module ConnectionAdapters

    class MysqlAdapter
      def type_to_sql_with_unsigned(type, limit = nil, precision = nil, scale = nil, unsigned = false)
        returning type_to_sql_without_unsigned(type, limit, precision, scale) do |sql|
          sql << ' UNSIGNED' if unsigned && type.to_s == 'integer'
        end
      end
      alias_method_chain :type_to_sql, :unsigned

      def add_column(table_name, column_name, type, options = {})
        add_column_sql = "ALTER TABLE #{quote_table_name(table_name)} ADD #{quote_column_name(column_name)} #{type_to_sql(type, options[:limit], options[:precision], options[:scale], options[:unsigned])}"
        add_column_options!(add_column_sql, options)
        execute(add_column_sql)
      end

      def change_column(table_name, column_name, type, options = {})
        unless options_include_default?(options)
          if column = columns(table_name).find { |c| c.name == column_name.to_s }
            options[:default] = column.default
          else
            raise "No such column: #{table_name}.#{column_name}"
          end
        end

        change_column_sql = "ALTER TABLE #{quote_table_name(table_name)} CHANGE #{quote_column_name(column_name)} #{quote_column_name(column_name)} #{type_to_sql(type, options[:limit], options[:precision], options[:scale], options[:unsigned])}"
        add_column_options!(change_column_sql, options)
        execute(change_column_sql)
      end
    end

    class Column
      attr_reader :unsigned

      def initialize_with_unsigned(name, default, sql_type = nil, null = true)
        initialize_without_unsigned(name, default, sql_type, null)
        @unsigned = extract_unsigned(sql_type)
      end
      alias_method_chain :initialize, :unsigned

      def extract_unsigned(sql_type)
        false
      end
    end

    class MysqlColumn
      def extract_unsigned(sql_type)
        sql_type =~ /unsigned/ ? true : false
      end
    end

    class ColumnDefinition
      attr_accessor :unsigned

      def sql_type
        base.type_to_sql(type.to_sym, limit, precision, scale, unsigned) rescue type
      end
    end

    class Table
      def integer(*args)
        options = args.extract_options!
        column_names = args

        column_names.each do |name|
          column = ColumnDefinition.new(@base, name, 'integer')
          if options[:limit]
            column.limit = options[:limit]
          elsif native[:integer].is_a?(Hash)
            column.limit = native[:integer][:limit]
          end
          column.precision = options[:precision]
          column.scale = options[:scale]
          column.default = options[:default]
          column.null = options[:null]
          column.unsigned = options[:unsigned]
          @base.add_column(@table_name, name, column.sql_type, options)
        end
      end
    end

    class TableDefinition
      def column_with_unsigned(name, type, options = {})
        column_without_unsigned(name, type, options)[name].unsigned = options[:unsigned]
      end
      alias_method_chain :column, :unsigned
    end

  end
end

This adds the :unsigned option (which defaults to false) to migration files so that you can do things like this:

create_table :suppliers do |t|
  t.integer :category_id, :null => true, :unsigned => true
  t.column :region_id, :integer, :unsigned => true, :limit => 2
end

It works with ‘sexy’ syntax as well as the new :limit handling (where Rails now selects a more appropriate integer type, such as MEDIUMINT or BIGINT, depending on the :limit option) and you can also use it in add_column and change_column like this:

add_column :suppliers, :category_id, :integer, :unsigned => true
change_column :suppliers, :category_id, :integer, :unsigned => false

The good news is that by using the unsigned option your migrations are still database agnostic: the option is only used by the MySQL adapter and will be ignored by the others.

Don’t forget SchemaDumper!

The final piece of the puzzle is the SchemaDumper class which is responsible for generating the schema.rb file - it also needs to know about unsigned fields so that it can generate the correct output. Some of the code is already implemented above, for example the Column and MysqlColumn classes have been extended to understand unsigned integers). It is also necessary to update the SchemaDumper class:

module ActiveRecord
  class SchemaDumper
    def table(table, stream)
      columns = @connection.columns(table)
      begin
        tbl = StringIO.new

        if @connection.respond_to?(:pk_and_sequence_for)
          pk, pk_seq = @connection.pk_and_sequence_for(table)
        end
        pk ||= 'id'

        tbl.print " create_table #{table.inspect}"
        if columns.detect { |c| c.name == pk }
          if pk != 'id'
            tbl.print %Q(, :primary_key => "#{pk}")
          end
        else
          tbl.print ", :id => false"
        end
        tbl.print ", :force => true"
        tbl.puts " do |t|"

        column_specs = columns.map do |column|
          raise StandardError, "Unknown type '#{column.sql_type}' for column '#{column.name}'" if @types[column.type].nil?
          next if column.name == pk
          spec = {}
          spec[:name] = column.name.inspect
          spec[:type] = column.type.to_s
          spec[:limit] = column.limit.inspect if column.limit != @types[column.type][:limit] && column.type != :decimal
          spec[:precision] = column.precision.inspect if !column.precision.nil?
          spec[:scale] = column.scale.inspect if !column.scale.nil?
          spec[:null] = 'false' if !column.null
          spec[:default] = default_string(column.default) if !column.default.nil?
          spec[:unsigned] = 'true' if column.unsigned == true
          (spec.keys - [:name, :type]).each{ |k| spec[k].insert(0, "#{k.inspect} => ")}
          spec
        end.compact

        # find all migration keys used in this table
        keys = [:name, :limit, :precision, :scale, :default, :null, :unsigned] & column_specs.map(&:keys).flatten

        # figure out the lengths for each column based on above keys
        lengths = keys.map{ |key| column_specs.map{ |spec| spec[key] ? spec[key].length + 2 : 0 }.max }

        # the string we're going to sprintf our values against, with standardized column widths
        format_string = lengths.map{ |len| "%-#{len}s" }

        # find the max length for the 'type' column, which is special
        type_length = column_specs.map{ |column| column[:type].length }.max

        # add column type definition to our format string
        format_string.unshift " t.%-#{type_length}s "

        format_string *= ''

        column_specs.each do |colspec|
          values = keys.zip(lengths).map{ |key, len| colspec.key?(key) ? colspec[key] + ", " : " " * len }
          values.unshift colspec[:type]
          tbl.print((format_string % values).gsub(/,\s*$/, ''))
          tbl.puts
        end

        tbl.puts " end"
        tbl.puts

        indexes(table, tbl)

        tbl.rewind
        stream.print tbl.read
      rescue => e
        stream.puts "# Could not dump table #{table.inspect} because of following #{e.class}"
        stream.puts "# #{e.message}"
        stream.puts
      end

      stream
    end
  end
end

Unfortunately I couldn’t see a tidier way of doing this when monkey-patching other than copying, pasting and editing the original method. There are actually only two changes:

spec[:unsigned] = 'true' if column.unsigned == true

and

keys = [:name, :limit, :precision, :scale, :default, :null, :unsigned] & column_specs.map(&:keys).flatten

A patch for Rails

I’ve also submitted a patch that I’ve tested with MySQL and SQLite3 so please take a look, let me know if you’ve got any suggestions on how these changes could be made better and maybe then they’ll be good enough to make it into Rails 2.2!

You can find the Rails-core discussion of the patch here.

Updated on 07 February 2019
First published by Rob Anderton on 02 June 2008
© Rob Anderton 2019
"Unsigned integers for MySQL on Rails" by Rob Anderton at TheWebFellas is licensed under a Creative Commons Attribution 4.0 International License.