diff --git a/.gitignore b/.gitignore index 560d1a6..619b240 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.gem *.rbc .bundle +Gemfile.lock .config coverage InstalledFiles @@ -16,3 +17,6 @@ tmp .yardoc _yardoc doc/ + +# virtual environment configs +.ruby-* \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 09dd097..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,14 +0,0 @@ -PATH - remote: . - specs: - activerecord-database-views (0.1.0) - -GEM - remote: https://rubygems.org/ - specs: - -PLATFORMS - ruby - -DEPENDENCIES - activerecord-database-views! diff --git a/README.md b/README.md index 314b828..9503837 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,8 @@ end ```ruby class ReverseUser < ActiveRecord::Base private - - def self.discriminate_class_for_record(record) + + def self.discriminate_class_for_record(record) User end end @@ -97,3 +97,13 @@ end ``` As a bonus you will be able to reload views using rake task as well, i.e. `rake reload_views:db:migrate` + +### How to conditionally exclude views +Sometimes selected views may need to be excluded from loading, for example if not valid for a certain environment. + +An exclusion filter may be registered (for example in an initialization file): + +```ruby +ActiveRecord::DatabaseViews.register_view_exclusion_filter( lambda { |name| name == 'exclude_this_view' }) +``` + diff --git a/Rakefile b/Rakefile index 47e7ef4..b30b22b 100644 --- a/Rakefile +++ b/Rakefile @@ -2,7 +2,7 @@ require 'rake' require 'rake/testtask' Rake::TestTask.new do |t| - t.libs << 'lib' + t.libs = ['lib','test'] t.pattern = 'test/**/*_test.rb' t.verbose = false end diff --git a/activerecord-database-views.gemspec b/activerecord-database-views.gemspec index fff5a57..ea90683 100644 --- a/activerecord-database-views.gemspec +++ b/activerecord-database-views.gemspec @@ -16,4 +16,9 @@ Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY s.require_paths = ['lib'] s.rubyforge_project = '[none]' + + s.add_development_dependency 'minitest' + s.add_development_dependency 'mocha' + s.add_development_dependency 'pg' + s.add_runtime_dependency 'activerecord', '>= 3.0', '<= 5.0' end diff --git a/db/views/sql_a_queries_from_base_view.sql b/db/views/sql_a_queries_from_base_view.sql new file mode 100644 index 0000000..e69de29 diff --git a/db/views/sql_base_view.sql b/db/views/sql_base_view.sql new file mode 100644 index 0000000..e69de29 diff --git a/lib/activerecord-database-views.rb b/lib/activerecord-database-views.rb index 13ad494..9c3771b 100644 --- a/lib/activerecord-database-views.rb +++ b/lib/activerecord-database-views.rb @@ -2,19 +2,29 @@ require 'activerecord-database-views/view_collection' module ActiveRecord::DatabaseViews - def self.views - ViewCollection.new + + def self.register_view_exclusion_filter(proc_handle=nil) + if proc_handle && proc_handle.respond_to?(:call) + @view_exclusion_filter = proc_handle + elsif proc_handle == false + @view_exclusion_filter = nil + end + @view_exclusion_filter + end + + def self.views(verbose=true) + ViewCollection.new verbose end - def self.without - views.drop! + def self.without(verbose=true) + views(verbose).drop! yield if block_given? - views.load! + views(verbose).load! end - def self.reload! + def self.reload!(verbose=true) ActiveRecord::Base.transaction do - without + without verbose end end end diff --git a/lib/activerecord-database-views/view.rb b/lib/activerecord-database-views/view.rb index f68eee9..1db4259 100644 --- a/lib/activerecord-database-views/view.rb +++ b/lib/activerecord-database-views/view.rb @@ -1,3 +1,5 @@ +require 'erb' + module ActiveRecord::DatabaseViews class View FILE_NAME_MATCHER_WITH_PREFIX = /^\d+?_(.+)/ @@ -27,7 +29,7 @@ def name private def basename - @basename ||= File.basename(path, '.sql') + @basename ||= File.basename(File.basename(path, '.erb'), '.sql') end def full_path @@ -35,7 +37,11 @@ def full_path end def sql - File.read(full_path) + if File.extname(path) == '.erb' + ERB.new(File.read(full_path)).result + else + File.read(full_path) + end end def call_sql!(sql) diff --git a/lib/activerecord-database-views/view_collection.rb b/lib/activerecord-database-views/view_collection.rb index b1ad7dd..630bada 100644 --- a/lib/activerecord-database-views/view_collection.rb +++ b/lib/activerecord-database-views/view_collection.rb @@ -1,15 +1,26 @@ module ActiveRecord::DatabaseViews + class ViewCollection MISSING_RELATION_REGEX = /relation \"(.*)\" does not exist/ + MISSING_VIEW_REGEX = /view \"(.*)\" does not exist/ + DROP_COLUMNS_REGEX = /cannot drop columns from view/ + CHANGE_COLUMNS_REGEX = /cannot change name of view column \"(.*)\" to \"(.*)\"/ + UNDEFINED_COLUMN_REGEX = /column (.*) does not exist/ include Enumerable - attr_reader :views + attr_reader :views, :verbose delegate :each, to: :views - def initialize - @views = view_paths.map { |path| View.new(path) } + def initialize(verbose=true) + @verbose = verbose + view_exclusion_filter = ActiveRecord::DatabaseViews.register_view_exclusion_filter + @views = view_paths.map do |path| + view = View.new(path) + next if view_exclusion_filter && view_exclusion_filter.call(view.name) + view + end.compact end def drop! @@ -22,14 +33,42 @@ def load! private + def log(msg) + return unless verbose + puts msg + end + def load_view(view) + name = view.name + begin view.load! and views.delete(view) + log "#{name}: Loaded" rescue ActiveRecord::StatementInvalid => exception - if (related_view = retrieve_related_view(exception)) + ActiveRecord::Base.connection.rollback_db_transaction + + if schema_changed?(exception) + log "#{name}: Column definitions have changed" + # Drop the view + view.drop! + # Load it again + load_view(view) + elsif undefined_column?(exception) + log "#{name}: Undefined column" + # Drop all the remaining views since we can't detect which one it is + views.each(&:drop!) + # Load the view again (which will trigger a missing relation error and proceed to load that view) + load_view(view) + elsif (related_view = retrieve_related_view(exception)) + log "#{name}: Contains missing relation" + # Load the relation that is mentioned + load_view(related_view) and retry + elsif (related_view = retrieve_missing_view(exception)) + log "#{name}: Contains missing view" + # Load the view that is mentioned load_view(related_view) and retry else - raise exc + raise exception end end end @@ -40,8 +79,23 @@ def retrieve_related_view(exception) find { |view| view.name == related_view_name } end + def retrieve_missing_view(exception) + related_view_name = exception.message.scan(MISSING_VIEW_REGEX).flatten.first + return false if related_view_name.blank? + find { |view| view.name == related_view_name } + end + + def schema_changed?(exception) + exception.message =~ DROP_COLUMNS_REGEX || + exception.message =~ CHANGE_COLUMNS_REGEX + end + + def undefined_column?(exception) + exception.message =~ UNDEFINED_COLUMN_REGEX + end + def view_paths - Dir.glob('db/views/**/*.sql').sort + Dir.glob('db/views/**/*.{sql,sql.erb}').sort end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 1d9c9fb..7e848aa 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,2 +1,5 @@ require 'bundler/setup' - +require 'minitest/autorun' +require 'mocha/test_unit' +require 'active_record' +require 'activerecord-database-views' diff --git a/test/view_collection_test.rb b/test/view_collection_test.rb new file mode 100644 index 0000000..e48c012 --- /dev/null +++ b/test/view_collection_test.rb @@ -0,0 +1,59 @@ +require File.dirname(__FILE__) + '/test_helper' + +class ViewCollectionTests < MiniTest::Test + def test_it_can_find_sql_files + collection = ActiveRecord::DatabaseViews::ViewCollection.new + assert_equal 2, collection.views.length + end + + def test_it_can_properly_catch_and_execute_a_relation_dependency + collection = ActiveRecord::DatabaseViews::ViewCollection.new + ActiveRecord::DatabaseViews::View.any_instance.expects(:load!).raises(ActiveRecord::StatementInvalid, 'relation "sql_base_view" does not exist') + assert_raises (ActiveRecord::ConnectionNotEstablished) { + collection.load! + } + end + + def test_it_can_properly_catch_and_execute_a_dependency + collection = ActiveRecord::DatabaseViews::ViewCollection.new + ActiveRecord::DatabaseViews::View.any_instance.expects(:load!).raises(ActiveRecord::StatementInvalid, 'view "sql_base_view" does not exist') + assert_raises (ActiveRecord::ConnectionNotEstablished) { + collection.load! + } + end + + def test_it_can_properly_catch_and_execute_when_columns_are_dropped + collection = ActiveRecord::DatabaseViews::ViewCollection.new + ActiveRecord::DatabaseViews::View.any_instance.expects(:load!).raises(ActiveRecord::StatementInvalid, 'cannot drop columns from view') + assert_raises (ActiveRecord::ConnectionNotEstablished) { + collection.load! + } + end + + def test_it_can_properly_catch_and_execute_when_columns_are_changed + collection = ActiveRecord::DatabaseViews::ViewCollection.new + ActiveRecord::DatabaseViews::View.any_instance.expects(:load!).raises(ActiveRecord::StatementInvalid, 'cannot change name of view column "test1" to "test1_full"') + assert_raises (ActiveRecord::ConnectionNotEstablished) { + collection.load! + } + end + + def test_it_can_property_catch_and_execute_when_a_column_is_undefined + collection = ActiveRecord::DatabaseViews::ViewCollection.new + ActiveRecord::DatabaseViews::View.any_instance.expects(:load!).raises(ActiveRecord::StatementInvalid, 'column test1 does not exist') + assert_raises (ActiveRecord::ConnectionNotEstablished) { + collection.load! + } + end + + def test_it_respects_view_exclusion_filter + ActiveRecord::DatabaseViews.register_view_exclusion_filter( lambda { |name| name == 'sql_a_queries_from_base_view' }) + assert ActiveRecord::DatabaseViews.register_view_exclusion_filter.is_a?(Proc) + collection = ActiveRecord::DatabaseViews::ViewCollection.new + assert_equal ['sql_base_view'], collection.collect(&:name) + + ActiveRecord::DatabaseViews.register_view_exclusion_filter(false) + assert ActiveRecord::DatabaseViews.register_view_exclusion_filter.nil? + end + +end