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");