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 +4 -4
- data/Rakefile +1 -1
- data/Readme.rdoc +77 -2
- data/lib/spectacles.rb +2 -0
- data/lib/spectacles/materialized_view.rb +39 -0
- data/lib/spectacles/schema_dumper.rb +42 -0
- data/lib/spectacles/schema_statements.rb +5 -3
- data/lib/spectacles/schema_statements/abstract_adapter.rb +28 -0
- data/lib/spectacles/schema_statements/mysql_adapter.rb +20 -1
- data/lib/spectacles/schema_statements/postgresql_adapter.rb +113 -0
- data/lib/spectacles/schema_statements/sqlite3_adapter.rb +15 -0
- data/lib/spectacles/version.rb +1 -1
- data/specs/adapters/postgresql_adapter_spec.rb +45 -6
- data/specs/spectacles/schema_statements/abstract_adapter_spec.rb +62 -0
- data/specs/support/minitest_shared.rb +2 -3
- data/specs/support/schema_statement_examples.rb +119 -2
- data/specs/support/view_examples.rb +29 -0
- data/spectacles.gemspec +1 -0
- metadata +5 -5
- data/lib/spectacles/schema_statements/sqlite_adapter.rb +0 -33
- data/specs/adapters/sqlite_adapter_spec.rb +0 -14
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e7e7045b1b35b15bf40def12928244509e55b708
|
4
|
+
data.tar.gz: 6b2ddd076d9ef8b34ba7d9da13a3f090366b23c4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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, :
|
5
|
+
adapters = [ :mysql, :mysql2, :postgresql, :sqlite3 ]
|
6
6
|
task :all => [ :spectacles ] + adapters
|
7
7
|
|
8
8
|
adapters.each do |adapter|
|
data/Readme.rdoc
CHANGED
@@ -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
|
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)
|
data/lib/spectacles.rb
CHANGED
@@ -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
|
-
|
13
|
-
|
14
|
-
|
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(',')}
|
data/lib/spectacles/version.rb
CHANGED
@@ -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
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
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
|
-
|
16
|
-
|
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
|
-
|
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
|
data/spectacles.gemspec
CHANGED
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.
|
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-
|
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:
|
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
|