Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for CASCADE in drop_view and update_view #218

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
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
64 changes: 56 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
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.reject{|v| v.name == name}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
existing_views = views.reject{|v| v.name == name}
potential_dependent_views = views.reject{|v| v.name == name}


# Get indexes of existing materialized views
Copy link
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
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
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
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};"

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
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,32 +159,47 @@ 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
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)

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
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

if cascade
# Get existing views that could be dependent on this one.
Copy link
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.reject{|v| v.name == name}

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 }.

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
existing_views = views.reject{|v| v.name == name}
existing_views = views.reject { |v| v.name == name }

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
existing_views = views.reject{|v| v.name == name}
potential_dependent_views = views.reject{|v| v.name == name}


# Get indexes of existing materialized views

Choose a reason for hiding this comment

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

Trailing whitespace detected.

Copy link
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
# Get indexes of existing materialized views

Copy link
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|

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]

Copy link
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 { |view| indexes.on(view.name) }

indexes.on(view.name)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
indexes.on(view.name)

end
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
end

end

IndexReapplication.new(connection: connection).on(name) do
drop_materialized_view(name)
drop_materialized_view(name, cascade)
create_materialized_view(name, sql_definition)
end

recreate_dropped_views(existing_views, views, view_indexes) if cascade
Copy link
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

# Drops a materialized view in the database
#
# 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
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)

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
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};"

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
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 +268,24 @@ def refresh_dependencies_for(name)
connection,
)
end

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

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
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
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}}

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
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
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
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}

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
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}

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"

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
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
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)

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]

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
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)

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]

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
120 changes: 120 additions & 0 deletions spec/integration/cascade_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
require "spec_helper"

RSpec.shared_examples "a cascading migration" do |materialized|
it 'recreates the dependent view' do
views = Scenic::Adapters::Postgres::Views.new(connection)
run_migration(migration_for_create(materialized: materialized), :up)
expect {
run_migration(migration_for_update(materialized: materialized), :up)
}.to_not change {
views.all.length
}
end

it 'recreates indexes on the dependent view' do
indexes = Scenic::Adapters::Postgres::Indexes.new(connection: connection)
run_migration(migration_for_create_materialized_dependent(materialized: materialized), :up)
run_migration(index_migration, :up)
expect {
run_migration(migration_for_update(materialized: materialized), :up)
}.to_not change {
indexes.on('dependent_greetings')
}
end

it 'reverts' do
run_migration(migration_for_create(materialized: materialized), :up)
run_migration(migration_for_update(materialized: materialized), :up)
run_migration(migration_for_update(materialized: materialized), :down)
greeting = execute("SELECT * FROM dependent_greetings")[0]["greeting"]
expect(greeting).to eq 'hola'
end
end


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

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 {

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

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

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

context 'with a non-materialized parent view' do
it_behaves_like "a cascading migration", false
end

context 'with a materialized parent view' do
it_behaves_like "a cascading migration", true
end
end

def migration_for_create(materialized: false)
eval <<-EOF
Class.new(migration_class) do
def change
create_view :greetings, materialized: #{materialized}
create_view :dependent_greetings
end
end
EOF
end

def migration_for_create_materialized_dependent(materialized: false)
eval <<-EOF
Class.new(migration_class) do
def change
create_view :greetings, materialized: #{materialized}
create_view :dependent_greetings, materialized: true
end
end
EOF
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(materialized: false)
eval <<-EOF
Class.new(migration_class) do
def change
update_view :greetings,
version: 2,
revert_to_version: 1,
cascade: true,
materialized: #{materialized}
end
end
EOF
end

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change


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
Contributor

Choose a reason for hiding this comment

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

Suggested change

end
Loading