feat(commons-serialize): port over gson streams as backend for new SerializeReader/SerializeWriter API in commons-serialize

This commit is contained in:
Johannes Frohnmeyer 2024-04-12 15:32:54 +02:00
parent 108a370c51
commit df78e10c6a
Signed by: Johannes
GPG Key ID: E76429612C2929F4
25 changed files with 7609 additions and 3 deletions

View File

@ -7,7 +7,7 @@ plugins {
dependencies {
api(libs.gson)
implementation(projects.commons)
implementation(projects.commonsSerialize)
api(projects.commonsSerialize)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)

View File

@ -0,0 +1,31 @@
import io.gitlab.jfronny.scripts.*
plugins {
commons.library
}
dependencies {
implementation(projects.commons)
api(projects.commonsSerialize)
testImplementation(libs.junit.jupiter.api)
testImplementation(libs.google.truth)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.vintage)
}
publishing {
publications {
create<MavenPublication>("maven") {
groupId = "io.gitlab.jfronny"
artifactId = "commons-serialize-json"
from(components["java"])
}
}
}
tasks.javadoc {
linksOffline("https://maven.frohnmeyer-wds.de/javadoc/artifacts/io/gitlab/jfronny/commons/$version/raw", projects.commons)
linksOffline("https://maven.frohnmeyer-wds.de/javadoc/artifacts/io/gitlab/jfronny/commons-serialize/$version/raw", projects.commonsSerialize)
}

View File

@ -0,0 +1,350 @@
package io.gitlab.jfronny.commons.serialize.json;
import io.gitlab.jfronny.commons.serialize.StringEscapeUtil;
import io.gitlab.jfronny.commons.serialize.stream.MalformedDataException;
import io.gitlab.jfronny.commons.serialize.stream.SerializeWriter;
import java.io.Closeable;
import java.io.IOException;
import java.io.Writer;
import java.util.*;
import static io.gitlab.jfronny.commons.serialize.json.impl.JsonScope.*;
public class JsonWriter extends SerializeWriter<IOException, JsonWriter> implements Closeable {
private final Writer out;
private int[] stack = new int[32];
private int stackSize = 0;
{
push(EMPTY_DOCUMENT);
}
private String newline;
private String indent;
private String formattedColon;
private String formattedComma;
private boolean usesEmptyNewlineAndIndent;
private boolean omitQuotes = false;
private String deferredName;
private List<String> deferredComments = new LinkedList<>();
public JsonWriter(Writer out) {
this.out = Objects.requireNonNull(out, "out == null");
newline = indent = "";
setIndent("");
setNewline("");
}
public JsonWriter setIndent(String indent) {
if (indent == null || indent.isEmpty()) {
this.indent = "";
this.formattedColon = ":";
this.formattedComma = ",";
this.usesEmptyNewlineAndIndent = newline.isEmpty();
} else {
this.newline = "\n"; // if someone sets an indent, this is probably intended
this.indent = indent;
this.formattedColon = ": ";
this.formattedComma = ",";
this.usesEmptyNewlineAndIndent = false;
}
return this;
}
public String getIndent() {
return indent;
}
public JsonWriter setNewline(String newline) {
if (newline == null || newline.isEmpty()) {
this.newline = "";
this.formattedComma = indent.isEmpty() ? "," : ", ";
this.usesEmptyNewlineAndIndent = indent.isEmpty();
} else {
this.newline = newline;
this.formattedComma = ",";
this.usesEmptyNewlineAndIndent = false;
}
return this;
}
public String getNewline() {
return newline;
}
@Override
public JsonWriter beginArray() throws IOException {
writeDeferredName();
return openScope(EMPTY_ARRAY, '[');
}
@Override
public JsonWriter endArray() throws IOException {
return closeScope(EMPTY_ARRAY, NONEMPTY_ARRAY, ']');
}
@Override
public JsonWriter beginObject() throws IOException {
writeDeferredName();
return openScope(EMPTY_OBJECT, '{');
}
@Override
public JsonWriter endObject() throws IOException {
return closeScope(EMPTY_OBJECT, NONEMPTY_OBJECT, '}');
}
private JsonWriter openScope(int empty, char openBracket) throws IOException {
beforeValue();
push(empty);
out.write(openBracket);
return this;
}
private JsonWriter closeScope(int empty, int nonempty, char closeBracket) throws IOException {
int context = peek();
if (context != nonempty && context != empty) {
throw new IllegalStateException("Nesting problem.");
}
if (deferredName != null) {
throw new IllegalStateException("Dangling name: " + deferredName);
}
if (!deferredComments.isEmpty()) {
newline();
writeDeferredComment();
context = nonempty;
}
stackSize--;
if (context == nonempty) {
newline();
}
out.write(closeBracket);
return this;
}
private void push(int newTop) {
if (stackSize == stack.length) {
stack = Arrays.copyOf(stack, stackSize * 2);
}
stack[stackSize++] = newTop;
}
/** Returns the value on the top of the stack. */
private int peek() {
if (stackSize == 0) {
throw new IllegalStateException("JsonWriter is closed.");
}
return stack[stackSize - 1];
}
/** Replace the value on the top of the stack with the given value. */
private void replaceTop(int topOfStack) {
stack[stackSize - 1] = topOfStack;
}
@Override
public JsonWriter comment(String comment) throws IOException {
if (!lenient) throw new MalformedDataException("Cannot write comment in non-lenient JsonWriter.");
if (comment == null || comment.isBlank()) return this;
String[] parts = comment.split("\n");
Collections.addAll(deferredComments, parts);
if (peek() == NONEMPTY_DOCUMENT) {
newline();
writeDeferredComment();
}
return this;
}
private void writeDeferredComment() throws IOException {
if (!deferredComments.isEmpty()) {
if (newline.isEmpty()) {
out.append("/* ").append(String.join(" / ", deferredComments)).append(" */");
} else {
boolean first = true;
for (String s : deferredComments) {
if (!first) newline();
first = false;
out.append("// ").append(s);
}
}
deferredComments.clear();
}
}
@Override
public JsonWriter name(String name) throws IOException {
Objects.requireNonNull(name, "name == null");
if (deferredName != null) {
throw new IllegalStateException("Already wrote a name, expecting a value.");
}
int context = peek();
if (context != EMPTY_OBJECT && context != NONEMPTY_OBJECT) {
throw new IllegalStateException("Please begin an object before writing a name.");
}
deferredName = name;
return this;
}
private void writeDeferredName() throws IOException {
if (deferredName != null) {
beforeName();
if (omitQuotes && deferredName.matches("[a-zA-Z_$][\\w$]*")) {
out.write(deferredName);
}
else {
string(deferredName);
}
deferredName = null;
}
}
@Override
public JsonWriter value(String value) throws IOException {
if (value == null) {
return nullValue();
}
writeDeferredName();
beforeValue();
string(value);
return this;
}
@Override
public JsonWriter literalValue(String value) throws IOException {
if (value == null) {
return nullValue();
}
writeDeferredName();
beforeValue();
out.append(value);
return this;
}
@Override
public void flush() throws IOException {
if (stackSize == 0) {
throw new IllegalStateException("JsonWriter is closed.");
}
out.flush();
}
@Override
public void close() throws IOException {
out.close();
int size = stackSize;
if (size > 1 || (size == 1 && stack[size - 1] != NONEMPTY_DOCUMENT)) {
throw new IOException("Incomplete document");
}
stackSize = 0;
}
private void string(String value) throws IOException {
out.write('\"');
int last = 0;
int length = value.length();
for (int i = 0; i < length; i++) {
char c = value.charAt(i);
String replacement = StringEscapeUtil.getReplacement(c);
if (replacement == null) {
continue;
}
if (last < i) {
out.write(value, last, i - last);
}
out.write(replacement);
last = i + 1;
}
if (last < length) {
out.write(value, last, length - last);
}
out.write('\"');
}
private void newline() throws IOException {
if (usesEmptyNewlineAndIndent) {
return;
}
out.write(newline);
for (int i = 1, size = stackSize; i < size; i++) {
out.write(indent);
}
}
/**
* Inserts any necessary separators and whitespace before a name. Also adjusts the stack to expect
* the name's value.
*/
private void beforeName() throws IOException {
int context = peek();
if (context == NONEMPTY_OBJECT) { // first in object
out.write(formattedComma);
} else if (context != EMPTY_OBJECT) { // not in an object!
throw new IllegalStateException("Nesting problem.");
}
newline();
if (!deferredComments.isEmpty()) {
writeDeferredComment();
newline();
}
replaceTop(DANGLING_NAME);
}
/**
* Inserts any necessary separators and whitespace before a literal value, inline array, or inline
* object. Also adjusts the stack to expect either a closing bracket or another element.
*/
@SuppressWarnings("fallthrough")
private void beforeValue() throws IOException {
switch (peek()) {
case NONEMPTY_DOCUMENT:
if (!lenient) {
throw new IllegalStateException("JSON must have only one top-level value.");
}
// fall-through
case EMPTY_DOCUMENT: // first in document
replaceTop(NONEMPTY_DOCUMENT);
if (!deferredComments.isEmpty()) {
writeDeferredComment();
newline();
}
break;
case EMPTY_ARRAY: // first in array
replaceTop(NONEMPTY_ARRAY);
newline();
if (!deferredComments.isEmpty()) {
writeDeferredComment();
newline();
}
break;
case NONEMPTY_ARRAY: // another in array
out.append(formattedComma);
newline();
if (!deferredComments.isEmpty()) {
writeDeferredComment();
newline();
}
break;
case DANGLING_NAME: // value for name
out.append(formattedColon);
if (!deferredComments.isEmpty()) {
newline();
writeDeferredComment();
newline();
}
replaceTop(NONEMPTY_OBJECT);
break;
default:
throw new IllegalStateException("Nesting problem.");
}
}
}

