提交 65c33009 编写于 作者: S Sean Griffin

Add a public API to allow users to specify column types

As a result of all of the refactoring that's been done, it's now
possible for us to define a public API to allow users to specify
behavior. This is an initial implementation so that I can work off of it
in smaller pieces for additional features/refactorings.

The current behavior will continue to stay the same, though I'd like to
refactor towards the automatic schema detection being built off of this
API, and add the ability to opt out of automatic schema detection.

Use cases:

- We can deprecate a lot of the edge cases around types, now that there
  is an alternate path for users who wish to maintain the same behavior.
- I intend to refactor serialized columns to be built on top of this
  API.
- Gem and library maintainers are able to interact with `ActiveRecord`
  at a slightly lower level in a more stable way.
- Interesting ability to reverse the work flow of adding to the schema.
  Model can become the single source of truth for the structure. We can
  compare that to what the database says the schema is, diff them, and
  generate a migration.
上级 9a6ed049
* Add a properties API to allow custom types and type casting behavior
to be specified. Will enable many edge cases to be deprecated, and
allow for additional interesting features in the future.
*Sean Griffin*
* Fix has_and_belongs_to_many public reflection.
When defining a has_and_belongs_to_many, internally we convert that to two has_many.
But as `reflections` is a public API, people expect to see the right macro.
......
......@@ -19,6 +19,7 @@
require 'active_record/log_subscriber'
require 'active_record/explain_subscriber'
require 'active_record/relation/delegation'
require 'active_record/properties'
module ActiveRecord #:nodoc:
# = Active Record
......@@ -321,6 +322,7 @@ class Base
include Reflection
include Serialization
include Store
include Properties
end
ActiveSupport.run_load_hooks(:active_record, Base)
......
module ActiveRecord
module ConnectionAdapters
module Type
class Binary < Value # :nodoc:
class Binary < Value
def type
:binary
end
......
module ActiveRecord
module ConnectionAdapters
module Type
class Boolean < Value # :nodoc:
class Boolean < Value
def type
:boolean
end
......
module ActiveRecord
module ConnectionAdapters
module Type
class Date < Value # :nodoc:
class Date < Value
def type
:date
end
......
module ActiveRecord
module ConnectionAdapters
module Type
class DateTime < Value # :nodoc:
class DateTime < Value
include TimeValue
def type
......
module ActiveRecord
module ConnectionAdapters
module Type
class Decimal < Value # :nodoc:
class Decimal < Value
include Numeric
def type
......
module ActiveRecord
module ConnectionAdapters
module Type
class Float < Value # :nodoc:
class Float < Value
include Numeric
def type
......
module ActiveRecord
module ConnectionAdapters
module Type
class Integer < Value # :nodoc:
class Integer < Value
include Numeric
def type
......
module ActiveRecord
module ConnectionAdapters
module Type
class String < Value # :nodoc:
class String < Value
def type
:string
end
......
......@@ -3,7 +3,7 @@
module ActiveRecord
module ConnectionAdapters
module Type
class Text < String # :nodoc:
class Text < String
def type
:text
end
......
module ActiveRecord
module ConnectionAdapters
module Type
class Time < Value # :nodoc:
class Time < Value
include TimeValue
def type
......
module ActiveRecord
module ConnectionAdapters
module Type
class Value # :nodoc:
class Value
attr_reader :precision, :scale, :limit
# Valid options are +precision+, +scale+, and +limit+.
# They are only used when dumping schema.
def initialize(options = {})
options.assert_valid_keys(:precision, :scale, :limit)
@precision = options[:precision]
......@@ -11,8 +13,13 @@ def initialize(options = {})
@limit = options[:limit]
end
# The simplified that this object represents. Subclasses
# should override this method.
def type; end
# Takes an input from the database, or from attribute setters,
# and casts it to a type appropriate for this object. This method
# should not be overriden by subclasses. Instead, override `cast_value`.
def type_cast(value)
cast_value(value) unless value.nil?
end
......@@ -43,7 +50,9 @@ def klass
private
def cast_value(value)
# Responsible for casting values from external sources to the appropriate
# type. Called by `type_cast` for all values except `nil`.
def cast_value(value) # :api: public
value
end
end
......
......@@ -217,16 +217,6 @@ def table_exists?
connection.schema_cache.table_exists?(table_name)
end
# Returns an array of column objects for the table associated with this class.
def columns
connection.schema_cache.columns(table_name)
end
# Returns a hash of column objects for the table associated with this class.
def columns_hash
connection.schema_cache.columns_hash(table_name)
end
def column_types # :nodoc:
@column_types ||= decorate_columns(columns_hash.dup)
end
......
module ActiveRecord
module Properties
extend ActiveSupport::Concern
Type = ConnectionAdapters::Type
module ClassMethods
# Defines or overrides a property on this model. This allows customization of
# Active Record's type casting behavior, as well as adding support for user defined
# types.
#
# ==== Examples
#
# The type detected by Active Record can be overriden.
#
# # db/schema.rb
# create_table :store_listings, force: true do |t|
# t.decimal :price_in_cents
# end
#
# # app/models/store_listing.rb
# class StoreListing < ActiveRecord::Base
# end
#
# store_listing = StoreListing.new(price_in_cents: '10.1')
#
# # before
# store_listing.price_in_cents # => BigDecimal.new(10.1)
#
# class StoreListing < ActiveRecord::Base
# property :price_in_cents, Type::Integer.new
# end
#
# # after
# store_listing.price_in_cents # => 10
#
# Users may also define their own custom types, as long as they respond to the methods
# defined on the value type. The `type_cast` method on your type object will be called
# with values both from the database, and from your controllers. See
# `ActiveRecord::Properties::Type::Value` for the expected API. It is recommended that your
# type objects inherit from an existing type, or the base value type.
#
# class MoneyType < ActiveRecord::Type::Integer
# def type_cast(value)
# if value.include?('$')
# price_in_dollars = value.gsub(/\$/, '').to_f
# price_in_dollars * 100
# else
# value.to_i
# end
# end
# end
#
# class StoreListing < ActiveRecord::Base
# property :price_in_cents, MoneyType.new
# end
#
# store_listing = StoreListing.new(price_in_cents: '$10.00')
# store_listing.price_in_cents # => 1000
def property(name, cast_type)
name = name.to_s
user_provided_columns[name] = ConnectionAdapters::Column.new(name, nil, cast_type)
end
# Returns an array of column objects for the table associated with this class.
def columns
@columns ||= add_user_provided_columns(connection.schema_cache.columns(table_name))
end
# Returns a hash of column objects for the table associated with this class.
def columns_hash
@columns_hash ||= Hash[columns.map { |c| [c.name, c] }]
end
def reset_column_information # :nodoc:
super
@columns = nil
@columns_hash = nil
end
private
def user_provided_columns
@user_provided_columns ||= {}
end
def add_user_provided_columns(schema_columns)
schema_columns.reject { |column|
user_provided_columns.key? column.name
} + user_provided_columns.values
end
end
end
end
require 'cases/helper'
class OverloadedType < ActiveRecord::Base
property :overloaded_float, Type::Integer.new
property :overloaded_string_with_limit, Type::String.new(limit: 50)
property :non_existent_decimal, Type::Decimal.new
end
class UnoverloadedType < ActiveRecord::Base
self.table_name = 'overloaded_types'
end
module ActiveRecord
class CustomPropertiesTest < ActiveRecord::TestCase
def test_overloading_types
data = OverloadedType.new
data.overloaded_float = "1.1"
data.unoverloaded_float = "1.1"
assert_equal 1, data.overloaded_float
assert_equal 1.1, data.unoverloaded_float
end
def test_overloaded_properties_save
data = OverloadedType.new
data.overloaded_float = "2.2"
data.save!
data.reload
assert_equal 2, data.overloaded_float
assert_equal 2.0, UnoverloadedType.last.overloaded_float
end
def test_properties_assigned_in_constructor
data = OverloadedType.new(overloaded_float: '3.3')
assert_equal 3, data.overloaded_float
end
def test_overloaded_properties_with_limit
assert_equal 50, OverloadedType.columns_hash['overloaded_string_with_limit'].limit
assert_equal 255, UnoverloadedType.columns_hash['overloaded_string_with_limit'].limit
end
def test_nonexistent_property
data = OverloadedType.new(non_existent_decimal: 1)
assert_equal BigDecimal.new(1), data.non_existent_decimal
assert_raise ActiveRecord::UnknownAttributeError do
UnoverloadedType.new(non_existent_decimal: 1)
end
end
def test_overloaded_properties_have_no_default
data = OverloadedType.new
unoverloaded_data = UnoverloadedType.new
assert_nil data.overloaded_float
assert unoverloaded_data.overloaded_float
end
end
end
......@@ -855,6 +855,12 @@ def except(adapter_names_to_exclude)
execute "ALTER TABLE lessons_students ADD CONSTRAINT student_id_fk FOREIGN KEY (#{quote_column_name 'student_id'}) REFERENCES #{quote_table_name 'students'} (#{quote_column_name 'id'})"
end
create_table :overloaded_types, force: true do |t|
t.float :overloaded_float, default: 500
t.float :unoverloaded_float
t.string :overloaded_string_with_limit, limit: 255
end
end
Course.connection.create_table :courses, force: true do |t|
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册