activerecord_views 0.0.16 → 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
- SHA1:
3
- metadata.gz: a3665d75c1a7665c0c436548c6aef0b045cf65a6
4
- data.tar.gz: 411bd452c4ffc8afaf05e9a06ea2bf708e2c6b54
2
+ SHA256:
3
+ metadata.gz: 846f267566c95c8e6adc3a7dc503e0aaefa83d48dcd5c27b865f6ff60dc70bc0
4
+ data.tar.gz: be109db5a85a682c00244c2ff52d7f29cefb72d20b54ea03431937d04355bdcb
5
5
  SHA512:
6
- metadata.gz: '0693473e52cbd7add2dfc0ba7d95c700e877b39ab8cb97f0a38eba7be10424e788534788ab1d48f37b1066e75a16bfc82c4ee06c018eb6fc3ccc138a861fa623'
7
- data.tar.gz: 8f7940ea28efbee5f26fdd0ec6273b0fa665bc876c5634d6bdc37e7633ea7e86b1572079b1b52098e2163da385cdf187326bba53992c0799b966697ebc44f82c
6
+ metadata.gz: 530ed043fe22df67d38ce2a5ce63f65af5617f2ead4e0de329fe47fe6f0072c25f837861f90f222af24e084d43516785a182125becd0b06e81409d930215a0c0
7
+ data.tar.gz: ea5cacee78d55faaaa1aed554c34317a43d156f71f5bc017de4a9031df8c3432b0b785da335efdc85b67ee1dcc68ce064fc1bdc707d8214912a8fd69b0281052
data/.gitignore CHANGED
@@ -1,5 +1,8 @@
1
1
  .bundle
2
+ tmp/**
2
3
  Gemfile.lock
3
4
  gemfiles/*.gemfile.lock
4
5
  pkg
6
+ spec/internal/db/schema.rb
7
+ spec/internal/app/models_temp/**
5
8
  spec/internal/log/*.log
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 2.6.7
data/Appraisals CHANGED
@@ -1,15 +1,25 @@
1
- appraise 'rails3_2' do
2
- gem 'rails', '~> 3.2.0'
1
+ appraise 'rails4_2' do
2
+ gem 'rails', '~> 4.2.0'
3
+ gem "pg", "< 0.21"
3
4
  end
4
5
 
5
- appraise 'rails4_0' do
6
- gem 'rails', '~> 4.0.0'
6
+ appraise 'rails5_0' do
7
+ gem 'rails', '~> 5.0.0'
8
+ gem "pg", "< 1.0"
7
9
  end
8
10
 
9
- appraise 'rails4_1' do
10
- gem 'rails', '~> 4.1.0'
11
+ appraise 'rails5_1' do
12
+ gem 'rails', '~> 5.1.0'
11
13
  end
12
14
 
13
- appraise 'rails4_2' do
14
- gem 'rails', '~> 4.2.0'
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 < 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', ['>= 3.2', '< 4.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
- gemspec :path => "../"
8
+ gemspec path: "../"
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 5.0.0"
6
+ gem "pg", "< 1.0"
7
+
8
+ gemspec path: "../"
@@ -2,6 +2,6 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "rails", "~> 3.2.0"
5
+ gem "rails", "~> 5.1.0"
6
6
 
7
- gemspec :path => "../"
7
+ gemspec path: "../"
@@ -2,6 +2,6 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "rails", "~> 4.0.0"
5
+ gem "rails", "~> 5.2.0"
6
6
 
7
- gemspec :path => "../"
7
+ gemspec path: "../"
@@ -2,6 +2,6 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "rails", "~> 4.1.0"
5
+ gem "rails", "~> 6.0.0"
6
6
 
7
- gemspec :path => "../"
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
@@ -152,36 +152,39 @@ module ActiveRecordViews
152
152
  end
153
153
  end
154
154
 
155
- def self.drop_all_views(connection)
156
- names = Set.new connection.select_values('SELECT name FROM active_record_views;')
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
- func = lambda do |name|
159
- if view_exists?(connection, name)
160
- get_view_dependants(connection, name).each do |dependant_name, _, _, _|
161
- func.call(dependant_name)
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
- names.each { |name| func.call(name) }
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[:materialized]
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[:unique_columns]
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
- #{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(', ')}
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 = @connection.table_exists?('active_record_views')
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.transaction :requires_new => true do
13
- @connection.select_values('SELECT name FROM active_record_views;').each do |view_name|
14
- @connection.execute "DROP VIEW IF EXISTS #{view_name} CASCADE;"
15
- end
16
- @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;"
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
- def self.currently_migrating?
8
- if defined?(Rake) && Rake.respond_to?(:application)
9
- Rake.application.top_level_tasks.any? { |task_name| task_name =~ /^db:migrate($|:)/ }
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
- ActiveRecordViews.create_view self.connection, self.table_name, self.name, sql, self.view_options
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[:concurrent]
36
- when nil, false
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
- ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(value)
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!