View File

@ -0,0 +1,27 @@
package io.gitlab.jfronny.commons.serialize.json.impl;
public class JsonScope {
/** An array with no elements requires no separator before the next element. */
public static final int EMPTY_ARRAY = 1;
/** An array with at least one value requires a separator before the next element. */
public static final int NONEMPTY_ARRAY = 2;
/** An object with no name/value pairs requires no separator before the next element. */
public static final int EMPTY_OBJECT = 3;
/** An object whose most recent element is a key. The next element must be a value. */
public static final int DANGLING_NAME = 4;
/** An object with at least one name/value pair requires a separator before the next element. */
public static final int NONEMPTY_OBJECT = 5;
/** No top-level value has been started yet. */
public static final int EMPTY_DOCUMENT = 6;
/** A top-level value has already been started. */
public static final int NONEMPTY_DOCUMENT = 7;
/** A document that's been closed and cannot be accessed. */
public static final int CLOSED = 8;
}

View File

@ -0,0 +1,6 @@
module io.gitlab.jfronny.commons.serialize.json {
requires io.gitlab.jfronny.commons;
requires io.gitlab.jfronny.commons.serialize;
requires static org.jetbrains.annotations;
exports io.gitlab.jfronny.commons.serialize.json;
}

View File

@ -0,0 +1,429 @@
/*
* Copyright (C) 2014 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.gitlab.jfronny.commons.serialize.json.test;
import io.gitlab.jfronny.commons.serialize.json.JsonReader;
import io.gitlab.jfronny.commons.serialize.stream.SerializeReader;
import io.gitlab.jfronny.commons.serialize.stream.Token;
import io.gitlab.jfronny.commons.serialize.stream.emulated.DataElementSerializer;
import io.gitlab.jfronny.commons.serialize.stream.emulated.EmulatedReader;
import io.gitlab.jfronny.commons.throwable.Try;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import java.io.IOException;
import java.io.StringReader;
import java.util.Arrays;
import java.util.List;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import static org.junit.Assume.assumeTrue;
@SuppressWarnings("resource")
@RunWith(Parameterized.class)
public class JsonReaderPathTest {
@Parameterized.Parameters(name = "{0}")
public static List<Object[]> parameters() {
return Arrays.asList(
new Object[] {Factory.STRING_READER}, new Object[] {Factory.OBJECT_READER});
}
@Parameterized.Parameter public Factory factory;
@Test
public void path() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("{\"a\":[2,true,false,null,\"b\",{\"c\":\"d\"},[3]]}");
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
reader.beginObject();
assertThat(reader.getPreviousPath()).isEqualTo("$.");
assertThat(reader.getPath()).isEqualTo("$.");
String unused1 = reader.nextName();
assertThat(reader.getPreviousPath()).isEqualTo("$.a");
assertThat(reader.getPath()).isEqualTo("$.a");
reader.beginArray();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[0]");
assertThat(reader.getPath()).isEqualTo("$.a[0]");
int unused2 = reader.nextInt();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[0]");
assertThat(reader.getPath()).isEqualTo("$.a[1]");
boolean unused3 = reader.nextBoolean();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[1]");
assertThat(reader.getPath()).isEqualTo("$.a[2]");
boolean unused4 = reader.nextBoolean();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[2]");
assertThat(reader.getPath()).isEqualTo("$.a[3]");
reader.nextNull();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[3]");
assertThat(reader.getPath()).isEqualTo("$.a[4]");
String unused5 = reader.nextString();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[4]");
assertThat(reader.getPath()).isEqualTo("$.a[5]");
reader.beginObject();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[5].");
assertThat(reader.getPath()).isEqualTo("$.a[5].");
String unused6 = reader.nextName();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[5].c");
assertThat(reader.getPath()).isEqualTo("$.a[5].c");
String unused7 = reader.nextString();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[5].c");
assertThat(reader.getPath()).isEqualTo("$.a[5].c");
reader.endObject();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[5]");
assertThat(reader.getPath()).isEqualTo("$.a[6]");
reader.beginArray();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[6][0]");
assertThat(reader.getPath()).isEqualTo("$.a[6][0]");
int unused8 = reader.nextInt();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[6][0]");
assertThat(reader.getPath()).isEqualTo("$.a[6][1]");
reader.endArray();
assertThat(reader.getPreviousPath()).isEqualTo("$.a[6]");
assertThat(reader.getPath()).isEqualTo("$.a[7]");
reader.endArray();
assertThat(reader.getPreviousPath()).isEqualTo("$.a");
assertThat(reader.getPath()).isEqualTo("$.a");
reader.endObject();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
}
@Test
public void objectPath() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("{\"a\":1,\"b\":2}");
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
Token unused1 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
reader.beginObject();
assertThat(reader.getPreviousPath()).isEqualTo("$.");
assertThat(reader.getPath()).isEqualTo("$.");
Token unused2 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$.");
assertThat(reader.getPath()).isEqualTo("$.");
String unused3 = reader.nextName();
assertThat(reader.getPreviousPath()).isEqualTo("$.a");
assertThat(reader.getPath()).isEqualTo("$.a");
Token unused4 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$.a");
assertThat(reader.getPath()).isEqualTo("$.a");
int unused5 = reader.nextInt();
assertThat(reader.getPreviousPath()).isEqualTo("$.a");
assertThat(reader.getPath()).isEqualTo("$.a");
Token unused6 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$.a");
assertThat(reader.getPath()).isEqualTo("$.a");
String unused7 = reader.nextName();
assertThat(reader.getPreviousPath()).isEqualTo("$.b");
assertThat(reader.getPath()).isEqualTo("$.b");
Token unused8 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$.b");
assertThat(reader.getPath()).isEqualTo("$.b");
int unused9 = reader.nextInt();
assertThat(reader.getPreviousPath()).isEqualTo("$.b");
assertThat(reader.getPath()).isEqualTo("$.b");
Token unused10 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$.b");
assertThat(reader.getPath()).isEqualTo("$.b");
reader.endObject();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
Token unused11 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
Try.orThrow(reader::close);
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
}
@Test
public void arrayPath() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("[1,2]");
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
Token unused1 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
reader.beginArray();
assertThat(reader.getPreviousPath()).isEqualTo("$[0]");
assertThat(reader.getPath()).isEqualTo("$[0]");
Token unused2 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$[0]");
assertThat(reader.getPath()).isEqualTo("$[0]");
int unused3 = reader.nextInt();
assertThat(reader.getPreviousPath()).isEqualTo("$[0]");
assertThat(reader.getPath()).isEqualTo("$[1]");
Token unused4 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$[0]");
assertThat(reader.getPath()).isEqualTo("$[1]");
int unused5 = reader.nextInt();
assertThat(reader.getPreviousPath()).isEqualTo("$[1]");
assertThat(reader.getPath()).isEqualTo("$[2]");
Token unused6 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$[1]");
assertThat(reader.getPath()).isEqualTo("$[2]");
reader.endArray();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
Token unused7 = reader.peek();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
Try.orThrow(reader::close);
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
}
@Test
public void multipleTopLevelValuesInOneDocument() throws IOException {
assumeTrue(factory == Factory.STRING_READER);
SerializeReader<? extends IOException, ?> reader = factory.create("[][]");
reader.setLenient(true);
reader.beginArray();
reader.endArray();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
reader.beginArray();
reader.endArray();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
}
@Test
public void skipArrayElements() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("[1,2,3]");
reader.beginArray();
reader.skipValue();
reader.skipValue();
assertThat(reader.getPreviousPath()).isEqualTo("$[1]");
assertThat(reader.getPath()).isEqualTo("$[2]");
}
@Test
public void skipArrayEnd() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("[[],1]");
reader.beginArray();
reader.beginArray();
assertThat(reader.getPreviousPath()).isEqualTo("$[0][0]");
assertThat(reader.getPath()).isEqualTo("$[0][0]");
assertThrows("Attempt to skip led outside its parent", IllegalStateException.class, reader::skipValue);
assertThat(reader.getPreviousPath()).isEqualTo("$[0]");
assertThat(reader.getPath()).isEqualTo("$[1]");
}
@Test
public void skipObjectNames() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("{\"a\":[]}");
reader.beginObject();
reader.skipValue();
assertThat(reader.getPreviousPath()).isEqualTo("$.<skipped>");
assertThat(reader.getPath()).isEqualTo("$.<skipped>");
reader.beginArray();
assertThat(reader.getPreviousPath()).isEqualTo("$.<skipped>[0]");
assertThat(reader.getPath()).isEqualTo("$.<skipped>[0]");
}
@Test
public void skipObjectValues() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("{\"a\":1,\"b\":2}");
reader.beginObject();
assertThat(reader.getPreviousPath()).isEqualTo("$.");
assertThat(reader.getPath()).isEqualTo("$.");
String unused1 = reader.nextName();
reader.skipValue();
assertThat(reader.getPreviousPath()).isEqualTo("$.a");
assertThat(reader.getPath()).isEqualTo("$.a");
String unused2 = reader.nextName();
assertThat(reader.getPreviousPath()).isEqualTo("$.b");
assertThat(reader.getPath()).isEqualTo("$.b");
}
@Test
public void skipObjectEnd() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("{\"a\":{},\"b\":2}");
reader.beginObject();
String unused = reader.nextName();
reader.beginObject();
assertThat(reader.getPreviousPath()).isEqualTo("$.a.");
assertThat(reader.getPath()).isEqualTo("$.a.");
// skip end of object
assertThat(reader.peek()).isEqualTo(Token.END_OBJECT);
assertThrows("Attempt to skip led outside its parent", IllegalStateException.class, reader::skipValue);
assertThat(reader.getPreviousPath()).isEqualTo("$.a");
assertThat(reader.getPath()).isEqualTo("$.a");
}
@Test
public void skipNestedStructures() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("[[1,2,3],4]");
reader.beginArray();
reader.skipValue();
assertThat(reader.getPreviousPath()).isEqualTo("$[0]");
assertThat(reader.getPath()).isEqualTo("$[1]");
}
@Test
public void skipEndOfDocument() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("[]");
reader.beginArray();
reader.endArray();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
assertThrows("Attempt to skip led outside the document", IllegalStateException.class, reader::skipValue);
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
}
@Test
public void arrayOfObjects() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("[{},{},{}]");
reader.beginArray();
assertThat(reader.getPreviousPath()).isEqualTo("$[0]");
assertThat(reader.getPath()).isEqualTo("$[0]");
reader.beginObject();
assertThat(reader.getPreviousPath()).isEqualTo("$[0].");
assertThat(reader.getPath()).isEqualTo("$[0].");
reader.endObject();
assertThat(reader.getPreviousPath()).isEqualTo("$[0]");
assertThat(reader.getPath()).isEqualTo("$[1]");
reader.beginObject();
assertThat(reader.getPreviousPath()).isEqualTo("$[1].");
assertThat(reader.getPath()).isEqualTo("$[1].");
reader.endObject();
assertThat(reader.getPreviousPath()).isEqualTo("$[1]");
assertThat(reader.getPath()).isEqualTo("$[2]");
reader.beginObject();
assertThat(reader.getPreviousPath()).isEqualTo("$[2].");
assertThat(reader.getPath()).isEqualTo("$[2].");
reader.endObject();
assertThat(reader.getPreviousPath()).isEqualTo("$[2]");
assertThat(reader.getPath()).isEqualTo("$[3]");
reader.endArray();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
}
@Test
public void arrayOfArrays() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("[[],[],[]]");
reader.beginArray();
assertThat(reader.getPreviousPath()).isEqualTo("$[0]");
assertThat(reader.getPath()).isEqualTo("$[0]");
reader.beginArray();
assertThat(reader.getPreviousPath()).isEqualTo("$[0][0]");
assertThat(reader.getPath()).isEqualTo("$[0][0]");
reader.endArray();
assertThat(reader.getPreviousPath()).isEqualTo("$[0]");
assertThat(reader.getPath()).isEqualTo("$[1]");
reader.beginArray();
assertThat(reader.getPreviousPath()).isEqualTo("$[1][0]");
assertThat(reader.getPath()).isEqualTo("$[1][0]");
reader.endArray();
assertThat(reader.getPreviousPath()).isEqualTo("$[1]");
assertThat(reader.getPath()).isEqualTo("$[2]");
reader.beginArray();
assertThat(reader.getPreviousPath()).isEqualTo("$[2][0]");
assertThat(reader.getPath()).isEqualTo("$[2][0]");
reader.endArray();
assertThat(reader.getPreviousPath()).isEqualTo("$[2]");
assertThat(reader.getPath()).isEqualTo("$[3]");
reader.endArray();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
}
@Test
public void objectOfObjects() throws IOException {
SerializeReader<? extends IOException, ?> reader = factory.create("{\"a\":{\"a1\":1,\"a2\":2},\"b\":{\"b1\":1}}");
reader.beginObject();
assertThat(reader.getPreviousPath()).isEqualTo("$.");
assertThat(reader.getPath()).isEqualTo("$.");
String unused1 = reader.nextName();
assertThat(reader.getPreviousPath()).isEqualTo("$.a");
assertThat(reader.getPath()).isEqualTo("$.a");
reader.beginObject();
assertThat(reader.getPreviousPath()).isEqualTo("$.a.");
assertThat(reader.getPath()).isEqualTo("$.a.");
String unused2 = reader.nextName();
assertThat(reader.getPreviousPath()).isEqualTo("$.a.a1");
assertThat(reader.getPath()).isEqualTo("$.a.a1");
int unused3 = reader.nextInt();
assertThat(reader.getPreviousPath()).isEqualTo("$.a.a1");
assertThat(reader.getPath()).isEqualTo("$.a.a1");
String unused4 = reader.nextName();
assertThat(reader.getPreviousPath()).isEqualTo("$.a.a2");
assertThat(reader.getPath()).isEqualTo("$.a.a2");
int unused5 = reader.nextInt();
assertThat(reader.getPreviousPath()).isEqualTo("$.a.a2");
assertThat(reader.getPath()).isEqualTo("$.a.a2");
reader.endObject();
assertThat(reader.getPreviousPath()).isEqualTo("$.a");
assertThat(reader.getPath()).isEqualTo("$.a");
String unused6 = reader.nextName();
assertThat(reader.getPreviousPath()).isEqualTo("$.b");
assertThat(reader.getPath()).isEqualTo("$.b");
reader.beginObject();
assertThat(reader.getPreviousPath()).isEqualTo("$.b.");
assertThat(reader.getPath()).isEqualTo("$.b.");
String unused7 = reader.nextName();
assertThat(reader.getPreviousPath()).isEqualTo("$.b.b1");
assertThat(reader.getPath()).isEqualTo("$.b.b1");
int unused8 = reader.nextInt();
assertThat(reader.getPreviousPath()).isEqualTo("$.b.b1");
assertThat(reader.getPath()).isEqualTo("$.b.b1");
reader.endObject();
assertThat(reader.getPreviousPath()).isEqualTo("$.b");
assertThat(reader.getPath()).isEqualTo("$.b");
reader.endObject();
assertThat(reader.getPreviousPath()).isEqualTo("$");
assertThat(reader.getPath()).isEqualTo("$");
}
public enum Factory {
STRING_READER {
@Override
public SerializeReader<? extends IOException, ?> create(String data) {
return new JsonReader(new StringReader(data));
}
},
OBJECT_READER {
@Override
public SerializeReader<? extends IOException, ?> create(String data) {
JsonReader source = new JsonReader(new StringReader(data));
return new EmulatedReader(Try.orThrow(() -> DataElementSerializer.deserialize(source)));
}
};
abstract SerializeReader<? extends IOException, ?> create(String data);
}
}

View File

@ -1,9 +1,11 @@
import io.gitlab.jfronny.scripts.*
plugins {
commons.library
}
dependencies {
implementation(projects.commons)
}
publishing {
publications {
create<MavenPublication>("maven") {

View File

@ -0,0 +1,56 @@
package io.gitlab.jfronny.commons.serialize;
/**
* Utilities methods for escaping strings, extracted from gsons JsonWriter.
* @author JFronny
*/
public class StringEscapeUtil {
/*
* From RFC 8259, "All Unicode characters may be placed within the
* quotation marks except for the characters that must be escaped:
* quotation mark, reverse solidus, and the control characters
* (U+0000 through U+001F)."
*
* We also escape '\u2028' and '\u2029', which JavaScript interprets as
* newline characters. This prevents eval() from failing with a syntax
* error. http://code.google.com/p/google-gson/issues/detail?id=341
*/
private static final String[] REPLACEMENT_CHARS;
static {
REPLACEMENT_CHARS = new String[128];
for (int i = 0; i <= 0x1f; i++) {
REPLACEMENT_CHARS[i] = String.format("\\u%04x", i);
}
REPLACEMENT_CHARS['"'] = "\\\"";
REPLACEMENT_CHARS['\\'] = "\\\\";
REPLACEMENT_CHARS['\t'] = "\\t";
REPLACEMENT_CHARS['\b'] = "\\b";
REPLACEMENT_CHARS['\n'] = "\\n";
REPLACEMENT_CHARS['\r'] = "\\r";
REPLACEMENT_CHARS['\f'] = "\\f";
REPLACEMENT_CHARS['\0'] = "\\0";
}
/**
* Returns the replacement for the character, or null if the character does not need to be escaped.
* @param c the character to escape
* @return the replacement for the character, or null if the character does not need to be escaped
*/
public static String getReplacement(char c) {
String replacement;
if (c < 128) {
replacement = REPLACEMENT_CHARS[c];
if (replacement == null) {
return null;
}
} else if (c == '\u2028') {
replacement = "\\u2028";
} else if (c == '\u2029') {
replacement = "\\u2029";
} else {
return null;
}
return replacement;
}
}

