Implement respond_to? in RubyMessage (#9677)

All synthetic methods implemented by `method_missing` are now supported by `respond_to?`.

Fixes issue #9202.

* Fix null pointer exceptions exposed by new regression tests.
* Fix clear_ on oneofs so that it is safe to call repeatedly and so that respond_to? does not depend on whether the oneof is currently cleared.
* Code cleanup: reenable more tests on JRuby.
* Align JRuby behavior with CRuby by throwing a RuntimeError when attempting to assign to a oneof.
This commit is contained in:
Jason Lunn 2022-03-28 18:43:53 -04:00 committed by GitHub
parent b5a35bcc7e
commit 8e7f936696
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 344 additions and 31 deletions

View File

@ -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;
}

View File

@ -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]);
}

View File

@ -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

View File

@ -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

View File

@ -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