activerecord-view 0.1.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 +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
|