spectacles 0.5.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 82b4f3a1f6c49ed3344c2785aa3df557cb0a6da2
4
- data.tar.gz: 28c778a66c9db7be1675b40710b5050e2140689f
3
+ metadata.gz: e7e7045b1b35b15bf40def12928244509e55b708
4
+ data.tar.gz: 6b2ddd076d9ef8b34ba7d9da13a3f090366b23c4
5
5
  SHA512:
6
- metadata.gz: 99068ef2f0a6ccaafe0a4893df24bed9746bd34c6e8c5578fabacd1a5697cd536c29ed621937f75ddbb017800c17f2561ee15eada168aee80a116fc0131b8110
7
- data.tar.gz: 4bceebf90b0546a333c2317b5c786fcdb2bf72b0ca16c8af0e87334f4612db130eef94a3876840df96509b3d7cddebf45e1efe0997908988d9a60c564262757e
6
+ metadata.gz: 5601559ad5b5f928b6ef91f01df5296ef9969561feb4e2aad11c79fc112a3d5961e9fb0d4f0cf4688bc1f4488eb14fd2042e30d0114fda7320f257ff4004b00d
7
+ data.tar.gz: a47a95fae8d26bf30e6b37a5218557eee8e0f825dc54ab57f6da63d20a274df145a29bd24e3f5ba462c066eea34200909b6318ac3bf840296410ca89cc52eb95
data/Rakefile CHANGED
@@ -2,7 +2,7 @@ require "bundler/gem_tasks"
2
2
  require 'rake/testtask'
3
3
 
4
4
  namespace :test do
5
- adapters = [ :mysql, :mysql2, :postgresql, :sqlite, :sqlite3 ]
5
+ adapters = [ :mysql, :mysql2, :postgresql, :sqlite3 ]
6
6
  task :all => [ :spectacles ] + adapters
7
7
 
8
8
  adapters.each do |adapter|
@@ -4,7 +4,7 @@ Spectacles adds database view functionality to ActiveRecord. It is heavily inspi
4
4
 
5
5
  Spectacles provides the ability to create views in migrations using a similar format to creating tables. It also provides an abstract view class that inherits from ActiveRecord::Base that can be used to create view-backed models.
6
6
 
7
- It currently only works with the SQLite, PostgreSQL, and Vertica drivers. MySQL & MySQL2 have a couple of issues that are still being worked out.
7
+ It currently works with the SQLite, MySQL, MySQL2, PostgreSQL, and Vertica drivers.
8
8
 
9
9
  = Using Spectacles
10
10
  Install it
@@ -38,6 +38,81 @@ Create a migration from an ARel object:
38
38
  # Your fancy methods
39
39
  end
40
40
 
41
+ == Materialized Views
42
+
43
+ *This feature is only supported for PostgreSQL backends.*
44
+ These are essentially views that cache their result set. In this way
45
+ they are kind of a cross between tables (which persist data) and views
46
+ (which are windows onto other tables).
47
+
48
+ create_materialized_view :product_users do
49
+ <<-SQL.squish
50
+ SELECT name AS product_name, first_name AS username
51
+ FROM products
52
+ JOIN users ON users.id = products.user_id
53
+ SQL
54
+ end
55
+
56
+ class ProductUser < Spectacles::MaterializedView
57
+ # just like Spectacles::View
58
+ end
59
+
60
+ Because these materialized views cache a snapshot of the data as it
61
+ exists at a point in time (typically when the view was created), you
62
+ need to manually _refresh_ the view when new data is added to the
63
+ original tables. You can do this with the +#refresh!+ method on
64
+ the +Spectacles::MaterializedView+ subclass:
65
+
66
+ User.create(first_name: "Bob", email: "bob@example.com")
67
+ ProductUser.refresh!
68
+
69
+ Also, you can specify a few different options to +create_materialized_view+
70
+ to affect how the new view is created:
71
+
72
+ * +:force+ - if +false+ (the default), the create will fail if a
73
+ materialized view with the given name already exists. If +true+,
74
+ any materialized view with that name will be dropped before the
75
+ create runs.
76
+
77
+ create_materialized_view :product_users, force: true do
78
+ # ...
79
+ end
80
+
81
+ * +:data+ - if +true+ (the default), the view is immediately populated
82
+ with the corresponding data. If +false+, the view will be empty initially,
83
+ and must be populated by invoking the +#refresh!+ method.
84
+
85
+ create_materialized_view :product_users, data: false do
86
+ # ...
87
+ end
88
+
89
+ * +:columns+ - an optional array of names to give the columns in the view.
90
+ By default, columns in the view will use the names given in the query.
91
+
92
+ create_materialized_view :product_users, columns: %i(product_name username) do
93
+ <<-SQL.squish
94
+ SELECT products.name, users.first_name
95
+ FROM products
96
+ JOIN users ON users.id = products.user_id
97
+ SQL
98
+ end
99
+
100
+ * +:tablespace+ - an optional identifier (string or symbol) indicating
101
+ which namespace the materialized view ought to be created in.
102
+
103
+ create_materialized_view :product_users, tablespace: "awesomesauce" do
104
+ # ...
105
+ end
106
+
107
+ * +:storage+ - an optional hash of (database-specific) storage parameters to
108
+ optimize how the materialized view is stored. (See
109
+ http://www.postgresql.org/docs/9.4/static/sql-createtable.html#SQL-CREATETABLE-STORAGE-PARAMETERS
110
+ for details.)
111
+
112
+ create_materialized_view :product_users, storage: { fillfactor: 70 } do
113
+ # ...
114
+ end
115
+
41
116
  = License
