required_scopes 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,121 @@
1
+ require 'active_record'
2
+ require 'required_scopes/errors'
3
+ require 'required_scopes/active_record/version_compatibility'
4
+
5
+ # This file simply adds a few small methods to ::ActiveRecord::Relation to allow tracking which scope categories have
6
+ # been satisfied on a relation.
7
+ ::ActiveRecord::Relation.class_eval do
8
+ # Call this method inline, exactly as you would any class-defined scope, to indicate that a particular category
9
+ # or categories have been satisfied. It's really intended for use in a class method, but both of these will
10
+ # work:
11
+ #
12
+ # class User < ActiveRecord::Base
13
+ # must_scope_by :client, :deleted
14
+ #
15
+ # class << self
16
+ # def active_for_client_named(client_name)
17
+ # client_id = CLIENT_MAP[client_name]
18
+ # where(:client_id => client_id).where(:deleted => false).scope_categories_satisfied(:client)
19
+ # end
20
+ # end
21
+ # end
22
+ #
23
+ # User.active_for_client_named('foo').first
24
+ # User.where(:client_id => client_id).where(:deleted => false).scope_categories_satisfied(:client, :deleted).first
25
+ def scope_categories_satisfied(*categories, &block)
26
+ categories = categories.flatten
27
+
28
+ new_scope = if categories.length == 0
29
+ self
30
+ else
31
+ out = clone
32
+ out.scope_categories_satisfied!(categories)
33
+ out
34
+ end
35
+
36
+ if block
37
+ new_scope.scoping(&block)
38
+ else
39
+ new_scope
40
+ end
41
+ end
42
+
43
+ # Alias for #scope_categories_satisfied.
44
+ def scope_category_satisfied(category, &block)
45
+ scope_categories_satisfied(category, &block)
46
+ end
47
+
48
+ # Tells this Relation that one or more categories have been satisfied.
49
+ def scope_categories_satisfied!(categories)
50
+ categories = categories.flatten
51
+
52
+ @satisfied_scope_categories ||= [ ]
53
+ @satisfied_scope_categories |= categories
54
+ end
55
+
56
+ # Tells this Relation that _all_ categories have been satisfied.
57
+ def all_scope_categories_satisfied!
58
+ scope_categories_satisfied!(required_scope_categories)
59
+ end
60
+
61
+ def all_scope_categories_satisfied(&block)
62
+ scope_categories_satisfied(required_scope_categories, &block)
63
+ end
64
+
65
+ # Returns the set of scope categories that have been satisfied.
66
+ def satisfied_scope_categories
67
+ @satisfied_scope_categories ||= [ ]
68
+ end
69
+
70
+ # Overrides #merge to merge the information about which scope categories have been satisfied, too.
71
+ def merge(other_relation)
72
+ super.scope_categories_satisfied(satisfied_scope_categories | other_relation.satisfied_scope_categories)
73
+ end
74
+
75
+ delegate :required_scope_categories, :to => :klass
76
+
77
+
78
+ private
79
+ # Raises an exception if there is at least one required scope category that has not yet been satisfied.
80
+ # +triggering_method+ is the name of the method called that triggered this check; we include this in the error
81
+ # we raise.
82
+ def ensure_categories_satisfied!(triggering_method)
83
+ required_categories = required_scope_categories
84
+ missing_categories = required_categories - satisfied_scope_categories
85
+
86
+ if missing_categories.length > 0
87
+ # We return a special exception for the category +:base+, because we want to give a simpler, cleaner error
88
+ # message for users who are just using the #base_scope_required! syntactic sugar instead of the full categories
89
+ # system.
90
+ # $stderr.puts "RAISING AT: #{caller.join("\n ")}"
91
+ if missing_categories == [ :base ]
92
+ raise RequiredScopes::Errors::BaseScopeNotSatisfiedError.new(klass, self, triggering_method)
93
+ else
94
+ raise RequiredScopes::Errors::RequiredScopeCategoriesNotSatisfiedError.new(
95
+ klass, self, triggering_method, required_categories, satisfied_scope_categories)
96
+ end
97
+ end
98
+ end
99
+
100
+ # Override certain key methods in ActiveRecord::Relation to make sure they check for category satisfaction before
101
+ # running.
102
+ [ :exec_queries, :perform_calculation, :update_all, :delete_all, :exists?, :pluck ].each do |method_name|
103
+ method_base_name = method_name
104
+ method_suffix = ""
105
+
106
+ if method_base_name.to_s =~ /^(.*?)([\?\!])$/
107
+ method_base_name = $1
108
+ method_suffix = $2
109
+ end
110
+
111
+ with_name = "#{method_base_name}_with_scope_categories_check#{method_suffix}"
112
+ without_name = "#{method_base_name}_without_scope_categories_check#{method_suffix}"
113
+
114
+ define_method(with_name) do |*args, &block|
115
+ ensure_categories_satisfied!(method_name) unless RequiredScopes::ActiveRecord::VersionCompatibility.is_association_relation?(self)
116
+ send(without_name, *args, &block)
117
+ end
118
+
119
+ alias_method_chain method_name, :scope_categories_check
120
+ end
121
+ end
@@ -0,0 +1,157 @@
1
+ module RequiredScopes
2
+ module ActiveRecord
3
+ module VersionCompatibility
4
+ class << self
5
+ delegate :is_association_relation?, :supports_references_method?, :apply_version_specific_fixes!,
6
+ :supports_find_by?, :relation_method_for_ignoring_scopes, :supports_load?, :supports_take?,
7
+ :supports_ids?, :supports_spawn?, :supports_bang_methods?, :supports_references?,
8
+ :supports_unscope?, :supports_none?, :supports_distinct?, :to => :impl
9
+
10
+ private
11
+ def impl
12
+ @impl ||= if ::ActiveRecord::VERSION::MAJOR == 4
13
+ ActiveRecord4.new
14
+ elsif ::ActiveRecord::VERSION::MAJOR == 3
15
+ ActiveRecord3.new
16
+ else
17
+ raise "RequiredScopes does not support ActiveRecord version #{ActiveRecord::VERSION::STRING} currently."
18
+ end
19
+ end
20
+ end
21
+
22
+ class ActiveRecord4
23
+ def is_association_relation?(relation)
24
+ relation.kind_of?(::ActiveRecord::AssociationRelation)
25
+ end
26
+
27
+ def supports_references_method?
28
+ true
29
+ end
30
+
31
+ def apply_version_specific_fixes!
32
+
33
+ end
34
+
35
+ def supports_find_by?
36
+ true
37
+ end
38
+
39
+ def supports_load?
40
+ true
41
+ end
42
+
43
+ def supports_take?
44
+ true
45
+ end
46
+
47
+ def supports_ids?
48
+ true
49
+ end
50
+
51
+ def supports_spawn?
52
+ true
53
+ end
54
+
55
+ def supports_bang_methods?
56
+ true
57
+ end
58
+
59
+ def supports_references?
60
+ true
61
+ end
62
+
63
+ def supports_unscope?
64
+ true
65
+ end
66
+
67
+ def supports_none?
68
+ true
69
+ end
70
+
71
+ def supports_distinct?
72
+ true
73
+ end
74
+
75
+ def relation_method_for_ignoring_scopes
76
+ :all
77
+ end
78
+ end
79
+
80
+ class ActiveRecord3
81
+ def is_association_relation?(relation)
82
+ false
83
+ end
84
+
85
+ def supports_references_method?
86
+ false
87
+ end
88
+
89
+ def apply_version_specific_fixes!
90
+ ::ActiveRecord::Associations::Association.class_eval do
91
+ def target_scope_with_required_scopes_removed
92
+ out = target_scope_without_required_scopes_removed
93
+ out.all_scope_categories_satisfied!
94
+ out
95
+ end
96
+
97
+ alias_method_chain :target_scope, :required_scopes_removed
98
+ end
99
+
100
+ ::ActiveRecord::Base.class_eval do
101
+ def destroy_with_required_scopes_removed
102
+ self.class.all_scope_categories_satisfied do
103
+ destroy_without_required_scopes_removed
104
+ end
105
+ end
106
+
107
+ alias_method_chain :destroy, :required_scopes_removed
108
+ end
109
+ end
110
+
111
+ def supports_find_by?
112
+ false
113
+ end
114
+
115
+ def supports_load?
116
+ false
117
+ end
118
+
119
+ def supports_take?
120
+ false
121
+ end
122
+
123
+ def supports_ids?
124
+ false
125
+ end
126
+
127
+ def supports_spawn?
128
+ false
129
+ end
130
+
131
+ def supports_bang_methods?
132
+ false
133
+ end
134
+
135
+ def supports_references?
136
+ false
137
+ end
138
+
139
+ def supports_unscope?
140
+ false
141
+ end
142
+
143
+ def supports_none?
144
+ false
145
+ end
146
+
147
+ def supports_distinct?
148
+ false
149
+ end
150
+
151
+ def relation_method_for_ignoring_scopes
152
+ :relation
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,48 @@
1
+ module RequiredScopes
2
+ module Errors
3
+ # The parent of all errors raised by RequiredScopes.
4
+ class Base < StandardError; end
5
+
6
+ # Raised if you try to execute an operation on a model or relation without having satisfied one or more
7
+ # scopes.
8
+ class RequiredScopeCategoriesNotSatisfiedError < Base
9
+ attr_reader :model_class, :current_relation, :triggering_method, :required_categories, :satisfied_categories, :missing_categories
10
+
11
+ def initialize(model_class, current_relation, triggering_method, required_categories, satisfied_categories)
12
+ @model_class = model_class
13
+ @current_relation = current_relation
14
+ @triggering_method = triggering_method
15
+ @required_categories = required_categories
16
+ @satisfied_categories = satisfied_categories
17
+ @missing_categories = @required_categories - @satisfied_categories
18
+
19
+ super(build_message)
20
+ end
21
+
22
+ private
23
+ def build_message
24
+ %{Model #{model_class.name} requires that you apply scope(s) satisfying the following
25
+ categories before you use it: #{missing_categories.sort_by(&:to_s).join(", ")}.
26
+
27
+ Satisfy these categories by including scopes in your query that are tagged with
28
+ :satisfies => <category name>, for each of the categories.}
29
+ end
30
+ end
31
+
32
+ # Raised if you try to execute an operation on a model or relation without having satisfied the base scope.
33
+ # (We use this instead of RequiredScopeCategoriesNotSatisfiedError, above, simply to make the error message
34
+ # simpler and more comprehensible to users who only are using the "base scope" syntactic sugar.)
35
+ class BaseScopeNotSatisfiedError < RequiredScopeCategoriesNotSatisfiedError
36
+ def initialize(model_class, current_relation, triggering_method)
37
+ super(model_class, current_relation, triggering_method, [ :base ], [ ])
38
+ end
39
+
40
+ private
41
+ def build_message
42
+ %{Model #{model_class.name} requires specification of a base scope before using it in a query
43
+ or other such operation. (Base scopes are those declared with #base_scope rather than just #scope,
44
+ or class methods that include #base_scope_satisfied.)}
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,3 @@
1
+ module RequiredScopes
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,56 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'required_scopes/version'
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "required_scopes"
8
+ s.version = RequiredScopes::VERSION
9
+ s.authors = ["Andrew Geweke"]
10
+ s.email = ["andrew@geweke.org"]
11
+ s.description = %q{Don't let developers forget about critical scopes for queries.}
12
+ s.summary = %q{Don't let developers forget about critical scopes for queries.}
13
+ s.homepage = "https://github.com/ageweke/required_scopes"
14
+ s.license = "MIT"
15
+
16
+ s.files = `git ls-files`.split($/)
17
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
19
+ s.require_paths = ["lib"]
20
+
21
+ s.add_development_dependency "bundler", "~> 1.3"
22
+ s.add_development_dependency "rake"
23
+ s.add_development_dependency "rspec", "~> 2.14"
24
+
25
+ if (RUBY_VERSION =~ /^1\.9\./ || RUBY_VERSION =~ /^2\./) && ((! defined?(RUBY_ENGINE)) || (RUBY_ENGINE != 'jruby'))
26
+ s.add_development_dependency "pry"
27
+ s.add_development_dependency "pry-debugger"
28
+ s.add_development_dependency "pry-stack_explorer"
29
+ end
30
+
31
+ ar_version = ENV['REQUIRED_SCOPES_AR_TEST_VERSION']
32
+ ar_version = ar_version.strip if ar_version
33
+
34
+ version_spec = case ar_version
35
+ when nil then [ ">= 3.0", "<= 4.99.99" ]
36
+ when 'master' then nil
37
+ else [ "=#{ar_version}" ]
38
+ end
39
+
40
+ if version_spec
41
+ s.add_dependency("activerecord", *version_spec)
42
+ end
43
+
44
+ s.add_dependency "activesupport", ">= 3.0", "<= 4.99.99"
45
+
46
+ require File.expand_path(File.join(File.dirname(__FILE__), 'spec', 'required_scopes', 'helpers', 'database_helper'))
47
+ database_gem_name = RequiredScopes::Helpers::DatabaseHelper.maybe_database_gem_name
48
+
49
+ # Ugh. Later versions of the 'mysql2' gem are incompatible with AR 3.0.x; so, here, we explicitly trap that case
50
+ # and use an earlier version of that Gem.
51
+ if database_gem_name && database_gem_name == 'mysql2' && ar_version && ar_version =~ /^3\.0\./
52
+ s.add_development_dependency('mysql2', '~> 0.2.0')
53
+ else
54
+ s.add_development_dependency(database_gem_name)
55
+ end
56
+ end
@@ -0,0 +1,174 @@
1
+ module RequiredScopes
2
+ module Helpers
3
+ class DatabaseHelper
4
+ class InvalidDatabaseConfigurationError < StandardError; end
5
+
6
+ class << self
7
+ def maybe_database_gem_name
8
+ begin
9
+ dh = new
10
+ dh.database_gem_name
11
+ rescue InvalidDatabaseConfigurationError => idce
12
+ nil
13
+ end
14
+ end
15
+ end
16
+
17
+ def initialize
18
+ config # make sure we raise on instantiation if configuration is invalid
19
+ end
20
+
21
+ def database_type
22
+ case database_gem_name
23
+ when /mysql/i then :mysql
24
+ when /sqlite/i then :sqlite
25
+ when /pg/i, /postgres/i then :postgres
26
+ else raise "Unknown database type for Gem name: #{database_gem_name.inspect}"
27
+ end
28
+ end
29
+
30
+ def setup_activerecord!
31
+ require 'active_record'
32
+ require config[:require]
33
+ ::ActiveRecord::Base.establish_connection(config[:config])
34
+
35
+ require 'logger'
36
+ require 'stringio'
37
+ @logs = StringIO.new
38
+ ::ActiveRecord::Base.logger = Logger.new(@logs)
39
+
40
+ if config[:config][:adapter] == 'sqlite3'
41
+ sqlite_version = ::ActiveRecord::Base.connection.send(:sqlite_version).instance_variable_get("@version").inspect rescue "unknown"
42
+ end
43
+ end
44
+
45
+ def table_name(name)
46
+ "rec_spec_#{name}"
47
+ end
48
+
49
+ def database_gem_name
50
+ config[:database_gem_name]
51
+ end
52
+
53
+ private
54
+ def config
55
+ config_from_config_file || travis_ci_config_from_environment || invalid_config_file!
56
+ end
57
+
58
+ def config_from_config_file
59
+ return nil unless File.exist?(config_file_path)
60
+ require config_file_path
61
+
62
+ return nil unless defined?(REQUIRED_SCOPES_SPEC_DATABASE_CONFIG)
63
+ return nil unless REQUIRED_SCOPES_SPEC_DATABASE_CONFIG.kind_of?(Hash)
64
+
65
+ return nil unless REQUIRED_SCOPES_SPEC_DATABASE_CONFIG[:require]
66
+ return nil unless REQUIRED_SCOPES_SPEC_DATABASE_CONFIG[:database_gem_name]
67
+
68
+ return nil unless REQUIRED_SCOPES_SPEC_DATABASE_CONFIG
69
+ REQUIRED_SCOPES_SPEC_DATABASE_CONFIG
70
+ end
71
+
72
+ def travis_ci_config_from_environment
73
+ dbtype = (ENV['REQUIRED_SCOPES_TRAVIS_CI_DATABASE_TYPE'] || '').strip.downcase
74
+ is_jruby = defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby'
75
+
76
+ if is_jruby
77
+ case dbtype
78
+ when 'mysql'
79
+ {
80
+ :require => 'activerecord-jdbcmysql-adapter',
81
+ :database_gem_name => 'activerecord-jdbcmysql-adapter',
82
+ :config => {
83
+ :adapter => 'jdbcmysql',
84
+ :database => 'myapp_test',
85
+ :username => 'travis',
86
+ :encoding => 'utf8'
87
+ }
88
+ }
89
+ when 'postgres', 'postgresql'
90
+ {
91
+ :require => 'activerecord-jdbcpostgresql-adapter',
92
+ :database_gem_name => 'activerecord-jdbcpostgresql-adapter',
93
+ :config => {
94
+ :adapter => 'jdbcpostgresql',
95
+ :database => 'myapp_test',
96
+ :username => 'postgres'
97
+ }
98
+ }
99
+ when 'sqlite'
100
+ {
101
+ :require => 'activerecord-jdbcsqlite3-adapter',
102
+ :database_gem_name => 'activerecord-jdbcsqlite3-adapter',
103
+ :config => {
104
+ :adapter => 'jdbcsqlite3',
105
+ :database => ':memory:'
106
+ }
107
+ }
108
+ when '', nil then nil
109
+ else
110
+ raise "Unknown Travis CI database type: #{dbtype.inspect}"
111
+ end
112
+ else
113
+ case dbtype
114
+ when 'postgres', 'postgresql'
115
+ {
116
+ :require => 'pg',
117
+ :database_gem_name => 'pg',
118
+ :config => {
119
+ :adapter => 'postgresql',
120
+ :database => 'myapp_test',
121
+ :username => 'postgres',
122
+ :min_messages => 'WARNING'
123
+ }
124
+ }
125
+ when 'mysql'
126
+ {
127
+ :require => 'mysql2',
128
+ :database_gem_name => 'mysql2',
129
+ :config => {
130
+ :adapter => 'mysql2',
131
+ :database => 'myapp_test',
132
+ :username => 'travis',
133
+ :encoding => 'utf8'
134
+ }
135
+ }
136
+ when 'sqlite'
137
+ {
138
+ :require => 'sqlite3',
139
+ :database_gem_name => 'sqlite3',
140
+ :config => {
141
+ :adapter => 'sqlite3',
142
+ :database => ':memory:',
143
+ :timeout => 500
144
+ }
145
+ }
146
+ when '', nil then nil
147
+ else
148
+ raise "Unknown Travis CI database type: #{dbtype.inspect}"
149
+ end
150
+ end
151
+ end
152
+
153
+ def config_file_path
154
+ @config_file_path ||= File.expand_path(File.join(File.dirname(__FILE__), '..', '..', '..', 'spec_database_config.rb'))
155
+ end
156
+
157
+ def invalid_config_file!
158
+ raise Errno::ENOENT, %{In order to run specs for required_scopes, you need to create a file at:
159
+
160
+ #{config_file_path}
161
+
162
+ ...that defines a top-level REQUIRED_SCOPES_SPEC_DATABASE_CONFIG hash, with members:
163
+
164
+ :require => 'name_of_adapter_to_require',
165
+ :database_gem_name => 'name_of_gem_for_adapter',
166
+ :config => { ...whatever ActiveRecord::Base.establish_connection should be passed... }
167
+
168
+ Alternatively, if you're running under Travis CI, you can set the environment variable
169
+ REQUIRED_SCOPES_TRAVIS_CI_DATABASE_TYPE to 'postgres', 'mysql', or 'sqlite', and it will
170
+ use the correct configuration for testing on Travis CI.}
171
+ end
172
+ end
173
+ end
174
+ end