From 0a17e730b43f9de2d06d75c0e6849e11a3e55c42 Mon Sep 17 00:00:00 2001 From: Cathode Date: Sun, 1 Nov 2026 17:31:47 -0500 Subject: [PATCH 1/3] add list --- include/sqlgen/create_table.hpp | 2 + include/sqlgen/duckdb/Connection.hpp | 77 ++++++++- include/sqlgen/duckdb/make_chunk_ptrs.hpp | 22 ++- include/sqlgen/duckdb/parsing/Parser.hpp | 1 + include/sqlgen/duckdb/parsing/Parser_date.hpp | 8 + .../sqlgen/duckdb/parsing/Parser_default.hpp | 13 ++ include/sqlgen/duckdb/parsing/Parser_enum.hpp | 7 + include/sqlgen/duckdb/parsing/Parser_json.hpp | 10 ++ .../sqlgen/duckdb/parsing/Parser_optional.hpp | 11 ++ .../duckdb/parsing/Parser_reflection_type.hpp | 6 + .../duckdb/parsing/Parser_smart_ptr.hpp | 11 ++ .../sqlgen/duckdb/parsing/Parser_string.hpp | 7 + .../duckdb/parsing/Parser_timestamp.hpp | 9 ++ .../sqlgen/duckdb/parsing/Parser_vector.hpp | 47 ++++++ include/sqlgen/duckdb/read_list_vector.hpp | 84 ++++++++++ include/sqlgen/duckdb/set_null.hpp | 16 ++ include/sqlgen/duckdb/write_data_chunk.hpp | 77 +++++++++ include/sqlgen/dynamic/Column.hpp | 2 + include/sqlgen/insert.hpp | 3 + include/sqlgen/internal/collection_suffix.hpp | 18 +++ .../sqlgen/internal/supports_collections.hpp | 33 ++++ include/sqlgen/parsing/Parser.hpp | 1 + include/sqlgen/parsing/Parser_vector.hpp | 148 ++++++++++++++++++ include/sqlgen/postgres/Connection.hpp | 11 ++ include/sqlgen/read.hpp | 2 + include/sqlgen/transpilation/is_vector.hpp | 20 +++ include/sqlgen/transpilation/make_columns.hpp | 61 +++++++- include/sqlgen/update.hpp | 2 + include/sqlgen/write.hpp | 3 + src/sqlgen/duckdb/to_sql.cpp | 2 + src/sqlgen/mysql/to_sql.cpp | 1 - src/sqlgen/postgres/to_sql.cpp | 2 + tests/duckdb/test_list.cpp | 47 ++++++ tests/postgres/test_array.cpp | 50 ++++++ tests/postgres/test_array_dry.cpp | 33 ++++ 35 files changed, 837 insertions(+), 10 deletions(-) create mode 100644 include/sqlgen/duckdb/parsing/Parser_vector.hpp create mode 100644 include/sqlgen/duckdb/read_list_vector.hpp create mode 100644 include/sqlgen/duckdb/set_null.hpp create mode 100644 include/sqlgen/duckdb/write_data_chunk.hpp create mode 100644 include/sqlgen/internal/collection_suffix.hpp create mode 100644 include/sqlgen/internal/supports_collections.hpp create mode 100644 include/sqlgen/parsing/Parser_vector.hpp create mode 100644 include/sqlgen/transpilation/is_vector.hpp create mode 100644 tests/duckdb/test_list.cpp create mode 100644 tests/postgres/test_array.cpp create mode 100644 tests/postgres/test_array_dry.cpp diff --git a/include/sqlgen/create_table.hpp b/include/sqlgen/create_table.hpp index 29dbaddc..b8755810 100644 --- a/include/sqlgen/create_table.hpp +++ b/include/sqlgen/create_table.hpp @@ -4,6 +4,7 @@ #include #include "is_connection.hpp" +#include "internal/supports_collections.hpp" #include "transpilation/to_create_table.hpp" namespace sqlgen { @@ -12,6 +13,7 @@ template requires is_connection Result> create_table_impl(const Ref& _conn, const bool _if_not_exists) { + internal::assert_collections_supported(); const auto query = transpilation::to_create_table(_if_not_exists); return _conn->execute(_conn->to_sql(query)).transform([&](const auto&) { return _conn; diff --git a/include/sqlgen/duckdb/Connection.hpp b/include/sqlgen/duckdb/Connection.hpp index b348bbc4..c45ea92a 100644 --- a/include/sqlgen/duckdb/Connection.hpp +++ b/include/sqlgen/duckdb/Connection.hpp @@ -22,6 +22,7 @@ #include "../dynamic/Write.hpp" #include "../internal/iterator_t.hpp" #include "../internal/remove_auto_incr_primary_t.hpp" +#include "../internal/supports_collections.hpp" #include "../internal/to_container.hpp" #include "../is_connection.hpp" #include "../sqlgen_api.hpp" @@ -31,6 +32,7 @@ #include "DuckDBResult.hpp" #include "Iterator.hpp" #include "to_sql.hpp" +#include "write_data_chunk.hpp" namespace sqlgen::duckdb { @@ -156,16 +158,74 @@ class SQLGEN_API Connection { template Result write_to_appender(ItBegin _begin, ItEnd _end, duckdb_appender _appender) { - for (auto it = _begin; it < _end; ++it) { - const auto res = write_row(*it, _appender); - if (!res) { - return res; + using ValueType = + std::remove_cvref_t::value_type>; + if constexpr (chunk_writer::has_vector_field_v) { + return write_data_chunks(_begin, _end, _appender); + } else { + for (auto it = _begin; it < _end; ++it) { + const auto res = write_row(*it, _appender); + if (!res) { + return res; + } + const auto state = duckdb_appender_end_row(_appender); + if (state == DuckDBError) { + return error(duckdb_appender_error(_appender)); + } } - const auto state = duckdb_appender_end_row(_appender); - if (state == DuckDBError) { - return error(duckdb_appender_error(_appender)); + return Nothing{}; + } + } + + template + Result write_data_chunks(ItBegin _begin, ItEnd _end, + duckdb_appender _appender) { + const auto column_count = duckdb_appender_column_count(_appender); + std::vector types(column_count); + for (idx_t i = 0; i < column_count; ++i) { + types[i] = duckdb_appender_column_type(_appender, i); + if (!types[i]) { + for (idx_t j = 0; j < i; ++j) { + duckdb_destroy_logical_type(&types[j]); + } + return error("Could not retrieve DuckDB appender column type."); } } + + auto destroy_types = [&] { + for (auto& type : types) duckdb_destroy_logical_type(&type); + }; + + auto it = _begin; + while (it != _end) { + auto chunk = duckdb_create_data_chunk(types.data(), column_count); + if (!chunk) { + destroy_types(); + return error("Could not create DuckDB data chunk."); + } + + idx_t row = 0; + for (; it != _end && row < duckdb_vector_size(); ++it, ++row) { + auto result = chunk_writer::assign_row(chunk, row, *it); + if (!result) { + duckdb_destroy_data_chunk(&chunk); + destroy_types(); + return result; + } + } + duckdb_data_chunk_set_size(chunk, row); + if (duckdb_append_data_chunk(_appender, chunk) == DuckDBError) { + const auto appender_error = duckdb_appender_error(_appender); + const auto message = + appender_error ? std::string(appender_error) + : std::string("Could not append DuckDB data chunk."); + duckdb_destroy_data_chunk(&chunk); + destroy_types(); + return error(message); + } + duckdb_destroy_data_chunk(&chunk); + } + destroy_types(); return Nothing{}; } @@ -203,6 +263,9 @@ struct IteratorType { using Type = duckdb::Iterator; }; +template <> +struct supports_collections : std::true_type {}; + static_assert(is_connection, "Must fulfill the is_connection concept."); static_assert(is_connection>, diff --git a/include/sqlgen/duckdb/make_chunk_ptrs.hpp b/include/sqlgen/duckdb/make_chunk_ptrs.hpp index 6a9c6b1a..78e42ae3 100644 --- a/include/sqlgen/duckdb/make_chunk_ptrs.hpp +++ b/include/sqlgen/duckdb/make_chunk_ptrs.hpp @@ -10,11 +10,13 @@ #include #include "../Ref.hpp" +#include "../transpilation/is_vector.hpp" #include "ColumnData.hpp" #include "DuckDBResult.hpp" #include "cast_duckdb_type.hpp" #include "check_duckdb_type.hpp" #include "chunk_ptrs_t.hpp" +#include "read_list_vector.hpp" namespace sqlgen::duckdb { @@ -42,7 +44,25 @@ struct MakeChunkPtrs...>> { auto vec = duckdb_data_chunk_get_vector(_chunk, _i); - if (check_duckdb_type(actual_duckdb_type)) { + if constexpr (transpilation::is_vector_v) { + if (actual_duckdb_type != DUCKDB_TYPE_LIST || + !check_list_vector_type(vec)) { + throw std::runtime_error("Wrong type in field '" + ColName().str() + + "'. Expected a DuckDB LIST."); + } + auto values = + Ref>::make(duckdb_data_chunk_get_size(_chunk)); + const auto validity = duckdb_vector_get_validity(vec); + for (idx_t i = 0; i < values->size(); ++i) { + if (!validity || duckdb_validity_row_is_valid(validity, i)) { + (*values)[i] = read_list_vector_value(vec, i).value(); + } + } + return ColumnData{.vec = vec, + .data = values->data(), + .validity = validity, + .ptr = values.ptr()}; + } else if (check_duckdb_type(actual_duckdb_type)) { return ColumnData{ .vec = vec, .data = static_cast(duckdb_vector_get_data(vec)), diff --git a/include/sqlgen/duckdb/parsing/Parser.hpp b/include/sqlgen/duckdb/parsing/Parser.hpp index 453eb4bb..18f8142b 100644 --- a/include/sqlgen/duckdb/parsing/Parser.hpp +++ b/include/sqlgen/duckdb/parsing/Parser.hpp @@ -11,5 +11,6 @@ #include "Parser_smart_ptr.hpp" #include "Parser_string.hpp" #include "Parser_timestamp.hpp" +#include "Parser_vector.hpp" #endif diff --git a/include/sqlgen/duckdb/parsing/Parser_date.hpp b/include/sqlgen/duckdb/parsing/Parser_date.hpp index 615c32ed..4e75fede 100644 --- a/include/sqlgen/duckdb/parsing/Parser_date.hpp +++ b/include/sqlgen/duckdb/parsing/Parser_date.hpp @@ -35,6 +35,14 @@ struct Parser { ? Result(Nothing{}) : Result(error("Could not append date value.")); } + + static Result write_to_vector(duckdb_vector _vector, idx_t _index, + const Date& _value) { + static_cast(duckdb_vector_get_data(_vector))[_index] = + duckdb_date{ + .days = static_cast(_value.to_time_t() / seconds_per_day)}; + return Nothing{}; + } }; } // namespace sqlgen::duckdb::parsing diff --git a/include/sqlgen/duckdb/parsing/Parser_default.hpp b/include/sqlgen/duckdb/parsing/Parser_default.hpp index 400cf0b6..142b1fdf 100644 --- a/include/sqlgen/duckdb/parsing/Parser_default.hpp +++ b/include/sqlgen/duckdb/parsing/Parser_default.hpp @@ -87,6 +87,19 @@ struct Parser { return error("Unsupported type."); } } + + static Result write_to_vector(duckdb_vector _vector, idx_t _index, + const T& _value) { + if constexpr (std::is_arithmetic_v) { + static_cast(duckdb_vector_get_data(_vector))[_index] = _value; + return Nothing{}; + + } else { + static_assert(rfl::always_false_v, + "Unsupported DuckDB data chunk value type."); + return error("Unsupported DuckDB data chunk value type."); + } + } }; } // namespace sqlgen::duckdb::parsing diff --git a/include/sqlgen/duckdb/parsing/Parser_enum.hpp b/include/sqlgen/duckdb/parsing/Parser_enum.hpp index 566251e0..42816941 100644 --- a/include/sqlgen/duckdb/parsing/Parser_enum.hpp +++ b/include/sqlgen/duckdb/parsing/Parser_enum.hpp @@ -37,6 +37,13 @@ struct Parser { ? Result(Nothing{}) : Result(error("Could not append string value.")); } + + static Result write_to_vector(duckdb_vector _vector, idx_t _index, + const EnumT& _value) { + static_cast(duckdb_vector_get_data(_vector))[_index] = + static_cast(_value); + return Nothing{}; + } }; } // namespace sqlgen::duckdb::parsing diff --git a/include/sqlgen/duckdb/parsing/Parser_json.hpp b/include/sqlgen/duckdb/parsing/Parser_json.hpp index 03cdd44a..ec52c9a7 100644 --- a/include/sqlgen/duckdb/parsing/Parser_json.hpp +++ b/include/sqlgen/duckdb/parsing/Parser_json.hpp @@ -32,6 +32,16 @@ struct Parser> { return error(e.what()); } } + + static Result write_to_vector(duckdb_vector _vector, idx_t _index, + const JSON& _value) { + try { + return Parser::write_to_vector( + _vector, _index, rfl::json::write(_value.value())); + } catch (const std::exception& e) { + return error(e.what()); + } + } }; } // namespace sqlgen::duckdb::parsing diff --git a/include/sqlgen/duckdb/parsing/Parser_optional.hpp b/include/sqlgen/duckdb/parsing/Parser_optional.hpp index e5b373d9..79b47437 100644 --- a/include/sqlgen/duckdb/parsing/Parser_optional.hpp +++ b/include/sqlgen/duckdb/parsing/Parser_optional.hpp @@ -8,6 +8,7 @@ #include #include "../../Result.hpp" +#include "../set_null.hpp" #include "Parser_base.hpp" namespace sqlgen::duckdb::parsing { @@ -36,6 +37,16 @@ struct Parser> { } return Parser>::write(*_o, _appender); } + + static Result write_to_vector(duckdb_vector _vector, idx_t _index, + const std::optional& _value) { + if (!_value) { + set_null(_vector, _index); + return Nothing{}; + } + return Parser>::write_to_vector(_vector, _index, + *_value); + } }; } // namespace sqlgen::duckdb::parsing diff --git a/include/sqlgen/duckdb/parsing/Parser_reflection_type.hpp b/include/sqlgen/duckdb/parsing/Parser_reflection_type.hpp index 8f3a0f10..45b973d8 100644 --- a/include/sqlgen/duckdb/parsing/Parser_reflection_type.hpp +++ b/include/sqlgen/duckdb/parsing/Parser_reflection_type.hpp @@ -29,6 +29,12 @@ struct Parser { return Parser>::write( _t.reflection(), _appender); } + + static Result write_to_vector(duckdb_vector _vector, idx_t _index, + const T& _value) { + return Parser>:: + write_to_vector(_vector, _index, _value.reflection()); + } }; } // namespace sqlgen::duckdb::parsing diff --git a/include/sqlgen/duckdb/parsing/Parser_smart_ptr.hpp b/include/sqlgen/duckdb/parsing/Parser_smart_ptr.hpp index dcea1519..289212ab 100644 --- a/include/sqlgen/duckdb/parsing/Parser_smart_ptr.hpp +++ b/include/sqlgen/duckdb/parsing/Parser_smart_ptr.hpp @@ -8,6 +8,7 @@ #include "../../Result.hpp" #include "../../transpilation/is_nullable.hpp" +#include "../set_null.hpp" #include "Parser_base.hpp" namespace sqlgen::duckdb::parsing { @@ -39,6 +40,16 @@ struct Parser { } return Parser>::write(*_ptr, _appender); } + + static Result write_to_vector(duckdb_vector _vector, idx_t _index, + const T& _value) { + if (!_value) { + set_null(_vector, _index); + return Nothing{}; + } + return Parser::write_to_vector(_vector, _index, + *_value); + } }; } // namespace sqlgen::duckdb::parsing diff --git a/include/sqlgen/duckdb/parsing/Parser_string.hpp b/include/sqlgen/duckdb/parsing/Parser_string.hpp index 27cb341b..3cf05bd3 100644 --- a/include/sqlgen/duckdb/parsing/Parser_string.hpp +++ b/include/sqlgen/duckdb/parsing/Parser_string.hpp @@ -33,6 +33,13 @@ struct Parser { ? Result(Nothing{}) : Result(error("Could not append string value.")); } + + static Result write_to_vector(duckdb_vector _vector, idx_t _index, + const std::string& _value) { + duckdb_vector_assign_string_element_len(_vector, _index, _value.data(), + _value.size()); + return Nothing{}; + } }; } // namespace sqlgen::duckdb::parsing diff --git a/include/sqlgen/duckdb/parsing/Parser_timestamp.hpp b/include/sqlgen/duckdb/parsing/Parser_timestamp.hpp index 2d9f12a0..e16cdbd6 100644 --- a/include/sqlgen/duckdb/parsing/Parser_timestamp.hpp +++ b/include/sqlgen/duckdb/parsing/Parser_timestamp.hpp @@ -33,6 +33,15 @@ struct Parser> { ? Result(Nothing{}) : Result(error("Could not append timestamp value.")); } + + static Result write_to_vector( + duckdb_vector _vector, idx_t _index, + const rfl::Timestamp<_format>& _value) { + static_cast(duckdb_vector_get_data(_vector))[_index] = + duckdb_timestamp{ + .micros = static_cast(_value.to_time_t()) * 1000000}; + return Nothing{}; + } }; } // namespace sqlgen::duckdb::parsing diff --git a/include/sqlgen/duckdb/parsing/Parser_vector.hpp b/include/sqlgen/duckdb/parsing/Parser_vector.hpp new file mode 100644 index 00000000..39b36b7f --- /dev/null +++ b/include/sqlgen/duckdb/parsing/Parser_vector.hpp @@ -0,0 +1,47 @@ +#ifndef SQLGEN_DUCKDB_PARSING_PARSER_VECTOR_HPP_ +#define SQLGEN_DUCKDB_PARSING_PARSER_VECTOR_HPP_ + +#include + +#include + +#include "../../Result.hpp" +#include "Parser_base.hpp" + +namespace sqlgen::duckdb::parsing { + +template +struct Parser> { + using Type = std::vector; + using ResultingType = Type; + + static Result read(const Type* _value) noexcept { + if (!_value) return error("LIST value cannot be NULL."); + return *_value; + } + + static Result write_to_vector(duckdb_vector _vector, idx_t _index, + const Type& _value) { + const auto offset = duckdb_list_vector_get_size(_vector); + const auto length = static_cast(_value.size()); + if (duckdb_list_vector_reserve(_vector, offset + length) == DuckDBError) { + return error("Could not reserve DuckDB LIST child vector."); + } + auto child = duckdb_list_vector_get_child(_vector); + for (idx_t i = 0; i < length; ++i) { + auto result = Parser::write_to_vector(child, offset + i, _value[i]); + if (!result) return result; + } + if (duckdb_list_vector_set_size(_vector, offset + length) == DuckDBError) { + return error("Could not set DuckDB LIST child vector size."); + } + auto entries = + static_cast(duckdb_vector_get_data(_vector)); + entries[_index] = duckdb_list_entry{.offset = offset, .length = length}; + return Nothing{}; + } +}; + +} // namespace sqlgen::duckdb::parsing + +#endif diff --git a/include/sqlgen/duckdb/read_list_vector.hpp b/include/sqlgen/duckdb/read_list_vector.hpp new file mode 100644 index 00000000..61426993 --- /dev/null +++ b/include/sqlgen/duckdb/read_list_vector.hpp @@ -0,0 +1,84 @@ +#ifndef SQLGEN_DUCKDB_READ_LIST_VECTOR_HPP_ +#define SQLGEN_DUCKDB_READ_LIST_VECTOR_HPP_ + +#include + +#include +#include +#include + +#include "../Result.hpp" +#include "../transpilation/is_nullable.hpp" +#include "../transpilation/is_vector.hpp" +#include "check_duckdb_type.hpp" +#include "parsing/Parser.hpp" + +namespace sqlgen::duckdb { + +/// A `std::optional` wrapping a LIST. Its NULL flag has to be resolved +/// structurally here, because the root `Parser` reads through a pointer, +/// which cannot address a nested LIST value. +template +struct is_optional_vector : std::false_type {}; + +template +struct is_optional_vector> : transpilation::is_vector {}; + +template +Result read_list_vector_value(duckdb_vector _vector, idx_t _index) { + const auto validity = duckdb_vector_get_validity(_vector); + const bool valid = + !validity || duckdb_validity_row_is_valid(validity, _index); + if constexpr (is_optional_vector::value) { + if (!valid) return T{}; + return read_list_vector_value(_vector, _index) + .transform([](auto&& _value) -> T { + return T(std::move(_value)); + }); + } else if constexpr (transpilation::is_vector_v) { + if (!valid) return error("NULL encountered in non-nullable DuckDB LIST."); + const auto entries = + static_cast(duckdb_vector_get_data(_vector)); + const auto entry = entries[_index]; + const auto child = duckdb_list_vector_get_child(_vector); + T result; + result.reserve(entry.length); + for (idx_t i = 0; i < entry.length; ++i) { + auto value = read_list_vector_value( + child, entry.offset + i); + if (!value) return error(value.error().what()); + result.emplace_back(std::move(*value)); + } + return result; + } else { + // Scalar elements (including `std::optional` ones) are read through the + // root `Parser`, which owns the NULL handling for their type. + using Stored = typename parsing::Parser::ResultingType; + const auto data = + static_cast(duckdb_vector_get_data(_vector)); + return parsing::Parser::read(valid ? data + _index : nullptr); + } +} + +template +bool check_list_vector_type(duckdb_vector _vector) { + if constexpr (transpilation::is_optional>::value) { + return check_list_vector_type(_vector); + } else { + auto logical_type = duckdb_vector_get_column_type(_vector); + const auto type = duckdb_get_type_id(logical_type); + duckdb_destroy_logical_type(&logical_type); + if constexpr (transpilation::is_vector_v) { + return type == DUCKDB_TYPE_LIST && + check_list_vector_type( + duckdb_list_vector_get_child(_vector)); + } else { + using Stored = typename parsing::Parser::ResultingType; + return check_duckdb_type(type); + } + } +} + +} // namespace sqlgen::duckdb + +#endif diff --git a/include/sqlgen/duckdb/set_null.hpp b/include/sqlgen/duckdb/set_null.hpp new file mode 100644 index 00000000..a8700128 --- /dev/null +++ b/include/sqlgen/duckdb/set_null.hpp @@ -0,0 +1,16 @@ +#ifndef SQLGEN_DUCKDB_SET_NULL_HPP_ +#define SQLGEN_DUCKDB_SET_NULL_HPP_ + +#include + +namespace sqlgen::duckdb { + +/// Marks the row `_index` of `_vector` as NULL. +inline void set_null(duckdb_vector _vector, idx_t _index) { + duckdb_vector_ensure_validity_writable(_vector); + duckdb_validity_set_row_invalid(duckdb_vector_get_validity(_vector), _index); +} + +} // namespace sqlgen::duckdb + +#endif diff --git a/include/sqlgen/duckdb/write_data_chunk.hpp b/include/sqlgen/duckdb/write_data_chunk.hpp new file mode 100644 index 00000000..f758b36d --- /dev/null +++ b/include/sqlgen/duckdb/write_data_chunk.hpp @@ -0,0 +1,77 @@ +#ifndef SQLGEN_DUCKDB_WRITE_DATA_CHUNK_HPP_ +#define SQLGEN_DUCKDB_WRITE_DATA_CHUNK_HPP_ + +#include + +#include +#include +#include +#include +#include + +#include "../Result.hpp" +#include "../internal/remove_auto_incr_primary_t.hpp" +#include "../transpilation/is_vector.hpp" +#include "parsing/Parser.hpp" + +namespace sqlgen::duckdb::chunk_writer { + +template +struct contains_vector : transpilation::is_vector> {}; + +template +struct contains_vector> : contains_vector {}; + +template +struct contains_vector> : contains_vector {}; + +template +struct contains_vector> : contains_vector {}; + +template +struct named_tuple_contains_vector; + +template +struct named_tuple_contains_vector> + : std::bool_constant<(contains_vector::value || + ...)> {}; + +template +inline constexpr bool has_vector_field_v = + named_tuple_contains_vector>::value; + +/// Stores `_value` in row `_index` of `_vector`. The type-specific logic +/// lives in the root `parsing::Parser`. +template +Result assign(duckdb_vector _vector, idx_t _index, const T& _value) { + using Type = std::remove_cvref_t; + return parsing::Parser::write_to_vector(_vector, _index, _value); +} + +template +Result assign_row(duckdb_data_chunk _chunk, idx_t _row, + const T& _value) { + using View = internal::remove_auto_incr_primary_t>; + try { + idx_t column = 0; + std::optional error_value; + View(rfl::to_view(_value)).apply([&](const auto&... fields) { + ( + [&] { + if (error_value) return; + auto result = assign(duckdb_data_chunk_get_vector(_chunk, column++), + _row, *fields.value()); + if (!result) error_value = result.error(); + }(), + ...); + }); + if (error_value) return error(error_value->what()); + return Nothing{}; + } catch (const std::exception& exception) { + return error(exception.what()); + } +} + +} // namespace sqlgen::duckdb::chunk_writer + +#endif diff --git a/include/sqlgen/dynamic/Column.hpp b/include/sqlgen/dynamic/Column.hpp index cf4ff6c6..3eb65922 100644 --- a/include/sqlgen/dynamic/Column.hpp +++ b/include/sqlgen/dynamic/Column.hpp @@ -1,6 +1,7 @@ #ifndef SQLGEN_DYNAMIC_COLUMN_HPP_ #define SQLGEN_DYNAMIC_COLUMN_HPP_ +#include #include #include @@ -13,6 +14,7 @@ struct Column { std::optional alias; std::string name; Type type = types::Unknown{}; + size_t collection_depth = 0; }; } // namespace sqlgen::dynamic diff --git a/include/sqlgen/insert.hpp b/include/sqlgen/insert.hpp index ee897a53..c91bbcdd 100644 --- a/include/sqlgen/insert.hpp +++ b/include/sqlgen/insert.hpp @@ -12,6 +12,7 @@ #include "internal/batch_size.hpp" #include "internal/has_constraint.hpp" +#include "internal/supports_collections.hpp" #include "internal/to_str_vec.hpp" #include "is_connection.hpp" #include "transpilation/to_insert_or_write.hpp" @@ -27,6 +28,8 @@ Result> insert_impl(const Ref& _conn, using T = std::remove_cvref_t::value_type>; + internal::assert_collections_supported(); + const auto insert_stmt = transpilation::to_insert_or_write(_or_replace); diff --git a/include/sqlgen/internal/collection_suffix.hpp b/include/sqlgen/internal/collection_suffix.hpp new file mode 100644 index 00000000..ec8b4073 --- /dev/null +++ b/include/sqlgen/internal/collection_suffix.hpp @@ -0,0 +1,18 @@ +#ifndef SQLGEN_INTERNAL_COLLECTION_SUFFIX_HPP_ +#define SQLGEN_INTERNAL_COLLECTION_SUFFIX_HPP_ + +#include +#include + +namespace sqlgen::internal { + +inline std::string collection_suffix(const size_t _depth) { + std::string suffix; + suffix.reserve(_depth * 2); + for (size_t i = 0; i < _depth; ++i) suffix += "[]"; + return suffix; +} + +} // namespace sqlgen::internal + +#endif diff --git a/include/sqlgen/internal/supports_collections.hpp b/include/sqlgen/internal/supports_collections.hpp new file mode 100644 index 00000000..47d18a5a --- /dev/null +++ b/include/sqlgen/internal/supports_collections.hpp @@ -0,0 +1,33 @@ +#ifndef SQLGEN_INTERNAL_SUPPORTS_COLLECTIONS_HPP_ +#define SQLGEN_INTERNAL_SUPPORTS_COLLECTIONS_HPP_ + +#include + +#include "../Session.hpp" +#include "../Transaction.hpp" +#include "../transpilation/make_columns.hpp" + +namespace sqlgen::internal { + +template +struct supports_collections : std::false_type {}; + +template +struct supports_collections> + : supports_collections {}; + +template +struct supports_collections> + : supports_collections {}; + +template +consteval void assert_collections_supported() { + static_assert( + supports_collections>::value || + !transpilation::has_collection_field>::value, + "std::vector fields are not supported by this database."); +} + +} // namespace sqlgen::internal + +#endif diff --git a/include/sqlgen/parsing/Parser.hpp b/include/sqlgen/parsing/Parser.hpp index 8e3af5cc..d8d36cdf 100644 --- a/include/sqlgen/parsing/Parser.hpp +++ b/include/sqlgen/parsing/Parser.hpp @@ -13,5 +13,6 @@ #include "Parser_unique.hpp" #include "Parser_unique_ptr.hpp" #include "Parser_varchar.hpp" +#include "Parser_vector.hpp" #endif diff --git a/include/sqlgen/parsing/Parser_vector.hpp b/include/sqlgen/parsing/Parser_vector.hpp new file mode 100644 index 00000000..6e78c26a --- /dev/null +++ b/include/sqlgen/parsing/Parser_vector.hpp @@ -0,0 +1,148 @@ +#ifndef SQLGEN_PARSING_PARSER_VECTOR_HPP_ +#define SQLGEN_PARSING_PARSER_VECTOR_HPP_ + +#include +#include +#include +#include +#include +#include + +#include "../Result.hpp" +#include "../dynamic/Type.hpp" +#include "../transpilation/is_vector.hpp" +#include "Parser_base.hpp" + +namespace sqlgen::parsing { + +namespace vector_parser { + +inline Result>> split( + std::string_view _input) noexcept { + if (const auto equals = _input.find('='); equals != std::string_view::npos && + !_input.empty() && + _input.front() == '[') { + _input.remove_prefix(equals + 1); + } + if (_input.size() < 2 || _input.front() != '{' || _input.back() != '}') { + return error("Invalid PostgreSQL array value."); + } + _input.remove_prefix(1); + _input.remove_suffix(1); + std::vector> result; + size_t start = 0; + size_t depth = 0; + bool quoted = false; + bool escaped = false; + for (size_t i = 0; i <= _input.size(); ++i) { + const bool at_end = i == _input.size(); + const char c = at_end ? ',' : _input[i]; + if (!at_end && escaped) { + escaped = false; + continue; + } + if (!at_end && c == '\\') { + escaped = true; + continue; + } + if (!at_end && c == '"') { + quoted = !quoted; + continue; + } + if (!quoted && !at_end && c == '{') ++depth; + if (!quoted && !at_end && c == '}') { + if (depth == 0) return error("Invalid PostgreSQL array dimensions."); + --depth; + } + if (!quoted && depth == 0 && c == ',') { + auto token = _input.substr(start, i - start); + if (token.empty() && _input.empty()) return result; + if (token == "NULL") { + result.emplace_back(std::nullopt); + } else { + std::string value; + size_t begin = 0; + size_t end = token.size(); + if (end >= 2 && token.front() == '"' && token.back() == '"') { + begin = 1; + --end; + } + value.reserve(end - begin); + bool token_escaped = false; + for (size_t j = begin; j < end; ++j) { + if (token_escaped) { + value += token[j]; + token_escaped = false; + } else if (token[j] == '\\') { + token_escaped = true; + } else { + value += token[j]; + } + } + if (token_escaped) return error("Invalid escape in PostgreSQL array."); + result.emplace_back(std::move(value)); + } + start = i + 1; + } + } + if (quoted || escaped || depth != 0) + return error("Invalid PostgreSQL array value."); + return result; +} + +inline std::string quote(std::string_view _value) { + std::string result = "\""; + result.reserve(_value.size() + 2); + for (const char c : _value) { + if (c == '"' || c == '\\') result += '\\'; + result += c; + } + result += '"'; + return result; +} + +} // namespace vector_parser + +template +struct Parser> { + using Vector = std::vector; + + static Result read(const std::optional& _str) noexcept { + if (!_str) return error("NULL value encountered: array cannot be NULL."); + return vector_parser::split(*_str).and_then( + [](auto&& _tokens) -> Result { + Vector result; + result.reserve(_tokens.size()); + for (const auto& token : _tokens) { + auto value = Parser::read(token); + if (!value) return error(value.error().what()); + result.emplace_back(std::move(*value)); + } + return result; + }); + } + + static std::optional write(const Vector& _values) noexcept { + std::string result = "{"; + for (size_t i = 0; i < _values.size(); ++i) { + if (i != 0) result += ','; + const T value = _values[i]; + const auto encoded = Parser::write(value); + if (!encoded) { + result += "NULL"; + } else if constexpr (transpilation::is_vector_v) { + result += *encoded; + } else { + result += vector_parser::quote(*encoded); + } + } + result += '}'; + return result; + } + + static dynamic::Type to_type() noexcept { return Parser::to_type(); } +}; + +} // namespace sqlgen::parsing + +#endif diff --git a/include/sqlgen/postgres/Connection.hpp b/include/sqlgen/postgres/Connection.hpp index 244ba970..19a023a5 100644 --- a/include/sqlgen/postgres/Connection.hpp +++ b/include/sqlgen/postgres/Connection.hpp @@ -23,7 +23,9 @@ #include "../dynamic/Union.hpp" #include "../dynamic/Write.hpp" #include "../internal/iterator_t.hpp" +#include "../internal/supports_collections.hpp" #include "../internal/to_container.hpp" +#include "../internal/to_str.hpp" #include "../internal/write_or_insert.hpp" #include "../is_connection.hpp" #include "../sqlgen_api.hpp" @@ -100,6 +102,12 @@ class SQLGEN_API Connection { return _val ? to_param(*_val) : std::nullopt; } + template + static std::optional to_param( + const std::vector& _val) { + return internal::to_str(_val); + } + Result execute_params( const std::string& _sql, const std::vector>& _params) noexcept; @@ -178,6 +186,9 @@ struct IteratorType { using Type = Iterator; }; +template <> +struct supports_collections : std::true_type {}; + static_assert(is_connection, "Must fulfill the is_connection concept."); static_assert(is_connection>, diff --git a/include/sqlgen/read.hpp b/include/sqlgen/read.hpp index a2111aea..911df3eb 100644 --- a/include/sqlgen/read.hpp +++ b/include/sqlgen/read.hpp @@ -7,6 +7,7 @@ #include "Ref.hpp" #include "Result.hpp" #include "internal/is_range.hpp" +#include "internal/supports_collections.hpp" #include "is_connection.hpp" #include "limit.hpp" #include "offset.hpp" @@ -24,6 +25,7 @@ template & _conn, const WhereType& _where, const LimitType& _limit, const OffsetType& _offset) { using ValueType = transpilation::value_t; + internal::assert_collections_supported(); const auto query = transpilation::read_to_select_from(_where, _limit, _offset); diff --git a/include/sqlgen/transpilation/is_vector.hpp b/include/sqlgen/transpilation/is_vector.hpp new file mode 100644 index 00000000..96b99257 --- /dev/null +++ b/include/sqlgen/transpilation/is_vector.hpp @@ -0,0 +1,20 @@ +#ifndef SQLGEN_TRANSPILATION_IS_VECTOR_HPP_ +#define SQLGEN_TRANSPILATION_IS_VECTOR_HPP_ + +#include +#include + +namespace sqlgen::transpilation { + +template +class is_vector : public std::false_type {}; + +template +class is_vector> : public std::true_type {}; + +template +constexpr bool is_vector_v = is_vector>::value; + +} // namespace sqlgen::transpilation + +#endif diff --git a/include/sqlgen/transpilation/make_columns.hpp b/include/sqlgen/transpilation/make_columns.hpp index 833882a3..68888fdf 100644 --- a/include/sqlgen/transpilation/make_columns.hpp +++ b/include/sqlgen/transpilation/make_columns.hpp @@ -15,6 +15,62 @@ namespace sqlgen::transpilation { +template +struct collection_traits { + using value_type = T; + static constexpr size_t depth = 0; +}; + +template +struct collection_traits> { + using nested = collection_traits; + using value_type = typename nested::value_type; + static constexpr size_t depth = nested::depth + 1; +}; + +// Element nullability does not make the collection column itself nullable. +template +struct collection_traits, VectorAllocator>> { + using nested = collection_traits; + using value_type = typename nested::value_type; + static constexpr size_t depth = nested::depth + 1; +}; + +template +struct collection_traits> { + using nested = collection_traits; + using value_type = std::optional; + static constexpr size_t depth = nested::depth; +}; + +/// Whether `T` has at least one field that is wrapped in a collection +/// (e.g. `std::vector`) and therefore needs a collection column type. +/// Only the reflectable aggregates that `to_create_table(...)` and friends +/// accept are inspected; anything else cannot declare a collection field. +template +struct has_collection_field : std::false_type {}; + +template +struct has_collection_field< + T, std::enable_if_t> && + std::is_aggregate_v>>> { + private: + using NamedTupleType = rfl::named_tuple_t>; + using Fields = typename NamedTupleType::Fields; + + template + static constexpr bool check(std::integer_sequence) { + return ((collection_traits::Type>>::depth > + 0) || + ...); + } + + public: + static constexpr bool value = + check(std::make_integer_sequence>()); +}; + template std::string to_colname() { return Name().str(); @@ -28,8 +84,11 @@ dynamic::Type to_type() { template dynamic::Column to_column() { + using Type = std::remove_cvref_t; + using Traits = collection_traits; return dynamic::Column{.name = to_colname(), - .type = to_type()}; + .type = to_type(), + .collection_depth = Traits::depth}; } template diff --git a/include/sqlgen/update.hpp b/include/sqlgen/update.hpp index ae0e190c..e0470989 100644 --- a/include/sqlgen/update.hpp +++ b/include/sqlgen/update.hpp @@ -6,6 +6,7 @@ #include "Ref.hpp" #include "Result.hpp" +#include "internal/supports_collections.hpp" #include "is_connection.hpp" #include "transpilation/to_update.hpp" #include "where.hpp" @@ -17,6 +18,7 @@ template Result> update_impl(const Ref& _conn, const SetsType& _sets, const WhereType& _where) { + internal::assert_collections_supported(); const auto query = transpilation::to_update(_sets, _where); return _conn->execute(_conn->to_sql(query)).transform([&](const auto&) { diff --git a/include/sqlgen/write.hpp b/include/sqlgen/write.hpp index 2027da4f..dbd124ce 100644 --- a/include/sqlgen/write.hpp +++ b/include/sqlgen/write.hpp @@ -14,6 +14,7 @@ #include "Result.hpp" #include "dynamic/Write.hpp" #include "internal/batch_size.hpp" +#include "internal/supports_collections.hpp" #include "internal/to_str_vec.hpp" #include "is_connection.hpp" #include "transpilation/to_create_table.hpp" @@ -28,6 +29,8 @@ Result> write(const Ref& _conn, ItBegin _begin, using T = std::remove_cvref_t::value_type>; + internal::assert_collections_supported(); + const auto start_write = [&](const auto&) -> Result { const auto write_stmt = transpilation::to_insert_or_write(); diff --git a/src/sqlgen/duckdb/to_sql.cpp b/src/sqlgen/duckdb/to_sql.cpp index 034aaedb..938608bb 100644 --- a/src/sqlgen/duckdb/to_sql.cpp +++ b/src/sqlgen/duckdb/to_sql.cpp @@ -10,6 +10,7 @@ #include "sqlgen/dynamic/Column.hpp" #include "sqlgen/dynamic/Join.hpp" #include "sqlgen/dynamic/Operation.hpp" +#include "sqlgen/internal/collection_suffix.hpp" #include "sqlgen/internal/collect/vector.hpp" #include "sqlgen/internal/strings/strings.hpp" @@ -264,6 +265,7 @@ std::string condition_to_sql_impl(const ConditionType& _condition) noexcept { std::string column_to_sql_definition(const dynamic::Table& _table, const dynamic::Column& _col) noexcept { return wrap_in_quotes(_col.name) + " " + type_to_sql(_col.type) + + internal::collection_suffix(_col.collection_depth) + properties_to_sql(_table, _col); } diff --git a/src/sqlgen/mysql/to_sql.cpp b/src/sqlgen/mysql/to_sql.cpp index 4683a78e..e4864b25 100644 --- a/src/sqlgen/mysql/to_sql.cpp +++ b/src/sqlgen/mysql/to_sql.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include #include "sqlgen/dynamic/Join.hpp" diff --git a/src/sqlgen/postgres/to_sql.cpp b/src/sqlgen/postgres/to_sql.cpp index 534c6506..5768d178 100644 --- a/src/sqlgen/postgres/to_sql.cpp +++ b/src/sqlgen/postgres/to_sql.cpp @@ -8,6 +8,7 @@ #include "sqlgen/dynamic/Join.hpp" #include "sqlgen/dynamic/Operation.hpp" +#include "sqlgen/internal/collection_suffix.hpp" #include "sqlgen/internal/collect/vector.hpp" #include "sqlgen/internal/strings/strings.hpp" @@ -252,6 +253,7 @@ std::string condition_to_sql_impl(const ConditionType& _condition) noexcept { std::string column_to_sql_definition(const dynamic::Column& _col) noexcept { return wrap_in_quotes(_col.name) + " " + type_to_sql(_col.type) + + internal::collection_suffix(_col.collection_depth) + properties_to_sql( _col.type.visit([](const auto& _t) { return _t.properties; })); } diff --git a/tests/duckdb/test_list.cpp b/tests/duckdb/test_list.cpp new file mode 100644 index 00000000..b3220adf --- /dev/null +++ b/tests/duckdb/test_list.cpp @@ -0,0 +1,47 @@ +#include + +#include +#include +#include + +namespace test_list { + +struct ListTable { + int32_t id; + std::vector numbers; + std::vector labels; + std::vector> nested; + std::optional> nullable_list; + std::vector> nullable_elements; + + bool operator==(const ListTable&) const = default; +}; + +TEST(duckdb, native_list_round_trip) { + EXPECT_EQ( + sqlgen::duckdb::to_sql(sqlgen::create_table), + R"(CREATE TABLE IF NOT EXISTS "ListTable" ("id" INTEGER NOT NULL, "numbers" INTEGER[] NOT NULL, "labels" TEXT[] NOT NULL, "nested" INTEGER[][] NOT NULL, "nullable_list" INTEGER[], "nullable_elements" INTEGER[] NOT NULL);)"); + + const auto conn = sqlgen::duckdb::connect().value(); + ASSERT_TRUE(sqlgen::create_table(conn)); + + const std::vector expected{ + {.id = 1, + .numbers = {1, 2, 3}, + .labels = {"a", "comma,value", "NULL", "quote\"slash\\"}, + .nested = {{1, 2}, {}, {3}}, + .nullable_list = std::vector{4, 5}, + .nullable_elements = {6, std::nullopt, 7}}, + {.id = 2, + .numbers = {}, + .labels = {}, + .nested = {}, + .nullable_list = std::nullopt, + .nullable_elements = {}}}; + ASSERT_TRUE(sqlgen::insert(expected)(conn)); + const auto actual = sqlgen::read>(conn); + ASSERT_TRUE(actual); + EXPECT_EQ(*actual, expected); +} + +} // namespace test_list diff --git a/tests/postgres/test_array.cpp b/tests/postgres/test_array.cpp new file mode 100644 index 00000000..6fe63ed0 --- /dev/null +++ b/tests/postgres/test_array.cpp @@ -0,0 +1,50 @@ +#ifndef SQLGEN_BUILD_DRY_TESTS_ONLY + +#include + +#include +#include +#include + +#include "test_helpers.hpp" + +namespace test_array { + +struct ArrayRecord { + sqlgen::PrimaryKey id; + std::vector numbers; + std::vector labels; + std::vector> nullable_elements; + std::optional> nullable_array; + std::vector> matrix; +}; + +TEST(postgres, native_array_round_trip) { + const std::vector expected{ + {.id = 1, + .numbers = {1, 2, 3}, + .labels = {"comma,value", "quote\"value", "back\\slash", "NULL", "", + "tab\tvalue", "line\nvalue"}, + .nullable_elements = {10, std::nullopt, 30}, + .nullable_array = std::vector{1.25, 2.5}, + .matrix = {{1, 2}, {3, 4}}}, + {.id = 2, + .numbers = {}, + .labels = {}, + .nullable_elements = {}, + .nullable_array = std::nullopt, + .matrix = {}}}; + + const auto conn = + sqlgen::postgres::connect(sqlgen::postgres::test::make_credentials()) + .and_then(sqlgen::drop | sqlgen::if_exists); + const auto actual = sqlgen::write(conn, expected) + .and_then(sqlgen::read>) + .value(); + + EXPECT_EQ(rfl::json::write(actual), rfl::json::write(expected)); +} + +} // namespace test_array + +#endif diff --git a/tests/postgres/test_array_dry.cpp b/tests/postgres/test_array_dry.cpp new file mode 100644 index 00000000..4c18f6f2 --- /dev/null +++ b/tests/postgres/test_array_dry.cpp @@ -0,0 +1,33 @@ +#include + +#include +#include +#include + +namespace test_array_dry { + +struct ArrayTable { + std::vector numbers; + std::vector labels; + std::vector> matrix; +}; + +TEST(postgres, array_schema_uses_native_types) { + const auto expected = + R"(CREATE TABLE IF NOT EXISTS "ArrayTable" ("numbers" INTEGER[] NOT NULL, "labels" TEXT[] NOT NULL, "matrix" BIGINT[][] NOT NULL);)"; + EXPECT_EQ(sqlgen::postgres::to_sql(sqlgen::create_table), + expected); +} + +TEST(postgres, array_text_format_round_trip) { + const std::vector expected{"comma,value", "quote\"value", + "back\\slash", "NULL", ""}; + using Values = std::remove_cvref_t; + const auto encoded = sqlgen::parsing::Parser::write(expected); + ASSERT_TRUE(encoded); + const auto decoded = sqlgen::parsing::Parser::read(encoded); + ASSERT_TRUE(decoded); + EXPECT_EQ(*decoded, expected); +} + +} // namespace test_array_dry From 400b3a54e916cf5c42ca6fb451318eec3b759e39 Mon Sep 17 00:00:00 2001 From: Cathode Date: Mon, 2 Nov 2026 19:11:51 -0500 Subject: [PATCH 2/3] fix DuckDB's list test --- tests/duckdb/test_list.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/duckdb/test_list.cpp b/tests/duckdb/test_list.cpp index b3220adf..a9ff4848 100644 --- a/tests/duckdb/test_list.cpp +++ b/tests/duckdb/test_list.cpp @@ -20,7 +20,7 @@ struct ListTable { TEST(duckdb, native_list_round_trip) { EXPECT_EQ( sqlgen::duckdb::to_sql(sqlgen::create_table), - R"(CREATE TABLE IF NOT EXISTS "ListTable" ("id" INTEGER NOT NULL, "numbers" INTEGER[] NOT NULL, "labels" TEXT[] NOT NULL, "nested" INTEGER[][] NOT NULL, "nullable_list" INTEGER[], "nullable_elements" INTEGER[] NOT NULL);)"); + R"(CREATE TABLE IF NOT EXISTS "ListTable"("id" INTEGER NOT NULL, "numbers" INTEGER[] NOT NULL, "labels" TEXT[] NOT NULL, "nested" INTEGER[][] NOT NULL, "nullable_list" INTEGER[], "nullable_elements" INTEGER[] NOT NULL);)"); const auto conn = sqlgen::duckdb::connect().value(); ASSERT_TRUE(sqlgen::create_table(conn)); From 6c46b8c478a58e4a6f9c725730148ea4d961cf9a Mon Sep 17 00:00:00 2001 From: Cathode Date: Tue, 3 Nov 2026 07:32:54 -0500 Subject: [PATCH 3/3] add docs for lists --- docs/content/_index.md | 1 + docs/content/list.md | 108 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 docs/content/list.md diff --git a/docs/content/_index.md b/docs/content/_index.md index ce0b98a8..1d688ec6 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -59,6 +59,7 @@ Welcome to the sqlgen documentation. This guide provides detailed information ab {{< card link="dynamic" title="sqlgen::Dynamic" icon="book-open" subtitle="How to define custom SQL types not natively supported by sqlgen.">}} {{< card link="foreign_key" title="sqlgen::ForeignKey" icon="book-open" subtitle="How to establish referential integrity between tables.">}} {{< card link="json" title="sqlgen::JSON" icon="book-open" subtitle="How to store and work with JSON fields.">}} + {{< card link="list" title="std::vector" icon="book-open" subtitle="How to store std::vector fields as native lists and arrays (DuckDB, PostgreSQL).">}} {{< card link="pattern" title="sqlgen::Pattern" icon="book-open" subtitle="How to add regex pattern validation to avoid SQL injection.">}} {{< card link="timestamp" title="sqlgen::Timestamp" icon="book-open" subtitle="How timestamps work in sqlgen.">}} {{< card link="unique" title="sqlgen::Unique" icon="book-open" subtitle="How to enforce uniqueness constraints on table columns.">}} diff --git a/docs/content/list.md b/docs/content/list.md new file mode 100644 index 00000000..73b91364 --- /dev/null +++ b/docs/content/list.md @@ -0,0 +1,108 @@ +# `std::vector` + +`std::vector` fields map directly to native collection types in supported databases. + +Currently supported for DuckDB and PostgreSQL. On other databases, `std::vector` fields fail with a compile-time error. + +## Usage + +### Basic Definition + +Define a list field directly as `std::vector`: + +```cpp +struct ListTable { + int32_t id; + std::vector numbers; + std::vector labels; +}; +``` + +This generates the following SQL schema: + +```sql +-- DuckDB/PostgreSQL +CREATE TABLE IF NOT EXISTS "ListTable"("id" INTEGER NOT NULL, "numbers" INTEGER[] NOT NULL, "labels" TEXT[] NOT NULL); +``` + +### Nested Lists + +Nested vectors are supported to arbitrary depth, with one `[]` suffix added per nesting level: + +```cpp +struct Matrix { + int32_t id; + std::vector> nested; +}; +``` + +```sql +-- DuckDB/PostgreSQL +CREATE TABLE IF NOT EXISTS "Matrix"("id" INTEGER NOT NULL, "nested" INTEGER[][] NOT NULL); +``` + +### Nullable Lists and Nullable Elements + +Nullability is controlled the same way as for scalar fields: + +- `std::optional>`: the entire list may be `NULL` +- `std::vector>`: the list is `NOT NULL`, but individual elements may be `NULL` + +```cpp +struct NullableLists { + int32_t id; + std::optional> nullable_list; + std::vector> nullable_elements; +}; +``` + +```sql +-- DuckDB / PostgreSQL +CREATE TABLE IF NOT EXISTS "NullableLists"("id" INTEGER NOT NULL, "nullable_list" INTEGER[], "nullable_elements" INTEGER[] NOT NULL); +``` + +### Reading and Writing + +Use the regular `sqlgen::write` and `sqlgen::read` APIs. + +```cpp +using namespace sqlgen; +using namespace sqlgen::literals; + +struct Person { + int32_t id; + std::string first_name; + std::vector children; +}; + +const auto people = std::vector({ + Person{.id = 1, .first_name = "Homer", .children = {"Bart", "Lisa", "Maggie"}}, + Person{.id = 2, .first_name = "Bart", .children = {}} +}); + +// DuckDB example (analogous for PostgreSQL) +const std::vector result = sqlgen::duckdb::connect() + .and_then(create_table) + .and_then(write(std::ref(people))) + .and_then(sqlgen::read>) + .value(); + +// result is identical to people +``` + +Empty lists and missing values round-trip correctly. + +### Database Integration + +`std::vector` maps to the dialect's native collection type: + +- **DuckDB**: `LIST` (variable-length, no shape restrictions) +- **PostgreSQL**: `ARRAY` (governed by a strict structural rule) + +**Important**: PostgreSQL arrays must be rectangular, so every sub-array within a dimension must have the same length: `{{1, 2}, {3, 4}}` is valid, while `{{1, 2}, {3}}` is not ([PostgreSQL docs](https://www.postgresql.org/docs/current/arrays.html#ARRAYS-INPUT)). sqlgen cannot check this at compile-time, so a non-rectangular value only surfaces as an error when the database rejects it on write. + +## Notes + +- Only supported for DuckDB and PostgreSQL. Compilation fails with `"std::vector fields are not supported by this database."` if the database doesn't support it. +- Works with type-safe operations such as `create_table`, `write`/`insert` and `read` +- Nested vectors (`std::vector>`) supported to arbitrary depth