diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a583fc9..d51198acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [2.11.0](https://github.com/googleapis/java-spanner-jdbc/compare/v2.10.0...v2.11.0) (2023-06-12) + + +### Features + +* Support untyped NULL value parameters ([#1224](https://github.com/googleapis/java-spanner-jdbc/issues/1224)) ([80d2b9d](https://github.com/googleapis/java-spanner-jdbc/commit/80d2b9d3e4c3265522bbb20766bff1f164617711)) + + +### Dependencies + +* Update dependency com.google.cloud:google-cloud-shared-dependencies to v3.11.0 ([#1254](https://github.com/googleapis/java-spanner-jdbc/issues/1254)) ([41f40fc](https://github.com/googleapis/java-spanner-jdbc/commit/41f40fce634cea205d5e5a9c1eb567ecb97ff655)) +* Update dependency com.google.cloud:google-cloud-spanner-bom to v6.42.3 ([#1248](https://github.com/googleapis/java-spanner-jdbc/issues/1248)) ([397d573](https://github.com/googleapis/java-spanner-jdbc/commit/397d5738a8126aaf090d533d0f20efb74a77a788)) +* Update dependency com.google.cloud:google-cloud-spanner-bom to v6.43.0 ([#1255](https://github.com/googleapis/java-spanner-jdbc/issues/1255)) ([ffe36b6](https://github.com/googleapis/java-spanner-jdbc/commit/ffe36b6b2087157c8d895fa348cff614435a4735)) + ## [2.10.0](https://github.com/googleapis/java-spanner-jdbc/compare/v2.9.16...v2.10.0) (2023-05-30) diff --git a/pom.xml b/pom.xml index 116956d90..9b0d4aec6 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 google-cloud-spanner-jdbc - 2.10.0 + 2.11.0 jar Google Cloud Spanner JDBC https://github.com/googleapis/java-spanner-jdbc @@ -14,7 +14,7 @@ com.google.cloud google-cloud-shared-config - 1.5.5 + 1.5.6 @@ -62,14 +62,14 @@ com.google.cloud google-cloud-spanner-bom - 6.42.2 + 6.43.0 pom import com.google.cloud google-cloud-shared-dependencies - 3.10.1 + 3.11.0 pom import @@ -393,7 +393,7 @@ org.apache.maven.plugins maven-project-info-reports-plugin - 3.4.4 + 3.4.5 diff --git a/samples/install-without-bom/pom.xml b/samples/install-without-bom/pom.xml index 7ac31aeb4..42875833f 100644 --- a/samples/install-without-bom/pom.xml +++ b/samples/install-without-bom/pom.xml @@ -29,7 +29,7 @@ com.google.cloud google-cloud-spanner-jdbc - 2.9.16 + 2.10.0 diff --git a/samples/snapshot/pom.xml b/samples/snapshot/pom.xml index 6cd1705d3..9b0ce3fe4 100644 --- a/samples/snapshot/pom.xml +++ b/samples/snapshot/pom.xml @@ -28,7 +28,7 @@ com.google.cloud google-cloud-spanner-jdbc - 2.10.0 + 2.11.0 diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml index cf10eb51d..06de2d0ec 100644 --- a/samples/snippets/pom.xml +++ b/samples/snippets/pom.xml @@ -30,7 +30,7 @@ com.google.cloud libraries-bom - 26.15.0 + 26.16.0 pom import diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java index 847f54d38..cd1c50a73 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java @@ -81,7 +81,8 @@ public void addBatch(String sql) throws SQLException { @Override public void setNull(int parameterIndex, int sqlType) throws SQLException { checkClosed(); - parameters.setParameter(parameterIndex, null, sqlType, null); + parameters.setParameter( + parameterIndex, /* value = */ null, sqlType, /* scaleOrLength = */ null); } @Override diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java index 3ce105aa7..5fb4177fc 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java @@ -24,6 +24,7 @@ import com.google.cloud.spanner.Value; import com.google.cloud.spanner.ValueBinder; import com.google.common.io.CharStreams; +import com.google.protobuf.NullValue; import com.google.rpc.Code; import java.io.IOException; import java.io.InputStream; @@ -231,6 +232,10 @@ void setParameter( } private void checkTypeAndValueSupported(Object value, int sqlType) throws SQLException { + if (value == null) { + // null is always supported, as we will just fall back to an untyped NULL value. + return; + } if (!isTypeSupported(sqlType)) { throw JdbcSqlExceptionFactory.of( "Type " + sqlType + " is not supported", Code.INVALID_ARGUMENT); @@ -775,8 +780,13 @@ private Builder setArrayValue(ValueBinder binder, int type, Object valu case Types.LONGVARBINARY: case Types.BLOB: return binder.toBytesArray(null); + default: + return binder.to( + Value.untyped( + com.google.protobuf.Value.newBuilder() + .setNullValue(NullValue.NULL_VALUE) + .build())); } - throw JdbcSqlExceptionFactory.unsupported("Unknown/unsupported array base type: " + type); } if (boolean[].class.isAssignableFrom(value.getClass())) { @@ -864,7 +874,9 @@ private List toDoubleList(Number[] input) { */ private Builder setNullValue(ValueBinder binder, Integer sqlType) { if (sqlType == null) { - return binder.to((String) null); + return binder.to( + Value.untyped( + com.google.protobuf.Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())); } switch (sqlType) { case Types.BIGINT: @@ -924,8 +936,14 @@ private Builder setNullValue(ValueBinder binder, Integer sqlType) { return binder.to((ByteArray) null); case Types.VARCHAR: return binder.to((String) null); + case JsonType.VENDOR_TYPE_NUMBER: + return binder.to(Value.json(null)); + case PgJsonbType.VENDOR_TYPE_NUMBER: + return binder.to(Value.pgJsonb(null)); default: - throw new IllegalArgumentException("Unsupported sql type for setting to null: " + sqlType); + return binder.to( + Value.untyped( + com.google.protobuf.Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())); } } } diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java index b4c33a802..8f21e68d3 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java @@ -20,7 +20,6 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.mock; @@ -39,7 +38,6 @@ import com.google.cloud.spanner.Value; import com.google.cloud.spanner.connection.AbstractStatementParser; import com.google.cloud.spanner.connection.Connection; -import com.google.rpc.Code; import java.io.ByteArrayInputStream; import java.io.StringReader; import java.math.BigDecimal; @@ -47,7 +45,6 @@ import java.net.URL; import java.sql.Date; import java.sql.JDBCType; -import java.sql.PreparedStatement; import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Time; @@ -197,7 +194,10 @@ public void testParameters() throws SQLException, MalformedURLException { ps.setObject(35, "TEST"); ps.setObject(36, "TEST", Types.NVARCHAR); ps.setObject(37, "TEST", Types.NVARCHAR, 20); + ps.setRef(38, null); + ps.setRowId(39, null); ps.setShort(40, (short) 1); + ps.setSQLXML(41, null); ps.setString(42, "TEST"); ps.setTime(43, new Time(1000L)); ps.setTime(44, new Time(1000L), Calendar.getInstance(TimeZone.getTimeZone("GMT"))); @@ -211,8 +211,6 @@ public void testParameters() throws SQLException, MalformedURLException { ps.setObject(52, "{}", JsonType.VENDOR_TYPE_NUMBER); ps.setObject(53, "{}", PgJsonbType.VENDOR_TYPE_NUMBER); - testSetUnsupportedTypes(ps); - JdbcParameterMetaData pmd = ps.getParameterMetaData(); assertEquals(numberOfParams, pmd.getParameterCount()); assertEquals(JdbcArray.class.getName(), pmd.getParameterClassName(1)); @@ -274,33 +272,9 @@ public void testParameters() throws SQLException, MalformedURLException { } } - private void testSetUnsupportedTypes(PreparedStatement ps) { - try { - ps.setRef(38, null); - fail("missing expected exception"); - } catch (SQLException e) { - assertTrue(e instanceof JdbcSqlException); - assertEquals(Code.INVALID_ARGUMENT, ((JdbcSqlException) e).getCode()); - } - try { - ps.setRowId(39, null); - fail("missing expected exception"); - } catch (SQLException e) { - assertTrue(e instanceof JdbcSqlException); - assertEquals(Code.INVALID_ARGUMENT, ((JdbcSqlException) e).getCode()); - } - try { - ps.setSQLXML(41, null); - fail("missing expected exception"); - } catch (SQLException e) { - assertTrue(e instanceof JdbcSqlException); - assertEquals(Code.INVALID_ARGUMENT, ((JdbcSqlException) e).getCode()); - } - } - @Test public void testSetNullValues() throws SQLException { - final int numberOfParameters = 27; + final int numberOfParameters = 31; String sql = generateSqlWithParameters(numberOfParameters); try (JdbcPreparedStatement ps = new JdbcPreparedStatement(createMockConnection(), sql)) { int index = 0; @@ -331,6 +305,10 @@ public void testSetNullValues() throws SQLException { ps.setNull(++index, Types.BIT); ps.setNull(++index, Types.VARBINARY); ps.setNull(++index, Types.VARCHAR); + ps.setNull(++index, JsonType.VENDOR_TYPE_NUMBER); + ps.setNull(++index, PgJsonbType.VENDOR_TYPE_NUMBER); + ps.setNull(++index, Types.OTHER); + ps.setNull(++index, Types.NULL); assertEquals(numberOfParameters, index); JdbcParameterMetaData pmd = ps.getParameterMetaData(); diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementWithMockedServerTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementWithMockedServerTest.java index 9da17d7db..d3607d842 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementWithMockedServerTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementWithMockedServerTest.java @@ -25,6 +25,7 @@ import com.google.cloud.spanner.MockSpannerServiceImpl; import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult; import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.Value; import com.google.cloud.spanner.connection.SpannerPool; import com.google.cloud.spanner.jdbc.JdbcSqlExceptionFactory.JdbcSqlBatchUpdateException; import io.grpc.Server; @@ -36,6 +37,7 @@ import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.sql.Types; import java.util.Arrays; import java.util.Collection; import org.junit.After; @@ -193,4 +195,69 @@ public void testExecuteBatch_withException() throws SQLException { } } } + + @Test + public void testInsertUntypedNullValues() throws SQLException { + mockSpanner.putStatementResult( + StatementResult.update( + Statement.newBuilder( + "insert into all_nullable_types (ColInt64, ColFloat64, ColBool, ColString, ColBytes, ColDate, ColTimestamp, ColNumeric, ColJson, ColInt64Array, ColFloat64Array, ColBoolArray, ColStringArray, ColBytesArray, ColDateArray, ColTimestampArray, ColNumericArray, ColJsonArray) " + + "values (@p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11, @p12, @p13, @p14, @p15, @p16, @p17, @p18)") + .bind("p1") + .to((Value) null) + .bind("p2") + .to((Value) null) + .bind("p3") + .to((Value) null) + .bind("p4") + .to((Value) null) + .bind("p5") + .to((Value) null) + .bind("p6") + .to((Value) null) + .bind("p7") + .to((Value) null) + .bind("p8") + .to((Value) null) + .bind("p9") + .to((Value) null) + .bind("p10") + .to((Value) null) + .bind("p11") + .to((Value) null) + .bind("p12") + .to((Value) null) + .bind("p13") + .to((Value) null) + .bind("p14") + .to((Value) null) + .bind("p15") + .to((Value) null) + .bind("p16") + .to((Value) null) + .bind("p17") + .to((Value) null) + .bind("p18") + .to((Value) null) + .build(), + 1L)); + try (Connection connection = createConnection()) { + for (int type : new int[] {Types.OTHER, Types.NULL}) { + try (PreparedStatement statement = + connection.prepareStatement( + "insert into all_nullable_types (" + + "ColInt64, ColFloat64, ColBool, ColString, ColBytes, ColDate, ColTimestamp, ColNumeric, ColJson, " + + "ColInt64Array, ColFloat64Array, ColBoolArray, ColStringArray, ColBytesArray, ColDateArray, ColTimestampArray, ColNumericArray, ColJsonArray) " + + "values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")) { + for (int param = 1; + param <= statement.getParameterMetaData().getParameterCount(); + param++) { + statement.setNull(param, type); + } + assertEquals(1, statement.executeUpdate()); + } + mockSpanner.clearRequests(); + } + } + } } diff --git a/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcDatabaseMetaDataTest.java b/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcDatabaseMetaDataTest.java index ea7c236a4..7e2481a23 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcDatabaseMetaDataTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcDatabaseMetaDataTest.java @@ -512,7 +512,8 @@ private IndexInfo( new IndexInfo("TableWithRef", false, "PRIMARY_KEY", 1, "Id", "A"), new IndexInfo("TableWithRef", true, "FOREIGN_KEY", 1, "RefFloat", "A"), new IndexInfo("TableWithRef", true, "FOREIGN_KEY", 2, "RefString", "A"), - new IndexInfo("TableWithRef", true, "FOREIGN_KEY", 3, "RefDate", "A")); + new IndexInfo("TableWithRef", true, "FOREIGN_KEY", 3, "RefDate", "A"), + new IndexInfo("all_nullable_types", false, "PRIMARY_KEY", 1, "ColInt64", "A")); @Test public void testGetIndexInfo() throws SQLException { @@ -860,7 +861,8 @@ private Table(String name, String type) { new Table("SingersView", "VIEW"), new Table("Songs"), new Table("TableWithAllColumnTypes"), - new Table("TableWithRef")); + new Table("TableWithRef"), + new Table("all_nullable_types")); @Test public void testGetTables() throws SQLException { diff --git a/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPgDatabaseMetaDataTest.java b/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPgDatabaseMetaDataTest.java index bbd37122c..ff0bff7a9 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPgDatabaseMetaDataTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPgDatabaseMetaDataTest.java @@ -420,6 +420,7 @@ private IndexInfo( new IndexInfo("albums", false, "PRIMARY_KEY", 1, "singerid", "A"), new IndexInfo("albums", false, "PRIMARY_KEY", 2, "albumid", "A"), new IndexInfo("albums", true, "albumsbyalbumtitle", 1, "albumtitle", "A"), + new IndexInfo("all_nullable_types", false, "PRIMARY_KEY", 1, "colint64", "A"), new IndexInfo("concerts", false, "PRIMARY_KEY", 1, "venueid", "A"), new IndexInfo("concerts", false, "PRIMARY_KEY", 2, "singerid", "A"), new IndexInfo("concerts", false, "PRIMARY_KEY", 3, "concertdate", "A"), @@ -790,6 +791,7 @@ private Table(String name, String type) { private static final List EXPECTED_TABLES = Arrays.asList( new Table("albums"), + new Table("all_nullable_types"), new Table("concerts"), new Table("singers"), // TODO: Enable when views are supported for PostgreSQL dialect databases. diff --git a/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPreparedStatementTest.java b/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPreparedStatementTest.java index c1d841696..2864559c8 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPreparedStatementTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPreparedStatementTest.java @@ -1294,6 +1294,39 @@ public void test12_InsertReturningTestData() throws SQLException { } } + @Test + public void test13_InsertUntypedNullValues() throws SQLException { + try (Connection connection = createConnection(env, database)) { + try (PreparedStatement preparedStatement = + connection.prepareStatement( + "insert into all_nullable_types (" + + "ColInt64, ColFloat64, ColBool, ColString, ColBytes, ColDate, ColTimestamp, ColNumeric, ColJson, " + + "ColInt64Array, ColFloat64Array, ColBoolArray, ColStringArray, ColBytesArray, ColDateArray, ColTimestampArray, ColNumericArray, ColJsonArray) " + + "values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")) { + for (int param = 1; + param <= preparedStatement.getParameterMetaData().getParameterCount(); + param++) { + preparedStatement.setNull(param, Types.OTHER); + } + if (getDialect() == Dialect.POSTGRESQL) { + // PostgreSQL-dialect databases do not allow NULLs in primary keys. + preparedStatement.setLong(1, 1L); + } + assertEquals(1, preparedStatement.executeUpdate()); + + // Verify that calling preparedStatement.setObject(index, null) works. + for (int param = 1; + param <= preparedStatement.getParameterMetaData().getParameterCount(); + param++) { + preparedStatement.setObject(param, null); + } + // We need a different primary key value to insert another row. + preparedStatement.setLong(1, 2L); + assertEquals(1, preparedStatement.executeUpdate()); + } + } + } + private List readValuesFromFile(String filename) { StringBuilder builder = new StringBuilder(); try (InputStream stream = ITJdbcPreparedStatementTest.class.getResourceAsStream(filename)) { diff --git a/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables.sql b/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables.sql index 24579b272..153dd773d 100644 --- a/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables.sql +++ b/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables.sql @@ -97,6 +97,29 @@ CREATE TABLE TableWithAllColumnTypes ( ) PRIMARY KEY (ColInt64) ; +CREATE TABLE all_nullable_types ( + ColInt64 INT64, + ColFloat64 FLOAT64, + ColBool BOOL, + ColString STRING(100), + ColBytes BYTES(100), + ColDate DATE, + ColTimestamp TIMESTAMP, + ColNumeric NUMERIC, + ColJson JSON, + + ColInt64Array ARRAY, + ColFloat64Array ARRAY, + ColBoolArray ARRAY, + ColStringArray ARRAY, + ColBytesArray ARRAY, + ColDateArray ARRAY, + ColTimestampArray ARRAY, + ColNumericArray ARRAY, + ColJsonArray ARRAY, +) PRIMARY KEY (ColInt64) +; + CREATE TABLE TableWithRef ( Id INT64 NOT NULL, RefFloat FLOAT64 NOT NULL, diff --git a/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables_Emulator.sql b/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables_Emulator.sql index aaba70c19..6aa8e843c 100644 --- a/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables_Emulator.sql +++ b/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables_Emulator.sql @@ -92,6 +92,29 @@ CREATE TABLE TableWithAllColumnTypes ( ) PRIMARY KEY (ColInt64) ; +CREATE TABLE all_nullable_types ( + ColInt64 INT64, + ColFloat64 FLOAT64, + ColBool BOOL, + ColString STRING(100), + ColBytes BYTES(100), + ColDate DATE, + ColTimestamp TIMESTAMP, + ColNumeric NUMERIC, + ColJson JSON, + + ColInt64Array ARRAY, + ColFloat64Array ARRAY, + ColBoolArray ARRAY, + ColStringArray ARRAY, + ColBytesArray ARRAY, + ColDateArray ARRAY, + ColTimestampArray ARRAY, + ColNumericArray ARRAY, + ColJsonArray ARRAY, +) PRIMARY KEY (ColInt64) +; + CREATE TABLE TableWithRef ( Id INT64 NOT NULL, RefFloat FLOAT64 NOT NULL, diff --git a/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables_PG.sql b/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables_PG.sql index 438d2d5df..c77968b45 100644 --- a/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables_PG.sql +++ b/src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables_PG.sql @@ -74,6 +74,27 @@ CREATE TABLE TableWithAllColumnTypes ( ColNumeric NUMERIC NOT NULL, ColJson VARCHAR NOT NULL ); +CREATE TABLE all_nullable_types ( + ColInt64 bigint primary key, + ColFloat64 float8, + ColBool boolean, + ColString varchar(100), + ColBytes bytea, + ColDate date, + ColTimestamp timestamptz, + ColNumeric numeric, + ColJson jsonb, + + ColInt64Array bigint[], + ColFloat64Array float8[], + ColBoolArray boolean[], + ColStringArray varchar(100)[], + ColBytesArray bytea[], + ColDateArray date[], + ColTimestampArray timestamptz[], + ColNumericArray numeric[], + ColJsonArray jsonb[] +); CREATE TABLE TableWithRef ( Id BIGINT NOT NULL PRIMARY KEY, diff --git a/versions.txt b/versions.txt index 363915b72..bee319b9a 100644 --- a/versions.txt +++ b/versions.txt @@ -1,4 +1,4 @@ # Format: # module:released-version:current-version -google-cloud-spanner-jdbc:2.10.0:2.10.0 +google-cloud-spanner-jdbc:2.11.0:2.11.0