activerecord_views 0.0.17 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/.tool-versions +1 -0
- data/Appraisals +18 -8
- data/README.markdown +28 -14
- data/activerecord_views.gemspec +3 -1
- data/gemfiles/rails4_2.gemfile +2 -1
- data/gemfiles/rails5_0.gemfile +8 -0
- data/gemfiles/{rails3_2.gemfile → rails5_1.gemfile} +2 -2
- data/gemfiles/{rails4_0.gemfile → rails5_2.gemfile} +2 -2
- data/gemfiles/{rails4_1.gemfile → rails6_0.gemfile} +2 -2
- data/gemfiles/rails6_1.gemfile +7 -0
- data/lib/active_record_views.rb +53 -14
- data/lib/active_record_views/checksum_cache.rb +14 -6
- data/lib/active_record_views/extension.rb +29 -13
- data/lib/active_record_views/railtie.rb +14 -4
- data/lib/active_record_views/registered_view.rb +7 -2
- data/lib/active_record_views/version.rb +1 -1
- data/lib/tasks/active_record_views.rake +70 -17
- data/spec/active_record_views_checksum_cache_spec.rb +21 -12
- data/spec/active_record_views_extension_spec.rb +176 -68
- data/spec/active_record_views_spec.rb +62 -3
- data/spec/internal/Rakefile +6 -1
- data/spec/internal/app/models/erb_test_model.rb +4 -0
- data/spec/internal/app/models/erb_test_model.sql.erb +1 -1
- data/spec/internal/config/database.yml +6 -1
- data/spec/internal/config/routes.rb +3 -0
- data/spec/spec_helper.rb +65 -17
- data/spec/support/silence_warnings.rb +23 -0
- data/spec/tasks_spec.rb +86 -10
- metadata +49 -19
- data/spec/internal/app/models/missing_file_test_model.rb +0 -3
- data/spec/internal/db/schema.rb +0 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 777d910ae40ada55060df98887059878cc8ddb8ec65f6d49a890d2de9164810f
|
4
|
+
data.tar.gz: d905c23880139d85f0fb9ec3c00318670a1638351fc8e42bab05656c8d097d2e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ed3ac3d78b82bf20501936c0c3f4a30815300da89d2516d9149d37723d977638f06f0c6414a62d1460239da7e94478110bb89ec13459d656d28cbe37ca94e254
|
7
|
+
data.tar.gz: 0f6f5bf130436bc8fcf1a9fd1ad383044bc4a0373dd1f18a56c5691ee20a1eb55d38476e8af294c9daeb740182b520aff7202c4c6b75cc638d46dd12cda26dff
|
data/.gitignore
CHANGED
data/.tool-versions
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
ruby 2.6.7
|
data/Appraisals
CHANGED
@@ -1,15 +1,25 @@
|
|
1
|
-
appraise '
|
2
|
-
gem 'rails', '~>
|
1
|
+
appraise 'rails4_2' do
|
2
|
+
gem 'rails', '~> 4.2.0'
|
3
|
+
gem "pg", "< 0.21"
|
3
4
|
end
|
4
5
|
|
5
|
-
appraise '
|
6
|
-
gem 'rails', '~>
|
6
|
+
appraise 'rails5_0' do
|
7
|
+
gem 'rails', '~> 5.0.0'
|
8
|
+
gem "pg", "< 1.0"
|
7
9
|
end
|
8
10
|
|
9
|
-
appraise '
|
10
|
-
gem 'rails', '~>
|
11
|
+
appraise 'rails5_1' do
|
12
|
+
gem 'rails', '~> 5.1.0'
|
11
13
|
end
|
12
14
|
|
13
|
-
appraise '
|
14
|
-
gem 'rails', '~>
|
15
|
+
appraise 'rails5_2' do
|
16
|
+
gem 'rails', '~> 5.2.0'
|
17
|
+
end
|
18
|
+
|
19
|
+
appraise 'rails6_0' do
|
20
|
+
gem 'rails', '~> 6.0.0'
|
21
|
+
end
|
22
|
+
|
23
|
+
appraise 'rails6_1' do
|
24
|
+
gem 'rails', '~> 6.1.0'
|
15
25
|
end
|
data/README.markdown
CHANGED
@@ -23,7 +23,7 @@ gem 'activerecord_views'
|
|
23
23
|
app/models/account.rb:
|
24
24
|
|
25
25
|
```ruby
|
26
|
-
class Account <
|
26
|
+
class Account < ApplicationRecord
|
27
27
|
has_many :transactions
|
28
28
|
|
29
29
|
has_one :account_balance
|
@@ -34,7 +34,7 @@ end
|
|
34
34
|
app/models/transaction.rb:
|
35
35
|
|
36
36
|
```ruby
|
37
|
-
class Transaction <
|
37
|
+
class Transaction < ApplicationRecord
|
38
38
|
belongs_to :account
|
39
39
|
end
|
40
40
|
```
|
@@ -42,7 +42,7 @@ end
|
|
42
42
|
app/models/account_balance.rb:
|
43
43
|
|
44
44
|
```ruby
|
45
|
-
class AccountBalance <
|
45
|
+
class AccountBalance < ApplicationRecord
|
46
46
|
is_view
|
47
47
|
|
48
48
|
belongs_to :account
|
@@ -70,7 +70,7 @@ end
|
|
70
70
|
|
71
71
|
## Dependencies
|
72
72
|
|
73
|
-
You can use
|
73
|
+
You can use a view model from another view model or within SQL blocks in your application code.
|
74
74
|
In order to ensure the model file is loaded (and thus the view is created), you should reference
|
75
75
|
the model class when you use the view rather than using the database table name directly:
|
76
76
|
|
@@ -88,11 +88,11 @@ a safety check which will require you to specify the dependency explicitly if yo
|
|
88
88
|
to another view model:
|
89
89
|
|
90
90
|
```ruby
|
91
|
-
class AccountBalance <
|
91
|
+
class AccountBalance < ApplicationRecord
|
92
92
|
is_view
|
93
93
|
end
|
94
94
|
|
95
|
-
class AccountSummary <
|
95
|
+
class AccountSummary < ApplicationRecord
|
96
96
|
is_view dependencies: [AccountBalance]
|
97
97
|
end
|
98
98
|
```
|
@@ -106,7 +106,7 @@ Materialized views let you cache the output of views. This is useful for views w
|
|
106
106
|
To configure an ActiveRecordViews model as being materialized, pass the `materialized: true` option to `is_view`:
|
107
107
|
|
108
108
|
```ruby
|
109
|
-
class AccountBalance <
|
109
|
+
class AccountBalance < ApplicationRecord
|
110
110
|
is_view materialized: true
|
111
111
|
end
|
112
112
|
```
|
@@ -121,24 +121,34 @@ AccountBalance.refresh_view!
|
|
121
121
|
AccountBalance.view_populated? # => true
|
122
122
|
```
|
123
123
|
|
124
|
-
ActiveRecordViews records when a view was last refreshed. This is often useful for giving users an idea of how
|
124
|
+
ActiveRecordViews records when a view was last refreshed. This is often useful for giving users an idea of how old the cached data is. To retrieve this timestamp, call `.refreshed_at` on the model:
|
125
125
|
|
126
126
|
ActiveRecordViews also has a convenience method called `ensure_populated!` which checks all materialized views in a chain of dependencies have been initially populated. This can be used as a safeguard to lazily populate views on demand. You will probably also setup a schedule to periodically refresh the view data when it gets stale.
|
127
127
|
|
128
128
|
PostgreSQL 9.4 supports refreshing materialized views concurrently. This allows other processes to continue reading old cached data while the view is being updated. To use this feature you must have define a unique index on the materialized view:
|
129
129
|
|
130
|
-
```
|
131
|
-
class AccountBalance <
|
130
|
+
```ruby
|
131
|
+
class AccountBalance < ApplicationRecord
|
132
132
|
is_view materialized: true, unique_columns: %w[account_id]
|
133
133
|
end
|
134
134
|
```
|
135
135
|
|
136
136
|
Note: If your view has a single column as the unique key, you can also tell ActiveRecord about it by adding `self.primary_key = :account_id` in your model file. This is required for features such as `.find` and `.find_each` to work.
|
137
137
|
|
138
|
-
Once you have defined the unique columns for the view,
|
138
|
+
Once you have defined the unique columns for the view, ActiveRecordViews will automatically start refreshing concurrently when you call `refresh_view!`. If you wish to force a non-concurrent refresh, which is typically faster but blocks reads while the view is refreshing, you can then use the `concurrent: false` option:
|
139
139
|
|
140
140
|
```ruby
|
141
|
-
AccountBalance.refresh_view! concurrent:
|
141
|
+
AccountBalance.refresh_view! concurrent: false
|
142
|
+
```
|
143
|
+
|
144
|
+
### Resetting all materialised views
|
145
|
+
|
146
|
+
If you are using [Database Cleaner](https://github.com/DatabaseCleaner/database_cleaner) in your test suite,
|
147
|
+
you probably also want to reset the contents of materialised views for each test run to ensure state
|
148
|
+
does not leak between tests. You can do this with the following in your test suite hooks:
|
149
|
+
|
150
|
+
```ruby
|
151
|
+
ActiveRecordViews.reset_materialized_views
|
142
152
|
```
|
143
153
|
|
144
154
|
## Pre-populating views in Rails development mode
|
@@ -153,9 +163,11 @@ Rails.application.eager_load!
|
|
153
163
|
|
154
164
|
## Handling renames/deletions
|
155
165
|
|
156
|
-
ActiveRecordViews tracks database views by their name. When an ActiveRecordViews model is renamed or deleted, there is no longer a link between the model and the associated database table. This means an orphan view
|
166
|
+
ActiveRecordViews tracks database views by their name. When an ActiveRecordViews model is renamed or deleted, there is no longer a link between the model and the associated database table. This means an orphan view would be left in the database.
|
157
167
|
|
158
|
-
|
168
|
+
ActiveRecordViews will automatically drop deleted views while checking for changes in both production mode when migrations are run and in development mode when code reloads (e.g. refreshing the browser).
|
169
|
+
|
170
|
+
You should avoid dropping views using `DROP VIEW` as this will cause the internal state of ActiveRecordViews to become out of sync. If you want to drop an existing view manually you can instead run the following:
|
159
171
|
|
160
172
|
```ruby
|
161
173
|
ActiveRecordViews.drop_view connection, 'account_balances'
|
@@ -167,6 +179,8 @@ Alternatively, all view models can be dropped with the following:
|
|
167
179
|
ActiveRecordViews.drop_all_views connection
|
168
180
|
```
|
169
181
|
|
182
|
+
Dropping all views is most useful when doing significant structual changes. Both methods can be used in migrations where there are incompatible dependency changes that would otherwise error. ActiveRecordViews will automatically recreate views again when it next checks for pending changes (e.g. production migrations or dev reloading).
|
183
|
+
|
170
184
|
## Usage outside of Rails
|
171
185
|
|
172
186
|
When included in a Ruby on Rails project, ActiveRecordViews will automatically detect `.sql` files alongside models in `app/models`.
|
data/activerecord_views.gemspec
CHANGED
@@ -17,10 +17,12 @@ Gem::Specification.new do |gem|
|
|
17
17
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
18
18
|
gem.require_paths = ['lib']
|
19
19
|
|
20
|
-
gem.add_dependency 'activerecord', ['>=
|
20
|
+
gem.add_dependency 'activerecord', ['>= 4.2', '< 6.2']
|
21
21
|
|
22
22
|
gem.add_development_dependency 'appraisal'
|
23
23
|
gem.add_development_dependency 'rspec-rails', '>= 2.14'
|
24
|
+
gem.add_development_dependency 'super_diff'
|
24
25
|
gem.add_development_dependency 'combustion', '>= 0.5.1'
|
25
26
|
gem.add_development_dependency 'pg'
|
27
|
+
gem.add_development_dependency 'warning'
|
26
28
|
end
|
data/gemfiles/rails4_2.gemfile
CHANGED
data/lib/active_record_views.rb
CHANGED
@@ -28,9 +28,9 @@ module ActiveRecordViews
|
|
28
28
|
raise "could not find #{name}.sql"
|
29
29
|
end
|
30
30
|
|
31
|
-
def self.read_sql_file(sql_path)
|
31
|
+
def self.read_sql_file(model_class, sql_path)
|
32
32
|
if sql_path.end_with?('.erb')
|
33
|
-
ERB.new(File.read(sql_path)).result
|
33
|
+
ERB.new(File.read(sql_path)).result(model_class.instance_eval { binding })
|
34
34
|
else
|
35
35
|
File.read(sql_path)
|
36
36
|
end
|
@@ -152,36 +152,39 @@ module ActiveRecordViews
|
|
152
152
|
end
|
153
153
|
end
|
154
154
|
|
155
|
-
def self.drop_all_views(
|
156
|
-
|
155
|
+
def self.drop_all_views(base_connection)
|
156
|
+
without_transaction base_connection do |connection|
|
157
|
+
ActiveRecordViews::ChecksumCache.new(connection)
|
158
|
+
names = Set.new connection.select_values('SELECT name FROM active_record_views;')
|
157
159
|
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
160
|
+
func = lambda do |name|
|
161
|
+
if view_exists?(connection, name)
|
162
|
+
get_view_dependants(connection, name).each do |dependant_name, _, _, _|
|
163
|
+
func.call(dependant_name)
|
164
|
+
end
|
165
|
+
drop_view connection, name
|
162
166
|
end
|
163
|
-
drop_view connection, name
|
164
167
|
end
|
165
|
-
end
|
166
168
|
|
167
|
-
|
169
|
+
names.each { |name| func.call(name) }
|
170
|
+
end
|
168
171
|
end
|
169
172
|
|
170
173
|
def self.execute_create_view(connection, name, sql, options)
|
171
174
|
options.assert_valid_keys :dependencies, :materialized, :unique_columns
|
172
175
|
sql = sql.sub(/;\s*\z/, '')
|
173
176
|
|
174
|
-
if options
|
177
|
+
if options.fetch(:materialized, false)
|
175
178
|
connection.execute "CREATE MATERIALIZED VIEW #{connection.quote_table_name name} AS #{sql} WITH NO DATA;"
|
176
179
|
else
|
177
180
|
connection.execute "CREATE VIEW #{connection.quote_table_name name} AS #{sql};"
|
178
181
|
end
|
179
182
|
|
180
|
-
if options
|
183
|
+
if unique_columns = options.fetch(:unique_columns, nil)
|
181
184
|
connection.execute <<-SQL.squish
|
182
185
|
CREATE UNIQUE INDEX #{connection.quote_table_name "#{name}_pkey"}
|
183
186
|
ON #{connection.quote_table_name name}(
|
184
|
-
#{
|
187
|
+
#{unique_columns.map { |column_name| connection.quote_table_name(column_name) }.join(', ')}
|
185
188
|
);
|
186
189
|
SQL
|
187
190
|
end
|
@@ -318,6 +321,42 @@ module ActiveRecordViews
|
|
318
321
|
if registered_view.stale?
|
319
322
|
registered_view.reload!
|
320
323
|
end
|
324
|
+
if registered_view.dead?
|
325
|
+
self.registered_views.delete registered_view
|
326
|
+
end
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
def self.drop_unregistered_views!
|
331
|
+
connection = ActiveRecord::Base.connection
|
332
|
+
ActiveRecordViews::ChecksumCache.new(connection)
|
333
|
+
|
334
|
+
connection.select_rows('SELECT name, class_name FROM active_record_views')
|
335
|
+
.reject { |name, class_name| Object.const_defined? class_name }
|
336
|
+
.each { |name, class_name| ActiveRecordViews.drop_view connection, name }
|
337
|
+
end
|
338
|
+
|
339
|
+
def self.reset_materialized_views
|
340
|
+
connection = ActiveRecord::Base.connection
|
341
|
+
ActiveRecordViews::ChecksumCache.new(connection)
|
342
|
+
|
343
|
+
connection.transaction do
|
344
|
+
materialized_views = connection.select_values(<<~SQL)
|
345
|
+
SELECT name
|
346
|
+
FROM active_record_views
|
347
|
+
WHERE (options ->> 'materialized')::boolean
|
348
|
+
AND refreshed_at IS NOT NULL
|
349
|
+
SQL
|
350
|
+
|
351
|
+
materialized_views.each do |view|
|
352
|
+
connection.execute(<<~SQL)
|
353
|
+
REFRESH MATERIALIZED VIEW #{view} WITH NO DATA;
|
354
|
+
SQL
|
355
|
+
end
|
356
|
+
|
357
|
+
connection.execute(<<~SQL)
|
358
|
+
UPDATE active_record_views SET refreshed_at = NULL;
|
359
|
+
SQL
|
321
360
|
end
|
322
361
|
end
|
323
362
|
end
|
@@ -6,15 +6,20 @@ module ActiveRecordViews
|
|
6
6
|
end
|
7
7
|
|
8
8
|
def init_state_table!
|
9
|
-
table_exists =
|
9
|
+
table_exists = if Rails::VERSION::MAJOR >= 5
|
10
|
+
@connection.data_source_exists?('active_record_views')
|
11
|
+
else
|
12
|
+
@connection.table_exists?('active_record_views')
|
13
|
+
end
|
10
14
|
|
11
15
|
if table_exists && !@connection.column_exists?('active_record_views', 'class_name')
|
12
|
-
@connection.
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
@connection.execute
|
16
|
+
@connection.begin_transaction
|
17
|
+
in_transaction = true
|
18
|
+
|
19
|
+
@connection.select_values('SELECT name FROM active_record_views;').each do |view_name|
|
20
|
+
@connection.execute "DROP VIEW IF EXISTS #{view_name} CASCADE;"
|
17
21
|
end
|
22
|
+
@connection.execute 'DROP TABLE active_record_views;'
|
18
23
|
table_exists = false
|
19
24
|
end
|
20
25
|
|
@@ -29,6 +34,9 @@ module ActiveRecordViews
|
|
29
34
|
unless table_exists
|
30
35
|
@connection.execute "CREATE TABLE active_record_views(name text PRIMARY KEY, class_name text NOT NULL UNIQUE, checksum text NOT NULL, options json NOT NULL DEFAULT '{}', refreshed_at timestamp);"
|
31
36
|
end
|
37
|
+
|
38
|
+
ensure
|
39
|
+
@connection.commit_transaction if in_transaction
|
32
40
|
end
|
33
41
|
|
34
42
|
def get(name)
|
@@ -4,16 +4,20 @@ module ActiveRecordViews
|
|
4
4
|
module Extension
|
5
5
|
extend ActiveSupport::Concern
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
7
|
+
mattr_accessor :create_enabled
|
8
|
+
self.create_enabled = true
|
9
|
+
|
10
|
+
mattr_accessor :create_queue
|
11
|
+
self.create_queue = []
|
12
|
+
|
13
|
+
def self.process_create_queue!
|
14
|
+
while create_args = create_queue.shift
|
15
|
+
ActiveRecordViews.create_view ActiveRecord::Base.connection, *create_args
|
10
16
|
end
|
11
17
|
end
|
12
18
|
|
13
19
|
module ClassMethods
|
14
20
|
def is_view(*args)
|
15
|
-
return if ActiveRecordViews::Extension.currently_migrating?
|
16
|
-
|
17
21
|
cattr_accessor :view_options
|
18
22
|
self.view_options = args.extract_options!
|
19
23
|
|
@@ -23,22 +27,27 @@ module ActiveRecordViews
|
|
23
27
|
sql ||= begin
|
24
28
|
sql_path = ActiveRecordViews.find_sql_file(self.name.underscore)
|
25
29
|
ActiveRecordViews.register_for_reload self, sql_path
|
26
|
-
ActiveRecordViews.read_sql_file(sql_path)
|
30
|
+
ActiveRecordViews.read_sql_file(self, sql_path)
|
27
31
|
end
|
28
32
|
|
29
|
-
|
33
|
+
create_args = [self.table_name, self.name, sql, self.view_options]
|
34
|
+
if ActiveRecordViews::Extension.create_enabled
|
35
|
+
ActiveRecordViews.create_view self.connection, *create_args
|
36
|
+
else
|
37
|
+
ActiveRecordViews::Extension.create_queue << create_args
|
38
|
+
end
|
30
39
|
end
|
31
40
|
|
32
41
|
def refresh_view!(options = {})
|
33
42
|
options.assert_valid_keys :concurrent
|
34
43
|
|
35
|
-
concurrent = case options
|
36
|
-
when
|
44
|
+
concurrent = case options.fetch(:concurrent, :auto)
|
45
|
+
when false
|
37
46
|
false
|
38
47
|
when true
|
39
48
|
true
|
40
49
|
when :auto
|
41
|
-
view_populated? && ActiveRecordViews.supports_concurrent_refresh?(connection)
|
50
|
+
view_options.fetch(:unique_columns, nil) && view_populated? && ActiveRecordViews.supports_concurrent_refresh?(connection)
|
42
51
|
else
|
43
52
|
raise ArgumentError, 'invalid concurrent option'
|
44
53
|
end
|
@@ -46,6 +55,7 @@ module ActiveRecordViews
|
|
46
55
|
connection.transaction do
|
47
56
|
connection.execute "REFRESH MATERIALIZED VIEW#{' CONCURRENTLY' if concurrent} #{connection.quote_table_name self.table_name};"
|
48
57
|
connection.execute "UPDATE active_record_views SET refreshed_at = current_timestamp AT TIME ZONE 'UTC' WHERE name = #{connection.quote self.table_name};"
|
58
|
+
connection.clear_query_cache
|
49
59
|
end
|
50
60
|
end
|
51
61
|
|
@@ -60,7 +70,11 @@ module ActiveRecordViews
|
|
60
70
|
raise ArgumentError, 'not a materialized view'
|
61
71
|
end
|
62
72
|
|
63
|
-
|
73
|
+
if Rails::VERSION::MAJOR < 5
|
74
|
+
value = ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(value)
|
75
|
+
end
|
76
|
+
|
77
|
+
value
|
64
78
|
end
|
65
79
|
|
66
80
|
def refreshed_at
|
@@ -70,9 +84,11 @@ module ActiveRecordViews
|
|
70
84
|
WHERE name = #{connection.quote self.table_name};
|
71
85
|
SQL
|
72
86
|
|
73
|
-
if value
|
74
|
-
ActiveSupport::TimeZone['UTC'].parse(value)
|
87
|
+
if value.is_a? String
|
88
|
+
value = ActiveSupport::TimeZone['UTC'].parse(value)
|
75
89
|
end
|
90
|
+
|
91
|
+
value
|
76
92
|
end
|
77
93
|
|
78
94
|
def ensure_populated!
|