diff --git a/README.md b/README.md index 57b756f7..cb8bd3a5 100644 --- a/README.md +++ b/README.md @@ -1373,6 +1373,224 @@ Listed below are all configuration options. corresponding table in DynamoDB at model persisting if the table doesn't exist yet. Default is `true` +## Multi-Configuration Support + +Dynamoid supports multiple configurations to connect to different DynamoDB instances +across multiple AWS accounts or regions. This is useful when you need to: + +- Connect to DynamoDB tables in different AWS accounts +- Use different regions for different models +- Separate production/staging data across different AWS setups +- Implement cross-account data access patterns + +### Setting up Multiple Configurations + +Configure multiple DynamoDB connections in your application initializer: + +```ruby +# config/initializers/dynamoid.rb +Dynamoid.multi_configure do |config| + # Primary configuration (e.g., main application data) + config.add_config(:primary) do |c| + c.access_key = ENV.fetch('PRIMARY_AWS_ACCESS_KEY', nil) + c.secret_key = ENV.fetch('PRIMARY_AWS_SECRET_KEY', nil) + c.region = 'us-east-1' + c.namespace = 'myapp_primary' + end + + # Secondary configuration (e.g., analytics data) + config.add_config(:analytics) do |c| + c.access_key = ENV.fetch('ANALYTICS_AWS_ACCESS_KEY', nil) + c.secret_key = ENV.fetch('ANALYTICS_AWS_SECRET_KEY', nil) + c.region = 'us-west-2' + c.namespace = 'myapp_analytics' + end + + # Cross-account configuration (e.g., partner data) + config.add_config(:partner) do |c| + c.credentials = Aws::AssumeRoleCredentials.new( + role_arn: ENV.fetch('PARTNER_ROLE_ARN', nil), + role_session_name: 'dynamoid-cross-account' + ) + c.region = 'eu-west-1' + c.namespace = 'partner_shared' + end +end +``` + +### Using Multiple Configurations in Models + +Specify which configuration a model should use with the `dynamoid_config` method: + +```ruby +# Models using primary configuration +class User + include Dynamoid::Document + + dynamoid_config :primary + + field :name, :string + field :email, :string + + has_many :orders +end + +class Order + include Dynamoid::Document + + dynamoid_config :primary + + field :total, :number + field :status, :string + + belongs_to :user +end + +# Models using analytics configuration +class PageView + include Dynamoid::Document + + dynamoid_config :analytics + + field :url, :string + field :user_id, :string + field :timestamp, :datetime + + global_secondary_index hash_key: :user_id, range_key: :timestamp +end + +class Report + include Dynamoid::Document + + dynamoid_config :analytics + + field :name, :string + field :data, :serialized + field :generated_at, :datetime +end + +# Models using partner configuration +class SharedData + include Dynamoid::Document + + dynamoid_config :partner + + field :partner_id, :string + field :content, :serialized + field :sync_status, :string +end + +# Models using default configuration (fallback to main Dynamoid.configure) +class SystemLog + include Dynamoid::Document + + # No dynamoid_config specified - uses default configuration + + field :level, :string + field :message, :string + field :timestamp, :datetime +end +``` + +### Configuration Inheritance + +Models automatically inherit the correct configuration for all operations: + +- **Table operations**: `create_table`, `delete_table` +- **CRUD operations**: `create`, `save`, `update`, `delete`, `find` +- **Queries**: `where`, `all`, `first`, `last` +- **Batch operations**: `import`, `batch_write` +- **Scanning**: `scan` + +```ruby +# Each model uses its own DynamoDB connection +User.create(name: "John", email: "john@example.com") # Uses :primary config +PageView.create(url: "/home", user_id: "123") # Uses :analytics config +SharedData.create(partner_id: "partner1", content: {}) # Uses :partner config +SystemLog.create(level: "info", message: "App started") # Uses default config +``` + +### Table Names and Namespaces + +Each configuration can have its own namespace, resulting in different table prefixes: + +```ruby +# With the configurations above: +User.table_name # => "myapp_primary_users" +PageView.table_name # => "myapp_analytics_page_views" +SharedData.table_name # => "partner_shared_shared_data" +SystemLog.table_name # => "dynamoid_system_logs" (uses default namespace) +``` + +### Configuration Management + +```ruby +# List all configured names +Dynamoid::MultiConfig.configuration_names +# => [:primary, :analytics, :partner] + +# Check if a configuration exists +Dynamoid::MultiConfig.configuration_exists?(:primary) +# => true + +# Get a specific configuration +config = Dynamoid::MultiConfig.get_config(:primary) +config.region # => "us-east-1" + +# Remove a configuration +Dynamoid::MultiConfig.remove_config(:analytics) + +# Clear all configurations +Dynamoid::MultiConfig.clear_all +``` + +### Error Handling + +If you specify a non-existent configuration, Dynamoid will raise an error: + +```ruby +class InvalidModel + include Dynamoid::Document + + dynamoid_config :nonexistent # This configuration doesn't exist + + field :name, :string +end + +InvalidModel.create(name: "test") +# => Dynamoid::Errors::UnknownConfiguration: Unknown configuration: nonexistent +``` + +### Best Practices + +1. **Environment-based configuration**: Use environment variables for sensitive credentials +2. **Logical separation**: Group related models in the same configuration +3. **Namespace isolation**: Use distinct namespaces to avoid table name conflicts +4. **Role-based access**: Use IAM roles for cross-account access when possible +5. **Connection reuse**: Configurations create connection pools, so reuse them efficiently + +```ruby +# Example: Environment-based setup +Dynamoid.multi_configure do |config| + # Production data + config.add_config(:production) do |c| + c.credentials = Aws::InstanceProfileCredentials.new + c.region = ENV.fetch('PRODUCTION_REGION', 'us-east-1') + c.namespace = "#{Rails.application.class.module_parent_name.downcase}_prod" + end + + # Analytics warehouse + config.add_config(:warehouse) do |c| + c.credentials = Aws::AssumeRoleCredentials.new( + role_arn: ENV['WAREHOUSE_ROLE_ARN'], + role_session_name: "#{Rails.application.class.module_parent_name.downcase}-warehouse" + ) + c.region = ENV.fetch('WAREHOUSE_REGION', 'us-west-2') + c.namespace = "warehouse_#{Rails.env}" + end +end +``` + ## Concurrency diff --git a/lib/dynamoid.rb b/lib/dynamoid.rb index a51fc05c..06bd3a51 100644 --- a/lib/dynamoid.rb +++ b/lib/dynamoid.rb @@ -35,6 +35,7 @@ require 'dynamoid/components' require 'dynamoid/document' require 'dynamoid/adapter' +require 'dynamoid/multi_config' require 'dynamoid/transaction_write' require 'dynamoid/tasks/database' @@ -51,6 +52,10 @@ def configure end alias config configure + def multi_configure(&block) + Dynamoid::MultiConfig.configure(&block) + end + def logger Dynamoid::Config.logger end diff --git a/lib/dynamoid/criteria/chain.rb b/lib/dynamoid/criteria/chain.rb index f24ab321..d679f1e0 100644 --- a/lib/dynamoid/criteria/chain.rb +++ b/lib/dynamoid/criteria/chain.rb @@ -233,18 +233,18 @@ def delete_all ranges = [] if @key_fields_detector.key_present? - Dynamoid.adapter.query(source.table_name, query_key_conditions, query_non_key_conditions, query_options).flat_map { |i| i }.collect do |hash| + source.adapter.query(source.table_name, query_key_conditions, query_non_key_conditions, query_options).flat_map { |i| i }.collect do |hash| ids << hash[source.hash_key.to_sym] ranges << hash[source.range_key.to_sym] if source.range_key end else - Dynamoid.adapter.scan(source.table_name, scan_conditions, scan_options).flat_map { |i| i }.collect do |hash| + source.adapter.scan(source.table_name, scan_conditions, scan_options).flat_map { |i| i }.collect do |hash| ids << hash[source.hash_key.to_sym] ranges << hash[source.range_key.to_sym] if source.range_key end end - Dynamoid.adapter.delete(source.table_name, ids, range_key: ranges.presence) + source.adapter.delete(source.table_name, ids, range_key: ranges.presence) end alias destroy_all delete_all @@ -575,7 +575,7 @@ def raw_pages # @since 3.1.0 def raw_pages_via_query Enumerator.new do |y| - Dynamoid.adapter.query(source.table_name, query_key_conditions, query_non_key_conditions, query_options).each do |items, metadata| + source.adapter.query(source.table_name, query_key_conditions, query_non_key_conditions, query_options).each do |items, metadata| options = metadata.slice(:last_evaluated_key) y.yield items, options @@ -590,7 +590,7 @@ def raw_pages_via_query # @since 3.1.0 def raw_pages_via_scan Enumerator.new do |y| - Dynamoid.adapter.scan(source.table_name, scan_conditions, scan_options).each do |items, metadata| + source.adapter.scan(source.table_name, scan_conditions, scan_options).each do |items, metadata| options = metadata.slice(:last_evaluated_key) y.yield items, options @@ -613,11 +613,11 @@ def issue_scan_warning end def count_via_query - Dynamoid.adapter.query_count(source.table_name, query_key_conditions, query_non_key_conditions, query_options) + source.adapter.query_count(source.table_name, query_key_conditions, query_non_key_conditions, query_options) end def count_via_scan - Dynamoid.adapter.scan_count(source.table_name, scan_conditions, scan_options) + source.adapter.scan_count(source.table_name, scan_conditions, scan_options) end def field_condition(key, value_before_type_casting) diff --git a/lib/dynamoid/document.rb b/lib/dynamoid/document.rb index 8591e7d0..4b0d03b3 100644 --- a/lib/dynamoid/document.rb +++ b/lib/dynamoid/document.rb @@ -8,10 +8,11 @@ module Document include Dynamoid::Components included do - class_attribute :options, :read_only_attributes, :base_class, instance_accessor: false + class_attribute :options, :read_only_attributes, :base_class, :dynamoid_config_name, instance_accessor: false self.options = {} self.read_only_attributes = [] self.base_class = self + self.dynamoid_config_name = nil Dynamoid.included_models << self unless Dynamoid.included_models.include? self end @@ -21,6 +22,26 @@ def attr_readonly(*read_only_attributes) self.read_only_attributes.concat read_only_attributes.map(&:to_s) end + # Set the DynamoDB configuration to use for this model + # + # @param [Symbol] config_name the name of the configuration + # @since 4.0.0 + def dynamoid_config(config_name) + self.dynamoid_config_name = config_name.to_sym + end + + # Get the adapter for this model's configuration + # + # @return [Dynamoid::Adapter] the adapter instance + # @since 4.0.0 + def adapter + if dynamoid_config_name + Dynamoid::MultiConfig.get_adapter(dynamoid_config_name) + else + Dynamoid.adapter + end + end + # Returns the read capacity for this table. # # @return [Integer] read capacity units @@ -80,7 +101,7 @@ def hash_key # @return [Integer] items count in a table # @since 0.6.1 def count - Dynamoid.adapter.count(table_name) + adapter.count(table_name) end # Initialize a new object. diff --git a/lib/dynamoid/errors.rb b/lib/dynamoid/errors.rb index 14389a45..4b621479 100644 --- a/lib/dynamoid/errors.rb +++ b/lib/dynamoid/errors.rb @@ -106,5 +106,11 @@ def initialize(_msg = nil) super('Scan operations prohibited. Modify Dynamoid::Config.error_on_scan to change this behavior.') end end + + class UnknownConfiguration < Error + def initialize(config_name) + super("Unknown configuration: #{config_name}") + end + end end end diff --git a/lib/dynamoid/finders.rb b/lib/dynamoid/finders.rb index e6b59ac0..0e2bc94b 100644 --- a/lib/dynamoid/finders.rb +++ b/lib/dynamoid/finders.rb @@ -127,7 +127,7 @@ def _find_all(ids, options = {}) items = if Dynamoid.config.backoff items = [] backoff = nil - Dynamoid.adapter.read(table_name, ids, read_options) do |hash, has_unprocessed_items| + adapter.read(table_name, ids, read_options) do |hash, has_unprocessed_items| items += hash[table_name] if has_unprocessed_items @@ -139,7 +139,7 @@ def _find_all(ids, options = {}) end items else - items = Dynamoid.adapter.read(table_name, ids, read_options) + items = adapter.read(table_name, ids, read_options) items ? items[table_name] : [] end @@ -165,7 +165,7 @@ def _find_by_id(id, options = {}) options[:range_key] = cast_and_dump(range_key, options[:range_key]) end - if item = Dynamoid.adapter.read(table_name, partition_key_dumped, options.slice(:range_key, :consistent_read)) + if item = adapter.read(table_name, partition_key_dumped, options.slice(:range_key, :consistent_read)) model = from_database(item) model.run_callbacks :find model @@ -211,7 +211,7 @@ def find_by_composite_key(hash_key, range_key, options = {}) def find_all_by_composite_key(hash_key, options = {}) Dynamoid.deprecator.warn('[Dynamoid] .find_all_composite_key is deprecated! Call .where instead of') - Dynamoid.adapter.query(table_name, options.merge(hash_value: hash_key)).flat_map { |i| i }.collect do |item| + adapter.query(table_name, options.merge(hash_value: hash_key)).flat_map { |i| i }.collect do |item| from_database(item) end end @@ -272,7 +272,7 @@ def find_all_by_secondary_index(hash, options = {}) query_options = options.slice(*Dynamoid::AdapterPlugin::AwsSdkV3::Query::OPTIONS_KEYS) query_options[:index_name] = index.name - Dynamoid.adapter.query(table_name, query_key_conditions, query_non_key_conditions, query_options) + adapter.query(table_name, query_key_conditions, query_non_key_conditions, query_options) .flat_map { |i| i } .map { |item| from_database(item) } end diff --git a/lib/dynamoid/multi_config.rb b/lib/dynamoid/multi_config.rb new file mode 100644 index 00000000..0b02d2cb --- /dev/null +++ b/lib/dynamoid/multi_config.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +module Dynamoid + # Manages multiple configurations for connecting to different DynamoDB instances + # across multiple AWS accounts or regions. + # + # @example Setting up multiple configurations + # Dynamoid::MultiConfig.configure do |config| + # config.add_config(:primary) do |c| + # c.access_key = 'primary_access_key' + # c.secret_key = 'primary_secret_key' + # c.region = 'us-east-1' + # c.namespace = 'primary_app' + # end + # + # config.add_config(:secondary) do |c| + # c.access_key = 'secondary_access_key' + # c.secret_key = 'secondary_secret_key' + # c.region = 'us-west-2' + # c.namespace = 'secondary_app' + # end + # end + # + # @example Using in models + # class User + # include Dynamoid::Document + # + # dynamoid_config :primary + # + # field :name, :string + # end + # + # class Order + # include Dynamoid::Document + # + # dynamoid_config :secondary + # + # field :total, :number + # end + # + # @since 4.0.0 + module MultiConfig + extend self + + # Registry to store multiple configurations + @configurations = {} + @adapters = {} + + # Configure multiple DynamoDB configurations + # + # @yield [Configurator] yields a configurator object to add configurations + def configure + yield(Configurator.new) if block_given? + end + + # Add a new configuration + # + # @param [Symbol] name the name of the configuration + # @param [Hash] options configuration options + # @yield [Dynamoid::Config] yields config object for configuration + def add_config(name, options = {}) + config = build_config(options) + yield(config) if block_given? + @configurations[name.to_sym] = config + @adapters[name.to_sym] = nil # Will be lazy loaded + end + + # Get configuration by name + # + # @param [Symbol] name the name of the configuration + # @return [Dynamoid::Config] the configuration object + def get_config(name) + @configurations[name.to_sym] || raise(Dynamoid::Errors::UnknownConfiguration, "Configuration '#{name}' not found") + end + + # Get adapter for a specific configuration + # + # @param [Symbol] name the name of the configuration + # @return [Dynamoid::Adapter] the adapter instance + def get_adapter(name) + config_name = name.to_sym + + unless @configurations.key?(config_name) + raise Dynamoid::Errors::UnknownConfiguration, "Configuration '#{name}' not found" + end + + @adapters[config_name] ||= create_adapter_for_config(config_name) + end + + # List all available configuration names + # + # @return [Array] array of configuration names + def configuration_names + @configurations.keys + end + + # Check if a configuration exists + # + # @param [Symbol] name the name of the configuration + # @return [Boolean] true if configuration exists + def configuration_exists?(name) + @configurations.key?(name.to_sym) + end + + # Remove a configuration + # + # @param [Symbol] name the name of the configuration to remove + def remove_config(name) + config_name = name.to_sym + @configurations.delete(config_name) + @adapters.delete(config_name) + end + + # Clear all configurations + def clear_all + @configurations.clear + @adapters.clear + end + + private + + # Build a new configuration object with default values + def build_config(options = {}) + config = create_config_object + setup_config_defaults(config) + copy_main_config_values(config) + apply_custom_options(config, options) + config + end + + def create_config_object + config = Object.new + config.extend(Dynamoid::Config::Options) + # Initialize settings and defaults hashes + config.instance_variable_set(:@settings, {}) + config.instance_variable_set(:@defaults, {}) + config + end + + def setup_config_defaults(config) + # Define all the same options as main config + Dynamoid::Config.defaults.each do |key, default_value| + next if key == :adapter + + config.option(key, default: default_value) + end + end + + def copy_main_config_values(config) + # Copy current values from main config + Dynamoid::Config.settings.each do |key, value| + next if key == :adapter + + config.send("#{key}=", value) if config.respond_to?("#{key}=") + end + end + + def apply_custom_options(config, options) + # Apply custom options + options.each do |key, value| + next if key == :adapter + + config.send("#{key}=", value) if config.respond_to?("#{key}=") + end + end + + # Create adapter instance for specific configuration + def create_adapter_for_config(name) + config = @configurations[name] + MultiConfigAdapter.new(config) + end + + # Configurator class for DSL-style configuration + class Configurator + def add_config(name, options = {}, &block) + Dynamoid::MultiConfig.add_config(name, options, &block) + end + end + end + + # Specialized adapter that uses a specific configuration instead of global config + class MultiConfigAdapter < Adapter + def initialize(config) + super() + @config = config + end + + # Override adapter method to use specific config + def adapter + unless @adapter_.value + adapter = MultiConfigAwsSdkV3.new(@config) + adapter.connect! + @adapter_.compare_and_set(nil, adapter) + clear_cache! + end + @adapter_.value + end + + def self.adapter_plugin_class + MultiConfigAwsSdkV3 + end + end + + # AWS SDK v3 adapter that accepts a specific configuration + class MultiConfigAwsSdkV3 < AdapterPlugin::AwsSdkV3 + def initialize(config) + super() + @config = config + end + + def connection_config + @connection_hash = {} + add_connection_options + add_credentials + add_logging_config + @connection_hash + end + + def add_connection_options + connection_config_options = %i[endpoint region http_continue_timeout http_idle_timeout http_open_timeout + http_read_timeout].freeze + (connection_config_options & @config.settings.compact.keys).each do |option| + @connection_hash[option] = @config.send(option) + end + end + + def add_credentials + # if credentials are passed, they already contain access key & secret key + if @config.credentials? + @connection_hash[:credentials] = @config.credentials + else + # otherwise, pass access key & secret key for credentials creation + @connection_hash[:access_key_id] = @config.access_key if @config.access_key? + @connection_hash[:secret_access_key] = @config.secret_key if @config.secret_key? + end + end + + def add_logging_config + @connection_hash[:logger] = @config.logger || Dynamoid::Config.logger + @connection_hash[:log_level] = :debug + + return unless @config.log_formatter || Dynamoid::Config.log_formatter + + @connection_hash[:log_formatter] = @config.log_formatter || Dynamoid::Config.log_formatter + end + end +end diff --git a/lib/dynamoid/persistence.rb b/lib/dynamoid/persistence.rb index 300ee420..8c500d75 100644 --- a/lib/dynamoid/persistence.rb +++ b/lib/dynamoid/persistence.rb @@ -30,7 +30,14 @@ module ClassMethods def table_name table_base_name = options[:name] || base_class.name.split('::').last.downcase.pluralize - @table_name ||= [Dynamoid::Config.namespace.to_s, table_base_name].reject(&:empty?).join('_') + namespace = if dynamoid_config_name + config = Dynamoid::MultiConfig.get_config(dynamoid_config_name) + config.namespace.to_s + else + Dynamoid::Config.namespace.to_s + end + + @table_name ||= [namespace, table_base_name].reject(&:empty?).join('_') end # Create a table. @@ -111,11 +118,11 @@ def create_table(options = {}) global_secondary_indexes: global_secondary_indexes.values }.merge(options) - created_successfuly = Dynamoid.adapter.create_table(options[:table_name], options[:id], options) + created_successfuly = adapter.create_table(options[:table_name], options[:id], options) if created_successfuly && self.options[:expires] attribute = self.options[:expires][:field] - Dynamoid.adapter.update_time_to_live(options[:table_name], attribute) + adapter.update_time_to_live(options[:table_name], attribute) end self @@ -129,7 +136,7 @@ def create_table(options = {}) # Subsequent method calls for the same table will be ignored. # @return [Model class] self def delete_table - Dynamoid.adapter.delete_table(table_name) + adapter.delete_table(table_name) self end @@ -688,7 +695,7 @@ def update!(conditions = {}) partition_key_dumped = Dumping.dump_field(hash_key, self.class.attributes[self.class.hash_key]) update_item_options = options.merge(conditions: conditions) - new_attrs = Dynamoid.adapter.update_item(table_name, partition_key_dumped, update_item_options) do |t| + new_attrs = self.class.adapter.update_item(table_name, partition_key_dumped, update_item_options) do |t| item_updater = ItemUpdaterWithDumping.new(self.class, t) item_updater.add(lock_version: 1) if self.class.attributes[:lock_version] @@ -943,7 +950,7 @@ def delete @destroyed = true - Dynamoid.adapter.delete(self.class.table_name, partition_key_dumped, options) + self.class.adapter.delete(self.class.table_name, partition_key_dumped, options) self.class.associations.each_key do |name| send(name).disassociate_source diff --git a/lib/dynamoid/persistence/import.rb b/lib/dynamoid/persistence/import.rb index 4d0f01c9..2d64a502 100644 --- a/lib/dynamoid/persistence/import.rb +++ b/lib/dynamoid/persistence/import.rb @@ -52,7 +52,7 @@ def import_with_backoff(models) table_name = @model_class.table_name items = array_of_dumped_attributes(models) - Dynamoid.adapter.batch_write_item(table_name, items) do |has_unprocessed_items| + @model_class.adapter.batch_write_item(table_name, items) do |has_unprocessed_items| if has_unprocessed_items backoff ||= Dynamoid.config.build_backoff backoff.call @@ -63,7 +63,7 @@ def import_with_backoff(models) end def import(models) - Dynamoid.adapter.batch_write_item(@model_class.table_name, array_of_dumped_attributes(models)) + @model_class.adapter.batch_write_item(@model_class.table_name, array_of_dumped_attributes(models)) end def array_of_dumped_attributes(models) diff --git a/lib/dynamoid/persistence/inc.rb b/lib/dynamoid/persistence/inc.rb index 5c2f2284..8963178d 100644 --- a/lib/dynamoid/persistence/inc.rb +++ b/lib/dynamoid/persistence/inc.rb @@ -23,7 +23,7 @@ def call touch = @counters.delete(:touch) hash_key_dumped = cast_and_dump(@model_class.hash_key, @hash_key) - Dynamoid.adapter.update_item(@model_class.table_name, hash_key_dumped, update_item_options) do |t| + @model_class.adapter.update_item(@model_class.table_name, hash_key_dumped, update_item_options) do |t| item_updater = ItemUpdaterWithCastingAndDumping.new(@model_class, t) @counters.each do |name, value| diff --git a/lib/dynamoid/persistence/save.rb b/lib/dynamoid/persistence/save.rb index 20c1df41..2c450170 100644 --- a/lib/dynamoid/persistence/save.rb +++ b/lib/dynamoid/persistence/save.rb @@ -33,13 +33,13 @@ def call if @model.new_record? attributes_dumped = Dumping.dump_attributes(@model.attributes, @model.class.attributes) - Dynamoid.adapter.write(@model.class.table_name, attributes_dumped, conditions_for_write) + @model.class.adapter.write(@model.class.table_name, attributes_dumped, conditions_for_write) else attributes_to_persist = @model.attributes.slice(*@model.changed.map(&:to_sym)) partition_key_dumped = dump(@model.class.hash_key, @model.hash_key) options = options_to_update_item(partition_key_dumped) - Dynamoid.adapter.update_item(@model.class.table_name, partition_key_dumped, options) do |t| + @model.class.adapter.update_item(@model.class.table_name, partition_key_dumped, options) do |t| item_updater = ItemUpdaterWithDumping.new(@model.class, t) attributes_to_persist.each do |name, value| diff --git a/lib/dynamoid/persistence/update_fields.rb b/lib/dynamoid/persistence/update_fields.rb index a1dc18dd..74f3e954 100644 --- a/lib/dynamoid/persistence/update_fields.rb +++ b/lib/dynamoid/persistence/update_fields.rb @@ -35,7 +35,7 @@ def call private def update_item - Dynamoid.adapter.update_item(@model_class.table_name, @partition_key_dumped, options_to_update_item) do |t| + @model_class.adapter.update_item(@model_class.table_name, @partition_key_dumped, options_to_update_item) do |t| item_updater = ItemUpdaterWithCastingAndDumping.new(@model_class, t) @attributes.each do |k, v| diff --git a/lib/dynamoid/persistence/upsert.rb b/lib/dynamoid/persistence/upsert.rb index 8967a1e8..a5215d7f 100644 --- a/lib/dynamoid/persistence/upsert.rb +++ b/lib/dynamoid/persistence/upsert.rb @@ -35,7 +35,7 @@ def call def update_item partition_key_dumped = cast_and_dump(@model_class.hash_key, @partition_key) - Dynamoid.adapter.update_item(@model_class.table_name, partition_key_dumped, options_to_update_item) do |t| + @model_class.adapter.update_item(@model_class.table_name, partition_key_dumped, options_to_update_item) do |t| item_updater = ItemUpdaterWithCastingAndDumping.new(@model_class, t) @attributes.each do |k, v| diff --git a/spec/dynamoid/multi_config_document_spec.rb b/spec/dynamoid/multi_config_document_spec.rb new file mode 100644 index 00000000..2eee431d --- /dev/null +++ b/spec/dynamoid/multi_config_document_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Multi-config Document Integration' do + let(:primary_config) do + { + namespace: 'primary_test', + region: 'us-east-1' + } + end + + let(:secondary_config) do + { + namespace: 'secondary_test', + region: 'us-west-2' + } + end + + before do + Dynamoid::MultiConfig.clear_all + + Dynamoid::MultiConfig.configure do |config| + config.add_config(:primary, primary_config) + config.add_config(:secondary, secondary_config) + end + end + + after do + Dynamoid::MultiConfig.clear_all + end + + describe 'model with multi-config' do + let(:primary_user_class) do + new_class(class_name: 'PrimaryUser') do + include Dynamoid::Document + + dynamoid_config :primary + + field :name, :string + field :email, :string + end + end + + let(:secondary_user_class) do + new_class(class_name: 'SecondaryUser') do + include Dynamoid::Document + + dynamoid_config :secondary + + field :name, :string + field :age, :integer + end + end + + let(:default_user_class) do + new_class(class_name: 'DefaultUser') do + include Dynamoid::Document + + field :name, :string + field :username, :string + end + end + + it 'uses correct configuration for primary model' do + expect(primary_user_class.dynamoid_config_name).to eq(:primary) + expect(primary_user_class.adapter).to be_a(Dynamoid::MultiConfigAdapter) + end + + it 'uses correct configuration for secondary model' do + expect(secondary_user_class.dynamoid_config_name).to eq(:secondary) + expect(secondary_user_class.adapter).to be_a(Dynamoid::MultiConfigAdapter) + end + + it 'uses default adapter for model without config' do + expect(default_user_class.dynamoid_config_name).to be_nil + expect(default_user_class.adapter).to eq(Dynamoid.adapter) + end + + it 'generates correct table names with different namespaces' do + expect(primary_user_class.table_name).to start_with('primary_test_') + expect(secondary_user_class.table_name).to start_with('secondary_test_') + expect(default_user_class.table_name).to start_with(Dynamoid::Config.namespace.to_s) + end + + it 'each model uses its own adapter for operations' do + allow(Dynamoid::MultiConfig).to receive(:get_adapter).with(:primary).and_call_original + allow(Dynamoid::MultiConfig).to receive(:get_adapter).with(:secondary).and_call_original + + primary_adapter = primary_user_class.adapter + secondary_adapter = secondary_user_class.adapter + + expect(primary_adapter).not_to eq(secondary_adapter) + + # Test that each model uses its own adapter + expect(primary_adapter).to receive(:count).with(primary_user_class.table_name) + primary_user_class.count + + expect(secondary_adapter).to receive(:count).with(secondary_user_class.table_name) + secondary_user_class.count + end + end + + describe 'error handling' do + let(:invalid_config_class) do + new_class(class_name: 'InvalidConfigUser') do + include Dynamoid::Document + + dynamoid_config :nonexistent + + field :name, :string + end + end + + it 'raises error when using unknown configuration' do + expect { invalid_config_class.adapter } + .to raise_error(Dynamoid::Errors::UnknownConfiguration, /nonexistent/) + end + end +end diff --git a/spec/dynamoid/multi_config_e2e_spec.rb b/spec/dynamoid/multi_config_e2e_spec.rb new file mode 100644 index 00000000..5f57d192 --- /dev/null +++ b/spec/dynamoid/multi_config_e2e_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Multi-config E2E Integration' do + before do + Dynamoid::MultiConfig.clear_all + + Dynamoid::MultiConfig.configure do |config| + config.add_config(:primary) do |c| + c.namespace = 'primary_test_e2e' + c.region = 'us-east-1' + c.endpoint = 'http://localhost:8000' # DynamoDB Local + end + + config.add_config(:secondary) do |c| + c.namespace = 'secondary_test_e2e' + c.region = 'us-west-2' + c.endpoint = 'http://localhost:8000' # DynamoDB Local + end + end + end + + after do + Dynamoid::MultiConfig.clear_all + end + + let(:primary_model) do + new_class(class_name: 'PrimaryModel') do + include Dynamoid::Document + + dynamoid_config :primary + + field :name, :string + field :value, :integer + end + end + + let(:secondary_model) do + new_class(class_name: 'SecondaryModel') do + include Dynamoid::Document + + dynamoid_config :secondary + + field :title, :string + field :count, :integer + end + end + + it 'models use different adapters' do + primary_adapter = primary_model.adapter + secondary_adapter = secondary_model.adapter + + expect(primary_adapter).to be_a(Dynamoid::MultiConfigAdapter) + expect(secondary_adapter).to be_a(Dynamoid::MultiConfigAdapter) + expect(primary_adapter.object_id).not_to eq(secondary_adapter.object_id) + end + + it 'models have correct table names with different namespaces' do + # Test table names use correct namespaces + expect(primary_model.table_name).to include('primary_test_e2e') + expect(secondary_model.table_name).to include('secondary_test_e2e') + + # Ensure they use different namespaces + expect(primary_model.table_name).not_to eq(secondary_model.table_name) + end + + it 'models use correct configurations' do + primary_config = Dynamoid::MultiConfig.get_config(:primary) + secondary_config = Dynamoid::MultiConfig.get_config(:secondary) + + expect(primary_config.namespace).to eq('primary_test_e2e') + expect(secondary_config.namespace).to eq('secondary_test_e2e') + + expect(primary_config.region).to eq('us-east-1') + expect(secondary_config.region).to eq('us-west-2') + end +end diff --git a/spec/dynamoid/multi_config_spec.rb b/spec/dynamoid/multi_config_spec.rb new file mode 100644 index 00000000..04118c07 --- /dev/null +++ b/spec/dynamoid/multi_config_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Dynamoid::MultiConfig do + let(:primary_config) do + { + access_key: 'primary_access_key', + secret_key: 'primary_secret_key', + region: 'us-east-1', + namespace: 'primary_test' + } + end + + let(:secondary_config) do + { + access_key: 'secondary_access_key', + secret_key: 'secondary_secret_key', + region: 'us-west-2', + namespace: 'secondary_test' + } + end + + before do + Dynamoid::MultiConfig.clear_all + end + + after do + Dynamoid::MultiConfig.clear_all + end + + describe '.configure' do + it 'allows configuration through a block' do + Dynamoid::MultiConfig.configure do |config| + config.add_config(:primary, primary_config) + config.add_config(:secondary, secondary_config) + end + + expect(Dynamoid::MultiConfig.configuration_names).to include(:primary, :secondary) + end + end + + describe '.add_config' do + it 'adds a new configuration' do + Dynamoid::MultiConfig.add_config(:primary, primary_config) + + expect(Dynamoid::MultiConfig.configuration_exists?(:primary)).to be true + end + + it 'allows configuration through a block' do + Dynamoid::MultiConfig.add_config(:primary) do |config| + config.access_key = 'test_key' + config.secret_key = 'test_secret' + config.region = 'us-east-1' + end + + config = Dynamoid::MultiConfig.get_config(:primary) + expect(config.access_key).to eq('test_key') + expect(config.secret_key).to eq('test_secret') + expect(config.region).to eq('us-east-1') + end + end + + describe '.get_config' do + before do + Dynamoid::MultiConfig.add_config(:primary, primary_config) + end + + it 'returns the configuration for a given name' do + config = Dynamoid::MultiConfig.get_config(:primary) + expect(config.access_key).to eq('primary_access_key') + expect(config.region).to eq('us-east-1') + end + + it 'raises an error for unknown configuration' do + expect { Dynamoid::MultiConfig.get_config(:unknown) } + .to raise_error(Dynamoid::Errors::UnknownConfiguration) + end + end + + describe '.get_adapter' do + before do + Dynamoid::MultiConfig.add_config(:primary, primary_config) + end + + it 'returns an adapter for the configuration' do + adapter = Dynamoid::MultiConfig.get_adapter(:primary) + expect(adapter).to be_a(Dynamoid::MultiConfigAdapter) + end + + it 'raises an error for unknown configuration' do + expect { Dynamoid::MultiConfig.get_adapter(:unknown) } + .to raise_error(Dynamoid::Errors::UnknownConfiguration) + end + end + + describe '.configuration_names' do + it 'returns all configuration names' do + Dynamoid::MultiConfig.add_config(:primary, primary_config) + Dynamoid::MultiConfig.add_config(:secondary, secondary_config) + + names = Dynamoid::MultiConfig.configuration_names + expect(names).to contain_exactly(:primary, :secondary) + end + end + + describe '.configuration_exists?' do + before do + Dynamoid::MultiConfig.add_config(:primary, primary_config) + end + + it 'returns true for existing configuration' do + expect(Dynamoid::MultiConfig.configuration_exists?(:primary)).to be true + end + + it 'returns false for non-existing configuration' do + expect(Dynamoid::MultiConfig.configuration_exists?(:unknown)).to be false + end + end + + describe '.remove_config' do + before do + Dynamoid::MultiConfig.add_config(:primary, primary_config) + end + + it 'removes a configuration' do + Dynamoid::MultiConfig.remove_config(:primary) + expect(Dynamoid::MultiConfig.configuration_exists?(:primary)).to be false + end + end + + describe '.clear_all' do + before do + Dynamoid::MultiConfig.add_config(:primary, primary_config) + Dynamoid::MultiConfig.add_config(:secondary, secondary_config) + end + + it 'removes all configurations' do + Dynamoid::MultiConfig.clear_all + expect(Dynamoid::MultiConfig.configuration_names).to be_empty + end + end +end