activerecord_views 0.0.20 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7bd9cd5324106a6834c7121d6dc674bb622a61866edcecd968a0ed87b2000c2
4
- data.tar.gz: 850fcbff468562ace2ae45a4cf8a9f255ef0e9dcc26be6efba8191247731b4b3
3
+ metadata.gz: 846f267566c95c8e6adc3a7dc503e0aaefa83d48dcd5c27b865f6ff60dc70bc0
4
+ data.tar.gz: be109db5a85a682c00244c2ff52d7f29cefb72d20b54ea03431937d04355bdcb
5
5
  SHA512:
6
- metadata.gz: a87e872a8469f95873acf78c06ab136156514c70d0fecf63da7f0a6038ebd39cd75e0a79b52786abcff8f5b23fc443dd8393d0a6f96d57d23864c846fce1df28
7
- data.tar.gz: 6bf70cae5bffa0791d9d5ad82ea931cb06b02e893d19fd801c33b5401f841e2b5cb01ed88574762a6448f2ec3e0d9e0e0310139ec25a1105b7cb6858b7cb2eeb
6
+ metadata.gz: 530ed043fe22df67d38ce2a5ce63f65af5617f2ead4e0de329fe47fe6f0072c25f837861f90f222af24e084d43516785a182125becd0b06e81409d930215a0c0
7
+ data.tar.gz: ea5cacee78d55faaaa1aed554c34317a43d156f71f5bc017de4a9031df8c3432b0b785da335efdc85b67ee1dcc68ce064fc1bdc707d8214912a8fd69b0281052
data/.gitignore CHANGED
@@ -1,6 +1,8 @@
1
1
  .bundle