View File

@ -0,0 +1,17 @@
package io.gitlab.jfronny.commons.serialize.stream;
import java.io.IOException;
public class MalformedDataException extends IOException {
public MalformedDataException(String msg) {
super(msg);
}
public MalformedDataException(String msg, Throwable throwable) {
super(msg, throwable);
}
public MalformedDataException(Throwable throwable) {
super(throwable);
}
}

View File

@ -0,0 +1,81 @@
package io.gitlab.jfronny.commons.serialize.stream;
import io.gitlab.jfronny.commons.SamWithReceiver;
public abstract class SerializeReader<TEx extends Throwable, T extends SerializeReader<TEx, T>> implements AutoCloseable {
protected boolean lenient = false;
protected boolean serializeSpecialFloatingPointValues = false;
public boolean isLenient() {
return lenient;
}
public T setLenient(boolean lenient) {
this.lenient = lenient;
if (lenient) return setSerializeSpecialFloatingPointValues(true);
return (T) this;
}
public boolean isSerializeSpecialFloatingPointValues() {
return serializeSpecialFloatingPointValues;
}
public T setSerializeSpecialFloatingPointValues(boolean serializeSpecialFloatingPointValues) {
this.serializeSpecialFloatingPointValues = serializeSpecialFloatingPointValues;
return (T) this;
}
public abstract T beginArray() throws TEx;
public abstract T endArray() throws TEx;
public <R> R array(SerializeReaderFunction<TEx, T, R> consumer) throws TEx {
beginArray();
var result = consumer.accept((T) this);
endArray();
return result;
}
public abstract T beginObject() throws TEx;
public abstract T endObject() throws TEx;
public <R> R object(SerializeReaderFunction<TEx, T, R> consumer) throws TEx {
beginObject();
var result = consumer.accept((T) this);
endObject();
return result;
}
public abstract boolean hasNext() throws TEx;
public abstract Token peek() throws TEx;
public abstract String nextName() throws TEx;
public abstract String nextString() throws TEx;
public abstract boolean nextBoolean() throws TEx;
public abstract void nextNull() throws TEx;
public double nextDouble() throws TEx {
return nextNumber().doubleValue();
}
public long nextLong() throws TEx {
return nextNumber().longValue();
}
public int nextInt() throws TEx {
return nextNumber().intValue();
}
public abstract Number nextNumber() throws TEx;
public abstract void skipValue() throws TEx;
public abstract String getPath();
public abstract String getPreviousPath();
@Override
public String toString() {
return getClass().getSimpleName() + locationString();
}
protected String locationString() {
return " at path " + getPath();
}
@SamWithReceiver
public interface SerializeReaderFunction<TEx extends Throwable, T extends SerializeReader<TEx, T>, R> {
R accept(T reader) throws TEx;
}
}