42
117
 
43
- Spectacles is licensed under MIT license (Read lib/spectactles.rb for full license)
118
+ Spectacles is licensed under MIT license (Read lib/spectactles.rb for full license)
@@ -3,6 +3,7 @@ require 'active_support/core_ext'
3
3
  require 'spectacles/schema_statements'
4
4
  require 'spectacles/schema_dumper'
5
5
  require 'spectacles/view'
6
+ require 'spectacles/materialized_view'
6
7
  require 'spectacles/version'
7
8
  require 'spectacles/configuration'
8
9
 
@@ -36,6 +37,7 @@ ActiveRecord::SchemaDumper.class_eval do
36
37
 
37
38
  def trailer(stream)
38
39
  ::Spectacles::SchemaDumper.dump_views(stream, @connection)
40
+ ::Spectacles::SchemaDumper.dump_materialized_views(stream, @connection)
39
41
  _spectacles_orig_trailer(stream)
40
42
  end
41
43
  end
@@ -0,0 +1,39 @@
1
+ module Spectacles
2
+ class MaterializedView < ActiveRecord::Base
3
+ self.abstract_class = true
4
+
5
+ def self.new(*)
6
+ raise NotImplementedError
7
+ end
8
+
9
+ def self.materialized_view_exists?
10
+ self.connection.materialized_view_exists?(self.view_name)
11
+ end
12
+
13
+ def self.refresh!
14
+ self.connection.refresh_materialized_view(self.view_name)
15
+ end
16
+
17
+ class << self
18
+ alias_method :table_exists?, :materialized_view_exists?
19
+ alias_method :view_name, :table_name
20
+ end
21
+
22
+ def ==(comparison_object)
23
+ super ||
24
+ comparison_object.instance_of?(self.class) &&
25
+ attributes.present? &&
26
+ comparison_object.attributes == attributes
27
+ end
28
+
29
+ def persisted?
30
+ false
31
+ end
32
+
33
+ def readonly?
34
+ true
35
+ end
36
+ end
37
+
38
+ ::ActiveSupport.run_load_hooks(:spectacles, MaterializedView)
39
+ end
@@ -8,12 +8,54 @@ module Spectacles
8
8
  end
9
9
  end
10
10
 
11
+ def self.dump_materialized_views(stream, connection)
12
+ unless (Spectacles.config.enable_schema_dump == false)
13
+ if connection.supports_materialized_views?
14
+ connection.materialized_views.sort.each do |view|
15
+ dump_materialized_view(stream, connection, view)
16
+ end
17
+ end
18
+ end
19
+ end
20
+
11
21
  def self.dump_view(stream, connection, view_name)
12
22
  stream.print <<-CREATEVIEW
23
+
13
24
  create_view :#{view_name}, :force => true do
14
25
  "#{connection.view_build_query(view_name)}"
15
26
  end
27
+
16
28
  CREATEVIEW
17
29
  end
30
+
31
+ def self.dump_materialized_view(stream, connection, view_name)
32
+ definition, options = connection.materialized_view_build_query(view_name)
33
+ options[:force] = true
34
+
35
+ stream.print <<-CREATEVIEW
36
+
37
+ create_materialized_view #{view_name.to_sym.inspect}, #{format_option_hash(options)} do
38
+ <<-SQL
39
+ #{definition}
40
+ SQL
41
+ end
42
+ CREATEVIEW
43
+ end
44
+
45
+ def self.format_option_hash(hash)
46
+ hash.map do |key, value|
47
+ "#{key}: #{format_option_value(value)}"
48
+ end.join(", ")
49
+ end
50
+
51
+ def self.format_option_value(value)
52
+ case value
53
+ when Hash then "{ #{format_option_hash(value)} }"
54
+ when /^\d+$/ then value.to_i
55
+ when /^\d+\.\d+$/ then value.to_f
56
+ when true, false then value.inspect
57
+ else raise "can't format #{value.inspect}"
58
+ end
59
+ end
18
60
  end
