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 +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
|