2
+ tmp/**
2
3
  Gemfile.lock
3
4
  gemfiles/*.gemfile.lock
4
5
  pkg
5
6
  spec/internal/db/schema.rb
7
+ spec/internal/app/models_temp/**
6
8
  spec/internal/log/*.log
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 2.6.7
data/Appraisals CHANGED
@@ -1,9 +1,11 @@
1
1
  appraise 'rails4_2' do
2
2
  gem 'rails', '~> 4.2.0'
3
+ gem "pg", "< 0.21"
3
4
  end
4
5
 
5
6
  appraise 'rails5_0' do
6
7
  gem 'rails', '~> 5.0.0'
8
+ gem "pg", "< 1.0"
7
9
  end
8
10
 
9
11
  appraise 'rails5_1' do
@@ -13,3 +15,11 @@ end
13
15
  appraise 'rails5_2' do
14
16
  gem 'rails', '~> 5.2.0'
15
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'
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 < ActiveRecord::Base
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 < ActiveRecord::Base
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 < ActiveRecord::Base
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 an view model from another view model or within SQL blocks in your application code.
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 < ActiveRecord::Base
91
+ class AccountBalance < ApplicationRecord
92
92
  is_view
93
93
  end
94
94
 
95
- class AccountSummary < ActiveRecord::Base
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 < ActiveRecord::Base
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 stale data. To retrieve this timestamp, call `.refreshed_at` on the model:
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
- ```sql
131
- class AccountBalance < ActiveRecord::Base
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, you can then use `concurrent: true` to force a concurrent refresh or `concurrent: :auto` to concurrently refresh when possible:
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: :auto
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 will be left in the database.
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
- In order to keep things tidy and to avoid accidentally referencing a stale view, you should remove the view and associated ActiveRecordViews metadata when renaming or deleting a model using ActiveRecordViews. This is best done with a database migration (use `rails generate migration`) containing the following:
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`.
@@ -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', ['>= 4.2', '< 5.3']
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
@@ -3,5 +3,6 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "~> 4.2.0"
6
+ gem "pg", "< 0.21"
6
7
 
7
8
  gemspec path: "../"
@@ -3,5 +3,6 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "~> 5.0.0"
6
+ gem "pg", "< 1.0"
6
7
 
7
8
  gemspec path: "../"
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 6.0.0"
6
+
7
+ gemspec path: "../"
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 6.1.0"
6
+
7
+ gemspec path: "../"
@@ -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
@@ -154,6 +154,7 @@ module ActiveRecordViews
154
154
 
155
155
  def self.drop_all_views(base_connection)
156
156
  without_transaction base_connection do |connection|
157
+ ActiveRecordViews::ChecksumCache.new(connection)
157
158
  names = Set.new connection.select_values('SELECT name FROM active_record_views;')
158
159
 
159
160
  func = lambda do |name|
@@ -173,17 +174,17 @@ module ActiveRecordViews
173
174
  options.assert_valid_keys :dependencies, :materialized, :unique_columns
174
175
  sql = sql.sub(/;\s*\z/, '')
175
176
 
176
- if options[:materialized]
177
+ if options.fetch(:materialized, false)
177
178
  connection.execute "CREATE MATERIALIZED VIEW #{connection.quote_table_name name} AS #{sql} WITH NO DATA;"
178
179
  else
179
180
  connection.execute "CREATE VIEW #{connection.quote_table_name name} AS #{sql};"
180
181
  end
181
182
 
182
- if options[:unique_columns]
183
+ if unique_columns = options.fetch(:unique_columns, nil)
183
184
  connection.execute <<-SQL.squish
184
185
  CREATE UNIQUE INDEX #{connection.quote_table_name "#{name}_pkey"}
185
186
  ON #{connection.quote_table_name name}(
186
- #{options[:unique_columns].map { |column_name| connection.quote_table_name(column_name) }.join(', ')}
187
+ #{unique_columns.map { |column_name| connection.quote_table_name(column_name) }.join(', ')}
187
188
  );
188
189
  SQL
189
190
  end
@@ -320,14 +321,42 @@ module ActiveRecordViews
320
321
  if registered_view.stale?
321
322
  registered_view.reload!
322
323
  end
324
+ if registered_view.dead?
325
+ self.registered_views.delete registered_view
326
+ end
323
327
  end
324
328
  end
325
329
 
326
330
  def self.drop_unregistered_views!
327
331
  connection = ActiveRecord::Base.connection
332
+ ActiveRecordViews::ChecksumCache.new(connection)
328
333
 
329
334
  connection.select_rows('SELECT name, class_name FROM active_record_views')
330
335
  .reject { |name, class_name| Object.const_defined? class_name }
331
336
  .each { |name, class_name| ActiveRecordViews.drop_view connection, name }
332
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
360
+ end
361
+ end
333
362
  end
@@ -13,12 +13,13 @@ module ActiveRecordViews
13
13
  end
14
14
 
15
15
  if table_exists && !@connection.column_exists?('active_record_views', 'class_name')
16
- @connection.transaction :requires_new => true do
17
- @connection.select_values('SELECT name FROM active_record_views;').each do |view_name|
18
- @connection.execute "DROP VIEW IF EXISTS #{view_name} CASCADE;"
19
- end
20
- @connection.execute 'DROP TABLE active_record_views;'
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;"
21
21
  end
22
+ @connection.execute 'DROP TABLE active_record_views;'
22
23
  table_exists = false
23
24
  end
24
25
 
@@ -33,6 +34,9 @@ module ActiveRecordViews
33
34
  unless table_exists
34
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);"
35
36
  end
37
+
38
+ ensure
39
+ @connection.commit_transaction if in_transaction
36
40
  end
37
41
 
38
42
  def get(name)
@@ -16,12 +16,6 @@ module ActiveRecordViews
16
16
  end
17
17
  end
18
18
 
19
- def self.currently_migrating?
20
- if defined?(Rake) && Rake.respond_to?(:application)
21
- Rake.application.top_level_tasks.any? { |task_name| task_name =~ /^db:migrate($|:)/ }
22
- end
23
- end
24
-
25
19
  module ClassMethods
26
20
  def is_view(*args)
27
21
  cattr_accessor :view_options
@@ -33,11 +27,11 @@ module ActiveRecordViews
33
27
  sql ||= begin
34
28
  sql_path = ActiveRecordViews.find_sql_file(self.name.underscore)
35
29
  ActiveRecordViews.register_for_reload self, sql_path
36
- ActiveRecordViews.read_sql_file(sql_path)
30
+ ActiveRecordViews.read_sql_file(self, sql_path)
37
31
  end
38
32
 
39
33
  create_args = [self.table_name, self.name, sql, self.view_options]
40
- if ActiveRecordViews::Extension.create_enabled && !ActiveRecordViews::Extension.currently_migrating?
34
+ if ActiveRecordViews::Extension.create_enabled
41
35
  ActiveRecordViews.create_view self.connection, *create_args
42
36
  else
43
37
  ActiveRecordViews::Extension.create_queue << create_args
@@ -47,13 +41,13 @@ module ActiveRecordViews
47
41
  def refresh_view!(options = {})
48
42
  options.assert_valid_keys :concurrent
49
43
 
50
- concurrent = case options[:concurrent]
51
- when nil, false
44
+ concurrent = case options.fetch(:concurrent, :auto)
45
+ when false
52
46
  false
53
47
  when true
54
48
  true
55
49
  when :auto
56
- view_populated? && ActiveRecordViews.supports_concurrent_refresh?(connection)
50
+ view_options.fetch(:unique_columns, nil) && view_populated? && ActiveRecordViews.supports_concurrent_refresh?(connection)
57
51
  else
58
52
  raise ArgumentError, 'invalid concurrent option'
59
53
  end
@@ -61,6 +55,7 @@ module ActiveRecordViews
61
55
  connection.transaction do
62
56
  connection.execute "REFRESH MATERIALIZED VIEW#{' CONCURRENTLY' if concurrent} #{connection.quote_table_name self.table_name};"
63
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
64
59
  end
65
60
  end
66
61
 
@@ -89,9 +84,11 @@ module ActiveRecordViews
89
84
  WHERE name = #{connection.quote self.table_name};
90
85
  SQL
91
86
 
92
- if value
93
- ActiveSupport::TimeZone['UTC'].parse(value)
87
+ if value.is_a? String
88
+ value = ActiveSupport::TimeZone['UTC'].parse(value)
94
89
  end
90
+
91
+ value
95
92
  end
96
93
 
97
94
  def ensure_populated!
@@ -2,16 +2,25 @@ module ActiveRecordViews
2
2
  class Railtie < ::Rails::Railtie
3
3
  initializer 'active_record_views' do |app|
4
4
  ActiveSupport.on_load :active_record do
5
- ActiveRecordViews.sql_load_path << Rails.root + 'app/models'
5
+ ActiveRecordViews.sql_load_path += Rails.application.config.paths['app/models'].to_a
6
6
  ActiveRecordViews.init!
7
7
  ActiveRecordViews::Extension.create_enabled = !Rails.env.production?
8
8
  end
9
9
 
10
- ActiveSupport.on_load :action_controller do
11
- unless app.config.cache_classes
12
- ActionDispatch::Callbacks.before do
10
+ unless app.config.cache_classes
11
+ if app.respond_to?(:reloader)
12
+ app.reloader.before_class_unload do
13
13
  ActiveRecordViews.reload_stale_views!
14
14
  end
15
+ app.executor.to_run do
16
+ ActiveRecordViews.reload_stale_views!
17
+ end
18
+ else
19
+ ActiveSupport.on_load :action_controller do
20
+ ActionDispatch::Callbacks.before do
21
+ ActiveRecordViews.reload_stale_views!
22
+ end
23
+ end
15
24
  end
16
25
  end
17
26
  end
@@ -1,6 +1,6 @@
1
1
  module ActiveRecordViews
2
2
  class RegisteredView
3
- attr_reader :sql_path
3
+ attr_reader :model_class_name, :sql_path
4
4
 
5
5
  def initialize(model_class, sql_path)
6
6
  @model_class_name = model_class.name
@@ -16,9 +16,14 @@ module ActiveRecordViews
16
16
  sql_timestamp != @cached_sql_timestamp
17
17
  end
18
18
 
19
+ def dead?
20
+ !File.exist?(sql_path)
21
+ end
22
+
19
23
  def reload!
20
24
  if File.exist? sql_path
21
- ActiveRecordViews.create_view model_class.connection, model_class.table_name, model_class.name, ActiveRecordViews.read_sql_file(sql_path), model_class.view_options
25
+ ActiveRecordViews.create_view model_class.connection, model_class.table_name, model_class.name, ActiveRecordViews.read_sql_file(model_class, sql_path), model_class.view_options
26
+ model_class.reset_column_information
22
27
  else
23
28
  ActiveRecordViews.drop_view model_class.connection, model_class.table_name
24
29
  end