diff --git a/README.md b/README.md index f62e1fa..3fe72a7 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,8 @@ lynn ## What is here -- `include/zu.hpp`, the header-only C++20 wrapper. RAII on every handle, exceptions carrying the GQLSTATUS condition, ranges over results, `std::span` over columns, and a `std::expected` mirror of the whole error model under C++23. Optional and additive, the C API stays usable on its own. -- `test/`, the suite. Every case is built twice, once at C++23 and once at the C++20 floor, so the standard the header claims to support is the standard it is tested against. +- `include/zu.hpp`, the header-only C++20 wrapper. RAII on every handle, exceptions carrying the GQLSTATUS condition, ranges over results, `std::span` over columns, `std::formatter` on everything worth printing, and a `std::expected` mirror of the whole error model under C++23. Optional and additive, the C API stays usable on its own. +- `test/`, the suite. Every case is built twice, once at C++23 and once at the C++20 floor, so the standard the header claims to support is the standard it is tested against. `test/test_idiom.cpp` is mostly `static_assert`, and deliberately: that a `Result` is a random access range, that a handle moves and refuses to be copied, that a view into a result is not a borrowed one, and that an exception is a `std::exception` are promises the compiler should keep at every call site rather than ones a case checked once. - `examples/`, one per thing worth knowing. Every example is also a test, because an example that compiles and does not run is documentation that lies. - `readme/`, which lifts the two programs above off this page, builds them, runs them and diffs what they print against the blocks under them. The page is the code most people read and the code least often run, and it is the only code here that had nothing compiling it. - `bench/`, the numbers below, with a timing harness that needs no package manager to run. diff --git a/include/zu.hpp b/include/zu.hpp index ade2452..9473238 100644 --- a/include/zu.hpp +++ b/include/zu.hpp @@ -56,6 +56,7 @@ #include #include #include +#include #include #include #include @@ -72,6 +73,25 @@ #include #include +/* First, and not in alphabetical order with the rest, because the two + * feature tests below read macros that only exist once something has + * defined them and is the header whose whole job is defining + * them. + * + * Without it this was reading __cpp_lib_expected before anything had + * declared it, so an undefined macro evaluated to nought and + * ZU_HAS_EXPECTED came out 0 on GCC 13 at -std=c++23, where + * std::expected has been there since GCC 12. Every try_ call in this + * header then went undeclared, on a toolchain that has them, and the + * suite for that half compiled away to a placeholder rather than + * failing, so nothing said so. + * + * Worse than missing: it depended on include order. A translation unit + * that had already included or before this file + * got the try_ half and one that had not did not, which is two + * different APIs out of one header in one build. */ +#include + #if __cpp_lib_expected >= 202202L #include #define ZU_HAS_EXPECTED 1 @@ -791,18 +811,18 @@ class Value { inline ValueRange elements() const; #if ZU_HAS_EXPECTED - expected try_as_bool() const { return detail::to_expected(bool_impl()); } - 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 { + [[nodiscard]] expected try_as_bool() const { return detail::to_expected(bool_impl()); } + [[nodiscard]] expected try_as_int() const { return detail::to_expected(int_impl()); } + [[nodiscard]] expected try_as_double() const { return detail::to_expected(double_impl()); } + [[nodiscard]] expected try_as_string() const { return detail::to_expected(string_impl()); } + [[nodiscard]] 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()); } - expected try_at(std::uint64_t i) const { return detail::to_expected(at_impl(i)); } - expected try_field(std::uint64_t i) const { + [[nodiscard]] expected try_as_temporal() const { return detail::to_expected(temporal_impl()); } + [[nodiscard]] expected try_as_node() const { return detail::to_expected(node_impl()); } + [[nodiscard]] expected try_as_rel() const { return detail::to_expected(rel_impl()); } + [[nodiscard]] expected try_at(std::uint64_t i) const { return detail::to_expected(at_impl(i)); } + [[nodiscard]] expected try_field(std::uint64_t i) const { return detail::to_expected(field_impl(i)); } #endif @@ -1265,53 +1285,53 @@ class Result { inline void to_arrow(ArrowArrayStream* out, std::uint64_t rows_per_batch = 0) &&; #if ZU_HAS_EXPECTED - expected try_name(std::uint32_t col) const { + [[nodiscard]] expected try_name(std::uint32_t col) const { return detail::to_expected(name_impl(col)); } - expected try_column(std::string_view name) const { + [[nodiscard]] expected try_column(std::string_view name) const { return detail::to_expected(column_impl(name)); } - expected try_type(std::uint64_t row, std::uint32_t col) const { + [[nodiscard]] expected try_type(std::uint64_t row, std::uint32_t col) const { return detail::to_expected(type_impl(row, col)); } - expected> try_ints(std::uint32_t col) const { + [[nodiscard]] expected> try_ints(std::uint32_t col) const { return detail::to_expected(ints_impl(col)); } - expected> try_doubles(std::uint32_t col) const { + [[nodiscard]] expected> try_doubles(std::uint32_t col) const { return detail::to_expected(doubles_impl(col)); } - expected> try_node_offsets(std::uint32_t col) const { + [[nodiscard]] expected> try_node_offsets(std::uint32_t col) const { return detail::to_expected(node_offsets_impl(col)); } - expected> try_valid(std::uint32_t col) const { + [[nodiscard]] expected> try_valid(std::uint32_t col) const { return detail::to_expected(valid_impl(col)); } - expected try_str(std::uint64_t row, std::uint32_t col) const { + [[nodiscard]] expected try_str(std::uint64_t row, std::uint32_t col) const { return detail::to_expected(str_impl(row, col)); } - expected try_cell(std::uint64_t row, std::uint32_t col) const { + [[nodiscard]] expected try_cell(std::uint64_t row, std::uint32_t col) const { return detail::to_expected(cell_impl(row, col)); } - expected try_chunk(std::uint64_t i) const { + [[nodiscard]] expected try_chunk(std::uint64_t i) const { return detail::to_expected(chunk_impl(i)); } - expected> try_chunk_ints(std::uint64_t chunk, + [[nodiscard]] expected> try_chunk_ints(std::uint64_t chunk, std::uint32_t col) const { return detail::to_expected(chunk_ints_impl(chunk, col)); } - expected> try_chunk_doubles(std::uint64_t chunk, + [[nodiscard]] expected> try_chunk_doubles(std::uint64_t chunk, std::uint32_t col) const { return detail::to_expected(chunk_doubles_impl(chunk, col)); } - expected> try_chunk_node_offsets(std::uint64_t chunk, + [[nodiscard]] expected> try_chunk_node_offsets(std::uint64_t chunk, std::uint32_t col) const { return detail::to_expected(chunk_node_offsets_impl(chunk, col)); } - expected> try_chunk_valid(std::uint64_t chunk, + [[nodiscard]] expected> try_chunk_valid(std::uint64_t chunk, std::uint32_t col) const { return detail::to_expected(chunk_valid_impl(chunk, col)); } - inline expected try_to_arrow(Connection& conn, ArrowArrayStream* out, + [[nodiscard]] inline expected try_to_arrow(Connection& conn, ArrowArrayStream* out, std::uint64_t rows_per_batch = 0) &&; #endif @@ -1582,25 +1602,25 @@ class Statement { Result execute() { return detail::unwrap(execute_impl()); } #if ZU_HAS_EXPECTED - expected try_bind(std::string_view name, std::int64_t v) { + [[nodiscard]] expected try_bind(std::string_view name, std::int64_t v) { return detail::to_expected_void(bind_int_impl(name, v)); } - expected try_bind(std::string_view name, double v) { + [[nodiscard]] expected try_bind(std::string_view name, double v) { return detail::to_expected_void(bind_double_impl(name, v)); } - expected try_bind(std::string_view name, bool v) { + [[nodiscard]] expected try_bind(std::string_view name, bool v) { return detail::to_expected_void(bind_bool_impl(name, v)); } - expected try_bind(std::string_view name, std::string_view v) { + [[nodiscard]] expected try_bind(std::string_view name, std::string_view v) { return detail::to_expected_void(bind_str_impl(name, v)); } - expected try_bind(std::string_view name, Temporal v) { + [[nodiscard]] expected try_bind(std::string_view name, Temporal v) { return detail::to_expected_void(bind_temporal_impl(name, v)); } - expected try_bind_null(std::string_view name) { + [[nodiscard]] expected try_bind_null(std::string_view name) { return detail::to_expected_void(bind_null_impl(name)); } - expected try_execute() { return detail::to_expected(execute_impl()); } + [[nodiscard]] expected try_execute() { return detail::to_expected(execute_impl()); } #endif private: @@ -1748,25 +1768,25 @@ class Appender { std::uint64_t close() { return detail::unwrap(close_impl()); } #if ZU_HAS_EXPECTED - expected try_append(bool v) { return detail::to_expected_void(append_bool_impl(v)); } - expected try_append(std::int64_t v) { return detail::to_expected_void(append_int_impl(v)); } - expected try_append(double v) { return detail::to_expected_void(append_double_impl(v)); } - expected try_append(std::string_view v) { + [[nodiscard]] expected try_append(bool v) { return detail::to_expected_void(append_bool_impl(v)); } + [[nodiscard]] expected try_append(std::int64_t v) { return detail::to_expected_void(append_int_impl(v)); } + [[nodiscard]] expected try_append(double v) { return detail::to_expected_void(append_double_impl(v)); } + [[nodiscard]] expected try_append(std::string_view v) { return detail::to_expected_void(append_str_impl(v)); } - expected try_append(std::span v) { + [[nodiscard]] expected try_append(std::span v) { return detail::to_expected_void(append_bytes_impl(v)); } - expected try_append(Temporal v) { + [[nodiscard]] expected try_append(Temporal v) { return detail::to_expected_void(append_temporal_impl(v)); } - expected try_col_name(std::uint32_t col) const { + [[nodiscard]] expected try_col_name(std::uint32_t col) const { return detail::to_expected(col_name_impl(col)); } - expected try_end_row() { return detail::to_expected_void(end_row_impl()); } - expected try_flush() { return detail::to_expected_void(flush_impl()); } - expected try_discard() { return detail::to_expected(discard_impl()); } - expected try_close() { return detail::to_expected(close_impl()); } + [[nodiscard]] expected try_end_row() { return detail::to_expected_void(end_row_impl()); } + [[nodiscard]] expected try_flush() { return detail::to_expected_void(flush_impl()); } + [[nodiscard]] expected try_discard() { return detail::to_expected(discard_impl()); } + [[nodiscard]] expected try_close() { return detail::to_expected(close_impl()); } #endif private: @@ -1889,7 +1909,7 @@ class Loader { /* Fails if the path exists, which is what a bulk load is: it builds a * database rather than adding to one. */ - static Loader create(std::string_view path) { return detail::unwrap(create_impl(path)); } + [[nodiscard]] static Loader create(std::string_view path) { return detail::unwrap(create_impl(path)); } zu_loader* raw() const noexcept { return h_.get(); } explicit operator bool() const noexcept { return static_cast(h_); } @@ -1948,33 +1968,33 @@ class Loader { void finish() { detail::unwrap_void(finish_impl()); } #if ZU_HAS_EXPECTED - static expected try_create(std::string_view path) { + [[nodiscard]] static expected try_create(std::string_view path) { return detail::to_expected(create_impl(path)); } - expected try_table(std::string_view nodes, std::string_view edges, std::uint64_t rows) { + [[nodiscard]] expected try_table(std::string_view nodes, std::string_view edges, std::uint64_t rows) { return detail::to_expected_void(table_impl(nodes, edges, rows)); } - expected try_edges(std::span from, std::span to) { + [[nodiscard]] expected try_edges(std::span from, std::span to) { return detail::to_expected_void(edges_impl(from, to)); } - expected try_ints(std::string_view name, std::span values) { + [[nodiscard]] expected try_ints(std::string_view name, std::span values) { return detail::to_expected_void(col_ints_impl(name, values)); } - expected try_doubles(std::string_view name, std::span values) { + [[nodiscard]] expected try_doubles(std::string_view name, std::span values) { return detail::to_expected_void(col_doubles_impl(name, values)); } - expected try_bools(std::string_view name, std::span values) { + [[nodiscard]] expected try_bools(std::string_view name, std::span values) { return detail::to_expected_void(col_bools_impl(name, values)); } template - expected try_strings(std::string_view name, const R& values) { + [[nodiscard]] expected try_strings(std::string_view name, const R& values) { return detail::to_expected_void(col_strs_impl(name, detail::to_views(values))); } - expected try_temporals(std::string_view name, TemporalKind kind, + [[nodiscard]] expected try_temporals(std::string_view name, TemporalKind kind, std::span values) { return detail::to_expected_void(col_temporal_impl(name, kind, values)); } - expected try_finish() { return detail::to_expected_void(finish_impl()); } + [[nodiscard]] expected try_finish() { return detail::to_expected_void(finish_impl()); } #endif private: @@ -2103,20 +2123,20 @@ class Frame { explicit Frame(zu_frame* f) noexcept : h_(f) {} /* A frame over buffers the caller keeps alive itself. */ - static Frame create(std::string_view name, std::uint64_t rows) { + [[nodiscard]] static Frame create(std::string_view name, std::uint64_t rows) { return detail::unwrap(create_impl(name, rows, {})); } /* A frame that says when the engine has finished with it. The * callback runs once, on a thread of the library's, and is where a * host that has to take a lock to let go of what it passed takes * it. */ - static Frame create(std::string_view name, std::uint64_t rows, std::function release) { + [[nodiscard]] static Frame create(std::string_view name, std::uint64_t rows, std::function release) { return detail::unwrap(create_impl(name, rows, std::move(release))); } /* The same, keeping something alive rather than running something: a * shared_ptr to whatever owns the buffers, dropped when the engine is * done. */ - static Frame create(std::string_view name, std::uint64_t rows, std::shared_ptr keepalive) { + [[nodiscard]] static Frame create(std::string_view name, std::uint64_t rows, std::shared_ptr keepalive) { return create(name, rows, [held = std::move(keepalive)]() mutable { held.reset(); }); } @@ -2174,20 +2194,20 @@ class Frame { } #if ZU_HAS_EXPECTED - static expected try_create(std::string_view name, std::uint64_t rows, + [[nodiscard]] static expected try_create(std::string_view name, std::uint64_t rows, std::function release = {}) { return detail::to_expected(create_impl(name, rows, std::move(release))); } template - expected try_column(std::string_view name, const R& values, std::int64_t scale = 1, + [[nodiscard]] expected try_column(std::string_view name, const R& values, std::int64_t scale = 1, TemporalKind temporal = TemporalKind::plain) { return detail::to_expected_void(numeric_impl(name, values, scale, temporal)); } - expected try_bools(std::string_view name, std::span bitmap, + [[nodiscard]] expected try_bools(std::string_view name, std::span bitmap, std::uint64_t count) { return detail::to_expected_void(col_bool_impl(name, bitmap.data(), count)); } - expected try_strings(std::string_view name, std::span offsets, + [[nodiscard]] expected try_strings(std::string_view name, std::span offsets, std::span data) { return detail::to_expected_void(col_str_impl(name, offsets.data(), 0, data.data(), data.size(), offsets.empty() ? 0 : offsets.size() - 1)); @@ -2341,7 +2361,7 @@ class Config { } #if ZU_HAS_EXPECTED - expected try_set(std::string_view key, std::string_view value) { + [[nodiscard]] expected try_set(std::string_view key, std::string_view value) { return detail::to_expected_void(set_impl(key, value)); } #endif @@ -2375,18 +2395,18 @@ class Database { Database() = default; explicit Database(zu_database* db) noexcept : h_(db) {} - static Database open(std::string_view path, const Config& cfg = Config{}) { + [[nodiscard]] static Database open(std::string_view path, const Config& cfg = Config{}) { return detail::unwrap(open_impl(path, cfg)); } /* The path must not exist. A create that opened what it found there * would be the call that quietly writes into somebody else's data. */ - static Database create(std::string_view path, const Config& cfg = Config{}) { + [[nodiscard]] static Database create(std::string_view path, const Config& cfg = Config{}) { return detail::unwrap(create_impl(path, cfg)); } /* A database that never touches the filesystem. Every call makes one * of its own: two connections on one handle are two views of one * graph, and two handles share nothing. */ - static Database memory(const Config& cfg = Config{}) { return detail::unwrap(memory_impl(cfg)); } + [[nodiscard]] static Database memory(const Config& cfg = Config{}) { return detail::unwrap(memory_impl(cfg)); } zu_database* raw() const noexcept { return h_.get(); } explicit operator bool() const noexcept { return static_cast(h_); } @@ -2401,17 +2421,17 @@ class Database { inline Connection connect() const; #if ZU_HAS_EXPECTED - static expected try_open(std::string_view path, const Config& cfg = Config{}) { + [[nodiscard]] static expected try_open(std::string_view path, const Config& cfg = Config{}) { return detail::to_expected(open_impl(path, cfg)); } - static expected try_create(std::string_view path, const Config& cfg = Config{}) { + [[nodiscard]] static expected try_create(std::string_view path, const Config& cfg = Config{}) { return detail::to_expected(create_impl(path, cfg)); } - static expected try_memory(const Config& cfg = Config{}) { + [[nodiscard]] static expected try_memory(const Config& cfg = Config{}) { return detail::to_expected(memory_impl(cfg)); } - expected try_path() const { return detail::to_expected(path_impl()); } - inline expected try_connect() const; + [[nodiscard]] expected try_path() const { return detail::to_expected(path_impl()); } + [[nodiscard]] inline expected try_connect() const; #endif private: @@ -2485,9 +2505,9 @@ class Connection { /* One database with the default configuration, one connection on it, * and nothing else to keep track of. */ - static Connection open(std::string_view path) { return detail::unwrap(open_impl(path)); } - static Connection create(std::string_view path) { return detail::unwrap(create_impl(path)); } - static Connection memory() { return detail::unwrap(memory_impl()); } + [[nodiscard]] static Connection open(std::string_view path) { return detail::unwrap(open_impl(path)); } + [[nodiscard]] static Connection create(std::string_view path) { return detail::unwrap(create_impl(path)); } + [[nodiscard]] static Connection memory() { return detail::unwrap(memory_impl()); } zu_conn* raw() const noexcept { return h_.get(); } explicit operator bool() const noexcept { return static_cast(h_); } @@ -2589,41 +2609,41 @@ class Connection { } #if ZU_HAS_EXPECTED - static expected try_open(std::string_view path) { + [[nodiscard]] static expected try_open(std::string_view path) { return detail::to_expected(open_impl(path)); } - static expected try_create(std::string_view path) { + [[nodiscard]] static expected try_create(std::string_view path) { return detail::to_expected(create_impl(path)); } - static expected try_memory() { return detail::to_expected(memory_impl()); } - expected try_duplicate() { return detail::to_expected(duplicate_impl()); } - expected try_query(std::string_view q) { return detail::to_expected(query_impl(q)); } - expected try_prepare(std::string_view q) { + [[nodiscard]] static expected try_memory() { return detail::to_expected(memory_impl()); } + [[nodiscard]] expected try_duplicate() { return detail::to_expected(duplicate_impl()); } + [[nodiscard]] expected try_query(std::string_view q) { return detail::to_expected(query_impl(q)); } + [[nodiscard]] expected try_prepare(std::string_view q) { return detail::to_expected(prepare_impl(q)); } - expected try_interrupt() { return detail::to_expected_void(interrupt_impl()); } - expected try_rows_read() const { return detail::to_expected(rows_read_impl()); } - expected try_on_progress(std::chrono::milliseconds every, Progress watcher) { + [[nodiscard]] expected try_interrupt() { return detail::to_expected_void(interrupt_impl()); } + [[nodiscard]] expected try_rows_read() const { return detail::to_expected(rows_read_impl()); } + [[nodiscard]] expected try_on_progress(std::chrono::milliseconds every, Progress watcher) { return detail::to_expected_void(set_progress_impl(every, std::move(watcher))); } [[nodiscard]] inline expected try_transaction(bool read_only = false); - expected try_begin(bool read_only = false) { + [[nodiscard]] expected try_begin(bool read_only = false) { return detail::to_expected_void(begin_impl(read_only)); } - expected try_commit() { return detail::to_expected_void(commit_impl()); } - expected try_rollback() { return detail::to_expected_void(rollback_impl()); } - expected try_in_transaction() const { return detail::to_expected(in_transaction_impl()); } - expected try_appender(std::string_view table) { + [[nodiscard]] expected try_commit() { return detail::to_expected_void(commit_impl()); } + [[nodiscard]] expected try_rollback() { return detail::to_expected_void(rollback_impl()); } + [[nodiscard]] expected try_in_transaction() const { return detail::to_expected(in_transaction_impl()); } + [[nodiscard]] expected try_appender(std::string_view table) { return detail::to_expected(appender_impl(table)); } - expected try_register_frame(Frame& f) { return detail::to_expected_void(register_impl(f)); } - expected try_unregister_frame(std::string_view name) { + [[nodiscard]] expected try_register_frame(Frame& f) { return detail::to_expected_void(register_impl(f)); } + [[nodiscard]] expected try_unregister_frame(std::string_view name) { return detail::to_expected(unregister_impl(name)); } - expected> try_registered() const { + [[nodiscard]] expected> try_registered() const { return detail::to_expected(registered_impl()); } - expected> try_table_name(std::uint32_t table) const { + [[nodiscard]] expected> try_table_name(std::uint32_t table) const { return detail::to_expected(table_name_impl(table)); } #endif @@ -2961,6 +2981,260 @@ inline expected Result::try_to_arrow(Connection& conn, ArrowArrayStream* o } #endif +/* ---- printing ---- + * + * All of this is for a person to read: a log line, a test failure, a + * debugger watch. A program that wants the bits calls the accessor. + * + * to_string is the whole of it, and the std::formatter specializations + * below are one line each over it. That way the C++20 floor gets the + * same text as C++23 without needing to be there, and the two + * spellings cannot come to disagree about what a value looks like, for + * the same reason the throwing and try_ halves are one line over one + * implementation. + * + * The enums answer a view of a string literal, which costs nothing and + * needs no allocation to print a status in a hot path. The rest build a + * string, because there is nothing to point at otherwise. + * + * Every one of these is an overload of a single name rather than + * to_string_status and to_string_node, so a generic caller writes + * to_string(x) and argument dependent lookup finds it. */ + +inline std::string_view to_string(Status s) noexcept { + switch (s) { + case Status::ok: return "ok"; + case Status::done: return "done"; + case Status::error: return "error"; + case Status::misuse: return "misuse"; + case Status::misuse_concurrent: return "misuse_concurrent"; + case Status::misuse_closed: return "misuse_closed"; + case Status::interrupted: return "interrupted"; + case Status::conflict: return "conflict"; + case Status::corrupt: return "corrupt"; + case Status::unsupported: return "unsupported"; + case Status::io: return "io"; + } + /* Not unreachable. The ABI numbers these and a library built from a + * later zu.h than this header can hand back one it has never heard + * of, which should print as a mystery rather than fall off the end of + * the function. */ + return "unknown"; +} + +inline std::string_view to_string(Severity s) noexcept { + switch (s) { + case Severity::success: return "success"; + case Severity::no_data: return "no_data"; + case Severity::warning: return "warning"; + case Severity::informational: return "informational"; + case Severity::exception: return "exception"; + } + return "unknown"; +} + +/* The names the API model uses, not the C++ spellings. A column of + * whole numbers is an INT everywhere else a reader will meet it, and a + * printer that called it `integer` because that is what the enumerator + * had to be named would be teaching a vocabulary nothing else speaks. */ +inline std::string_view to_string(Type t) noexcept { + switch (t) { + case Type::null: return "null"; + case Type::boolean: return "bool"; + case Type::integer: return "int"; + case Type::floating: return "float"; + case Type::string: return "str"; + case Type::node: return "node"; + case Type::rel: return "rel"; + case Type::list: return "list"; + case Type::path: return "path"; + case Type::temporal: return "temporal"; + case Type::record: return "record"; + case Type::graph: return "graph"; + case Type::binding_table: return "binding_table"; + case Type::bytes: return "bytes"; + } + return "unknown"; +} + +inline std::string_view to_string(TemporalKind k) noexcept { + switch (k) { + case TemporalKind::date: return "date"; + case TemporalKind::local_time: return "local_time"; + case TemporalKind::zoned_time: return "zoned_time"; + case TemporalKind::local_datetime: return "local_datetime"; + case TemporalKind::zoned_datetime: return "zoned_datetime"; + case TemporalKind::duration_year_month: return "duration_year_month"; + case TemporalKind::duration_day_time: return "duration_day_time"; + case TemporalKind::plain: return "plain"; + } + return "unknown"; +} + +namespace detail { + +/* A double as a person reads it. + * + * Fifteen significant digits rather than the seventeen that round-trip + * every double exactly, because this is the printing section: 0.1 + * should print as 0.1 and not as 0.10000000000000001, and a caller who + * needs the bits back has as_double and is not scraping them out of a + * log line. + * + * snprintf rather than std::to_chars, which is the better tool and is + * C++17. The floating point half of to_chars landed in the standard + * libraries years after the integer half, and this header promises to + * compile at the C++20 floor rather than on the subset of C++20 + * toolchains that happen to have shipped it. */ +inline std::string printed(double d) { + char buf[32]; + const int n = std::snprintf(buf, sizeof buf, "%.15g", d); + if (n <= 0) { + return "nan"; + } + const auto len = static_cast(n); + return std::string(buf, len < sizeof buf ? len : sizeof buf - 1); +} + +} // namespace detail + +inline std::string to_string(Position p) { + /* Line and column, and not the offset. The offset is for a tool that + * is going to index into the statement; a person reading a failure + * wants the two numbers their editor shows them. */ + return "line " + std::to_string(p.line) + ", column " + std::to_string(p.column); +} + +inline std::string to_string(Node n) { + return "node " + std::to_string(n.table) + ":" + std::to_string(n.offset); +} + +inline std::string to_string(Rel r) { + return "rel " + std::to_string(r.table) + ":" + std::to_string(r.src) + "->" + + std::to_string(r.dst); +} + +inline std::string to_string(Temporal t) { + /* The kind first, because the count means nothing without it: 19000 + * is a date in 2022 and a duration of nineteen microseconds, and the + * only thing that tells them apart is the word in front. */ + std::string out(to_string(t.kind)); + out += ' '; + out += std::to_string(t.count); + if (t.offset != 0) { + out += t.offset > 0 ? " +" : " "; + out += std::to_string(t.offset); + } + return out; +} + +/* A failure on one line, which is what a log wants. Error::report is + * the other spelling, three lines with a caret under the column, for + * the program that is showing somebody a statement to fix. + * + * The code and the place go in front of the message, because that is + * the order a reader wants them in: what class of thing went wrong, + * where, and then what the engine had to say about it. A condition with + * neither prints as the message alone rather than as an empty prefix + * and a colon. */ +inline std::string to_string(const Error& e) { + std::string out; + if (const auto code = e.code(); code.has_value()) { + out += *code; + } + if (const auto at = e.position(); at.has_value()) { + if (!out.empty()) { + out += ' '; + } + out += "at " + to_string(*at); + } + if (!out.empty()) { + out += ": "; + } + out += e.message(); + return out; +} + +/* A cell on one line. + * + * Dispatched on the type the value says it is rather than on a call + * that could refuse, so printing a cell is not a thing that throws + * where the printing is the last thing left working. + * + * The shapes a scalar cannot hold print as what they are and how many + * they hold. A list printed elementwise is a different function and a + * recursive one, and the caller who wants it has elements() and a range + * that composes with std::views. */ +inline std::string to_string(const Value& v) { + if (v.raw() == nullptr) { + return ""; + } + switch (v.type()) { + case Type::null: return "null"; + case Type::boolean: return v.as_bool() ? "true" : "false"; + case Type::integer: return std::to_string(v.as_int()); + case Type::floating: return detail::printed(v.as_double()); + case Type::string: return std::string(v.as_string()); + case Type::node: return to_string(v.as_node()); + case Type::rel: return to_string(v.as_rel()); + case Type::temporal: return to_string(v.as_temporal()); + case Type::bytes: return std::to_string(v.as_bytes().size()) + " bytes"; + default: break; + } + return std::string(to_string(v.type())) + " of " + std::to_string(v.size()); +} + } // namespace zu +#if ZU_HAS_FORMAT +/* std::format over the same text. + * + * Each of these inherits formatter, so the whole standard + * format spec arrives with it and none of it had to be written here: + * {:>20} pads a status the way it pads any other string, and a bad spec + * is rejected at compile time by the base class rather than accepted + * and ignored. + * + * was already included at the top of this header and + * ZU_HAS_FORMAT was already defined, and nothing used either, which is + * a header claiming a capability it did not have. + * + * No operator<< to go with it, on purpose. is one of the + * heaviest headers in the standard library and it would land in every + * translation unit that includes this one whether it prints or not, for + * a wrapper whose first line is that it includes zu.h and calls nothing + * else. A caller who wants a stream writes + * + * os << zu::to_string(e); + * + * which is one call, no dependency, and the same bytes. + * + * The text goes in a named local first. to_string answers a std::string + * for most of these, and a view of a temporary is a view of nothing + * once the full expression it was built in has ended. */ +#define ZU_FORMATTER(TYPE) \ + template <> \ + struct std::formatter : std::formatter { \ + template \ + auto format(const TYPE& v, Context& ctx) const { \ + const auto text = zu::to_string(v); \ + return std::formatter::format(std::string_view(text), \ + ctx); \ + } \ + } + +ZU_FORMATTER(zu::Status); +ZU_FORMATTER(zu::Severity); +ZU_FORMATTER(zu::Type); +ZU_FORMATTER(zu::TemporalKind); +ZU_FORMATTER(zu::Position); +ZU_FORMATTER(zu::Node); +ZU_FORMATTER(zu::Rel); +ZU_FORMATTER(zu::Temporal); +ZU_FORMATTER(zu::Error); +ZU_FORMATTER(zu::Value); + +#undef ZU_FORMATTER +#endif + #endif /* ZU_HPP */ diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8ce361d..ec1164e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -54,7 +54,8 @@ set(ZU_CPP_TEST_FILES test_frame.cpp test_progress.cpp test_expected.cpp - test_arrow.cpp) + test_arrow.cpp + test_idiom.cpp) foreach(file IN LISTS ZU_CPP_TEST_FILES) cmake_path(GET file STEM name) diff --git a/test/test_conditions.cpp b/test/test_conditions.cpp index 9ad317f..62d67e4 100644 --- a/test/test_conditions.cpp +++ b/test/test_conditions.cpp @@ -250,7 +250,11 @@ ZU_TEST(a_failure_that_is_not_a_statement_carries_no_condition_yet) { const std::string notadb = dir.file("notadb.zu"); zt::write_file(notadb, "this is not a database"); try { - zu::Connection::open(notadb); + /* Cast away, because the call is here to throw and the connection + * it would answer on the path this case says cannot happen is one + * nothing wants. Everywhere else, a dropped return is the mistake + * [[nodiscard]] is for. */ + (void)zu::Connection::open(notadb); zt::fail(__FILE__, __LINE__, "a file that is not a database opened"); } catch (const zu::Exception& e) { CHECK(!e.error().message().empty()); diff --git a/test/test_expected.cpp b/test/test_expected.cpp index e7263fa..131826d 100644 --- a/test/test_expected.cpp +++ b/test/test_expected.cpp @@ -174,6 +174,28 @@ ZU_TEST(a_frame_registers_without_throwing) { #else +/* A file that compiles to nothing is a file that passes for the wrong + * reason, and this one did. + * + * zu.hpp used to read __cpp_lib_expected before had defined + * it, so ZU_HAS_EXPECTED came out 0 on GCC 13 at -std=c++23 and every + * case above compiled away. What ran instead was one placeholder that + * queries a number, ctest wrote "Passed", and twelve cases about the + * half of the API a caller who builds without exceptions depends on + * had not been compiled anywhere for months. + * + * So the floor build says so out loud. Below C++23 there is nothing to + * run here and the throwing half is complete on its own; at C++23 and + * above, this half not being there is a broken build rather than a + * quiet one, and the compiler is the only thing positioned to notice. + * + * MSVC without /Zc:__cplusplus reports 199711L, which skips the check + * rather than firing it, and a gate that is off is better than a gate + * that is wrong. */ +#if __cplusplus > 202002L +#error "C++23 or later and no std::expected: zu.hpp turned the try_ half off. Check that is included before the feature tests in zu.hpp rather than deleting this line." +#endif + ZU_TEST(this_toolchain_has_no_std_expected_and_the_throwing_half_is_enough) { auto conn = zu::Connection::memory(); CHECK_EQ(conn.query("RETURN 1 AS one").rows(), 1u); diff --git a/test/test_idiom.cpp b/test/test_idiom.cpp new file mode 100644 index 0000000..08b9338 --- /dev/null +++ b/test/test_idiom.cpp @@ -0,0 +1,390 @@ +/* The shapes the language expects, pinned rather than described. + * + * Every other file here checks what the wrapper answers. This one + * checks what it *is*: that a Result is a range the standard views + * compose over, that a handle moves and refuses to be copied, that an + * exception is a std::exception, that the value structs are regular, + * and that a view into a result cannot silently outlive it. + * + * Most of it is static_assert, which is the point. A concept that holds + * is a promise the compiler keeps at every call site rather than one a + * case checked once; and when a change breaks it, it breaks here, with + * the name of the concept in the message, instead of three repositories + * away inside a std::views expression a user wrote. + * + * The runtime half is there so the static half cannot be satisfied by + * something that compiles and does nothing. A concept says a pipeline + * would compile; the case below runs one and checks the number. + * + * One promise of this item is not testable from inside the language. + * Ninety three calls here are [[nodiscard]], and the failure they catch + * is a caller writing `conn.try_query(q);` and dropping the expected + * that IS the failure report. A case that dropped one to prove the + * warning fires would be a case that fails the build, since CI compiles + * this repository with -Werror. So that one is enforced at every call + * site by the compiler and is deliberately not a case here. + */ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fixture.hpp" +#include "harness.hpp" + +/* ---- a result is a range ---- */ + +static_assert(std::ranges::range); +static_assert(std::ranges::sized_range); +static_assert(std::ranges::common_range); +/* Random access rather than input, which is the difference between + * `rows[500]` costing a bounds check and costing five hundred reads. + * The rows are all in memory before the first is handed out, so an + * input range here would be the wrapper lying downwards. */ +static_assert(std::ranges::random_access_range); +static_assert(std::random_access_iterator>); +static_assert(std::same_as, zu::Row>); + +/* And NOT a borrowed range, which is the compiler's half of the rule + * the README states in English: a Row points into the Result it came + * from, so an iterator that outlived the Result would point into freed + * memory. + * + * Because this is false, `conn.query(q) | std::views::filter(f)` over a + * temporary answers a std::ranges::dangling rather than an iterator, + * and the mistake is a compile error at the point it is written. A + * Result marked borrowed would turn that into a use after free that + * happens to work in a debug build. */ +static_assert(!std::ranges::borrowed_range); + +static_assert(std::ranges::view); +static_assert(std::ranges::random_access_range); +static_assert(std::same_as, zu::Value>); + +/* ---- a handle is moved, never copied ---- */ + +/* A copied connection would be two owners of one zu_conn and a double + * close, so the copy is gone rather than deep. Movable, because a + * factory that answers one has to be able to. */ +static_assert(std::movable); +static_assert(!std::copyable); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::movable); +static_assert(!std::copyable); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::movable); +static_assert(!std::copyable); +static_assert(std::movable); +static_assert(!std::copyable); + +/* A transaction moves and does not copy, on the same reasoning: it is a + * scope guard, and two guards over one transaction would roll back + * twice. Moving is allowed because a host that wants to hand the guard + * to the scope below it has to be able to. */ +static_assert(std::movable); +static_assert(!std::copyable); + +/* Nothing here needs a destructor called by hand, which is what makes + * the throwing spelling safe. */ +static_assert(std::is_nothrow_destructible_v); +static_assert(std::is_nothrow_destructible_v); +static_assert(std::is_nothrow_destructible_v); + +/* ---- a failure is an exception ---- */ + +static_assert(std::derived_from); +static_assert(std::derived_from); +static_assert(std::derived_from); +static_assert(std::derived_from); +static_assert(std::derived_from); +static_assert(std::derived_from); +static_assert(std::derived_from); +static_assert(std::derived_from); +static_assert(std::derived_from); +static_assert(std::derived_from); +static_assert(std::derived_from); + +/* An Error is a value and travels like one, which is what lets a + * std::expected carry it and a host store it. */ +static_assert(std::copyable); +static_assert(std::is_nothrow_move_constructible_v); + +/* ---- the value structs are regular ---- */ + +/* Default constructible, copyable, comparable: what the standard + * library means by a value. A struct that was not could not go in a + * std::vector and be searched for, which is the first thing a caller + * does with a node id. */ +static_assert(std::regular); +static_assert(std::regular); +static_assert(std::regular); +static_assert(std::regular); +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); + +/* ---- and the cases ---- */ + +ZU_TEST(a_result_composes_with_the_standard_views) { + zt::TempDir dir("idiom-views"); + const std::string path = dir.file("people.zu1"); + zt::people(path); + auto conn = zu::Connection::open(path); + + auto rows = conn.query("MATCH (p:Person) RETURN p.id AS id, p.name AS name ORDER BY p.id"); + + /* The pipeline a caller would actually write. If any of the concepts + * above stopped holding, this is the expression that would stop + * compiling. */ + std::vector loud; + for (auto name : rows | std::views::filter([](zu::Row r) { return r.get("id") != 2; }) | + std::views::transform([](zu::Row r) { return r.get("name"); })) { + loud.push_back(std::move(name)); + } + CHECK_EQ(loud.size(), 2u); + CHECK_EQ(loud[0], std::string("ada")); + CHECK_EQ(loud[1], std::string("alan")); + + /* And the algorithms, which take the range rather than two + * iterators, because that is how they are written in C++20. Of ada, + * grace and alan, one is three letters. */ + const auto short_names = std::ranges::count_if( + rows, [](zu::Row r) { return r.get("name").size() == 3; }); + CHECK_EQ(short_names, 1); + + /* Random access, used as random access. */ + CHECK_EQ(rows.begin()[2].get("name"), std::string_view("alan")); + CHECK_EQ(std::ranges::distance(rows), 3); +} + +ZU_TEST(a_connection_moves_and_the_one_it_left_is_still_safe_to_destroy) { + auto first = zu::Connection::memory(); + CHECK(static_cast(first)); + + auto second = std::move(first); + /* The moved-from handle is empty rather than undefined, which is what + * lets its destructor run at the end of this scope without a second + * close of the connection `second` now owns. */ + CHECK(!static_cast(first)); + CHECK(static_cast(second)); + CHECK_EQ(second.query("RETURN 7 AS v").cell(0, 0).as_int(), 7); +} + +ZU_TEST(a_failure_is_caught_by_the_class_of_its_condition_and_by_std_exception) { + auto conn = zu::Connection::memory(); + + /* By its own class. */ + CHECK_THROWS_AS(zu::SyntaxError, conn.query("RETURN RETURN")); + + /* By the base, which is what a host with one catch at the top of a + * request writes. */ + bool caught = false; + try { + conn.query("RETURN RETURN"); + } catch (const std::exception& e) { + caught = true; + /* what() is the message and not a class name, because the string a + * generic handler logs should be the one that says what happened. */ + CHECK(std::string_view(e.what()).size() > 0); + CHECK(std::string_view(e.what()) != std::string_view("zu::Exception")); + } + CHECK(caught); + + /* And the whole report is still on the exception, which is the half a + * return code cannot carry. + * + * An unclosed bracket rather than RETURN RETURN, because the second + * one parses: RETURN is accepted as a variable name, so the failure + * comes from the binder at no particular token and carries no + * position. That is tamnd/zu#579's neighbourhood and not this case's + * subject. */ + try { + conn.query("MATCH (p:Person RETURN p"); + } catch (const zu::Exception& e) { + CHECK(e.error().position().has_value()); + CHECK(e.error().code().has_value()); + CHECK(e.error().excerpt().has_value()); + } +} + +ZU_TEST(the_value_structs_go_in_containers_and_come_back_out) { + std::vector nodes{{0, 3}, {0, 1}, {1, 1}}; + CHECK(std::ranges::find(nodes, zu::Node{1, 1}) != nodes.end()); + CHECK(std::ranges::find(nodes, zu::Node{9, 9}) == nodes.end()); + + /* Sorted by a comparator rather than by <=>, because these carry no + * ordering: two tables number their rows from zero and there is no + * one order the pair is in. */ + std::ranges::sort(nodes, [](zu::Node a, zu::Node b) { + return a.table != b.table ? a.table < b.table : a.offset < b.offset; + }); + CHECK_EQ(nodes.front(), (zu::Node{0, 1})); +} + +/* ---- printing ---- */ + +ZU_TEST(every_kind_of_thing_prints_as_something_a_person_can_read) { + CHECK_EQ(zu::to_string(zu::Status::misuse_concurrent), + std::string_view("misuse_concurrent")); + CHECK_EQ(zu::to_string(zu::Severity::warning), std::string_view("warning")); + CHECK_EQ(zu::to_string(zu::Type::integer), std::string_view("int")); + CHECK_EQ(zu::to_string(zu::TemporalKind::duration_day_time), + std::string_view("duration_day_time")); + + CHECK_EQ(zu::to_string(zu::Position{3, 14, 40}), std::string("line 3, column 14")); + CHECK_EQ(zu::to_string(zu::Node{2, 7}), std::string("node 2:7")); + CHECK_EQ(zu::to_string(zu::Rel{1, 4, 9}), std::string("rel 1:4->9")); + + /* The kind first, because 19000 is a date in 2022 and a duration of + * nineteen microseconds and nothing else tells them apart. */ + CHECK_EQ(zu::to_string(zu::Temporal{zu::TemporalKind::date, 19000, 0}), + std::string("date 19000")); + CHECK_EQ(zu::to_string(zu::Temporal{zu::TemporalKind::zoned_time, 5, 90}), + std::string("zoned_time 5 +90")); + CHECK_EQ(zu::to_string(zu::Temporal{zu::TemporalKind::zoned_time, 5, -90}), + std::string("zoned_time 5 -90")); + + /* A status the ABI has and this header has not is a mystery rather + * than a walk off the end of a switch. */ + CHECK_EQ(zu::to_string(static_cast(9999)), std::string_view("unknown")); +} + +ZU_TEST(a_cell_prints_as_what_it_holds) { + auto conn = zu::Connection::memory(); + auto r = conn.query("RETURN 1 AS i, 1.5 AS f, 'ada' AS s, true AS b, null AS n"); + + CHECK_EQ(zu::to_string(r.cell(0, 0)), std::string("1")); + CHECK_EQ(zu::to_string(r.cell(0, 1)), std::string("1.5")); + CHECK_EQ(zu::to_string(r.cell(0, 2)), std::string("ada")); + CHECK_EQ(zu::to_string(r.cell(0, 3)), std::string("true")); + CHECK_EQ(zu::to_string(r.cell(0, 4)), std::string("null")); + + /* A cell nobody read is not a crash. */ + CHECK_EQ(zu::to_string(zu::Value{}), std::string("")); + + /* Fifteen significant digits, so a tenth prints as a tenth. This is + * the printing path and not the round trip: a caller who needs the + * bits has as_double. */ + auto tenth = conn.query("RETURN 0.1 AS f"); + CHECK_EQ(zu::to_string(tenth.cell(0, 0)), std::string("0.1")); + + /* The shapes a scalar cannot hold say what they are and how many they + * hold, because a list printed elementwise is a different function + * and the caller who wants it has elements(). */ + auto list = conn.query("RETURN ['ada', 'grace'] AS v"); + CHECK_EQ(zu::to_string(list.cell(0, 0)), std::string("list of 2")); + + auto bytes = conn.query("RETURN X'00AB' AS b"); + CHECK_EQ(zu::to_string(bytes.cell(0, 0)), std::string("2 bytes")); +} + +ZU_TEST(a_failure_prints_on_one_line_with_its_code_and_its_place_in_front) { + auto conn = zu::Connection::memory(); + try { + conn.query("MATCH (p:Person RETURN p"); + } catch (const zu::Exception& e) { + const std::string line = zu::to_string(e.error()); + /* Everything on one line, which is what a log wants; report() is + * the other spelling, with a caret under the column. */ + CHECK(line.find('\n') == std::string::npos); + CHECK(line.find(std::string(*e.error().code())) == 0); + CHECK(line.find("line 1, column 17") != std::string::npos); + CHECK(line.find(std::string(e.error().message())) != std::string::npos); + CHECK(zu::to_string(e.error()) != e.error().report()); + } + + /* A condition with no code and no position is the message alone, and + * not an empty prefix and a colon. */ + const zu::Error bare = zu::Error::take(zu::Status::interrupted, nullptr); + CHECK(!bare.code().has_value()); + CHECK_EQ(zu::to_string(bare), std::string(bare.message())); +} + +#if ZU_HAS_FORMAT +ZU_TEST(std_format_prints_the_same_text_and_takes_the_whole_format_spec) { + auto conn = zu::Connection::memory(); + auto r = conn.query("RETURN 42 AS v"); + + /* One implementation under both spellings, so they cannot come to + * disagree about what a value looks like. */ + CHECK_EQ(std::format("{}", zu::Status::conflict), + std::string(zu::to_string(zu::Status::conflict))); + CHECK_EQ(std::format("{}", zu::Node{2, 7}), zu::to_string(zu::Node{2, 7})); + CHECK_EQ(std::format("{}", r.cell(0, 0)), std::string("42")); + + /* The spec comes with the base class rather than being written here, + * which is the reason for inheriting formatter instead + * of implementing parse. */ + CHECK_EQ(std::format("[{:>8}]", zu::Type::string), std::string("[ str]")); + CHECK_EQ(std::format("[{:<6}]", zu::Status::io), std::string("[io ]")); + + try { + conn.query("RETURN RETURN"); + } catch (const zu::Exception& e) { + CHECK_EQ(std::format("{}", e.error()), zu::to_string(e.error())); + } + + /* And it composes, which is the thing a formatter is for: a row + * printed by a host is one call and no stream. */ + auto people = conn.query("RETURN 1 AS id, 'ada' AS name"); + CHECK_EQ(std::format("{} is {}", people.cell(0, 0), people.cell(0, 1)), + std::string("1 is ada")); +} +#endif + +#if ZU_HAS_EXPECTED +/* The other half of the error model, and the reason it is std::expected + * rather than a pair or an optional: it composes. */ +ZU_TEST(the_expected_half_composes_the_way_std_expected_is_meant_to) { + auto conn = zu::Connection::memory(); + + const auto answer = conn.try_query("RETURN 41 AS v") + .transform([](zu::Result r) { return r.cell(0, 0).as_int() + 1; }) + .value_or(-1); + CHECK_EQ(answer, 42); + + /* A failure short circuits the rest of the chain without a branch + * being written for it, which is the whole point. */ + int ran = 0; + const auto refused = conn.try_query("RETURN RETURN") + .transform([&](zu::Result r) { + ++ran; + return r.rows(); + }) + .value_or(0); + CHECK_EQ(refused, 0u); + CHECK_EQ(ran, 0); + + /* And the error that came out is the whole Error, not a code. */ + const auto failed = conn.try_query("MATCH (p:Person RETURN p"); + CHECK(!failed.has_value()); + CHECK(failed.error().position().has_value()); + CHECK(failed.error().code().has_value()); + CHECK_EQ(failed.error().status(), zu::Status::error); + + /* or_else is the recovery path, and it gets the same object. */ + const auto recovered = + conn.try_query("RETURN RETURN") + .transform([](zu::Result r) { return r.rows(); }) + .or_else([](const zu::Error& e) -> zu::expected { + CHECK(e.code().has_value()); + return 99u; + }) + .value_or(0); + CHECK_EQ(recovered, 99u); +} + +static_assert(std::same_as::error_type, zu::Error>); +#endif + +ZU_TEST_MAIN()