diff --git a/README.md b/README.md index 1f7f85fc..fd8037cf 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,70 @@ class UpdateSearchResultsToVersion2 < ActiveRecord::Migration end ``` +## Can I use Scenic with multiple databases? + +You bet! If you're using Rails 6.0 or higher with multiple databases configured, +Scenic has you covered. Just pass a `--database` option when generating your +view: + +```sh +$ rails generate scenic:view analytics --database=secondary + create db/secondary_views/analytics_v01.sql + create db/secondary_migrate/[TIMESTAMP]_create_analytics.rb +``` + +Scenic will create your view definition in a database-specific directory +(`db/secondary_views/` instead of `db/views/`) and the generated migration will +include the `database:` parameter: + +```ruby +class CreateAnalytics < ActiveRecord::Migration[7.0] + def change + create_view :analytics, database: :secondary + end +end +``` + +Run the migration for your secondary database the same way you would for any +Rails multiple database setup: + +```sh +$ rake db:migrate:secondary +``` + +All of Scenic's migration methods accept the `database:` parameter, so you can +create, update, and drop views on any configured database: + +```ruby +def change + create_view :reports, version: 1, database: :secondary + update_view :reports, version: 2, database: :secondary + drop_view :reports, database: :secondary +end +``` + +If you need custom paths for your views or migrations, you can configure them +in `database.yml` just like Rails' `migrations_paths`: + +```yaml +# config/database.yml +secondary: + database: my_secondary_db + migrations_paths: db/secondary_migrate + views_paths: db/secondary_views +``` + +If you're using different adapters for different databases (say, Postgres for +your primary database and MySQL for analytics), you can configure them in an +initializer: + +```ruby +# config/initializers/scenic.rb +Scenic.configure do |config| + config.databases[:secondary] = Scenic::Adapters::Postgres.new(SecondaryRecord) +end +``` + ## I don't need this view anymore. Make it go away. Scenic gives you `drop_view` too: @@ -232,6 +296,7 @@ Scenic gives you `drop_view` too: def change drop_view :search_results, revert_to_version: 2 drop_view :materialized_admin_reports, revert_to_version: 3, materialized: true + drop_view :analytics_view, revert_to_version: 1, database: :secondary end ``` diff --git a/lib/generators/scenic/materializable.rb b/lib/generators/scenic/materializable.rb index b6078237..c9f22f4b 100644 --- a/lib/generators/scenic/materializable.rb +++ b/lib/generators/scenic/materializable.rb @@ -27,6 +27,11 @@ module Materializable required: false, desc: "Uses replace_view instead of update_view", default: false + class_option :database, + type: :string, + required: false, + desc: "The database to use (requires Rails 6.0+)", + default: nil end private @@ -47,6 +52,20 @@ def side_by_side? options[:side_by_side] end + def database + options[:database]&.to_sym + end + + def validate_rails_version_for_multiple_databases! + return unless database + + if Rails::VERSION::MAJOR < 6 + raise ArgumentError, + "Multiple database support requires Rails 6.0 or higher. " \ + "You are using Rails #{Rails::VERSION::STRING}." + end + end + def materialized_view_update_options set_options = {no_data: no_data?, side_by_side: side_by_side?} .select { |_, v| v } diff --git a/lib/generators/scenic/view/templates/db/migrate/update_view.erb b/lib/generators/scenic/view/templates/db/migrate/update_view.erb index 87c94bdb..0296115a 100644 --- a/lib/generators/scenic/view/templates/db/migrate/update_view.erb +++ b/lib/generators/scenic/view/templates/db/migrate/update_view.erb @@ -5,9 +5,9 @@ class <%= migration_class_name %> < <%= activerecord_migration_class %> <%= method_name %> <%= formatted_plural_name %>, version: <%= version %>, revert_to_version: <%= previous_version %>, - materialized: <%= materialized_view_update_options %> + materialized: <%= materialized_view_update_options %><%= update_view_options %> <%- else -%> - <%= method_name %> <%= formatted_plural_name %>, version: <%= version %>, revert_to_version: <%= previous_version %> + <%= method_name %> <%= formatted_plural_name %>, version: <%= version %>, revert_to_version: <%= previous_version %><%= update_view_options %> <%- end -%> end end diff --git a/lib/generators/scenic/view/view_generator.rb b/lib/generators/scenic/view/view_generator.rb index 15133fd6..88d1eabf 100644 --- a/lib/generators/scenic/view/view_generator.rb +++ b/lib/generators/scenic/view/view_generator.rb @@ -11,6 +11,10 @@ class ViewGenerator < Rails::Generators::NamedBase source_root File.expand_path("templates", __dir__) + def validate_multiple_database_support + validate_rails_version_for_multiple_databases! + end + def create_views_directory unless views_directory_path.exist? empty_directory(views_directory_path) @@ -29,12 +33,12 @@ def create_migration_file if creating_new_view? || destroying_initial_view? migration_template( "db/migrate/create_view.erb", - "db/migrate/create_#{plural_file_name}.rb" + File.join(migration_directory, "create_#{plural_file_name}.rb") ) else migration_template( "db/migrate/update_view.erb", - "db/migrate/update_#{plural_file_name}_to_version_#{version}.rb" + File.join(migration_directory, "update_#{plural_file_name}_to_version_#{version}.rb") ) end end @@ -81,7 +85,51 @@ def file_name end def views_directory_path - @views_directory_path ||= Rails.root.join("db", "views") + @views_directory_path ||= begin + db_config = ActiveRecord::Base.configurations.configs_for( + env_name: Rails.env, + name: database_name + ) + + if db_config&.respond_to?(:views_paths) && (configured_path = Array(db_config.views_paths).first) + Rails.root.join(configured_path) + else + conventional_views_path + end + end + end + + def conventional_views_path + if database && database != :default + Rails.root.join("db", "#{database}_views") + else + Rails.root.join("db", "views") + end + end + + def migration_directory + db_config = ActiveRecord::Base.configurations.configs_for( + env_name: Rails.env, + name: database_name + ) + + Array(db_config&.migrations_paths).first || conventional_migration_path + end + + def database_name + (database || :primary).to_s + end + + def conventional_migration_path + if database && database != :default + "db/#{database}_migrate" + else + "db/migrate" + end + end + + def different_database_set? + database && database != :default end def version_regex @@ -93,11 +141,11 @@ def creating_new_view? end def definition - Scenic::Definition.new(plural_file_name, version) + Scenic::Definition.new(plural_file_name, version, database) end def previous_definition - Scenic::Definition.new(plural_file_name, previous_version) + Scenic::Definition.new(plural_file_name, previous_version, database) end def destroying? @@ -113,8 +161,21 @@ def formatted_plural_name end def create_view_options + options = "" if materialized? - ", materialized: #{no_data? ? "{ no_data: true }" : true}" + options << ", materialized: #{no_data? ? "{ no_data: true }" : true}" + end + + if different_database_set? + options << ", database: :#{database}" + end + + options + end + + def update_view_options + if different_database_set? + ", database: :#{database}" else "" end diff --git a/lib/scenic.rb b/lib/scenic.rb index 3f2ab768..49669439 100644 --- a/lib/scenic.rb +++ b/lib/scenic.rb @@ -23,11 +23,14 @@ def self.load ActiveRecord::SchemaDumper.prepend Scenic::SchemaDumper end - # The current database adapter used by Scenic. + # Returns the database adapter for the specified database. # # This defaults to {Adapters::Postgres} but can be overridden # via {Configuration}. - def self.database - configuration.database + # + # @param name [Symbol] the database name (defaults to :default) + # @return [Scenic::Adapters::Postgres] the database adapter + def self.database(name = :default) + configuration.database_adapter(name) end end diff --git a/lib/scenic/configuration.rb b/lib/scenic/configuration.rb index aec067fa..0cc5f37e 100644 --- a/lib/scenic/configuration.rb +++ b/lib/scenic/configuration.rb @@ -1,13 +1,39 @@ module Scenic class Configuration + # Collection of database adapters for multi-database support. + # Access specific databases using database names as keys. + # + # @example + # Scenic.configure do |config| + # config.databases[:secondary] = Scenic::Adapters::Postgres.new(SecondaryRecord) + # end + # + # @return [ActiveSupport::OrderedOptions] hash of database adapters + attr_reader :databases + # The Scenic database adapter instance to use when executing SQL. # # Defaults to an instance of {Adapters::Postgres} # @return Scenic adapter - attr_accessor :database + def database + @databases[:default] + end + + def database=(adapter) + @databases[:default] = adapter + end def initialize - @database = Scenic::Adapters::Postgres.new + @databases = ActiveSupport::OrderedOptions.new + @databases[:default] = Scenic::Adapters::Postgres.new + end + + # Returns the database adapter for the specified database name. + # + # @param name [Symbol] the database name (defaults to :default) + # @return [Scenic::Adapters::Postgres] the database adapter + def database_adapter(name = :default) + @databases[name] || @databases[:default] end end diff --git a/lib/scenic/definition.rb b/lib/scenic/definition.rb index 639cee3c..09001e10 100644 --- a/lib/scenic/definition.rb +++ b/lib/scenic/definition.rb @@ -1,9 +1,10 @@ module Scenic # @api private class Definition - def initialize(name, version) + def initialize(name, version, database = nil) @name = name.to_s @version = version.to_i + @database = database end def to_sql @@ -19,7 +20,12 @@ def full_path end def path - File.join("db", "views", filename) + views_dir = if @database && @database != :default + "#{@database}_views" + else + "views" + end + File.join("db", views_dir, filename) end def version diff --git a/lib/scenic/statements.rb b/lib/scenic/statements.rb index 14e1f4a5..acabe8cc 100644 --- a/lib/scenic/statements.rb +++ b/lib/scenic/statements.rb @@ -14,6 +14,8 @@ module Statements # @option materialized [Boolean] :no_data (false) Set to true to create # materialized view without running the associated query. You will need # to perform a non-concurrent refresh to populate with data. + # @param database [Symbol] The database to use (requires Rails 6.0+). + # Defaults to :default. # @return The database response from executing the create statement. # # @example Create from `db/views/searches_v02.sql` @@ -24,7 +26,10 @@ module Statements # SELECT * FROM users WHERE users.active = 't' # SQL # - def create_view(name, version: nil, sql_definition: nil, materialized: false) + # @example Create view on secondary database + # create_view(:searches, version: 2, database: :secondary) + # + def create_view(name, version: nil, sql_definition: nil, materialized: false, database: :default) if version.present? && sql_definition.present? raise( ArgumentError, @@ -36,18 +41,18 @@ def create_view(name, version: nil, sql_definition: nil, materialized: false) version = 1 end - sql_definition ||= definition(name, version) + sql_definition ||= definition(name, version, database) if materialized options = materialized_options(materialized) - Scenic.database.create_materialized_view( + Scenic.database(database).create_materialized_view( name, sql_definition, no_data: options[:no_data] ) else - Scenic.database.create_view(name, sql_definition) + Scenic.database(database).create_view(name, sql_definition) end end @@ -59,16 +64,18 @@ def create_view(name, version: nil, sql_definition: nil, materialized: false) # `version` argument to {#create_view}. # @param materialized [Boolean] Set to true if dropping a meterialized view. # defaults to false. + # @param database [Symbol] The database to use (requires Rails 6.0+). + # Defaults to :default. # @return The database response from executing the drop statement. # # @example Drop a view, rolling back to version 3 on rollback # drop_view(:users_who_recently_logged_in, revert_to_version: 3) # - def drop_view(name, revert_to_version: nil, materialized: false) + def drop_view(name, revert_to_version: nil, materialized: false, database: :default) if materialized - Scenic.database.drop_materialized_view(name) + Scenic.database(database).drop_materialized_view(name) else - Scenic.database.drop_view(name) + Scenic.database(database).drop_view(name) end end @@ -96,12 +103,14 @@ def drop_view(name, revert_to_version: nil, materialized: false) # The view is initially updated with a temporary name and atomically # swapped once it is successfully created with data. Cannot be combined # with the :no_data option. + # @param database [Symbol] The database to use (requires Rails 6.0+). + # Defaults to :default. # @return The database response from executing the create statement. # # @example # update_view :engagement_reports, version: 3, revert_to_version: 2 # update_view :comments, version: 2, revert_to_version: 1, materialized: { side_by_side: true } - def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, materialized: false) + def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, materialized: false, database: :default) if version.blank? && sql_definition.blank? raise( ArgumentError, @@ -116,7 +125,7 @@ def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, ) end - sql_definition ||= definition(name, version) + sql_definition ||= definition(name, version, database) if materialized options = materialized_options(materialized) @@ -132,14 +141,14 @@ def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, raise "a transaction is required to perform a side-by-side update" end - Scenic.database.update_materialized_view( + Scenic.database(database).update_materialized_view( name, sql_definition, no_data: options[:no_data], side_by_side: options[:side_by_side] ) else - Scenic.database.update_view(name, sql_definition) + Scenic.database(database).update_view(name, sql_definition) end end @@ -154,12 +163,14 @@ def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, # @param version [Fixnum] The version number of the view. # @param revert_to_version [Fixnum] The version number to rollback to on # `rake db rollback` + # @param database [Symbol] The database to use (requires Rails 6.0+). + # Defaults to :default. # @return The database response from executing the create statement. # # @example # replace_view :engagement_reports, version: 3, revert_to_version: 2 # - def replace_view(name, version: nil, revert_to_version: nil, materialized: false) + def replace_view(name, version: nil, revert_to_version: nil, materialized: false, database: :default) if version.blank? raise ArgumentError, "version is required" end @@ -168,15 +179,16 @@ def replace_view(name, version: nil, revert_to_version: nil, materialized: false raise ArgumentError, "Cannot replace materialized views" end - sql_definition = definition(name, version) + sql_definition = definition(name, version, database) - Scenic.database.replace_view(name, sql_definition) + Scenic.database(database).replace_view(name, sql_definition) end private - def definition(name, version) - Scenic::Definition.new(name, version).to_sql + def definition(name, version, database = nil) + db = (database == :default) ? nil : database + Scenic::Definition.new(name, version, db).to_sql end def materialized_options(materialized) diff --git a/scenic.gemspec b/scenic.gemspec index 276beb75..73944cdb 100644 --- a/scenic.gemspec +++ b/scenic.gemspec @@ -34,6 +34,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "standard" spec.add_dependency "activerecord", ">= 4.0.0" + spec.add_dependency "activesupport", ">= 4.0.0" spec.add_dependency "railties", ">= 4.0.0" spec.required_ruby_version = ">= 2.3.0" diff --git a/spec/acceptance/user_manages_multiple_database_views_spec.rb b/spec/acceptance/user_manages_multiple_database_views_spec.rb new file mode 100644 index 00000000..97937b41 --- /dev/null +++ b/spec/acceptance/user_manages_multiple_database_views_spec.rb @@ -0,0 +1,95 @@ +require "acceptance_helper" +require "English" +require "fileutils" + +describe "User manages views across multiple databases", :db do + it "handles views on secondary database" do + # Generate a view for the secondary database + successfully "rails generate scenic:model analytics --database=secondary" + + # Verify the migration was created in a database-specific migration directory + # Either the configured path (db/secondary_migrate) or the convention + migrations = Dir["db/**/*_create_analytics.rb"] + expect(migrations).not_to be_empty + expect(migrations.first).to match(/db\/(secondary_migrate)\/.*_create_analytics\.rb/) + + # Verify the view definition was created in the secondary database views directory + expect(File.exist?("db/secondary_views/analytics_v01.sql")).to be true + + # Write a view definition + write_definition_in_dir "secondary_views", "analytics_v01", "SELECT 'data'::text AS value" + # successfully "rake db:migrate:secondary" + successfully "rake db:migrate:secondary" + verify_result "Analytic.take.value", "data" + + # Verify we can update to a new version + successfully "rails generate scenic:view analytics --database=secondary" + verify_identical_view_definitions_in_dir "secondary_views", "analytics_v01", "analytics_v02" + + # Update the view definition + write_definition_in_dir "secondary_views", "analytics_v02", "SELECT 'new_data'::text AS value" + successfully "rake db:migrate:secondary" + verify_result "Analytic.take.value", "new_data" + + # Clean up + successfully "rake db:rollback:secondary" + successfully "rake db:rollback:secondary" + successfully "rails destroy scenic:model analytics --database=secondary" + end + + it "keeps views on default and secondary databases separate" do + # Generate view on default database + successfully "rails generate scenic:view reports" + write_definition "reports_v01", "SELECT 'default_data'::text AS result" + + # Generate view on secondary database + successfully "rails generate scenic:view metrics --database=secondary" + write_definition_in_dir "secondary_views", "metrics_v01", "SELECT 'secondary_data'::text AS result" + + # Verify migrations are in separate directories + expect(Dir["db/migrate/*_create_reports.rb"]).not_to be_empty + # Check for either configured path or convention-based path + secondary_migrations = Dir["db/**/*_create_metrics.rb"].select { |p| p !~ /db\/migrate\/[^\/]*_create_metrics\.rb/ } + expect(secondary_migrations).not_to be_empty + + # Verify view definitions are in separate directories + expect(File.exist?("db/views/reports_v01.sql")).to be true + expect(File.exist?("db/secondary_views/metrics_v01.sql")).to be true + + successfully "rake db:rollback:primary" + successfully "rake db:rollback:secondary" + # Clean up + successfully "rails destroy scenic:view reports" + successfully "rails destroy scenic:view metrics --database=secondary" + end + + private + + def verify_result(command, expected_output) + successfully %{rails runner "#{command} == '#{expected_output}' || exit(1)"} + end + + def successfully(command) + `RAILS_ENV=test #{command}` + expect($CHILD_STATUS.exitstatus).to eq(0), "'#{command}' was unsuccessful" + end + + def write_definition(file, contents) + File.open("db/views/#{file}.sql", File::WRONLY) do |definition| + definition.truncate(0) + definition.write(contents) + end + end + + def write_definition_in_dir(dir, filename, sql) + FileUtils.mkdir_p("db/#{dir}") + File.open("db/#{dir}/#{filename}.sql", File::WRONLY | File::CREAT) do |definition| + definition.truncate(0) + definition.write(sql) + end + end + + def verify_identical_view_definitions_in_dir(dir, first, second) + successfully "cmp db/#{dir}/#{first}.sql db/#{dir}/#{second}.sql" + end +end diff --git a/spec/acceptance/user_manages_views_spec.rb b/spec/acceptance/user_manages_views_spec.rb index fa106faf..965130aa 100644 --- a/spec/acceptance/user_manages_views_spec.rb +++ b/spec/acceptance/user_manages_views_spec.rb @@ -18,8 +18,8 @@ successfully "rake db:reset" verify_result "SearchResult.take.term", "haystack" - successfully "rake db:rollback" - successfully "rake db:rollback" + successfully "rake db:rollback:primary" + successfully "rake db:rollback:primary" successfully "rails destroy scenic:model search_result" end @@ -55,9 +55,9 @@ verify_result "Child.take.name", "Juniper" verify_schema_contains 'add_index "children"' - successfully "rake db:rollback" - successfully "rake db:rollback" - successfully "rake db:rollback" + successfully "rake db:rollback:primary" + successfully "rake db:rollback:primary" + successfully "rake db:rollback:primary" successfully "rails destroy scenic:model child" end diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml index 5f43185a..8fe44b86 100644 --- a/spec/dummy/config/database.yml +++ b/spec/dummy/config/database.yml @@ -1,14 +1,25 @@ -development: &default - adapter: postgresql - database: dummy_development - encoding: unicode - host: localhost - pool: 5 - <% if ENV.fetch("GITHUB_ACTIONS", false) || ENV.fetch("CODESPACES", false) %> - username: <%= ENV.fetch("POSTGRES_USER") %> - password: <%= ENV.fetch("POSTGRES_PASSWORD") %> - <% end %> +development: + primary: &default + adapter: postgresql + database: dummy_development + encoding: unicode + host: localhost + pool: 5 + <% if ENV.fetch("GITHUB_ACTIONS", false) || ENV.fetch("CODESPACES", false) %> + username: <%= ENV.fetch("POSTGRES_USER") %> + password: <%= ENV.fetch("POSTGRES_PASSWORD") %> + <% end %> + secondary: + <<: *default + database: dummy_development2 + migrations_paths: db/secondary_migrate + test: - <<: *default - database: dummy_test + primary: + <<: *default + database: dummy_test + secondary: + <<: *default + database: dummy_test2 + migrations_paths: db/secondary_migrate diff --git a/spec/dummy/db/secondary_schema.rb b/spec/dummy/db/secondary_schema.rb new file mode 100644 index 00000000..e46e48f3 --- /dev/null +++ b/spec/dummy/db/secondary_schema.rb @@ -0,0 +1,16 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define(version: 0) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" +end diff --git a/spec/scenic/configuration_spec.rb b/spec/scenic/configuration_spec.rb index c38d1270..386a7e0d 100644 --- a/spec/scenic/configuration_spec.rb +++ b/spec/scenic/configuration_spec.rb @@ -20,6 +20,73 @@ module Scenic expect(Scenic.database).to eq adapter end + it "allows multiple database adapters to be configured" do + secondary_adapter = double("Secondary Adapter") + + Scenic.configure do |config| + config.databases[:secondary] = secondary_adapter + end + + expect(Scenic.configuration.databases[:secondary]).to eq secondary_adapter + end + + it "returns the specified database adapter via Scenic.database(name)" do + secondary_adapter = double("Secondary Adapter") + + Scenic.configure do |config| + config.databases[:secondary] = secondary_adapter + end + + expect(Scenic.database(:secondary)).to eq secondary_adapter + end + + it "returns default adapter when database not found" do + expect(Scenic.database(:nonexistent)).to eq Scenic.database(:default) + end + + it "database= sets the default adapter" do + adapter = double("Custom Adapter") + + Scenic.configure do |config| + config.database = adapter + end + + expect(Scenic.database).to eq adapter + expect(Scenic.database(:default)).to eq adapter + end + + describe "adapter routing with different adapter types" do + it "routes to the correct adapter for each database" do + primary_adapter = FakeAdapter.new("FakePostgres") + secondary_adapter = FakeAdapter.new("FakeMySQL") + + Scenic.configure do |config| + config.databases[:default] = primary_adapter + config.databases[:secondary] = secondary_adapter + end + + expect(Scenic.database(:default)).to eq primary_adapter + expect(Scenic.database(:secondary)).to eq secondary_adapter + expect(Scenic.database(:default).name).to eq "FakePostgres" + expect(Scenic.database(:secondary).name).to eq "FakeMySQL" + end + + it "maintains adapter independence" do + adapter_a = FakeAdapter.new("AdapterA") + adapter_b = FakeAdapter.new("AdapterB") + + Scenic.configure do |config| + config.databases[:db_a] = adapter_a + config.databases[:db_b] = adapter_b + end + + Scenic.database(:db_a).create_view(:users, "SELECT 1") + + expect(adapter_a.called?(:create_view)).to be true + expect(adapter_b.called?(:create_view)).to be false + end + end + def restore_default_config Scenic.configuration = Configuration.new end diff --git a/spec/scenic/definition_spec.rb b/spec/scenic/definition_spec.rb index e840ac6b..f6ac4669 100644 --- a/spec/scenic/definition_spec.rb +++ b/spec/scenic/definition_spec.rb @@ -43,6 +43,30 @@ module Scenic expect(definition.path).to eq "db/views/searches_v01.sql" end end + + it "returns path in secondary_views for secondary database" do + definition = Definition.new("analytics", 1, :secondary) + + expect(definition.path).to eq "db/secondary_views/analytics_v01.sql" + end + + it "returns path in views for default database" do + definition = Definition.new("analytics", 1, :default) + + expect(definition.path).to eq "db/views/analytics_v01.sql" + end + + it "returns path in views for nil database" do + definition = Definition.new("analytics", 1, nil) + + expect(definition.path).to eq "db/views/analytics_v01.sql" + end + + it "handles custom database names in path" do + definition = Definition.new("reports", 5, :warehouse) + + expect(definition.path).to eq "db/warehouse_views/reports_v05.sql" + end end describe "full_path" do diff --git a/spec/scenic/statements_spec.rb b/spec/scenic/statements_spec.rb index c632f4dd..ba284db4 100644 --- a/spec/scenic/statements_spec.rb +++ b/spec/scenic/statements_spec.rb @@ -12,7 +12,7 @@ module Scenic version = 15 definition_stub = instance_double("Definition", to_sql: "foo") allow(Definition).to receive(:new) - .with(:views, version) + .with(:views, version, nil) .and_return(definition_stub) connection.create_view :views, version: version @@ -34,7 +34,7 @@ module Scenic version = 1 definition_stub = instance_double("Definition", to_sql: "foo") allow(Definition).to receive(:new) - .with(:views, version) + .with(:views, version, nil) .and_return(definition_stub) connection.create_view :views @@ -98,7 +98,7 @@ module Scenic it "updates the view in the database" do definition = instance_double("Definition", to_sql: "definition") allow(Definition).to receive(:new) - .with(:name, 3) + .with(:name, 3, nil) .and_return(definition) connection.update_view(:name, version: 3) @@ -119,7 +119,7 @@ module Scenic it "updates the materialized view in the database" do definition = instance_double("Definition", to_sql: "definition") allow(Definition).to receive(:new) - .with(:name, 3) + .with(:name, 3, nil) .and_return(definition) connection.update_view(:name, version: 3, materialized: true) @@ -131,7 +131,7 @@ module Scenic it "updates the materialized view in the database with NO DATA" do definition = instance_double("Definition", to_sql: "definition") allow(Definition).to receive(:new) - .with(:name, 3) + .with(:name, 3, nil) .and_return(definition) connection.update_view( @@ -147,7 +147,7 @@ module Scenic it "updates the materialized view with side-by-side mode" do definition = instance_double("Definition", to_sql: "definition") allow(Definition).to receive(:new) - .with(:name, 3) + .with(:name, 3, nil) .and_return(definition) connection.update_view( @@ -180,7 +180,7 @@ module Scenic it "raises an error is no_data and side_by_side are both set" do definition = instance_double("Definition", to_sql: "definition") allow(Definition).to receive(:new) - .with(:name, 3) + .with(:name, 3, nil) .and_return(definition) expect do @@ -195,7 +195,7 @@ module Scenic it "raises an error if not in a transaction" do definition = instance_double("Definition", to_sql: "definition") allow(Definition).to receive(:new) - .with(:name, 3) + .with(:name, 3, nil) .and_return(definition) expect do @@ -212,7 +212,7 @@ module Scenic it "replaces the view in the database" do definition = instance_double("Definition", to_sql: "definition") allow(Definition).to receive(:new) - .with(:name, 3) + .with(:name, 3, nil) .and_return(definition) connection.replace_view(:name, version: 3) @@ -224,7 +224,7 @@ module Scenic it "fails to replace the materialized view in the database" do definition = instance_double("Definition", to_sql: "definition") allow(Definition).to receive(:new) - .with(:name, 3) + .with(:name, 3, nil) .and_return(definition) expect do @@ -238,6 +238,233 @@ module Scenic end end + describe "multiple database support" do + describe "create_view with database parameter" do + it "calls the specified database adapter" do + secondary_adapter = instance_double("Scenic::Adapters::Postgres").as_null_object + allow(Scenic).to receive(:database).with(:secondary).and_return(secondary_adapter) + + definition = instance_double("Definition", to_sql: "SELECT 1") + allow(Definition).to receive(:new) + .with(:analytics, 1, :secondary) + .and_return(definition) + + connection.create_view(:analytics, version: 1, database: :secondary) + + expect(secondary_adapter).to have_received(:create_view) + .with(:analytics, definition.to_sql) + end + + it "uses default adapter when database parameter is :default" do + default_adapter = instance_double("Scenic::Adapters::Postgres").as_null_object + allow(Scenic).to receive(:database).with(:default).and_return(default_adapter) + + definition = instance_double("Definition", to_sql: "SELECT 1") + allow(Definition).to receive(:new) + .with(:users, 1, nil) + .and_return(definition) + + connection.create_view(:users, version: 1, database: :default) + + expect(default_adapter).to have_received(:create_view) + end + end + + describe "drop_view with database parameter" do + it "calls the specified database adapter" do + secondary_adapter = instance_double("Scenic::Adapters::Postgres").as_null_object + allow(Scenic).to receive(:database).with(:secondary).and_return(secondary_adapter) + + connection.drop_view(:analytics, database: :secondary) + + expect(secondary_adapter).to have_received(:drop_view).with(:analytics) + end + end + + describe "update_view with database parameter" do + it "calls the specified database adapter" do + secondary_adapter = instance_double("Scenic::Adapters::Postgres").as_null_object + allow(Scenic).to receive(:database).with(:secondary).and_return(secondary_adapter) + + definition = instance_double("Definition", to_sql: "SELECT 2") + allow(Definition).to receive(:new) + .with(:analytics, 2, :secondary) + .and_return(definition) + + connection.update_view(:analytics, version: 2, database: :secondary) + + expect(secondary_adapter).to have_received(:update_view) + .with(:analytics, definition.to_sql) + end + end + + describe "replace_view with database parameter" do + it "calls the specified database adapter" do + secondary_adapter = instance_double("Scenic::Adapters::Postgres").as_null_object + allow(Scenic).to receive(:database).with(:secondary).and_return(secondary_adapter) + + definition = instance_double("Definition", to_sql: "SELECT 2") + allow(Definition).to receive(:new) + .with(:analytics, 2, :secondary) + .and_return(definition) + + connection.replace_view(:analytics, version: 2, database: :secondary) + + expect(secondary_adapter).to have_received(:replace_view) + .with(:analytics, definition.to_sql) + end + end + + describe "materialized views with database parameter" do + it "creates materialized view on specified database" do + secondary_adapter = instance_double("Scenic::Adapters::Postgres").as_null_object + allow(Scenic).to receive(:database).with(:secondary).and_return(secondary_adapter) + + definition = instance_double("Definition", to_sql: "SELECT 1") + allow(Definition).to receive(:new) + .with(:reports, 1, :secondary) + .and_return(definition) + + connection.create_view( + :reports, + version: 1, + database: :secondary, + materialized: true + ) + + expect(secondary_adapter).to have_received(:create_materialized_view) + .with(:reports, definition.to_sql, no_data: false) + end + + it "updates materialized view on specified database" do + secondary_adapter = instance_double("Scenic::Adapters::Postgres").as_null_object + allow(Scenic).to receive(:database).with(:secondary).and_return(secondary_adapter) + + definition = instance_double("Definition", to_sql: "SELECT 2") + allow(Definition).to receive(:new) + .with(:reports, 2, :secondary) + .and_return(definition) + + connection.update_view( + :reports, + version: 2, + database: :secondary, + materialized: true + ) + + expect(secondary_adapter).to have_received(:update_materialized_view) + .with(:reports, definition.to_sql, no_data: false, side_by_side: false) + end + + it "drops materialized view on specified database" do + secondary_adapter = instance_double("Scenic::Adapters::Postgres").as_null_object + allow(Scenic).to receive(:database).with(:secondary).and_return(secondary_adapter) + + connection.drop_view(:reports, database: :secondary, materialized: true) + + expect(secondary_adapter).to have_received(:drop_materialized_view) + end + end + + describe "adapter routing with different adapter types" do + before do + allow(Scenic).to receive(:database).and_call_original + end + + after do + Scenic.configuration = Configuration.new + end + + it "routes view operations to the correct adapter" do + postgres_adapter = FakeAdapter.new("Postgres") + mysql_adapter = FakeAdapter.new("MySQL") + + Scenic.configure do |config| + config.databases[:default] = postgres_adapter + config.databases[:secondary] = mysql_adapter + end + + allow(Definition).to receive(:new).and_return( + instance_double("Definition", to_sql: "SELECT 1") + ) + + connection.create_view(:users, version: 1, database: :default) + connection.create_view(:analytics, version: 1, database: :secondary) + + expect(postgres_adapter.call_count(:create_view)).to eq 1 + expect(mysql_adapter.call_count(:create_view)).to eq 1 + + postgres_call = postgres_adapter.calls.find { |c| c[:method] == :create_view } + mysql_call = mysql_adapter.calls.find { |c| c[:method] == :create_view } + + expect(postgres_call[:args][:name]).to eq :users + expect(mysql_call[:args][:name]).to eq :analytics + end + + it "routes materialized view operations to the correct adapter" do + postgres_adapter = FakeAdapter.new("Postgres") + mysql_adapter = FakeAdapter.new("MySQL") + + Scenic.configure do |config| + config.databases[:default] = postgres_adapter + config.databases[:warehouse] = mysql_adapter + end + + allow(Definition).to receive(:new).and_return( + instance_double("Definition", to_sql: "SELECT 1") + ) + + connection.create_view( + :reports, + version: 1, + database: :default, + materialized: true + ) + connection.create_view( + :aggregates, + version: 1, + database: :warehouse, + materialized: true + ) + + expect(postgres_adapter.called?(:create_materialized_view)).to be true + expect(mysql_adapter.called?(:create_materialized_view)).to be true + + postgres_call = postgres_adapter.calls.find { |c| c[:method] == :create_materialized_view } + mysql_call = mysql_adapter.calls.find { |c| c[:method] == :create_materialized_view } + + expect(postgres_call[:args][:name]).to eq :reports + expect(mysql_call[:args][:name]).to eq :aggregates + end + + it "does not cross-contaminate adapter calls" do + adapter_a = FakeAdapter.new("AdapterA") + adapter_b = FakeAdapter.new("AdapterB") + + Scenic.configure do |config| + config.databases[:db_a] = adapter_a + config.databases[:db_b] = adapter_b + end + + allow(Definition).to receive(:new).and_return( + instance_double("Definition", to_sql: "SELECT 1") + ) + + connection.create_view(:view1, version: 1, database: :db_a) + connection.update_view(:view2, version: 2, database: :db_b) + connection.drop_view(:view3, database: :db_a) + + expect(adapter_a.call_count(:create_view)).to eq 1 + expect(adapter_a.call_count(:update_view)).to eq 0 + expect(adapter_a.call_count(:drop_view)).to eq 1 + + expect(adapter_b.call_count(:create_view)).to eq 0 + expect(adapter_b.call_count(:update_view)).to eq 1 + expect(adapter_b.call_count(:drop_view)).to eq 0 + end + end + end + def connection(transactions_enabled: true) DummyConnection.new(transactions_enabled: transactions_enabled) end diff --git a/spec/support/fake_adapter.rb b/spec/support/fake_adapter.rb new file mode 100644 index 00000000..fc528f49 --- /dev/null +++ b/spec/support/fake_adapter.rb @@ -0,0 +1,68 @@ +class FakeAdapter + attr_reader :name, :calls + + def initialize(name) + @name = name + @calls = [] + end + + def views + record_call(__method__) + [] + end + + def create_view(name, sql_definition) + record_call(__method__, name: name, sql_definition: sql_definition) + end + + def update_view(name, sql_definition) + record_call(__method__, name: name, sql_definition: sql_definition) + end + + def replace_view(name, sql_definition) + record_call(__method__, name: name, sql_definition: sql_definition) + end + + def drop_view(name) + record_call(__method__, name: name) + end + + def create_materialized_view(name, sql_definition, no_data: false) + record_call(__method__, name: name, sql_definition: sql_definition, no_data: no_data) + end + + def update_materialized_view(name, sql_definition, no_data: false, side_by_side: false) + record_call(__method__, name: name, sql_definition: sql_definition, no_data: no_data, side_by_side: side_by_side) + end + + def drop_materialized_view(name) + record_call(__method__, name: name) + end + + def refresh_materialized_view(name, concurrently: false, cascade: false) + record_call(__method__, name: name, concurrently: concurrently, cascade: cascade) + end + + def populated?(name) + record_call(__method__, name: name) + true + end + + def connection + self + end + + def called?(method_name) + @calls.any? { |call| call[:method] == method_name } + end + + def call_count(method_name) + @calls.count { |call| call[:method] == method_name } + end + + private + + def record_call(method_name, **args) + @calls << {method: method_name, args: args, adapter: @name} + end +end