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
data/lib/scenic.rb ADDED
@@ -0,0 +1,31 @@
1
+ require "scenic/configuration"
2
+ require "scenic/adapters/postgres"
3
+ require "scenic/command_recorder"
4
+ require "scenic/definition"
5
+ require "scenic/schema_dumper"
6
+ require "scenic/statements"
7
+ require "scenic/version"
8
+ require "scenic/view"
9
+ require "scenic/index"
10
+
11
+ # Scenic adds methods `ActiveRecord::Migration` to create and manage database
12
+ # views in Rails applications.
13
+ module Scenic
14
+ # Hooks Scenic into Rails.
15
+ #
16
+ # Enables scenic migration methods, migration reversability, and `schema.rb`
17
+ # dumping.
18
+ def self.load
19
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.include Scenic::Statements
20
+ ActiveRecord::Migration::CommandRecorder.include Scenic::CommandRecorder
21
+ ActiveRecord::SchemaDumper.prepend Scenic::SchemaDumper
22
+ end
23
+
24
+ # The current database adapter used by Scenic.
25
+ #
26
+ # This defaults to {Adapters::Postgres} but can be overridden
27
+ # via {Configuration}.
28
+ def self.database
29
+ configuration.database
30
+ end
31
+ end
@@ -0,0 +1,256 @@
1
+ require_relative "postgres/connection"
2
+ require_relative "postgres/errors"
3
+ require_relative "postgres/index_reapplication"
4
+ require_relative "postgres/indexes"
5
+ require_relative "postgres/views"
6
+ require_relative "postgres/refresh_dependencies"
7
+
8
+ module Scenic
9
+ # Scenic database adapters.
10
+ #
11
+ # Scenic ships with a Postgres adapter only but can be extended with
12
+ # additional adapters. The {Adapters::Postgres} adapter provides the
13
+ # interface.
14
+ module Adapters
15
+ # An adapter for managing Postgres views.
16
+ #
17
+ # These methods are used interally by Scenic and are not intended for direct
18
+ # use. Methods that alter database schema are intended to be called via
19
+ # {Statements}, while {#refresh_materialized_view} is called via
20
+ # {Scenic.database}.
21
+ #
22
+ # The methods are documented here for insight into specifics of how Scenic
23
+ # integrates with Postgres and the responsibilities of {Adapters}.
24
+ class Postgres
25
+ # Creates an instance of the Scenic Postgres adapter.
26
+ #
27
+ # This is the default adapter for Scenic. Configuring it via
28
+ # {Scenic.configure} is not required, but the example below shows how one
29
+ # would explicitly set it.
30
+ #
31
+ # @param [#connection] connectable An object that returns the connection
32
+ # for Scenic to use. Defaults to `ActiveRecord::Base`.
33
+ #
34
+ # @example
35
+ # Scenic.configure do |config|
36
+ # config.database = Scenic::Adapters::Postgres.new
37
+ # end
38
+ def initialize(connectable = ActiveRecord::Base)
39
+ @connectable = connectable
40
+ end
41
+
42
+ # Returns an array of views in the database.
43
+ #
44
+ # This collection of views is used by the [Scenic::SchemaDumper] to
45
+ # populate the `schema.rb` file.
46
+ #
47
+ # @return [Array<Scenic::View>]
48
+ def views
49
+ Views.new(connection).all
50
+ end
51
+
52
+ # Creates a view in the database.
53
+ #
54
+ # This is typically called in a migration via {Statements#create_view}.
55
+ #
56
+ # @param name The name of the view to create
57
+ # @param sql_definition The SQL schema for the view.
58
+ #
59
+ # @return [void]
60
+ def create_view(name, sql_definition)
61
+ execute "CREATE VIEW #{quote_table_name(name)} AS #{sql_definition};"
62
+ end
63
+
64
+ # Updates a view in the database.
65
+ #
66
+ # This results in a {#drop_view} followed by a {#create_view}. The
67
+ # explicitness of that two step process is preferred to `CREATE OR
68
+ # REPLACE VIEW` because the former ensures that the view you are trying to
69
+ # update did, in fact, already exist. Additionally, `CREATE OR REPLACE
70
+ # VIEW` is allowed only to add new columns to the end of an existing
71
+ # view schema. Existing columns cannot be re-ordered, removed, or have
72
+ # their types changed. Drop and create overcomes this limitation as well.
73
+ #
74
+ # This is typically called in a migration via {Statements#update_view}.
75
+ #
76
+ # @param name The name of the view to update
77
+ # @param sql_definition The SQL schema for the updated view.
78
+ #
79
+ # @return [void]
80
+ def update_view(name, sql_definition)
81
+ drop_view(name)
82
+ create_view(name, sql_definition)
83
+ end
84
+
85
+ # Replaces a view in the database using `CREATE OR REPLACE VIEW`.
86
+ #
87
+ # This results in a `CREATE OR REPLACE VIEW`. Most of the time the
88
+ # explicitness of the two step process used in {#update_view} is preferred
89
+ # to `CREATE OR REPLACE VIEW` because the former ensures that the view you
90
+ # are trying to update did, in fact, already exist. Additionally,
91
+ # `CREATE OR REPLACE VIEW` is allowed only to add new columns to the end
92
+ # of an existing view schema. Existing columns cannot be re-ordered,
93
+ # removed, or have their types changed. Drop and create overcomes this
94
+ # limitation as well.
95
+ #
96
+ # However, when there is a tangled dependency tree
97
+ # `CREATE OR REPLACE VIEW` can be preferable.
98
+ #
99
+ # This is typically called in a migration via
100
+ # {Statements#replace_view}.
101
+ #
102
+ # @param name The name of the view to update
103
+ # @param sql_definition The SQL schema for the updated view.
104
+ #
105
+ # @return [void]
106
+ def replace_view(name, sql_definition)
107
+ execute "CREATE OR REPLACE VIEW #{quote_table_name(name)} AS #{sql_definition};"
108
+ end
109
+
110
+ # Drops the named view from the database
111
+ #
112
+ # This is typically called in a migration via {Statements#drop_view}.
113
+ #
114
+ # @param name The name of the view to drop
115
+ #
116
+ # @return [void]
117
+ def drop_view(name)
118
+ execute "DROP VIEW #{quote_table_name(name)};"
119
+ end
120
+
121
+ # Creates a materialized view in the database
122
+ #
123
+ # @param name The name of the materialized view to create
124
+ # @param sql_definition The SQL schema that defines the materialized view.
125
+ # @param no_data [Boolean] Default: false. Set to true to create
126
+ # materialized view without running the associated query. You will need
127
+ # to perform a non-concurrent refresh to populate with data.
128
+ #
129
+ # This is typically called in a migration via {Statements#create_view}.
130
+ #
131
+ # @raise [MaterializedViewsNotSupportedError] if the version of Postgres
132
+ # in use does not support materialized views.
133
+ #
134
+ # @return [void]
135
+ def create_materialized_view(name, sql_definition, no_data: false)
136
+ raise_unless_materialized_views_supported
137
+
138
+ execute <<-SQL
139
+ CREATE MATERIALIZED VIEW #{quote_table_name(name)} AS
140
+ #{sql_definition.rstrip.chomp(';')}
141
+ #{'WITH NO DATA' if no_data};
142
+ SQL
143
+ end
144
+
145
+ # Updates a materialized view in the database.
146
+ #
147
+ # Drops and recreates the materialized view. Attempts to maintain all
148
+ # previously existing and still applicable indexes on the materialized
149
+ # view after the view is recreated.
150
+ #
151
+ # This is typically called in a migration via {Statements#update_view}.
152
+ #
153
+ # @param name The name of the view to update
154
+ # @param sql_definition The SQL schema for the updated view.
155
+ # @param no_data [Boolean] Default: false. Set to true to create
156
+ # materialized view without running the associated query. You will need
157
+ # to perform a non-concurrent refresh to populate with data.
158
+ #
159
+ # @raise [MaterializedViewsNotSupportedError] if the version of Postgres
160
+ # in use does not support materialized views.
161
+ #
162
+ # @return [void]
163
+ def update_materialized_view(name, sql_definition, no_data: false)
164
+ raise_unless_materialized_views_supported
165
+
166
+ IndexReapplication.new(connection: connection).on(name) do
167
+ drop_materialized_view(name)
168
+ create_materialized_view(name, sql_definition, no_data: no_data)
169
+ end
170
+ end
171
+
172
+ # Drops a materialized view in the database
173
+ #
174
+ # This is typically called in a migration via {Statements#update_view}.
175
+ #
176
+ # @param name The name of the materialized view to drop.
177
+ # @raise [MaterializedViewsNotSupportedError] if the version of Postgres
178
+ # in use does not support materialized views.
179
+ #
180
+ # @return [void]
181
+ def drop_materialized_view(name)
182
+ raise_unless_materialized_views_supported
183
+ execute "DROP MATERIALIZED VIEW #{quote_table_name(name)};"
184
+ end
185
+
186
+ # Refreshes a materialized view from its SQL schema.
187
+ #
188
+ # This is typically called from application code via {Scenic.database}.
189
+ #
190
+ # @param name The name of the materialized view to refresh.
191
+ # @param concurrently [Boolean] Whether the refreshs hould happen
192
+ # concurrently or not. A concurrent refresh allows the view to be
193
+ # refreshed without locking the view for select but requires that the
194
+ # table have at least one unique index that covers all rows. Attempts to
195
+ # refresh concurrently without a unique index will raise a descriptive
196
+ # error.
197
+ #
198
+ # @raise [MaterializedViewsNotSupportedError] if the version of Postgres
199
+ # in use does not support materialized views.
200
+ # @raise [ConcurrentRefreshesNotSupportedError] when attempting a
201
+ # concurrent refresh on version of Postgres that does not support
202
+ # concurrent materialized view refreshes.
203
+ #
204
+ # @example Non-concurrent refresh
205
+ # Scenic.database.refresh_materialized_view(:search_results)
206
+ # @example Concurrent refresh
207
+ # Scenic.database.refresh_materialized_view(:posts, concurrently: true)
208
+ #
209
+ # @return [void]
210
+ def refresh_materialized_view(name, concurrently: false, cascade: false)
211
+ raise_unless_materialized_views_supported
212
+
213
+ if cascade
214
+ refresh_dependencies_for(name, concurrently: concurrently)
215
+ end
216
+
217
+ if concurrently
218
+ raise_unless_concurrent_refresh_supported
219
+ execute "REFRESH MATERIALIZED VIEW CONCURRENTLY #{quote_table_name(name)};"
220
+ else
221
+ execute "REFRESH MATERIALIZED VIEW #{quote_table_name(name)};"
222
+ end
223
+ end
224
+
225
+ private
226
+
227
+ attr_reader :connectable
228
+ delegate :execute, :quote_table_name, to: :connection
229
+
230
+ def connection
231
+ Connection.new(connectable.connection)
232
+ end
233
+
234
+ def raise_unless_materialized_views_supported
235
+ unless connection.supports_materialized_views?
236
+ raise MaterializedViewsNotSupportedError
237
+ end
238
+ end
239
+
240
+ def raise_unless_concurrent_refresh_supported
241
+ unless connection.supports_concurrent_refreshes?
242
+ raise ConcurrentRefreshesNotSupportedError
243
+ end
244
+ end
245
+
246
+ def refresh_dependencies_for(name, concurrently: false)
247
+ Scenic::Adapters::Postgres::RefreshDependencies.call(
248
+ name,
249
+ self,
250
+ connection,
251
+ concurrently: concurrently,
252
+ )
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,57 @@
1
+ module Scenic
2
+ module Adapters
3
+ class Postgres
4
+ # Decorates an ActiveRecord connection with methods that help determine
5
+ # the connections capabilities.
6
+ #
7
+ # Every attempt is made to use the versions of these methods defined by
8
+ # Rails where they are available and public before falling back to our own
9
+ # implementations for older Rails versions.
10
+ #
11
+ # @api private
12
+ class Connection < SimpleDelegator
13
+ # True if the connection supports materialized views.
14
+ #
15
+ # Delegates to the method of the same name if it is already defined on
16
+ # the connection. This is the case for Rails 4.2 or higher.
17
+ #
18
+ # @return [Boolean]
19
+ def supports_materialized_views?
20
+ if undecorated_connection.respond_to?(:supports_materialized_views?)
21
+ super
22
+ else
23
+ postgresql_version >= 90300
24
+ end
25
+ end
26
+
27
+ # True if the connection supports concurrent refreshes of materialized
28
+ # views.
29
+ #
30
+ # @return [Boolean]
31
+ def supports_concurrent_refreshes?
32
+ postgresql_version >= 90400
33
+ end
34
+
35
+ # An integer representing the version of Postgres we're connected to.
36
+ #
37
+ # postgresql_version is public in Rails 5, but protected in earlier
38
+ # versions.
39
+ #
40
+ # @return [Integer]
41
+ def postgresql_version
42
+ if undecorated_connection.respond_to?(:postgresql_version)
43
+ super
44
+ else
45
+ undecorated_connection.send(:postgresql_version)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def undecorated_connection
52
+ __getobj__
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ module Scenic
2
+ module Adapters
3
+ class Postgres
4
+ # Raised when a materialized view operation is attempted on a database
5
+ # version that does not support materialized views.
6
+ #
7
+ # Materialized views are supported on Postgres 9.3 or newer.
8
+ class MaterializedViewsNotSupportedError < StandardError
9
+ def initialize
10
+ super("Materialized views require Postgres 9.3 or newer")
11
+ end
12
+ end
13
+
14
+ # Raised when attempting a concurrent materialized view refresh on a
15
+ # database version that does not support that.
16
+ #
17
+ # Concurrent materialized view refreshes are supported on Postgres 9.4 or
18
+ # newer.
19
+ class ConcurrentRefreshesNotSupportedError < StandardError
20
+ def initialize
21
+ super("Concurrent materialized view refreshes require Postgres 9.4 or newer")
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,71 @@
1
+ module Scenic
2
+ module Adapters
3
+ class Postgres
4
+ # Updating a materialized view causes the view to be dropped and
5
+ # recreated. This causes any associated indexes to be dropped as well.
6
+ # This object can be used to capture the existing indexes before the drop
7
+ # and then reapply appropriate indexes following the create.
8
+ #
9
+ # @api private
10
+ class IndexReapplication
11
+ # Creates the index reapplication object.
12
+ #
13
+ # @param connection [Connection] The connection to execute SQL against.
14
+ # @param speaker [#say] (ActiveRecord::Migration) The object used for
15
+ # logging the results of reapplying indexes.
16
+ def initialize(connection:, speaker: ActiveRecord::Migration)
17
+ @connection = connection
18
+ @speaker = speaker
19
+ end
20
+
21
+ # Caches indexes on the provided object before executing the block and
22
+ # then reapplying the indexes. Each recreated or skipped index is
23
+ # announced to STDOUT by default. This can be overridden in the
24
+ # constructor.
25
+ #
26
+ # @param name The name of the object we are reapplying indexes on.
27
+ # @yield Operations to perform before reapplying indexes.
28
+ #
29
+ # @return [void]
30
+ def on(name)
31
+ indexes = Indexes.new(connection: connection).on(name)
32
+
33
+ yield
34
+
35
+ indexes.each(&method(:try_index_create))
36
+ end
37
+
38
+ private
39
+
40
+ 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
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,53 @@
1
+ module Scenic
2
+ module Adapters
3
+ class Postgres
4
+ # Fetches indexes on objects from the Postgres connection.
5
+ #
6
+ # @api private
7
+ class Indexes
8
+ def initialize(connection:)
9
+ @connection = connection
10
+ end
11
+
12
+ # Indexes on the provided object.
13
+ #
14
+ # @param name [String] The name of the object we want indexes from.
15
+ # @return [Array<Scenic::Index>]
16
+ def on(name)
17
+ indexes_on(name).map(&method(:index_from_database))
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :connection
23
+ delegate :quote_table_name, to: :connection
24
+
25
+ def indexes_on(name)
26
+ connection.execute(<<-SQL)
27
+ SELECT
28
+ t.relname as object_name,
29
+ i.relname as index_name,
30
+ pg_get_indexdef(d.indexrelid) AS definition
31
+ FROM pg_class t
32
+ INNER JOIN pg_index d ON t.oid = d.indrelid
33
+ INNER JOIN pg_class i ON d.indexrelid = i.oid
34
+ LEFT JOIN pg_namespace n ON n.oid = i.relnamespace
35
+ WHERE i.relkind = 'i'
36
+ AND d.indisprimary = 'f'
37
+ AND t.relname = '#{name}'
38
+ AND n.nspname = ANY (current_schemas(false))
39
+ ORDER BY i.relname
40
+ SQL
41
+ end
42
+
43
+ def index_from_database(result)
44
+ Scenic::Index.new(
45
+ object_name: result["object_name"],
46
+ index_name: result["index_name"],
47
+ definition: result["definition"],
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end