scenic 1.7.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +29 -4
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +55 -17
  5. data/CONTRIBUTING.md +1 -0
  6. data/FUNDING.yml +1 -0
  7. data/Gemfile +4 -4
  8. data/README.md +84 -25
  9. data/Rakefile +1 -1
  10. data/bin/standardrb +27 -0
  11. data/lib/generators/scenic/materializable.rb +27 -1
  12. data/lib/generators/scenic/model/model_generator.rb +7 -16
  13. data/lib/generators/scenic/model/templates/model.erb +4 -0
  14. data/lib/generators/scenic/view/templates/db/migrate/update_view.erb +2 -2
  15. data/lib/generators/scenic/view/view_generator.rb +5 -5
  16. data/lib/scenic/adapters/postgres/index_creation.rb +68 -0
  17. data/lib/scenic/adapters/postgres/index_migration.rb +70 -0
  18. data/lib/scenic/adapters/postgres/index_reapplication.rb +3 -28
  19. data/lib/scenic/adapters/postgres/indexes.rb +1 -1
  20. data/lib/scenic/adapters/postgres/refresh_dependencies.rb +3 -3
  21. data/lib/scenic/adapters/postgres/side_by_side.rb +50 -0
  22. data/lib/scenic/adapters/postgres/temporary_name.rb +34 -0
  23. data/lib/scenic/adapters/postgres/views.rb +85 -12
  24. data/lib/scenic/adapters/postgres.rb +64 -16
  25. data/lib/scenic/definition.rb +1 -1
  26. data/lib/scenic/schema_dumper.rb +0 -14
  27. data/lib/scenic/statements.rb +49 -16
  28. data/lib/scenic/version.rb +1 -1
  29. data/scenic.gemspec +15 -11
  30. data/spec/acceptance/user_manages_views_spec.rb +11 -0
  31. data/spec/dummy/Rakefile +5 -5
  32. data/spec/dummy/bin/bundle +2 -2
  33. data/spec/dummy/bin/rails +3 -3
  34. data/spec/dummy/bin/rake +2 -2
  35. data/spec/dummy/config/application.rb +4 -0
  36. data/spec/dummy/config.ru +1 -1
  37. data/spec/dummy/db/migrate/20220112154220_add_pg_stat_statements_extension.rb +1 -1
  38. data/spec/dummy/db/schema.rb +0 -2
  39. data/spec/generators/scenic/model/model_generator_spec.rb +9 -1
  40. data/spec/generators/scenic/view/view_generator_spec.rb +28 -2
  41. data/spec/integration/revert_spec.rb +1 -1
  42. data/spec/scenic/adapters/postgres/connection_spec.rb +1 -1
  43. data/spec/scenic/adapters/postgres/index_creation_spec.rb +54 -0
  44. data/spec/scenic/adapters/postgres/index_migration_spec.rb +24 -0
  45. data/spec/scenic/adapters/postgres/refresh_dependencies_spec.rb +9 -9
  46. data/spec/scenic/adapters/postgres/side_by_side_spec.rb +24 -0
  47. data/spec/scenic/adapters/postgres/temporary_name_spec.rb +23 -0
  48. data/spec/scenic/adapters/postgres_spec.rb +95 -8
  49. data/spec/scenic/command_recorder/statement_arguments_spec.rb +4 -4
  50. data/spec/scenic/command_recorder_spec.rb +30 -12
  51. data/spec/scenic/schema_dumper_spec.rb +35 -14
  52. data/spec/scenic/statements_spec.rb +66 -8
  53. data/spec/spec_helper.rb +19 -4
  54. data/spec/support/database_schema_helpers.rb +28 -0
  55. data/spec/support/generator_spec_setup.rb +2 -2
  56. data/spec/support/view_definition_helpers.rb +1 -1
  57. metadata +35 -48
  58. data/.hound.yml +0 -2
  59. data/.rubocop.yml +0 -129
@@ -28,12 +28,12 @@ module Scenic
28
28
  if creating_new_view? || destroying_initial_view?
29
29
  migration_template(
30
30
  "db/migrate/create_view.erb",
31
- "db/migrate/create_#{plural_file_name}.rb",
31
+ "db/migrate/create_#{plural_file_name}.rb"
32
32
  )
33
33
  else
34
34
  migration_template(
35
35
  "db/migrate/update_view.erb",
36
- "db/migrate/update_#{plural_file_name}_to_version_#{version}.rb",
36
+ "db/migrate/update_#{plural_file_name}_to_version_#{version}.rb"
37
37
  )
38
38
  end
39
39
  end
@@ -56,7 +56,7 @@ module Scenic
56
56
 
57
57
  def migration_class_name
58
58
  if creating_new_view?
59
- "Create#{class_name.tr('.', '').pluralize}"
59
+ "Create#{class_name.tr(".", "").pluralize}"
60
60
  else
