spectacles 0.5.3 → 1.0.0

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