View File

@ -0,0 +1,138 @@
package io.gitlab.jfronny.commons.serialize.stream;
import io.gitlab.jfronny.commons.SamWithReceiver;
import java.io.Flushable;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
public abstract class SerializeWriter<TEx extends Throwable, T extends SerializeWriter<TEx, T>> implements AutoCloseable, Flushable {
private static final Pattern VALID_JSON_NUMBER_PATTERN =
Pattern.compile("-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?(?:[eE][-+]?[0-9]+)?");
protected boolean lenient = false;
protected boolean serializeNulls = true;
protected boolean serializeSpecialFloatingPointValues = false;
public boolean isLenient() {
return lenient;
}
public T setLenient(boolean lenient) {
this.lenient = lenient;
if (lenient) return setSerializeSpecialFloatingPointValues(true).setSerializeNulls(true);
return (T) this;
}
public boolean isSerializeNulls() {
return serializeNulls;
}
public T setSerializeNulls(boolean serializeNulls) {
this.serializeNulls = serializeNulls;
return (T) this;
}
public boolean isSerializeSpecialFloatingPointValues() {
return serializeSpecialFloatingPointValues;
}
public T setSerializeSpecialFloatingPointValues(boolean serializeSpecialFloatingPointValues) {
this.serializeSpecialFloatingPointValues = serializeSpecialFloatingPointValues;
return (T) this;
}
public abstract T beginArray() throws TEx;
public abstract T endArray() throws TEx;
public T array(SerializeWriterConsumer<TEx, T> consumer) throws TEx {
return consumer.accept(this.beginArray()).endObject();
}
public abstract T beginObject() throws TEx;
public abstract T endObject() throws TEx;
public T object(SerializeWriterConsumer<TEx, T> consumer) throws TEx {
return consumer.accept(this.beginObject()).endObject();
}
public abstract T comment(String comment) throws TEx;
public abstract T name(String name) throws TEx;
public T nullValue() throws TEx {
if (serializeNulls) {
return literalValue("null");
} else {
throw new IllegalArgumentException("Null values are not allowed");
}
}
public abstract T value(String value) throws TEx;
public T value(boolean value) throws TEx {
return literalValue(value ? "true" : "false");
}
public T value(Boolean value) throws TEx {
return value == null ? nullValue() : value(value.booleanValue());
}
public T value(float value) throws TEx {
if (!serializeSpecialFloatingPointValues && (Float.isNaN(value) || Float.isInfinite(value)))
throw new IllegalArgumentException("Numeric values must be finite, but was " + value);
return literalValue(Float.toString(value));
}
public T value(Float value) throws TEx {
return value == null ? nullValue() : value(value.floatValue());
}
public T value(double value) throws TEx {
if (!serializeSpecialFloatingPointValues && (Double.isNaN(value) || Double.isInfinite(value)))
throw new IllegalArgumentException("Numeric values must be finite, but was " + value);
return literalValue(Double.toString(value));
}
public T value(Double value) throws TEx {
return value == null ? nullValue() : value(value.doubleValue());
}
public T value(long value) throws TEx {
return literalValue(Long.toString(value));
}
public T value(Long value) throws TEx {
return value == null ? nullValue() : value(value.longValue());
}
public T value(Number value) throws TEx {
if (value == null) return nullValue();
String s = value.toString();
if (s.equals("NaN") || s.equals("Infinity") || s.equals("-Infinity")) {
if (!serializeSpecialFloatingPointValues) {
throw new IllegalArgumentException("Numeric values must be finite, but was " + s);
}
} else {
Class<? extends Number> numberClass = value.getClass();
if (!isTrustedNumberClass(numberClass) && !VALID_JSON_NUMBER_PATTERN.matcher(s).matches()) {
throw new IllegalArgumentException("String created by " + numberClass + " is not a valid number: " + s);
}
}
return literalValue(s);
}
private static boolean isTrustedNumberClass(Class<? extends Number> c) {
return c == Integer.class
|| c == Long.class
|| c == Double.class
|| c == Float.class
|| c == Byte.class
|| c == Short.class
|| c == BigDecimal.class
|| c == BigInteger.class
|| c == AtomicInteger.class
|| c == AtomicLong.class;
}
/**
* Writes a literal value to the output without quoting or escaping.
* This may not be supported by all implementations, if not supported an {@link UnsupportedOperationException} will be thrown.
* @param value the literal value to write
* @return this writer
*/
public abstract T literalValue(String value) throws TEx;
@SamWithReceiver
public interface SerializeWriterConsumer<TEx extends Throwable, T extends SerializeWriter<TEx, T>> {
T accept(T writer) throws TEx;
}
}

View File

@ -0,0 +1,5 @@
package io.gitlab.jfronny.commons.serialize.stream;
public enum Token {
BEGIN_ARRAY, END_ARRAY, BEGIN_OBJECT, END_OBJECT, NAME, STRING, NUMBER, BOOLEAN, NULL, END_DOCUMENT
}

View File

@ -0,0 +1,48 @@
package io.gitlab.jfronny.commons.serialize.stream.emulated;
import io.gitlab.jfronny.commons.data.LinkedTreeMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public sealed interface DataElement {
record Null() implements DataElement {}
sealed interface Primitive extends DataElement {
java.lang.String asString();
record Boolean(boolean value) implements Primitive {
@Override
public java.lang.String asString() {
return value ? "true" : "false";
}
}
record Number(java.lang.Number value) implements Primitive {
public Number {
Objects.requireNonNull(value);
}
@Override
public java.lang.String asString() {
return value.toString();
}
}
record String(java.lang.String value) implements Primitive {
@Override
public java.lang.String asString() {
return value;
}
}
}
record Object(Map<String, DataElement> members) implements DataElement {
public Object() {
this(new LinkedTreeMap<>(false));
}
}
record Array(List<DataElement> elements) implements DataElement {
public Array() {
this(new ArrayList<>());
}
}
}

View File

@ -0,0 +1,61 @@
package io.gitlab.jfronny.commons.serialize.stream.emulated;
import io.gitlab.jfronny.commons.serialize.stream.SerializeReader;
import io.gitlab.jfronny.commons.serialize.stream.SerializeWriter;
import java.util.Map;
public class DataElementSerializer {
public static <TEx extends Throwable, T extends SerializeWriter<TEx, T>> void serialize(DataElement element, T out) throws TEx {
switch (element) {
case DataElement.Array(var elements) -> out.array(b -> {
for (DataElement e : elements) {
serialize(e, b);
}
return b;
});
case DataElement.Null n -> out.nullValue();
case DataElement.Object(var members) -> out.object(b -> {
for (Map.Entry<String, DataElement> e : members.entrySet()) {
b.name(e.getKey());
serialize(e.getValue(), b);
}
return b;
});
case DataElement.Primitive p -> {
switch (p) {
case DataElement.Primitive.Boolean(var value) -> out.value(value);
case DataElement.Primitive.Number(var value) -> out.value(value);
case DataElement.Primitive.String(var value) -> out.value(value);
}
}
}
}
public static <TEx extends Throwable, T extends SerializeReader<TEx, T>> DataElement deserialize(T in) throws TEx {
return switch (in.peek()) {
case STRING -> new DataElement.Primitive.String(in.nextString());
case NUMBER -> new DataElement.Primitive.Number(in.nextNumber());
case BOOLEAN -> new DataElement.Primitive.Boolean(in.nextBoolean());
case NULL -> {
in.nextNull();
yield new DataElement.Null();
}
case BEGIN_ARRAY -> in.array(b -> {
DataElement.Array array = new DataElement.Array();
while (b.hasNext()) {
array.elements().add(deserialize(b));
}
return array;
});
case BEGIN_OBJECT -> in.object(b -> {
DataElement.Object object = new DataElement.Object();
while (b.hasNext()) {
object.members().put(b.nextName(), deserialize(b));
}
return object;
});
case END_ARRAY, END_OBJECT, END_DOCUMENT, NAME -> throw new IllegalStateException();
};
}
}

