scenic 1.0.0 → 1.1.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.travis.yml +14 -4
  4. data/.yardopts +0 -1
  5. data/Appraisals +25 -0
  6. data/CONTRIBUTING.md +5 -4
  7. data/LICENSE.txt +1 -1
  8. data/NEWS.md +20 -0
  9. data/README.md +68 -28
  10. data/bin/appraisal +16 -0
  11. data/bin/setup +5 -0
  12. data/bin/yard +16 -0
  13. data/gemfiles/rails40.gemfile +8 -0
  14. data/gemfiles/rails41.gemfile +8 -0
  15. data/gemfiles/rails42.gemfile +8 -0
  16. data/gemfiles/rails50.gemfile +14 -0
  17. data/lib/generators/scenic/generators.rb +1 -0
  18. data/lib/generators/scenic/model/templates/model.erb +1 -1
  19. data/lib/scenic.rb +1 -0
  20. data/lib/scenic/adapters/postgres.rb +140 -33
  21. data/lib/scenic/adapters/postgres/connection.rb +57 -0
  22. data/lib/scenic/adapters/postgres/errors.rb +26 -0
  23. data/lib/scenic/adapters/postgres/index_reapplication.rb +71 -0
  24. data/lib/scenic/adapters/postgres/indexes.rb +53 -0
  25. data/lib/scenic/adapters/postgres/views.rb +51 -0
  26. data/lib/scenic/configuration.rb +2 -2
  27. data/lib/scenic/index.rb +36 -0
  28. data/lib/scenic/schema_dumper.rb +1 -1
  29. data/lib/scenic/statements.rb +7 -13
  30. data/lib/scenic/version.rb +1 -1
  31. data/lib/scenic/view.rb +0 -3
  32. data/scenic.gemspec +4 -1
  33. data/spec/dummy/config/application.rb +3 -0
  34. data/spec/scenic/adapters/postgres/connection_spec.rb +79 -0
  35. data/spec/scenic/adapters/postgres/views_spec.rb +37 -0
  36. data/spec/scenic/adapters/postgres_spec.rb +84 -26
  37. data/spec/scenic/statements_spec.rb +14 -15
  38. data/spec/smoke +16 -3
  39. data/spec/spec_helper.rb +4 -0
  40. metadata +64 -8
  41. data/spec/dummy/config/environments/development.rb +0 -6
  42. data/spec/dummy/config/environments/test.rb +0 -5
