From 963e8d31d9b8435816b28731cea656851b4c3e3e Mon Sep 17 00:00:00 2001 From: Thomas Segismont Date: Mon, 6 Jul 2026 17:59:11 +0200 Subject: [PATCH] JSON support for MSSQL Closes #1680 Signed-off-by: Thomas Segismont --- .../src/main/asciidoc/index.adoc | 29 +++ .../java/examples/MSSQLClientExamples.java | 40 ++++ .../io/vertx/mssqlclient/impl/MSSQLRow.java | 8 +- .../mssqlclient/impl/codec/DataType.java | 41 +++- .../impl/codec/InitMSSQLCommandMessage.java | 23 ++- .../impl/codec/MSSQLCommandMessage.java | 16 +- .../protocol/client/login/LoginPacket.java | 3 +- .../data/MSSQLJsonDataTypeTest.java | 195 ++++++++++++++++++ .../tests/mssqlclient/junit/MSSQLRule.java | 4 + 9 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/data/MSSQLJsonDataTypeTest.java diff --git a/vertx-mssql-client/src/main/asciidoc/index.adoc b/vertx-mssql-client/src/main/asciidoc/index.adoc index 22fff170e..5f4ebd847 100644 --- a/vertx-mssql-client/src/main/asciidoc/index.adoc +++ b/vertx-mssql-client/src/main/asciidoc/index.adoc @@ -250,6 +250,10 @@ Currently the client supports the following SQL Server types: | `GUID` | `java.util.UUID` | + +| `json` +| `io.vertx.core.json.JsonObject` / `io.vertx.core.json.JsonArray` +| Native JSON type (SQL Server 2025+). See <> |=== Tuple decoding uses the above types when storing values. @@ -290,6 +294,31 @@ Otherwise, you should declare the type explicitly using one of the {@link io.ver {@link examples.MSSQLClientExamples#explicitNullHandling} ---- +[[handling-json]] +=== Handling JSON + +SQL Server 2025 introduces a native `json` data type for storing JSON documents. + +IMPORTANT: The native `json` type requires SQL Server 2025 or later and only supports JSON objects and arrays. +Scalars, booleans, and the JSON null literal are not supported. + +The `json` data type is represented by the following Java types: + +- `io.vertx.core.json.JsonObject` +- `io.vertx.core.json.JsonArray` + +[source,$lang] +---- +{@link examples.MSSQLClientExamples#jsonExample(io.vertx.sqlclient.SqlClient)} +---- + +[source,$lang] +---- +{@link examples.MSSQLClientExamples#jsonArrayExample(io.vertx.sqlclient.SqlClient)} +---- + +NOTE: Attempting to insert scalar values, booleans, or {@link io.vertx.sqlclient.Tuple#JSON_NULL} will result in an error. + == Collector queries You can use Java collectors with the query API: diff --git a/vertx-mssql-client/src/main/java/examples/MSSQLClientExamples.java b/vertx-mssql-client/src/main/java/examples/MSSQLClientExamples.java index 9038ad147..c3784f528 100644 --- a/vertx-mssql-client/src/main/java/examples/MSSQLClientExamples.java +++ b/vertx-mssql-client/src/main/java/examples/MSSQLClientExamples.java @@ -12,6 +12,8 @@ package examples; import io.vertx.core.Vertx; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; import io.vertx.core.net.ClientSSLOptions; import io.vertx.core.net.PemTrustOptions; import io.vertx.docgen.Source; @@ -353,4 +355,42 @@ public void infoHandler(MSSQLConnection connection) { System.out.println("Received info " + info.getSeverity() + "" + info.getMessage()); }); } + + public void jsonExample(SqlClient client) { + JsonObject json = new JsonObject() + .put("name", "Alice") + .put("age", 30); + + client + .preparedQuery("INSERT INTO users (id, data) VALUES (@p1, @p2)") + .execute(Tuple.of(1, json)) + .compose(v -> client + .preparedQuery("SELECT data FROM users WHERE id = @p1") + .execute(Tuple.of(1))) + .onComplete(ar -> { + if (ar.succeeded()) { + Row row = ar.result().iterator().next(); + JsonObject data = row.getJsonObject(0); + System.out.println("Received: " + data.getString("name")); + } + }); + } + + public void jsonArrayExample(SqlClient client) { + JsonArray tags = new JsonArray().add("admin").add("user"); + + client + .preparedQuery("INSERT INTO users (id, data) VALUES (@p1, @p2)") + .execute(Tuple.of(2, tags)) + .compose(v -> client + .preparedQuery("SELECT data FROM users WHERE id = @p1") + .execute(Tuple.of(2))) + .onComplete(ar -> { + if (ar.succeeded()) { + Row row = ar.result().iterator().next(); + JsonArray data = row.getJsonArray(0); + System.out.println("Received: " + data); + } + }); + } } diff --git a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/MSSQLRow.java b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/MSSQLRow.java index bcd03e4c2..3bf2b842e 100644 --- a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/MSSQLRow.java +++ b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/MSSQLRow.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2021 Contributors to the Eclipse Foundation + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at @@ -12,6 +12,8 @@ package io.vertx.mssqlclient.impl; import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; import io.vertx.sqlclient.impl.RowBase; import io.vertx.sqlclient.internal.RowDescriptorBase; @@ -59,6 +61,10 @@ public T get(Class type, int position) { return type.cast(getOffsetDateTime(position)); } else if (type == Buffer.class) { return type.cast(getBuffer(position)); + } else if (type == JsonObject.class) { + return type.cast(getJsonObject(position)); + } else if (type == JsonArray.class) { + return type.cast(getJsonArray(position)); } else if (type == UUID.class) { return type.cast(getValue(position)); } else if (type == Object.class) { diff --git a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/DataType.java b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/DataType.java index 9c2ff6c33..de0b20cb4 100644 --- a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/DataType.java +++ b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/DataType.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2022 Contributors to the Eclipse Foundation + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at @@ -18,6 +18,9 @@ import io.netty.util.collection.IntObjectMap; import io.vertx.core.buffer.Buffer; import io.vertx.core.internal.buffer.BufferInternal; +import io.vertx.core.json.Json; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; import java.math.BigDecimal; import java.math.BigInteger; @@ -969,6 +972,40 @@ public Object decodeValue(ByteBuf byteBuf, TypeInfo typeInfo) { return result; } }, + JSON(0xF4) { + @Override + public TypeInfo decodeTypeInfo(ByteBuf byteBuf) { + return new TypeInfo().maxLength(0xFFFF); + } + + @Override + public JDBCType jdbcType(TypeInfo typeInfo) { + return JDBCType.OTHER; + } + + @Override + public Object decodeValue(ByteBuf byteBuf, TypeInfo typeInfo) { + long payloadLength = byteBuf.readLongLE(); + if (isPLPNull(payloadLength)) { + return null; + } + String jsonString = readPLP(byteBuf).toString(StandardCharsets.UTF_16LE); + return Json.decodeValue(jsonString); + } + + @Override + public String paramDefinition(Object value) { + // SQL Server expects text for JSON params + return NVARCHAR.paramDefinition(value); + } + + @Override + public void encodeParam(ByteBuf byteBuf, String name, boolean out, Object value) { + // SQL Server expects text for JSON params + NVARCHAR.encodeParam(byteBuf, name, out, value); + } + }, + SSVARIANT(0x62) { @Override public TypeInfo decodeTypeInfo(ByteBuf byteBuf) { @@ -1108,6 +1145,8 @@ public void encodeParam(ByteBuf byteBuf, String name, boolean out, Object value) typesByValueClass.put(OffsetDateTime.class, DATETIMEOFFSETN); typesByValueClass.put(UUID.class, GUID); typesByValueClass.put(Buffer.class, BIGVARBINARY); + typesByValueClass.put(JsonObject.class, JSON); + typesByValueClass.put(JsonArray.class, JSON); } public static DataType forId(int id) { diff --git a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/InitMSSQLCommandMessage.java b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/InitMSSQLCommandMessage.java index e3bee07a9..86ecddc17 100644 --- a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/InitMSSQLCommandMessage.java +++ b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/InitMSSQLCommandMessage.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2021 Contributors to the Eclipse Foundation + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at @@ -56,7 +56,7 @@ void encode() { LoginPacket.OPTION_FLAGS2_ODBC_ON ); // OptionFlags2 content.writeByte(LoginPacket.DEFAULT_TYPE_FLAGS); // TypeFlags - content.writeByte(LoginPacket.DEFAULT_OPTION_FLAGS3); // OptionFlags3 + content.writeByte(LoginPacket.DEFAULT_OPTION_FLAGS3 | LoginPacket.OPTION_FLAGS3_EXTENSION); // OptionFlags3 content.writeZero(8); // ClientTimeZone + ClientLCID /* @@ -98,9 +98,10 @@ void encode() { content.writeZero(2); // offset content.writeShortLE(serverName.length()); - // Unused or Extension - int unusedOffsetLengthIdx = content.writerIndex(); - content.writeZero(4); + // Extension + int extensionOffsetLengthIdx = content.writerIndex(); + content.writeZero(2); // offset + content.writeShortLE(4); // CltIntName CharSequence interfaceLibraryName = properties.get("clientInterfaceName"); @@ -158,7 +159,9 @@ void encode() { content.setShortLE(serverNameOffsetLengthIdx, content.writerIndex() - startIdx); content.writeCharSequence(serverName, UTF_16LE); - content.setShortLE(unusedOffsetLengthIdx, content.writerIndex() - startIdx); + content.setShortLE(extensionOffsetLengthIdx, content.writerIndex() - startIdx); + int featureExtOffsetIdx = content.writerIndex(); + content.writeIntLE(0); content.setShortLE(cltIntNameOffsetLengthIdx, content.writerIndex() - startIdx); content.writeCharSequence(interfaceLibraryName, UTF_16LE); @@ -174,6 +177,14 @@ void encode() { content.setShortLE(changePasswordOffsetLengthIdx, content.writerIndex() - startIdx); + content.setIntLE(featureExtOffsetIdx, content.writerIndex() - startIdx); + // TDS_FEATURE_EXT_JSONSUPPORT + content.writeByte(0x0D); // feature ID + content.writeIntLE(1); // data length + content.writeByte(0x01); // JSON support version 1 + // FEATUREEXT terminator + content.writeByte(0xFF); + // set length content.setIntLE(startIdx, content.writerIndex() - startIdx); diff --git a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/MSSQLCommandMessage.java b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/MSSQLCommandMessage.java index 5de708baa..d101d7515 100644 --- a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/MSSQLCommandMessage.java +++ b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/codec/MSSQLCommandMessage.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2024 Contributors to the Eclipse Foundation + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at @@ -73,6 +73,9 @@ void decode(ByteBuf payload) { payload.skipBytes(payload.readUnsignedShortLE()); handleLoginAck(); break; + case FEATUREEXTACK: + handleFeatureExtAck(payload); + break; case COLMETADATA: handleColumnMetadata(payload); break; @@ -129,6 +132,17 @@ protected void handleInfo(ByteBuf payload) { tdsMessageCodec.chctx().fireChannelRead(info); } + private void handleFeatureExtAck(ByteBuf payload) { + while (payload.isReadable()) { + short featureId = payload.readUnsignedByte(); + if (featureId == (0xFF)) { // no more features + break; + } + // We don't keep track of supported features (e.g. JSON) + payload.skipBytes((int) payload.readUnsignedIntLE()); + } + } + protected void handleLoginAck() { } diff --git a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/protocol/client/login/LoginPacket.java b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/protocol/client/login/LoginPacket.java index ddc7b32c9..80f76165a 100644 --- a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/protocol/client/login/LoginPacket.java +++ b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/protocol/client/login/LoginPacket.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2021 Contributors to the Eclipse Foundation + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at @@ -90,6 +90,7 @@ public final class LoginPacket { 3FRESERVEDBIT */ public static final byte DEFAULT_OPTION_FLAGS3 = 0x00; + public static final byte OPTION_FLAGS3_EXTENSION = 0x10; private byte typeFlags = 0x00; diff --git a/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/data/MSSQLJsonDataTypeTest.java b/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/data/MSSQLJsonDataTypeTest.java new file mode 100644 index 000000000..a0759e5dd --- /dev/null +++ b/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/data/MSSQLJsonDataTypeTest.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.tests.mssqlclient.data; + +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mssqlclient.MSSQLConnectOptions; +import io.vertx.mssqlclient.MSSQLConnection; +import io.vertx.mssqlclient.MSSQLException; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.Tuple; +import io.vertx.tests.mssqlclient.MSSQLTestBase; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assume.assumeTrue; + +@RunWith(VertxUnitRunner.class) +public class MSSQLJsonDataTypeTest extends MSSQLTestBase { + + private Vertx vertx; + private MSSQLConnectOptions connectOptions; + + @BeforeClass + public static void beforeClass() { + assumeTrue("JSON type requires SQL Server 2025+", rule.supportsJsonType()); + } + + @Before + public void setUp(TestContext ctx) { + vertx = Vertx.vertx(); + connectOptions = new MSSQLConnectOptions(MSSQLTestBase.options); + Async async = ctx.async(); + MSSQLConnection.connect(vertx, connectOptions).onComplete(ctx.asyncAssertSuccess(conn -> { + conn.query("DROP TABLE IF EXISTS json_test").execute() + .compose(v -> conn.query("CREATE TABLE json_test (id INT PRIMARY KEY, data json)").execute()) + .onComplete(ctx.asyncAssertSuccess(v -> { + conn.close(); + async.complete(); + })); + })); + } + + @After + public void tearDown(TestContext ctx) { + MSSQLConnection.connect(vertx, connectOptions).onComplete(ctx.asyncAssertSuccess(conn -> { + conn.query("DROP TABLE IF EXISTS json_test").execute() + .onComplete(ctx.asyncAssertSuccess(v -> { + conn.close(); + vertx.close().onComplete(ctx.asyncAssertSuccess()); + })); + })); + } + + @Test + public void testDecodeJsonObject(TestContext ctx) { + JsonObject expected = new JsonObject() + .put("name", "Alice") + .put("age", 30); + MSSQLConnection.connect(vertx, connectOptions).onComplete(ctx.asyncAssertSuccess(conn -> { + conn.preparedQuery("INSERT INTO json_test (id, data) VALUES (@p1, @p2)") + .execute(Tuple.of(1, expected)) + .compose(v -> conn.preparedQuery("SELECT data FROM json_test WHERE id = @p1").execute(Tuple.of(1))) + .onComplete(ctx.asyncAssertSuccess(rows -> { + ctx.assertEquals(1, rows.size()); + Row row = rows.iterator().next(); + ctx.assertEquals(expected, row.getJson(0)); + ctx.assertEquals(expected, row.getJsonObject(0)); + conn.close(); + })); + })); + } + + @Test + public void testDecodeJsonArray(TestContext ctx) { + JsonArray expected = new JsonArray().add(1).add(2).add(3); + MSSQLConnection.connect(vertx, connectOptions).onComplete(ctx.asyncAssertSuccess(conn -> { + conn.preparedQuery("INSERT INTO json_test (id, data) VALUES (@p1, @p2)") + .execute(Tuple.of(2, expected)) + .compose(v -> conn.preparedQuery("SELECT data FROM json_test WHERE id = @p1").execute(Tuple.of(2))) + .onComplete(ctx.asyncAssertSuccess(rows -> { + ctx.assertEquals(1, rows.size()); + Row row = rows.iterator().next(); + ctx.assertEquals(expected, row.getJson(0)); + ctx.assertEquals(expected, row.getJsonArray(0)); + conn.close(); + })); + })); + } + + @Test + public void testDecodeSqlNull(TestContext ctx) { + MSSQLConnection.connect(vertx, connectOptions).onComplete(ctx.asyncAssertSuccess(conn -> { + conn.preparedQuery("INSERT INTO json_test (id, data) VALUES (@p1, @p2)") + .execute(Tuple.of(3, (Object) null)) + .compose(v -> conn.preparedQuery("SELECT data FROM json_test WHERE id = @p1").execute(Tuple.of(3))) + .onComplete(ctx.asyncAssertSuccess(rows -> { + ctx.assertEquals(1, rows.size()); + Row row = rows.iterator().next(); + ctx.assertNull(row.getJson(0)); + ctx.assertNull(row.getJsonObject(0)); + ctx.assertNull(row.getJsonArray(0)); + conn.close(); + })); + })); + } + + @Test + public void testDecodeNestedJsonObject(TestContext ctx) { + JsonObject expected = new JsonObject() + .put("address", new JsonObject() + .put("city", "New York") + .put("zip", "10001")) + .put("tags", new JsonArray().add("admin").add("user")); + MSSQLConnection.connect(vertx, connectOptions).onComplete(ctx.asyncAssertSuccess(conn -> { + conn.preparedQuery("INSERT INTO json_test (id, data) VALUES (@p1, @p2)") + .execute(Tuple.of(4, expected)) + .compose(v -> conn.preparedQuery("SELECT data FROM json_test WHERE id = @p1").execute(Tuple.of(4))) + .onComplete(ctx.asyncAssertSuccess(rows -> { + ctx.assertEquals(1, rows.size()); + Row row = rows.iterator().next(); + ctx.assertEquals(expected, row.getJsonObject(0)); + conn.close(); + })); + })); + } + + @Test + public void testScalarRejection(TestContext ctx) { + MSSQLConnection.connect(vertx, connectOptions).onComplete(ctx.asyncAssertSuccess(conn -> { + conn.preparedQuery("INSERT INTO json_test (id, data) VALUES (@p1, @p2)") + .execute(Tuple.of(5, 42)) + .onComplete(ctx.asyncAssertFailure(err -> { + conn.close(); + if (err instanceof MSSQLException) { + MSSQLException exception = (MSSQLException) err; + ctx.assertEquals("S0002", exception.getSqlState()); + ctx.assertEquals(206, exception.getErrorCode()); + } else { + ctx.fail(err); + } + })); + })); + } + + @Test + public void testDecodeUnicode(TestContext ctx) { + JsonObject expected = new JsonObject() + .put("emoji", "😀") + .put("japanese", "フレームワーク") + .put("arabic", "مرحبا"); + MSSQLConnection.connect(vertx, connectOptions).onComplete(ctx.asyncAssertSuccess(conn -> { + conn.preparedQuery("INSERT INTO json_test (id, data) VALUES (@p1, @p2)") + .execute(Tuple.of(7, expected)) + .compose(v -> conn.preparedQuery("SELECT data FROM json_test WHERE id = @p1").execute(Tuple.of(7))) + .onComplete(ctx.asyncAssertSuccess(rows -> { + ctx.assertEquals(1, rows.size()); + Row row = rows.iterator().next(); + ctx.assertEquals(expected, row.getJsonObject(0)); + conn.close(); + })); + })); + } + + @Test + public void testSimpleQueryDecodeJsonObject(TestContext ctx) { + JsonObject expected = new JsonObject().put("key", "value"); + MSSQLConnection.connect(vertx, connectOptions).onComplete(ctx.asyncAssertSuccess(conn -> { + conn.preparedQuery("INSERT INTO json_test (id, data) VALUES (@p1, @p2)") + .execute(Tuple.of(6, expected)) + .compose(v -> conn.query("SELECT data FROM json_test WHERE id = 6").execute()) + .onComplete(ctx.asyncAssertSuccess(rows -> { + ctx.assertEquals(1, rows.size()); + Row row = rows.iterator().next(); + ctx.assertEquals(expected, row.getJsonObject(0)); + conn.close(); + })); + })); + } +} diff --git a/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/junit/MSSQLRule.java b/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/junit/MSSQLRule.java index 38d0143f9..fb08fd393 100644 --- a/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/junit/MSSQLRule.java +++ b/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/junit/MSSQLRule.java @@ -242,6 +242,10 @@ public MSSQLConnectOptions options() { return new MSSQLConnectOptions(options); } + public boolean supportsJsonType() { + return serverVersion == null || serverVersion == ServerVersion.MSSQL_2025; + } + private static class ServerContainer> extends GenericContainer { public ServerContainer(String dockerImageName) {