Skip to content

merge branches and enhance with configurable view filtering #3

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

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
*.gem
*.rbc
.bundle
Gemfile.lock
.config
coverage
InstalledFiles
Expand All @@ -16,3 +17,6 @@ tmp
.yardoc
_yardoc
doc/

# virtual environment configs
.ruby-*
14 changes: 0 additions & 14 deletions Gemfile.lock

This file was deleted.

14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' })
```

2 changes: 1 addition & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions activerecord-database-views.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Empty file.
Empty file added db/views/sql_base_view.sql
Empty file.
24 changes: 17 additions & 7 deletions lib/activerecord-database-views.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 8 additions & 2 deletions lib/activerecord-database-views/view.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require 'erb'

module ActiveRecord::DatabaseViews
class View
FILE_NAME_MATCHER_WITH_PREFIX = /^\d+?_(.+)/
Expand Down Expand Up @@ -27,15 +29,19 @@ def name
private

def basename
@basename ||= File.basename(path, '.sql')
@basename ||= File.basename(File.basename(path, '.erb'), '.sql')
end

def full_path
Rails.root.join(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)
Expand Down
66 changes: 60 additions & 6 deletions lib/activerecord-database-views/view_collection.rb
Original file line number Diff line number Diff line change
@@ -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!
Expand All @@ -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
Expand All @@ -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
5 changes: 4 additions & 1 deletion test/test_helper.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
require 'bundler/setup'

require 'minitest/autorun'
require 'mocha/test_unit'
require 'active_record'
require 'activerecord-database-views'
59 changes: 59 additions & 0 deletions test/view_collection_test.rb
Original file line number Diff line number Diff line change
@@ -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