activerecord-view 0.1.0

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