19
61
  end
@@ -9,9 +9,11 @@ module Spectacles
9
9
 
10
10
  if ActiveRecord::ConnectionAdapters.const_defined?(adapter_class)
11
11
  require "spectacles/schema_statements/#{db.downcase}_adapter"
12
- ActiveRecord::ConnectionAdapters.const_get(adapter_class).class_eval do
13
- include Spectacles::SchemaStatements.const_get(adapter_class)
14
- end
12
+
13
+ adapter = ActiveRecord::ConnectionAdapters.const_get(adapter_class)
14
+ extension = Spectacles::SchemaStatements.const_get(adapter_class)
15
+
16
+ adapter.send :prepend, extension
15
17
  end
16
18
  end
17
19
  end
@@ -46,6 +46,34 @@ module Spectacles
46
46
  def views
47
47
  raise "Override view for your db adapter in #{self.class}"
48
48
  end
49
+
50
+ def supports_materialized_views?
51
+ false
52
+ end
53
+
54
+ def materialized_view_exists?(name)
55
+ return materialized_views.include?(name.to_s)
56
+ end
57
+
58
+ def materialized_views
59
+ raise NotImplementedError, "Override materialized_views for your db adapter in #{self.class}"
60
+ end
61
+
62
+ def materialized_view_build_query(view_name)
63
+ raise NotImplementedError, "Override materialized_view_build_query for your db adapter in #{self.class}"
64
+ end
65
+
66
+ def create_materialized_view(view_name, *args)
67
+ raise NotImplementedError, "Override create_materialized_view for your db adapter in #{self.class}"
68
+ end
69
+
70
+ def drop_materialized_view(view_name)
71
+ raise NotImplementedError, "Override drop_materialized_view for your db adapter in #{self.class}"
72
+ end
73
+
74
+ def refresh_materialized_view(view_name)
75
+ raise NotImplementedError, "Override refresh_materialized_view for your db adapter in #{self.class}"
76
+ end
49
77
  end
50
78
  end
51
79
  end
@@ -4,7 +4,26 @@ module Spectacles
4
4
  module SchemaStatements
5
5
  module MysqlAdapter
6
6
  include Spectacles::SchemaStatements::AbstractAdapter
7
-
7
+
8
+ # overrides the #tables method from ActiveRecord's MysqlAdapter
9
+ # to return only tables, and not views.
10
+ def tables(name = nil, database = nil, like = nil)
11
+ database = database ? quote_table_name(database) : "DATABASE()"
12
+ by_name = like ? "AND table_name LIKE #{quote(like)}" : ""
13
+
14
+ sql = <<-SQL.squish
15
+ SELECT table_name, table_type
16
+ FROM information_schema.tables
17
+ WHERE table_schema = #{database}
18
+ AND table_type = 'BASE TABLE'
19
+ #{by_name}
20
+ SQL
21
+
22
+ execute_and_free(sql, 'SCHEMA') do |result|
23
+ result.collect(&:first)
24
+ end
25
+ end
26
+
8
27
  def views(name = nil) #:nodoc:
9
28
  execute("SHOW FULL TABLES WHERE TABLE_TYPE='VIEW'").map { |row| row[0] }
10
29
  end
@@ -28,6 +28,119 @@ module Spectacles
28
28
  view_sql = select_value(q, name) or raise "No view called #{view} found"
29
29
  view_sql.gsub("\"", "\\\"")
30
30
  end
