diff --git a/ruby/src/main/java/com/google/protobuf/jruby/RubyDescriptor.java b/ruby/src/main/java/com/google/protobuf/jruby/RubyDescriptor.java index a59596a5b6..bfe7ec3255 100644 --- a/ruby/src/main/java/com/google/protobuf/jruby/RubyDescriptor.java +++ b/ruby/src/main/java/com/google/protobuf/jruby/RubyDescriptor.java @@ -209,6 +209,8 @@ public class RubyDescriptor extends RubyObject { klass.include(new IRubyObject[] {messageExts}); klass.instance_variable_set(runtime.newString(Utils.DESCRIPTOR_INSTANCE_VAR), this); klass.defineAnnotatedMethods(RubyMessage.class); + // Workaround for https://github.com/jruby/jruby/issues/7154 + klass.searchMethod("respond_to?").setIsBuiltin(false); return klass; } diff --git a/ruby/src/main/java/com/google/protobuf/jruby/RubyMessage.java b/ruby/src/main/java/com/google/protobuf/jruby/RubyMessage.java index 0d76116097..2167f15dc4 100644 --- a/ruby/src/main/java/com/google/protobuf/jruby/RubyMessage.java +++ b/ruby/src/main/java/com/google/protobuf/jruby/RubyMessage.java @@ -260,6 +260,86 @@ public class RubyMessage extends RubyObject { return runtime.getTrue(); } + /* + * call-seq: + * Message.respond_to?(method_name, search_private_and_protected) => boolean + * + * Parallels method_missing, returning true when this object implements a method with the given + * method_name. + */ + @JRubyMethod(name="respond_to?", required = 1, optional = 1) + public IRubyObject respondTo(ThreadContext context, IRubyObject [] args) { + String methodName = args[0].asJavaString(); + if (descriptor.findFieldByName(methodName) != null) { + return context.runtime.getTrue(); + } + RubyDescriptor rubyDescriptor = (RubyDescriptor) getDescriptor(context, metaClass); + IRubyObject oneofDescriptor = rubyDescriptor.lookupOneof(context, args[0]); + if (!oneofDescriptor.isNil()) { + return context.runtime.getTrue(); + } + if (methodName.startsWith(CLEAR_PREFIX)) { + String strippedMethodName = methodName.substring(6); + oneofDescriptor = rubyDescriptor.lookupOneof(context, context.runtime.newSymbol(strippedMethodName)); + if (!oneofDescriptor.isNil()) { + return context.runtime.getTrue(); + } + + if (descriptor.findFieldByName(strippedMethodName) != null) { + return context.runtime.getTrue(); + } + } + if (methodName.startsWith(HAS_PREFIX) && methodName.endsWith(QUESTION_MARK)) { + String strippedMethodName = methodName.substring(4, methodName.length() - 1); + FieldDescriptor fieldDescriptor = descriptor.findFieldByName(strippedMethodName); + if (fieldDescriptor != null && + (!proto3 || fieldDescriptor.getContainingOneof() == null || fieldDescriptor + .getContainingOneof().isSynthetic()) && + fieldDescriptor.hasPresence()) { + return context.runtime.getTrue(); + } + oneofDescriptor = rubyDescriptor.lookupOneof(context, RubyString.newString(context.runtime, strippedMethodName)); + if (!oneofDescriptor.isNil()) { + return context.runtime.getTrue(); + } + } + if (methodName.endsWith(AS_VALUE_SUFFIX)) { + FieldDescriptor fieldDescriptor = descriptor.findFieldByName( + methodName.substring(0, methodName.length() - 9)); + if (fieldDescriptor != null && isWrappable(fieldDescriptor)) { + return context.runtime.getTrue(); + } + } + if (methodName.endsWith(CONST_SUFFIX)) { + FieldDescriptor fieldDescriptor = descriptor.findFieldByName( + methodName.substring(0, methodName.length() - 6)); + if (fieldDescriptor != null) { + if (fieldDescriptor.getType() == FieldDescriptor.Type.ENUM) { + return context.runtime.getTrue(); + } + } + } + if (methodName.endsWith(Utils.EQUAL_SIGN)) { + String strippedMethodName = methodName.substring(0, methodName.length() - 1); + FieldDescriptor fieldDescriptor = descriptor.findFieldByName(strippedMethodName); + if (fieldDescriptor != null) { + return context.runtime.getTrue(); + } + if (strippedMethodName.endsWith(AS_VALUE_SUFFIX)) { + strippedMethodName = methodName.substring(0, strippedMethodName.length() - 9); + fieldDescriptor = descriptor.findFieldByName(strippedMethodName); + if (fieldDescriptor != null && isWrappable(fieldDescriptor)) { + return context.runtime.getTrue(); + } + } + } + boolean includePrivate = false; + if (args.length == 2) { + includePrivate = context.runtime.getTrue().equals(args[1]); + } + return metaClass.respondsToMethod(methodName, includePrivate) ? context.runtime.getTrue() : context.runtime.getFalse(); + } + /* * call-seq: * Message.method_missing(*args) @@ -291,10 +371,9 @@ public class RubyMessage extends RubyObject { public IRubyObject methodMissing(ThreadContext context, IRubyObject[] args) { Ruby runtime = context.runtime; String methodName = args[0].asJavaString(); + RubyDescriptor rubyDescriptor = (RubyDescriptor) getDescriptor(context, metaClass); if (args.length == 1) { - RubyDescriptor rubyDescriptor = (RubyDescriptor) getDescriptor(context, metaClass); - // If we find a Oneof return it's name (use lookupOneof because it has an index) IRubyObject oneofDescriptor = rubyDescriptor.lookupOneof(context, args[0]); @@ -331,9 +410,12 @@ public class RubyMessage extends RubyObject { if (methodName.startsWith(CLEAR_PREFIX)) { methodName = methodName.substring(6); oneofDescriptor = rubyDescriptor.lookupOneof(context, runtime.newSymbol(methodName)); - if (!oneofDescriptor.isNil()) { fieldDescriptor = oneofCases.get(((RubyOneofDescriptor) oneofDescriptor).getDescriptor()); + if (fieldDescriptor == null) { + // Clearing an already cleared oneof; return here to avoid NoMethodError. + return context.nil; + } } if (fieldDescriptor == null) { @@ -379,8 +461,7 @@ public class RubyMessage extends RubyObject { } else if (methodName.endsWith(CONST_SUFFIX)) { methodName = methodName.substring(0, methodName.length() - 6); fieldDescriptor = descriptor.findFieldByName(methodName); - - if (fieldDescriptor.getType() == FieldDescriptor.Type.ENUM) { + if (fieldDescriptor != null && fieldDescriptor.getType() == FieldDescriptor.Type.ENUM) { IRubyObject enumValue = getFieldInternal(context, fieldDescriptor); if (!enumValue.isNil()) { @@ -404,17 +485,21 @@ public class RubyMessage extends RubyObject { methodName = methodName.substring(0, methodName.length() - 1); // Trim equals sign FieldDescriptor fieldDescriptor = descriptor.findFieldByName(methodName); - if (fieldDescriptor != null) { return setFieldInternal(context, fieldDescriptor, args[1]); } + IRubyObject oneofDescriptor = rubyDescriptor.lookupOneof(context, RubyString.newString(context.runtime, methodName)); + if (!oneofDescriptor.isNil()) { + throw runtime.newRuntimeError("Oneof accessors are read-only."); + } + if (methodName.endsWith(AS_VALUE_SUFFIX)) { methodName = methodName.substring(0, methodName.length() - 9); fieldDescriptor = descriptor.findFieldByName(methodName); - if (fieldDescriptor != null) { + if (fieldDescriptor != null && isWrappable(fieldDescriptor)) { if (args[1].isNil()) { return setFieldInternal(context, fieldDescriptor, args[1]); } diff --git a/ruby/tests/basic.rb b/ruby/tests/basic.rb index 4ff1c15583..5b781b7a69 100755 --- a/ruby/tests/basic.rb +++ b/ruby/tests/basic.rb @@ -66,8 +66,11 @@ module BasicTest def test_issue_8559_crash msg = TestMessage.new msg.repeated_int32 = ::Google::Protobuf::RepeatedField.new(:int32, [1, 2, 3]) - # TODO: Remove the platform check once https://github.com/jruby/jruby/issues/6818 is released in JRuby 9.3.0.0 - GC.start(full_mark: true, immediate_sweep: true) unless RUBY_PLATFORM == "java" + + # https://github.com/jruby/jruby/issues/6818 was fixed in JRuby 9.3.0.0 + if cruby_or_jruby_9_3_or_higher? + GC.start(full_mark: true, immediate_sweep: true) + end TestMessage.encode(msg) end @@ -99,7 +102,7 @@ module BasicTest begin encoded = msgclass.encode(m) - rescue java.lang.NullPointerException => e + rescue java.lang.NullPointerException flunk "NPE rescued" end decoded = msgclass.decode(encoded) @@ -173,7 +176,7 @@ module BasicTest m = TestSingularFields.new m.singular_int32 = -42 - assert_equal -42, m.singular_int32 + assert_equal( -42, m.singular_int32 ) m.clear_singular_int32 assert_equal 0, m.singular_int32 @@ -568,8 +571,6 @@ module BasicTest def test_json_maps - # TODO: Fix JSON in JRuby version. - return if RUBY_PLATFORM == "java" m = MapMessage.new(:map_string_int32 => {"a" => 1}) expected = {mapStringInt32: {a: 1}, mapStringMsg: {}, mapStringEnum: {}} expected_preserve = {map_string_int32: {a: 1}, map_string_msg: {}, map_string_enum: {}} @@ -583,8 +584,6 @@ module BasicTest end def test_json_maps_emit_defaults_submsg - # TODO: Fix JSON in JRuby version. - return if RUBY_PLATFORM == "java" m = MapMessage.new(:map_string_msg => {"a" => TestMessage2.new(foo: 0)}) expected = {mapStringInt32: {}, mapStringMsg: {a: {foo: 0}}, mapStringEnum: {}} @@ -594,8 +593,6 @@ module BasicTest end def test_json_emit_defaults_submsg - # TODO: Fix JSON in JRuby version. - return if RUBY_PLATFORM == "java" m = TestSingularFields.new(singular_msg: proto_module::TestMessage2.new) expected = { @@ -618,8 +615,6 @@ module BasicTest end def test_respond_to - # This test fails with JRuby 1.7.23, likely because of an old JRuby bug. - return if RUBY_PLATFORM == "java" msg = MapMessage.new assert msg.respond_to?(:map_string_int32) assert !msg.respond_to?(:bacon) @@ -694,5 +689,51 @@ module BasicTest m2 = proto_module::TestMessage.decode(proto_module::TestMessage.encode(m)) assert_equal m2, m end + + def test_map_fields_respond_to? # regression test for issue 9202 + msg = proto_module::MapMessage.new + assert msg.respond_to?(:map_string_int32=) + msg.map_string_int32 = Google::Protobuf::Map.new(:string, :int32) + assert msg.respond_to?(:map_string_int32) + assert_equal( Google::Protobuf::Map.new(:string, :int32), msg.map_string_int32 ) + assert msg.respond_to?(:clear_map_string_int32) + msg.clear_map_string_int32 + + assert !msg.respond_to?(:has_map_string_int32?) + assert_raise NoMethodError do + msg.has_map_string_int32? + end + assert !msg.respond_to?(:map_string_int32_as_value) + assert_raise NoMethodError do + msg.map_string_int32_as_value + end + assert !msg.respond_to?(:map_string_int32_as_value=) + assert_raise NoMethodError do + msg.map_string_int32_as_value = :boom + end + end + end + + def test_oneof_fields_respond_to? # regression test for issue 9202 + msg = proto_module::OneofMessage.new + # `has_` prefix + "?" suffix actions should only work for oneofs fields. + assert msg.has_my_oneof? + assert msg.respond_to? :has_my_oneof? + assert !msg.respond_to?( :has_a? ) + assert_raise NoMethodError do + msg.has_a? + end + assert !msg.respond_to?( :has_b? ) + assert_raise NoMethodError do + msg.has_b? + end + assert !msg.respond_to?( :has_c? ) + assert_raise NoMethodError do + msg.has_c? + end + assert !msg.respond_to?( :has_d? ) + assert_raise NoMethodError do + msg.has_d? + end end end diff --git a/ruby/tests/basic_proto2.rb b/ruby/tests/basic_proto2.rb index a7114ea6ed..5391c302ee 100755 --- a/ruby/tests/basic_proto2.rb +++ b/ruby/tests/basic_proto2.rb @@ -142,7 +142,7 @@ module BasicTestProto2 m = TestMessageDefaults.new m.optional_int32 = -42 - assert_equal -42, m.optional_int32 + assert_equal( -42, m.optional_int32 ) assert m.has_optional_int32? m.clear_optional_int32 assert_equal 1, m.optional_int32 @@ -255,5 +255,20 @@ module BasicTestProto2 assert_equal "tests/basic_test_proto2.proto", file_descriptor.name assert_equal :proto2, file_descriptor.syntax end + + def test_oneof_fields_respond_to? # regression test for issue 9202 + msg = proto_module::OneofMessage.new(a: "foo") + # `has_` prefix + "?" suffix actions should only work for oneofs fields. + assert msg.respond_to? :has_my_oneof? + assert msg.has_my_oneof? + assert msg.respond_to? :has_a? + assert msg.has_a? + assert msg.respond_to? :has_b? + assert !msg.has_b? + assert msg.respond_to? :has_c? + assert !msg.has_c? + assert msg.respond_to? :has_d? + assert !msg.has_d? + end end end diff --git a/ruby/tests/common_tests.rb b/ruby/tests/common_tests.rb index 358846916d..5918c8a8b1 100644 --- a/ruby/tests/common_tests.rb +++ b/ruby/tests/common_tests.rb @@ -796,7 +796,7 @@ module CommonTests m.repeated_string += %w[two three] assert_equal %w[one two three], m.repeated_string - m.repeated_string.push *['four', 'five'] + m.repeated_string.push( *['four', 'five'] ) assert_equal %w[one two three four five], m.repeated_string m.repeated_string.push 'six', 'seven' @@ -1085,8 +1085,6 @@ module CommonTests end def test_json - # TODO: Fix JSON in JRuby version. - return if RUBY_PLATFORM == "java" m = proto_module::TestMessage.new(:optional_int32 => 1234, :optional_int64 => -0x1_0000_0000, :optional_uint32 => 0x8000_0000, @@ -1288,6 +1286,7 @@ module CommonTests m2 = proto_module::Wrapper.decode(m.to_proto) run_asserts.call(m2) m3 = proto_module::Wrapper.decode_json(m.to_json) + run_asserts.call(m3) end def test_wrapper_getters @@ -1539,8 +1538,6 @@ module CommonTests assert_nil m.bytes_as_value } - m = proto_module::Wrapper.new - m2 = proto_module::Wrapper.new( double: Google::Protobuf::DoubleValue.new(value: 2.0), float: Google::Protobuf::FloatValue.new(value: 4.0), @@ -1787,27 +1784,200 @@ module CommonTests assert_nil h[m2] end + def cruby_or_jruby_9_3_or_higher? + # https://github.com/jruby/jruby/issues/6818 was fixed in JRuby 9.3.0.0 + match = RUBY_PLATFORM == "java" && + JRUBY_VERSION.match(/^(\d+)\.(\d+)\.\d+\.\d+$/) + match && (match[1].to_i > 9 || (match[1].to_i == 9 && match[2].to_i >= 3)) + end + def test_object_gc m = proto_module::TestMessage.new(optional_msg: proto_module::TestMessage2.new) m.optional_msg - # TODO: Remove the platform check once https://github.com/jruby/jruby/issues/6818 is released in JRuby 9.3.0.0 - GC.start(full_mark: true, immediate_sweep: true) unless RUBY_PLATFORM == "java" + # https://github.com/jruby/jruby/issues/6818 was fixed in JRuby 9.3.0.0 + GC.start(full_mark: true, immediate_sweep: true) if cruby_or_jruby_9_3_or_higher? m.optional_msg.inspect end def test_object_gc_freeze m = proto_module::TestMessage.new m.repeated_float.freeze - # TODO: Remove the platform check once https://github.com/jruby/jruby/issues/6818 is released in JRuby 9.3.0.0 - GC.start(full_mark: true) unless RUBY_PLATFORM == "java" + # https://github.com/jruby/jruby/issues/6818 was fixed in JRuby 9.3.0.0 + GC.start(full_mark: true) if cruby_or_jruby_9_3_or_higher? # Make sure we remember that the object is frozen. # The wrapper object contains this information, so we need to ensure that # the previous GC did not collect it. assert m.repeated_float.frozen? - # TODO: Remove the platform check once https://github.com/jruby/jruby/issues/6818 is released in JRuby 9.3.0.0 - GC.start(full_mark: true, immediate_sweep: true) unless RUBY_PLATFORM == "java" + # https://github.com/jruby/jruby/issues/6818 was fixed in JRuby 9.3.0.0 + GC.start(full_mark: true, immediate_sweep: true) if cruby_or_jruby_9_3_or_higher? assert m.repeated_float.frozen? end + + def test_optional_fields_respond_to? # regression test for issue 9202 + msg = proto_module::TestMessage.new + assert msg.respond_to? :optional_int32= + msg.optional_int32 = 42 + + assert msg.respond_to? :optional_int32 + assert_equal 42, msg.optional_int32 + + assert msg.respond_to? :clear_optional_int32 + msg.clear_optional_int32 + assert_equal 0, msg.optional_int32 + + assert msg.respond_to? :has_optional_int32? + assert !msg.has_optional_int32? + + assert !msg.respond_to?( :optional_int32_as_value= ) + assert_raise NoMethodError do + msg.optional_int32_as_value = 42 + end + + assert !msg.respond_to?( :optional_int32_as_value ) + assert_raise NoMethodError do + msg.optional_int32_as_value + end + + assert msg.respond_to? :optional_enum_const + assert_equal 0, msg.optional_enum_const + + assert !msg.respond_to?( :foo ) + assert_raise NoMethodError do + msg.foo + end + + assert !msg.respond_to?( :foo_const ) + assert_raise NoMethodError do + msg.foo_const + end + + assert !msg.respond_to?( :optional_int32_const ) + assert_raise NoMethodError do + msg.optional_int32_const + end + end + + def test_oneof_fields_respond_to? # regression test for issue 9202 + msg = proto_module::OneofMessage.new + + # names of the elements of a oneof and the oneof itself are valid actions. + assert msg.respond_to? :my_oneof + assert_nil msg.my_oneof + assert msg.respond_to? :a + assert_equal "", msg.a + assert msg.respond_to? :b + assert_equal 0, msg.b + assert msg.respond_to? :c + assert_nil msg.c + assert msg.respond_to? :d + assert_equal :Default, msg.d + + # `clear` prefix actions work on elements of a oneof and the oneof itself. + assert msg.respond_to? :clear_my_oneof + msg.clear_my_oneof + # Repeatedly clearing a oneof used to cause a NoMethodError under JRuby + msg.clear_my_oneof + assert msg.respond_to? :clear_a + msg.clear_a + assert msg.respond_to? :clear_b + msg.clear_b + assert msg.respond_to? :clear_c + msg.clear_c + assert msg.respond_to? :clear_d + msg.clear_d + + # `=` suffix actions should work on elements of a oneof but not the oneof itself. + assert !msg.respond_to?( :my_oneof= ) + error = assert_raise RuntimeError do + msg.my_oneof = nil + end + assert_equal "Oneof accessors are read-only.", error.message + assert msg.respond_to? :a= + msg.a = "foo" + assert msg.respond_to? :b= + msg.b = 42 + assert msg.respond_to? :c= + msg.c = proto_module::TestMessage2.new + assert msg.respond_to? :d= + msg.d = :Default + + # `has_` prefix + "?" suffix actions work for oneofs fields. + assert msg.respond_to? :has_my_oneof? + assert msg.has_my_oneof? + + # `_as_value` suffix actions should only work for wrapped fields. + assert !msg.respond_to?( :my_oneof_as_value ) + assert_raise NoMethodError do + msg.my_oneof_as_value + end + assert !msg.respond_to?( :a_as_value ) + assert_raise NoMethodError do + msg.a_as_value + end + assert !msg.respond_to?( :b_as_value ) + assert_raise NoMethodError do + msg.b_as_value + end + assert !msg.respond_to?( :c_as_value ) + assert_raise NoMethodError do + msg.c_as_value + end + assert !msg.respond_to?( :d_as_value ) + assert_raise NoMethodError do + msg.d_as_value + end + + # `_as_value=` suffix actions should only work for wrapped fields. + assert !msg.respond_to?( :my_oneof_as_value= ) + assert_raise NoMethodError do + msg.my_oneof_as_value = :boom + end + assert !msg.respond_to?( :a_as_value= ) + assert_raise NoMethodError do + msg.a_as_value = "" + end + assert !msg.respond_to?( :b_as_value= ) + assert_raise NoMethodError do + msg.b_as_value = 42 + end + assert !msg.respond_to?( :c_as_value= ) + assert_raise NoMethodError do + msg.c_as_value = proto_module::TestMessage2.new + end + assert !msg.respond_to?( :d_as_value= ) + assert_raise NoMethodError do + msg.d_as_value = :Default + end + + # `_const` suffix actions should only work for enum fields. + assert !msg.respond_to?( :my_oneof_const ) + assert_raise NoMethodError do + msg.my_oneof_const + end + assert !msg.respond_to?( :a_const ) + assert_raise NoMethodError do + msg.a_const + end + assert !msg.respond_to?( :b_const ) + assert_raise NoMethodError do + msg.b_const + end + assert !msg.respond_to?( :c_const ) + assert_raise NoMethodError do + msg.c_const + end + assert msg.respond_to? :d_const + assert_equal 0, msg.d_const + end + + def test_wrapped_fields_respond_to? # regression test for issue 9202 + msg = proto_module::Wrapper.new + assert msg.respond_to?( :double_as_value= ) + msg.double_as_value = 42 + assert msg.respond_to?( :double_as_value ) + assert_equal 42, msg.double_as_value + assert_equal Google::Protobuf::DoubleValue.new(value: 42), msg.double + end end