Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/errors.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::int16_t>("big");
} catch (const zu::ProgrammingError& e) {
Expand Down
67 changes: 67 additions & 0 deletions include/zu.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<const std::uint8_t> 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()); }
Expand All @@ -760,6 +770,9 @@ class Value {
expected<std::int64_t> try_as_int() const { return detail::to_expected(int_impl()); }
expected<double> try_as_double() const { return detail::to_expected(double_impl()); }
expected<std::string_view> try_as_string() const { return detail::to_expected(string_impl()); }
expected<std::span<const std::uint8_t>> try_as_bytes() const {
return detail::to_expected(bytes_impl());
}
expected<Temporal> try_as_temporal() const { return detail::to_expected(temporal_impl()); }
expected<Node> try_as_node() const { return detail::to_expected(node_impl()); }
expected<Rel> try_as_rel() const { return detail::to_expected(rel_impl()); }
Expand Down Expand Up @@ -799,6 +812,20 @@ class Value {
}
return std::string_view(p == nullptr ? "" : p, len);
}
detail::Outcome<std::span<const std::uint8_t>> 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<const std::uint8_t>{};
}
return std::span<const std::uint8_t>(p, len);
}
detail::Outcome<Temporal> temporal_impl() const {
std::int32_t kind = 0;
std::int64_t count = 0;
Expand Down Expand Up @@ -1435,6 +1462,11 @@ T Row::get(std::uint32_t col) const {
return result_->str(row_, col);
} else if constexpr (std::is_same_v<T, std::string>) {
return std::string(result_->str(row_, col));
} else if constexpr (std::is_same_v<T, std::span<const std::uint8_t>>) {
return result_->cell(row_, col).as_bytes();
} else if constexpr (std::is_same_v<T, std::vector<std::uint8_t>>) {
const auto v = result_->cell(row_, col).as_bytes();
return std::vector<std::uint8_t>(v.begin(), v.end());
} else if constexpr (std::is_same_v<T, bool>) {
return result_->cell(row_, col).as_bool();
} else if constexpr (std::is_same_v<T, Temporal>) {
Expand Down Expand Up @@ -2513,6 +2545,24 @@ class Connection {
* call and a vector of views into them would be a trap. */
std::vector<std::string> 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<std::string> table_name(std::uint32_t table) const {
return detail::unwrap(table_name_impl(table));
}

#if ZU_HAS_EXPECTED
static expected<Connection> try_open(std::string_view path) {
return detail::to_expected(open_impl(path));
Expand Down Expand Up @@ -2548,6 +2598,9 @@ class Connection {
expected<std::vector<std::string>> try_registered() const {
return detail::to_expected(registered_impl());
}
expected<std::optional<std::string>> try_table_name(std::uint32_t table) const {
return detail::to_expected(table_name_impl(table));
}
#endif

private:
Expand Down Expand Up @@ -2741,6 +2794,20 @@ class Connection {
}
return out;
}
detail::Outcome<std::optional<std::string>> 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<std::string>{};
}
return std::optional<std::string>(std::in_place, p, len);
}

detail::Handle<zu_conn, zu_conn_close> h_;
std::unique_ptr<Watch> watch_;
Expand Down
10 changes: 9 additions & 1 deletion test/test_errors.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::int64_t>(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
Expand All @@ -131,6 +135,10 @@ ZU_TEST(a_closed_handle_is_a_misuse_and_not_a_crash) {
CHECK(!static_cast<bool>(conn));
CHECK(static_cast<bool>(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) {
Expand Down
21 changes: 21 additions & 0 deletions test/test_expected.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
10 changes: 7 additions & 3 deletions test/test_frame.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::int64_t>("big"), 1);
CHECK_EQ(r.row(0).get<std::int64_t>("mid"), 3);
CHECK_EQ(r.row(0).get<std::int64_t>("small"), 5);
Expand Down Expand Up @@ -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<zu::Temporal>(0);
CHECK_EQ(read.kind, zu::TemporalKind::local_datetime);
CHECK_EQ(read.count, 1'700'000'000'000'000'000);
Expand Down
2 changes: 1 addition & 1 deletion test/test_query.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
72 changes: 72 additions & 0 deletions test/test_values.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
#include <zu.hpp>

#include <chrono>
#include <cstdint>
#include <iterator>
#include <optional>
#include <span>
#include <string>
#include <vector>

Expand Down Expand Up @@ -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<const std::uint8_t> 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<int>(b[0]), 0);
CHECK_EQ(static_cast<int>(b[1]), 0xAB);

const auto same = r.row(0).get<std::span<const std::uint8_t>>(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<std::vector<std::uint8_t>>(0);
CHECK_EQ(owned.size(), 2u);
CHECK_EQ(static_cast<int>(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");
Expand All @@ -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<std::string> 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<std::string> 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");
Expand Down