31
+
32
+ def supports_materialized_views?
33
+ true
34
+ end
35
+
36
+ def materialized_views(name = nil)
37
+ query = <<-SQL.squish
38
+ SELECT relname
39
+ FROM pg_class
40
+ WHERE relnamespace IN (
41
+ SELECT oid
42
+ FROM pg_namespace
43
+ WHERE nspname = ANY(current_schemas(false)))
44
+ AND relkind = 'm';
45
+ SQL
46
+
47
+ execute(query, name).map { |row| row['relname'] }
48
+ end
49
+
50
+ # Returns a tuple [string, hash], where string is the query used
51
+ # to construct the view, and hash contains the options given when
52
+ # the view was created.
53
+ def materialized_view_build_query(view, name = nil)
54
+ result = execute <<-SQL.squish, name
55
+ SELECT a.reloptions, b.tablespace, b.ispopulated, b.definition
56
+ FROM pg_class a, pg_matviews b
57
+ WHERE a.relname=#{quote(view)}
58
+ AND b.matviewname=a.relname
59
+ SQL
60
+
61
+ row = result[0]
62
+
63
+ storage = row["reloptions"]
64
+ tablespace = row["tablespace"]
65
+ ispopulated = row["ispopulated"]
66
+ definition = row["definition"].strip.sub(/;$/, "")
67
+
68
+ options = {}
69
+ options[:data] = false if ispopulated == 'f'
70
+ options[:storage] = parse_storage_definition(storage) if storage.present?
71
+ options[:tablespace] = tablespace if tablespace.present?
72
+
73
+ [definition, options]
74
+ end
75
+
76
+ def create_materialized_view_statement(view_name, query, options={})
77
+ columns = if options[:columns]
78
+ "(" + options[:columns].map { |c| quote_column_name(c) }.join(",") + ")"
79
+ else
80
+ ""
81
+ end
82
+
83
+ storage = if options[:storage] && options[:storage].any?
84
+ "WITH (" + options[:storage].map { |key, value| "#{key}=#{value}" }.join(", ") + ")"
85
+ else
86
+ ""
87
+ end
88
+
89
+ tablespace = if options[:tablespace]
90
+ "TABLESPACE #{quote_table_name(options[:tablespace])}"
91
+ else
92
+ ""
93
+ end
94
+
95
+ with_data = if options.fetch(:data, true)
96
+ "WITH DATA"
97
+ else
98
+ "WITH NO DATA"
99
+ end
100
+
101
+ <<-SQL.squish
102
+ CREATE MATERIALIZED VIEW #{quote_table_name(view_name)}
103
+ #{columns}
104
+ #{storage}
105
+ #{tablespace}
106
+ AS #{query}
107
+ #{with_data}
108
+ SQL
109
+ end
110
+
111
+ def create_materialized_view(view_name, *args)
112
+ options = args.extract_options!
113
+ build_query = args.shift
114
+
115
+ raise "#create_materialized_view requires a query or block" if build_query.nil? && !block_given?
116
+
117
+ build_query = yield if block_given?
118
+ build_query = build_query.to_sql if build_query.respond_to?(:to_sql)
119
+
120
+ if options[:force] && materialized_view_exists?(view_name)
121
+ drop_materialized_view(view_name)
122
+ end
123
+
124
+ query = create_materialized_view_statement(view_name, build_query, options)
125
+ execute(query)
126
+ end
127
+
128
+ def drop_materialized_view(view_name)
129
+ execute "DROP MATERIALIZED VIEW IF EXISTS #{quote_table_name(view_name)}"
130
+ end
131
+
132
+ def refresh_materialized_view(view_name)
133
+ execute "REFRESH MATERIALIZED VIEW #{quote_table_name(view_name)}"
134
+ end
135
+
136
+ def parse_storage_definition(storage)
137
+ storage = storage.gsub(/^{|}$/, "")
138
+ storage.split(/,/).inject({}) do |hash, item|
139
+ key, value = item.strip.split(/=/)
140
+ hash[key.to_sym] = value
141
+ hash
142
+ end
143
+ end
31
144
  end
32
145
  end
33
146
  end
@@ -5,6 +5,21 @@ module Spectacles
5
5
  module SQLite3Adapter
6
6
  include Spectacles::SchemaStatements::AbstractAdapter
7
7
 
8
+ # overrides the #tables method from ActiveRecord's SQLite3Adapter
9
+ # to return only tables, and not views.
10
+ def tables(name = nil, table_name = nil)
11
+ sql = <<-SQL
12
+ SELECT name
13
+ FROM sqlite_master
14
+ WHERE type = 'table' AND NOT name = 'sqlite_sequence'
15
+ SQL
16
+ sql << " AND name = #{quote_table_name(table_name)}" if table_name
17
+
18
+ exec_query(sql, 'SCHEMA').map do |row|
19
+ row['name']
20
+ end
21
+ end
22
+
8
23
  def generate_view_query(*columns)
9
24
  sql = <<-SQL
10
25
  SELECT #{columns.join(',')}
@@ -1,3 +1,3 @@
1
1
  module Spectacles
