scenic 0.3.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +6 -4
  3. data/.yardopts +5 -0
  4. data/CONTRIBUTING.md +25 -0
  5. data/LICENSE.txt +1 -1
  6. data/NEWS.md +61 -9
  7. data/README.md +98 -62
  8. data/Rakefile +5 -0
  9. data/bin/setup +7 -0
  10. data/lib/generators/scenic/generators.rb +11 -0
  11. data/lib/generators/scenic/materializable.rb +22 -0
  12. data/lib/generators/scenic/model/USAGE +2 -0
  13. data/lib/generators/scenic/model/model_generator.rb +31 -4
  14. data/lib/generators/scenic/model/templates/model.erb +3 -2
  15. data/lib/generators/scenic/view/USAGE +2 -0
  16. data/lib/generators/scenic/view/templates/db/migrate/create_view.erb +1 -1
  17. data/lib/generators/scenic/view/templates/db/migrate/update_view.erb +7 -0
  18. data/lib/generators/scenic/view/view_generator.rb +5 -2
  19. data/lib/scenic.rb +13 -2
  20. data/lib/scenic/adapters/postgres.rb +74 -7
  21. data/lib/scenic/command_recorder.rb +1 -0
  22. data/lib/scenic/command_recorder/statement_arguments.rb +1 -0
  23. data/lib/scenic/configuration.rb +37 -0
  24. data/lib/scenic/definition.rb +2 -1
  25. data/lib/scenic/railtie.rb +4 -0
  26. data/lib/scenic/schema_dumper.rb +3 -6
  27. data/lib/scenic/statements.rb +61 -41
  28. data/lib/scenic/version.rb +1 -1
  29. data/lib/scenic/view.rb +41 -9
  30. data/scenic.gemspec +2 -2
  31. data/spec/dummy/db/views/.keep +0 -0
  32. data/spec/generators/scenic/model/model_generator_spec.rb +11 -0
  33. data/spec/generators/scenic/view/view_generator_spec.rb +13 -0
  34. data/spec/scenic/adapters/postgres_spec.rb +57 -14
  35. data/spec/scenic/configuration_spec.rb +27 -0
  36. data/spec/scenic/statements_spec.rb +33 -2
  37. data/spec/smoke +92 -67
  38. data/spec/support/generator_spec_setup.rb +1 -0
  39. metadata +19 -10
@@ -1,2 +1,3 @@
1
- class <%= class_name %> < ActiveRecord::Base
2
- end
1
+ def self.refresh
2
+ Scenic.database.refresh_materialized_view(table_name)
3
+ end
@@ -5,6 +5,8 @@ Description:
5
5
  If a view of the given name already exists, create a new version of the view
6
6
  and a migration to replace the old version with the new.
7
7
 
8
+ To create a materialized view, pass the '--materialized' option.
9
+
8
10
  Examples:
9
11
  rails generate scenic:view searches
10
12
 
@@ -1,5 +1,5 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration
2
2
  def change
3
- create_view :<%= plural_name %>
3
+ create_view :<%= plural_name %><%= ", materialized: true" if materialized? %>
4
4
  end
5
5
  end
@@ -1,5 +1,12 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration
2
2
  def change
3
+ <%- if materialized? -%>
4
+ update_view :<%= plural_name %>,
5
+ version: <%= version %>,
6
+ revert_to_version: <%= previous_version %>,
7
+ materialized: true
8
+ <%- else -%>
3
9
  update_view :<%= plural_name %>, version: <%= version %>, revert_to_version: <%= previous_version %>
10
+ <%- end -%>
4
11
  end
5
12
  end
@@ -1,10 +1,13 @@
1
1
  require "rails/generators"
2
2
  require "rails/generators/active_record"
3
+ require "generators/scenic/materializable"
3
4
 
4
5
  module Scenic
5
6
  module Generators
7
+ # @api private
6
8
  class ViewGenerator < Rails::Generators::NamedBase
7
9
  include Rails::Generators::Migration