View File

@ -0,0 +1,281 @@
package io.gitlab.jfronny.commons.serialize.stream.emulated;
import io.gitlab.jfronny.commons.serialize.stream.MalformedDataException;
import io.gitlab.jfronny.commons.serialize.stream.SerializeReader;
import io.gitlab.jfronny.commons.serialize.stream.Token;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Map;
public class EmulatedReader extends SerializeReader<MalformedDataException, EmulatedReader> {
private static final Object SENTINEL_CLOSED = new Object();
/*
* The nesting stack. Using a manual array rather than an ArrayList saves 20%.
*/
private Object[] stack = new Object[32];
private int stackSize = 0;
/*
* The path members. It corresponds directly to stack: At indices where the
* stack contains an object (EMPTY_OBJECT, DANGLING_NAME or NONEMPTY_OBJECT),
* pathNames contains the name at this scope. Where it contains an array
* (EMPTY_ARRAY, NONEMPTY_ARRAY) pathIndices contains the current index in
* that array. Otherwise the value is undefined, and we take advantage of that
* by incrementing pathIndices when doing so isn't useful.
*/
private String[] pathNames = new String[32];
private int[] pathIndices = new int[32];
public EmulatedReader(DataElement element) {
push(element);
}
private void push(Object newTop) {
if (stackSize == stack.length) {
int newLength = stackSize * 2;
stack = Arrays.copyOf(stack, newLength);
pathIndices = Arrays.copyOf(pathIndices, newLength);
pathNames = Arrays.copyOf(pathNames, newLength);
}
stack[stackSize++] = newTop;
}
private Object peekStack() {
return stack[stackSize - 1];
}
private Object popStack() {
Object result = stack[--stackSize];
stack[stackSize] = null;
return result;
}
private void expect(Token expected) throws MalformedDataException {
if (peek() != expected) {
throw new IllegalStateException(
"Expected " + expected + " but was " + peek() + locationString());
}
}
private String nextName(boolean skipName) throws MalformedDataException {
expect(Token.NAME);
Iterator<?> i = (Iterator<?>) peekStack();
Map.Entry<?, ?> entry = (Map.Entry<?, ?>) i.next();
String result = (String) entry.getKey();
pathNames[stackSize - 1] = skipName ? "<skipped>" : result;
push(entry.getValue());
return result;
}
@Override
public EmulatedReader beginArray() throws MalformedDataException {
expect(Token.BEGIN_ARRAY);
DataElement.Array array = (DataElement.Array) peekStack();
push(array.elements().iterator());
pathIndices[stackSize - 1] = 0;
return this;
}
@Override
public EmulatedReader endArray() throws MalformedDataException {
expect(Token.END_ARRAY);
popStack(); // empty iterator
popStack(); // array
if (stackSize > 0) {
pathIndices[stackSize - 1]++;
}
return this;
}
@Override
public EmulatedReader beginObject() throws MalformedDataException {
expect(Token.BEGIN_OBJECT);
DataElement.Object object = (DataElement.Object) peekStack();
push(object.members().entrySet().iterator());
return this;
}
@Override
public EmulatedReader endObject() throws MalformedDataException {
expect(Token.END_OBJECT);
pathNames[stackSize - 1] = null; // Free the last path name so that it can be garbage collected
popStack(); // empty iterator
popStack(); // object
if (stackSize > 0) {
pathIndices[stackSize - 1]++;
}
return this;
}
@Override
public boolean hasNext() throws MalformedDataException {
Token token = peek();
return token != Token.END_OBJECT
&& token != Token.END_ARRAY
&& token != Token.END_DOCUMENT;
}
@Override
public Token peek() throws MalformedDataException {
if (stackSize == 0) {
return Token.END_DOCUMENT;
}
Object o = peekStack();
if (o instanceof Iterator) {
boolean isObject = stack[stackSize - 2] instanceof DataElement.Object;
Iterator<?> iterator = (Iterator<?>) o;
if (iterator.hasNext()) {
if (isObject) {
return Token.NAME;
} else {
push(iterator.next());
return peek();
}
} else {
return isObject ? Token.END_OBJECT : Token.END_ARRAY;
}
} else if (o instanceof DataElement e) {
return switch (e) {
case DataElement.Object l -> Token.BEGIN_OBJECT;
case DataElement.Array l -> Token.BEGIN_ARRAY;
case DataElement.Primitive p -> switch (p) {
case DataElement.Primitive.String s -> Token.STRING;
case DataElement.Primitive.Boolean b -> Token.BOOLEAN;
case DataElement.Primitive.Number n -> Token.NUMBER;
};
case DataElement.Null l -> Token.NULL;
};
} else if (o == SENTINEL_CLOSED) {
throw new IllegalStateException("JsonReader is closed");
} else {
throw new MalformedDataException("Custom JsonElement subclass " + o.getClass().getName() + " is not supported");
}
}
@Override
public String nextName() throws MalformedDataException {
return nextName(false);
}
@Override
public String nextString() throws MalformedDataException {
Token token = peek();
if (token != Token.STRING && token != Token.NUMBER) {
throw new IllegalStateException(
"Expected " + Token.STRING + " but was " + token + locationString());
}
String result = ((DataElement.Primitive) popStack()).asString();
if (stackSize > 0) {
pathIndices[stackSize - 1]++;
}
return result;
}
@Override
public boolean nextBoolean() throws MalformedDataException {
expect(Token.BOOLEAN);
if (!(popStack() instanceof DataElement.Primitive.Boolean result)) {
throw new IllegalStateException("Expected a boolean");
}
if (stackSize > 0) {
pathIndices[stackSize - 1]++;
}
return result.value();
}
@Override
public void nextNull() throws MalformedDataException {
expect(Token.NULL);
popStack();
if (stackSize > 0) {
pathIndices[stackSize - 1]++;
}
}
@Override
public Number nextNumber() throws MalformedDataException {
Token token = peek();
if (token != Token.NUMBER && token != Token.STRING) {
throw new IllegalStateException(
"Expected " + Token.NUMBER + " but was " + token + locationString());
}
if (!(popStack() instanceof DataElement.Primitive.Number result)) {
throw new IllegalStateException("Expected a number");
}
popStack();
if (stackSize > 0) {
pathIndices[stackSize - 1]++;
}
return result.value();
}
@Override
public void skipValue() throws MalformedDataException {
Token peeked = peek();
switch (peeked) {
case NAME:
@SuppressWarnings("unused")
String unused = nextName(true);
break;
case END_ARRAY:
endArray();
throw new IllegalStateException("Attempt to skip led outside its parent");
case END_OBJECT:
endObject();
throw new IllegalStateException("Attempt to skip led outside its parent");
case END_DOCUMENT:
throw new IllegalStateException("Attempt to skip led outside the document");
default:
popStack();
if (stackSize > 0) {
pathIndices[stackSize - 1]++;
}
break;
}
}
private String getPath(boolean usePreviousPath) {
StringBuilder result = new StringBuilder().append('$');
for (int i = 0; i < stackSize; i++) {
if (stack[i] instanceof DataElement.Array) {
if (++i < stackSize && stack[i] instanceof Iterator) {
int pathIndex = pathIndices[i];
// If index is last path element it points to next array element; have to decrement
// `- 1` covers case where iterator for next element is on stack
// `- 2` covers case where peek() already pushed next element onto stack
if (usePreviousPath && pathIndex > 0 && (i == stackSize - 1 || i == stackSize - 2)) {
pathIndex--;
}
result.append('[').append(pathIndex).append(']');
}
} else if (stack[i] instanceof DataElement.Object) {
if (++i < stackSize && stack[i] instanceof Iterator) {
result.append('.');
if (pathNames[i] != null) {
result.append(pathNames[i]);
}
}
}
}
return result.toString();
}
@Override
public String getPath() {
return getPath(false);
}
@Override
public String getPreviousPath() {
return getPath(true);
}
@Override
public void close() throws Exception {
stack = new Object[] {SENTINEL_CLOSED};
stackSize = 1;
}
}

View File