2
- VERSION = "0.5.3"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -15,15 +15,54 @@ describe "Spectacles::SchemaStatements::PostgreSQLAdapter" do
15
15
  it_behaves_like "an adapter", "PostgreSQLAdapter"
16
16
  it_behaves_like "a view model"
17
17
 
18
- describe "#view_build_query" do
19
- test_base = Class.new do
20
- extend Spectacles::SchemaStatements::PostgreSQLAdapter
21
- def self.schema_search_path; ""; end
22
- def self.select_value(_, _); "\"products\""; end;
23
- end
18
+ test_base = Class.new do
19
+ extend Spectacles::SchemaStatements::PostgreSQLAdapter
20
+ def self.schema_search_path; ""; end
21
+ def self.select_value(_, _); "\"products\""; end;
22
+ def self.quote_table_name(name); name; end
23
+ def self.quote_column_name(name); name; end
24
+ end
24
25
 
26
+ describe "#view_build_query" do
25
27
  it "should escape double-quotes returned by Postgres" do
26
28
  test_base.view_build_query(:new_product_users).must_match(/\\"/)
27
29
  end
28
30
  end
31
+
32
+ describe "#materialized_views" do
33
+ it "should support materialized views" do
34
+ test_base.supports_materialized_views?.must_equal true
35
+ end
36
+ end
37
+
38
+ describe "#create_materialized_view_statement" do
39
+ it "should work with no options" do
40
+ query = test_base.create_materialized_view_statement(:view_name, "select_query_here")
41
+ query.must_match(/create materialized view view_name as select_query_here with data/i)
42
+ end
43
+
44
+ it "should allow column names to be specified" do
45
+ query = test_base.create_materialized_view_statement(:view_name, "select_query_here",
46
+ columns: %i(first second third))
47
+ query.must_match(/create materialized view view_name \(first,second,third\) as select_query_here with data/i)
48
+ end
49
+
50
+ it "should allow storage parameters to be specified" do
51
+ query = test_base.create_materialized_view_statement(:view_name, "select_query_here",
52
+ storage: { bats_in_belfry: true, max_wingspan: 15 })
53
+ query.must_match(/create materialized view view_name with \(bats_in_belfry=true, max_wingspan=15\) as select_query_here with data/i)
54
+ end
55
+
56
+ it "should allow tablespace to be specified" do
57
+ query = test_base.create_materialized_view_statement(:view_name, "select_query_here",
58
+ tablespace: :the_final_frontier)
59
+ query.must_match(/create materialized view view_name tablespace the_final_frontier as select_query_here with data/i)
60
+ end
61
+
62
+ it "should allow empty view to be created" do
63
+ query = test_base.create_materialized_view_statement(:view_name, "select_query_here",
64
+ data: false)
65
+ query.must_match(/create materialized view view_name as select_query_here with no data/i)
66
+ end
67
+ end
29
68
  end
@@ -3,6 +3,17 @@ require 'spec_helper'
3
3
  describe Spectacles::SchemaStatements::AbstractAdapter do
4
4
  class TestBase
5
5
  extend Spectacles::SchemaStatements::AbstractAdapter
6
+
7
+ def self.materialized_views
8
+ @materialized_views || super
9
+ end
10
+
11
+ def self.with_materialized_views(list)
12
+ @materialized_views = list
13
+ yield
14
+ ensure
15
+ @materialized_views = nil
16
+ end
6
17
  end
7
18
 
8
19
  describe "#create_view" do
@@ -16,4 +27,55 @@ describe Spectacles::SchemaStatements::AbstractAdapter do
16
27
  lambda { TestBase.views }.must_raise(RuntimeError)
17
28
  end
18
29
  end
30
+
31
+ describe "#supports_materialized_views?" do
32
+ it "returns false when accessed on AbstractAdapter" do
33
+ TestBase.supports_materialized_views?.must_equal false
34
+ end
35
+ end
36
+
37
+ describe "#materialized_views" do
38
+ it "throws error when accessed on AbstractAdapter" do
39
+ lambda { TestBase.materialized_views }.must_raise(NotImplementedError)
40
+ end
41
+ end
42
+
43
+ describe "#materialized_view_exists?" do
44
+ it "is true when materialized_views includes the view" do
45
+ TestBase.with_materialized_views(%w(alpha beta gamma)) do
46
+ TestBase.materialized_view_exists?(:beta).must_equal true
47
+ end
48
+ end
49
+
50
+ it "is false when materialized_views does not include the view" do
51
+ TestBase.with_materialized_views(%w(alpha beta gamma)) do
52
+ TestBase.materialized_view_exists?(:delta).must_equal false
53
+ end
54
+ end
55
+ end
56
+
57
+ describe "#materialized_view_build_query" do
58
+ it "throws error when accessed on AbstractAdapter" do
59
+ lambda { TestBase.materialized_view_build_query(:books) }.must_raise(NotImplementedError)
60
+ end
61
+ end
62
+
63
+ describe "#create_materialized_view" do
64
+ it "throws error when accessed on AbstractAdapter" do
65
+ lambda { TestBase.create_materialized_view(:books) }.must_raise(NotImplementedError)
66
+ end
67
+ end
68
+
69
+ describe "#drop_materialized_view" do
70
+ it "throws error when accessed on AbstractAdapter" do
71
+ lambda { TestBase.drop_materialized_view(:books) }.must_raise(NotImplementedError)
72
+ end
73
+ end
74
+
75
+ describe "#refresh_materialized_view" do
76
+ it "throws error when accessed on AbstractAdapter" do
77
+ lambda { TestBase.refresh_materialized_view(:books) }.must_raise(NotImplementedError)
78
+ end
79
+ end
80
+
19
81
  end
@@ -12,9 +12,8 @@ module MiniTest::Spec::SharedExamples
12
12
  end
13
13
 
14
14
  def it_behaves_like(desc, *args)
15
- self.instance_eval do
16
- MiniTest::Spec.shared_examples[desc].call(*args)
17
- end
15
+ examples = MiniTest::Spec.shared_examples[desc]
16
+ instance_exec(*args, &examples)
18
17
  end
19
18
  end
20
19
 
@@ -3,6 +3,8 @@ require 'spec_helper'
3
3
  shared_examples_for "an adapter" do |adapter|
4
4
  shared_base = Class.new do
5
5
  extend Spectacles::SchemaStatements.const_get(adapter)
6
+ def self.quote_table_name(name); name; end
7
+ def self.quote_column_name(name); name; end
6
8
  def self.execute(query); query; end
7
9
  end
8
10
 
@@ -14,6 +16,18 @@ shared_examples_for "an adapter" do |adapter|
14
16
  "SELECT name AS product_name, first_name AS username FROM
15
17
  products JOIN users ON users.id = products.user_id"
16
18
  end
19
+
20
+ if ActiveRecord::Base.connection.supports_materialized_views?
21
+ ActiveRecord::Base.connection.create_materialized_view(:materialized_product_users, force: true) do
22
+ "SELECT name AS product_name, first_name AS username FROM
23
+ products JOIN users ON users.id = products.user_id"
24
+ end
25
+
26
+ ActiveRecord::Base.connection.create_materialized_view(:empty_materialized_product_users, storage: { fillfactor: 50 }, data: false, force: true) do
27
+ "SELECT name AS product_name, first_name AS username FROM
28
+ products JOIN users ON users.id = products.user_id"
29
+ end
30
+ end
17
31
  end
18
32
 
19
33
  it "should return create_view in dump stream" do
@@ -22,10 +36,31 @@ shared_examples_for "an adapter" do |adapter|
22
36
  stream.string.must_match(/create_view/)
23
37
  end
24
38
 
25
- it "should return create_view in dump stream" do
39
+ if ActiveRecord::Base.connection.supports_materialized_views?
40
+ it "should return create_materialized_view in dump stream" do
41
+ stream = StringIO.new
42
+ ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream)
43
+ stream.string.must_match(/create_materialized_view/)
44
+ end
45
+
46
+ it "should include options for create_materialized_view" do
47
+ stream = StringIO.new
48
+ ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream)
49
+ stream.string.must_match(/create_materialized_view.*fillfactor: 50/)
50
+ stream.string.must_match(/create_materialized_view.*data: false/)
51
+ end
52
+ end
53
+
54
+ it "should rebuild views in dump stream" do
26
55
  stream = StringIO.new