10
+ include Scenic::Generators::Materializable
8
11
  source_root File.expand_path("../templates", __FILE__)
9
12
 
10
13
  def create_views_directory
@@ -25,12 +28,12 @@ module Scenic
25
28
  if creating_new_view? || destroying_initial_view?
26
29
  migration_template(
27
30
  "db/migrate/create_view.erb",
28
- "db/migrate/create_#{plural_file_name}.rb"
31
+ "db/migrate/create_#{plural_file_name}.rb",
29
32
  )
30
33
  else
31
34
  migration_template(
32
35
  "db/migrate/update_view.erb",
33
- "db/migrate/update_#{plural_file_name}_to_version_#{version}.rb"
36
+ "db/migrate/update_#{plural_file_name}_to_version_#{version}.rb",
34
37
  )
35
38
  end
36
39
  end
@@ -1,3 +1,4 @@
1
+ require "scenic/configuration"
1
2
  require "scenic/adapters/postgres"
2
3
  require "scenic/command_recorder"
3
4
  require "scenic/definition"
@@ -7,14 +8,24 @@ require "scenic/statements"
7
8
  require "scenic/version"
8
9
  require "scenic/view"
9
10
 
11
+ # Scenic adds methods `ActiveRecord::Migration` to create and manage database
12
+ # views in Rails applications.
10
13
  module Scenic
14
+ # Hooks Scenic into Rails.
15
+ #
16
+ # Enables scenic migration methods, migration reversability, and `schema.rb`
17
+ # dumping.
11
18
  def self.load
12
19
  ActiveRecord::ConnectionAdapters::AbstractAdapter.include Scenic::Statements
13
20
  ActiveRecord::Migration::CommandRecorder.include Scenic::CommandRecorder
14
- ActiveRecord::SchemaDumper.include Scenic::SchemaDumper
21
+ ActiveRecord::SchemaDumper.prepend Scenic::SchemaDumper
15
22
  end
16
23
 
24
+ # The current database adapter used by Scenic.
25
+ #
26
+ # This defaults to {Adapters::Postgres} but can be overridden
27
+ # via {Configuration}.
17
28
  def self.database
18
- Scenic::Adapters::Postgres
29
+ configuration.database
19
30
  end
20
31
  end
@@ -1,28 +1,95 @@
1
1
  module Scenic
2
+ # Scenic database adapters.
3
+ #
4
+ # Scenic ships with a Postgres adapter only but can be extended with
5
+ # additional adapters. The {Adapters::Postgres} adapter provides the
6
+ # interface.
2
7
  module Adapters
3
- module Postgres
4
- def self.views
5
- execute(<<-SQL).map { |result| Scenic::View.new(result) }
6
- SELECT viewname, definition
8
+ # An adapter for managing Postgres views.
9
+ #
10
+ # **This object is used internally by adapters and the schema dumper and is
11
+ # not intended to be used by application code. It is documented here for
12
+ # use by adapter gems.**
13
+ #
14
+ # For methods usable in migrations see {Statements}.
15
+ #
16
+ # @api extension
17
+ class Postgres
18
+ # Returns an array of views in the database.
19
+ #
20
+ # This collection of views is used by the [Scenic::SchemaDumper] to
21
+ # populate the `schema.rb` file.
22
+ #
23
+ # @return [Array<Scenic::View>]
24
+ def views
25
+ execute(<<-SQL).map { |result| view_from_database(result) }
26
+ SELECT viewname, definition, FALSE AS materialized
7
27
  FROM pg_views
8
28
  WHERE schemaname = ANY (current_schemas(false))
9
29
  AND viewname NOT IN (SELECT extname FROM pg_extension)
30
+ UNION
31
+ SELECT matviewname AS viewname, definition, TRUE AS materialized
32
+ FROM pg_matviews
33
+ WHERE schemaname = ANY (current_schemas(false))
34
+ ORDER BY viewname
10
35
  SQL
11
36
  end
12
37
 
