From b4016238c2e5485a4918b6bd0f972a98b99b82a5 Mon Sep 17 00:00:00 2001 From: Siarhei Kavaliou Date: Wed, 8 Feb 2023 15:20:30 +0300 Subject: [PATCH] Add if_not_exists to create_view and if_exists to drop_view --- .gitignore | 1 + lib/scenic/adapters/postgres.rb | 32 +++++++--- .../adapters/postgres/index_reapplication.rb | 2 +- lib/scenic/adapters/postgres/indexes.rb | 1 + lib/scenic/statements.rb | 20 +++++-- spec/scenic/adapters/postgres_spec.rb | 59 +++++++++++++++++++ spec/scenic/statements_spec.rb | 40 +++++++++++-- spec/support/generator_spec_setup.rb | 4 +- 8 files changed, 138 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 92bd1ab4..d15da594 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ tmp gemfiles/*.lock .DS_Store .ruby-version +.ruby-gemset diff --git a/lib/scenic/adapters/postgres.rb b/lib/scenic/adapters/postgres.rb index 2c94e015..2da95a93 100644 --- a/lib/scenic/adapters/postgres.rb +++ b/lib/scenic/adapters/postgres.rb @@ -55,9 +55,15 @@ def views # # @param name The name of the view to create # @param sql_definition The SQL schema for the view. + # @param if_not_exists [Boolean] Default: false. Set to true to create + # view only if it does not exist. # # @return [void] - def create_view(name, sql_definition) + def create_view(name, sql_definition, if_not_exists: false) + if if_not_exists && views.any? { |view| view.name == name } + return + end + execute "CREATE VIEW #{quote_table_name(name)} AS #{sql_definition};" end @@ -112,10 +118,13 @@ 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 if_exists [Boolean] Default: false. Set to true to drop + # view only if it exists. # # @return [void] - def drop_view(name) - execute "DROP VIEW #{quote_table_name(name)};" + def drop_view(name, if_exists: false) + definition_if_exists = if_exists ? "IF EXISTS " : "" + execute "DROP VIEW #{definition_if_exists}#{quote_table_name(name)};" end # Creates a materialized view in the database @@ -125,6 +134,8 @@ def drop_view(name) # @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. + # @param if_not_exists [Boolean] Default: false. Set to true to create + # materialized view only if it does not exist. # # This is typically called in a migration via {Statements#create_view}. # @@ -132,11 +143,13 @@ def drop_view(name) # in use does not support materialized views. # # @return [void] - def create_materialized_view(name, sql_definition, no_data: false) + def create_materialized_view(name, sql_definition, no_data: false, + if_not_exists: false) raise_unless_materialized_views_supported + definition_if_not_exists = if_not_exists ? "IF NOT EXISTS " : "" execute <<-SQL - CREATE MATERIALIZED VIEW #{quote_table_name(name)} AS + CREATE MATERIALIZED VIEW #{definition_if_not_exists}#{quote_table_name(name)} AS #{sql_definition.rstrip.chomp(';')} #{'WITH NO DATA' if no_data}; SQL @@ -174,13 +187,17 @@ def update_materialized_view(name, sql_definition, no_data: false) # This is typically called in a migration via {Statements#update_view}. # # @param name The name of the materialized view to drop. + # @param if_exists [Boolean] Default: false. Set to true to drop + # materialized view only if it exists. # @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, if_exists: false) raise_unless_materialized_views_supported - execute "DROP MATERIALIZED VIEW #{quote_table_name(name)};" + definition_if_exists = if_exists ? "IF EXISTS " : "" + execute "DROP MATERIALIZED VIEW #{definition_if_exists}" \ + "#{quote_table_name(name)};" end # Refreshes a materialized view from its SQL schema. @@ -225,6 +242,7 @@ def refresh_materialized_view(name, concurrently: false, cascade: false) private attr_reader :connectable + delegate :execute, :quote_table_name, to: :connection def connection diff --git a/lib/scenic/adapters/postgres/index_reapplication.rb b/lib/scenic/adapters/postgres/index_reapplication.rb index 59ab5add..96a26843 100644 --- a/lib/scenic/adapters/postgres/index_reapplication.rb +++ b/lib/scenic/adapters/postgres/index_reapplication.rb @@ -56,7 +56,7 @@ def with_savepoint(name) yield connection.execute("RELEASE SAVEPOINT #{name}") true - rescue + rescue StandardError connection.execute("ROLLBACK TO SAVEPOINT #{name}") false end diff --git a/lib/scenic/adapters/postgres/indexes.rb b/lib/scenic/adapters/postgres/indexes.rb index 8ce36de6..74d13c27 100644 --- a/lib/scenic/adapters/postgres/indexes.rb +++ b/lib/scenic/adapters/postgres/indexes.rb @@ -20,6 +20,7 @@ def on(name) private attr_reader :connection + delegate :quote_table_name, to: :connection def indexes_on(name) diff --git a/lib/scenic/statements.rb b/lib/scenic/statements.rb index 84bf24b0..e65e974e 100644 --- a/lib/scenic/statements.rb +++ b/lib/scenic/statements.rb @@ -12,6 +12,8 @@ module Statements # @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 if_not_exists [Boolean] Set to true to only create the view if it + # does not already exist. Defaults to false. # @return The database response from executing the create statement. # # @example Create from `db/views/searches_v02.sql` @@ -22,7 +24,8 @@ module Statements # SELECT * FROM users WHERE users.active = 't' # SQL # - def create_view(name, version: nil, sql_definition: nil, materialized: false) + def create_view(name, version: nil, sql_definition: nil, + materialized: false, if_not_exists: false) if version.present? && sql_definition.present? raise( ArgumentError, @@ -41,9 +44,11 @@ def create_view(name, version: nil, sql_definition: nil, materialized: false) name, sql_definition, no_data: no_data(materialized), + if_not_exists: if_not_exists, ) else - Scenic.database.create_view(name, sql_definition) + Scenic.database.create_view(name, sql_definition, + if_not_exists: if_not_exists) end end @@ -55,18 +60,23 @@ def create_view(name, version: nil, sql_definition: nil, materialized: false) # `version` argument to {#create_view}. # @param materialized [Boolean] Set to true if dropping a meterialized view. # defaults to false. + # @param if_exists [Boolean] Set to true to only drop the view if itexists. + # 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) + # rubocop:disable Lint/UnusedMethodArgument + def drop_view(name, revert_to_version: nil, materialized: false, + if_exists: false) if materialized - Scenic.database.drop_materialized_view(name) + Scenic.database.drop_materialized_view(name, if_exists: if_exists) else - Scenic.database.drop_view(name) + Scenic.database.drop_view(name, if_exists: if_exists) end end + # rubocop:enable Lint/UnusedMethodArgument # Update a database view to a new version. # diff --git a/spec/scenic/adapters/postgres_spec.rb b/spec/scenic/adapters/postgres_spec.rb index 0fa438ec..fe6828ca 100644 --- a/spec/scenic/adapters/postgres_spec.rb +++ b/spec/scenic/adapters/postgres_spec.rb @@ -11,6 +11,27 @@ module Adapters expect(adapter.views.map(&:name)).to include("greetings") end + + it "successfully creates a view with :if_not_exists if view " \ + "does not exist" do + adapter = Postgres.new + + adapter.create_view("greetings", "SELECT text 'hi' AS greeting", + if_not_exists: true) + + expect(adapter.views.map(&:name)).to include("greetings") + end + + it "does not raise an error with :if_not_exists if view exists" do + adapter = Postgres.new + + adapter.create_view("greetings", "SELECT text 'hi' AS greeting") + expect { + adapter.create_view("greetings", "SELECT text 'hi' AS greeting", + if_not_exists: true) + } + .not_to raise_error + end end describe "#create_materialized_view" do @@ -77,6 +98,23 @@ module Adapters expect(adapter.views.map(&:name)).not_to include("greetings") end + + it "successfully drops with :if_exists if view exists" do + adapter = Postgres.new + + adapter.create_view("greetings", "SELECT text 'hi' AS greeting") + adapter.drop_view("greetings", if_exists: true) + + expect(adapter.views.map(&:name)).not_to include("greetings") + end + + it "does not raise error with :if_exists if view does not exist" do + adapter = Postgres.new + + expect { + adapter.drop_view("greetings", if_exists: true) + }.not_to raise_error + end end describe "#drop_materialized_view" do @@ -92,6 +130,27 @@ module Adapters expect(adapter.views.map(&:name)).not_to include("greetings") end + it "successfully drops with :if_exists if view exists" do + adapter = Postgres.new + + adapter.create_materialized_view( + "greetings", + "SELECT text 'hi' AS greeting", + ) + adapter.drop_materialized_view("greetings", if_exists: true) + + expect(adapter.views.map(&:name)).not_to include("greetings") + end + + it "does not raise error with :if_exists if view does not exist" do + adapter = Postgres.new + + expect { + adapter.drop_materialized_view("greetings", + if_exists: true) + }.not_to raise_error + 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) diff --git a/spec/scenic/statements_spec.rb b/spec/scenic/statements_spec.rb index b0aa5120..146cdf08 100644 --- a/spec/scenic/statements_spec.rb +++ b/spec/scenic/statements_spec.rb @@ -18,7 +18,7 @@ module Scenic connection.create_view :views, version: version expect(Scenic.database).to have_received(:create_view) - .with(:views, definition_stub.to_sql) + .with(:views, definition_stub.to_sql, if_not_exists: false) end it "creates a view from a text definition" do @@ -27,7 +27,7 @@ module Scenic connection.create_view(:views, sql_definition: sql_definition) expect(Scenic.database).to have_received(:create_view) - .with(:views, sql_definition) + .with(:views, sql_definition, if_not_exists: false) end it "creates version 1 of the view if neither version nor sql_defintion are provided" do @@ -40,7 +40,7 @@ module Scenic connection.create_view :views expect(Scenic.database).to have_received(:create_view) - .with(:views, definition_stub.to_sql) + .with(:views, definition_stub.to_sql, if_not_exists: false) end it "raises an error if both version and sql_defintion are provided" do @@ -58,7 +58,7 @@ module Scenic connection.create_view(:views, version: 1, materialized: true) expect(Scenic.database).to have_received(:create_materialized_view) - .with(:views, definition.to_sql, no_data: false) + .with(:views, definition.to_sql, no_data: false, if_not_exists: false) end end @@ -74,7 +74,20 @@ module Scenic ) expect(Scenic.database).to have_received(:create_materialized_view) - .with(:views, definition.to_sql, no_data: true) + .with(:views, definition.to_sql, no_data: true, if_not_exists: false) + end + end + + describe "create_view :materialized with :if_not_exists" do + it "sends the create_materialized_view message with if not exists" do + definition = instance_double("Scenic::Definition", to_sql: "definition") + allow(Definition).to receive(:new).and_return(definition) + + connection.create_view(:views, version: 1, materialized: true, + if_not_exists: true) + + expect(Scenic.database).to have_received(:create_materialized_view) + .with(:views, definition.to_sql, no_data: false, if_not_exists: true) end end @@ -82,7 +95,15 @@ module Scenic it "removes a view from the database" do connection.drop_view :name - expect(Scenic.database).to have_received(:drop_view).with(:name) + expect(Scenic.database) + .to have_received(:drop_view).with(:name, if_exists: false) + end + + it "removes a view from the database if it exists" do + connection.drop_view :name, if_exists: true + + expect(Scenic.database).to have_received(:drop_view) + .with(:name, if_exists: true) end end @@ -92,6 +113,13 @@ module Scenic expect(Scenic.database).to have_received(:drop_materialized_view) end + + it "removes a materialized view from the database if it exists" do + connection.drop_view :name, materialized: true, if_exists: true + + expect(Scenic.database).to have_received(:drop_materialized_view) + .with(:name, if_exists: true) + end end describe "update_view" do diff --git a/spec/support/generator_spec_setup.rb b/spec/support/generator_spec_setup.rb index fdeaa9d1..ead31fc6 100644 --- a/spec/support/generator_spec_setup.rb +++ b/spec/support/generator_spec_setup.rb @@ -1,6 +1,6 @@ require "rspec/rails" -require "ammeter/rspec/generator/example.rb" -require "ammeter/rspec/generator/matchers.rb" +require "ammeter/rspec/generator/example" +require "ammeter/rspec/generator/matchers" require "ammeter/init" RSpec.configure do |config|