arfi 0.5.0 → 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/.env.sample +2 -0
- data/.rspec +1 -0
- data/.rubocop.yml +8 -1
- data/.rubocop_todo.yml +12 -0
- data/CHANGELOG.md +94 -0
- data/CODE_OF_CONDUCT.md +5 -1
- data/CONTRIBUTING.md +26 -0
- data/README.md +327 -118
- data/SECURITY.md +17 -0
- data/Steepfile +1 -29
- data/compose.yml +33 -0
- data/docscribe.yml +92 -0
- data/gemfiles/rails_6_0.gemfile +11 -0
- data/gemfiles/rails_6_1.gemfile +11 -0
- data/gemfiles/rails_7_0.gemfile +9 -0
- data/gemfiles/rails_7_1.gemfile +10 -0
- data/gemfiles/rails_7_2.gemfile +10 -0
- data/gemfiles/rails_8_0.gemfile +10 -0
- data/gemfiles/rails_8_1.gemfile +10 -0
- data/lib/arfi/cli.rb +24 -9
- data/lib/arfi/commands/f_idx.rb +5 -230
- data/lib/arfi/commands/functions.rb +128 -0
- data/lib/arfi/commands/functions_candidates.rb +133 -0
- data/lib/arfi/commands/functions_creation.rb +157 -0
- data/lib/arfi/commands/functions_helpers.rb +181 -0
- data/lib/arfi/commands/functions_paths.rb +131 -0
- data/lib/arfi/commands/functions_rendering.rb +137 -0
- data/lib/arfi/commands/init.rb +88 -0
- data/lib/arfi/commands/project.rb +5 -51
- data/lib/arfi/errors.rb +15 -3
- data/lib/arfi/extensions/active_record/base.rb +33 -23
- data/lib/arfi/extensions/active_record/connection_adapters/postgresql/database_statements.rb +159 -24
- data/lib/arfi/sql_function_loader.rb +291 -88
- data/lib/arfi/tasks/db.rake +60 -18
- data/lib/arfi/version.rb +1 -1
- data/lib/arfi.rb +2 -0
- data/rbs_collection.lock.yaml +93 -61
- data/sig/compat/active_record_base_compat.rbs +17 -0
- data/sig/compat/thor_dsl.rbs +8 -0
- data/sig/lib/arfi/commands/f_idx.rbs +5 -103
- data/sig/lib/arfi/commands/functions.rbs +99 -0
- data/sig/lib/arfi/commands/functions_candidates.rbs +25 -0
- data/sig/lib/arfi/commands/functions_creation.rbs +29 -0
- data/sig/lib/arfi/commands/functions_helpers.rbs +41 -0
- data/sig/lib/arfi/commands/functions_paths.rbs +25 -0
- data/sig/lib/arfi/commands/functions_rendering.rbs +25 -0
- data/sig/lib/arfi/commands/init.rbs +22 -0
- data/sig/lib/arfi/commands/project.rbs +5 -24
- data/sig/lib/arfi/extensions/active_record/base.rbs +5 -10
- data/sig/lib/arfi/extensions/active_record/connection_adapters/postgresql/database_statements.rbs +28 -0
- data/sig/lib/arfi/sql_function_loader.rbs +45 -87
- data/sig/lib/arfi/tasks/db.rbs +3 -0
- data/sig/lib/arfi/version.rbs +1 -1
- metadata +125 -14
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arfi
|
|
4
|
+
module Commands
|
|
5
|
+
# Table rendering helpers for {Arfi::Commands::Functions}.
|
|
6
|
+
module FunctionsRendering
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Render the resolved function list in the selected format (paths, json, or table).
|
|
10
|
+
#
|
|
11
|
+
# @private
|
|
12
|
+
# @param [Array<Hash<Symbol, Object>>] rows Resolved function rows
|
|
13
|
+
# @return [void]
|
|
14
|
+
def render_list(rows)
|
|
15
|
+
case options[:format].to_s
|
|
16
|
+
when 'paths'
|
|
17
|
+
rows.each { puts rel(_1[:path]) }
|
|
18
|
+
when 'json'
|
|
19
|
+
puts JSON.pretty_generate(rows.map { |r| r.merge(path: rel(r[:path])) })
|
|
20
|
+
else
|
|
21
|
+
print_table(rows)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Print the function list as a formatted ASCII table.
|
|
26
|
+
#
|
|
27
|
+
# @private
|
|
28
|
+
# @param [Array<Hash<Symbol, Object>>] rows Resolved function rows
|
|
29
|
+
# @return [void]
|
|
30
|
+
def print_table(rows)
|
|
31
|
+
cols = table_columns
|
|
32
|
+
table = stringify_rows(rows)
|
|
33
|
+
widths = calculate_widths(cols, table)
|
|
34
|
+
puts cols.map { |c| c.ljust(widths[c]) }.join(' ')
|
|
35
|
+
puts cols.map { |c| '-' * widths[c] }.join(' ')
|
|
36
|
+
render_table_rows(table, cols, widths)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Determine which columns to display based on the --all flag.
|
|
40
|
+
#
|
|
41
|
+
# @private
|
|
42
|
+
# @return [Array<String>] List of column names
|
|
43
|
+
def table_columns
|
|
44
|
+
if options[:all]
|
|
45
|
+
%w[chosen schema function source origin priority path shadowed_by]
|
|
46
|
+
else
|
|
47
|
+
%w[schema function source origin priority path shadowed]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Convert all row values to strings for display, handling special columns.
|
|
52
|
+
#
|
|
53
|
+
# @private
|
|
54
|
+
# @param [Array<Hash<Symbol, Object>>] rows Resolved function rows
|
|
55
|
+
# @return [Array<Hash<Symbol, Object>>] Rows with string values
|
|
56
|
+
def stringify_rows(rows)
|
|
57
|
+
rows.map do |r|
|
|
58
|
+
r = r.dup
|
|
59
|
+
r[:path] = rel(r[:path])
|
|
60
|
+
r[:chosen] = r[:chosen] ? 'yes' : 'no' if r.key?(:chosen)
|
|
61
|
+
r[:shadowed] = (r[:shadowed] || []).join(', ') if r.key?(:shadowed)
|
|
62
|
+
r
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Calculate the maximum display width for each column.
|
|
67
|
+
#
|
|
68
|
+
# @private
|
|
69
|
+
# @param [Array<String>] cols Column names
|
|
70
|
+
# @param [Array<Hash<Symbol, Object>>] table Stringified table rows
|
|
71
|
+
# @return [Hash<String, Integer>] Column widths keyed by column name
|
|
72
|
+
def calculate_widths(cols, table)
|
|
73
|
+
widths = {} # steep:ignore
|
|
74
|
+
cols.each do |c|
|
|
75
|
+
widths[c] = ([c.length] + table.map { |r| r[c.to_sym].to_s.length }).max
|
|
76
|
+
end
|
|
77
|
+
widths
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Render each table row with proper column alignment.
|
|
81
|
+
#
|
|
82
|
+
# @private
|
|
83
|
+
# @param [Array<Hash<Symbol, Object>>] table Stringified table rows
|
|
84
|
+
# @param [Array<String>] cols Column names
|
|
85
|
+
# @param [Hash<String, Integer>] widths Calculated column widths
|
|
86
|
+
# @return [void]
|
|
87
|
+
def render_table_rows(table, cols, widths)
|
|
88
|
+
table.each do |r|
|
|
89
|
+
puts cols.map { |c| r[c.to_sym].to_s.ljust(widths[c]) }.join(' ')
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Resolve a group of same-key candidates to the chosen one with shadowed info.
|
|
94
|
+
#
|
|
95
|
+
# @private
|
|
96
|
+
# @param [Array<Arfi::Commands::candidate>] arr Candidates for one function key
|
|
97
|
+
# @return [Array<Hash<Symbol, Object>>] Resolved row(s) for display
|
|
98
|
+
def resolve_key_group(arr)
|
|
99
|
+
chosen = arr.max_by { |c| c[:priority] }
|
|
100
|
+
return [] unless chosen
|
|
101
|
+
|
|
102
|
+
if options[:all]
|
|
103
|
+
all_mode_rows(arr, chosen)
|
|
104
|
+
else
|
|
105
|
+
default_mode_row(chosen, arr)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Build display rows for --all mode (shows all candidates including overridden ones).
|
|
110
|
+
#
|
|
111
|
+
# @private
|
|
112
|
+
# @param [Array<Arfi::Commands::candidate>] arr All candidates for one function key
|
|
113
|
+
# @param [Arfi::Commands::candidate] chosen The selected (highest-priority) candidate
|
|
114
|
+
# @return [Array<Hash<Symbol, Object>>] Display rows
|
|
115
|
+
def all_mode_rows(arr, chosen)
|
|
116
|
+
chosen_path = chosen[:path]
|
|
117
|
+
arr.sort_by { |c| [-c[:priority], c[:schema], c[:function]] }.map do |c|
|
|
118
|
+
c.merge(
|
|
119
|
+
chosen: (c == chosen),
|
|
120
|
+
shadowed_by: (c == chosen ? nil : rel(chosen_path))
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Build a single display row for default mode (only the chosen candidate).
|
|
126
|
+
#
|
|
127
|
+
# @private
|
|
128
|
+
# @param [Arfi::Commands::candidate] chosen The selected (highest-priority) candidate
|
|
129
|
+
# @param [Array<Arfi::Commands::candidate>] arr All candidates for one function key
|
|
130
|
+
# @return [Array<Hash<Symbol, Object>>] Single display row
|
|
131
|
+
def default_mode_row(chosen, arr)
|
|
132
|
+
shadowed = (arr - [chosen])
|
|
133
|
+
[chosen.merge(chosen: true, shadowed: shadowed.map { rel(_1[:path]) })]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'rails'
|
|
6
|
+
|
|
7
|
+
module Arfi
|
|
8
|
+
module Commands
|
|
9
|
+
# Initializes ARFI directory structure inside a Rails project.
|
|
10
|
+
#
|
|
11
|
+
# Creates/ensures:
|
|
12
|
+
# - db/functions/public
|
|
13
|
+
# - db/functions/<adapter>/public (when adapter provided)
|
|
14
|
+
#
|
|
15
|
+
# @api public
|
|
16
|
+
class Init < Thor
|
|
17
|
+
ADAPTERS = %i[postgresql mysql].freeze
|
|
18
|
+
ROOT_DIR = 'db/functions'
|
|
19
|
+
DEFAULT_SCHEMA = 'public'
|
|
20
|
+
|
|
21
|
+
default_task :create
|
|
22
|
+
|
|
23
|
+
# UX aliases
|
|
24
|
+
map %w[setup] => :create
|
|
25
|
+
|
|
26
|
+
desc 'create', 'Initialize project by creating db/functions structure (explicit public schema dirs)'
|
|
27
|
+
option :adapter, type: :string,
|
|
28
|
+
desc: "Specify database adapter. Available adapters: #{ADAPTERS.join(', ')}",
|
|
29
|
+
banner: 'adapter'
|
|
30
|
+
|
|
31
|
+
# Run the full init flow: validate schema, create base dirs, and optionally create adapter dirs.
|
|
32
|
+
#
|
|
33
|
+
# @return [void]
|
|
34
|
+
def create
|
|
35
|
+
validate_schema_format!
|
|
36
|
+
create_base_dirs
|
|
37
|
+
create_adapter_dirs if options[:adapter] # steep:ignore NoMethod
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Validate that the Rails schema format is set to :ruby.
|
|
43
|
+
#
|
|
44
|
+
# @private
|
|
45
|
+
# @raise [Arfi::Errors::InvalidSchemaFormat] If schema format is not :ruby
|
|
46
|
+
# @return [void]
|
|
47
|
+
def validate_schema_format!
|
|
48
|
+
fmt =
|
|
49
|
+
if defined?(Rails) && Rails.application
|
|
50
|
+
Rails.application.config.active_record.schema_format
|
|
51
|
+
elsif defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:schema_format)
|
|
52
|
+
ActiveRecord::Base.schema_format
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
raise Arfi::Errors::InvalidSchemaFormat unless fmt == :ruby
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Create the base db/functions and db/functions/public directories.
|
|
59
|
+
#
|
|
60
|
+
# @private
|
|
61
|
+
# @return [void]
|
|
62
|
+
def create_base_dirs
|
|
63
|
+
root = Rails.root.join(ROOT_DIR)
|
|
64
|
+
FileUtils.mkdir_p(root)
|
|
65
|
+
puts "Ensured: #{root}"
|
|
66
|
+
FileUtils.mkdir_p(root.join(DEFAULT_SCHEMA))
|
|
67
|
+
puts "Ensured: #{root.join(DEFAULT_SCHEMA)}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Create the adapter-specific function directories (e.g. db/functions/postgresql/public).
|
|
71
|
+
#
|
|
72
|
+
# @private
|
|
73
|
+
# @raise [Arfi::Errors::AdapterNotSupported] If adapter is not in the supported list
|
|
74
|
+
# @return [void]
|
|
75
|
+
def create_adapter_dirs
|
|
76
|
+
adapter = options[:adapter].to_s # steep:ignore NoMethod
|
|
77
|
+
raise Arfi::Errors::AdapterNotSupported unless ADAPTERS.include?(adapter.to_sym)
|
|
78
|
+
|
|
79
|
+
root = Rails.root.join(ROOT_DIR)
|
|
80
|
+
adapter_root = root.join(adapter)
|
|
81
|
+
FileUtils.mkdir_p(adapter_root)
|
|
82
|
+
puts "Ensured: #{adapter_root}"
|
|
83
|
+
FileUtils.mkdir_p(adapter_root.join(DEFAULT_SCHEMA))
|
|
84
|
+
puts "Ensured: #{adapter_root.join(DEFAULT_SCHEMA)}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -1,59 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require 'fileutils'
|
|
5
|
-
require 'rails'
|
|
3
|
+
require_relative 'init'
|
|
6
4
|
|
|
7
5
|
module Arfi
|
|
8
6
|
module Commands
|
|
9
7
|
# +Arfi::Commands::Project+ class is used to create `db/functions` directory.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
desc 'create', 'Initialize project by creating db/functions directory'
|
|
15
|
-
option :adapter, type: :string,
|
|
16
|
-
desc: 'Specify database adapter, used for projects with multiple database architecture. ' \
|
|
17
|
-
"Available adapters: #{ADAPTERS.join(', ')}",
|
|
18
|
-
banner: 'adapter'
|
|
19
|
-
# steep:ignore:end
|
|
20
|
-
|
|
21
|
-
# +Arfi::Commands::Project#create+ -> void
|
|
22
|
-
#
|
|
23
|
-
# This command is used to create `db/functions` directory.
|
|
24
|
-
#
|
|
25
|
-
# @example
|
|
26
|
-
# bundle exec arfi project create
|
|
27
|
-
# @return [void]
|
|
28
|
-
# @raise [Arfi::Errors::InvalidSchemaFormat] if ActiveRecord.schema_format is not :ruby.
|
|
29
|
-
def create
|
|
30
|
-
raise Arfi::Errors::InvalidSchemaFormat unless ActiveRecord.schema_format == :ruby # steep:ignore NoMethod
|
|
31
|
-
return puts "Directory #{functions_dir} already exists" if Dir.exist?(functions_dir)
|
|
32
|
-
|
|
33
|
-
FileUtils.mkdir_p(functions_dir)
|
|
34
|
-
puts "Created: #{functions_dir}"
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
private
|
|
38
|
-
|
|
39
|
-
# +Arfi::Commands::Project#functions_dir+ -> Pathname
|
|
40
|
-
#
|
|
41
|
-
# Helper method to get path to `db/functions` directory.
|
|
42
|
-
#
|
|
43
|
-
# @!visibility private
|
|
44
|
-
# @private
|
|
45
|
-
# @return [Pathname] Path to `db/functions` directory
|
|
46
|
-
def functions_dir
|
|
47
|
-
# steep:ignore:start
|
|
48
|
-
if options[:adapter]
|
|
49
|
-
raise Arfi::Errors::AdapterNotSupported unless ADAPTERS.include?(options[:adapter].to_sym)
|
|
50
|
-
|
|
51
|
-
Rails.root.join("db/functions/#{options[:adapter]}")
|
|
52
|
-
# steep:ignore:end
|
|
53
|
-
else
|
|
54
|
-
Rails.root.join('db/functions')
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
8
|
+
#
|
|
9
|
+
# Backward-compatible constant for code that references Arfi::Commands::Project.
|
|
10
|
+
# @deprecated
|
|
11
|
+
Project = Init
|
|
58
12
|
end
|
|
59
13
|
end
|
data/lib/arfi/errors.rb
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Arfi
|
|
4
4
|
module Errors
|
|
5
|
-
#
|
|
5
|
+
# Raised when there is no `db/functions` directory in the Rails project.
|
|
6
6
|
class NoFunctionsDir < StandardError
|
|
7
|
+
# Initialize a new NoFunctionsDir error with an optional custom message.
|
|
8
|
+
#
|
|
9
|
+
# @param [String] message Error message
|
|
10
|
+
# @return [void]
|
|
7
11
|
def initialize(message =
|
|
8
12
|
'There is no such directory: db/functions. Did you run `bundle exec arfi project:create`?')
|
|
9
13
|
@message = message
|
|
@@ -11,16 +15,24 @@ module Arfi
|
|
|
11
15
|
end
|
|
12
16
|
end
|
|
13
17
|
|
|
14
|
-
#
|
|
18
|
+
# Raised when Rails schema format is not ruby (`schema.rb`).
|
|
15
19
|
class InvalidSchemaFormat < StandardError
|
|
20
|
+
# Initialize a new InvalidSchemaFormat error with an optional custom message.
|
|
21
|
+
#
|
|
22
|
+
# @param [String] message Error message
|
|
23
|
+
# @return [void]
|
|
16
24
|
def initialize(message = 'Invalid schema format. ARFI supports only ruby format schemas.')
|
|
17
25
|
@message = message
|
|
18
26
|
super
|
|
19
27
|
end
|
|
20
28
|
end
|
|
21
29
|
|
|
22
|
-
#
|
|
30
|
+
# Raised when the configured/selected adapter is not supported by ARFI.
|
|
23
31
|
class AdapterNotSupported < StandardError
|
|
32
|
+
# Initialize a new AdapterNotSupported error with an optional custom message.
|
|
33
|
+
#
|
|
34
|
+
# @param [String] message Error message
|
|
35
|
+
# @return [void]
|
|
24
36
|
def initialize(message = 'Adapter not supported')
|
|
25
37
|
@message = message
|
|
26
38
|
super
|
|
@@ -4,33 +4,43 @@ require 'active_record'
|
|
|
4
4
|
|
|
5
5
|
module ActiveRecord
|
|
6
6
|
class Base # :nodoc:
|
|
7
|
-
#
|
|
7
|
+
# Check if a SQL function exists in the database, dispatching to the correct adapter method.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# @
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
when ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
|
19
|
-
connection.execute("SELECT * FROM pg_proc WHERE proname = '#{function_name}'").any?
|
|
20
|
-
when ActiveRecord::ConnectionAdapters::Mysql2Adapter
|
|
21
|
-
sql = <<~SQL
|
|
22
|
-
SELECT 1
|
|
23
|
-
FROM information_schema.ROUTINES
|
|
24
|
-
WHERE ROUTINE_TYPE = 'FUNCTION'
|
|
25
|
-
AND ROUTINE_SCHEMA = '#{connection.current_database}'
|
|
26
|
-
AND ROUTINE_NAME = '#{function_name}'
|
|
27
|
-
LIMIT 1;
|
|
28
|
-
SQL
|
|
29
|
-
|
|
30
|
-
!!connection.execute(sql).first
|
|
9
|
+
# @param [String] function_name Function name to check
|
|
10
|
+
# @raise [ActiveRecord::AdapterNotFound] If adapter is not supported
|
|
11
|
+
# @return [Boolean] Whether the function exists
|
|
12
|
+
def self.function_exists?(function_name)
|
|
13
|
+
case connection.class.name
|
|
14
|
+
when 'ActiveRecord::ConnectionAdapters::PostgreSQLAdapter'
|
|
15
|
+
pg_function_exists?(function_name)
|
|
16
|
+
when 'ActiveRecord::ConnectionAdapters::Mysql2Adapter', 'ActiveRecord::ConnectionAdapters::TrilogyAdapter'
|
|
17
|
+
mysql_function_exists?(function_name)
|
|
31
18
|
else
|
|
32
19
|
raise ActiveRecord::AdapterNotFound, "adapter #{connection.class.name} is not supported"
|
|
33
20
|
end
|
|
34
21
|
end
|
|
22
|
+
|
|
23
|
+
# Check if a function exists in PostgreSQL via pg_proc catalog table.
|
|
24
|
+
#
|
|
25
|
+
# @param [String] function_name Function name to check
|
|
26
|
+
# @return [Boolean] Whether the function exists
|
|
27
|
+
def self.pg_function_exists?(function_name)
|
|
28
|
+
sql = "SELECT 1 FROM pg_proc WHERE proname = #{connection.quote(function_name)} LIMIT 1"
|
|
29
|
+
!connection.select_value(sql).nil?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Check if a function exists in MySQL/MariaDB via information_schema.ROUTINES.
|
|
33
|
+
#
|
|
34
|
+
# @param [String] function_name Function name to check
|
|
35
|
+
# @return [Boolean] Whether the function exists
|
|
36
|
+
def self.mysql_function_exists?(function_name)
|
|
37
|
+
!connection.select_value(<<~SQL).nil?
|
|
38
|
+
SELECT 1 FROM information_schema.ROUTINES
|
|
39
|
+
WHERE ROUTINE_TYPE = 'FUNCTION'
|
|
40
|
+
AND ROUTINE_SCHEMA = #{connection.quote(connection.current_database)}
|
|
41
|
+
AND ROUTINE_NAME = #{connection.quote(function_name)}
|
|
42
|
+
LIMIT 1;
|
|
43
|
+
SQL
|
|
44
|
+
end
|
|
35
45
|
end
|
|
36
46
|
end
|
data/lib/arfi/extensions/active_record/connection_adapters/postgresql/database_statements.rb
CHANGED
|
@@ -1,32 +1,167 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
3
|
+
begin
|
|
4
|
+
require "active_record/connection_adapters/postgresql_adapter"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# no postgres adapter; ok
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require "arfi/sql_function_loader"
|
|
10
|
+
|
|
11
|
+
module Arfi
|
|
12
|
+
module PostgreSQL
|
|
13
|
+
# ActiveRecord adapter patch for PostgreSQL.
|
|
14
|
+
#
|
|
15
|
+
# When a query fails with `PG::UndefinedFunction`, ARFI checks whether the missing function
|
|
16
|
+
# is managed by ARFI (file exists under db/functions). If yes, ARFI loads function SQL files
|
|
17
|
+
# via {Arfi::SqlFunctionLoader.load!} and retries the failed query once.
|
|
18
|
+
#
|
|
19
|
+
# @api public
|
|
20
|
+
module DatabaseStatementsPatch
|
|
21
|
+
ARFI_UNDEFINED_FUNCTION = /
|
|
22
|
+
function\s+([a-zA-Z0-9_."]+)\s*\(.*?\)\s+does\s+not\s+exist
|
|
23
|
+
/ix.freeze
|
|
24
|
+
|
|
25
|
+
THREAD_GUARD_KEY = :arfi_reloading_functions
|
|
26
|
+
|
|
27
|
+
# Execute a SQL query, reloading missing ARFI-managed functions and retrying on PG::UndefinedFunction.
|
|
28
|
+
#
|
|
29
|
+
# @param [Array<Object>] args Positional arguments forwarded to the original exec_query
|
|
30
|
+
# @param [Object] kwargs Keyword arguments forwarded to the original exec_query
|
|
31
|
+
# @raise [StandardError] If the error is not recoverable
|
|
32
|
+
# @return [Object] Query result
|
|
33
|
+
# @return [Object] if StandardError (retry successful)
|
|
34
|
+
def exec_query(*args, **kwargs)
|
|
35
|
+
super
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
raise unless arfi_try_reload_and_retry?(e)
|
|
38
|
+
retry
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Execute raw SQL, reloading missing ARFI-managed functions and retrying on PG::UndefinedFunction.
|
|
42
|
+
#
|
|
43
|
+
# @param [Array<Object>] args Positional arguments forwarded to the original execute
|
|
44
|
+
# @param [Object] kwargs Keyword arguments forwarded to the original execute
|
|
45
|
+
# @raise [StandardError] If the error is not recoverable
|
|
46
|
+
# @return [Object] Execution result
|
|
47
|
+
# @return [Object] if StandardError (retry successful)
|
|
48
|
+
def execute(*args, **kwargs)
|
|
49
|
+
super
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
raise unless arfi_try_reload_and_retry?(e)
|
|
52
|
+
retry
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Execute a raw SQL statement, reloading missing ARFI-managed functions and retrying on PG::UndefinedFunction.
|
|
56
|
+
#
|
|
57
|
+
# @param [Array<Object>] args Positional arguments forwarded to the original raw_execute
|
|
58
|
+
# @param [Object] kwargs Keyword arguments forwarded to the original raw_execute
|
|
59
|
+
# @raise [StandardError] If the error is not recoverable
|
|
60
|
+
# @return [Object] Execution result
|
|
61
|
+
# @return [Object] if StandardError (retry successful)
|
|
62
|
+
def raw_execute(*args, **kwargs)
|
|
63
|
+
super
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
raise unless arfi_try_reload_and_retry?(e)
|
|
66
|
+
retry
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Execute an internal query, reloading missing ARFI-managed functions and retrying on PG::UndefinedFunction.
|
|
70
|
+
#
|
|
71
|
+
# @param [Array<Object>] args Positional arguments forwarded to the original internal_exec_query
|
|
72
|
+
# @param [Object] kwargs Keyword arguments forwarded to the original internal_exec_query
|
|
73
|
+
# @raise [StandardError] If the error is not recoverable
|
|
74
|
+
# @return [Object] Query result
|
|
75
|
+
# @return [Object] if StandardError (retry successful)
|
|
76
|
+
def internal_exec_query(*args, **kwargs)
|
|
77
|
+
super
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
raise unless arfi_try_reload_and_retry?(e)
|
|
80
|
+
retry
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Check if the error is caused by a missing ARFI-managed function and attempt to reload it.
|
|
86
|
+
#
|
|
87
|
+
# Uses a thread guard to prevent recursive retries.
|
|
88
|
+
#
|
|
89
|
+
# @private
|
|
90
|
+
# @param [Object] e The raised exception (StandardError with possible PG::UndefinedFunction cause)
|
|
91
|
+
# @return [Boolean] Whether the error was handled (reload attempted)
|
|
92
|
+
def arfi_try_reload_and_retry?(e)
|
|
93
|
+
pg_error = e.cause || e
|
|
94
|
+
return false unless pg_error.class.name == "PG::UndefinedFunction"
|
|
95
|
+
return false if Thread.current[THREAD_GUARD_KEY]
|
|
96
|
+
|
|
97
|
+
schema, fn = arfi_extract_function_ident(pg_error.message)
|
|
98
|
+
return false unless arfi_has_function_file_for?(schema, fn)
|
|
99
|
+
|
|
100
|
+
Thread.current[THREAD_GUARD_KEY] = true
|
|
101
|
+
begin
|
|
102
|
+
Arfi::SqlFunctionLoader.load!(
|
|
103
|
+
task_name: "arfi:runtime",
|
|
104
|
+
connection: self, # same connection
|
|
105
|
+
clear_active_connections: false, # runtime retry path
|
|
106
|
+
verbose: false
|
|
107
|
+
)
|
|
108
|
+
ensure
|
|
109
|
+
Thread.current[THREAD_GUARD_KEY] = false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Extract the schema and function name from a PG::UndefinedFunction error message.
|
|
116
|
+
#
|
|
117
|
+
# @private
|
|
118
|
+
# @param [Object] message The error message string
|
|
119
|
+
# @return [Array<nil>, Object] Array of [schema, function_name] or [nil, nil] if not matched
|
|
120
|
+
def arfi_extract_function_ident(message)
|
|
121
|
+
m = message.to_s.match(ARFI_UNDEFINED_FUNCTION)
|
|
122
|
+
return [nil, nil] unless m
|
|
123
|
+
|
|
124
|
+
ident = m[1].to_s.delete('"')
|
|
125
|
+
parts = ident.split(".", 2)
|
|
126
|
+
parts.length == 2 ? [parts[0], parts[1]] : [nil, parts[0]]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Check whether a function file exists under db/functions for the given schema and function name.
|
|
130
|
+
#
|
|
131
|
+
# @private
|
|
132
|
+
# @param [Object] schema Schema name (possibly nil)
|
|
133
|
+
# @param [Object] fn Function name
|
|
134
|
+
# @return [Boolean, Object] Whether a matching file exists on disk
|
|
135
|
+
def arfi_has_function_file_for?(schema, fn)
|
|
136
|
+
return false if fn.nil? || fn.empty?
|
|
137
|
+
|
|
138
|
+
root = Rails.root.join("db", "functions")
|
|
139
|
+
return false unless root.directory?
|
|
140
|
+
|
|
141
|
+
candidates = []
|
|
142
|
+
candidates << root.join("public", "#{fn}.sql")
|
|
143
|
+
candidates << root.join("#{fn}.sql") # legacy generic
|
|
144
|
+
|
|
145
|
+
pg_root = root.join("postgresql")
|
|
146
|
+
if pg_root.directory?
|
|
147
|
+
candidates << pg_root.join("public", "#{fn}.sql")
|
|
148
|
+
candidates << pg_root.join("#{fn}.sql") # legacy adapter
|
|
149
|
+
|
|
150
|
+
if schema && !schema.empty?
|
|
151
|
+
candidates << pg_root.join(schema, "#{fn}.sql")
|
|
152
|
+
else
|
|
153
|
+
candidates.concat Dir.glob(pg_root.join("*", "#{fn}.sql").to_s)
|
|
27
154
|
end
|
|
28
155
|
end
|
|
156
|
+
|
|
157
|
+
candidates.any? { |p| File.exist?(p) }
|
|
29
158
|
end
|
|
30
159
|
end
|
|
31
160
|
end
|
|
32
161
|
end
|
|
162
|
+
|
|
163
|
+
if defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
|
|
164
|
+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(
|
|
165
|
+
Arfi::PostgreSQL::DatabaseStatementsPatch
|
|
166
|
+
)
|
|
167
|
+
end
|