27
56
  ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream)
28
57
 
58
+ if ActiveRecord::Base.connection.supports_materialized_views?
59
+ ActiveRecord::Base.connection.materialized_views.each do |view|
60
+ ActiveRecord::Base.connection.drop_materialized_view(view)
61
+ end
62
+ end
63
+
29
64
  ActiveRecord::Base.connection.views.each do |view|
30
65
  ActiveRecord::Base.connection.drop_view(view)
31
66
  end
@@ -35,8 +70,12 @@ shared_examples_for "an adapter" do |adapter|
35
70
  end
36
71
 
37
72
  eval(stream.string)
38
-
73
+
39
74
  ActiveRecord::Base.connection.views.must_include('new_product_users')
75
+
76
+ if ActiveRecord::Base.connection.supports_materialized_views?
77
+ ActiveRecord::Base.connection.materialized_views.must_include('materialized_product_users')
78
+ end
40
79
  end
41
80
  end
42
81
 
@@ -95,4 +134,82 @@ shared_examples_for "an adapter" do |adapter|
95
134
  end
96
135
  end
97
136
  end
137
+
138
+ if shared_base.supports_materialized_views?
139
+ describe "#create_materialized_view" do
140
+ let(:view_name) { :view_name }
141
+
142
+ it "throws error when block not given and no build_query" do
143
+ lambda { shared_base.create_materialized_view(view_name) }.must_raise(RuntimeError)
144
+ end
145
+
146
+ describe "view_name" do
147
+ it "takes a symbol as the view_name" do
148
+ shared_base.create_materialized_view(view_name.to_sym, Product.all).must_match(/#{view_name}/)
149
+ end
150
+
151
+ it "takes a string as the view_name" do
152
+ shared_base.create_materialized_view(view_name.to_s, Product.all).must_match(/#{view_name}/)
153
+ end
154
+ end
155
+
156
+ describe "build_query" do
157
+ it "uses a string if passed" do
158
+ select_statement = "SELECT * FROM products"
159
+ shared_base.create_materialized_view(view_name, select_statement).must_match(/#{Regexp.escape(select_statement)}/)
160
+ end
161
+
162
+ it "uses an Arel::Relation if passed" do
163
+ select_statement = Product.all.to_sql
164
+ shared_base.create_materialized_view(view_name, Product.all).must_match(/#{Regexp.escape(select_statement)}/)
165
+ end
166
+ end
167
+
168
+ describe "block" do
169
+ it "can use an Arel::Relation from the yield" do
170
+ select_statement = Product.all.to_sql
171
+ shared_base.create_materialized_view(view_name) { Product.all }.must_match(/#{Regexp.escape(select_statement)}/)
172
+ end
173
+
174
+ it "can use a String from the yield" do
175
+ select_statement = "SELECT * FROM products"
176
+ shared_base.create_materialized_view(view_name) { "SELECT * FROM products" }.must_match(/#{Regexp.escape(select_statement)}/)
177
+ end
178
+ end
179
+ end
180
+
181
+ describe "#drop_materialized_view" do
182
+ let(:view_name) { :view_name }
183
+
184
+ describe "view_name" do
185
+ it "takes a symbol as the view_name" do
186
+ shared_base.drop_materialized_view(view_name.to_sym).must_match(/#{view_name}/)
187
+ end
188
+
189
+ it "takes a string as the view_name" do
190
+ shared_base.drop_materialized_view(view_name.to_s).must_match(/#{view_name}/)
191
+ end
192
+ end
193
+ end
194
+
195
+ describe "#refresh_materialized_view" do
196
+ let(:view_name) { :view_name }
197
+
198
+ describe "view_name" do
199
+ it "takes a symbol as the view_name" do
200
+ shared_base.refresh_materialized_view(view_name.to_sym).must_match(/#{view_name}/)
201
+ end
202
+
203
+ it "takes a string as the view_name" do
204
+ shared_base.refresh_materialized_view(view_name.to_s).must_match(/#{view_name}/)
205
+ end
206
+ end
207
+ end
208
+ else
209
+ describe "#materialized_views" do
210
+ it "should not be supported by #{adapter}" do
211
+ lambda { shared_base.materialized_views }.must_raise(NotImplementedError)
212
+ end
213
+ end
214
+ end
98
215
  end
@@ -28,4 +28,33 @@ shared_examples_for "a view model" do
28
28
  end
29
29
  end
30
30
  end
31
+
32
+ if ActiveRecord::Base.connection.supports_materialized_views?
33
+ ActiveRecord::Base.connection.create_materialized_view(:materialized_product_users) do
34
+ "SELECT name AS product_name, first_name AS username FROM
35
+ products JOIN users ON users.id = products.user_id"
36
+ end
37
+
38
+ class MaterializedProductUser < Spectacles::MaterializedView
39
+ scope :duck_lovers, lambda { where(:product_name => 'Rubber Duck') }
40
+ end
41
+
42
+ describe "Spectacles::MaterializedView" do
43
+ before(:each) do
44
+ User.delete_all
45
+ Product.delete_all
46
+ @john = User.create(:first_name => 'John', :last_name => 'Doe')
47
+ @duck = @john.products.create(:name => 'Rubber Duck', :value => 10)
48
+ MaterializedProductUser.refresh!
49
+ end
50
+
51
+ it "can has scopes" do
52
+ MaterializedProductUser.duck_lovers.load.first.username.must_be @john.first_name
53
+ end
54
+
55
+ it "is readonly" do
56
+ MaterializedProductUser.first.readonly?.must_be true
57
+ end
58
+ end
59
+ end
31
60
  end
@@ -21,6 +21,7 @@ Gem::Specification.new do |gem|
21
21
  ##
22
22
  # Dependencies
23
23
  #
24
+ gem.required_ruby_version = ">= 2.0.0"
24
25
  gem.add_dependency "activerecord", ">= 3.2.0"
25
26
  gem.add_dependency "activesupport", ">= 3.2.0"
26
27
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spectacles
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Hutchison, Brandon Dewitt
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-03-16 00:00:00.000000000 Z
11
+ date: 2015-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -81,6 +81,7 @@ files:
81
81
  - Readme.rdoc
82
82
  - lib/spectacles.rb
83
83
  - lib/spectacles/configuration.rb
84
+ - lib/spectacles/materialized_view.rb
84
85
  - lib/spectacles/railtie.rb
85
86
  - lib/spectacles/schema_dumper.rb
86
87
  - lib/spectacles/schema_statements.rb
@@ -89,7 +90,6 @@ files:
89
90
  - lib/spectacles/schema_statements/mysql_adapter.rb
90
91
  - lib/spectacles/schema_statements/postgresql_adapter.rb
91
92
  - lib/spectacles/schema_statements/sqlite3_adapter.rb
92
- - lib/spectacles/schema_statements/sqlite_adapter.rb
93
93
  - lib/spectacles/schema_statements/sqlserver_adapter.rb
94
94
  - lib/spectacles/schema_statements/vertica_adapter.rb
95
95
  - lib/spectacles/version.rb
@@ -98,7 +98,6 @@ files:
98
98
  - specs/adapters/mysql_adapter_spec.rb
99
99
  - specs/adapters/postgresql_adapter_spec.rb
100
100
  - specs/adapters/sqlite3_adapter_spec.rb
101
- - specs/adapters/sqlite_adapter_spec.rb
102
101
  - specs/spec_helper.rb
103
102
  - specs/spectacles/schema_statements/abstract_adapter_spec.rb
104
103
  - specs/spectacles/view_spec.rb
@@ -119,7 +118,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
119
118
  requirements:
120
119
  - - ">="
121
120
  - !ruby/object:Gem::Version
122
- version: '0'
121
+ version: 2.0.0
123
122
  required_rubygems_version: !ruby/object:Gem::Requirement
124
123
  requirements:
125
124
  - - ">="
@@ -133,3 +132,4 @@ specification_version: 4
133
132
  summary: Spectacles (derived from RailsSQLViews) adds database view functionality
134
133
  to ActiveRecord.
135
134
  test_files: []
135
+ has_rdoc:
@@ -1,33 +0,0 @@
1
- require 'spectacles/schema_statements/abstract_adapter'
2
-
3
- module Spectacles
4
- module SchemaStatements
5
- module SQLiteAdapter
6
- include Spectacles::SchemaStatements::AbstractAdapter
7
-
8
- def generate_view_query(*columns)
9
- sql = <<-SQL
10
- SELECT #{columns.join(',')}
11
- FROM sqlite_master
12
- WHERE type = 'view'
13
- SQL
14
- end
15
-
16
- def views #:nodoc:
17
- sql = generate_view_query(:name)
18
-
19
- exec_query(sql, "SCHEMA").map do |row|
20
- row['name']
21
- end
22
- end
23
-
24
- def view_build_query(table_name)
25
- sql = generate_view_query(:sql)
26
- sql << " AND name = #{quote_table_name(table_name)}"
27
-
28
- row = exec_query(sql, "SCHEMA").first
29
- row['sql'].gsub(/CREATE VIEW .*? AS/i, "")
30
- end
31
- end
32
- end
33
- end
@@ -1,14 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe "Spectacles::SchemaStatements::SQLiteAdapter" do
4
- File.delete(File.expand_path(File.dirname(__FILE__) + "/../test.db")) rescue nil
5
-
6
- ActiveRecord::Base.establish_connection(
7
- :adapter => "sqlite3",
8
- :database => "specs/test.db"
9
- )
10
- load_schema
11
-
12
- it_behaves_like "an adapter", "SQLiteAdapter"
13
- it_behaves_like "a view model"
14
- end