From 0ef9a2f5985adb43ac5b904e83392a0183082aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alen=20Vre=C4=8Dko?= <332217+avrecko@users.noreply.github.com> Date: Sun, 19 Feb 2023 20:39:12 +0100 Subject: [PATCH] Enhancements to Json support. * on linux unicode problems without + 10 (JsonReader#readHexChar) * call no-arg constructor if annotated with @Before (needed for e.g. field init) * if long value outside javascript safe integer range write it as text * support assigning parsable json text for java number and boolean fields * support parsing json number and json boolean for java String field --- src/one/nio/serial/Before.java | 15 +++ src/one/nio/serial/Json.java | 17 ++++ src/one/nio/serial/JsonReader.java | 80 ++++++++++++---- src/one/nio/serial/LongSerializer.java | 2 +- src/one/nio/serial/gen/DelegateGenerator.java | 19 ++++ test/one/nio/serial/JsonReaderTest.java | 27 +++++- .../nio/serial/gen/DelegateGeneratorTest.java | 95 +++++++++++++++++++ 7 files changed, 235 insertions(+), 20 deletions(-) create mode 100644 src/one/nio/serial/Before.java create mode 100644 test/one/nio/serial/gen/DelegateGeneratorTest.java diff --git a/src/one/nio/serial/Before.java b/src/one/nio/serial/Before.java new file mode 100644 index 0000000..868844b --- /dev/null +++ b/src/one/nio/serial/Before.java @@ -0,0 +1,15 @@ +package one.nio.serial; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Constructor calls are skipped on JSON deserialization unless annotated with this annotation. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.CONSTRUCTOR}) +public @interface Before { + +} diff --git a/src/one/nio/serial/Json.java b/src/one/nio/serial/Json.java index b7354ee..63c12a8 100755 --- a/src/one/nio/serial/Json.java +++ b/src/one/nio/serial/Json.java @@ -24,6 +24,11 @@ public class Json { + + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MIN_SAFE_INTEGER + public static final long JS_MIN_SAFE_INTEGER = -9007199254740991L; + public static final long JS_MAX_SAFE_INTEGER = 9007199254740991L; + public static void appendChar(StringBuilder builder, char c) { builder.append('"'); if (c == '"' || c == '\\') { @@ -70,6 +75,18 @@ public static void appendObject(StringBuilder builder, Object obj) throws IOExce } } + public static void appendLong(StringBuilder builder, long value) { + if (isJsSafeInteger(value)) { + builder.append(value); + } else { + builder.append('"').append(value).append('"'); + } + } + + public static boolean isJsSafeInteger(long value) { + return value >= JS_MIN_SAFE_INTEGER && JS_MAX_SAFE_INTEGER >= value; + } + @SuppressWarnings("unchecked") public static String toJson(Object obj) throws IOException { if (obj == null) { diff --git a/src/one/nio/serial/JsonReader.java b/src/one/nio/serial/JsonReader.java index a301df3..0131491 100644 --- a/src/one/nio/serial/JsonReader.java +++ b/src/one/nio/serial/JsonReader.java @@ -74,10 +74,22 @@ public final void expect(int b, String message) throws IOException { } public final boolean readBoolean() throws IOException { + boolean readBooleanAsString = false; + if (next == '\"') + { + readBooleanAsString = true; + read(); + } int b = read(); if (b == 't' && read() == 'r' && read() == 'u' && read() == 'e') { + if (readBooleanAsString) { + expect('\"', "Unexpected end of string"); + } return true; } else if (b == 'f' && read() == 'a' && read() == 'l' && read() == 's' && read() == 'e') { + if (readBooleanAsString) { + expect('\"', "Unexpected end of string"); + } return false; } throw exception("Expected boolean"); @@ -114,6 +126,13 @@ public final double readDouble() throws IOException { public final String readNumber() throws IOException { StringBuilder sb = new StringBuilder(); + boolean readNumberAsString = false; + if (next == '"') + { + readNumberAsString = true; + read(); + } + // Sign if (next == '-') { sb.append((char) read()); @@ -150,6 +169,10 @@ public final String readNumber() throws IOException { } while (next >= '0' && next <= '9'); } + if (readNumberAsString) + { + expect('\"', "Unexpected end of string"); + } return sb.toString(); } @@ -158,9 +181,9 @@ public final int readHexChar() throws IOException { if (b >= '0' && b <= '9') { return b - '0'; } else if (b >= 'A' && b <= 'F') { - return b - 'A'; + return b - 'A' + 10; } else if (b >= 'a' && b <= 'f') { - return b - 'a'; + return b - 'a' + 10; } throw exception("Invalid escape character"); } @@ -193,23 +216,44 @@ public Object readNull() throws IOException { } public String readString() throws IOException { - StringBuilder sb = new StringBuilder(); - expect('\"', "Expected string"); - while (next >= 0 && next != '\"') { - int b = read(); - if ((b & 0x80) == 0) { - sb.append(b == '\\' ? readEscapeChar() : (char) b); - } else if ((b & 0xe0) == 0xc0) { - sb.append((char) ((b & 0x1f) << 6 | (read() & 0x3f))); - } else if ((b & 0xf0) == 0xe0) { - sb.append((char) ((b & 0x0f) << 12 | (read() & 0x3f) << 6 | (read() & 0x3f))); - } else { - int v = (b & 0x07) << 18 | (read() & 0x3f) << 12 | (read() & 0x3f) << 6 | (read() & 0x3f); - sb.append((char) (0xd800 | (v - 0x10000) >>> 10)).append((char) (0xdc00 | (v & 0x3ff))); - } + switch (next) { + case '\"': + read(); + StringBuilder sb = new StringBuilder(); + while (next >= 0 && next != '\"') { + int b = read(); + if ((b & 0x80) == 0) { + sb.append(b == '\\' ? readEscapeChar() : (char) b); + } else if ((b & 0xe0) == 0xc0) { + sb.append((char) ((b & 0x1f) << 6 | (read() & 0x3f))); + } else if ((b & 0xf0) == 0xe0) { + sb.append((char) ((b & 0x0f) << 12 | (read() & 0x3f) << 6 | (read() & 0x3f))); + } else { + int v = (b & 0x07) << 18 | (read() & 0x3f) << 12 | (read() & 0x3f) << 6 | (read() & 0x3f); + sb.append((char) (0xd800 | (v - 0x10000) >>> 10)).append((char) (0xdc00 | (v & 0x3ff))); + } + } + expect('\"', "Unexpected end of string"); + return sb.toString(); + case 'f': + case 't': + return Boolean.toString(readBoolean()); + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case '-': + case '.': + return readNumber(); + default: + throw exception("Unexpected start of string"); } - expect('\"', "Unexpected end of string"); - return sb.toString(); } public byte[] readBinary() throws IOException { diff --git a/src/one/nio/serial/LongSerializer.java b/src/one/nio/serial/LongSerializer.java index cef512c..988bc91 100755 --- a/src/one/nio/serial/LongSerializer.java +++ b/src/one/nio/serial/LongSerializer.java @@ -48,7 +48,7 @@ public void skip(DataStream in) throws IOException { @Override public void toJson(Long obj, StringBuilder builder) { - builder.append(obj.longValue()); + Json.appendLong(builder, obj); } @Override diff --git a/src/one/nio/serial/gen/DelegateGenerator.java b/src/one/nio/serial/gen/DelegateGenerator.java index 7881492..0f47454 100755 --- a/src/one/nio/serial/gen/DelegateGenerator.java +++ b/src/one/nio/serial/gen/DelegateGenerator.java @@ -17,6 +17,7 @@ package one.nio.serial.gen; import one.nio.gen.BytecodeGenerator; +import one.nio.serial.Before; import one.nio.serial.Default; import one.nio.serial.FieldDescriptor; import one.nio.serial.JsonName; @@ -38,6 +39,7 @@ import java.io.ObjectOutputStream; import java.lang.invoke.MethodHandleInfo; import java.lang.invoke.MethodType; +import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Modifier; @@ -401,6 +403,10 @@ private static void generateToJson(ClassVisitor cv, Class cls, FieldDescriptor[] mv.visitMethodInsn(INVOKESTATIC, "one/nio/serial/Json", "appendChar", "(Ljava/lang/StringBuilder;C)V", false); mv.visitVarInsn(ALOAD, 2); break; + case Long: + mv.visitMethodInsn(INVOKESTATIC, "one/nio/serial/Json", "appendLong", "(Ljava/lang/StringBuilder;J)V", false); + mv.visitVarInsn(ALOAD, 2); + break; default: mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", srcType.appendSignature(), false); } @@ -433,6 +439,19 @@ private static void generateFromJson(ClassVisitor cv, Class cls, FieldDescriptor // Create instance mv.visitTypeInsn(NEW, Type.getInternalName(cls)); + // support for calling constructor annotated with @Before + for (Class searchClass = cls; searchClass != null; searchClass = searchClass.getSuperclass()) { + Constructor constructor = JavaInternals.findConstructor(searchClass); + if (constructor != null && constructor.getAnnotation(Before.class) != null) { + if (searchClass != cls) { + throw new IllegalArgumentException("To avoid unexpected behavior it is required to add @Before to no-arg constructor in " + cls + " because parent class does the same."); + } + mv.visitInsn(DUP); + mv.visitMethodInsn(INVOKESPECIAL,Type.getInternalName(cls) , "", "()V", false); + break; + } + } + // Prepare a multimap (fieldHash -> fds) for lookupswitch TreeMap fieldHashes = new TreeMap<>(); boolean isRecord = JavaFeatures.isRecord(cls); diff --git a/test/one/nio/serial/JsonReaderTest.java b/test/one/nio/serial/JsonReaderTest.java index bd57be0..ea6a998 100644 --- a/test/one/nio/serial/JsonReaderTest.java +++ b/test/one/nio/serial/JsonReaderTest.java @@ -29,7 +29,7 @@ public class JsonReaderTest { private static final String sample = "[{\n" + " \"created_at\": \"Thu Jun 22 21:00:00 +0000 2017\",\n" + - " \"id\": 877994604561387500,\n" + + " \"id\": \"877994604561387500\",\n" + " \"id_str\": \"877994604561387520\",\n" + " \"text\": \"Creating a Grocery List Manager \\u0026 Display Items https://t.co/xFox12345 #Angular\",\n" + " \"truncated\": false,\n" + @@ -108,9 +108,34 @@ public void nullableFields() throws IOException, ClassNotFoundException { Assert.assertTrue(x.set instanceof Set && x.set.isEmpty()); } + @Test + public void seamlessConvertStringToBoolean() throws IOException, ClassNotFoundException { + Assert.assertEquals(true, Json.fromJson("{\"boolValue\":\"true\"}", Custom.class).boolValue); + } + + @Test + public void seamlessConvertStringToNumber() throws IOException, ClassNotFoundException { + Assert.assertEquals(877994604561387500L, Json.fromJson("{\"longValue\":\"877994604561387500\"}", Custom.class).longValue.longValue()); + Assert.assertEquals(123, Json.fromJson("{\"intValue\":\"123\"}", Custom.class).intValue); + Assert.assertEquals(123.456, Json.fromJson("{\"doubleValue\":\"123.456\"}", Custom.class).doubleValue, 0.0001D); + } + + @Test + public void seamlessConvertBooleanToString() throws IOException, ClassNotFoundException { + Assert.assertEquals("true", Json.fromJson("{\"stringValue\":true}", Custom.class).stringValue); + } + @Test + public void seamlessConvertNumberToString() throws IOException, ClassNotFoundException { + Assert.assertEquals("877994604561387500", Json.fromJson("{\"stringValue\":877994604561387500}", Custom.class).stringValue); + } + static class Custom implements Serializable { + + boolean boolValue; int intValue; + double doubleValue; Long longValue = -77L; + String stringValue; final String string = "zzz"; Object[] FB = {"A", true}; Set set = Collections.emptySet(); diff --git a/test/one/nio/serial/gen/DelegateGeneratorTest.java b/test/one/nio/serial/gen/DelegateGeneratorTest.java new file mode 100644 index 0000000..d2d4ecc --- /dev/null +++ b/test/one/nio/serial/gen/DelegateGeneratorTest.java @@ -0,0 +1,95 @@ +package one.nio.serial.gen; + +import one.nio.serial.Before; +import one.nio.serial.Json; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.io.Serializable; + +public class DelegateGeneratorTest { + + @Test + public void testSerializeLongAsString() throws Exception { + // we will fallback to writing a long value as string when we cross MIN/MAX safe integer range + assertFallbackToTextForLong(false, 123L); + assertFallbackToTextForLong(false, -123L); + assertFallbackToTextForLong(false, 0L); + assertFallbackToTextForLong(false, Json.JS_MAX_SAFE_INTEGER); + assertFallbackToTextForLong(true, Json.JS_MAX_SAFE_INTEGER + 1); + assertFallbackToTextForLong(false, Json.JS_MIN_SAFE_INTEGER); + assertFallbackToTextForLong(true, Json.JS_MIN_SAFE_INTEGER - 1); + assertFallbackToTextForLong(true, Long.MAX_VALUE); + assertFallbackToTextForLong(true, Long.MIN_VALUE); + } + + private void assertFallbackToTextForLong(boolean fallbackToText, long value) throws Exception { + LongTestClass obj = new LongTestClass(); + obj.longField = value; + String test = Json.toJson(obj); + if (fallbackToText) { + Assert.assertTrue(test.contains(String.format("\"longField\":\"%d\"", value))); + } else { + Assert.assertTrue(test.contains(String.format("\"longField\":%d", value))); + } + Assert.assertEquals(value, Json.fromJson(test, LongTestClass.class).longField); + } + + + private static class LongTestClass implements Serializable { + public long longField; + } + + @Test + public void testBeforeAnnotationSupport() throws IOException, ClassNotFoundException { + TestClass testClass = Json.fromJson("{}", TestClass.class); + Assert.assertNull(testClass.name); + Assert.assertFalse(testClass.called); + + TestClassWithBeforeAnnotation beforeClass = Json.fromJson("{}", TestClassWithBeforeAnnotation.class); + Assert.assertEquals("foo", beforeClass.name); + Assert.assertEquals("bar", beforeClass.name2); + Assert.assertEquals(true, beforeClass.called); + + ValidExtendingOfClassWithBeforeAnnotation beforeExtended = Json.fromJson("{}", ValidExtendingOfClassWithBeforeAnnotation.class); + Assert.assertEquals("foo", beforeExtended.name); + Assert.assertEquals("bar", beforeExtended.name2); + Assert.assertEquals(true, beforeExtended.called); + + try { + Json.fromJson("{}", InvalidExtendingOfClassWithBeforeAnnotation.class); + Assert.fail(); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains(InvalidExtendingOfClassWithBeforeAnnotation.class.getName())); + } + } + + private static class InvalidExtendingOfClassWithBeforeAnnotation extends TestClassWithBeforeAnnotation { + } + + private static class ValidExtendingOfClassWithBeforeAnnotation extends TestClassWithBeforeAnnotation { + @Before + public ValidExtendingOfClassWithBeforeAnnotation() { + } + } + + private static class TestClassWithBeforeAnnotation extends TestClass implements Serializable { + public String name2; + + @Before + public TestClassWithBeforeAnnotation() { + name2 = "bar"; + } + } + + private static class TestClass implements Serializable { + public String name; + public boolean called; + + public TestClass() { + name = "foo"; + called = true; + } + } +} \ No newline at end of file