13
- def self.create_view(name, sql_definition)
38
+ # Creates a view in the database.
39
+ #
40
+ # @param name The name of the view to create
41
+ # @param sql_definition the SQL schema for the view.
42
+ # @return [void]
43
+ def create_view(name, sql_definition)
14
44
  execute "CREATE VIEW #{name} AS #{sql_definition};"
15
45
  end
16
46
 
17
- def self.drop_view(name)
47
+ # Drops the named view from the database
48
+ #
49
+ # @param name The name of the view to drop
50
+ # @return [void]
51
+ def drop_view(name)
18
52
  execute "DROP VIEW #{name};"
19
53
  end
20
54
 
55
+ # Creates a materialized view in the database
56
+ #
57
+ # @param name The name of the materialized view to create
58
+ # @param sql_definition The SQL schema that defines the materialized view.
59
+ # @return [void]
60
+ def create_materialized_view(name, sql_definition)
61
+ execute "CREATE MATERIALIZED VIEW #{name} AS #{sql_definition};"
62
+ end
63
+
64
+ # Drops a materialized view in the database
65
+ #
66
+ # @param name The name of the materialized view to drop.
67
+ # @return [void]
68
+ def drop_materialized_view(name)
69
+ execute "DROP MATERIALIZED VIEW #{name};"
70
+ end
71
+
72
+ # Refreshes a materialized view from its SQL schema.
73
+ #
74
+ # @param name The name of the materialized view to refresh..
75
+ # @return [void]
76
+ def refresh_materialized_view(name)
77
+ execute "REFRESH MATERIALIZED VIEW #{name};"
78
+ end
79
+
21
80
  private
22
81
 
23
- def self.execute(sql, base = ActiveRecord::Base)
82
+ def execute(sql, base = ActiveRecord::Base)
24
83
  base.connection.execute sql
25
84
  end
85
+
86
+ def view_from_database(result)
87
+ Scenic::View.new(
88
+ name: result["viewname"],
89
+ definition: result["definition"].strip,
90
+ materialized: result["materialized"] == "t",
91
+ )
92
+ end
26
93
  end
27
94
  end
28
95
  end
@@ -1,6 +1,7 @@
1
1
  require "scenic/command_recorder/statement_arguments"
2
2
 
3
3
  module Scenic
4
+ # @api private
4
5
  module CommandRecorder
5
6
  def create_view(*args)
6
7
  record(:create_view, args)
@@ -1,5 +1,6 @@
1
1
  module Scenic
2
2
  module CommandRecorder
3
+ # @api private
3
4
  class StatementArguments
4
5
  def initialize(args)
5
6
  @args = args.freeze
@@ -0,0 +1,37 @@
1
+ module Scenic
2
+ class Configuration
3
+ # The Scenic database adapter instance to use when executing SQL.
4
+ #
5
+ # Defualts to an instance of [Scenic::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
32
+ # end
33
+ # ```
34
+ def self.configure
35
+ yield configuration
36
+ end
37
+ end
@@ -1,4 +1,5 @@
1
1
  module Scenic
2
+ # @api private
2
3
  class Definition
3
4
  def initialize(name, version)
4
5
  @name = name
@@ -8,7 +9,7 @@ module Scenic
8
9
  def to_sql
9
10
  File.read(full_path).tap do |content|
10
11
  if content.empty?
11
- raise "Define view query in #{@path} before migrating."
12
+ raise "Define view query in #{path} before migrating."
12
13
  end
13
14
  end
14
15
  end
@@ -1,6 +1,10 @@
1
1
  require "rails/railtie"
2
2
 
3
3
  module Scenic
4
+ # Automatically initializes Scenic in the context of a Rails application when
5
+ # ActiveRecord is loaded.
6
+ #
7
+ # @see Scenic.load
4
8
  class Railtie < Rails::Railtie
5
9
  initializer "scenic.load" do
6
10
  ActiveSupport.on_load :active_record do
@@ -1,13 +1,10 @@
1
1
  require "rails"
2
2
 
3
3
  module Scenic
4
+ # @api private
4
5
  module SchemaDumper