61
61
  "Update#{class_name.pluralize}ToVersion#{version}"
62
62
  end
@@ -73,7 +73,7 @@ module Scenic
73
73
 
74
74
  private
75
75
 
76
- alias singular_name file_name
76
+ alias_method :singular_name, :file_name
77
77
 
78
78
  def file_name
79
79
  super.tr(".", "_")
@@ -113,7 +113,7 @@ module Scenic
113
113
 
114
114
  def create_view_options
115
115
  if materialized?
116
- ", materialized: #{no_data? ? '{ no_data: true }' : true}"
116
+ ", materialized: #{no_data? ? "{ no_data: true }" : true}"
117
117
  else
118
118
  ""
119
119
  end
@@ -0,0 +1,68 @@
1
+ module Scenic
2
+ module Adapters
3
+ class Postgres
4
+ # Used to resiliently create indexes on a materialized view. If the index
5
+ # cannot be applied to the view (e.g. the columns don't exist any longer),
6
+ # we log that information and continue rather than raising an error. It is
7
+ # left to the user to judge whether the index is necessary and recreate
8
+ # it.
9
+ #
10
+ # Used when updating a materialized view to ensure the new version has all
11
+ # apprioriate indexes.
12
+ #
13
+ # @api private
14
+ class IndexCreation
15
+ # Creates the index creation object.
16
+ #
17
+ # @param connection [Connection] The connection to execute SQL against.
18
+ # @param speaker [#say] (ActiveRecord::Migration) The object used for
19
+ # logging the results of creating indexes.
20
+ def initialize(connection:, speaker: ActiveRecord::Migration.new)
21
+ @connection = connection
22
+ @speaker = speaker
23
+ end
24
+
25
+ # Creates the provided indexes. If an index cannot be created, it is
26
+ # logged and the process continues.
27
+ #
28
+ # @param indexes [Array<Scenic::Index>] The indexes to create.
29
+ #
30
+ # @return [void]
31
+ def try_create(indexes)
32
+ Array(indexes).each(&method(:try_index_create))
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :connection, :speaker
38
+
39
+ def try_index_create(index)
40
+ success = with_savepoint(index.index_name) do
41
+ connection.execute(index.definition)
42
+ end
43
+
44
+ if success
45
+ say "index '#{index.index_name}' on '#{index.object_name}' has been created"
46
+ else
47
+ say "index '#{index.index_name}' on '#{index.object_name}' is no longer valid and has been dropped."
48
+ end
49
+ end
50
+
51
+ def with_savepoint(name)
52
+ connection.execute("SAVEPOINT #{name}")
53
+ yield
54
+ connection.execute("RELEASE SAVEPOINT #{name}")
55
+ true
56
+ rescue
57
+ connection.execute("ROLLBACK TO SAVEPOINT #{name}")
58
+ false
59
+ end
60
+
61
+ def say(message)
62
+ subitem = true
63
+ speaker.say(message, subitem)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,70 @@
1
+ module Scenic
2
+ module Adapters
3
+ class Postgres
4
+ # Used during side-by-side materialized view updates to migrate indexes
5
+ # from the original view to the new view.
6
+ #
7
+ # @api private
8
+ class IndexMigration
9
+ # Creates the index migration object.
10
+ #
11
+ # @param connection [Connection] The connection to execute SQL against.
12
+ # @param speaker [#say] (ActiveRecord::Migration) The object used for
13
+ # logging the results of migrating indexes.
14
+ def initialize(connection:, speaker: ActiveRecord::Migration.new)
15
+ @connection = connection
16
+ @speaker = speaker
17
+ end
18
+
19
+ # Retreives the indexes on the original view, renames them to avoid
20
+ # collisions, retargets the indexes to the destination view, and then
21
+ # creates the retargeted indexes.
22
+ #
23
+ # @param from [String] The name of the original view.
24
+ # @param to [String] The name of the destination view.
25
+ #
26
+ # @return [void]
27
+ def migrate(from:, to:)
28
+ source_indexes = Indexes.new(connection: connection).on(from)
29
+ retargeted_indexes = source_indexes.map { |i| retarget(i, to: to) }
30
+ source_indexes.each(&method(:rename))
31
+
32
+ if source_indexes.any?
33
+ say "indexes on '#{from}' have been renamed to avoid collisions"
34
+ end
35
+
36
+ IndexCreation
37
+ .new(connection: connection, speaker: speaker)
38
+ .try_create(retargeted_indexes)
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :connection, :speaker
44
+
45
+ def retarget(index, to:)
46
+ new_definition = index.definition.sub(
47
+ /ON (.*)\.#{index.object_name}/,
48
+ 'ON \1.' + to + " "
49
+ )
50
+
51
+ Scenic::Index.new(
52
+ object_name: to,
53
+ index_name: index.index_name,
54
+ definition: new_definition
55
+ )
56
+ end
57
+
58
+ def rename(index)
59
+ temporary_name = TemporaryName.new(index.index_name).to_s
60
+ connection.rename_index(index.object_name, index.index_name, temporary_name)
61
+ end
62
+
63
+ def say(message)
64
+ subitem = true
65
+ speaker.say(message, subitem)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -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
@@ -44,7 +44,7 @@ module Scenic
44
44
  Scenic::Index.new(
45
45
  object_name: result["object_name"],
46
46
  index_name: result["index_name"],
47
- definition: result["definition"],
47
+ definition: result["definition"]
48
48
  )
