Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ Scenic detected that we already had an existing `search_results` view at version
update to the version 2 schema. All that's left for you to do is tweak the
schema in the new definition and run the `update_view` migration.

If your view has dependent objects (e.g. other views) you can pass `cascade:
true` to the `update_view` call to drop and recreate your view's children too:

```ruby
class UpdateSearchResultsToVersion2 < ActiveRecord::Migration
def change
update_view :search_results, version: 2, revert_to_version: 1, cascade: true
end
end
```

If your view has any dependent _materialized_ views with indexes, those
indexes will be recreated too. If you have a complex heirarchy of materialized
views with expensive calculations and large indexes this could take some time.

## What if I want to change a view without dropping it?

The `update_view` statement used by default will drop your view then create
Expand Down Expand Up @@ -201,6 +216,14 @@ def change
end
```

If your view has dependencies and you want Scenic to drop those too:

```ruby
def change
drop_view :search_results, revert_to_version: 2, cascade: true
end
```

## FAQs

**Why do I get an error when querying a view-backed model with `find`, `last`, or `first`?**
Expand Down
51 changes: 43 additions & 8 deletions lib/scenic/adapters/postgres.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,25 @@ def create_view(name, sql_definition)
#
# @param name The name of the view to update
# @param sql_definition The SQL schema for the updated view.
# @param cascade Whether to drop and recreate dependent objects or not
#
# @return [void]
def update_view(name, sql_definition)
drop_view(name)
def update_view(name, sql_definition, cascade=false)
if cascade
# Get existing views that could be dependent on this one.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Get existing views that could be dependent on this one.

existing_views = views.drop_while{|v| v.name != name}.drop(1)

# Get indexes of existing materialized views
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Get indexes of existing materialized views

indexes = Indexes.new(connection: connection)
view_indexes = existing_views.select(&:materialized).flat_map do |view|
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
view_indexes = existing_views.select(&:materialized).flat_map do |view|
potential_dependent_view_indexes = potential_dependent_views.select(&:materialized).flat_map do |view|

indexes.on(view.name)
end
end

drop_view(name, cascade)
create_view(name, sql_definition)

recreate_dropped_views(existing_views, views, view_indexes) if cascade
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
recreate_dropped_views(existing_views, views, view_indexes) if cascade
if cascade
recreate_dropped_views(potential_dependent_views, views, potential_dependent_view_indexes)
end

end

# Replaces a view in the database using `CREATE OR REPLACE VIEW`.
Expand Down Expand Up @@ -112,10 +126,11 @@ def replace_view(name, sql_definition)
# This is typically called in a migration via {Statements#drop_view}.
#
# @param name The name of the view to drop
# @param cascade Whether to drop dependent objects or not
#
# @return [void]
def drop_view(name)
execute "DROP VIEW #{quote_table_name(name)};"
def drop_view(name, cascade=false)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def drop_view(name, cascade=false)
def drop_view(name, cascade = false)

execute "DROP VIEW #{quote_table_name(name)}#{" CASCADE" if cascade};"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer single-quoted strings inside interpolations.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
execute "DROP VIEW #{quote_table_name(name)}#{" CASCADE" if cascade};"
execute "DROP VIEW #{quote_table_name(name)}#{' CASCADE' if cascade};"

end

# Creates a materialized view in the database
Expand Down Expand Up @@ -144,16 +159,17 @@ def create_materialized_view(name, sql_definition)
#
# @param name The name of the view to update
# @param sql_definition The SQL schema for the updated view.
# @param cascade Whether to drop and recreate dependent objects or not
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# @param cascade Whether to drop and recreate dependent objects or not
# @param cascade Whether to drop and recreate dependent objects

#
# @raise [MaterializedViewsNotSupportedError] if the version of Postgres
# in use does not support materialized views.
#
# @return [void]
def update_materialized_view(name, sql_definition)
def update_materialized_view(name, sql_definition, cascade=false)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surrounding space missing in default value assignment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def update_materialized_view(name, sql_definition, cascade=false)
def update_materialized_view(name, sql_definition, cascade = false)

raise_unless_materialized_views_supported

IndexReapplication.new(connection: connection).on(name) do
drop_materialized_view(name)
drop_materialized_view(name, cascade)
create_materialized_view(name, sql_definition)
end
end
Expand All @@ -163,13 +179,14 @@ def update_materialized_view(name, sql_definition)
# This is typically called in a migration via {Statements#update_view}.
#
# @param name The name of the materialized view to drop.
# @param cascade Whether to drop dependent objects or not.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# @param cascade Whether to drop dependent objects or not.
# @param cascade Whether to drop dependent objects.

# @raise [MaterializedViewsNotSupportedError] if the version of Postgres
# in use does not support materialized views.
#
# @return [void]
def drop_materialized_view(name)
def drop_materialized_view(name, cascade=false)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surrounding space missing in default value assignment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def drop_materialized_view(name, cascade=false)
def drop_materialized_view(name, cascade = false)

raise_unless_materialized_views_supported
execute "DROP MATERIALIZED VIEW #{quote_table_name(name)};"
execute "DROP MATERIALIZED VIEW #{quote_table_name(name)}#{" CASCADE" if cascade};"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer single-quoted strings inside interpolations.
Line is too long. [91/80]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
execute "DROP MATERIALIZED VIEW #{quote_table_name(name)}#{" CASCADE" if cascade};"
execute "DROP MATERIALIZED VIEW #{quote_table_name(name)}#{' CASCADE' if cascade};"

end

# Refreshes a materialized view from its SQL schema.
Expand Down Expand Up @@ -238,6 +255,24 @@ def refresh_dependencies_for(name)
connection,
)
end

def recreate_dropped_views(old_views, current_views, indexes=[])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surrounding space missing in default value assignment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def recreate_dropped_views(old_views, current_views, indexes=[])
def recreate_dropped_views(old_views, current_views, indexes = [])

index_reapplier = IndexReapplication.new(connection: connection)

# Find any views that were lost
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Find any views that were lost

dropped_views = old_views.reject{|ov| current_views.any?{|cv| ov.name == cv.name}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Space missing to the left of {.
Space between { and | missing.
Line is too long. [90/80]
Space missing inside }.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
dropped_views = old_views.reject{|ov| current_views.any?{|cv| ov.name == cv.name}}
dropped_views = old_views.reject do |ov|
current_views.any? { |cv| ov.name == cv.name }
end

# Recreate them
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Recreate them

dropped_views.each do |view|
if view.materialized
create_materialized_view view.name, view.definition
# Also recreate any indexes that were lost
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Also recreate any indexes that were lost

lost_indexes = indexes.select{|index| index.object_name == view.name}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Space missing to the left of {.
Space between { and | missing.
Space missing inside }.
Line is too long. [81/80]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
lost_indexes = indexes.select{|index| index.object_name == view.name}
lost_indexes = indexes.select { |index| index.object_name == view.name }

lost_indexes.each{|index| index_reapplier.try_index_create index}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Space missing to the left of {.
Space between { and | missing.
Space missing inside }.

else
create_view view.name, view.definition
end
end
end
end
end
end
8 changes: 4 additions & 4 deletions lib/scenic/adapters/postgres/index_reapplication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,6 @@ def on(name)
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)
Expand All @@ -51,6 +47,10 @@ def try_index_create(index)
end
end

private

attr_reader :connection, :speaker

def with_savepoint(name)
connection.execute("SAVEPOINT #{name}")
yield
Expand Down
5 changes: 5 additions & 0 deletions lib/scenic/command_recorder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ def perform_scenic_inversion(method, args)
raise ActiveRecord::IrreversibleMigration, message
end

if method == :create_view && scenic_args.cascade
message = "#{method} is not reversible if dependent objects were also dropped with CASCADE"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line is too long. [99/80]

raise ActiveRecord::IrreversibleMigration, message
end

[method, scenic_args.invert_version.to_a]
end
end
Expand Down
4 changes: 4 additions & 0 deletions lib/scenic/command_recorder/statement_arguments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ def revert_to_version
options[:revert_to_version]
end

def cascade
options[:cascade]
end

def invert_version
StatementArguments.new([view, options_for_revert])
end
Expand Down
6 changes: 6 additions & 0 deletions lib/scenic/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,11 @@ def initialize(object_name:, index_name:, definition:)
@index_name = index_name
@definition = definition
end

def ==(index)
@object_name == index.object_name &&
@index_name = index.index_name &&
@definition == index.definition
end
end
end
16 changes: 10 additions & 6 deletions lib/scenic/statements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# defaults to false.
# Defaults to false.

# @param cascade [Boolean] Set to true if dependent objects should also be
# dropped. defaults to false.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# dropped. defaults to false.
# dropped. Defaults to false.

# @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, cascade: false)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused method argument - revert_to_version.
Line is too long. [84/80]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused method argument - revert_to_version.
Line is too long. [84/80]

if materialized
Scenic.database.drop_materialized_view(name)
Scenic.database.drop_materialized_view(name, cascade)
else
Scenic.database.drop_view(name)
Scenic.database.drop_view(name, cascade)
end
end

Expand All @@ -77,12 +79,14 @@ def drop_view(name, revert_to_version: nil, materialized: false)
# `rake db rollback`
# @param materialized [Boolean] True if updating a materialized view.
# Defaults to false.
# @param cascade [Boolean] Set to true if dependent objects should also be
# dropped. defaults to false.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# dropped. defaults to false.
# dropped. Defaults to false.

# @return The database response from executing the create statement.
#
# @example
# update_view :engagement_reports, version: 3, revert_to_version: 2
#
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, cascade: false)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused method argument - revert_to_version.
Line is too long. [121/80]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused method argument - revert_to_version.
Line is too long. [121/80]

if version.blank? && sql_definition.blank?
raise(
ArgumentError,
Expand All @@ -100,9 +104,9 @@ def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil,
sql_definition ||= definition(name, version)

if materialized
Scenic.database.update_materialized_view(name, sql_definition)
Scenic.database.update_materialized_view(name, sql_definition, cascade)
else
Scenic.database.update_view(name, sql_definition)
Scenic.database.update_view(name, sql_definition, cascade)
end
end

Expand Down
99 changes: 99 additions & 0 deletions spec/integration/cascade_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
require "spec_helper"

describe "Dropping a view and its dependencies with cascade", :db do
around do |example|
with_view_definition :greetings, 1, "SELECT text 'hola' as greeting" do
with_view_definition :dependent_greetings, 1, "SELECT * from greetings" do
example.run
end
end
end

it 'works' do
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.

run_migration(migration_for_create, :up)
expect {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid using {...} for multi-line blocks.

run_migration(migration_for_drop, :up)
}.to_not raise_error
end

describe 'as part of updating a view' do
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.

around do |example|
with_view_definition :greetings, 2, "SELECT text 'good day' AS greeting" do
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line is too long. [81/80]

example.run
end
end

it 'recreates the dependent view' do
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.

views = Scenic::Adapters::Postgres::Views.new(connection)
run_migration(migration_for_create, :up)
expect {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parenthesize the param `change {
Avoid using {...} for multi-line blocks.

run_migration(migration_for_update, :up)
}.to_not change {
views.all.length
}
end

