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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +29 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +55 -17
- data/CONTRIBUTING.md +1 -0
- data/FUNDING.yml +1 -0
- data/Gemfile +4 -4
- data/README.md +84 -25
- data/Rakefile +1 -1
- data/bin/standardrb +27 -0
- data/lib/generators/scenic/materializable.rb +27 -1
- data/lib/generators/scenic/model/model_generator.rb +7 -16
- data/lib/generators/scenic/model/templates/model.erb +4 -0
- data/lib/generators/scenic/view/templates/db/migrate/update_view.erb +2 -2
- data/lib/generators/scenic/view/view_generator.rb +5 -5
- 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/indexes.rb +1 -1
- data/lib/scenic/adapters/postgres/refresh_dependencies.rb +3 -3
- 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 +85 -12
- data/lib/scenic/adapters/postgres.rb +64 -16
- data/lib/scenic/definition.rb +1 -1
- data/lib/scenic/schema_dumper.rb +0 -14
- data/lib/scenic/statements.rb +49 -16
- data/lib/scenic/version.rb +1 -1
- data/scenic.gemspec +15 -11
- data/spec/acceptance/user_manages_views_spec.rb +11 -0
- data/spec/dummy/Rakefile +5 -5
- data/spec/dummy/bin/bundle +2 -2
- data/spec/dummy/bin/rails +3 -3
- data/spec/dummy/bin/rake +2 -2
- data/spec/dummy/config/application.rb +4 -0
- data/spec/dummy/config.ru +1 -1
- data/spec/dummy/db/migrate/20220112154220_add_pg_stat_statements_extension.rb +1 -1
- data/spec/dummy/db/schema.rb +0 -2
- data/spec/generators/scenic/model/model_generator_spec.rb +9 -1
- data/spec/generators/scenic/view/view_generator_spec.rb +28 -2
- data/spec/integration/revert_spec.rb +1 -1
- data/spec/scenic/adapters/postgres/connection_spec.rb +1 -1
- 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/refresh_dependencies_spec.rb +9 -9
- 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 +95 -8
- data/spec/scenic/command_recorder/statement_arguments_spec.rb +4 -4
- data/spec/scenic/command_recorder_spec.rb +30 -12
- data/spec/scenic/schema_dumper_spec.rb +35 -14
- data/spec/scenic/statements_spec.rb +66 -8
- data/spec/spec_helper.rb +19 -4
- data/spec/support/database_schema_helpers.rb +28 -0
- data/spec/support/generator_spec_setup.rb +2 -2
- data/spec/support/view_definition_helpers.rb +1 -1
- metadata +35 -48
- data/.hound.yml +0 -2
- 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(
|
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
|
-
|
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? ?
|
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
|
-
|
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
|
@@ -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
|
107
|
-
private_constant
|
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
|
-
|
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
|
-
|
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)
|
60
|
-
return name if
|
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
|
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
|
#
|
@@ -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
|
-
#{
|
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
|
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,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
|
-
|
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
|
-
|
228
|
-
|
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
|
data/lib/scenic/definition.rb
CHANGED
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
|