Skip to content

Commit ab5b81f

Browse files
committed
Enable models to connect/query different adapters.
Previously, models cached many adapter specific values which would prevent making queries against different database types. Now, those caches are per `schema_context`, which can be configured in `database.yml`: ```yaml primary: adapter: trilogy database: myapp_development primary_pg: adapter: postgresql database: myapp_development schema_context: pg ``` This enables models to connect to both MySQL and PostgreSQL databases in the same application, and query them successfully: ```ruby class User < ApplicationRecord connects_to shards: { mysql: { writing: :primary, reading: :primary }, pg: { writing: :primary_pg, reading: :primary_pg }, } end User.connected_to(shard: :mysql) { User.first } # queries MySQL User.connected_to(shard: :pg) { User.first } # queries PostgreSQL ```
1 parent 4df8089 commit ab5b81f

9 files changed

Lines changed: 383 additions & 75 deletions

File tree

activemodel/lib/active_model/attribute_registration.rb

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ def type_for_attribute(attribute_name, &block)
5555
end
5656
end
5757

58+
def apply_pending_attribute_modifications(attribute_set) # :nodoc:
59+
if superclass.respond_to?(:apply_pending_attribute_modifications, true)
60+
superclass.send(:apply_pending_attribute_modifications, attribute_set)
61+
end
62+
63+
pending_attribute_modifications.each do |modification|
64+
modification.apply_to(attribute_set)
65+
end
66+
end
67+
5868
private
5969
PendingType = Struct.new(:name, :type) do # :nodoc:
6070
def apply_to(attribute_set)
@@ -83,16 +93,6 @@ def pending_attribute_modifications
8393
@pending_attribute_modifications ||= []
8494
end
8595

86-
def apply_pending_attribute_modifications(attribute_set)
87-
if superclass.respond_to?(:apply_pending_attribute_modifications, true)
88-
superclass.send(:apply_pending_attribute_modifications, attribute_set)
89-
end
90-
91-
pending_attribute_modifications.each do |modification|
92-
modification.apply_to(attribute_set)
93-
end
94-
end
95-
9696
def reset_default_attributes
9797
reset_default_attributes!
9898
subclasses.each { |subclass| subclass.send(:reset_default_attributes) }

activerecord/CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,37 @@
1+
* Enable experimental support for models to connect to and query databases
2+
with different adapters.
3+
4+
Previously, models cached many adapter specific values which would prevent
5+
making queries against different database types. Now, those caches are per
6+
`schema_context`, which can be configured in `database.yml`:
7+
8+
```yaml
9+
primary:
10+
adapter: trilogy
11+
database: myapp_development
12+
primary_pg:
13+
adapter: postgresql
14+
database: myapp_development
15+
schema_context: pg
16+
```
17+
18+
This enables models to connect to both MySQL and PostgreSQL databases in the
19+
same application, and query them successfully:
20+
21+
```ruby
22+
class User < ApplicationRecord
23+
connects_to shards: {
24+
mysql: { writing: :primary, reading: :primary },
25+
pg: { writing: :primary_pg, reading: :primary_pg },
26+
}
27+
end
28+
29+
User.connected_to(shard: :mysql) { User.first } # queries MySQL
30+
User.connected_to(shard: :pg) { User.first } # queries PostgreSQL
31+
```
32+
33+
*Hartley McGuire*
34+
135
* Batch SQL statements when creating tables to improve performance.
236
337
*Andrew Novoselac*

