scenic 0.3.0 → 1.0.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 (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