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,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arfi
|
|
4
|
+
module Commands
|
|
5
|
+
# Candidate discovery helpers for {Arfi::Commands::Functions}.
|
|
6
|
+
module FunctionsCandidates
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Collect all function candidates from generic, adapter, and (for PostgreSQL) schema directories.
|
|
10
|
+
#
|
|
11
|
+
# @private
|
|
12
|
+
# @param [Pathname] root Project root directory (Rails.root/db/functions)
|
|
13
|
+
# @param [String] adapter Database adapter name (postgresql, mysql, trilogy)
|
|
14
|
+
# @return [Array<Arfi::Commands::candidate>]
|
|
15
|
+
def collect_all_candidates(root, adapter)
|
|
16
|
+
candidates = generic_candidates(root)
|
|
17
|
+
adapter_root = root.join(adapter)
|
|
18
|
+
return candidates unless adapter_root.directory?
|
|
19
|
+
|
|
20
|
+
candidates.concat adapter_candidates(adapter_root, adapter)
|
|
21
|
+
candidates.concat collect_postgresql_schema_candidates(adapter_root, adapter) if adapter == 'postgresql'
|
|
22
|
+
candidates
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Group candidates by their schema/function key, sorted by priority within each group.
|
|
26
|
+
#
|
|
27
|
+
# @private
|
|
28
|
+
# @param [Array<Arfi::Commands::candidate>] candidates Flat list of candidates
|
|
29
|
+
# @return [Hash<String, Array<Arfi::Commands::candidate>>]
|
|
30
|
+
def group_candidates_by_key(candidates)
|
|
31
|
+
by_key = Hash.new { |h, k| h[k] = [] } # steep:ignore
|
|
32
|
+
candidates.each do |c|
|
|
33
|
+
by_key[c[:key]] << c
|
|
34
|
+
end
|
|
35
|
+
by_key.each_value { |arr| arr.sort_by! { |c| c[:priority] } }
|
|
36
|
+
by_key
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Build the final resolved rows by picking the highest-priority candidate per key.
|
|
40
|
+
#
|
|
41
|
+
# @private
|
|
42
|
+
# @param [Hash<String, Array<Arfi::Commands::candidate>>] by_key Candidates grouped by key
|
|
43
|
+
# @return [Array<Hash<Symbol, Object>>]
|
|
44
|
+
def build_resolved_rows(by_key)
|
|
45
|
+
by_key.keys.sort.flat_map do |key|
|
|
46
|
+
resolve_key_group(by_key[key])
|
|
47
|
+
end.compact
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Convert an absolute filesystem path to a project-relative path.
|
|
51
|
+
#
|
|
52
|
+
# @private
|
|
53
|
+
# @param [Pathname, String] path Absolute filesystem path
|
|
54
|
+
# @return [String] Relative path starting from Rails.root
|
|
55
|
+
def rel(path)
|
|
56
|
+
root = Rails.root.to_s
|
|
57
|
+
p = path.to_s
|
|
58
|
+
p.start_with?(root) ? p.sub(root + File::SEPARATOR, '') : p
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Collect generic function candidates from legacy root and explicit public/ directory.
|
|
62
|
+
#
|
|
63
|
+
# @private
|
|
64
|
+
# @param [Pathname] root Project root directory (Rails.root/db/functions)
|
|
65
|
+
# @return [Array<Arfi::Commands::candidate>]
|
|
66
|
+
def generic_candidates(root)
|
|
67
|
+
candidates = [] # steep:ignore
|
|
68
|
+
candidates.concat collect_candidates(glob: root.join('*.sql'), schema: DEFAULT_SCHEMA, source: 'generic',
|
|
69
|
+
origin: 'legacy', priority: 1)
|
|
70
|
+
candidates.concat collect_candidates(glob: root.join(DEFAULT_SCHEMA, '*.sql'), schema: DEFAULT_SCHEMA,
|
|
71
|
+
source: 'generic', origin: 'explicit', priority: 2)
|
|
72
|
+
candidates
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Collect function candidates from an adapter-specific directory (legacy + explicit public).
|
|
76
|
+
#
|
|
77
|
+
# @private
|
|
78
|
+
# @param [Pathname] adapter_root Adapter root directory (e.g. db/functions/postgresql)
|
|
79
|
+
# @param [String] adapter Database adapter name
|
|
80
|
+
# @return [Array<Arfi::Commands::candidate>]
|
|
81
|
+
def adapter_candidates(adapter_root, adapter)
|
|
82
|
+
candidates = [] # steep:ignore
|
|
83
|
+
candidates.concat collect_candidates(glob: adapter_root.join('*.sql'), schema: DEFAULT_SCHEMA,
|
|
84
|
+
source: adapter, origin: 'legacy', priority: 8)
|
|
85
|
+
candidates.concat collect_candidates(glob: adapter_root.join(DEFAULT_SCHEMA, '*.sql'),
|
|
86
|
+
schema: DEFAULT_SCHEMA, source: adapter, origin: 'explicit', priority: 9)
|
|
87
|
+
candidates
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Collect function candidates from PostgreSQL-specific schema subdirectories (except public).
|
|
91
|
+
#
|
|
92
|
+
# @private
|
|
93
|
+
# @param [Pathname] adapter_root PostgreSQL adapter root directory
|
|
94
|
+
# @param [String] adapter Database adapter name
|
|
95
|
+
# @return [Array<Arfi::Commands::candidate>]
|
|
96
|
+
def collect_postgresql_schema_candidates(adapter_root, adapter)
|
|
97
|
+
Dir.children(adapter_root).sort.each_with_object([]) do |child, acc|
|
|
98
|
+
next if child.start_with?('_')
|
|
99
|
+
next if child == DEFAULT_SCHEMA
|
|
100
|
+
|
|
101
|
+
dir = adapter_root.join(child)
|
|
102
|
+
next unless dir.directory?
|
|
103
|
+
|
|
104
|
+
acc.concat collect_candidates(glob: dir.join('*.sql'), schema: child, source: adapter,
|
|
105
|
+
origin: 'explicit', priority: 10)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Collect SQL file candidates matching a glob pattern, skipping underscore-prefixed files.
|
|
110
|
+
#
|
|
111
|
+
# @private
|
|
112
|
+
# @param [Pathname] glob Glob pattern to match SQL files
|
|
113
|
+
# @param [String] schema Schema name to assign to all matched files
|
|
114
|
+
# @param [String] source Source type ('generic' or adapter name)
|
|
115
|
+
# @param [String] origin Origin type ('legacy' for root level, 'explicit' for public/ subdirectory)
|
|
116
|
+
# @param [Integer] priority Numeric priority for override resolution (higher = preferred)
|
|
117
|
+
# @return [Array<Arfi::Commands::candidate>]
|
|
118
|
+
def collect_candidates(glob:, schema:, source:, origin:, priority:)
|
|
119
|
+
Dir.glob(glob.to_s).filter_map do |path|
|
|
120
|
+
base = File.basename(path)
|
|
121
|
+
next if base.start_with?('_')
|
|
122
|
+
|
|
123
|
+
key = "#{schema}/#{base}"
|
|
124
|
+
function_name = File.basename(path, '.sql')
|
|
125
|
+
{
|
|
126
|
+
key: key, schema: schema, function: function_name,
|
|
127
|
+
source: source, origin: origin, priority: priority, path: path
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arfi
|
|
4
|
+
module Commands
|
|
5
|
+
# Function file creation helpers for {Arfi::Commands::Functions}.
|
|
6
|
+
module FunctionsCreation
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Ensure required function directories exist, creating them if necessary.
|
|
10
|
+
#
|
|
11
|
+
# @private
|
|
12
|
+
# @param [String?] adapter Database adapter name (nil for generic)
|
|
13
|
+
# @param [String?] schema PostgreSQL schema name (optional)
|
|
14
|
+
# @raise [Arfi::Errors::NoFunctionsDir] If db/functions directory doesn't exist
|
|
15
|
+
# @return [void]
|
|
16
|
+
def ensure_dirs!(adapter:, schema:)
|
|
17
|
+
root = Rails.root.join(ROOT_DIR)
|
|
18
|
+
raise Arfi::Errors::NoFunctionsDir unless root.directory?
|
|
19
|
+
|
|
20
|
+
FileUtils.mkdir_p(root.join(DEFAULT_SCHEMA))
|
|
21
|
+
return if adapter.nil?
|
|
22
|
+
|
|
23
|
+
adapter_root = root.join(adapter)
|
|
24
|
+
FileUtils.mkdir_p(adapter_root)
|
|
25
|
+
FileUtils.mkdir_p(adapter_root.join(DEFAULT_SCHEMA))
|
|
26
|
+
return unless adapter == 'postgresql'
|
|
27
|
+
|
|
28
|
+
sch = schema || DEFAULT_SCHEMA
|
|
29
|
+
FileUtils.mkdir_p(adapter_root.join(sch))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Build the SQL function content, either from a custom template or from a skeleton.
|
|
33
|
+
#
|
|
34
|
+
# @private
|
|
35
|
+
# @param [String?] schema PostgreSQL schema name (optional)
|
|
36
|
+
# @param [String] function_name Function name
|
|
37
|
+
# @param [String] original_ref Original function reference as passed by the user
|
|
38
|
+
# @raise [StandardError] If adapter is unknown
|
|
39
|
+
# @return [String] SQL function body
|
|
40
|
+
def build_sql_function(schema, function_name, original_ref:)
|
|
41
|
+
return build_from_file(schema, function_name, original_ref: original_ref) if options[:template]
|
|
42
|
+
|
|
43
|
+
opt = adapter_opt
|
|
44
|
+
if opt.nil? || opt == 'postgresql'
|
|
45
|
+
build_postgresql_skeleton(schema, function_name)
|
|
46
|
+
elsif %w[mysql trilogy].include?(opt)
|
|
47
|
+
build_mysql_skeleton(function_name)
|
|
48
|
+
else
|
|
49
|
+
raise "Unknown adapter: #{opt}. Supported adapters: #{ADAPTERS.join(', ')}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Build SQL content by evaluating a user-supplied template file.
|
|
54
|
+
#
|
|
55
|
+
# @private
|
|
56
|
+
# @param [String?] schema PostgreSQL schema name (optional)
|
|
57
|
+
# @param [String] function_name Function name
|
|
58
|
+
# @param [String] original_ref Original function reference as passed by the user
|
|
59
|
+
# @return [String] Evaluated SQL content
|
|
60
|
+
def build_from_file(schema, function_name, original_ref:)
|
|
61
|
+
schema_name = resolve_schema_name(schema)
|
|
62
|
+
qualified_name = schema_name ? "#{schema_name}.#{function_name}" : function_name
|
|
63
|
+
evaluate_template(function_name, schema_name, qualified_name, original_ref)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Write the SQL function content to disk, respecting --force option.
|
|
67
|
+
#
|
|
68
|
+
# @private
|
|
69
|
+
# @param [String?] schema PostgreSQL schema name (optional)
|
|
70
|
+
# @param [String] function_name Function name
|
|
71
|
+
# @param [String] content SQL function body to write
|
|
72
|
+
# @return [void]
|
|
73
|
+
def write_file(schema, function_name, content)
|
|
74
|
+
path = canonical_path(schema, function_name)
|
|
75
|
+
if File.exist?(path) && !options[:force]
|
|
76
|
+
puts "Already exists: #{rel(path)} (use --force to overwrite)"
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
File.write(path, content.to_s)
|
|
80
|
+
puts "Created: #{rel(path)}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Delete a function file from disk, searching known locations.
|
|
84
|
+
#
|
|
85
|
+
# @private
|
|
86
|
+
# @param [String?] schema PostgreSQL schema name (optional)
|
|
87
|
+
# @param [String] function_name Function name
|
|
88
|
+
# @return [void]
|
|
89
|
+
def remove_function_file(schema, function_name)
|
|
90
|
+
candidates = function_paths(schema, function_name)
|
|
91
|
+
path = candidates.find { |p| File.exist?(p) }
|
|
92
|
+
unless path
|
|
93
|
+
puts "Not found. Looked in:\n - #{candidates.map { rel(_1) }.join("\n - ")}"
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
FileUtils.rm(path)
|
|
97
|
+
puts "Deleted: #{rel(path)}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Evaluate a user-supplied Ruby template file to produce SQL content.
|
|
101
|
+
#
|
|
102
|
+
# @private
|
|
103
|
+
# @param [String] function_name Function name variable available in the template
|
|
104
|
+
# @param [String?] schema_name Schema name variable available in the template
|
|
105
|
+
# @param [String] qualified_name Qualified function name variable available in the template
|
|
106
|
+
# @param [String] original_ref Original function reference variable available in the template
|
|
107
|
+
# @return [Object] Evaluated template result (expected to be a String)
|
|
108
|
+
def evaluate_template(function_name, schema_name, qualified_name, original_ref)
|
|
109
|
+
tpl = File.read(options[:template])
|
|
110
|
+
RubyVM::InstructionSequence.compile(<<~RUBY).eval # steep:ignore
|
|
111
|
+
index_name = #{function_name.inspect}
|
|
112
|
+
function_name = #{function_name.inspect}
|
|
113
|
+
schema_name = #{schema_name.inspect}
|
|
114
|
+
qualified_name = #{qualified_name.inspect}
|
|
115
|
+
original_ref = #{original_ref.inspect}
|
|
116
|
+
#{tpl}
|
|
117
|
+
RUBY
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Build a default PostgreSQL function skeleton.
|
|
121
|
+
#
|
|
122
|
+
# @private
|
|
123
|
+
# @param [String?] schema PostgreSQL schema name (defaults to 'public')
|
|
124
|
+
# @param [String] function_name Function name
|
|
125
|
+
# @return [String] SQL skeleton
|
|
126
|
+
def build_postgresql_skeleton(schema, function_name)
|
|
127
|
+
sch = schema || DEFAULT_SCHEMA
|
|
128
|
+
qualified = "#{sch}.#{function_name}"
|
|
129
|
+
<<~SQL
|
|
130
|
+
CREATE OR REPLACE FUNCTION #{qualified}() RETURNS TEXT[]
|
|
131
|
+
LANGUAGE SQL
|
|
132
|
+
IMMUTABLE AS
|
|
133
|
+
$$
|
|
134
|
+
-- Function body here
|
|
135
|
+
$$
|
|
136
|
+
SQL
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Build a default MySQL function skeleton.
|
|
140
|
+
#
|
|
141
|
+
# @private
|
|
142
|
+
# @param [String] function_name Function name
|
|
143
|
+
# @return [String] SQL skeleton
|
|
144
|
+
def build_mysql_skeleton(function_name)
|
|
145
|
+
<<~SQL
|
|
146
|
+
-- MySQL note: you may need to DROP FUNCTION IF EXISTS #{function_name};
|
|
147
|
+
-- and ensure your connection allows multi-statements if you include both.
|
|
148
|
+
CREATE FUNCTION #{function_name} ()
|
|
149
|
+
RETURNS return_type
|
|
150
|
+
BEGIN
|
|
151
|
+
-- Function body here
|
|
152
|
+
END;
|
|
153
|
+
SQL
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arfi
|
|
4
|
+
module Commands
|
|
5
|
+
# Shared helper methods for {Arfi::Commands::Functions}.
|
|
6
|
+
module FunctionsHelpers
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Validate that the Rails schema format is set to :ruby.
|
|
10
|
+
#
|
|
11
|
+
# @private
|
|
12
|
+
# @raise [Arfi::Errors::InvalidSchemaFormat] If schema format is not :ruby
|
|
13
|
+
# @return [void]
|
|
14
|
+
def validate_schema_format!
|
|
15
|
+
fmt =
|
|
16
|
+
if defined?(Rails) && Rails.application
|
|
17
|
+
Rails.application.config.active_record.schema_format
|
|
18
|
+
elsif defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:schema_format)
|
|
19
|
+
ActiveRecord::Base.schema_format
|
|
20
|
+
end
|
|
21
|
+
raise Arfi::Errors::InvalidSchemaFormat unless fmt == :ruby
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Validate that the --adapter option, if provided, is one of the supported adapters.
|
|
25
|
+
#
|
|
26
|
+
# @private
|
|
27
|
+
# @raise [Arfi::Errors::AdapterNotSupported] If adapter is not in the supported list
|
|
28
|
+
# @return [void]
|
|
29
|
+
def validate_adapter_option!
|
|
30
|
+
opt = adapter_opt
|
|
31
|
+
return if opt.nil?
|
|
32
|
+
raise Arfi::Errors::AdapterNotSupported unless ADAPTERS.map(&:to_s).include?(opt)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Parse a function reference into an optional schema and function name.
|
|
36
|
+
#
|
|
37
|
+
# Accepts 'schema.function_name' or just 'function_name' form.
|
|
38
|
+
#
|
|
39
|
+
# @private
|
|
40
|
+
# @param [String] ref Function reference string
|
|
41
|
+
# @return [[ ::String?, ::String ]] Array of [schema, function_name]
|
|
42
|
+
def parse_function_ref(ref)
|
|
43
|
+
validate_function_ref!(ref)
|
|
44
|
+
parsed_schema, parsed_fn = ref.split('.', 2)
|
|
45
|
+
if parsed_fn.nil?
|
|
46
|
+
parsed_fn = parsed_schema
|
|
47
|
+
parsed_schema = nil
|
|
48
|
+
end
|
|
49
|
+
check_schema_conflict!(parsed_schema)
|
|
50
|
+
schema = schema_opt || parsed_schema
|
|
51
|
+
[schema, parsed_fn || '']
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Validate that schema and function name match the allowed identifier pattern.
|
|
55
|
+
#
|
|
56
|
+
# Schema-qualified functions are only allowed for PostgreSQL adapter.
|
|
57
|
+
#
|
|
58
|
+
# @private
|
|
59
|
+
# @param [String?] schema Schema name to validate (optional)
|
|
60
|
+
# @param [String] function_name Function name to validate
|
|
61
|
+
# @raise [ArgumentError] If identifiers are invalid
|
|
62
|
+
# @return [void]
|
|
63
|
+
def validate_identifiers!(schema, function_name)
|
|
64
|
+
raise ArgumentError, "Invalid function name: #{function_name.inspect}" unless IDENT.match?(function_name)
|
|
65
|
+
return if schema.nil?
|
|
66
|
+
raise ArgumentError, "Invalid schema name: #{schema.inspect}" unless IDENT.match?(schema)
|
|
67
|
+
return unless adapter_opt && adapter_opt != 'postgresql'
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, 'Schema-qualified functions are only supported for PostgreSQL (adapter=postgresql).'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Validate that a function reference is a non-empty string without path separators.
|
|
73
|
+
#
|
|
74
|
+
# @private
|
|
75
|
+
# @param [String] ref Function reference string
|
|
76
|
+
# @raise [ArgumentError] If reference is invalid
|
|
77
|
+
# @return [void]
|
|
78
|
+
def validate_function_ref!(ref)
|
|
79
|
+
raise ArgumentError, "Invalid function name: #{ref.inspect}" unless ref.is_a?(String)
|
|
80
|
+
|
|
81
|
+
sep = [File::SEPARATOR, File::ALT_SEPARATOR].compact
|
|
82
|
+
bad = ref.empty? || ref.include?('..') || sep.any? { |s| ref.include?(s) }
|
|
83
|
+
raise ArgumentError, "Invalid function name: #{ref.inspect}" if bad
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Raise if the schema is specified both inline and via the --schema option.
|
|
87
|
+
#
|
|
88
|
+
# @private
|
|
89
|
+
# @param [String?] parsed_schema Schema parsed from 'schema.function' form
|
|
90
|
+
# @raise [ArgumentError] If schema is specified twice
|
|
91
|
+
# @return [void]
|
|
92
|
+
def check_schema_conflict!(parsed_schema)
|
|
93
|
+
return unless schema_opt && parsed_schema
|
|
94
|
+
|
|
95
|
+
raise ArgumentError, "Schema specified twice (both 'schema.fn' and --schema). Pick one."
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Resolve the effective schema name, defaulting to 'public' for PostgreSQL/generic.
|
|
99
|
+
#
|
|
100
|
+
# @private
|
|
101
|
+
# @param [String?] schema User-provided schema name (optional)
|
|
102
|
+
# @return [String?] Resolved schema name or nil for MySQL/Trilogy
|
|
103
|
+
def resolve_schema_name(schema)
|
|
104
|
+
adapter = adapter_opt
|
|
105
|
+
adapter.nil? || adapter == 'postgresql' ? (schema || DEFAULT_SCHEMA) : nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Resolve the effective adapter from CLI option or Rails DB config.
|
|
109
|
+
#
|
|
110
|
+
# @private
|
|
111
|
+
# @raise [ArgumentError] If adapter cannot be inferred
|
|
112
|
+
# @return [String] Resolved adapter name
|
|
113
|
+
def resolve_adapter
|
|
114
|
+
adapter_opt || infer_adapter_from_config || raise(
|
|
115
|
+
ArgumentError,
|
|
116
|
+
'Could not infer adapter. Pass --adapter=[postgresql|mysql|trilogy].'
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Resolve the effective list of function files for a given adapter.
|
|
121
|
+
#
|
|
122
|
+
# @private
|
|
123
|
+
# @param [String] adapter Adapter name (postgresql, mysql, trilogy)
|
|
124
|
+
# @raise [Arfi::Errors::NoFunctionsDir] If db/functions directory doesn't exist
|
|
125
|
+
# @return [Array<Hash<Symbol, Object>>] Resolved function rows
|
|
126
|
+
def resolve_functions_for(adapter:)
|
|
127
|
+
root = Rails.root.join(ROOT_DIR)
|
|
128
|
+
raise Arfi::Errors::NoFunctionsDir unless root.directory?
|
|
129
|
+
|
|
130
|
+
candidates = collect_all_candidates(root, adapter)
|
|
131
|
+
by_key = group_candidates_by_key(candidates)
|
|
132
|
+
build_resolved_rows(by_key)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Read the --adapter option value from CLI options.
|
|
136
|
+
#
|
|
137
|
+
# @private
|
|
138
|
+
# @return [String?] Adapter name or nil if not provided
|
|
139
|
+
def adapter_opt
|
|
140
|
+
options[:adapter]&.to_s
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Infer the database adapter from the Rails primary DB configuration.
|
|
144
|
+
#
|
|
145
|
+
# @private
|
|
146
|
+
# @raise [StandardError]
|
|
147
|
+
# @return [String?] Adapter name, or nil if inference fails
|
|
148
|
+
# @return [nil] if StandardError
|
|
149
|
+
def infer_adapter_from_config
|
|
150
|
+
cfg = primary_db_config
|
|
151
|
+
h = cfg&.configuration_hash
|
|
152
|
+
adapter = h && (h[:adapter] || h['adapter'])
|
|
153
|
+
adapter&.to_s
|
|
154
|
+
rescue StandardError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Read the --schema option value from CLI options.
|
|
159
|
+
#
|
|
160
|
+
# @private
|
|
161
|
+
# @return [String?] Schema name or nil if not provided
|
|
162
|
+
def schema_opt
|
|
163
|
+
options[:schema]&.to_s
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Get the primary database configuration for the current Rails environment.
|
|
167
|
+
#
|
|
168
|
+
# @private
|
|
169
|
+
# @return [Object] ActiveRecord database config object
|
|
170
|
+
def primary_db_config
|
|
171
|
+
cfgs =
|
|
172
|
+
if ActiveRecord::Base.respond_to?(:configurations) && ActiveRecord::Base.configurations
|
|
173
|
+
ActiveRecord::Base.configurations.configurations.select { _1.env_name == Rails.env }
|
|
174
|
+
else
|
|
175
|
+
[] # steep:ignore
|
|
176
|
+
end
|
|
177
|
+
cfgs.find { _1.name == 'primary' } || cfgs.first
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arfi
|
|
4
|
+
module Commands
|
|
5
|
+
# Path resolution helpers for {Arfi::Commands::Functions}.
|
|
6
|
+
module FunctionsPaths
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Resolve the canonical output path for a function file, respecting adapter option.
|
|
10
|
+
#
|
|
11
|
+
# @private
|
|
12
|
+
# @param [String?] schema Schema name (optional)
|
|
13
|
+
# @param [String] function_name Function name
|
|
14
|
+
# @return [String] Absolute path to the function file
|
|
15
|
+
def canonical_path(schema, function_name)
|
|
16
|
+
root = Rails.root.join(ROOT_DIR)
|
|
17
|
+
if adapter_opt.nil?
|
|
18
|
+
generic_canonical_path(root, schema, function_name)
|
|
19
|
+
else
|
|
20
|
+
adapter_canonical_path(root, schema, function_name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# List all possible filesystem paths where a function file might exist, respecting adapter.
|
|
25
|
+
#
|
|
26
|
+
# @private
|
|
27
|
+
# @param [String?] schema Schema name (optional)
|
|
28
|
+
# @param [String] function_name Function name
|
|
29
|
+
# @raise [Arfi::Errors::AdapterNotSupported] If adapter is not supported
|
|
30
|
+
# @return [Array<String>] List of absolute paths to check
|
|
31
|
+
def function_paths(schema, function_name)
|
|
32
|
+
root = Rails.root.join(ROOT_DIR)
|
|
33
|
+
out = case adapter_opt
|
|
34
|
+
when nil then generic_function_paths(root, function_name)
|
|
35
|
+
when 'postgresql' then postgresql_function_paths(root, schema, function_name)
|
|
36
|
+
when 'mysql', 'trilogy' then mysql_function_paths(root, function_name)
|
|
37
|
+
else raise Arfi::Errors::AdapterNotSupported
|
|
38
|
+
end
|
|
39
|
+
out.uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Build the canonical path for a generic (non-adapter) function.
|
|
43
|
+
#
|
|
44
|
+
# Generic functions can only target the 'public' schema.
|
|
45
|
+
#
|
|
46
|
+
# @private
|
|
47
|
+
# @param [Pathname] root Project root directory (Rails.root/db/functions)
|
|
48
|
+
# @param [String?] schema Schema name (must be nil or 'public')
|
|
49
|
+
# @param [String] function_name Function name
|
|
50
|
+
# @raise [ArgumentError] If schema is not public
|
|
51
|
+
# @return [String] Absolute canonical path
|
|
52
|
+
def generic_canonical_path(root, schema, function_name)
|
|
53
|
+
sch = schema || DEFAULT_SCHEMA
|
|
54
|
+
unless sch == DEFAULT_SCHEMA
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"Generic functions can only target schema '#{DEFAULT_SCHEMA}'"
|
|
57
|
+
end
|
|
58
|
+
root.join(DEFAULT_SCHEMA, "#{function_name}.sql").to_s
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Build the canonical path for an adapter-specific function.
|
|
62
|
+
#
|
|
63
|
+
# @private
|
|
64
|
+
# @param [Pathname] root Project root directory (Rails.root/db/functions)
|
|
65
|
+
# @param [String?] schema Schema name (PostgreSQL only)
|
|
66
|
+
# @param [String] function_name Function name
|
|
67
|
+
# @raise [ArgumentError] If schema is provided for non-PostgreSQL adapter
|
|
68
|
+
# @raise [Arfi::Errors::AdapterNotSupported] If adapter is not supported
|
|
69
|
+
# @return [String] Absolute canonical path
|
|
70
|
+
def adapter_canonical_path(root, schema, function_name)
|
|
71
|
+
case adapter_opt
|
|
72
|
+
when 'postgresql'
|
|
73
|
+
root.join('postgresql', schema || DEFAULT_SCHEMA, "#{function_name}.sql").to_s
|
|
74
|
+
when 'mysql', 'trilogy'
|
|
75
|
+
raise ArgumentError, 'Schema-qualified functions are only supported for PostgreSQL.' if schema
|
|
76
|
+
|
|
77
|
+
root.join('mysql', DEFAULT_SCHEMA, "#{function_name}.sql").to_s
|
|
78
|
+
else
|
|
79
|
+
raise Arfi::Errors::AdapterNotSupported
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# List file paths to check when looking for a generic function file.
|
|
84
|
+
#
|
|
85
|
+
# @private
|
|
86
|
+
# @param [Pathname] root Project root directory
|
|
87
|
+
# @param [String] function_name Function name
|
|
88
|
+
# @return [Array<String>] Ordered list of paths to check
|
|
89
|
+
def generic_function_paths(root, function_name)
|
|
90
|
+
[
|
|
91
|
+
root.join(DEFAULT_SCHEMA, "#{function_name}.sql").to_s,
|
|
92
|
+
root.join("#{function_name}.sql").to_s
|
|
93
|
+
]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# List file paths to check when looking for a PostgreSQL function file.
|
|
97
|
+
#
|
|
98
|
+
# @private
|
|
99
|
+
# @param [Pathname] root Project root directory
|
|
100
|
+
# @param [String?] schema Schema name (optional)
|
|
101
|
+
# @param [String] function_name Function name
|
|
102
|
+
# @return [Array<String>] Ordered list of paths to check
|
|
103
|
+
def postgresql_function_paths(root, schema, function_name)
|
|
104
|
+
sch = schema || DEFAULT_SCHEMA
|
|
105
|
+
out = [
|
|
106
|
+
root.join('postgresql', sch, "#{function_name}.sql").to_s,
|
|
107
|
+
root.join('postgresql', DEFAULT_SCHEMA, "#{function_name}.sql").to_s,
|
|
108
|
+
root.join(DEFAULT_SCHEMA, "#{function_name}.sql").to_s,
|
|
109
|
+
root.join("#{function_name}.sql").to_s
|
|
110
|
+
]
|
|
111
|
+
out.insert(2, root.join('postgresql', "#{function_name}.sql").to_s) if sch == DEFAULT_SCHEMA
|
|
112
|
+
out
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# List file paths to check when looking for a MySQL/Trilogy function file.
|
|
116
|
+
#
|
|
117
|
+
# @private
|
|
118
|
+
# @param [Pathname] root Project root directory
|
|
119
|
+
# @param [String] function_name Function name
|
|
120
|
+
# @return [Array<String>] Ordered list of paths to check
|
|
121
|
+
def mysql_function_paths(root, function_name)
|
|
122
|
+
[
|
|
123
|
+
root.join('mysql', DEFAULT_SCHEMA, "#{function_name}.sql").to_s,
|
|
124
|
+
root.join('mysql', "#{function_name}.sql").to_s,
|
|
125
|
+
root.join(DEFAULT_SCHEMA, "#{function_name}.sql").to_s,
|
|
126
|
+
root.join("#{function_name}.sql").to_s
|
|
127
|
+
]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|