@ -0,0 +1,186 @@
package io.gitlab.jfronny.commons.serialize.stream.emulated;
import io.gitlab.jfronny.commons.serialize.stream.SerializeWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class EmulatedWriter extends SerializeWriter<RuntimeException, EmulatedWriter> {
/** Added to the top of the stack when this writer is closed to cause following ops to fail. */
private static final DataElement.Primitive.String SENTINEL_CLOSED = new DataElement.Primitive.String("closed");
/** The JsonElements and JsonArrays under modification, outermost to innermost. */
private final List<DataElement> stack = new ArrayList<>();
/** The name for the next JSON object value. If non-null, the top of the stack is a JsonObject. */
private String pendingName;
/** the JSON element constructed by this writer. */
private DataElement product = new DataElement.Null();
public DataElement get() {
if (!stack.isEmpty()) {
throw new IllegalStateException("Expected one JSON element but was " + stack);
}
return product;
}
private void put(DataElement value) {
if (pendingName != null) {
if (!(value instanceof DataElement.Null) || serializeNulls) {
((DataElement.Object) stack.getLast()).members().put(pendingName, value);
}
pendingName = null;
} else if (stack.isEmpty()) {
product = value;
} else {
DataElement element = stack.getLast();
if (element instanceof DataElement.Array array) {
array.elements().add(value);
} else {
throw new IllegalStateException();
}
}
}
@Override
public EmulatedWriter beginArray() throws RuntimeException {
DataElement.Array array = new DataElement.Array();
put(array);
stack.add(array);
return this;
}
@Override
public EmulatedWriter endArray() throws RuntimeException {
if (stack.isEmpty() || pendingName != null) {
throw new IllegalStateException();
}
DataElement element = stack.getLast();
if (element instanceof DataElement.Array) {
stack.removeLast();
return this;
}
throw new IllegalStateException();
}
@Override
public EmulatedWriter beginObject() throws RuntimeException {
DataElement.Object object = new DataElement.Object();
put(object);
stack.add(object);
return this;
}
@Override
public EmulatedWriter endObject() throws RuntimeException {
if (stack.isEmpty() || pendingName != null) {
throw new IllegalStateException();
}
DataElement element = stack.getLast();
if (element instanceof DataElement.Object) {
stack.removeLast();
return this;
}
throw new IllegalStateException();
}
@Override
public EmulatedWriter comment(String comment) throws RuntimeException {
return this;
}
@Override
public EmulatedWriter name(String name) throws RuntimeException {
Objects.requireNonNull(name, "name == null");
if (stack.isEmpty() || pendingName != null) {
throw new IllegalStateException("Did not expect a name");
}
DataElement element = stack.getLast();
if (element instanceof DataElement.Object) {
pendingName = name;
return this;
}
throw new IllegalStateException("Please begin an object before writing a name.");
}
@Override
public EmulatedWriter value(String value) throws RuntimeException {
if (value == null) {
return nullValue();
}
put(new DataElement.Primitive.String(value));
return this;
}
@Override
public EmulatedWriter value(boolean value) throws RuntimeException {
put(new DataElement.Primitive.Boolean(value));
return this;
}
@Override
public EmulatedWriter value(float value) throws RuntimeException {
if (!serializeSpecialFloatingPointValues && (Float.isNaN(value) || Float.isInfinite(value))) {
throw new IllegalArgumentException("NaN and infinities are not permitted in this writer: " + value);
}
put(new DataElement.Primitive.Number(value));
return this;
}
@Override
public EmulatedWriter value(double value) throws RuntimeException {
if (!serializeSpecialFloatingPointValues && (Double.isNaN(value) || Double.isInfinite(value))) {
throw new IllegalArgumentException("NaN and infinities are not permitted in this writer: " + value);
}
put(new DataElement.Primitive.Number(value));
return this;
}
@Override
public EmulatedWriter value(long value) throws RuntimeException {
put(new DataElement.Primitive.Number(value));
return this;
}
@Override
public EmulatedWriter value(Number value) throws RuntimeException {
if (value == null) {
return nullValue();
}
if (!isLenient()) {
double d = value.doubleValue();
if (Double.isNaN(d) || Double.isInfinite(d)) {
throw new IllegalArgumentException("NaN and infinities are not permitted in this writer: " + value);
}
}
put(new DataElement.Primitive.Number(value));
return this;
}
@Override
public EmulatedWriter nullValue() throws RuntimeException {
put(new DataElement.Null());
return this;
}
@Override
public EmulatedWriter literalValue(String value) throws RuntimeException {
throw new UnsupportedOperationException();
}
@Override
public void flush() throws IOException {}
@Override
public void close() throws Exception {
if (!stack.isEmpty()) {
throw new IOException("Incomplete document");
}
stack.add(SENTINEL_CLOSED);
}
}

View File

@ -1,4 +1,6 @@
module io.gitlab.jfronny.commons.serialize {
requires static org.jetbrains.annotations;
requires io.gitlab.jfronny.commons;
exports io.gitlab.jfronny.commons.serialize;
exports io.gitlab.jfronny.commons.serialize.stream;
}

View File

@ -0,0 +1,72 @@
package io.gitlab.jfronny.commons.data;
import java.math.BigDecimal;
import java.util.Objects;
public class LazilyParsedNumber extends Number {
private final String value;
public LazilyParsedNumber(String value) {
this.value = value;
}
private BigDecimal asBigDecimal() {
if (value.length() > 10_000) {
throw new NumberFormatException("Number string too large: " + value.substring(0, 30) + "...");
}
BigDecimal decimal = new BigDecimal(value);
// Cast to long to avoid issues with abs when value is Integer.MIN_VALUE
if (Math.abs((long) decimal.scale()) >= 10_000) {
throw new NumberFormatException("Number has unsupported scale: " + value);
}
return decimal;
}
@Override
public int intValue() {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
try {
return (int) Long.parseLong(value);
} catch (NumberFormatException nfe) {
return asBigDecimal().intValue();
}
}
}
@Override
public long longValue() {
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
return asBigDecimal().longValue();
}
}
@Override
public float floatValue() {
return Float.parseFloat(value);
}
@Override
public double doubleValue() {
return Double.parseDouble(value);
}
@Override
public String toString() {
return value;
}
@Override
public boolean equals(Object o) {
return o instanceof LazilyParsedNumber that && Objects.equals(value, that.value);
}
@Override
public int hashCode() {
return Objects.hashCode(value);
}
}

View File