it 'recreates indexes on the dependent view' do
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.

indexes = Scenic::Adapters::Postgres::Indexes.new(connection: connection)
run_migration(migration_for_create_materialized_dependent, :up)
run_migration(index_migration, :up)
expect {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parenthesize the param `change {
Avoid using {...} for multi-line blocks.

run_migration(migration_for_update, :up)
}.to_not change {
indexes.on('dependent_greetings')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.

}
end

it 'reverts' do
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.

run_migration(migration_for_create, :up)
run_migration(migration_for_update, :up)
run_migration(migration_for_update, :down)
greeting = execute("SELECT * FROM dependent_greetings")[0]["greeting"]
expect(greeting).to eq 'hola'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.

end
end

def migration_for_create
Class.new(migration_class) do
def change
create_view :greetings
create_view :dependent_greetings
end
end
end

def migration_for_create_materialized_dependent
Class.new(migration_class) do
def change
create_view :greetings
create_view :dependent_greetings, materialized: true
end
end
end

def migration_for_drop
Class.new(migration_class) do
def change
drop_view :greetings, revert_to_version: 1, cascade: true
end
end
end

def migration_for_update
Class.new(migration_class) do
def change
update_view :greetings, version: 2, revert_to_version: 1, cascade: true
end
end
end

def index_migration
Class.new(migration_class) do
def change
add_index :dependent_greetings, :greeting
end
end
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra blank line detected.
Extra empty line detected at block body end.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

end
19 changes: 0 additions & 19 deletions spec/integration/revert_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,4 @@ def change
end
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

def migration_class
if Rails::VERSION::MAJOR >= 5
::ActiveRecord::Migration[5.0]
else
::ActiveRecord::Migration
end
end

def run_migration(migration, directions)
silence_stream(STDOUT) do
Array.wrap(directions).each do |direction|
migration.migrate(direction)
end
end
end

def execute(sql)
ActiveRecord::Base.connection.execute(sql)
end
end
7 changes: 7 additions & 0 deletions spec/scenic/command_recorder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@
expect { recorder.revert { recorder.drop_view(*args) } }
.to raise_error(ActiveRecord::IrreversibleMigration)
end

it "raises when reverting with cascade set" do
args = [:users, { cascade: true, revert_to_version: 3 }]

expect { recorder.revert { recorder.drop_view(*args) } }
.to raise_error(ActiveRecord::IrreversibleMigration)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Place the . on the previous line, together with the method call receiver.

end
end

describe "#update_view" do
Expand Down
Loading