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.
@@ -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