scenic-jets 1.5.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +78 -0
- data/.gitignore +19 -0
- data/.hound.yml +2 -0
- data/.rubocop.yml +129 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +223 -0
- data/CODE_OF_CONDUCT.md +76 -0
- data/CONTRIBUTING.md +24 -0
- data/Gemfile +16 -0
- data/LICENSE.txt +22 -0
- data/README.md +5 -0
- data/Rakefile +29 -0
- data/SECURITY.md +14 -0
- data/bin/rake +17 -0
- data/bin/rspec +17 -0
- data/bin/setup +18 -0
- data/bin/yard +16 -0
- data/lib/generators/scenic/generators.rb +12 -0
- data/lib/generators/scenic/materializable.rb +31 -0
- data/lib/generators/scenic/model/USAGE +12 -0
- data/lib/generators/scenic/model/model_generator.rb +52 -0
- data/lib/generators/scenic/model/templates/model.erb +3 -0
- data/lib/generators/scenic/view/USAGE +20 -0
- data/lib/generators/scenic/view/templates/db/migrate/create_view.erb +5 -0
- data/lib/generators/scenic/view/templates/db/migrate/update_view.erb +12 -0
- data/lib/generators/scenic/view/view_generator.rb +127 -0
- data/lib/scenic.rb +31 -0
- data/lib/scenic/adapters/postgres.rb +256 -0
- data/lib/scenic/adapters/postgres/connection.rb +57 -0
- data/lib/scenic/adapters/postgres/errors.rb +26 -0
- data/lib/scenic/adapters/postgres/index_reapplication.rb +71 -0
- data/lib/scenic/adapters/postgres/indexes.rb +53 -0
- data/lib/scenic/adapters/postgres/refresh_dependencies.rb +116 -0
- data/lib/scenic/adapters/postgres/views.rb +74 -0
- data/lib/scenic/command_recorder.rb +52 -0
- data/lib/scenic/command_recorder/statement_arguments.rb +51 -0
- data/lib/scenic/configuration.rb +37 -0
- data/lib/scenic/definition.rb +35 -0
- data/lib/scenic/index.rb +36 -0
- data/lib/scenic/schema_dumper.rb +44 -0
- data/lib/scenic/statements.rb +163 -0
- data/lib/scenic/version.rb +3 -0
- data/lib/scenic/view.rb +54 -0
- data/scenic.gemspec +36 -0
- data/spec/acceptance/user_manages_views_spec.rb +88 -0
- data/spec/acceptance_helper.rb +33 -0
- data/spec/dummy/.gitignore +16 -0
- data/spec/dummy/Rakefile +13 -0
- data/spec/dummy/app/models/application_record.rb +5 -0
- data/spec/dummy/bin/bundle +3 -0
- data/spec/dummy/bin/rails +4 -0
- data/spec/dummy/bin/rake +4 -0
- data/spec/dummy/config.ru +4 -0
- data/spec/dummy/config/application.rb +15 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/database.yml +14 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/db/migrate/.keep +0 -0
- data/spec/dummy/db/views/.keep +0 -0
- data/spec/generators/scenic/model/model_generator_spec.rb +36 -0
- data/spec/generators/scenic/view/view_generator_spec.rb +57 -0
- data/spec/integration/revert_spec.rb +74 -0
- data/spec/scenic/adapters/postgres/connection_spec.rb +79 -0
- data/spec/scenic/adapters/postgres/refresh_dependencies_spec.rb +82 -0
- data/spec/scenic/adapters/postgres/views_spec.rb +37 -0
- data/spec/scenic/adapters/postgres_spec.rb +209 -0
- data/spec/scenic/command_recorder/statement_arguments_spec.rb +41 -0
- data/spec/scenic/command_recorder_spec.rb +111 -0
- data/spec/scenic/configuration_spec.rb +27 -0
- data/spec/scenic/definition_spec.rb +62 -0
- data/spec/scenic/schema_dumper_spec.rb +115 -0
- data/spec/scenic/statements_spec.rb +199 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/support/generator_spec_setup.rb +14 -0
- data/spec/support/view_definition_helpers.rb +10 -0
- metadata +307 -0
@@ -0,0 +1,116 @@
|
|
1
|
+
module Scenic
|
2
|
+
module Adapters
|
3
|
+
class Postgres
|
4
|
+
class RefreshDependencies
|
5
|
+
def self.call(name, adapter, connection, concurrently: false)
|
6
|
+
new(name, adapter, connection, concurrently: concurrently).call
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize(name, adapter, connection, concurrently:)
|
10
|
+
@name = name
|
11
|
+
@adapter = adapter
|
12
|
+
@connection = connection
|
13
|
+
@concurrently = concurrently
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
dependencies.each do |dependency|
|
18
|
+
adapter.refresh_materialized_view(
|
19
|
+
dependency,
|
20
|
+
concurrently: concurrently,
|
21
|
+
)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
attr_reader :name, :adapter, :connection, :concurrently
|
28
|
+
|
29
|
+
class DependencyParser
|
30
|
+
def initialize(raw_dependencies, view_to_refresh)
|
31
|
+
@raw_dependencies = raw_dependencies
|
32
|
+
@view_to_refresh = view_to_refresh
|
33
|
+
end
|
34
|
+
|
35
|
+
# We're given an array from the SQL query that looks kind of like this
|
36
|
+
# [["view_name", "{'dependency_1', 'dependency_2'}"]]
|
37
|
+
#
|
38
|
+
# We need to parse that into a more easy to understand data type so we
|
39
|
+
# can use the Tsort module from the Standard Library to topologically
|
40
|
+
# sort those out so we can refresh in the correct order, so we parse
|
41
|
+
# that raw data into a hash.
|
42
|
+
#
|
43
|
+
# Then, once Tsort has worked it magic, we're given a sorted 1-D array
|
44
|
+
# ["dependency_1", "dependency_2", "view_name"]
|
45
|
+
#
|
46
|
+
# So we then need to slice off just the bit leading up to the view
|
47
|
+
# that we're refreshing, so we find where in the topologically sorted
|
48
|
+
# array our given view is, and return all the dependencies up to that
|
49
|
+
# point.
|
50
|
+
def to_sorted_array
|
51
|
+
dependency_hash = parse_to_hash(raw_dependencies)
|
52
|
+
sorted_arr = tsort(dependency_hash)
|
53
|
+
|
54
|
+
idx = sorted_arr.find_index do |dep|
|
55
|
+
if view_to_refresh.to_s.include?(".")
|
56
|
+
dep == view_to_refresh.to_s
|
57
|
+
else
|
58
|
+
dep.ends_with?(".#{view_to_refresh}")
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
if idx.present?
|
63
|
+
sorted_arr[0...idx]
|
64
|
+
else
|
65
|
+
[]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
attr_reader :raw_dependencies, :view_to_refresh
|
72
|
+
|
73
|
+
def parse_to_hash(dependency_rows)
|
74
|
+
dependency_rows.each_with_object({}) do |row, hash|
|
75
|
+
formatted_dependencies = row.last.tr("{}", "").split(",")
|
76
|
+
formatted_dependencies.each do |dependency|
|
77
|
+
hash[dependency] = [] unless hash[dependency]
|
78
|
+
end
|
79
|
+
hash[row.first] = formatted_dependencies
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def tsort(hash)
|
84
|
+
each_node = lambda { |&b| hash.each_key(&b) }
|
85
|
+
each_child = lambda { |n, &b| hash[n].each(&b) }
|
86
|
+
TSort.tsort(each_node, each_child)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
DEPENDENCY_SQL = <<-SQL.freeze
|
91
|
+
SELECT rewrite_namespace.nspname || '.' || class_for_rewrite.relname AS materialized_view,
|
92
|
+
array_agg(depend_namespace.nspname || '.' || class_for_depend.relname) AS depends_on
|
93
|
+
FROM pg_rewrite AS rewrite
|
94
|
+
JOIN pg_class AS class_for_rewrite ON rewrite.ev_class = class_for_rewrite.oid
|
95
|
+
JOIN pg_depend AS depend ON rewrite.oid = depend.objid
|
96
|
+
JOIN pg_class AS class_for_depend ON depend.refobjid = class_for_depend.oid
|
97
|
+
JOIN pg_namespace AS rewrite_namespace ON rewrite_namespace.oid = class_for_rewrite.relnamespace
|
98
|
+
JOIN pg_namespace AS depend_namespace ON depend_namespace.oid = class_for_depend.relnamespace
|
99
|
+
WHERE class_for_depend.relkind = 'm'
|
100
|
+
AND class_for_rewrite.relkind = 'm'
|
101
|
+
AND class_for_depend.relname != class_for_rewrite.relname
|
102
|
+
GROUP BY class_for_rewrite.relname, rewrite_namespace.nspname
|
103
|
+
ORDER BY class_for_rewrite.relname;
|
104
|
+
SQL
|
105
|
+
|
106
|
+
private_constant "DependencyParser"
|
107
|
+
private_constant "DEPENDENCY_SQL"
|
108
|
+
|
109
|
+
def dependencies
|
110
|
+
raw_dependency_info = connection.select_rows(DEPENDENCY_SQL)
|
111
|
+
DependencyParser.new(raw_dependency_info, name).to_sorted_array
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Scenic
|
2
|
+
module Adapters
|
3
|
+
class Postgres
|
4
|
+
# Fetches defined views from the postgres connection.
|
5
|
+
# @api private
|
6
|
+
class Views
|
7
|
+
def initialize(connection)
|
8
|
+
@connection = connection
|
9
|
+
end
|
10
|
+
|
11
|
+
# All of the views that this connection has defined.
|
12
|
+
#
|
13
|
+
# This will include materialized views if those are supported by the
|
14
|
+
# connection.
|
15
|
+
#
|
16
|
+
# @return [Array<Scenic::View>]
|
17
|
+
def all
|
18
|
+
views_from_postgres.map(&method(:to_scenic_view))
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
attr_reader :connection
|
24
|
+
|
25
|
+
def views_from_postgres
|
26
|
+
connection.execute(<<-SQL)
|
27
|
+
SELECT
|
28
|
+
c.relname as viewname,
|
29
|
+
pg_get_viewdef(c.oid) AS definition,
|
30
|
+
c.relkind AS kind,
|
31
|
+
n.nspname AS namespace
|
32
|
+
FROM pg_class c
|
33
|
+
LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
|
34
|
+
WHERE
|
35
|
+
c.relkind IN ('m', 'v')
|
36
|
+
AND c.relname NOT IN (SELECT extname FROM pg_extension)
|
37
|
+
AND n.nspname = ANY (current_schemas(false))
|
38
|
+
ORDER BY c.oid
|
39
|
+
SQL
|
40
|
+
end
|
41
|
+
|
42
|
+
def to_scenic_view(result)
|
43
|
+
namespace, viewname = result.values_at "namespace", "viewname"
|
44
|
+
|
45
|
+
if namespace != "public"
|
46
|
+
namespaced_viewname = "#{pg_identifier(namespace)}.#{pg_identifier(viewname)}"
|
47
|
+
else
|
48
|
+
namespaced_viewname = pg_identifier(viewname)
|
49
|
+
end
|
50
|
+
|
51
|
+
Scenic::View.new(
|
52
|
+
name: namespaced_viewname,
|
53
|
+
definition: result["definition"].strip,
|
54
|
+
materialized: result["kind"] == "m",
|
55
|
+
)
|
56
|
+
end
|
57
|
+
|
58
|
+
def pg_identifier(name)
|
59
|
+
return name if name =~ /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
60
|
+
|
61
|
+
pgconn.quote_ident(name)
|
62
|
+
end
|
63
|
+
|
64
|
+
def pgconn
|
65
|
+
if defined?(PG::Connection)
|
66
|
+
PG::Connection
|
67
|
+
else
|
68
|
+
PGconn
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require "scenic/command_recorder/statement_arguments"
|
2
|
+
|
3
|
+
module Scenic
|
4
|
+
# @api private
|
5
|
+
module CommandRecorder
|
6
|
+
def create_view(*args)
|
7
|
+
record(:create_view, args)
|
8
|
+
end
|
9
|
+
|
10
|
+
def drop_view(*args)
|
11
|
+
record(:drop_view, args)
|
12
|
+
end
|
13
|
+
|
14
|
+
def update_view(*args)
|
15
|
+
record(:update_view, args)
|
16
|
+
end
|
17
|
+
|
18
|
+
def replace_view(*args)
|
19
|
+
record(:replace_view, args)
|
20
|
+
end
|
21
|
+
|
22
|
+
def invert_create_view(args)
|
23
|
+
drop_view_args = StatementArguments.new(args).remove_version.to_a
|
24
|
+
[:drop_view, drop_view_args]
|
25
|
+
end
|
26
|
+
|
27
|
+
def invert_drop_view(args)
|
28
|
+
perform_scenic_inversion(:create_view, args)
|
29
|
+
end
|
30
|
+
|
31
|
+
def invert_update_view(args)
|
32
|
+
perform_scenic_inversion(:update_view, args)
|
33
|
+
end
|
34
|
+
|
35
|
+
def invert_replace_view(args)
|
36
|
+
perform_scenic_inversion(:replace_view, args)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def perform_scenic_inversion(method, args)
|
42
|
+
scenic_args = StatementArguments.new(args)
|
43
|
+
|
44
|
+
if scenic_args.revert_to_version.nil?
|
45
|
+
message = "#{method} is reversible only if given a revert_to_version"
|
46
|
+
raise ActiveRecord::IrreversibleMigration, message
|
47
|
+
end
|
48
|
+
|
49
|
+
[method, scenic_args.invert_version.to_a]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Scenic
|
2
|
+
module CommandRecorder
|
3
|
+
# @api private
|
4
|
+
class StatementArguments
|
5
|
+
def initialize(args)
|
6
|
+
@args = args.freeze
|
7
|
+
end
|
8
|
+
|
9
|
+
def view
|
10
|
+
@args[0]
|
11
|
+
end
|
12
|
+
|
13
|
+
def version
|
14
|
+
options[:version]
|
15
|
+
end
|
16
|
+
|
17
|
+
def revert_to_version
|
18
|
+
options[:revert_to_version]
|
19
|
+
end
|
20
|
+
|
21
|
+
def invert_version
|
22
|
+
StatementArguments.new([view, options_for_revert])
|
23
|
+
end
|
24
|
+
|
25
|
+
def remove_version
|
26
|
+
StatementArguments.new([view, options_without_version])
|
27
|
+
end
|
28
|
+
|
29
|
+
def to_a
|
30
|
+
@args.to_a.dup.delete_if(&:empty?)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def options
|
36
|
+
@options ||= @args[1] || {}
|
37
|
+
end
|
38
|
+
|
39
|
+
def options_for_revert
|
40
|
+
options.clone.tap do |revert_options|
|
41
|
+
revert_options[:version] = revert_to_version
|
42
|
+
revert_options.delete(:revert_to_version)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def options_without_version
|
47
|
+
options.except(:version)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Scenic
|
2
|
+
class Configuration
|
3
|
+
# The Scenic database adapter instance to use when executing SQL.
|
4
|
+
#
|
5
|
+
# Defaults to an instance of {Adapters::Postgres}
|
6
|
+
# @return Scenic adapter
|
7
|
+
attr_accessor :database
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@database = Scenic::Adapters::Postgres.new
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# @return [Scenic::Configuration] Scenic's current configuration
|
15
|
+
def self.configuration
|
16
|
+
@configuration ||= Configuration.new
|
17
|
+
end
|
18
|
+
|
19
|
+
# Set Scenic's configuration
|
20
|
+
#
|
21
|
+
# @param config [Scenic::Configuration]
|
22
|
+
def self.configuration=(config)
|
23
|
+
@configuration = config
|
24
|
+
end
|
25
|
+
|
26
|
+
# Modify Scenic's current configuration
|
27
|
+
#
|
28
|
+
# @yieldparam [Scenic::Configuration] config current Scenic config
|
29
|
+
# ```
|
30
|
+
# Scenic.configure do |config|
|
31
|
+
# config.database = Scenic::Adapters::Postgres.new
|
32
|
+
# end
|
33
|
+
# ```
|
34
|
+
def self.configure
|
35
|
+
yield configuration
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Scenic
|
2
|
+
# @api private
|
3
|
+
class Definition
|
4
|
+
def initialize(name, version)
|
5
|
+
@name = name
|
6
|
+
@version = version.to_i
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_sql
|
10
|
+
File.read(full_path).tap do |content|
|
11
|
+
if content.empty?
|
12
|
+
raise "Define view query in #{path} before migrating."
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def full_path
|
18
|
+
Jets.root.join(path)
|
19
|
+
end
|
20
|
+
|
21
|
+
def path
|
22
|
+
File.join("db", "views", filename)
|
23
|
+
end
|
24
|
+
|
25
|
+
def version
|
26
|
+
@version.to_s.rjust(2, "0")
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def filename
|
32
|
+
"#{@name.to_s.tr('.', '_')}_v#{version}.sql"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/scenic/index.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
module Scenic
|
2
|
+
# The in-memory representation of a database index.
|
3
|
+
#
|
4
|
+
# **This object is used internally by adapters and the schema dumper and is
|
5
|
+
# not intended to be used by application code. It is documented here for
|
6
|
+
# use by adapter gems.**
|
7
|
+
#
|
8
|
+
# @api extension
|
9
|
+
class Index
|
10
|
+
# The name of the object that has the index
|
11
|
+
# @return [String]
|
12
|
+
attr_reader :object_name
|
13
|
+
|
14
|
+
# The name of the index
|
15
|
+
# @return [String]
|
16
|
+
attr_reader :index_name
|
17
|
+
|
18
|
+
# The SQL statement that defines the index
|
19
|
+
# @return [String]
|
20
|
+
#
|
21
|
+
# @example
|
22
|
+
# "CREATE INDEX index_users_on_email ON users USING btree (email)"
|
23
|
+
attr_reader :definition
|
24
|
+
|
25
|
+
# Returns a new instance of Index
|
26
|
+
#
|
27
|
+
# @param object_name [String] The name of the object that has the index
|
28
|
+
# @param index_name [String] The name of the index
|
29
|
+
# @param definition [String] The SQL statements that defined the index
|
30
|
+
def initialize(object_name:, index_name:, definition:)
|
31
|
+
@object_name = object_name
|
32
|
+
@index_name = index_name
|
33
|
+
@definition = definition
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require "rails"
|
2
|
+
|
3
|
+
module Scenic
|
4
|
+
# @api private
|
5
|
+
module SchemaDumper
|
6
|
+
def tables(stream)
|
7
|
+
super
|
8
|
+
views(stream)
|
9
|
+
end
|
10
|
+
|
11
|
+
def views(stream)
|
12
|
+
if dumpable_views_in_database.any?
|
13
|
+
stream.puts
|
14
|
+
end
|
15
|
+
|
16
|
+
dumpable_views_in_database.each do |view|
|
17
|
+
stream.puts(view.to_schema)
|
18
|
+
indexes(view.name, stream)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def dumpable_views_in_database
|
25
|
+
@dumpable_views_in_database ||= Scenic.database.views.reject do |view|
|
26
|
+
ignored?(view.name)
|
27
|
+
end
|
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
|
+
end
|
44
|
+
end
|