activerecord-view 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.travis.yml +11 -0
- data/CODE_OF_CONDUCT.md +15 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +139 -0
- data/Rakefile +12 -0
- data/activerecord-view.gemspec +36 -0
- data/bin/console +18 -0
- data/bin/setup +10 -0
- data/lib/activerecord-view.rb +1 -0
- data/lib/activerecord/view.rb +28 -0
- data/lib/activerecord/view/error.rb +19 -0
- data/lib/activerecord/view/integration.rb +38 -0
- data/lib/activerecord/view/integration/command_recorder_methods.rb +61 -0
- data/lib/activerecord/view/integration/model_methods.rb +27 -0
- data/lib/activerecord/view/integration/schema_methods.rb +178 -0
- data/lib/activerecord/view/introspection.rb +100 -0
- data/lib/activerecord/view/introspection/abstract.rb +106 -0
- data/lib/activerecord/view/introspection/mysql.rb +29 -0
- data/lib/activerecord/view/introspection/postgres.rb +47 -0
- data/lib/activerecord/view/introspection/sqlite3.rb +38 -0
- data/lib/activerecord/view/introspection/view_definition.rb +18 -0
- data/lib/activerecord/view/materialized_view_methods.rb +19 -0
- data/lib/activerecord/view/read_only.rb +39 -0
- data/lib/activerecord/view/schema.rb +10 -0
- data/lib/activerecord/view/utility.rb +12 -0
- data/lib/activerecord/view/version.rb +5 -0
- data/lib/activerecord/view/view_methods.rb +17 -0
- metadata +262 -0
@@ -0,0 +1,27 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module View
|
3
|
+
module Integration
|
4
|
+
module ModelMethods
|
5
|
+
# @return [void]
|
6
|
+
def is_view!(readonly: true, **kwargs)
|
7
|
+
include ActiveRecord::View::ViewMethods
|
8
|
+
include ActiveRecord::View::ReadOnly if readonly
|
9
|
+
end
|
10
|
+
|
11
|
+
def is_materialized_view!(**kwargs)
|
12
|
+
is_view!(**kwargs)
|
13
|
+
|
14
|
+
include ActiveRecord::View::MaterializedViewMethods
|
15
|
+
end
|
16
|
+
|
17
|
+
def materialized_view?
|
18
|
+
self < ActiveRecord::View::MaterializedViewMethods
|
19
|
+
end
|
20
|
+
|
21
|
+
def view?
|
22
|
+
self < ActiveRecord::View::ViewMethods
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module View
|
3
|
+
module Integration
|
4
|
+
module SchemaMethods
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
extend ActiveRecord::View::Utility
|
7
|
+
|
8
|
+
CREATE_VIEW_FMT = cleanup <<-SQL
|
9
|
+
CREATE%<or_replace>s VIEW %<name>s AS %<body>s
|
10
|
+
SQL
|
11
|
+
|
12
|
+
DROP_VIEW_FMT = cleanup <<-SQL
|
13
|
+
DROP VIEW%<if_exists>s %<name>s%<restriction>s
|
14
|
+
SQL
|
15
|
+
|
16
|
+
CREATE_MATERIALIZED_VIEW_FMT = cleanup <<-SQL
|
17
|
+
CREATE%<or_replace>s MATERIALIZED VIEW %<name>s AS %<body>s %<with_data>s
|
18
|
+
SQL
|
19
|
+
|
20
|
+
DROP_MATERIALIZED_VIEW_FMT = cleanup <<-SQL
|
21
|
+
DROP MATERIALIZED VIEW%<if_exists>s %<name>s%<restriction>s
|
22
|
+
SQL
|
23
|
+
|
24
|
+
MATERIALIZED_VIEW_ADAPTERS = %w[PostgreSQL PostGIS]
|
25
|
+
|
26
|
+
# Create a SQL view.
|
27
|
+
#
|
28
|
+
# @param (see #build_create_view_query)
|
29
|
+
# @return [void]
|
30
|
+
def create_view(name, body = nil, force: false, **kwargs, &block)
|
31
|
+
kwargs[:sqlite3] = !!(adapter_name =~ /sqlite/i)
|
32
|
+
|
33
|
+
drop_view(name) if force && table_exists?(name)
|
34
|
+
|
35
|
+
execute build_create_view_query(name, body, **kwargs, &block)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Drop a SQL view.
|
39
|
+
#
|
40
|
+
# @param (see #build_drop_view_query)
|
41
|
+
# @return [void]
|
42
|
+
def drop_view(name, **kwargs)
|
43
|
+
kwargs[:sqlite3] = !!(adapter_name =~ /sqlite/i)
|
44
|
+
|
45
|
+
execute build_drop_view_query(name, **kwargs)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Create a materialized view.
|
49
|
+
#
|
50
|
+
# Only valid on Postgres.
|
51
|
+
#
|
52
|
+
# @param (see #build_create_materialized_view_query)
|
53
|
+
# @return [void]
|
54
|
+
def create_materialized_view(name, body = nil, force: false, **kwargs, &block)
|
55
|
+
supports_materialized_view!
|
56
|
+
|
57
|
+
drop_materialized_view(name) if force && table_exists?(name)
|
58
|
+
|
59
|
+
execute build_create_materialized_view_query(name, body, **kwargs, &block)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Drop a materialized view.
|
63
|
+
#
|
64
|
+
# Only valid on Postgres.
|
65
|
+
#
|
66
|
+
# @param (see #build_drop_materialized_view_query)
|
67
|
+
# @return [void]
|
68
|
+
def drop_materialized_view(name, **kwargs)
|
69
|
+
supports_materialized_view!
|
70
|
+
|
71
|
+
execute build_drop_materialized_view_query(name, **kwargs)
|
72
|
+
end
|
73
|
+
|
74
|
+
protected
|
75
|
+
# @raise [ActiveRecord::View::MaterializedViewNotSupported] if unsupported
|
76
|
+
# @return [void]
|
77
|
+
def supports_materialized_view!
|
78
|
+
raise ActiveRecord::View::MaterializedViewNotSupported, adapter_name unless MATERIALIZED_VIEW_ADAPTERS.include?(adapter_name)
|
79
|
+
end
|
80
|
+
|
81
|
+
module_function
|
82
|
+
# @param [#to_s] name
|
83
|
+
# @param [String, #to_sql] body
|
84
|
+
# @param [Boolean] replace
|
85
|
+
# @param [Boolean] with_data Whether the materialized view should be automatically populated on create.
|
86
|
+
# @yieldreturn [String, #to_sql] alternatively the body can be provided in a block
|
87
|
+
# @return [String]
|
88
|
+
def build_create_materialized_view_query(name, body, force: nil, replace: false, with_data: false, &block)
|
89
|
+
options = {
|
90
|
+
or_replace: replace ? ' OR REPLACE' : '',
|
91
|
+
with_data: with_data ? 'WITH DATA' : 'WITH NO DATA',
|
92
|
+
name: quote_table_name(name)
|
93
|
+
}
|
94
|
+
|
95
|
+
options[:body] = fetch_view_body(body, &block)
|
96
|
+
|
97
|
+
sprintf CREATE_MATERIALIZED_VIEW_FMT, options
|
98
|
+
end
|
99
|
+
|
100
|
+
# @param [#to_s] name
|
101
|
+
# @param [String, #to_sql] body
|
102
|
+
# @param [Boolean] replace
|
103
|
+
# @param [Boolean] sqlite3 whether this should be built for SQLite3 (SQLite3 does not support `OR REPLACE` syntax)
|
104
|
+
# @yieldreturn [String, #to_sql] alternatively the body can be provided in a block
|
105
|
+
# @return [String]
|
106
|
+
def build_create_view_query(name, body, force: nil, replace: false, sqlite3: false, &block)
|
107
|
+
options = {
|
108
|
+
or_replace: replace ? ' OR REPLACE' : '',
|
109
|
+
name: quote_table_name(name)
|
110
|
+
}
|
111
|
+
|
112
|
+
options[:or_replace] = unless sqlite3
|
113
|
+
replace ? ' OR REPLACE' : ''
|
114
|
+
else
|
115
|
+
raise ActiveRecord::View::UnsupportedSyntax, 'SQLite3 does not support `OR REPLACE` syntax' if replace
|
116
|
+
''
|
117
|
+
end
|
118
|
+
|
119
|
+
options[:body] = fetch_view_body(body, &block)
|
120
|
+
|
121
|
+
sprintf CREATE_VIEW_FMT, options
|
122
|
+
end
|
123
|
+
|
124
|
+
# @return [String]
|
125
|
+
def build_drop_materialized_view_query(name, if_exists: false, force: false, **kwargs)
|
126
|
+
options = {
|
127
|
+
if_exists: if_exists ? ' IF EXISTS' : '',
|
128
|
+
name: quote_table_name(name)
|
129
|
+
}
|
130
|
+
|
131
|
+
options[:restriction] = force ? ' CASCADE' : ' RESTRICT'
|
132
|
+
|
133
|
+
sprintf DROP_MATERIALIZED_VIEW_FMT, options
|
134
|
+
end
|
135
|
+
|
136
|
+
# @return [String]
|
137
|
+
def build_drop_view_query(name, if_exists: false, force: false, sqlite3: false, **kwargs)
|
138
|
+
options = {
|
139
|
+
if_exists: if_exists ? ' IF EXISTS' : '',
|
140
|
+
name: quote_table_name(name)
|
141
|
+
}
|
142
|
+
|
143
|
+
options[:restriction] = unless sqlite3
|
144
|
+
force ? ' CASCADE' : ' RESTRICT'
|
145
|
+
else
|
146
|
+
''
|
147
|
+
end
|
148
|
+
|
149
|
+
sprintf DROP_VIEW_FMT, options
|
150
|
+
end
|
151
|
+
|
152
|
+
# @api private
|
153
|
+
# @param [String, #to_sql, nil] body
|
154
|
+
# @yieldreturn [String, #to_sql] alternatively the body can be provided in a block
|
155
|
+
# @return [String]
|
156
|
+
def fetch_view_body(body, &block)
|
157
|
+
body ||= yield if block_given?
|
158
|
+
|
159
|
+
raise 'Must provide body to to create a view' if body.blank?
|
160
|
+
|
161
|
+
body &&= body.to_sql if body.respond_to? :to_sql
|
162
|
+
|
163
|
+
body
|
164
|
+
end
|
165
|
+
|
166
|
+
class << self
|
167
|
+
# @api private
|
168
|
+
# Stub method for tests.
|
169
|
+
# @param [String] name
|
170
|
+
# @return [String]
|
171
|
+
def quote_table_name(name)
|
172
|
+
%["#{name}"]
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module View
|
3
|
+
module Introspection
|
4
|
+
# @overload definition_for(name, connection:)
|
5
|
+
# @param [String] name
|
6
|
+
# @param [ActiveRecord::ConnectionAdapters::AbstractAdapter] connection
|
7
|
+
# @overload definition_for(model, connection: nil)
|
8
|
+
# @param [#connection, ActiveRecord::View] model
|
9
|
+
# @param [ActiveRecord::ConnectionAdapters::AbstractAdapter] connection
|
10
|
+
# @return [String]
|
11
|
+
def definition_for(model_or_name, connection: nil, raise_error: false)
|
12
|
+
view_name, connection = detect_view_and_connection(model_or_name, connection)
|
13
|
+
|
14
|
+
introspector = introspector_for(connection, raise_error: raise_error)
|
15
|
+
|
16
|
+
introspector.definition_for view_name
|
17
|
+
end
|
18
|
+
|
19
|
+
# @param [Class] connection
|
20
|
+
# @return [ActiveRecord::View::Introspection::Abstract]
|
21
|
+
def introspector_for(connection, raise_error: false)
|
22
|
+
connection = connection.connection if connection.respond_to?(:connection)
|
23
|
+
|
24
|
+
klass = case connection.adapter_name
|
25
|
+
when /mysql/i then 'MySQL'
|
26
|
+
when /postg(res|gis)/i then 'Postgres'
|
27
|
+
when /sqlite/i then 'SQLite3'
|
28
|
+
else
|
29
|
+
raise ActiveRecord::View::UnsupportedDatabase, "`#{connection.adapter_name}` is not a supported adapter"
|
30
|
+
end
|
31
|
+
|
32
|
+
"ActiveRecord::View::Introspection::#{klass}".constantize.new connection: connection, raise_error: raise_error
|
33
|
+
end
|
34
|
+
|
35
|
+
# @api private
|
36
|
+
# @param [Class, Arel::Table, String] model_or_name
|
37
|
+
# @param [ActiveRecord::ConnectionAdapters::AbstractAdapter, nil] connection
|
38
|
+
# @return [(String, ActiveRecord::ConnectionAdapters::AbstractAdapter)]
|
39
|
+
def detect_view_and_connection(model_or_name, connection)
|
40
|
+
view_name = case model_or_name
|
41
|
+
when String, Symbol
|
42
|
+
model_or_name.to_s
|
43
|
+
when IS_MODEL
|
44
|
+
model_or_name.table_name
|
45
|
+
when Arel::Table
|
46
|
+
model_or_name.name
|
47
|
+
end
|
48
|
+
|
49
|
+
connection = coerce_connection(connection.presence || model_or_name)
|
50
|
+
|
51
|
+
raise Error, "Could not derive view name from provided: #{model_or_name.inspect}" unless view_name.present?
|
52
|
+
raise Error, "Could not derive connection from provided: #{model_or_name.inspect}" unless connection.present?
|
53
|
+
|
54
|
+
return [view_name, connection]
|
55
|
+
end
|
56
|
+
|
57
|
+
# @param [Class#connection, Arel::Table, ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
58
|
+
# @return [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
59
|
+
def coerce_connection(connectible)
|
60
|
+
connection_coercer.coerce connectible
|
61
|
+
end
|
62
|
+
|
63
|
+
def connection_coercer
|
64
|
+
@_connection_coercer ||= Connectible.new Axiom::Types::Object, default_value: nil, coercer: nil
|
65
|
+
end
|
66
|
+
|
67
|
+
# @param [#to_sql, String] body
|
68
|
+
# @yieldreturn [#to_sql, String]
|
69
|
+
# @return [#to_sql, String]
|
70
|
+
def validate_body(body = nil, &block)
|
71
|
+
body if valid_body?(body, &block)
|
72
|
+
end
|
73
|
+
|
74
|
+
def valid_body?(body)
|
75
|
+
body.is_a?(String) || body.respond_to?(:to_sql) || block_given?
|
76
|
+
end
|
77
|
+
|
78
|
+
IS_MODEL = ->(klass) { klass.is_a?(Class) && ActiveRecord::Base > klass }
|
79
|
+
|
80
|
+
# @api private
|
81
|
+
class Connectible < Virtus::Attribute
|
82
|
+
def coerce(value)
|
83
|
+
case value
|
84
|
+
when ActiveRecord::ConnectionAdapters::AbstractAdapter then value
|
85
|
+
when Arel::Table then value.engine.connection
|
86
|
+
when Dux[:connection] then value.connection
|
87
|
+
else
|
88
|
+
nil
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
require_relative './introspection/abstract'
|
94
|
+
require_relative './introspection/mysql'
|
95
|
+
require_relative './introspection/postgres'
|
96
|
+
require_relative './introspection/sqlite3'
|
97
|
+
require_relative './introspection/view_definition'
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module View
|
3
|
+
module Introspection
|
4
|
+
|
5
|
+
# @abstract
|
6
|
+
# @api private
|
7
|
+
class Abstract
|
8
|
+
extend ActiveModel::Callbacks
|
9
|
+
include Virtus.model strict: true
|
10
|
+
|
11
|
+
define_model_callbacks :fetch_view_definition
|
12
|
+
|
13
|
+
delegate :adapter_name, :current_database, :select_value, :select_all, to: :connection
|
14
|
+
|
15
|
+
attribute :connection, Connectible, default: :default_connection
|
16
|
+
attribute :raise_error, Boolean, default: false
|
17
|
+
|
18
|
+
# @abstract
|
19
|
+
# @return [<ActiveRecord::View::Introspection::ViewDefinition>]
|
20
|
+
def views
|
21
|
+
end
|
22
|
+
|
23
|
+
# @param [String] view_name
|
24
|
+
# @param [Hash] options
|
25
|
+
# @option options [Boolean] :pretty whether the SQL definition should be pretty-printed. Only works in postgres.
|
26
|
+
# @return [String]
|
27
|
+
def definition_for(view_name, **options)
|
28
|
+
run_callbacks :fetch_view_definition do
|
29
|
+
result = select_value fetch_view_definition_query(view_name, **options)
|
30
|
+
|
31
|
+
process_view_definition(result).tap do |processed|
|
32
|
+
raise ActiveRecord::View::Error, 'Empty Definition' if raise_error? && processed.blank?
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# @return [Arel::SelectManager]
|
38
|
+
def select_manager
|
39
|
+
Arel::SelectManager.new self
|
40
|
+
end
|
41
|
+
|
42
|
+
# @param [String]
|
43
|
+
# @return [String]
|
44
|
+
def process_view_definition(result)
|
45
|
+
result
|
46
|
+
end
|
47
|
+
|
48
|
+
# @api private
|
49
|
+
# @abstract
|
50
|
+
# @param [String] view_name
|
51
|
+
# @return [Arel::SelectManager]
|
52
|
+
def fetch_view_definition_query(view_name, **options)
|
53
|
+
# :nocov:
|
54
|
+
raise NotImplementedError, "Must implement for #{self.class}"
|
55
|
+
# :nocov:
|
56
|
+
end
|
57
|
+
|
58
|
+
def inspect
|
59
|
+
# :nocov:
|
60
|
+
"<#{self.class.name}(:raise_error => #{raise_error?})>"
|
61
|
+
# :nocov:
|
62
|
+
end
|
63
|
+
|
64
|
+
# Build a SQL-compliant cast statement
|
65
|
+
#
|
66
|
+
# @param [String, Arel::Nodes::Quoted] value
|
67
|
+
# @param [String, Arel::Nodes::SqlLiteral] type
|
68
|
+
# @return [Arel::Nodes::NamedFunction("CAST", [Arel::Nodes::As(Arel::Nodes::Quoted, Arel::Nodes::SqlLiteral)])]
|
69
|
+
def arel_cast(value, type, quote_value: true, literalize_type: true)
|
70
|
+
value = arel_quoted(value) if quote_value
|
71
|
+
type = Arel.sql(type) if literalize_type
|
72
|
+
|
73
|
+
as_expr = Arel::Nodes::As.new value, type
|
74
|
+
|
75
|
+
arel_fn 'CAST', as_expr
|
76
|
+
end
|
77
|
+
|
78
|
+
# Build an Arel function
|
79
|
+
#
|
80
|
+
# @return [Arel::Nodes::NamedFunction]
|
81
|
+
def arel_fn(name, *args)
|
82
|
+
Arel::Nodes::NamedFunction.new(name, args)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Build a quoted string
|
86
|
+
#
|
87
|
+
# @return [Arel::Nodes::Quoted]
|
88
|
+
def arel_quoted(value)
|
89
|
+
arel_quoted?(value) ? value : Arel::Nodes.build_quoted(value)
|
90
|
+
end
|
91
|
+
|
92
|
+
def arel_quoted?(value)
|
93
|
+
value.kind_of? Arel::Nodes::Quoted
|
94
|
+
end
|
95
|
+
|
96
|
+
# @api private
|
97
|
+
# @return [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
98
|
+
def default_connection
|
99
|
+
# :nocov:
|
100
|
+
ActiveRecord::Base.connection
|
101
|
+
# :nocov:
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module View
|
3
|
+
module Introspection
|
4
|
+
class MySQL < Abstract
|
5
|
+
def fetch_view_definition_query(view_name, **options)
|
6
|
+
schema_table.project(schema_definition).where(in_current_database.and(view_name_eq(view_name)))
|
7
|
+
end
|
8
|
+
|
9
|
+
# @!attribute [r] schema_table
|
10
|
+
# @return [Arel::Table]
|
11
|
+
def schema_table
|
12
|
+
@_schema_table ||= Arel::Table.new 'information_schema.views', self
|
13
|
+
end
|
14
|
+
|
15
|
+
def schema_definition
|
16
|
+
schema_table['view_definition']
|
17
|
+
end
|
18
|
+
|
19
|
+
def view_name_eq(name)
|
20
|
+
schema_table['table_name'].eq(name)
|
21
|
+
end
|
22
|
+
|
23
|
+
def in_current_database
|
24
|
+
schema_table['table_schema'].eq(current_database)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module View
|
3
|
+
module Introspection
|
4
|
+
class Postgres < Abstract
|
5
|
+
REGCLASS = Arel.sql 'regclass'
|
6
|
+
PG_TRUE = Arel.sql 'true'
|
7
|
+
PG_FALSE = Arel.sql 'false'
|
8
|
+
NOT_A_VIEW = Arel::Nodes.build_quoted 'Not a view'
|
9
|
+
|
10
|
+
around_fetch_view_definition :catch_undefined_table
|
11
|
+
|
12
|
+
def fetch_view_definition_query(view_name, **options)
|
13
|
+
select_manager.project nullify_if_not_view pg_get_viewdef(view_name, **options)
|
14
|
+
end
|
15
|
+
|
16
|
+
# @api private
|
17
|
+
# @return [Arel::Nodes::NamedFunction]
|
18
|
+
def pg_get_viewdef(view_name, pretty: false, **options)
|
19
|
+
pretty_bool = pretty ? PG_TRUE : PG_FALSE
|
20
|
+
|
21
|
+
arel_fn 'pg_get_viewdef', arel_cast(view_name, REGCLASS), pretty_bool
|
22
|
+
end
|
23
|
+
|
24
|
+
# @api private
|
25
|
+
# @return [Arel::Nodes::NamedFunction]
|
26
|
+
def nullify_if_not_view(expression)
|
27
|
+
arel_fn 'NULLIF', expression, NOT_A_VIEW
|
28
|
+
end
|
29
|
+
|
30
|
+
# @api private
|
31
|
+
def catch_undefined_table
|
32
|
+
yield
|
33
|
+
rescue ActiveRecord::StatementInvalid => e
|
34
|
+
if e.original_exception.kind_of? PG::UndefinedTable
|
35
|
+
raise e if raise_error?
|
36
|
+
|
37
|
+
nil
|
38
|
+
else
|
39
|
+
# :nocov:
|
40
|
+
raise e
|
41
|
+
# :nocov:
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|