scenic 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
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