You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by is...@apache.org on 2022/12/13 10:33:54 UTC

[ignite-3] branch main updated: IGNITE-17588 SQL API for C++ Client (#1440)

This is an automated email from the ASF dual-hosted git repository.

isapego pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git


The following commit(s) were added to refs/heads/main by this push:
     new e09b7fbee5 IGNITE-17588 SQL API for C++ Client (#1440)
e09b7fbee5 is described below

commit e09b7fbee56ce19bf5d3b004d6038a9ef40ba752
Author: Igor Sapego <is...@apache.org>
AuthorDate: Tue Dec 13 13:33:48 2022 +0300

    IGNITE-17588 SQL API for C++ Client (#1440)
---
 .../ignite/internal/client/proto/ClientOp.java     |   2 +-
 .../requests/sql/ClientSqlExecuteRequest.java      |   9 +-
 .../internal/client/sql/ClientColumnMetadata.java  |   6 +
 modules/platforms/cpp/ignite/client/CMakeLists.txt |   5 +
 .../cpp/ignite/client/detail/client_operation.h    |   9 +
 .../ignite/client/detail/cluster_connection.cpp    |   2 +-
 .../cpp/ignite/client/detail/cluster_connection.h  |  60 +++-
 .../cpp/ignite/client/detail/ignite_client_impl.h  |  14 +-
 .../cpp/ignite/client/detail/node_connection.cpp   |   5 +-
 .../cpp/ignite/client/detail/node_connection.h     |  42 ++-
 .../cpp/ignite/client/detail/response_handler.h    | 102 ++++++-
 .../cpp/ignite/client/detail/sql/result_set_impl.h | 338 +++++++++++++++++++++
 .../cpp/ignite/client/detail/sql/sql_impl.cpp      |  95 ++++++
 .../cpp/ignite/client/detail/sql/sql_impl.h        |  71 +++++
 .../cpp/ignite/client/detail/table/table_impl.cpp  |  59 +---
 .../platforms/cpp/ignite/client/detail/utils.cpp   | 251 +++++++++++++++
 modules/platforms/cpp/ignite/client/detail/utils.h |  74 +++++
 .../platforms/cpp/ignite/client/ignite_client.cpp  |   4 +
 .../platforms/cpp/ignite/client/ignite_client.h    |  28 +-
 modules/platforms/cpp/ignite/client/primitive.h    | 184 +++++++++++
 .../cpp/ignite/client/sql/column_metadata.h        | 125 ++++++++
 .../cpp/ignite/client/sql/column_origin.h          |  76 +++++
 .../platforms/cpp/ignite/client/sql/result_set.cpp |  59 ++++
 .../platforms/cpp/ignite/client/sql/result_set.h   | 134 ++++++++
 .../cpp/ignite/client/sql/result_set_metadata.h    |  78 +++++
 .../client/sql/sql.cpp}                            |  30 +-
 modules/platforms/cpp/ignite/client/sql/sql.h      |  83 +++++
 .../cpp/ignite/client/sql/sql_column_type.h        |  87 ++++++
 .../cpp/ignite/client/sql/sql_statement.h          | 152 +++++++++
 .../cpp/ignite/client/table/ignite_tuple.h         |  29 +-
 .../platforms/cpp/ignite/client/table/tables.cpp   |   6 +-
 modules/platforms/cpp/ignite/client/table/tables.h |   7 -
 modules/platforms/cpp/ignite/protocol/reader.cpp   |  11 +-
 modules/platforms/cpp/ignite/protocol/reader.h     |  49 ++-
 modules/platforms/cpp/ignite/protocol/utils.cpp    |  63 +++-
 modules/platforms/cpp/ignite/protocol/utils.h      |  48 +++
 .../cpp/ignite/schema/binary_tuple_builder.h       |   6 +-
 modules/platforms/cpp/ignite/schema/tuple_test.cpp |   2 +-
 .../platforms/cpp/tests/client-test/CMakeLists.txt |   1 +
 .../cpp/tests/client-test/ignite_client_test.cpp   |   2 +-
 .../cpp/tests/client-test/ignite_runner_suite.h    |   1 +
 .../tests/client-test/record_binary_view_test.cpp  |   4 +-
 .../platforms/cpp/tests/client-test/sql_test.cpp   | 323 ++++++++++++++++++++
 .../cpp/tests/client-test/tables_test.cpp          |  10 +-
 .../dotnet/Apache.Ignite.Tests/FakeServer.cs       |   3 +
 .../dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs |   5 +
 46 files changed, 2568 insertions(+), 186 deletions(-)

diff --git a/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientOp.java b/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientOp.java
index 35f01e0cd2..29159cb9c3 100644
--- a/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientOp.java
+++ b/modules/client-common/src/main/java/org/apache/ignite/internal/client/proto/ClientOp.java
@@ -138,6 +138,6 @@ public class ClientOp {
     /** Close cursor. */
     public static final int SQL_CURSOR_CLOSE = 52;
 
-    /** Close cursor. */
+    /** Get partition assignment. */
     public static final int PARTITION_ASSIGNMENT_GET = 53;
 }
diff --git a/modules/client-handler/src/main/java/org/apache/ignite/client/handler/requests/sql/ClientSqlExecuteRequest.java b/modules/client-handler/src/main/java/org/apache/ignite/client/handler/requests/sql/ClientSqlExecuteRequest.java
index f193d4928a..cab1b9f98e 100644
--- a/modules/client-handler/src/main/java/org/apache/ignite/client/handler/requests/sql/ClientSqlExecuteRequest.java
+++ b/modules/client-handler/src/main/java/org/apache/ignite/client/handler/requests/sql/ClientSqlExecuteRequest.java
@@ -45,6 +45,7 @@ import org.apache.ignite.sql.Session.SessionBuilder;
 import org.apache.ignite.sql.Statement;
 import org.apache.ignite.sql.Statement.StatementBuilder;
 import org.apache.ignite.sql.async.AsyncResultSet;
+import org.jetbrains.annotations.Nullable;
 
 /**
  * Client SQL execute request.
@@ -160,7 +161,7 @@ public class ClientSqlExecuteRequest {
         return sessionBuilder.build();
     }
 
-    private static void packMeta(ClientMessagePacker out, ResultSetMetadata meta) {
+    private static void packMeta(ClientMessagePacker out, @Nullable ResultSetMetadata meta) {
         // TODO IGNITE-17179 metadata caching - avoid sending same meta over and over.
         if (meta == null || meta.columns() == null) {
             out.packArrayHeader(0);
@@ -178,6 +179,10 @@ public class ClientSqlExecuteRequest {
 
         for (int i = 0; i < cols.size(); i++) {
             ColumnMetadata col = cols.get(i);
+            ColumnOrigin origin = col.origin();
+
+            int fieldsNum = origin == null ? 6 : 9;
+            out.packArrayHeader(fieldsNum);
 
             out.packString(col.name());
             out.packBoolean(col.nullable());
@@ -185,8 +190,6 @@ public class ClientSqlExecuteRequest {
             out.packInt(col.scale());
             out.packInt(col.precision());
 
-            ColumnOrigin origin = col.origin();
-
             if (origin == null) {
                 out.packBoolean(false);
                 continue;
diff --git a/modules/client/src/main/java/org/apache/ignite/internal/client/sql/ClientColumnMetadata.java b/modules/client/src/main/java/org/apache/ignite/internal/client/sql/ClientColumnMetadata.java
index c2bd33de42..e70bcd5984 100644
--- a/modules/client/src/main/java/org/apache/ignite/internal/client/sql/ClientColumnMetadata.java
+++ b/modules/client/src/main/java/org/apache/ignite/internal/client/sql/ClientColumnMetadata.java
@@ -52,6 +52,10 @@ public class ClientColumnMetadata implements ColumnMetadata {
      * @param prevColumns Previous columns.
      */
     public ClientColumnMetadata(ClientMessageUnpacker unpacker, List<ColumnMetadata> prevColumns) {
+        var propCnt = unpacker.unpackArrayHeader();
+
+        assert propCnt >= 6;
+
         name = unpacker.unpackString();
         nullable = unpacker.unpackBoolean();
         type = ClientSqlColumnTypeConverter.ordinalToColumnType(unpacker.unpackInt());
@@ -59,6 +63,8 @@ public class ClientColumnMetadata implements ColumnMetadata {
         precision = unpacker.unpackInt();
 
         if (unpacker.unpackBoolean()) {
+            assert propCnt >= 9;
+
             origin = new ClientColumnOrigin(unpacker, name, prevColumns);
         } else {
             origin = null;
diff --git a/modules/platforms/cpp/ignite/client/CMakeLists.txt b/modules/platforms/cpp/ignite/client/CMakeLists.txt
index 23147130c2..091873c64a 100644
--- a/modules/platforms/cpp/ignite/client/CMakeLists.txt
+++ b/modules/platforms/cpp/ignite/client/CMakeLists.txt
@@ -21,11 +21,15 @@ set(TARGET ${PROJECT_NAME})
 
 set(SOURCES
     ignite_client.cpp
+    sql/sql.cpp
+    sql/result_set.cpp
     table/record_view.cpp
     table/table.cpp
     table/tables.cpp
     detail/cluster_connection.cpp
+    detail/utils.cpp
     detail/node_connection.cpp
+    detail/sql/sql_impl.cpp
     detail/table/table_impl.cpp
     detail/table/tables_impl.cpp
 )
@@ -34,6 +38,7 @@ set(PUBLIC_HEADERS
     ignite_client.h
     ignite_client_configuration.h
     ignite_logger.h
+    sql/sql.h
     table/ignite_tuple.h
     table/record_view.h
     table/table.h
diff --git a/modules/platforms/cpp/ignite/client/detail/client_operation.h b/modules/platforms/cpp/ignite/client/detail/client_operation.h
index fa72a4019d..030cb1dedc 100644
--- a/modules/platforms/cpp/ignite/client/detail/client_operation.h
+++ b/modules/platforms/cpp/ignite/client/detail/client_operation.h
@@ -76,6 +76,15 @@ enum class client_operation {
 
     /** Get and delete tuple. */
     TUPLE_GET_AND_DELETE = 32,
+
+    /** Execute SQL query. */
+    SQL_EXEC = 50,
+
+    /** Get next page. */
+    SQL_CURSOR_NEXT_PAGE = 51,
+
+    /** Close cursor. */
+    SQL_CURSOR_CLOSE = 52,
 };
 
 /**
diff --git a/modules/platforms/cpp/ignite/client/detail/cluster_connection.cpp b/modules/platforms/cpp/ignite/client/detail/cluster_connection.cpp
index 531c1cc7dd..2229b2935e 100644
--- a/modules/platforms/cpp/ignite/client/detail/cluster_connection.cpp
+++ b/modules/platforms/cpp/ignite/client/detail/cluster_connection.cpp
@@ -75,7 +75,7 @@ void cluster_connection::on_connection_success(const network::end_point &addr, u
     m_logger->log_info("Established connection with remote host " + addr.to_string());
     m_logger->log_debug("Connection ID: " + std::to_string(id));
 
-    auto connection = std::make_shared<node_connection>(id, m_pool, m_logger);
+    auto connection = node_connection::make_new(id, m_pool, m_logger);
     {
         [[maybe_unused]] std::unique_lock<std::recursive_mutex> lock(m_connections_mutex);
 
diff --git a/modules/platforms/cpp/ignite/client/detail/cluster_connection.h b/modules/platforms/cpp/ignite/client/detail/cluster_connection.h
index 315fed5a5c..3f143e372d 100644
--- a/modules/platforms/cpp/ignite/client/detail/cluster_connection.h
+++ b/modules/platforms/cpp/ignite/client/detail/cluster_connection.h
@@ -89,19 +89,18 @@ public:
     void stop();
 
     /**
-     * Perform request.
+     * Perform request raw.
      *
      * @tparam T Result type.
      * @param op Operation code.
      * @param wr Request writer function.
-     * @param rd Response reader function.
-     * @param callback Callback to call on result.
+     * @param handler Request handler.
+     * @return Channel used for the request.
      */
     template<typename T>
-    void perform_request(client_operation op, const std::function<void(protocol::writer &)> &wr,
-        std::function<T(protocol::reader &)> rd, ignite_callback<T> callback) {
-        auto handler = std::make_shared<response_handler_impl<T>>(std::move(rd), std::move(callback));
-
+    void perform_request_handler(client_operation op,
+        const std::function<void(protocol::writer &)> &wr, const std::shared_ptr<response_handler>& handler)
+    {
         while (true) {
             auto channel = get_random_channel();
             if (!channel)
@@ -113,6 +112,42 @@ public:
         }
     }
 
+    /**
+     * Perform request raw.
+     *
+     * @tparam T Result type.
+     * @param op Operation code.
+     * @param wr Request writer function.
+     * @param rd Response reader function.
+     * @param callback Callback to call on result.
+     * @return Channel used for the request.
+     */
+    template<typename T>
+    void perform_request_raw(client_operation op, const std::function<void(protocol::writer &)> &wr,
+        std::function<T(std::shared_ptr<node_connection>, bytes_view)> rd, ignite_callback<T> callback)
+    {
+        auto handler = std::make_shared<response_handler_bytes<T>>(std::move(rd), std::move(callback));
+        perform_request_handler<T>(op, wr, std::move(handler));
+    }
+
+    /**
+     * Perform request.
+     *
+     * @tparam T Result type.
+     * @param op Operation code.
+     * @param wr Request writer function.
+     * @param rd Response reader function.
+     * @param callback Callback to call on result.
+     * @return Channel used for the request.
+     */
+    template<typename T>
+    void perform_request(client_operation op, const std::function<void(protocol::writer &)> &wr,
+        std::function<T(protocol::reader &)> rd, ignite_callback<T> callback)
+    {
+        auto handler = std::make_shared<response_handler_reader<T>>(std::move(rd), std::move(callback));
+        perform_request_handler<T>(op, wr, std::move(handler));
+    }
+
     /**
      * Perform request without input data.
      *
@@ -120,11 +155,12 @@ public:
      * @param op Operation code.
      * @param rd Response reader function.
      * @param callback Callback to call on result.
+     * @return Channel used for the request.
      */
     template<typename T>
-    void perform_request_rd(client_operation op, std::function<T(protocol::reader &)> rd, ignite_callback<T> callback) {
-        perform_request<T>(
-            op, [](protocol::writer &) {}, std::move(rd), std::move(callback));
+    void perform_request_rd(
+        client_operation op, std::function<T(protocol::reader &)> rd, ignite_callback<T> callback) {
+        perform_request<T>(op, [](protocol::writer &) {}, std::move(rd), std::move(callback));
     }
 
     /**
@@ -134,12 +170,12 @@ public:
      * @param op Operation code.
      * @param wr Request writer function.
      * @param callback Callback to call on result.
+     * @return Channel used for the request.
      */
     template<typename T>
     void perform_request_wr(
         client_operation op, const std::function<void(protocol::writer &)> &wr, ignite_callback<T> callback) {
-        perform_request<T>(
-            op, wr, [](protocol::reader &) {}, std::move(callback));
+        perform_request<T>(op, wr, [](protocol::reader &) {}, std::move(callback));
     }
 
 private:
diff --git a/modules/platforms/cpp/ignite/client/detail/ignite_client_impl.h b/modules/platforms/cpp/ignite/client/detail/ignite_client_impl.h
index ac0a0ce999..69323db1f0 100644
--- a/modules/platforms/cpp/ignite/client/detail/ignite_client_impl.h
+++ b/modules/platforms/cpp/ignite/client/detail/ignite_client_impl.h
@@ -19,6 +19,7 @@
 
 #include <ignite/client/detail/cluster_connection.h>
 #include <ignite/client/detail/table/tables_impl.h>
+#include <ignite/client/detail/sql/sql_impl.h>
 #include <ignite/client/ignite_client_configuration.h>
 
 #include <ignite/common/ignite_result.h>
@@ -48,7 +49,8 @@ public:
     explicit ignite_client_impl(ignite_client_configuration configuration)
         : m_configuration(std::move(configuration))
         , m_connection(cluster_connection::create(m_configuration))
-        , m_tables(std::make_shared<tables_impl>(m_connection)) {}
+        , m_tables(std::make_shared<tables_impl>(m_connection))
+        , m_sql(std::make_shared<sql_impl>(m_connection)) {}
 
     /**
      * Destructor.
@@ -82,6 +84,13 @@ public:
      */
     [[nodiscard]] std::shared_ptr<tables_impl> get_tables_impl() const { return m_tables; }
 
+    /**
+     * Get SQL management API implementation.
+     *
+     * @return SQL management API implementation.
+     */
+    [[nodiscard]] std::shared_ptr<sql_impl> get_sql_impl() const { return m_sql; }
+
 private:
     /** Configuration. */
     const ignite_client_configuration m_configuration;
@@ -91,6 +100,9 @@ private:
 
     /** Tables. */
     std::shared_ptr<tables_impl> m_tables;
+
+    /** SQL. */
+    std::shared_ptr<sql_impl> m_sql;
 };
 
 } // namespace ignite::detail
diff --git a/modules/platforms/cpp/ignite/client/detail/node_connection.cpp b/modules/platforms/cpp/ignite/client/detail/node_connection.cpp
index e6a5e14fd5..b814de709a 100644
--- a/modules/platforms/cpp/ignite/client/detail/node_connection.cpp
+++ b/modules/platforms/cpp/ignite/client/detail/node_connection.cpp
@@ -82,7 +82,6 @@ void node_connection::process_message(bytes_view msg) {
     (void) flags; // Flags are unused for now.
 
     auto handler = get_and_remove_handler(reqId);
-
     if (!handler) {
         m_logger->log_error("Missing handler for request with id=" + std::to_string(reqId));
         return;
@@ -98,7 +97,9 @@ void node_connection::process_message(bytes_view msg) {
         return;
     }
 
-    auto handlingRes = handler->handle(reader);
+    auto pos = reader.position();
+    bytes_view data{msg.data() + pos, msg.size() - pos};
+    auto handlingRes = handler->handle(shared_from_this(), data);
     if (handlingRes.has_error())
         m_logger->log_error("Uncaught user callback exception: " + handlingRes.error().what_str());
 }
diff --git a/modules/platforms/cpp/ignite/client/detail/node_connection.h b/modules/platforms/cpp/ignite/client/detail/node_connection.h
index bf5ca04fd1..12d81e4d1e 100644
--- a/modules/platforms/cpp/ignite/client/detail/node_connection.h
+++ b/modules/platforms/cpp/ignite/client/detail/node_connection.h
@@ -42,7 +42,7 @@ class cluster_connection;
  *
  * Considered established while there is connection to at least one server.
  */
-class node_connection {
+class node_connection : public std::enable_shared_from_this<node_connection> {
     friend class cluster_connection;
 
 public:
@@ -59,14 +59,17 @@ public:
     ~node_connection();
 
     /**
-     * Constructor.
+     * Makes new instance.
      *
      * @param id Connection ID.
      * @param pool Connection pool.
      * @param logger Logger.
+     * @return New instance.
      */
-    node_connection(
-        uint64_t id, std::shared_ptr<network::async_client_pool> pool, std::shared_ptr<ignite_logger> logger);
+    static std::shared_ptr<node_connection> make_new(
+        uint64_t id, std::shared_ptr<network::async_client_pool> pool, std::shared_ptr<ignite_logger> logger) {
+        return std::shared_ptr<node_connection>(new node_connection(id, std::move(pool), std::move(logger)));
+    }
 
     /**
      * Get connection ID.
@@ -91,9 +94,8 @@ public:
      * @param handler Response handler.
      * @return @c true on success and @c false otherwise.
      */
-    template<typename T>
     bool perform_request(client_operation op, const std::function<void(protocol::writer &)> &wr,
-        std::shared_ptr<response_handler_impl<T>> handler) {
+        std::shared_ptr<response_handler> handler) {
         auto reqId = generate_request_id();
         std::vector<std::byte> message;
         {
@@ -121,6 +123,24 @@ public:
         return true;
     }
 
+    /**
+     * Perform request.
+     *
+     * @tparam T Result type.
+     * @param op Operation code.
+     * @param wr Request writer function.
+     * @param rd Response reader function.
+     * @param callback Callback to call on result.
+     * @return Channel used for the request.
+     */
+    template<typename T>
+    bool perform_request(client_operation op, const std::function<void(protocol::writer &)> &wr,
+        std::function<T(protocol::reader &)> rd, ignite_callback<T> callback)
+    {
+        auto handler = std::make_shared<response_handler_reader<T>>(std::move(rd), std::move(callback));
+        return perform_request(op, wr, std::move(handler));
+    }
+
     /**
      * Perform handshake.
      *
@@ -150,6 +170,16 @@ public:
     const protocol_context& get_protocol_context() const { return m_protocol_context; }
 
 private:
+    /**
+     * Constructor.
+     *
+     * @param id Connection ID.
+     * @param pool Connection pool.
+     * @param logger Logger.
+     */
+    node_connection(
+        uint64_t id, std::shared_ptr<network::async_client_pool> pool, std::shared_ptr<ignite_logger> logger);
+
     /**
      * Generate next request ID.
      *
diff --git a/modules/platforms/cpp/ignite/client/detail/response_handler.h b/modules/platforms/cpp/ignite/client/detail/response_handler.h
index 63f297147f..cd79c93bbf 100644
--- a/modules/platforms/cpp/ignite/client/detail/response_handler.h
+++ b/modules/platforms/cpp/ignite/client/detail/response_handler.h
@@ -17,9 +17,10 @@
 
 #pragma once
 
-#include <ignite/common/ignite_error.h>
-#include <ignite/common/ignite_result.h>
-#include <ignite/protocol/reader.h>
+#include "ignite/client/detail/node_connection.h"
+#include "ignite/common/ignite_error.h"
+#include "ignite/common/ignite_result.h"
+#include "ignite/protocol/reader.h"
 
 #include <functional>
 #include <future>
@@ -28,6 +29,8 @@
 
 namespace ignite::detail {
 
+class node_connection;
+
 /**
  * Response handler.
  */
@@ -46,7 +49,7 @@ public:
     /**
      * Handle response.
      */
-    [[nodiscard]] virtual ignite_result<void> handle(protocol::reader &) = 0;
+    [[nodiscard]] virtual ignite_result<void> handle(std::shared_ptr<node_connection>, bytes_view) = 0;
 
     /**
      * Set error.
@@ -55,34 +58,107 @@ public:
 };
 
 /**
- * Response handler implementation for specific type.
+ * Response handler implementation for bytes.
+ */
+template<typename T>
+class response_handler_bytes final : public response_handler {
+public:
+    // Default
+    response_handler_bytes() = default;
+
+    /**
+     * Constructor.
+     *
+     * @param read_func Read function.
+     * @param callback Callback.
+     */
+    explicit response_handler_bytes(
+        std::function<T(std::shared_ptr<node_connection>, bytes_view)> read_func, ignite_callback<T> callback)
+        : m_read_func(std::move(read_func))
+        , m_callback(std::move(callback))
+        , m_mutex() {}
+
+    /**
+     * Handle response.
+     *
+     * @param channel Channel.
+     * @param msg Message.
+     */
+    [[nodiscard]] ignite_result<void> handle(std::shared_ptr<node_connection> channel, bytes_view msg) final {
+        ignite_callback<T> callback = remove_callback();
+        if (!callback)
+            return {};
+
+        auto res = result_of_operation<T>([&]() { return m_read_func(std::move(channel), msg); });
+        return result_of_operation<void>([&]() { callback(std::move(res)); });
+    }
+
+    /**
+     * Set error.
+     *
+     * @param err Error to set.
+     */
+    [[nodiscard]] ignite_result<void> set_error(ignite_error err) final {
+        ignite_callback<T> callback = remove_callback();
+        if (!callback)
+            return {};
+
+        return result_of_operation<void>([&]() { callback({std::move(err)}); });
+    }
+
+private:
+    /**
+     * Remove callback and return it.
+     *
+     * @return Callback.
+     */
+    ignite_callback<T> remove_callback() {
+        std::lock_guard<std::mutex> guard(m_mutex);
+        ignite_callback<T> callback = {};
+        std::swap(callback, m_callback);
+        return callback;
+    }
+
+    /** Read function. */
+    std::function<T(std::shared_ptr<node_connection>, bytes_view)> m_read_func;
+
+    /** Promise. */
+    ignite_callback<T> m_callback;
+
+    /** Callback mutex. */
+    std::mutex m_mutex;
+};
+
+/**
+ * Response handler implementation for reader.
  */
 template<typename T>
-class response_handler_impl final : public response_handler {
+class response_handler_reader final : public response_handler {
 public:
     // Default
-    response_handler_impl() = default;
+    response_handler_reader() = default;
 
     /**
      * Constructor.
      *
-     * @param func Function.
+     * @param read_func Read function.
      */
-    explicit response_handler_impl(std::function<T(protocol::reader &)> readFunc, ignite_callback<T> callback)
-        : m_read_func(std::move(readFunc))
+    explicit response_handler_reader(std::function<T(protocol::reader &)> read_func, ignite_callback<T> callback)
+        : m_read_func(std::move(read_func))
         , m_callback(std::move(callback))
         , m_mutex() {}
 
     /**
      * Handle response.
      *
-     * @param reader Reader to be used to read response.
+     * @param msg Message.
      */
-    [[nodiscard]] ignite_result<void> handle(protocol::reader &reader) final {
+    [[nodiscard]] ignite_result<void> handle(std::shared_ptr<node_connection>, bytes_view msg) final {
         ignite_callback<T> callback = remove_callback();
         if (!callback)
             return {};
 
+        protocol::reader reader(msg);
         auto res = result_of_operation<T>([&]() { return m_read_func(reader); });
         return result_of_operation<void>([&]() { callback(std::move(res)); });
     }
@@ -114,7 +190,7 @@ private:
     }
 
     /** Read function. */
-    std::function<T(protocol::reader &)> m_read_func;
+    std::function<T(protocol::reader&)> m_read_func;
 
     /** Promise. */
     ignite_callback<T> m_callback;
diff --git a/modules/platforms/cpp/ignite/client/detail/sql/result_set_impl.h b/modules/platforms/cpp/ignite/client/detail/sql/result_set_impl.h
new file mode 100644
index 0000000000..1462127e0c
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/detail/sql/result_set_impl.h
@@ -0,0 +1,338 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#pragma once
+
+#include "ignite/client/sql/result_set_metadata.h"
+#include "ignite/client/table/ignite_tuple.h"
+#include "ignite/client/detail/node_connection.h"
+#include "ignite/client/detail/utils.h"
+#include "ignite/schema/binary_tuple_parser.h"
+
+#include <cstdint>
+
+namespace ignite::detail {
+
+/**
+ * Query result set.
+ */
+class result_set_impl : public std::enable_shared_from_this<result_set_impl> {
+public:
+    // Default
+    result_set_impl() = default;
+
+    /**
+     * Constructor.
+     *
+     * @param connection Node connection.
+     * @param data Row set data.
+     */
+    result_set_impl(std::shared_ptr<node_connection> connection, bytes_view data)
+        : m_connection(std::move(connection)) {
+        protocol::reader reader(data);
+
+        m_resource_id = reader.read_object_nullable<std::int64_t>();
+        m_has_rowset = reader.read_bool();
+        m_has_more_pages = reader.read_bool();
+        m_was_applied = reader.read_bool();
+        m_affected_rows = reader.read_int64();
+
+        if (m_has_rowset) {
+            auto columns = read_meta(reader);
+            m_meta = std::move(result_set_metadata(columns));
+            m_page = read_page(reader, m_meta);
+        }
+    }
+
+    /**
+     * Destructor.
+     */
+    ~result_set_impl() {
+        close_async([](auto){});
+    }
+
+    /**
+     * Gets metadata.
+     *
+     * @return Metadata.
+     */
+    [[nodiscard]] const result_set_metadata& metadata() const {
+        return m_meta;
+    }
+
+    /**
+     * Gets a value indicating whether this result set contains a collection of rows.
+     *
+     * @return A value indicating whether this result set contains a collection of rows.
+     */
+    [[nodiscard]] bool has_rowset() const {
+        return m_has_rowset;
+    }
+
+    /**
+     * Gets the number of rows affected by the DML statement execution (such as "INSERT", "UPDATE", etc.), or 0 if
+     * the statement returns nothing (such as "ALTER TABLE", etc), or -1 if not applicable.
+     *
+     * @return The number of rows affected by the DML statement execution.
+     */
+    [[nodiscard]] std::int64_t affected_rows() const {
+        return m_affected_rows;
+    }
+
+    /**
+     * Gets a value indicating whether a conditional query (such as "CREATE TABLE IF NOT EXISTS") was applied
+     * successfully.
+     *
+     * @return A value indicating whether a conditional query was applied successfully.
+     */
+    [[nodiscard]] bool was_applied() const {
+        return m_was_applied;
+    }
+
+    /**
+     * Close result set asynchronously.
+     *
+     * @param callback Callback to call on completion.
+     * @return @c true if the request was sent, and false if the result set was already closed.
+     */
+    bool close_async(std::function<void(ignite_result<void>)> callback) {
+        if (!m_resource_id)
+            return false;
+
+        auto writer_func = [id = m_resource_id.value()](protocol::writer &writer) {
+            writer.write(id);
+        };
+
+        auto reader_func = [weak_self = weak_from_this()](protocol::reader &reader) {
+            auto self = weak_self.lock();
+            if (!self)
+                return;
+
+            self->m_resource_id = std::nullopt;
+        };
+
+        return m_connection->perform_request<void>(
+            client_operation::SQL_CURSOR_CLOSE, writer_func, std::move(reader_func), std::move(callback));
+    }
+
+    /**
+     * Close result set synchronously.
+     *
+     * @return @c true if the request was sent, and false if the result set was already closed.
+     */
+    bool close() {
+        auto pr = std::make_shared<std::promise<void>>();
+        bool res = close_async([pr](auto) mutable {
+            pr->set_value();
+        });
+
+        if (!res)
+            return res;
+
+        pr->get_future().get();
+        return true;
+    }
+
+    /**
+     * Get current page size.
+     *
+     * @return Current page size.
+     */
+    [[nodiscard]] std::vector<ignite_tuple> current_page() {
+        require_result_set();
+
+        auto ret = std::move(m_page);
+        m_page.clear();
+
+        return ret;
+    }
+
+    /**
+     * Checks whether there are more pages of results.
+     *
+     * @return @c true if there are more pages with results and @c false otherwise.
+     */
+    [[nodiscard]] IGNITE_API bool has_more_pages() {
+        return m_resource_id.has_value() && m_has_more_pages;
+    }
+
+    /**
+     * Fetch the next page of results asynchronously.
+     * The current page is changed after the operation is complete.
+     *
+     * @param callback Callback to call on completion.
+     */
+    IGNITE_API void fetch_next_page_async(std::function<void(ignite_result<void>)> callback) {
+        require_result_set();
+
+        if (!m_resource_id)
+            throw ignite_error("Query cursor is closed");
+
+        if (!m_has_more_pages)
+            throw ignite_error("There are no more pages");
+
+        auto writer_func = [id = m_resource_id.value()](protocol::writer &writer) {
+            writer.write(id);
+        };
+
+        auto reader_func = [weak_self = weak_from_this()](protocol::reader &reader) {
+            auto self = weak_self.lock();
+            if (!self)
+                return;
+
+            self->m_page = read_page(reader, self->m_meta);
+            self->m_has_more_pages = reader.read_bool();
+        };
+
+        m_connection->perform_request<void>(
+            client_operation::SQL_CURSOR_NEXT_PAGE, writer_func, std::move(reader_func), std::move(callback));
+    }
+
+private:
+    /**
+     * Checks that query has result set and throws error if it has not.
+     */
+    void require_result_set() const {
+        if (!m_has_rowset)
+            throw ignite_error("Query does not produce result set");
+    }
+
+    /**
+     * Reads result set metadata.
+     *
+     * @param reader Reader.
+     * @return Result set meta coumns.
+     */
+    static std::vector<column_metadata> read_meta(protocol::reader& reader) {
+        auto size = reader.read_array_size();
+
+        std::vector<column_metadata> columns;
+        columns.reserve(size);
+
+        reader.read_array_raw([&columns] (std::uint32_t idx, const msgpack_object &obj) {
+            if (obj.type != MSGPACK_OBJECT_ARRAY)
+                throw ignite_error("Meta column expected to be serialized as array");
+
+            const msgpack_object_array &arr = obj.via.array;
+
+            constexpr std::uint32_t minCount = 6;
+            assert(arr.size >= minCount);
+
+            auto name = protocol::unpack_object<std::string>(arr.ptr[0]);
+            auto nullable = protocol::unpack_object<bool>(arr.ptr[1]);
+            auto typ = column_type(protocol::unpack_object<std::int32_t>(arr.ptr[2]));
+            auto scale = protocol::unpack_object<std::int32_t>(arr.ptr[3]);
+            auto precision = protocol::unpack_object<std::int32_t>(arr.ptr[4]);
+
+            bool origin_present = protocol::unpack_object<bool>(arr.ptr[5]);
+
+            if (!origin_present) {
+                columns.emplace_back(std::move(name), typ, precision, scale, nullable, column_origin{});
+                return;
+            }
+
+            assert(arr.size >= minCount + 3);
+            auto origin_name = arr.ptr[6].type == MSGPACK_OBJECT_NIL
+                    ? name
+                    : protocol::unpack_object<std::string>(arr.ptr[6]);
+
+            auto origin_schema_id = protocol::try_unpack_object<std::int32_t>(arr.ptr[7]);
+            std::string origin_schema;
+            if (origin_schema_id) {
+                if (*origin_schema_id >= columns.size()) {
+                    throw ignite_error("Origin schema ID is too large: " + std::to_string(*origin_schema_id) +
+                                       ", id=" + std::to_string(idx));
+                }
+                origin_schema = columns[*origin_schema_id].origin().schema_name();
+            } else {
+                origin_schema = protocol::unpack_object<std::string>(arr.ptr[7]);
+            }
+
+            auto origin_table_id = protocol::try_unpack_object<std::int32_t>(arr.ptr[8]);
+            std::string origin_table;
+            if (origin_table_id) {
+                if (*origin_table_id >= columns.size()) {
+                    throw ignite_error("Origin table ID is too large: " + std::to_string(*origin_table_id) +
+                                       ", id=" + std::to_string(idx));
+                }
+                origin_table = columns[*origin_table_id].origin().table_name();
+            } else {
+                origin_table = protocol::unpack_object<std::string>(arr.ptr[8]);
+            }
+
+            column_origin origin{std::move(origin_name), std::move(origin_table), std::move(origin_schema)};
+            columns.emplace_back(std::move(name), typ, precision, scale, nullable, std::move(origin));
+        });
+
+        return columns;
+    }
+
+    /**
+     * Read page.
+     *
+     * @param reader Reader to use.
+     * @return Page.
+     */
+    static std::vector<ignite_tuple> read_page(protocol::reader &reader, const result_set_metadata &meta) {
+        auto size = reader.read_array_size();
+
+        std::vector<ignite_tuple> page;
+        page.reserve(size);
+
+        reader.read_array_raw([&columns = meta.columns(), &page] (std::uint32_t idx, const msgpack_object &obj) {
+            auto tuple_data = protocol::unpack_binary(obj);
+
+            auto columns_cnt = columns.size();
+            ignite_tuple res(columns_cnt);
+            binary_tuple_parser parser(std::int32_t(columns_cnt), tuple_data);
+
+            for (size_t i = 0; i < columns_cnt; ++i) {
+                auto &column = columns[i];
+                res.set(column.name(), read_next_column(parser, column.type()));
+            }
+            page.emplace_back(std::move(res));
+        });
+
+        return page;
+    }
+
+    /** Result set metadata. */
+    result_set_metadata m_meta;
+
+    /** Has row set. */
+    bool m_has_rowset{false};
+
+    /** Affected rows. */
+    std::int64_t m_affected_rows{-1};
+
+    /** Statement was applied. */
+    bool m_was_applied{false};
+
+    /** Connection. */
+    std::shared_ptr<node_connection> m_connection;
+
+    /** Resource ID. */
+    std::optional<std::int64_t> m_resource_id;
+
+    /** Has more pages. */
+    bool m_has_more_pages{false};
+
+    /** Current page. */
+    std::vector<ignite_tuple> m_page;
+};
+
+} // namespace ignite::detail
diff --git a/modules/platforms/cpp/ignite/client/detail/sql/sql_impl.cpp b/modules/platforms/cpp/ignite/client/detail/sql/sql_impl.cpp
new file mode 100644
index 0000000000..655c2157d4
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/detail/sql/sql_impl.cpp
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#include "ignite/client/detail/sql/result_set_impl.h"
+#include "ignite/client/detail/sql/sql_impl.h"
+#include "ignite/client/detail/utils.h"
+
+#include "ignite/schema/binary_tuple_builder.h"
+
+namespace ignite::detail {
+
+void sql_impl::execute_async(
+    transaction *tx, const sql_statement &statement, std::vector<primitive>&& args, ignite_callback<result_set>&& callback)
+{
+    transactions_not_implemented(tx);
+
+    auto writer_func = [&statement, &args](protocol::writer &writer) {
+        writer.write_nil(); // TODO: IGNITE-17604 Implement transactions
+        writer.write(statement.schema());
+        writer.write(statement.page_size());
+        writer.write(std::int64_t(statement.timeout().count()));
+        writer.write_nil(); // Session timeout (unused, session is closed by the server immediately).
+
+        const auto& properties = statement.properties();
+        auto props_num = std::int32_t(properties.size());
+
+        writer.write(props_num);
+
+        binary_tuple_builder prop_builder{props_num * 4};
+
+        prop_builder.start();
+        for (const auto &property : properties) {
+            prop_builder.claim_string(property.first);
+            claim_primitive_with_type(prop_builder, property.second);
+        }
+
+        prop_builder.layout();
+        for (const auto &property : properties) {
+            prop_builder.append_string(property.first);
+            append_primitive_with_type(prop_builder, property.second);
+        }
+
+        auto prop_data = prop_builder.build();
+        writer.write_binary(prop_data);
+
+        writer.write(statement.query());
+
+        if (args.empty()) {
+            writer.write_nil();
+            return;
+        }
+
+        auto args_num = std::int32_t(args.size());
+
+        writer.write(args_num);
+
+        binary_tuple_builder args_builder{args_num * 3};
+
+        args_builder.start();
+        for (const auto &arg : args) {
+            claim_primitive_with_type(args_builder, arg);
+        }
+
+        args_builder.layout();
+        for (const auto &arg : args) {
+            append_primitive_with_type(args_builder, arg);
+        }
+
+        auto args_data = args_builder.build();
+        writer.write_binary(args_data);
+    };
+
+    auto reader_func = [](std::shared_ptr<node_connection> channel, bytes_view msg) -> result_set {
+        return result_set{std::make_shared<result_set_impl>(std::move(channel), msg)};
+    };
+
+    m_connection->perform_request_raw<result_set>(
+        client_operation::SQL_EXEC, writer_func, std::move(reader_func), std::move(callback));
+}
+
+} // namespace ignite::detail
diff --git a/modules/platforms/cpp/ignite/client/detail/sql/sql_impl.h b/modules/platforms/cpp/ignite/client/detail/sql/sql_impl.h
new file mode 100644
index 0000000000..7692bbdc25
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/detail/sql/sql_impl.h
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#pragma once
+
+#include "ignite/client/detail/cluster_connection.h"
+#include "ignite/client/transaction/transaction.h"
+#include "ignite/client/sql/sql_statement.h"
+#include "ignite/client/sql/result_set.h"
+#include "ignite/client/primitive.h"
+
+#include <memory>
+#include <utility>
+
+namespace ignite::detail {
+
+/**
+ * Ignite SQL query facade.
+ */
+class sql_impl {
+public:
+    // Default
+    ~sql_impl() = default;
+    sql_impl(sql_impl &&) noexcept = default;
+    sql_impl &operator=(sql_impl &&) noexcept = default;
+
+    // Deleted
+    sql_impl() = delete;
+    sql_impl(const sql_impl &) = delete;
+    sql_impl &operator=(const sql_impl &) = delete;
+
+    /**
+     * Constructor.
+     *
+     * @param connection Connection.
+     */
+    explicit sql_impl(std::shared_ptr<cluster_connection> connection)
+        : m_connection(std::move(connection)) {}
+
+    /**
+     * Executes single SQL statement and returns rows.
+     *
+     * @param tx Optional transaction. If nullptr implicit transaction for this
+     *   single operation is used.
+     * @param statement Statement to execute.
+     * @param args Arguments for the statement.
+     * @param callback A callback called on operation completion with SQL result set.
+     */
+    void execute_async(
+        transaction *tx, const sql_statement& statement, std::vector<primitive>&& args, ignite_callback<result_set>&& callback);
+
+private:
+    /** Cluster connection. */
+    std::shared_ptr<cluster_connection> m_connection;
+};
+
+} // namespace ignite::detail
diff --git a/modules/platforms/cpp/ignite/client/detail/table/table_impl.cpp b/modules/platforms/cpp/ignite/client/detail/table/table_impl.cpp
index 0e7f434555..ac3fef247b 100644
--- a/modules/platforms/cpp/ignite/client/detail/table/table_impl.cpp
+++ b/modules/platforms/cpp/ignite/client/detail/table/table_impl.cpp
@@ -16,6 +16,7 @@
  */
 
 #include "ignite/client/detail/table/table_impl.h"
+#include "ignite/client/detail/utils.h"
 
 #include "ignite/common/bits.h"
 #include "ignite/common/ignite_error.h"
@@ -59,10 +60,10 @@ void claim_column(binary_tuple_builder &builder, ignite_type typ, std::int32_t i
             builder.claim_uuid(tuple.get<uuid>(index));
             break;
         case ignite_type::STRING:
-            builder.claim(SizeT(tuple.get<const std::string &>(index).size()));
+            builder.claim(SizeT(tuple.get<std::string>(index).size()));
             break;
         case ignite_type::BINARY:
-            builder.claim(SizeT(tuple.get<const std::vector<std::byte> &>(index).size()));
+            builder.claim(SizeT(tuple.get<std::vector<std::byte>>(index).size()));
             break;
         default:
             // TODO: IGNITE-18035 Support other types
@@ -102,13 +103,13 @@ void append_column(binary_tuple_builder &builder, ignite_type typ, std::int32_t
             builder.append_uuid(tuple.get<uuid>(index));
             break;
         case ignite_type::STRING: {
-            const auto &str = tuple.get<const std::string &>(index);
+            const auto &str = tuple.get<std::string>(index);
             bytes_view view{reinterpret_cast<const std::byte *>(str.data()), str.size()};
             builder.append(typ, view);
             break;
         }
         case ignite_type::BINARY:
-            builder.append(typ, tuple.get<const std::vector<std::byte> &>(index));
+            builder.append(typ, tuple.get<std::vector<std::byte>>(index));
             break;
         default:
             // TODO: IGNITE-18035 Support other types
@@ -116,56 +117,6 @@ void append_column(binary_tuple_builder &builder, ignite_type typ, std::int32_t
     }
 }
 
-/**
- * Read column value from binary tuple.
- *
- * @param parser Binary tuple parser.
- * @param typ Column type.
- * @return Column value.
- */
-std::any read_next_column(binary_tuple_parser &parser, ignite_type typ) {
-    auto val_opt = parser.get_next();
-    if (!val_opt)
-        return {};
-
-    auto val = val_opt.value();
-
-    switch (typ) {
-        case ignite_type::INT8:
-            return binary_tuple_parser::get_int8(val);
-        case ignite_type::INT16:
-            return binary_tuple_parser::get_int16(val);
-        case ignite_type::INT32:
-            return binary_tuple_parser::get_int32(val);
-        case ignite_type::INT64:
-            return binary_tuple_parser::get_int64(val);
-        case ignite_type::FLOAT:
-            return binary_tuple_parser::get_float(val);
-        case ignite_type::DOUBLE:
-            return binary_tuple_parser::get_double(val);
-        case ignite_type::UUID:
-            return binary_tuple_parser::get_uuid(val);
-        case ignite_type::STRING:
-            return std::string(reinterpret_cast<const char *>(val.data()), val.size());
-        case ignite_type::BINARY:
-            return std::vector<std::byte>(val);
-        default:
-            // TODO: IGNITE-18035 Support other types
-            throw ignite_error("Type with id " + std::to_string(int(typ)) + " is not yet supported");
-    }
-}
-
-/**
- * Check transaction and throw an exception if it is not nullptr.
- *
- * @param tx Transaction.
- */
-void transactions_not_implemented(transaction *tx) {
-    // TODO: IGNITE-17604 Implement transactions
-    if (tx)
-        throw ignite_error("Transactions are not implemented");
-}
-
 /**
  * Serialize tuple using table schema.
  *
diff --git a/modules/platforms/cpp/ignite/client/detail/utils.cpp b/modules/platforms/cpp/ignite/client/detail/utils.cpp
new file mode 100644
index 0000000000..d647c679dd
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/detail/utils.cpp
@@ -0,0 +1,251 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#include "ignite/client/detail/utils.h"
+#include "ignite/common/uuid.h"
+
+#include <string>
+
+namespace ignite::detail {
+
+/**
+ * Claim type and scale header for a value written in binary tuple.
+ *
+ * @param builder Tuple builder.
+ * @param typ Type.
+ * @param scale Scale.
+ */
+void claim_type_and_scale(binary_tuple_builder &builder, ignite_type typ, std::int32_t scale = 0) {
+    builder.claim_int32(std::int32_t(typ));
+    builder.claim_int32(scale);
+}
+
+/**
+ * Append type and scale header for a value written in binary tuple.
+ *
+ * @param builder Tuple builder.
+ * @param typ Type.
+ * @param scale Scale.
+ */
+void append_type_and_scale(binary_tuple_builder &builder, ignite_type typ, std::int32_t scale = 0) {
+    builder.append_int32(std::int32_t(typ));
+    builder.append_int32(scale);
+}
+
+void claim_primitive_with_type(binary_tuple_builder &builder, const primitive &value) {
+    switch (value.get_type()) {
+        case column_type::BOOLEAN: {
+            claim_type_and_scale(builder, ignite_type::INT8);
+            builder.claim_int8(1);
+            break;
+        }
+        case column_type::INT8: {
+            claim_type_and_scale(builder, ignite_type::INT8);
+            builder.claim_int8(value.get<std::int8_t>());
+            break;
+        }
+        case column_type::INT16: {
+            claim_type_and_scale(builder, ignite_type::INT16);
+            builder.claim_int16(value.get<std::int16_t>());
+            break;
+        }
+        case column_type::INT32: {
+            claim_type_and_scale(builder, ignite_type::INT32);
+            builder.claim_int32(value.get<std::int32_t>());
+            break;
+        }
+        case column_type::INT64: {
+            claim_type_and_scale(builder, ignite_type::INT64);
+            builder.claim_int64(value.get<std::int64_t>());
+            break;
+        }
+        case column_type::FLOAT: {
+            claim_type_and_scale(builder, ignite_type::FLOAT);
+            builder.claim_float(value.get<float>());
+            break;
+        }
+        case column_type::DOUBLE: {
+            claim_type_and_scale(builder, ignite_type::DOUBLE);
+            builder.claim_double(value.get<double>());
+            break;
+        }
+        case column_type::UUID: {
+            claim_type_and_scale(builder, ignite_type::UUID);
+            builder.claim_uuid(value.get<uuid>());
+            break;
+        }
+        case column_type::STRING: {
+            claim_type_and_scale(builder, ignite_type::STRING);
+            builder.claim_string(value.get<std::string>());
+            break;
+        }
+        case column_type::BYTE_ARRAY: {
+            claim_type_and_scale(builder, ignite_type::BINARY);
+            auto &data = value.get<std::vector<std::byte>>();
+            builder.claim(ignite_type::BINARY, data);
+            break;
+        }
+
+        case column_type::DECIMAL:
+        case column_type::DATE:
+        case column_type::TIME:
+        case column_type::DATETIME:
+        case column_type::TIMESTAMP:
+        case column_type::BITMASK:
+        case column_type::PERIOD:
+        case column_type::DURATION:
+        case column_type::NUMBER:
+        default:
+            throw ignite_error("Unsupported type: " + std::to_string(int(value.get_type())));
+    }
+}
+
+void append_primitive_with_type(binary_tuple_builder &builder, const primitive &value) {
+    switch (value.get_type()) {
+        case column_type::BOOLEAN: {
+            append_type_and_scale(builder, ignite_type::INT8);
+            builder.append_int8(1);
+            break;
+        }
+        case column_type::INT8: {
+            append_type_and_scale(builder, ignite_type::INT8);
+            builder.append_int8(value.get<std::int8_t>());
+            break;
+        }
+        case column_type::INT16: {
+            append_type_and_scale(builder, ignite_type::INT16);
+            builder.append_int16(value.get<std::int16_t>());
+            break;
+        }
+        case column_type::INT32: {
+            append_type_and_scale(builder, ignite_type::INT32);
+            builder.append_int32(value.get<std::int32_t>());
+            break;
+        }
+        case column_type::INT64: {
+            append_type_and_scale(builder, ignite_type::INT64);
+            builder.append_int64(value.get<std::int64_t>());
+            break;
+        }
+        case column_type::FLOAT: {
+            append_type_and_scale(builder, ignite_type::FLOAT);
+            builder.append_float(value.get<float>());
+            break;
+        }
+        case column_type::DOUBLE: {
+            append_type_and_scale(builder, ignite_type::DOUBLE);
+            builder.append_double(value.get<double>());
+            break;
+        }
+        case column_type::UUID: {
+            append_type_and_scale(builder, ignite_type::UUID);
+            builder.append_uuid(value.get<uuid>());
+            break;
+        }
+        case column_type::STRING: {
+            append_type_and_scale(builder, ignite_type::STRING);
+            builder.append_string(value.get<std::string>());
+            break;
+        }
+        case column_type::BYTE_ARRAY: {
+            append_type_and_scale(builder, ignite_type::BINARY);
+            auto &data = value.get<std::vector<std::byte>>();
+            builder.append(ignite_type::BINARY, data);
+            break;
+        }
+
+        case column_type::DECIMAL:
+        case column_type::DATE:
+        case column_type::TIME:
+        case column_type::DATETIME:
+        case column_type::TIMESTAMP:
+        case column_type::BITMASK:
+        case column_type::PERIOD:
+        case column_type::DURATION:
+        case column_type::NUMBER:
+        default:
+            throw ignite_error("Unsupported type: " + std::to_string(int(value.get_type())));
+    }
+}
+
+primitive read_next_column(binary_tuple_parser &parser, ignite_type typ) {
+    auto val_opt = parser.get_next();
+    if (!val_opt)
+        return {};
+
+    auto val = val_opt.value();
+
+    switch (typ) {
+        case ignite_type::INT8:
+            return binary_tuple_parser::get_int8(val);
+        case ignite_type::INT16:
+            return binary_tuple_parser::get_int16(val);
+        case ignite_type::INT32:
+            return binary_tuple_parser::get_int32(val);
+        case ignite_type::INT64:
+            return binary_tuple_parser::get_int64(val);
+        case ignite_type::FLOAT:
+            return binary_tuple_parser::get_float(val);
+        case ignite_type::DOUBLE:
+            return binary_tuple_parser::get_double(val);
+        case ignite_type::UUID:
+            return binary_tuple_parser::get_uuid(val);
+        case ignite_type::STRING:
+            return std::string(reinterpret_cast<const char *>(val.data()), val.size());
+        case ignite_type::BINARY:
+            return std::vector<std::byte>(val);
+        default:
+            // TODO: IGNITE-18035 Support other types
+            throw ignite_error("Type with id " + std::to_string(int(typ)) + " is not yet supported");
+    }
+}
+
+primitive read_next_column(binary_tuple_parser &parser, column_type typ) {
+    auto val_opt = parser.get_next();
+    if (!val_opt)
+        return {};
+
+    auto val = val_opt.value();
+
+    switch (typ) {
+        case column_type::BOOLEAN:
+            return binary_tuple_parser::get_int8(val) != 0;
+        case column_type::INT8:
+            return binary_tuple_parser::get_int8(val);
+        case column_type::INT16:
+            return binary_tuple_parser::get_int16(val);
+        case column_type::INT32:
+            return binary_tuple_parser::get_int32(val);
+        case column_type::INT64:
+            return binary_tuple_parser::get_int64(val);
+        case column_type::FLOAT:
+            return binary_tuple_parser::get_float(val);
+        case column_type::DOUBLE:
+            return binary_tuple_parser::get_double(val);
+        case column_type::UUID:
+            return binary_tuple_parser::get_uuid(val);
+        case column_type::STRING:
+            return std::string(reinterpret_cast<const char *>(val.data()), val.size());
+        case column_type::BYTE_ARRAY:
+            return std::vector<std::byte>(val);
+        default:
+            // TODO: IGNITE-18035 Support other types
+            throw ignite_error("Type with id " + std::to_string(int(typ)) + " is not yet supported");
+    }
+}
+
+} // namespace ignite::detail
diff --git a/modules/platforms/cpp/ignite/client/detail/utils.h b/modules/platforms/cpp/ignite/client/detail/utils.h
new file mode 100644
index 0000000000..e25be803b1
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/detail/utils.h
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#pragma once
+
+#include "ignite/client/transaction/transaction.h"
+#include "ignite/client/primitive.h"
+#include "ignite/schema/binary_tuple_builder.h"
+#include "ignite/schema/ignite_type.h"
+#include "ignite/schema/binary_tuple_parser.h"
+
+namespace ignite::detail {
+
+/**
+ * Claim space for a value with type header in tuple builder.
+ *
+ * @param builder Tuple builder.
+ * @param value Value.
+ */
+void claim_primitive_with_type(binary_tuple_builder &builder, const primitive &value);
+
+/**
+ * Append a value with type header in tuple builder.
+ *
+ * @param builder Tuple builder.
+ * @param value Value.
+ */
+void append_primitive_with_type(binary_tuple_builder &builder, const primitive &value);
+
+/**
+ * Check transaction and throw an exception if it is not nullptr.
+ *
+ * @param tx Transaction.
+ */
+inline void transactions_not_implemented(transaction *tx) {
+    // TODO: IGNITE-17604 Implement transactions
+    if (tx)
+        throw ignite_error("Transactions are not implemented");
+}
+
+
+/**
+ * Read column value from binary tuple.
+ *
+ * @param parser Binary tuple parser.
+ * @param typ Column type.
+ * @return Column value.
+ */
+[[nodiscard]] primitive read_next_column(binary_tuple_parser &parser, ignite_type typ);
+
+/**
+ * Read column value from binary tuple.
+ *
+ * @param parser Binary tuple parser.
+ * @param typ Column type.
+ * @return Column value.
+ */
+[[nodiscard]] primitive read_next_column(binary_tuple_parser &parser, column_type typ);
+
+} // namespace ignite::detail
diff --git a/modules/platforms/cpp/ignite/client/ignite_client.cpp b/modules/platforms/cpp/ignite/client/ignite_client.cpp
index db44f6be28..649e3119cd 100644
--- a/modules/platforms/cpp/ignite/client/ignite_client.cpp
+++ b/modules/platforms/cpp/ignite/client/ignite_client.cpp
@@ -70,6 +70,10 @@ tables ignite_client::get_tables() const noexcept {
     return tables(impl().get_tables_impl());
 }
 
+sql ignite_client::get_sql() const noexcept {
+    return sql(impl().get_sql_impl());
+}
+
 detail::ignite_client_impl &ignite_client::impl() noexcept {
     return *((detail::ignite_client_impl *) (m_impl.get()));
 }
diff --git a/modules/platforms/cpp/ignite/client/ignite_client.h b/modules/platforms/cpp/ignite/client/ignite_client.h
index 0d90668c91..d3e143f914 100644
--- a/modules/platforms/cpp/ignite/client/ignite_client.h
+++ b/modules/platforms/cpp/ignite/client/ignite_client.h
@@ -17,12 +17,14 @@
 
 #pragma once
 
-#include <ignite/client/ignite_client_configuration.h>
-#include <ignite/client/table/tables.h>
+#include "ignite/client/ignite_client_configuration.h"
+#include "ignite/client/table/tables.h"
+#include "ignite/client/sql/sql.h"
 
-#include <ignite/common/config.h>
-#include <ignite/common/ignite_result.h>
+#include "ignite/common/config.h"
+#include "ignite/common/ignite_result.h"
 
+#include <chrono>
 #include <functional>
 #include <memory>
 
@@ -50,7 +52,7 @@ public:
     ignite_client &operator=(const ignite_client &) = delete;
 
     /**
-     * Start client asynchronously.
+     * Starts client asynchronously.
      *
      * Client tries to establish connection to every endpoint. First endpoint is
      * selected randomly. After that round-robin is used to determine the next
@@ -73,7 +75,7 @@ public:
         ignite_callback<ignite_client> callback);
 
     /**
-     * Start client synchronously.
+     * Starts client synchronously.
      *
      * @see start_async for details.
      *
@@ -81,22 +83,30 @@ public:
      * @param timeout Operation timeout.
      * @return ignite_client instance.
      */
-    IGNITE_API static ignite_client start(ignite_client_configuration configuration, std::chrono::milliseconds timeout);
+    [[nodiscard]] IGNITE_API static ignite_client start(
+        ignite_client_configuration configuration, std::chrono::milliseconds timeout);
 
     /**
-     * Get client configuration.
+     * Gets client configuration.
      *
      * @return Configuration.
      */
     [[nodiscard]] IGNITE_API const ignite_client_configuration &configuration() const noexcept;
 
     /**
-     * Get the table API.
+     * Gets the table API.
      *
      * @return Table API.
      */
     [[nodiscard]] IGNITE_API tables get_tables() const noexcept;
 
+    /**
+     * Gets the SQL API.
+     *
+     * @return SQL API.
+     */
+    [[nodiscard]] IGNITE_API sql get_sql() const noexcept;
+
 private:
     /**
      * Constructor
diff --git a/modules/platforms/cpp/ignite/client/primitive.h b/modules/platforms/cpp/ignite/client/primitive.h
new file mode 100644
index 0000000000..b3c0c0d835
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/primitive.h
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#pragma once
+
+#include "ignite/client/sql/sql_column_type.h"
+#include "ignite/common/ignite_error.h"
+#include "ignite/common/uuid.h"
+
+#include <cstdint>
+#include <variant>
+#include <vector>
+#include <type_traits>
+
+namespace ignite {
+
+/**
+ * Ignite primitive type.
+ */
+class primitive {
+public:
+    // Default
+    primitive() = default;
+
+    /**
+     * Constructor for boolean value.
+     *
+     * @param value Value.
+     */
+    primitive(bool value) : m_value(value) {} // NOLINT(google-explicit-constructor)
+
+    /**
+     * Constructor for std::int8_t value.
+     *
+     * @param value Value.
+     */
+    primitive(std::int8_t value) : m_value(value) {} // NOLINT(google-explicit-constructor)
+
+    /**
+     * Constructor for std::int16_t value.
+     *
+     * @param value Value.
+     */
+    primitive(std::int16_t value) : m_value(value) {} // NOLINT(google-explicit-constructor)
+
+    /**
+     * Constructor for std::int32_t value.
+     *
+     * @param value Value.
+     */
+    primitive(std::int32_t value) : m_value(value) {} // NOLINT(google-explicit-constructor)
+
+    /**
+     * Constructor for std::int64_t value.
+     *
+     * @param value Value.
+     */
+    primitive(std::int64_t value) : m_value(value) {} // NOLINT(google-explicit-constructor)
+
+    /**
+     * Constructor for float value.
+     *
+     * @param value Value.
+     */
+    primitive(float value) : m_value(value) {} // NOLINT(google-explicit-constructor)
+
+    /**
+     * Constructor for double value.
+     *
+     * @param value Value.
+     */
+    primitive(double value) : m_value(value) {} // NOLINT(google-explicit-constructor)
+
+    /**
+     * Constructor for UUID value.
+     *
+     * @param value Value.
+     */
+    primitive(uuid value) : m_value(value) {} // NOLINT(google-explicit-constructor)
+
+    /**
+     * Constructor for string value.
+     *
+     * @param value Value.
+     */
+    primitive(std::string value) : m_value(std::move(value)) {} // NOLINT(google-explicit-constructor)
+
+    /**
+     * Constructor for byte array value.
+     *
+     * @param value Value.
+     */
+    primitive(std::vector<std::byte> value) : m_value(std::move(value)) {} // NOLINT(google-explicit-constructor)
+
+    /**
+     * Constructor for byte array value.
+     *
+     * @param buf Buffer.
+     * @param len Buffer length.
+     */
+    primitive(std::byte* buf, std::size_t len) : m_value(std::vector<std::byte>(buf, buf + len)) {}
+
+    /**
+     * Get underlying value.
+     *
+     * @tparam T Type of value to try and get.
+     * @return Value of the specified type.
+     * @throw ignite_error if primitive contains value of any other type.
+     */
+    template<typename T>
+    [[nodiscard]] const T& get() const {
+        if constexpr (
+                std::is_same_v<T, bool> ||
+                std::is_same_v<T, std::int8_t> ||
+                std::is_same_v<T, std::int16_t> ||
+                std::is_same_v<T, std::int32_t> ||
+                std::is_same_v<T, std::int64_t> ||
+                std::is_same_v<T, float> ||
+                std::is_same_v<T, double> ||
+                std::is_same_v<T, uuid> ||
+                std::is_same_v<T, std::string> ||
+                std::is_same_v<T, std::vector<std::byte>>) {
+            return std::get<T>(m_value);
+        } else {
+            static_assert(sizeof(T) == 0, "Type is not an Ignite primitive type or is not yet supported");
+        }
+    }
+
+    /**
+     * Get primitive type.
+     *
+     * @return Primitive type.
+     */
+    [[nodiscard]] column_type get_type() const {
+        // TODO: Ensure by tests
+        return static_cast<column_type>(m_value.index());
+    }
+
+private:
+    /** Unsupported type. */
+    typedef void *unsupported_type;
+
+    /** Value type. */
+    typedef std::variant<
+                bool,
+                std::int8_t,
+                std::int16_t,
+                std::int32_t,
+                std::int64_t,
+                float,
+                double,
+                unsupported_type, // Decimal
+                unsupported_type, // Date
+                unsupported_type, // Time
+                unsupported_type, // Datetime
+                unsupported_type, // Timestamp
+                uuid,
+                unsupported_type, // Bitmask
+                std::string,
+                std::vector<std::byte>,
+                unsupported_type, // Period
+                unsupported_type, // Duration
+                unsupported_type  // Number
+            > value_type;
+
+    /** Value. */
+    value_type m_value;
+};
+
+} // namespace ignite
diff --git a/modules/platforms/cpp/ignite/client/sql/column_metadata.h b/modules/platforms/cpp/ignite/client/sql/column_metadata.h
new file mode 100644
index 0000000000..466468641f
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/sql/column_metadata.h
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#pragma once
+
+#include "ignite/client/sql/sql_column_type.h"
+#include "ignite/client/sql/column_origin.h"
+
+#include <string>
+#include <cstdint>
+
+namespace ignite {
+
+/**
+ * Column metadata.
+ */
+class column_metadata {
+public:
+    // Default
+    column_metadata() = default;
+
+    /**
+     * Constructor.
+     *
+     * @param name Column name.
+     * @param type Column type.
+     * @param precision Precision.
+     * @param scale Scale.
+     * @param nullable Column nullability.
+     * @param origin Column origin.
+     */
+    column_metadata(std::string name, column_type type, std::int32_t precision, std::int32_t scale,
+        bool nullable, column_origin origin)
+        : m_name(std::move(name))
+        , m_type(type)
+        , m_precision(precision)
+        , m_scale(scale)
+        , m_nullable(nullable)
+        , m_origin(std::move(origin)) { }
+
+    /**
+     * Gets the column name.
+     *
+     * @return Column name.
+     */
+    [[nodiscard]] const std::string &name() const { return m_name; }
+
+    /**
+     * Gets the column type.
+     *
+     * @return Column type.
+     */
+    [[nodiscard]] column_type type() const { return m_type; }
+
+    /**
+     * Gets the column precision, or -1 when not applicable to the current
+     * column type.
+     *
+     * @return Number of decimal digits for exact numeric types; number of
+     *   decimal digits in mantissa for approximate numeric types; number of
+     *   decimal digits for fractional seconds of datetime types; length in
+     *   characters for character types; length in bytes for binary types;
+     *   length in bits for bit types; 1 for BOOLEAN; -1 if precision is not
+     *   valid for the type.
+     */
+    [[nodiscard]] std::int32_t precision() const { return m_precision; }
+
+    /**
+     * Gets the column scale.
+     *
+     * @return Number of digits of scale.
+     */
+    [[nodiscard]] std::int32_t scale() const { return m_scale; }
+
+    /**
+     * Gets a value indicating whether the column is nullable.
+     *
+     * @return A value indicating whether the column is nullable.
+     */
+    [[nodiscard]] bool nullable() const { return m_nullable; }
+
+    /**
+     * Gets the column origin.
+     *
+     * For example, for "select foo as bar" query, column name will be "bar", but origin name will be "foo".
+     *
+     * @return The column origin.
+     */
+    [[nodiscard]] const column_origin &origin() const { return m_origin; }
+
+private:
+    /** Column name. */
+    std::string m_name;
+
+    /** Column type. */
+    column_type m_type{column_type::UNDEFINED};
+
+    /** Precision. */
+    std::int32_t m_precision{0};
+
+    /** Scale. */
+    std::int32_t m_scale{0};
+
+    /** Nullable. */
+    bool m_nullable{false};
+
+    /** Origin. */
+    column_origin m_origin;
+};
+
+} // namespace ignite
diff --git a/modules/platforms/cpp/ignite/client/sql/column_origin.h b/modules/platforms/cpp/ignite/client/sql/column_origin.h
new file mode 100644
index 0000000000..8efb70019e
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/sql/column_origin.h
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#pragma once
+
+#include <string>
+
+namespace ignite {
+
+/**
+ * SQL column origin.
+ */
+class column_origin {
+public:
+    // Default
+    column_origin() = default;
+
+    /**
+     * Constructor.
+     *
+     * @param column_name Column name.
+     * @param table_name Table name
+     * @param schema_name Schema name.
+     */
+    column_origin(std::string column_name, std::string table_name, std::string schema_name)
+        : m_column_name(std::move(column_name))
+        , m_table_name(std::move(table_name))
+        , m_schema_name(std::move(schema_name)) {}
+
+    /**
+     * Gets the column name.
+     *
+     * @return Column name.
+     */
+    [[nodiscard]] const std::string &column_name() const { return m_column_name; }
+
+    /**
+     * Gets the table name.
+     *
+     * @return Table name.
+     */
+    [[nodiscard]] const std::string &table_name() const { return m_table_name; }
+
+    /**
+     * Gets the schema name.
+     *
+     * @return Schema name.
+     */
+    [[nodiscard]] const std::string &schema_name() const { return m_schema_name; }
+
+private:
+    /** Column name. */
+    std::string m_column_name;
+
+    /** Table name. */
+    std::string m_table_name;
+
+    /** Schema name. */
+    std::string m_schema_name;
+};
+
+} // namespace ignite
diff --git a/modules/platforms/cpp/ignite/client/sql/result_set.cpp b/modules/platforms/cpp/ignite/client/sql/result_set.cpp
new file mode 100644
index 0000000000..78513b2250
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/sql/result_set.cpp
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#include "ignite/client/sql/result_set.h"
+#include "ignite/client/detail/sql/result_set_impl.h"
+
+namespace ignite {
+
+const result_set_metadata& result_set::metadata() const {
+    return m_impl->metadata();
+}
+
+bool result_set::has_rowset() const {
+    return m_impl->has_rowset();
+}
+
+std::int64_t result_set::affected_rows() const {
+    return m_impl->affected_rows();
+}
+
+bool result_set::was_applied() const {
+    return m_impl->was_applied();
+}
+
+bool result_set::close_async(std::function<void(ignite_result<void>)> callback) {
+    return m_impl->close_async(std::move(callback));
+}
+
+bool result_set::close() {
+    return m_impl->close();
+}
+
+std::vector<ignite_tuple> result_set::current_page() {
+    return m_impl->current_page();
+}
+
+bool result_set::has_more_pages() {
+    return m_impl->has_more_pages();
+}
+
+void result_set::fetch_next_page_async(std::function<void(ignite_result<void>)> callback) {
+    m_impl->fetch_next_page_async(std::move(callback));
+}
+
+} // namespace ignite
diff --git a/modules/platforms/cpp/ignite/client/sql/result_set.h b/modules/platforms/cpp/ignite/client/sql/result_set.h
new file mode 100644
index 0000000000..fe23d520ce
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/sql/result_set.h
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#pragma once
+
+#include "ignite/client/sql/result_set_metadata.h"
+#include "ignite/client/table/ignite_tuple.h"
+#include "ignite/common/ignite_result.h"
+#include "ignite/common/config.h"
+
+#include <memory>
+#include <functional>
+
+namespace ignite {
+
+namespace detail {
+class result_set_impl;
+}
+
+/**
+ * Query result set.
+ */
+class result_set {
+public:
+    // Default
+    result_set() = default;
+
+    /**
+     * Constructor
+     *
+     * @param impl Implementation
+     */
+    explicit result_set(std::shared_ptr<detail::result_set_impl> impl)
+            : m_impl(std::move(impl)) {}
+
+    /**
+     * Gets metadata.
+     *
+     * @return Metadata.
+     */
+    [[nodiscard]] IGNITE_API const result_set_metadata& metadata() const;
+
+    /**
+     * Gets a value indicating whether this result set contains a collection of rows.
+     *
+     * @return A value indicating whether this result set contains a collection of rows.
+     */
+    [[nodiscard]] IGNITE_API bool has_rowset() const;
+
+    /**
+     * Gets the number of rows affected by the DML statement execution (such as "INSERT", "UPDATE", etc.), or 0 if
+     * the statement returns nothing (such as "ALTER TABLE", etc), or -1 if not applicable.
+     *
+     * @return The number of rows affected by the DML statement execution.
+     */
+    [[nodiscard]] IGNITE_API std::int64_t affected_rows() const;
+
+    /**
+     * Gets a value indicating whether a conditional query (such as "CREATE TABLE IF NOT EXISTS") was applied
+     * successfully.
+     *
+     * @return A value indicating whether a conditional query was applied successfully.
+     */
+    [[nodiscard]] IGNITE_API bool was_applied() const;
+
+    /**
+     * Close result set asynchronously.
+     *
+     * @param callback Callback to call on completion.
+     * @return @c true if the request was sent, and false if the result set was already closed.
+     */
+    IGNITE_API bool close_async(std::function<void(ignite_result<void>)> callback);
+
+    /**
+     * Close result set synchronously.
+     *
+     * @return @c true if the request was sent, and false if the result set was already closed.
+     */
+    IGNITE_API bool close();
+
+    /**
+     * Retrieves current page.
+     * Result set is left empty after this operation and will return empty page on subsequent request
+     * unless there are more available pages and you call @c fetch_next_page().
+     *
+     * @return Current page.
+     */
+    [[nodiscard]] IGNITE_API std::vector<ignite_tuple> current_page();
+
+    /**
+     * Checks whether there are more pages of results.
+     *
+     * @return @c true if there are more pages with results and @c false otherwise.
+     */
+    [[nodiscard]] IGNITE_API bool has_more_pages();
+
+    /**
+     * Fetch the next page of results asynchronously.
+     * The current page is changed after the operation is complete.
+     *
+     * @param callback Callback to call on completion.
+     */
+    IGNITE_API void fetch_next_page_async(std::function<void(ignite_result<void>)> callback);
+
+    /**
+     * Fetch the next page of results synchronously.
+     * The current page is changed after the operation is complete.
+     */
+    IGNITE_API void fetch_next_page() {
+        return sync<void>([this](auto callback) mutable {
+            fetch_next_page_async(std::move(callback));
+        });
+    }
+
+private:
+    /** Implementation. */
+    std::shared_ptr<detail::result_set_impl> m_impl;
+};
+
+} // namespace ignite
diff --git a/modules/platforms/cpp/ignite/client/sql/result_set_metadata.h b/modules/platforms/cpp/ignite/client/sql/result_set_metadata.h
new file mode 100644
index 0000000000..aa806c7f29
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/sql/result_set_metadata.h
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#pragma once
+
+#include "ignite/client/sql/column_metadata.h"
+
+#include <string>
+#include <vector>
+#include <unordered_map>
+
+namespace ignite {
+
+/**
+ * SQL result set metadata.
+ */
+class result_set_metadata {
+public:
+    // Default
+    result_set_metadata() = default;
+
+    /**
+     * Constructor.
+     *
+     * @param columns Columns.
+     */
+    result_set_metadata(std::vector<column_metadata> columns) : m_columns(std::move(columns)) {}
+
+    /**
+     * Gets the columns in the same order as they appear in the result set data.
+     *
+     * @return The columns metadata.
+     */
+    [[nodiscard]] const std::vector<column_metadata>& columns() const { return m_columns; }
+
+    /**
+     * Gets the index of the specified column, or -1 when there is no column
+     * with the specified name.
+     *
+     * @param name The column name.
+     * @return Column index.
+     */
+    [[nodiscard]] std::int32_t index_of(const std::string& name) const {
+        if (m_indices.empty()) {
+            for (size_t i = 0; i < m_columns.size(); ++i) {
+                m_indices[m_columns[i].name()] = i;
+            }
+        }
+
+        auto it = m_indices.find(name);
+        if (it == m_indices.end())
+            return -1;
+        return std::int32_t(it->second);
+    }
+
+private:
+    /** Columns metadata. */
+    std::vector<column_metadata> m_columns;
+
+    /** Indices of the columns corresponding to their names. */
+    mutable std::unordered_map<std::string, size_t> m_indices;
+};
+
+} // namespace ignite
diff --git a/modules/platforms/cpp/tests/client-test/ignite_runner_suite.h b/modules/platforms/cpp/ignite/client/sql/sql.cpp
similarity index 60%
copy from modules/platforms/cpp/tests/client-test/ignite_runner_suite.h
copy to modules/platforms/cpp/ignite/client/sql/sql.cpp
index b72ffe2bd5..6ff7cff94d 100644
--- a/modules/platforms/cpp/tests/client-test/ignite_runner_suite.h
+++ b/modules/platforms/cpp/ignite/client/sql/sql.cpp
@@ -15,32 +15,14 @@
  * limitations under the License.
  */
 
-#pragma once
-
-#include "gtest_logger.h"
-
-#include <gtest/gtest.h>
-
-#include <memory>
-#include <string_view>
+#include "ignite/client/sql/sql.h"
+#include "ignite/client/detail/sql/sql_impl.h"
 
 namespace ignite {
 
-using namespace std::string_view_literals;
-
-/**
- * Test suite.
- */
-class ignite_runner_suite : public ::testing::Test {
-protected:
-    static constexpr std::initializer_list<std::string_view> NODE_ADDRS = {"127.0.0.1:10942"sv, "127.0.0.1:10943"sv};
-
-    /**
-     * Get logger.
-     *
-     * @return Logger for tests.
-     */
-    static std::shared_ptr<gtest_logger> get_logger() { return std::make_shared<gtest_logger>(false, true); }
-};
+void sql::execute_async(
+    transaction *tx, const sql_statement &statement, std::vector<primitive> args, ignite_callback<result_set> callback) {
+    m_impl->execute_async(tx, statement, std::move(args), std::move(callback));
+}
 
 } // namespace ignite
diff --git a/modules/platforms/cpp/ignite/client/sql/sql.h b/modules/platforms/cpp/ignite/client/sql/sql.h
new file mode 100644
index 0000000000..f12b87053f
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/sql/sql.h
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#pragma once
+
+#include "ignite/client/transaction/transaction.h"
+#include "ignite/client/sql/sql_statement.h"
+#include "ignite/client/sql/result_set.h"
+#include "ignite/client/primitive.h"
+#include "ignite/common/config.h"
+#include "ignite/common/ignite_result.h"
+
+#include <memory>
+#include <utility>
+
+namespace ignite {
+
+namespace detail {
+class sql_impl;
+}
+
+/**
+ * Ignite SQL query facade.
+ */
+class sql {
+    friend class ignite_client;
+public:
+    // Default
+    sql() = default;
+
+    /**
+     * Executes single SQL statement asynchronously and returns rows.
+     *
+     * @param tx Optional transaction. If nullptr implicit transaction for this single operation is used.
+     * @param statement Statement to execute.
+     * @param args Arguments for the statement.
+     * @param callback A callback called on operation completion with SQL result set.
+     */
+    IGNITE_API void execute_async(
+        transaction *tx, const sql_statement &statement, std::vector<primitive> args, ignite_callback<result_set> callback);
+
+    /**
+     * Executes single SQL statement and returns rows.
+     *
+     * @param tx Optional transaction. If nullptr implicit transaction for this single operation is used.
+     * @param statement Statement to execute.
+     * @param args Arguments for the statement.
+     * @return SQL result set.
+     */
+    IGNITE_API result_set execute(transaction *tx, const sql_statement &statement, std::vector<primitive> args)  {
+        return sync<result_set>([this, tx, &statement, args = std::move(args)](auto callback) mutable {
+            execute_async(tx, statement, std::move(args), std::move(callback));
+        });
+    }
+
+private:
+    /**
+     * Constructor
+     *
+     * @param impl Implementation
+     */
+    explicit sql(std::shared_ptr<detail::sql_impl> impl)
+        : m_impl(std::move(impl)) {}
+
+    /** Implementation. */
+    std::shared_ptr<detail::sql_impl> m_impl;
+};
+
+} // namespace ignite
diff --git a/modules/platforms/cpp/ignite/client/sql/sql_column_type.h b/modules/platforms/cpp/ignite/client/sql/sql_column_type.h
new file mode 100644
index 0000000000..74e4ebb3f2
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/sql/sql_column_type.h
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#pragma once
+
+namespace ignite {
+
+/**
+ * SQL column type.
+ */
+enum class column_type {
+    /** Boolean. */
+    BOOLEAN = 0,
+
+    /** 8-bit signed integer. */
+    INT8 = 1,
+
+    /** 16-bit signed integer. */
+    INT16 = 2,
+
+    /** 32-bit signed integer. */
+    INT32 = 3,
+
+    /** 64-bit signed integer. */
+    INT64 = 4,
+
+    /** 32-bit single-precision floating-point number. */
+    FLOAT = 5,
+
+    /** 64-bit double-precision floating-point number. */
+    DOUBLE = 6,
+
+    /** A decimal floating-point number. */
+    DECIMAL = 7,
+
+    /** Timezone-free date. */
+    DATE = 8,
+
+    /** Timezone-free time with precision. */
+    TIME = 9,
+
+    /** Timezone-free datetime. */
+    DATETIME = 10,
+
+    /** Number of ticks since Jan 1, 1970 00:00:00.000 (with no timezone). Tick unit depends on precision. */
+    TIMESTAMP = 11,
+
+    /** 128-bit UUID. */
+    UUID = 12,
+
+    /** Bit mask. */
+    BITMASK = 13,
+
+    /** String. */
+    STRING = 14,
+
+    /** Binary data. */
+    BYTE_ARRAY = 15,
+
+    /** Date interval. */
+    PERIOD = 16,
+
+    /** Time interval. */
+    DURATION = 17,
+
+    /** Number. */
+    NUMBER = 18,
+
+    /** Undefined. */
+    UNDEFINED
+};
+
+} // namespace ignite
diff --git a/modules/platforms/cpp/ignite/client/sql/sql_statement.h b/modules/platforms/cpp/ignite/client/sql/sql_statement.h
new file mode 100644
index 0000000000..d0ca4360e5
--- /dev/null
+++ b/modules/platforms/cpp/ignite/client/sql/sql_statement.h
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#pragma once
+
+#include "ignite/client/primitive.h"
+
+#include <chrono>
+#include <string>
+#include <unordered_map>
+#include <cstdint>
+
+namespace ignite {
+
+/**
+ * Column metadata.
+ */
+class sql_statement {
+public:
+    /** Default SQL schema name. */
+    static constexpr const char* DEFAULT_SCHEMA{"PUBLIC"};
+
+    /** Default number of rows per data page. */
+    static constexpr std::int32_t DEFAULT_PAGE_SIZE{1024};
+
+    /** Default query timeout (zero means no timeout). */
+    static constexpr std::chrono::milliseconds DEFAULT_TIMEOUT{0};
+
+    // Default
+    sql_statement() = default;
+
+    /**
+     * Constructor.
+     *
+     * @param query Query text.
+     * @param timeout Timeout.
+     * @param schema Schema.
+     * @param page_size Page size.
+     * @param properties Properties list.
+     */
+    sql_statement( // NOLINT(google-explicit-constructor)
+            std::string query, std::chrono::milliseconds timeout = DEFAULT_TIMEOUT,
+            std::string schema = DEFAULT_SCHEMA, std::int32_t page_size = DEFAULT_PAGE_SIZE,
+            std::initializer_list<std::pair<const std::string, primitive>> properties = {})
+        : m_query(std::move(query))
+        , m_timeout(timeout)
+        , m_schema(std::move(schema))
+        , m_page_size(page_size)
+        , m_properties(properties) { }
+
+    /**
+     * Gets the query text.
+     *
+     * @return Query text.
+     */
+    [[nodiscard]] const std::string &query() const { return m_query; }
+
+    /**
+     * Sets the query text.
+     *
+     * @param val Query text.
+     */
+    void query(std::string val) { m_query = std::move(val); }
+
+    /**
+     * Gets the query timeout (zero means no timeout).
+     *
+     * @return Query timeout (zero means no timeout).
+     */
+    [[nodiscard]] std::chrono::milliseconds timeout() const { return m_timeout; }
+
+    /**
+     * Sets the query timeout (zero means no timeout).
+     *
+     * @param val Query timeout (zero means no timeout).
+     */
+    void timeout(std::chrono::milliseconds val) { m_timeout = val; }
+
+    /**
+     * Gets the SQL schema name.
+     *
+     * @return Schema name.
+     */
+    [[nodiscard]] const std::string &schema() const { return m_schema; }
+
+    /**
+     * Sets the SQL schema name.
+     *
+     * @param val Schema name.
+     */
+    void schema(std::string val) { m_schema = std::move(val); }
+
+    /**
+     * Gets the number of rows per data page.
+     *
+     * @return Number of rows per data page.
+     */
+    [[nodiscard]] std::int32_t page_size() const { return m_page_size; }
+
+    /**
+     * Sets the number of rows per data page.
+     *
+     * @param val Number of rows per data page.
+     */
+    void page_size(std::int32_t val) { m_page_size = val; }
+
+    /**
+     * Gets the statement properties.
+     *
+     * @return Properties.
+     */
+    [[nodiscard]] const std::unordered_map<std::string, primitive>& properties() const { return m_properties; }
+
+    /**
+     * Sets the statement properties.
+     *
+     * @param val Properties.
+     */
+    void properties(std::initializer_list<std::pair<const std::string, primitive>> val) { m_properties = val; }
+
+private:
+    /** Query text. */
+    std::string m_query;
+
+    /** Timeout. */
+    std::chrono::milliseconds m_timeout{DEFAULT_TIMEOUT};
+
+    /** Schema. */
+    std::string m_schema{DEFAULT_SCHEMA};
+
+    /** Page size. */
+    std::int32_t m_page_size{DEFAULT_PAGE_SIZE};
+
+    /** Properties. */
+    std::unordered_map<std::string, primitive> m_properties;
+};
+
+} // namespace ignite
diff --git a/modules/platforms/cpp/ignite/client/table/ignite_tuple.h b/modules/platforms/cpp/ignite/client/table/ignite_tuple.h
index 11cb8bf0cd..a3933163c3 100644
--- a/modules/platforms/cpp/ignite/client/table/ignite_tuple.h
+++ b/modules/platforms/cpp/ignite/client/table/ignite_tuple.h
@@ -17,10 +17,9 @@
 
 #pragma once
 
-#include "ignite/common/config.h"
+#include "ignite/client/primitive.h"
 #include "ignite/common/ignite_error.h"
 
-#include <any>
 #include <initializer_list>
 #include <string_view>
 #include <unordered_map>
@@ -29,14 +28,10 @@
 
 namespace ignite {
 
-class ignite_tuple_builder;
-
 /**
  * Ignite tuple.
  */
 class ignite_tuple {
-    friend class ignite_tuple_builder;
-
 public:
     // Default
     ignite_tuple() = default;
@@ -53,7 +48,7 @@ public:
      *
      * @param pairs Pairs.
      */
-    ignite_tuple(std::initializer_list<std::pair<std::string, std::any>> pairs)
+    ignite_tuple(std::initializer_list<std::pair<std::string, primitive>> pairs)
         : m_pairs(pairs)
         , m_indices() {
         for (size_t i = 0; i < m_pairs.size(); ++i)
@@ -73,7 +68,7 @@ public:
      * @param idx The column index.
      * @return Column value.
      */
-    [[nodiscard]] const std::any &get(uint32_t idx) const {
+    [[nodiscard]] const primitive &get(uint32_t idx) const {
         if (idx > m_pairs.size()) {
             throw ignite_error(
                 "Index is too large: idx=" + std::to_string(idx) + ", columns_num=" + std::to_string(m_pairs.size()));
@@ -90,7 +85,7 @@ public:
      */
     template<typename T>
     [[nodiscard]] T get(uint32_t idx) const {
-        return std::any_cast<T>(get(idx));
+        return get(idx).template get<T>();
     }
 
     /**
@@ -115,7 +110,7 @@ public:
      * @param name The column name.
      * @return Column value.
      */
-    [[nodiscard]] const std::any &get(std::string_view name) const {
+    [[nodiscard]] const primitive &get(std::string_view name) const {
         auto it = m_indices.find(parse_name(name));
         if (it == m_indices.end())
             throw ignite_error("Can not find column with the name '" + std::string(name) + "' in the tuple");
@@ -132,7 +127,7 @@ public:
      */
     template<typename T>
     [[nodiscard]] T get(std::string_view name) const {
-        return std::any_cast<T>(get(name));
+        return get(name).template get<T>();
     }
 
     /**
@@ -175,13 +170,13 @@ public:
      * the column with the given name does not exist.
      *
      * @param name The column name.
-     * @return Column name.
+     * @return Column index.
      */
-    [[nodiscard]] int32_t column_ordinal(std::string_view name) const {
+    [[nodiscard]] std::int32_t column_ordinal(std::string_view name) const {
         auto it = m_indices.find(parse_name(name));
         if (it == m_indices.end())
             return -1;
-        return int32_t(it->second);
+        return std::int32_t(it->second);
     }
 
 private:
@@ -191,7 +186,7 @@ private:
      * @param pairs Pairs.
      * @param indices Indices.
      */
-    ignite_tuple(std::vector<std::pair<std::string, std::any>> &&pairs, std::unordered_map<std::string, size_t> indices)
+    ignite_tuple(std::vector<std::pair<std::string, primitive>> &&pairs, std::unordered_map<std::string, size_t> indices)
         : m_pairs(std::move(pairs))
         , m_indices(std::move(indices)) {}
 
@@ -219,10 +214,10 @@ private:
     }
 
     /** Pairs of column names and values. */
-    std::vector<std::pair<std::string, std::any>> m_pairs;
+    std::vector<std::pair<std::string, primitive>> m_pairs;
 
     /** Indices of the columns corresponding to their names. */
-    std::unordered_map<std::string, size_t> m_indices;
+    std::unordered_map<std::string, std::size_t> m_indices;
 };
 
 } // namespace ignite
diff --git a/modules/platforms/cpp/ignite/client/table/tables.cpp b/modules/platforms/cpp/ignite/client/table/tables.cpp
index 54f6c6602d..6796c405cc 100644
--- a/modules/platforms/cpp/ignite/client/table/tables.cpp
+++ b/modules/platforms/cpp/ignite/client/table/tables.cpp
@@ -15,13 +15,9 @@
  * limitations under the License.
  */
 
-#include "tables.h"
-
+#include "ignite/client/table/tables.h"
 #include "ignite/client/detail/table/tables_impl.h"
 
-#include <future>
-#include <utility>
-
 namespace ignite {
 
 std::optional<table> tables::get_table(std::string_view name) {
diff --git a/modules/platforms/cpp/ignite/client/table/tables.h b/modules/platforms/cpp/ignite/client/table/tables.h
index b220ce4dce..712cd50c9c 100644
--- a/modules/platforms/cpp/ignite/client/table/tables.h
+++ b/modules/platforms/cpp/ignite/client/table/tables.h
@@ -46,13 +46,6 @@ class tables {
 public:
     // Default
     tables() = default;
-    ~tables() = default;
-    tables(tables &&) = default;
-    tables &operator=(tables &&) = default;
-
-    // Deleted
-    tables(const tables &) = delete;
-    tables &operator=(const tables &) = delete;
 
     /**
      * Gets a table by name, if it was created before.
diff --git a/modules/platforms/cpp/ignite/protocol/reader.cpp b/modules/platforms/cpp/ignite/protocol/reader.cpp
index 951ef0e53a..24ddcc18c1 100644
--- a/modules/platforms/cpp/ignite/protocol/reader.cpp
+++ b/modules/platforms/cpp/ignite/protocol/reader.cpp
@@ -17,21 +17,14 @@
 
 #include "ignite/protocol/reader.h"
 
-#include <ignite/common/bytes.h>
 #include <ignite/protocol/utils.h>
 
 namespace ignite::protocol {
 
 reader::reader(bytes_view buffer)
     : m_buffer(buffer)
-    , m_unpacker()
     , m_current_val()
     , m_move_res(MSGPACK_UNPACK_SUCCESS) {
-    // TODO: Research if we can get rid of copying here.
-    msgpack_unpacker_init(&m_unpacker, MSGPACK_UNPACKER_INIT_BUFFER_SIZE);
-    msgpack_unpacker_reserve_buffer(&m_unpacker, m_buffer.size());
-    memcpy(msgpack_unpacker_buffer(&m_unpacker), m_buffer.data(), m_buffer.size());
-    msgpack_unpacker_buffer_consumed(&m_unpacker, m_buffer.size());
 
     msgpack_unpacked_init(&m_current_val);
 
@@ -49,7 +42,9 @@ bool reader::try_read_nil() {
 void reader::next() {
     check_data_in_stream();
 
-    m_move_res = msgpack_unpacker_next(&m_unpacker, &m_current_val);
+    m_offset = m_offset_next;
+    m_move_res = msgpack_unpack_next(
+            &m_current_val, reinterpret_cast<const char *>(m_buffer.data()), m_buffer.size(), &m_offset_next);
 }
 
 } // namespace ignite::protocol
diff --git a/modules/platforms/cpp/ignite/protocol/reader.h b/modules/platforms/cpp/ignite/protocol/reader.h
index f5f44eb001..2b86956ad1 100644
--- a/modules/platforms/cpp/ignite/protocol/reader.h
+++ b/modules/platforms/cpp/ignite/protocol/reader.h
@@ -51,7 +51,7 @@ public:
     /**
      * Destructor.
      */
-    ~reader() { msgpack_unpacker_destroy(&m_unpacker); }
+    ~reader() { msgpack_unpacked_destroy(&m_current_val); }
 
     /**
      * Read object of type T from msgpack stream.
@@ -70,6 +70,24 @@ public:
         return res;
     }
 
+    /**
+     * Read object of type T from msgpack stream.
+     *
+     * @tparam T Type of the object to read.
+     * @return Object of type T or @c nullopt if there is object of other type in the stream.
+     * @throw ignite_error if there is no data left in the stream.
+     */
+    template<typename T>
+    [[nodiscard]] std::optional<T> try_read_object() {
+        check_data_in_stream();
+
+        auto res = try_unpack_object<T>(m_current_val.data);
+        if (res)
+            next();
+
+        return res;
+    }
+
     /**
      * Read object of type T from msgpack stream or nil.
      *
@@ -115,6 +133,13 @@ public:
      */
     [[nodiscard]] std::int32_t read_int32() { return read_object<std::int32_t>(); }
 
+    /**
+     * Read int32 or nullopt.
+     *
+     * @return Value or nullopt if the next value in stream is not integer.
+     */
+    [[nodiscard]] std::optional<std::int32_t> try_read_int32() { return try_read_object<std::int32_t>(); }
+
     /**
      * Read int64 number.
      *
@@ -211,10 +236,10 @@ public:
      *
      * @param read_func Object read function.
      */
-    void read_array_raw(const std::function<void(const msgpack_object &)> &read_func) {
+    void read_array_raw(const std::function<void(std::uint32_t idx, const msgpack_object &)> &read_func) {
         auto size = read_array_size();
         for (std::uint32_t i = 0; i < size; ++i) {
-            read_func(m_current_val.data.via.array.ptr[i]);
+            read_func(i, m_current_val.data.via.array.ptr[i]);
         }
         next();
     }
@@ -272,6 +297,15 @@ public:
      */
     void skip() { next(); }
 
+    /**
+     * Position.
+     *
+     * @return Current position in memory.
+     */
+    [[nodiscard]] size_t position() const {
+        return m_offset;
+    }
+
 private:
     /**
      * Move to the next value.
@@ -289,14 +323,17 @@ private:
     /** Buffer. */
     bytes_view m_buffer;
 
-    /** Unpacker. */
-    msgpack_unpacker m_unpacker;
-
     /** Current value. */
     msgpack_unpacked m_current_val;
 
     /** Result of the last move operation. */
     msgpack_unpack_return m_move_res;
+
+    /** Offset to next value. */
+    size_t m_offset_next{0};
+
+    /** Offset. */
+    size_t m_offset{0};
 };
 
 } // namespace ignite::protocol
diff --git a/modules/platforms/cpp/ignite/protocol/utils.cpp b/modules/platforms/cpp/ignite/protocol/utils.cpp
index 62888dde9f..841e8e8e63 100644
--- a/modules/platforms/cpp/ignite/protocol/utils.cpp
+++ b/modules/platforms/cpp/ignite/protocol/utils.cpp
@@ -28,6 +28,55 @@
 
 namespace ignite::protocol {
 
+/**
+ * Check if int value fits in @c T.
+ *
+ * @tparam T Int type to fit value to.
+ * @param value Int value.
+ */
+template<typename T>
+inline void check_int_fits(std::int64_t value) {
+    if (value > std::int64_t(std::numeric_limits<T>::max()))
+        throw ignite_error("The number in stream is too large to fit in type: " + std::to_string(value));
+
+    if (value < std::int64_t(std::numeric_limits<T>::min()))
+        throw ignite_error("The number in stream is too small to fit in type: " + std::to_string(value));
+}
+
+template<typename T>
+std::optional<T> try_unpack_int(const msgpack_object &object) {
+    static_assert(
+            std::numeric_limits<T>::is_integer && std::numeric_limits<T>::is_signed, "Type T is not a signed integer type");
+
+    auto i64_val = try_unpack_object<std::int64_t>(object);
+    if (!i64_val)
+        return std::nullopt;
+
+    check_int_fits<T>(*i64_val);
+    return T(*i64_val);
+}
+
+template<>
+std::optional<std::int64_t> try_unpack_object(const msgpack_object &object) {
+    if (object.type != MSGPACK_OBJECT_NEGATIVE_INTEGER && object.type != MSGPACK_OBJECT_POSITIVE_INTEGER)
+        return std::nullopt;
+
+    return object.via.i64;
+}
+
+template<>
+std::optional<std::int32_t> try_unpack_object(const msgpack_object &object) {
+    return try_unpack_int<std::int32_t>(object);
+}
+
+template<>
+std::optional<std::string> try_unpack_object(const msgpack_object &object) {
+    if (object.type != MSGPACK_OBJECT_STR)
+        return std::nullopt;
+
+    return std::string{object.via.str.ptr, object.via.str.size};
+}
+
 template<typename T>
 T unpack_int(const msgpack_object &object) {
     static_assert(
@@ -35,13 +84,17 @@ T unpack_int(const msgpack_object &object) {
 
     auto i64_val = unpack_object<std::int64_t>(object);
 
-    if (i64_val > std::int64_t(std::numeric_limits<T>::max()))
-        throw ignite_error("The number in stream is too large to fit in type: " + std::to_string(i64_val));
+    check_int_fits<T>(i64_val);
+    return T(i64_val);
+}
+
 
-    if (i64_val < std::int64_t(std::numeric_limits<T>::min()))
-        throw ignite_error("The number in stream is too small to fit in type: " + std::to_string(i64_val));
+template<>
+std::optional<std::string> unpack_nullable(const msgpack_object &object) {
+    if (object.type == MSGPACK_OBJECT_NIL)
+        return std::nullopt;
 
-    return T(i64_val);
+    return unpack_object<std::string>(object);
 }
 
 template<>
diff --git a/modules/platforms/cpp/ignite/protocol/utils.h b/modules/platforms/cpp/ignite/protocol/utils.h
index e6efae2f94..e13297608d 100644
--- a/modules/platforms/cpp/ignite/protocol/utils.h
+++ b/modules/platforms/cpp/ignite/protocol/utils.h
@@ -40,6 +40,54 @@ class reader;
 static constexpr std::array<std::byte, 4> MAGIC_BYTES = {
     std::byte('I'), std::byte('G'), std::byte('N'), std::byte('I')};
 
+template<typename T>
+[[nodiscard]] std::optional<T> try_unpack_object(const msgpack_object &) {
+    static_assert(sizeof(T) == 0, "Unpacking is not implemented for the type");
+}
+
+/**
+ * Try unpack number.
+ *
+ * @param object MsgPack object.
+ * @return Number or @c nullopt if the object is not a number.
+ */
+template<>
+[[nodiscard]] std::optional<std::int64_t> try_unpack_object(const msgpack_object &object);
+
+/**
+ * Try unpack number.
+ *
+ * @param object MsgPack object.
+ * @return Number or @c nullopt if the object is not a number.
+ */
+template<>
+[[nodiscard]] std::optional<std::int32_t> try_unpack_object(const msgpack_object &object);
+
+/**
+ * Try unpack string.
+ *
+ * @param object MsgPack object.
+ * @return String or @c nullopt if the object is not a string.
+ */
+template<>
+[[nodiscard]] std::optional<std::string> try_unpack_object(const msgpack_object &object);
+
+
+template<typename T>
+[[nodiscard]] std::optional<T> unpack_nullable(const msgpack_object &) {
+    static_assert(sizeof(T) == 0, "Unpacking is not implemented for the type");
+}
+
+/**
+ * Unpack string.
+ *
+ * @param object MsgPack object.
+ * @return String of @c nullopt if the object is @c nil.
+ * @throw ignite_error if the object is not a string.
+ */
+template<>
+[[nodiscard]] std::optional<std::string> unpack_nullable(const msgpack_object &object);
+
 template<typename T>
 [[nodiscard]] T unpack_object(const msgpack_object &) {
     static_assert(sizeof(T) == 0, "Unpacking is not implemented for the type");
diff --git a/modules/platforms/cpp/ignite/schema/binary_tuple_builder.h b/modules/platforms/cpp/ignite/schema/binary_tuple_builder.h
index 732bb288c8..0dc565c1d5 100644
--- a/modules/platforms/cpp/ignite/schema/binary_tuple_builder.h
+++ b/modules/platforms/cpp/ignite/schema/binary_tuple_builder.h
@@ -202,14 +202,14 @@ public:
      *
      * @param value Element value.
      */
-    void claim_string(const std::string &value) noexcept { claim(value.size()); }
+    void claim_string(const std::string &value) noexcept { claim(SizeT(value.size())); }
 
     /**
      * @brief Assigns a binary value for the next element.
      *
      * @param value Element value.
      */
-    void claim_bytes(const bytes_view &value) noexcept { claim(value.size()); }
+    void claim_bytes(const bytes_view &value) noexcept { claim(SizeT(value.size())); }
 
     /**
      * @brief Assigns a binary value for the next element.
@@ -659,7 +659,7 @@ private:
      * @return Required size.
      */
     static SizeT gauge_double(double value) noexcept {
-        float floatValue = static_cast<float>(value);
+        auto floatValue = static_cast<float>(value);
         return floatValue == value ? gauge_float(floatValue) : sizeof(double);
     }
 
diff --git a/modules/platforms/cpp/ignite/schema/tuple_test.cpp b/modules/platforms/cpp/ignite/schema/tuple_test.cpp
index 8644d6969b..d76801b76e 100644
--- a/modules/platforms/cpp/ignite/schema/tuple_test.cpp
+++ b/modules/platforms/cpp/ignite/schema/tuple_test.cpp
@@ -103,7 +103,7 @@ std::string read_tuple(std::optional<bytes_view> data) {
 struct SchemaDescriptor {
     std::vector<column_info> columns;
 
-    [[nodiscard]] IntT length() const { return columns.size(); }
+    [[nodiscard]] IntT length() const { return IntT(columns.size()); }
 
     [[nodiscard]] binary_tuple_schema to_tuple_schema() const {
         return binary_tuple_schema({columns.begin(), columns.end()});
diff --git a/modules/platforms/cpp/tests/client-test/CMakeLists.txt b/modules/platforms/cpp/tests/client-test/CMakeLists.txt
index 56d3e52aac..b2c8ec187c 100644
--- a/modules/platforms/cpp/tests/client-test/CMakeLists.txt
+++ b/modules/platforms/cpp/tests/client-test/CMakeLists.txt
@@ -25,6 +25,7 @@ set(SOURCES
     ignite_runner_suite.h
     main.cpp
     record_binary_view_test.cpp
+    sql_test.cpp
     tables_test.cpp
 )
 
diff --git a/modules/platforms/cpp/tests/client-test/ignite_client_test.cpp b/modules/platforms/cpp/tests/client-test/ignite_client_test.cpp
index e43ce5026f..143476505d 100644
--- a/modules/platforms/cpp/tests/client-test/ignite_client_test.cpp
+++ b/modules/platforms/cpp/tests/client-test/ignite_client_test.cpp
@@ -36,7 +36,7 @@ TEST_F(client_test, get_configuration) {
     cfg.set_logger(get_logger());
     cfg.set_connection_limit(42);
 
-    auto client = ignite_client::start(cfg, std::chrono::seconds(5));
+    auto client = ignite_client::start(cfg, std::chrono::seconds(30));
 
     const auto &cfg2 = client.configuration();
 
diff --git a/modules/platforms/cpp/tests/client-test/ignite_runner_suite.h b/modules/platforms/cpp/tests/client-test/ignite_runner_suite.h
index b72ffe2bd5..8de0f337ba 100644
--- a/modules/platforms/cpp/tests/client-test/ignite_runner_suite.h
+++ b/modules/platforms/cpp/tests/client-test/ignite_runner_suite.h
@@ -34,6 +34,7 @@ using namespace std::string_view_literals;
 class ignite_runner_suite : public ::testing::Test {
 protected:
     static constexpr std::initializer_list<std::string_view> NODE_ADDRS = {"127.0.0.1:10942"sv, "127.0.0.1:10943"sv};
+    static constexpr std::string_view TABLE_1 = "tbl1"sv;
 
     /**
      * Get logger.
diff --git a/modules/platforms/cpp/tests/client-test/record_binary_view_test.cpp b/modules/platforms/cpp/tests/client-test/record_binary_view_test.cpp
index d2d104f709..493ca553a8 100644
--- a/modules/platforms/cpp/tests/client-test/record_binary_view_test.cpp
+++ b/modules/platforms/cpp/tests/client-test/record_binary_view_test.cpp
@@ -39,8 +39,8 @@ protected:
         ignite_client_configuration cfg{NODE_ADDRS};
         cfg.set_logger(get_logger());
 
-        m_client = ignite_client::start(cfg, std::chrono::minutes(5));
-        auto table = m_client.get_tables().get_table("tbl1");
+        m_client = ignite_client::start(cfg, std::chrono::seconds(30));
+        auto table = m_client.get_tables().get_table(TABLE_1);
 
         tuple_view = table->record_binary_view();
     }
diff --git a/modules/platforms/cpp/tests/client-test/sql_test.cpp b/modules/platforms/cpp/tests/client-test/sql_test.cpp
new file mode 100644
index 0000000000..6978f06618
--- /dev/null
+++ b/modules/platforms/cpp/tests/client-test/sql_test.cpp
@@ -0,0 +1,323 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+#include "ignite_runner_suite.h"
+
+#include "ignite/client/ignite_client.h"
+#include "ignite/client/ignite_client_configuration.h"
+
+#include <gtest/gtest.h>
+#include <gmock/gmock-matchers.h>
+
+#include <chrono>
+
+using namespace ignite;
+
+/**
+ * Test suite.
+ */
+class sql_test : public ignite_runner_suite {
+protected:
+    static void SetUpTestSuite() {
+        ignite_client_configuration cfg{NODE_ADDRS};
+        cfg.set_logger(get_logger());
+        auto client = ignite_client::start(cfg, std::chrono::seconds(30));
+
+        auto res = client.get_sql().execute(nullptr,
+            {"CREATE TABLE IF NOT EXISTS TEST(ID INT PRIMARY KEY, VAL VARCHAR)"}, {});
+
+        if (!res.was_applied()) {
+            client.get_sql().execute(nullptr, {"DELETE FROM TEST"}, {});
+        }
+
+        for (std::int32_t i = 0; i < 10; ++i) {
+            client.get_sql().execute(nullptr, {"INSERT INTO TEST VALUES (?, ?)"}, {i, "s-" + std::to_string(i)});
+        }
+    }
+
+    static void TearDownTestSuite() {
+        ignite_client_configuration cfg{NODE_ADDRS};
+        cfg.set_logger(get_logger());
+        auto client = ignite_client::start(cfg, std::chrono::seconds(30));
+
+        client.get_sql().execute(nullptr, {"DROP TABLE TEST"}, {});
+        client.get_sql().execute(nullptr, {"DROP TABLE IF EXISTS TestDdlDml"}, {});
+    }
+
+    void SetUp() override {
+        ignite_client_configuration cfg{NODE_ADDRS};
+        cfg.set_logger(get_logger());
+
+        m_client = ignite_client::start(cfg, std::chrono::seconds(30));
+    }
+
+    void TearDown() override {
+        // remove all
+    }
+
+    /** Ignite client. */
+    ignite_client m_client;
+};
+
+void check_columns(const result_set_metadata& meta,
+    std::initializer_list<std::tuple<std::string, column_type>> columns) {
+
+    ASSERT_EQ(columns.size(), meta.columns().size());
+    size_t i = 0;
+    for (auto &column : columns) {
+        EXPECT_EQ(i, meta.index_of(std::get<0>(column)));
+        EXPECT_EQ(meta.columns()[i].name(), std::get<0>(column));
+        EXPECT_EQ(meta.columns()[i].type(), std::get<1>(column));
+        ++i;
+    }
+}
+
+TEST_F(sql_test, sql_simple_select) {
+    auto result_set = m_client.get_sql().execute(nullptr, {"select 42, 'Lorem'"}, {});
+
+    EXPECT_FALSE(result_set.was_applied());
+    EXPECT_TRUE(result_set.has_rowset());
+    EXPECT_EQ(-1, result_set.affected_rows());
+
+    check_columns(result_set.metadata(), {{"42", column_type::INT32}, {"'Lorem'", column_type::STRING}});
+
+    auto page = result_set.current_page();
+
+    EXPECT_EQ(1, page.size());
+    EXPECT_EQ(42, page.front().get(0).get<std::int32_t>());
+    EXPECT_EQ("Lorem", page.front().get(1).get<std::string>());
+
+    EXPECT_FALSE(result_set.has_more_pages());
+    EXPECT_EQ(0, result_set.current_page().size());
+}
+
+TEST_F(sql_test, sql_table_select) {
+    auto result_set = m_client.get_sql().execute(nullptr, {"select id, val from TEST order by id"}, {});
+
+    EXPECT_FALSE(result_set.was_applied());
+    EXPECT_TRUE(result_set.has_rowset());
+    EXPECT_EQ(-1, result_set.affected_rows());
+
+    check_columns(result_set.metadata(), {{"ID", column_type::INT32}, {"VAL", column_type::STRING}});
+
+    auto page = result_set.current_page();
+
+    EXPECT_EQ(10, page.size());
+
+    for (std::int32_t i = 0; i < page.size(); ++i) {
+        EXPECT_EQ(i, page[i].get(0).get<std::int32_t>());
+        EXPECT_EQ("s-" + std::to_string(i), page[i].get(1).get<std::string>());
+    }
+
+    EXPECT_FALSE(result_set.has_more_pages());
+    EXPECT_EQ(0, result_set.current_page().size());
+}
+
+TEST_F(sql_test, sql_select_multiple_pages) {
+    sql_statement statement{"select id, val from TEST order by id"};
+    statement.page_size(1);
+
+    auto result_set = m_client.get_sql().execute(nullptr, statement, {});
+
+    EXPECT_FALSE(result_set.was_applied());
+    EXPECT_TRUE(result_set.has_rowset());
+    EXPECT_EQ(-1, result_set.affected_rows());
+
+    check_columns(result_set.metadata(), {{"ID", column_type::INT32}, {"VAL", column_type::STRING}});
+
+    for (std::int32_t i = 0; i < 10; ++i) {
+        auto page = result_set.current_page();
+
+        EXPECT_EQ(1, page.size()) << "i=" << i;
+        EXPECT_EQ(i, page.front().get(0).get<std::int32_t>());
+        EXPECT_EQ("s-" + std::to_string(i), page.front().get(1).get<std::string>());
+
+        if (i < 9) {
+            ASSERT_TRUE(result_set.has_more_pages());
+            result_set.fetch_next_page();
+        }
+    }
+
+    EXPECT_FALSE(result_set.has_more_pages());
+    EXPECT_EQ(0, result_set.current_page().size());
+}
+
+TEST_F(sql_test, sql_close_non_empty_cursor) {
+    sql_statement statement{"select id, val from TEST order by id"};
+    statement.page_size(3);
+
+    auto result_set = m_client.get_sql().execute(nullptr, statement, {});
+
+    EXPECT_FALSE(result_set.was_applied());
+    EXPECT_TRUE(result_set.has_rowset());
+    EXPECT_EQ(-1, result_set.affected_rows());
+
+    auto page = result_set.current_page();
+    ASSERT_TRUE(result_set.has_more_pages());
+
+    result_set.close();
+}
+
+TEST_F(sql_test, sql_ddl_dml) {
+    auto result_set = m_client.get_sql().execute(nullptr, {"DROP TABLE IF EXISTS SQL_DDL_DML_TEST"}, {});
+
+    EXPECT_FALSE(result_set.has_rowset());
+    EXPECT_EQ(-1, result_set.affected_rows());
+    EXPECT_TRUE(result_set.metadata().columns().empty());
+
+    result_set = m_client.get_sql().execute(nullptr,
+        {"CREATE TABLE SQL_DDL_DML_TEST(ID BIGINT PRIMARY KEY, VAL VARCHAR)"}, {});
+
+    EXPECT_TRUE(result_set.was_applied());
+    EXPECT_FALSE(result_set.has_rowset());
+    EXPECT_EQ(-1, result_set.affected_rows());
+    EXPECT_TRUE(result_set.metadata().columns().empty());
+
+    result_set = m_client.get_sql().execute(nullptr, {"INSERT INTO SQL_DDL_DML_TEST VALUES (?, ?)"}, {13LL, "Hello"});
+
+    EXPECT_FALSE(result_set.was_applied());
+    EXPECT_FALSE(result_set.has_rowset());
+    EXPECT_EQ(1, result_set.affected_rows());
+    EXPECT_TRUE(result_set.metadata().columns().empty());
+
+    result_set = m_client.get_sql().execute(nullptr, {"INSERT INTO SQL_DDL_DML_TEST VALUES (?, ?)"}, {14LL, "World"});
+
+    EXPECT_FALSE(result_set.was_applied());
+    EXPECT_FALSE(result_set.has_rowset());
+    EXPECT_EQ(1, result_set.affected_rows());
+    EXPECT_TRUE(result_set.metadata().columns().empty());
+
+
+    result_set = m_client.get_sql().execute(nullptr, {"UPDATE SQL_DDL_DML_TEST SET VAL = ?"}, {"Test"});
+
+    EXPECT_FALSE(result_set.was_applied());
+    EXPECT_FALSE(result_set.has_rowset());
+    EXPECT_EQ(2, result_set.affected_rows());
+    EXPECT_TRUE(result_set.metadata().columns().empty());
+
+    result_set = m_client.get_sql().execute(nullptr, {"DROP TABLE SQL_DDL_DML_TEST"}, {});
+
+    EXPECT_TRUE(result_set.was_applied());
+    EXPECT_FALSE(result_set.has_rowset());
+    EXPECT_EQ(-1, result_set.affected_rows());
+    EXPECT_TRUE(result_set.metadata().columns().empty());
+}
+
+TEST_F(sql_test, sql_insert_null) {
+    auto result_set = m_client.get_sql().execute(nullptr, {"DROP TABLE IF EXISTS SQL_INSERT_NULL_TEST"}, {});
+    result_set = m_client.get_sql().execute(nullptr,
+        {"CREATE TABLE SQL_INSERT_NULL_TEST(ID INT PRIMARY KEY, VAL VARCHAR)"}, {});
+
+    ASSERT_TRUE(result_set.was_applied());
+
+    result_set = m_client.get_sql().execute(nullptr, {"INSERT INTO SQL_INSERT_NULL_TEST VALUES (13, NULL)"}, {});
+
+    EXPECT_FALSE(result_set.was_applied());
+    EXPECT_FALSE(result_set.has_rowset());
+    EXPECT_EQ(1, result_set.affected_rows());
+    EXPECT_TRUE(result_set.metadata().columns().empty());
+
+    result_set = m_client.get_sql().execute(nullptr, {"DROP TABLE SQL_INSERT_NULL_TEST"}, {});
+    EXPECT_TRUE(result_set.was_applied());
+}
+
+TEST_F(sql_test, sql_invalid_query) {
+    EXPECT_THROW(
+        {
+            try {
+                m_client.get_sql().execute(nullptr, {"not a query"}, {});
+            } catch (const ignite_error &e) {
+                EXPECT_THAT(e.what_str(), ::testing::HasSubstr("Failed to parse query: Non-query expression"));
+                throw;
+            }
+        },
+        ignite_error);
+}
+
+TEST_F(sql_test, sql_unknown_table) {
+    EXPECT_THROW(
+        {
+            try {
+                m_client.get_sql().execute(nullptr, {"select id from unknown_table"}, {});
+            } catch (const ignite_error &e) {
+                EXPECT_THAT(e.what_str(), ::testing::HasSubstr("Object 'UNKNOWN_TABLE' not found"));
+                throw;
+            }
+        },
+        ignite_error);
+}
+
+TEST_F(sql_test, sql_unknown_column) {
+    EXPECT_THROW(
+        {
+            try {
+                m_client.get_sql().execute(nullptr, {"select unknown_column from test"}, {});
+            } catch (const ignite_error &e) {
+                EXPECT_THAT(e.what_str(), ::testing::HasSubstr("Column 'UNKNOWN_COLUMN' not found in any table"));
+                throw;
+            }
+        },
+        ignite_error);
+}
+
+TEST_F(sql_test, sql_create_existing_table) {
+    EXPECT_THROW(
+        {
+            try {
+                m_client.get_sql().execute(nullptr, {"CREATE TABLE TEST(ID INT PRIMARY KEY, VAL VARCHAR)"}, {});
+            } catch (const ignite_error &e) {
+                EXPECT_THAT(e.what_str(), ::testing::HasSubstr("Table already exists"));
+                throw;
+            }
+        },
+        ignite_error);
+}
+
+TEST_F(sql_test, sql_add_existing_column) {
+    EXPECT_THROW(
+        {
+            try {
+                m_client.get_sql().execute(nullptr, {"ALTER TABLE TEST ADD COLUMN ID INT"}, {});
+            } catch (const ignite_error &e) {
+                EXPECT_THAT(e.what_str(), ::testing::HasSubstr("Column already exists"));
+                throw;
+            }
+        },
+        ignite_error);
+}
+
+TEST_F(sql_test, sql_alter_nonexisting_table) {
+    EXPECT_THROW(
+        {
+            try {
+                m_client.get_sql().execute(nullptr, {"ALTER TABLE UNKNOWN_TABLE ADD COLUMN ID INT"}, {});
+            } catch (const ignite_error &e) {
+                EXPECT_THAT(e.what_str(), ::testing::HasSubstr("The table does not exist"));
+                throw;
+            }
+        },
+        ignite_error);
+}
+
+TEST_F(sql_test, sql_statement_defaults) {
+    sql_statement statement;
+
+    EXPECT_EQ(statement.page_size(), sql_statement::DEFAULT_PAGE_SIZE);
+    EXPECT_EQ(statement.schema(), sql_statement::DEFAULT_SCHEMA);
+    EXPECT_EQ(statement.timeout(), sql_statement::DEFAULT_TIMEOUT);
+}
+
diff --git a/modules/platforms/cpp/tests/client-test/tables_test.cpp b/modules/platforms/cpp/tests/client-test/tables_test.cpp
index f762eb4d46..04f6bc49e0 100644
--- a/modules/platforms/cpp/tests/client-test/tables_test.cpp
+++ b/modules/platforms/cpp/tests/client-test/tables_test.cpp
@@ -37,7 +37,7 @@ TEST_F(tables_test, tables_get_table) {
     ignite_client_configuration cfg{NODE_ADDRS};
     cfg.set_logger(get_logger());
 
-    auto client = ignite_client::start(cfg, std::chrono::seconds(5));
+    auto client = ignite_client::start(cfg, std::chrono::seconds(30));
     auto tables = client.get_tables();
 
     auto tableUnknown = tables.get_table("some_unknown");
@@ -53,7 +53,7 @@ TEST_F(tables_test, tables_get_table_async_promises) {
     cfg.set_logger(get_logger());
 
     auto clientPromise = std::make_shared<std::promise<ignite_client>>();
-    ignite_client::start_async(cfg, std::chrono::seconds(5), result_promise_setter(clientPromise));
+    ignite_client::start_async(cfg, std::chrono::seconds(30), result_promise_setter(clientPromise));
 
     auto client = clientPromise->get_future().get();
 
@@ -83,7 +83,7 @@ TEST_F(tables_test, tables_get_table_async_callbacks) {
 
     ignite_client client;
 
-    ignite_client::start_async(cfg, std::chrono::seconds(5), [&](ignite_result<ignite_client> clientRes) {
+    ignite_client::start_async(cfg, std::chrono::seconds(30), [&](ignite_result<ignite_client> clientRes) {
         if (!check_and_set_operation_error(*operation0, clientRes))
             return;
 
@@ -133,7 +133,7 @@ TEST_F(tables_test, tables_get_tables) {
     ignite_client_configuration cfg{NODE_ADDRS};
     cfg.set_logger(get_logger());
 
-    auto client = ignite_client::start(cfg, std::chrono::seconds(5));
+    auto client = ignite_client::start(cfg, std::chrono::seconds(30));
 
     auto tablesApi = client.get_tables();
 
@@ -149,7 +149,7 @@ TEST_F(tables_test, tables_get_tables_async_promises) {
     ignite_client_configuration cfg{NODE_ADDRS};
     cfg.set_logger(get_logger());
 
-    auto client = ignite_client::start(cfg, std::chrono::seconds(5));
+    auto client = ignite_client::start(cfg, std::chrono::seconds(30));
 
     auto tablesApi = client.get_tables();
 
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/FakeServer.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/FakeServer.cs
index 28da95ad37..d87f76558f 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/FakeServer.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/FakeServer.cs
@@ -276,6 +276,7 @@ namespace Apache.Ignite.Tests
 
                 writer.WriteArrayHeader(2); // Meta.
 
+                writer.WriteArrayHeader(6); // Column props.
                 writer.Write("NAME"); // Column name.
                 writer.Write(false); // Nullable.
                 writer.Write((int)SqlColumnType.String);
@@ -283,6 +284,7 @@ namespace Apache.Ignite.Tests
                 writer.Write(0); // Precision.
                 writer.Write(false); // No origin.
 
+                writer.WriteArrayHeader(6); // Column props.
                 writer.Write("VAL"); // Column name.
                 writer.Write(false); // Nullable.
                 writer.Write((int)SqlColumnType.String);
@@ -307,6 +309,7 @@ namespace Apache.Ignite.Tests
                 writer.Write(0); // AffectedRows.
 
                 writer.WriteArrayHeader(1); // Meta.
+                writer.WriteArrayHeader(6); // Column props.
                 writer.Write("ID"); // Column name.
                 writer.Write(false); // Nullable.
                 writer.Write((int)SqlColumnType.Int32);
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs
index 4862c1d18a..391d39d400 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs
@@ -200,6 +200,11 @@ namespace Apache.Ignite.Internal.Sql
 
             for (int i = 0; i < size; i++)
             {
+                var propertyCount = reader.ReadArrayHeader();
+                const int minCount = 6;
+
+                Debug.Assert(propertyCount >= minCount, "propertyCount >= " + minCount);
+
                 var name = reader.ReadString();
                 var nullable = reader.ReadBoolean();
                 var type = (SqlColumnType)reader.ReadInt32();