From 86754ad6bf247e215faae51b65081b2d15ca2c1f Mon Sep 17 00:00:00 2001 From: Leijurv Date: Thu, 24 Sep 2026 14:12:07 -0700 Subject: [PATCH 1/4] use binary copy for flex and middle tables --- src/db-copy-mgr.hpp | 343 ++++++++++++++++++++++++++++++++++++- src/db-copy.cpp | 16 ++ src/db-copy.hpp | 50 +++++- src/flex-table-column.cpp | 49 ++++++ src/flex-table-column.hpp | 11 ++ src/flex-table.cpp | 29 ++++ src/flex-table.hpp | 7 + src/middle-pgsql.cpp | 26 ++- tests/test-db-copy-mgr.cpp | 255 +++++++++++++++++++++++++++ 9 files changed, 775 insertions(+), 11 deletions(-) diff --git a/src/db-copy-mgr.hpp b/src/db-copy-mgr.hpp index a92af7388..6986f2d5f 100644 --- a/src/db-copy-mgr.hpp +++ b/src/db-copy-mgr.hpp @@ -11,14 +11,50 @@ */ #include +#include +#include +#include +#include +#include #include +#include #include +#include +#include + +#include #include "db-copy.hpp" +#include "format.hpp" #include "hex.hpp" +/** + * Convert a double to a float for a real column. Values that PostgreSQL + * would reject as out of range for type real in the text format (overflow + * to infinity or underflow to zero) throw an exception. + */ +inline float copy_to_float4(double value) +{ + if (std::isnan(value)) { // the text format sends every NaN as "nan" + return std::numeric_limits::quiet_NaN(); + } + + auto const result = static_cast(value); + if (std::isfinite(value) && + (std::isinf(result) || (result == 0.0F && value != 0.0))) { + throw fmt_error("Value {} is out of range for type real.", value); + } + + return result; +} + /** * Management class that fills and manages copy buffers. + * + * Rows are written in PostgreSQL's text COPY format, or in its binary COPY + * format if the target has binary_types() set. The same calls are used for + * both, in the binary format the type of each field is taken from the + * target's list of column types. */ template class db_copy_mgr_t @@ -43,12 +79,15 @@ class db_copy_mgr_t m_current = db_cmd_copy_delete_t(table); } m_committed = m_current.buffer.size(); + m_binary_types = table->binary() ? &table->binary_types() : nullptr; + m_field = 0; } void rollback_line() { assert(m_current); m_current.buffer.resize(m_committed); + m_field = 0; } /** @@ -64,10 +103,15 @@ class db_copy_mgr_t auto &buf = m_current.buffer; assert(!buf.empty()); - // Expect that a column has been written last which ended in a '\t'. - // Replace it with the row delimiter '\n'. - assert(buf.back() == '\t'); - buf.back() = '\n'; + if (m_binary_types) { + // Binary rows have no delimiter, but all fields must be there. + assert(m_field == m_binary_types->size()); + } else { + // Expect that a column has been written last which ended in a + // '\t'. Replace it with the row delimiter '\n'. + assert(buf.back() == '\t'); + buf.back() = '\n'; + } if (m_current.is_full()) { m_processor->send_command(std::move(m_current)); @@ -95,6 +139,10 @@ class db_copy_mgr_t template void add_column(T &&value) { + if (m_binary_types) { + add_binary(next_field(), std::forward(value)); + return; + } add_value(std::forward(value)); m_current.buffer += '\t'; } @@ -104,7 +152,15 @@ class db_copy_mgr_t * * Adds a NULL value for the column. */ - void add_null_column() { m_current.buffer += "\\N\t"; } + void add_null_column() + { + if (m_binary_types) { + next_field(); + put_be(static_cast(-1)); + return; + } + m_current.buffer += "\\N\t"; + } /** * Start an array column. @@ -113,7 +169,20 @@ class db_copy_mgr_t * * Must be finished with a call to finish_array(). */ - void new_array() { m_current.buffer += "{"; } + void new_array() + { + if (m_binary_types) { + check_field_type(next_field(), copy_field_type::int8_array); + m_field_start = start_field_length(); + put_be(static_cast(1)); // number of dimensions + put_be(static_cast(0)); // no NULL elements + put_be(static_cast(INT8OID)); // element type + put_be(static_cast(0)); // length, set in finish_array() + put_be(static_cast(1)); // lower bound + return; + } + m_current.buffer += "{"; + } /** * Add a single value to an array column. @@ -123,6 +192,11 @@ class db_copy_mgr_t */ void add_array_elem(osmid_t value) { + if (m_binary_types) { + put_be(static_cast(sizeof(int64_t))); + put_be(static_cast(value)); + return; + } add_value(value); m_current.buffer += ','; } @@ -135,6 +209,21 @@ class db_copy_mgr_t */ void finish_array() { + if (m_binary_types) { + auto const header = m_field_start + 4; + auto const elements = + (m_current.buffer.size() - header - ARRAY_HEADER_SIZE) / + ARRAY_ELEMENT_SIZE; + if (elements == 0) { + // An empty array has zero dimensions and no dimension info. + m_current.buffer.resize(header + 12); + put_be_at(header, static_cast(0)); + } else { + put_be_at(header + 12, static_cast(elements)); + } + finish_field_length(m_field_start); + return; + } assert(!m_current.buffer.empty()); if (m_current.buffer.back() == '{') { m_current.buffer += '}'; @@ -156,7 +245,13 @@ class db_copy_mgr_t * Must be closed with a finish_hash() call. */ void new_hash() - { /* nothing */ + { + if (m_binary_types) { + check_field_type(next_field(), copy_field_type::hstore); + m_field_start = start_field_length(); + put_be(static_cast(0)); // pairs, set in finish_hash() + m_hash_pairs = 0; + } } void add_hash_elem(std::string const &k, std::string const &v) @@ -172,6 +267,10 @@ class db_copy_mgr_t */ void add_hash_elem(char const *k, char const *v) { + if (m_binary_types) { + add_binary_hash_elem(k, v); + return; + } m_current.buffer += '"'; add_escaped_string(k); m_current.buffer += "\"=>\""; @@ -187,6 +286,10 @@ class db_copy_mgr_t */ void add_hash_elem_noescape(char const *k, char const *v) { + if (m_binary_types) { + add_binary_hash_elem(k, v); + return; + } m_current.buffer += '"'; m_current.buffer += k; m_current.buffer += "\"=>\""; @@ -207,6 +310,10 @@ class db_copy_mgr_t template void add_hstore_num_noescape(char const *k, T const value) { + if (m_binary_types) { + add_binary_hash_elem(k, std::to_string(value).c_str()); + return; + } m_current.buffer += '"'; m_current.buffer += k; m_current.buffer += "\"=>\""; @@ -222,6 +329,11 @@ class db_copy_mgr_t */ void finish_hash() { + if (m_binary_types) { + put_be_at(m_field_start + 4, m_hash_pairs); + finish_field_length(m_field_start); + return; + } auto const idx = m_current.buffer.size() - 1; if (!m_current.buffer.empty() && m_current.buffer[idx] == ',') { m_current.buffer[idx] = '\t'; @@ -233,10 +345,16 @@ class db_copy_mgr_t /** * Add a column with the given WKB geometry in WKB hex format. * - * The geometry is converted on-the-fly from WKB binary to WKB hex. + * The geometry is converted on-the-fly from WKB binary to WKB hex. In + * the binary format the WKB is sent as is. */ void add_hex_geom(std::string const &wkb) { + if (m_binary_types) { + check_field_type(next_field(), copy_field_type::geometry); + add_binary_bytes(wkb); + return; + } util::encode_hex(wkb, &m_current.buffer); m_current.buffer += '\t'; } @@ -277,12 +395,212 @@ class db_copy_mgr_t } private: + /// OID of the int8 type, needed for the elements of int8[]. + static constexpr uint32_t INT8OID = 20; + /// Array header: ndim, flags, element type, one dimension, lower bound + static constexpr std::size_t ARRAY_HEADER_SIZE = 20; + /// Array element: length and int8 value + static constexpr std::size_t ARRAY_ELEMENT_SIZE = 12; + + /** + * Get the type of the next field of the current row in binary format. + * A row starts with the number of fields, that is written together with + * the first field, so that nothing is left in the buffer from a + * new_line() that is only used to delete objects. + */ + copy_field_type next_field() + { + assert(m_binary_types); + assert(m_field < m_binary_types->size()); + if (m_field == 0) { + put_be(static_cast(m_binary_types->size())); + } + return (*m_binary_types)[m_field++]; + } + + static void check_field_type(copy_field_type type, copy_field_type expected) + { + if (type != expected) { + throw_type_mismatch(type); + } + } + + [[noreturn]] static void throw_type_mismatch(copy_field_type type) + { + throw fmt_error("Internal error: Wrong data for column of type {} in" + " binary COPY.", + static_cast(type)); + } + + template + void put_be(T value) + { + using U = std::make_unsigned_t; + auto v = static_cast(value); + char bytes[sizeof(T)]; + for (std::size_t i = sizeof(T); i > 0; --i) { + bytes[i - 1] = static_cast(v & 0xffU); + v >>= 8U; + } + m_current.buffer.append(bytes, sizeof(T)); + } + + template + void put_be_at(std::size_t pos, T value) + { + using U = std::make_unsigned_t; + auto v = static_cast(value); + for (std::size_t i = sizeof(T); i > 0; --i) { + m_current.buffer[pos + i - 1] = static_cast(v & 0xffU); + v >>= 8U; + } + } + + /// Reserve space for the length of a field, return its position. + std::size_t start_field_length() + { + auto const pos = m_current.buffer.size(); + m_current.buffer.append(4, '\0'); + return pos; + } + + void finish_field_length(std::size_t pos) + { + put_be_at(pos, static_cast(m_current.buffer.size() - pos - 4)); + } + + void add_binary_bytes(std::string_view data) + { + put_be(static_cast(data.size())); + m_current.buffer += data; + } + + void add_binary(copy_field_type type, std::string_view str) + { + switch (type) { + case copy_field_type::text: + add_binary_bytes(str); + break; + case copy_field_type::jsonb: + put_be(static_cast(str.size() + 1)); + m_current.buffer += '\1'; // jsonb format version + m_current.buffer += str; + break; + default: + throw_type_mismatch(type); + } + } + + void add_binary(copy_field_type type, char const *str) + { + add_binary(type, std::string_view{str}); + } + + void add_binary(copy_field_type type, std::string const &str) + { + add_binary(type, std::string_view{str}); + } + + void add_binary(copy_field_type type, osmium::Timestamp timestamp) + { + check_field_type(type, copy_field_type::timestamptz); + if (!timestamp.valid()) { + // The text format sends an empty string, which is invalid, too. + throw std::runtime_error{"Invalid timestamp (0)."}; + } + // Microseconds since 2000-01-01 00:00:00 UTC + constexpr int64_t pg_epoch = 946684800; + put_be(static_cast(sizeof(int64_t))); + put_be( + (static_cast(timestamp.seconds_since_epoch()) - pg_epoch) * + 1000000); + } + + template + std::enable_if_t> add_binary(copy_field_type type, + T value) + { + if constexpr (std::is_same_v) { + check_field_type(type, copy_field_type::text); + add_binary_bytes(std::string_view{&value, 1}); + return; + } + + switch (type) { + case copy_field_type::boolean: + put_be(static_cast(1)); + m_current.buffer += (value != 0) ? '\1' : '\0'; + return; + case copy_field_type::float4: { + float const f = copy_to_float4(static_cast(value)); + uint32_t bits = 0; + std::memcpy(&bits, &f, sizeof(bits)); + put_be(static_cast(sizeof(bits))); + put_be(bits); + return; + } + case copy_field_type::float8: { + auto d = static_cast(value); + if (std::isnan(d)) { + d = std::numeric_limits::quiet_NaN(); + } + uint64_t bits = 0; + std::memcpy(&bits, &d, sizeof(bits)); + put_be(static_cast(sizeof(bits))); + put_be(bits); + return; + } + case copy_field_type::text: + add_binary_bytes(fmt::to_string(value)); + return; + default: + break; + } + + if constexpr (std::is_integral_v) { + switch (type) { + case copy_field_type::int2: + assert(value >= std::numeric_limits::min() && + value <= std::numeric_limits::max()); + put_be(static_cast(sizeof(int16_t))); + put_be(static_cast(value)); + return; + case copy_field_type::int4: + assert(value >= std::numeric_limits::min() && + value <= std::numeric_limits::max()); + put_be(static_cast(sizeof(int32_t))); + put_be(static_cast(value)); + return; + case copy_field_type::int8: + put_be(static_cast(sizeof(int64_t))); + put_be(static_cast(value)); + return; + default: + break; + } + } + + throw_type_mismatch(type); + } + + void add_binary_hash_elem(char const *k, char const *v) + { + add_binary_bytes(k); + add_binary_bytes(v); + ++m_hash_pairs; + } + template void add_value(T value) { m_current.buffer += fmt::to_string(value); } + void add_value(osmium::Timestamp timestamp) + { + m_current.buffer += timestamp.to_iso(); + } + void add_value(std::string const &s) { add_value(s.c_str()); } void add_value(char const *s) @@ -341,6 +659,15 @@ class db_copy_mgr_t std::shared_ptr m_processor; db_cmd_copy_delete_t m_current; std::size_t m_committed = 0; + + /// Column types of the current target if it uses the binary format. + std::vector const *m_binary_types = nullptr; + /// Number of the next field in the current row (binary format). + std::size_t m_field = 0; + /// Start of the array or hash field being written (binary format). + std::size_t m_field_start = 0; + /// Number of pairs in the hash field being written (binary format). + int32_t m_hash_pairs = 0; }; #endif // OSM2PGSQL_DB_COPY_MGR_HPP diff --git a/src/db-copy.cpp b/src/db-copy.cpp index e28e744a2..d810f9936 100644 --- a/src/db-copy.cpp +++ b/src/db-copy.cpp @@ -220,6 +220,10 @@ void db_copy_thread_t::thread_t::start_copy( target->rows()); } + if (target->binary()) { + fmt::format_to(std::back_inserter(sql), " (FORMAT binary)"); + } + if (!target->conditions().empty()) { fmt::format_to(std::back_inserter(sql), FMT_STRING(" WHERE {}"), target->conditions()); @@ -228,12 +232,24 @@ void db_copy_thread_t::thread_t::start_copy( sql.push_back('\0'); m_db_connection.copy_start(to_string(sql)); + if (target->binary()) { + // Signature, flags (none), length of header extension (none) + static constexpr std::string_view header{ + "PGCOPY\n\xff\r\n\0\0\0\0\0\0\0\0\0", 19}; + m_db_connection.copy_send(header, target->name()); + } + m_inflight = target; } void db_copy_thread_t::thread_t::finish_copy() { if (m_inflight) { + if (m_inflight->binary()) { + // File trailer: a row with field count -1 + static constexpr std::string_view trailer{"\xff\xff", 2}; + m_db_connection.copy_send(trailer, m_inflight->name()); + } m_db_connection.copy_end(m_inflight->name()); m_inflight.reset(); } diff --git a/src/db-copy.hpp b/src/db-copy.hpp index 84faf8515..e37ab216d 100644 --- a/src/db-copy.hpp +++ b/src/db-copy.hpp @@ -15,8 +15,9 @@ #include "pgsql-params.hpp" #include -#include #include +#include +#include #include #include #include @@ -27,6 +28,27 @@ #include #include +/** + * The PostgreSQL type of a column as far as the binary COPY format is + * concerned. Every type has its own binary representation which must match + * the column type exactly, the server does not convert anything. + */ +enum class copy_field_type : uint8_t +{ + text, ///< text, char(n), json: the string itself + boolean, + int2, + int4, + int8, + float4, + float8, + int8_array, ///< one-dimensional int8[] without NULLs + hstore, + jsonb, + geometry, ///< PostGIS geometry, sent as EWKB + timestamptz ///< from osmium::Timestamp only +}; + /** * Table information necessary for building SQL queries. */ @@ -55,6 +77,27 @@ class db_target_descr_t m_conditions = std::move(conditions); } + /** + * Rows are sent in PostgreSQL's binary COPY format if the types of all + * columns are known, in the text format otherwise. + */ + bool binary() const noexcept { return !m_binary_types.empty(); } + + /// The types of the columns for the binary COPY format. + std::vector const &binary_types() const noexcept + { + return m_binary_types; + } + + /** + * Set the types of all columns (in the order of rows()) to switch to the + * binary COPY format. An empty vector means text format. + */ + void set_binary_types(std::vector types) + { + m_binary_types = std::move(types); + } + /** * Check if the buffer would use exactly the same copy operation. */ @@ -62,7 +105,8 @@ class db_target_descr_t { return (this == &other) || (m_schema == other.m_schema && m_name == other.m_name && - m_id == other.m_id && m_rows == other.m_rows); + m_id == other.m_id && m_rows == other.m_rows && + binary() == other.binary()); } private: @@ -76,6 +120,8 @@ class db_target_descr_t std::string m_rows; /// Conditions for the COPY command. std::string m_conditions; + /// Column types for binary COPY format (when empty: text format). + std::vector m_binary_types; }; /** diff --git a/src/flex-table-column.cpp b/src/flex-table-column.cpp index 98a9cd4e1..22a06231b 100644 --- a/src/flex-table-column.cpp +++ b/src/flex-table-column.cpp @@ -9,6 +9,7 @@ #include "flex-table-column.hpp" +#include "db-copy.hpp" #include "format.hpp" #include "geom-boost-adaptor.hpp" #include "overloaded.hpp" @@ -128,6 +129,54 @@ void flex_table_column_t::set_projection(char const *projection) } } +std::optional +flex_table_column_t::binary_copy_type() const noexcept +{ + if (!m_sql_type.empty()) { + return {}; // we don't know the binary format of arbitrary types + } + + switch (m_type) { + case table_column_type::text: + case table_column_type::json: + case table_column_type::id_type: + return copy_field_type::text; + case table_column_type::boolean: + return copy_field_type::boolean; + case table_column_type::int2: + case table_column_type::direction: + return copy_field_type::int2; + case table_column_type::int4: + return copy_field_type::int4; + case table_column_type::int8: + case table_column_type::id_num: + return copy_field_type::int8; + case table_column_type::real: + return copy_field_type::float4; + case table_column_type::double_precision: + return copy_field_type::float8; + case table_column_type::timestamp: + case table_column_type::timestamptz: + // These can get strings from Lua in any format PostgreSQL + // understands, so we leave the parsing to PostgreSQL. + return {}; + case table_column_type::hstore: + return copy_field_type::hstore; + case table_column_type::jsonb: + return copy_field_type::jsonb; + case table_column_type::geometry: + case table_column_type::point: + case table_column_type::linestring: + case table_column_type::polygon: + case table_column_type::multipoint: + case table_column_type::multilinestring: + case table_column_type::multipolygon: + case table_column_type::geometrycollection: + return copy_field_type::geometry; + } + return {}; +} + std::string flex_table_column_t::sql_type_name() const { if (!m_sql_type.empty()) { diff --git a/src/flex-table-column.hpp b/src/flex-table-column.hpp index 861ec7218..ec956d813 100644 --- a/src/flex-table-column.hpp +++ b/src/flex-table-column.hpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -58,6 +59,8 @@ enum class table_column_type : uint8_t class geometry_cache_t; +enum class copy_field_type : uint8_t; + /** * A column in a flex_table_t. */ @@ -71,6 +74,14 @@ class flex_table_column_t table_column_type type() const noexcept { return m_type; } + /** + * The type of this column in the binary COPY format. Nothing if the + * column can only be written in the text format, because it has a + * user-defined SQL type or because it can contain strings which only + * PostgreSQL can parse (timestamps). + */ + std::optional binary_copy_type() const noexcept; + bool is_point_column() const noexcept { return (m_type == table_column_type::point) || diff --git a/src/flex-table.cpp b/src/flex-table.cpp index fbd16f704..c83c349c3 100644 --- a/src/flex-table.cpp +++ b/src/flex-table.cpp @@ -207,6 +207,22 @@ flex_table_t::build_sql_create_table(table_type ttype, return sql; } +std::vector flex_table_t::binary_copy_types() const +{ + std::vector types; + for (auto const &column : m_columns) { + if (column.create_only()) { + continue; + } + auto const type = column.binary_copy_type(); + if (!type) { + return {}; + } + types.push_back(*type); + } + return types; +} + std::string flex_table_t::build_sql_column_list() const { assert(!m_columns.empty()); @@ -295,6 +311,19 @@ namespace { void table_connection_t::start(pg_conn_t const &db_connection, bool append) const { + if (m_target->binary()) { + log_debug("Table '{}' uses the binary COPY format.", table().name()); + } else { + for (auto const &column : table().columns()) { + if (!column.create_only() && !column.binary_copy_type()) { + log_debug("Table '{}' uses the text COPY format because of" + " column '{}'.", + table().name(), column.name()); + break; + } + } + } + if (!append) { drop_table_if_exists(db_connection, table().schema(), table().name()); } diff --git a/src/flex-table.hpp b/src/flex-table.hpp index be932f6c7..6c7d41f8e 100644 --- a/src/flex-table.hpp +++ b/src/flex-table.hpp @@ -159,6 +159,12 @@ class flex_table_t std::string build_sql_copy_condition() const; + /** + * The types of all columns written by COPY for the binary COPY format. + * Empty if the table must use the text format. + */ + std::vector binary_copy_types() const; + std::string build_sql_create_id_index() const; /// Does this table take objects of the specified type? @@ -293,6 +299,7 @@ class table_connection_t table->build_sql_column_list(), table->build_sql_copy_condition())), m_copy_mgr(copy_thread) { + m_target->set_binary_types(table->binary_copy_types()); } void start(pg_conn_t const &db_connection, bool append) const; diff --git a/src/middle-pgsql.cpp b/src/middle-pgsql.cpp index 631ed95aa..561900e6c 100644 --- a/src/middle-pgsql.cpp +++ b/src/middle-pgsql.cpp @@ -18,12 +18,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -352,7 +354,7 @@ void members_to_json(osmium::RelationMemberList const &members, void middle_pgsql_t::copy_attributes(osmium::OSMObject const &obj) { if (obj.timestamp()) { - m_db_copy.add_column(obj.timestamp().to_iso()); + m_db_copy.add_column(obj.timestamp()); } else { m_db_copy.add_null_column(); } @@ -1070,6 +1072,8 @@ void middle_pgsql_t::write_users_table() auto const users_table = std::make_shared( m_options->dbschema, table_name, "id"); + users_table->set_binary_types( + {copy_field_type::int4, copy_field_type::text}); for (auto const &[id, name] : m_users) { m_db_copy.new_line(users_table); @@ -1259,6 +1263,26 @@ middle_pgsql_t::middle_pgsql_t(std::shared_ptr thread_pool, m_tables.nodes() = table_desc_t{*options, "nodes"}; m_tables.ways() = table_desc_t{*options, "ways"}; m_tables.relations() = table_desc_t{*options, "rels"}; + + // We know the types of all columns of the middle tables (see + // table_setup()), so they always use the binary COPY format. + using cft = copy_field_type; + std::vector attributes; + if (m_store_options.with_attributes) { + attributes = {cft::timestamptz, cft::int4, cft::int4, cft::int4}; + } + auto const types = [&](std::vector columns, + std::initializer_list after_attributes) { + columns.insert(columns.end(), attributes.cbegin(), attributes.cend()); + columns.insert(columns.end(), after_attributes); + return columns; + }; + m_tables.nodes().copy_target()->set_binary_types( + types({cft::int8, cft::int4, cft::int4}, {cft::jsonb})); + m_tables.ways().copy_target()->set_binary_types( + types({cft::int8}, {cft::int8_array, cft::jsonb})); + m_tables.relations().copy_target()->set_binary_types( + types({cft::int8}, {cft::jsonb, cft::jsonb})); } void middle_pgsql_t::set_requirements( diff --git a/tests/test-db-copy-mgr.cpp b/tests/test-db-copy-mgr.cpp index 00ec098b5..29a2fd69d 100644 --- a/tests/test-db-copy-mgr.cpp +++ b/tests/test-db-copy-mgr.cpp @@ -9,12 +9,16 @@ #include +#include +#include #include #include #include #include "common-pg.hpp" #include "db-copy-mgr.hpp" +#include "geom.hpp" +#include "wkb.hpp" namespace { @@ -88,6 +92,67 @@ void check_row(std::vector const &row) } } +/** + * Write a row with the value added by the write function into a table with + * the text format and into one with the binary format, then check that the + * database ends up with the same value in both. + */ +template +void check_text_and_binary(std::string const &sql_type, copy_field_type type, + FUNC &&write) +{ + auto const conn = db.connect(); + conn.exec("DROP TABLE IF EXISTS test_copy_text, test_copy_binary"); + conn.exec("CREATE TABLE test_copy_text (id int8, v {})", sql_type); + conn.exec("CREATE TABLE test_copy_binary (id int8, v {})", sql_type); + + auto const text = + std::make_shared("public", "test_copy_text", "id"); + auto const binary = + std::make_shared("public", "test_copy_binary", "id"); + binary->set_binary_types({copy_field_type::int8, type}); + + copy_mgr_t mgr{std::make_shared(db.connection_params())}; + for (auto const &target : {text, binary}) { + mgr.new_line(target); + mgr.add_column(1); + write(&mgr); + mgr.finish_line(); + } + mgr.sync(); + + auto const res = + conn.exec("SELECT t.v::text, b.v::text, t.v IS NULL, b.v IS NULL" + " FROM test_copy_text t, test_copy_binary b"); + REQUIRE(res.num_tuples() == 1); + CHECK(std::string{res.get_value(0, 0)} == res.get_value(0, 1)); + CHECK(std::string{res.get_value(0, 2)} == res.get_value(0, 3)); +} + +template +void check_value(std::string const &sql_type, copy_field_type type, T value) +{ + check_text_and_binary(sql_type, type, + [&](copy_mgr_t *mgr) { mgr->add_column(value); }); + check_text_and_binary(sql_type, type, + [&](copy_mgr_t *mgr) { mgr->add_null_column(); }); +} + +/// Write a single value in binary format, the write is expected to throw. +template +void check_binary_throws(copy_field_type type, T value) +{ + auto const t = setup_table("v text"); + t->set_binary_types({copy_field_type::int8, type}); + + copy_mgr_t mgr{std::make_shared(db.connection_params())}; + mgr.new_line(t); + mgr.add_column(1); + REQUIRE_THROWS(mgr.add_column(value)); + mgr.rollback_line(); + mgr.sync(); +} + } // anonymous namespace TEST_CASE("copy_mgr_t: Insert null") @@ -229,3 +294,193 @@ TEST_CASE("copy_mgr_t: Insert something, insert more, roll back, insert " CHECK(res.get(0, 0) == "good"); CHECK(res.get(1, 0) == "better"); } + +TEST_CASE("copy_mgr_t: Binary format gives the same text values as text") +{ + for (char const *str : {"foo", "", "va\tr\nK\\P\r\"quoted\"", + "\xc3\xa4\xe6\xbc\xa2\xf0\x9f\x99\x82"}) { + check_value("text", copy_field_type::text, str); + check_value("text", copy_field_type::text, std::string{str}); + } + check_value("char(1)", copy_field_type::text, 'N'); + check_value("text", copy_field_type::text, 42); + check_value("json", copy_field_type::text, R"({"a": [1, 2.5]})"); +} + +TEST_CASE("copy_mgr_t: Binary format gives the same booleans as text") +{ + check_value("boolean", copy_field_type::boolean, true); + check_value("boolean", copy_field_type::boolean, false); + check_value("boolean", copy_field_type::boolean, 1); +} + +TEST_CASE("copy_mgr_t: Binary format gives the same integers as text") +{ + for (int64_t const v : + {int64_t{0}, int64_t{-1}, int64_t{-32768}, int64_t{32767}}) { + check_value("int2", copy_field_type::int2, v); + } + for (int64_t const v : {int64_t{std::numeric_limits::min()}, + int64_t{std::numeric_limits::max()}}) { + check_value("int4", copy_field_type::int4, v); + } + for (int64_t const v : {std::numeric_limits::min(), + std::numeric_limits::max()}) { + check_value("int8", copy_field_type::int8, v); + } + check_value("int4", copy_field_type::int4, -1); +} + +TEST_CASE("copy_mgr_t: Binary format gives the same reals as text") +{ + for (double const v : + {0.0, -0.0, 0.1, 1.5, -123456.789, 3.4028235e38, 1e-40, 1e-45, + std::numeric_limits::quiet_NaN(), + -std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), + -std::numeric_limits::infinity()}) { + check_value("real", copy_field_type::float4, v); + check_value("double precision", copy_field_type::float8, v); + } + check_value("double precision", copy_field_type::float8, 1e300); + check_value("real", copy_field_type::float4, 7); +} + +TEST_CASE("copy_mgr_t: Binary format rejects reals out of range like text") +{ + check_binary_throws(copy_field_type::float4, 1e39); + check_binary_throws(copy_field_type::float4, -1e39); + check_binary_throws(copy_field_type::float4, 1e-50); +} + +TEST_CASE("copy_mgr_t: Binary format gives the same jsonb as text") +{ + check_value("jsonb", copy_field_type::jsonb, + R"({"b": null, "a": [1, 2.5, "x\ty"], "c": "รค"})"); + check_value("jsonb", copy_field_type::jsonb, std::string{"[]"}); +} + +TEST_CASE("copy_mgr_t: Binary format rounds doubles to the nearest float") +{ + // The text format rounds twice (to the shortest decimal representation, + // then to float) which gives a different result for values exactly + // halfway between two floats. The binary format rounds once, correctly. + auto const t = setup_table("v real"); + t->set_binary_types({copy_field_type::int8, copy_field_type::float4}); + + copy_mgr_t mgr{std::make_shared(db.connection_params())}; + double const halfway_down = 1.0 + std::ldexp(1.0, -24); // to even: 1 + double const halfway_up = 1.0 + 3 * std::ldexp(1.0, -24); // 1 + 2^-22 + add_row(&mgr, t, 1, halfway_down); + add_row(&mgr, t, 2, halfway_up); + + auto const conn = db.connect(); + CHECK(conn.result_as_int("SELECT count(*) FROM test_copy_mgr WHERE" + " (id = 1 AND v = 1::real) OR" + " (id = 2 AND v = (1 + 2 ^ (-22))::real)") == 2); +} + +TEST_CASE("copy_mgr_t: Binary format gives the same timestamps as text") +{ + for (uint32_t const seconds : {1U, 946684800U, 1234567890U, 0xffffffffU}) { + check_value("timestamptz", copy_field_type::timestamptz, + osmium::Timestamp{seconds}); + } + check_binary_throws(copy_field_type::timestamptz, osmium::Timestamp{}); +} + +TEST_CASE("copy_mgr_t: Binary format gives the same arrays as text") +{ + for (std::vector const &values : + {std::vector{}, std::vector{1, -2, 3}, + std::vector{std::numeric_limits::max()}}) { + check_text_and_binary("int8[]", copy_field_type::int8_array, + [&](copy_mgr_t *mgr) { + mgr->new_array(); + for (auto const v : values) { + mgr->add_array_elem(v); + } + mgr->finish_array(); + }); + } +} + +TEST_CASE("copy_mgr_t: Binary format gives the same hstore as text") +{ + std::vector> const values = { + {"one", "two"}, + {"key 1", "value 1"}, + {"\"key\"", "\"value\""}, + {"key\t2", "value\t2"}, + {"key\n3", "value\n3"}, + {"key\\5", "value\\5"}, + {"", ""}}; + + check_text_and_binary("hstore", copy_field_type::hstore, + [&](copy_mgr_t *mgr) { + mgr->new_hash(); + for (auto const &[k, v] : values) { + mgr->add_hash_elem(k, v); + } + mgr->add_hstore_num_noescape("num", 17); + mgr->finish_hash(); + }); + + check_text_and_binary("hstore", copy_field_type::hstore, + [&](copy_mgr_t *mgr) { + mgr->new_hash(); + mgr->finish_hash(); + }); +} + +TEST_CASE("copy_mgr_t: Binary format gives the same geometries as text") +{ + geom::geometry_t point{geom::point_t{1.5, -2.25}}; + point.set_srid(3857); + geom::geometry_t line{geom::linestring_t{{0, 0}, {1, 1}, {2, 0}}}; + line.set_srid(4326); + + for (auto const *geom : {&point, &line}) { + for (bool const wrap_multi : {false, true}) { + auto const wkb = geom_to_ewkb(*geom, wrap_multi); + check_text_and_binary( + "geometry", copy_field_type::geometry, + [&](copy_mgr_t *mgr) { mgr->add_hex_geom(wkb); }); + } + } +} + +TEST_CASE("copy_mgr_t: Binary format with deletes, rollback and many rows") +{ + auto const t = setup_table("t text, n int4"); + t->set_binary_types( + {copy_field_type::int8, copy_field_type::text, copy_field_type::int4}); + + copy_mgr_t mgr{std::make_shared(db.connection_params())}; + + // Long enough so that the rows need several buffers + std::string const text(200, 'x'); + + // A new_line() just for deleting must not leave anything in the buffer. + mgr.new_line(t); + mgr.delete_object(1); + + for (int i = 0; i < 100000; ++i) { + mgr.new_line(t); + mgr.add_column(i); + mgr.add_column(text); + if (i % 3 == 0) { + mgr.rollback_line(); + continue; + } + mgr.add_column(i * 2); + mgr.finish_line(); + } + mgr.sync(); + + auto const conn = db.connect(); + CHECK(conn.get_count("test_copy_mgr") == 66666); + CHECK(conn.result_as_int("SELECT count(*) FROM test_copy_mgr" + " WHERE n = id * 2 AND t = repeat('x', 200)") == + 66666); +} From caabbe413c964fa8abe7a7395d79f43fe2a44189 Mon Sep 17 00:00:00 2001 From: Leijurv Date: Thu, 24 Sep 2026 16:46:32 -0700 Subject: [PATCH 2/4] ci sorry --- src/db-copy-mgr.hpp | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/db-copy-mgr.hpp b/src/db-copy-mgr.hpp index 6986f2d5f..a2f2d700d 100644 --- a/src/db-copy-mgr.hpp +++ b/src/db-copy-mgr.hpp @@ -432,6 +432,19 @@ class db_copy_mgr_t static_cast(type)); } + /// Is the integer value representable in the (signed) type R? + template + static constexpr bool fits_in(T value) noexcept + { + if constexpr (std::is_signed_v) { + return value >= std::numeric_limits::min() && + value <= std::numeric_limits::max(); + } else { + return static_cast(value) <= + static_cast(std::numeric_limits::max()); + } + } + template void put_be(T value) { @@ -560,14 +573,12 @@ class db_copy_mgr_t if constexpr (std::is_integral_v) { switch (type) { case copy_field_type::int2: - assert(value >= std::numeric_limits::min() && - value <= std::numeric_limits::max()); + assert(fits_in(value)); put_be(static_cast(sizeof(int16_t))); put_be(static_cast(value)); return; case copy_field_type::int4: - assert(value >= std::numeric_limits::min() && - value <= std::numeric_limits::max()); + assert(fits_in(value)); put_be(static_cast(sizeof(int32_t))); put_be(static_cast(value)); return; From 05660a9aecf4af57d6870716b670188920deff3e Mon Sep 17 00:00:00 2001 From: Leijurv Date: Thu, 24 Sep 2026 16:58:50 -0700 Subject: [PATCH 3/4] clang-tidy --- src/db-copy-mgr.hpp | 12 +++++------- src/db-copy.cpp | 8 ++++---- tests/test-db-copy-mgr.cpp | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/db-copy-mgr.hpp b/src/db-copy-mgr.hpp index a2f2d700d..eda6ae9fe 100644 --- a/src/db-copy-mgr.hpp +++ b/src/db-copy-mgr.hpp @@ -449,13 +449,11 @@ class db_copy_mgr_t void put_be(T value) { using U = std::make_unsigned_t; - auto v = static_cast(value); - char bytes[sizeof(T)]; + auto const v = static_cast(static_cast(value)); for (std::size_t i = sizeof(T); i > 0; --i) { - bytes[i - 1] = static_cast(v & 0xffU); - v >>= 8U; + m_current.buffer += + static_cast((v >> (8U * (i - 1))) & 0xffU); } - m_current.buffer.append(bytes, sizeof(T)); } template @@ -522,10 +520,10 @@ class db_copy_mgr_t throw std::runtime_error{"Invalid timestamp (0)."}; } // Microseconds since 2000-01-01 00:00:00 UTC - constexpr int64_t pg_epoch = 946684800; + constexpr int64_t PG_EPOCH = 946684800; put_be(static_cast(sizeof(int64_t))); put_be( - (static_cast(timestamp.seconds_since_epoch()) - pg_epoch) * + (static_cast(timestamp.seconds_since_epoch()) - PG_EPOCH) * 1000000); } diff --git a/src/db-copy.cpp b/src/db-copy.cpp index d810f9936..048aec2df 100644 --- a/src/db-copy.cpp +++ b/src/db-copy.cpp @@ -234,9 +234,9 @@ void db_copy_thread_t::thread_t::start_copy( if (target->binary()) { // Signature, flags (none), length of header extension (none) - static constexpr std::string_view header{ + static constexpr std::string_view HEADER{ "PGCOPY\n\xff\r\n\0\0\0\0\0\0\0\0\0", 19}; - m_db_connection.copy_send(header, target->name()); + m_db_connection.copy_send(HEADER, target->name()); } m_inflight = target; @@ -247,8 +247,8 @@ void db_copy_thread_t::thread_t::finish_copy() if (m_inflight) { if (m_inflight->binary()) { // File trailer: a row with field count -1 - static constexpr std::string_view trailer{"\xff\xff", 2}; - m_db_connection.copy_send(trailer, m_inflight->name()); + static constexpr std::string_view TRAILER{"\xff\xff", 2}; + m_db_connection.copy_send(TRAILER, m_inflight->name()); } m_db_connection.copy_end(m_inflight->name()); m_inflight.reset(); diff --git a/tests/test-db-copy-mgr.cpp b/tests/test-db-copy-mgr.cpp index 29a2fd69d..ff023855a 100644 --- a/tests/test-db-copy-mgr.cpp +++ b/tests/test-db-copy-mgr.cpp @@ -99,7 +99,7 @@ void check_row(std::vector const &row) */ template void check_text_and_binary(std::string const &sql_type, copy_field_type type, - FUNC &&write) + FUNC const &write) { auto const conn = db.connect(); conn.exec("DROP TABLE IF EXISTS test_copy_text, test_copy_binary"); From 9e807483e6f19248a7af27f804a98b6bd0caccdd Mon Sep 17 00:00:00 2001 From: Leijurv Date: Sat, 26 Sep 2026 09:57:48 -0700 Subject: [PATCH 4/4] address comments --- src/db-copy-mgr.hpp | 115 +++++++++++++++++++++++++++----------------- 1 file changed, 71 insertions(+), 44 deletions(-) diff --git a/src/db-copy-mgr.hpp b/src/db-copy-mgr.hpp index eda6ae9fe..7209ba5ce 100644 --- a/src/db-copy-mgr.hpp +++ b/src/db-copy-mgr.hpp @@ -10,6 +10,7 @@ * For a full list of authors see the git log. */ +#include #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include @@ -156,7 +158,7 @@ class db_copy_mgr_t { if (m_binary_types) { next_field(); - put_be(static_cast(-1)); + put_int32(-1); // NULL return; } m_current.buffer += "\\N\t"; @@ -174,11 +176,11 @@ class db_copy_mgr_t if (m_binary_types) { check_field_type(next_field(), copy_field_type::int8_array); m_field_start = start_field_length(); - put_be(static_cast(1)); // number of dimensions - put_be(static_cast(0)); // no NULL elements - put_be(static_cast(INT8OID)); // element type - put_be(static_cast(0)); // length, set in finish_array() - put_be(static_cast(1)); // lower bound + put_int32(1); // number of dimensions + put_int32(0); // no NULL elements + put_int32(INT8OID); // element type + put_int32(0); // length, set in finish_array() + put_int32(1); // lower bound return; } m_current.buffer += "{"; @@ -193,8 +195,7 @@ class db_copy_mgr_t void add_array_elem(osmid_t value) { if (m_binary_types) { - put_be(static_cast(sizeof(int64_t))); - put_be(static_cast(value)); + put_value(value); return; } add_value(value); @@ -217,9 +218,9 @@ class db_copy_mgr_t if (elements == 0) { // An empty array has zero dimensions and no dimension info. m_current.buffer.resize(header + 12); - put_be_at(header, static_cast(0)); + put_length_at(header, 0); // number of dimensions } else { - put_be_at(header + 12, static_cast(elements)); + put_length_at(header + 12, elements); } finish_field_length(m_field_start); return; @@ -249,7 +250,7 @@ class db_copy_mgr_t if (m_binary_types) { check_field_type(next_field(), copy_field_type::hstore); m_field_start = start_field_length(); - put_be(static_cast(0)); // pairs, set in finish_hash() + put_int32(0); // pairs, set in finish_hash() m_hash_pairs = 0; } } @@ -330,7 +331,7 @@ class db_copy_mgr_t void finish_hash() { if (m_binary_types) { - put_be_at(m_field_start + 4, m_hash_pairs); + put_length_at(m_field_start + 4, m_hash_pairs); finish_field_length(m_field_start); return; } @@ -396,7 +397,7 @@ class db_copy_mgr_t private: /// OID of the int8 type, needed for the elements of int8[]. - static constexpr uint32_t INT8OID = 20; + static constexpr int32_t INT8OID = 20; /// Array header: ndim, flags, element type, one dimension, lower bound static constexpr std::size_t ARRAY_HEADER_SIZE = 20; /// Array element: length and int8 value @@ -413,7 +414,7 @@ class db_copy_mgr_t assert(m_binary_types); assert(m_field < m_binary_types->size()); if (m_field == 0) { - put_be(static_cast(m_binary_types->size())); + put_int16(static_cast(m_binary_types->size())); } return (*m_binary_types)[m_field++]; } @@ -445,26 +446,58 @@ class db_copy_mgr_t } } + /** + * The bytes of an integer in big-endian (network) byte order, which is + * what the binary format uses. Compilers turn this into a single byte + * swap instruction. + */ + template + static constexpr std::array + big_endian_bytes(T value, std::index_sequence /*bytes*/) noexcept + { + auto const v = + static_cast(static_cast>(value)); + return {static_cast(v >> (8U * (sizeof(T) - 1 - I)))...}; + } + + template + static constexpr std::array + big_endian_bytes(T value) noexcept + { + return big_endian_bytes(value, std::make_index_sequence{}); + } + template void put_be(T value) { - using U = std::make_unsigned_t; - auto const v = static_cast(static_cast(value)); - for (std::size_t i = sizeof(T); i > 0; --i) { - m_current.buffer += - static_cast((v >> (8U * (i - 1))) & 0xffU); - } + auto const bytes = big_endian_bytes(value); + m_current.buffer.append(bytes.data(), bytes.size()); + } + + void put_int16(int16_t value) { put_be(value); } + void put_int32(int32_t value) { put_be(value); } + + /// Write the length of a field or the number of elements of something. + void put_length(std::size_t length) + { + assert(length <= std::numeric_limits::max()); + put_int32(static_cast(length)); } + /// Overwrite an int32 written earlier at position pos. + void put_length_at(std::size_t pos, std::size_t length) + { + assert(length <= std::numeric_limits::max()); + auto const bytes = big_endian_bytes(static_cast(length)); + m_current.buffer.replace(pos, bytes.size(), bytes.data(), bytes.size()); + } + + /// Write a complete field with a fixed-size value: length, then value. template - void put_be_at(std::size_t pos, T value) + void put_value(T value) { - using U = std::make_unsigned_t; - auto v = static_cast(value); - for (std::size_t i = sizeof(T); i > 0; --i) { - m_current.buffer[pos + i - 1] = static_cast(v & 0xffU); - v >>= 8U; - } + put_length(sizeof(T)); + put_be(value); } /// Reserve space for the length of a field, return its position. @@ -477,12 +510,12 @@ class db_copy_mgr_t void finish_field_length(std::size_t pos) { - put_be_at(pos, static_cast(m_current.buffer.size() - pos - 4)); + put_length_at(pos, m_current.buffer.size() - pos - 4); } void add_binary_bytes(std::string_view data) { - put_be(static_cast(data.size())); + put_length(data.size()); m_current.buffer += data; } @@ -493,7 +526,7 @@ class db_copy_mgr_t add_binary_bytes(str); break; case copy_field_type::jsonb: - put_be(static_cast(str.size() + 1)); + put_length(str.size() + 1); m_current.buffer += '\1'; // jsonb format version m_current.buffer += str; break; @@ -521,8 +554,7 @@ class db_copy_mgr_t } // Microseconds since 2000-01-01 00:00:00 UTC constexpr int64_t PG_EPOCH = 946684800; - put_be(static_cast(sizeof(int64_t))); - put_be( + put_value( (static_cast(timestamp.seconds_since_epoch()) - PG_EPOCH) * 1000000); } @@ -539,15 +571,14 @@ class db_copy_mgr_t switch (type) { case copy_field_type::boolean: - put_be(static_cast(1)); + put_length(1); m_current.buffer += (value != 0) ? '\1' : '\0'; return; case copy_field_type::float4: { float const f = copy_to_float4(static_cast(value)); uint32_t bits = 0; std::memcpy(&bits, &f, sizeof(bits)); - put_be(static_cast(sizeof(bits))); - put_be(bits); + put_value(bits); return; } case copy_field_type::float8: { @@ -557,8 +588,7 @@ class db_copy_mgr_t } uint64_t bits = 0; std::memcpy(&bits, &d, sizeof(bits)); - put_be(static_cast(sizeof(bits))); - put_be(bits); + put_value(bits); return; } case copy_field_type::text: @@ -572,17 +602,14 @@ class db_copy_mgr_t switch (type) { case copy_field_type::int2: assert(fits_in(value)); - put_be(static_cast(sizeof(int16_t))); - put_be(static_cast(value)); + put_value(static_cast(value)); return; case copy_field_type::int4: assert(fits_in(value)); - put_be(static_cast(sizeof(int32_t))); - put_be(static_cast(value)); + put_value(static_cast(value)); return; case copy_field_type::int8: - put_be(static_cast(sizeof(int64_t))); - put_be(static_cast(value)); + put_value(static_cast(value)); return; default: break; @@ -676,7 +703,7 @@ class db_copy_mgr_t /// Start of the array or hash field being written (binary format). std::size_t m_field_start = 0; /// Number of pairs in the hash field being written (binary format). - int32_t m_hash_pairs = 0; + std::size_t m_hash_pairs = 0; }; #endif // OSM2PGSQL_DB_COPY_MGR_HPP