From ba6c729d14ea119c808e315ff5278510c99caadc Mon Sep 17 00:00:00 2001 From: peterxcli Date: Mon, 24 Aug 2026 12:18:17 +0800 Subject: [PATCH 1/2] Add RapidCheck property-based tests (test/property) Standalone property-based test harness using RapidCheck, in the spirit of proptest harnesses (compare apache/arrow-rs#10352). Generates random LogicalTypes (incl. nested STRUCT/LIST/ARRAY/MAP/UNION/ENUM) and Values with adversarial special cases, and checks properties against independent oracles: text/SQL/JSON round trips, LIKE vs a reference matcher, string and list functions, integer/HUGEINT/DECIMAL arithmetic vs __int128, date parts vs civil-calendar algorithms, and persistent storage round trips across force_compression settings. Bugs found by these tests are documented with minimal reproductions in test/property/FINDINGS.md; the corresponding properties skip them via RC_PRE guards marked KNOWN ISSUE so they keep hunting for new ones. Co-Authored-By: Claude Fable 5 --- test/property/CMakeLists.txt | 64 +++ test/property/FINDINGS.md | 146 +++++ test/property/README.md | 92 +++ test/property/generators.cpp | 730 ++++++++++++++++++++++++ test/property/include/property_test.hpp | 290 ++++++++++ test/property/main.cpp | 2 + test/property/test_arithmetic.cpp | 389 +++++++++++++ test/property/test_datetime.cpp | 203 +++++++ test/property/test_lists.cpp | 240 ++++++++ test/property/test_roundtrip.cpp | 201 +++++++ test/property/test_storage.cpp | 125 ++++ test/property/test_strings.cpp | 562 ++++++++++++++++++ 12 files changed, 3044 insertions(+) create mode 100644 test/property/CMakeLists.txt create mode 100644 test/property/FINDINGS.md create mode 100644 test/property/README.md create mode 100644 test/property/generators.cpp create mode 100644 test/property/include/property_test.hpp create mode 100644 test/property/main.cpp create mode 100644 test/property/test_arithmetic.cpp create mode 100644 test/property/test_datetime.cpp create mode 100644 test/property/test_lists.cpp create mode 100644 test/property/test_roundtrip.cpp create mode 100644 test/property/test_storage.cpp create mode 100644 test/property/test_strings.cpp diff --git a/test/property/CMakeLists.txt b/test/property/CMakeLists.txt new file mode 100644 index 000000000000..55a9308f2ab8 --- /dev/null +++ b/test/property/CMakeLists.txt @@ -0,0 +1,64 @@ +cmake_minimum_required(VERSION 3.15) +project(duckdb_property_tests CXX) + +# Property-based tests for DuckDB using RapidCheck (https://github.com/emil-e/rapidcheck). +# +# This is a standalone project that links against an existing DuckDB build directory: +# +# cmake -S test/property -B build/property -DDUCKDB_BUILD_DIR=build/relassert +# cmake --build build/property +# RC_PARAMS="max_success=500" build/property/property_test +# +# RapidCheck is fetched via FetchContent unless RAPIDCHECK_SOURCE_DIR points to a checkout. + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +get_filename_component(DUCKDB_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +set(DUCKDB_BUILD_DIR "${DUCKDB_ROOT}/build/relassert" CACHE PATH "DuckDB build directory containing src/libduckdb") +set(RAPIDCHECK_SOURCE_DIR "" CACHE PATH "Local RapidCheck checkout (optional)") +option(PROPERTY_TEST_SANITIZE "Build with -fsanitize=address,undefined (required when linking a sanitized libduckdb)" ON) + +if(NOT EXISTS "${DUCKDB_BUILD_DIR}/src") + message(FATAL_ERROR "DUCKDB_BUILD_DIR=${DUCKDB_BUILD_DIR} does not look like a DuckDB build directory") +endif() + +# Locate the shared library +find_library(DUCKDB_LIBRARY NAMES duckdb PATHS "${DUCKDB_BUILD_DIR}/src" NO_DEFAULT_PATH) +if(NOT DUCKDB_LIBRARY) + message(FATAL_ERROR "Could not find libduckdb in ${DUCKDB_BUILD_DIR}/src") +endif() + +# RapidCheck +if(RAPIDCHECK_SOURCE_DIR) + add_subdirectory("${RAPIDCHECK_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/rapidcheck" EXCLUDE_FROM_ALL) +else() + include(FetchContent) + FetchContent_Declare(rapidcheck + GIT_REPOSITORY https://github.com/emil-e/rapidcheck.git + GIT_TAG master + GIT_SHALLOW TRUE) + FetchContent_MakeAvailable(rapidcheck) + set(RAPIDCHECK_SOURCE_DIR "${rapidcheck_SOURCE_DIR}") +endif() + +# RapidCheck's Catch adapter includes ; DuckDB vendors Catch v2 at third_party/catch/catch.hpp. +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/catch_shim/catch2") +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/catch_shim/catch2/catch.hpp" "#include \"${DUCKDB_ROOT}/third_party/catch/catch.hpp\"\n") + +file(GLOB PROPERTY_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp") +add_executable(property_test ${PROPERTY_TEST_SOURCES}) +target_include_directories(property_test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${DUCKDB_ROOT}/src/include" + "${DUCKDB_ROOT}/third_party/catch" + "${DUCKDB_ROOT}/third_party/fmt/include" + "${CMAKE_CURRENT_BINARY_DIR}/catch_shim" + "${RAPIDCHECK_SOURCE_DIR}/extras/catch/include") +target_link_libraries(property_test PRIVATE rapidcheck "${DUCKDB_LIBRARY}") +target_compile_definitions(property_test PRIVATE DUCKDB_ROOT_DIR="${DUCKDB_ROOT}") +if(PROPERTY_TEST_SANITIZE) + target_compile_options(property_test PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(property_test PRIVATE -fsanitize=address,undefined) +endif() diff --git a/test/property/FINDINGS.md b/test/property/FINDINGS.md new file mode 100644 index 000000000000..25eb42e067d5 --- /dev/null +++ b/test/property/FINDINGS.md @@ -0,0 +1,146 @@ +# Bugs and quirks found by the property tests + +Found against `v1.6.0-dev13151` (relassert build, macOS arm64). Ordered roughly by severity. +Each entry that the tests must work around is referenced by a `KNOWN ISSUE` comment in the test code. + +## 1. `last_day` at the maximum date raises an INTERNAL error (invalidates the database) + +```sql +SELECT last_day(DATE '5881580-07-10'); +-- INTERNAL Error: Scalar function ""last_day"" threw an execution error, +-- but the function is not marked as fallible - the function must call SetFallible(). +-- Error: Date out of range: 5881580-8-1 +``` + +`last_day` computes first-of-next-month, which is out of range in the maximum month. Because the function is +not marked fallible, the recoverable conversion error escalates to an `InternalException`, which invalidates +the whole database. Fix: mark the function fallible (or compute the result without materializing the +out-of-range date). + +## 2. `date_trunc('week', ...)` near the minimum date: signed integer overflow (UB) + +```sql +SELECT date_trunc('week', DATE '5877642-06-25 (BC)'); +SELECT date_trunc('isoyear', DATE '5877642-06-25 (BC)'); -- same overflow +-- UBSan: src/include/duckdb/common/types/date.hpp:58: signed integer overflow: -2147483646 - 3 +``` + +`date_t::operator-` does raw `int32` arithmetic; the week/isoyear truncation paths subtract the weekday offset +without a range check. Undefined behavior in release builds; aborts under `-fsanitize=undefined -fno-sanitize-recover`. + +## 3. TIMETZ offsets with zero minutes but nonzero seconds format incorrectly + +```sql +SELECT '12:00:00-05:00:59'::TIMETZ; +-- prints 12:00:00-05:59 (which denotes offset -05:59:00, a different value) +``` + +`StringCast::Operation(dtime_tz_t)` (src/common/operator/string_cast.cpp) only prints the minutes field +`if (mm)`, but prints seconds independently, so `-05:00:59` collapses to `-05:59`. Displayed values are wrong +and the text round trip yields a different value. Fix: print minutes whenever minutes or seconds are nonzero. + +## 4. `list_sort` orders INTERVALs inconsistently with comparisons and ORDER BY + +```sql +SELECT INTERVAL '31 days' > INTERVAL '1 month'; -- true +SELECT i FROM (VALUES (INTERVAL '31 days'), (INTERVAL '1 month')) t(i) ORDER BY i; +-- 1 month, 31 days +SELECT list_sort([INTERVAL '31 days', INTERVAL '1 month']); +-- [31 days, 1 month] <- disagrees with both +``` + +`create_sort_key` encodes intervals by raw `(months, days, micros)` without the normalization +(1 month = 30 days, 1 day = 24 h) that comparisons, ORDER BY, aggregates and window functions apply. +Affects `list_sort`, `list_reverse_sort`, `list_grade_up`, `list_distinct` and any other `create_sort_key` consumer. + +## 5. `Value::ToSQLString()` produces unparseable/wrong SQL for several cases + +- STRUCT keys are not escaped: `Value::STRUCT({{"k'", ...}})` renders as `{'k'': ...}` → parser error. + (src/common/types/value.cpp, `ret += "'" + name + "': "`) +- BIT values are rendered bare: `Value::BIT("10")` renders as `10`, which re-parses as INTEGER and casts to a + 32-bit bit string. Should be `'10'::BIT`. +- UNION values render as `union_value(tag := x)`, which loses the other members; + `SELECT [union_value(a := 1), union_value(b := 'x')]` then fails to bind. + +## 6. INTERVAL text output cannot always be parsed back (hours ≥ 10 digits) + +```sql +SELECT to_microseconds(9223372036854775807)::VARCHAR; -- '2562047788:00:54.775807' +SELECT '2562047788:00:54.775807'::INTERVAL; -- Conversion Error +``` + +`Interval::ToString` emits up to 10-digit hour counts, but `Time::TryConvertInternal` +(src/common/types/time.cpp, "Allow up to 9 digit hours") rejects more than 9 digits. + +## 7. `list_contains` and `list_position` disagree on NULL semantics + +```sql +SELECT list_contains([false, true, NULL], NULL); -- NULL +SELECT list_position([false, true, NULL], NULL); -- 3 +``` + +`list_position` treats a NULL needle as matchable (IS NOT DISTINCT semantics), `list_contains` propagates NULL. + +## 8. Week-based date parts fail on the minimum year + +```sql +SELECT weekofyear(DATE '5877642-06-25 (BC)'); +-- Conversion Error: Date out of range: -5877641-1-1 +``` + +`weekofyear`/`isoyear`/`yearweek` (and `date_trunc('year'/'quarter'/'month')`) materialize Jan 1 of the year +as a `date_t`, which is out of range for dates in the minimum year even though the requested result is +representable. + +## 9. Sort binds `decode_sort_key` by round-tripping types through SQL strings + +`Sort::Sort` passes column types as strings that `DecodeSortKeyBind` re-parses with `Parser::ParseColumnList` +(src/function/scalar/create_sort_key.cpp). Types whose string form is not parseable break sorting; e.g. an +API-created ENUM with a value containing a NUL byte (possible via Arrow dictionaries): + +``` +list_sort() +-- Invalid Input Error: Value "ENUM('a', chr(0), 'b')" can not be converted to a DuckDB Type +``` + +## 10. JSON → DECIMAL loses precision (goes through a double) + +```sql +SELECT '883406386745030.3'::JSON::DECIMAL(16,1); -- 883406386745030.2 +``` + +JSON numbers with a fractional part are parsed as doubles before conversion to DECIMAL; parsing the raw JSON +number text directly into DECIMAL would be exact. + +## 11. `INT_MIN % -1` errors instead of returning 0 + +```sql +SELECT (-32768)::SMALLINT % (-1)::SMALLINT; +-- Out of Range Error: Overflow in division of -32768 / -1 +``` + +The mathematical result (0) is representable (PostgreSQL returns 0); the error message also says "division" +for a modulo. + +## 12. `levenshtein`/`damerau_levenshtein`/`hamming` operate on bytes, not characters + +```sql +SELECT levenshtein('中', 'a'); -- 3 (bytes), documentation says "single-character edits" +``` + +Multi-byte UTF-8 characters count once per byte. (Also: `damerau_levenshtein` implements the unrestricted +distance, not the more common optimal-string-alignment variant, and `hamming` rejects empty strings.) + +--- + +### Notable non-bugs the tests must account for (DuckDB semantics) + +- the nested-value text format is lossy by design (strings inside lists/structs are not quoted), so only + "safe" strings round trip through `CAST(v AS VARCHAR)` inside nested types; +- `VARCHAR → UNION` casts only try members that VARCHAR *implicitly* casts to; +- `substring` counts negative starts from the end and interprets negative lengths as a window before the start; +- prepared-statement parameters may bind to wider types than the passed value (`-$1` with a SMALLINT parameter + binds INTEGER negation); +- `epoch_ms` rounds half away from zero rather than flooring; +- `millisecond()`/`microsecond()` include the seconds component (PostgreSQL semantics); +- HUGEINT and UHUGEINT literals in one list unify to DOUBLE (documented promotion rule). diff --git a/test/property/README.md b/test/property/README.md new file mode 100644 index 000000000000..43726d4d7ba1 --- /dev/null +++ b/test/property/README.md @@ -0,0 +1,92 @@ +# Property-based tests (RapidCheck) + +Property-based tests for DuckDB using [RapidCheck](https://github.com/emil-e/rapidcheck), in the spirit of +Rust's `proptest` (compare [apache/arrow-rs#10352](https://github.com/apache/arrow-rs/pull/10352)). + +Instead of fixed inputs, each test states a *property* ("any value must survive a cast to VARCHAR and back", +"`LIKE` must agree with a 20-line reference matcher", "integer arithmetic must error exactly when the `__int128` +result is out of range") and RapidCheck runs it against hundreds of randomly generated inputs, shrinking failures +to minimal counterexamples. + +## Building + +This is a standalone CMake project that links against an existing DuckDB build directory, so iterating on tests +does not require rebuilding DuckDB: + +```bash +# build DuckDB with assertions + sanitizers (recommended for bug hunting) +GEN=ninja CORE_EXTENSIONS='json' make relassert + +# configure + build the property tests +cmake -S test/property -B build/property -G Ninja -DDUCKDB_BUILD_DIR=$PWD/build/relassert +cmake --build build/property +``` + +RapidCheck is fetched via CMake `FetchContent` by default; pass `-DRAPIDCHECK_SOURCE_DIR=/path/to/rapidcheck` +to use a local checkout. When linking a sanitized DuckDB build the tests are built with +`-fsanitize=address,undefined` as well (`-DPROPERTY_TEST_SANITIZE=OFF` to disable). + +## Running + +The binary is a regular Catch2 test runner; RapidCheck is configured through the `RC_PARAMS` environment variable: + +```bash +# run everything (100 cases per property by default) +build/property/property_test "[property]" + +# more iterations, bounded value sizes +RC_PARAMS="max_success=1000 max_size=50" build/property/property_test "[property]" + +# one suite +build/property/property_test "[strings]" + +# reproduce a failure deterministically +RC_PARAMS="seed=12345" build/property/property_test "VARCHAR cast round trip" + +# disable shrinking (useful for a fast first survey; shrinking large nested values can be slow) +RC_PARAMS="max_success=1000 noshrink=1" build/property/property_test "[property]" +``` + +Set `PROPERTY_TEST_TMP` to control where `[storage]` writes its temporary database files (default `/tmp`). + +## Layout + +| file | contents | +|---|---| +| `include/property_test.hpp` | `PropDB` (in-memory/persistent connection helpers), value comparison, assertion macros, generator declarations | +| `generators.cpp` | random `LogicalType`s (incl. nested STRUCT/LIST/ARRAY/MAP/UNION/ENUM) and random `Value`s for any type, plus UTF-8 string, numeric and temporal generators with adversarial special values | +| `test_roundtrip.cpp` | value → VARCHAR/SQL-literal/JSON/table → value round trips | +| `test_strings.cpp` | LIKE/ILIKE vs a reference matcher; substring/split/pad/trim/translate/distance/encoding functions vs oracles | +| `test_lists.cpp` | list_sort/contains/position/distinct/slice/resize/concat/aggregates, range/generate_series vs closed forms | +| `test_arithmetic.cpp` | integer/HUGEINT/DECIMAL arithmetic vs `__int128` oracles (overflow must error exactly when out of range) | +| `test_datetime.cpp` | date parts vs independent civil-calendar algorithms, epoch round trips, strftime/strptime | +| `test_storage.cpp` | persistent DB round trip across `force_compression` settings, incl. update/delete + checkpoint + reopen | + +## Known issues found by these tests + +Failures caused by already-identified DuckDB bugs are skipped with `RC_PRE(...)` guards marked `KNOWN ISSUE`, +so the properties keep hunting for new bugs. See `FINDINGS.md` for the list of bugs these tests have found, +with minimal reproductions. + +## Writing a new property + +```cpp +TEST_CASE("my property", "[property][mytag]") { + PropDB db; + rc::prop("what must hold", [&] { + auto type = *GenType(2); // random type, nested up to depth 2 + auto v = *GenValue(type, 0.1); // random value, 10% NULLs + auto out = db.Scalar("SELECT ...", {v}); + PROP_ASSERT_VALUES_EQUAL(out, v); + }); +} +``` + +Gotchas: +- `RC_PRE`/`RC_ASSERT` use expression decomposition, which silences `||`/`&&` short-circuiting — + compute the condition into a `bool` first. +- prefer an *independent oracle* (a small reference implementation) over comparing DuckDB with itself; + where that is impossible, comparing two DuckDB code paths (constant vs parameterized, stored vs computed) + still finds inconsistencies. +- `duckdb::vector` (pulled in by `using namespace duckdb`) bounds-checks `back()`/`operator[]` and throws + `InternalException` — do not index blindly in test code. diff --git a/test/property/generators.cpp b/test/property/generators.cpp new file mode 100644 index 000000000000..097ed7dc0633 --- /dev/null +++ b/test/property/generators.cpp @@ -0,0 +1,730 @@ +#include "property_test.hpp" + +#include "duckdb/common/types/uuid.hpp" +#include "duckdb/common/types/bit.hpp" + +#include + +namespace duckdb_prop { + +//===--------------------------------------------------------------------===// +// Strings +//===--------------------------------------------------------------------===// +string FromCodepoints(const vector &cps) { + string result; + for (auto cp : cps) { + if (cp < 0x80) { + result.push_back(char(cp)); + } else if (cp < 0x800) { + result.push_back(char(0xC0 | (cp >> 6))); + result.push_back(char(0x80 | (cp & 0x3F))); + } else if (cp < 0x10000) { + result.push_back(char(0xE0 | (cp >> 12))); + result.push_back(char(0x80 | ((cp >> 6) & 0x3F))); + result.push_back(char(0x80 | (cp & 0x3F))); + } else { + result.push_back(char(0xF0 | (cp >> 18))); + result.push_back(char(0x80 | ((cp >> 12) & 0x3F))); + result.push_back(char(0x80 | ((cp >> 6) & 0x3F))); + result.push_back(char(0x80 | (cp & 0x3F))); + } + } + return result; +} + +vector ToCodepoints(const string &s) { + vector result; + idx_t i = 0; + while (i < s.size()) { + auto c = uint8_t(s[i]); + uint32_t cp; + idx_t len; + if (c < 0x80) { + cp = c; + len = 1; + } else if ((c >> 5) == 0x6) { + cp = c & 0x1F; + len = 2; + } else if ((c >> 4) == 0xE) { + cp = c & 0x0F; + len = 3; + } else { + cp = c & 0x07; + len = 4; + } + for (idx_t j = 1; j < len; j++) { + cp = (cp << 6) | (uint8_t(s[i + j]) & 0x3F); + } + result.push_back(cp); + i += len; + } + return result; +} + +static rc::Gen GenCodepoint() { + return rc::gen::weightedOneOf({ + {12, rc::gen::inRange(0x61, 0x7B)}, // a-z + {8, rc::gen::inRange(0x20, 0x7F)}, // printable ASCII + {6, Elements({'\'', '"', '\\', ',', '{', '}', '[', ']', '(', ')', ':', ';', '=', '>', '<', '%', + '_', ' ', '.', '-', '+', '0', '1', '9', 'e', 'E', 'N', 'U', 'L', '\t', '\n', '\r'})}, + {1, rc::gen::inRange(0x01, 0x20)}, // control chars + {1, rc::gen::just(0)}, // NUL + {2, rc::gen::inRange(0x7F, 0x100)}, // DEL + latin-1 + {2, rc::gen::inRange(0x100, 0x800)}, // 2-byte + {1, rc::gen::inRange(0x300, 0x370)}, // combining diacritics + {2, rc::gen::inRange(0x800, 0xD800)}, // 3-byte (CJK etc) + {1, rc::gen::inRange(0xE000, 0x10000)}, // 3-byte private use/specials + {1, Elements({0xFEFF, 0x200D, 0xFFFD, 0x2028, 0x00A0, 0x1F600, 0x1F1E6, 0xFFFF})}, + {2, rc::gen::inRange(0x10000, 0x110000)}, // 4-byte + }); +} + +rc::Gen GenUtf8String() { + static const vector specials = {"", + "NULL", + "null", + "'", + "''", + "\"", + "\\", + "{", + "}", + "[", + "]", + ",", + " ", + "\n", + "\t", + "1", + "-0", + "0", + "true", + "false", + "inf", + "-inf", + "nan", + "1e10", + "::", + "=", + "=>", + ">", + "a, b", + "'a'", + "\\x00", + "\\x", + "[]", + "{}", + "{'a':1}", + "[1, 2]", + " x ", + "x\0y", + "NaN", + "Infinity", + "\xF0\x9F\x98\x80", + "e\xCC\x81", + "\xE2\x80\x8B", + "%", + "_", + "%_%"}; + return rc::gen::weightedOneOf( + {{1, rc::gen::elementOf(specials)}, + {6, rc::gen::map(rc::gen::container>(GenCodepoint()), FromCodepoints)}, + {2, + rc::gen::map(rc::gen::container>(rc::gen::inRange(0x20, 0x7F)), FromCodepoints)}}); +} + +rc::Gen GenAsciiString() { + return rc::gen::map(rc::gen::container>(rc::gen::inRange(0x20, 0x7F)), FromCodepoints); +} + +rc::Gen GenIdentifier() { + return rc::gen::map(rc::gen::container>(rc::gen::weightedOneOf( + {{10, rc::gen::inRange('a', 'z' + 1)}, {1, rc::gen::just('_')}})), + [](const vector &cps) { + auto s = FromCodepoints(cps); + return "c" + s; + }); +} + +rc::Gen GenBytes() { + return rc::gen::map(rc::gen::container>(rc::gen::arbitrary()), + [](const vector &bytes) { return string(bytes.begin(), bytes.end()); }); +} + +bool TypeContainsAny(const LogicalType &type, const vector &ids) { + return TypeContains(type, [&](const LogicalType &t) { + for (auto id : ids) { + if (t.id() == id) { + return true; + } + } + return false; + }); +} + +bool IsSafeString(const string &s) { + if (s.empty() || s.front() == ' ' || s.back() == ' ') { + return false; + } + for (auto c : s) { + if (!isalnum(uint8_t(c)) && c != ' ') { + return false; + } + } + auto lower = StringUtil::Lower(s); + return lower != "null"; +} + +rc::Gen GenSafeString() { + return rc::gen::suchThat(rc::gen::map(rc::gen::container>(rc::gen::weightedOneOf( + {{10, rc::gen::inRange('a', 'z' + 1)}, + {3, rc::gen::inRange('A', 'Z' + 1)}, + {3, rc::gen::inRange('0', '9' + 1)}, + {1, rc::gen::just(' ')}})), + FromCodepoints), + IsSafeString); +} + +bool TypeHasSafeEnumValues(const LogicalType &type) { + return !TypeContains(type, [](const LogicalType &t) { + if (t.id() != LogicalTypeId::ENUM) { + return false; + } + auto &values = EnumType::GetValuesInsertOrder(t); + auto size = EnumType::GetSize(t); + auto data = FlatVector::GetData(values); + for (idx_t i = 0; i < size; i++) { + if (!IsSafeString(data[i].GetString())) { + return true; + } + } + return false; + }); +} + +bool TypeHasSafeFieldNames(const LogicalType &type) { + return !TypeContains(type, [](const LogicalType &t) { + if (t.id() != LogicalTypeId::STRUCT) { + return false; + } + for (auto &child : StructType::GetChildTypes(t)) { + if (!IsSafeString(child.first.GetIdentifierName())) { + return true; + } + } + return false; + }); +} + +string Join(const vector &parts, const string &sep) { + string result; + for (idx_t i = 0; i < parts.size(); i++) { + if (i > 0) { + result += sep; + } + result += parts[i]; + } + return result; +} + +//===--------------------------------------------------------------------===// +// Numerics +//===--------------------------------------------------------------------===// +rc::Gen GenHugeint() { + return rc::gen::weightedOneOf( + {{6, rc::gen::map( + rc::gen::tuple(rc::gen::arbitrary(), rc::gen::arbitrary()), + [](const std::tuple &t) { return hugeint_t(std::get<0>(t), std::get<1>(t)); })}, + {3, rc::gen::map(GenInt(), [](int64_t v) { return hugeint_t(v); })}, + {2, rc::gen::element(NumericLimits::Minimum(), NumericLimits::Maximum(), + hugeint_t(0), hugeint_t(1), hugeint_t(-1), + hugeint_t(NumericLimits::Maximum()) + 1, + hugeint_t(NumericLimits::Minimum()) - 1)}}); +} + +rc::Gen GenUhugeint() { + return rc::gen::weightedOneOf( + {{6, rc::gen::map( + rc::gen::tuple(rc::gen::arbitrary(), rc::gen::arbitrary()), + [](const std::tuple &t) { return uhugeint_t(std::get<0>(t), std::get<1>(t)); })}, + {3, rc::gen::map(GenInt(), [](uint64_t v) { return uhugeint_t(v); })}, + {2, rc::gen::element(NumericLimits::Minimum(), NumericLimits::Maximum(), + uhugeint_t(0), uhugeint_t(1), + uhugeint_t(NumericLimits::Maximum()) + 1)}}); +} + +rc::Gen GenFiniteDouble() { + return rc::gen::weightedOneOf( + {{4, rc::gen::map(rc::gen::tuple(rc::gen::arbitrary(), rc::gen::inRange(-1074, 1024)), + [](const std::tuple &t) { + // random mantissa and exponent, covers the whole range incl. denormals + double m = double(std::get<0>(t)) / 9223372036854775808.0; // in (-1, 1) + double r = std::ldexp(m, std::get<1>(t)); + return std::isfinite(r) ? r : 0.0; + })}, + {3, rc::gen::map(rc::gen::inRange(-1000000, 1000000), [](int64_t v) { return double(v) / 1000.0; })}, + {2, rc::gen::map(GenInt(), [](int64_t v) { return double(v); })}, + {1, rc::gen::map(rc::gen::inRange(-40, 40), [](int e) { return std::pow(10.0, e); })}, + {2, rc::gen::element(0.0, -0.0, 1.0, -1.0, 0.5, 0.1, 0.3, 1e-7, 1e15, 1e16, 1e17, 1e18, 1e19, 1e20, + 1e38, 1e308, -1e308, 4.9e-324, 2.2250738585072014e-308, 1.7976931348623157e308, + 9007199254740992.0, 9007199254740993.0, 0.30000000000000004, 123456789.123456789, + 2147483647.5, 2147483648.0, -2147483648.5, 9223372036854775807.0, + -9223372036854775808.0, 18446744073709551616.0, 1.5, 2.5, -0.5, -1.5, + 0.49999999999999994)}}); +} + +rc::Gen GenDouble() { + return rc::gen::weightedOneOf( + {{9, GenFiniteDouble()}, + {1, rc::gen::element(std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN())}}); +} + +rc::Gen GenFiniteFloat() { + return rc::gen::weightedOneOf( + {{4, rc::gen::map(rc::gen::tuple(rc::gen::arbitrary(), rc::gen::inRange(-149, 128)), + [](const std::tuple &t) { + float m = float(std::get<0>(t)) / 2147483648.0f; + float r = std::ldexp(m, std::get<1>(t)); + return std::isfinite(r) ? r : 0.0f; + })}, + {3, rc::gen::map(rc::gen::inRange(-1000000, 1000000), [](int32_t v) { return float(v) / 1000.0f; })}, + {2, rc::gen::map(GenInt(), [](int32_t v) { return float(v); })}, + {2, rc::gen::element(0.0f, -0.0f, 1.0f, -1.0f, 0.5f, 0.1f, 1e-7f, 1e15f, 1e16f, 3.4028235e38f, + -3.4028235e38f, 1.4e-45f, 1.1754944e-38f, 16777216.0f, 16777217.0f, 0.3f, + 2147483648.0f, 1.5f, 2.5f, -0.5f)}}); +} + +rc::Gen GenFloat() { + return rc::gen::weightedOneOf( + {{9, GenFiniteFloat()}, + {1, rc::gen::element(std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN())}}); +} + +//===--------------------------------------------------------------------===// +// Temporal +//===--------------------------------------------------------------------===// +static int32_t MinDateDays() { + static const int32_t days = Date::FromDate(Date::DATE_MIN_YEAR, Date::DATE_MIN_MONTH, Date::DATE_MIN_DAY).days; + return days; +} +static int32_t MaxDateDays() { + static const int32_t days = Date::FromDate(Date::DATE_MAX_YEAR, Date::DATE_MAX_MONTH, Date::DATE_MAX_DAY).days; + return days; +} + +rc::Gen GenFiniteDate() { + return rc::gen::weightedOneOf( + {{4, rc::gen::map(rc::gen::inRange(MinDateDays(), MaxDateDays() + 1), + [](int32_t d) { return date_t(d); })}, + {4, rc::gen::map(rc::gen::inRange(-800000, 800000), [](int32_t d) { return date_t(d); })}, + {2, rc::gen::map(rc::gen::inRange(-1000, 30000), [](int32_t d) { return date_t(d); })}, + {1, rc::gen::element(date_t(MinDateDays()), date_t(MaxDateDays()), date_t(0), date_t(-1), date_t(1), + Date::FromDate(1, 1, 1), Date::FromDate(0, 1, 1), Date::FromDate(-1, 12, 31), + Date::FromDate(1582, 10, 15), Date::FromDate(1900, 3, 1), + Date::FromDate(2000, 2, 29), Date::FromDate(9999, 12, 31), + Date::FromDate(10000, 1, 1), Date::FromDate(-4713, 11, 24))}}); +} + +rc::Gen GenDate() { + return rc::gen::weightedOneOf( + {{15, GenFiniteDate()}, {1, rc::gen::element(date_t::infinity(), date_t::ninfinity())}}); +} + +rc::Gen GenTime() { + return rc::gen::weightedOneOf( + {{6, + rc::gen::map(rc::gen::inRange(0, Interval::MICROS_PER_DAY), [](int64_t v) { return dtime_t(v); })}, + {2, rc::gen::map(rc::gen::inRange(0, 86400), [](int64_t v) { return dtime_t(v * 1000000); })}, + {1, rc::gen::element(dtime_t(0), dtime_t(1), dtime_t(Interval::MICROS_PER_DAY - 1), + dtime_t(Interval::MICROS_PER_DAY), dtime_t(12 * Interval::MICROS_PER_HOUR))}}); +} + +//! Minimum finite TIMESTAMP in micros (290309-12-22 (BC) 00:00:00); the int64 range below it cannot be +//! converted to a date and is rejected by all SQL-level constructors +static const int64_t MIN_TS_MICROS = -9223372022400000000LL; + +rc::Gen GenFiniteTimestamp() { + return rc::gen::weightedOneOf( + {{4, rc::gen::map(rc::gen::inRange(MIN_TS_MICROS, NumericLimits::Maximum()), + [](int64_t v) { return timestamp_t(v); })}, + {4, rc::gen::map(rc::gen::inRange(-100000000000000000LL, 100000000000000000LL), + [](int64_t v) { return timestamp_t(v); })}, + {2, rc::gen::map(rc::gen::inRange(-2000000000, 2000000000), + [](int64_t v) { return timestamp_t(v * 1000000); })}, + {1, rc::gen::element(timestamp_t(0), timestamp_t(1), timestamp_t(-1), timestamp_t(MIN_TS_MICROS), + timestamp_t(MIN_TS_MICROS + 1), + timestamp_t(NumericLimits::Maximum() - 1), + timestamp_t(-62135596800000000LL), // 0001-01-01 + timestamp_t(-62167219200000000LL), // 0000-01-01 + timestamp_t(253402300799999999LL))}}); // 9999-12-31 23:59:59.999999 +} + +rc::Gen GenTimestamp() { + return rc::gen::weightedOneOf( + {{15, GenFiniteTimestamp()}, + {1, rc::gen::element(timestamp_t::infinity(), timestamp_t::ninfinity())}}); +} + +rc::Gen GenInterval() { + return rc::gen::map( + rc::gen::tuple( + rc::gen::weightedOneOf( + {{3, rc::gen::inRange(-1000, 1000)}, {2, GenInt()}, {2, rc::gen::just(0)}}), + rc::gen::weightedOneOf( + {{3, rc::gen::inRange(-1000, 1000)}, {2, GenInt()}, {2, rc::gen::just(0)}}), + rc::gen::weightedOneOf( + {{3, rc::gen::inRange(-Interval::MICROS_PER_DAY * 2, Interval::MICROS_PER_DAY * 2)}, + {2, GenInt()}, + {2, rc::gen::just(0)}})), + [](const std::tuple &t) { + interval_t result; + result.months = std::get<0>(t); + result.days = std::get<1>(t); + result.micros = std::get<2>(t); + return result; + }); +} + +//===--------------------------------------------------------------------===// +// Types +//===--------------------------------------------------------------------===// +rc::Gen GenIntegerType() { + return rc::gen::element(LogicalType::TINYINT, LogicalType::SMALLINT, LogicalType::INTEGER, + LogicalType::BIGINT, LogicalType::UTINYINT, LogicalType::USMALLINT, + LogicalType::UINTEGER, LogicalType::UBIGINT, LogicalType::HUGEINT, + LogicalType::UHUGEINT); +} + +rc::Gen GenDecimalType() { + return rc::gen::exec([] { + auto width = *rc::gen::weightedOneOf( + {{6, rc::gen::inRange(1, 39)}, {2, rc::gen::element(1, 4, 5, 9, 10, 18, 19, 38)}}); + auto scale = *rc::gen::inRange(0, width + 1); + return LogicalType::DECIMAL(uint8_t(width), uint8_t(scale)); + }); +} + +rc::Gen GenNumericType() { + return rc::gen::weightedOneOf( + {{5, GenIntegerType()}, + {2, rc::gen::element(LogicalType::FLOAT, LogicalType::DOUBLE)}, + {2, GenDecimalType()}}); +} + +static rc::Gen GenEnumType() { + // NUL bytes excluded: an enum value containing chr(0) breaks the internal sort-key type round trip (FINDINGS.md #9) + auto value_gen = rc::gen::suchThat(rc::gen::weightedOneOf({{6, GenIdentifier()}, {2, GenUtf8String()}}), + [](const string &s) { return s.find('\0') == string::npos; }); + return rc::gen::map(rc::gen::nonEmpty(rc::gen::unique>(value_gen)), + [](const vector &values) { + Vector vec(LogicalType::VARCHAR, values.size()); + auto data = FlatVector::GetDataMutable(vec); + for (idx_t i = 0; i < values.size(); i++) { + data[i] = StringVector::AddString(vec, string_t(values[i])); + } + return LogicalType::ENUM(vec, values.size()); + }); +} + +rc::Gen GenScalarType() { + return rc::gen::weightedOneOf( + {{10, GenNumericType()}, + {3, rc::gen::just(LogicalType(LogicalType::VARCHAR))}, + {6, rc::gen::element(LogicalType::BOOLEAN, LogicalType::BLOB, LogicalType::BIT, LogicalType::UUID, + LogicalType::DATE, LogicalType::TIME, LogicalType::TIME_TZ, + LogicalType::TIME_NS, LogicalType::TIMESTAMP, LogicalType::TIMESTAMP_S, + LogicalType::TIMESTAMP_MS, LogicalType::TIMESTAMP_NS, + LogicalType::TIMESTAMP_TZ, LogicalType::INTERVAL)}, + {1, GenEnumType()}}); +} + +rc::Gen GenSortableType(int max_depth) { + if (max_depth <= 0) { + return GenScalarType(); + } + return rc::gen::weightedOneOf( + {{6, GenScalarType()}, + {1, rc::gen::map(GenSortableType(max_depth - 1), + [](const LogicalType &child) { return LogicalType::LIST(child); })}, + {1, rc::gen::exec([max_depth] { + auto n = *rc::gen::inRange(1, 4); + child_list_t children; + for (int i = 0; i < n; i++) { + children.emplace_back("f" + std::to_string(i), *GenSortableType(max_depth - 1)); + } + return LogicalType::STRUCT(std::move(children)); + })}}); +} + +static rc::Gen GenFieldName() { + return rc::gen::weightedOneOf( + {{8, GenIdentifier()}, + {1, rc::gen::element("a b", "A", "x\"y", "k'", "NULL", "select", "é", "a,b", "a:b", "{a}", "a b c", + "1", "__", "a\\b")}, + {1, rc::gen::nonEmpty(GenUtf8String())}}); +} + +rc::Gen GenType(int max_depth) { + if (max_depth <= 0) { + return GenScalarType(); + } + return rc::gen::weightedOneOf( + {{8, GenScalarType()}, + {2, rc::gen::map(GenType(max_depth - 1), [](const LogicalType &child) { return LogicalType::LIST(child); })}, + {1, rc::gen::exec([max_depth] { + auto child = *GenType(max_depth - 1); + auto size = *rc::gen::inRange(1, 5); + return LogicalType::ARRAY(child, optional_idx(size)); + })}, + {2, rc::gen::exec([max_depth] { + // struct field names are case-insensitive + auto names = *rc::gen::nonEmpty(rc::gen::uniqueBy>(GenFieldName(), StringUtil::Lower)); + child_list_t children; + for (auto &name : names) { + children.emplace_back(name, *GenType(max_depth - 1)); + } + return LogicalType::STRUCT(std::move(children)); + })}, + {1, rc::gen::exec([max_depth] { + auto key = *GenType(max_depth - 1); + auto value = *GenType(max_depth - 1); + return LogicalType::MAP(key, value); + })}, + {1, rc::gen::exec([max_depth] { + auto names = *rc::gen::nonEmpty(rc::gen::uniqueBy>(GenIdentifier(), StringUtil::Lower)); + if (names.size() > 8) { + names.resize(8); + } + child_list_t members; + for (auto &name : names) { + members.emplace_back(name, *GenType(max_depth - 1)); + } + return LogicalType::UNION(std::move(members)); + })}}); +} + +//===--------------------------------------------------------------------===// +// Values +//===--------------------------------------------------------------------===// +static Value DecimalValue(hugeint_t v, uint8_t width, uint8_t scale) { + if (width <= Decimal::MAX_WIDTH_INT16) { + return Value::DECIMAL(int16_t(v.lower), width, scale); + } else if (width <= Decimal::MAX_WIDTH_INT32) { + return Value::DECIMAL(int32_t(v.lower), width, scale); + } else if (width <= Decimal::MAX_WIDTH_INT64) { + return Value::DECIMAL(int64_t(v.lower), width, scale); + } else { + return Value::DECIMAL(v, width, scale); + } +} + +static hugeint_t PowerOfTen(int n) { + hugeint_t result(1); + for (int i = 0; i < n; i++) { + result *= hugeint_t(10); + } + return result; +} + +static rc::Gen GenDecimalValue(const LogicalType &type) { + auto width = DecimalType::GetWidth(type); + auto scale = DecimalType::GetScale(type); + auto limit = PowerOfTen(width); // exclusive + return rc::gen::exec([width, scale, limit] { + auto kind = *rc::gen::inRange(0, 10); + hugeint_t v; + if (kind < 5) { + // uniform over the full range + auto h = *GenHugeint(); + v = h % limit; + } else if (kind < 8) { + // small values + v = hugeint_t(*rc::gen::inRange(-100000, 100000)) % limit; + } else { + // extremes + v = *rc::gen::element(limit - 1, -(limit - 1), hugeint_t(0), hugeint_t(1), hugeint_t(-1), + limit / 2, -(limit / 2)); + } + return DecimalValue(v, width, scale); + }); +} + +rc::Gen GenNonNullValue(const LogicalType &type, const GenOptions &options) { + switch (type.id()) { + case LogicalTypeId::BOOLEAN: + return rc::gen::map(rc::gen::arbitrary(), [](bool b) { return Value::BOOLEAN(b); }); + case LogicalTypeId::TINYINT: + return rc::gen::map(GenInt(), [](int8_t v) { return Value::TINYINT(v); }); + case LogicalTypeId::SMALLINT: + return rc::gen::map(GenInt(), [](int16_t v) { return Value::SMALLINT(v); }); + case LogicalTypeId::INTEGER: + return rc::gen::map(GenInt(), [](int32_t v) { return Value::INTEGER(v); }); + case LogicalTypeId::BIGINT: + return rc::gen::map(GenInt(), [](int64_t v) { return Value::BIGINT(v); }); + case LogicalTypeId::UTINYINT: + return rc::gen::map(GenInt(), [](uint8_t v) { return Value::UTINYINT(v); }); + case LogicalTypeId::USMALLINT: + return rc::gen::map(GenInt(), [](uint16_t v) { return Value::USMALLINT(v); }); + case LogicalTypeId::UINTEGER: + return rc::gen::map(GenInt(), [](uint32_t v) { return Value::UINTEGER(v); }); + case LogicalTypeId::UBIGINT: + return rc::gen::map(GenInt(), [](uint64_t v) { return Value::UBIGINT(v); }); + case LogicalTypeId::HUGEINT: + return rc::gen::map(GenHugeint(), [](hugeint_t v) { return Value::HUGEINT(v); }); + case LogicalTypeId::UHUGEINT: + return rc::gen::map(GenUhugeint(), [](uhugeint_t v) { return Value::UHUGEINT(v); }); + case LogicalTypeId::FLOAT: + return rc::gen::map(GenFloat(), [](float v) { return Value::FLOAT(v); }); + case LogicalTypeId::DOUBLE: + return rc::gen::map(GenDouble(), [](double v) { return Value::DOUBLE(v); }); + case LogicalTypeId::DECIMAL: + return GenDecimalValue(type); + case LogicalTypeId::VARCHAR: + return rc::gen::map(options.safe_strings ? GenSafeString() : GenUtf8String(), + [](const string &s) { return Value(s); }); + case LogicalTypeId::BLOB: + return rc::gen::map(options.safe_strings ? GenSafeString() : GenBytes(), + [](const string &s) { return Value::BLOB(const_data_ptr_cast(s.data()), s.size()); }); + case LogicalTypeId::BIT: + return rc::gen::map(rc::gen::nonEmpty(rc::gen::container(rc::gen::element('0', '1'))), + [](const string &s) { return Value::BIT(s); }); + case LogicalTypeId::UUID: + return rc::gen::map(GenHugeint(), [](hugeint_t v) { return Value::UUID(v); }); + case LogicalTypeId::DATE: + return rc::gen::map(GenDate(), [](date_t d) { return Value::DATE(d); }); + case LogicalTypeId::TIME: + return rc::gen::map(GenTime(), [](dtime_t t) { return Value::TIME(t); }); + case LogicalTypeId::TIME_NS: + return rc::gen::map( + rc::gen::weightedOneOf( + {{6, rc::gen::inRange(0, Interval::NANOS_PER_DAY)}, + {1, rc::gen::element(0, 1, Interval::NANOS_PER_DAY - 1, Interval::NANOS_PER_DAY)}}), + [](int64_t v) { return Value::TIME_NS(dtime_ns_t(v)); }); + case LogicalTypeId::TIME_TZ: + return rc::gen::map( + rc::gen::tuple( + GenTime(), + rc::gen::weightedOneOf( + {{4, rc::gen::inRange(dtime_tz_t::MIN_OFFSET, dtime_tz_t::MAX_OFFSET + 1)}, + {2, rc::gen::map(rc::gen::inRange(-15, 16), [](int32_t h) { return h * 3600; })}, + {1, rc::gen::just(0)}})), + [](const std::tuple &t) { + return Value::TIMETZ(dtime_tz_t(std::get<0>(t), std::get<1>(t))); + }); + case LogicalTypeId::TIMESTAMP: + return rc::gen::map(GenTimestamp(), [](timestamp_t t) { return Value::TIMESTAMP(t); }); + case LogicalTypeId::TIMESTAMP_TZ: + return rc::gen::map(GenTimestamp(), [](timestamp_t t) { return Value::TIMESTAMPTZ(timestamp_tz_t(t.value)); }); + case LogicalTypeId::TIMESTAMP_SEC: + return rc::gen::map( + rc::gen::weightedOneOf( + {{6, rc::gen::inRange(MIN_TS_MICROS / 1000000, NumericLimits::Maximum() / 1000000)}, + {3, rc::gen::inRange(-100000000000LL, 100000000000LL)}, + {1, rc::gen::element(0, 1, -1)}}), + [](int64_t v) { return Value::TIMESTAMPSEC(timestamp_sec_t(v)); }); + case LogicalTypeId::TIMESTAMP_MS: + return rc::gen::map( + rc::gen::weightedOneOf( + {{6, rc::gen::inRange(MIN_TS_MICROS / 1000, NumericLimits::Maximum() / 1000)}, + {3, rc::gen::inRange(-100000000000000LL, 100000000000000LL)}, + {1, rc::gen::element(0, 1, -1)}}), + [](int64_t v) { return Value::TIMESTAMPMS(timestamp_ms_t(v)); }); + case LogicalTypeId::TIMESTAMP_NS: + return rc::gen::map(rc::gen::weightedOneOf( + {{6, rc::gen::inRange(NumericLimits::Minimum() + 1, + NumericLimits::Maximum())}, + {3, rc::gen::inRange(-100000000000000000LL, 100000000000000000LL)}, + {1, rc::gen::element(0, 1, -1, NumericLimits::Maximum() - 1)}}), + [](int64_t v) { return Value::TIMESTAMPNS(timestamp_ns_t(v)); }); + case LogicalTypeId::INTERVAL: + return rc::gen::map(GenInterval(), [](interval_t i) { return Value::INTERVAL(i); }); + case LogicalTypeId::ENUM: { + auto size = EnumType::GetSize(type); + return rc::gen::map(rc::gen::inRange(0, size), + [type](uint64_t idx) { return Value::ENUM(idx, type); }); + } + case LogicalTypeId::LIST: { + auto child = ListType::GetChildType(type); + return rc::gen::map(GenValues(child, options), + [child](const vector &values) { return Value::LIST(child, values); }); + } + case LogicalTypeId::ARRAY: { + auto child = ArrayType::GetChildType(type); + auto size = ArrayType::GetSize(type); + return rc::gen::map(rc::gen::container>(size, GenValue(child, options)), + [child](const vector &values) { return Value::ARRAY(child, values); }); + } + case LogicalTypeId::STRUCT: { + auto &children = StructType::GetChildTypes(type); + return rc::gen::exec([type, children, options] { + vector values; + for (auto &child : children) { + values.push_back(*GenValue(child.second, options)); + } + return Value::STRUCT(type, std::move(values)); + }); + } + case LogicalTypeId::MAP: { + auto key_type = MapType::KeyType(type); + auto value_type = MapType::ValueType(type); + return rc::gen::exec([key_type, value_type, options] { + GenOptions key_options = options; + key_options.null_probability = 0.0; + auto raw_keys = *GenValues(key_type, key_options); + vector keys; + vector values; + for (auto &key : raw_keys) { + bool duplicate = false; + for (auto &existing : keys) { + if (Value::NotDistinctFrom(existing, key)) { + duplicate = true; + break; + } + } + if (duplicate) { + continue; + } + keys.push_back(key); + values.push_back(*GenValue(value_type, options)); + } + return Value::MAP(key_type, value_type, std::move(keys), std::move(values)); + }); + } + case LogicalTypeId::UNION: { + auto members = UnionType::CopyMemberTypes(type); + return rc::gen::exec([members, options] { + auto tag = *rc::gen::inRange(0, members.size()); + auto value = *GenValue(members[tag].second, options); + return Value::UNION(members, uint8_t(tag), std::move(value)); + }); + } + default: + throw InternalException("GenNonNullValue: unsupported type " + type.ToString()); + } +} + +rc::Gen GenValue(const LogicalType &type, const GenOptions &options) { + if (options.null_probability <= 0.0) { + return GenNonNullValue(type, options); + } + auto null_weight = std::size_t(options.null_probability * 100); + auto value_weight = std::size_t(100 - null_weight); + return rc::gen::weightedOneOf( + {{null_weight, rc::gen::just(Value(type))}, {value_weight, GenNonNullValue(type, options)}}); +} + +rc::Gen GenValue(const LogicalType &type, double null_probability) { + return GenValue(type, GenOptions(null_probability)); +} + +rc::Gen> GenValues(const LogicalType &type, const GenOptions &options) { + return rc::gen::container>(GenValue(type, options)); +} + +rc::Gen> GenValues(const LogicalType &type, double null_probability) { + return GenValues(type, GenOptions(null_probability)); +} + +} // namespace duckdb_prop diff --git a/test/property/include/property_test.hpp b/test/property/include/property_test.hpp new file mode 100644 index 000000000000..65bd7c119aea --- /dev/null +++ b/test/property/include/property_test.hpp @@ -0,0 +1,290 @@ +//===----------------------------------------------------------------------===// +// DuckDB +// +// test/property/include/property_test.hpp +// +// Shared helpers for RapidCheck-based property tests. +//===----------------------------------------------------------------------===// + +#pragma once + +#include "catch.hpp" +#include "duckdb.hpp" +#include "duckdb/common/types/date.hpp" +#include "duckdb/common/types/decimal.hpp" +#include "duckdb/common/types/hugeint.hpp" +#include "duckdb/common/types/interval.hpp" +#include "duckdb/common/types/time.hpp" +#include "duckdb/common/types/timestamp.hpp" +#include "duckdb/common/types/uhugeint.hpp" +#include "duckdb/common/types/value.hpp" +#include "duckdb/common/types/vector.hpp" +#include "duckdb/main/prepared_statement.hpp" + +#include +#include + +#include +#include + +namespace duckdb_prop { +using namespace duckdb; + +//===--------------------------------------------------------------------===// +// Database helpers +//===--------------------------------------------------------------------===// +struct PropDB { + DuckDB db; + Connection con; + + explicit PropDB(const string &path = "") : db(path.empty() ? nullptr : path.c_str()), con(db) { + } + + //! Run a query, return the (possibly errored) result + unique_ptr Query(const string &sql) { + return con.Query(sql); + } + //! Run a prepared query with positional parameters ($1, $2, ...) + unique_ptr Query(const string &sql, vector params) { + auto prepared = con.Prepare(sql); + if (prepared->HasError()) { + auto res = make_uniq(prepared->GetErrorObject()); + return std::move(res); + } + return prepared->Execute(params, false); + } + //! Run a statement that must succeed (fails the property otherwise) + void Exec(const string &sql) { + auto res = con.Query(sql); + if (res->HasError()) { + RC_FAIL("Query failed: " + sql + "\n" + res->GetError()); + } + } + //! Run a query that must succeed and return exactly one value + Value Scalar(const string &sql) { + auto res = con.Query(sql); + if (res->HasError()) { + RC_FAIL("Query failed: " + sql + "\n" + res->GetError()); + } + if (res->RowCount() != 1 || res->ColumnCount() != 1) { + RC_FAIL("Query did not return a single value: " + sql); + } + return res->GetValue(0, 0); + } + Value Scalar(const string &sql, vector params) { + auto res = Query(sql, std::move(params)); + if (res->HasError()) { + RC_FAIL("Query failed: " + sql + "\n" + res->GetError()); + } + auto &mat = res->Cast(); + if (mat.RowCount() != 1 || mat.ColumnCount() != 1) { + RC_FAIL("Query did not return a single value: " + sql); + } + return mat.GetValue(0, 0); + } +}; + +//! Describe a value for failure messages +inline string Describe(const Value &v) { + string result = v.type().ToString() + ": "; + if (v.IsNull()) { + return result + "NULL"; + } + result += v.ToString(); + result += " (sql: " + v.ToSQLString() + ")"; + return result; +} + +//! Value equality treating NULL == NULL and NaN == NaN, also requiring identical types +inline bool ValuesEqual(const Value &a, const Value &b) { + if (a.type() != b.type()) { + return false; + } + return Value::NotDistinctFrom(a, b); +} + +//! Value equality ignoring type differences (e.g. INTEGER 1 vs BIGINT 1) +inline bool ValuesEqualLoose(const Value &a, const Value &b) { + return Value::NotDistinctFrom(a, b); +} + +//! Fail the property unless a == b, printing both values +#define PROP_ASSERT_VALUES_EQUAL(a, b) \ + do { \ + const duckdb::Value &pa_ = (a); \ + const duckdb::Value &pb_ = (b); \ + if (!duckdb_prop::ValuesEqual(pa_, pb_)) { \ + RC_FAIL(std::string("Values differ:\n actual: ") + duckdb_prop::Describe(pa_) + \ + "\n expected: " + duckdb_prop::Describe(pb_)); \ + } \ + } while (0) + +//! Fail the property with the error of a result +#define PROP_REQUIRE_NO_ERROR(result, context) \ + do { \ + if ((result)->HasError()) { \ + RC_FAIL(std::string("Query failed (") + (context) + "): " + (result)->GetError()); \ + } \ + } while (0) + +//===--------------------------------------------------------------------===// +// Generators +//===--------------------------------------------------------------------===// +//! Valid UTF-8 string with a mix of ASCII, punctuation, control chars, multi-byte code points and special strings +rc::Gen GenUtf8String(); +//! Printable ASCII string (0x20-0x7e) +rc::Gen GenAsciiString(); +//! Lower-case identifier [a-z][a-z0-9_]* +rc::Gen GenIdentifier(); +//! Random bytes +rc::Gen GenBytes(); + +//! Pick one of the given elements (avoids narrowing issues of rc::gen::element) +template +rc::Gen Elements(std::vector elements) { + return rc::gen::elementOf(std::move(elements)); +} + +//! Integer generators with extremes mixed in +template +rc::Gen GenInt() { + return rc::gen::weightedOneOf( + {{8, rc::gen::arbitrary()}, + {2, rc::gen::element(NumericLimits::Minimum(), NumericLimits::Maximum(), T(0), T(1), T(T(0) - T(1)))}, + {2, rc::gen::cast(rc::gen::inRange(-100, 100))}}); +} +rc::Gen GenHugeint(); +rc::Gen GenUhugeint(); +rc::Gen GenDouble(); +rc::Gen GenFloat(); +//! Finite double/float only +rc::Gen GenFiniteDouble(); +rc::Gen GenFiniteFloat(); +rc::Gen GenDate(); +rc::Gen GenFiniteDate(); +rc::Gen GenTime(); +rc::Gen GenTimestamp(); +rc::Gen GenFiniteTimestamp(); +rc::Gen GenInterval(); + +//! Random scalar logical type (no nested types) +rc::Gen GenScalarType(); +//! Random logical type, including nested types up to max_depth +rc::Gen GenType(int max_depth = 2); +//! Random integer (signed/unsigned, incl HUGEINT/UHUGEINT) type +rc::Gen GenIntegerType(); +//! Random numeric type (integers, floats, decimals) +rc::Gen GenNumericType(); +//! Random DECIMAL type +rc::Gen GenDecimalType(); +//! Types that can be compared/sorted and hashed +rc::Gen GenSortableType(int max_depth = 1); + +struct GenOptions { + //! Probability of NULL at every nesting level + double null_probability = 0.1; + //! Only generate "safe" strings (alphanumeric, no NULL-like words) - for properties that go through the lossy + //! nested ToString format where strings are not quoted + bool safe_strings = false; + + GenOptions() { + } + explicit GenOptions(double null_probability_p) : null_probability(null_probability_p) { + } +}; + +//! Random non-NULL value of the given type +rc::Gen GenNonNullValue(const LogicalType &type, const GenOptions &options = GenOptions()); +//! Random value of the given type, NULL with the given probability (also applies to nested children) +rc::Gen GenValue(const LogicalType &type, double null_probability = 0.1); +rc::Gen GenValue(const LogicalType &type, const GenOptions &options); +//! Random list of values of the given type +rc::Gen> GenValues(const LogicalType &type, double null_probability = 0.1); +rc::Gen> GenValues(const LogicalType &type, const GenOptions &options); +//! Alphanumeric strings that survive the (unquoted) nested ToString format +rc::Gen GenSafeString(); +bool IsSafeString(const string &s); +//! True if all ENUM values in the type are safe strings +bool TypeHasSafeEnumValues(const LogicalType &type); +//! True if all STRUCT field names in the type are safe strings +bool TypeHasSafeFieldNames(const LogicalType &type); + +//! Returns true if the type or any nested child type satisfies the predicate +template +bool TypeContains(const LogicalType &type, F &&pred) { + if (pred(type)) { + return true; + } + switch (type.id()) { + case LogicalTypeId::LIST: + return TypeContains(ListType::GetChildType(type), pred); + case LogicalTypeId::ARRAY: + return TypeContains(ArrayType::GetChildType(type), pred); + case LogicalTypeId::MAP: + return TypeContains(MapType::KeyType(type), pred) || TypeContains(MapType::ValueType(type), pred); + case LogicalTypeId::STRUCT: + case LogicalTypeId::UNION: + for (auto &child : StructType::GetChildTypes(type)) { + if (TypeContains(child.second, pred)) { + return true; + } + } + return false; + default: + return false; + } +} +//! Returns true if the value or any nested child value satisfies the predicate +template +bool ValueContains(const Value &value, F &&pred) { + if (pred(value)) { + return true; + } + if (value.IsNull()) { + return false; + } + switch (value.type().id()) { + case LogicalTypeId::LIST: + for (auto &child : ListValue::GetChildren(value)) { + if (ValueContains(child, pred)) { + return true; + } + } + return false; + case LogicalTypeId::ARRAY: + for (auto &child : ArrayValue::GetChildren(value)) { + if (ValueContains(child, pred)) { + return true; + } + } + return false; + case LogicalTypeId::MAP: + for (auto &child : MapValue::GetChildren(value)) { + if (ValueContains(child, pred)) { + return true; + } + } + return false; + case LogicalTypeId::STRUCT: + case LogicalTypeId::UNION: + for (auto &child : StructValue::GetChildren(value)) { + if (ValueContains(child, pred)) { + return true; + } + } + return false; + default: + return false; + } +} +//! Returns true if the type (or any nested child) has one of the given type ids +bool TypeContainsAny(const LogicalType &type, const vector &ids); + +//! Join a list of SQL snippets with a separator +string Join(const vector &parts, const string &sep); +//! Convert a (valid UTF-8) string to code points +vector ToCodepoints(const string &s); +//! Encode code points as UTF-8 +string FromCodepoints(const vector &cps); + +} // namespace duckdb_prop diff --git a/test/property/main.cpp b/test/property/main.cpp new file mode 100644 index 000000000000..0c7c351f437f --- /dev/null +++ b/test/property/main.cpp @@ -0,0 +1,2 @@ +#define CATCH_CONFIG_MAIN +#include "catch.hpp" diff --git a/test/property/test_arithmetic.cpp b/test/property/test_arithmetic.cpp new file mode 100644 index 000000000000..941f760c547e --- /dev/null +++ b/test/property/test_arithmetic.cpp @@ -0,0 +1,389 @@ +// Arithmetic properties: overflow detection and results checked against __int128 oracles. +#include "property_test.hpp" + +using namespace duckdb_prop; + +namespace { + +//! Run a scalar binary operation, returning either the value or the error +struct OpResult { + Value value; + bool error = false; + string error_message; +}; + +OpResult RunOp(PropDB &db, const string &expr, vector params) { + OpResult result; + auto res = db.Query("SELECT " + expr, std::move(params)); + if (res->HasError()) { + result.error = true; + result.error_message = res->GetError(); + return result; + } + result.value = res->Cast().GetValue(0, 0); + return result; +} + +//! Format an int128 decimal raw value at the given scale the way DuckDB renders decimals +Value DecimalToStringValue(__int128 raw, uint8_t scale) { + bool neg = raw < 0; + unsigned __int128 u = neg ? (unsigned __int128)(-raw) : (unsigned __int128)raw; + string digits; + while (u) { + digits.insert(digits.begin(), char('0' + int(u % 10))); + u /= 10; + } + if (digits.empty()) { + digits = "0"; + } + string result; + if (scale > 0) { + while (digits.size() < idx_t(scale) + 1) { + digits.insert(digits.begin(), '0'); + } + result = digits.substr(0, digits.size() - scale) + "." + digits.substr(digits.size() - scale); + } else { + result = digits; + } + if (neg && result.find_first_not_of("0.") != string::npos) { + result = "-" + result; + } + return Value(result); +} + +inline __int128 MakeInt128(int64_t upper, uint64_t lower) { + return __int128((unsigned __int128)(uint64_t(upper)) << 64 | lower); +} + +const __int128 HUGEINT_MIN128 = + MakeInt128(NumericLimits::Minimum().upper, NumericLimits::Minimum().lower); + +__int128 ToInt128(const Value &v) { + if (v.type().id() == LogicalTypeId::HUGEINT) { + auto h = v.GetValue(); + return MakeInt128(h.upper, h.lower); + } + if (v.type().id() == LogicalTypeId::UBIGINT) { + return __int128(v.GetValue()); + } + return __int128(v.GetValue()); +} + +bool InRange(__int128 x, const LogicalType &type) { + switch (type.id()) { + case LogicalTypeId::TINYINT: + return x >= -128 && x <= 127; + case LogicalTypeId::SMALLINT: + return x >= -32768 && x <= 32767; + case LogicalTypeId::INTEGER: + return x >= -2147483648LL && x <= 2147483647LL; + case LogicalTypeId::BIGINT: + return x >= __int128(NumericLimits::Minimum()) && x <= __int128(NumericLimits::Maximum()); + case LogicalTypeId::UTINYINT: + return x >= 0 && x <= 255; + case LogicalTypeId::USMALLINT: + return x >= 0 && x <= 65535; + case LogicalTypeId::UINTEGER: + return x >= 0 && x <= 4294967295LL; + case LogicalTypeId::UBIGINT: + return x >= 0 && x <= __int128(NumericLimits::Maximum()); + default: + throw InternalException("InRange: unexpected type"); + } +} + +Value MakeTyped(__int128 x, const LogicalType &type) { + switch (type.id()) { + case LogicalTypeId::TINYINT: + return Value::TINYINT(int8_t(x)); + case LogicalTypeId::SMALLINT: + return Value::SMALLINT(int16_t(x)); + case LogicalTypeId::INTEGER: + return Value::INTEGER(int32_t(x)); + case LogicalTypeId::BIGINT: + return Value::BIGINT(int64_t(x)); + case LogicalTypeId::UTINYINT: + return Value::UTINYINT(uint8_t(x)); + case LogicalTypeId::USMALLINT: + return Value::USMALLINT(uint16_t(x)); + case LogicalTypeId::UINTEGER: + return Value::UINTEGER(uint32_t(x)); + case LogicalTypeId::UBIGINT: + return Value::UBIGINT(uint64_t(x)); + default: + throw InternalException("MakeTyped: unexpected type"); + } +} + +} // namespace + +TEST_CASE("Integer arithmetic overflow oracle", "[property][arithmetic]") { + PropDB db; + rc::prop("a op b errors iff the exact result is out of range", [&] { + auto type = *rc::gen::elementOf(vector { + LogicalType::TINYINT, LogicalType::SMALLINT, LogicalType::INTEGER, LogicalType::BIGINT, + LogicalType::UTINYINT, LogicalType::USMALLINT, LogicalType::UINTEGER, LogicalType::UBIGINT}); + auto a = *GenNonNullValue(type); + auto b = *GenNonNullValue(type); + auto op = *rc::gen::element('+', '-', '*'); + RC_TAG(string(1, op) + " " + LogicalTypeIdToString(type.id())); + + __int128 x = ToInt128(a), y = ToInt128(b); + __int128 exact = 0; + bool int128_overflow; + switch (op) { + case '+': + int128_overflow = __builtin_add_overflow(x, y, &exact); + break; + case '-': + int128_overflow = __builtin_sub_overflow(x, y, &exact); + break; + default: + int128_overflow = __builtin_mul_overflow(x, y, &exact); + break; + } + // if even int128 overflows (UBIGINT * UBIGINT), the result is certainly out of range + bool expect_error = int128_overflow || !InRange(exact, type); + + auto tn = type.ToString(); + auto result = RunOp(db, string("$1::") + tn + " " + op + " $2::" + tn, {a, b}); + if (expect_error != result.error) { + RC_FAIL("overflow mismatch for " + a.ToString() + " " + op + " " + b.ToString() + " (" + type.ToString() + + "): expected " + (expect_error ? "error" : "success") + ", got " + + (result.error ? ("error: " + result.error_message) : ("value " + result.value.ToString()))); + } + if (!result.error) { + PROP_ASSERT_VALUES_EQUAL(result.value, MakeTyped(exact, type)); + } + }); + + rc::prop("integer division and modulo", [&] { + auto type = *rc::gen::elementOf(vector {LogicalType::TINYINT, LogicalType::SMALLINT, + LogicalType::INTEGER, LogicalType::BIGINT}); + auto a = *GenNonNullValue(type); + auto b = *GenNonNullValue(type); + __int128 x = ToInt128(a), y = ToInt128(b); + + auto tn = type.ToString(); + // a // b: truncating division; division by zero yields NULL + auto div_result = RunOp(db, "$1::" + tn + " // $2::" + tn, {a, b}); + auto mod_result = RunOp(db, "$1::" + tn + " % $2::" + tn, {a, b}); + if (y == 0) { + if (div_result.error || !div_result.value.IsNull()) { + RC_FAIL("x // 0 should be NULL, got " + + (div_result.error ? div_result.error_message : Describe(div_result.value))); + } + if (mod_result.error || !mod_result.value.IsNull()) { + RC_FAIL("x % 0 should be NULL, got " + + (mod_result.error ? mod_result.error_message : Describe(mod_result.value))); + } + } else { + __int128 q = x / y; // C++ truncating division + __int128 r = x % y; + bool expect_error = !InRange(q, type); // only INT_MIN // -1 overflows + if (expect_error != div_result.error) { + RC_FAIL("division overflow mismatch: " + a.ToString() + " // " + b.ToString() + " -> " + + (div_result.error ? div_result.error_message : div_result.value.ToString())); + } + if (!div_result.error) { + PROP_ASSERT_VALUES_EQUAL(div_result.value, MakeTyped(q, type)); + } + // KNOWN ISSUE: INT_MIN % -1 errors instead of returning 0 + bool mod_overflows = expect_error; + if (mod_overflows != mod_result.error) { + RC_FAIL("modulo mismatch: " + a.ToString() + " % " + b.ToString() + " -> " + + (mod_result.error ? mod_result.error_message : mod_result.value.ToString())); + } + if (!mod_result.error) { + PROP_ASSERT_VALUES_EQUAL(mod_result.value, MakeTyped(r, type)); + } + } + }); + + rc::prop("negation and abs", [&] { + auto type = *rc::gen::elementOf(vector {LogicalType::TINYINT, LogicalType::SMALLINT, + LogicalType::INTEGER, LogicalType::BIGINT}); + auto a = *GenNonNullValue(type); + __int128 x = ToInt128(a); + auto neg = RunOp(db, "-($1::" + type.ToString() + ")", {a}); + bool expect_error = !InRange(-x, type); + if (expect_error != neg.error) { + RC_FAIL("negation mismatch for " + a.ToString() + ": got " + + (neg.error ? neg.error_message : neg.value.ToString())); + } + if (!neg.error) { + PROP_ASSERT_VALUES_EQUAL(neg.value, MakeTyped(-x, type)); + } + auto abs_r = RunOp(db, "abs($1::" + type.ToString() + ")", {a}); + __int128 ax = x < 0 ? -x : x; + expect_error = !InRange(ax, type); + if (expect_error != abs_r.error) { + RC_FAIL("abs mismatch for " + a.ToString() + ": got " + + (abs_r.error ? abs_r.error_message : abs_r.value.ToString())); + } + if (!abs_r.error) { + PROP_ASSERT_VALUES_EQUAL(abs_r.value, MakeTyped(ax, type)); + } + }); + + rc::prop("bitwise operations match C semantics", [&] { + auto a = *GenInt(); + auto b = *GenInt(); + auto va = Value::BIGINT(a), vb = Value::BIGINT(b); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT $1 & $2", {va, vb}), Value::BIGINT(a & b)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT $1 | $2", {va, vb}), Value::BIGINT(a | b)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT xor($1, $2)", {va, vb}), Value::BIGINT(a ^ b)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT ~$1", {va}), Value::BIGINT(~a)); + int64_t pc = 0; + uint64_t ua = uint64_t(a); + while (ua) { + pc += ua & 1; + ua >>= 1; + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT bit_count($1)", {va}), Value::TINYINT(int8_t(pc))); + }); + + rc::prop("greatest/least/sign", [&] { + auto a = *GenInt(); + auto b = *GenInt(); + auto c = *GenInt(); + auto va = Value::BIGINT(a), vb = Value::BIGINT(b), vc = Value::BIGINT(c); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT greatest($1, $2, $3)", {va, vb, vc}), + Value::BIGINT(std::max(std::max(a, b), c))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT least($1, $2, $3)", {va, vb, vc}), + Value::BIGINT(std::min(std::min(a, b), c))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT sign($1)", {va}), Value::TINYINT(a > 0 ? 1 : (a < 0 ? -1 : 0))); + }); +} + +TEST_CASE("HUGEINT arithmetic oracle", "[property][arithmetic]") { + PropDB db; + rc::prop("hugeint add/sub/mul with builtin int128 overflow check", [&] { + auto a = *GenHugeint(); + auto b = *GenHugeint(); + auto op = *rc::gen::element('+', '-', '*'); + __int128 x = MakeInt128(a.upper, a.lower); + __int128 y = MakeInt128(b.upper, b.lower); + __int128 exact; + bool overflow; + switch (op) { + case '+': + overflow = __builtin_add_overflow(x, y, &exact); + break; + case '-': + overflow = __builtin_sub_overflow(x, y, &exact); + break; + default: + overflow = __builtin_mul_overflow(x, y, &exact); + break; + } + // DuckDB reserves true INT128_MIN as an invalid HUGEINT value + if (!overflow && exact < HUGEINT_MIN128) { + overflow = true; + } + auto result = RunOp(db, string("$1 ") + op + " $2", {Value::HUGEINT(a), Value::HUGEINT(b)}); + if (overflow != result.error) { + RC_FAIL("hugeint overflow mismatch: " + Value::HUGEINT(a).ToString() + " " + op + " " + + Value::HUGEINT(b).ToString() + " -> " + + (result.error ? result.error_message : result.value.ToString())); + } + if (!result.error) { + PROP_ASSERT_VALUES_EQUAL(result.value, Value::HUGEINT(hugeint_t(int64_t(exact >> 64), uint64_t(exact)))); + } + }); + + rc::prop("hugeint div/mod", [&] { + auto a = *GenHugeint(); + auto b = *GenHugeint(); + __int128 x = MakeInt128(a.upper, a.lower); + __int128 y = MakeInt128(b.upper, b.lower); + auto div_result = RunOp(db, "$1 // $2", {Value::HUGEINT(a), Value::HUGEINT(b)}); + auto mod_result = RunOp(db, "$1 % $2", {Value::HUGEINT(a), Value::HUGEINT(b)}); + if (y == 0) { + if (div_result.error || !div_result.value.IsNull()) { + RC_FAIL("hugeint // 0 should be NULL"); + } + if (mod_result.error || !mod_result.value.IsNull()) { + RC_FAIL("hugeint % 0 should be NULL"); + } + return; + } + bool overflow = (y == -1) && (x == HUGEINT_MIN128); + if (overflow != div_result.error) { + RC_FAIL("hugeint division mismatch: " + Value::HUGEINT(a).ToString() + " // " + + Value::HUGEINT(b).ToString() + " -> " + + (div_result.error ? div_result.error_message : div_result.value.ToString())); + } + if (!div_result.error) { + __int128 q = x / y; + PROP_ASSERT_VALUES_EQUAL(div_result.value, Value::HUGEINT(hugeint_t(int64_t(q >> 64), uint64_t(q)))); + } + if (!mod_result.error) { + __int128 r = x % y; + PROP_ASSERT_VALUES_EQUAL(mod_result.value, Value::HUGEINT(hugeint_t(int64_t(r >> 64), uint64_t(r)))); + } else if (!overflow) { + RC_FAIL("hugeint modulo errored: " + mod_result.error_message); + } + }); +} + +TEST_CASE("DECIMAL arithmetic oracle", "[property][arithmetic]") { + PropDB db; + rc::prop("decimal addition/subtraction is exact", [&] { + auto t1 = *GenDecimalType(); + auto t2 = *GenDecimalType(); + auto a = *GenNonNullValue(t1); + auto b = *GenNonNullValue(t2); + auto w1 = DecimalType::GetWidth(t1), s1 = DecimalType::GetScale(t1); + auto w2 = DecimalType::GetWidth(t2), s2 = DecimalType::GetScale(t2); + // result type: scale = max(s1, s2), width = max(w1 - s1, w2 - s2) + scale + 1 (capped at 38) + auto rs = std::max(s1, s2); + auto rw = std::max(w1 - s1, w2 - s2) + rs + 1; + RC_PRE(rw <= 38); + // compute exact result in int128 at the result scale + auto raw = [](const Value &v) { + __int128 r; + switch (v.type().InternalType()) { + case PhysicalType::INT16: + r = v.GetValueUnsafe(); + break; + case PhysicalType::INT32: + r = v.GetValueUnsafe(); + break; + case PhysicalType::INT64: + r = v.GetValueUnsafe(); + break; + default: { + auto h = v.GetValueUnsafe(); + r = MakeInt128(h.upper, h.lower); + break; + } + } + return r; + }; + __int128 xa = raw(a), xb = raw(b); + for (auto i = s1; i < rs; i++) { + xa *= 10; + } + for (auto i = s2; i < rs; i++) { + xb *= 10; + } + auto sum = xa + xb; + auto diff = xa - xb; + auto add_result = RunOp(db, "$1 + $2", {a, b}); + auto sub_result = RunOp(db, "$1 - $2", {a, b}); + if (add_result.error) { + RC_FAIL("decimal + errored: " + add_result.error_message); + } + if (sub_result.error) { + RC_FAIL("decimal - errored: " + sub_result.error_message); + } + auto expected_type = LogicalType::DECIMAL(uint8_t(rw), rs); + PROP_ASSERT_VALUES_EQUAL(add_result.value.DefaultCastAs(LogicalType::VARCHAR), DecimalToStringValue(sum, rs)); + PROP_ASSERT_VALUES_EQUAL(sub_result.value.DefaultCastAs(LogicalType::VARCHAR), DecimalToStringValue(diff, rs)); + if (add_result.value.type() != expected_type) { + RC_FAIL("unexpected decimal + result type: " + add_result.value.type().ToString() + " expected " + + expected_type.ToString()); + } + }); +} diff --git a/test/property/test_datetime.cpp b/test/property/test_datetime.cpp new file mode 100644 index 000000000000..8e1ee4aed739 --- /dev/null +++ b/test/property/test_datetime.cpp @@ -0,0 +1,203 @@ +// Date/time properties checked against independent calendar algorithms +// (days_from_civil / civil_from_days, Howard Hinnant's public-domain algorithms). +#include "property_test.hpp" + +using namespace duckdb_prop; + +namespace { + +struct Civil { + int64_t y; + int32_t m; + int32_t d; +}; + +//! days since 1970-01-01 from proleptic Gregorian date +int64_t DaysFromCivil(int64_t y, int32_t m, int32_t d) { + y -= m <= 2; + const int64_t era = (y >= 0 ? y : y - 399) / 400; + const int64_t yoe = y - era * 400; // [0, 399] + const int64_t doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; // [0, 365] + const int64_t doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] + return era * 146097 + doe - 719468; +} + +Civil CivilFromDays(int64_t z) { + z += 719468; + const int64_t era = (z >= 0 ? z : z - 146096) / 146097; + const int64_t doe = z - era * 146097; // [0, 146096] + const int64_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399] + const int64_t y = yoe + era * 400; + const int64_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + const int64_t mp = (5 * doy + 2) / 153; // [0, 11] + const int64_t d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + const int64_t m = mp < 10 ? mp + 3 : mp - 9; // [1, 12] + Civil result; + result.y = y + (m <= 2); + result.m = int32_t(m); + result.d = int32_t(d); + return result; +} + +bool IsLeap(int64_t y) { + return y % 4 == 0 && (y % 100 != 0 || y % 400 == 0); +} + +int32_t LastDayOfMonth(int64_t y, int32_t m) { + static const int32_t days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + return m == 2 && IsLeap(y) ? 29 : days[m - 1]; +} + +//! ISO 8601 week and week-based year +void IsoWeek(int64_t days, int64_t &iso_year, int32_t &iso_week) { + auto c = CivilFromDays(days); + // ISO weekday: Mon=1..Sun=7; 1970-01-01 was a Thursday (4) + auto weekday = [](int64_t z) { + return int32_t(((z % 7) + 10) % 7) + 1; + }; // Mon=1..Sun=7 + // Thursday of the current week determines the ISO year + int64_t thursday = days - (weekday(days) - 4); + auto tc = CivilFromDays(thursday); + iso_year = tc.y; + int64_t jan1 = DaysFromCivil(tc.y, 1, 1); + iso_week = int32_t((thursday - jan1) / 7) + 1; + (void)c; +} + +} // namespace + +TEST_CASE("Date functions match calendar algorithms", "[property][datetime]") { + PropDB db; + rc::prop("date part extraction", [&] { + auto d = *GenFiniteDate(); + int64_t days = d.days; + auto c = CivilFromDays(days); + // KNOWN ISSUE: week-based parts materialize Jan 1 of the year, out of range for the minimum year + RC_PRE(c.y > Date::DATE_MIN_YEAR); + // KNOWN ISSUE: last_day at the maximum month raises an INTERNAL error (first-of-next-month overflows) + bool last_day_safe = !(c.y == Date::DATE_MAX_YEAR && c.m >= Date::DATE_MAX_MONTH); + RC_PRE(last_day_safe); + auto vd = Value::DATE(d); + auto res = db.Query("SELECT year($1), month($1), day($1), dayofweek($1), isodow($1), dayofyear($1), " + "quarter($1), last_day($1), monthname($1) IS NOT NULL, weekofyear($1), isoyear($1), " + "make_date(CAST($2 AS BIGINT), $3, $4)", + {vd, Value::BIGINT(c.y), Value::INTEGER(c.m), Value::INTEGER(c.d)}); + PROP_REQUIRE_NO_ERROR(res, "date parts for " + vd.ToString()); + auto &m = res->Cast(); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(0, 0), Value::BIGINT(c.y)); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(1, 0), Value::BIGINT(c.m)); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(2, 0), Value::BIGINT(c.d)); + // dayofweek: Sunday=0..Saturday=6; 1970-01-01 was Thursday(4) + int64_t dow = ((days % 7) + 10) % 7 + 1; // Mon=1..Sun=7 + PROP_ASSERT_VALUES_EQUAL(m.GetValue(3, 0), Value::BIGINT(dow % 7)); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(4, 0), Value::BIGINT(dow)); + int64_t doy = days - DaysFromCivil(c.y, 1, 1) + 1; + PROP_ASSERT_VALUES_EQUAL(m.GetValue(5, 0), Value::BIGINT(doy)); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(6, 0), Value::BIGINT((c.m - 1) / 3 + 1)); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(7, 0), + Value::DATE(date_t(int32_t(DaysFromCivil(c.y, c.m, LastDayOfMonth(c.y, c.m)))))); + int64_t iso_year; + int32_t iso_week; + IsoWeek(days, iso_year, iso_week); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(9, 0), Value::BIGINT(iso_week)); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(10, 0), Value::BIGINT(iso_year)); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(11, 0), vd); + }); + + rc::prop("date arithmetic", [&] { + auto d = *GenFiniteDate(); + auto n = *rc::gen::weightedOneOf( + {{5, rc::gen::inRange(-100000, 100000)}, {2, rc::gen::cast(GenInt())}}); + int64_t days = d.days; + auto vd = Value::DATE(d); + // d + n days + __int128 target = __int128(days) + n; + auto res = db.Query("SELECT $1 + $2::INTEGER", {vd, Value::BIGINT(n)}); + auto min_days = Date::FromDate(Date::DATE_MIN_YEAR, Date::DATE_MIN_MONTH, Date::DATE_MIN_DAY).days; + auto max_days = Date::FromDate(Date::DATE_MAX_YEAR, Date::DATE_MAX_MONTH, Date::DATE_MAX_DAY).days; + bool valid = target >= min_days && target <= max_days; + if (valid) { + PROP_REQUIRE_NO_ERROR(res, "date + int"); + auto got = res->Cast().GetValue(0, 0); + PROP_ASSERT_VALUES_EQUAL(got, Value::DATE(date_t(int32_t(target)))); + } else if (!res->HasError()) { + auto got = res->Cast().GetValue(0, 0); + RC_FAIL("expected out-of-range error for " + vd.ToString() + " + " + std::to_string(n) + ", got " + + got.ToString()); + } + // date difference + auto d2 = *GenFiniteDate(); + auto diff = db.Scalar("SELECT $1 - $2", {vd, Value::DATE(d2)}); + PROP_ASSERT_VALUES_EQUAL(diff, Value::BIGINT(int64_t(days) - int64_t(d2.days))); + auto datediff = db.Scalar("SELECT date_diff('day', $2, $1)", {vd, Value::DATE(d2)}); + PROP_ASSERT_VALUES_EQUAL(datediff, Value::BIGINT(int64_t(days) - int64_t(d2.days))); + }); + + rc::prop("timestamp epoch round trips", [&] { + auto ts = *GenFiniteTimestamp(); + auto vts = Value::TIMESTAMP(ts); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT epoch_us($1)", {vts}), Value::BIGINT(ts.value)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT make_timestamp($1)", {Value::BIGINT(ts.value)}), vts); + // epoch_ms rounds half away from zero + int64_t ms = ts.value / 1000; + int64_t rem = ts.value % 1000; + if (rem >= 500) { + ms++; + } else if (rem <= -500) { + ms--; + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT epoch_ms($1)", {vts}), Value::BIGINT(ms)); + // date/time decomposition: ts::DATE + ts::TIME == ts + auto res = db.Query("SELECT CAST($1 AS DATE), CAST($1 AS TIME)", {vts}); + PROP_REQUIRE_NO_ERROR(res, "ts decompose"); + auto &m = res->Cast(); + auto date_part = m.GetValue(0, 0); + auto time_part = m.GetValue(1, 0); + __int128 fd = + (__int128(ts.value) - (ts.value < 0 ? Interval::MICROS_PER_DAY - 1 : 0)) / Interval::MICROS_PER_DAY; + int64_t expected_days = int64_t(fd); + int64_t expected_micros = int64_t(__int128(ts.value) - fd * Interval::MICROS_PER_DAY); + PROP_ASSERT_VALUES_EQUAL(date_part, Value::DATE(date_t(int32_t(expected_days)))); + PROP_ASSERT_VALUES_EQUAL(time_part, Value::TIME(dtime_t(expected_micros))); + // recompose + auto recomposed = db.Scalar("SELECT $1 + $2", {date_part, time_part}); + PROP_ASSERT_VALUES_EQUAL(recomposed, vts); + }); + + rc::prop("strftime/strptime round trip", [&] { + auto ts = *GenFiniteTimestamp(); + // strptime requires years representable in the format; stay within a wide but printable range + auto c = CivilFromDays(int64_t((__int128(ts.value) - (ts.value < 0 ? Interval::MICROS_PER_DAY - 1 : 0)) / + Interval::MICROS_PER_DAY)); + RC_PRE(c.y >= 1 && c.y <= 9999); + auto fmt = *rc::gen::elementOf(vector { + "%Y-%m-%d %H:%M:%S.%f", + "%Y/%m/%d %H:%M:%S.%f", + "%d.%m.%Y %H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S.%f", + "%m/%d/%Y %H:%M:%S.%f", + }); + auto vts = Value::TIMESTAMP(ts); + auto str = db.Scalar("SELECT strftime($1, '" + fmt + "')", {vts}); + auto back = db.Scalar("SELECT strptime($1, '" + fmt + "')", {str}); + PROP_ASSERT_VALUES_EQUAL(back, vts); + }); + + rc::prop("time part extraction", [&] { + auto t = *GenTime(); + auto vt = Value::TIME(t); + auto micros = t.value; + auto res = db.Query("SELECT hour($1), minute($1), second($1), millisecond($1), microsecond($1)", {vt}); + PROP_REQUIRE_NO_ERROR(res, "time parts"); + auto &m = res->Cast(); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(0, 0), Value::BIGINT(micros / Interval::MICROS_PER_HOUR)); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(1, 0), + Value::BIGINT((micros % Interval::MICROS_PER_HOUR) / Interval::MICROS_PER_MINUTE)); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(2, 0), + Value::BIGINT((micros % Interval::MICROS_PER_MINUTE) / Interval::MICROS_PER_SEC)); + // millisecond/microsecond include the seconds component (PostgreSQL semantics) + PROP_ASSERT_VALUES_EQUAL(m.GetValue(3, 0), + Value::BIGINT((micros % Interval::MICROS_PER_MINUTE) / Interval::MICROS_PER_MSEC)); + PROP_ASSERT_VALUES_EQUAL(m.GetValue(4, 0), Value::BIGINT(micros % Interval::MICROS_PER_MINUTE)); + }); +} diff --git a/test/property/test_lists.cpp b/test/property/test_lists.cpp new file mode 100644 index 000000000000..8e25f0358077 --- /dev/null +++ b/test/property/test_lists.cpp @@ -0,0 +1,240 @@ +// List function properties checked against reference implementations. +#include "property_test.hpp" + +#include + +using namespace duckdb_prop; + +namespace { + +Value ListOf(const LogicalType &child, vector values) { + return Value::LIST(child, std::move(values)); +} + +//! DuckDB's ORDER BY comparison for values: NULLs sort with NULLS LAST in ASC +bool OrderByLess(const Value &a, const Value &b) { + if (a.IsNull()) { + return false; + } + if (b.IsNull()) { + return true; + } + return a < b; +} + +} // namespace + +TEST_CASE("List functions match reference implementations", "[property][lists]") { + PropDB db; + + rc::prop("list_sort matches std::stable_sort with Value comparison", [&] { + // KNOWN ISSUE: create_sort_key encodes INTERVALs unnormalized, so list_sort disagrees with ORDER BY + auto type = *rc::gen::suchThat( + GenSortableType(1), [](const LogicalType &t) { return !TypeContainsAny(t, {LogicalTypeId::INTERVAL}); }); + auto values = *GenValues(type, 0.15); + RC_TAG(LogicalTypeIdToString(type.id())); + auto l = ListOf(type, values); + + auto sorted = values; + std::stable_sort(sorted.begin(), sorted.end(), OrderByLess); + auto actual = db.Scalar("SELECT list_sort($1)", {l}); + PROP_ASSERT_VALUES_EQUAL(actual, ListOf(type, sorted)); + + auto rsorted = values; + std::stable_sort(rsorted.begin(), rsorted.end(), [](const Value &a, const Value &b) { + // DESC with NULLS LAST (DuckDB's global default null order) + if (a.IsNull()) { + return false; + } + if (b.IsNull()) { + return true; + } + return b < a; + }); + auto actual_r = db.Scalar("SELECT list_reverse_sort($1)", {l}); + PROP_ASSERT_VALUES_EQUAL(actual_r, ListOf(type, rsorted)); + }); + + rc::prop("list_contains/list_position/list_distinct", [&] { + // KNOWN ISSUE: INTERVAL sort order (see above); interval equality also normalizes (1 month = 30 days) + auto type = *rc::gen::suchThat( + GenSortableType(1), [](const LogicalType &t) { return !TypeContainsAny(t, {LogicalTypeId::INTERVAL}); }); + auto values = *GenValues(type, 0.15); + auto l = ListOf(type, values); + // needle: either random or an element of the list + auto needle = + values.empty() ? *GenValue(type, 0.2) : *rc::gen::oneOf(GenValue(type, 0.2), rc::gen::elementOf(values)); + + // list_contains: NULL needle -> NULL, NULL elements ignored; + // list_position: NULL needle matches NULL elements (IS NOT DISTINCT semantics) + Value expected_contains; + Value expected_position; + { + int32_t pos = 0; + for (idx_t i = 0; i < values.size(); i++) { + if (Value::NotDistinctFrom(values[i], needle)) { + pos = int32_t(i) + 1; + break; + } + } + expected_position = pos ? Value::INTEGER(pos) : Value(LogicalType::INTEGER); + if (needle.IsNull()) { + expected_contains = Value(LogicalType::BOOLEAN); + } else { + expected_contains = Value::BOOLEAN(pos != 0 && !values[pos - 1].IsNull()); + } + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT list_contains($1, $2)", {l, needle}), expected_contains); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT list_position($1, $2)", {l, needle}), expected_position); + + // list_distinct: distinct non-NULL elements, order unspecified -> compare as sorted multiset + vector distinct; + for (auto &v : values) { + if (v.IsNull()) { + continue; + } + bool dup = false; + for (auto &d : distinct) { + if (Value::NotDistinctFrom(d, v)) { + dup = true; + break; + } + } + if (!dup) { + distinct.push_back(v); + } + } + std::stable_sort(distinct.begin(), distinct.end(), OrderByLess); + auto actual_distinct = db.Scalar("SELECT list_sort(list_distinct($1))", {l}); + PROP_ASSERT_VALUES_EQUAL(actual_distinct, ListOf(type, distinct)); + }); + + rc::prop("list slicing matches the substring model", [&] { + auto type = *GenScalarType(); + auto values = *GenValues(type, 0.1); + auto l = ListOf(type, values); + int64_t n = int64_t(values.size()); + auto begin = *rc::gen::inRange(-n - 3, n + 4); + auto end = *rc::gen::inRange(-n - 3, n + 4); + // list[begin:end]: 1-based inclusive bounds, negative from the end + int64_t lo = begin < 0 ? std::max(n + begin + 1, 1) : std::max(begin, 1); + int64_t hi = end < 0 ? n + end + 1 : std::min(end, n); + vector expected; + for (int64_t p = lo; p <= hi && p >= 1 && p <= n; p++) { + expected.push_back(values[p - 1]); + } + auto actual = db.Scalar("SELECT list_slice($1, $2, $3)", {l, Value::BIGINT(begin), Value::BIGINT(end)}); + PROP_ASSERT_VALUES_EQUAL(actual, ListOf(type, expected)); + }); + + rc::prop("list_reverse/list_resize/flatten/list_concat", [&] { + auto type = *GenScalarType(); + auto values = *GenValues(type, 0.1); + auto l = ListOf(type, values); + + auto reversed = values; + std::reverse(reversed.begin(), reversed.end()); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT list_reverse($1)", {l}), ListOf(type, reversed)); + + auto new_size = *rc::gen::inRange(0, int64_t(values.size()) + 5); + vector resized; + for (int64_t i = 0; i < new_size; i++) { + resized.push_back(i < int64_t(values.size()) ? values[i] : Value(type)); + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT list_resize($1, $2)", {l, Value::BIGINT(new_size)}), + ListOf(type, resized)); + + auto values2 = *GenValues(type, 0.1); + auto l2 = ListOf(type, values2); + auto concat = values; + concat.insert(concat.end(), values2.begin(), values2.end()); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT list_concat($1, $2)", {l, l2}), ListOf(type, concat)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT $1 || $2", {l, l2}), ListOf(type, concat)); + + // flatten: [l, l2] -> l || l2; NULL sublists are skipped + auto nested = Value::LIST(LogicalType::LIST(type), {l, l2}); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT flatten($1)", {nested}), ListOf(type, concat)); + }); + + rc::prop("range/generate_series match the closed-form count", [&] { + auto start = + *rc::gen::weightedOneOf({{5, rc::gen::inRange(-1000, 1000)}, {2, GenInt()}}); + auto stop = + *rc::gen::weightedOneOf({{5, rc::gen::inRange(-1000, 1000)}, {2, GenInt()}}); + auto step = *rc::gen::weightedOneOf({{5, rc::gen::inRange(-50, 51)}, {2, GenInt()}}); + RC_PRE(step != 0); + // closed-form expected count using __int128 to dodge overflow + __int128 s = start, e = stop, st = step; + __int128 range_count = 0, series_count = 0; + if (st > 0) { + if (e > s) { + range_count = (e - s + st - 1) / st; + } + if (e >= s) { + series_count = (e - s) / st + 1; + } + } else { + if (e < s) { + range_count = (s - e + (-st) - 1) / (-st); + } + if (e <= s) { + series_count = (s - e) / (-st) + 1; + } + } + // keep result sizes sane + RC_PRE(range_count < 100000 && series_count < 100000); + auto res = db.Query("SELECT count(*), min(r), max(r) FROM range($1, $2, $3) t(r)", + {Value::BIGINT(start), Value::BIGINT(stop), Value::BIGINT(step)}); + PROP_REQUIRE_NO_ERROR(res, "range"); + auto &mat = res->Cast(); + PROP_ASSERT_VALUES_EQUAL(mat.GetValue(0, 0), Value::BIGINT(int64_t(range_count))); + if (range_count > 0) { + auto expected_min = step > 0 ? start : start + (int64_t(range_count) - 1) * step; + auto expected_max = step > 0 ? start + (int64_t(range_count) - 1) * step : start; + PROP_ASSERT_VALUES_EQUAL(mat.GetValue(1, 0), Value::BIGINT(expected_min)); + PROP_ASSERT_VALUES_EQUAL(mat.GetValue(2, 0), Value::BIGINT(expected_max)); + } + auto res2 = db.Query("SELECT count(*), min(r), max(r) FROM generate_series($1, $2, $3) t(r)", + {Value::BIGINT(start), Value::BIGINT(stop), Value::BIGINT(step)}); + PROP_REQUIRE_NO_ERROR(res2, "generate_series"); + auto &mat2 = res2->Cast(); + PROP_ASSERT_VALUES_EQUAL(mat2.GetValue(0, 0), Value::BIGINT(int64_t(series_count))); + }); + + rc::prop("list aggregates match scalar aggregates", [&] { + auto values = *GenValues(LogicalType::BIGINT, 0.15); + auto l = ListOf(LogicalType::BIGINT, values); + int64_t cnt = 0; + __int128 sum = 0; + Value min_v, max_v; + for (auto &v : values) { + if (v.IsNull()) { + continue; + } + auto x = v.GetValue(); + cnt++; + sum += x; + if (min_v.IsNull() || x < min_v.GetValue()) { + min_v = v; + } + if (max_v.IsNull() || x > max_v.GetValue()) { + max_v = v; + } + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT list_count($1)", {l}), Value::BIGINT(cnt)); + if (cnt > 0) { + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT list_min($1)", {l}), Value::BIGINT(min_v.GetValue())); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT list_max($1)", {l}), Value::BIGINT(max_v.GetValue())); + } + // list_sum promotes to HUGEINT + auto actual_sum = db.Scalar("SELECT list_sum($1)", {l}); + if (cnt == 0) { + if (!actual_sum.IsNull()) { + RC_FAIL("expected NULL sum, got " + Describe(actual_sum)); + } + } else { + PROP_ASSERT_VALUES_EQUAL(actual_sum.DefaultCastAs(LogicalType::HUGEINT), + Value::HUGEINT(hugeint_t(int64_t(sum >> 64), uint64_t(sum)))); + } + }); +} diff --git a/test/property/test_roundtrip.cpp b/test/property/test_roundtrip.cpp new file mode 100644 index 000000000000..22cfa88df6e8 --- /dev/null +++ b/test/property/test_roundtrip.cpp @@ -0,0 +1,201 @@ +// Round-trip properties: a value must survive conversions to text/SQL/JSON and back unchanged. +#include "property_test.hpp" + +using namespace duckdb_prop; + +namespace { + +//! Tag the test case with the top-level type id so the distribution is visible in the output +void TagType(const LogicalType &type) { + RC_TAG(LogicalTypeIdToString(type.id())); +} + +//! Known text-format round-trip bugs (see FINDINGS.md): filtered so they do not mask other failures +bool HasKnownTextIssue(const Value &v) { + return ValueContains(v, [](const Value &x) { + if (x.IsNull()) { + return false; + } + switch (x.type().id()) { + case LogicalTypeId::TIME_TZ: { + // KNOWN ISSUE: offsets with 0 minutes but nonzero seconds format incorrectly (-05:00:59 -> -05:59) + auto offset = std::abs(x.GetValue().offset()); + return offset % 3600 != 0 && (offset % 3600) / 60 == 0; + } + case LogicalTypeId::INTERVAL: { + // KNOWN ISSUE: Interval::ToString prints 10-digit hours but the parser only accepts 9 digits + auto micros = x.GetValue().micros; + const int64_t limit = 1000000000LL * Interval::MICROS_PER_HOUR; + return micros >= limit || micros <= -limit; + } + case LogicalTypeId::UHUGEINT: + // LIMITATION: UHUGEINT literals above HUGEINT range promote sibling list entries to DOUBLE + return x.GetValue() > uhugeint_t(0x7FFFFFFFFFFFFFFFULL, 0xFFFFFFFFFFFFFFFFULL); + default: + return false; + } + }); +} + +//! Dump raw bits of temporal leaf values (some print identically but differ in raw representation) +string DumpBits(const Value &v) { + string out; + ValueContains(v, [&](const Value &x) { + if (x.IsNull()) { + return false; + } + switch (x.type().id()) { + case LogicalTypeId::TIME_TZ: + out += " timetz_bits=" + std::to_string(x.GetValue().bits); + break; + default: + break; + } + return false; + }); + return out; +} + +} // namespace + +TEST_CASE("VARCHAR cast round trip", "[property][roundtrip]") { + PropDB db; + rc::prop("CAST(CAST(v AS VARCHAR) AS T) IS NOT DISTINCT FROM v", [&] { + // VARCHAR -> UNION only tries members that VARCHAR implicitly casts to, so unions do not round trip by design + auto type = *rc::gen::suchThat(GenType(2), [](const LogicalType &t) { + return !TypeContainsAny(t, {LogicalTypeId::UNION}) && TypeHasSafeEnumValues(t) && TypeHasSafeFieldNames(t); + }); + // the nested text format does not quote strings, so only safe strings round trip inside nested types + GenOptions options(0.05); + options.safe_strings = type.IsNested(); + auto v = *GenValue(type, options); + TagType(type); + RC_PRE(!HasKnownTextIssue(v)); + auto res = db.Query("SELECT CAST(CAST($1 AS VARCHAR) AS " + type.ToString() + "), CAST($1 AS VARCHAR)", {v}); + PROP_REQUIRE_NO_ERROR(res, "type " + type.ToString() + " value " + Describe(v)); + auto &mat = res->Cast(); + auto back = mat.GetValue(0, 0); + auto str = mat.GetValue(1, 0); + if (!ValuesEqual(back, v)) { + RC_FAIL("Round trip through VARCHAR failed\n original: " + Describe(v) + DumpBits(v) + + "\n as text: " + str.ToString() + "\n back: " + Describe(back) + DumpBits(back)); + } + }); +} + +TEST_CASE("ToSQLString round trip", "[property][roundtrip]") { + PropDB db; + rc::prop("SELECT returns v with the same type", [&] { + // KNOWN ISSUES: ToSQLString does not escape quotes/backslashes in STRUCT keys, and emits BIT unquoted + auto type = *rc::gen::suchThat(GenType(2), [](const LogicalType &t) { + if (TypeContainsAny(t, {LogicalTypeId::BIT, LogicalTypeId::UNION})) { + return false; + } + return !TypeContains(t, [](const LogicalType &c) { + if (c.id() != LogicalTypeId::STRUCT) { + return false; + } + for (auto &child : StructType::GetChildTypes(c)) { + auto &name = child.first.GetIdentifierName(); + if (name.find('\'') != string::npos || name.find('\\') != string::npos) { + return true; + } + } + return false; + }); + }); + auto v = *GenValue(type, 0.05); + TagType(type); + RC_PRE(!HasKnownTextIssue(v)); + auto sql = v.ToSQLString(); + auto res = db.Query("SELECT " + sql); + PROP_REQUIRE_NO_ERROR(res, "SELECT " + sql); + if (res->RowCount() != 1 || res->ColumnCount() != 1) { + RC_FAIL("unexpected result shape for SELECT " + sql); + } + auto back = res->GetValue(0, 0); + // literals may bind to a different (wider) type, e.g. SMALLINT 0 -> INTEGER 0: compare loosely. + // nested literals can even change type shape (ARRAY -> LIST), which NotDistinctFrom cannot bridge, + // and FLOAT text reparsed as DOUBLE differs from the widened FLOAT, so the loose check is scalars-only + if (!type.IsNested() && type.id() != LogicalTypeId::FLOAT && !ValuesEqualLoose(back, v)) { + RC_FAIL("ToSQLString round trip failed\n original: " + Describe(v) + "\n sql: " + sql + + "\n back: " + Describe(back)); + } + // with an explicit cast the type must match exactly + auto typed = db.Scalar("SELECT CAST(" + sql + " AS " + type.ToString() + ")"); + if (!ValuesEqual(typed, v)) { + RC_FAIL("ToSQLString round trip with cast failed\n original: " + Describe(v) + "\n sql: " + sql + + "\n back: " + Describe(typed)); + } + }); +} + +TEST_CASE("Prepared statement parameter round trip", "[property][roundtrip]") { + PropDB db; + rc::prop("SELECT $1 returns v", [&] { + auto type = *GenType(2); + auto v = *GenValue(type, 0.05); + TagType(type); + auto back = db.Scalar("SELECT $1", {v}); + PROP_ASSERT_VALUES_EQUAL(back, v); + }); +} + +TEST_CASE("In-memory table round trip", "[property][roundtrip]") { + PropDB db; + rc::prop("INSERT values, SELECT them back", [&] { + auto type = *GenType(2); + auto values = *GenValues(type, 0.1); + TagType(type); + db.Exec("CREATE OR REPLACE TABLE t(i INTEGER, v " + type.ToString() + ")"); + for (idx_t i = 0; i < values.size(); i++) { + auto res = db.Query("INSERT INTO t VALUES ($1, $2)", {Value::INTEGER(int32_t(i)), values[i]}); + PROP_REQUIRE_NO_ERROR(res, "insert " + Describe(values[i])); + } + auto res = db.Query("SELECT v FROM t ORDER BY i"); + PROP_REQUIRE_NO_ERROR(res, "select"); + if (res->RowCount() != values.size()) { + RC_FAIL("row count mismatch"); + } + for (idx_t i = 0; i < values.size(); i++) { + PROP_ASSERT_VALUES_EQUAL(res->GetValue(0, i), values[i]); + } + }); +} + +TEST_CASE("JSON cast round trip", "[property][roundtrip][json]") { + PropDB db; + auto check = db.Query("SELECT '1'::JSON"); + if (check->HasError()) { + WARN("JSON extension not available, skipping"); + return; + } + rc::prop("CAST(to_json(v) AS T) IS NOT DISTINCT FROM v", [&] { + // JSON cannot represent everything: restrict to types with a lossless JSON representation + auto type = *rc::gen::suchThat(GenType(2), [](const LogicalType &t) { + if (TypeContainsAny(t, + {LogicalTypeId::BLOB, LogicalTypeId::BIT, LogicalTypeId::FLOAT, LogicalTypeId::DOUBLE, + LogicalTypeId::MAP, LogicalTypeId::UNION, LogicalTypeId::ENUM, LogicalTypeId::INTERVAL, + LogicalTypeId::TIME_TZ, LogicalTypeId::TIMESTAMP_TZ})) { + return false; + } + // KNOWN ISSUE: JSON -> DECIMAL goes through a double and loses precision beyond ~15 digits + return !TypeContains(t, [](const LogicalType &c) { + return c.id() == LogicalTypeId::DECIMAL && DecimalType::GetWidth(c) > 15; + }); + }); + auto v = *GenValue(type, 0.05); + TagType(type); + // wrap in a struct: CAST(JSON AS VARCHAR) returns the raw JSON text, nested strings are unwrapped + auto res = db.Query( + "SELECT (CAST(to_json({'v': $1}) AS STRUCT(v " + type.ToString() + "))).v, to_json($1)::VARCHAR", {v}); + PROP_REQUIRE_NO_ERROR(res, "type " + type.ToString() + " value " + Describe(v)); + auto &mat = res->Cast(); + auto back = mat.GetValue(0, 0); + auto str = mat.GetValue(1, 0); + if (!ValuesEqual(back, v)) { + RC_FAIL("Round trip through JSON failed\n original: " + Describe(v) + "\n as json: " + str.ToString() + + "\n back: " + Describe(back)); + } + }); +} diff --git a/test/property/test_storage.cpp b/test/property/test_storage.cpp new file mode 100644 index 000000000000..20f86358a248 --- /dev/null +++ b/test/property/test_storage.cpp @@ -0,0 +1,125 @@ +// Storage round trip: data written to a persistent database (with forced compression schemes) +// must read back identically after checkpoint + reopen, also after updates and deletes. +#include "property_test.hpp" + +#include "duckdb/main/appender.hpp" + +#include +#include + +using namespace duckdb_prop; + +namespace { + +string ScratchDir() { + const char *dir = getenv("PROPERTY_TEST_TMP"); + if (dir) { + return dir; + } + return "/tmp"; +} + +struct ScopedDBFile { + string path; + explicit ScopedDBFile(const string &name) { + static int counter = 0; + path = ScratchDir() + "/" + name + "_" + std::to_string(getpid()) + "_" + std::to_string(counter++) + ".db"; + Cleanup(); + } + ~ScopedDBFile() { + Cleanup(); + } + void Cleanup() { + std::remove(path.c_str()); + std::remove((path + ".wal").c_str()); + } +}; + +// chimp/patas are deprecated and rejected by force_compression +const vector compressions = {"uncompressed", "rle", "bitpacking", "dictionary", "fsst", + "alp", "alprd", "zstd", "roaring", "dict_fsst"}; + +} // namespace + +TEST_CASE("Persistent storage round trip", "[property][storage]") { + rc::prop("write, checkpoint, reopen, read back", [&] { + auto type = *GenType(1); + auto compression = *rc::gen::elementOf(compressions); + auto values = *rc::gen::scale(8.0, GenValues(type, 0.15)); + auto do_delete = *rc::gen::arbitrary(); + auto do_update = *rc::gen::arbitrary(); + RC_TAG(compression); + + ScopedDBFile file("storage_rt"); + { + PropDB db(file.path); + auto set_res = db.Query("SET force_compression='" + compression + "'"); + PROP_REQUIRE_NO_ERROR(set_res, "set force_compression"); + db.Exec("CREATE TABLE t(id INTEGER, v " + type.ToString() + ")"); + { + Appender appender(db.con, "t"); + for (idx_t i = 0; i < values.size(); i++) { + appender.BeginRow(); + appender.Append(int32_t(i)); + appender.Append(values[i]); + appender.EndRow(); + } + appender.Close(); + } + db.Exec("CHECKPOINT"); + } + // modify + verify against the in-memory model + auto model = values; + { + PropDB db(file.path); + if (do_delete && !model.empty()) { + // delete every third row + db.Exec("DELETE FROM t WHERE id % 3 = 1"); + vector kept; + for (idx_t i = 0; i < model.size(); i++) { + if (i % 3 != 1) { + kept.push_back(model[i]); + } + } + // model keyed by original ids: rebuild as pairs below instead + model = kept; + } + if (do_update && !values.empty()) { + // overwrite v with the first generated value for every second remaining row + auto res = db.Query("UPDATE t SET v = $1 WHERE id % 2 = 0", {values[0]}); + PROP_REQUIRE_NO_ERROR(res, "update"); + } + db.Exec("CHECKPOINT"); + } + { + PropDB db(file.path); + auto res = db.Query("SELECT id, v FROM t ORDER BY id"); + PROP_REQUIRE_NO_ERROR(res, "select after reopen"); + // rebuild expected from the original values + the applied modifications + vector> expected; + for (idx_t i = 0; i < values.size(); i++) { + if (do_delete && i % 3 == 1) { + continue; + } + auto v = (do_update && i % 2 == 0) ? values[0] : values[i]; + expected.emplace_back(int32_t(i), v); + } + if (res->RowCount() != expected.size()) { + RC_FAIL("row count mismatch after reopen: got " + std::to_string(res->RowCount()) + " expected " + + std::to_string(expected.size())); + } + for (idx_t i = 0; i < expected.size(); i++) { + auto id = res->GetValue(0, i); + if (id.GetValue() != expected[i].first) { + RC_FAIL("id mismatch at row " + std::to_string(i)); + } + auto v = res->GetValue(1, i); + if (!ValuesEqual(v, expected[i].second)) { + RC_FAIL("value mismatch at id " + std::to_string(expected[i].first) + " (compression " + + compression + ")\n stored: " + Describe(v) + + "\n expected: " + Describe(expected[i].second)); + } + } + } + }); +} diff --git a/test/property/test_strings.cpp b/test/property/test_strings.cpp new file mode 100644 index 000000000000..3f75245ffab6 --- /dev/null +++ b/test/property/test_strings.cpp @@ -0,0 +1,562 @@ +// String function properties checked against simple reference implementations. +#include "property_test.hpp" + +#include +#include +#include +#include + +using namespace duckdb_prop; + +namespace { + +using Codepoints = vector; + +bool IsAscii(const string &s) { + for (auto c : s) { + if (uint8_t(c) >= 0x80) { + return false; + } + } + return true; +} + +//! Reference LIKE implementation on code points: '_' matches one code point, '%' any sequence, +//! escape followed by a code point matches that code point literally +bool LikeOracle(const Codepoints &s, const Codepoints &p, int64_t escape, idx_t si = 0, idx_t pi = 0) { + while (pi < p.size()) { + auto pc = p[pi]; + if (escape >= 0 && pc == uint32_t(escape)) { + pi++; + if (pi >= p.size()) { + throw std::runtime_error("pattern ends with escape"); + } + if (si >= s.size() || s[si] != p[pi]) { + return false; + } + si++; + pi++; + } else if (pc == '%') { + // collapse consecutive % + while (pi < p.size() && p[pi] == '%') { + pi++; + } + if (pi == p.size()) { + return true; + } + for (idx_t k = si; k <= s.size(); k++) { + if (LikeOracle(s, p, escape, k, pi)) { + return true; + } + } + return false; + } else if (pc == '_') { + if (si >= s.size()) { + return false; + } + si++; + pi++; + } else { + if (si >= s.size() || s[si] != pc) { + return false; + } + si++; + pi++; + } + } + return si == s.size(); +} + +Codepoints LowerAscii(Codepoints cps) { + for (auto &c : cps) { + if (c >= 'A' && c <= 'Z') { + c += 32; + } + } + return cps; +} + +//! Generator for LIKE patterns: strings with a higher density of wildcards +rc::Gen GenLikePattern() { + return rc::gen::map(rc::gen::container>(rc::gen::weightedOneOf( + {{3, Elements({'%', '_'})}, + {2, Elements({'\\', '$', '#'})}, + {6, rc::gen::inRange('a', 'd' + 1)}, + {1, rc::gen::inRange('A', 'D' + 1)}, + {1, rc::gen::inRange(0x80, 0x800)}, + {1, Elements({0x4E2D, 0x1F600, 0xE9, 0x0130, 0x00DF})}})), + FromCodepoints); +} + +//! Generator for strings to match against LIKE patterns (same alphabet) +rc::Gen GenLikeSubject() { + return rc::gen::map(rc::gen::container>(rc::gen::weightedOneOf( + {{1, Elements({'%', '_'})}, + {1, Elements({'\\', '$', '#'})}, + {8, rc::gen::inRange('a', 'd' + 1)}, + {1, rc::gen::inRange('A', 'D' + 1)}, + {1, rc::gen::inRange(0x80, 0x800)}, + {1, Elements({0x4E2D, 0x1F600, 0xE9, 0x0130, 0x00DF})}})), + FromCodepoints); +} + +string SqlString(const string &s) { + return Value(s).ToSQLString(); +} + +idx_t Levenshtein(const Codepoints &a, const Codepoints &b) { + vector prev(b.size() + 1), cur(b.size() + 1); + for (idx_t j = 0; j <= b.size(); j++) { + prev[j] = j; + } + for (idx_t i = 1; i <= a.size(); i++) { + cur[0] = i; + for (idx_t j = 1; j <= b.size(); j++) { + idx_t cost = a[i - 1] == b[j - 1] ? 0 : 1; + cur[j] = std::min(std::min(prev[j] + 1, cur[j - 1] + 1), prev[j - 1] + cost); + } + std::swap(prev, cur); + } + return prev[b.size()]; +} + +idx_t DamerauLevenshtein(const Codepoints &a, const Codepoints &b) { + // true (unrestricted) Damerau-Levenshtein distance, which is what DuckDB implements + idx_t n = a.size(), m = b.size(); + idx_t inf = n + m; + std::map last_row; + vector> d(n + 2, vector(m + 2, 0)); + d[0][0] = inf; + for (idx_t i = 0; i <= n; i++) { + d[i + 1][0] = inf; + d[i + 1][1] = i; + } + for (idx_t j = 0; j <= m; j++) { + d[0][j + 1] = inf; + d[1][j + 1] = j; + } + for (idx_t i = 1; i <= n; i++) { + idx_t last_col = 0; + for (idx_t j = 1; j <= m; j++) { + idx_t i2 = last_row.count(b[j - 1]) ? last_row[b[j - 1]] : 0; + idx_t j2 = last_col; + idx_t cost = 0; + if (a[i - 1] == b[j - 1]) { + last_col = j; + } else { + cost = 1; + } + d[i + 1][j + 1] = std::min(std::min(d[i][j] + cost, std::min(d[i + 1][j] + 1, d[i][j + 1] + 1)), + d[i2][j2] + (i - i2 - 1) + 1 + (j - j2 - 1)); + } + last_row[a[i - 1]] = i; + } + return d[n + 1][m + 1]; +} + +vector SplitOracle(const string &s, const string &sep) { + vector result; + idx_t start = 0; + while (true) { + auto pos = s.find(sep, start); + if (pos == string::npos) { + result.push_back(s.substr(start)); + break; + } + result.push_back(s.substr(start, pos - start)); + start = pos + sep.size(); + } + return result; +} + +string ReplaceOracle(const string &s, const string &from, const string &to) { + if (from.empty()) { + return s; + } + string result; + idx_t start = 0; + while (true) { + auto pos = s.find(from, start); + if (pos == string::npos) { + result += s.substr(start); + break; + } + result += s.substr(start, pos - start) + to; + start = pos + from.size(); + } + return result; +} + +Value ListOfStrings(const vector &strings) { + vector values; + for (auto &s : strings) { + values.emplace_back(s); + } + return Value::LIST(LogicalType::VARCHAR, std::move(values)); +} + +} // namespace + +TEST_CASE("LIKE matches the reference implementation", "[property][strings]") { + PropDB db; + rc::prop("s LIKE p (parameterized and constant pattern)", [&] { + auto s = *GenLikeSubject(); + auto p = *GenLikePattern(); + auto expected = LikeOracle(ToCodepoints(s), ToCodepoints(p), -1); + RC_CLASSIFY(expected, "match"); + // parameterized (generic vectorized path) + auto actual = db.Scalar("SELECT $1 LIKE $2", {Value(s), Value(p)}); + PROP_ASSERT_VALUES_EQUAL(actual, Value::BOOLEAN(expected)); + // constant pattern (optimizer may rewrite to prefix/suffix/contains) + auto actual_const = db.Scalar("SELECT $1 LIKE " + SqlString(p), {Value(s)}); + PROP_ASSERT_VALUES_EQUAL(actual_const, Value::BOOLEAN(expected)); + // NOT LIKE + auto actual_not = db.Scalar("SELECT $1 NOT LIKE " + SqlString(p), {Value(s)}); + PROP_ASSERT_VALUES_EQUAL(actual_not, Value::BOOLEAN(!expected)); + }); + rc::prop("s LIKE p ESCAPE e", [&] { + auto s = *GenLikeSubject(); + auto p = *GenLikePattern(); + auto escape = *rc::gen::element('\\', '$', '#'); + auto pcps = ToCodepoints(p); + // DuckDB errors when the pattern ends with the escape character + // (RC_PRE uses expression decomposition which breaks || short-circuiting, so compute the bool first) + bool valid_pattern = pcps.empty() || pcps.back() != uint32_t(escape); + RC_PRE(valid_pattern); + auto expected = LikeOracle(ToCodepoints(s), pcps, escape); + RC_CLASSIFY(expected, "match"); + auto esc = string(1, escape); + auto actual = db.Scalar("SELECT $1 LIKE $2 ESCAPE $3", {Value(s), Value(p), Value(esc)}); + PROP_ASSERT_VALUES_EQUAL(actual, Value::BOOLEAN(expected)); + auto actual_const = db.Scalar("SELECT $1 LIKE " + SqlString(p) + " ESCAPE " + SqlString(esc), {Value(s)}); + PROP_ASSERT_VALUES_EQUAL(actual_const, Value::BOOLEAN(expected)); + auto actual_fun = db.Scalar("SELECT like_escape($1, $2, $3)", {Value(s), Value(p), Value(esc)}); + PROP_ASSERT_VALUES_EQUAL(actual_fun, Value::BOOLEAN(expected)); + }); + rc::prop("s ILIKE p (ASCII)", [&] { + auto s = *rc::gen::suchThat(GenLikeSubject(), IsAscii); + auto p = *rc::gen::suchThat(GenLikePattern(), IsAscii); + auto escape = *rc::gen::element(-1, '\\', '$', '#'); + auto pcps = ToCodepoints(p); + bool valid_pattern = pcps.empty() || escape < 0 || pcps.back() != uint32_t(escape); + RC_PRE(valid_pattern); + auto expected = LikeOracle(LowerAscii(ToCodepoints(s)), LowerAscii(pcps), escape); + RC_CLASSIFY(expected, "match"); + string escape_clause = escape < 0 ? "" : " ESCAPE " + SqlString(string(1, char(escape))); + auto actual_const = db.Scalar("SELECT $1 ILIKE " + SqlString(p) + escape_clause, {Value(s)}); + PROP_ASSERT_VALUES_EQUAL(actual_const, Value::BOOLEAN(expected)); + if (escape < 0) { + auto actual = db.Scalar("SELECT $1 ILIKE $2", {Value(s), Value(p)}); + PROP_ASSERT_VALUES_EQUAL(actual, Value::BOOLEAN(expected)); + } else { + auto actual = + db.Scalar("SELECT $1 ILIKE $2 ESCAPE $3", {Value(s), Value(p), Value(string(1, char(escape)))}); + PROP_ASSERT_VALUES_EQUAL(actual, Value::BOOLEAN(expected)); + auto actual_fun = + db.Scalar("SELECT ilike_escape($1, $2, $3)", {Value(s), Value(p), Value(string(1, char(escape)))}); + PROP_ASSERT_VALUES_EQUAL(actual_fun, Value::BOOLEAN(expected)); + } + }); +} + +TEST_CASE("String functions match reference implementations", "[property][strings]") { + PropDB db; + rc::prop("length/strlen/substring/left/right", [&] { + auto s = *GenUtf8String(); + auto cps = ToCodepoints(s); + auto n = int64_t(cps.size()); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT length($1)", {Value(s)}), Value::BIGINT(n)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT strlen($1)", {Value(s)}), Value::BIGINT(int64_t(s.size()))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT octet_length(encode($1))", {Value(s)}), + Value::BIGINT(int64_t(s.size()))); + + auto start = *rc::gen::inRange(-n - 3, n + 4); + auto len = *rc::gen::inRange(-n - 3, n + 4); + // substring(s, start, len): 1-based positions; negative start counts from the end (-1 = last char); + // negative len takes the |len| characters before start + int64_t eff = start < 0 ? n + start + 1 : start; + int64_t lo = len >= 0 ? eff : eff + len; + int64_t hi = len >= 0 ? eff + len : eff; + Codepoints expected; + for (int64_t pos = lo; pos < hi; pos++) { + if (pos >= 1 && pos <= n) { + expected.push_back(cps[pos - 1]); + } + } + auto actual = db.Scalar("SELECT substring($1, $2, $3)", {Value(s), Value::BIGINT(start), Value::BIGINT(len)}); + PROP_ASSERT_VALUES_EQUAL(actual, Value(FromCodepoints(expected))); + + auto k = *rc::gen::inRange(-n - 2, n + 3); + // left(s, k): first k chars; negative k: all but the last |k| chars + Codepoints left_expected, right_expected; + int64_t left_count = k >= 0 ? std::min(k, n) : std::max(0, n + k); + int64_t right_count = k >= 0 ? std::min(k, n) : std::max(0, n + k); + left_expected.assign(cps.begin(), cps.begin() + left_count); + right_expected.assign(cps.end() - right_count, cps.end()); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT left($1, $2)", {Value(s), Value::BIGINT(k)}), + Value(FromCodepoints(left_expected))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT right($1, $2)", {Value(s), Value::BIGINT(k)}), + Value(FromCodepoints(right_expected))); + }); + + rc::prop("strpos/contains/starts_with/ends_with/replace/split", [&] { + auto s = *GenUtf8String(); + // sub is either a random string or an actual substring of s + auto sub = *rc::gen::oneOf(GenUtf8String(), rc::gen::exec([&] { + auto cps = ToCodepoints(s); + auto a = *rc::gen::inRange(0, cps.size() + 1); + auto b = *rc::gen::inRange(a, cps.size() + 1); + return FromCodepoints(Codepoints(cps.begin() + a, cps.begin() + b)); + })); + auto cps = ToCodepoints(s); + auto pos = s.find(sub); + RC_CLASSIFY(pos != string::npos, "found"); + // strpos is 1-based and counts characters + int64_t expected_pos = 0; + if (pos != string::npos) { + expected_pos = int64_t(ToCodepoints(s.substr(0, pos)).size()) + 1; + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT strpos($1, $2)", {Value(s), Value(sub)}), + Value::BIGINT(expected_pos)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT instr($1, $2)", {Value(s), Value(sub)}), + Value::BIGINT(expected_pos)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT position($2 IN $1)", {Value(s), Value(sub)}), + Value::BIGINT(expected_pos)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT contains($1, $2)", {Value(s), Value(sub)}), + Value::BOOLEAN(pos != string::npos)); + // constant-folded / optimized variants + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT contains($1, " + SqlString(sub) + ")", {Value(s)}), + Value::BOOLEAN(pos != string::npos)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT starts_with($1, $2)", {Value(s), Value(sub)}), + Value::BOOLEAN(s.compare(0, sub.size(), sub) == 0)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT prefix($1, $2)", {Value(s), Value(sub)}), + Value::BOOLEAN(s.compare(0, sub.size(), sub) == 0)); + bool ends = s.size() >= sub.size() && s.compare(s.size() - sub.size(), sub.size(), sub) == 0; + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT ends_with($1, $2)", {Value(s), Value(sub)}), Value::BOOLEAN(ends)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT suffix($1, $2)", {Value(s), Value(sub)}), Value::BOOLEAN(ends)); + + auto to = *GenUtf8String(); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT replace($1, $2, $3)", {Value(s), Value(sub), Value(to)}), + Value(ReplaceOracle(s, sub, to))); + + if (!sub.empty()) { + auto parts = SplitOracle(s, sub); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT string_split($1, $2)", {Value(s), Value(sub)}), + ListOfStrings(parts)); + PROP_ASSERT_VALUES_EQUAL( + db.Scalar("SELECT array_to_string(string_split($1, $2), $2)", {Value(s), Value(sub)}), Value(s)); + auto idx = *rc::gen::inRange(-int64_t(parts.size()) - 1, int64_t(parts.size()) + 2); + string expected_part; + if (idx > 0 && idx <= int64_t(parts.size())) { + expected_part = parts[idx - 1]; + } else if (idx < 0 && -idx <= int64_t(parts.size())) { + expected_part = parts[parts.size() + idx]; + } + PROP_ASSERT_VALUES_EQUAL( + db.Scalar("SELECT split_part($1, $2, $3)", {Value(s), Value(sub), Value::BIGINT(idx)}), + Value(expected_part)); + } + }); + + rc::prop("repeat/reverse/upper/lower/lpad/rpad/translate", [&] { + auto s = *GenUtf8String(); + auto cps = ToCodepoints(s); + auto n = *rc::gen::inRange(-3, 6); + string repeated; + for (int64_t i = 0; i < n; i++) { + repeated += s; + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT repeat($1, $2)", {Value(s), Value::BIGINT(n)}), Value(repeated)); + + auto ascii = *GenAsciiString(); + string upper = ascii, lower = ascii; + for (auto &c : upper) { + c = char(toupper(c)); + } + for (auto &c : lower) { + c = char(tolower(c)); + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT upper($1)", {Value(ascii)}), Value(upper)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT lower($1)", {Value(ascii)}), Value(lower)); + // reverse on ASCII (grapheme clusters == code points, except CRLF) + RC_PRE(ascii.find("\r\n") == string::npos); + auto reversed = ascii; + std::reverse(reversed.begin(), reversed.end()); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT reverse($1)", {Value(ascii)}), Value(reversed)); + + auto width = *rc::gen::inRange(-2, int64_t(cps.size()) + 5); + auto fill = *rc::gen::suchThat(GenUtf8String(), [](const string &f) { return !f.empty(); }); + auto fill_cps = ToCodepoints(fill); + Codepoints lpad, rpad; + if (width <= int64_t(cps.size())) { + auto cnt = std::max(0, width); + lpad.assign(cps.begin(), cps.begin() + cnt); + rpad = lpad; + } else { + idx_t missing = idx_t(width) - cps.size(); + Codepoints padding; + while (padding.size() < missing) { + for (auto c : fill_cps) { + if (padding.size() >= missing) { + break; + } + padding.push_back(c); + } + } + lpad = padding; + lpad.insert(lpad.end(), cps.begin(), cps.end()); + rpad = cps; + rpad.insert(rpad.end(), padding.begin(), padding.end()); + } + PROP_ASSERT_VALUES_EQUAL( + db.Scalar("SELECT lpad($1, $2, $3)", {Value(s), Value::INTEGER(int32_t(width)), Value(fill)}), + Value(FromCodepoints(lpad))); + PROP_ASSERT_VALUES_EQUAL( + db.Scalar("SELECT rpad($1, $2, $3)", {Value(s), Value::INTEGER(int32_t(width)), Value(fill)}), + Value(FromCodepoints(rpad))); + + // translate(s, from, to): replace each char in from with the char at the same position in to (or remove) + auto from = *GenUtf8String(); + auto to = *GenUtf8String(); + auto from_cps = ToCodepoints(from), to_cps = ToCodepoints(to); + Codepoints translated; + for (auto c : cps) { + idx_t i = 0; + for (; i < from_cps.size(); i++) { + if (from_cps[i] == c) { + break; + } + } + if (i == from_cps.size()) { + translated.push_back(c); + } else if (i < to_cps.size()) { + translated.push_back(to_cps[i]); + } + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT translate($1, $2, $3)", {Value(s), Value(from), Value(to)}), + Value(FromCodepoints(translated))); + }); + + rc::prop("trim/ltrim/rtrim with characters", [&] { + auto s = *GenUtf8String(); + auto chars = *GenUtf8String(); + auto cps = ToCodepoints(s); + auto set = ToCodepoints(chars); + auto in_set = [&](uint32_t c) { + return std::find(set.begin(), set.end(), c) != set.end(); + }; + idx_t l = 0, r = cps.size(); + while (l < r && in_set(cps[l])) { + l++; + } + while (r > l && in_set(cps[r - 1])) { + r--; + } + idx_t r2 = cps.size(); + while (r2 > 0 && in_set(cps[r2 - 1])) { + r2--; + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT trim($1, $2)", {Value(s), Value(chars)}), + Value(FromCodepoints(Codepoints(cps.begin() + l, cps.begin() + r)))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT ltrim($1, $2)", {Value(s), Value(chars)}), + Value(FromCodepoints(Codepoints(cps.begin() + l, cps.end())))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT rtrim($1, $2)", {Value(s), Value(chars)}), + Value(FromCodepoints(Codepoints(cps.begin(), cps.begin() + r2)))); + }); + + rc::prop("levenshtein/damerau_levenshtein/hamming", [&] { + auto a = *GenUtf8String(); + auto b = + *rc::gen::oneOf(GenUtf8String(), rc::gen::map(GenUtf8String(), [&](const string &x) { return a + x; })); + // NOTE: these functions operate on bytes, not characters (see FINDINGS.md) + Codepoints acps(a.begin(), a.end()), bcps(b.begin(), b.end()); + RC_PRE(acps.size() < 200 && bcps.size() < 200); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT levenshtein($1, $2)", {Value(a), Value(b)}), + Value::BIGINT(int64_t(Levenshtein(acps, bcps)))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT damerau_levenshtein($1, $2)", {Value(a), Value(b)}), + Value::BIGINT(int64_t(DamerauLevenshtein(acps, bcps)))); + // hamming() rejects empty strings + if (acps.size() == bcps.size() && !acps.empty()) { + int64_t dist = 0; + for (idx_t i = 0; i < acps.size(); i++) { + dist += acps[i] != bcps[i]; + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT hamming($1, $2)", {Value(a), Value(b)}), Value::BIGINT(dist)); + } + }); + + rc::prop("encoding round trips: hex/base64/url/encode/nfc", [&] { + auto bytes = *GenBytes(); + auto blob = Value::BLOB(const_data_ptr_cast(bytes.data()), bytes.size()); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT unhex(hex($1))", {blob}), blob); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT from_base64(base64($1))", {blob}), blob); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT from_base64(to_base64($1))", {blob}), blob); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT $1::BLOB::VARCHAR::BLOB", {blob}), blob); + auto s = *GenUtf8String(); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT decode(encode($1))", {Value(s)}), Value(s)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT url_decode(url_encode($1))", {Value(s)}), Value(s)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT nfc_normalize(nfc_normalize($1)) = nfc_normalize($1)", {Value(s)}), + Value::BOOLEAN(true)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT format('{}', $1)", {Value(s)}), Value(s)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT printf('%s', $1)", {Value(s)}), Value(s)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT concat($1, $2)", {Value(s), Value(bytes.empty() ? "" : "x")}), + Value(s + (bytes.empty() ? "" : "x"))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT $1 || $2", {Value(s), Value(s)}), Value(s + s)); + }); + + rc::prop("chr/ascii/unicode/ord", [&] { + auto cp = *rc::gen::weightedOneOf({{3, rc::gen::inRange(1, 0x80)}, + {3, rc::gen::inRange(0x80, 0xD800)}, + {2, rc::gen::inRange(0xE000, 0x110000)}}); + auto s = FromCodepoints({cp}); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT chr($1)", {Value::INTEGER(int32_t(cp))}), Value(s)); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT unicode($1)", {Value(s)}), Value::INTEGER(int32_t(cp))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT ord($1)", {Value(s)}), Value::INTEGER(int32_t(cp))); + auto tail = *GenUtf8String(); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT unicode($1)", {Value(s + tail)}), Value::INTEGER(int32_t(cp))); + auto ascii = *GenAsciiString(); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT ascii($1)", {Value(ascii)}), + Value::INTEGER(ascii.empty() ? 0 : int32_t(uint8_t(ascii[0])))); + }); + + rc::prop("numeric formatting: format/printf/to_base/bin/hex", [&] { + auto v = *GenInt(); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT format('{}', $1)", {Value::BIGINT(v)}), Value(std::to_string(v))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT format('{:d}', $1)", {Value::BIGINT(v)}), Value(std::to_string(v))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT printf('%d', $1)", {Value::BIGINT(v)}), Value(std::to_string(v))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT printf('%lld', $1)", {Value::BIGINT(v)}), Value(std::to_string(v))); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT $1::VARCHAR", {Value::BIGINT(v)}), Value(std::to_string(v))); + char buf[64]; + snprintf(buf, sizeof(buf), "%llX", (unsigned long long)v); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT hex($1)", {Value::BIGINT(v)}), Value(string(buf))); + snprintf(buf, sizeof(buf), "%llx", (unsigned long long)v); + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT printf('%x', $1)", {Value::BIGINT(v)}), Value(string(buf))); + // bin + string bin; + uint64_t u = uint64_t(v); + if (u == 0) { + bin = "0"; + } + while (u) { + bin.insert(bin.begin(), char('0' + (u & 1))); + u >>= 1; + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT bin($1)", {Value::BIGINT(v)}), Value(bin)); + // to_base for non-negative values + auto base = *rc::gen::inRange(2, 37); + if (v >= 0) { + string digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + string expected; + uint64_t x = uint64_t(v); + if (x == 0) { + expected = "0"; + } + while (x) { + expected.insert(expected.begin(), digits[x % base]); + x /= base; + } + PROP_ASSERT_VALUES_EQUAL(db.Scalar("SELECT to_base($1, $2)", {Value::BIGINT(v), Value::INTEGER(base)}), + Value(expected)); + } + }); +} From e836861d40c6dc5893a54547f30f8ac9acac8375 Mon Sep 17 00:00:00 2001 From: peterxcli Date: Mon, 24 Aug 2026 12:23:36 +0800 Subject: [PATCH 2/2] Apply make format-fix to test/property Co-Authored-By: Claude Fable 5 --- test/property/CMakeLists.txt | 72 ++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/test/property/CMakeLists.txt b/test/property/CMakeLists.txt index 55a9308f2ab8..18cdcd134447 100644 --- a/test/property/CMakeLists.txt +++ b/test/property/CMakeLists.txt @@ -1,41 +1,60 @@ cmake_minimum_required(VERSION 3.15) project(duckdb_property_tests CXX) -# Property-based tests for DuckDB using RapidCheck (https://github.com/emil-e/rapidcheck). +# Property-based tests for DuckDB using RapidCheck +# (https://github.com/emil-e/rapidcheck). # -# This is a standalone project that links against an existing DuckDB build directory: +# This is a standalone project that links against an existing DuckDB build +# directory: # -# cmake -S test/property -B build/property -DDUCKDB_BUILD_DIR=build/relassert -# cmake --build build/property -# RC_PARAMS="max_success=500" build/property/property_test +# cmake -S test/property -B build/property -DDUCKDB_BUILD_DIR=build/relassert +# cmake --build build/property RC_PARAMS="max_success=500" +# build/property/property_test # -# RapidCheck is fetched via FetchContent unless RAPIDCHECK_SOURCE_DIR points to a checkout. +# RapidCheck is fetched via FetchContent unless RAPIDCHECK_SOURCE_DIR points to +# a checkout. set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) get_filename_component(DUCKDB_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) -set(DUCKDB_BUILD_DIR "${DUCKDB_ROOT}/build/relassert" CACHE PATH "DuckDB build directory containing src/libduckdb") -set(RAPIDCHECK_SOURCE_DIR "" CACHE PATH "Local RapidCheck checkout (optional)") -option(PROPERTY_TEST_SANITIZE "Build with -fsanitize=address,undefined (required when linking a sanitized libduckdb)" ON) +set(DUCKDB_BUILD_DIR + "${DUCKDB_ROOT}/build/relassert" + CACHE PATH "DuckDB build directory containing src/libduckdb") +set(RAPIDCHECK_SOURCE_DIR + "" + CACHE PATH "Local RapidCheck checkout (optional)") +option( + PROPERTY_TEST_SANITIZE + "Build with -fsanitize=address,undefined (required when linking a sanitized libduckdb)" + ON) if(NOT EXISTS "${DUCKDB_BUILD_DIR}/src") - message(FATAL_ERROR "DUCKDB_BUILD_DIR=${DUCKDB_BUILD_DIR} does not look like a DuckDB build directory") + message( + FATAL_ERROR + "DUCKDB_BUILD_DIR=${DUCKDB_BUILD_DIR} does not look like a DuckDB build directory" + ) endif() # Locate the shared library -find_library(DUCKDB_LIBRARY NAMES duckdb PATHS "${DUCKDB_BUILD_DIR}/src" NO_DEFAULT_PATH) +find_library( + DUCKDB_LIBRARY + NAMES duckdb + PATHS "${DUCKDB_BUILD_DIR}/src" + NO_DEFAULT_PATH) if(NOT DUCKDB_LIBRARY) message(FATAL_ERROR "Could not find libduckdb in ${DUCKDB_BUILD_DIR}/src") endif() # RapidCheck if(RAPIDCHECK_SOURCE_DIR) - add_subdirectory("${RAPIDCHECK_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/rapidcheck" EXCLUDE_FROM_ALL) + add_subdirectory("${RAPIDCHECK_SOURCE_DIR}" + "${CMAKE_CURRENT_BINARY_DIR}/rapidcheck" EXCLUDE_FROM_ALL) else() include(FetchContent) - FetchContent_Declare(rapidcheck + FetchContent_Declare( + rapidcheck GIT_REPOSITORY https://github.com/emil-e/rapidcheck.git GIT_TAG master GIT_SHALLOW TRUE) @@ -43,22 +62,27 @@ else() set(RAPIDCHECK_SOURCE_DIR "${rapidcheck_SOURCE_DIR}") endif() -# RapidCheck's Catch adapter includes ; DuckDB vendors Catch v2 at third_party/catch/catch.hpp. +# RapidCheck's Catch adapter includes ; DuckDB vendors Catch +# v2 at third_party/catch/catch.hpp. file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/catch_shim/catch2") -file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/catch_shim/catch2/catch.hpp" "#include \"${DUCKDB_ROOT}/third_party/catch/catch.hpp\"\n") +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/catch_shim/catch2/catch.hpp" + "#include \"${DUCKDB_ROOT}/third_party/catch/catch.hpp\"\n") file(GLOB PROPERTY_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp") add_executable(property_test ${PROPERTY_TEST_SOURCES}) -target_include_directories(property_test PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/include" - "${DUCKDB_ROOT}/src/include" - "${DUCKDB_ROOT}/third_party/catch" - "${DUCKDB_ROOT}/third_party/fmt/include" - "${CMAKE_CURRENT_BINARY_DIR}/catch_shim" - "${RAPIDCHECK_SOURCE_DIR}/extras/catch/include") +target_include_directories( + property_test + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${DUCKDB_ROOT}/src/include" + "${DUCKDB_ROOT}/third_party/catch" + "${DUCKDB_ROOT}/third_party/fmt/include" + "${CMAKE_CURRENT_BINARY_DIR}/catch_shim" + "${RAPIDCHECK_SOURCE_DIR}/extras/catch/include") target_link_libraries(property_test PRIVATE rapidcheck "${DUCKDB_LIBRARY}") -target_compile_definitions(property_test PRIVATE DUCKDB_ROOT_DIR="${DUCKDB_ROOT}") +target_compile_definitions(property_test + PRIVATE DUCKDB_ROOT_DIR="${DUCKDB_ROOT}") if(PROPERTY_TEST_SANITIZE) - target_compile_options(property_test PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) + target_compile_options(property_test PRIVATE -fsanitize=address,undefined + -fno-omit-frame-pointer) target_link_options(property_test PRIVATE -fsanitize=address,undefined) endif()