diff --git a/google-cloud-spanner/clirr-ignored-differences.xml b/google-cloud-spanner/clirr-ignored-differences.xml index fb952176bd..8d597d29b3 100644 --- a/google-cloud-spanner/clirr-ignored-differences.xml +++ b/google-cloud-spanner/clirr-ignored-differences.xml @@ -131,4 +131,25 @@ com/google/cloud/spanner/spi/v1/GapicSpannerRpc com.google.iam.v1.Policy getDatabaseAdminIAMPolicy(java.lang.String) + + + 7012 + com/google/cloud/spanner/StructReader + java.lang.String getPgJsonb(int) + + + 7012 + com/google/cloud/spanner/StructReader + java.lang.String getPgJsonb(java.lang.String) + + + 7012 + com/google/cloud/spanner/StructReader + java.util.List getPgJsonbList(int) + + + 7012 + com/google/cloud/spanner/StructReader + java.util.List getPgJsonbList(java.lang.String) + diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/AbstractResultSet.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/AbstractResultSet.java index bc4f224b93..6ccb28900f 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/AbstractResultSet.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/AbstractResultSet.java @@ -67,6 +67,7 @@ import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; import javax.annotation.Nullable; /** Implementation of {@link ResultSet}. */ @@ -384,6 +385,9 @@ private Object writeReplace() { case JSON: builder.set(fieldName).to(Value.json((String) value)); break; + case PG_JSONB: + builder.set(fieldName).to(Value.pgJsonb((String) value)); + break; case BYTES: builder.set(fieldName).to((ByteArray) value); break; @@ -417,6 +421,9 @@ private Object writeReplace() { case JSON: builder.set(fieldName).toJsonArray((Iterable) value); break; + case PG_JSONB: + builder.set(fieldName).toPgJsonbArray((Iterable) value); + break; case BYTES: builder.set(fieldName).toBytesArray((Iterable) value); break; @@ -491,10 +498,9 @@ private static Object decodeValue(Type fieldType, com.google.protobuf.Value prot checkType(fieldType, proto, KindCase.STRING_VALUE); return new BigDecimal(proto.getStringValue()); case PG_NUMERIC: - checkType(fieldType, proto, KindCase.STRING_VALUE); - return proto.getStringValue(); case STRING: case JSON: + case PG_JSONB: checkType(fieldType, proto, KindCase.STRING_VALUE); return proto.getStringValue(); case BYTES: @@ -558,14 +564,14 @@ static Object decodeArrayValue(Type elementType, ListValue listValue) { return list; } case PG_NUMERIC: - return Lists.transform( - listValue.getValuesList(), - input -> input.getKindCase() == KindCase.NULL_VALUE ? null : input.getStringValue()); case STRING: case JSON: - return Lists.transform( - listValue.getValuesList(), - input -> input.getKindCase() == KindCase.NULL_VALUE ? null : input.getStringValue()); + case PG_JSONB: + return listValue.getValuesList().stream() + .map( + input -> + input.getKindCase() == KindCase.NULL_VALUE ? null : input.getStringValue()) + .collect(Collectors.toList()); case BYTES: { // Materialize list: element conversion is expensive and should happen only once. @@ -679,6 +685,11 @@ protected String getJsonInternal(int columnIndex) { return (String) rowData.get(columnIndex); } + @Override + protected String getPgJsonbInternal(int columnIndex) { + return (String) rowData.get(columnIndex); + } + @Override protected ByteArray getBytesInternal(int columnIndex) { return (ByteArray) rowData.get(columnIndex); @@ -715,6 +726,8 @@ protected Value getValueInternal(int columnIndex) { return Value.string(isNull ? null : getStringInternal(columnIndex)); case JSON: return Value.json(isNull ? null : getJsonInternal(columnIndex)); + case PG_JSONB: + return Value.pgJsonb(isNull ? null : getPgJsonbInternal(columnIndex)); case BYTES: return Value.bytes(isNull ? null : getBytesInternal(columnIndex)); case TIMESTAMP: @@ -740,6 +753,8 @@ protected Value getValueInternal(int columnIndex) { return Value.stringArray(isNull ? null : getStringListInternal(columnIndex)); case JSON: return Value.jsonArray(isNull ? null : getJsonListInternal(columnIndex)); + case PG_JSONB: + return Value.pgJsonbArray(isNull ? null : getPgJsonbListInternal(columnIndex)); case BYTES: return Value.bytesArray(isNull ? null : getBytesListInternal(columnIndex)); case TIMESTAMP: @@ -816,11 +831,17 @@ protected List getStringListInternal(int columnIndex) { } @Override - @SuppressWarnings("unchecked") // We know ARRAY produces a List. + @SuppressWarnings("unchecked") // We know ARRAY produces a List. protected List getJsonListInternal(int columnIndex) { return Collections.unmodifiableList((List) rowData.get(columnIndex)); } + @Override + @SuppressWarnings("unchecked") // We know ARRAY produces a List. + protected List getPgJsonbListInternal(int columnIndex) { + return Collections.unmodifiableList((List) rowData.get(columnIndex)); + } + @Override @SuppressWarnings("unchecked") // We know ARRAY produces a List. protected List getBytesListInternal(int columnIndex) { @@ -1352,6 +1373,11 @@ protected String getJsonInternal(int columnIndex) { return currRow().getJsonInternal(columnIndex); } + @Override + protected String getPgJsonbInternal(int columnIndex) { + return currRow().getPgJsonbInternal(columnIndex); + } + @Override protected ByteArray getBytesInternal(int columnIndex) { return currRow().getBytesInternal(columnIndex); @@ -1417,6 +1443,11 @@ protected List getJsonListInternal(int columnIndex) { return currRow().getJsonListInternal(columnIndex); } + @Override + protected List getPgJsonbListInternal(int columnIndex) { + return currRow().getJsonListInternal(columnIndex); + } + @Override protected List getBytesListInternal(int columnIndex) { return currRow().getBytesListInternal(columnIndex); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/AbstractStructReader.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/AbstractStructReader.java index d9038466a4..1e89763624 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/AbstractStructReader.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/AbstractStructReader.java @@ -48,6 +48,10 @@ protected String getJsonInternal(int columnIndex) { throw new UnsupportedOperationException("Not implemented"); } + protected String getPgJsonbInternal(int columnIndex) { + throw new UnsupportedOperationException("Not implemented"); + } + protected abstract ByteArray getBytesInternal(int columnIndex); protected abstract Timestamp getTimestampInternal(int columnIndex); @@ -78,6 +82,10 @@ protected List getJsonListInternal(int columnIndex) { throw new UnsupportedOperationException("Not implemented"); } + protected List getPgJsonbListInternal(int columnIndex) { + throw new UnsupportedOperationException("Not implemented"); + } + protected abstract List getBytesListInternal(int columnIndex); protected abstract List getTimestampListInternal(int columnIndex); @@ -189,6 +197,19 @@ public String getJson(String columnName) { return getJsonInternal(columnIndex); } + @Override + public String getPgJsonb(int columnIndex) { + checkNonNullOfType(columnIndex, Type.pgJsonb(), columnIndex); + return getPgJsonbInternal(columnIndex); + } + + @Override + public String getPgJsonb(String columnName) { + int columnIndex = getColumnIndex(columnName); + checkNonNullOfType(columnIndex, Type.pgJsonb(), columnName); + return getPgJsonbInternal(columnIndex); + } + @Override public ByteArray getBytes(int columnIndex) { checkNonNullOfType(columnIndex, Type.bytes(), columnIndex); @@ -365,6 +386,19 @@ public List getJsonList(String columnName) { return getJsonListInternal(columnIndex); } + @Override + public List getPgJsonbList(int columnIndex) { + checkNonNullOfType(columnIndex, Type.array(Type.pgJsonb()), columnIndex); + return getPgJsonbListInternal(columnIndex); + } + + @Override + public List getPgJsonbList(String columnName) { + int columnIndex = getColumnIndex(columnName); + checkNonNullOfType(columnIndex, Type.array(Type.pgJsonb()), columnName); + return getPgJsonbListInternal(columnIndex); + } + @Override public List getBytesList(int columnIndex) { checkNonNullOfType(columnIndex, Type.array(Type.bytes()), columnIndex); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ForwardingStructReader.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ForwardingStructReader.java index e225bdcc1b..2a85006fa9 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ForwardingStructReader.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ForwardingStructReader.java @@ -168,6 +168,18 @@ public String getJson(String columnName) { return delegate.get().getJson(columnName); } + @Override + public String getPgJsonb(int columnIndex) { + checkValidState(); + return delegate.get().getPgJsonb(columnIndex); + } + + @Override + public String getPgJsonb(String columnName) { + checkValidState(); + return delegate.get().getPgJsonb(columnName); + } + @Override public ByteArray getBytes(int columnIndex) { checkValidState(); @@ -310,6 +322,18 @@ public List getJsonList(String columnName) { return delegate.get().getJsonList(columnName); } + @Override + public List getPgJsonbList(int columnIndex) { + checkValidState(); + return delegate.get().getPgJsonbList(columnIndex); + } + + @Override + public List getPgJsonbList(String columnName) { + checkValidState(); + return delegate.get().getPgJsonbList(columnName); + } + @Override public List getBytesList(int columnIndex) { checkValidState(); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ResultSets.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ResultSets.java index af57b5b848..6eacd3208e 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ResultSets.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ResultSets.java @@ -253,6 +253,16 @@ public String getJson(String columnName) { return getCurrentRowAsStruct().getJson(columnName); } + @Override + public String getPgJsonb(int columnIndex) { + return getCurrentRowAsStruct().getPgJsonb(columnIndex); + } + + @Override + public String getPgJsonb(String columnName) { + return getCurrentRowAsStruct().getPgJsonb(columnName); + } + @Override public ByteArray getBytes(int columnIndex) { return getCurrentRowAsStruct().getBytes(columnIndex); @@ -383,6 +393,16 @@ public List getJsonList(String columnName) { return getCurrentRowAsStruct().getJsonList(columnName); } + @Override + public List getPgJsonbList(int columnIndex) { + return getCurrentRowAsStruct().getPgJsonbList(columnIndex); + } + + @Override + public List getPgJsonbList(String columnName) { + return getCurrentRowAsStruct().getPgJsonbList(columnName); + } + @Override public List getBytesList(int columnIndex) { return getCurrentRowAsStruct().getBytesList(columnIndex); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Struct.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Struct.java index c986767d3a..48c989d145 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Struct.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Struct.java @@ -197,6 +197,11 @@ protected String getJsonInternal(int columnIndex) { return values.get(columnIndex).getJson(); } + @Override + protected String getPgJsonbInternal(int columnIndex) { + return values.get(columnIndex).getPgJsonb(); + } + @Override protected ByteArray getBytesInternal(int columnIndex) { return values.get(columnIndex).getBytes(); @@ -267,6 +272,11 @@ protected List getJsonListInternal(int columnIndex) { return values.get(columnIndex).getJsonArray(); } + @Override + protected List getPgJsonbListInternal(int columnIndex) { + return values.get(columnIndex).getPgJsonbArray(); + } + @Override protected List getBytesListInternal(int columnIndex) { return values.get(columnIndex).getBytesArray(); @@ -355,6 +365,8 @@ private Object getAsObject(int columnIndex) { return getStringInternal(columnIndex); case JSON: return getJsonInternal(columnIndex); + case PG_JSONB: + return getPgJsonbInternal(columnIndex); case BYTES: return getBytesInternal(columnIndex); case TIMESTAMP: @@ -379,6 +391,8 @@ private Object getAsObject(int columnIndex) { return getStringListInternal(columnIndex); case JSON: return getJsonListInternal(columnIndex); + case PG_JSONB: + return getPgJsonbListInternal(columnIndex); case BYTES: return getBytesListInternal(columnIndex); case TIMESTAMP: diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/StructReader.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/StructReader.java index 3779e8067d..a96c95cb95 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/StructReader.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/StructReader.java @@ -114,16 +114,26 @@ public interface StructReader { /** Returns the value of a non-{@code NULL} column with type {@link Type#string()}. */ String getString(String columnName); - /** Returns the value of a non-{@code NULL} column with type {@link Type#string()}. */ + /** Returns the value of a non-{@code NULL} column with type {@link Type#json()}. */ default String getJson(int columnIndex) { throw new UnsupportedOperationException("method should be overwritten"); } - /** Returns the value of a non-{@code NULL} column with type {@link Type#string()}. */ + /** Returns the value of a non-{@code NULL} column with type {@link Type#json()}. */ default String getJson(String columnName) { throw new UnsupportedOperationException("method should be overwritten"); } + /** Returns the value of a non-{@code NULL} column with type {@link Type#pgJsonb()}. */ + default String getPgJsonb(int columnIndex) { + throw new UnsupportedOperationException("method should be overwritten"); + } + + /** Returns the value of a non-{@code NULL} column with type {@link Type#pgJsonb()}. */ + default String getPgJsonb(String columnName) { + throw new UnsupportedOperationException("method should be overwritten"); + } + /** Returns the value of a non-{@code NULL} column with type {@link Type#bytes()}. */ ByteArray getBytes(int columnIndex); @@ -238,16 +248,30 @@ default Value getValue(String columnName) { /** Returns the value of a non-{@code NULL} column with type {@code Type.array(Type.string())}. */ List getStringList(String columnName); - /** Returns the value of a non-{@code NULL} column with type {@code Type.array(Type.string())}. */ + /** Returns the value of a non-{@code NULL} column with type {@code Type.array(Type.json())}. */ default List getJsonList(int columnIndex) { throw new UnsupportedOperationException("method should be overwritten"); }; - /** Returns the value of a non-{@code NULL} column with type {@code Type.array(Type.string())}. */ + /** Returns the value of a non-{@code NULL} column with type {@code Type.array(Type.json())}. */ default List getJsonList(String columnName) { throw new UnsupportedOperationException("method should be overwritten"); }; + /** + * Returns the value of a non-{@code NULL} column with type {@code Type.array(Type.pgJsonb())}. + */ + default List getPgJsonbList(int columnIndex) { + throw new UnsupportedOperationException("method should be overwritten"); + }; + + /** + * Returns the value of a non-{@code NULL} column with type {@code Type.array(Type.pgJsonb())}. + */ + default List getPgJsonbList(String columnName) { + throw new UnsupportedOperationException("method should be overwritten"); + }; + /** Returns the value of a non-{@code NULL} column with type {@code Type.array(Type.bytes())}. */ List getBytesList(int columnIndex); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Type.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Type.java index 15305e0cda..7ba6b9a41e 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Type.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Type.java @@ -52,6 +52,7 @@ public final class Type implements Serializable { private static final Type TYPE_PG_NUMERIC = new Type(Code.PG_NUMERIC, null, null); private static final Type TYPE_STRING = new Type(Code.STRING, null, null); private static final Type TYPE_JSON = new Type(Code.JSON, null, null); + private static final Type TYPE_PG_JSONB = new Type(Code.PG_JSONB, null, null); private static final Type TYPE_BYTES = new Type(Code.BYTES, null, null); private static final Type TYPE_TIMESTAMP = new Type(Code.TIMESTAMP, null, null); private static final Type TYPE_DATE = new Type(Code.DATE, null, null); @@ -62,6 +63,7 @@ public final class Type implements Serializable { private static final Type TYPE_ARRAY_PG_NUMERIC = new Type(Code.ARRAY, TYPE_PG_NUMERIC, null); private static final Type TYPE_ARRAY_STRING = new Type(Code.ARRAY, TYPE_STRING, null); private static final Type TYPE_ARRAY_JSON = new Type(Code.ARRAY, TYPE_JSON, null); + private static final Type TYPE_ARRAY_PG_JSONB = new Type(Code.ARRAY, TYPE_PG_JSONB, null); private static final Type TYPE_ARRAY_BYTES = new Type(Code.ARRAY, TYPE_BYTES, null); private static final Type TYPE_ARRAY_TIMESTAMP = new Type(Code.ARRAY, TYPE_TIMESTAMP, null); private static final Type TYPE_ARRAY_DATE = new Type(Code.ARRAY, TYPE_DATE, null); @@ -115,6 +117,11 @@ public static Type json() { return TYPE_JSON; } + /** Returns the descriptor for the {@code JSONB} type. */ + public static Type pgJsonb() { + return TYPE_PG_JSONB; + } + /** Returns the descriptor for the {@code BYTES} type: a variable-length byte string. */ public static Type bytes() { return TYPE_BYTES; @@ -154,6 +161,8 @@ public static Type array(Type elementType) { return TYPE_ARRAY_STRING; case JSON: return TYPE_ARRAY_JSON; + case PG_JSONB: + return TYPE_ARRAY_PG_JSONB; case BYTES: return TYPE_ARRAY_BYTES; case TIMESTAMP: @@ -209,6 +218,7 @@ public enum Code { FLOAT64(TypeCode.FLOAT64), STRING(TypeCode.STRING), JSON(TypeCode.JSON), + PG_JSONB(TypeCode.JSON, TypeAnnotationCode.PG_JSONB), BYTES(TypeCode.BYTES), TIMESTAMP(TypeCode.TIMESTAMP), DATE(TypeCode.DATE), @@ -446,6 +456,8 @@ static Type fromProto(com.google.spanner.v1.Type proto) { return string(); case JSON: return json(); + case PG_JSONB: + return pgJsonb(); case BYTES: return bytes(); case TIMESTAMP: diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Value.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Value.java index e3c53de937..3ec7b67f65 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Value.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Value.java @@ -200,7 +200,7 @@ public static Value string(@Nullable String v) { } /** - * Returns a {@code STRING} value. + * Returns a {@code JSON} value. * * @param v the value, which may be null */ @@ -208,6 +208,15 @@ public static Value json(@Nullable String v) { return new JsonImpl(v == null, v); } + /** + * Returns a {@code PG JSONB} value. + * + * @param v the value, which may be null + */ + public static Value pgJsonb(@Nullable String v) { + return new PgJsonbImpl(v == null, v); + } + /** * Returns a {@code BYTES} value. * @@ -393,7 +402,7 @@ public static Value stringArray(@Nullable Iterable v) { } /** - * Returns an {@code ARRAY} value. + * Returns an {@code ARRAY} value. * * @param v the source of element values. This may be {@code null} to produce a value for which * {@code isNull()} is {@code true}. Individual elements may also be {@code null}. @@ -402,6 +411,16 @@ public static Value jsonArray(@Nullable Iterable v) { return new JsonArrayImpl(v == null, v == null ? null : immutableCopyOf(v)); } + /** + * Returns an {@code ARRAY} value. + * + * @param v the source of element values. This may be {@code null} to produce a value for which + * {@code isNull()} is {@code true}. Individual elements may also be {@code null}. + */ + public static Value pgJsonbArray(@Nullable Iterable v) { + return new PgJsonbArrayImpl(v == null, v == null ? null : immutableCopyOf(v)); + } + /** * Returns an {@code ARRAY} value. * @@ -513,6 +532,15 @@ public String getJson() { throw new UnsupportedOperationException("Not implemented"); } + /** + * Returns the value of a {@code JSONB}-typed instance. + * + * @throws IllegalStateException if {@code isNull()} or the value is not of the expected type + */ + public String getPgJsonb() { + throw new UnsupportedOperationException("Not implemented"); + } + /** * Returns the value of a {@code BYTES}-typed instance. * @@ -595,6 +623,16 @@ public List getJsonArray() { throw new UnsupportedOperationException("Not implemented"); } + /** + * Returns the value of an {@code ARRAY}-typed instance. While the returned list itself + * will never be {@code null}, elements of that list may be null. + * + * @throws IllegalStateException if {@code isNull()} or the value is not of the expected type + */ + public List getPgJsonbArray() { + throw new UnsupportedOperationException("Not implemented"); + } + /** * Returns the value of an {@code ARRAY}-typed instance. While the returned list itself * will never be {@code null}, elements of that list may be null. @@ -808,6 +846,11 @@ public String getJson() { throw defaultGetter(Type.json()); } + @Override + public String getPgJsonb() { + throw defaultGetter(Type.pgJsonb()); + } + @Override public ByteArray getBytes() { throw defaultGetter(Type.bytes()); @@ -862,6 +905,11 @@ public List getJsonArray() { throw defaultGetter(Type.array(Type.json())); } + @Override + public List getPgJsonbArray() { + throw defaultGetter(Type.array(Type.pgJsonb())); + } + @Override public List getBytesArray() { throw defaultGetter(Type.array(Type.bytes())); @@ -1229,6 +1277,34 @@ void valueToString(StringBuilder b) { } } + private static class PgJsonbImpl extends AbstractObjectValue { + + private PgJsonbImpl(boolean isNull, @Nullable String value) { + super(isNull, Type.pgJsonb(), value); + } + + @Override + public String getPgJsonb() { + checkType(Type.pgJsonb()); + checkNotNull(); + return value; + } + + @Override + public String getString() { + return getPgJsonb(); + } + + @Override + void valueToString(StringBuilder b) { + if (value.length() > MAX_DEBUG_STRING_LENGTH) { + b.append(value, 0, MAX_DEBUG_STRING_LENGTH - ELLIPSIS.length()).append(ELLIPSIS); + } else { + b.append(value); + } + } + } + private static class BytesImpl extends AbstractObjectValue { private BytesImpl(boolean isNull, ByteArray value) { @@ -1666,6 +1742,30 @@ void appendElement(StringBuilder b, String element) { } } + private static class PgJsonbArrayImpl extends AbstractArrayValue { + + private PgJsonbArrayImpl(boolean isNull, @Nullable List values) { + super(isNull, Type.pgJsonb(), values); + } + + @Override + public List getPgJsonbArray() { + checkType(getType()); + checkNotNull(); + return value; + } + + @Override + public List getStringArray() { + return this.getPgJsonbArray(); + } + + @Override + void appendElement(StringBuilder b, String element) { + b.append(element); + } + } + private static class BytesArrayImpl extends AbstractArrayValue { private BytesArrayImpl(boolean isNull, @Nullable List values) { super(isNull, Type.bytes(), values); @@ -1857,6 +1957,8 @@ private Value getValue(int fieldIndex) { return Value.string(value.getString(fieldIndex)); case JSON: return Value.json(value.getJson(fieldIndex)); + case PG_JSONB: + return Value.pgJsonb(value.getPgJsonb(fieldIndex)); case BYTES: return Value.bytes(value.getBytes(fieldIndex)); case FLOAT64: @@ -1883,6 +1985,8 @@ private Value getValue(int fieldIndex) { return Value.stringArray(value.getStringList(fieldIndex)); case JSON: return Value.jsonArray(value.getJsonList(fieldIndex)); + case PG_JSONB: + return Value.pgJsonbArray(value.getPgJsonbList(fieldIndex)); case BYTES: return Value.bytesArray(value.getBytesList(fieldIndex)); case FLOAT64: diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ValueBinder.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ValueBinder.java index cdca5d84a2..ec9e5a43d8 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ValueBinder.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/ValueBinder.java @@ -188,6 +188,11 @@ public R toJsonArray(@Nullable Iterable values) { return handle(Value.jsonArray(values)); } + /** Binds to {@code Value.jsonbArray(values)} */ + public R toPgJsonbArray(@Nullable Iterable values) { + return handle(Value.pgJsonbArray(values)); + } + /** Binds to {@code Value.bytesArray(values)} */ public R toBytesArray(@Nullable Iterable values) { return handle(Value.bytesArray(values)); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ChecksumResultSet.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ChecksumResultSet.java index 2c01396083..bb2f2fb817 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ChecksumResultSet.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ChecksumResultSet.java @@ -248,6 +248,9 @@ public void funnel(Struct row, PrimitiveSink into) { case JSON: funnelValue(type, row.getJson(i), into); break; + case PG_JSONB: + funnelValue(type, row.getPgJsonb(i), into); + break; case TIMESTAMP: funnelValue(type, row.getTimestamp(i), into); break; @@ -318,6 +321,12 @@ private void funnelArray( funnelValue(Code.JSON, value, into); } break; + case PG_JSONB: + into.putInt(row.getPgJsonbList(columnIndex).size()); + for (String value : row.getPgJsonbList(columnIndex)) { + funnelValue(Code.PG_JSONB, value, into); + } + break; case TIMESTAMP: into.putInt(row.getTimestampList(columnIndex).size()); for (Timestamp value : row.getTimestampList(columnIndex)) { @@ -370,6 +379,7 @@ private void funnelValue(Code type, T value, PrimitiveSink into) { case PG_NUMERIC: case STRING: case JSON: + case PG_JSONB: String stringValue = (String) value; into.putInt(stringValue.length()); into.putUnencodedChars(stringValue); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/DirectExecuteResultSet.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/DirectExecuteResultSet.java index 21c3dae7ec..8fb0bbe440 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/DirectExecuteResultSet.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/DirectExecuteResultSet.java @@ -203,6 +203,18 @@ public String getJson(String columnName) { return delegate.getJson(columnName); } + @Override + public String getPgJsonb(int columnIndex) { + Preconditions.checkState(nextCalledByClient, MISSING_NEXT_CALL); + return delegate.getPgJsonb(columnIndex); + } + + @Override + public String getPgJsonb(String columnName) { + Preconditions.checkState(nextCalledByClient, MISSING_NEXT_CALL); + return delegate.getPgJsonb(columnName); + } + @Override public ByteArray getBytes(int columnIndex) { Preconditions.checkState(nextCalledByClient, MISSING_NEXT_CALL); @@ -359,6 +371,18 @@ public List getJsonList(String columnName) { return delegate.getJsonList(columnName); } + @Override + public List getPgJsonbList(int columnIndex) { + Preconditions.checkState(nextCalledByClient, MISSING_NEXT_CALL); + return delegate.getPgJsonbList(columnIndex); + } + + @Override + public List getPgJsonbList(String columnName) { + Preconditions.checkState(nextCalledByClient, MISSING_NEXT_CALL); + return delegate.getPgJsonbList(columnName); + } + @Override public List getBytesList(int columnIndex) { Preconditions.checkState(nextCalledByClient, MISSING_NEXT_CALL); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ReplaceableForwardingResultSet.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ReplaceableForwardingResultSet.java index 6bf8f046c5..cc9759a487 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ReplaceableForwardingResultSet.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ReplaceableForwardingResultSet.java @@ -208,6 +208,18 @@ public String getJson(String columnName) { return delegate.getJson(columnName); } + @Override + public String getPgJsonb(int columnIndex) { + checkClosed(); + return delegate.getPgJsonb(columnIndex); + } + + @Override + public String getPgJsonb(String columnName) { + checkClosed(); + return delegate.getPgJsonb(columnName); + } + @Override public ByteArray getBytes(int columnIndex) { checkClosed(); @@ -364,6 +376,18 @@ public List getJsonList(String columnName) { return delegate.getJsonList(columnName); } + @Override + public List getPgJsonbList(int columnIndex) { + checkClosed(); + return delegate.getPgJsonbList(columnIndex); + } + + @Override + public List getPgJsonbList(String columnName) { + checkClosed(); + return delegate.getPgJsonbList(columnName); + } + @Override public List getBytesList(int columnIndex) { checkClosed(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/AbstractStructReaderTypesTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/AbstractStructReaderTypesTest.java index 10167ddc9d..1b6280a636 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/AbstractStructReaderTypesTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/AbstractStructReaderTypesTest.java @@ -75,6 +75,11 @@ protected String getJsonInternal(int columnIndex) { return null; } + @Override + protected String getPgJsonbInternal(int columnIndex) { + return null; + } + @Override protected ByteArray getBytesInternal(int columnIndex) { return null; @@ -140,6 +145,11 @@ protected List getJsonListInternal(int columnIndex) { return null; } + @Override + protected List getPgJsonbListInternal(int columnIndex) { + return null; + } + @Override protected List getBytesListInternal(int columnIndex) { return null; diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/GrpcResultSetTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/GrpcResultSetTest.java index a11ac78b54..ff4e92a521 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/GrpcResultSetTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/GrpcResultSetTest.java @@ -716,6 +716,25 @@ public void getJson() { assertEquals("[]", resultSet.getJson(0)); } + @Test + public void getPgJsonb() { + consumer.onPartialResultSet( + PartialResultSet.newBuilder() + .setMetadata(makeMetadata(Type.struct(Type.StructField.of("f", Type.pgJsonb())))) + .addValues(Value.pgJsonb("{\"color\":\"red\",\"value\":\"#f00\"}").toProto()) + .addValues(Value.pgJsonb("{}").toProto()) + .addValues(Value.pgJsonb("[]").toProto()) + .build()); + consumer.onCompleted(); + + assertTrue(resultSet.next()); + assertEquals("{\"color\":\"red\",\"value\":\"#f00\"}", resultSet.getPgJsonb(0)); + assertTrue(resultSet.next()); + assertEquals("{}", resultSet.getPgJsonb(0)); + assertTrue(resultSet.next()); + assertEquals("[]", resultSet.getPgJsonb(0)); + } + @Test public void getBooleanArray() { boolean[] boolArray = {true, true, false}; @@ -838,4 +857,23 @@ public void getJsonList() { assertTrue(resultSet.next()); assertEquals(jsonList, resultSet.getJsonList(0)); } + + @Test + public void getPgJsonbList() { + List jsonList = new ArrayList<>(); + jsonList.add("{\"color\":\"red\",\"value\":\"#f00\"}"); + jsonList.add("{\"special\":\"%😃∮πρότερονแผ่นดินฮั่นเสื่อมሰማይᚻᛖ\"}"); + jsonList.add("[]"); + + consumer.onPartialResultSet( + PartialResultSet.newBuilder() + .setMetadata( + makeMetadata(Type.struct(Type.StructField.of("f", Type.array(Type.pgJsonb()))))) + .addValues(Value.pgJsonbArray(jsonList).toProto()) + .build()); + consumer.onCompleted(); + + assertTrue(resultSet.next()); + assertEquals(jsonList, resultSet.getPgJsonbList(0)); + } } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MutationTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MutationTest.java index eb96334b50..fe2b7aec94 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MutationTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MutationTest.java @@ -541,6 +541,14 @@ private Mutation.WriteBuilder appendAllTypes(Mutation.WriteBuilder builder) { .to(Value.numeric(BigDecimal.TEN)) .set("pgNumericValue") .to(Value.pgNumeric("4.2")) + .set("json") + .to(Value.json("{\"key\": \"value\"}}")) + .set("jsonNull") + .to(Value.json(null)) + .set("pgJsonb") + .to(Value.pgJsonb("{\"key\": \"value\"}}")) + .set("pgJsonbNull") + .to(Value.pgJsonb(null)) .set("timestamp") .to(Timestamp.MAX_VALUE) .set("timestampNull") @@ -589,6 +597,18 @@ private Mutation.WriteBuilder appendAllTypes(Mutation.WriteBuilder builder) { .toPgNumericArray(null) .set("pgNumericArrValue") .to(Value.pgNumericArray(ImmutableList.of("10.20", "20.30"))) + .set("jsonArr") + .toJsonArray(ImmutableList.of("{\"key\": \"value1\"}}", "{\"key\": \"value2\"}")) + .set("jsonArrNull") + .toJsonArray(null) + .set("jsonArrValue") + .to(Value.jsonArray(ImmutableList.of("{\"key\": \"value1\"}}", "{\"key\": \"value2\"}"))) + .set("pgJsonbArr") + .toPgJsonbArray(ImmutableList.of("{\"key\": \"value1\"}}", "{\"key\": \"value2\"}")) + .set("pgJsonbArrNull") + .toPgJsonbArray(null) + .set("pgJsonbArrValue") + .to(Value.pgJsonbArray(ImmutableList.of("{\"key\": \"value1\"}}", "{\"key\": \"value2\"}"))) .set("timestampArr") .toTimestampArray(ImmutableList.of(Timestamp.MAX_VALUE, Timestamp.MAX_VALUE)) .set("timestampArrNull") diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ResultSetsTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ResultSetsTest.java index 85cdc0f687..87be602808 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ResultSetsTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ResultSetsTest.java @@ -17,6 +17,7 @@ package com.google.cloud.spanner; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; @@ -89,6 +90,7 @@ public void resultSetIteration() { Type.StructField.of("bigDecimalVal", Type.numeric()), Type.StructField.of("stringVal", Type.string()), Type.StructField.of("jsonVal", Type.json()), + Type.StructField.of("pgJsonbVal", Type.pgJsonb()), Type.StructField.of("byteVal", Type.bytes()), Type.StructField.of("timestamp", Type.timestamp()), Type.StructField.of("date", Type.date()), @@ -100,7 +102,8 @@ public void resultSetIteration() { Type.StructField.of("timestampArray", Type.array(Type.timestamp())), Type.StructField.of("dateArray", Type.array(Type.date())), Type.StructField.of("stringArray", Type.array(Type.string())), - Type.StructField.of("jsonArray", Type.array(Type.json()))); + Type.StructField.of("jsonArray", Type.array(Type.json())), + Type.StructField.of("pgJsonbArray", Type.array(Type.pgJsonb()))); Struct struct1 = Struct.newBuilder() .set("f1") @@ -117,6 +120,8 @@ public void resultSetIteration() { .to(stringVal) .set("jsonVal") .to(Value.json(jsonVal)) + .set("pgJsonbVal") + .to(Value.pgJsonb(jsonVal)) .set("byteVal") .to(Value.bytes(ByteArray.copyFrom(byteVal))) .set("timestamp") @@ -141,6 +146,8 @@ public void resultSetIteration() { .to(Value.stringArray(Arrays.asList(stringArray))) .set("jsonArray") .to(Value.jsonArray(Arrays.asList(jsonArray))) + .set("pgJsonbArray") + .to(Value.pgJsonbArray(Arrays.asList(jsonArray))) .build(); Struct struct2 = Struct.newBuilder() @@ -158,6 +165,8 @@ public void resultSetIteration() { .to(stringVal) .set("jsonVal") .to(Value.json(jsonVal)) + .set("pgJsonbVal") + .to(Value.pgJsonb(jsonVal)) .set("byteVal") .to(Value.bytes(ByteArray.copyFrom(byteVal))) .set("timestamp") @@ -182,10 +191,12 @@ public void resultSetIteration() { .to(Value.stringArray(Arrays.asList(stringArray))) .set("jsonArray") .to(Value.jsonArray(Arrays.asList(jsonArray))) + .set("pgJsonbArray") + .to(Value.pgJsonbArray(Arrays.asList(jsonArray))) .build(); ResultSet rs = ResultSets.forRows(type, Arrays.asList(struct1, struct2)); - IllegalStateException e = assertThrows(IllegalStateException.class, () -> rs.getType()); + IllegalStateException e = assertThrows(IllegalStateException.class, rs::getType); assertThat(e.getMessage()).contains("Must be preceded by a next() call"); int columnIndex = 0; @@ -227,6 +238,12 @@ public void resultSetIteration() { assertThat(rs.getValue(columnIndex++)).isEqualTo(Value.json(jsonVal)); assertThat(rs.getJson("jsonVal")).isEqualTo(jsonVal); assertThat(rs.getValue("jsonVal")).isEqualTo(Value.json(jsonVal)); + + assertEquals(jsonVal, rs.getPgJsonb(columnIndex)); + assertEquals(Value.pgJsonb(jsonVal), rs.getValue(columnIndex++)); + assertEquals(jsonVal, rs.getPgJsonb("pgJsonbVal")); + assertEquals(Value.pgJsonb(jsonVal), rs.getValue("pgJsonbVal")); + assertThat(rs.getBytes(columnIndex)).isEqualTo(ByteArray.copyFrom(byteVal)); assertThat(rs.getValue(columnIndex++)).isEqualTo(Value.bytes(ByteArray.copyFrom(byteVal))); assertThat(rs.getBytes("byteVal")).isEqualTo(ByteArray.copyFrom(byteVal)); @@ -285,9 +302,12 @@ public void resultSetIteration() { assertThat(rs.getValue(columnIndex++)).isEqualTo(Value.stringArray(Arrays.asList(stringArray))); assertThat(rs.getStringList("stringArray")).isEqualTo(Arrays.asList(stringArray)); assertThat(rs.getValue("stringArray")).isEqualTo(Value.stringArray(Arrays.asList(stringArray))); - assertThat(rs.getJsonList(columnIndex)).isEqualTo(Arrays.asList(jsonArray)); + assertThat(rs.getJsonList(columnIndex++)).isEqualTo(Arrays.asList(jsonArray)); assertThat(rs.getJsonList("jsonArray")).isEqualTo(Arrays.asList(jsonArray)); + assertEquals(Arrays.asList(jsonArray), rs.getPgJsonbList(columnIndex)); + assertEquals(Arrays.asList(jsonArray), rs.getPgJsonbList("pgJsonbArray")); + assertThat(rs.next()).isTrue(); assertThat(rs.getCurrentRowAsStruct()).isEqualTo(struct2); assertThat(rs.getString(0)).isEqualTo("y"); @@ -296,7 +316,7 @@ public void resultSetIteration() { assertThat(rs.next()).isFalse(); UnsupportedOperationException unsupported = - assertThrows(UnsupportedOperationException.class, () -> rs.getStats()); + assertThrows(UnsupportedOperationException.class, rs::getStats); assertThat(unsupported.getMessage()) .contains("ResultSetStats are available only for results returned from analyzeQuery"); } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/TypeTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/TypeTest.java index 7dfe9f3a98..3ed6fc6c57 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/TypeTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/TypeTest.java @@ -152,6 +152,16 @@ Type newType() { }.test(); } + @Test + public void pgJsonb() { + new ScalarTypeTester(Code.PG_JSONB, TypeCode.JSON, TypeAnnotationCode.PG_JSONB) { + @Override + Type newType() { + return Type.pgJsonb(); + } + }.test(); + } + @Test public void bytes() { new ScalarTypeTester(Type.Code.BYTES, TypeCode.BYTES) { @@ -306,6 +316,16 @@ Type newElementType() { }.test(); } + @Test + public void pgJsonbArray() { + new ArrayTypeTester(Code.PG_JSONB, TypeCode.JSON, TypeAnnotationCode.PG_JSONB, true) { + @Override + Type newElementType() { + return Type.pgJsonb(); + } + }.test(); + } + @Test public void bytesArray() { new ArrayTypeTester(Type.Code.BYTES, TypeCode.BYTES, true) { diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueBinderTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueBinderTest.java index ea26f09c2e..91263457ba 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueBinderTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueBinderTest.java @@ -17,6 +17,7 @@ package com.google.cloud.spanner; import static com.google.cloud.spanner.ValueBinderTest.DefaultValues.defaultJson; +import static com.google.cloud.spanner.ValueBinderTest.DefaultValues.defaultPgJsonb; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; @@ -37,6 +38,7 @@ @RunWith(JUnit4.class) public class ValueBinderTest { private static final String JSON_METHOD_NAME = "json"; + private static final String PG_JSONB_METHOD_NAME = "pgJsonb"; private static final String PG_NUMERIC_METHOD_NAME = "pgNumeric"; public static final String DEFAULT_PG_NUMERIC = "1.23"; @@ -125,6 +127,9 @@ public void reflection() // ValueBinder.to(Value) binderMethod = ValueBinder.class.getMethod("to", Value.class); assertThat(binderMethod.invoke(binder, Value.json(null))).isEqualTo(lastReturnValue); + } else if (method.getName().equalsIgnoreCase(PG_JSONB_METHOD_NAME)) { + binderMethod = ValueBinder.class.getMethod("to", Value.class); + assertThat(binderMethod.invoke(binder, Value.pgJsonb(null))).isEqualTo(lastReturnValue); } else if (method.getName().equalsIgnoreCase(PG_NUMERIC_METHOD_NAME)) { binderMethod = ValueBinder.class.getMethod("to", Value.class); assertThat(binderMethod.invoke(binder, Value.pgNumeric(null))) @@ -145,6 +150,11 @@ public void reflection() binderMethod = ValueBinder.class.getMethod("to", Value.class); assertThat(binderMethod.invoke(binder, Value.json(defaultJson()))) .isEqualTo(lastReturnValue); + } else if (method.getName().equalsIgnoreCase(PG_JSONB_METHOD_NAME)) { + defaultObject = defaultPgJsonb(); + binderMethod = ValueBinder.class.getMethod("to", Value.class); + assertThat(binderMethod.invoke(binder, Value.pgJsonb(defaultPgJsonb()))) + .isEqualTo(lastReturnValue); } else if (method.getName().equalsIgnoreCase(PG_NUMERIC_METHOD_NAME)) { defaultObject = DEFAULT_PG_NUMERIC; binderMethod = ValueBinder.class.getMethod("to", Value.class); @@ -232,6 +242,10 @@ public static String defaultJson() { return "{\"color\":\"red\",\"value\":\"#f00\"}"; } + public static String defaultPgJsonb() { + return "{\"color\":\"red\",\"value\":\"#f00\"}"; + } + public static ByteArray defaultByteArray() { return ByteArray.copyFrom(new byte[] {'x'}); } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueTest.java index 54f6799e8c..a466fab1ab 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueTest.java @@ -491,6 +491,56 @@ public void jsonNested() { assertEquals(json, v.getJson()); } + @Test + public void testPgJsonb() { + String json = "{\"color\":\"red\",\"value\":\"#f00\"}"; + Value v = Value.pgJsonb(json); + assertEquals(Type.pgJsonb(), v.getType()); + assertFalse(v.isNull()); + assertEquals(json, v.getPgJsonb()); + assertEquals(json, v.getString()); + } + + @Test + public void testPgJsonbNull() { + Value v = Value.pgJsonb(null); + assertEquals(Type.pgJsonb(), v.getType()); + assertTrue(v.isNull()); + assertEquals(NULL_STRING, v.toString()); + assertThrowsWithMessage(v::getPgJsonb, "null value"); + assertThrowsWithMessage(v::getString, "null value"); + } + + @Test + public void testPgJsonbEmpty() { + String json = "{}"; + Value v = Value.pgJsonb(json); + assertEquals(json, v.getPgJsonb()); + } + + @Test + public void testPgJsonbWithEmptyArray() { + String json = "[]"; + Value v = Value.pgJsonb(json); + assertEquals(json, v.getPgJsonb()); + } + + @Test + public void testPgJsonbWithArray() { + String json = + "[{\"color\":\"red\",\"value\":\"#f00\"},{\"color\":\"green\",\"value\":\"#0f0\"},{\"color\":\"blue\",\"value\":\"#00f\"},{\"color\":\"cyan\",\"value\":\"#0ff\"},{\"color\":\"magenta\",\"value\":\"#f0f\"},{\"color\":\"yellow\",\"value\":\"#ff0\"},{\"color\":\"black\",\"value\":\"#000\"}]"; + Value v = Value.pgJsonb(json); + assertEquals(json, v.getPgJsonb()); + } + + @Test + public void testPgJsonbNested() { + String json = + "[{\"id\":\"0001\",\"type\":\"donut\",\"name\":\"Cake\",\"ppu\":0.55,\"batters\":{\"batter\":[{\"id\":\"1001\",\"type\":\"Regular\"},{\"id\":\"1002\",\"type\":\"Chocolate\"},{\"id\":\"1003\",\"type\":\"Blueberry\"},{\"id\":\"1004\",\"type\":\"Devil's Food\"}]},\"topping\":[{\"id\":\"5001\",\"type\":\"None\"},{\"id\":\"5002\",\"type\":\"Glazed\"},{\"id\":\"5005\",\"type\":\"Sugar\"},{\"id\":\"5007\",\"type\":\"Powdered Sugar\"},{\"id\":\"5006\",\"type\":\"Chocolate with Sprinkles\"},{\"id\":\"5003\",\"type\":\"Chocolate\"},{\"id\":\"5004\",\"type\":\"Maple\"}]},{\"id\":\"0002\",\"type\":\"donut\",\"name\":\"Raised\",\"ppu\":0.55,\"batters\":{\"batter\":[{\"id\":\"1001\",\"type\":\"Regular\"}]},\"topping\":[{\"id\":\"5001\",\"type\":\"None\"},{\"id\":\"5002\",\"type\":\"Glazed\"},{\"id\":\"5005\",\"type\":\"Sugar\"},{\"id\":\"5003\",\"type\":\"Chocolate\"},{\"id\":\"5004\",\"type\":\"Maple\"}]},{\"id\":\"0003\",\"type\":\"donut\",\"name\":\"Old Fashioned\",\"ppu\":0.55,\"batters\":{\"batter\":[{\"id\":\"1001\",\"type\":\"Regular\"},{\"id\":\"1002\",\"type\":\"Chocolate\"}]},\"topping\":[{\"id\":\"5001\",\"type\":\"None\"},{\"id\":\"5002\",\"type\":\"Glazed\"},{\"id\":\"5003\",\"type\":\"Chocolate\"},{\"id\":\"5004\",\"type\":\"Maple\"}]}]"; + Value v = Value.pgJsonb(json); + assertEquals(json, v.getPgJsonb()); + } + @Test public void bytes() { ByteArray bytes = newByteArray("abc"); @@ -894,6 +944,41 @@ public void jsonArrayTryGetFloat64Array() { assertThrowsWithMessage(value::getFloat64Array, "Expected: ARRAY actual: ARRAY"); } + @Test + public void testPgJsonbArray() { + String one = "{}"; + String two = null; + String three = "{\"color\":\"red\",\"value\":\"#f00\"}"; + Value v = Value.pgJsonbArray(Arrays.asList(one, two, three)); + assertFalse(v.isNull()); + assertArrayEquals(new String[] {one, two, three}, v.getPgJsonbArray().toArray()); + assertEquals("[{},NULL,{\"color\":\"red\",\"value\":\"#f00\"}]", v.toString()); + assertArrayEquals(new String[] {one, two, three}, v.getStringArray().toArray()); + } + + @Test + public void testPgJsonbArrayNull() { + Value v = Value.pgJsonbArray(null); + assertTrue(v.isNull()); + assertEquals(NULL_STRING, v.toString()); + assertThrowsWithMessage(v::getPgJsonbArray, "null value"); + assertThrowsWithMessage(v::getStringArray, "null value"); + } + + @Test + public void testPgJsonbArrayTryGetBytesArray() { + Value value = Value.pgJsonbArray(Collections.singletonList("{}")); + assertThrowsWithMessage( + value::getBytesArray, "Expected: ARRAY actual: ARRAY>"); + } + + @Test + public void testPgJsonbArrayTryGetFloat64Array() { + Value value = Value.pgJsonbArray(Collections.singletonList("{}")); + assertThrowsWithMessage( + value::getFloat64Array, "Expected: ARRAY actual: ARRAY>"); + } + @Test public void bytesArray() { ByteArray a = newByteArray("a"); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ChecksumResultSetTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ChecksumResultSetTest.java new file mode 100644 index 0000000000..1f3f59e96a --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ChecksumResultSetTest.java @@ -0,0 +1,338 @@ +/* + * Copyright 2022 Google LLC + * + * 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 com.google.cloud.spanner.connection; + +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.cloud.ByteArray; +import com.google.cloud.Date; +import com.google.cloud.Timestamp; +import com.google.cloud.spanner.AbortedDueToConcurrentModificationException; +import com.google.cloud.spanner.AbortedException; +import com.google.cloud.spanner.ResultSet; +import com.google.cloud.spanner.ResultSets; +import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.Struct; +import com.google.cloud.spanner.Struct.Builder; +import com.google.cloud.spanner.Type; +import com.google.cloud.spanner.Type.StructField; +import com.google.cloud.spanner.Value; +import com.google.cloud.spanner.connection.AbstractStatementParser.ParsedStatement; +import com.google.common.collect.ImmutableList; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.Callable; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ChecksumResultSetTest { + private static final Struct DIFFERENT_NON_NULL_VALUES = + Struct.newBuilder() + .set("boolVal") + .to(false) + .set("longVal") + .to(2 * 2) + .set("doubleVal") + .to(Value.float64(3.14d * 2d)) + .set("bigDecimalVal") + .to(Value.numeric(BigDecimal.valueOf(123 * 2, 2))) + .set("pgNumericVal") + .to(Value.pgNumeric("2.46")) + .set("stringVal") + .to("testtest") + .set("jsonVal") + .to(Value.json("{\"color\":\"red\",\"value\":\"#ff0\"}")) + .set("pgJsonbVal") + .to(Value.pgJsonb("{\"color\":\"red\",\"value\":\"#00f\"}")) + .set("byteVal") + .to(Value.bytes(ByteArray.copyFrom("bytes".getBytes(StandardCharsets.UTF_8)))) + .set("timestamp") + .to(Timestamp.parseTimestamp("2022-08-04T11:20:00.123456789Z")) + .set("date") + .to(Date.fromYearMonthDay(2022, 8, 3)) + .set("boolArray") + .to(Value.boolArray(Arrays.asList(Boolean.FALSE, null, Boolean.TRUE))) + .set("longArray") + .to(Value.int64Array(Arrays.asList(2L, null, 1L, 0L))) + .set("doubleArray") + .to(Value.float64Array(Arrays.asList(3.14d, null, 6.6626d, 10.1d))) + .set("bigDecimalArray") + .to(Value.numericArray(Arrays.asList(BigDecimal.TEN, null, BigDecimal.ONE))) + .set("pgNumericArray") + .to(Value.pgNumericArray(Arrays.asList("10", null, "1", "NaN"))) + .set("byteArray") + .to( + Value.bytesArray( + Arrays.asList(ByteArray.copyFrom("test2"), null, ByteArray.copyFrom("test1")))) + .set("timestampArray") + .to( + Value.timestampArray( + Arrays.asList( + Timestamp.parseTimestamp("2000-01-01T00:00:00Z"), + null, + Timestamp.parseTimestamp("2022-07-04T10:24:00.123456789Z")))) + .set("dateArray") + .to( + Value.dateArray( + Arrays.asList(Date.parseDate("2000-01-01"), null, Date.parseDate("2022-08-03")))) + .set("stringArray") + .to(Value.stringArray(Arrays.asList("test2", null, "test1"))) + .set("jsonArray") + .to(Value.jsonArray(Arrays.asList("{\"color\":\"red\",\"value\":\"#f00\"}", null, "[]"))) + .set("pgJsonbArray") + .to( + Value.pgJsonbArray( + Arrays.asList("{\"color\":\"red\",\"value\":\"#f00\"}", null, "[]"))) + .build(); + + @Test + public void testRetry() { + Type type = + Type.struct( + Type.StructField.of("boolVal", Type.bool()), + Type.StructField.of("longVal", Type.int64()), + Type.StructField.of("doubleVal", Type.float64()), + Type.StructField.of("bigDecimalVal", Type.numeric()), + Type.StructField.of("pgNumericVal", Type.pgNumeric()), + Type.StructField.of("stringVal", Type.string()), + Type.StructField.of("jsonVal", Type.json()), + Type.StructField.of("pgJsonbVal", Type.pgJsonb()), + Type.StructField.of("byteVal", Type.bytes()), + Type.StructField.of("timestamp", Type.timestamp()), + Type.StructField.of("date", Type.date()), + Type.StructField.of("boolArray", Type.array(Type.bool())), + Type.StructField.of("longArray", Type.array(Type.int64())), + Type.StructField.of("doubleArray", Type.array(Type.float64())), + Type.StructField.of("bigDecimalArray", Type.array(Type.numeric())), + Type.StructField.of("pgNumericArray", Type.array(Type.pgNumeric())), + Type.StructField.of("byteArray", Type.array(Type.bytes())), + Type.StructField.of("timestampArray", Type.array(Type.timestamp())), + Type.StructField.of("dateArray", Type.array(Type.date())), + Type.StructField.of("stringArray", Type.array(Type.string())), + Type.StructField.of("jsonArray", Type.array(Type.json())), + Type.StructField.of("pgJsonbArray", Type.array(Type.pgJsonb()))); + Struct rowNonNullValues = + Struct.newBuilder() + .set("boolVal") + .to(true) + .set("longVal") + .to(2) + .set("doubleVal") + .to(Value.float64(3.14d)) + .set("bigDecimalVal") + .to(Value.numeric(BigDecimal.valueOf(123, 2))) + .set("pgNumericVal") + .to(Value.pgNumeric("1.23")) + .set("stringVal") + .to("test") + .set("jsonVal") + .to(Value.json("{\"color\":\"red\",\"value\":\"#f00\"}")) + .set("pgJsonbVal") + .to(Value.pgJsonb("{\"color\":\"red\",\"value\":\"#f00\"}")) + .set("byteVal") + .to(Value.bytes(ByteArray.copyFrom("test".getBytes(StandardCharsets.UTF_8)))) + .set("timestamp") + .to(Timestamp.parseTimestamp("2022-08-04T10:19:00.123456789Z")) + .set("date") + .to(Date.fromYearMonthDay(2022, 8, 4)) + .set("boolArray") + .to(Value.boolArray(Arrays.asList(Boolean.TRUE, null, Boolean.FALSE))) + .set("longArray") + .to(Value.int64Array(Arrays.asList(1L, null, 2L))) + .set("doubleArray") + .to(Value.float64Array(Arrays.asList(3.14d, null, 6.6626d))) + .set("bigDecimalArray") + .to(Value.numericArray(Arrays.asList(BigDecimal.ONE, null, BigDecimal.TEN))) + .set("pgNumericArray") + .to(Value.pgNumericArray(Arrays.asList("1", null, "10"))) + .set("byteArray") + .to( + Value.bytesArray( + Arrays.asList(ByteArray.copyFrom("test1"), null, ByteArray.copyFrom("test2")))) + .set("timestampArray") + .to( + Value.timestampArray( + Arrays.asList( + Timestamp.parseTimestamp("2000-01-01T00:00:00Z"), + null, + Timestamp.parseTimestamp("2022-08-04T10:24:00.123456789Z")))) + .set("dateArray") + .to( + Value.dateArray( + Arrays.asList( + Date.parseDate("2000-01-01"), null, Date.parseDate("2022-08-04")))) + .set("stringArray") + .to(Value.stringArray(Arrays.asList("test1", null, "test2"))) + .set("jsonArray") + .to( + Value.jsonArray( + Arrays.asList("{\"color\":\"red\",\"value\":\"#f00\"}", null, "{}"))) + .set("pgJsonbArray") + .to( + Value.pgJsonbArray( + Arrays.asList("{\"color\":\"red\",\"value\":\"#f00\"}", null, "{}"))) + .build(); + Struct rowNullValues = + Struct.newBuilder() + .set("boolVal") + .to((Boolean) null) + .set("longVal") + .to((Long) null) + .set("doubleVal") + .to((Double) null) + .set("bigDecimalVal") + .to((BigDecimal) null) + .set("pgNumericVal") + .to(Value.pgNumeric(null)) + .set("stringVal") + .to((String) null) + .set("jsonVal") + .to(Value.json(null)) + .set("pgJsonbVal") + .to(Value.pgJsonb(null)) + .set("byteVal") + .to((ByteArray) null) + .set("timestamp") + .to((Timestamp) null) + .set("date") + .to((Date) null) + .set("boolArray") + .toBoolArray((Iterable) null) + .set("longArray") + .toInt64Array((Iterable) null) + .set("doubleArray") + .toFloat64Array((Iterable) null) + .set("bigDecimalArray") + .toNumericArray(null) + .set("pgNumericArray") + .toPgNumericArray(null) + .set("byteArray") + .toBytesArray(null) + .set("timestampArray") + .toTimestampArray(null) + .set("dateArray") + .toDateArray(null) + .set("stringArray") + .toStringArray(null) + .set("jsonArray") + .toJsonArray(null) + .set("pgJsonbArray") + .toPgJsonbArray(null) + .build(); + + ParsedStatement parsedStatement = mock(ParsedStatement.class); + Statement statement = Statement.of("select * from foo"); + when(parsedStatement.getStatement()).thenReturn(statement); + AbortedException abortedException = mock(AbortedException.class); + ReadWriteTransaction transaction = mock(ReadWriteTransaction.class); + when(transaction.runWithRetry(any(Callable.class))) + .thenAnswer(invocationOnMock -> ((Callable) invocationOnMock.getArgument(0)).call()); + when(transaction.getStatementExecutor()).thenReturn(mock(StatementExecutor.class)); + + ResultSet queryResult = + ResultSets.forRows(type, ImmutableList.of(rowNonNullValues, rowNullValues)); + ChecksumResultSet resultSet = + new ChecksumResultSet( + transaction, + DirectExecuteResultSet.ofResultSet(queryResult), + parsedStatement, + AnalyzeMode.NONE); + assertTrue(resultSet.next()); + assertTrue(resultSet.next()); + + // Ensure that retrying will return the same result. + ResultSet retryResult = + ResultSets.forRows(type, ImmutableList.of(rowNonNullValues, rowNullValues)); + when(transaction.internalExecuteQuery(parsedStatement, AnalyzeMode.NONE)) + .thenReturn(retryResult); + + // There have been no changes, so the retry should succeed. + resultSet.retry(abortedException); + + // Change field value from one non-null value to another non-null value. + for (StructField fieldToChange : rowNonNullValues.getType().getStructFields()) { + Builder builder = Struct.newBuilder(); + for (StructField field : rowNonNullValues.getType().getStructFields()) { + if (field.equals(fieldToChange)) { + builder.set(field.getName()).to(DIFFERENT_NON_NULL_VALUES.getValue(field.getName())); + } else { + builder.set(field.getName()).to(rowNonNullValues.getValue(field.getName())); + } + } + ResultSet newRetryResult = + ResultSets.forRows(type, ImmutableList.of(builder.build(), rowNullValues)); + when(transaction.internalExecuteQuery(parsedStatement, AnalyzeMode.NONE)) + .thenReturn(newRetryResult); + // The query result has changed, so this should now fail. + assertThrows( + "Missing exception for " + fieldToChange.getName(), + AbortedDueToConcurrentModificationException.class, + () -> resultSet.retry(abortedException)); + } + + // Change field value from non-null value to null value. + for (StructField fieldToChange : rowNonNullValues.getType().getStructFields()) { + Builder builder = Struct.newBuilder(); + for (StructField field : rowNonNullValues.getType().getStructFields()) { + if (field.equals(fieldToChange)) { + builder.set(field.getName()).to(rowNullValues.getValue(field.getName())); + } else { + builder.set(field.getName()).to(rowNonNullValues.getValue(field.getName())); + } + } + ResultSet newRetryResult = + ResultSets.forRows(type, ImmutableList.of(builder.build(), rowNullValues)); + when(transaction.internalExecuteQuery(parsedStatement, AnalyzeMode.NONE)) + .thenReturn(newRetryResult); + // The query result has changed, so this should now fail. + assertThrows( + "Missing exception for " + fieldToChange.getName(), + AbortedDueToConcurrentModificationException.class, + () -> resultSet.retry(abortedException)); + } + + // Change field value from null value to non-null value. + for (StructField fieldToChange : rowNonNullValues.getType().getStructFields()) { + Builder builder = Struct.newBuilder(); + for (StructField field : rowNullValues.getType().getStructFields()) { + if (field.equals(fieldToChange)) { + builder.set(field.getName()).to(rowNonNullValues.getValue(field.getName())); + } else { + builder.set(field.getName()).to(rowNullValues.getValue(field.getName())); + } + } + // In this case the modified values are in the second row that first only contained null + // values. + ResultSet newRetryResult = + ResultSets.forRows(type, ImmutableList.of(rowNonNullValues, builder.build())); + when(transaction.internalExecuteQuery(parsedStatement, AnalyzeMode.NONE)) + .thenReturn(newRetryResult); + // The query result has changed, so this should now fail. + assertThrows( + "Missing exception for " + fieldToChange.getName(), + AbortedDueToConcurrentModificationException.class, + () -> resultSet.retry(abortedException)); + } + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DirectExecuteResultSetTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DirectExecuteResultSetTest.java index 896055ee9a..094503cfbc 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DirectExecuteResultSetTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DirectExecuteResultSetTest.java @@ -249,6 +249,15 @@ public void testValidMethodCall() throws IllegalArgumentException { subject.getJsonList("test2"); verify(delegate).getJsonList("test2"); + subject.getPgJsonb(0); + verify(delegate).getPgJsonb(0); + subject.getPgJsonb("test0"); + verify(delegate).getPgJsonb("test0"); + subject.getPgJsonbList(2); + verify(delegate).getPgJsonbList(2); + subject.getPgJsonbList("test2"); + verify(delegate).getPgJsonbList("test2"); + subject.getStructList(0); subject.getStructList("test0"); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/RandomResultSetGenerator.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/RandomResultSetGenerator.java index 024032ab76..c3ac655a40 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/RandomResultSetGenerator.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/RandomResultSetGenerator.java @@ -51,9 +51,12 @@ private static Type[] generateTypes(Dialect dialect) { .build() : Type.newBuilder().setCode(TypeCode.NUMERIC).build(), Type.newBuilder().setCode(TypeCode.STRING).build(), - Type.newBuilder() - .setCode(dialect == Dialect.POSTGRESQL ? TypeCode.STRING : TypeCode.JSON) - .build(), + dialect == Dialect.POSTGRESQL + ? Type.newBuilder() + .setCode(TypeCode.JSON) + .setTypeAnnotation(TypeAnnotationCode.PG_JSONB) + .build() + : Type.newBuilder().setCode(TypeCode.JSON).build(), Type.newBuilder().setCode(TypeCode.BYTES).build(), Type.newBuilder().setCode(TypeCode.DATE).build(), Type.newBuilder().setCode(TypeCode.TIMESTAMP).build(), @@ -85,8 +88,11 @@ private static Type[] generateTypes(Dialect dialect) { Type.newBuilder() .setCode(TypeCode.ARRAY) .setArrayElementType( - Type.newBuilder() - .setCode(dialect == Dialect.POSTGRESQL ? TypeCode.STRING : TypeCode.JSON)) + dialect == Dialect.POSTGRESQL + ? Type.newBuilder() + .setCode(TypeCode.JSON) + .setTypeAnnotation(TypeAnnotationCode.PG_JSONB) + : Type.newBuilder().setCode(TypeCode.JSON)) .build(), Type.newBuilder() .setCode(TypeCode.ARRAY) diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITPgJsonbTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITPgJsonbTest.java new file mode 100644 index 0000000000..74edbebb0b --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITPgJsonbTest.java @@ -0,0 +1,440 @@ +/* + * Copyright 2022 Google LLC + * + * 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 com.google.cloud.spanner.it; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeFalse; + +import com.google.cloud.spanner.Database; +import com.google.cloud.spanner.DatabaseAdminClient; +import com.google.cloud.spanner.DatabaseClient; +import com.google.cloud.spanner.DatabaseId; +import com.google.cloud.spanner.Dialect; +import com.google.cloud.spanner.ErrorCode; +import com.google.cloud.spanner.IntegrationTestEnv; +import com.google.cloud.spanner.Mutation; +import com.google.cloud.spanner.ParallelIntegrationTest; +import com.google.cloud.spanner.ResultSet; +import com.google.cloud.spanner.SpannerException; +import com.google.cloud.spanner.SpannerExceptionFactory; +import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.Value; +import com.google.cloud.spanner.testing.EmulatorSpannerHelper; +import com.google.cloud.spanner.testing.RemoteSpannerHelper; +import com.google.common.collect.ImmutableList; +import com.google.protobuf.NullValue; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.threeten.bp.Duration; + +// TODO: Re-enable when jsonb is GA. +@Ignore("Feature is not yet generally available") +@Category(ParallelIntegrationTest.class) +@RunWith(JUnit4.class) +public class ITPgJsonbTest { + + private static final Duration OPERATION_TIMEOUT = Duration.ofMinutes(5); + + @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); + private static RemoteSpannerHelper testHelper; + private static DatabaseAdminClient databaseAdminClient; + private static List databasesToDrop; + private static String projectId; + private static String instanceId; + private static String databaseId; + private DatabaseClient databaseClient; + private String tableName; + + @BeforeClass + public static void beforeClass() throws Exception { + assumeFalse( + "PgJsonb is not supported in the emulator", EmulatorSpannerHelper.isUsingEmulator()); + testHelper = env.getTestHelper(); + databaseAdminClient = testHelper.getClient().getDatabaseAdminClient(); + databasesToDrop = new ArrayList<>(); + projectId = testHelper.getInstanceId().getProject(); + instanceId = testHelper.getInstanceId().getInstance(); + databaseId = testHelper.getUniqueDatabaseId(); + final Database database = + databaseAdminClient + .newDatabaseBuilder(DatabaseId.of(projectId, instanceId, databaseId)) + .setDialect(Dialect.POSTGRESQL) + .build(); + databaseAdminClient + .createDatabase(database, Collections.emptyList()) + .get(OPERATION_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + databasesToDrop.add(database.getId()); + } + + @AfterClass + public static void afterClass() throws Exception { + if (databasesToDrop != null) { + for (DatabaseId id : databasesToDrop) { + try { + databaseAdminClient.dropDatabase(id.getInstanceId().getInstance(), id.getDatabase()); + } catch (Exception e) { + System.err.println("Failed to drop database " + id + ", skipping...: " + e.getMessage()); + } + } + } + } + + @Before + public void setUp() throws Exception { + databaseClient = + testHelper.getClient().getDatabaseClient(DatabaseId.of(projectId, instanceId, databaseId)); + tableName = testHelper.getUniqueDatabaseId(); + databaseAdminClient + .updateDatabaseDdl( + instanceId, + databaseId, + Collections.singletonList( + "CREATE TABLE \"" + tableName + "\" (id BIGINT PRIMARY KEY, col1 JSONB)"), + null) + .get(OPERATION_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } + + @Test + public void testPgJsonbAsPrimaryKey() { + // JSONB is not allowed as a primary key. + ExecutionException executionException = + assertThrows( + ExecutionException.class, + () -> + databaseAdminClient + .updateDatabaseDdl( + instanceId, + databaseId, + Collections.singletonList( + "CREATE TABLE with_jsonb_pk (id jsonb primary key)"), + null) + .get(OPERATION_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); + SpannerException spannerException = + SpannerExceptionFactory.asSpannerException(executionException.getCause()); + assertEquals(ErrorCode.INVALID_ARGUMENT, spannerException.getErrorCode()); + assertTrue( + spannerException.getMessage(), + spannerException + .getMessage() + .contains( + "Column with_jsonb_pk.id has type PG.JSONB, but is part of the primary key.")); + } + + @Test + public void testPgJsonbInSecondaryIndex() { + // JSONB is not allowed as a key in a secondary index. + ExecutionException executionException = + assertThrows( + ExecutionException.class, + () -> + databaseAdminClient + .updateDatabaseDdl( + instanceId, + databaseId, + Collections.singletonList( + "CREATE INDEX idx_jsonb on \"" + tableName + "\" (col1)"), + null) + .get(OPERATION_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); + SpannerException spannerException = + SpannerExceptionFactory.asSpannerException(executionException.getCause()); + assertEquals(ErrorCode.FAILED_PRECONDITION, spannerException.getErrorCode()); + assertTrue( + spannerException.getMessage(), + spannerException + .getMessage() + .contains("Index idx_jsonb is defined on a column of unsupported type PG.JSONB.")); + } + + private static final String JSON_VALUE_1 = "{\"color\":\"red\",\"value\":\"#f00\"}"; + private static final String JSON_VALUE_2 = + "[" + + " {\"color\":\"red\",\"value\":\"#f00\"}," + + " {\"color\":\"green\",\"value\":\"#0f0\"}," + + " {\"color\":\"blue\",\"value\":\"#00f\"}" + + "]"; + + @Test + public void testLiteralPgJsonb() { + assumeFalse( + "PgJsonb is not supported in the emulator", EmulatorSpannerHelper.isUsingEmulator()); + databaseClient + .readWriteTransaction() + .run( + transaction -> { + transaction.executeUpdate( + Statement.of( + "INSERT INTO " + + tableName + + " (id, col1) VALUES" + + " (1, '" + + JSON_VALUE_1 + + "')" + + ", (2, '" + + JSON_VALUE_2 + + "')" + + ", (3, '{}')" + + ", (4, '[]')" + + ", (5, null)")); + return null; + }); + + verifyContents(); + } + + @Test + public void testPgJsonbParameter() { + assumeFalse( + "PgJsonb is not supported in the emulator", EmulatorSpannerHelper.isUsingEmulator()); + databaseClient + .readWriteTransaction() + .run( + transaction -> { + transaction.executeUpdate( + Statement.newBuilder( + "INSERT INTO " + + tableName + + " (id, col1) VALUES" + + " (1, $1)" + + ", (2, $2)" + + ", (3, $3)" + + ", (4, $4)" + + ", (5, $5)") + .bind("p1") + .to(Value.pgJsonb(JSON_VALUE_1)) + .bind("p2") + .to(Value.pgJsonb(JSON_VALUE_2)) + .bind("p3") + .to(Value.pgJsonb("{}")) + .bind("p4") + .to(Value.pgJsonb("[]")) + .bind("p5") + .to(Value.pgJsonb(null)) + .build()); + return null; + }); + + verifyContents(); + } + + @Ignore("Untyped jsonb parameters are not yet supported") + @Test + public void testPgJsonbUntypedParameter() { + assumeFalse( + "PgJsonb is not supported in the emulator", EmulatorSpannerHelper.isUsingEmulator()); + + // Verify that we can use Jsonb as an untyped parameter. This is especially important for + // PGAdapter and the JDBC driver, as these will often use untyped parameters. + databaseClient + .readWriteTransaction() + .run( + transaction -> { + transaction.executeUpdate( + Statement.newBuilder( + "INSERT INTO " + + tableName + + " (id, col1) VALUES" + + " (1, $1)" + + ", (2, $2)" + + ", (3, $3)" + + ", (4, $4)" + + ", (5, $5)") + .bind("p1") + .to( + Value.untyped( + com.google.protobuf.Value.newBuilder() + .setStringValue(JSON_VALUE_1) + .build())) + .bind("p2") + .to( + Value.untyped( + com.google.protobuf.Value.newBuilder() + .setStringValue(JSON_VALUE_2) + .build())) + .bind("p3") + .to( + Value.untyped( + com.google.protobuf.Value.newBuilder().setStringValue("{}").build())) + .bind("p4") + .to( + Value.untyped( + com.google.protobuf.Value.newBuilder().setStringValue("[]").build())) + .bind("p5") + .to( + Value.untyped( + com.google.protobuf.Value.newBuilder() + .setNullValue(NullValue.NULL_VALUE) + .build())) + .build()); + return null; + }); + + verifyContents(); + } + + @Test + public void testMutationsWithPgJsonbAsString() { + assumeFalse( + "PgJsonb is not supported in the emulator", EmulatorSpannerHelper.isUsingEmulator()); + databaseClient + .readWriteTransaction() + .run( + transaction -> { + transaction.buffer( + ImmutableList.of( + Mutation.newInsertBuilder(tableName) + .set("id") + .to(1) + .set("col1") + .to(JSON_VALUE_1) + .build(), + Mutation.newInsertBuilder(tableName) + .set("id") + .to(2) + .set("col1") + .to(JSON_VALUE_2) + .build(), + Mutation.newInsertBuilder(tableName) + .set("id") + .to(3) + .set("col1") + .to("{}") + .build(), + Mutation.newInsertBuilder(tableName) + .set("id") + .to(4) + .set("col1") + .to("[]") + .build(), + Mutation.newInsertBuilder(tableName) + .set("id") + .to(5) + .set("col1") + .to((String) null) + .build())); + return null; + }); + + verifyContents(); + } + + @Test + public void testMutationsWithPgJsonbAsValue() { + assumeFalse( + "PgJsonb is not supported in the emulator", EmulatorSpannerHelper.isUsingEmulator()); + databaseClient + .readWriteTransaction() + .run( + transaction -> { + transaction.buffer( + ImmutableList.of( + Mutation.newInsertBuilder(tableName) + .set("id") + .to(1) + .set("col1") + .to(Value.pgJsonb(JSON_VALUE_1)) + .build(), + Mutation.newInsertBuilder(tableName) + .set("id") + .to(2) + .set("col1") + .to(Value.pgJsonb(JSON_VALUE_2)) + .build(), + Mutation.newInsertBuilder(tableName) + .set("id") + .to(3) + .set("col1") + .to(Value.pgJsonb("{}")) + .build(), + Mutation.newInsertBuilder(tableName) + .set("id") + .to(4) + .set("col1") + .to(Value.pgJsonb("[]")) + .build(), + Mutation.newInsertBuilder(tableName) + .set("id") + .to(5) + .set("col1") + .to(Value.pgJsonb(null)) + .build())); + return null; + }); + + verifyContents(); + } + + private void verifyContents() { + try (ResultSet resultSet = + databaseClient + .singleUse() + .executeQuery(Statement.of("SELECT * FROM " + tableName + " ORDER BY id"))) { + + assertTrue(resultSet.next()); + // Note: We do not use the JSON_VALUE_1 constant here, because the backend prettifies the + // value a little, which means that there is a small difference between what we insert and + // what we get back. + assertEquals("{\"color\": \"red\", \"value\": \"#f00\"}", resultSet.getPgJsonb("col1")); + assertEquals( + Value.pgJsonb("{\"color\": \"red\", \"value\": \"#f00\"}"), resultSet.getValue("col1")); + + assertTrue(resultSet.next()); + assertEquals( + "[" + + "{\"color\": \"red\", \"value\": \"#f00\"}, " + + "{\"color\": \"green\", \"value\": \"#0f0\"}, " + + "{\"color\": \"blue\", \"value\": \"#00f\"}" + + "]", + resultSet.getPgJsonb("col1")); + assertEquals( + Value.pgJsonb( + "[" + + "{\"color\": \"red\", \"value\": \"#f00\"}, " + + "{\"color\": \"green\", \"value\": \"#0f0\"}, " + + "{\"color\": \"blue\", \"value\": \"#00f\"}" + + "]"), + resultSet.getValue("col1")); + + assertTrue(resultSet.next()); + assertEquals("{}", resultSet.getPgJsonb("col1")); + assertEquals(Value.pgJsonb("{}"), resultSet.getValue("col1")); + + assertTrue(resultSet.next()); + assertEquals("[]", resultSet.getPgJsonb("col1")); + assertEquals(Value.pgJsonb("[]"), resultSet.getValue("col1")); + + assertTrue(resultSet.next()); + assertTrue(resultSet.isNull("col1")); + + assertFalse(resultSet.next()); + } + } +}