From 5e082a73579cbd0755bf98a0042053167d204da4 Mon Sep 17 00:00:00 2001 From: Natalia Korosteleva Date: Thu, 9 Jul 2026 18:38:43 +0300 Subject: [PATCH] HUPO-1039 Prepare ruby-plsql for Rails 7.2, Ruby 3.4, and Oracle 18c+ --- .gitignore | 2 + VERSION | 2 +- lib/plsql/oci_connection.rb | 15 +- lib/plsql/procedure.rb | 122 +++++- lib/plsql/procedure_call.rb | 4 +- lib/plsql/schema.rb | 4 +- lib/plsql/subprogram_call.rb | 14 +- lib/plsql/table.rb | 8 +- lib/plsql/variable.rb | 4 +- ruby-plsql.gemspec | 2 +- spec/plsql/schema_spec.rb | 19 +- spec/unit/default_timezone_test.rb | 110 +++++ .../oracle18c_metadata_enrichment_test.rb | 390 ++++++++++++++++++ test/compatibility/oci_unwrap_test.rb | 126 ++++++ .../ruby34_compatibility_test.rb | 63 +++ 15 files changed, 856 insertions(+), 29 deletions(-) create mode 100644 spec/unit/default_timezone_test.rb create mode 100644 spec/unit/oracle18c_metadata_enrichment_test.rb create mode 100644 test/compatibility/oci_unwrap_test.rb create mode 100644 test/compatibility/ruby34_compatibility_test.rb diff --git a/.gitignore b/.gitignore index 5ede6ec1..5099bbd2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ tmp sqlnet.log Gemfile.lock .idea + +.tool-versions \ No newline at end of file diff --git a/VERSION b/VERSION index 79a2734b..a918a2aa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 \ No newline at end of file +0.6.0 diff --git a/lib/plsql/oci_connection.rb b/lib/plsql/oci_connection.rb index a74cd9d8..8d042b4c 100644 --- a/lib/plsql/oci_connection.rb +++ b/lib/plsql/oci_connection.rb @@ -174,12 +174,12 @@ def plsql_to_ruby_data_type(metadata) def ruby_value_to_ora_value(value, type=nil) type ||= value.class case type.to_s.to_sym - when :Fixnum, :BigDecimal, :String + when :Integer, :BigDecimal, :String value when :OraNumber # pass parameters as OraNumber to avoid rounding errors case value - when Bignum + when Integer OraNumber.new(value.to_s) when BigDecimal OraNumber.new(value.to_s('F')) @@ -299,16 +299,19 @@ def database_version def raw_oci_connection if raw_connection.is_a? OCI8 raw_connection - # ActiveRecord Oracle enhanced adapter puts OCI8EnhancedAutoRecover wrapper around OCI8 - # in this case we need to pass original OCI8 connection + # ActiveRecord Oracle enhanced adapter wraps OCI8 in OCI8EnhancedAutoRecover. + # Current oracle-enhanced stores the bare OCI8 in @raw_connection. + # Legacy oracle-enhanced stored it in @connection. + # Support both for safe unwrapping. else - raw_connection.instance_variable_get(:@connection) + raw_connection.instance_variable_get(:@raw_connection) || + raw_connection.instance_variable_get(:@connection) end end def ora_number_to_ruby_number(num) # return BigDecimal instead of Float to avoid rounding errors - num == (num_to_i = num.to_i) ? num_to_i : (num.is_a?(BigDecimal) ? num : BigDecimal.new(num.to_s)) + num == (num_to_i = num.to_i) ? num_to_i : (num.is_a?(BigDecimal) ? num : BigDecimal(num.to_s)) end def ora_date_to_ruby_date(val) diff --git a/lib/plsql/procedure.rb b/lib/plsql/procedure.rb index 2522d4c5..35093907 100644 --- a/lib/plsql/procedure.rb +++ b/lib/plsql/procedure.rb @@ -193,6 +193,8 @@ def get_argument_metadata #:nodoc: # if procedure is without arguments then create default empty argument list for default overload @arguments[0] = {} if @arguments.keys.empty? + enrich_composite_metadata_from_plsql_views + construct_argument_list_for_overloads end @@ -207,7 +209,7 @@ def construct_argument_list_for_overloads #:nodoc: def ensure_tmp_tables_created(overload) #:nodoc: return if @tmp_tables_created.nil? || @tmp_tables_created[overload] @tmp_table_names[overload] && @tmp_table_names[overload].each do |table_name, argument_metadata| - sql = "CREATE GLOBAL TEMPORARY TABLE #{table_name} (\n" + sql = +"CREATE GLOBAL TEMPORARY TABLE #{table_name} (\n" element_metadata = argument_metadata[:element] case element_metadata[:data_type] when 'PL/SQL RECORD' @@ -238,6 +240,122 @@ def collection_type?(data_type) #:nodoc: PLSQL_COLLECTION_TYPES.include? data_type end + # KNOWN_SQL_TYPES: element type names that are scalar SQL data types (not composite). + # Used by enrich_composite_metadata_from_plsql_views to distinguish scalar collections + # (e.g. TABLE OF VARCHAR2) from record/object collections (e.g. TABLE OF T_RECORD). + KNOWN_SQL_TYPES = %w[ + NUMBER VARCHAR VARCHAR2 NVARCHAR2 NCHAR CHAR CLOB BLOB BFILE RAW + INTEGER INT SMALLINT FLOAT DOUBLE PRECISION BINARY_FLOAT BINARY_DOUBLE + DATE TIMESTAMP LONG LONG RAW BOOLEAN + ].freeze + + # Oracle 18c+ changed ALL_ARGUMENTS behavior: composite type arguments/returns appear + # as a single row at data_level=0; nested rows (data_level>0) are no longer present. + # This method enriches @return metadata by querying ALL_PLSQL_COLL_TYPES and + # ALL_PLSQL_TYPE_ATTRS to reconstruct the missing :element and :fields structure. + def enrich_composite_metadata_from_plsql_views #:nodoc: + @return.each_key do |overload| + return_meta = @return[overload] + next unless return_meta + next unless collection_type?(return_meta[:data_type]) + next unless return_meta[:element].nil? + + type_owner = return_meta[:type_owner] || @schema_name + + if return_meta[:type_subname] + # Package-local type: TYPE_NAME=package, TYPE_SUBNAME=local type + type_name = return_meta[:type_subname] + coll_package_name = return_meta[:type_name] + else + # Schema-level type + type_name = return_meta[:type_name] + coll_package_name = nil + end + next unless type_name + + if coll_package_name + coll_row = @schema.select_first( + "SELECT coll_type, elem_type_name, elem_type_owner, elem_type_package, " \ + "length, precision, scale " \ + "FROM all_plsql_coll_types " \ + "WHERE type_name = :type_name AND owner = :owner AND package_name = :package_name", + type_name, type_owner, coll_package_name + ) + else + coll_row = @schema.select_first( + "SELECT coll_type, elem_type_name, elem_type_owner, NULL AS elem_type_package, " \ + "length, precision, scale " \ + "FROM all_plsql_coll_types " \ + "WHERE type_name = :type_name AND owner = :owner", + type_name, type_owner + ) + end + next unless coll_row + + _coll_type, elem_type_name, elem_type_owner, elem_type_package, + elem_length, elem_precision, elem_scale = coll_row + + is_simple_type = KNOWN_SQL_TYPES.include?(elem_type_name) + data_type = is_simple_type ? elem_type_name : 'PL/SQL RECORD' + + element_metadata = { + data_type: data_type, + type_name: elem_type_name, + type_owner: elem_type_owner, + data_length: elem_length && elem_length.to_i, + data_precision: elem_precision && elem_precision.to_i, + data_scale: elem_scale && elem_scale.to_i, + position: nil, + in_out: return_meta[:in_out], + } + + unless is_simple_type + element_metadata[:fields] = {} + fetch_plsql_type_fields(elem_type_name, elem_type_owner || @schema_name, + element_metadata[:fields], package_name: elem_type_package) + end + + return_meta[:element] = element_metadata + end + end + + # Fetch field definitions from ALL_PLSQL_TYPE_ATTRS for a record/object type. + # When package_name: is provided, queries package-local type attrs. + def fetch_plsql_type_fields(type_name, type_owner, fields_hash, package_name: nil) #:nodoc: + if package_name + rows = @schema.select_all( + "SELECT attr_name, attr_type_name, attr_no, " \ + "length, precision, scale, character_set_name " \ + "FROM all_plsql_type_attrs " \ + "WHERE type_name = :type_name AND owner = :owner AND package_name = :package_name " \ + "ORDER BY attr_no", + type_name, type_owner, package_name + ) + else + rows = @schema.select_all( + "SELECT attr_name, attr_type_name, attr_no, " \ + "length, precision, scale, character_set_name " \ + "FROM all_plsql_type_attrs " \ + "WHERE type_name = :type_name AND owner = :owner " \ + "ORDER BY attr_no", + type_name, type_owner + ) + end + rows.each do |row| + attr_name, attr_type_name, attr_no, + length_val, precision, scale, _char_set_name = row + + fields_hash[attr_name.downcase.to_sym] = { + data_type: attr_type_name, + position: attr_no && attr_no.to_i, + data_length: length_val && length_val.to_i, + data_precision: precision && precision.to_i, + data_scale: scale && scale.to_i, + in_out: nil, + } + end + end + def overloaded? #:nodoc: @overloaded end @@ -266,4 +384,4 @@ def exec(*args, &block) end end -end \ No newline at end of file +end diff --git a/lib/plsql/procedure_call.rb b/lib/plsql/procedure_call.rb index 1c94ffbc..94daac5e 100644 --- a/lib/plsql/procedure_call.rb +++ b/lib/plsql/procedure_call.rb @@ -37,7 +37,7 @@ def exec def construct_sql(arguments) prepare_sql_construction - call_sql = "" + call_sql = +"" call_sql << add_return if return_metadata # construct procedure call if procedure name is available @@ -121,4 +121,4 @@ def dbms_output_log output_stream.flush end end -end \ No newline at end of file +end diff --git a/lib/plsql/schema.rb b/lib/plsql/schema.rb index bc119b6e..5b6a83db 100644 --- a/lib/plsql/schema.rb +++ b/lib/plsql/schema.rb @@ -100,7 +100,7 @@ def default_timezone else @default_timezone || # Use ActiveRecord class default_timezone when ActiveRecord connection is used - (@connection && (ar_class = @connection.activerecord_class) && ar_class.default_timezone) || + (@connection && (ar_class = @connection.activerecord_class) && (defined?(ActiveRecord) && ActiveRecord.respond_to?(:default_timezone) ? ActiveRecord.default_timezone : ar_class.default_timezone)) || # default to local timezone :local end @@ -253,7 +253,7 @@ def find_database_object(name, override_schema_name = nil) end def _errors(object_schema_name, object_name, object_type) - result = "" + result = +"" previous_line = 0 select_all( "SELECT e.line, e.position, e.text error_text, s.text source_text diff --git a/lib/plsql/subprogram_call.rb b/lib/plsql/subprogram_call.rb index 62a33981..7226bfcf 100644 --- a/lib/plsql/subprogram_call.rb +++ b/lib/plsql/subprogram_call.rb @@ -15,10 +15,10 @@ def initialize(subprogram, args = [], options = {}) private def prepare_sql_construction - @declare_sql = "" - @assignment_sql = "" + @declare_sql = +"" + @assignment_sql = +"" @binds = {:values => {}, :metadata => {}} - @return = {:sql => '', :variables => [], :metadata => {}} + @return = {:sql => +'', :variables => [], :metadata => {}} end def get_overload_from_arguments_list(args) @@ -106,7 +106,7 @@ def matching_oracle_types_for_ruby_value(value) case value when NilClass :all - when Fixnum, Bignum + when Integer MATCHING_TYPES[:integer] when BigDecimal, Float MATCHING_TYPES[:decimal] @@ -213,7 +213,7 @@ def add_argument(argument, value, argument_metadata=nil) ## Next methods adds declaration in DECLARE block def add_variable_declaration(name, type, options = {}) - variable_name = options[:output_variable] ? 'o_' : 'l_' + variable_name = options[:output_variable] ? +'o_' : +'l_' variable_name << name.to_s << ' ' << type.to_s.upcase variable_name << ' := '<< options[:value].to_s if options[:value] variable_name << ";\n" @@ -381,7 +381,7 @@ def return_variable_value(argument, argument_metadata) end def record_assignment_sql_values_metadata(argument, argument_metadata, record_value) - sql = "" + sql = +"" bind_values = {} bind_metadata = {} (record_value||{}).each do |key, value| @@ -538,4 +538,4 @@ def insert_values_into_tmp_table(argument, argument_metadata, values) @schema.connection.autocommit = true if old_autocommit end end -end \ No newline at end of file +end diff --git a/lib/plsql/table.rb b/lib/plsql/table.rb index d457cbfb..ce6f7a09 100644 --- a/lib/plsql/table.rb +++ b/lib/plsql/table.rb @@ -88,9 +88,9 @@ def column_names def select(first_or_all, sql_params='', *bindvars) case first_or_all when :first, :all - select_sql = "SELECT * " + select_sql = +"SELECT * " when :count - select_sql = "SELECT COUNT(*) " + select_sql = +"SELECT COUNT(*) " else raise ArgumentError, "Only :first, :all or :count are supported" end @@ -232,7 +232,7 @@ def update(params) # # => DELETE FROM employees WHERE employee_id = 1 # def delete(sql_params='', *bindvars) - delete_sql = "DELETE FROM \"#{@schema_name}\".\"#{@table_name}\" " + delete_sql = +"DELETE FROM \"#{@schema_name}\".\"#{@table_name}\" " case sql_params when String delete_sql << sql_params @@ -334,7 +334,7 @@ def call_sql(params_string) when :insert "INSERT INTO \"#{@table.schema_name}\".\"#{@table.table_name}\"(#{@argument_list[0].map{|a| a.to_s}.join(', ')}) VALUES (#{params_string});\n" when :update - update_sql = "UPDATE \"#{@table.schema_name}\".\"#{@table.table_name}\" SET #{@set_sqls.join(', ')}" + update_sql = +"UPDATE \"#{@table.schema_name}\".\"#{@table.table_name}\" SET #{@set_sqls.join(', ')}" update_sql << " WHERE #{@where_sqls.join(' AND ')}" unless @where_sqls.empty? update_sql << ";\n" update_sql diff --git a/lib/plsql/variable.rb b/lib/plsql/variable.rb index 6c573a8d..d47f3f60 100644 --- a/lib/plsql/variable.rb +++ b/lib/plsql/variable.rb @@ -128,7 +128,7 @@ def procedure end def call_sql(params_string) - sql = (schema_name = @variable.schema_name) ? "#{schema_name}." : "" + sql = (schema_name = @variable.schema_name) ? "#{schema_name}." : +"" sql << "#{@variable.package_name}.#{@variable.variable_name}" case @operation when :get @@ -143,4 +143,4 @@ def call_sql(params_string) end -end \ No newline at end of file +end diff --git a/ruby-plsql.gemspec b/ruby-plsql.gemspec index b3848787..73157d96 100644 --- a/ruby-plsql.gemspec +++ b/ruby-plsql.gemspec @@ -5,7 +5,7 @@ Gem::Specification.new do |s| s.name = "ruby-plsql" - s.version = "0.5.0" + s.version = "0.6.0" s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= s.authors = ["Raimonds Simanovskis"] diff --git a/spec/plsql/schema_spec.rb b/spec/plsql/schema_spec.rb index f4bd2eca..c81291c0 100644 --- a/spec/plsql/schema_spec.rb +++ b/spec/plsql/schema_spec.rb @@ -201,11 +201,26 @@ class TestModel < TestBaseModel plsql.schema_name.should == 'HR' end - it "should use ActiveRecord::Base.default_timezone as default" do - ActiveRecord::Base.default_timezone = :utc + it "should use ActiveRecord class default_timezone as default" do + tz_method = if defined?(ActiveRecord) && ActiveRecord.respond_to?(:default_timezone) + ActiveRecord + else + ActiveRecord::Base + end + tz_method.default_timezone = :utc plsql.default_timezone.should == :utc end + it "should prefer ActiveRecord.default_timezone over ActiveRecord::Base.default_timezone on Rails 7.2+" do + if defined?(ActiveRecord) && ActiveRecord.respond_to?(:default_timezone) + ActiveRecord.default_timezone = :local + ActiveRecord::Base.default_timezone = :utc rescue nil + plsql.default_timezone.should == :local + else + # On pre-7.2 Rails, ActiveRecord.default_timezone is not available; skip this test. + end + end + it "should have the same connection as default schema" do plsql.hr.connection.should == plsql.connection end diff --git a/spec/unit/default_timezone_test.rb b/spec/unit/default_timezone_test.rb new file mode 100644 index 00000000..20da8dba --- /dev/null +++ b/spec/unit/default_timezone_test.rb @@ -0,0 +1,110 @@ +# encoding: utf-8 +# +# Targeted unit test for PLSQL::Schema#default_timezone Rails 7.2 compatibility. +# Does NOT require Oracle connection. +# +# Usage: +# ruby -Ilib -Ispec/unit spec/unit/default_timezone_test.rb + +$LOAD_PATH.unshift(File.dirname(__FILE__) + '/../lib') + +require 'minitest/autorun' + +require 'ruby-plsql' + +class TestDefaultTimezone < Minitest::Test + def setup + @schema = PLSQL::Schema.new + end + + def test_default_timezone_fallback_to_local + assert_equal :local, @schema.default_timezone + end + + def test_explicit_default_timezone + @schema.default_timezone = :utc + assert_equal :utc, @schema.default_timezone + @schema.default_timezone = :local + assert_equal :local, @schema.default_timezone + end + + def test_default_timezone_raises_on_invalid_value + assert_raises(ArgumentError) { @schema.default_timezone = :invalid } + end + + def test_delegates_to_original_schema + original = PLSQL::Schema.new + original.default_timezone = :utc + wrapper = PLSQL::Schema.new + wrapper.instance_variable_set(:@original_schema, original) + assert_equal :utc, wrapper.default_timezone + end +end + +# Test the fix logic expression independently of ActiveRecord runtime. +# The expression now in PLSQL::Schema#default_timezone is: +# (@connection && (ar_class = @connection.activerecord_class) && +# (defined?(ActiveRecord) && ActiveRecord.respond_to?(:default_timezone) ? +# ActiveRecord.default_timezone : ar_class.default_timezone)) || :local +class TestDefaultTimezoneFixExpression < Minitest::Test + def test_prefers_active_record_module_when_available + # Rails 7.2+: ActiveRecord module responds to .default_timezone + ar_module = stub_ar_module(:local) + ar_class = stub_ar_class(:utc) + + result = if defined?(ar_module) && ar_module.respond_to?(:default_timezone) + ar_module.default_timezone + else + ar_class.default_timezone + end + + assert_equal :local, result, "Should prefer ActiveRecord module (Rails 7.2+)" + end + + def test_falls_back_to_ar_class_when_module_not_responding + # pre-7.2 Rails: ActiveRecord module does not respond to .default_timezone + ar_module = stub_ar_module(nil, respond_to_default_timezone: false) + ar_class = stub_ar_class(:utc) + + result = if defined?(ar_module) && ar_module.respond_to?(:default_timezone) + ar_module.default_timezone + else + ar_class.default_timezone + end + + assert_equal :utc, result, "Should fall back to ar_class.default_timezone on pre-7.2" + end + + def test_fallback_to_local_when_nothing_available + connection = nil + ar_class = nil + ar_module = nil + + result = (connection && (ar_class = connection) && + (defined?(ar_module) && ar_module.respond_to?(:default_timezone) ? + ar_module.default_timezone : (ar_class && ar_class.default_timezone))) || :local + + assert_equal :local, result + end + + private + + def stub_ar_module(tz, respond_to_default_timezone: true) + mod = Module.new + mod.define_singleton_method(:default_timezone) { tz } if respond_to_default_timezone + mod.define_singleton_method(:respond_to?) do |method_name, *args| + if method_name == :default_timezone + respond_to_default_timezone + else + super(method_name, *args) + end + end + mod + end + + def stub_ar_class(tz) + Class.new do + define_singleton_method(:default_timezone) { tz } + end + end +end diff --git a/spec/unit/oracle18c_metadata_enrichment_test.rb b/spec/unit/oracle18c_metadata_enrichment_test.rb new file mode 100644 index 00000000..5eb0cba0 --- /dev/null +++ b/spec/unit/oracle18c_metadata_enrichment_test.rb @@ -0,0 +1,390 @@ +# encoding: utf-8 +# +# Unit test for Oracle 18c+ composite metadata enrichment in +# PLSQL::ProcedureCommon#enrich_composite_metadata_from_plsql_views. +# Does NOT require Oracle connection. +# +# Usage: +# ruby -Ilib -Ispec/unit spec/unit/oracle18c_metadata_enrichment_test.rb +# + +$LOAD_PATH.unshift(File.dirname(__FILE__) + '/../lib') + +require 'minitest/autorun' +require 'ruby-plsql' + +class TestOracle18cMetadataEnrichment < Minitest::Test + # Mock schema that returns canned responses for select_first / select_all + class MockSchema + attr_reader :schema_name + + def initialize(schema_name = 'HR') + @schema_name = schema_name + end + + def select_first(sql, *bindvars) + # Simulate ALL_PLSQL_COLL_TYPES lookup + if sql.include?('all_plsql_coll_types') + # Package-local query has package_name in WHERE clause + if sql.include?('package_name') + type_name = bindvars[0] + package_name = bindvars[2] + case [type_name, package_name] + when ['T_PKG_TABLE', 'MY_PKG'] + # TABLE OF T_PKG_REC (package-local record element -> has fields) + ['TABLE', 'T_PKG_REC', 'HR', 'MY_PKG', nil, nil, nil] + when ['T_PKG_VC_TABLE', 'MY_PKG'] + # TABLE OF VARCHAR2 (scalar element -> no fields) + ['TABLE', 'VARCHAR2', nil, nil, 100, nil, nil] + else + nil + end + else + type_name = bindvars[0] + case type_name + when 'T_RECORD_TABLE' + # TABLE OF T_RECORD (object/record element -> has fields) + ['TABLE', 'T_RECORD', 'HR', nil, nil, nil, nil] + when 'T_VC_TABLE' + # TABLE OF VARCHAR2 (scalar element -> no fields) + ['TABLE', 'VARCHAR2', nil, nil, 100, nil, nil] + when 'T_NUM_TABLE' + # TABLE OF NUMBER (scalar element -> no fields) + ['TABLE', 'NUMBER', nil, nil, nil, 10, 2] + when 'T_NO_TYPE' + # Collection type not found in ALL_PLSQL_COLL_TYPES + nil + else + nil + end + end + else + nil + end + end + + def select_all(sql, *bindvars, &block) + # Simulate ALL_PLSQL_TYPE_ATTRS lookup + if sql.include?('all_plsql_type_attrs') + # Package-local query + if sql.include?('package_name') + type_name = bindvars[0] + package_name = bindvars[2] + case [type_name, package_name] + when ['T_PKG_REC', 'MY_PKG'] + rows = [ + ['ID', 'NUMBER', 1, nil, 10, 0, nil], + ['NAME', 'VARCHAR2', 2, 100, nil, nil, nil], + ] + if block + rows.each { |r| block.call(r) } + rows.size + else + rows + end + else + [] + end + else + type_name = bindvars[0] + case type_name + when 'T_RECORD' + rows = [ + ['ID', 'NUMBER', 1, nil, 10, 0, nil], + ['NAME', 'VARCHAR2', 2, 100, nil, nil, nil], + ] + if block + rows.each { |r| block.call(r) } + rows.size + else + rows + end + else + [] + end + end + else + [] + end + end + + def execute(sql, *bindvars) + nil + end + end + + def setup + @mock_schema = MockSchema.new('HR') + @return = {} + end + + # Simulate a ProcedureCommon enrichment call by calling + # enrich_composite_metadata_from_plsql_views on a test object. + def create_test_procedure(return_meta) + obj = Object.new + obj.extend(PLSQL::ProcedureCommon) + + # Set instance variables the enrichment method expects + obj.instance_variable_set(:@schema, @mock_schema) + obj.instance_variable_set(:@return, { 0 => return_meta }) + obj.instance_variable_set(:@schema_name, 'HR') + obj.instance_variable_set(:@arguments, { 0 => {} }) + + obj + end + + def test_collection_of_record_enriches_element_and_fields + # Simulate Oracle 18c+ ALL_ARGUMENTS return: + # one row, data_type='TABLE', type_name='T_RECORD_TABLE' + return_meta = { + data_type: 'TABLE', + type_name: 'T_RECORD_TABLE', + type_owner: 'HR', + type_subname: nil, + position: nil, + in_out: 'OUT', + } + + procedure = create_test_procedure(return_meta) + procedure.send(:enrich_composite_metadata_from_plsql_views) + + enriched = procedure.instance_variable_get(:@return)[0] + + refute_nil enriched[:element], 'TABLE element should be populated from ALL_PLSQL_COLL_TYPES' + assert_equal 'PL/SQL RECORD', enriched[:element][:data_type], + 'Element data_type should be PL/SQL RECORD for object/record element' + assert_equal 'T_RECORD', enriched[:element][:type_name] + + fields = enriched[:element][:fields] + refute_nil fields, 'Fields should be populated for record element' + refute_empty fields, 'Fields should not be empty' + + id_field = fields[:id] + refute_nil id_field, 'Should have :id field' + assert_equal 'NUMBER', id_field[:data_type] + assert_equal 1, id_field[:position] + assert_equal 10, id_field[:data_precision] + assert_equal 0, id_field[:data_scale] + + name_field = fields[:name] + refute_nil name_field, 'Should have :name field' + assert_equal 'VARCHAR2', name_field[:data_type] + assert_equal 2, name_field[:position] + assert_equal 100, name_field[:data_length] + end + + def test_collection_of_scalar_enriches_element_without_fields + # TABLE OF VARCHAR2 + return_meta = { + data_type: 'TABLE', + type_name: 'T_VC_TABLE', + type_owner: 'HR', + type_subname: nil, + position: nil, + in_out: 'OUT', + } + + procedure = create_test_procedure(return_meta) + procedure.send(:enrich_composite_metadata_from_plsql_views) + + enriched = procedure.instance_variable_get(:@return)[0] + + refute_nil enriched[:element], 'Scalar TABLE element should be populated' + assert_equal 'VARCHAR2', enriched[:element][:data_type], + 'Element data_type should be VARCHAR2 for scalar element' + assert_equal 100, enriched[:element][:data_length] + assert_nil enriched[:element][:fields], + 'Scalar element should NOT have :fields' + end + + def test_collection_of_number_enriches_element_without_fields + # TABLE OF NUMBER(10,2) + return_meta = { + data_type: 'TABLE', + type_name: 'T_NUM_TABLE', + type_owner: 'HR', + type_subname: nil, + position: nil, + in_out: 'OUT', + } + + procedure = create_test_procedure(return_meta) + procedure.send(:enrich_composite_metadata_from_plsql_views) + + enriched = procedure.instance_variable_get(:@return)[0] + + refute_nil enriched[:element] + assert_equal 'NUMBER', enriched[:element][:data_type], + 'Element data_type should be NUMBER' + assert_equal 10, enriched[:element][:data_precision] + assert_equal 2, enriched[:element][:data_scale] + assert_nil enriched[:element][:fields] + end + + def test_skips_when_type_not_in_plsql_coll_types + # Collection type not found in ALL_PLSQL_COLL_TYPES + return_meta = { + data_type: 'TABLE', + type_name: 'T_NO_TYPE', + type_owner: 'HR', + type_subname: nil, + position: nil, + in_out: 'OUT', + } + + procedure = create_test_procedure(return_meta) + procedure.send(:enrich_composite_metadata_from_plsql_views) + + enriched = procedure.instance_variable_get(:@return)[0] + + assert_nil enriched[:element], + 'Should skip enrichment when type not found in ALL_PLSQL_COLL_TYPES' + end + + def test_skips_when_element_already_present + # Pre-18c case: element is already populated + return_meta = { + data_type: 'TABLE', + type_name: 'T_RECORD_TABLE', + type_owner: 'HR', + type_subname: nil, + element: { data_type: 'PL/SQL RECORD' }, + } + + procedure = create_test_procedure(return_meta) + procedure.send(:enrich_composite_metadata_from_plsql_views) + + enriched = procedure.instance_variable_get(:@return)[0] + + # Should NOT overwrite existing element + assert_equal 'PL/SQL RECORD', enriched[:element][:data_type], + 'Should keep pre-existing element metadata' + end + + def test_package_local_collection_of_record_enriches_element_and_fields + # Oracle 18c+: package-local type with type_subname + # TYPE_NAME=package, TYPE_SUBNAME=local collection type + return_meta = { + data_type: 'TABLE', + type_name: 'MY_PKG', + type_owner: 'HR', + type_subname: 'T_PKG_TABLE', # package-local type + position: nil, + in_out: 'OUT', + } + + procedure = create_test_procedure(return_meta) + procedure.send(:enrich_composite_metadata_from_plsql_views) + + enriched = procedure.instance_variable_get(:@return)[0] + + refute_nil enriched[:element], + 'Package-local TABLE element should be populated from ALL_PLSQL_COLL_TYPES' + assert_equal 'PL/SQL RECORD', enriched[:element][:data_type], + 'Element data_type should be PL/SQL RECORD for record element' + assert_equal 'T_PKG_REC', enriched[:element][:type_name] + + fields = enriched[:element][:fields] + refute_nil fields, 'Fields should be populated for package-local record element' + refute_empty fields, 'Fields should not be empty' + + id_field = fields[:id] + refute_nil id_field, 'Should have :id field' + assert_equal 'NUMBER', id_field[:data_type] + assert_equal 1, id_field[:position] + + name_field = fields[:name] + refute_nil name_field, 'Should have :name field' + assert_equal 'VARCHAR2', name_field[:data_type] + assert_equal 2, name_field[:position] + end + + def test_package_local_collection_of_scalar_enriches_element_without_fields + # Package-local TABLE OF VARCHAR2 + return_meta = { + data_type: 'TABLE', + type_name: 'MY_PKG', + type_owner: 'HR', + type_subname: 'T_PKG_VC_TABLE', # package-local type + position: nil, + in_out: 'OUT', + } + + procedure = create_test_procedure(return_meta) + procedure.send(:enrich_composite_metadata_from_plsql_views) + + enriched = procedure.instance_variable_get(:@return)[0] + + refute_nil enriched[:element], + 'Package-local scalar TABLE element should be populated' + assert_equal 'VARCHAR2', enriched[:element][:data_type], + 'Element data_type should be VARCHAR2 for scalar element' + assert_equal 100, enriched[:element][:data_length] + assert_nil enriched[:element][:fields], + 'Scalar element should NOT have :fields' + end + + def test_skips_package_local_type_not_found_in_coll_types + # Package-local collection type that is NOT found in ALL_PLSQL_COLL_TYPES + return_meta = { + data_type: 'TABLE', + type_name: 'MY_PKG', + type_owner: 'HR', + type_subname: 'T_UNKNOWN', # not defined in ALL_PLSQL_COLL_TYPES + position: nil, + in_out: 'OUT', + } + + procedure = create_test_procedure(return_meta) + procedure.send(:enrich_composite_metadata_from_plsql_views) + + enriched = procedure.instance_variable_get(:@return)[0] + + assert_nil enriched[:element], + 'Should skip package-local type when not found in ALL_PLSQL_COLL_TYPES' + end + + def test_skips_when_return_is_not_a_collection + # Non-collection composite type: PL/SQL RECORD (not a TABLE/VARRAY) + return_meta = { + data_type: 'PL/SQL RECORD', + type_name: 'T_RECORD', + type_owner: 'HR', + type_subname: nil, + position: nil, + in_out: 'OUT', + fields: {}, + } + + procedure = create_test_procedure(return_meta) + procedure.send(:enrich_composite_metadata_from_plsql_views) + + enriched = procedure.instance_variable_get(:@return)[0] + + refute_nil enriched[:fields], + 'PL/SQL RECORD should not be affected by enrichment' + assert_nil enriched[:element], + 'PL/SQL RECORD should not get :element key' + end + + def test_enriches_owner_fallback_to_schema_name + # When type_owner is nil, enrichment should fall back to @schema_name + return_meta = { + data_type: 'TABLE', + type_name: 'T_RECORD_TABLE', + type_owner: nil, + type_subname: nil, + position: nil, + in_out: 'OUT', + } + + procedure = create_test_procedure(return_meta) + procedure.send(:enrich_composite_metadata_from_plsql_views) + + enriched = procedure.instance_variable_get(:@return)[0] + + refute_nil enriched[:element], + 'Element should be populated even when type_owner is nil (fallback to schema_name)' + assert_equal 'PL/SQL RECORD', enriched[:element][:data_type] + end +end diff --git a/test/compatibility/oci_unwrap_test.rb b/test/compatibility/oci_unwrap_test.rb new file mode 100644 index 00000000..bdd695d1 --- /dev/null +++ b/test/compatibility/oci_unwrap_test.rb @@ -0,0 +1,126 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Tests for raw_oci_connection unwrapping behavior. +# These tests verify that ruby-plsql can correctly unwrap: +# - bare OCI8 connection +# - wrapper with @raw_connection (current oracle-enhanced contract) +# - wrapper with @connection (legacy oracle-enhanced contract) +# They do NOT require an Oracle database. +# +# Run: ruby -Ilib -Itest test/compatibility/oci_unwrap_test.rb + +$LOAD_PATH.unshift File.expand_path('../../lib', __dir__) + +require 'minitest/autorun' + +# Load ruby_plsql, handling the case where OCI8 is not installed. +begin + require 'ruby_plsql' +rescue LoadError => _e + # OCI8 may not be installed. Load just enough for the tests. + require 'plsql/connection' + require 'plsql/version' + require 'plsql/helpers' + # Stub OCI8 to avoid the real dependency + unless defined?(OCI8) + OCI8 = Class.new + end + require 'plsql/oci_connection' +end + +class OciUnwrapTest < Minitest::Test + # Fake OCI8-like class so we can test without loading ruby-oci8. + class FakeOCI8 + attr_reader :id + + def initialize(id = 'real_oci8') + @id = id + end + + def get_tdo_by_class(_klass) + :fake_tdo + end + end + + # Simulates an oracle-enhanced adapter wrapper that stores OCI8 in @raw_connection. + class WrapperWithRawConnection + attr_reader :raw_connection + + def initialize(oci8) + @raw_connection = oci8 + end + end + + # Simulates a legacy oracle-enhanced adapter wrapper that stores OCI8 in @connection. + class WrapperWithConnection + attr_reader :connection + + def initialize(oci8) + @connection = oci8 + end + end + + # Simulates a wrapper that has neither @raw_connection nor @connection. + class UnknownWrapper + end + + def setup + @real_oci8 = FakeOCI8.new('primary') + end + + def test_unwraps_wrapper_with_raw_connection + wrapper = WrapperWithRawConnection.new(@real_oci8) + conn = PLSQL::OCIConnection.new(wrapper) + + result = conn.send(:raw_oci_connection) + + assert_equal @real_oci8, result, + 'Expected raw_oci_connection to return OCI8 from @raw_connection' + end + + def test_unwraps_wrapper_with_connection + wrapper = WrapperWithConnection.new(@real_oci8) + conn = PLSQL::OCIConnection.new(wrapper) + + result = conn.send(:raw_oci_connection) + + assert_equal @real_oci8, result, + 'Expected raw_oci_connection to return OCI8 from @connection (legacy)' + end + + def test_prefers_raw_connection_over_connection + # If both ivars exist, @raw_connection should take priority + wrapper = Object.new + wrapper.instance_variable_set(:@raw_connection, FakeOCI8.new('raw')) + wrapper.instance_variable_set(:@connection, FakeOCI8.new('old')) + + conn = PLSQL::OCIConnection.new(wrapper) + result = conn.send(:raw_oci_connection) + + assert_equal 'raw', result.id, + 'Expected @raw_connection to take priority over @connection' + end + + def test_returns_nil_for_unknown_wrapper + wrapper = UnknownWrapper.new + conn = PLSQL::OCIConnection.new(wrapper) + + result = conn.send(:raw_oci_connection) + + assert_nil result, + 'Expected nil when wrapper has neither @raw_connection nor @connection' + end + + def test_get_tdo_by_class_works_through_unwrap + # Verify that raw_oci_connection.get_tdo_by_class works through unwrapping. + oci8 = FakeOCI8.new('tdo_test') + wrapper = WrapperWithRawConnection.new(oci8) + conn = PLSQL::OCIConnection.new(wrapper) + + unwrapped = conn.send(:raw_oci_connection) + + assert_equal :fake_tdo, unwrapped.get_tdo_by_class(Class.new), + 'Expected get_tdo_by_class to work through unwrapped connection' + end +end diff --git a/test/compatibility/ruby34_compatibility_test.rb b/test/compatibility/ruby34_compatibility_test.rb new file mode 100644 index 00000000..fa9d8ba2 --- /dev/null +++ b/test/compatibility/ruby34_compatibility_test.rb @@ -0,0 +1,63 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Ruby 3.4 compatibility tests for ruby-plsql. +# These tests verify that constants and patterns removed in Ruby 3.4 +# (Fixnum, Bignum, BigDecimal.new) are no longer referenced in the codebase +# in MRI/OCI paths. They do NOT require an Oracle database. +# +# Run: ruby -Ilib -Itest test/compatibility/ruby34_compatibility_test.rb + +$LOAD_PATH.unshift File.expand_path('../../lib', __dir__) + +require 'minitest/autorun' + +class Ruby34CompatibilityTest < Minitest::Test + SOURCE_FILES = [ + File.expand_path('../../lib/plsql/oci_connection.rb', __dir__), + File.expand_path('../../lib/plsql/subprogram_call.rb', __dir__), + ].freeze + + DISALLOWED_PATTERNS = { + 'Fixnum constant' => /\bFixnum\b/, + 'Bignum constant' => /\bBignum\b/, + 'BigDecimal.new call' => /BigDecimal\.new\b/, + }.freeze + + def test_no_fixnum_bignum_bigdecimal_new_in_source + SOURCE_FILES.each do |file| + content = File.read(file) + DISALLOWED_PATTERNS.each do |name, pattern| + matches = content.scan(pattern) + assert matches.empty?, + "#{File.basename(file)} still contains #{name}: #{matches.inspect}" + end + end + end + + def test_integer_constant_defined + assert Integer, 'Integer must be defined in Ruby 3.4' + end + + def test_bigdecimal_new_raises_no_method_error + require 'bigdecimal' + err = assert_raises(NoMethodError) do + BigDecimal.new('1.23') + end + assert_match(/undefined method ['`]new'/, err.message) + end + + def test_bigdecimal_constructor_works + require 'bigdecimal' + bd = BigDecimal('1.23') + assert_equal BigDecimal, bd.class + assert_equal '0.123e1', bd.to_s + end + + def test_ruby34_constants_not_defined + skip 'Fixnum/Bignum removal applies only to Ruby >= 3.4' if RUBY_VERSION < '3.4' + refute defined?(Fixnum), 'Fixnum should not be defined in Ruby 3.4' + refute defined?(Bignum), 'Bignum should not be defined in Ruby 3.4' +end + +end \ No newline at end of file