scenic 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +13 -8
- data/.gitignore +1 -0
- data/CHANGELOG.md +48 -19
- data/FUNDING.yml +1 -0
- data/Gemfile +2 -2
- data/README.md +71 -18
- data/lib/generators/scenic/materializable.rb +27 -1
- data/lib/generators/scenic/view/templates/db/migrate/update_view.erb +1 -1
- data/lib/scenic/adapters/postgres/index_creation.rb +68 -0
- data/lib/scenic/adapters/postgres/index_migration.rb +70 -0
- data/lib/scenic/adapters/postgres/index_reapplication.rb +3 -28
- data/lib/scenic/adapters/postgres/side_by_side.rb +50 -0
- data/lib/scenic/adapters/postgres/temporary_name.rb +34 -0
- data/lib/scenic/adapters/postgres/views.rb +83 -10
- data/lib/scenic/adapters/postgres.rb +41 -18
- data/lib/scenic/schema_dumper.rb +0 -14
- data/lib/scenic/statements.rb +46 -13
- data/lib/scenic/version.rb +1 -1
- data/scenic.gemspec +5 -1
- data/spec/acceptance/user_manages_views_spec.rb +11 -0
- data/spec/dummy/config/application.rb +4 -0
- data/spec/generators/scenic/view/view_generator_spec.rb +26 -0
- data/spec/scenic/adapters/postgres/index_creation_spec.rb +54 -0
- data/spec/scenic/adapters/postgres/index_migration_spec.rb +24 -0
- data/spec/scenic/adapters/postgres/side_by_side_spec.rb +24 -0
- data/spec/scenic/adapters/postgres/temporary_name_spec.rb +23 -0
- data/spec/scenic/adapters/postgres_spec.rb +44 -3
- data/spec/scenic/command_recorder_spec.rb +18 -0
- data/spec/scenic/schema_dumper_spec.rb +29 -8
- data/spec/scenic/statements_spec.rb +62 -4
- data/spec/spec_helper.rb +19 -4
- data/spec/support/database_schema_helpers.rb +28 -0
- metadata +19 -11
@@ -32,39 +32,14 @@ module Scenic
|
|
32
32
|
|
33
33
|
yield
|
34
34
|
|
35
|
-
|
35
|
+
IndexCreation
|
36
|
+
.new(connection: connection, speaker: speaker)
|
37
|
+
.try_create(indexes)
|
36
38
|
end
|
37
39
|
|
38
40
|
private
|
39
41
|
|
40
42
|
attr_reader :connection, :speaker
|
41
|
-
|
42
|
-
def try_index_create(index)
|
43
|
-
success = with_savepoint(index.index_name) do
|
44
|
-
connection.execute(index.definition)
|
45
|
-
end
|
46
|
-
|
47
|
-
if success
|
48
|
-
say "index '#{index.index_name}' on '#{index.object_name}' has been recreated"
|
49
|
-
else
|
50
|
-
say "index '#{index.index_name}' on '#{index.object_name}' is no longer valid and has been dropped."
|
51
|
-
end
|
52
|
-
end
|
53
|
-
|
54
|
-
def with_savepoint(name)
|
55
|
-
connection.execute("SAVEPOINT #{name}")
|
56
|
-
yield
|
57
|
-
connection.execute("RELEASE SAVEPOINT #{name}")
|
58
|
-
true
|
59
|
-
rescue
|
60
|
-
connection.execute("ROLLBACK TO SAVEPOINT #{name}")
|
61
|
-
false
|
62
|
-
end
|
63
|
-
|
64
|
-
def say(message)
|
65
|
-
subitem = true
|
66
|
-
speaker.say(message, subitem)
|
67
|
-
end
|
68
43
|
end
|
69
44
|
end
|
70
45
|
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Scenic
|
2
|
+
module Adapters
|
3
|
+
class Postgres
|
4
|
+
# Updates a view using the `side-by-side` strategy where the new view is
|
5
|
+
# created and populated under a temporary name before the existing view is
|
6
|
+
# dropped and the temporary view is renamed to the original name.
|
7
|
+
class SideBySide
|
8
|
+
def initialize(adapter:, name:, definition:, speaker: ActiveRecord::Migration.new)
|
9
|
+
@adapter = adapter
|
10
|
+
@name = name
|
11
|
+
@definition = definition
|
12
|
+
@temporary_name = TemporaryName.new(name).to_s
|
13
|
+
@speaker = speaker
|
14
|
+
end
|
15
|
+
|
16
|
+
def update
|
17
|
+
adapter.create_materialized_view(temporary_name, definition)
|
18
|
+
say "temporary materialized view '#{temporary_name}' has been created"
|
19
|
+
|
20
|
+
IndexMigration
|
21
|
+
.new(connection: adapter.connection, speaker: speaker)
|
22
|
+
.migrate(from: name, to: temporary_name)
|
23
|
+
|
24
|
+
adapter.drop_materialized_view(name)
|
25
|
+
say "materialized view '#{name}' has been dropped"
|
26
|
+
|
27
|
+
rename_materialized_view(temporary_name, name)
|
28
|
+
say "temporary materialized view '#{temporary_name}' has been renamed to '#{name}'"
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
attr_reader :adapter, :name, :definition, :temporary_name, :speaker
|
34
|
+
|
35
|
+
def connection
|
36
|
+
adapter.connection
|
37
|
+
end
|
38
|
+
|
39
|
+
def rename_materialized_view(from, to)
|
40
|
+
connection.execute("ALTER MATERIALIZED VIEW #{from} RENAME TO #{to}")
|
41
|
+
end
|
42
|
+
|
43
|
+
def say(message)
|
44
|
+
subitem = true
|
45
|
+
speaker.say(message, subitem)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Scenic
|
2
|
+
module Adapters
|
3
|
+
class Postgres
|
4
|
+
# Generates a temporary object name used internally by Scenic. This is
|
5
|
+
# used during side-by-side materialized view updates to avoid naming
|
6
|
+
# collisions. The generated name is based on a SHA1 hash of the original
|
7
|
+
# which ensures we do not exceed the 63 character limit for object names.
|
8
|
+
#
|
9
|
+
# @api private
|
10
|
+
class TemporaryName
|
11
|
+
# The prefix used for all temporary names.
|
12
|
+
PREFIX = "_scenic_sbs_".freeze
|
13
|
+
|
14
|
+
# Creates a new temporary name object.
|
15
|
+
#
|
16
|
+
# @param name [String] The original name to base the temporary name on.
|
17
|
+
def initialize(name)
|
18
|
+
@name = name
|
19
|
+
@salt = SecureRandom.hex(4)
|
20
|
+
@temporary_name = "#{PREFIX}#{Digest::SHA1.hexdigest(name + salt)}"
|
21
|
+
end
|
22
|
+
|
23
|
+
# @return [String] The temporary name.
|
24
|
+
def to_s
|
25
|
+
temporary_name
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
attr_reader :name, :temporary_name, :salt
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -8,20 +8,91 @@ module Scenic
|
|
8
8
|
@connection = connection
|
9
9
|
end
|
10
10
|
|
11
|
-
# All of the views that this connection has defined
|
11
|
+
# All of the views that this connection has defined, sorted according to
|
12
|
+
# dependencies between the views to facilitate dumping and loading.
|
12
13
|
#
|
13
14
|
# This will include materialized views if those are supported by the
|
14
15
|
# connection.
|
15
16
|
#
|
16
17
|
# @return [Array<Scenic::View>]
|
17
18
|
def all
|
18
|
-
views_from_postgres.map(&method(:to_scenic_view))
|
19
|
+
scenic_views = views_from_postgres.map(&method(:to_scenic_view))
|
20
|
+
sort(scenic_views)
|
19
21
|
end
|
20
22
|
|
21
23
|
private
|
22
24
|
|
25
|
+
def sort(scenic_views)
|
26
|
+
scenic_view_names = scenic_views.map(&:name)
|
27
|
+
|
28
|
+
tsorted_views(scenic_view_names).map do |view_name|
|
29
|
+
scenic_views.find do |sv|
|
30
|
+
sv.name == view_name || sv.name == view_name.split(".").last
|
31
|
+
end
|
32
|
+
end.compact
|
33
|
+
end
|
34
|
+
|
35
|
+
# When dumping the views, their order must be topologically
|
36
|
+
# sorted to take into account dependencies
|
37
|
+
def tsorted_views(views_names)
|
38
|
+
views_hash = TSortableHash.new
|
39
|
+
|
40
|
+
::Scenic.database.execute(DEPENDENT_SQL).each do |relation|
|
41
|
+
source_v = [
|
42
|
+
relation["source_schema"],
|
43
|
+
relation["source_table"]
|
44
|
+
].compact.join(".")
|
45
|
+
|
46
|
+
dependent = [
|
47
|
+
relation["dependent_schema"],
|
48
|
+
relation["dependent_view"]
|
49
|
+
].compact.join(".")
|
50
|
+
|
51
|
+
views_hash[dependent] ||= []
|
52
|
+
views_hash[source_v] ||= []
|
53
|
+
views_hash[dependent] << source_v
|
54
|
+
|
55
|
+
views_names.delete(relation["source_table"])
|
56
|
+
views_names.delete(relation["dependent_view"])
|
57
|
+
end
|
58
|
+
|
59
|
+
# after dependencies, there might be some views left
|
60
|
+
# that don't have any dependencies
|
61
|
+
views_names.sort.each { |v| views_hash[v] ||= [] }
|
62
|
+
views_hash.tsort
|
63
|
+
end
|
64
|
+
|
23
65
|
attr_reader :connection
|
24
66
|
|
67
|
+
# Query for the dependencies between views
|
68
|
+
DEPENDENT_SQL = <<~SQL.freeze
|
69
|
+
SELECT distinct dependent_ns.nspname AS dependent_schema
|
70
|
+
, dependent_view.relname AS dependent_view
|
71
|
+
, source_ns.nspname AS source_schema
|
72
|
+
, source_table.relname AS source_table
|
73
|
+
FROM pg_depend
|
74
|
+
JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid
|
75
|
+
JOIN pg_class as dependent_view ON pg_rewrite.ev_class = dependent_view.oid
|
76
|
+
JOIN pg_class as source_table ON pg_depend.refobjid = source_table.oid
|
77
|
+
JOIN pg_namespace dependent_ns ON dependent_ns.oid = dependent_view.relnamespace
|
78
|
+
JOIN pg_namespace source_ns ON source_ns.oid = source_table.relnamespace
|
79
|
+
WHERE dependent_ns.nspname = ANY (current_schemas(false)) AND source_ns.nspname = ANY (current_schemas(false))
|
80
|
+
AND source_table.relname != dependent_view.relname
|
81
|
+
AND source_table.relkind IN ('m', 'v') AND dependent_view.relkind IN ('m', 'v')
|
82
|
+
ORDER BY dependent_view.relname;
|
83
|
+
SQL
|
84
|
+
private_constant :DEPENDENT_SQL
|
85
|
+
|
86
|
+
class TSortableHash < Hash
|
87
|
+
include TSort
|
88
|
+
|
89
|
+
alias_method :tsort_each_node, :each_key
|
90
|
+
def tsort_each_child(node, &)
|
91
|
+
fetch(node).each(&)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
private_constant :TSortableHash
|
95
|
+
|
25
96
|
def views_from_postgres
|
26
97
|
connection.execute(<<-SQL)
|
27
98
|
SELECT
|
@@ -41,19 +112,21 @@ module Scenic
|
|
41
112
|
end
|
42
113
|
|
43
114
|
def to_scenic_view(result)
|
44
|
-
|
115
|
+
Scenic::View.new(
|
116
|
+
name: namespaced_view_name(result),
|
117
|
+
definition: result["definition"].strip,
|
118
|
+
materialized: result["kind"] == "m"
|
119
|
+
)
|
120
|
+
end
|
45
121
|
|
46
|
-
|
122
|
+
def namespaced_view_name(result)
|
123
|
+
namespace, viewname = result.values_at("namespace", "viewname")
|
124
|
+
|
125
|
+
if namespace != "public"
|
47
126
|
"#{pg_identifier(namespace)}.#{pg_identifier(viewname)}"
|
48
127
|
else
|
49
128
|
pg_identifier(viewname)
|
50
129
|
end
|
51
|
-
|
52
|
-
Scenic::View.new(
|
53
|
-
name: namespaced_viewname,
|
54
|
-
definition: result["definition"].strip,
|
55
|
-
materialized: result["kind"] == "m"
|
56
|
-
)
|
57
130
|
end
|
58
131
|
|
59
132
|
def pg_identifier(name)
|
@@ -4,6 +4,10 @@ require_relative "postgres/index_reapplication"
|
|
4
4
|
require_relative "postgres/indexes"
|
5
5
|
require_relative "postgres/views"
|
6
6
|
require_relative "postgres/refresh_dependencies"
|
7
|
+
require_relative "postgres/side_by_side"
|
8
|
+
require_relative "postgres/index_creation"
|
9
|
+
require_relative "postgres/index_migration"
|
10
|
+
require_relative "postgres/temporary_name"
|
7
11
|
|
8
12
|
module Scenic
|
9
13
|
# Scenic database adapters.
|
@@ -14,7 +18,7 @@ module Scenic
|
|
14
18
|
module Adapters
|
15
19
|
# An adapter for managing Postgres views.
|
16
20
|
#
|
17
|
-
# These methods are used
|
21
|
+
# These methods are used internally by Scenic and are not intended for direct
|
18
22
|
# use. Methods that alter database schema are intended to be called via
|
19
23
|
# {Statements}, while {#refresh_materialized_view} is called via
|
20
24
|
# {Scenic.database}.
|
@@ -124,7 +128,7 @@ module Scenic
|
|
124
128
|
# @param sql_definition The SQL schema that defines the materialized view.
|
125
129
|
# @param no_data [Boolean] Default: false. Set to true to create
|
126
130
|
# materialized view without running the associated query. You will need
|
127
|
-
# to perform a
|
131
|
+
# to perform a refresh to populate with data.
|
128
132
|
#
|
129
133
|
# This is typically called in a migration via {Statements#create_view}.
|
130
134
|
#
|
@@ -154,18 +158,27 @@ module Scenic
|
|
154
158
|
# @param sql_definition The SQL schema for the updated view.
|
155
159
|
# @param no_data [Boolean] Default: false. Set to true to create
|
156
160
|
# materialized view without running the associated query. You will need
|
157
|
-
# to perform a
|
161
|
+
# to perform a refresh to populate with data.
|
162
|
+
# @param side_by_side [Boolean] Default: false. Set to true to create the
|
163
|
+
# new version under a different name and atomically swap them, limiting
|
164
|
+
# the time that a view is inaccessible at the cost of doubling disk usage
|
158
165
|
#
|
159
166
|
# @raise [MaterializedViewsNotSupportedError] if the version of Postgres
|
160
167
|
# in use does not support materialized views.
|
161
168
|
#
|
162
169
|
# @return [void]
|
163
|
-
def update_materialized_view(name, sql_definition, no_data: false)
|
170
|
+
def update_materialized_view(name, sql_definition, no_data: false, side_by_side: false)
|
164
171
|
raise_unless_materialized_views_supported
|
165
172
|
|
166
|
-
|
167
|
-
|
168
|
-
|
173
|
+
if side_by_side
|
174
|
+
SideBySide
|
175
|
+
.new(adapter: self, name: name, definition: sql_definition)
|
176
|
+
.update
|
177
|
+
else
|
178
|
+
IndexReapplication.new(connection: connection).on(name) do
|
179
|
+
drop_materialized_view(name)
|
180
|
+
create_materialized_view(name, sql_definition, no_data: no_data)
|
181
|
+
end
|
169
182
|
end
|
170
183
|
end
|
171
184
|
|
@@ -193,7 +206,10 @@ module Scenic
|
|
193
206
|
# refreshed without locking the view for select but requires that the
|
194
207
|
# table have at least one unique index that covers all rows. Attempts to
|
195
208
|
# refresh concurrently without a unique index will raise a descriptive
|
196
|
-
# error.
|
209
|
+
# error. This option is ignored if the view is not populated, as it
|
210
|
+
# would cause an error to be raised by Postgres. Default: false.
|
211
|
+
# @param cascade [Boolean] Whether to refresh dependent materialized
|
212
|
+
# views. Default: false.
|
197
213
|
#
|
198
214
|
# @raise [MaterializedViewsNotSupportedError] if the version of Postgres
|
199
215
|
# in use does not support materialized views.
|
@@ -205,26 +221,29 @@ module Scenic
|
|
205
221
|
# Scenic.database.refresh_materialized_view(:search_results)
|
206
222
|
# @example Concurrent refresh
|
207
223
|
# Scenic.database.refresh_materialized_view(:posts, concurrently: true)
|
224
|
+
# @example Cascade refresh
|
225
|
+
# Scenic.database.refresh_materialized_view(:posts, cascade: true)
|
208
226
|
#
|
209
227
|
# @return [void]
|
210
228
|
def refresh_materialized_view(name, concurrently: false, cascade: false)
|
211
229
|
raise_unless_materialized_views_supported
|
212
230
|
|
231
|
+
if concurrently
|
232
|
+
raise_unless_concurrent_refresh_supported
|
233
|
+
end
|
234
|
+
|
213
235
|
if cascade
|
214
236
|
refresh_dependencies_for(name, concurrently: concurrently)
|
215
237
|
end
|
216
238
|
|
217
|
-
if concurrently
|
218
|
-
raise_unless_concurrent_refresh_supported
|
239
|
+
if concurrently && populated?(name)
|
219
240
|
execute "REFRESH MATERIALIZED VIEW CONCURRENTLY #{quote_table_name(name)};"
|
220
241
|
else
|
221
242
|
execute "REFRESH MATERIALIZED VIEW #{quote_table_name(name)};"
|
222
243
|
end
|
223
244
|
end
|
224
245
|
|
225
|
-
# True if supplied relation name is populated.
|
226
|
-
# state of materialized views which may error if created `WITH NO DATA`
|
227
|
-
# and used before they are refreshed. True for all other relation types.
|
246
|
+
# True if supplied relation name is populated.
|
228
247
|
#
|
229
248
|
# @param name The name of the relation
|
230
249
|
#
|
@@ -235,7 +254,7 @@ module Scenic
|
|
235
254
|
def populated?(name)
|
236
255
|
raise_unless_materialized_views_supported
|
237
256
|
|
238
|
-
schemaless_name = name.split(".").last
|
257
|
+
schemaless_name = name.to_s.split(".").last
|
239
258
|
|
240
259
|
sql = "SELECT relispopulated FROM pg_class WHERE relname = '#{schemaless_name}'"
|
241
260
|
relations = execute(sql)
|
@@ -247,15 +266,19 @@ module Scenic
|
|
247
266
|
end
|
248
267
|
end
|
249
268
|
|
269
|
+
# A decorated ActiveRecord connection object with some Scenic-specific
|
270
|
+
# methods. Not intended for direct use outside of the Postgres adapter.
|
271
|
+
#
|
272
|
+
# @api private
|
273
|
+
def connection
|
274
|
+
Connection.new(connectable.connection)
|
275
|
+
end
|
276
|
+
|
250
277
|
private
|
251
278
|
|
252
279
|
attr_reader :connectable
|
253
280
|
delegate :execute, :quote_table_name, to: :connection
|
254
281
|
|
255
|
-
def connection
|
256
|
-
Connection.new(connectable.connection)
|
257
|
-
end
|
258
|
-
|
259
282
|
def raise_unless_materialized_views_supported
|
260
283
|
unless connection.supports_materialized_views?
|
261
284
|
raise MaterializedViewsNotSupportedError
|
data/lib/scenic/schema_dumper.rb
CHANGED
@@ -26,19 +26,5 @@ module Scenic
|
|
26
26
|
ignored?(view.name)
|
27
27
|
end
|
28
28
|
end
|
29
|
-
|
30
|
-
unless ActiveRecord::SchemaDumper.private_instance_methods(false).include?(:ignored?)
|
31
|
-
# This method will be present in Rails 4.2.0 and can be removed then.
|
32
|
-
def ignored?(table_name)
|
33
|
-
["schema_migrations", ignore_tables].flatten.any? do |ignored|
|
34
|
-
case ignored
|
35
|
-
when String then remove_prefix_and_suffix(table_name) == ignored
|
36
|
-
when Regexp then remove_prefix_and_suffix(table_name) =~ ignored
|
37
|
-
else
|
38
|
-
raise StandardError, "ActiveRecord::SchemaDumper.ignore_tables accepts an array of String and / or Regexp values."
|
39
|
-
end
|
40
|
-
end
|
41
|
-
end
|
42
|
-
end
|
43
29
|
end
|
44
30
|
end
|
data/lib/scenic/statements.rb
CHANGED
@@ -9,9 +9,11 @@ module Scenic
|
|
9
9
|
# @param sql_definition [String] The SQL query for the view schema. An error
|
10
10
|
# will be raised if `sql_definition` and `version` are both set,
|
11
11
|
# as they are mutually exclusive.
|
12
|
-
# @param materialized [Boolean, Hash] Set to
|
13
|
-
#
|
14
|
-
#
|
12
|
+
# @param materialized [Boolean, Hash] Set to a truthy value to create a
|
13
|
+
# materialized view. Hash
|
14
|
+
# @option materialized [Boolean] :no_data (false) Set to true to create
|
15
|
+
# materialized view without running the associated query. You will need
|
16
|
+
# to perform a non-concurrent refresh to populate with data.
|
15
17
|
# @return The database response from executing the create statement.
|
16
18
|
#
|
17
19
|
# @example Create from `db/views/searches_v02.sql`
|
@@ -37,10 +39,12 @@ module Scenic
|
|
37
39
|
sql_definition ||= definition(name, version)
|
38
40
|
|
39
41
|
if materialized
|
42
|
+
options = materialized_options(materialized)
|
43
|
+
|
40
44
|
Scenic.database.create_materialized_view(
|
41
45
|
name,
|
42
46
|
sql_definition,
|
43
|
-
no_data: no_data
|
47
|
+
no_data: options[:no_data]
|
44
48
|
)
|
45
49
|
else
|
46
50
|
Scenic.database.create_view(name, sql_definition)
|
@@ -80,14 +84,23 @@ module Scenic
|
|
80
84
|
# as they are mutually exclusive.
|
81
85
|
# @param revert_to_version [Fixnum] The version number to rollback to on
|
82
86
|
# `rake db rollback`
|
83
|
-
# @param materialized [Boolean, Hash] True if updating a
|
84
|
-
#
|
85
|
-
#
|
87
|
+
# @param materialized [Boolean, Hash] True or a Hash if updating a
|
88
|
+
# materialized view.
|
89
|
+
# @option materialized [Boolean] :no_data (false) Set to true to update
|
90
|
+
# a materialized view without loading data. You will need to perform a
|
91
|
+
# refresh to populate with data. Cannot be combined with the :side_by_side
|
92
|
+
# option.
|
93
|
+
# @option materialized [Boolean] :side_by_side (false) Set to true to update
|
94
|
+
# update a materialized view using our side-by-side strategy, which will
|
95
|
+
# limit the time the view is locked at the cost of increasing disk usage.
|
96
|
+
# The view is initially updated with a temporary name and atomically
|
97
|
+
# swapped once it is successfully created with data. Cannot be combined
|
98
|
+
# with the :no_data option.
|
86
99
|
# @return The database response from executing the create statement.
|
87
100
|
#
|
88
101
|
# @example
|
89
102
|
# update_view :engagement_reports, version: 3, revert_to_version: 2
|
90
|
-
#
|
103
|
+
# update_view :comments, version: 2, revert_to_version: 1, materialized: { side_by_side: true }
|
91
104
|
def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, materialized: false)
|
92
105
|
if version.blank? && sql_definition.blank?
|
93
106
|
raise(
|
@@ -106,10 +119,24 @@ module Scenic
|
|
106
119
|
sql_definition ||= definition(name, version)
|
107
120
|
|
108
121
|
if materialized
|
122
|
+
options = materialized_options(materialized)
|
123
|
+
|
124
|
+
if options[:no_data] && options[:side_by_side]
|
125
|
+
raise(
|
126
|
+
ArgumentError,
|
127
|
+
"no_data and side_by_side options cannot be combined"
|
128
|
+
)
|
129
|
+
end
|
130
|
+
|
131
|
+
if options[:side_by_side] && !transaction_open?
|
132
|
+
raise "a transaction is required to perform a side-by-side update"
|
133
|
+
end
|
134
|
+
|
109
135
|
Scenic.database.update_materialized_view(
|
110
136
|
name,
|
111
137
|
sql_definition,
|
112
|
-
no_data: no_data
|
138
|
+
no_data: options[:no_data],
|
139
|
+
side_by_side: options[:side_by_side]
|
113
140
|
)
|
114
141
|
else
|
115
142
|
Scenic.database.update_view(name, sql_definition)
|
@@ -152,11 +179,17 @@ module Scenic
|
|
152
179
|
Scenic::Definition.new(name, version).to_sql
|
153
180
|
end
|
154
181
|
|
155
|
-
def
|
156
|
-
if materialized.is_a?
|
157
|
-
|
182
|
+
def materialized_options(materialized)
|
183
|
+
if materialized.is_a? Hash
|
184
|
+
{
|
185
|
+
no_data: materialized.fetch(:no_data, false),
|
186
|
+
side_by_side: materialized.fetch(:side_by_side, false)
|
187
|
+
}
|
158
188
|
else
|
159
|
-
|
189
|
+
{
|
190
|
+
no_data: false,
|
191
|
+
side_by_side: false
|
192
|
+
}
|
160
193
|
end
|
161
194
|
end
|
162
195
|
end
|
data/lib/scenic/version.rb
CHANGED
data/scenic.gemspec
CHANGED
@@ -18,11 +18,15 @@ Gem::Specification.new do |spec|
|
|
18
18
|
spec.files = `git ls-files -z`.split("\x0")
|
19
19
|
spec.require_paths = ["lib"]
|
20
20
|
|
21
|
+
spec.metadata = {
|
22
|
+
"funding-uri" => "https://github.com/scenic-views/scenic"
|
23
|
+
}
|
24
|
+
|
21
25
|
spec.add_development_dependency "bundler", ">= 1.5"
|
22
26
|
spec.add_development_dependency "database_cleaner"
|
23
27
|
spec.add_development_dependency "rake"
|
24
28
|
spec.add_development_dependency "rspec", ">= 3.3"
|
25
|
-
spec.add_development_dependency "pg"
|
29
|
+
spec.add_development_dependency "pg"
|
26
30
|
spec.add_development_dependency "pry"
|
27
31
|
spec.add_development_dependency "ammeter", ">= 1.1.3"
|
28
32
|
spec.add_development_dependency "yard"
|
@@ -45,6 +45,17 @@ describe "User manages views" do
|
|
45
45
|
verify_result "Child.take.name", "Elliot"
|
46
46
|
verify_schema_contains 'add_index "children"'
|
47
47
|
|
48
|
+
successfully "rails generate scenic:view child --materialized --side-by-side"
|
49
|
+
verify_identical_view_definitions "children_v02", "children_v03"
|
50
|
+
|
51
|
+
write_definition "children_v03", "SELECT 'Juniper'::text AS name"
|
52
|
+
successfully "rake db:migrate"
|
53
|
+
|
54
|
+
successfully "rake db:reset"
|
55
|
+
verify_result "Child.take.name", "Juniper"
|
56
|
+
verify_schema_contains 'add_index "children"'
|
57
|
+
|
58
|
+
successfully "rake db:rollback"
|
48
59
|
successfully "rake db:rollback"
|
49
60
|
successfully "rake db:rollback"
|
50
61
|
successfully "rails destroy scenic:model child"
|
@@ -11,5 +11,9 @@ module Dummy
|
|
11
11
|
config.cache_classes = true
|
12
12
|
config.eager_load = false
|
13
13
|
config.active_support.deprecation = :stderr
|
14
|
+
|
15
|
+
if config.active_support.respond_to?(:to_time_preserves_timezone)
|
16
|
+
config.active_support.to_time_preserves_timezone = :zone
|
17
|
+
end
|
14
18
|
end
|
15
19
|
end
|
@@ -37,6 +37,32 @@ describe Scenic::Generators::ViewGenerator, :generator do
|
|
37
37
|
end
|
38
38
|
end
|
39
39
|
|
40
|
+
it "sets the no_data option when updating a materialized view" do
|
41
|
+
with_view_definition("aired_episodes", 1, "hello") do
|
42
|
+
allow(Dir).to receive(:entries).and_return(["aired_episodes_v01.sql"])
|
43
|
+
|
44
|
+
run_generator ["aired_episode", "--materialized", "--no-data"]
|
45
|
+
migration = migration_file(
|
46
|
+
"db/migrate/update_aired_episodes_to_version_2.rb"
|
47
|
+
)
|
48
|
+
expect(migration).to contain "materialized: { no_data: true }"
|
49
|
+
expect(migration).not_to contain "side_by_side"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
it "sets the side-by-side option when updating a materialized view" do
|
54
|
+
with_view_definition("aired_episodes", 1, "hello") do
|
55
|
+
allow(Dir).to receive(:entries).and_return(["aired_episodes_v01.sql"])
|
56
|
+
|
57
|
+
run_generator ["aired_episode", "--materialized", "--side-by-side"]
|
58
|
+
migration = migration_file(
|
59
|
+
"db/migrate/update_aired_episodes_to_version_2.rb"
|
60
|
+
)
|
61
|
+
expect(migration).to contain "materialized: { side_by_side: true }"
|
62
|
+
expect(migration).not_to contain "no_data"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
40
66
|
it "uses 'replace_view' instead of 'update_view' if replace flag is set" do
|
41
67
|
with_view_definition("aired_episodes", 1, "hello") do
|
42
68
|
allow(Dir).to receive(:entries).and_return(["aired_episodes_v01.sql"])
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
module Scenic
|
4
|
+
module Adapters
|
5
|
+
describe Postgres::IndexCreation, :db do
|
6
|
+
it "successfully recreates applicable indexes" do
|
7
|
+
create_materialized_view("hi", "SELECT 'hi' AS greeting")
|
8
|
+
speaker = DummySpeaker.new
|
9
|
+
|
10
|
+
index = Scenic::Index.new(
|
11
|
+
object_name: "hi",
|
12
|
+
index_name: "hi_greeting_idx",
|
13
|
+
definition: "CREATE INDEX hi_greeting_idx ON hi (greeting)"
|
14
|
+
)
|
15
|
+
|
16
|
+
Postgres::IndexCreation
|
17
|
+
.new(connection: ActiveRecord::Base.connection, speaker: speaker)
|
18
|
+
.try_create([index])
|
19
|
+
|
20
|
+
expect(indexes_for("hi")).not_to be_empty
|
21
|
+
expect(speaker.messages).to include(/index 'hi_greeting_idx' .* has been created/)
|
22
|
+
end
|
23
|
+
|
24
|
+
it "skips indexes that are not applicable" do
|
25
|
+
create_materialized_view("hi", "SELECT 'hi' AS greeting")
|
26
|
+
speaker = DummySpeaker.new
|
27
|
+
index = Scenic::Index.new(
|
28
|
+
object_name: "hi",
|
29
|
+
index_name: "hi_person_idx",
|
30
|
+
definition: "CREATE INDEX hi_person_idx ON hi (person)"
|
31
|
+
)
|
32
|
+
|
33
|
+
Postgres::IndexCreation
|
34
|
+
.new(connection: ActiveRecord::Base.connection, speaker: speaker)
|
35
|
+
.try_create([index])
|
36
|
+
|
37
|
+
expect(indexes_for("hi")).to be_empty
|
38
|
+
expect(speaker.messages).to include(/index 'hi_person_idx' .* has been dropped/)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
class DummySpeaker
|
43
|
+
attr_reader :messages
|
44
|
+
|
45
|
+
def initialize
|
46
|
+
@messages = []
|
47
|
+
end
|
48
|
+
|
49
|
+
def say(message, bool = false)
|
50
|
+
@messages << message
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|