Skip to content

Commit

Permalink
Enhancements to Json support.
Browse files Browse the repository at this point in the history
  * 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
  • Loading branch information
avrecko committed Feb 19, 2023
1 parent 4bd740b commit 0ef9a2f
Show file tree
Hide file tree
Showing 7 changed files with 235 additions and 20 deletions.
15 changes: 15 additions & 0 deletions src/one/nio/serial/Before.java
Original file line number Diff line number Diff line change
@@ -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 {

}
17 changes: 17 additions & 0 deletions src/one/nio/serial/Json.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 == '\\') {
Expand Down Expand Up @@ -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) {
Expand Down
80 changes: 62 additions & 18 deletions src/one/nio/serial/JsonReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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();
}

Expand All @@ -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");
}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/one/nio/serial/LongSerializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/one/nio/serial/gen/DelegateGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) , "<init>", "()V", false);
break;
}
}

// Prepare a multimap (fieldHash -> fds) for lookupswitch
TreeMap<Integer, FieldDescriptor> fieldHashes = new TreeMap<>();
boolean isRecord = JavaFeatures.isRecord(cls);
Expand Down
27 changes: 26 additions & 1 deletion test/one/nio/serial/JsonReaderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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" +
Expand Down Expand Up @@ -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<String> set = Collections.emptySet();
Expand Down
95 changes: 95 additions & 0 deletions test/one/nio/serial/gen/DelegateGeneratorTest.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
}

0 comments on commit 0ef9a2f

Please sign in to comment.