activerecord_views 0.0.6 → 0.0.7

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
  SHA1:
3
- metadata.gz: 888479aa2afb60445b3c837ae67a597d2b799569
4
- data.tar.gz: f86bb06ff3b0d2edfe8f0e524e34b8ed1d8076bb
3
+ metadata.gz: b5c0f7297c9900d6d2ce6e803a35eca6a8015c84
4
+ data.tar.gz: 5a5874b77eec4404e21b2a2ac07bc9a78e2ff0cd
5
5
  SHA512:
6
- metadata.gz: 429f8e647553fa58bb61bd0318446aca7b6b00496e01fd9f3686614129b3ac57d7621d6b073182db5c366bd24eb9da70a31e1d6aa081741e6291fc41d388ff12
7
- data.tar.gz: 0a96d8276ef10685005852d9c8d4c86bab7e0a01ca6eac93dded99b6b13c04459809f2cf9b9b0a901bf3d9dcc6688cc422203f266727b60918878e5ab451ad25
6
+ metadata.gz: f58e18bb2e9ae78e98b127074038856bf4a94273d482022d5ab38fc5f7bac2be9e1deb0f989ba32c9040019008c1dcf49247ef7a4c5de56ae5b2b2283d9df584
7
+ data.tar.gz: 8d38a7027e201afa5f6884792b4da2e2957400d2b0908525d838c29cf03d019c0f02646f97fed8e0b88b94cd3420b617881ee1c5b187c1cdbea399198b45ca8e
data/README.markdown CHANGED
@@ -18,7 +18,7 @@ Add this line to your application's `Gemfile`:
18
18
  gem 'activerecord_views'
19
19
  ```
20
20
 
21
- ## Usage
21
+ ## Example
22
22
 
23
23
  app/models/account.rb:
24
24
 
@@ -68,6 +68,46 @@ Account.includes(:account_balance).find_each do |account|
68
68
  end
69
69
  ```
70
70
 