@ -0,0 +1,649 @@
package io.gitlab.jfronny.commons.data;
import java.io.*;
import java.util.*;
/**
* A map of comparable keys to values. Unlike {@code TreeMap}, this class uses insertion order for
* iteration order. Comparison order is only used as an optimization for efficient insertion and
* removal.
*
* <p>This implementation was derived from Android 4.1's TreeMap class.
*/
public class LinkedTreeMap<K, V> extends AbstractMap<K, V> implements Serializable {
@SuppressWarnings({"unchecked", "rawtypes"}) // to avoid Comparable<Comparable<Comparable<...>>>
private static final Comparator<Comparable> NATURAL_ORDER =
new Comparator<Comparable>() {
@Override
public int compare(Comparable a, Comparable b) {
return a.compareTo(b);
}
};
private final Comparator<? super K> comparator;
private final boolean allowNullValues;
Node<K, V> root;
int size = 0;
int modCount = 0;
// Used to preserve iteration order
final Node<K, V> header;
/**
* Create a natural order, empty tree map whose keys must be mutually comparable and non-null, and
* whose values can be {@code null}.
*/
@SuppressWarnings("unchecked") // unsafe! this assumes K is comparable
public LinkedTreeMap() {
this((Comparator<? super K>) NATURAL_ORDER, true);
}
/**
* Create a natural order, empty tree map whose keys must be mutually comparable and non-null.
*
* @param allowNullValues whether {@code null} is allowed as entry value
*/
@SuppressWarnings("unchecked") // unsafe! this assumes K is comparable
public LinkedTreeMap(boolean allowNullValues) {
this((Comparator<? super K>) NATURAL_ORDER, allowNullValues);
}
/**
* Create a tree map ordered by {@code comparator}. This map's keys may only be null if {@code
* comparator} permits.
*
* @param comparator the comparator to order elements with, or {@code null} to use the natural
* ordering.
* @param allowNullValues whether {@code null} is allowed as entry value
*/
// unsafe! if comparator is null, this assumes K is comparable
@SuppressWarnings({"unchecked", "rawtypes"})
public LinkedTreeMap(Comparator<? super K> comparator, boolean allowNullValues) {
this.comparator = comparator != null ? comparator : (Comparator) NATURAL_ORDER;
this.allowNullValues = allowNullValues;
this.header = new Node<>(allowNullValues);
}
@Override
public int size() {
return size;
}
@Override
public V get(Object key) {
Node<K, V> node = findByObject(key);
return node != null ? node.value : null;
}
@Override
public boolean containsKey(Object key) {
return findByObject(key) != null;
}
@Override
public V put(K key, V value) {
if (key == null) {
throw new NullPointerException("key == null");
}
if (value == null && !allowNullValues) {
throw new NullPointerException("value == null");
}
Node<K, V> created = find(key, true);
V result = created.value;
created.value = value;
return result;
}
@Override
public void clear() {
root = null;
size = 0;
modCount++;
// Clear iteration order
Node<K, V> header = this.header;
header.next = header.prev = header;
}
@Override
public V remove(Object key) {
Node<K, V> node = removeInternalByKey(key);
return node != null ? node.value : null;
}
/**
* Returns the node at or adjacent to the given key, creating it if requested.
*
* @throws ClassCastException if {@code key} and the tree's keys aren't mutually comparable.
*/
Node<K, V> find(K key, boolean create) {
Comparator<? super K> comparator = this.comparator;
Node<K, V> nearest = root;
int comparison = 0;
if (nearest != null) {
// Micro-optimization: avoid polymorphic calls to Comparator.compare().
@SuppressWarnings("unchecked") // Throws a ClassCastException below if there's trouble.
Comparable<Object> comparableKey =
(comparator == NATURAL_ORDER) ? (Comparable<Object>) key : null;
while (true) {
comparison =
(comparableKey != null)
? comparableKey.compareTo(nearest.key)
: comparator.compare(key, nearest.key);
// We found the requested key.
if (comparison == 0) {
return nearest;
}
// If it exists, the key is in a subtree. Go deeper.
Node<K, V> child = (comparison < 0) ? nearest.left : nearest.right;
if (child == null) {
break;
}
nearest = child;
}
}
// The key doesn't exist in this tree.
if (!create) {
return null;
}
// Create the node and add it to the tree or the table.
Node<K, V> header = this.header;
Node<K, V> created;
if (nearest == null) {
// Check that the value is comparable if we didn't do any comparisons.
if (comparator == NATURAL_ORDER && !(key instanceof Comparable)) {
throw new ClassCastException(key.getClass().getName() + " is not Comparable");
}
created = new Node<>(allowNullValues, nearest, key, header, header.prev);
root = created;
} else {
created = new Node<>(allowNullValues, nearest, key, header, header.prev);
if (comparison < 0) { // nearest.key is higher
nearest.left = created;
} else { // comparison > 0, nearest.key is lower
nearest.right = created;
}
rebalance(nearest, true);
}
size++;
modCount++;
return created;
}
@SuppressWarnings("unchecked")
Node<K, V> findByObject(Object key) {
try {
return key != null ? find((K) key, false) : null;
} catch (ClassCastException e) {
return null;
}
}
/**
* Returns this map's entry that has the same key and value as {@code entry}, or null if this map
* has no such entry.
*
* <p>This method uses the comparator for key equality rather than {@code equals}. If this map's
* comparator isn't consistent with equals (such as {@code String.CASE_INSENSITIVE_ORDER}), then
* {@code remove()} and {@code contains()} will violate the collections API.
*/
Node<K, V> findByEntry(Entry<?, ?> entry) {
Node<K, V> mine = findByObject(entry.getKey());
boolean valuesEqual = mine != null && equal(mine.value, entry.getValue());
return valuesEqual ? mine : null;
}
private static boolean equal(Object a, Object b) {
return Objects.equals(a, b);
}
/**
* Removes {@code node} from this tree, rearranging the tree's structure as necessary.
*
* @param unlink true to also unlink this node from the iteration linked list.
*/
void removeInternal(Node<K, V> node, boolean unlink) {
if (unlink) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
Node<K, V> left = node.left;
Node<K, V> right = node.right;
Node<K, V> originalParent = node.parent;
if (left != null && right != null) {
/*
* To remove a node with both left and right subtrees, move an
* adjacent node from one of those subtrees into this node's place.
*
* Removing the adjacent node may change this node's subtrees. This
* node may no longer have two subtrees once the adjacent node is
* gone!
*/
Node<K, V> adjacent = (left.height > right.height) ? left.last() : right.first();
removeInternal(adjacent, false); // takes care of rebalance and size--
int leftHeight = 0;
left = node.left;
if (left != null) {
leftHeight = left.height;
adjacent.left = left;
left.parent = adjacent;
node.left = null;
}
int rightHeight = 0;
right = node.right;
if (right != null) {
rightHeight = right.height;
adjacent.right = right;
right.parent = adjacent;
node.right = null;
}
adjacent.height = Math.max(leftHeight, rightHeight) + 1;
replaceInParent(node, adjacent);
return;
} else if (left != null) {
replaceInParent(node, left);
node.left = null;
} else if (right != null) {
replaceInParent(node, right);
node.right = null;
} else {
replaceInParent(node, null);
}
rebalance(originalParent, false);
size--;
modCount++;
}
Node<K, V> removeInternalByKey(Object key) {
Node<K, V> node = findByObject(key);
if (node != null) {
removeInternal(node, true);
}
return node;
}
@SuppressWarnings("ReferenceEquality")
private void replaceInParent(Node<K, V> node, Node<K, V> replacement) {
Node<K, V> parent = node.parent;
node.parent = null;
if (replacement != null) {
replacement.parent = parent;
}
if (parent != null) {
if (parent.left == node) {
parent.left = replacement;
} else {
assert parent.right == node;
parent.right = replacement;
}
} else {
root = replacement;
}
}
/**
* Rebalances the tree by making any AVL rotations necessary between the newly-unbalanced node and
* the tree's root.
*
* @param insert true if the node was unbalanced by an insert; false if it was by a removal.
*/
private void rebalance(Node<K, V> unbalanced, boolean insert) {
for (Node<K, V> node = unbalanced; node != null; node = node.parent) {
Node<K, V> left = node.left;
Node<K, V> right = node.right;
int leftHeight = left != null ? left.height : 0;
int rightHeight = right != null ? right.height : 0;
int delta = leftHeight - rightHeight;
if (delta == -2) {
Node<K, V> rightLeft = right.left;
Node<K, V> rightRight = right.right;
int rightRightHeight = rightRight != null ? rightRight.height : 0;
int rightLeftHeight = rightLeft != null ? rightLeft.height : 0;
int rightDelta = rightLeftHeight - rightRightHeight;
if (rightDelta == -1 || (rightDelta == 0 && !insert)) {
rotateLeft(node); // AVL right right
} else {
assert (rightDelta == 1);
rotateRight(right); // AVL right left
rotateLeft(node);
}
if (insert) {
break; // no further rotations will be necessary
}
} else if (delta == 2) {
Node<K, V> leftLeft = left.left;
Node<K, V> leftRight = left.right;
int leftRightHeight = leftRight != null ? leftRight.height : 0;
int leftLeftHeight = leftLeft != null ? leftLeft.height : 0;
int leftDelta = leftLeftHeight - leftRightHeight;
if (leftDelta == 1 || (leftDelta == 0 && !insert)) {
rotateRight(node); // AVL left left
} else {
assert (leftDelta == -1);
rotateLeft(left); // AVL left right
rotateRight(node);
}
if (insert) {
break; // no further rotations will be necessary
}
} else if (delta == 0) {
node.height = leftHeight + 1; // leftHeight == rightHeight
if (insert) {
break; // the insert caused balance, so rebalancing is done!
}
} else {
assert (delta == -1 || delta == 1);
node.height = Math.max(leftHeight, rightHeight) + 1;
if (!insert) {
break; // the height hasn't changed, so rebalancing is done!
}
}
}
}
/** Rotates the subtree so that its root's right child is the new root. */
private void rotateLeft(Node<K, V> root) {
Node<K, V> left = root.left;
Node<K, V> pivot = root.right;
Node<K, V> pivotLeft = pivot.left;
Node<K, V> pivotRight = pivot.right;
// move the pivot's left child to the root's right
root.right = pivotLeft;
if (pivotLeft != null) {
pivotLeft.parent = root;
}
replaceInParent(root, pivot);
// move the root to the pivot's left
pivot.left = root;
root.parent = pivot;
// fix heights
root.height =
Math.max(left != null ? left.height : 0, pivotLeft != null ? pivotLeft.height : 0) + 1;
pivot.height = Math.max(root.height, pivotRight != null ? pivotRight.height : 0) + 1;
}
/** Rotates the subtree so that its root's left child is the new root. */
private void rotateRight(Node<K, V> root) {
Node<K, V> pivot = root.left;
Node<K, V> right = root.right;
Node<K, V> pivotLeft = pivot.left;
Node<K, V> pivotRight = pivot.right;
// move the pivot's right child to the root's left
root.left = pivotRight;
if (pivotRight != null) {
pivotRight.parent = root;
}
replaceInParent(root, pivot);
// move the root to the pivot's right
pivot.right = root;
root.parent = pivot;
// fixup heights
root.height =
Math.max(right != null ? right.height : 0, pivotRight != null ? pivotRight.height : 0) + 1;
pivot.height = Math.max(root.height, pivotLeft != null ? pivotLeft.height : 0) + 1;
}
private EntrySet entrySet;
private KeySet keySet;
@Override
public Set<Entry<K, V>> entrySet() {
EntrySet result = entrySet;
return result != null ? result : (entrySet = new EntrySet());
}
@Override
public Set<K> keySet() {
KeySet result = keySet;
return result != null ? result : (keySet = new KeySet());
}
static final class Node<K, V> implements Entry<K, V> {
Node<K, V> parent;
Node<K, V> left;
Node<K, V> right;
Node<K, V> next;
Node<K, V> prev;
final K key;
final boolean allowNullValue;
V value;
int height;
/** Create the header entry */
Node(boolean allowNullValue) {
key = null;
this.allowNullValue = allowNullValue;
next = prev = this;
}
/** Create a regular entry */
Node(boolean allowNullValue, Node<K, V> parent, K key, Node<K, V> next, Node<K, V> prev) {
this.parent = parent;
this.key = key;
this.allowNullValue = allowNullValue;
this.height = 1;
this.next = next;
this.prev = prev;
prev.next = this;
next.prev = this;
}
@Override
public K getKey() {
return key;
}
@Override
public V getValue() {
return value;
}
@Override
public V setValue(V value) {
if (value == null && !allowNullValue) {
throw new NullPointerException("value == null");
}
V oldValue = this.value;
this.value = value;
return oldValue;
}
@Override
public boolean equals(Object o) {
if (o instanceof Entry) {
Entry<?, ?> other = (Entry<?, ?>) o;
return (key == null ? other.getKey() == null : key.equals(other.getKey()))
&& (value == null ? other.getValue() == null : value.equals(other.getValue()));
}
return false;
}
@Override
public int hashCode() {
return (key == null ? 0 : key.hashCode()) ^ (value == null ? 0 : value.hashCode());
}
@Override
public String toString() {
return key + "=" + value;
}
/** Returns the first node in this subtree. */
public Node<K, V> first() {
Node<K, V> node = this;
Node<K, V> child = node.left;
while (child != null) {
node = child;
child = node.left;
}
return node;
}
/** Returns the last node in this subtree. */
public Node<K, V> last() {
Node<K, V> node = this;
Node<K, V> child = node.right;
while (child != null) {
node = child;
child = node.right;
}
return node;
}
}
private abstract class LinkedTreeMapIterator<T> implements Iterator<T> {
Node<K, V> next = header.next;
Node<K, V> lastReturned = null;
int expectedModCount = modCount;
LinkedTreeMapIterator() {}
@Override
@SuppressWarnings("ReferenceEquality")
public final boolean hasNext() {
return next != header;
}
@SuppressWarnings("ReferenceEquality")
final Node<K, V> nextNode() {
Node<K, V> e = next;
if (e == header) {
throw new NoSuchElementException();
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
next = e.next;
return lastReturned = e;
}
@Override
public final void remove() {
if (lastReturned == null) {
throw new IllegalStateException();
}
removeInternal(lastReturned, true);
lastReturned = null;
expectedModCount = modCount;
}
}
class EntrySet extends AbstractSet<Entry<K, V>> {
@Override
public int size() {
return size;
}
@Override
public Iterator<Entry<K, V>> iterator() {
return new LinkedTreeMapIterator<Entry<K, V>>() {
@Override
public Entry<K, V> next() {
return nextNode();
}
};
}
@Override
public boolean contains(Object o) {
return o instanceof Entry && findByEntry((Entry<?, ?>) o) != null;
}
@Override
public boolean remove(Object o) {
if (!(o instanceof Entry)) {
return false;
}
Node<K, V> node = findByEntry((Entry<?, ?>) o);
if (node == null) {
return false;
}
removeInternal(node, true);
return true;
}
@Override
public void clear() {
LinkedTreeMap.this.clear();
}
}
final class KeySet extends AbstractSet<K> {
@Override
public int size() {
return size;
}
@Override
public Iterator<K> iterator() {
return new LinkedTreeMapIterator<K>() {
@Override
public K next() {
return nextNode().key;
}
};
}
@Override
public boolean contains(Object o) {
return containsKey(o);
}
@Override
public boolean remove(Object key) {
return removeInternalByKey(key) != null;
}
@Override
public void clear() {
LinkedTreeMap.this.clear();
}
}
/**
* If somebody is unlucky enough to have to serialize one of these, serialize it as a
* LinkedHashMap so that they won't need Gson on the other side to deserialize it. Using
* serialization defeats our DoS defence, so most apps shouldn't use it.
*/
private Object writeReplace() throws ObjectStreamException {
return new LinkedHashMap<>(this);
}
private void readObject(ObjectInputStream in) throws IOException {
// Don't permit directly deserializing this class; writeReplace() should have written a
// replacement
throw new InvalidObjectException("Deserialization is unsupported");
}
}

View File

@ -0,0 +1,239 @@
/*
* Copyright (C) 2012 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.gitlab.jfronny.commons.test;
import io.gitlab.jfronny.commons.data.LinkedTreeMap;
import org.junit.jupiter.api.Test;
import java.io.*;
import java.util.*;
import java.util.Map.Entry;
import static org.junit.jupiter.api.Assertions.*;
public final class LinkedTreeMapTest {
@Test
public void testIterationOrder() {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>();
map.put("a", "android");
map.put("c", "cola");
map.put("b", "bbq");
assertIterationOrder(map.keySet(), "a", "c", "b");
assertIterationOrder(map.values(), "android", "cola", "bbq");
}
@Test
public void testRemoveRootDoesNotDoubleUnlink() {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>();
map.put("a", "android");
map.put("c", "cola");
map.put("b", "bbq");
Iterator<Entry<String, String>> it = map.entrySet().iterator();
it.next();
it.next();
it.next();
it.remove();
assertIterationOrder(map.keySet(), "a", "c");
}
@Test
@SuppressWarnings("ModifiedButNotUsed")
public void testPutNullKeyFails() {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>();
try {
map.put(null, "android");
fail();
} catch (NullPointerException expected) {
}
}
@Test
@SuppressWarnings("ModifiedButNotUsed")
public void testPutNonComparableKeyFails() {
LinkedTreeMap<Object, String> map = new LinkedTreeMap<>();
try {
map.put(new Object(), "android");
fail();
} catch (ClassCastException expected) {
}
}
@Test
public void testPutNullValue() {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>();
map.put("a", null);
assertEquals(1, map.size());
assertTrue(map.containsKey("a"));
assertTrue(map.containsValue(null));
assertNull(map.get("a"));
}
@Test
public void testPutNullValue_Forbidden() {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>(false);
try {
map.put("a", null);
fail();
} catch (NullPointerException e) {
assertEquals("value == null", e.getMessage());
}
assertEquals(0, map.size());
assertFalse(map.containsKey("a"));
assertFalse(map.containsValue(null));
}
@Test
public void testEntrySetValueNull() {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>();
map.put("a", "1");
assertEquals("1", map.get("a"));
Entry<String, String> entry = map.entrySet().iterator().next();
assertEquals("a", entry.getKey());
assertEquals("1", entry.getValue());
entry.setValue(null);
assertNull(entry.getValue());
assertTrue(map.containsKey("a"));
assertTrue(map.containsValue(null));
assertNull(map.get("a"));
}
@Test
public void testEntrySetValueNull_Forbidden() {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>(false);
map.put("a", "1");
Entry<String, String> entry = map.entrySet().iterator().next();
try {
entry.setValue(null);
fail();
} catch (NullPointerException e) {
assertEquals("value == null", e.getMessage());
}
assertEquals("1", entry.getValue());
assertEquals("1", map.get("a"));
assertFalse(map.containsValue(null));
}
@Test
public void testContainsNonComparableKeyReturnsFalse() {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>();
map.put("a", "android");
assertFalse(map.containsKey(new Object()));
}
@Test
public void testContainsNullKeyIsAlwaysFalse() {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>();
assertFalse(map.containsKey(null));
map.put("a", "android");
assertFalse(map.containsKey(null));
}
@Test
public void testPutOverrides() throws Exception {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>();
assertNull(map.put("d", "donut"));
assertNull(map.put("e", "eclair"));
assertNull(map.put("f", "froyo"));
assertEquals(3, map.size());
assertEquals("donut", map.get("d"));
assertEquals("donut", map.put("d", "done"));
assertEquals(3, map.size());
}
@Test
public void testEmptyStringValues() {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>();
map.put("a", "");
assertTrue(map.containsKey("a"));
assertEquals("", map.get("a"));
}
@Test
public void testLargeSetOfRandomKeys() {
Random random = new Random(1367593214724L);
LinkedTreeMap<String, String> map = new LinkedTreeMap<>();
String[] keys = new String[1000];
for (int i = 0; i < keys.length; i++) {
keys[i] = Integer.toString(random.nextInt(), 36) + "-" + i;
map.put(keys[i], "" + i);
}
for (int i = 0; i < keys.length; i++) {
String key = keys[i];
assertTrue(map.containsKey(key));
assertEquals("" + i, map.get(key));
}
}
@Test
public void testClear() {
LinkedTreeMap<String, String> map = new LinkedTreeMap<>();
map.put("a", "android");
map.put("c", "cola");
map.put("b", "bbq");
map.clear();
assertIterationOrder(map.keySet());
assertEquals(0, map.size());
}
@Test
public void testEqualsAndHashCode() throws Exception {
LinkedTreeMap<String, Integer> map1 = new LinkedTreeMap<>();
map1.put("A", 1);
map1.put("B", 2);
map1.put("C", 3);
map1.put("D", 4);
LinkedTreeMap<String, Integer> map2 = new LinkedTreeMap<>();
map2.put("C", 3);
map2.put("B", 2);
map2.put("D", 4);
map2.put("A", 1);
assertEquals(map1, map2);
assertEquals(map1.hashCode(), map2.hashCode());
}
@Test
public void testJavaSerialization() throws IOException, ClassNotFoundException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
ObjectOutputStream objOut = new ObjectOutputStream(out);
Map<String, Integer> map = new LinkedTreeMap<>();
map.put("a", 1);
objOut.writeObject(map);
objOut.close();
ObjectInputStream objIn = new ObjectInputStream(new ByteArrayInputStream(out.toByteArray()));
@SuppressWarnings("unchecked")
Map<String, Integer> deserialized = (Map<String, Integer>) objIn.readObject();
assertEquals(Collections.singletonMap("a", 1), deserialized);
}
@SuppressWarnings("varargs")
@SafeVarargs
private static final <T> void assertIterationOrder(Iterable<T> actual, T... expected) {
ArrayList<T> actualList = new ArrayList<>();
for (T t : actual) {
actualList.add(t);
}
assertEquals(Arrays.asList(expected), actualList);
}
}

View File

@ -4,12 +4,15 @@ annotations = "24.1.0"
gradle-kotlin-dsl = "4.3.0"
jf-scripts = "1.5-SNAPSHOT"
gson = "2.10.3-SNAPSHOT"
google-truth = "1.4.2"
[libraries]
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" }
junit-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit" }
annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" }
gson = { module = "io.gitlab.jfronny:gson", version.ref = "gson" }
google-truth = { module = "com.google.truth:truth", version.ref = "google-truth" }
plugin-kotlin = { module = "org.gradle.kotlin:gradle-kotlin-dsl-plugins", version.ref = "gradle-kotlin-dsl"}
plugin-convention = { module = "io.gitlab.jfronny:convention", version.ref="jf-scripts" }

View File

@ -3,6 +3,7 @@ rootProject.name = "JfCommons"
include("commons")
include("commons-serialize")
include("commons-serialize-gson")
include("commons-serialize-json")
include("commons-serialize-gson-dsl")
include("commons-io")
include("commons-logger")