activerecord/lib/active_record/attributes.rb

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -251,15 +251,15 @@ def define_attribute(
251251
end
252252

253253
def _default_attributes # :nodoc:
254-
@default_attributes ||= begin
255-
attributes_hash = columns_hash.transform_values do |column|
256-
ActiveModel::Attribute.from_database(column.name, column.default, type_for_column(column))
257-
end
254+
model_schema._default_attributes
255+
end
258256

259-
attribute_set = ActiveModel::AttributeSet.new(attributes_hash)
260-
apply_pending_attribute_modifications(attribute_set)
261-
attribute_set
262-
end
257+
def attribute_types # :nodoc:
258+
model_schema.attribute_types
259+
end
260+
261+
def type_for_column(column) # :nodoc:
262+
hook_attribute_type(column.name, super)
263263
end
264264

265265
##
@@ -307,10 +307,6 @@ def reset_default_attributes
307307
def resolve_type_name(name, **options)
308308
Type.lookup(name, **options, adapter: Type.adapter_name_from(self))
309309
end
310-
311-
def type_for_column(column)
312-
hook_attribute_type(column.name, super)
313-
end
314310
end
315311
end
316312
end

activerecord/lib/active_record/core.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ def self.strict_loading_violation!(owner:, reflection:) # :nodoc:
263263

264264
module ClassMethods
265265
def initialize_find_by_cache # :nodoc:
266-
@find_by_statement_cache = { true => Concurrent::Map.new, false => Concurrent::Map.new }
266+
model_schemas.each_value(&:initialize_find_by_cache)
267267
end
268268

269269
def find(*ids) # :nodoc:
@@ -412,8 +412,7 @@ def type_caster # :nodoc:
412412
end
413413

414414
def cached_find_by_statement(connection, key, &block) # :nodoc:
415-
cache = @find_by_statement_cache[connection.prepared_statements]
416-
cache.compute_if_absent(key) { StatementCache.create(connection, &block) }
415+
model_schema.cached_find_by_statement(connection, key, &block)
417416
end
418417

419418
private

activerecord/lib/active_record/database_configurations/hash_config.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ def use_metadata_table? # :nodoc:
195195
configuration_hash.fetch(:use_metadata_table, true)
196196
end
197197

198+
def schema_context # :nodoc:
199+
configuration_hash.fetch(:schema_context, "default").to_s
200+
end
201+
198202
private
199203
def schema_file_type(format)
200204
case format.to_sym

activerecord/lib/active_record/model_schema.rb

Lines changed: 50 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "monitor"
4+
require "active_record/model_schema/schema"
45

56
module ActiveRecord
67
module ModelSchema
@@ -199,6 +200,21 @@ def self.derive_join_table_name(first_table, second_table) # :nodoc:
199200
end
200201

201202
module ClassMethods
203+
def current_schema_context # :nodoc:
204+
connection_db_config.schema_context
205+
rescue ConnectionNotDefined
206+
"default"
207+
end
208+
209+
def model_schema # :nodoc:
210+
context_key = current_schema_context
211+
model_schemas[context_key] ||= Schema.new(self, context_key)
212+
end
213+
214+
def model_schemas # :nodoc:
215+
@model_schemas ||= {}
216+
end
217+
202218
# Guesses the table name (in forced lower-case) based on the name of the class in the
203219
# inheritance hierarchy descending directly from ActiveRecord::Base. So if the hierarchy
204220
# looks like: Reply < Message < ActiveRecord::Base, then Message is used
@@ -448,29 +464,19 @@ def table_exists?
448464
end
449465

450466
def attributes_builder # :nodoc:
451-
@attributes_builder ||= begin
452-
defaults = _default_attributes.except(*(column_names - [primary_key]))
453-
ActiveModel::AttributeSet::Builder.new(attribute_types, defaults)
454-
end
467+
model_schema.attributes_builder
455468
end
456469

457470
def columns_hash # :nodoc:
458-
load_schema unless @columns_hash
459-
@columns_hash
471+
model_schema.columns_hash
460472
end
461473

462474
def columns
463-
@columns ||= columns_hash.values.freeze
475+
model_schema.columns
464476
end
465477

466478
def _returning_columns_for_insert(connection) # :nodoc:
467-
@_returning_columns_for_insert ||= begin
468-
auto_populated_columns = columns.filter_map do |c|
469-
c.name if connection.return_value_after_insert?(c)
470-
end
471-
472-
auto_populated_columns.empty? ? Array(primary_key) : auto_populated_columns
473-
end
479+
model_schema._returning_columns_for_insert(connection)
474480
end
475481

476482
# Returns the column object for the named attribute.
@@ -496,28 +502,22 @@ def column_for_attribute(name)
496502
# Returns a hash where the keys are column names and the values are
497503
# default values when instantiating the Active Record object for this table.
498504
def column_defaults
499-
load_schema
500-
@column_defaults ||= _default_attributes.deep_dup.to_hash.freeze
505+
model_schema.column_defaults
501506
end
502507

503508
# Returns an array of column names as strings.
504509
def column_names
505-
@column_names ||= columns.map(&:name).freeze
510+
model_schema.column_names
506511
end
507512

508513
def symbol_column_to_string(name_symbol) # :nodoc:
509-
@symbol_column_to_string_name_hash ||= column_names.index_by(&:to_sym)
510-
@symbol_column_to_string_name_hash[name_symbol]
514+
model_schema.symbol_column_to_string(name_symbol)
511515
end
512516

513517
# Returns an array of column objects where the primary id, all columns ending in "_id" or "_count",
514518
# and columns used for single table inheritance have been removed.
515519
def content_columns
516-
@content_columns ||= columns.reject do |c|
517-
c.name == primary_key ||
518-
c.name == inheritance_column ||
519-
c.name.end_with?("_id", "_count")
520-
end.freeze
520+
model_schema.content_columns
521521
end
522522

523523
# Resets all the cached information about columns, which will cause them
@@ -557,14 +557,25 @@ def reset_column_information
557557

558558
# Load the model's schema information either from the schema cache
559559
# or directly from the database.
560+
#
561+
# This separates two concerns:
562+
# 1. Adapter-specific: populating the current Schema context's columns_hash
563+
# and default_attributes (runs per context, since different adapters may
564+
# return different column types).
565+
# 2. Model-specific: the load_schema! super chain (counter_cache, encryption,
566+
# etc.) that mutates model-class-level state (runs once, since it's the
567+
# same regardless of adapter).
560568
def load_schema
561569
return if schema_loaded?
562570
@load_schema_monitor.synchronize do
563571
return if schema_loaded?
564572

565-
load_schema!
573+
model_schema.load_schema!
566574

567-
@schema_loaded = true
575+
unless @any_schema_loaded
576+
load_schema!
577+
@any_schema_loaded = true
578+
end
568579
rescue
569580
reload_schema_from_cache # If the schema loading failed half way through, we must reset the state.
570581
raise
@@ -577,17 +588,13 @@ def initialize_load_schema_monitor
577588
end
578589

579590
def reload_schema_from_cache(recursive = true)
580-
@_returning_columns_for_insert = nil
591+
@any_schema_loaded = false
581592
@arel_table = nil
582-
@column_names = nil
583-
@symbol_column_to_string_name_hash = nil
584-
@content_columns = nil
585-
@column_defaults = nil
586-
@attributes_builder = nil
587-
@columns = nil
588-
@columns_hash = nil
589-
@schema_loaded = false
590593
@attribute_names = nil
594+
595+
# Reset all Schema instances
596+
model_schemas.each_value(&:reload_schema_from_cache)
597+
591598
if recursive
592599
subclasses.each do |descendant|
593600
descendant.send(:reload_schema_from_cache)
@@ -607,23 +614,17 @@ def inherited(child_class)
607614
end
608615

609616
def schema_loaded?
610-
@schema_loaded
617+
model_schema.schema_loaded?
611618
end
612619

613620
def load_schema!
614-
unless table_name
615-
raise ActiveRecord::TableNotSpecified, "#{self} has no table configured. Set one with #{self}.table_name="
616-
end
617-
618-
columns_hash = schema_cache.columns_hash(table_name)
619-
if only_columns.present?
620-
columns_hash = columns_hash.slice(*only_columns)
621-
elsif ignored_columns.present?
622-
columns_hash = columns_hash.except(*ignored_columns)
623-
end
624-
@columns_hash = columns_hash.freeze
625-
626-
_default_attributes # Precompute to cache DB-dependent attribute types
621+
# Base implementation is a no-op. The adapter-specific schema loading
622+
# (columns_hash, default_attributes) is handled by model_schema.load_schema!
623+
# which is called directly from load_schema before this super chain.
624+
#
625+
# This method exists as the hook point for the super chain — overrides
626+
# in CounterCache, EncryptableRecord, etc. call super and then do
627+
# model-class-level setup that should only happen once.
627628
end
628629

629630
# Guesses the table name, but does not decorate it with prefix and suffix information.

0 commit comments

Comments
 (0)