71
+ ## Materialized views
72
+
73
+ ActiveRecordViews has support [PostgreSQL's materialized views](http://www.postgresql.org/docs/9.4/static/rules-materializedviews.html).
74
+ By default, views execute their query to calculate the output every time they are accessed.
75
+ Materialized views let you cache the output of views. This is useful for views which have expensive calculations. Your application can then trigger a refresh of the cached data as required.
76
+
77
+ To configure an ActiveRecordViews model as being materialized, pass the `materialized: true` option to `is_view`:
78
+
79
+ ```ruby
80
+ class AccountBalance < ActiveRecord::Base
81
+ is_view materialized: true
82
+ end
83
+ ```
84
+
85
+ Materialized views are not initially populated upon creation as this could greatly slow down application startup.
86
+ An exception will be raised if you attempt to read from a view before it is populated.
87
+ You can test if a materialized view has been populated with the `view_populated?` class method and trigger a refresh with the `refresh_view!` class method:
88
+
89
+ ```ruby
90
+ AccountBalance.view_populated? # => false
91
+ AccountBalance.refresh_view!
92
+ AccountBalance.view_populated? # => true
93
+ ```
94
+
95
+ 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:
96
+
97
+ ```sql
98
+ class AccountBalance < ActiveRecord::Base
99
+ is_view materialized: true, unique_columns: %w[account_id]
100
+ end
101
+ ```
102
+
103
+ 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.
104
+
105
+ Once you have defined the unique columns for the view, you can then use `concurrent: true` when refreshing:
106
+
107
+ ```ruby
108
+ AccountBalance.refresh_view! concurrent: true
109
+ ```
110
+
71
111
  ## Pre-populating views in Rails development mode
72
112
 
73
113
  Rails loads classes lazily in development mode by default.
@@ -88,7 +128,7 @@ In order to keep things tidy and to avoid accidentally referencing a stale view,
88
128
  ActiveRecordViews.drop_view connection, 'account_balances'
89
129
  ```
90
130
 
91
- ### Usage outside of Rails
131
+ ## Usage outside of Rails
92
132
 
93
133
  When included in a Ruby on Rails project, ActiveRecordViews will automatically detect `.sql` files alongside models in `app/models`.
94
134
  Outside of Rails, you will have to tell ActiveRecordViews where to find associated `.sql` files for models:
@@ -18,20 +18,58 @@ module ActiveRecordViews
18
18
  table_exists = false
19
19
  end
20
20
 
21
+ if table_exists && !@connection.column_exists?('active_record_views', 'options')
22
+ @connection.execute "ALTER TABLE active_record_views ADD COLUMN options json NOT NULL DEFAULT '{}';"
23
+ end
24
+
21
25
  unless table_exists
22
- @connection.execute 'CREATE TABLE active_record_views(name text PRIMARY KEY, class_name text NOT NULL UNIQUE, checksum text NOT NULL);'
26
+ @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 '{}');"
23
27
  end
24
28
  end
25
29
 
26
30
  def get(name)
27
- @connection.select_one("SELECT class_name, checksum FROM active_record_views WHERE name = #{@connection.quote name}").try(:symbolize_keys)
31
+ if data = @connection.select_one("SELECT class_name, checksum, options FROM active_record_views WHERE name = #{@connection.quote name}")
32
+ data.symbolize_keys!
33
+ data[:options] = JSON.load(data[:options]).symbolize_keys
34
+ data
35
+ end
28
36
  end
29
37
 
30
38
  def set(name, data)
31
39
  if data
32
- data.assert_valid_keys :class_name, :checksum
33
- if @connection.update("UPDATE active_record_views SET class_name = #{@connection.quote data[:class_name]}, checksum = #{@connection.quote data[:checksum]} WHERE name = #{@connection.quote name}") == 0
34
- @connection.insert "INSERT INTO active_record_views (name, class_name, checksum) VALUES (#{@connection.quote name}, #{@connection.quote data[:class_name]}, #{@connection.quote data[:checksum]})"
40
+ data.assert_valid_keys :class_name, :checksum, :options
41
+
42
+ options = data[:options] || {}
43
+ unless options.keys.all? { |key| key.is_a?(Symbol) }
44
+ raise ArgumentError, 'option keys must be symbols'
45
+ end
46
+ options_json = JSON.dump(options)
47
+
48
+ rows_updated = @connection.update(<<-SQL)
49
+ UPDATE active_record_views
50
+ SET
51
+ class_name = #{@connection.quote data[:class_name]},
52
+ checksum = #{@connection.quote data[:checksum]},
53
+ options = #{@connection.quote options_json}
54
+ WHERE
55
+ name = #{@connection.quote name}
56
+ ;
57
+ SQL
58
+
59
+ if rows_updated == 0
60
+ @connection.insert <<-SQL
61
+ INSERT INTO active_record_views (
62
+ name,
63
+ class_name,
64
+ checksum,
65
+ options
66
+ ) VALUES (
67
+ #{@connection.quote name},
68
+ #{@connection.quote data[:class_name]},
69
+ #{@connection.quote data[:checksum]},
70
+ #{@connection.quote options_json}
71
+ )
72
+ SQL
35
73
  end
36
74
  else
37
75
  @connection.delete "DELETE FROM active_record_views WHERE name = #{@connection.quote name}"
@@ -11,7 +11,15 @@ module ActiveRecordViews
11
11
  end
12
12
 
13
13
  module ClassMethods
14
- def is_view(sql = nil)
14
+ def is_view(*args)
15
+ return if ActiveRecordViews::Extension.currently_migrating?
16
+
17
+ cattr_accessor :view_options
18
+ self.view_options = args.extract_options!
19
+
20
+ raise ArgumentError, "wrong number of arguments (#{args.size} for 0..1)" unless (0..1).cover?(args.size)
21
+ sql = args.shift
22
+
15
23
  sql ||= begin
16
24
  sql_path = ActiveRecordViews.find_sql_file(self.name.underscore)
17
25
  ActiveRecordViews.register_for_reload self, sql_path
@@ -23,9 +31,26 @@ module ActiveRecordViews
23
31
  end
24
32
  end
25
33
 
26
- unless ActiveRecordViews::Extension.currently_migrating?
27
- ActiveRecordViews.create_view self.connection, self.table_name, self.name, sql
34
+ ActiveRecordViews.create_view self.connection, self.table_name, self.name, sql, self.view_options
35
+ end
36
+
37
+ def refresh_view!(options = {})
38
+ options.assert_valid_keys :concurrent
39
+ connection.execute "REFRESH MATERIALIZED VIEW#{' CONCURRENTLY' if options[:concurrent]} #{connection.quote_table_name self.table_name};"
40
+ end
41
+
42
+ def view_populated?
43
+ value = connection.select_value(<<-SQL)
44
+ SELECT ispopulated
45
+ FROM pg_matviews
46
+ WHERE schemaname = 'public' AND matviewname = #{connection.quote self.table_name};
47
+ SQL
48
+
49
+ if value.nil?
50
+ raise ArgumentError, 'not a materialized view'
28
51
  end
52
+
53
+ ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(value)
29
54
  end
30
55
  end
31
56
  end
@@ -1,6 +1,6 @@
1
1
  module ActiveRecordViews
2
2
  class RegisteredView
3
- attr_reader :model_class, :sql_path
3
+ attr_reader :sql_path
4
4
 
5
5
  def initialize(model_class, sql_path)
6
6
  @model_class_name = model_class.name
@@ -17,8 +17,8 @@ module ActiveRecordViews
17
17
  end
18
18
 
19
19
  def reload!
20
- if File.exists? sql_path
21
- ActiveRecordViews.create_view model_class.connection, model_class.table_name, model_class.name, File.read(sql_path)
20
+ if File.exist? sql_path
21
+ ActiveRecordViews.create_view model_class.connection, model_class.table_name, model_class.name, File.read(sql_path), model_class.view_options
22
22
  else
23
23
  ActiveRecordViews.drop_view model_class.connection, model_class.table_name
24
24
  end
@@ -28,7 +28,7 @@ module ActiveRecordViews
28
28
  private
29
29
 
30
30
  def sql_timestamp
31
- File.exists?(sql_path) ? File.mtime(sql_path) : nil
31
+ File.exist?(sql_path) ? File.mtime(sql_path) : nil
32
32
  end
33
33
 
34
34
  def update_timestamp!
@@ -1,3 +1,3 @@
1
1
  module ActiveRecordViews
2
- VERSION = '0.0.6'
2
+ VERSION = '0.0.7'
3
3
  end
@@ -20,9 +20,9 @@ module ActiveRecordViews
20
20
  def self.find_sql_file(name)
21
21
  self.sql_load_path.each do |dir|
22
22
  path = "#{dir}/#{name}.sql"
23
- return path if File.exists?(path)
23
+ return path if File.exist?(path)
24
24
  path = path + '.erb'
25
- return path if File.exists?(path)
25
+ return path if File.exist?(path)
26
26
  end
27
27
  raise "could not find #{name}.sql"
28
28
  end
@@ -53,20 +53,31 @@ module ActiveRecordViews
53
53
  end
54
54
  end
55
55
 
56
- def self.create_view(connection, name, class_name, sql)
57
- without_transaction connection do |connection|
56
+ def self.create_view(base_connection, name, class_name, sql, options = {})
57
+ options.assert_valid_keys :materialized, :unique_columns
58
+
59
+ without_transaction base_connection do |connection|
58
60
  cache = ActiveRecordViews::ChecksumCache.new(connection)
59
- data = {class_name: class_name, checksum: Digest::SHA1.hexdigest(sql)}
61
+ data = {class_name: class_name, checksum: Digest::SHA1.hexdigest(sql), options: options}
60
62
  return if cache.get(name) == data
61
63
 
62
- begin
63
- connection.execute "CREATE OR REPLACE VIEW #{connection.quote_table_name name} AS #{sql}"
64
- rescue ActiveRecord::StatementInvalid => original_exception
65
- raise unless view_exists?(connection, name)
64
+ drop_and_create = if options[:materialized]
65
+ true
66
+ else
67
+ raise ArgumentError, 'unique_columns option requires view to be materialized' if options[:unique_columns]
68
+ begin
69
+ connection.execute "CREATE OR REPLACE VIEW #{connection.quote_table_name name} AS #{sql}"
70
+ false
71
+ rescue ActiveRecord::StatementInvalid
72
+ true
73
+ end
74
+ end
75
+
76
+ if drop_and_create
66
77
  connection.transaction :requires_new => true do
67
78
  without_dependencies connection, name do
68
- connection.execute "DROP VIEW #{connection.quote_table_name name}"
69
- connection.execute "CREATE VIEW #{connection.quote_table_name name} AS #{sql}"
79
+ execute_drop_view connection, name
80
+ execute_create_view connection, name, sql, options
70
81
  end
71
82
  end
72
83
  end
@@ -75,22 +86,66 @@ module ActiveRecordViews
75
86
  end
76
87
  end
77
88
 
78
- def self.drop_view(connection, name)
79
- without_transaction connection do |connection|
89
+ def self.drop_view(base_connection, name)
90
+ without_transaction base_connection do |connection|
80
91
  cache = ActiveRecordViews::ChecksumCache.new(connection)
81
- connection.execute "DROP VIEW IF EXISTS #{connection.quote_table_name name}"
92
+ execute_drop_view connection, name
82
93
  cache.set name, nil
83
94
  end
84
95
  end
85
96
 
97
+ def self.execute_create_view(connection, name, sql, options)
98
+ options.assert_valid_keys :materialized, :unique_columns
99
+ sql = sql.sub(/;\s*/, '')
100
+
101
+ if options[:materialized]
102
+ connection.execute "CREATE MATERIALIZED VIEW #{connection.quote_table_name name} AS #{sql} WITH NO DATA;"
103
+ else
104
+ connection.execute "CREATE VIEW #{connection.quote_table_name name} AS #{sql};"
105
+ end
106
+
107
+ if options[:unique_columns]
108
+ connection.execute <<-SQL
109
+ CREATE UNIQUE INDEX #{connection.quote_table_name "#{name}_pkey"}
110
+ ON #{connection.quote_table_name name}(
111
+ #{options[:unique_columns].map { |column_name| connection.quote_table_name(column_name) }.join(', ')}
112
+ );
113
+ SQL
114
+ end
115
+ end
116
+
117
+ def self.execute_drop_view(connection, name)
118
+ if materialized_view?(connection, name)
119
+ connection.execute "DROP MATERIALIZED VIEW IF EXISTS #{connection.quote_table_name name};"
120
+ else
121
+ connection.execute "DROP VIEW IF EXISTS #{connection.quote_table_name name};"
122
+ end
123
+ end
124
+
86
125
  def self.view_exists?(connection, name)
87
126
  connection.select_value(<<-SQL).present?
88
127
  SELECT 1
89
128
  FROM information_schema.views
90
- WHERE table_schema = 'public' AND table_name = #{connection.quote name};
129
+ WHERE table_schema = 'public' AND table_name = #{connection.quote name}
130
+ UNION ALL
131
+ SELECT 1
132
+ FROM pg_matviews
133
+ WHERE schemaname = 'public' AND matviewname = #{connection.quote name};
134
+ SQL
135
+ end
136
+
137
+ def self.materialized_view?(connection, name)
138
+ connection.select_value(<<-SQL).present?
139
+ SELECT 1
140
+ FROM pg_matviews
141
+ WHERE schemaname = 'public' AND matviewname = #{connection.quote name};
91
142
  SQL
92
143
  end
93
144
 
145
+ def self.supports_concurrent_refresh?(connection)
146
+ connection.raw_connection.server_version >= 90400
147
+ end
148
+
94
149
  def self.get_view_dependencies(connection, name)
95
150
  connection.select_rows <<-SQL
96
151
  WITH RECURSIVE dependants AS (
@@ -111,32 +166,39 @@ module ActiveRecordViews
111
166
 
112
167
  SELECT
113
168
  oid::regclass::text AS name,
114
- class_name,
115
- pg_catalog.pg_get_viewdef(oid) AS definition
169
+ MIN(class_name) AS class_name,
170
+ pg_catalog.pg_get_viewdef(oid) AS definition,
171
+ MIN(options::text) AS options_json
116
172
  FROM dependants
117
173
  INNER JOIN active_record_views ON active_record_views.name = oid::regclass::text
118
174
  WHERE level > 0
119
- GROUP BY oid, class_name
175
+ GROUP BY oid
120
176
  ORDER BY MAX(level)
121
177
  ;
122
178
  SQL
123
179
  end
124
180
 
125
181
  def self.without_dependencies(connection, name)
182
+ unless view_exists?(connection, name)
183
+ yield
184
+ return
185
+ end
186
+
126
187
  dependencies = get_view_dependencies(connection, name)
127
188
 
128
- dependencies.reverse.each do |name, _, _|
129
- connection.execute "DROP VIEW #{name};"
189
+ dependencies.reverse.each do |dependency_name, _, _, _|
190
+ execute_drop_view connection, dependency_name
130
191
  end
131
192
 
132
193
  yield
133
194
 
134
- dependencies.each do |name, class_name, definition|
195
+ dependencies.each do |dependency_name, class_name, definition, options_json|
135
196
  begin
136
197
  class_name.constantize
137
198
  rescue NameError => e
138
199
  raise unless e.missing_name?(class_name)
139
- connection.execute "CREATE VIEW #{name} AS #{definition};"
200
+ options = JSON.load(options_json).symbolize_keys
201
+ execute_create_view connection, dependency_name, definition, options
140
202
  end
141
203
  end
142
204
  end
@@ -4,17 +4,20 @@ describe ActiveRecordViews::ChecksumCache do
4
4
  let(:connection) { ActiveRecord::Base.connection }
5
5
 
6
6
  describe 'initialisation' do
7
- context 'with no existing table' do
7
+ context 'no existing table' do
8
8
  it 'creates the table' do
9
9
  expect(ActiveRecord::Base.connection).to receive(:execute).with(/\ACREATE TABLE active_record_views/).once.and_call_original
10
10
 
11
11
  expect(connection.table_exists?('active_record_views')).to eq false
12
12
  ActiveRecordViews::ChecksumCache.new(connection)
13
13
  expect(connection.table_exists?('active_record_views')).to eq true
14
+
15
+ expect(connection.column_exists?('active_record_views', 'class_name')).to eq true
16
+ expect(connection.column_exists?('active_record_views', 'options')).to eq true
14
17
  end
15
18
  end
16
19
 
17
- context 'with existing table' do
20
+ context 'existing table with current structure' do
18
21
  before do
19
22
  ActiveRecordViews::ChecksumCache.new(connection)
20
23
  expect(connection.table_exists?('active_record_views')).to eq true
@@ -27,7 +30,7 @@ describe ActiveRecordViews::ChecksumCache do
27
30
  end
28
31
  end
29
32
 
30
- context 'with old table' do
33
+ context 'existing table without class name' do
31
34
  before do
32
35
  connection.execute 'CREATE TABLE active_record_views(name text PRIMARY KEY, checksum text NOT NULL);'
33
36
 
@@ -58,5 +61,44 @@ describe ActiveRecordViews::ChecksumCache do
58
61
  expect(ActiveRecordViews.view_exists?(connection, 'other_view')).to eq true
59
62
  end
60
63
  end
64
+
65
+ context 'existing table without options' do
66
+ before do
67
+ connection.execute 'CREATE TABLE active_record_views(name text PRIMARY KEY, class_name text NOT NULL UNIQUE, checksum text NOT NULL);'
68
+
69
+ connection.execute 'CREATE VIEW test_view AS SELECT 42 AS id;'
70
+ connection.execute "INSERT INTO active_record_views VALUES ('test_view', 'dummy', 'Dummy');"
71
+ end
72
+
73
+ it 'upgrades the table without losing data' do
74
+ expect(ActiveRecord::Base.connection).to receive(:execute).with("ALTER TABLE active_record_views ADD COLUMN options json NOT NULL DEFAULT '{}';").once.and_call_original
75
+
76
+ expect(connection.column_exists?('active_record_views', 'options')).to eq false
77
+ expect(ActiveRecordViews.view_exists?(connection, 'test_view')).to eq true
78
+
79
+ ActiveRecordViews::ChecksumCache.new(connection)
80
+
81
+ expect(connection.column_exists?('active_record_views', 'options')).to eq true
82
+ expect(ActiveRecordViews.view_exists?(connection, 'test_view')).to eq true
83
+ end
84
+ end
85
+ end
86
+
87
+ describe 'options serialization' do
88
+ let(:dummy_data) { {class_name: 'Test', checksum: '12345'} }
89
+ it 'errors if setting with non-symbol keys' do
90
+ cache = ActiveRecordViews::ChecksumCache.new(connection)
91
+ expect {
92
+ cache.set 'test', dummy_data.merge(options: {'blah' => 123})
93
+ }.to raise_error ArgumentError, 'option keys must be symbols'
94
+ end
95
+
96
+ it 'retrieves hash with symbolized keys and preserves value types' do
97
+ options = {foo: 'hi', bar: 123, baz: true}
98
+
99
+ cache = ActiveRecordViews::ChecksumCache.new(connection)
100
+ cache.set 'test', dummy_data.merge(options: options)
101
+ expect(cache.get('test')[:options]).to eq options
102
+ end
61
103
  end
62
104
  end
@@ -1,7 +1,7 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe ActiveRecordViews::Extension do
4
- describe '.as_view' do
4
+ describe '.is_view' do
5
5
  it 'creates database views from heredocs' do
6
6
  expect(ActiveRecordViews).to receive(:create_view).once.and_call_original
7
7
  expect(HeredocTestModel.first.name).to eq 'Here document'
@@ -34,7 +34,8 @@ describe ActiveRecordViews::Extension do
34
34
  anything,
35
35
  'modified_file_test_models',
36
36
  'ModifiedFileTestModel',
37
- sql
37
+ sql,
38
+ {}
38
39
  ).once.ordered
39
40
  end
40
41
 
@@ -95,5 +96,75 @@ describe ActiveRecordViews::Extension do
95
96
 
96
97
  expect(ModifiedB.first.attributes.except(nil)).to eq('new_name' => 22)
97
98
  end
99
+
100
+ it 'errors if more than one argument is specified' do
101
+ expect {
102
+ class TooManyArguments < ActiveRecord::Base
103
+ is_view 'SELECT 1 AS ID;', 'SELECT 2 AS ID;'
104
+ end
105
+ }.to raise_error ArgumentError, 'wrong number of arguments (2 for 0..1)'
106
+ end
107
+
108
+ it 'errors if an invalid option is specified' do
109
+ expect {
110
+ class InvalidOption < ActiveRecord::Base
111
+ is_view 'SELECT 1 AS ID;', blargh: 123
112
+ end
113
+ }.to raise_error ArgumentError, /^Unknown key: :?blargh/
114
+ end
115
+
116
+ it 'creates/refreshes/drops materialized views' do
117
+ with_temp_sql_dir do |temp_dir|
118
+ sql_file = File.join(temp_dir, 'materialized_view_test_model.sql')
119
+ File.write sql_file, 'SELECT 123 AS id;'
120
+
121
+ class MaterializedViewTestModel < ActiveRecord::Base
122
+ is_view materialized: true
123
+ end
124
+
125
+ expect {
126
+ MaterializedViewTestModel.first!
127
+ }.to raise_error ActiveRecord::StatementInvalid, /materialized view "materialized_view_test_models" has not been populated/
128
+
129
+ expect(MaterializedViewTestModel.view_populated?).to eq false
130
+ MaterializedViewTestModel.refresh_view!
131
+ expect(MaterializedViewTestModel.view_populated?).to eq true
132
+
133
+ expect(MaterializedViewTestModel.first!.id).to eq 123
134
+
135
+ File.unlink sql_file
136
+ test_request
137
+
138
+ expect {
139
+ MaterializedViewTestModel.first!
140
+ }.to raise_error ActiveRecord::StatementInvalid, /relation "materialized_view_test_models" does not exist/
141
+ end
142
+ end
143
+
144
+ it 'raises an error for `view_populated?` if view is not materialized' do
145
+ class NonMaterializedViewPopulatedTestModel < ActiveRecord::Base
146
+ is_view 'SELECT 1 AS id;'
147
+ end
148
+
149
+ expect {
150
+ NonMaterializedViewPopulatedTestModel.view_populated?
151
+ }.to raise_error ArgumentError, 'not a materialized view'
152
+ end
153
+
154
+ it 'supports refreshing materialized views concurrently' do
155
+ class MaterializedViewRefreshTestModel < ActiveRecord::Base
156
+ is_view 'SELECT 1 AS id;', materialized: true
157
+ end
158
+ class MaterializedViewConcurrentRefreshTestModel < ActiveRecord::Base
159
+ is_view 'SELECT 1 AS id;', materialized: true, unique_columns: [:id]
160
+ end
161
+ MaterializedViewConcurrentRefreshTestModel.refresh_view!
162
+
163
+ expect(ActiveRecord::Base.connection).to receive(:execute).with('REFRESH MATERIALIZED VIEW "materialized_view_refresh_test_models";').once.and_call_original
164
+ expect(ActiveRecord::Base.connection).to receive(:execute).with('REFRESH MATERIALIZED VIEW CONCURRENTLY "materialized_view_concurrent_refresh_test_models";').once.and_call_original
165
+
166
+ MaterializedViewRefreshTestModel.refresh_view!
167
+ MaterializedViewConcurrentRefreshTestModel.refresh_view! concurrent: true
168
+ end
98
169
  end
99
170
  end
@@ -4,8 +4,12 @@ describe ActiveRecordViews do
4
4
  describe '.create_view' do
5
5
  let(:connection) { ActiveRecord::Base.connection }
6
6
 
7
- def create_test_view(sql)
8
- ActiveRecordViews.create_view connection, 'test', 'Test', sql
7
+ def create_test_view(sql, options = {})
8
+ ActiveRecordViews.create_view connection, 'test', 'Test', sql, options
9
+ end
10
+
11
+ def drop_test_view
12
+ ActiveRecordViews.drop_view connection, 'test'
9
13
  end
10
14
 
11
15
  def test_view_sql
@@ -24,19 +28,36 @@ describe ActiveRecordViews do
24
28
  SQL
25
29
  end
26
30
 
31
+ def test_materialized_view_sql
32
+ connection.select_value(<<-SQL).try(&:squish)
33
+ SELECT definition
34
+ FROM pg_matviews
35
+ WHERE schemaname = 'public' AND matviewname = 'test'
36
+ SQL
37
+ end
38
+
39
+ def materialized_view_names
40
+ connection.select_values <<-SQL
41
+ SELECT matviewname
42
+ FROM pg_matviews
43
+ WHERE schemaname = 'public'
44
+ SQL
45
+ end
46
+
27
47
  it 'creates database view' do
28
48
  expect(test_view_sql).to be_nil
29
49
  create_test_view 'select 1 as id'
30
50
  expect(test_view_sql).to eq 'SELECT 1 AS id;'
31
51
  end
32
52
 
33
- it 'records checksum and class name' do
34
- create_test_view 'select 1 as id'
53
+ it 'records checksum, class name, and options' do
54
+ create_test_view 'select 1 as id', materialized: true
35
55
  expect(connection.select_all('select * from active_record_views').to_a).to eq [
36
56
  {
37
57
  'name' => 'test',
38
58
  'class_name' => 'Test',
39
- 'checksum' => Digest::SHA1.hexdigest('select 1 as id')
59
+ 'checksum' => Digest::SHA1.hexdigest('select 1 as id'),
60
+ 'options' => '{"materialized":true}',
40
61
  }
41
62
  ]
42
63
  end
@@ -134,6 +155,57 @@ describe ActiveRecordViews do
134
155
  end
135
156
  end
136
157
  end
158
+
159
+ it 'creates and drops materialized views' do
160
+ create_test_view 'select 123 as id', materialized: true
161
+ expect(test_view_sql).to eq nil
162
+ expect(test_materialized_view_sql).to eq 'SELECT 123 AS id;'
163
+
164
+ drop_test_view
165
+ expect(test_view_sql).to eq nil
166
+ expect(test_materialized_view_sql).to eq nil
167
+ end
168
+
169
+ it 'replaces a normal view with a materialized view' do
170
+ create_test_view 'select 11 as id'
171
+ create_test_view 'select 22 as id', materialized: true
172
+
173
+ expect(test_view_sql).to eq nil
174
+ expect(test_materialized_view_sql).to eq 'SELECT 22 AS id;'
175
+ end
176
+
177
+ it 'replaces a materialized view with a normal view' do
178
+ create_test_view 'select 22 as id', materialized: true
179
+ create_test_view 'select 11 as id'
180
+
181
+ expect(test_view_sql).to eq 'SELECT 11 AS id;'
182
+ expect(test_materialized_view_sql).to eq nil
183
+ end
184
+
185
+ it 'can test if materialized views can be refreshed concurrently' do
186
+ expect(ActiveRecordViews.supports_concurrent_refresh?(connection)).to be true
187
+ end
188
+
189
+ it 'preserves materialized view if dropping/recreating' do
190
+ ActiveRecordViews.create_view connection, 'test1', 'Test1', 'SELECT 1 AS foo'
191
+ ActiveRecordViews.create_view connection, 'test2', 'Test2', 'SELECT * FROM test1', materialized: true
192
+ ActiveRecordViews.create_view connection, 'test1', 'Test1', 'SELECT 2 AS bar, 1 AS foo'
193
+
194
+ expect(materialized_view_names).to eq %w[test2]
195
+ expect(view_names).to eq %w[test1]
196
+ end
197
+
198
+ it 'supports creating unique indexes on materialized views' do
199
+ create_test_view 'select 1 as foo, 2 as bar, 3 as baz', materialized: true, unique_columns: [:foo, 'bar']
200
+ index_sql = connection.select_value("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND indexname = 'test_pkey';")
201
+ expect(index_sql).to eq 'CREATE UNIQUE INDEX test_pkey ON test USING btree (foo, bar)'
202
+ end
203
+
204
+ it 'errors if trying to create unique index on non-materialized view' do
205
+ expect {
206
+ create_test_view 'select 1 as foo, 2 as bar, 3 as baz', materialized: false, unique_columns: [:foo, 'bar']
207
+ }.to raise_error ArgumentError, 'unique_columns option requires view to be materialized'
208
+ end
137
209
  end
138
210
 
139
211
  describe '.without_transaction' do
data/spec/spec_helper.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  require 'bundler'
2
2
  Bundler.setup
3
3
 
4
+ require 'rails/version'
5
+ $VERBOSE = true unless Rails::VERSION::MAJOR < 4
6
+
4
7
  require 'combustion'
5
8
  require 'active_record_views'
6
9
  Combustion.initialize! :active_record, :action_controller do
@@ -25,6 +28,15 @@ RSpec.configure do |config|
25
28
  view_names.each do |view_name|
26
29
  connection.execute "DROP VIEW IF EXISTS #{connection.quote_table_name view_name} CASCADE"
27
30
  end
31
+
32
+ materialized_view_names = connection.select_values <<-SQL
33
+ SELECT matviewname
34
+ FROM pg_matviews
35
+ WHERE schemaname = 'public'
36
+ SQL
37
+ materialized_view_names.each do |view_name|
38
+ connection.execute "DROP MATERIALIZED VIEW IF EXISTS #{connection.quote_table_name view_name} CASCADE"
39
+ end
28
40
  end
29
41
 
30
42
  end
@@ -49,7 +61,7 @@ def with_temp_sql_dir
49
61
  end
50
62
 
51
63
  def update_file(file, new_content)
52
- time = File.exists?(file) ? File.mtime(file) : Time.parse('2012-01-01')
64
+ time = File.exist?(file) ? File.mtime(file) : Time.parse('2012-01-01')
53
65
  time = time + 1
54
66
  File.write file, new_content
55
67
  File.utime time, time, file
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord_views
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Weathered
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-12-20 00:00:00.000000000 Z
11
+ date: 2015-01-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord