Skip to content

Commit

Permalink
Merge pull request #19978 from kamipo/collation_option_support_for_po…
Browse files Browse the repository at this point in the history
…stgresql

PostgreSQL: `:collation` support for string and text columns
  • Loading branch information
rafaelfranca committed May 3, 2015
2 parents 03b0911 + f8e748b commit 1515c4d
Show file tree
Hide file tree
Showing 10 changed files with 116 additions and 32 deletions.
11 changes: 11 additions & 0 deletions activerecord/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
* PostgreSQL: `:collation` support for string and text columns.

Example:

create_table :foos do |t|
t.string :string_en, collation: 'en_US.UTF-8'
t.text :text_ja, collation: 'ja_JP.UTF-8'
end

*Ryuta Kamizono*

* Make `unscope` aware of "less than" and "greater than" conditions.

*TAKAHASHI Kazuaki*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def column_options(o)
column_options[:after] = o.after
column_options[:auto_increment] = o.auto_increment
column_options[:primary_key] = o.primary_key
column_options[:collation] = o.collation
column_options
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class IndexDefinition < Struct.new(:table, :name, :unique, :columns, :lengths, :
# are typically created by methods in TableDefinition, and added to the
# +columns+ attribute of said TableDefinition object, in order to be used
# for generating a number of table creation or table changing SQL statements.
class ColumnDefinition < Struct.new(:name, :type, :limit, :precision, :scale, :default, :null, :first, :after, :auto_increment, :primary_key, :sql_type) #:nodoc:
class ColumnDefinition < Struct.new(:name, :type, :limit, :precision, :scale, :default, :null, :first, :after, :auto_increment, :primary_key, :collation, :sql_type) #:nodoc:

def primary_key?
primary_key || type.to_sym == :primary_key
Expand Down Expand Up @@ -437,6 +437,7 @@ def new_column_definition(name, type, options) # :nodoc:
column.after = options[:after]
column.auto_increment = options[:auto_increment]
column.primary_key = type == :primary_key || options[:primary_key]
column.collation = options[:collation]
column
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,16 @@ def prepare_column_options(column)
default = schema_default(column) if column.has_default?
spec[:default] = default unless default.nil?

if collation = schema_collation(column)
spec[:collation] = collation
end

spec
end

# Lists the valid migration options
def migration_keys
[:name, :limit, :precision, :scale, :default, :null]
[:name, :limit, :precision, :scale, :default, :null, :collation]
end

private
Expand All @@ -56,6 +60,10 @@ def schema_default(column)
type.type_cast_for_schema(default)
end
end

def schema_collation(column)
column.collation.inspect if column.collation
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,8 @@ def type_map # :nodoc:
end
end

def new_column(name, default, sql_type_metadata = nil, null = true)
Column.new(name, default, sql_type_metadata, null)
def new_column(name, default, sql_type_metadata = nil, null = true, default_function = nil, collation = nil)
Column.new(name, default, sql_type_metadata, null, default_function, collation)
end

def lookup_cast_type(sql_type) # :nodoc:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def primary_key(name, type = :primary_key, **options)
end

class ColumnDefinition < ActiveRecord::ConnectionAdapters::ColumnDefinition
attr_accessor :charset, :collation
attr_accessor :charset
end

class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition
Expand All @@ -28,7 +28,6 @@ def new_column_definition(name, type, options) # :nodoc:
column.auto_increment = true
end
column.charset = options[:charset]
column.collation = options[:collation]
column
end

Expand Down Expand Up @@ -75,7 +74,6 @@ def visit_ChangeColumnDefinition(o)
def column_options(o)
column_options = super
column_options[:charset] = o.charset
column_options[:collation] = o.collation
column_options
end

Expand Down Expand Up @@ -128,20 +126,20 @@ def prepare_column_options(column)
spec = super
spec.delete(:precision) if /time/ === column.sql_type && column.precision == 0
spec.delete(:limit) if :boolean === column.type
spec
end

def schema_collation(column)
if column.collation && table_name = column.instance_variable_get(:@table_name)
@collation_cache ||= {}
@collation_cache[table_name] ||= select_one("SHOW TABLE STATUS LIKE '#{table_name}'")["Collation"]
spec[:collation] = column.collation.inspect if column.collation != @collation_cache[table_name]
column.collation.inspect if column.collation != @collation_cache[table_name]
end
spec
end

def migration_keys
super + [:collation]
end
private :schema_collation

class Column < ConnectionAdapters::Column # :nodoc:
delegate :strict, :collation, :extra, to: :sql_type_metadata, allow_nil: true
delegate :strict, :extra, to: :sql_type_metadata, allow_nil: true

def initialize(*)
super
Expand Down Expand Up @@ -195,12 +193,11 @@ def assert_valid_default(default)
end

class MysqlTypeMetadata < DelegateClass(SqlTypeMetadata) # :nodoc:
attr_reader :collation, :extra, :strict
attr_reader :extra, :strict

def initialize(type_metadata, collation: "", extra: "", strict: false)
def initialize(type_metadata, extra: "", strict: false)
super(type_metadata)
@type_metadata = type_metadata
@collation = collation
@extra = extra
@strict = strict
end
Expand All @@ -218,7 +215,7 @@ def hash
protected

def attributes_for_hash
[self.class, @type_metadata, collation, extra, strict]
[self.class, @type_metadata, extra, strict]
end
end

Expand Down Expand Up @@ -342,8 +339,8 @@ def each_hash(result) # :nodoc:
raise NotImplementedError
end

def new_column(field, default, sql_type_metadata = nil, null = true) # :nodoc:
Column.new(field, default, sql_type_metadata, null)
def new_column(field, default, sql_type_metadata = nil, null = true, default_function = nil, collation = nil) # :nodoc:
Column.new(field, default, sql_type_metadata, null, default_function, collation)
end

# Must return the MySQL error number from the exception, if the exception has an
Expand Down Expand Up @@ -566,8 +563,8 @@ def columns(table_name)#:nodoc:
each_hash(result).map do |field|
field_name = set_field_encoding(field[:Field])
sql_type = field[:Type]
type_metadata = fetch_type_metadata(sql_type, field[:Collation], field[:Extra])
new_column(field_name, field[:Default], type_metadata, field[:Null] == "YES")
type_metadata = fetch_type_metadata(sql_type, field[:Extra])
new_column(field_name, field[:Default], type_metadata, field[:Null] == "YES", nil, field[:Collation])
end
end
end
Expand Down Expand Up @@ -826,8 +823,8 @@ def extract_precision(sql_type)
end
end

def fetch_type_metadata(sql_type, collation = "", extra = "")
MysqlTypeMetadata.new(super(sql_type), collation: collation, extra: extra, strict: strict_mode?)
def fetch_type_metadata(sql_type, extra = "")
MysqlTypeMetadata.new(super(sql_type), extra: extra, strict: strict_mode?)
end

# MySQL is too stupid to create a temporary table for use subquery, so we have
Expand Down
7 changes: 4 additions & 3 deletions activerecord/lib/active_record/connection_adapters/column.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module Format
ISO_DATETIME = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(\.\d+)?\z/
end

attr_reader :name, :null, :sql_type_metadata, :default, :default_function
attr_reader :name, :null, :sql_type_metadata, :default, :default_function, :collation

delegate :precision, :scale, :limit, :type, :sql_type, to: :sql_type_metadata, allow_nil: true

Expand All @@ -22,12 +22,13 @@ module Format
# +default+ is the type-casted default value, such as +new+ in <tt>sales_stage varchar(20) default 'new'</tt>.
# +sql_type_metadata+ is various information about the type of the column
# +null+ determines if this column allows +NULL+ values.
def initialize(name, default, sql_type_metadata = nil, null = true, default_function = nil)
def initialize(name, default, sql_type_metadata = nil, null = true, default_function = nil, collation = nil)
@name = name
@sql_type_metadata = sql_type_metadata
@null = null
@default = default
@default_function = default_function
@collation = collation
@table_name = nil
end

Expand Down Expand Up @@ -60,7 +61,7 @@ def hash
protected

def attributes_for_hash
[self.class, name, default, sql_type_metadata, null, default_function]
[self.class, name, default, sql_type_metadata, null, default_function, collation]
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ def visit_ColumnDefinition(o)
o.sql_type = type_to_sql(o.type, o.limit, o.precision, o.scale, o.array)
super
end

def add_column_options!(sql, options)
if options[:collation]
sql << " COLLATE \"#{options[:collation]}\""
end
super
end
end

module SchemaStatements
Expand Down Expand Up @@ -159,18 +166,18 @@ def indexes(table_name, name = nil)
# Returns the list of all column definitions for a table.
def columns(table_name)
# Limit, precision, and scale are all handled by the superclass.
column_definitions(table_name).map do |column_name, type, default, notnull, oid, fmod|
column_definitions(table_name).map do |column_name, type, default, notnull, oid, fmod, collation|
oid = oid.to_i
fmod = fmod.to_i
type_metadata = fetch_type_metadata(column_name, type, oid, fmod)
default_value = extract_value_from_default(default)
default_function = extract_default_function(default_value, default)
new_column(column_name, default_value, type_metadata, !notnull, default_function)
new_column(column_name, default_value, type_metadata, !notnull, default_function, collation)
end
end

def new_column(name, default, sql_type_metadata = nil, null = true, default_function = nil) # :nodoc:
PostgreSQLColumn.new(name, default, sql_type_metadata, null, default_function)
def new_column(name, default, sql_type_metadata = nil, null = true, default_function = nil, collation = nil) # :nodoc:
PostgreSQLColumn.new(name, default, sql_type_metadata, null, default_function, collation)
end

# Returns the current database name.
Expand Down Expand Up @@ -409,6 +416,9 @@ def change_column(table_name, column_name, type, options = {}) #:nodoc:
quoted_column_name = quote_column_name(column_name)
sql_type = type_to_sql(type, options[:limit], options[:precision], options[:scale], options[:array])
sql = "ALTER TABLE #{quoted_table_name} ALTER COLUMN #{quoted_column_name} TYPE #{sql_type}"
if options[:collation]
sql << " COLLATE \"#{options[:collation]}\""
end
if options[:using]
sql << " USING #{options[:using]}"
elsif options[:cast_as]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -772,7 +772,9 @@ def last_insert_id_result(sequence_name) #:nodoc:
def column_definitions(table_name) # :nodoc:
exec_query(<<-end_sql, 'SCHEMA').rows
SELECT a.attname, format_type(a.atttypid, a.atttypmod),
pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod
pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod,
(SELECT c.collname FROM pg_collation c, pg_type t
WHERE c.oid = a.attcollation AND t.oid = a.atttypid AND a.attcollation <> t.typcollation)
FROM pg_attribute a LEFT JOIN pg_attrdef d
ON a.attrelid = d.adrelid AND a.attnum = d.adnum
WHERE a.attrelid = '#{quote_table_name(table_name)}'::regclass
Expand Down
53 changes: 53 additions & 0 deletions activerecord/test/cases/adapters/postgresql/collation_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
require "cases/helper"
require 'support/schema_dumping_helper'

class PostgresqlCollationTest < ActiveRecord::TestCase
include SchemaDumpingHelper

def setup
@connection = ActiveRecord::Base.connection
@connection.create_table :postgresql_collations, force: true do |t|
t.string :string_c, collation: 'C'
t.text :text_posix, collation: 'POSIX'
end
end

def teardown
@connection.drop_table :postgresql_collations, if_exists: true
end

test "string column with collation" do
column = @connection.columns(:postgresql_collations).find { |c| c.name == 'string_c' }
assert_equal :string, column.type
assert_equal 'C', column.collation
end

test "text column with collation" do
column = @connection.columns(:postgresql_collations).find { |c| c.name == 'text_posix' }
assert_equal :text, column.type
assert_equal 'POSIX', column.collation
end

test "add column with collation" do
@connection.add_column :postgresql_collations, :title, :string, collation: 'C'

column = @connection.columns(:postgresql_collations).find { |c| c.name == 'title' }
assert_equal :string, column.type
assert_equal 'C', column.collation
end

test "change column with collation" do
@connection.add_column :postgresql_collations, :description, :string
@connection.change_column :postgresql_collations, :description, :text, collation: 'POSIX'

column = @connection.columns(:postgresql_collations).find { |c| c.name == 'description' }
assert_equal :text, column.type
assert_equal 'POSIX', column.collation
end

test "schema dump includes collation" do
output = dump_table_schema("postgresql_collations")
assert_match %r{t.string\s+"string_c",\s+collation: "C"$}, output
assert_match %r{t.text\s+"text_posix",\s+collation: "POSIX"$}, output
end
end

0 comments on commit 1515c4d

Please sign in to comment.