From 2eb98dbaac6759c5e69dacf0576f62091b1b1539 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Thu, 29 May 2025 19:49:56 +0200 Subject: [PATCH 01/17] Preliminary support for PG-compatible version --- .../postgresql/procedures.rb | 118 +++++++++++++ lib/active_record/plpgsql/base.rb | 7 + lib/active_record/plpgsql/engine.rb | 16 ++ lib/active_record/plpgsql/function_methods.rb | 158 +++++++++++++++++ lib/active_record/plpgsql/set_of.rb | 167 ++++++++++++++++++ .../plpgsql/set_of_assoc_relation.rb | 13 ++ lib/active_record/plpgsql/set_of_relation.rb | 154 ++++++++++++++++ lib/active_record/plpgsql/set_of_scope.rb | 21 +++ lib/active_record/plsql/pipelined.rb | 2 +- lib/active_record/plxsql/compat.rb | 6 + lib/active_record/plxsql/pipelined.rb | 19 ++ lib/active_record/plxsql/procedure_methods.rb | 35 ++++ lib/plpgsql/log_subscriber.rb | 46 +++++ lib/rails-plpgsql.rb | 4 + lib/rails-plsql.rb | 21 ++- lib/ruby-plpgsql.rb | 129 ++++++++++++++ rails-plsql.gemspec | 6 +- 17 files changed, 912 insertions(+), 10 deletions(-) create mode 100644 lib/active_record/connection_adapters/postgresql/procedures.rb create mode 100644 lib/active_record/plpgsql/base.rb create mode 100644 lib/active_record/plpgsql/engine.rb create mode 100644 lib/active_record/plpgsql/function_methods.rb create mode 100644 lib/active_record/plpgsql/set_of.rb create mode 100644 lib/active_record/plpgsql/set_of_assoc_relation.rb create mode 100644 lib/active_record/plpgsql/set_of_relation.rb create mode 100644 lib/active_record/plpgsql/set_of_scope.rb create mode 100644 lib/active_record/plxsql/compat.rb create mode 100644 lib/active_record/plxsql/pipelined.rb create mode 100644 lib/active_record/plxsql/procedure_methods.rb create mode 100644 lib/plpgsql/log_subscriber.rb create mode 100644 lib/rails-plpgsql.rb create mode 100644 lib/ruby-plpgsql.rb diff --git a/lib/active_record/connection_adapters/postgresql/procedures.rb b/lib/active_record/connection_adapters/postgresql/procedures.rb new file mode 100644 index 0000000..80af367 --- /dev/null +++ b/lib/active_record/connection_adapters/postgresql/procedures.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "active_support" + +module ActiveRecord + module PostgreSQLProcedures + module ClassMethods + def set_create_method(&block) + self.custom_create_method = block + end + + def set_update_method(&block) + self.custom_update_method = block + end + + def set_delete_method(&block) + self.custom_delete_method = block + end + end + + def self.included(base) + base.class_eval do + extend ClassMethods + class_attribute :custom_create_method + class_attribute :custom_update_method + class_attribute :custom_delete_method + end + end + + def destroy + if self.class.custom_delete_method + with_transaction_returning_status do + run_callbacks(:destroy) { destroy_using_custom_method } + end + else + super + end + end + + private + def _create_record + if self.class.custom_create_method + run_callbacks(:create) do + if self.record_timestamps + current_time = current_time_from_proper_timezone + + all_timestamp_attributes_in_model.each do |column| + if respond_to?(column) && respond_to?("#{column}=") && self.send(column).nil? + write_attribute(column.to_s, current_time) + end + end + end + create_using_custom_method + end + else + super + end + end + + def create_using_custom_method + log_custom_method("custom create method", "#{self.class.name} Create") do + self.id = instance_eval(&self.class.custom_create_method) + end + @new_record = false + @persisted = true + id + end + + def _update_record(attribute_names = @attributes.keys) + if self.class.custom_update_method + run_callbacks(:update) do + if should_record_timestamps? + current_time = current_time_from_proper_timezone + + timestamp_attributes_for_update_in_model.each do |column| + column = column.to_s + next if will_save_change_to_attribute?(column) + write_attribute(column, current_time) + end + end + if partial_updates? + update_using_custom_method(changed | (attributes.keys & self.class.columns.select { |column| column.is_a?(Type::Serialized) })) + else + update_using_custom_method(attributes.keys) + end + end + else + super + end + end + + def update_using_custom_method(attribute_names) + return 0 if attribute_names.empty? + log_custom_method("custom update method with #{self.class.primary_key}=#{self.id}", "#{self.class.name} Update") do + instance_eval(&self.class.custom_update_method) + end + 1 + end + + def destroy_using_custom_method + unless new_record? || @destroyed + log_custom_method("custom delete method with #{self.class.primary_key}=#{self.id}", "#{self.class.name} Destroy") do + instance_eval(&self.class.custom_delete_method) + end + end + + @destroyed = true + freeze + end + + def log_custom_method(*args, &block) + self.class.connection.send(:log, *args, &block) + end + + alias_method :update_record, :_update_record if private_method_defined?(:_update_record) + alias_method :create_record, :_create_record if private_method_defined?(:_create_record) + end +end diff --git a/lib/active_record/plpgsql/base.rb b/lib/active_record/plpgsql/base.rb new file mode 100644 index 0000000..c127f6b --- /dev/null +++ b/lib/active_record/plpgsql/base.rb @@ -0,0 +1,7 @@ +module ActiveRecord::PLSQL + class Base < ActiveRecord::Base + self.abstract_class = true + include Pipelined + include ProcedureMethods + end +end diff --git a/lib/active_record/plpgsql/engine.rb b/lib/active_record/plpgsql/engine.rb new file mode 100644 index 0000000..4d118ab --- /dev/null +++ b/lib/active_record/plpgsql/engine.rb @@ -0,0 +1,16 @@ +require 'active_record/plpgsql/set_of' +require 'active_record/plpgsql/function_methods' +require 'active_record/plpgsql/base' +require 'active_record/plpgsql/set_of_relation' +require 'active_record/plpgsql/set_of_scope' +require 'active_record/plpgsql/set_of_assoc_relation' +# require 'active_record/oracle_enhanced_adapter_patch' +require 'plpgsql/log_subscriber' + +module ActiveRecord::PLPGSQL + class Engine < ::Rails::Engine + initializer 'plpgsql.logger', after: 'active_record.logger' do + PLPGSQL::LogSubscriber.logger = ActiveRecord::Base.logger + end + end +end diff --git a/lib/active_record/plpgsql/function_methods.rb b/lib/active_record/plpgsql/function_methods.rb new file mode 100644 index 0000000..15e1322 --- /dev/null +++ b/lib/active_record/plpgsql/function_methods.rb @@ -0,0 +1,158 @@ +require 'active_support/concern' +require 'active_record/connection_adapters/postgresql/procedures' + +module ActiveRecord::PLPGSQL + module FunctionMethods + extend ActiveSupport::Concern + + class CannotFetchId < StandardError; end + + included do + include ActiveRecord::PostgreSQLProcedures + + class_attribute :function_schema, :function_method_cache, instance_writer: false + self.function_schema = nil + self.function_method_cache = Hash.new do |cache, klass| + cache[klass] = Hash.new do |methods, method| + # Inherits procedure methods from base class + if klass.superclass.respond_to?(:function_methods) + methods[method] = klass.superclass.function_methods[method] + else + nil + end + end + end + end + + module ClassMethods + def set_create_function(function, options = {}, &reload_block) + block ||= proc do |record, result| + case result + when Hash + record.id = result.values.first + when Numeric + record.id = result + else + raise CannotFetchId, "Couldn't fetch primary key from create procedure (%s) result: %s" % + [procedure, result.inspect] + end + + reload_block ? reload_block.call(record) : record.reload + + record.instance_variable_set(:@new_record, true) + record.id + end + + function_method(:create, function, options, &block) + set_create_method {call_function_method(:create)} + end + + def set_update_function(function, options = {}) + function_method(:update, function, options) do |record| + record.reload + record.id + end + set_update_method {call_function_method(:update)} + end + + def set_destroy_function(function, options = {}) + function_method(:destroy, function, options) + set_delete_method {call_function_method(:destroy)} + end + + def function_methods + function_method_cache[self] + end + + def function_method(method, function_name = method, options = {}, &block) + function = if ::PLPGSQL::Function === function_name + function_name + else + find_function(function_name) + end + + # Raise error if procedure not found + raise ArgumentError, "Function (%s) not found for method (%s)" % [function_name, method] unless function + + function_methods[method] = {function: function, options: options, block: block} + + unless (instance_methods + private_instance_methods).find {|m| m == method} + @generated_attribute_methods.class_eval(<<-RUBY, __FILE__, __LINE__ + 1) + def #{method}(arguments = {}, options = {}) + call_function_method(:#{method}, arguments, options) + end + RUBY + end + end + + def functions_arguments + @functions_arguments ||= Hash.new do |cache, function| + # Always select arguments of first function (overloading not supported) + cache[function] = Hash[ function.arguments[0].sort_by {|arg| arg[1][:position]} ] + end + end + + private + + def find_function(function_name) + case function_name.to_s.split('.').compact + in [package, function] + plpgsql.send(package.to_sym)[function.to_sym] + in [function] + if function_schema + function_schema[function] || ::PLPGSQL::Function.find( + plpgsql, + schema_name: function_schema.name, + function_name: function + ) + else + raise ArgumentError, "Function (%s) not found" % function_name + end + end + end + end + + delegate :functions_arguments, :function_methods, to: 'self.class' + + private + + def call_function_method(method, arguments = {}, opts = {}) + function, options, block = function_methods[method].values_at(:function, :options, :block) + options = options.merge(opts) + + if options[:arguments] + if arguments.is_a?(Hash) + arguments = arguments.merge(instance_exec(&options[:arguments])) + else + arguments += options[:arguments] + end + end + + options[:arguments] = arguments + call_function(function, options, &block) + end + + def call_function(function, options = {}) + result = function.exec(*get_function_arguments(function, options)) + if block_given? + yield(self, result) + else + result + end + end + + def get_function_arguments(function, options) + arguments = options[:arguments] + arguments = arguments.dup if arguments.duplicable? + + if Hash === arguments + arguments.symbolize_keys! + arguments_metadata = procedures_arguments[procedure] + # throw away unnecessary arguments + [arguments.select {|k,_| arguments_metadata[k]}] + else + arguments + end + end + end +end diff --git a/lib/active_record/plpgsql/set_of.rb b/lib/active_record/plpgsql/set_of.rb new file mode 100644 index 0000000..7b05560 --- /dev/null +++ b/lib/active_record/plpgsql/set_of.rb @@ -0,0 +1,167 @@ +require 'active_support/concern' + +module ActiveRecord::PLPGSQL + module SetOf + extend ActiveSupport::Concern + + class SetOfFunctionError < ActiveRecord::ActiveRecordError; end + + class SetOfFunctionTableName < Arel::Nodes::SqlLiteral + alias_method :to_s, :itself + end + + included do + self.set_of_function = nil + end + + module DisableBinding + def can_be_bound?(*) + false + end + end + + module ClassMethods + def set_of_arguments + raise SetOfFunctionError, "Set of function wasn't set" unless set_of? + @set_of_arguments ||= get_set_of_arguments + end + + def set_of_arguments_names + set_of_arguments.map(&:name) + end + + def set_of_function + @set_of_function + end + + alias set_of? set_of_function + + def set_of_function=(function) + case function + when String, Symbol + # Name without schema expected + function_name = function.to_s.split('.').map(&:downcase).map(&:to_sym) + case function_name.size + when 2 + set_of_function = plsql.send(function_name.first)[function_name.second] + when 1 + set_of_function = PLPGSQL::SetOfFunction.find(plsql, function_name.first) + else + raise ArgumentError, 'Setting schema via string not supported yet' + end + raise ArgumentError, 'Set of function not found by string: %s' % function unless set_of_function + when ::PLPGSQL::Function, nil + set_of_function = function + else + raise ArgumentError, 'Unsupported type of function: %s' % function.inspect + end + + if set_of_function && set_of_function.overloaded? + raise ArgumentError, 'Overloaded functions are not supported yet' + end + + @set_of_function = set_of_function + @set_of_arguments = nil + @table_name = set_of_function_name if @set_of_function + end + + def set_of_function_name + return @full_function_name if defined? @full_function_name + schema_name, function_name = @set_of_function.schema, @set_of_function.name + @full_function_name = [schema_name, function_name].compact.join('.') + end + + def arel_table + if set_of? + @arel_table ||= Arel::Table.new( + table_name_with_arguments, + as: set_of_function_alias + ) + else + super + end + end + + def set_of_function_alias + # GET_USER_BY_NAME => GUBN + @set_of_function.procedure.scan(/^\w|_\w/).join('').gsub('_', '') + end + + def table_name_with_arguments + @table_name_with_arguments ||= SetOfFunctionTableName.new( + "%s(%s)" % [table_name, set_of_arguments.map{|a| ":#{a.name}"}.join(',')] + ) + end + + def table_exist? + set_of? || super + end + + def predicate_builder + if set_of? + @_predicate_builder ||= super.extend(DisableBinding) + else + super + end + end + + private + + def get_setof_arguments + # Always select arguments of first function (overloading not supported) + arguments_metadata = set_of_function.arguments[0].sort_by {|arg| arg[1][:position]} + arguments_metadata.map do |name, argument| + ActiveRecord::ConnectionAdapters::OracleEnhanced::Column.new( + name.to_s, nil, fetch_type_metadata(argument[:data_type]), set_of_function_name + ) + end + end + + def fetch_type_metadata(sql_type, virtual = nil) + ActiveRecord::ConnectionAdapters::OracleEnhanced::TypeMetadata.new(sql_type) + end + + def relation + return super unless set_of? + @relation ||= SetOfRelation.new(self, table: arel_table, predicate_builder: predicate_builder) + end + end + + delegate :set_of?, to: 'self.class' + + attr_accessor :found_by_arguments + + def reload(options = nil) + return super unless set_of? && (found_by_arguments.present? || options) + + clear_aggregation_cache + clear_association_cache + + fresh_object = self.class.unscoped do + args = try_get_arguments(found_by_arguments).merge(options || {}) + relation = self.class.where( + **args, + self.class.primary_key => id, + ) + + relation.to_a[0] + end + + @attributes = fresh_object.instance_variable_get("@attributes") + @new_record = false + + @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new + self + end + + private + + def try_get_arguments(arguments) + if arguments + arguments.each_with_object({}) { |arg, hash| hash[arg.name.to_sym] = arg.value } + else + {} + end + end + end +end diff --git a/lib/active_record/plpgsql/set_of_assoc_relation.rb b/lib/active_record/plpgsql/set_of_assoc_relation.rb new file mode 100644 index 0000000..e5d3877 --- /dev/null +++ b/lib/active_record/plpgsql/set_of_assoc_relation.rb @@ -0,0 +1,13 @@ +module ActiveRecord::PLPGSQL + module AssociationRelation + def build_from + if klass.set_of? + klass.arel_table + else + super + end + end + end +end + +ActiveRecord::AssociationRelation.prepend(ActiveRecord::PLPGSQL::AssociationRelation) diff --git a/lib/active_record/plpgsql/set_of_relation.rb b/lib/active_record/plpgsql/set_of_relation.rb new file mode 100644 index 0000000..085c645 --- /dev/null +++ b/lib/active_record/plpgsql/set_of_relation.rb @@ -0,0 +1,154 @@ +module ActiveRecord::PLPGSQL + class SetOfRelation < ActiveRecord::Relation + include ActiveRecord::PLPGSQL::SetOf::ClassMethods + + class FromClause < ActiveRecord::Relation::FromClause + def initialize(value, name, binds = nil) + super(value, name) + + @binds = binds + end + + def binds + @binds || super + end + + def table_binds + @binds || [] + end + end + + attr_accessor :set_of_arguments_values + + def where(opts, *rest) + return super unless @klass.set_of? && set_of_arguments.any? + + set_of_args = set_of_arguments_names.map(&:to_sym) + normalized_opts = normalize_arguments_conditions(opts, set_of_args) + return super if normalized_opts.empty? + + set_of_binds = get_set_of_arguments(table_binds, normalized_opts) + where_opts = normalized_opts.reject { |k| set_of_args.include?(k) } + + rel = spawn.from!( + table_name_with_arguments, + set_of_function_alias.to_sym, + set_of_binds + ) + + if where_opts.empty? && rest.empty? + rel + elsif where_opts.empty? + rel.where!(*rest) + elsif where_opts.is_a?(Array) + rel.where!(*where_opts, *rest) + else + rel.where!(where_opts, *rest) + end + end + + def where!(opts, *rest) + return super unless @klass.set_of? && set_of_arguments.any? + + set_of_args = set_of_arguments_names.map(&:to_sym) + normalized_opts = normalize_arguments_conditions(opts, set_of_args) + return super if normalized_opts.empty? + + set_of_binds = get_set_of_arguments(table_binds, normalized_opts) + where_opts = normalized_opts.reject { |k| set_of_args.include?(k) } + + from!( + table_name_with_arguments, + set_of_function_alias.to_sym, + set_of_binds + ) + + if where_opts.empty? && rest.empty? + self + elsif where_opts.empty? + super(*rest) + else + super(where_opts, *rest) + end + end + + def get_set_of_arguments(current, values) + if values.is_a?(Hash) + set_of_arguments_names.map do |name| + ActiveRecord::Attribute.with_cast_value( + name, + values.fetch(name.to_sym) { + cur = current.find { |arg| arg.name.to_sym == name.to_sym } + cur ? cur.value : nil + }, + ActiveRecord::Type.default_value + ) + end + else + current + end + end + + def table_binds + if from_clause.is_a?(FromClause) + from_clause.table_binds + else + [] + end + end + + def build_from + if @klass.set_of? + @klass.arel_table + else + super + end + end + + def table + if @klass.set_of? + @klass.arel_table + else + super + end + end + + def from!(value, subquery_name = nil, binds = nil) # :nodoc: + self.from_clause = FromClause.new(value, subquery_name, binds) + self + end + + def exec_queries + return super unless @klass.set_of? && !set_of_arguments.empty? + return @records if loaded? + super + return @records if @records.empty? + + # save arguments for easy reloading + @records.each { |record| record.found_by_arguments = table_binds } + @records + end + + protected + + def normalize_arguments_conditions(opts, args) + case opts + when Hash + if opts.key?(klass.set_of_function_name) + opts[klass.set_of_function_name].symbolize_keys + else + opts.symbolize_keys + end + when Arel::Nodes::Equality + column = opts.left.name.to_sym + + # only simple types for now + if args.include?(column) && !opts.right.is_a?(Arel::Attributes::Attribute) + { column => opts.right } + end + else + [opts] + end + end + end +end diff --git a/lib/active_record/plpgsql/set_of_scope.rb b/lib/active_record/plpgsql/set_of_scope.rb new file mode 100644 index 0000000..fca954b --- /dev/null +++ b/lib/active_record/plpgsql/set_of_scope.rb @@ -0,0 +1,21 @@ +module ActiveRecord::PLPGSQL + module SetOfScope + def last_chain_scope(scope, reflection, owner) + key = reflection.join_primary_key + foreign_key = reflection.join_foreign_key + + table = reflection.aliased_table + value = scope.klass.set_of? ? owner[foreign_key] : transform_value(owner[foreign_key]) + scope = apply_scope(scope, table, key, value) + + if reflection.type + polymorphic_type = transform_value(owner.class.base_class.name) + scope = apply_scope(scope, table, reflection.type, polymorphic_type) + end + + scope + end + end +end + +ActiveRecord::Associations::AssociationScope.prepend(ActiveRecord::PLPGSQL::SetOfScope) diff --git a/lib/active_record/plsql/pipelined.rb b/lib/active_record/plsql/pipelined.rb index fc161e7..b44ea4a 100644 --- a/lib/active_record/plsql/pipelined.rb +++ b/lib/active_record/plsql/pipelined.rb @@ -22,7 +22,7 @@ def can_be_bound?(*) module ClassMethods def pipelined_arguments - raise PipelinedFunctionError, "Pipelined function didn't set" unless pipelined? + raise PipelinedFunctionError, "Pipelined function wasn't set" unless pipelined? @pipelined_arguments ||= get_pipelined_arguments end diff --git a/lib/active_record/plxsql/compat.rb b/lib/active_record/plxsql/compat.rb new file mode 100644 index 0000000..fb57704 --- /dev/null +++ b/lib/active_record/plxsql/compat.rb @@ -0,0 +1,6 @@ +require 'active_record/plxsql/pipelined' +require 'active_record/plxsql/procedure_methods' + +module ActiveRecord + PLSQL = ActiveRecord::PLXSQL +end diff --git a/lib/active_record/plxsql/pipelined.rb b/lib/active_record/plxsql/pipelined.rb new file mode 100644 index 0000000..1487001 --- /dev/null +++ b/lib/active_record/plxsql/pipelined.rb @@ -0,0 +1,19 @@ +require 'active_record/plpgsql/set_of' + +module ActiveRecord::PLXSQL + module Pipelined + extend ActiveSupport::Concern + + include ActiveRecord::PLPGSQL::SetOf + + module ClassMethods + def pipelined_function=(function) + self.set_of_function = function + end + + def pipelined_function + set_of_function + end + end + end +end diff --git a/lib/active_record/plxsql/procedure_methods.rb b/lib/active_record/plxsql/procedure_methods.rb new file mode 100644 index 0000000..c41161a --- /dev/null +++ b/lib/active_record/plxsql/procedure_methods.rb @@ -0,0 +1,35 @@ +require 'active_record/plpgsql/function_methods' + +module ActiveRecord::PLXSQL + module ProcedureMethods + extend ActiveSupport::Concern + + include ActiveRecord::PLPGSQL::FunctionMethods + + module ClassMethods + def plsql_package=(schema) + self.function_schema = schema + end + + def plsql_package + self.function_schema + end + + def set_create_procedure(procedure, options = {}, &block) + set_create_method {call_function_method(:create)} + end + + def set_update_procedure(procedure, options = {}, &block) + set_update_method {call_function_method(:update)} + end + + def set_destroy_procedure(procedure, options = {}, &block) + set_delete_method {call_function_method(:destroy)} + end + + def procedure_method(*args, **kwargs, &block) + function_method(*args, **kwargs, &block) + end + end + end +end diff --git a/lib/plpgsql/log_subscriber.rb b/lib/plpgsql/log_subscriber.rb new file mode 100644 index 0000000..c8e5daa --- /dev/null +++ b/lib/plpgsql/log_subscriber.rb @@ -0,0 +1,46 @@ +class PLPGSQL + class LogSubscriber < ActiveSupport::LogSubscriber + def procedure_call(event) + return unless logger && (logger.debug? || uncaught_exception?(event.payload[:error])) + payload = event.payload + name = 'PL/pgSQL Procedure call (%.1fms)' % event.duration + sql = payload[:sql].strip + + if payload[:arguments].empty? + arguments = nil + elsif payload[:arguments].size == 1 && Hash === payload[:arguments].first + arguments = ' ' + payload[:arguments].first.inspect + else + arguments = ' ' + payload[:arguments].inspect + end + + if event.payload[:error] + exception = "Error occurred: %s\n%s" % + [event.payload[:error].class, event.payload[:error].message.split("\n").map{|l| " #{l}"}.join("\n")] + + name = color(name, RED, true) + exception = color(exception, RED, true) + sql = color(sql, nil, true) + + error " #{name} #{sql}#{arguments}\n #{exception}" + else + name = color(name, YELLOW, true) + sql = color(sql, nil, true) + + debug " #{name} #{sql}#{arguments}" + end + end + + private + + def uncaught_exception?(error) + error && OCIError === error && !error.code.in?(20000..20999) + end + + def logger + self.class.logger || super + end + end +end + +PLPGSQL::LogSubscriber.attach_to :plpgsql diff --git a/lib/rails-plpgsql.rb b/lib/rails-plpgsql.rb new file mode 100644 index 0000000..757d7a4 --- /dev/null +++ b/lib/rails-plpgsql.rb @@ -0,0 +1,4 @@ +require 'ruby-plpgsql' +require 'rails/engine' +require 'active_record/plxsql/compat' +require 'active_record/plpgsql/engine' diff --git a/lib/rails-plsql.rb b/lib/rails-plsql.rb index 3fca57e..a6f318a 100644 --- a/lib/rails-plsql.rb +++ b/lib/rails-plsql.rb @@ -1,9 +1,18 @@ if RUBY_ENGINE == 'ruby' - require 'oci8' + begin + require 'oci8' + rescue LoadError + # no oci8 + end end require 'active_record' -require 'activerecord-oracle_enhanced-adapter' -require 'ruby-plsql' -require 'oracle/named_error' -require 'rails/engine' -require 'active_record/plsql/engine' + +begin + require 'activerecord-oracle_enhanced-adapter' + require 'ruby-plsql' + require 'oracle/named_error' + require 'rails/engine' + require 'active_record/plsql/engine' +rescue LoadError + require 'rails-plpgsql' +end diff --git a/lib/ruby-plpgsql.rb b/lib/ruby-plpgsql.rb new file mode 100644 index 0000000..f0f3f79 --- /dev/null +++ b/lib/ruby-plpgsql.rb @@ -0,0 +1,129 @@ +class PLPGSQL + attr_writer :activerecord_class + + def initialize(activerecord_class = nil) + @activerecord_class = activerecord_class + end + + class Schema + attr_reader :schema_name + + alias name schema_name + + def initialize(ar_class:, schema_name:) + @ar_class = ar_class + @schema_name = + case schema_name + in String + schema_name + in Symbol + schema_name.to_s + else + raise ArgumentError, "Invalid schema name: #{schema_name}" + end + end + + def [](function_name) + Function.new( + ar_class: @ar_class, + schema_name: @schema_name, + function_name: function_name + ) + end + + private + + def method_missing(name, *args, **kwargs, &block) + self[name].(*args, **kwargs, &block) + end + + def respond_to_missing?(_name, _include_private = false) + true + end + end + + class Function + def self.find(plpgsql, schema_name:, function_name:) + plpgsql.public_send(schema_name)[function_name] + end + + def initialize(ar_class:, schema_name:, function_name:) + @ar_class = ar_class + @schema_name = schema_name + @function_name = function_name + end + + def schema_name + @schema_name + end + + alias schema schema_name + + def function_name + @function_name + end + + alias name function_name + + def call(*args, &_block) + @ar_class.connection.select_value( + "select #{@schema_name}.#{@function_name}(#{args_to_string(args)})" + ) + end + + def overloaded? + false + end + + private + + def args_to_string(args) + args.flat_map do |arg| + if arg.is_a?(::Hash) + arg.map do |key, value| + "#{key} => #{value_to_string(value)}" + end + else + [value_to_string(arg)] + end + end.join(', ') + end + + def value_to_string(value) + if value.is_a?(::String) + "'#{value}'" + elsif value.nil? + 'null' + else + value.to_s + end + end + end + + private + + def respond_to_missing?(_name, _include_private = false) + true + end + + def method_missing(schema_name) + Schema.new( + ar_class: @activerecord_class, + schema_name: schema_name + ) + end +end + +module Kernel + def self.plpgsql + @plpgsql ||= ::PLPGSQL.new + end + + singleton_class.alias_method :plsql, :plpgsql + + def plpgsql + ::Kernel.plpgsql + end + + alias plsql plpgsql +end diff --git a/rails-plsql.gemspec b/rails-plsql.gemspec index 5db434c..7028d3b 100644 --- a/rails-plsql.gemspec +++ b/rails-plsql.gemspec @@ -11,9 +11,9 @@ Gem::Specification.new do |s| s.description = 'rails-plsql adds functional that allows to use some special Oracle Database features in standard ActiveRecord models.' s.files = Dir['lib/**/*'] + %w(MIT-LICENSE README.md) - s.add_dependency('ruby-plsql', ['~> 0.6.0']) - s.add_dependency('activerecord', ['~> 5.2.8.1']) - s.add_dependency('activerecord-oracle_enhanced-adapter', ['~> 5.2']) + # s.add_dependency('ruby-plsql', ['~> 0.6.0']) + s.add_dependency('activerecord', ['~> 7.2.0']) + # s.add_dependency('activerecord-oracle_enhanced-adapter', ['~> 5.2']) s.require_paths = %w(lib) end From c11260b8459509a9277badf986dc736c4d183078 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Wed, 4 Jun 2025 17:37:15 +0200 Subject: [PATCH 02/17] Support for calling procedures in PG --- lib/active_record/plpgsql/function_methods.rb | 6 +- lib/active_record/plpgsql/set_of.rb | 2 +- lib/ruby-plpgsql.rb | 90 ++++++++++++++----- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/lib/active_record/plpgsql/function_methods.rb b/lib/active_record/plpgsql/function_methods.rb index 15e1322..11dabec 100644 --- a/lib/active_record/plpgsql/function_methods.rb +++ b/lib/active_record/plpgsql/function_methods.rb @@ -65,7 +65,7 @@ def function_methods end def function_method(method, function_name = method, options = {}, &block) - function = if ::PLPGSQL::Function === function_name + function = if ::PLPGSQL::Routine === function_name function_name else find_function(function_name) @@ -100,10 +100,10 @@ def find_function(function_name) plpgsql.send(package.to_sym)[function.to_sym] in [function] if function_schema - function_schema[function] || ::PLPGSQL::Function.find( + function_schema[function] || ::PLPGSQL::Routine.find( plpgsql, schema_name: function_schema.name, - function_name: function + routine_name: function ) else raise ArgumentError, "Function (%s) not found" % function_name diff --git a/lib/active_record/plpgsql/set_of.rb b/lib/active_record/plpgsql/set_of.rb index 7b05560..b3d063d 100644 --- a/lib/active_record/plpgsql/set_of.rb +++ b/lib/active_record/plpgsql/set_of.rb @@ -62,7 +62,7 @@ def set_of_function=(function) @set_of_function = set_of_function @set_of_arguments = nil - @table_name = set_of_function_name if @set_of_function + # @table_name = set_of_function_name if @set_of_function end def set_of_function_name diff --git a/lib/ruby-plpgsql.rb b/lib/ruby-plpgsql.rb index f0f3f79..6b997c6 100644 --- a/lib/ruby-plpgsql.rb +++ b/lib/ruby-plpgsql.rb @@ -3,6 +3,7 @@ class PLPGSQL def initialize(activerecord_class = nil) @activerecord_class = activerecord_class + @cache = {} end class Schema @@ -21,18 +22,50 @@ def initialize(ar_class:, schema_name:) else raise ArgumentError, "Invalid schema name: #{schema_name}" end + @cache = {} + @name_cache = {} end def [](function_name) - Function.new( - ar_class: @ar_class, - schema_name: @schema_name, - function_name: function_name - ) + @cache[normalize_function_name(function_name)] ||= resolve_routine(function_name) end private + def normalize_function_name(name) + @name_cache[name] ||= name.to_s.downcase + end + + def resolve_routine(name) + routine_type = @ar_class.connection.select_value(<<-SQL) + SELECT routine_type + FROM information_schema.routines + WHERE routine_schema = '#{@schema_name.to_s.downcase}' + AND routine_name = '#{normalize_function_name(name)}' + SQL + + case routine_type + when 'PROCEDURE' + Procedure.new( + ar_class: @ar_class, + schema_name: @schema_name, + routine_name: name + ) + when 'FUNCTION' + Function.new( + ar_class: @ar_class, + schema_name: @schema_name, + routine_name: name + ) + else + UnknownRoutine.new( + ar_class: @ar_class, + schema_name: @schema_name, + routine_name: name + ) + end + end + def method_missing(name, *args, **kwargs, &block) self[name].(*args, **kwargs, &block) end @@ -42,15 +75,15 @@ def respond_to_missing?(_name, _include_private = false) end end - class Function - def self.find(plpgsql, schema_name:, function_name:) - plpgsql.public_send(schema_name)[function_name] + class Routine + def self.find(plpgsql, schema_name:, routine_name:) + plpgsql.public_send(schema_name)[routine_name] end - def initialize(ar_class:, schema_name:, function_name:) + def initialize(ar_class:, schema_name:, routine_name:) @ar_class = ar_class @schema_name = schema_name - @function_name = function_name + @routine_name = routine_name end def schema_name @@ -59,17 +92,12 @@ def schema_name alias schema schema_name - def function_name - @function_name + def routine_name + @routine_name end - alias name function_name - - def call(*args, &_block) - @ar_class.connection.select_value( - "select #{@schema_name}.#{@function_name}(#{args_to_string(args)})" - ) - end + alias function_name routine_name + alias name routine_name def overloaded? false @@ -100,6 +128,28 @@ def value_to_string(value) end end + class Function < Routine + def call(*args, &_block) + @ar_class.connection.select_value( + "select #{@schema_name}.#{name}(#{args_to_string(args)})" + ) + end + end + + class Procedure < Routine + def call(*args, &_block) + @ar_class.connection.execute( + "call #{@schema_name}.#{name}(#{args_to_string(args)})" + ) + end + end + + class UnknownRoutine < Routine + def call(*args, &_block) + raise "Unknown routine: #{@schema_name}.#{@routine_name}" + end + end + private def respond_to_missing?(_name, _include_private = false) @@ -107,7 +157,7 @@ def respond_to_missing?(_name, _include_private = false) end def method_missing(schema_name) - Schema.new( + @cache[schema_name] ||= Schema.new( ar_class: @activerecord_class, schema_name: schema_name ) From e844a0aad508fbf5ea12d25616930229a894085f Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Wed, 11 Jun 2025 21:19:20 +0200 Subject: [PATCH 03/17] Add named errors for PG --- lib/active_record/plxsql/compat.rb | 5 +++++ lib/plpgsql/named_error.rb | 24 ++++++++++++++++++++++++ lib/ruby-plpgsql.rb | 30 +++++++++++++++++++++++------- 3 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 lib/plpgsql/named_error.rb diff --git a/lib/active_record/plxsql/compat.rb b/lib/active_record/plxsql/compat.rb index fb57704..4130926 100644 --- a/lib/active_record/plxsql/compat.rb +++ b/lib/active_record/plxsql/compat.rb @@ -1,6 +1,11 @@ +require 'plpgsql/named_error' require 'active_record/plxsql/pipelined' require 'active_record/plxsql/procedure_methods' module ActiveRecord PLSQL = ActiveRecord::PLXSQL end + +module Oracle + NamedError = PLPGSQL::NamedError +end diff --git a/lib/plpgsql/named_error.rb b/lib/plpgsql/named_error.rb new file mode 100644 index 0000000..3ae27eb --- /dev/null +++ b/lib/plpgsql/named_error.rb @@ -0,0 +1,24 @@ +class PLPGSQL + class NamedError < ::StandardError + class_attribute :error_code, instance_writer: false + + class << self + def ===(error) + if error.respond_to?(:message) + error.message.start_with?("PG::RaiseException") && + error.message.include?("ERROR: [#{error_code}]") + else + false + end + end + + def define_exception(class_name, error_code) + class_eval(<<-RUBY, __FILE__, __LINE__ + 1) + class ::#{class_name} < PLPGSQL::NamedError + self.error_code = #{error_code} + end + RUBY + end + end + end +end diff --git a/lib/ruby-plpgsql.rb b/lib/ruby-plpgsql.rb index 6b997c6..3cda731 100644 --- a/lib/ruby-plpgsql.rb +++ b/lib/ruby-plpgsql.rb @@ -1,9 +1,18 @@ class PLPGSQL + class ArgumentHandler + def call(_routine, arg) + arg + end + end + attr_writer :activerecord_class + attr_accessor :argument_handler + def initialize(activerecord_class = nil) @activerecord_class = activerecord_class @cache = {} + @argument_handler = ArgumentHandler.new end class Schema @@ -11,7 +20,7 @@ class Schema alias name schema_name - def initialize(ar_class:, schema_name:) + def initialize(ar_class:, schema_name:, argument_handler:) @ar_class = ar_class @schema_name = case schema_name @@ -24,6 +33,7 @@ def initialize(ar_class:, schema_name:) end @cache = {} @name_cache = {} + @argument_handler = argument_handler end def [](function_name) @@ -49,19 +59,22 @@ def resolve_routine(name) Procedure.new( ar_class: @ar_class, schema_name: @schema_name, - routine_name: name + routine_name: name, + argument_handler: @argument_handler ) when 'FUNCTION' Function.new( ar_class: @ar_class, schema_name: @schema_name, - routine_name: name + routine_name: name, + argument_handler: @argument_handler ) else UnknownRoutine.new( ar_class: @ar_class, schema_name: @schema_name, - routine_name: name + routine_name: name, + argument_handler: @argument_handler ) end end @@ -80,10 +93,11 @@ def self.find(plpgsql, schema_name:, routine_name:) plpgsql.public_send(schema_name)[routine_name] end - def initialize(ar_class:, schema_name:, routine_name:) + def initialize(ar_class:, schema_name:, routine_name:, argument_handler:) @ar_class = ar_class @schema_name = schema_name @routine_name = routine_name + @argument_handler = argument_handler end def schema_name @@ -109,7 +123,8 @@ def args_to_string(args) args.flat_map do |arg| if arg.is_a?(::Hash) arg.map do |key, value| - "#{key} => #{value_to_string(value)}" + arg_name = @argument_handler.call(self, key) + "#{arg_name} => #{value_to_string(value)}" end else [value_to_string(arg)] @@ -159,7 +174,8 @@ def respond_to_missing?(_name, _include_private = false) def method_missing(schema_name) @cache[schema_name] ||= Schema.new( ar_class: @activerecord_class, - schema_name: schema_name + schema_name: schema_name, + argument_handler: @argument_handler ) end end From 2b5a4d3772c1915f3df941546bb3ddc01798b68a Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Wed, 25 Jun 2025 17:15:03 +0200 Subject: [PATCH 04/17] Add support for arguments in PG routines --- lib/active_record/plpgsql/function_methods.rb | 8 +- lib/active_record/plpgsql/set_of.rb | 15 +- lib/active_record/plpgsql/set_of_relation.rb | 5 + lib/active_record/plsql/procedure_methods.rb | 4 +- lib/active_record/plxsql/procedure_methods.rb | 10 + lib/plpgsql/named_error.rb | 4 +- lib/ruby-plpgsql.rb | 179 ++++++++++++++---- 7 files changed, 176 insertions(+), 49 deletions(-) diff --git a/lib/active_record/plpgsql/function_methods.rb b/lib/active_record/plpgsql/function_methods.rb index 11dabec..409cc47 100644 --- a/lib/active_record/plpgsql/function_methods.rb +++ b/lib/active_record/plpgsql/function_methods.rb @@ -74,7 +74,7 @@ def function_method(method, function_name = method, options = {}, &block) # Raise error if procedure not found raise ArgumentError, "Function (%s) not found for method (%s)" % [function_name, method] unless function - function_methods[method] = {function: function, options: options, block: block} + function_methods[method] = {routine: function, options: options, block: block} unless (instance_methods + private_instance_methods).find {|m| m == method} @generated_attribute_methods.class_eval(<<-RUBY, __FILE__, __LINE__ + 1) @@ -117,7 +117,7 @@ def find_function(function_name) private def call_function_method(method, arguments = {}, opts = {}) - function, options, block = function_methods[method].values_at(:function, :options, :block) + function, options, block = function_methods[method].values_at(:routine, :options, :block) options = options.merge(opts) if options[:arguments] @@ -133,7 +133,7 @@ def call_function_method(method, arguments = {}, opts = {}) end def call_function(function, options = {}) - result = function.exec(*get_function_arguments(function, options)) + result = function.(*get_function_arguments(function, options)) if block_given? yield(self, result) else @@ -147,7 +147,7 @@ def get_function_arguments(function, options) if Hash === arguments arguments.symbolize_keys! - arguments_metadata = procedures_arguments[procedure] + arguments_metadata = procedures_arguments[function] # throw away unnecessary arguments [arguments.select {|k,_| arguments_metadata[k]}] else diff --git a/lib/active_record/plpgsql/set_of.rb b/lib/active_record/plpgsql/set_of.rb index b3d063d..662b275 100644 --- a/lib/active_record/plpgsql/set_of.rb +++ b/lib/active_record/plpgsql/set_of.rb @@ -84,7 +84,7 @@ def arel_table def set_of_function_alias # GET_USER_BY_NAME => GUBN - @set_of_function.procedure.scan(/^\w|_\w/).join('').gsub('_', '') + @set_of_function.routine_name.scan(/^\w|_\w/).join('').gsub('_', '') end def table_name_with_arguments @@ -107,14 +107,15 @@ def predicate_builder private - def get_setof_arguments + def get_set_of_arguments # Always select arguments of first function (overloading not supported) arguments_metadata = set_of_function.arguments[0].sort_by {|arg| arg[1][:position]} - arguments_metadata.map do |name, argument| - ActiveRecord::ConnectionAdapters::OracleEnhanced::Column.new( - name.to_s, nil, fetch_type_metadata(argument[:data_type]), set_of_function_name - ) - end + arguments_metadata.map(&:first) + # arguments_metadata.map do |name, argument| + # ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::Column.new( + # name.to_s, nil, fetch_type_metadata(argument[:data_type]), set_of_function_name + # ) + # end end def fetch_type_metadata(sql_type, virtual = nil) diff --git a/lib/active_record/plpgsql/set_of_relation.rb b/lib/active_record/plpgsql/set_of_relation.rb index 085c645..d60b6e8 100644 --- a/lib/active_record/plpgsql/set_of_relation.rb +++ b/lib/active_record/plpgsql/set_of_relation.rb @@ -18,6 +18,11 @@ def table_binds end end + def initialize(...) + super + @set_of_function = klass.set_of_function + end + attr_accessor :set_of_arguments_values def where(opts, *rest) diff --git a/lib/active_record/plsql/procedure_methods.rb b/lib/active_record/plsql/procedure_methods.rb index 6b6a0af..5076fa7 100644 --- a/lib/active_record/plsql/procedure_methods.rb +++ b/lib/active_record/plsql/procedure_methods.rb @@ -74,7 +74,7 @@ def procedure_method(method, procedure_name = method, options = {}, &block) # Raise error if procedure not found raise ArgumentError, "Procedure (%s) not found for method (%s)" % [procedure_name, method] unless procedure - procedure_methods[method] = {procedure: procedure, options: options, block: block} + procedure_methods[method] = {routine: procedure, options: options, block: block} unless (instance_methods + private_instance_methods).find {|m| m == method} @generated_attribute_methods.class_eval(<<-RUBY, __FILE__, __LINE__ + 1) @@ -115,7 +115,7 @@ def find_procedure(procedure_name) private def call_procedure_method(method, arguments = {}, opts = {}) - procedure, options, block = procedure_methods[method].values_at(:procedure, :options, :block) + procedure, options, block = procedure_methods[method].values_at(:routine, :options, :block) options = options.merge(opts) if options[:arguments] diff --git a/lib/active_record/plxsql/procedure_methods.rb b/lib/active_record/plxsql/procedure_methods.rb index c41161a..6f46822 100644 --- a/lib/active_record/plxsql/procedure_methods.rb +++ b/lib/active_record/plxsql/procedure_methods.rb @@ -30,6 +30,16 @@ def set_destroy_procedure(procedure, options = {}, &block) def procedure_method(*args, **kwargs, &block) function_method(*args, **kwargs, &block) end + + def procedure_methods + function_methods + end + + def procedures_arguments + functions_arguments + end end + + delegate :procedures_arguments, :procedure_methods, to: 'self.class' end end diff --git a/lib/plpgsql/named_error.rb b/lib/plpgsql/named_error.rb index 3ae27eb..76d698e 100644 --- a/lib/plpgsql/named_error.rb +++ b/lib/plpgsql/named_error.rb @@ -3,9 +3,11 @@ class NamedError < ::StandardError class_attribute :error_code, instance_writer: false class << self + CLASS_NAME= /PG::RaiseException|PG::ServerError/ + def ===(error) if error.respond_to?(:message) - error.message.start_with?("PG::RaiseException") && + error.message.start_with?(CLASS_NAME) && error.message.include?("ERROR: [#{error_code}]") else false diff --git a/lib/ruby-plpgsql.rb b/lib/ruby-plpgsql.rb index 3cda731..8678b4d 100644 --- a/lib/ruby-plpgsql.rb +++ b/lib/ruby-plpgsql.rb @@ -47,33 +47,40 @@ def normalize_function_name(name) end def resolve_routine(name) - routine_type = @ar_class.connection.select_value(<<-SQL) - SELECT routine_type + routine_info = @ar_class.connection.select_one(<<-SQL) + SELECT routine_type, specific_name FROM information_schema.routines WHERE routine_schema = '#{@schema_name.to_s.downcase}' AND routine_name = '#{normalize_function_name(name)}' SQL - case routine_type - when 'PROCEDURE' - Procedure.new( - ar_class: @ar_class, - schema_name: @schema_name, - routine_name: name, - argument_handler: @argument_handler - ) - when 'FUNCTION' - Function.new( - ar_class: @ar_class, - schema_name: @schema_name, - routine_name: name, - argument_handler: @argument_handler - ) + if routine_info + case routine_info['routine_type'] + when 'PROCEDURE' + Procedure.new( + ar_class: @ar_class, + schema_name: @schema_name, + routine_name: name, + specific_name: routine_info['specific_name'], + argument_handler: @argument_handler + ) + when 'FUNCTION' + Function.new( + ar_class: @ar_class, + schema_name: @schema_name, + routine_name: name, + specific_name: routine_info['specific_name'], + argument_handler: @argument_handler + ) + else + raise "Unknown routine type: #{routine_info['routine_type']}" + end else - UnknownRoutine.new( + MissingRoutine.new( ar_class: @ar_class, schema_name: @schema_name, routine_name: name, + specific_name: nil, argument_handler: @argument_handler ) end @@ -93,10 +100,11 @@ def self.find(plpgsql, schema_name:, routine_name:) plpgsql.public_send(schema_name)[routine_name] end - def initialize(ar_class:, schema_name:, routine_name:, argument_handler:) + def initialize(ar_class:, schema_name:, routine_name:, specific_name:, argument_handler:) @ar_class = ar_class @schema_name = schema_name @routine_name = routine_name + @specific_name = specific_name @argument_handler = argument_handler end @@ -117,26 +125,109 @@ def overloaded? false end + def arguments + @arguments ||= get_argument_metadata + end + + def argument_list + @argument_list ||= begin + args = arguments[0] || {} + args.keys.sort { |k1, k2| args[k1][:position] <=> args[k2][:position] } + end + end + + def out_list + @out_list ||= begin + args = arguments[0] || {} + argument_list.select { |k| args[k][:in_out] =~ /OUT/ } + end + end + private + def get_argument_metadata + # Build hash similar to Oracle's structure, but without overloading + args = {} + + @ar_class.connection.select_all(<<-SQL).each do |row| + SELECT + parameter_name, + data_type, + parameter_mode, + ordinal_position, + parameter_default, + character_maximum_length, + numeric_precision, + numeric_scale + FROM information_schema.parameters + WHERE specific_schema = '#{@schema_name.to_s.downcase}' + AND specific_name = '#{@specific_name}' + AND parameter_name IS NOT NULL + ORDER BY ordinal_position + SQL + + param_name = row['parameter_name']&.downcase&.to_sym + next unless param_name + + args[param_name] = { + position: row['ordinal_position'].to_i, + data_type: row['data_type'], + in_out: case row['parameter_mode'] + when 'IN' then 'IN' + when 'OUT' then 'OUT' + when 'INOUT' then 'IN OUT' + else 'IN' + end, + data_length: row['character_maximum_length']&.to_i, + data_precision: row['numeric_precision']&.to_i, + data_scale: row['numeric_scale']&.to_i, + defaulted: row['parameter_default'] ? 'Y' : 'N' + } + end + + # Return hash with overload key 0 to match Oracle structure + { 0 => args } + end + def args_to_string(args) - args.flat_map do |arg| - if arg.is_a?(::Hash) - arg.map do |key, value| - arg_name = @argument_handler.call(self, key) - "#{arg_name} => #{value_to_string(value)}" + [ + *args.flat_map { |arg| + if arg.is_a?(::Hash) + arg.map do |key, value| + arg_name = @argument_handler.call(self, key) + "#{arg_name} => #{value_to_string(value)}" + end + else + [value_to_string(arg)] end - else - [value_to_string(arg)] - end - end.join(', ') + }, + *out_args + ].join(', ') + end + + def out_args + @out_args ||= begin + args = arguments[0] || {} + argument_list.select { |k| args[k][:in_out] =~ /OUT/ }.map { |k| + arg_name = @argument_handler.call(self, k) + "#{arg_name} => NULL" + } + end end def value_to_string(value) if value.is_a?(::String) - "'#{value}'" + if value.empty? + "NULL" + else + "'#{value}'" + end + elsif value.is_a?(::Array) + "'{#{value.join(', ')}}'" elsif value.nil? 'null' + elsif value.is_a?(::Time) || value.is_a?(::DateTime) + "'#{value.strftime('%Y-%m-%d %H:%M:%S')}'::timestamp(0)" else value.to_s end @@ -145,9 +236,27 @@ def value_to_string(value) class Function < Routine def call(*args, &_block) - @ar_class.connection.select_value( - "select #{@schema_name}.#{name}(#{args_to_string(args)})" - ) + if set_of? + @ar_class.connection.select_all( + "select #{@schema_name}.#{name}(#{args_to_string(args)})" + ).to_a + else + @ar_class.connection.select_value( + "select #{@schema_name}.#{name}(#{args_to_string(args)})" + ) + end + end + + def set_of? + if defined?(@set_of) + @set_of + else + # determine if the function returns a set of rows + # by checking the return type + @set_of = @ar_class.connection.select_value( + "SELECT pg_get_function_result(oid) FROM pg_proc WHERE proname = '#{@routine_name}'" + ) =~ /setof/i + end end end @@ -155,13 +264,13 @@ class Procedure < Routine def call(*args, &_block) @ar_class.connection.execute( "call #{@schema_name}.#{name}(#{args_to_string(args)})" - ) + ).to_a.first end end - class UnknownRoutine < Routine + class MissingRoutine < Routine def call(*args, &_block) - raise "Unknown routine: #{@schema_name}.#{@routine_name}" + raise "Missing routine: #{@schema_name}.#{@routine_name}" end end From 0c582bc32fd7010684cc367d74d4fd69eaf8a605 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Wed, 12 Nov 2025 17:08:35 +0100 Subject: [PATCH 05/17] Update AR API --- lib/active_record/plsql/pipelined_scope.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/active_record/plsql/pipelined_scope.rb b/lib/active_record/plsql/pipelined_scope.rb index 4a4c728..6060e18 100644 --- a/lib/active_record/plsql/pipelined_scope.rb +++ b/lib/active_record/plsql/pipelined_scope.rb @@ -1,9 +1,8 @@ module ActiveRecord::PLSQL module PipelinedScope def last_chain_scope(scope, reflection, owner) - join_keys = reflection.join_keys - key = join_keys.key - foreign_key = join_keys.foreign_key + key = reflection.join_primary_key + foreign_key = reflection.join_foreign_key table = reflection.aliased_table value = scope.klass.pipelined? ? owner[foreign_key] : transform_value(owner[foreign_key]) From b469c6824dd827072649385a4cbc6fce6733f4c6 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Wed, 26 Nov 2025 14:02:50 +0100 Subject: [PATCH 06/17] Update connection accessor --- lib/active_record/oracle_enhanced_adapter_patch.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/active_record/oracle_enhanced_adapter_patch.rb b/lib/active_record/oracle_enhanced_adapter_patch.rb index d503582..67d7247 100644 --- a/lib/active_record/oracle_enhanced_adapter_patch.rb +++ b/lib/active_record/oracle_enhanced_adapter_patch.rb @@ -60,7 +60,7 @@ def parse_function_name(name) protected def translate_exception(exception, message) #:nodoc: - case @connection.error_code(exception) + case @raw_connection.error_code(exception) when 1 RecordNotUnique.new(message, exception) when 2291 From 16d5a89caeec8c2c79f004f62fad009a281ad310 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Fri, 5 Dec 2025 11:25:30 +0100 Subject: [PATCH 07/17] Update log subscriber --- lib/plpgsql/log_subscriber.rb | 10 +++++----- lib/plsql/log_subscriber.rb | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/plpgsql/log_subscriber.rb b/lib/plpgsql/log_subscriber.rb index c8e5daa..fb1aa51 100644 --- a/lib/plpgsql/log_subscriber.rb +++ b/lib/plpgsql/log_subscriber.rb @@ -18,14 +18,14 @@ def procedure_call(event) exception = "Error occurred: %s\n%s" % [event.payload[:error].class, event.payload[:error].message.split("\n").map{|l| " #{l}"}.join("\n")] - name = color(name, RED, true) - exception = color(exception, RED, true) - sql = color(sql, nil, true) + name = color(name, RED, bold: true) + exception = color(exception, RED, bold: true) + sql = color(sql, nil, bold: true) error " #{name} #{sql}#{arguments}\n #{exception}" else - name = color(name, YELLOW, true) - sql = color(sql, nil, true) + name = color(name, YELLOW, bold: true) + sql = color(sql, nil, bold: true) debug " #{name} #{sql}#{arguments}" end diff --git a/lib/plsql/log_subscriber.rb b/lib/plsql/log_subscriber.rb index f518afa..e9eae59 100644 --- a/lib/plsql/log_subscriber.rb +++ b/lib/plsql/log_subscriber.rb @@ -18,14 +18,14 @@ def procedure_call(event) exception = "Error occurred: %s\n%s" % [event.payload[:error].class, event.payload[:error].message.split("\n").map{|l| " #{l}"}.join("\n")] - name = color(name, RED, true) - exception = color(exception, RED, true) - sql = color(sql, nil, true) + name = color(name, RED, bold: true) + exception = color(exception, RED, bold: true) + sql = color(sql, nil, bold: true) error " #{name} #{sql}#{arguments}\n #{exception}" else - name = color(name, YELLOW, true) - sql = color(sql, nil, true) + name = color(name, YELLOW, bold: true) + sql = color(sql, nil, bold: true) debug " #{name} #{sql}#{arguments}" end From 876cec427f2cdc61d33433e572a89bc93d4495b5 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Tue, 9 Dec 2025 12:24:11 +0100 Subject: [PATCH 08/17] Delay code loading until adapter is known --- lib/active_record/plpgsql/engine.rb | 16 ------------ lib/active_record/plsql/engine.rb | 39 +++++++++++++++++++++-------- lib/rails-plpgsql.rb | 4 --- lib/rails-plsql.rb | 13 +++------- 4 files changed, 31 insertions(+), 41 deletions(-) delete mode 100644 lib/active_record/plpgsql/engine.rb delete mode 100644 lib/rails-plpgsql.rb diff --git a/lib/active_record/plpgsql/engine.rb b/lib/active_record/plpgsql/engine.rb deleted file mode 100644 index 4d118ab..0000000 --- a/lib/active_record/plpgsql/engine.rb +++ /dev/null @@ -1,16 +0,0 @@ -require 'active_record/plpgsql/set_of' -require 'active_record/plpgsql/function_methods' -require 'active_record/plpgsql/base' -require 'active_record/plpgsql/set_of_relation' -require 'active_record/plpgsql/set_of_scope' -require 'active_record/plpgsql/set_of_assoc_relation' -# require 'active_record/oracle_enhanced_adapter_patch' -require 'plpgsql/log_subscriber' - -module ActiveRecord::PLPGSQL - class Engine < ::Rails::Engine - initializer 'plpgsql.logger', after: 'active_record.logger' do - PLPGSQL::LogSubscriber.logger = ActiveRecord::Base.logger - end - end -end diff --git a/lib/active_record/plsql/engine.rb b/lib/active_record/plsql/engine.rb index b92c8f8..1e0e4f5 100644 --- a/lib/active_record/plsql/engine.rb +++ b/lib/active_record/plsql/engine.rb @@ -1,16 +1,33 @@ -require 'active_record/plsql/pipelined' -require 'active_record/plsql/procedure_methods' -require 'active_record/plsql/base' -require 'active_record/plsql/pipelined_relation' -require 'active_record/plsql/pipelined_scope' -require 'active_record/plsql/pipelined_assoc_relation' -require 'active_record/oracle_enhanced_adapter_patch' -require 'plsql/log_subscriber' - module ActiveRecord::PLSQL class Engine < ::Rails::Engine - initializer 'plsql.logger', after: 'active_record.logger' do - PLSQL::LogSubscriber.logger = ActiveRecord::Base.logger + initializer 'plsql.load' do + case ActiveRecord::Base.connection.adapter_name + in 'PostgreSQL' + require 'ruby-plpgsql' + require 'active_record/plxsql/compat' + require 'active_record/plpgsql/set_of' + require 'active_record/plpgsql/function_methods' + require 'active_record/plpgsql/base' + require 'active_record/plpgsql/set_of_relation' + require 'active_record/plpgsql/set_of_scope' + require 'active_record/plpgsql/set_of_assoc_relation' + require 'plpgsql/log_subscriber' + + PLPGSQL::LogSubscriber.logger = ActiveRecord::Base.logger + in 'OracleEnhanced' + require 'ruby-plsql' + require 'active_record/plsql/pipelined' + require 'active_record/plsql/procedure_methods' + require 'active_record/plsql/base' + require 'active_record/plsql/pipelined_relation' + require 'active_record/plsql/pipelined_scope' + require 'active_record/plsql/pipelined_assoc_relation' + require 'active_record/oracle_enhanced_adapter_patch' + require 'plsql/log_subscriber' + require 'oracle/named_error' + + PLSQL::LogSubscriber.logger = ActiveRecord::Base.logger + end end end end diff --git a/lib/rails-plpgsql.rb b/lib/rails-plpgsql.rb deleted file mode 100644 index 757d7a4..0000000 --- a/lib/rails-plpgsql.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'ruby-plpgsql' -require 'rails/engine' -require 'active_record/plxsql/compat' -require 'active_record/plpgsql/engine' diff --git a/lib/rails-plsql.rb b/lib/rails-plsql.rb index a6f318a..21e195b 100644 --- a/lib/rails-plsql.rb +++ b/lib/rails-plsql.rb @@ -5,14 +5,7 @@ # no oci8 end end -require 'active_record' -begin - require 'activerecord-oracle_enhanced-adapter' - require 'ruby-plsql' - require 'oracle/named_error' - require 'rails/engine' - require 'active_record/plsql/engine' -rescue LoadError - require 'rails-plpgsql' -end +require 'active_record' +require 'rails/engine' +require 'active_record/plsql/engine' From 23b299ae068b635a8b8829140e653120dd77f962 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Tue, 9 Dec 2025 16:52:22 +0100 Subject: [PATCH 09/17] Remove redundant require --- lib/rails-plsql.rb | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/rails-plsql.rb b/lib/rails-plsql.rb index 21e195b..c37d73c 100644 --- a/lib/rails-plsql.rb +++ b/lib/rails-plsql.rb @@ -1,11 +1,3 @@ -if RUBY_ENGINE == 'ruby' - begin - require 'oci8' - rescue LoadError - # no oci8 - end -end - require 'active_record' require 'rails/engine' require 'active_record/plsql/engine' From fb884d4bd2df4415978c5b2751f8fefaf12da362 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Sun, 14 Dec 2025 19:48:31 +0100 Subject: [PATCH 10/17] Pull return type info when set-of function --- lib/active_record/plpgsql/set_of.rb | 8 +++ lib/ruby-plpgsql.rb | 83 +++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/lib/active_record/plpgsql/set_of.rb b/lib/active_record/plpgsql/set_of.rb index 662b275..8319c46 100644 --- a/lib/active_record/plpgsql/set_of.rb +++ b/lib/active_record/plpgsql/set_of.rb @@ -93,6 +93,14 @@ def table_name_with_arguments ) end + def column_names + if set_of? + set_of_function.columns.map(&:name) + else + super + end + end + def table_exist? set_of? || super end diff --git a/lib/ruby-plpgsql.rb b/lib/ruby-plpgsql.rb index 8678b4d..1b1b07d 100644 --- a/lib/ruby-plpgsql.rb +++ b/lib/ruby-plpgsql.rb @@ -258,6 +258,89 @@ def set_of? ) =~ /setof/i end end + + def columns + if set_of? + # get return type of the function + return_type = @ar_class.connection.select_value(<<-SQL).downcase + SELECT pg_get_function_result(oid) FROM pg_proc WHERE proname = '#{@routine_name}' + SQL + + # Check if it's SETOF RECORD or SETOF specific_type + if return_type =~ /^setof record$/i + # For SETOF RECORD, get columns from function parameters (OUT params or RETURNS TABLE) + result = @ar_class.connection.select_all(<<-SQL) + SELECT + parameter_name, + data_type, + ordinal_position, + parameter_mode + FROM information_schema.parameters + WHERE specific_schema = '#{@schema_name.to_s.downcase}' + AND specific_name = '#{@specific_name}' + AND parameter_mode IN ('OUT', 'INOUT', 'TABLE') + ORDER BY ordinal_position + SQL + + result.map do |row| + name = row['parameter_name'] + type = row['data_type'] + + # Create a simple column-like object with name and type + OpenStruct.new( + name: name, + type: type, + sql_type: type + ) + end + else + # For SETOF specific_type, extract the type name and query its attributes + type_match = return_type.match(/^setof ([\w\.]+)/) + if type_match + type_name = type_match[1] + + # Check if type includes schema + if type_name.include?('.') + schema, table = type_name.split('.', 2) + else + # Use the function's schema as default + schema = @schema_name.to_s.downcase + table = type_name + end + + # Query composite type attributes from pg_type and pg_attribute + # This works for both custom composite types and table types + result = @ar_class.connection.select_all(<<-SQL) + SELECT + a.attname AS name, + format_type(a.atttypid, a.atttypmod) AS sql_type, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, + a.attnum AS position + FROM pg_type t + JOIN pg_namespace n ON t.typnamespace = n.oid + JOIN pg_attribute a ON a.attrelid = t.typrelid + WHERE n.nspname = '#{schema}' + AND t.typname = '#{table}' + AND a.attnum > 0 + AND NOT a.attisdropped + ORDER BY a.attnum + SQL + + result.map do |row| + OpenStruct.new( + name: row['name'], + type: row['data_type'], + sql_type: row['sql_type'] + ) + end + else + [] + end + end + else + nil + end + end end class Procedure < Routine From 5b7df55062a767b1f84d025c021b5dae6e0b98f8 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Sun, 14 Dec 2025 20:50:24 +0100 Subject: [PATCH 11/17] Implement columns_hash --- lib/active_record/plpgsql/set_of.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/active_record/plpgsql/set_of.rb b/lib/active_record/plpgsql/set_of.rb index 8319c46..bc1cb69 100644 --- a/lib/active_record/plpgsql/set_of.rb +++ b/lib/active_record/plpgsql/set_of.rb @@ -101,6 +101,14 @@ def column_names end end + def columns_hash + if set_of? + set_of_function.columns.to_h { |col| [col.name, col] } + else + super + end + end + def table_exist? set_of? || super end From 211cd5caf02b494702dcbaa62a659ebf4bb7fee8 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Sun, 14 Dec 2025 21:05:52 +0100 Subject: [PATCH 12/17] Handle missing routine --- lib/active_record/plpgsql/set_of.rb | 12 ++++++++++-- lib/ruby-plpgsql.rb | 12 ++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/active_record/plpgsql/set_of.rb b/lib/active_record/plpgsql/set_of.rb index bc1cb69..ce7ee70 100644 --- a/lib/active_record/plpgsql/set_of.rb +++ b/lib/active_record/plpgsql/set_of.rb @@ -95,7 +95,11 @@ def table_name_with_arguments def column_names if set_of? - set_of_function.columns.map(&:name) + if set_of_function.defined? + set_of_function.columns.map(&:name) + else + raise "Set of function not found: #{set_of_function.to_s}" + end else super end @@ -103,7 +107,11 @@ def column_names def columns_hash if set_of? - set_of_function.columns.to_h { |col| [col.name, col] } + if set_of_function.defined? + set_of_function.columns.to_h { |col| [col.name, col] } + else + raise "Set of function not found: #{set_of_function.to_s}" + end else super end diff --git a/lib/ruby-plpgsql.rb b/lib/ruby-plpgsql.rb index 1b1b07d..718a02f 100644 --- a/lib/ruby-plpgsql.rb +++ b/lib/ruby-plpgsql.rb @@ -143,6 +143,10 @@ def out_list end end + def defined? + true + end + private def get_argument_metadata @@ -352,9 +356,17 @@ def call(*args, &_block) end class MissingRoutine < Routine + def defined? + false + end + def call(*args, &_block) raise "Missing routine: #{@schema_name}.#{@routine_name}" end + + def to_s + "PLPGSQL::MissingRoutine(#{@schema_name}.#{@routine_name})" + end end private From 834f8127187047b3ba96b595d79e71ce3d9e0573 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Tue, 16 Dec 2025 12:00:54 +0100 Subject: [PATCH 13/17] Override load_schema! --- lib/active_record/plpgsql/set_of.rb | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/lib/active_record/plpgsql/set_of.rb b/lib/active_record/plpgsql/set_of.rb index ce7ee70..6555ebf 100644 --- a/lib/active_record/plpgsql/set_of.rb +++ b/lib/active_record/plpgsql/set_of.rb @@ -93,10 +93,16 @@ def table_name_with_arguments ) end - def column_names + def load_schema! + unless set_of? + super + end + end + + def columns if set_of? if set_of_function.defined? - set_of_function.columns.map(&:name) + set_of_function.columns else raise "Set of function not found: #{set_of_function.to_s}" end @@ -105,13 +111,17 @@ def column_names end end + def column_names + if set_of? + columns.map(&:name) + else + super + end + end + def columns_hash if set_of? - if set_of_function.defined? - set_of_function.columns.to_h { |col| [col.name, col] } - else - raise "Set of function not found: #{set_of_function.to_s}" - end + columns.to_h { |col| [col.name, col] } else super end From a87e136327b753cb2eb0e10b6a399a2794ff643d Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Tue, 16 Dec 2025 14:39:27 +0100 Subject: [PATCH 14/17] Add only not added out/in out params --- lib/ruby-plpgsql.rb | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/ruby-plpgsql.rb b/lib/ruby-plpgsql.rb index 718a02f..29088f1 100644 --- a/lib/ruby-plpgsql.rb +++ b/lib/ruby-plpgsql.rb @@ -205,17 +205,18 @@ def args_to_string(args) [value_to_string(arg)] end }, - *out_args + *out_args(args[0]) ].join(', ') end - def out_args - @out_args ||= begin - args = arguments[0] || {} - argument_list.select { |k| args[k][:in_out] =~ /OUT/ }.map { |k| - arg_name = @argument_handler.call(self, k) - "#{arg_name} => NULL" - } + def out_args(in_args) + args = arguments[0] || {} + argument_list.select { |k| + args[k][:in_out].end_with?('OUT') && + !in_args.is_a?(Hash) && !in_args.include?(k) + }.map do |k| + arg_name = @argument_handler.call(self, k) + "#{arg_name} => NULL" end end From 7486dcf65924369e5ace7b7c70a36c291f1f6420 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Tue, 23 Dec 2025 09:25:16 +0100 Subject: [PATCH 15/17] Fix condition --- lib/ruby-plpgsql.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ruby-plpgsql.rb b/lib/ruby-plpgsql.rb index 29088f1..e3710bb 100644 --- a/lib/ruby-plpgsql.rb +++ b/lib/ruby-plpgsql.rb @@ -213,7 +213,7 @@ def out_args(in_args) args = arguments[0] || {} argument_list.select { |k| args[k][:in_out].end_with?('OUT') && - !in_args.is_a?(Hash) && !in_args.include?(k) + (!in_args.is_a?(Hash) || !in_args.include?(k)) }.map do |k| arg_name = @argument_handler.call(self, k) "#{arg_name} => NULL" From 415f415fdd65fbb062b33ce6124b1b4e6294819d Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Tue, 23 Dec 2025 18:09:39 +0100 Subject: [PATCH 16/17] Check if schema exists --- lib/ruby-plpgsql.rb | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/ruby-plpgsql.rb b/lib/ruby-plpgsql.rb index e3710bb..71d3368 100644 --- a/lib/ruby-plpgsql.rb +++ b/lib/ruby-plpgsql.rb @@ -34,14 +34,33 @@ def initialize(ar_class:, schema_name:, argument_handler:) @cache = {} @name_cache = {} @argument_handler = argument_handler + check_schema_exists end def [](function_name) - @cache[normalize_function_name(function_name)] ||= resolve_routine(function_name) + normalized_function_name = normalize_function_name(function_name) + + if @cache.key?(normalized_function_name) + @cache[normalized_function_name] + else + @cache[normalized_function_name] = resolve_routine(normalized_function_name) + end end private + def check_schema_exists + exists = @ar_class.connection.select_value(<<-SQL) + SELECT EXISTS( + SELECT 1 FROM information_schema.schemata WHERE schema_name = '#{@schema_name.to_s.downcase}' + ) + SQL + + unless exists + raise ArgumentError, "Schema #{@schema_name} does not exist" + end + end + def normalize_function_name(name) @name_cache[name] ||= name.to_s.downcase end From 6dc591ce84e8deeaf158bce87098896ecee6ed5e Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Thu, 25 Dec 2025 12:30:15 +0100 Subject: [PATCH 17/17] Make error matching more flexible --- lib/plpgsql/named_error.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plpgsql/named_error.rb b/lib/plpgsql/named_error.rb index 76d698e..cf0f5aa 100644 --- a/lib/plpgsql/named_error.rb +++ b/lib/plpgsql/named_error.rb @@ -8,7 +8,7 @@ class << self def ===(error) if error.respond_to?(:message) error.message.start_with?(CLASS_NAME) && - error.message.include?("ERROR: [#{error_code}]") + error.message.match?(/ERROR: \[-?#{error_code}\]/) else false end