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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -8
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +48 -19
  5. data/FUNDING.yml +1 -0
  6. data/Gemfile +2 -2
  7. data/README.md +71 -18
  8. data/lib/generators/scenic/materializable.rb +27 -1
  9. data/lib/generators/scenic/view/templates/db/migrate/update_view.erb +1 -1
  10. data/lib/scenic/adapters/postgres/index_creation.rb +68 -0
  11. data/lib/scenic/adapters/postgres/index_migration.rb +70 -0
  12. data/lib/scenic/adapters/postgres/index_reapplication.rb +3 -28
  13. data/lib/scenic/adapters/postgres/side_by_side.rb +50 -0
  14. data/lib/scenic/adapters/postgres/temporary_name.rb +34 -0
  15. data/lib/scenic/adapters/postgres/views.rb +83 -10
  16. data/lib/scenic/adapters/postgres.rb +41 -18
  17. data/lib/scenic/schema_dumper.rb +0 -14
  18. data/lib/scenic/statements.rb +46 -13
  19. data/lib/scenic/version.rb +1 -1
  20. data/scenic.gemspec +5 -1
  21. data/spec/acceptance/user_manages_views_spec.rb +11 -0
  22. data/spec/dummy/config/application.rb +4 -0
  23. data/spec/generators/scenic/view/view_generator_spec.rb +26 -0
  24. data/spec/scenic/adapters/postgres/index_creation_spec.rb +54 -0
  25. data/spec/scenic/adapters/postgres/index_migration_spec.rb +24 -0
  26. data/spec/scenic/adapters/postgres/side_by_side_spec.rb +24 -0
  27. data/spec/scenic/adapters/postgres/temporary_name_spec.rb +23 -0
  28. data/spec/scenic/adapters/postgres_spec.rb +44 -3
  29. data/spec/scenic/command_recorder_spec.rb +18 -0
  30. data/spec/scenic/schema_dumper_spec.rb +29 -8
  31. data/spec/scenic/statements_spec.rb +62 -4
  32. data/spec/spec_helper.rb +19 -4
  33. data/spec/support/database_schema_helpers.rb +28 -0
  34. metadata +19 -11
@@ -32,39 +32,14 @@ module Scenic
32
32
 
33
33
  yield
34
34
 
35
- indexes.each(&method(:try_index_create))
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
- namespace, viewname = result.values_at "namespace", "viewname"
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
- namespaced_viewname = if namespace != "public"
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 interally by Scenic and are not intended for direct
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 non-concurrent refresh to populate with data.
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 non-concurrent refresh to populate with data.
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
- IndexReapplication.new(connection: connection).on(name) do
167
- drop_materialized_view(name)
168
- create_materialized_view(name, sql_definition, no_data: no_data)
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. Useful for checking the
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
@@ -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
@@ -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 true to create a materialized
13
- # view. Set to { no_data: true } to create materialized view without
14
- # loading data. Defaults to false.
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(materialized)
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 materialized view.
84
- # Set to { no_data: true } to update materialized view without loading
85
- # data. Defaults to false.
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(materialized)
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 no_data(materialized)
156
- if materialized.is_a?(Hash)
157
- materialized.fetch(:no_data, false)
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
- false
189
+ {
190
+ no_data: false,
191
+ side_by_side: false
192
+ }
160
193
  end
161
194
  end
162
195
  end
@@ -1,3 +1,3 @@
1
1
  module Scenic
2
- VERSION = "1.8.0".freeze
2
+ VERSION = "1.9.0".freeze
3
3
  end
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", "~> 0.19"
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