diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ccbc7c1..baa50212 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Run standardrb uses: standardrb/standard-ruby-action@f533e61f461ccb766b2d9c235abf59be02aea793 @@ -26,13 +26,21 @@ jobs: build: name: Ruby ${{ matrix.ruby }}, Rails ${{ matrix.rails }} + continue-on-error: ${{ matrix.continue-on-error }} strategy: fail-fast: false matrix: - ruby: ["3.1", "3.2"] - rails: ["6.1", "7.0"] + ruby: ["3.4", "3.3"] + rails: ["8.0", "7.2"] continue-on-error: [false] + include: + - ruby: "3.4" + rails: "main" + continue-on-error: true + - ruby: "head" + rails: "main" + continue-on-error: true runs-on: ubuntu-latest @@ -57,7 +65,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install Ruby ${{ matrix.ruby }} uses: ruby/setup-ruby@v1 @@ -71,7 +79,7 @@ jobs: run: bundle lock - name: Cache dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: vendor/bundle key: bundle-${{ hashFiles('Gemfile.lock') }} diff --git a/.gitignore b/.gitignore index 92bd1ab4..fc45e753 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ tmp gemfiles/*.lock .DS_Store .ruby-version +.vscode/ diff --git a/Gemfile b/Gemfile index 5fffd0bf..eafc2e3a 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" # Specify your gem's dependencies in scenic.gemspec gemspec -rails_version = ENV.fetch("RAILS_VERSION", "7.0") +rails_version = ENV.fetch("RAILS_VERSION", "8.0") rails_constraint = if rails_version == "main" {github: "rails/rails"} diff --git a/README.md b/README.md index 37f5f361..83baf3c9 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,8 @@ hierarchies of dependent views. Scenic offers a `replace_view` schema statement, resulting in a `CREATE OR REPLACE VIEW` SQL query which will update the supplied view in place, retaining -all dependencies. Materialized views cannot be replaced in this fashion. +all dependencies. Materialized views cannot be replaced in this fashion, though +the `side_by_side` update strategy may yield similar results (see below). You can generate a migration that uses the `replace_view` schema statement by passing the `--replace` option to the `scenic:view` generator: @@ -137,7 +138,7 @@ end ``` Scenic even provides a `scenic:model` generator that is a superset of -`scenic:view`. It will act identically to the Rails `model` generator except +`scenic:view`. It will act identically to the Rails `model` generator except that it will create a Scenic view migration rather than a table migration. There is no special base class or mixin needed. If desired, any code the model @@ -185,6 +186,44 @@ you would need to refresh view B first, then right after refresh view A. If you would like this cascading refresh of materialized views, set `cascade: true` when you refresh your materialized view. +## Can I update the definition of a materialized view without dropping it? + +No, but Scenic can help you approximate this behavior with its `side_by_side` +update strategy. + +Generally, changing the definition of a materialized view requires dropping it +and recreating it, either without data or with a non-concurrent refresh. The +materialized view will be locked for selects during the refresh process, which +can cause problems in your application if the refresh is not fast. + +The `side_by_side` update strategy prepares the new version of the view under a +temporary name. This includes copying the indexes from the original view and +refreshing the data. Once prepared, the original view is dropped and the new +view is renamed to the original view's name. This process minimizes the time the +view is locked for selects at the cost of additional disk space. + +You can generate a migration that uses the `side_by_side` strategy by passing +the `--side-by-side` option to the `scenic:view` generator: + +```sh +$ rails generate scenic:view search_results --materialized --side-by-side + create db/views/search_results_v02.sql + create db/migrate/[TIMESTAMP]_update_search_results_to_version_2.rb +``` + +The migration will look something like this: + +```ruby +class UpdateSearchResultsToVersion2 < ActiveRecord::Migration + def change + update_view :search_results, + version: 2, + revert_to_version: 1, + materialized: { side_by_side: true } + end +end +``` + ## I don't need this view anymore. Make it go away. Scenic gives you `drop_view` too: @@ -234,7 +273,7 @@ It's our experience that maintaining a library effectively requires regular use of its features. We're not in a good position to support MySQL, SQLite or other database users. -Scenic *does* support configuring different database adapters and should be +Scenic _does_ support configuring different database adapters and should be extendable with adapter libraries. If you implement such an adapter, we're happy to review and link to it. We're also happy to make changes that would better accommodate adapter gems. @@ -242,10 +281,10 @@ accommodate adapter gems. We are aware of the following existing adapter libraries for Scenic which may meet your needs: -* [`scenic_sqlite_adapter`]() -* [`scenic-mysql_adapter`]() -* [`scenic-sqlserver-adapter`]() -* [`scenic-oracle_adapter`]() +- [`scenic_sqlite_adapter`](https://github.com/pdebelak/scenic_sqlite_adapter) +- [`scenic-mysql_adapter`](https://github.com/cainlevy/scenic-mysql_adapter) +- [`scenic-sqlserver-adapter`](https://github.com/ClickMechanic/scenic_sqlserver_adapter) +- [`scenic-oracle_adapter`](https://github.com/cdinger/scenic-oracle_adapter) Please note that the maintainers of Scenic make no assertions about the quality or security of the above adapters. @@ -255,26 +294,24 @@ quality or security of the above adapters. ### Used By Scenic is used by some popular open source Rails apps: -[Mastodon](), -[Code.org](), and -[Lobste.rs](). +[Mastodon](https://github.com/mastodon/mastodon/), +[Code.org](https://github.com/code-dot-org/code-dot-org), and +[Lobste.rs](https://github.com/lobsters/lobsters/). ### Related projects -- [`fx`]() Versioned database functions and +- [`fx`](https://github.com/teoljungberg/fx) Versioned database functions and triggers for Rails - ### Media Here are a few posts we've seen discussing Scenic: -- [Announcing Scenic - Versioned Database Views for Rails]() by Derek Prior for thoughtbot -- [Effectively Using Materialized Views in Ruby on Rails]() by Leigh Halliday for pganalyze -- [Optimizing String Concatenation in Ruby on Rails]() -- [Materialized Views In Ruby On Rails With Scenic]() by Dawid Karczewski for Ideamotive -- [Using Scenic and SQL views to aggregate data]() by André Perdigão for Redlight Software - +- [Announcing Scenic - Versioned Database Views for Rails](https://thoughtbot.com/blog/announcing-scenic--versioned-database-views-for-rails) by Derek Prior for thoughtbot +- [Effectively Using Materialized Views in Ruby on Rails](https://pganalyze.com/blog/materialized-views-ruby-rails) by Leigh Halliday for pganalyze +- [Optimizing String Concatenation in Ruby on Rails](https://dev.to/pimp_my_ruby/from-slow-to-lightning-fast-optimizing-string-concatenation-in-ruby-on-rails-28nk) +- [Materialized Views In Ruby On Rails With Scenic](https://www.ideamotive.co/blog/materialized-views-ruby-rails-scenic) by Dawid Karczewski for Ideamotive +- [Using Scenic and SQL views to aggregate data](https://dev.to/weareredlight/using-scenic-and-sql-views-to-aggregate-data-226k) by André Perdigão for Redlight Software ### Maintainers diff --git a/lib/generators/scenic/materializable.rb b/lib/generators/scenic/materializable.rb index 7e7a6cdf..b6078237 100644 --- a/lib/generators/scenic/materializable.rb +++ b/lib/generators/scenic/materializable.rb @@ -14,7 +14,14 @@ module Materializable type: :boolean, required: false, desc: "Adds WITH NO DATA when materialized view creates/updates", - default: false + default: false, + aliases: ["--no-data"] + class_option :side_by_side, + type: :boolean, + required: false, + desc: "Uses side-by-side strategy to update materialized view", + default: false, + aliases: ["--side-by-side"] class_option :replace, type: :boolean, required: false, @@ -35,6 +42,25 @@ def replace_view? def no_data? options[:no_data] end + + def side_by_side? + options[:side_by_side] + end + + def materialized_view_update_options + set_options = {no_data: no_data?, side_by_side: side_by_side?} + .select { |_, v| v } + + if set_options.empty? + "true" + else + string_options = set_options.reduce("") do |memo, (key, value)| + memo + "#{key}: #{value}, " + end + + "{ #{string_options.chomp(", ")} }" + end + end end end end 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 906baefd..87c94bdb 100644 --- a/lib/generators/scenic/view/templates/db/migrate/update_view.erb +++ b/lib/generators/scenic/view/templates/db/migrate/update_view.erb @@ -5,7 +5,7 @@ class <%= migration_class_name %> < <%= activerecord_migration_class %> <%= method_name %> <%= formatted_plural_name %>, version: <%= version %>, revert_to_version: <%= previous_version %>, - materialized: <%= no_data? ? "{ no_data: true }" : true %> + materialized: <%= materialized_view_update_options %> <%- else -%> <%= method_name %> <%= formatted_plural_name %>, version: <%= version %>, revert_to_version: <%= previous_version %> <%- end -%> diff --git a/lib/scenic/adapters/postgres.rb b/lib/scenic/adapters/postgres.rb index 9941832a..b9cd13e3 100644 --- a/lib/scenic/adapters/postgres.rb +++ b/lib/scenic/adapters/postgres.rb @@ -4,6 +4,10 @@ require_relative "postgres/indexes" require_relative "postgres/views" require_relative "postgres/refresh_dependencies" +require_relative "postgres/side_by_side" +require_relative "postgres/index_creation" +require_relative "postgres/index_migration" +require_relative "postgres/temporary_name" module Scenic # Scenic database adapters. @@ -14,7 +18,7 @@ module Scenic module Adapters # An adapter for managing Postgres views. # - # These methods are used interally by Scenic and are not intended for direct + # These methods are used internally by Scenic and are not intended for direct # use. Methods that alter database schema are intended to be called via # {Statements}, while {#refresh_materialized_view} is called via # {Scenic.database}. @@ -124,7 +128,7 @@ def drop_view(name) # @param sql_definition The SQL schema that defines the materialized view. # @param no_data [Boolean] Default: 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. + # to perform a refresh to populate with data. # # This is typically called in a migration via {Statements#create_view}. # @@ -154,18 +158,27 @@ def create_materialized_view(name, sql_definition, no_data: false) # @param sql_definition The SQL schema for the updated view. # @param no_data [Boolean] Default: 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. + # to perform a refresh to populate with data. + # @param side_by_side [Boolean] Default: false. Set to true to create the + # new version under a different name and atomically swap them, limiting + # the time that a view is inaccessible at the cost of doubling disk usage # # @raise [MaterializedViewsNotSupportedError] if the version of Postgres # in use does not support materialized views. # # @return [void] - def update_materialized_view(name, sql_definition, no_data: false) + def update_materialized_view(name, sql_definition, no_data: false, side_by_side: false) raise_unless_materialized_views_supported - IndexReapplication.new(connection: connection).on(name) do - drop_materialized_view(name) - create_materialized_view(name, sql_definition, no_data: no_data) + if side_by_side + SideBySide + .new(adapter: self, name: name, definition: sql_definition) + .update + else + IndexReapplication.new(connection: connection).on(name) do + drop_materialized_view(name) + create_materialized_view(name, sql_definition, no_data: no_data) + end end end @@ -193,7 +206,10 @@ def drop_materialized_view(name) # refreshed without locking the view for select but requires that the # table have at least one unique index that covers all rows. Attempts to # refresh concurrently without a unique index will raise a descriptive - # error. + # error. This option is ignored if the view is not populated, as it + # would cause an error to be raised by Postgres. Default: false. + # @param cascade [Boolean] Whether to refresh dependent materialized + # views. Default: false. # # @raise [MaterializedViewsNotSupportedError] if the version of Postgres # in use does not support materialized views. @@ -205,26 +221,29 @@ def drop_materialized_view(name) # Scenic.database.refresh_materialized_view(:search_results) # @example Concurrent refresh # Scenic.database.refresh_materialized_view(:posts, concurrently: true) + # @example Cascade refresh + # Scenic.database.refresh_materialized_view(:posts, cascade: true) # # @return [void] def refresh_materialized_view(name, concurrently: false, cascade: false) raise_unless_materialized_views_supported + if concurrently + raise_unless_concurrent_refresh_supported + end + if cascade refresh_dependencies_for(name, concurrently: concurrently) end - if concurrently - raise_unless_concurrent_refresh_supported + if concurrently && populated?(name) execute "REFRESH MATERIALIZED VIEW CONCURRENTLY #{quote_table_name(name)};" else execute "REFRESH MATERIALIZED VIEW #{quote_table_name(name)};" end end - # True if supplied relation name is populated. Useful for checking the - # state of materialized views which may error if created `WITH NO DATA` - # and used before they are refreshed. True for all other relation types. + # True if supplied relation name is populated. # # @param name The name of the relation # @@ -235,7 +254,7 @@ def refresh_materialized_view(name, concurrently: false, cascade: false) def populated?(name) raise_unless_materialized_views_supported - schemaless_name = name.split(".").last + schemaless_name = name.to_s.split(".").last sql = "SELECT relispopulated FROM pg_class WHERE relname = '#{schemaless_name}'" relations = execute(sql) @@ -247,15 +266,19 @@ def populated?(name) end end + # A decorated ActiveRecord connection object with some Scenic-specific + # methods. Not intended for direct use outside of the Postgres adapter. + # + # @api private + def connection + Connection.new(connectable.connection) + end + private attr_reader :connectable delegate :execute, :quote_table_name, to: :connection - def connection - Connection.new(connectable.connection) - end - def raise_unless_materialized_views_supported unless connection.supports_materialized_views? raise MaterializedViewsNotSupportedError diff --git a/lib/scenic/adapters/postgres/index_creation.rb b/lib/scenic/adapters/postgres/index_creation.rb new file mode 100644 index 00000000..93be1ea8 --- /dev/null +++ b/lib/scenic/adapters/postgres/index_creation.rb @@ -0,0 +1,68 @@ +module Scenic + module Adapters + class Postgres + # Used to resiliently create indexes on a materialized view. If the index + # cannot be applied to the view (e.g. the columns don't exist any longer), + # we log that information and continue rather than raising an error. It is + # left to the user to judge whether the index is necessary and recreate + # it. + # + # Used when updating a materialized view to ensure the new version has all + # apprioriate indexes. + # + # @api private + class IndexCreation + # Creates the index creation object. + # + # @param connection [Connection] The connection to execute SQL against. + # @param speaker [#say] (ActiveRecord::Migration) The object used for + # logging the results of creating indexes. + def initialize(connection:, speaker: ActiveRecord::Migration.new) + @connection = connection + @speaker = speaker + end + + # Creates the provided indexes. If an index cannot be created, it is + # logged and the process continues. + # + # @param indexes [Array] The indexes to create. + # + # @return [void] + def try_create(indexes) + Array(indexes).each(&method(:try_index_create)) + end + + private + + attr_reader :connection, :speaker + + def try_index_create(index) + success = with_savepoint(index.index_name) do + connection.execute(index.definition) + end + + if success + say "index '#{index.index_name}' on '#{index.object_name}' has been created" + else + say "index '#{index.index_name}' on '#{index.object_name}' is no longer valid and has been dropped." + end + end + + def with_savepoint(name) + connection.execute("SAVEPOINT #{name}") + yield + connection.execute("RELEASE SAVEPOINT #{name}") + true + rescue + connection.execute("ROLLBACK TO SAVEPOINT #{name}") + false + end + + def say(message) + subitem = true + speaker.say(message, subitem) + end + end + end + end +end diff --git a/lib/scenic/adapters/postgres/index_migration.rb b/lib/scenic/adapters/postgres/index_migration.rb new file mode 100644 index 00000000..877a40cf --- /dev/null +++ b/lib/scenic/adapters/postgres/index_migration.rb @@ -0,0 +1,70 @@ +module Scenic + module Adapters + class Postgres + # Used during side-by-side materialized view updates to migrate indexes + # from the original view to the new view. + # + # @api private + class IndexMigration + # Creates the index migration object. + # + # @param connection [Connection] The connection to execute SQL against. + # @param speaker [#say] (ActiveRecord::Migration) The object used for + # logging the results of migrating indexes. + def initialize(connection:, speaker: ActiveRecord::Migration.new) + @connection = connection + @speaker = speaker + end + + # Retreives the indexes on the original view, renames them to avoid + # collisions, retargets the indexes to the destination view, and then + # creates the retargeted indexes. + # + # @param from [String] The name of the original view. + # @param to [String] The name of the destination view. + # + # @return [void] + def migrate(from:, to:) + source_indexes = Indexes.new(connection: connection).on(from) + retargeted_indexes = source_indexes.map { |i| retarget(i, to: to) } + source_indexes.each(&method(:rename)) + + if source_indexes.any? + say "indexes on '#{from}' have been renamed to avoid collisions" + end + + IndexCreation + .new(connection: connection, speaker: speaker) + .try_create(retargeted_indexes) + end + + private + + attr_reader :connection, :speaker + + def retarget(index, to:) + new_definition = index.definition.sub( + /ON (.*)\.#{index.object_name}/, + 'ON \1.' + to + " " + ) + + Scenic::Index.new( + object_name: to, + index_name: index.index_name, + definition: new_definition + ) + end + + def rename(index) + temporary_name = TemporaryName.new(index.index_name).to_s + connection.rename_index(index.object_name, index.index_name, temporary_name) + end + + def say(message) + subitem = true + speaker.say(message, subitem) + end + end + end + end +end diff --git a/lib/scenic/adapters/postgres/index_reapplication.rb b/lib/scenic/adapters/postgres/index_reapplication.rb index 59ab5add..7cb02fda 100644 --- a/lib/scenic/adapters/postgres/index_reapplication.rb +++ b/lib/scenic/adapters/postgres/index_reapplication.rb @@ -32,39 +32,14 @@ def on(name) yield - indexes.each(&method(:try_index_create)) + IndexCreation + .new(connection: connection, speaker: speaker) + .try_create(indexes) end private attr_reader :connection, :speaker - - def try_index_create(index) - success = with_savepoint(index.index_name) do - connection.execute(index.definition) - end - - if success - say "index '#{index.index_name}' on '#{index.object_name}' has been recreated" - else - say "index '#{index.index_name}' on '#{index.object_name}' is no longer valid and has been dropped." - end - end - - def with_savepoint(name) - connection.execute("SAVEPOINT #{name}") - yield - connection.execute("RELEASE SAVEPOINT #{name}") - true - rescue - connection.execute("ROLLBACK TO SAVEPOINT #{name}") - false - end - - def say(message) - subitem = true - speaker.say(message, subitem) - end end end end diff --git a/lib/scenic/adapters/postgres/side_by_side.rb b/lib/scenic/adapters/postgres/side_by_side.rb new file mode 100644 index 00000000..47919da2 --- /dev/null +++ b/lib/scenic/adapters/postgres/side_by_side.rb @@ -0,0 +1,50 @@ +module Scenic + module Adapters + class Postgres + # Updates a view using the `side-by-side` strategy where the new view is + # created and populated under a temporary name before the existing view is + # dropped and the temporary view is renamed to the original name. + class SideBySide + def initialize(adapter:, name:, definition:, speaker: ActiveRecord::Migration.new) + @adapter = adapter + @name = name + @definition = definition + @temporary_name = TemporaryName.new(name).to_s + @speaker = speaker + end + + def update + adapter.create_materialized_view(temporary_name, definition) + say "temporary materialized view '#{temporary_name}' has been created" + + IndexMigration + .new(connection: adapter.connection, speaker: speaker) + .migrate(from: name, to: temporary_name) + + adapter.drop_materialized_view(name) + say "materialized view '#{name}' has been dropped" + + rename_materialized_view(temporary_name, name) + say "temporary materialized view '#{temporary_name}' has been renamed to '#{name}'" + end + + private + + attr_reader :adapter, :name, :definition, :temporary_name, :speaker + + def connection + adapter.connection + end + + def rename_materialized_view(from, to) + connection.execute("ALTER MATERIALIZED VIEW #{from} RENAME TO #{to}") + end + + def say(message) + subitem = true + speaker.say(message, subitem) + end + end + end + end +end diff --git a/lib/scenic/adapters/postgres/temporary_name.rb b/lib/scenic/adapters/postgres/temporary_name.rb new file mode 100644 index 00000000..51cf4012 --- /dev/null +++ b/lib/scenic/adapters/postgres/temporary_name.rb @@ -0,0 +1,34 @@ +module Scenic + module Adapters + class Postgres + # Generates a temporary object name used internally by Scenic. This is + # used during side-by-side materialized view updates to avoid naming + # collisions. The generated name is based on a SHA1 hash of the original + # which ensures we do not exceed the 63 character limit for object names. + # + # @api private + class TemporaryName + # The prefix used for all temporary names. + PREFIX = "_scenic_sbs_".freeze + + # Creates a new temporary name object. + # + # @param name [String] The original name to base the temporary name on. + def initialize(name) + @name = name + @salt = SecureRandom.hex(4) + @temporary_name = "#{PREFIX}#{Digest::SHA1.hexdigest(name + salt)}" + end + + # @return [String] The temporary name. + def to_s + temporary_name + end + + private + + attr_reader :name, :temporary_name, :salt + end + end + end +end diff --git a/lib/scenic/adapters/postgres/views.rb b/lib/scenic/adapters/postgres/views.rb index 02b93588..4c526dd5 100644 --- a/lib/scenic/adapters/postgres/views.rb +++ b/lib/scenic/adapters/postgres/views.rb @@ -8,20 +8,91 @@ def initialize(connection) @connection = connection end - # All of the views that this connection has defined. + # All of the views that this connection has defined, sorted according to + # dependencies between the views to facilitate dumping and loading. # # This will include materialized views if those are supported by the # connection. # # @return [Array] def all - views_from_postgres.map(&method(:to_scenic_view)) + scenic_views = views_from_postgres.map(&method(:to_scenic_view)) + sort(scenic_views) end private + def sort(scenic_views) + scenic_view_names = scenic_views.map(&:name) + + tsorted_views(scenic_view_names).map do |view_name| + scenic_views.find do |sv| + sv.name == view_name || sv.name == view_name.split(".").last + end + end.compact + end + + # When dumping the views, their order must be topologically + # sorted to take into account dependencies + def tsorted_views(views_names) + views_hash = TSortableHash.new + + ::Scenic.database.execute(DEPENDENT_SQL).each do |relation| + source_v = [ + relation["source_schema"], + relation["source_table"] + ].compact.join(".") + + dependent = [ + relation["dependent_schema"], + relation["dependent_view"] + ].compact.join(".") + + views_hash[dependent] ||= [] + views_hash[source_v] ||= [] + views_hash[dependent] << source_v + + views_names.delete(relation["source_table"]) + views_names.delete(relation["dependent_view"]) + end + + # after dependencies, there might be some views left + # that don't have any dependencies + views_names.sort.each { |v| views_hash[v] ||= [] } + views_hash.tsort + end + attr_reader :connection + # Query for the dependencies between views + DEPENDENT_SQL = <<~SQL.freeze + SELECT distinct dependent_ns.nspname AS dependent_schema + , dependent_view.relname AS dependent_view + , source_ns.nspname AS source_schema + , source_table.relname AS source_table + FROM pg_depend + JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid + JOIN pg_class as dependent_view ON pg_rewrite.ev_class = dependent_view.oid + JOIN pg_class as source_table ON pg_depend.refobjid = source_table.oid + JOIN pg_namespace dependent_ns ON dependent_ns.oid = dependent_view.relnamespace + JOIN pg_namespace source_ns ON source_ns.oid = source_table.relnamespace + WHERE dependent_ns.nspname = ANY (current_schemas(false)) AND source_ns.nspname = ANY (current_schemas(false)) + AND source_table.relname != dependent_view.relname + AND source_table.relkind IN ('m', 'v') AND dependent_view.relkind IN ('m', 'v') + ORDER BY dependent_view.relname; + SQL + private_constant :DEPENDENT_SQL + + class TSortableHash < Hash + include TSort + + alias_method :tsort_each_node, :each_key + def tsort_each_child(node, &) + fetch(node).each(&) + end + end + private_constant :TSortableHash + def views_from_postgres connection.execute(<<-SQL) SELECT @@ -41,19 +112,21 @@ def views_from_postgres end def to_scenic_view(result) - namespace, viewname = result.values_at "namespace", "viewname" + Scenic::View.new( + name: namespaced_view_name(result), + definition: result["definition"].strip, + materialized: result["kind"] == "m" + ) + end - namespaced_viewname = if namespace != "public" + def namespaced_view_name(result) + namespace, viewname = result.values_at("namespace", "viewname") + + if namespace != "public" "#{pg_identifier(namespace)}.#{pg_identifier(viewname)}" else pg_identifier(viewname) end - - Scenic::View.new( - name: namespaced_viewname, - definition: result["definition"].strip, - materialized: result["kind"] == "m" - ) end def pg_identifier(name) diff --git a/lib/scenic/schema_dumper.rb b/lib/scenic/schema_dumper.rb index ebaf5232..340acfde 100644 --- a/lib/scenic/schema_dumper.rb +++ b/lib/scenic/schema_dumper.rb @@ -3,34 +3,6 @@ module Scenic # @api private module SchemaDumper - # A hash to do topological sort - class TSortableHash < Hash - include TSort - - alias_method :tsort_each_node, :each_key - def tsort_each_child(node, &) - fetch(node).each(&) - end - end - - # Query for the dependencies between views - DEPENDENT_SQL = <<~SQL.freeze - SELECT distinct dependent_ns.nspname AS dependent_schema - , dependent_view.relname AS dependent_view - , source_ns.nspname AS source_schema - , source_table.relname AS source_table - FROM pg_depend - JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid - JOIN pg_class as dependent_view ON pg_rewrite.ev_class = dependent_view.oid - JOIN pg_class as source_table ON pg_depend.refobjid = source_table.oid - JOIN pg_namespace dependent_ns ON dependent_ns.oid = dependent_view.relnamespace - JOIN pg_namespace source_ns ON source_ns.oid = source_table.relnamespace - WHERE dependent_ns.nspname = ANY (current_schemas(false)) AND source_ns.nspname = ANY (current_schemas(false)) - AND source_table.relname != dependent_view.relname - AND source_table.relkind IN ('m', 'v') AND dependent_view.relkind IN ('m', 'v') - ORDER BY dependent_view.relname; - SQL - def tables(stream) super views(stream) @@ -50,58 +22,8 @@ def views(stream) private def dumpable_views_in_database - @ordered_dumpable_views_in_database ||= begin - existing_views = Scenic.database.views.reject do |view| - ignored?(view.name) - end - - tsorted_views(existing_views.map(&:name)).map do |view_name| - existing_views.find do |ev| - ev.name == view_name || ev.name == view_name.split(".").last - end - end.compact - end - end - - # When dumping the views, their order must be topologically - # sorted to take into account dependencies - def tsorted_views(views_names) - views_hash = TSortableHash.new - - ::Scenic.database.execute(DEPENDENT_SQL).each do |relation| - source_v = [ - relation["source_schema"], - relation["source_table"] - ].compact.join(".") - dependent = [ - relation["dependent_schema"], - relation["dependent_view"] - ].compact.join(".") - views_hash[dependent] ||= [] - views_hash[source_v] ||= [] - views_hash[dependent] << source_v - views_names.delete(relation["source_table"]) - views_names.delete(relation["dependent_view"]) - end - - # after dependencies, there might be some views left - # that don't have any dependencies - views_names.sort.each { |v| views_hash[v] ||= [] } - - views_hash.tsort - end - - unless ActiveRecord::SchemaDumper.private_instance_methods(false).include?(:ignored?) - # This method will be present in Rails 4.2.0 and can be removed then. - def ignored?(table_name) - ["schema_migrations", ignore_tables].flatten.any? do |ignored| - case ignored - when String then remove_prefix_and_suffix(table_name) == ignored - when Regexp then remove_prefix_and_suffix(table_name) =~ ignored - else - raise StandardError, "ActiveRecord::SchemaDumper.ignore_tables accepts an array of String and / or Regexp values." - end - end + @dumpable_views_in_database ||= Scenic.database.views.reject do |view| + ignored?(view.name) end end end diff --git a/lib/scenic/statements.rb b/lib/scenic/statements.rb index c3caa0ca..14e1f4a5 100644 --- a/lib/scenic/statements.rb +++ b/lib/scenic/statements.rb @@ -9,9 +9,11 @@ module Statements # @param sql_definition [String] The SQL query for the view schema. An error # will be raised if `sql_definition` and `version` are both set, # as they are mutually exclusive. - # @param materialized [Boolean, Hash] Set to true to create a materialized - # view. Set to { no_data: true } to create materialized view without - # loading data. Defaults to false. + # @param materialized [Boolean, Hash] Set to a truthy value to create a + # materialized view. Hash + # @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. # @return The database response from executing the create statement. # # @example Create from `db/views/searches_v02.sql` @@ -37,10 +39,12 @@ def create_view(name, version: nil, sql_definition: nil, materialized: false) sql_definition ||= definition(name, version) if materialized + options = materialized_options(materialized) + Scenic.database.create_materialized_view( name, sql_definition, - no_data: no_data(materialized) + no_data: options[:no_data] ) else Scenic.database.create_view(name, sql_definition) @@ -80,14 +84,23 @@ def drop_view(name, revert_to_version: nil, materialized: false) # as they are mutually exclusive. # @param revert_to_version [Fixnum] The version number to rollback to on # `rake db rollback` - # @param materialized [Boolean, Hash] True if updating a materialized view. - # Set to { no_data: true } to update materialized view without loading - # data. Defaults to false. + # @param materialized [Boolean, Hash] True or a Hash if updating a + # materialized view. + # @option materialized [Boolean] :no_data (false) Set to true to update + # a materialized view without loading data. You will need to perform a + # refresh to populate with data. Cannot be combined with the :side_by_side + # option. + # @option materialized [Boolean] :side_by_side (false) Set to true to update + # update a materialized view using our side-by-side strategy, which will + # limit the time the view is locked at the cost of increasing disk usage. + # 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. # @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) if version.blank? && sql_definition.blank? raise( @@ -106,10 +119,24 @@ def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, sql_definition ||= definition(name, version) if materialized + options = materialized_options(materialized) + + if options[:no_data] && options[:side_by_side] + raise( + ArgumentError, + "no_data and side_by_side options cannot be combined" + ) + end + + if options[:side_by_side] && !transaction_open? + raise "a transaction is required to perform a side-by-side update" + end + Scenic.database.update_materialized_view( name, sql_definition, - no_data: no_data(materialized) + no_data: options[:no_data], + side_by_side: options[:side_by_side] ) else Scenic.database.update_view(name, sql_definition) @@ -152,11 +179,17 @@ def definition(name, version) Scenic::Definition.new(name, version).to_sql end - def no_data(materialized) - if materialized.is_a?(Hash) - materialized.fetch(:no_data, false) + def materialized_options(materialized) + if materialized.is_a? Hash + { + no_data: materialized.fetch(:no_data, false), + side_by_side: materialized.fetch(:side_by_side, false) + } else - false + { + no_data: false, + side_by_side: false + } end end end diff --git a/spec/acceptance/user_manages_views_spec.rb b/spec/acceptance/user_manages_views_spec.rb index 426e9220..fa106faf 100644 --- a/spec/acceptance/user_manages_views_spec.rb +++ b/spec/acceptance/user_manages_views_spec.rb @@ -45,6 +45,17 @@ verify_result "Child.take.name", "Elliot" verify_schema_contains 'add_index "children"' + successfully "rails generate scenic:view child --materialized --side-by-side" + verify_identical_view_definitions "children_v02", "children_v03" + + write_definition "children_v03", "SELECT 'Juniper'::text AS name" + successfully "rake db:migrate" + + successfully "rake db:reset" + 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 "rails destroy scenic:model child" diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb index e0d706e5..0e3639a5 100644 --- a/spec/dummy/config/application.rb +++ b/spec/dummy/config/application.rb @@ -11,5 +11,9 @@ class Application < Rails::Application config.cache_classes = true config.eager_load = false config.active_support.deprecation = :stderr + + if config.active_support.respond_to?(:to_time_preserves_timezone) + config.active_support.to_time_preserves_timezone = :zone + end end end diff --git a/spec/generators/scenic/view/view_generator_spec.rb b/spec/generators/scenic/view/view_generator_spec.rb index 6df79ac1..14cdc5b5 100644 --- a/spec/generators/scenic/view/view_generator_spec.rb +++ b/spec/generators/scenic/view/view_generator_spec.rb @@ -37,6 +37,32 @@ end end + it "sets the no_data option when updating a materialized view" do + with_view_definition("aired_episodes", 1, "hello") do + allow(Dir).to receive(:entries).and_return(["aired_episodes_v01.sql"]) + + run_generator ["aired_episode", "--materialized", "--no-data"] + migration = migration_file( + "db/migrate/update_aired_episodes_to_version_2.rb" + ) + expect(migration).to contain "materialized: { no_data: true }" + expect(migration).not_to contain "side_by_side" + end + end + + it "sets the side-by-side option when updating a materialized view" do + with_view_definition("aired_episodes", 1, "hello") do + allow(Dir).to receive(:entries).and_return(["aired_episodes_v01.sql"]) + + run_generator ["aired_episode", "--materialized", "--side-by-side"] + migration = migration_file( + "db/migrate/update_aired_episodes_to_version_2.rb" + ) + expect(migration).to contain "materialized: { side_by_side: true }" + expect(migration).not_to contain "no_data" + end + end + it "uses 'replace_view' instead of 'update_view' if replace flag is set" do with_view_definition("aired_episodes", 1, "hello") do allow(Dir).to receive(:entries).and_return(["aired_episodes_v01.sql"]) diff --git a/spec/scenic/adapters/postgres/index_creation_spec.rb b/spec/scenic/adapters/postgres/index_creation_spec.rb new file mode 100644 index 00000000..70d046a2 --- /dev/null +++ b/spec/scenic/adapters/postgres/index_creation_spec.rb @@ -0,0 +1,54 @@ +require "spec_helper" + +module Scenic + module Adapters + describe Postgres::IndexCreation, :db do + it "successfully recreates applicable indexes" do + create_materialized_view("hi", "SELECT 'hi' AS greeting") + speaker = DummySpeaker.new + + index = Scenic::Index.new( + object_name: "hi", + index_name: "hi_greeting_idx", + definition: "CREATE INDEX hi_greeting_idx ON hi (greeting)" + ) + + Postgres::IndexCreation + .new(connection: ActiveRecord::Base.connection, speaker: speaker) + .try_create([index]) + + expect(indexes_for("hi")).not_to be_empty + expect(speaker.messages).to include(/index 'hi_greeting_idx' .* has been created/) + end + + it "skips indexes that are not applicable" do + create_materialized_view("hi", "SELECT 'hi' AS greeting") + speaker = DummySpeaker.new + index = Scenic::Index.new( + object_name: "hi", + index_name: "hi_person_idx", + definition: "CREATE INDEX hi_person_idx ON hi (person)" + ) + + Postgres::IndexCreation + .new(connection: ActiveRecord::Base.connection, speaker: speaker) + .try_create([index]) + + expect(indexes_for("hi")).to be_empty + expect(speaker.messages).to include(/index 'hi_person_idx' .* has been dropped/) + end + end + + class DummySpeaker + attr_reader :messages + + def initialize + @messages = [] + end + + def say(message, bool = false) + @messages << message + end + end + end +end diff --git a/spec/scenic/adapters/postgres/index_migration_spec.rb b/spec/scenic/adapters/postgres/index_migration_spec.rb new file mode 100644 index 00000000..72af305f --- /dev/null +++ b/spec/scenic/adapters/postgres/index_migration_spec.rb @@ -0,0 +1,24 @@ +require "spec_helper" + +module Scenic + module Adapters + describe Postgres::IndexMigration, :db, :silence do + it "moves indexes from the old view to the new view" do + create_materialized_view("hi", "SELECT 'hi' AS greeting") + create_materialized_view("hi_temp", "SELECT 'hi' AS greeting") + add_index(:hi, :greeting, name: "hi_greeting_idx") + + Postgres::IndexMigration + .new(connection: ActiveRecord::Base.connection) + .migrate(from: "hi", to: "hi_temp") + indexes_for_original = indexes_for("hi") + indexes_for_temporary = indexes_for("hi_temp") + + expect(indexes_for_original.length).to eq 1 + expect(indexes_for_original.first.index_name).not_to eq "hi_greeting_idx" + expect(indexes_for_temporary.length).to eq 1 + expect(indexes_for_temporary.first.index_name).to eq "hi_greeting_idx" + end + end + end +end diff --git a/spec/scenic/adapters/postgres/side_by_side_spec.rb b/spec/scenic/adapters/postgres/side_by_side_spec.rb new file mode 100644 index 00000000..88fd2cbe --- /dev/null +++ b/spec/scenic/adapters/postgres/side_by_side_spec.rb @@ -0,0 +1,24 @@ +require "spec_helper" + +module Scenic + module Adapters + describe Postgres::SideBySide, :db, :silence do + it "updates the materialized view to the new version" do + adapter = Postgres.new + create_materialized_view("hi", "SELECT 'hi' AS greeting") + add_index(:hi, :greeting, name: "hi_greeting_idx") + new_definition = "SELECT 'hola' AS greeting" + + Postgres::SideBySide + .new(adapter: adapter, name: "hi", definition: new_definition) + .update + result = ar_connection.execute("SELECT * FROM hi").first["greeting"] + indexes = indexes_for("hi") + + expect(result).to eq "hola" + expect(indexes.length).to eq 1 + expect(indexes.first.index_name).to eq "hi_greeting_idx" + end + end + end +end diff --git a/spec/scenic/adapters/postgres/temporary_name_spec.rb b/spec/scenic/adapters/postgres/temporary_name_spec.rb new file mode 100644 index 00000000..dfefaa20 --- /dev/null +++ b/spec/scenic/adapters/postgres/temporary_name_spec.rb @@ -0,0 +1,23 @@ +require "spec_helper" + +module Scenic + module Adapters + describe Postgres::TemporaryName do + it "generates a temporary name based on a SHA1 hash of the original" do + name = "my_materialized_view" + + temporary_name = Postgres::TemporaryName.new(name).to_s + + expect(temporary_name).to match(/_scenic_sbs_[0-9a-f]{40}/) + end + + it "does not overflow the 63 character limit for object names" do + name = "long_view_name_" * 10 + + temporary_name = Postgres::TemporaryName.new(name).to_s + + expect(temporary_name.length).to eq 52 + end + end + end +end diff --git a/spec/scenic/adapters/postgres_spec.rb b/spec/scenic/adapters/postgres_spec.rb index 66819aa7..f95529e1 100644 --- a/spec/scenic/adapters/postgres_spec.rb +++ b/spec/scenic/adapters/postgres_spec.rb @@ -149,6 +149,14 @@ module Adapters adapter.refresh_materialized_view(:tests, concurrently: true) }.to raise_error e end + + it "falls back to non-concurrent refresh if not populated" do + adapter = Postgres.new + adapter.create_materialized_view(:testing, "SELECT unnest('{1, 2}'::int[])", no_data: true) + + expect { adapter.refresh_materialized_view(:testing, concurrently: true) } + .not_to raise_error + end end end @@ -176,8 +184,8 @@ module Adapters SQL expect(adapter.views.map(&:name)).to eq [ - "parents", "children", + "parents", "people", "people_with_names" ] @@ -193,13 +201,13 @@ module Adapters ActiveRecord::Base.connection.execute <<-SQL CREATE SCHEMA scenic; - CREATE VIEW scenic.parents AS SELECT text 'Maarten' AS name; + CREATE VIEW scenic.more_parents AS SELECT text 'Maarten' AS name; SET search_path TO scenic, public; SQL expect(adapter.views.map(&:name)).to eq [ "parents", - "scenic.parents" + "scenic.more_parents" ] end end @@ -250,6 +258,39 @@ module Adapters expect { adapter.populated?("greetings") }.to raise_error err end end + + describe "#update_materialized_view" do + it "updates the definition of a materialized view in place" do + adapter = Postgres.new + create_materialized_view("hi", "SELECT 'hi' AS greeting") + new_definition = "SELECT 'hello' AS greeting" + + adapter.update_materialized_view("hi", new_definition) + result = adapter.connection.execute("SELECT * FROM hi").first["greeting"] + + expect(result).to eq "hello" + end + + it "updates the definition of a materialized view side by side", :silence do + adapter = Postgres.new + create_materialized_view("hi", "SELECT 'hi' AS greeting") + new_definition = "SELECT 'hello' AS greeting" + + adapter.update_materialized_view("hi", new_definition, side_by_side: true) + result = adapter.connection.execute("SELECT * FROM hi").first["greeting"] + + expect(result).to eq "hello" + end + + it "raises an exception if the version of PostgreSQL is too old" do + connection = double("Connection", supports_materialized_views?: false) + connectable = double("Connectable", connection: connection) + adapter = Postgres.new(connectable) + + expect { adapter.create_materialized_view("greetings", "select 1") } + .to raise_error Postgres::MaterializedViewsNotSupportedError + end + end end end end diff --git a/spec/scenic/command_recorder_spec.rb b/spec/scenic/command_recorder_spec.rb index a33d2bc8..419fbc26 100644 --- a/spec/scenic/command_recorder_spec.rb +++ b/spec/scenic/command_recorder_spec.rb @@ -77,6 +77,24 @@ expect { recorder.revert { recorder.update_view(*args) } } .to raise_error(ActiveRecord::IrreversibleMigration) end + + it "reverts materialized views with no_data option appropriately" do + args = [:users, {version: 2, revert_to_version: 1, materialized: {no_data: true}}] + revert_args = [:users, {version: 1, materialized: {no_data: true}}] + + recorder.revert { recorder.update_view(*args) } + + expect(recorder.commands).to eq [[:update_view, revert_args]] + end + + it "reverts materialized views with side_by_side option appropriately" do + args = [:users, {version: 2, revert_to_version: 1, materialized: {side_by_side: true}}] + revert_args = [:users, {version: 1, materialized: {side_by_side: true}}] + + recorder.revert { recorder.update_view(*args) } + + expect(recorder.commands).to eq [[:update_view, revert_args]] + end end describe "#replace_view" do diff --git a/spec/scenic/schema_dumper_spec.rb b/spec/scenic/schema_dumper_spec.rb index 02378110..03eb8bee 100644 --- a/spec/scenic/schema_dumper_spec.rb +++ b/spec/scenic/schema_dumper_spec.rb @@ -12,7 +12,7 @@ class SearchInAHaystack < ActiveRecord::Base Search.connection.create_view :searches, sql_definition: view_definition stream = StringIO.new - ActiveRecord::SchemaDumper.dump(Search.connection, stream) + dump_schema(stream) output = stream.string @@ -31,7 +31,7 @@ class SearchInAHaystack < ActiveRecord::Base Search.connection.create_view :searches, sql_definition: view_definition stream = StringIO.new - ActiveRecord::SchemaDumper.dump(Search.connection, stream) + dump_schema(stream) output = stream.string expect(output).to include "~ '\\\\d+'::text" @@ -47,7 +47,7 @@ class SearchInAHaystack < ActiveRecord::Base Search.connection.create_view :searches, materialized: true, sql_definition: view_definition stream = StringIO.new - ActiveRecord::SchemaDumper.dump(Search.connection, stream) + dump_schema(stream) output = stream.string @@ -62,7 +62,7 @@ class SearchInAHaystack < ActiveRecord::Base Search.connection.create_view :"scenic.searches", sql_definition: view_definition stream = StringIO.new - ActiveRecord::SchemaDumper.dump(Search.connection, stream) + dump_schema(stream) output = stream.string expect(output).to include 'create_view "scenic.searches",' @@ -77,7 +77,7 @@ class SearchInAHaystack < ActiveRecord::Base Search.connection.execute("CREATE OR REPLACE VIEW scenic.apples AS SELECT * FROM scenic.bananas;") stream = StringIO.new - ActiveRecord::SchemaDumper.dump(Search.connection, stream) + dump_schema(stream) views = stream.string.lines.grep(/create_view/).map do |view_line| view_line.match('create_view "(?.*)"')[:name] end @@ -93,7 +93,7 @@ class SearchInAHaystack < ActiveRecord::Base Search.connection.create_view :a_searches_z, sql_definition: view_definition stream = StringIO.new - ActiveRecord::SchemaDumper.dump(Search.connection, stream) + dump_schema(stream) output = stream.string @@ -106,7 +106,7 @@ class SearchInAHaystack < ActiveRecord::Base Search.connection.create_view :searches, sql_definition: view_definition stream = StringIO.new - ActiveRecord::SchemaDumper.dump(Search.connection, stream) + dump_schema(stream) output = stream.string @@ -121,7 +121,7 @@ class SearchInAHaystack < ActiveRecord::Base Search.connection.create_view '"search in a haystack"', sql_definition: view_definition stream = StringIO.new - ActiveRecord::SchemaDumper.dump(Search.connection, stream) + dump_schema(stream) output = stream.string expect(output).to include 'create_view "\"search in a haystack\"",' @@ -145,7 +145,7 @@ class SearchInAHaystack < ActiveRecord::Base sql_definition: view_definition stream = StringIO.new - ActiveRecord::SchemaDumper.dump(Search.connection, stream) + dump_schema(stream) output = stream.string expect(output).to include 'create_view "scenic.\"search in a haystack\"",' @@ -153,6 +153,11 @@ class SearchInAHaystack < ActiveRecord::Base Search.connection.drop_view :'scenic."search in a haystack"' + case ActiveRecord.gem_version + when Gem::Requirement.new(">= 7.1") + Search.connection.drop_schema "scenic" + end + silence_stream($stdout) { eval(output) } # standard:disable Security/Eval expect(SearchInAHaystack.take.haystack).to eq "needle" diff --git a/spec/scenic/statements_spec.rb b/spec/scenic/statements_spec.rb index 39cf1387..c632f4dd 100644 --- a/spec/scenic/statements_spec.rb +++ b/spec/scenic/statements_spec.rb @@ -125,7 +125,7 @@ module Scenic connection.update_view(:name, version: 3, materialized: true) expect(Scenic.database).to have_received(:update_materialized_view) - .with(:name, definition.to_sql, no_data: false) + .with(:name, definition.to_sql, no_data: false, side_by_side: false) end it "updates the materialized view in the database with NO DATA" do @@ -141,7 +141,23 @@ module Scenic ) expect(Scenic.database).to have_received(:update_materialized_view) - .with(:name, definition.to_sql, no_data: true) + .with(:name, definition.to_sql, no_data: true, side_by_side: false) + end + + 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) + .and_return(definition) + + connection.update_view( + :name, + version: 3, + materialized: {side_by_side: true} + ) + + expect(Scenic.database).to have_received(:update_materialized_view) + .with(:name, definition.to_sql, no_data: false, side_by_side: true) end it "raises an error if not supplied a version or sql_defintion" do @@ -160,6 +176,36 @@ module Scenic ) end.to raise_error ArgumentError, /cannot both be set/ end + + 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) + .and_return(definition) + + expect do + connection.update_view( + :name, + version: 3, + materialized: {no_data: true, side_by_side: true} + ) + end.to raise_error ArgumentError, /cannot be combined/ + end + + 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) + .and_return(definition) + + expect do + connection(transactions_enabled: false).update_view( + :name, + version: 3, + materialized: {side_by_side: true} + ) + end.to raise_error RuntimeError, /transaction is required/ + end end describe "replace_view" do @@ -192,8 +238,20 @@ module Scenic end end - def connection - Class.new { extend Statements } + def connection(transactions_enabled: true) + DummyConnection.new(transactions_enabled: transactions_enabled) + end + end + + class DummyConnection + include Statements + + def initialize(transactions_enabled:) + @transactions_enabled = transactions_enabled + end + + def transaction_open? + @transactions_enabled end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b3dfc3a9..e76b8087 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,24 +2,39 @@ require "database_cleaner" require File.expand_path("dummy/config/environment", __dir__) -require "support/rails_configuration_helpers" -require "support/generator_spec_setup" -require "support/view_definition_helpers" + +Dir.glob("#{__dir__}/support/**/*.rb").each { |f| require f } RSpec.configure do |config| config.order = "random" + config.include DatabaseSchemaHelpers config.include ViewDefinitionHelpers config.include RailsConfigurationHelpers DatabaseCleaner.strategy = :transaction config.around(:each, db: true) do |example| - ActiveRecord::SchemaMigration.create_table + case ActiveRecord.gem_version + when Gem::Requirement.new(">= 7.2") + ActiveRecord::SchemaMigration + .new(ActiveRecord::Tasks::DatabaseTasks.migration_connection_pool) + .create_table + when Gem::Requirement.new("~> 7.1.0") + ActiveRecord::SchemaMigration + .new(ActiveRecord::Tasks::DatabaseTasks.migration_connection) + .create_table + when Gem::Requirement.new("< 7.1") + ActiveRecord::SchemaMigration.create_table + end DatabaseCleaner.start example.run DatabaseCleaner.clean end + config.before(:each, silence: true) do |example| + allow_any_instance_of(ActiveRecord::Migration).to receive(:say) + end + if defined? ActiveSupport::Testing::Stream config.include ActiveSupport::Testing::Stream end diff --git a/spec/support/database_schema_helpers.rb b/spec/support/database_schema_helpers.rb new file mode 100644 index 00000000..7207c197 --- /dev/null +++ b/spec/support/database_schema_helpers.rb @@ -0,0 +1,28 @@ +module DatabaseSchemaHelpers + def dump_schema(stream) + case ActiveRecord.gem_version + when Gem::Requirement.new(">= 7.2") + ActiveRecord::SchemaDumper.dump(Search.connection_pool, stream) + else + ActiveRecord::SchemaDumper.dump(Search.connection, stream) + end + end + + def ar_connection + ActiveRecord::Base.connection + end + + def create_materialized_view(name, sql) + ar_connection.execute("CREATE MATERIALIZED VIEW #{name} AS #{sql}") + end + + def add_index(view, columns, name: nil) + ar_connection.add_index(view, columns, name: name) + end + + def indexes_for(view_name) + Scenic::Adapters::Postgres::Indexes + .new(connection: ar_connection) + .on(view_name) + end +end