scenic-jets 1.5.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +78 -0
  3. data/.gitignore +19 -0
  4. data/.hound.yml +2 -0
  5. data/.rubocop.yml +129 -0
  6. data/.yardopts +4 -0
  7. data/CHANGELOG.md +223 -0
  8. data/CODE_OF_CONDUCT.md +76 -0
  9. data/CONTRIBUTING.md +24 -0
  10. data/Gemfile +16 -0
  11. data/LICENSE.txt +22 -0
  12. data/README.md +5 -0
  13. data/Rakefile +29 -0
  14. data/SECURITY.md +14 -0
  15. data/bin/rake +17 -0
  16. data/bin/rspec +17 -0
  17. data/bin/setup +18 -0
  18. data/bin/yard +16 -0
  19. data/lib/generators/scenic/generators.rb +12 -0
  20. data/lib/generators/scenic/materializable.rb +31 -0
  21. data/lib/generators/scenic/model/USAGE +12 -0
  22. data/lib/generators/scenic/model/model_generator.rb +52 -0
  23. data/lib/generators/scenic/model/templates/model.erb +3 -0
  24. data/lib/generators/scenic/view/USAGE +20 -0
  25. data/lib/generators/scenic/view/templates/db/migrate/create_view.erb +5 -0
  26. data/lib/generators/scenic/view/templates/db/migrate/update_view.erb +12 -0
  27. data/lib/generators/scenic/view/view_generator.rb +127 -0
  28. data/lib/scenic.rb +31 -0
  29. data/lib/scenic/adapters/postgres.rb +256 -0
  30. data/lib/scenic/adapters/postgres/connection.rb +57 -0
  31. data/lib/scenic/adapters/postgres/errors.rb +26 -0
  32. data/lib/scenic/adapters/postgres/index_reapplication.rb +71 -0
  33. data/lib/scenic/adapters/postgres/indexes.rb +53 -0
  34. data/lib/scenic/adapters/postgres/refresh_dependencies.rb +116 -0
  35. data/lib/scenic/adapters/postgres/views.rb +74 -0
  36. data/lib/scenic/command_recorder.rb +52 -0
  37. data/lib/scenic/command_recorder/statement_arguments.rb +51 -0
  38. data/lib/scenic/configuration.rb +37 -0
  39. data/lib/scenic/definition.rb +35 -0
  40. data/lib/scenic/index.rb +36 -0
  41. data/lib/scenic/schema_dumper.rb +44 -0
  42. data/lib/scenic/statements.rb +163 -0
  43. data/lib/scenic/version.rb +3 -0
  44. data/lib/scenic/view.rb +54 -0
  45. data/scenic.gemspec +36 -0
  46. data/spec/acceptance/user_manages_views_spec.rb +88 -0
  47. data/spec/acceptance_helper.rb +33 -0
  48. data/spec/dummy/.gitignore +16 -0
  49. data/spec/dummy/Rakefile +13 -0
  50. data/spec/dummy/app/models/application_record.rb +5 -0
  51. data/spec/dummy/bin/bundle +3 -0
  52. data/spec/dummy/bin/rails +4 -0
  53. data/spec/dummy/bin/rake +4 -0
  54. data/spec/dummy/config.ru +4 -0
  55. data/spec/dummy/config/application.rb +15 -0
  56. data/spec/dummy/config/boot.rb +5 -0
  57. data/spec/dummy/config/database.yml +14 -0
  58. data/spec/dummy/config/environment.rb +5 -0
  59. data/spec/dummy/db/migrate/.keep +0 -0
  60. data/spec/dummy/db/views/.keep +0 -0
  61. data/spec/generators/scenic/model/model_generator_spec.rb +36 -0
  62. data/spec/generators/scenic/view/view_generator_spec.rb +57 -0
  63. data/spec/integration/revert_spec.rb +74 -0
  64. data/spec/scenic/adapters/postgres/connection_spec.rb +79 -0
  65. data/spec/scenic/adapters/postgres/refresh_dependencies_spec.rb +82 -0
  66. data/spec/scenic/adapters/postgres/views_spec.rb +37 -0
  67. data/spec/scenic/adapters/postgres_spec.rb +209 -0
  68. data/spec/scenic/command_recorder/statement_arguments_spec.rb +41 -0
  69. data/spec/scenic/command_recorder_spec.rb +111 -0
  70. data/spec/scenic/configuration_spec.rb +27 -0
  71. data/spec/scenic/definition_spec.rb +62 -0
  72. data/spec/scenic/schema_dumper_spec.rb +115 -0
  73. data/spec/scenic/statements_spec.rb +199 -0
  74. data/spec/spec_helper.rb +22 -0
  75. data/spec/support/generator_spec_setup.rb +14 -0
  76. data/spec/support/view_definition_helpers.rb +10 -0
  77. 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
@@ -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