49
49
  end
50
50
  end
@@ -17,7 +17,7 @@ module Scenic
17
17
  dependencies.each do |dependency|
18
18
  adapter.refresh_materialized_view(
19
19
  dependency,
20
- concurrently: concurrently,
20
+ concurrently: concurrently
21
21
  )
22
22
  end
23
23
  end
@@ -103,8 +103,8 @@ module Scenic
103
103
  ORDER BY class_for_rewrite.relname;
104
104
  SQL
105
105
 
106
- private_constant "DependencyParser"
107
- private_constant "DEPENDENCY_SQL"
106
+ private_constant :DependencyParser
107
+ private_constant :DEPENDENCY_SQL
108
108
 
109
109
  def dependencies
110
110
  raw_dependency_info = connection.select_rows(DEPENDENCY_SQL)
@@ -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,23 +112,25 @@ 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
121
+
122
+ def namespaced_view_name(result)
123
+ namespace, viewname = result.values_at("namespace", "viewname")
45
124
 
46
125
  if namespace != "public"
47
- namespaced_viewname = "#{pg_identifier(namespace)}.#{pg_identifier(viewname)}"
126
+ "#{pg_identifier(namespace)}.#{pg_identifier(viewname)}"
48
127
  else
49
- namespaced_viewname = pg_identifier(viewname)
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)
60
- return name if name =~ /^[a-zA-Z_][a-zA-Z0-9_]*$/
133
+ return name if /^[a-zA-Z_][a-zA-Z0-9_]*$/.match?(name)
61
134
 
62
135
  pgconn.quote_ident(name)
63
136
  end
@@ -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
  #
@@ -137,8 +141,8 @@ module Scenic
137
141
 
138
142
  execute <<-SQL
139
143
  CREATE MATERIALIZED VIEW #{quote_table_name(name)} AS
140
- #{sql_definition.rstrip.chomp(';')}
141
- #{'WITH NO DATA' if no_data};
144
+ #{sql_definition.rstrip.chomp(";")}
145
+ #{"WITH NO DATA" if no_data};
142
146
  SQL
143
147
  end
144
148
 
@@ -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,32 +221,64 @@ 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
- private
246
+ # True if supplied relation name is populated.
247
+ #
248
+ # @param name The name of the relation
249
+ #
250
+ # @raise [MaterializedViewsNotSupportedError] if the version of Postgres
251
+ # in use does not support materialized views.
252
+ #
253
+ # @return [boolean]
254
+ def populated?(name)
255
+ raise_unless_materialized_views_supported
226
256
 
227
- attr_reader :connectable
228
- delegate :execute, :quote_table_name, to: :connection
257
+ schemaless_name = name.to_s.split(".").last
258
+
259
+ sql = "SELECT relispopulated FROM pg_class WHERE relname = '#{schemaless_name}'"
260
+ relations = execute(sql)
261
+
262
+ if relations.count.positive?
263
+ relations.first["relispopulated"].in?(["t", true])
264
+ else
265
+ false
266
+ end
267
+ end
229
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
230
273
  def connection
231
274
  Connection.new(connectable.connection)
232
275
  end
233
276
 
277
+ private
278
+
279
+ attr_reader :connectable
280
+ delegate :execute, :quote_table_name, to: :connection
281
+
234
282
  def raise_unless_materialized_views_supported
235
283
  unless connection.supports_materialized_views?
236
284
  raise MaterializedViewsNotSupportedError
@@ -248,7 +296,7 @@ module Scenic
248
296
  name,
249
297
  self,
250
298
  connection,
251
- concurrently: concurrently,
299
+ concurrently: concurrently
252
300
  )
253
301
  end
254
302
  end
@@ -31,7 +31,7 @@ module Scenic
31
31
  attr_reader :name
32
32
 
33
33
  def filename
34
- "#{UnaffixedName.for(name).tr('.', '_')}_v#{version}.sql"
34
+ "#{UnaffixedName.for(name).tr(".", "_")}_v#{version}.sql"
35
35
  end
36
36
  end
37
37
  end
@@ -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