5
- extend ActiveSupport::Concern
6
-
7
- included { alias_method_chain :tables, :views }
8
-
9
- def tables_with_views(stream)
10
- tables_without_views(stream)
6
+ def tables(stream)
7
+ super
11
8
  views(stream)
12
9
  end
13
10
 
@@ -1,27 +1,27 @@
1
1
  module Scenic
2
+ # Methods that are made available in migrations for managing Scenic views.
2
3
  module Statements
3
- # Public: Create a new database view.
4
+ # Create a new database view.
4
5
  #
5
- # name - A string or symbol containing the singular name of the database
6
- # view. Cannot conflict with any other view or table names.
7
- # version - The version number of the view. If present, will be used to find
8
- # the definition file in db/views in the form db/views/[pluralized
9
- # name]_v[2 digit zero padded version].sql.
10
- # Example: db/views/searches_v02.sql.
11
- # sql_definition - A string containing the SQL definition of the view. If
12
- # both sql_definition and version are provided,
13
- # sql_definition takes prescedence.
14
- #
15
- # Examples
6
+ # @param name [String, Symbol] The name of the database view.
7
+ # @param version [Fixnum] The version number of the view, used to find the
8
+ # definition file in `db/views`. This defaults to `1` if not provided.
9
+ # @param sql_definition [String] The SQL query for the view schema. If both
10
+ # `sql_defintiion` and `version` are provided, `sql_definition` takes
11
+ # prescedence.
12
+ # @param materialized [Boolean] Set to true to create a materialized view.
13
+ # Defaults to false.
14
+ # @return The database response from executing the create statement.
16
15
  #
16
+ # @example Create from `db/views/searches_v02.sql`
17
17
  # create_view(:searches, version: 2)
18
18
  #
19
+ # @example Create from provided SQL string
19
20
  # create_view(:active_users, sql_definition: <<-SQL)
20
21
  # SELECT * FROM users WHERE users.active = 't'
21
22
  # SQL
22
23
  #
23
- # Returns the database response from executing the CREATE VIEW statement.
24
- def create_view(name, version: 1, sql_definition: nil)
24
+ def create_view(name, version: 1, sql_definition: nil, materialized: false)
25
25
  if version.blank? && sql_definition.nil?
