diff options
-rw-r--r-- | activerecord/lib/active_record/type.rb | 25 | ||||
-rw-r--r-- | activerecord/lib/active_record/type/adapter_specific_registry.rb | 139 | ||||
-rw-r--r-- | activerecord/test/cases/type/adapter_specific_registry_test.rb | 133 | ||||
-rw-r--r-- | activerecord/test/cases/type_test.rb | 39 |
4 files changed, 336 insertions, 0 deletions
diff --git a/activerecord/lib/active_record/type.rb b/activerecord/lib/active_record/type.rb index f18b076d58..7ce0370d8b 100644 --- a/activerecord/lib/active_record/type.rb +++ b/activerecord/lib/active_record/type.rb @@ -16,5 +16,30 @@ require 'active_record/type/text' require 'active_record/type/time' require 'active_record/type/unsigned_integer' +require 'active_record/type/adapter_specific_registry' require 'active_record/type/type_map' require 'active_record/type/hash_lookup_type_map' + +module ActiveRecord + module Type + @registry = AdapterSpecificRegistry.new + + class << self + attr_accessor :registry # :nodoc: + + def register(*args) + registry.register(*args) + end + + def lookup(*args, adapter: current_adapter_name, **kwargs) + registry.lookup(*args, adapter: adapter, **kwargs) + end + + private + + def current_adapter_name + ActiveRecord::Base.connection.adapter_name.downcase.to_sym + end + end + end +end diff --git a/activerecord/lib/active_record/type/adapter_specific_registry.rb b/activerecord/lib/active_record/type/adapter_specific_registry.rb new file mode 100644 index 0000000000..ce9ff6a557 --- /dev/null +++ b/activerecord/lib/active_record/type/adapter_specific_registry.rb @@ -0,0 +1,139 @@ +module ActiveRecord + module Type + class AdapterSpecificRegistry # :nodoc: + def initialize + @registrations = [] + end + + def register(type_name, klass = nil, **options, &block) + block ||= proc { |_, *args| klass.new(*args) } + registrations << Registration.new(type_name, block, **options) + end + + def lookup(symbol, *args) + registration = registrations + .select { |r| r.matches?(symbol, *args) } + .max + + if registration + registration.call(self, symbol, *args) + else + raise ArgumentError, "Unknown type #{symbol.inspect}" + end + end + + def add_modifier(options, klass, **args) + registrations << DecorationRegistration.new(options, klass, **args) + end + + protected + + attr_reader :registrations + end + + class Registration + def initialize(name, block, adapter: nil, override: nil) + @name = name + @block = block + @adapter = adapter + @override = override + end + + def call(_registry, *args, adapter: nil, **kwargs) + if kwargs.any? # https://bugs.ruby-lang.org/issues/10856 + block.call(*args, **kwargs) + else + block.call(*args) + end + end + + def matches?(type_name, *args, **kwargs) + type_name == name && matches_adapter?(**kwargs) + end + + def <=>(other) + if conflicts_with?(other) + raise TypeConflictError.new("Type #{name} was registered for all + adapters, but shadows a native type with + the same name for #{other.adapter}".squish) + end + priority <=> other.priority + end + + protected + + attr_reader :name, :block, :adapter, :override + + def priority + result = 0 + if adapter + result |= 1 + end + if override + result |= 2 + end + result + end + + def priority_except_adapter + priority & 0b111111100 + end + + private + + def matches_adapter?(adapter: nil, **) + (self.adapter.nil? || adapter == self.adapter) + end + + def conflicts_with?(other) + same_priority_except_adapter?(other) && + has_adapter_conflict?(other) + end + + def same_priority_except_adapter?(other) + priority_except_adapter == other.priority_except_adapter + end + + def has_adapter_conflict?(other) + (override.nil? && other.adapter) || + (adapter && other.override.nil?) + end + end + + class DecorationRegistration < Registration + def initialize(options, klass, adapter: nil) + @options = options + @klass = klass + @adapter = adapter + end + + def call(registry, *args, **kwargs) + subtype = registry.lookup(*args, **kwargs.except(*options.keys)) + klass.new(subtype) + end + + def matches?(*args, **kwargs) + matches_adapter?(**kwargs) && matches_options?(**kwargs) + end + + def priority + super | 4 + end + + protected + + attr_reader :options, :klass + + private + + def matches_options?(**kwargs) + options.all? do |key, value| + kwargs[key] == value + end + end + end + end + + class TypeConflictError < StandardError + end +end diff --git a/activerecord/test/cases/type/adapter_specific_registry_test.rb b/activerecord/test/cases/type/adapter_specific_registry_test.rb new file mode 100644 index 0000000000..8b836b4793 --- /dev/null +++ b/activerecord/test/cases/type/adapter_specific_registry_test.rb @@ -0,0 +1,133 @@ +require "cases/helper" + +module ActiveRecord + class AdapterSpecificRegistryTest < ActiveRecord::TestCase + test "a class can be registered for a symbol" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, ::String) + registry.register(:bar, ::Array) + + assert_equal "", registry.lookup(:foo) + assert_equal [], registry.lookup(:bar) + end + + test "a block can be registered" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo) do |*args| + [*args, "block for foo"] + end + registry.register(:bar) do |*args| + [*args, "block for bar"] + end + + assert_equal [:foo, 1, "block for foo"], registry.lookup(:foo, 1) + assert_equal [:foo, 2, "block for foo"], registry.lookup(:foo, 2) + assert_equal [:bar, 1, 2, 3, "block for bar"], registry.lookup(:bar, 1, 2, 3) + end + + test "filtering by adapter" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String, adapter: :sqlite3) + registry.register(:foo, Array, adapter: :postgresql) + + assert_equal "", registry.lookup(:foo, adapter: :sqlite3) + assert_equal [], registry.lookup(:foo, adapter: :postgresql) + end + + test "an error is raised if both a generic and adapter specific type match" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String) + registry.register(:foo, Array, adapter: :postgresql) + + assert_raises TypeConflictError do + registry.lookup(:foo, adapter: :postgresql) + end + assert_equal "", registry.lookup(:foo, adapter: :sqlite3) + end + + test "a generic type can explicitly override an adapter specific type" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String, override: true) + registry.register(:foo, Array, adapter: :postgresql) + + assert_equal "", registry.lookup(:foo, adapter: :postgresql) + assert_equal "", registry.lookup(:foo, adapter: :sqlite3) + end + + test "a generic type can explicitly allow an adapter type to be used instead" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String, override: false) + registry.register(:foo, Array, adapter: :postgresql) + + assert_equal [], registry.lookup(:foo, adapter: :postgresql) + assert_equal "", registry.lookup(:foo, adapter: :sqlite3) + end + + test "a reasonable error is given when no type is found" do + registry = Type::AdapterSpecificRegistry.new + + e = assert_raises(ArgumentError) do + registry.lookup(:foo) + end + + assert_equal "Unknown type :foo", e.message + end + + test "construct args are passed to the type" do + type = Struct.new(:args) + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, type) + + assert_equal type.new, registry.lookup(:foo) + assert_equal type.new(:ordered_arg), registry.lookup(:foo, :ordered_arg) + assert_equal type.new(keyword: :arg), registry.lookup(:foo, keyword: :arg) + assert_equal type.new(keyword: :arg), registry.lookup(:foo, keyword: :arg, adapter: :postgresql) + end + + test "registering a modifier" do + decoration = Struct.new(:value) + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String) + registry.register(:bar, Hash) + registry.add_modifier({ array: true }, decoration) + + assert_equal decoration.new(""), registry.lookup(:foo, array: true) + assert_equal decoration.new({}), registry.lookup(:bar, array: true) + assert_equal "", registry.lookup(:foo) + end + + test "registering multiple modifiers" do + decoration = Struct.new(:value) + other_decoration = Struct.new(:value) + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String) + registry.add_modifier({ array: true }, decoration) + registry.add_modifier({ range: true }, other_decoration) + + assert_equal "", registry.lookup(:foo) + assert_equal decoration.new(""), registry.lookup(:foo, array: true) + assert_equal other_decoration.new(""), registry.lookup(:foo, range: true) + assert_equal( + decoration.new(other_decoration.new("")), + registry.lookup(:foo, array: true, range: true) + ) + end + + test "registering adapter specific modifiers" do + decoration = Struct.new(:value) + type = Struct.new(:args) + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, type) + registry.add_modifier({ array: true }, decoration, adapter: :postgresql) + + assert_equal( + decoration.new(type.new(keyword: :arg)), + registry.lookup(:foo, array: true, adapter: :postgresql, keyword: :arg) + ) + assert_equal( + type.new(array: true), + registry.lookup(:foo, array: true, adapter: :sqlite3) + ) + end + end +end diff --git a/activerecord/test/cases/type_test.rb b/activerecord/test/cases/type_test.rb new file mode 100644 index 0000000000..d45a9b3141 --- /dev/null +++ b/activerecord/test/cases/type_test.rb @@ -0,0 +1,39 @@ +require "cases/helper" + +class TypeTest < ActiveRecord::TestCase + setup do + @old_registry = ActiveRecord::Type.registry + ActiveRecord::Type.registry = ActiveRecord::Type::AdapterSpecificRegistry.new + end + + teardown do + ActiveRecord::Type.registry = @old_registry + end + + test "registering a new type" do + type = Struct.new(:args) + ActiveRecord::Type.register(:foo, type) + + assert_equal type.new(:arg), ActiveRecord::Type.lookup(:foo, :arg) + end + + test "looking up a type for a specific adapter" do + type = Struct.new(:args) + pgtype = Struct.new(:args) + ActiveRecord::Type.register(:foo, type, override: false) + ActiveRecord::Type.register(:foo, pgtype, adapter: :postgresql) + + assert_equal type.new, ActiveRecord::Type.lookup(:foo, adapter: :sqlite) + assert_equal pgtype.new, ActiveRecord::Type.lookup(:foo, adapter: :postgresql) + end + + test "lookup defaults to the current adapter" do + current_adapter = ActiveRecord::Base.connection.adapter_name.downcase.to_sym + type = Struct.new(:args) + adapter_type = Struct.new(:args) + ActiveRecord::Type.register(:foo, type, override: false) + ActiveRecord::Type.register(:foo, adapter_type, adapter: current_adapter) + + assert_equal adapter_type.new, ActiveRecord::Type.lookup(:foo) + end +end |