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 +4 -4
- data/README.markdown +42 -2
- data/lib/active_record_views/checksum_cache.rb +43 -5
- data/lib/active_record_views/extension.rb +28 -3
- data/lib/active_record_views/registered_view.rb +4 -4
- data/lib/active_record_views/version.rb +1 -1
- data/lib/active_record_views.rb +84 -22
- data/spec/active_record_views_checksum_cache_spec.rb +45 -3
- data/spec/active_record_views_extension_spec.rb +73 -2
- data/spec/active_record_views_spec.rb +77 -5
- data/spec/spec_helper.rb +13 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b5c0f7297c9900d6d2ce6e803a35eca6a8015c84
|
4
|
+
data.tar.gz: 5a5874b77eec4404e21b2a2ac07bc9a78e2ff0cd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
##
|
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
|
-
|
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
|
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}")
|
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
|
-
|
34
|
-
|
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(
|
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
|
-
|
27
|
-
|
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 :
|
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.
|
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.
|
31
|
+
File.exist?(sql_path) ? File.mtime(sql_path) : nil
|
32
32
|
end
|
33
33
|
|
34
34
|
def update_timestamp!
|
data/lib/active_record_views.rb
CHANGED
@@ -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.
|
23
|
+
return path if File.exist?(path)
|
24
24
|
path = path + '.erb'
|
25
|
-
return path if File.
|
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(
|
57
|
-
|
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
|
-
|
63
|
-
|
64
|
-
|
65
|
-
raise
|
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
|
-
|
69
|
-
|
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(
|
79
|
-
without_transaction
|
89
|
+
def self.drop_view(base_connection, name)
|
90
|
+
without_transaction base_connection do |connection|
|
80
91
|
cache = ActiveRecordViews::ChecksumCache.new(connection)
|
81
|
-
|
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
|
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 |
|
129
|
-
connection
|
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 |
|
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
|
-
|
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 '
|
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
|
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 '
|
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 '.
|
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
|
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.
|
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.
|
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:
|
11
|
+
date: 2015-01-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|