26
26
  raise(
27
27
  ArgumentError,
@@ -31,48 +31,68 @@ module Scenic
31
31
 
32
32
  sql_definition ||= definition(name, version)
33
33
 
34
- Scenic.database.create_view(name, sql_definition)
34
+ if materialized
35
+ Scenic.database.create_materialized_view(name, sql_definition)
36
+ else
37
+ Scenic.database.create_view(name, sql_definition)
38
+ end
35
39
  end
36
40
 
37
- # Public: Drop a database view by name.
38
- #
39
- # name - A string or symbol containing the singular name of the database
40
- # view. Must be an existing view.
41
- # revert_to_version - Used to revert the drop_view command in the
42
- # db:rollback rake task, which would pass the version
43
- # number to create_view. Usually the most recent
44
- # version.
41
+ # Drop a database view by name.
45
42
  #
46
- # Example
43
+ # @param name [String, Symbol] The name of the database view.
44
+ # @param revert_to_version [Fixnum] Used to reverse the `drop_view` command
45
+ # on `rake db:rollback`. The provided version will be passed as the
46
+ # `version` argument to {#create_view}.
47
+ # @param materialized [Boolean] Set to true if dropping a meterialized view.
48
+ # defaults to false.
49
+ # @return The database response from executing the drop statement.
47
50
  #
48
- # drop_view(:users_who_recently_logged_in, 3)
51
+ # @example Drop a view, rolling back to version 3 on rollback
52
+ # drop_view(:users_who_recently_logged_in, revert_to_version: 3)
49
53
  #
50
- # Returns the database response from executing the DROP VIEW statement.
51
- def drop_view(name, revert_to_version: nil)
52
- Scenic.database.drop_view(name)
54
+ def drop_view(name, revert_to_version: nil, materialized: false)
55
+ if materialized
56
+ Scenic.database.drop_materialized_view(name)
57
+ else
58
+ Scenic.database.drop_view(name)
59
+ end
53
60
  end
54
61
 
55
- # Public: Update a database view to a new version by first dropping the
56
- # previous version then creating the new version.
62
+ # Update a database view to a new version.
57
63
  #
58
- # name - A string or symbol containing the singular name of the database
59
- # view. Must be an existing view.
60
- # version - The version number of the view. See create_view for details.
61
- # revert_to_version - The version to revert to for db:rollback. Usually the
62
- # previous version. See drop_view for details.
64
+ # The existing view is dropped and recreated using the supplied `version`
65
+ # parameter.
63
66
  #
64
- # Example
67
+ # @param name [String, Symbol] The name of the database view.
68
+ # @param version [Fixnum] The version number of the view.
69
+ # @param revert_to_version [Fixnum] The version number to rollback to on
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.
75
+ # @return The database response from executing the create statement.
65
76
  #
66
- # update_view(:engagement_reports, version: 3, revert_to_version: 2)
77
+ # @example
78
+ # update_view :engagement_reports, version: 3, revert_to_version: 2
67
79
  #
68
- # Returns the database response from executing the CREATE VIEW statement.
69
- def update_view(name, version: nil, revert_to_version: nil)
80
+ def update_view(name, version: nil, revert_to_version: nil, materialized: false)
70
81
  if version.blank?
71
82
  raise ArgumentError, "version is required"
72
83
  end
73
84
 
74
- drop_view(name, revert_to_version: revert_to_version)
75
- create_view(name, version: version)
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."
90
+ end
91
+
92
+ drop_view name,
93
+ revert_to_version: revert_to_version,
94
+ materialized: materialized
95
+ create_view(name, version: version, materialized: materialized)
76
96
  end
77
97
 
78
98
  private
@@ -1,3 +1,3 @@
1
1
  module Scenic
2
- VERSION = "0.3.0"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -1,24 +1,56 @@
1
1
  module Scenic
2
+ # The in-memory representation of a view definition.
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
2
9
  class View
3
- attr_reader :name, :definition
10
+ # The name of the view
11
+ # @return [String]
12
+ attr_reader :name
13
+
14
+ # The SQL schema for the query that defines the view
15
+ # @return [String]
16
+ #
17
+ # @example
18
+ # "SELECT name, email FROM users UNION SELECT name, email FROM contacts"
19
+ attr_reader :definition
20
+
21
+ # True if the view is materialized
22
+ # @return [Boolean]
23
+ attr_reader :materialized
24
+
25
+ # @api private
4
26
  delegate :<=>, to: :name
5
27
 
6
- def initialize(view_row)
7
- @name = view_row["viewname"]
8
- @definition = view_row["definition"].strip
28
+ # Returns a new instance of View.
29
+ #
30
+ # @param name [String] The name of the view.
31
+ # @param definition [String] The SQL for the query that defines the view.
32
+ # @param materialized [String] `true` if the view is materialized.
33
+ def initialize(name:, definition:, materialized:)
34
+ @name = name
35
+ @definition = definition
36
+ @materialized = materialized
9
37
  end
10
38
 
39
+ # @api private
11
40
  def ==(other)
12
41
  name == other.name &&
13
- definition == other.definition
42
+ definition == other.definition &&
43
+ materialized == other.materialized
14
44
  end
15
45
 
46
+ # @api private
16
47
  def to_schema
17
- <<-DEFINITION.strip_heredoc
18
- create_view :#{name}, sql_definition:<<-\SQL
19
- #{definition}
20
- SQL
48
+ materialized_option = materialized ? "materialized: true, " : ""
49
+ <<-DEFINITION
21
50
 
51
+ create_view :#{name}, #{materialized_option} sql_definition: <<-\SQL
52
+ #{definition.indent(2)}
53
+ SQL
22
54
  DEFINITION
23
55
  end
24
56
  end