From 77d6aa8a375473dcb255c28ba60bd2424c5eb794 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:41:23 +0700 Subject: [PATCH] catch up to ABI 0.14 The engine moved twice since this wrapper was written and the suite has been red since. 0.13 added zu_conn_table_name and 0.14 added zu_value_bytes with a type to go with it, and separately two more words became reserved, which broke four cases and one example that had used them as aliases. zu_value_bytes is Value::as_bytes, a span of octets pointing into the result on the same terms as a string: not copied, not NUL terminated, good for as long as the result is. Row::get reads it as that span or as a vector for a caller who wants the octets to outlive the result they came from. Octets and text are separate types both ways, so reading one as the other is refused rather than guessed at, which is the whole point of the engine having a second type: a blob with a zero in it ends early as text and a blob that happens to be valid UTF-8 is still a blob. zu_conn_table_name is Connection::table_name, and it answers nothing rather than failing when no table has that id, because an id no table has is an answer to the question. It copies where the rest of the header borrows, and that is deliberate. The pointer the ABI hands back is good only until the next call of the same function on the same connection, so a string_view over it dangles one line later, and a lifetime nobody can see is worse than an allocation everybody can. It is also the only call on a connection the ABI returns no status for, so the closed handle it would otherwise dereference is caught here instead. The reserved words are big, small and at, and the fix is not to rename the columns. The engine's own message says to write the name in accent quotes, so the aliases are quoted and the columns keep their names, which holds whether or not the grammar reserves another word next month. A property is read by name and needs no quoting; only an alias after AS does. --- CMakeLists.txt | 6 ++-- examples/errors.cpp | 2 +- include/zu.hpp | 67 +++++++++++++++++++++++++++++++++++++++ test/test_errors.cpp | 10 +++++- test/test_expected.cpp | 21 ++++++++++++ test/test_frame.cpp | 10 ++++-- test/test_query.cpp | 2 +- test/test_values.cpp | 72 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 181 insertions(+), 9 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 73dfe71..f031e33 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,7 +29,7 @@ if(POLICY CMP0144) endif() project(zu-cpp - VERSION 0.12.0 + VERSION 0.14.0 DESCRIPTION "The header-only C++ wrapper over libzu" HOMEPAGE_URL "https://github.com/tamnd/zu-c" LANGUAGES C CXX) @@ -78,9 +78,9 @@ if(TARGET zu::zu) # is not fatal here, because the two numbers are counts and a header # newer than this wrapper is usually only wider, but it is worth # saying out loud rather than finding at the first struct. - if(NOT ZU_ABI_VERSION VERSION_EQUAL "0.12") + if(NOT ZU_ABI_VERSION VERSION_EQUAL "0.14") message(WARNING - "zu.h declares ABI ${ZU_ABI_VERSION} and this wrapper was written against 0.12") + "zu.h declares ABI ${ZU_ABI_VERSION} and this wrapper was written against 0.14") endif() else() message(STATUS diff --git a/examples/errors.cpp b/examples/errors.cpp index b3f1cf2..59ff61d 100644 --- a/examples/errors.cpp +++ b/examples/errors.cpp @@ -34,7 +34,7 @@ int main() { /* So is reading a value into a type it does not fit. A silently * truncated 100000 is the bug this exists to not have. */ - auto big = conn.query("RETURN 100000 AS big"); + auto big = conn.query("RETURN 100000 AS `big`"); try { big.row(0).get("big"); } catch (const zu::ProgrammingError& e) { diff --git a/include/zu.hpp b/include/zu.hpp index 52b8fe4..37e2572 100644 --- a/include/zu.hpp +++ b/include/zu.hpp @@ -132,6 +132,10 @@ enum class Type : int { record = ZU_TYPE_RECORD, graph = ZU_TYPE_GRAPH, binding_table = ZU_TYPE_BINDING_TABLE, + /* Octets rather than text, so nothing here is validated as UTF-8 and + * nothing is decoded on the way out. Last in the list because the + * order is the ABI's numbering and this is what ABI 0.14 added. */ + bytes = ZU_TYPE_BYTES, }; /* Which temporal a temporal is. The unit follows the kind: days for a @@ -737,6 +741,12 @@ class Value { /* Points into the result's bytes and is NOT NUL-terminated, which is * the price of not copying. */ std::string_view as_string() const { return detail::unwrap(string_impl()); } + /* Octets, on the same terms: into the result, not copied, and not + * NUL-terminated. A byte string and a string are different types here + * and reading one as the other fails, because a blob that happens to + * be valid UTF-8 is still a blob and a caller who wanted text should + * be told the column is not text. */ + std::span as_bytes() const { return detail::unwrap(bytes_impl()); } Temporal as_temporal() const { return detail::unwrap(temporal_impl()); } Node as_node() const { return detail::unwrap(node_impl()); } Rel as_rel() const { return detail::unwrap(rel_impl()); } @@ -760,6 +770,9 @@ class Value { expected try_as_int() const { return detail::to_expected(int_impl()); } expected try_as_double() const { return detail::to_expected(double_impl()); } expected try_as_string() const { return detail::to_expected(string_impl()); } + expected> try_as_bytes() const { + return detail::to_expected(bytes_impl()); + } expected try_as_temporal() const { return detail::to_expected(temporal_impl()); } expected try_as_node() const { return detail::to_expected(node_impl()); } expected try_as_rel() const { return detail::to_expected(rel_impl()); } @@ -799,6 +812,20 @@ class Value { } return std::string_view(p == nullptr ? "" : p, len); } + detail::Outcome> bytes_impl() const { + const std::uint8_t* p = nullptr; + std::size_t len = 0; + if (auto e = detail::checked(zu_value_bytes(v_, &p, &len), "zu_value_bytes")) { + return std::move(*e); + } + /* An empty byte string is a length of zero and a pointer that may be + * anything, and a span built on a null pointer is one nobody can + * safely iterate even when it is empty. */ + if (p == nullptr || len == 0) { + return std::span{}; + } + return std::span(p, len); + } detail::Outcome temporal_impl() const { std::int32_t kind = 0; std::int64_t count = 0; @@ -1435,6 +1462,11 @@ T Row::get(std::uint32_t col) const { return result_->str(row_, col); } else if constexpr (std::is_same_v) { return std::string(result_->str(row_, col)); + } else if constexpr (std::is_same_v>) { + return result_->cell(row_, col).as_bytes(); + } else if constexpr (std::is_same_v>) { + const auto v = result_->cell(row_, col).as_bytes(); + return std::vector(v.begin(), v.end()); } else if constexpr (std::is_same_v) { return result_->cell(row_, col).as_bool(); } else if constexpr (std::is_same_v) { @@ -2513,6 +2545,24 @@ class Connection { * call and a vector of views into them would be a trap. */ std::vector registered() const { return detail::unwrap(registered_impl()); } + /* What the table id in a node or a rel is called, and nothing when no + * table has that id. A node is a table and an offset and nothing else, + * which is what makes it cheap, so this is the call that turns one + * back into something a person reads. + * + * Nothing rather than a failure, because an id no table has is an + * answer to the question. Node and rel tables share one id space, so + * an id read off a rel and an id read off a node are asked for the + * same way. + * + * A copy, unlike the strings that come off a result. The pointer the + * ABI hands back is good only until the next one of these on the same + * connection, and a view with that lifetime is a dangling read one + * line later rather than a saving. */ + std::optional table_name(std::uint32_t table) const { + return detail::unwrap(table_name_impl(table)); + } + #if ZU_HAS_EXPECTED static expected try_open(std::string_view path) { return detail::to_expected(open_impl(path)); @@ -2548,6 +2598,9 @@ class Connection { expected> try_registered() const { return detail::to_expected(registered_impl()); } + expected> try_table_name(std::uint32_t table) const { + return detail::to_expected(table_name_impl(table)); + } #endif private: @@ -2741,6 +2794,20 @@ class Connection { } return out; } + detail::Outcome> table_name_impl(std::uint32_t table) const { + /* The only call on a connection with no status to return, so the + * closed handle it would otherwise read as a null pointer is caught + * here rather than by the engine. */ + if (!h_) { + return Error::take(Status::misuse, nullptr, "zu_conn_table_name"); + } + std::size_t len = 0; + const char* p = zu_conn_table_name(h_.get(), table, &len); + if (p == nullptr) { + return std::optional{}; + } + return std::optional(std::in_place, p, len); + } detail::Handle h_; std::unique_ptr watch_; diff --git a/test/test_errors.cpp b/test/test_errors.cpp index a758f17..dfc503d 100644 --- a/test/test_errors.cpp +++ b/test/test_errors.cpp @@ -110,7 +110,11 @@ ZU_TEST(a_cell_read_as_the_wrong_type_says_so) { ZU_TEST(an_integer_that_does_not_fit_is_refused_rather_than_wrapped) { auto conn = zu::Connection::memory(); - auto r = conn.query("RETURN 100000 AS big"); + /* The accent quotes are not decoration. big became a reserved word, + * and a reserved word written plainly after AS is a syntax error, so + * quoting is what keeps a name a name whatever the grammar does with + * it later. The column is still called big. */ + auto r = conn.query("RETURN 100000 AS `big`"); CHECK_EQ(r.row(0).get(0), 100000); /* A silently truncated 100000 is the bug this exists to not have. * The type was the caller's to choose, so choosing one the value does @@ -131,6 +135,10 @@ ZU_TEST(a_closed_handle_is_a_misuse_and_not_a_crash) { CHECK(!static_cast(conn)); CHECK(static_cast(other)); CHECK_THROWS_AS(zu::Exception, conn.query("RETURN 1 AS v")); + /* table_name is the one call on a connection the ABI gives no status + * for, so it is the one that would have read the null handle itself + * rather than been told about it. */ + CHECK_THROWS_AS(zu::Exception, conn.table_name(0)); } ZU_TEST(every_exception_is_a_runtime_error) { diff --git a/test/test_expected.cpp b/test/test_expected.cpp index f3569a5..e7263fa 100644 --- a/test/test_expected.cpp +++ b/test/test_expected.cpp @@ -97,6 +97,27 @@ ZU_TEST(a_column_that_is_not_there_returns_rather_than_throws) { CHECK_EQ(*found, 0u); } +ZU_TEST(the_calls_abi_0_14_added_have_the_expected_spelling_too) { + auto conn = zu::Connection::memory(); + auto r = conn.query("RETURN X'00AB' AS b, 'ada' AS s"); + + const auto b = r.cell(0, 0).try_as_bytes(); + CHECK(b.has_value()); + CHECK_EQ(b->size(), 2u); + /* Text read as octets is a mistake in the program rather than a + * failure of the engine, and here it comes back rather than throws. */ + const auto wrong = r.cell(0, 1).try_as_bytes(); + CHECK(!wrong.has_value()); + CHECK_EQ(wrong.error().status(), zu::Status::misuse); + + /* Two layers of nothing, and they mean different things: the outer + * one is the call having failed, the inner one is no table having + * that id. */ + const auto absent = conn.try_table_name(9999); + CHECK(absent.has_value()); + CHECK(!absent->has_value()); +} + ZU_TEST(the_bulk_paths_have_the_expected_spelling_too) { zt::TempDir dir("expected"); const std::string path = dir.file("people.zu"); diff --git a/test/test_frame.cpp b/test/test_frame.cpp index 7ecb8f9..3607598 100644 --- a/test/test_frame.cpp +++ b/test/test_frame.cpp @@ -106,9 +106,13 @@ ZU_TEST(every_width_and_sign_comes_back_as_the_number_it_is) { .column("unsign", unsign); conn.register_frame(frame); + /* Every alias in accent quotes, because several of these words are + * reserved and which ones is the grammar's business rather than this + * test's. A property is read by name and needs no quoting; an alias + * after AS is parsed as a name and does. */ auto r = conn.query( - "MATCH (w:Wide) RETURN w.big AS big, w.mid AS mid, w.small AS small, w.tiny AS tiny, " - "w.unsign AS unsign"); + "MATCH (w:Wide) RETURN w.big AS `big`, w.mid AS `mid`, w.small AS `small`, " + "w.tiny AS `tiny`, w.unsign AS `unsign`"); CHECK_EQ(r.row(0).get("big"), 1); CHECK_EQ(r.row(0).get("mid"), 3); CHECK_EQ(r.row(0).get("small"), 5); @@ -224,7 +228,7 @@ ZU_TEST(arrow_microseconds_are_scaled_to_the_nanoseconds_this_engine_counts_in) frame.column("at", at, 1000, zu::TemporalKind::local_datetime); conn.register_frame(frame); - auto r = conn.query("MATCH (e:Event) RETURN e.at AS at"); + auto r = conn.query("MATCH (e:Event) RETURN e.at AS `at`"); const zu::Temporal read = r.row(0).get(0); CHECK_EQ(read.kind, zu::TemporalKind::local_datetime); CHECK_EQ(read.count, 1'700'000'000'000'000'000); diff --git a/test/test_query.cpp b/test/test_query.cpp index 523b8ee..46c9d6a 100644 --- a/test/test_query.cpp +++ b/test/test_query.cpp @@ -21,7 +21,7 @@ ZU_TEST(a_database_in_memory_answers_a_statement) { } ZU_TEST(the_wrapper_and_the_library_agree_about_the_abi) { - CHECK_EQ(zu::abi_version(), "0.12"); + CHECK_EQ(zu::abi_version(), "0.14"); CHECK(!zu::version().empty()); } diff --git a/test/test_values.cpp b/test/test_values.cpp index acb516c..6a11dc0 100644 --- a/test/test_values.cpp +++ b/test/test_values.cpp @@ -6,6 +6,10 @@ #include #include +#include +#include +#include +#include #include #include @@ -72,6 +76,51 @@ ZU_TEST(a_record_has_names_as_well_as_values) { CHECK_EQ(rec[1].as_string(), "x"); } +ZU_TEST(a_byte_string_comes_back_as_the_octets_it_is) { + auto conn = zu::Connection::memory(); + auto r = conn.query("RETURN X'00AB' AS b"); + CHECK_EQ(r.type(0, 0), zu::Type::bytes); + + const std::span b = r.cell(0, 0).as_bytes(); + CHECK_EQ(b.size(), 2u); + /* The leading zero is the point. Read as text this value ends before + * it starts, which is why the engine has a second type for it and why + * this reads as a span of octets rather than as a string. */ + CHECK_EQ(static_cast(b[0]), 0); + CHECK_EQ(static_cast(b[1]), 0xAB); + + const auto same = r.row(0).get>(0); + CHECK_EQ(same.size(), 2u); + CHECK_EQ(same.data(), b.data()); + + /* The copying spelling, for a caller who wants the octets to outlive + * the result they came out of. */ + const auto owned = r.row(0).get>(0); + CHECK_EQ(owned.size(), 2u); + CHECK_EQ(static_cast(owned[1]), 0xAB); +} + +ZU_TEST(an_empty_byte_string_is_a_byte_string_and_not_a_null) { + auto conn = zu::Connection::memory(); + auto r = conn.query("RETURN X'' AS b"); + CHECK_EQ(r.type(0, 0), zu::Type::bytes); + CHECK(!r.cell(0, 0).is_null()); + const auto b = r.cell(0, 0).as_bytes(); + CHECK(b.empty()); + /* Empty and iterable, rather than empty and undefined to walk. */ + CHECK_EQ(std::distance(b.begin(), b.end()), 0); +} + +ZU_TEST(octets_and_text_are_not_read_as_one_another) { + auto conn = zu::Connection::memory(); + auto r = conn.query("RETURN X'00AB' AS b, 'ada' AS s"); + /* A blob that happened to be valid UTF-8 would read as text and one + * that did not would be a silent mess, so neither direction is + * allowed and a caller who guessed wrong is told. */ + CHECK_THROWS_AS(zu::Exception, r.cell(0, 0).as_string()); + CHECK_THROWS_AS(zu::Exception, r.cell(0, 1).as_bytes()); +} + ZU_TEST(a_node_is_a_table_and_a_row) { zt::TempDir dir("node"); const std::string path = dir.file("people.zu"); @@ -92,6 +141,29 @@ ZU_TEST(a_node_is_a_table_and_a_row) { CHECK_NE(r.cell(2, 0).as_node().offset, n.offset); } +ZU_TEST(a_table_id_says_what_it_is_called) { + zt::TempDir dir("tablename"); + const std::string path = dir.file("people.zu"); + zt::people(path); + + auto conn = zu::Connection::open(path); + auto r = conn.query("MATCH (p:Person) RETURN p ORDER BY p.id"); + const zu::Node n = r.cell(0, 0).as_node(); + + const std::optional name = conn.table_name(n.table); + CHECK(name.has_value()); + CHECK_EQ(*name, std::string("Person")); + + /* Nothing rather than a failure, because an id no table has is an + * answer to the question and not a broken call. */ + CHECK(!conn.table_name(9999).has_value()); + + /* A copy, so asking again does not move the first answer out from + * under it. The pointer the ABI hands back would have. */ + const std::optional again = conn.table_name(n.table); + CHECK_EQ(*name, *again); +} + ZU_TEST(a_node_column_reads_as_a_span_of_offsets) { zt::TempDir dir("nodespan"); const std::string path = dir.file("people.zu");