AVRO-2757: Avoid dollar signs when reflecting Java. (#914)

diff --git a/lang/java/avro/src/main/java/org/apache/avro/reflect/ReflectData.java b/lang/java/avro/src/main/java/org/apache/avro/reflect/ReflectData.java
index 96b17bb..7f15a65 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/reflect/ReflectData.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/reflect/ReflectData.java
@@ -703,7 +703,7 @@
         String name = c.getSimpleName();
         String space = c.getPackage() == null ? "" : c.getPackage().getName();
         if (c.getEnclosingClass() != null) // nested class
-          space = c.getEnclosingClass().getName();
+          space = c.getEnclosingClass().getName().replace('$', '.');
         Union union = c.getAnnotation(Union.class);
         if (union != null) { // union annotated
           return getAnnotatedUnion(union, names);
diff --git a/lang/java/avro/src/main/java/org/apache/avro/specific/SpecificData.java b/lang/java/avro/src/main/java/org/apache/avro/specific/SpecificData.java
index 58bc660..3cbb21d 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/specific/SpecificData.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/specific/SpecificData.java
@@ -17,36 +17,36 @@
  */
 package org.apache.avro.specific;
 
+import org.apache.avro.AvroRuntimeException;
+import org.apache.avro.AvroTypeException;
+import org.apache.avro.Protocol;
+import org.apache.avro.Schema;
+import org.apache.avro.Schema.Type;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.io.BinaryDecoder;
+import org.apache.avro.io.BinaryEncoder;
+import org.apache.avro.io.DatumReader;
+import org.apache.avro.io.DatumWriter;
+import org.apache.avro.io.DecoderFactory;
+import org.apache.avro.io.EncoderFactory;
+import org.apache.avro.util.ClassUtils;
+
+import java.io.ObjectInput;
+import java.io.ObjectOutput;
+import java.lang.reflect.Constructor;
 import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.nio.ByteBuffer;
 import java.util.Arrays;
-import java.util.HashSet;
-import java.util.Map;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.WeakHashMap;
 import java.util.concurrent.ConcurrentHashMap;
-import java.nio.ByteBuffer;
-import java.lang.reflect.Constructor;
-import java.lang.reflect.ParameterizedType;
-import java.io.ObjectInput;
-import java.io.ObjectOutput;
-
-import org.apache.avro.Schema;
-import org.apache.avro.Protocol;
-import org.apache.avro.AvroRuntimeException;
-import org.apache.avro.AvroTypeException;
-import org.apache.avro.Schema.Type;
-import org.apache.avro.util.ClassUtils;
-import org.apache.avro.generic.GenericData;
-import org.apache.avro.io.DatumReader;
-import org.apache.avro.io.DatumWriter;
-import org.apache.avro.io.EncoderFactory;
-import org.apache.avro.io.BinaryEncoder;
-import org.apache.avro.io.DecoderFactory;
-import org.apache.avro.io.BinaryDecoder;
 
 /** Utilities for generated Java classes and interfaces. */
 public class SpecificData extends GenericData {
@@ -241,15 +241,24 @@
       String name = schema.getFullName();
       if (name == null)
         return null;
-      Class c = classCache.computeIfAbsent(name, n -> {
+      Class<?> c = classCache.computeIfAbsent(name, n -> {
         try {
           return ClassUtils.forName(getClassLoader(), getClassName(schema));
         } catch (ClassNotFoundException e) {
-          try { // nested class?
-            return ClassUtils.forName(getClassLoader(), getNestedClassName(schema));
-          } catch (ClassNotFoundException ex) {
-            return NO_CLASS;
+          // This might be a nested namespace. Try using the last tokens in the
+          // namespace as an enclosing class by progressively replacing period
+          // delimiters with $
+          StringBuilder nestedName = new StringBuilder(n);
+          int lastDot = n.lastIndexOf('.');
+          while (lastDot != -1) {
+            nestedName.setCharAt(lastDot, '$');
+            try {
+              return ClassUtils.forName(getClassLoader(), nestedName.toString());
+            } catch (ClassNotFoundException ignored) {
+            }
+            lastDot = n.lastIndexOf('.', lastDot - 1);
           }
+          return NO_CLASS;
         }
       });
       return c == NO_CLASS ? null : c;
@@ -312,14 +321,6 @@
     return namespace + dot + name;
   }
 
-  private String getNestedClassName(Schema schema) {
-    String namespace = schema.getNamespace();
-    String name = schema.getName();
-    if (namespace == null || "".equals(namespace))
-      return name;
-    return namespace + "$" + name;
-  }
-
   // cache for schemas created from Class objects. Use ClassValue to avoid
   // locking classloaders and is GC and thread safe.
   private final ClassValue<Schema> schemaClassCache = new ClassValue<Schema>() {
diff --git a/lang/java/avro/src/test/java/org/apache/avro/reflect/TestReflect.java b/lang/java/avro/src/test/java/org/apache/avro/reflect/TestReflect.java
index 2555e9b..700ee4c 100644
--- a/lang/java/avro/src/test/java/org/apache/avro/reflect/TestReflect.java
+++ b/lang/java/avro/src/test/java/org/apache/avro/reflect/TestReflect.java
@@ -18,7 +18,11 @@
 package org.apache.avro.reflect;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.Assert.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -1211,12 +1215,22 @@
   @Test
   public void testDollarTerminatedNamespaceCompatibility() {
     ReflectData data = ReflectData.get();
-    Schema s = new Schema.Parser().parse(
+    Schema s = new Schema.Parser().setValidate(false).parse(
         "{\"type\":\"record\",\"name\":\"Z\",\"namespace\":\"org.apache.avro.reflect.TestReflect$\",\"fields\":[]}");
     assertEquals(data.getSchema(data.getClass(s)).toString(),
         "{\"type\":\"record\",\"name\":\"Z\",\"namespace\":\"org.apache.avro.reflect.TestReflect\",\"fields\":[]}");
   }
 
+  @Test
+  public void testDollarTerminatedNestedStaticClassNamespaceCompatibility() {
+    ReflectData data = ReflectData.get();
+    // Older versions of Avro generated this namespace on nested records.
+    Schema s = new Schema.Parser().setValidate(false).parse(
+        "{\"type\":\"record\",\"name\":\"AnotherSampleRecord\",\"namespace\":\"org.apache.avro.reflect.TestReflect$SampleRecord\",\"fields\":[]}");
+    assertThat(data.getSchema(data.getClass(s)).getFullName(),
+        is("org.apache.avro.reflect.TestReflect.SampleRecord.AnotherSampleRecord"));
+  }
+
   private static class ClassWithAliasOnField {
     @AvroAlias(alias = "aliasName")
     int primitiveField;