@@ -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
+ # Updatine 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 = '#{quote_table_name(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
@@ -0,0 +1,51 @@
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
+ FROM pg_class c
32
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
33
+ WHERE
34
+ c.relkind IN ('m', 'v')
35
+ AND c.relname NOT IN (SELECT extname FROM pg_extension)
36
+ AND n.nspname = ANY (current_schemas(false))
37
+ ORDER BY c.oid
38
+ SQL
39
+ end
40
+
41
+ def to_scenic_view(result)
42
+ Scenic::View.new(
43
+ name: result["viewname"],
44
+ definition: result["definition"].strip,
45
+ materialized: result["kind"] == "m",
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,7 +2,7 @@ module Scenic
2
2
  class Configuration
3
3
  # The Scenic database adapter instance to use when executing SQL.
4
4
  #
5
- # Defualts to an instance of [Scenic::Adapters::Postgres]
5
+ # Defualts to an instance of {Adapters::Postgres}
6
6
  # @return Scenic adapter
7
7
  attr_accessor :database
8
8
 
@@ -28,7 +28,7 @@ module Scenic
28
28
  # @yieldparam [Scenic::Configuration] config current Scenic config
29
29
  # ```
30
30
  # Scenic.configure do |config|
31
- # config.database = Scenic::Adapters::Postgres
31
+ # config.database = Scenic::Adapters::Postgres.new
32
32
  # end
33
33
  # ```
34
34
  def self.configure
@@ -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
@@ -15,7 +15,7 @@ module Scenic
15
15
  end
16
16
 
17
17
  def views_in_database
18
- @views_in_database ||= Scenic.database.views.sort
18
+ @views_in_database ||= Scenic.database.views
19
19
  end
20
20
 
21
21
  private
@@ -68,10 +68,8 @@ module Scenic
68
68
  # @param version [Fixnum] The version number of the view.
69
69
  # @param revert_to_version [Fixnum] The version number to rollback to on
70
70
  # `rake db rollback`
71
- # @param materialized [Boolean] Must be false. Updating a meterialized view
72
- # causes indexes on it to be dropped. For this reason you should
73
- # explicitly use {#drop_view} followed by {#create_view} and recreate
74
- # applicable indexes. Setting this to `true` will raise an error.
71
+ # @param materialized [Boolean] True if updating a materialized view.
72
+ # Defaults to false.
75
73
  # @return The database response from executing the create statement.
76
74
  #
77
75
  # @example
@@ -82,17 +80,13 @@ module Scenic
82
80
  raise ArgumentError, "version is required"
83
81
  end
84
82
 
83
+ sql_definition = definition(name, version)
84
+
85
85
  if materialized
86
- raise ArgumentError, "Updating materialized views is not supported "\
87
- "because it would cause any indexes to be dropped. Please use "\
88
- "'drop_view' followed by 'create_view', being sure to also recreate "\
89
- "any previously-existing indexes."
86
+ Scenic.database.update_materialized_view(name, sql_definition)
87
+ else
88
+ Scenic.database.update_view(name, sql_definition)
90
89
  end
91
-
92
- drop_view name,
93
- revert_to_version: revert_to_version,
94
- materialized: materialized
95
- create_view(name, version: version, materialized: materialized)
96
90
  end
97
91
 
98
92
  private
@@ -1,3 +1,3 @@
1
1
  module Scenic
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  end
@@ -22,9 +22,6 @@ module Scenic
22
22
  # @return [Boolean]
23
23
  attr_reader :materialized
24
24
 
25
- # @api private
26
- delegate :<=>, to: :name
27
-
28
25
  # Returns a new instance of View.
29
26
  #
30
27
  # @param name [String] The name of the view.
@@ -20,6 +20,7 @@ Gem::Specification.new do |spec|
20
20
  spec.test_files = spec.files.grep(%r{^spec/})
21
21
  spec.require_paths = ['lib']
22
22
 
23
+ spec.add_development_dependency 'appraisal'
23
24
  spec.add_development_dependency 'bundler', '>= 1.5'
24
25
  spec.add_development_dependency 'database_cleaner'
25
26
  spec.add_development_dependency 'rake'
@@ -27,9 +28,11 @@ Gem::Specification.new do |spec|
27
28
  spec.add_development_dependency 'pg'
28
29
  spec.add_development_dependency 'pry'
29
30
  spec.add_development_dependency 'ammeter', '>= 1.1.3'
31
+ spec.add_development_dependency 'yard'
32
+ spec.add_development_dependency 'redcarpet'
30
33
 
31
34
  spec.add_dependency 'activerecord', '>= 4.0.0'
32
35
  spec.add_dependency 'railties', '>= 4.0.0'
33
36
 
34
- spec.required_ruby_version = '~> 2.0'
37
+ spec.required_ruby_version = '~> 2.1'
35
38
  end
@@ -8,5 +8,8 @@ require "scenic"
8
8
 
9
9
  module Dummy
10
10
  class Application < Rails::Application
11
+ config.cache_classes = true
12
+ config.eager_load = false
13
+ config.active_support.deprecation = :stderr
11
14
  end
12
15
  end
@@ -0,0 +1,79 @@
1
+ require "spec_helper"
2
+
3
+ module Scenic
4
+ module Adapters
5
+ describe Postgres::Connection do
6
+ describe "supports_materialized_views?" do
7
+ context "supports_materialized_views? was defined on connection" do
8
+ it "uses the previously defined version" do
9
+ base_response = double("response from base connection")
10
+ base_connection = double(
11
+ "Connection",
12
+ supports_materialized_views?: base_response,
13
+ )
14
+
15
+ connection = Postgres::Connection.new(base_connection)
16
+
17
+ expect(connection.supports_materialized_views?).to be base_response
18
+ end
19
+ end
20
+
21
+ context "supports_materialized_views? is not already defined" do
22
+ it "is true if postgres version is at least than 9.3.0" do
23
+ base_connection = double("Connection", postgresql_version: 90300)
24
+
25
+ connection = Postgres::Connection.new(base_connection)
26
+
27
+ expect(connection.supports_materialized_views?).to be true
28
+ end
29
+
30
+ it "is false if postgres version is less than 9.3.0" do
31
+ base_connection = double("Connection", postgresql_version: 90299)
32
+
33
+ connection = Postgres::Connection.new(base_connection)
34
+
35
+ expect(connection.supports_materialized_views?).to be false
36
+ end
37
+ end
38
+ end
39
+
40
+ describe "#postgresql_version" do
41
+ it "uses the public method on the provided connection if defined" do
42
+ base_connection = Class.new do
43
+ def postgresql_version
44
+ 123
45
+ end
46
+ end
47
+
48
+ connection = Postgres::Connection.new(base_connection.new)
49
+
50
+ expect(connection.postgresql_version).to eq 123
51
+ end
52
+
53
+ it "uses the protected method if the underlying method is not public" do
54
+ base_connection = Class.new do
55
+ protected
56
+
57
+ def postgresql_version
58
+ 123
59
+ end
60
+ end
61
+
62
+ connection = Postgres::Connection.new(base_connection.new)
63
+
64
+ expect(connection.postgresql_version).to eq 123
65
+ end
66
+ end
67
+
68
+ describe "#supports_concurrent_refresh" do
69
+ it "is true if postgres version is at least 9.4.0" do
70
+ base_connection = double("Connection", postgresql_version: 90400)
71
+
72
+ connection = Postgres::Connection.new(base_connection)
73
+
74
+ expect(connection.supports_concurrent_refreshes?).to be true
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end