From 940711ae10393e55c2ff5cccbd4355ce3ccd4606 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 26 Apr 2026 21:15:17 +0200 Subject: [PATCH 1/9] [Lightweight] Vendor tui/ markdown table renderer + libunicode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/Lightweight/tui/ vendored from endo's TUI module: MarkdownTable (grapheme-aware width, alignment, wrap, truncate), MarkdownInline (CommonMark inline code spans), SgrBuilder (ANSI SGR escape builder), and a slim Style/RgbColor extracted from endo's TerminalOutput. Upstream camelCase API names are preserved (with NOLINTBEGIN(readability-identifier-naming) blocks) so future re-syncs against endo stay mechanical. Pulls libunicode 0.9.0 via cmake/FindOrFetchLibunicode.cmake — system package first (find_package), CPM fallback otherwise. Linked PRIVATE + BUILD_INTERFACE so the dependency stays internal to the static library and does not propagate into the installed export set. Used by an upcoming dbtool diff renderer. Signed-off-by: Christian Parpart --- cmake/FindOrFetchLibunicode.cmake | 33 ++ src/Lightweight/CMakeLists.txt | 37 ++ src/Lightweight/tui/MarkdownInline.hpp | 75 ++++ src/Lightweight/tui/MarkdownTable.cpp | 463 +++++++++++++++++++++++++ src/Lightweight/tui/MarkdownTable.hpp | 48 +++ src/Lightweight/tui/SgrBuilder.cpp | 101 ++++++ src/Lightweight/tui/SgrBuilder.hpp | 31 ++ src/Lightweight/tui/Style.hpp | 53 +++ src/Lightweight/tui/Unicode.cpp | 27 ++ src/Lightweight/tui/Unicode.hpp | 52 +++ 10 files changed, 920 insertions(+) create mode 100644 cmake/FindOrFetchLibunicode.cmake create mode 100644 src/Lightweight/tui/MarkdownInline.hpp create mode 100644 src/Lightweight/tui/MarkdownTable.cpp create mode 100644 src/Lightweight/tui/MarkdownTable.hpp create mode 100644 src/Lightweight/tui/SgrBuilder.cpp create mode 100644 src/Lightweight/tui/SgrBuilder.hpp create mode 100644 src/Lightweight/tui/Style.hpp create mode 100644 src/Lightweight/tui/Unicode.cpp create mode 100644 src/Lightweight/tui/Unicode.hpp diff --git a/cmake/FindOrFetchLibunicode.cmake b/cmake/FindOrFetchLibunicode.cmake new file mode 100644 index 000000000..e355cc53a --- /dev/null +++ b/cmake/FindOrFetchLibunicode.cmake @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Locates libunicode — first via find_package, falling back to CPM/FetchContent +# from upstream. Defines the imported target `unicode::unicode` (or, on older +# packagings, `unicode::core`) that callers can link against. +# +# Pinned to the same version as endo's `tui` module so the vendored +# `MarkdownTable.cpp` keeps compiling against a known-good API. + +set(LIBUNICODE_REQUIRED_VERSION "0.9.0") + +if(NOT TARGET unicode::unicode AND NOT TARGET unicode::core) + find_package(libunicode ${LIBUNICODE_REQUIRED_VERSION} QUIET) +endif() + +if(TARGET unicode::unicode OR TARGET unicode::core) + message(STATUS "libunicode: using system package") +else() + message(STATUS "libunicode: not found, fetching v${LIBUNICODE_REQUIRED_VERSION} via CPM") + CPMAddPackage( + NAME libunicode + GITHUB_REPOSITORY contour-terminal/libunicode + GIT_TAG v${LIBUNICODE_REQUIRED_VERSION} + OPTIONS + "LIBUNICODE_TESTING OFF" + "LIBUNICODE_BENCHMARK OFF" + "LIBUNICODE_TOOLS OFF" + "LIBUNICODE_EXAMPLES OFF" + "BUILD_SHARED_LIBS OFF" + EXCLUDE_FROM_ALL YES + SYSTEM YES + ) +endif() diff --git a/src/Lightweight/CMakeLists.txt b/src/Lightweight/CMakeLists.txt index 1def367d5..7f9ec3fda 100644 --- a/src/Lightweight/CMakeLists.txt +++ b/src/Lightweight/CMakeLists.txt @@ -99,6 +99,12 @@ set(HEADER_FILES SqlServerType.hpp SqlStatement.hpp TracyProfiler.hpp + + tui/MarkdownInline.hpp + tui/MarkdownTable.hpp + tui/SgrBuilder.hpp + tui/Style.hpp + tui/Unicode.hpp ) set(SOURCE_FILES @@ -175,14 +181,45 @@ target_sources(LightweightTools PRIVATE ) target_link_libraries(LightweightTools PUBLIC Lightweight) +# LightweightTui — vendored terminal/markdown rendering helpers (tui/). +# Kept as a separate static library so its symbols don't need to cross the +# Lightweight DLL ABI on Windows. libunicode stays a private detail. +add_library(LightweightTui STATIC + tui/MarkdownInline.hpp + tui/MarkdownTable.cpp + tui/MarkdownTable.hpp + tui/SgrBuilder.cpp + tui/SgrBuilder.hpp + tui/Style.hpp + tui/Unicode.cpp + tui/Unicode.hpp +) +add_library(Lightweight::Tui ALIAS LightweightTui) +target_compile_features(LightweightTui PUBLIC cxx_std_23) +target_include_directories(LightweightTui PUBLIC + $ +) +if(NOT WIN32) + set_target_properties(LightweightTui PROPERTIES POSITION_INDEPENDENT_CODE ON) +endif() + +include("${CMAKE_SOURCE_DIR}/cmake/FindOrFetchLibunicode.cmake") +if(TARGET unicode::unicode) + target_link_libraries(LightweightTui PRIVATE unicode::unicode) +elseif(TARGET unicode::core) + target_link_libraries(LightweightTui PRIVATE unicode::core) +endif() + if(CLANG_TIDY_EXE) set_target_properties(Lightweight PROPERTIES CXX_CLANG_TIDY "${CLANG_TIDY_EXE}") set_target_properties(LightweightTools PROPERTIES CXX_CLANG_TIDY "${CLANG_TIDY_EXE}") + set_target_properties(LightweightTui PROPERTIES CXX_CLANG_TIDY "${CLANG_TIDY_EXE}") endif() # Enable coverage instrumentation for library targets enable_coverage_for_target(Lightweight) enable_coverage_for_target(LightweightTools) +enable_coverage_for_target(LightweightTui) target_include_directories(Lightweight PUBLIC $ diff --git a/src/Lightweight/tui/MarkdownInline.hpp b/src/Lightweight/tui/MarkdownInline.hpp new file mode 100644 index 000000000..9cc63e12a --- /dev/null +++ b/src/Lightweight/tui/MarkdownInline.hpp @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +// Vendored from endo/src/tui/MarkdownInline.hpp. + +#pragma once + +#include +#include + +namespace tui +{ + +// NOLINTBEGIN(readability-identifier-naming) +// Vendored upstream API names (camelCase) preserved verbatim — keeps re-syncs mechanical. + +/// @brief Result of finding a CommonMark inline code span. +struct InlineCodeSpan +{ + std::string_view content; ///< The code content between backtick sequences. + std::size_t endPos; ///< Position in the text after the closing backtick sequence. +}; + +/// @brief Applies CommonMark space stripping to inline code content. +[[nodiscard]] constexpr auto stripInlineCodeSpaces(std::string_view content) noexcept -> std::string_view +{ + if (content.size() >= 2 && content.front() == ' ' && content.back() == ' ') + { + auto allSpaces = true; + for (auto ch: content) + { + if (ch != ' ') + { + allSpaces = false; + break; + } + } + if (!allSpaces) + return content.substr(1, content.size() - 2); + } + return content; +} + +/// @brief Finds the end of a CommonMark inline code span starting at pos. +[[nodiscard]] constexpr auto findInlineCodeEnd(std::string_view text, std::size_t pos) + -> std::optional +{ + auto const tickStart = pos; + while (pos < text.size() && text[pos] == '`') + ++pos; + auto const tickCount = pos - tickStart; + + auto searchPos = pos; + while (searchPos < text.size()) + { + auto const closeStart = text.find('`', searchPos); + if (closeStart == std::string_view::npos) + break; + + auto closeEnd = closeStart; + while (closeEnd < text.size() && text[closeEnd] == '`') + ++closeEnd; + + if (closeEnd - closeStart == tickCount) + { + auto const content = text.substr(pos, closeStart - pos); + return InlineCodeSpan { .content = stripInlineCodeSpaces(content), .endPos = closeEnd }; + } + searchPos = closeEnd; + } + + return std::nullopt; +} + +// NOLINTEND(readability-identifier-naming) + +} // namespace tui diff --git a/src/Lightweight/tui/MarkdownTable.cpp b/src/Lightweight/tui/MarkdownTable.cpp new file mode 100644 index 000000000..1838bebcb --- /dev/null +++ b/src/Lightweight/tui/MarkdownTable.cpp @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: Apache-2.0 +// Vendored from endo/src/tui/MarkdownTable.cpp. + +#include "MarkdownInline.hpp" +#include "MarkdownTable.hpp" +#include "Unicode.hpp" + +#if defined(__clang__) + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wold-style-cast" +#endif +#include +#if defined(__clang__) + #pragma clang diagnostic pop +#endif + +#include + +namespace tui +{ + +// NOLINTBEGIN(readability-identifier-naming, readability-function-cognitive-complexity) +// Vendored upstream API names (camelCase) preserved verbatim — keeps re-syncs mechanical. + +namespace +{ + auto trim(std::string_view sv) -> std::string_view + { + auto const start = sv.find_first_not_of(" \t"); + if (start == std::string_view::npos) + return {}; + auto const end = sv.find_last_not_of(" \t"); + return sv.substr(start, end - start + 1); + } + + auto displayWidth(std::string_view text) -> int + { + auto width = 0; + auto segmenter = unicode::utf8_grapheme_segmenter(text); + for (auto const& cluster: segmenter) + width += graphemeClusterWidth(cluster); + return width; + } +} // namespace + +auto detectTableRow(std::string_view line) -> bool +{ + auto const trimmed = trim(line); + return !trimmed.empty() && trimmed.front() == '|'; +} + +auto detectTableSeparator(std::string_view line) -> bool +{ + auto const trimmed = trim(line); + if (trimmed.empty() || trimmed.front() != '|') + return false; + + auto pos = std::size_t { 1 }; + auto foundCell = false; + + while (pos < trimmed.size()) + { + auto const pipePos = trimmed.find('|', pos); + auto const cellEnd = (pipePos != std::string_view::npos) ? pipePos : trimmed.size(); + auto const cell = trim(trimmed.substr(pos, cellEnd - pos)); + + if (!cell.empty()) + { + auto cellPos = std::size_t { 0 }; + if (cellPos < cell.size() && cell[cellPos] == ':') + ++cellPos; + + auto const dashStart = cellPos; + while (cellPos < cell.size() && cell[cellPos] == '-') + ++cellPos; + + if (cellPos == dashStart) + return false; + + if (cellPos < cell.size() && cell[cellPos] == ':') + ++cellPos; + + if (cellPos != cell.size()) + return false; + + foundCell = true; + } + + if (pipePos == std::string_view::npos) + break; + pos = pipePos + 1; + } + + return foundCell; +} + +auto splitTableRow(std::string_view line) -> std::vector +{ + auto const trimmed = trim(line); + auto result = std::vector {}; + + if (trimmed.empty() || trimmed.front() != '|') + return result; + + auto content = trimmed.substr(1); + if (!content.empty() && content.back() == '|') + content.remove_suffix(1); + + auto pos = std::size_t { 0 }; + while (pos <= content.size()) + { + auto const pipePos = content.find('|', pos); + auto const cellEnd = (pipePos != std::string_view::npos) ? pipePos : content.size(); + auto const cell = trim(content.substr(pos, cellEnd - pos)); + result.emplace_back(cell); + if (pipePos == std::string_view::npos) + break; + pos = pipePos + 1; + } + + return result; +} + +auto parseTableAlignments(std::string_view line) -> std::vector +{ + auto const cells = splitTableRow(line); + auto result = std::vector {}; + result.reserve(cells.size()); + + for (auto const& cell: cells) + { + auto const trimmed = trim(cell); + if (trimmed.empty()) + { + result.push_back(TableAlignment::Left); + continue; + } + + auto const leftColon = trimmed.front() == ':'; + auto const rightColon = trimmed.back() == ':'; + + if (leftColon && rightColon) + result.push_back(TableAlignment::Center); + else if (rightColon) + result.push_back(TableAlignment::Right); + else + result.push_back(TableAlignment::Left); + } + + return result; +} + +auto computeColumnWidths(ParsedTable const& table) -> std::vector +{ + auto widths = std::vector(table.columnCount, 3); + + for (std::size_t col = 0; col < table.columnCount; ++col) + { + if (col < table.headers.size()) + widths[col] = std::max(widths[col], inlineDisplayWidth(table.headers[col])); + + for (auto const& row: table.rows) + { + if (col < row.size()) + widths[col] = std::max(widths[col], inlineDisplayWidth(row[col])); + } + } + + return widths; +} + +auto alignCell(std::string_view text, int width, TableAlignment alignment) -> std::string +{ + auto const textWidth = displayWidth(text); + auto const padding = std::max(0, width - textWidth); + + switch (alignment) + { + case TableAlignment::Right: { + auto result = std::string(static_cast(padding), ' '); + result.append(text); + return result; + } + case TableAlignment::Center: { + auto const leftPad = padding / 2; + auto const rightPad = padding - leftPad; + auto result = std::string(static_cast(leftPad), ' '); + result.append(text); + result.append(static_cast(rightPad), ' '); + return result; + } + case TableAlignment::Left: + default: { + auto result = std::string(text); + result.append(static_cast(padding), ' '); + return result; + } + } +} + +void constrainColumnWidths(std::vector& widths, int maxTableWidth) +{ + if (widths.empty() || maxTableWidth <= 0) + return; + + auto const columnCount = static_cast(widths.size()); + auto const overhead = 1 + (columnCount * 3); + auto const availableContent = maxTableWidth - overhead; + + if (availableContent <= 0) + return; + + auto totalContent = 0; + for (auto w: widths) + totalContent += w; + + if (totalContent <= availableContent) + return; + + auto constexpr minWidth = 3; + auto excess = totalContent - availableContent; + + auto indices = std::vector(widths.size()); + for (std::size_t i = 0; i < indices.size(); ++i) + indices[i] = i; + std::ranges::sort(indices, [&](auto a, auto b) { return widths[a] > widths[b]; }); + + auto i = std::size_t { 0 }; + while (i < indices.size() && excess > 0) + { + auto const tierWidth = widths[indices[i]]; + auto j = i + 1; + while (j < indices.size() && widths[indices[j]] == tierWidth) + ++j; + auto const tierCount = static_cast(j - i); + + auto const nextTierWidth = (j < indices.size()) ? widths[indices[j]] : minWidth; + auto const target = std::max(nextTierWidth, minWidth); + auto const reductionPerCol = tierWidth - target; + auto const maxTierReduction = reductionPerCol * tierCount; + + if (maxTierReduction >= excess) + { + auto const perCol = excess / tierCount; + auto remainder = excess % tierCount; + for (auto k = i; k < j; ++k) + { + auto const extra = (remainder > 0) ? 1 : 0; + widths[indices[k]] -= perCol + extra; + if (remainder > 0) + --remainder; + } + excess = 0; + } + else + { + for (auto k = i; k < j; ++k) + widths[indices[k]] = target; + excess -= maxTierReduction; + + if (target <= minWidth) + break; + + continue; + } + i = j; + } +} + +auto wrapText(std::string_view text, int maxWidth) -> std::vector +{ + if (maxWidth <= 0) + return { std::string(text) }; + + auto const textW = inlineDisplayWidth(text); + if (textW <= maxWidth) + return { std::string(text) }; + + auto lines = std::vector {}; + auto currentLine = std::string {}; + auto currentWidth = 0; + auto pos = std::size_t { 0 }; + + while (pos < text.size()) + { + auto const wordStart = text.find_first_not_of(' ', pos); + if (wordStart == std::string_view::npos) + break; + + auto const wordEnd = text.find(' ', wordStart); + auto const word = text.substr( + wordStart, wordEnd == std::string_view::npos ? std::string_view::npos : wordEnd - wordStart); + auto const wordWidth = inlineDisplayWidth(word); + + if (currentLine.empty() && wordWidth > maxWidth) + { + auto segmenter = unicode::utf8_grapheme_segmenter(word); + auto chunk = std::string {}; + auto chunkWidth = 0; + for (auto const& cluster: segmenter) + { + auto const clusterW = graphemeClusterWidth(cluster); + if (chunkWidth + clusterW > maxWidth && !chunk.empty()) + { + lines.push_back(std::move(chunk)); + chunk.clear(); + chunkWidth = 0; + } + for (auto ch: cluster) + chunk += static_cast(ch); + chunkWidth += clusterW; + } + if (!chunk.empty()) + { + currentLine = std::move(chunk); + currentWidth = chunkWidth; + } + } + else if (currentLine.empty()) + { + currentLine = std::string(word); + currentWidth = wordWidth; + } + else if (currentWidth + 1 + wordWidth <= maxWidth) + { + currentLine += ' '; + currentLine += word; + currentWidth += 1 + wordWidth; + } + else + { + lines.push_back(std::move(currentLine)); + currentLine = std::string(word); + currentWidth = wordWidth; + } + + pos = (wordEnd == std::string_view::npos) ? text.size() : wordEnd; + } + + if (!currentLine.empty()) + lines.push_back(std::move(currentLine)); + + if (lines.empty()) + lines.emplace_back(); + + return lines; +} + +auto stripInlineMarkdown(std::string_view text) -> std::string +{ + auto result = std::string {}; + result.reserve(text.size()); + auto pos = std::size_t { 0 }; + + while (pos < text.size()) + { + if (text[pos] == '`') + { + if (auto const span = findInlineCodeEnd(text, pos)) + { + result.append(span->content); + pos = span->endPos; + continue; + } + } + + if (pos + 1 < text.size() && text[pos] == '*' && text[pos + 1] == '*') + { + auto const endBold = text.find("**", pos + 2); + if (endBold != std::string_view::npos) + { + result.append(text.substr(pos + 2, endBold - pos - 2)); + pos = endBold + 2; + continue; + } + } + + if (text[pos] == '*') + { + auto const endItalic = text.find('*', pos + 1); + if (endItalic != std::string_view::npos) + { + result.append(text.substr(pos + 1, endItalic - pos - 1)); + pos = endItalic + 1; + continue; + } + } + + if (pos + 1 < text.size() && text[pos] == '_' && text[pos + 1] == '_') + { + auto const endBold = text.find("__", pos + 2); + if (endBold != std::string_view::npos) + { + result.append(text.substr(pos + 2, endBold - pos - 2)); + pos = endBold + 2; + continue; + } + } + + if (text[pos] == '[') + { + auto const endBracket = text.find(']', pos + 1); + if (endBracket != std::string_view::npos && endBracket + 1 < text.size() + && text[endBracket + 1] == '(') + { + auto const endParen = text.find(')', endBracket + 2); + if (endParen != std::string_view::npos) + { + result.append(text.substr(pos + 1, endBracket - pos - 1)); + pos = endParen + 1; + continue; + } + } + } + + auto const nextSpecial = text.find_first_of("`*_[", pos + 1); + auto const end = (nextSpecial != std::string_view::npos) ? nextSpecial : text.size(); + result.append(text.substr(pos, end - pos)); + pos = end; + } + + return result; +} + +auto inlineDisplayWidth(std::string_view text) -> int +{ + return displayWidth(stripInlineMarkdown(text)); +} + +auto truncateToDisplayWidth(std::string_view text, int maxWidth) -> std::string +{ + if (maxWidth <= 0) + return {}; + + auto result = std::string {}; + auto width = 0; + auto segmenter = unicode::utf8_grapheme_segmenter(text); + for (auto const& cluster: segmenter) + { + auto const clusterW = graphemeClusterWidth(cluster); + if (width + clusterW > maxWidth) + { + if (maxWidth >= 1 && !result.empty()) + { + while (width > maxWidth - 1 && !result.empty()) + { + result.pop_back(); + width = displayWidth(result); + } + result += "\xe2\x80\xa6"; + } + return result; + } + for (auto ch: cluster) + result += static_cast(ch); + width += clusterW; + } + return result; +} + +// NOLINTEND(readability-identifier-naming, readability-function-cognitive-complexity) + +} // namespace tui diff --git a/src/Lightweight/tui/MarkdownTable.hpp b/src/Lightweight/tui/MarkdownTable.hpp new file mode 100644 index 000000000..da9709162 --- /dev/null +++ b/src/Lightweight/tui/MarkdownTable.hpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// Vendored from endo/src/tui/MarkdownTable.hpp. + +#pragma once + +#include +#include +#include +#include + +namespace tui +{ + +// NOLINTBEGIN(readability-identifier-naming) +// Vendored upstream API names (camelCase) preserved verbatim — keeps re-syncs mechanical. + +/// @brief Column alignment for table cells. +enum class TableAlignment : std::uint8_t +{ + Left, ///< Left-aligned (default). + Center, ///< Center-aligned. + Right, ///< Right-aligned. +}; + +/// @brief A fully parsed GFM-style pipe table. +struct ParsedTable +{ + std::vector headers; + std::vector alignments; + std::vector> rows; + std::size_t columnCount = 0; +}; + +[[nodiscard]] auto detectTableRow(std::string_view line) -> bool; +[[nodiscard]] auto detectTableSeparator(std::string_view line) -> bool; +[[nodiscard]] auto splitTableRow(std::string_view line) -> std::vector; +[[nodiscard]] auto parseTableAlignments(std::string_view line) -> std::vector; +[[nodiscard]] auto computeColumnWidths(ParsedTable const& table) -> std::vector; +[[nodiscard]] auto alignCell(std::string_view text, int width, TableAlignment alignment) -> std::string; +void constrainColumnWidths(std::vector& widths, int maxTableWidth); +[[nodiscard]] auto wrapText(std::string_view text, int maxWidth) -> std::vector; +[[nodiscard]] auto stripInlineMarkdown(std::string_view text) -> std::string; +[[nodiscard]] auto inlineDisplayWidth(std::string_view text) -> int; +[[nodiscard]] auto truncateToDisplayWidth(std::string_view text, int maxWidth) -> std::string; + +// NOLINTEND(readability-identifier-naming) + +} // namespace tui diff --git a/src/Lightweight/tui/SgrBuilder.cpp b/src/Lightweight/tui/SgrBuilder.cpp new file mode 100644 index 000000000..8c407eabf --- /dev/null +++ b/src/Lightweight/tui/SgrBuilder.cpp @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// Vendored from endo/src/tui/SgrBuilder.cpp — kept intentionally close to upstream +// (only header path changed) so re-syncing later is mechanical. + +#include "SgrBuilder.hpp" + +#include +#include +#include + +namespace tui +{ + +std::string buildSgrSequence(Style const& style) +{ + auto const underlineFromFlag = style.underline ? UnderlineStyle::Single : UnderlineStyle::None; + auto const effectiveUnderline = + style.underlineStyle != UnderlineStyle::None ? style.underlineStyle : underlineFromFlag; + auto const isDefaultUlColor = std::holds_alternative(style.underlineColor); + + auto const isDefaultFg = std::holds_alternative(style.fg); + auto const isDefaultBg = std::holds_alternative(style.bg); + if (isDefaultFg && isDefaultBg && !style.bold && !style.italic && !style.strikethrough && !style.dim + && !style.inverse && effectiveUnderline == UnderlineStyle::None) + return {}; + + std::string result = "\033["; + auto needSemicolon = false; + auto const appendSep = [&]() { + if (needSemicolon) + result += ';'; + needSemicolon = true; + }; + + if (style.bold) + { + appendSep(); + result += '1'; + } + if (style.dim) + { + appendSep(); + result += '2'; + } + if (style.italic) + { + appendSep(); + result += '3'; + } + if (effectiveUnderline != UnderlineStyle::None) + { + appendSep(); + result += std::format("4:{}", static_cast(effectiveUnderline)); + } + if (style.inverse) + { + appendSep(); + result += '7'; + } + if (style.strikethrough) + { + appendSep(); + result += '9'; + } + + if (auto const* idx = std::get_if(&style.fg)) + { + appendSep(); + result += std::format("38;5;{}", *idx); + } + else if (auto const* rgb = std::get_if(&style.fg)) + { + appendSep(); + result += std::format("38;2;{};{};{}", rgb->r, rgb->g, rgb->b); + } + + if (auto const* idx = std::get_if(&style.bg)) + { + appendSep(); + result += std::format("48;5;{}", *idx); + } + else if (auto const* rgb = std::get_if(&style.bg)) + { + appendSep(); + result += std::format("48;2;{};{};{}", rgb->r, rgb->g, rgb->b); + } + + result += 'm'; + + if (!isDefaultUlColor) + { + if (auto const* idx = std::get_if(&style.underlineColor)) + result += std::format("\033[58:5:{}m", *idx); + else if (auto const* rgb = std::get_if(&style.underlineColor)) + result += std::format("\033[58:2:{}:{}:{}m", rgb->r, rgb->g, rgb->b); + } + + return result; +} + +} // namespace tui diff --git a/src/Lightweight/tui/SgrBuilder.hpp b/src/Lightweight/tui/SgrBuilder.hpp new file mode 100644 index 000000000..e0562deb0 --- /dev/null +++ b/src/Lightweight/tui/SgrBuilder.hpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +// Vendored from endo/src/tui/SgrBuilder.hpp. + +#pragma once + +#include "Style.hpp" + +#include + +namespace tui +{ + +// NOLINTBEGIN(readability-identifier-naming) +// Vendored upstream API names (camelCase) preserved verbatim — keeps re-syncs mechanical. + +/// Builds a complete SGR (Select Graphic Rendition) escape sequence for the given style. +/// +/// Encodes bold, dim, italic, underline (with extended styles), inverse, strikethrough, +/// foreground/background colors (256-color and truecolor), and underline color (SGR 58). +/// Returns an empty string if the style is fully default. +[[nodiscard]] std::string buildSgrSequence(Style const& style); + +/// Returns the universal SGR reset sequence: `ESC [ 0 m`. +[[nodiscard]] inline std::string sgrReset() +{ + return "\033[0m"; +} + +// NOLINTEND(readability-identifier-naming) + +} // namespace tui diff --git a/src/Lightweight/tui/Style.hpp b/src/Lightweight/tui/Style.hpp new file mode 100644 index 000000000..61d0a52da --- /dev/null +++ b/src/Lightweight/tui/Style.hpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Vendored from endo (https://github.com/...) — extracted Style / RgbColor from +// endo's TerminalOutput.hpp so we depend on a small, stable surface rather than +// the full terminal-IO module. Keep the namespace and field names identical so +// the diff against upstream stays trivially reviewable. + +#pragma once + +#include +#include + +namespace tui +{ + +/// @brief RGB color representation. +struct RgbColor +{ + std::uint8_t r = 0; + std::uint8_t g = 0; + std::uint8_t b = 0; +}; + +/// @brief Color representation: default, 256-color index, or true color (RGB). +using Color = std::variant; + +/// @brief Underline style for terminal output (SGR 4:n). +enum class UnderlineStyle : std::uint8_t +{ + None = 0, + Single = 1, + Double = 2, + Curly = 3, + Dotted = 4, + Dashed = 5, +}; + +/// @brief Text styling attributes for terminal output. +struct Style +{ + Color fg; + Color bg; + bool bold = false; + bool italic = false; + bool underline = false; + bool strikethrough = false; + bool dim = false; + bool inverse = false; + UnderlineStyle underlineStyle = UnderlineStyle::None; + Color underlineColor; +}; + +} // namespace tui diff --git a/src/Lightweight/tui/Unicode.cpp b/src/Lightweight/tui/Unicode.cpp new file mode 100644 index 000000000..d28da722c --- /dev/null +++ b/src/Lightweight/tui/Unicode.cpp @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// Vendored from endo/src/tui/Unicode.cpp. + +#include "Unicode.hpp" + +#if defined(__clang__) + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wold-style-cast" +#endif +#include +#if defined(__clang__) + #pragma clang diagnostic pop +#endif + +namespace tui +{ + +int stringWidth(std::string_view text) noexcept +{ + auto width = 0; + auto segmenter = unicode::utf8_grapheme_segmenter(text); + for (auto const& cluster: segmenter) + width += graphemeClusterWidth(cluster); + return width; +} + +} // namespace tui diff --git a/src/Lightweight/tui/Unicode.hpp b/src/Lightweight/tui/Unicode.hpp new file mode 100644 index 000000000..84bc89902 --- /dev/null +++ b/src/Lightweight/tui/Unicode.hpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +// Vendored from endo/src/tui/Unicode.hpp. + +#pragma once + +#include +#include + +#if defined(__clang__) + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wold-style-cast" +#endif +#include +#include +#if defined(__clang__) + #pragma clang diagnostic pop +#endif + +namespace tui +{ + +// NOLINTBEGIN(readability-identifier-naming) +// Vendored upstream API names (camelCase) preserved verbatim — keeps re-syncs mechanical. + +/// Calculates the display width of a grapheme cluster. +inline int graphemeClusterWidth(std::u32string_view cluster) noexcept +{ + if (cluster.empty()) + return 0; + + auto const props = unicode::codepoint_properties::get(cluster[0]); + + if (props.is_emoji_presentation()) + return 2; + + if (props.is_emoji() && cluster.size() > 1) + { + bool const hasZwj = std::ranges::find(cluster, U'‍') != cluster.end(); + if (hasZwj) + return 2; + } + + int const baseWidth = static_cast(unicode::width(cluster[0])); + return baseWidth > 0 ? baseWidth : 1; +} + +/// Calculates the display width of a UTF-8 string in terminal columns. +int stringWidth(std::string_view text) noexcept; + +// NOLINTEND(readability-identifier-naming) + +} // namespace tui From 1435fd7d1dcb8325dc79e2d40d3c932ed7710a5f Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 26 Apr 2026 21:15:49 +0200 Subject: [PATCH 2/9] [Lightweight] Add SqlSchemaDiff and SqlDataDiff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pure-logic schema comparator and a streaming data comparator that together let callers ask "are these two databases equivalent?". SqlSchemaDiff::DiffSchemas pairs tables by (schema, name) and columns by name, reporting drift in column type, nullability, size, decimals, default value, auto-increment, primary-key membership, foreign keys, and indexes. No I/O — operates on already-fetched SqlSchema::TableList values. SqlDataDiff::DiffTableData runs `SELECT * FROM t ORDER BY ` on both sides, walks the cursors merge-style, and reports added / removed / changed rows. Tables without a primary key are skipped with a reason. Each column value is fetched as SqlVariant and stringified via SqlVariant::ToString() rather than asking the ODBC driver to coerce to string — this avoids "Numeric value out of range" errors on wide numeric columns under MSSQL ODBC Driver 18. Progress is reported via a rate-limited callback (~2 Hz) so callers can drive a live UI on million-row tables. Schema-diff tests are pure-logic (7 cases). Data-diff tests open SQLite files directly and exercise identical, added, removed, changed, no-PK, and progress- callback paths (4 cases). Both tag families pass against sqlite3, mssql2022, and postgres test envs. Signed-off-by: Christian Parpart --- src/Lightweight/CMakeLists.txt | 4 + src/Lightweight/SqlDataDiff.cpp | 303 ++++++++++++++++++++++++++++++ src/Lightweight/SqlDataDiff.hpp | 96 ++++++++++ src/Lightweight/SqlSchemaDiff.cpp | 274 +++++++++++++++++++++++++++ src/Lightweight/SqlSchemaDiff.hpp | 89 +++++++++ src/tests/CMakeLists.txt | 2 + src/tests/SqlDataDiffTests.cpp | 195 +++++++++++++++++++ src/tests/SqlSchemaDiffTests.cpp | 156 +++++++++++++++ 8 files changed, 1119 insertions(+) create mode 100644 src/Lightweight/SqlDataDiff.cpp create mode 100644 src/Lightweight/SqlDataDiff.hpp create mode 100644 src/Lightweight/SqlSchemaDiff.cpp create mode 100644 src/Lightweight/SqlSchemaDiff.hpp create mode 100644 src/tests/SqlDataDiffTests.cpp create mode 100644 src/tests/SqlSchemaDiffTests.cpp diff --git a/src/Lightweight/CMakeLists.txt b/src/Lightweight/CMakeLists.txt index 7f9ec3fda..dbb4282f2 100644 --- a/src/Lightweight/CMakeLists.txt +++ b/src/Lightweight/CMakeLists.txt @@ -93,7 +93,9 @@ set(HEADER_FILES SqlMigration.hpp SqlOdbcWide.hpp SqlQueryFormatter.hpp + SqlDataDiff.hpp SqlSchema.hpp + SqlSchemaDiff.hpp SqlScopedLock.hpp SqlScopedTraceLogger.hpp SqlServerType.hpp @@ -137,7 +139,9 @@ set(SOURCE_FILES SqlQueryFormatter.cpp SqlScopedLock.cpp + SqlDataDiff.cpp SqlSchema.cpp + SqlSchemaDiff.cpp SqlStatement.cpp SqlTransaction.cpp Utils.cpp diff --git a/src/Lightweight/SqlDataDiff.cpp b/src/Lightweight/SqlDataDiff.cpp new file mode 100644 index 000000000..04b6dbc3c --- /dev/null +++ b/src/Lightweight/SqlDataDiff.cpp @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "SqlDataDiff.hpp" + +#include "DataBinder/SqlVariant.hpp" +#include "SqlConnection.hpp" +#include "SqlServerType.hpp" +#include "SqlStatement.hpp" + +#include +#include +#include +#include + +namespace Lightweight::SqlSchema +{ + +namespace +{ + + /// Returns the dialect-appropriate delimiter pair for identifiers. + [[nodiscard]] std::pair IdentifierDelimiters(SqlServerType server) noexcept + { + switch (server) + { + case SqlServerType::MICROSOFT_SQL: + return { '[', ']' }; + case SqlServerType::MYSQL: + return { '`', '`' }; + case SqlServerType::POSTGRESQL: + case SqlServerType::SQLITE: + case SqlServerType::UNKNOWN: + return { '"', '"' }; + } + return { '"', '"' }; + } + + /// Quotes an identifier and escapes embedded delimiter chars by doubling them + /// (the standard SQL convention; works for all dialects we target). + [[nodiscard]] std::string Quote(std::string_view identifier, SqlServerType server) + { + auto const [open, close] = IdentifierDelimiters(server); + auto out = std::string {}; + out.reserve(identifier.size() + 2); + out += open; + for (auto const c: identifier) + { + out += c; + if (c == close) + out += close; // escape by doubling + } + out += close; + return out; + } + + /// Reads one row from the cursor as a vector of string-formatted column values. + /// Returns std::nullopt at end of result set. + /// + /// We fetch each column as `SqlVariant` (which lets the data binder pick the correct + /// ODBC C type per column) and then format with `SqlVariant::ToString()`. Going via + /// the typed variant avoids letting the ODBC driver coerce wide numeric columns + /// (`BIGINT`, large `DECIMAL`, `MONEY`, …) to string itself, which can fail with + /// `Numeric value out of range` on some drivers (e.g. ODBC Driver 18 for SQL Server). + [[nodiscard]] std::optional> FetchRowAsStrings(SqlResultCursor& cursor, + std::size_t numColumns) + { + if (!cursor.FetchRow()) + return std::nullopt; + auto values = std::vector {}; + values.reserve(numColumns); + for (auto const i: std::views::iota(std::size_t { 1 }, numColumns + 1)) + { + auto const v = cursor.GetColumn(static_cast(i)); + values.emplace_back(v.IsNull() ? std::string { "(null)" } : v.ToString()); + } + return values; + } + + /// Lex-compares two PK tuples (using the leading @c numKeyCols entries of each row). + [[nodiscard]] int ComparePk(std::vector const& a, + std::vector const& b, + std::size_t numKeyCols) noexcept + { + for (auto const i: std::views::iota(std::size_t { 0 }, numKeyCols)) + { + if (a[i] < b[i]) + return -1; + if (a[i] > b[i]) + return 1; + } + return 0; + } + + /// Extracts the PK columns of a row as a separate vector (for RowDiff::primaryKey). + [[nodiscard]] std::vector ExtractPk(std::vector const& row, std::size_t numKeyCols) + { + return { row.begin(), row.begin() + static_cast(numKeyCols) }; + } + + /// Returns indexes of columns that should appear after the PK columns in the SELECT list, + /// so the row layout is `[pk0..pkN-1, nonpk0..nonpkM-1]`. The schema-driven SELECT order + /// is fixed; this maps each non-PK column index to its name for diff reporting. + struct ColumnLayout + { + std::vector orderedColumnNames; ///< In the order used by the SELECT. + std::size_t numKeyCols = 0; + }; + + [[nodiscard]] ColumnLayout BuildColumnLayout(Table const& tableSchema) + { + // Stable column order: PK columns first (in the schema's primaryKeys order), + // then everything else in the schema's column order. + auto const& pkSet = tableSchema.primaryKeys; + auto layout = ColumnLayout {}; + layout.numKeyCols = pkSet.size(); + for (auto const& pk: pkSet) + layout.orderedColumnNames.push_back(pk); + for (auto const& col: tableSchema.columns) + { + auto const isPk = std::ranges::find(pkSet, col.name) != pkSet.end(); + if (!isPk) + layout.orderedColumnNames.push_back(col.name); + } + return layout; + } + + /// Like BuildScanQuery but uses the explicit column ordering from the layout. + [[nodiscard]] std::string BuildScanQueryFromLayout(Table const& tableSchema, + ColumnLayout const& layout, + SqlServerType server) + { + auto const tableRef = tableSchema.schema.empty() + ? Quote(tableSchema.name, server) + : std::format("{}.{}", Quote(tableSchema.schema, server), Quote(tableSchema.name, server)); + + auto cols = std::string {}; + for (auto const& [i, name]: std::views::enumerate(layout.orderedColumnNames)) + { + if (i != 0) + cols += ", "; + cols += Quote(name, server); + } + + auto orderBy = std::string {}; + for (auto const& [i, pk]: std::views::enumerate(tableSchema.primaryKeys)) + { + if (i != 0) + orderBy += ", "; + orderBy += Quote(pk, server); + } + + return std::format("SELECT {} FROM {} ORDER BY {}", cols, tableRef, orderBy); + } + + [[nodiscard]] std::vector> + DiffRowValues(std::vector const& a, + std::vector const& b, + ColumnLayout const& layout) + { + auto cells = std::vector> {}; + // Skip PK columns — by definition they match (we paired by them). + for (auto const i: std::views::iota(layout.numKeyCols, layout.orderedColumnNames.size())) + if (a[i] != b[i]) + cells.emplace_back(layout.orderedColumnNames[i], a[i], b[i]); + return cells; + } + + /// Records a single OnlyInA / OnlyInB row diff and returns the new row count delta. + void RecordOnlyOnOneSide(TableDataDiff& result, + std::vector const& row, + ColumnLayout const& layout, + DiffKind side) + { + result.rows.emplace_back(RowDiff { + .kind = side, + .primaryKey = ExtractPk(row, layout.numKeyCols), + }); + } + + /// Handles one merge step when both sides still have rows. Returns true if @p rowA + /// should be advanced, and the second bool when @p rowB should be advanced. + struct AdvanceFlags + { + bool advanceA; + bool advanceB; + }; + + [[nodiscard]] AdvanceFlags MergeStep(TableDataDiff& result, + std::vector const& rowA, + std::vector const& rowB, + ColumnLayout const& layout) + { + auto const cmp = ComparePk(rowA, rowB, layout.numKeyCols); + if (cmp == 0) + { + auto changes = DiffRowValues(rowA, rowB, layout); + if (!changes.empty()) + { + result.rows.emplace_back(RowDiff { + .kind = DiffKind::Changed, + .primaryKey = ExtractPk(rowA, layout.numKeyCols), + .changedCells = std::move(changes), + }); + } + ++result.aRowCount; + ++result.bRowCount; + return { .advanceA = true, .advanceB = true }; + } + if (cmp < 0) + { + RecordOnlyOnOneSide(result, rowA, layout, DiffKind::OnlyInA); + ++result.aRowCount; + return { .advanceA = true, .advanceB = false }; + } + RecordOnlyOnOneSide(result, rowB, layout, DiffKind::OnlyInB); + ++result.bRowCount; + return { .advanceA = false, .advanceB = true }; + } + +} // namespace + +TableDataDiff DiffTableData(SqlConnection& a, + SqlConnection& b, + Table const& tableSchema, + std::size_t maxRows, + DiffProgressCallback onProgress) +{ + auto result = TableDataDiff { .tableName = tableSchema.name }; + + if (tableSchema.primaryKeys.empty()) + { + result.skipReason = "no primary key"; + return result; + } + + auto const layout = BuildColumnLayout(tableSchema); + auto const queryA = BuildScanQueryFromLayout(tableSchema, layout, a.ServerType()); + auto const queryB = BuildScanQueryFromLayout(tableSchema, layout, b.ServerType()); + + auto stmtA = SqlStatement { a }; + auto stmtB = SqlStatement { b }; + + auto cursorA = stmtA.ExecuteDirect(queryA); + auto cursorB = stmtB.ExecuteDirect(queryB); + + auto const numCols = layout.orderedColumnNames.size(); + auto rowA = FetchRowAsStrings(cursorA, numCols); + auto rowB = FetchRowAsStrings(cursorB, numCols); + + auto lastReport = std::chrono::steady_clock::now(); + auto const reportInterval = std::chrono::milliseconds { 500 }; + + auto report = [&](bool force) { + if (!onProgress) + return; + auto const now = std::chrono::steady_clock::now(); + if (!force && now - lastReport < reportInterval) + return; + lastReport = now; + onProgress(DiffProgressEvent { + .tableName = tableSchema.name, + .rowsScannedA = result.aRowCount, + .rowsScannedB = result.bRowCount, + }); + }; + + while (rowA || rowB) + { + if (maxRows != 0 && (result.aRowCount >= maxRows || result.bRowCount >= maxRows)) + { + result.truncated = true; + break; + } + + if (rowA && rowB) + { + auto const flags = MergeStep(result, *rowA, *rowB, layout); + if (flags.advanceA) + rowA = FetchRowAsStrings(cursorA, numCols); + if (flags.advanceB) + rowB = FetchRowAsStrings(cursorB, numCols); + } + else if (rowA) + { + RecordOnlyOnOneSide(result, *rowA, layout, DiffKind::OnlyInA); + ++result.aRowCount; + rowA = FetchRowAsStrings(cursorA, numCols); + } + else + { + RecordOnlyOnOneSide(result, *rowB, layout, DiffKind::OnlyInB); + ++result.bRowCount; + rowB = FetchRowAsStrings(cursorB, numCols); + } + + report(false); + } + + report(true); + return result; +} + +} // namespace Lightweight::SqlSchema diff --git a/src/Lightweight/SqlDataDiff.hpp b/src/Lightweight/SqlDataDiff.hpp new file mode 100644 index 000000000..973978a84 --- /dev/null +++ b/src/Lightweight/SqlDataDiff.hpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "Api.hpp" +#include "SqlSchema.hpp" +#include "SqlSchemaDiff.hpp" + +#include +#include +#include +#include +#include +#include + +namespace Lightweight +{ + +class SqlConnection; + +namespace SqlSchema +{ + + /// One row-level data difference between two databases. + struct RowDiff + { + DiffKind kind {}; + + /// String form of the row's primary key tuple. Empty for tables without a PK + /// (in which case @ref TableDataDiff::skipReason is set instead). + std::vector primaryKey {}; + + /// For @ref DiffKind::Changed: per-column tuple of (column name, value-on-A, value-on-B) + /// where the values differ. Empty for OnlyInA / OnlyInB. + std::vector> changedCells {}; + }; + + /// Result of comparing the rows of one table across two databases. + struct TableDataDiff + { + std::string tableName {}; + + /// All row-level diffs found, in primary-key order. + std::vector rows {}; + + /// Total rows scanned from each side (informational; useful for headers). + std::size_t aRowCount = 0; + std::size_t bRowCount = 0; + + /// True when scanning was cut short by the @c maxRows cap. + bool truncated = false; + + /// When set, the diff was not performed; the value is a human-readable reason + /// (e.g. `"no primary key"`). + std::optional skipReason {}; + }; + + /// Live progress event reported during a data diff. Fired ~2 Hz at most. + struct DiffProgressEvent + { + std::string tableName; + std::size_t rowsScannedA = 0; + std::size_t rowsScannedB = 0; + std::size_t expectedRowsA = 0; ///< 0 if unknown. + std::size_t expectedRowsB = 0; ///< 0 if unknown. + }; + + /// Callback invoked during a data diff to report progress. + using DiffProgressCallback = std::function; + + /// Compares the rows of one table across two databases. + /// + /// Both @p a and @p b are expected to contain a table identical to @p tableSchema — + /// callers should run the schema diff first and only invoke this for tables that match. + /// Pairs rows by primary key (using the columns listed in @c tableSchema.primaryKeys). + /// Tables without a primary key are skipped: the returned @ref TableDataDiff has an empty + /// @c rows vector and @ref TableDataDiff::skipReason set. + /// + /// All column values are compared as their ODBC string representation (the same coercion + /// used by `dbtool exec`). This may produce false positives for floating-point or binary + /// columns whose textual encoding differs across drivers. + /// + /// @param a Connection to the left-hand database (already connected). + /// @param b Connection to the right-hand database (already connected). + /// @param tableSchema Schema of the table; columns and primary keys are read from it. + /// @param maxRows Maximum number of rows to scan per side. 0 means unlimited. + /// @param onProgress Optional callback fired ~2 Hz with current scan counters. + LIGHTWEIGHT_API TableDataDiff DiffTableData(SqlConnection& a, + SqlConnection& b, + Table const& tableSchema, + std::size_t maxRows = 0, + DiffProgressCallback onProgress = {}); + +} // namespace SqlSchema + +} // namespace Lightweight diff --git a/src/Lightweight/SqlSchemaDiff.cpp b/src/Lightweight/SqlSchemaDiff.cpp new file mode 100644 index 000000000..2880c8c9f --- /dev/null +++ b/src/Lightweight/SqlSchemaDiff.cpp @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "SqlSchemaDiff.hpp" + +#include +#include +#include +#include + +namespace Lightweight::SqlSchema +{ + +namespace +{ + + using TableKey = std::pair; // (schema, name) + + [[nodiscard]] TableKey KeyOf(Table const& t) + { + return { t.schema, t.name }; + } + + /// Indexes a TableList by `(schema, name)` for O(log n) pairing. + [[nodiscard]] std::map IndexTables(TableList const& list) + { + auto index = std::map {}; + for (auto const& t: list) + index.emplace(KeyOf(t), &t); + return index; + } + + /// Indexes a column vector by name. + [[nodiscard]] std::map> IndexColumns(std::vector const& columns) + { + auto index = std::map> {}; + for (auto const& c: columns) + index.emplace(c.name, &c); + return index; + } + + /// Returns the list of field names that differ between two columns. + [[nodiscard]] std::vector DiffColumnFields(Column const& a, Column const& b) + { + auto fields = std::vector {}; + if (a.dialectDependantTypeString != b.dialectDependantTypeString) + fields.emplace_back("type"); + if (a.isNullable != b.isNullable) + fields.emplace_back("nullable"); + if (a.size != b.size) + fields.emplace_back("size"); + if (a.decimalDigits != b.decimalDigits) + fields.emplace_back("decimalDigits"); + if (a.defaultValue != b.defaultValue) + fields.emplace_back("defaultValue"); + if (a.isAutoIncrement != b.isAutoIncrement) + fields.emplace_back("autoIncrement"); + if (a.isPrimaryKey != b.isPrimaryKey) + fields.emplace_back("primaryKey"); + if (a.isForeignKey != b.isForeignKey) + fields.emplace_back("foreignKey"); + if (a.isUnique != b.isUnique) + fields.emplace_back("unique"); + return fields; + } + + [[nodiscard]] std::vector DiffColumns(std::vector const& a, std::vector const& b) + { + auto const indexA = IndexColumns(a); + auto const indexB = IndexColumns(b); + + auto names = std::set> {}; + for (auto const& c: a) + names.insert(c.name); + for (auto const& c: b) + names.insert(c.name); + + auto diffs = std::vector {}; + for (auto const& name: names) + { + auto const itA = indexA.find(name); + auto const itB = indexB.find(name); + auto const* colA = itA != indexA.end() ? itA->second : nullptr; + auto const* colB = itB != indexB.end() ? itB->second : nullptr; + + if (colA && !colB) + diffs.emplace_back(ColumnDiff { .name = name, .kind = DiffKind::OnlyInA, .a = colA }); + else if (!colA && colB) + diffs.emplace_back(ColumnDiff { .name = name, .kind = DiffKind::OnlyInB, .b = colB }); + else if (colA && colB) + { + auto changed = DiffColumnFields(*colA, *colB); + if (!changed.empty()) + { + diffs.emplace_back(ColumnDiff { + .name = name, + .kind = DiffKind::Changed, + .changedFields = std::move(changed), + .a = colA, + .b = colB, + }); + } + } + } + return diffs; + } + + [[nodiscard]] std::vector DiffPrimaryKeys(std::vector const& a, + std::vector const& b) + { + if (a == b) + return {}; + auto out = std::vector {}; + // Order matters for composite PKs — report a single line with both sides. + auto join = [](std::vector const& v) { + auto s = std::string {}; + for (auto const& [i, name]: std::views::enumerate(v)) + { + if (i != 0) + s += ", "; + s += name; + } + return s.empty() ? std::string { "(none)" } : s; + }; + out.emplace_back(std::format("primary key: [{}] vs [{}]", join(a), join(b))); + return out; + } + + /// Renders a single foreign-key constraint to a stable, comparable form. + [[nodiscard]] std::string FormatForeignKey(ForeignKeyConstraint const& fk) + { + auto join = [](std::vector const& v) { + auto s = std::string {}; + for (auto const& [i, name]: std::views::enumerate(v)) + { + if (i != 0) + s += ","; + s += name; + } + return s; + }; + return std::format( + "{}.{}({}) -> {}.{}({})", + fk.foreignKey.table.schema, + fk.foreignKey.table.table, + join(fk.foreignKey.columns), + fk.primaryKey.table.schema, + fk.primaryKey.table.table, + join(fk.primaryKey.columns)); + } + + [[nodiscard]] std::vector DiffForeignKeys(std::vector const& a, + std::vector const& b) + { + auto setA = std::set {}; + auto setB = std::set {}; + for (auto const& fk: a) + setA.insert(FormatForeignKey(fk)); + for (auto const& fk: b) + setB.insert(FormatForeignKey(fk)); + + auto diffs = std::vector {}; + for (auto const& s: setA) + if (!setB.contains(s)) + diffs.emplace_back(std::format("only in A: {}", s)); + for (auto const& s: setB) + if (!setA.contains(s)) + diffs.emplace_back(std::format("only in B: {}", s)); + return diffs; + } + + [[nodiscard]] std::string FormatIndex(IndexDefinition const& idx) + { + auto cols = std::string {}; + for (auto const& [i, name]: std::views::enumerate(idx.columns)) + { + if (i != 0) + cols += ","; + cols += name; + } + return std::format("{}{}({})", idx.isUnique ? "UNIQUE " : "", idx.name, cols); + } + + [[nodiscard]] std::vector DiffIndexes(std::vector const& a, + std::vector const& b) + { + // Pair by index name; treat any change to columns/uniqueness as a difference. + auto indexByName = [](std::vector const& v) { + auto m = std::map> {}; + for (auto const& i: v) + m.emplace(i.name, &i); + return m; + }; + auto const mapA = indexByName(a); + auto const mapB = indexByName(b); + + auto names = std::set> {}; + for (auto const& i: a) + names.insert(i.name); + for (auto const& i: b) + names.insert(i.name); + + auto diffs = std::vector {}; + for (auto const& name: names) + { + auto const itA = mapA.find(name); + auto const itB = mapB.find(name); + auto const* idxA = itA != mapA.end() ? itA->second : nullptr; + auto const* idxB = itB != mapB.end() ? itB->second : nullptr; + if (idxA && !idxB) + diffs.emplace_back(std::format("only in A: {}", FormatIndex(*idxA))); + else if (!idxA && idxB) + diffs.emplace_back(std::format("only in B: {}", FormatIndex(*idxB))); + else if (idxA && idxB && (idxA->columns != idxB->columns || idxA->isUnique != idxB->isUnique)) + diffs.emplace_back(std::format("changed: {} | {}", FormatIndex(*idxA), FormatIndex(*idxB))); + } + return diffs; + } + +} // namespace + +SchemaDiff DiffSchemas(TableList const& a, TableList const& b) +{ + auto const indexA = IndexTables(a); + auto const indexB = IndexTables(b); + + auto allKeys = std::set {}; + for (auto const& t: a) + allKeys.insert(KeyOf(t)); + for (auto const& t: b) + allKeys.insert(KeyOf(t)); + + auto diff = SchemaDiff {}; + for (auto const& key: allKeys) + { + auto const itA = indexA.find(key); + auto const itB = indexB.find(key); + auto const* tableA = itA != indexA.end() ? itA->second : nullptr; + auto const* tableB = itB != indexB.end() ? itB->second : nullptr; + + if (tableA && !tableB) + { + diff.tables.emplace_back(TableDiff { .schema = key.first, .name = key.second, .kind = DiffKind::OnlyInA }); + continue; + } + if (!tableA && tableB) + { + diff.tables.emplace_back(TableDiff { .schema = key.first, .name = key.second, .kind = DiffKind::OnlyInB }); + continue; + } + if (!tableA || !tableB) + continue; // Unreachable: keys came from union of both indexes. + + auto columnDiffs = DiffColumns(tableA->columns, tableB->columns); + auto pkDiffs = DiffPrimaryKeys(tableA->primaryKeys, tableB->primaryKeys); + auto fkDiffs = DiffForeignKeys(tableA->foreignKeys, tableB->foreignKeys); + auto idxDiffs = DiffIndexes(tableA->indexes, tableB->indexes); + + if (!columnDiffs.empty() || !pkDiffs.empty() || !fkDiffs.empty() || !idxDiffs.empty()) + { + diff.tables.emplace_back(TableDiff { + .schema = key.first, + .name = key.second, + .kind = DiffKind::Changed, + .columns = std::move(columnDiffs), + .primaryKeyDiffs = std::move(pkDiffs), + .foreignKeyDiffs = std::move(fkDiffs), + .indexDiffs = std::move(idxDiffs), + }); + } + } + return diff; +} + +} // namespace Lightweight::SqlSchema diff --git a/src/Lightweight/SqlSchemaDiff.hpp b/src/Lightweight/SqlSchemaDiff.hpp new file mode 100644 index 000000000..4b67c956f --- /dev/null +++ b/src/Lightweight/SqlSchemaDiff.hpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "Api.hpp" +#include "SqlSchema.hpp" + +#include +#include +#include + +namespace Lightweight::SqlSchema +{ + +/// Classifies a schema-level diff entry. +enum class DiffKind : std::uint8_t +{ + OnlyInA, ///< Present only in the left-hand side. + OnlyInB, ///< Present only in the right-hand side. + Changed, ///< Present on both sides but with different definitions. +}; + +/// A diff entry for a single column. +struct ColumnDiff +{ + std::string name; + DiffKind kind {}; + + /// For @ref DiffKind::Changed: human-readable list of differing fields + /// (e.g. `"type"`, `"nullable"`, `"defaultValue"`). Empty otherwise. + std::vector changedFields {}; + + /// Pointer into the input @ref TableList for the left-hand column. Null when + /// @ref kind is @ref DiffKind::OnlyInB. + Column const* a = nullptr; + + /// Pointer into the input @ref TableList for the right-hand column. Null when + /// @ref kind is @ref DiffKind::OnlyInA. + Column const* b = nullptr; +}; + +/// A diff entry for a single table. +struct TableDiff +{ + std::string schema; + std::string name; + + /// Table-level classification: @ref DiffKind::OnlyInA / @ref DiffKind::OnlyInB + /// when one side lacks the table entirely; @ref DiffKind::Changed when both + /// sides have it but column / key / index definitions differ. + DiffKind kind {}; + + /// Per-column diffs. Populated only when @ref kind is @ref DiffKind::Changed. + std::vector columns {}; + + /// Human-readable PK differences (e.g. `"primary key columns differ"`). + std::vector primaryKeyDiffs {}; + + /// Human-readable FK differences. + std::vector foreignKeyDiffs {}; + + /// Human-readable index differences. + std::vector indexDiffs {}; +}; + +/// Result of a full schema comparison. +struct SchemaDiff +{ + /// All diff entries, sorted lexicographically by (schema, name). + std::vector tables {}; + + /// True if no differences were found. + [[nodiscard]] bool Empty() const noexcept + { + return tables.empty(); + } +}; + +/// Compares two table lists field-by-field and returns a structural diff. +/// +/// Pairs tables by `(schema, name)` and columns by name. Column comparison covers +/// type, nullability, size, decimal digits, default value, auto-increment, primary +/// key membership, foreign-key membership, and uniqueness. Foreign-key constraints +/// are compared at the table level (not per column). +/// +/// Pure function — no I/O, no logging, no exceptions. +LIGHTWEIGHT_API SchemaDiff DiffSchemas(TableList const& a, TableList const& b); + +} // namespace Lightweight::SqlSchema diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index f72184f1c..3bd0dab35 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -92,6 +92,8 @@ set(SOURCE_FILES MigrationReflectionTests.cpp QueryBuilderTests.cpp QueryFormatterTests.cpp + SqlDataDiffTests.cpp + SqlSchemaDiffTests.cpp SqlErrorDetectionTests.cpp SqlGuidTests.cpp SqlLoggerTests.cpp diff --git a/src/tests/SqlDataDiffTests.cpp b/src/tests/SqlDataDiffTests.cpp new file mode 100644 index 000000000..871d256f6 --- /dev/null +++ b/src/tests/SqlDataDiffTests.cpp @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +using namespace Lightweight; +using namespace Lightweight::SqlSchema; + +namespace +{ + +/// Removes the file at @p path on construction and again on destruction. Lets each test +/// start from a clean slate without leaking files between runs. +struct ScopedTempFile +{ + std::filesystem::path path; + + explicit ScopedTempFile(std::filesystem::path p): path(std::move(p)) + { + std::filesystem::remove(path); + } + ~ScopedTempFile() + { + std::error_code ec; + std::filesystem::remove(path, ec); + } + ScopedTempFile(ScopedTempFile const&) = delete; + ScopedTempFile& operator=(ScopedTempFile const&) = delete; + ScopedTempFile(ScopedTempFile&&) = delete; + ScopedTempFile& operator=(ScopedTempFile&&) = delete; +}; + +[[nodiscard]] SqlConnectionString SqliteConn(std::filesystem::path const& path) +{ + return SqlConnectionString { std::format("DRIVER=SQLite3;Database={}", path.string()) }; +} + +/// Creates a `users(id PK, name, email)` table and inserts the given rows. +void SetupUsersTable(SqlConnection& conn, std::vector> const& rows) +{ + auto stmt = SqlStatement { conn }; + (void) stmt.ExecuteDirect("DROP TABLE IF EXISTS users"); + (void) stmt.ExecuteDirect( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL)"); + stmt.Prepare("INSERT INTO users (id, name, email) VALUES (?, ?, ?)"); + for (auto const& [id, name, email]: rows) + (void) stmt.Execute(id, name, email); +} + +/// Reads the schema of @p tableName from @p conn (using SqlSchema::ReadAllTables) and +/// returns the matching Table — REQUIRE-fails if not present. +[[nodiscard]] Table FetchTableSchema(SqlConnection& conn, std::string_view tableName) +{ + auto stmt = SqlStatement { conn }; + auto const list = SqlSchema::ReadAllTables(stmt, conn.DatabaseName(), ""); + auto const it = std::ranges::find_if(list, [&](Table const& t) { return t.name == tableName; }); + REQUIRE(it != list.end()); + return *it; +} + +} // namespace + +TEST_CASE("DiffTableData: identical tables produce no row diffs", "[SqlDataDiff]") +{ + auto const tmp = std::filesystem::temp_directory_path(); + auto const guardA = ScopedTempFile { tmp / "diff_data_identical_a.sqlite" }; + auto const guardB = ScopedTempFile { tmp / "diff_data_identical_b.sqlite" }; + + auto connA = SqlConnection { SqliteConn(guardA.path) }; + auto connB = SqlConnection { SqliteConn(guardB.path) }; + REQUIRE(connA.IsAlive()); + REQUIRE(connB.IsAlive()); + + auto const rows = std::vector> { + { 1, "alice", "a@x" }, + { 2, "bob", "b@x" }, + }; + SetupUsersTable(connA, rows); + SetupUsersTable(connB, rows); + + auto const schema = FetchTableSchema(connA, "users"); + auto const diff = DiffTableData(connA, connB, schema); + + CHECK(diff.rows.empty()); + CHECK(diff.aRowCount == 2); + CHECK(diff.bRowCount == 2); + CHECK_FALSE(diff.skipReason.has_value()); +} + +TEST_CASE("DiffTableData: detects added, removed, and changed rows", "[SqlDataDiff]") +{ + auto const tmp = std::filesystem::temp_directory_path(); + auto const guardA = ScopedTempFile { tmp / "diff_data_drift_a.sqlite" }; + auto const guardB = ScopedTempFile { tmp / "diff_data_drift_b.sqlite" }; + + auto connA = SqlConnection { SqliteConn(guardA.path) }; + auto connB = SqlConnection { SqliteConn(guardB.path) }; + + SetupUsersTable(connA, { + { 1, "alice", "a@x" }, + { 2, "bob", "b@x" }, + { 3, "carol", "c@x" }, // only in A (removed in B) + }); + SetupUsersTable(connB, { + { 1, "alice", "a@x" }, + { 2, "bob", "b@y" }, // changed: email differs + { 4, "dave", "d@x" }, // only in B (added in B) + }); + + auto const schema = FetchTableSchema(connA, "users"); + auto const diff = DiffTableData(connA, connB, schema); + + REQUIRE(diff.rows.size() == 3); + + auto const findByPk = [&](std::string const& pkVal) -> RowDiff const* { + auto const it = std::ranges::find_if( + diff.rows, [&](RowDiff const& r) { return !r.primaryKey.empty() && r.primaryKey.front() == pkVal; }); + return it == diff.rows.end() ? nullptr : &*it; + }; + + auto const* changed = findByPk("2"); + REQUIRE(changed); + CHECK(changed->kind == DiffKind::Changed); + REQUIRE(changed->changedCells.size() == 1); + CHECK(std::get<0>(changed->changedCells.front()) == "email"); + CHECK(std::get<1>(changed->changedCells.front()) == "b@x"); + CHECK(std::get<2>(changed->changedCells.front()) == "b@y"); + + auto const* removed = findByPk("3"); + REQUIRE(removed); + CHECK(removed->kind == DiffKind::OnlyInA); + + auto const* added = findByPk("4"); + REQUIRE(added); + CHECK(added->kind == DiffKind::OnlyInB); +} + +TEST_CASE("DiffTableData: tables without a primary key are skipped", "[SqlDataDiff]") +{ + auto const tmp = std::filesystem::temp_directory_path(); + auto const guardA = ScopedTempFile { tmp / "diff_data_nopk_a.sqlite" }; + auto const guardB = ScopedTempFile { tmp / "diff_data_nopk_b.sqlite" }; + + auto connA = SqlConnection { SqliteConn(guardA.path) }; + auto connB = SqlConnection { SqliteConn(guardB.path) }; + + auto stmtA = SqlStatement { connA }; + auto stmtB = SqlStatement { connB }; + (void) stmtA.ExecuteDirect("CREATE TABLE log (msg TEXT)"); + (void) stmtA.ExecuteDirect("INSERT INTO log (msg) VALUES ('hello')"); + (void) stmtB.ExecuteDirect("CREATE TABLE log (msg TEXT)"); + + // Hand-build a Table with no primaryKeys. (SQLite reflects rowid PKs but we want the + // explicit no-PK case here; the function only checks tableSchema.primaryKeys.) + auto schema = Table {}; + schema.name = "log"; + schema.columns.push_back(Column { .name = "msg", .dialectDependantTypeString = "TEXT" }); + + auto const diff = DiffTableData(connA, connB, schema); + REQUIRE(diff.skipReason.has_value()); + CHECK(diff.skipReason.value_or("") == "no primary key"); + CHECK(diff.rows.empty()); +} + +TEST_CASE("DiffTableData: progress callback fires", "[SqlDataDiff]") +{ + auto const tmp = std::filesystem::temp_directory_path(); + auto const guardA = ScopedTempFile { tmp / "diff_data_progress_a.sqlite" }; + auto const guardB = ScopedTempFile { tmp / "diff_data_progress_b.sqlite" }; + + auto connA = SqlConnection { SqliteConn(guardA.path) }; + auto connB = SqlConnection { SqliteConn(guardB.path) }; + + // Empty rows on both sides — but the final "force" report at end of scan must still fire + // exactly once (the rate-limited mid-scan reports won't trigger with no rows). + SetupUsersTable(connA, {}); + SetupUsersTable(connB, {}); + + auto const schema = FetchTableSchema(connA, "users"); + auto callCount = 0; + auto const diff = DiffTableData(connA, connB, schema, 0, [&](DiffProgressEvent const&) { ++callCount; }); + + CHECK(diff.rows.empty()); + CHECK(callCount >= 1); +} diff --git a/src/tests/SqlSchemaDiffTests.cpp b/src/tests/SqlSchemaDiffTests.cpp new file mode 100644 index 000000000..4af74882a --- /dev/null +++ b/src/tests/SqlSchemaDiffTests.cpp @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +using namespace Lightweight; +using namespace Lightweight::SqlSchema; + +namespace +{ + +Column MakeColumn(std::string name, + std::string typeStr = "INTEGER", + bool nullable = true, + bool primaryKey = false) +{ + auto c = Column {}; + c.name = std::move(name); + c.dialectDependantTypeString = std::move(typeStr); + c.isNullable = nullable; + c.isPrimaryKey = primaryKey; + return c; +} + +Table MakeTable(std::string schema, std::string name, std::vector columns) +{ + auto t = Table {}; + t.schema = std::move(schema); + t.name = std::move(name); + t.columns = std::move(columns); + for (auto const& c: t.columns) + if (c.isPrimaryKey) + t.primaryKeys.push_back(c.name); + return t; +} + +[[nodiscard]] TableDiff const* FindTable(SchemaDiff const& d, std::string_view name) +{ + auto const it = std::ranges::find_if(d.tables, [&](TableDiff const& t) { return t.name == name; }); + return it == d.tables.end() ? nullptr : &*it; +} + +} // namespace + +TEST_CASE("DiffSchemas: identical schemas produce empty diff", "[SqlSchemaDiff]") +{ + auto const tables = TableList { + MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true), MakeColumn("name", "VARCHAR(50)") }), + }; + auto const diff = DiffSchemas(tables, tables); + CHECK(diff.Empty()); +} + +TEST_CASE("DiffSchemas: missing table on each side", "[SqlSchemaDiff]") +{ + auto const a = TableList { + MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true) }), + MakeTable("dbo", "posts", { MakeColumn("id", "INTEGER", false, true) }), + }; + auto const b = TableList { + MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true) }), + MakeTable("dbo", "comments", { MakeColumn("id", "INTEGER", false, true) }), + }; + + auto const diff = DiffSchemas(a, b); + REQUIRE(diff.tables.size() == 2); + + auto const* posts = FindTable(diff, "posts"); + REQUIRE(posts); + CHECK(posts->kind == DiffKind::OnlyInA); + + auto const* comments = FindTable(diff, "comments"); + REQUIRE(comments); + CHECK(comments->kind == DiffKind::OnlyInB); +} + +TEST_CASE("DiffSchemas: missing column produces Changed table with column diff", "[SqlSchemaDiff]") +{ + auto const a = TableList { + MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true), MakeColumn("nickname", "VARCHAR(20)") }), + }; + auto const b = TableList { + MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true) }), + }; + + auto const diff = DiffSchemas(a, b); + REQUIRE(diff.tables.size() == 1); + auto const& t = diff.tables.front(); + CHECK(t.kind == DiffKind::Changed); + REQUIRE(t.columns.size() == 1); + CHECK(t.columns.front().name == "nickname"); + CHECK(t.columns.front().kind == DiffKind::OnlyInA); +} + +TEST_CASE("DiffSchemas: type drift on shared column", "[SqlSchemaDiff]") +{ + auto const a = TableList { + MakeTable("dbo", "users", { MakeColumn("email", "VARCHAR(50)") }), + }; + auto const b = TableList { + MakeTable("dbo", "users", { MakeColumn("email", "VARCHAR(100)") }), + }; + + auto const diff = DiffSchemas(a, b); + REQUIRE(diff.tables.size() == 1); + auto const& col = diff.tables.front().columns.front(); + CHECK(col.kind == DiffKind::Changed); + CHECK(col.changedFields == std::vector { "type" }); +} + +TEST_CASE("DiffSchemas: nullable drift", "[SqlSchemaDiff]") +{ + auto const a = TableList { + MakeTable("dbo", "users", { MakeColumn("name", "VARCHAR(50)", true) }), + }; + auto const b = TableList { + MakeTable("dbo", "users", { MakeColumn("name", "VARCHAR(50)", false) }), + }; + + auto const diff = DiffSchemas(a, b); + REQUIRE(diff.tables.size() == 1); + auto const& col = diff.tables.front().columns.front(); + CHECK(col.changedFields == std::vector { "nullable" }); +} + +TEST_CASE("DiffSchemas: primary key drift", "[SqlSchemaDiff]") +{ + auto const a = TableList { + MakeTable("dbo", "t", { MakeColumn("id", "INTEGER", false, true) }), + }; + auto const b = TableList { + MakeTable("dbo", "t", { MakeColumn("uuid", "VARCHAR(36)", false, true) }), + }; + + auto const diff = DiffSchemas(a, b); + REQUIRE(diff.tables.size() == 1); + CHECK_FALSE(diff.tables.front().primaryKeyDiffs.empty()); +} + +TEST_CASE("DiffSchemas: index drift", "[SqlSchemaDiff]") +{ + auto a = TableList { + MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true) }), + }; + a.front().indexes.push_back(IndexDefinition { .name = "idx_users_id", .columns = { "id" }, .isUnique = false }); + auto b = a; + b.front().indexes.front().isUnique = true; // diverge: unique-ness flips + + auto const diff = DiffSchemas(a, b); + REQUIRE(diff.tables.size() == 1); + CHECK_FALSE(diff.tables.front().indexDiffs.empty()); +} From 75353e95716c0e87b45556378e40a8b30babf208 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 26 Apr 2026 22:32:14 +0200 Subject: [PATCH 3/9] [Lightweight] SqlSchemaDiff: cross-engine logical equivalence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema diff now produces a clean (zero-difference) result when comparing two databases that received the same migration run on different engines. Previously every cross-engine pair reported the entire schema as drifting, because pairing keys and field comparisons all encoded engine-specific incidental detail. Pairing — TableDiff is keyed by table name only. Engine-specific schema labels (`dbo`, `public`, `""`) are now metadata, not identity. TableDiff carries `schemaA` and `schemaB` so the renderer can still surface them. Foreign-key comparison drops schema labels from the canonical constraint-shape string for the same reason. Column comparison — moves to a logical projection (LogicalKind + LogicalType) computed via ToLogical(). The driver-reported `size`, `decimalDigits`, `defaultValue`, and per-column `isForeignKey` are no longer compared: they vary across engines for identical migrations (Postgres `int4` reports size=10, SQLite reports 8; defaults round-trip with engine-specific quoting; per-column FK status depends on the driver returning the FK column name in the same case as the table reader). FK constraints are still asserted at the table level via DiffForeignKeys, which compares constraint shape directly. Logical equivalences applied: * Char/NChar/Varchar/NVarchar/Text all collapse into LogicalKind::String. Drivers disagree on fixed-vs-variable for the same migration (SQLite's ODBC reports NCHAR as SQL_VARCHAR; Postgres preserves bpchar). * Tinyint folds into Smallint (PostgreSQL has no TINYINT and silently promotes to SMALLINT during migration). * Real precision drift is erased — engines round-trip Real{53} as Real{24} or Real{53} depending on whether they store it as float4 or float8. The intent is "floating point"; precision noise is dropped. * String columns whose driver-reported size is 0 or >= 4096 are treated as "unbounded" (TEXT). 4096 sits well above any hand-written bounded size and below any tested driver's "engine default unbounded" sentinel. * VarBinary is treated as unsized — PostgreSQL maps `VarBinary(N)` to `BYTEA` and loses N on round-trip. Also filter the SQLite-only `_migration_locks` table from diff input — it is engine-specific lock plumbing (PostgreSQL/MSSQL use advisory locks), not migration drift. Tests use designated initialisation directly on Table/Column rather than through helper factories, per project convention. Added cases for schema-agnostic table and FK pairing, the four logical-equivalence classes (NVarchar↔Varchar, NChar↔Char, Real precision, unbounded text), and the migration-internal-table filter. Performance: ToLogical runs once per column per side and is a pure visitor over a 19-alternative variant. For a 700-table schema this adds microseconds, dominated by the I/O cost of reading the schema. Risk: the comparison is now lossier within a single engine — it no longer flags pure size-without-type-class drift on a column. That is the intended cross-engine contract, and within-engine drift is still caught because the canonical type variant carries the size for sized types. If a stricter intra-engine diff is ever needed, the LogicalKind projection is the only thing to relax. Coverage: 16 unit tests covering both equivalence and divergence paths; end-to-end against SQLite, PostgreSQL, and MSSQL with 679-table LUP schemas all return "schemas match". Signed-off-by: Christian Parpart --- src/Lightweight/SqlSchemaDiff.cpp | 210 +++++++++++++++--- src/Lightweight/SqlSchemaDiff.hpp | 26 ++- src/tests/SqlSchemaDiffTests.cpp | 358 ++++++++++++++++++++++++++---- 3 files changed, 518 insertions(+), 76 deletions(-) diff --git a/src/Lightweight/SqlSchemaDiff.cpp b/src/Lightweight/SqlSchemaDiff.cpp index 2880c8c9f..f5676f105 100644 --- a/src/Lightweight/SqlSchemaDiff.cpp +++ b/src/Lightweight/SqlSchemaDiff.cpp @@ -1,11 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 +#include "SqlColumnTypeDefinitions.hpp" #include "SqlSchemaDiff.hpp" #include #include #include #include +#include namespace Lightweight::SqlSchema { @@ -13,17 +15,132 @@ namespace Lightweight::SqlSchema namespace { - using TableKey = std::pair; // (schema, name) + /// Logical column-type kinds used for cross-engine equivalence comparison. + /// + /// Driver reportage is engine-specific in ways that no migration can reconcile: a + /// migration declaring `NChar(30)` reads back as `Varchar(30)` on SQLite (NCHAR has + /// TEXT affinity, so the driver returns SQL_VARCHAR) and as `Char(30)` on PostgreSQL + /// (which honours fixed-width). MSSQL returns `NChar(30)`. We project all string-class + /// types into a single @ref LogicalKind::String so the diff sees them as equivalent. + /// Likewise the integer-class types stay distinct (Bigint/Integer/Smallint/Tinyint), + /// but Tinyint folds into Smallint because PostgreSQL has no TINYINT and silently + /// promotes it to SMALLINT during migration. + enum class LogicalKind : std::uint8_t + { + Bigint, + Binary, ///< Fixed-width binary + Bool, + Date, + DateTime, + Decimal, + Guid, + Integer, + Real, ///< Floating point — driver-level precision drift (24 vs 53) is collapsed. + Smallint, ///< Smallint and Tinyint fold together (Postgres lacks TINYINT). + String, ///< CHAR/NCHAR/VARCHAR/NVARCHAR/TEXT — engines disagree on fixed/wide. + Time, + Timestamp, + VarBinary, + }; + + struct LogicalType + { + LogicalKind kind {}; + /// Declared character/byte size for sized types. 0 means "unbounded" (e.g. TEXT, + /// or a driver that reports a sentinel huge size for unsized text columns). + std::size_t size = 0; + /// Decimal precision (only meaningful for @ref LogicalKind::Decimal). + std::size_t precision = 0; + /// Decimal scale (only meaningful for @ref LogicalKind::Decimal). + std::size_t scale = 0; + + auto operator<=>(LogicalType const&) const = default; + }; + + /// Projects an engine-reported column type into its logical, cross-engine form. + /// + /// Char-class and Varchar-class types all collapse into @ref LogicalKind::String + /// (drivers return inconsistent fixed/variable kinds for the same migration). String + /// columns whose driver-reported size is 0 or exceeds the typical user-declared + /// upper bound are treated as unbounded — that captures `TEXT` columns regardless of + /// whether the driver returns `0` (PostgreSQL on `text` historically) or a sentinel + /// huge value (SQLite, or the PostgreSQL ODBC `MaxLongVarcharSize` of 8190). + [[nodiscard]] LogicalType ToLogical(SqlColumnTypeDefinition const& type) + { + using namespace SqlColumnTypeDefinitions; + + // 4096 is well above any size a hand-written migration would declare for a + // bounded-string column, and below the smallest "engine default unbounded" size + // any tested driver reports. Anything at or above this is intent = unbounded. + constexpr auto kUnboundedStringThreshold = std::size_t { 4096 }; + + auto normalizeStringSize = [](std::size_t size) -> std::size_t { + return (size == 0 || size >= kUnboundedStringThreshold) ? 0 : size; + }; + + return std::visit( + [&](auto const& t) -> LogicalType { + using T = std::decay_t; + if constexpr (std::is_same_v) + return { .kind = LogicalKind::Bigint }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::Binary, .size = t.size }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::Bool }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::String, .size = normalizeStringSize(t.size) }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::String, .size = normalizeStringSize(t.size) }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::Date }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::DateTime }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::Decimal, .precision = t.precision, .scale = t.scale }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::Guid }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::Integer }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::String, .size = normalizeStringSize(t.size) }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::String, .size = normalizeStringSize(t.size) }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::String, .size = normalizeStringSize(t.size) }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::Real }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::Smallint }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::Time }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::Timestamp }; + else if constexpr (std::is_same_v) + return { .kind = LogicalKind::Smallint }; + else if constexpr (std::is_same_v) + // PostgreSQL maps `VarBinary(N)` to `BYTEA` and loses the size on + // round-trip; sqlite preserves it. Treat VarBinary as unsized for + // logical equivalence. + return { .kind = LogicalKind::VarBinary }; + else + static_assert(sizeof(T) == 0, "Unhandled SqlColumnTypeDefinition alternative"); + }, + type); + } + + /// Tables are paired by name only — schema labels are engine-specific (`dbo`, `public`, + /// `""`) and would otherwise prevent a SQLite/Postgres/MSSQL diff from ever matching. + using TableKey = std::string; [[nodiscard]] TableKey KeyOf(Table const& t) { - return { t.schema, t.name }; + return t.name; } - /// Indexes a TableList by `(schema, name)` for O(log n) pairing. - [[nodiscard]] std::map IndexTables(TableList const& list) + /// Indexes a TableList by name for O(log n) pairing. + [[nodiscard]] std::map> IndexTables(TableList const& list) { - auto index = std::map {}; + auto index = std::map> {}; for (auto const& t: list) index.emplace(KeyOf(t), &t); return index; @@ -38,26 +155,32 @@ namespace return index; } - /// Returns the list of field names that differ between two columns. + /// Returns the list of field names that differ between two columns, comparing on the + /// **logical** type and constraint flags only. + /// + /// Cross-engine drivers report a column's standalone `size`, `decimalDigits`, and + /// `defaultValue` inconsistently — Postgres sends `INTEGER` with `size=10`, SQLite with + /// `size=8`, MSSQL with `size=10`; defaults round-trip with engine-specific quoting + /// (`'NULL'`, `nextval(...)`, `''`). Those fields are intentionally not compared: + /// what the migration declared is encoded in @ref Column::type, and the + /// @ref ToLogical projection collapses the engine-specific variants back to a single + /// logical kind. + /// + /// Per-column `isForeignKey` is also intentionally skipped: it is a derived flag whose + /// reliability depends on the driver returning column names in the same case as the + /// table reader saw them. Cross-engine FK identity is instead asserted at the table + /// level via @ref DiffForeignKeys, which compares the actual constraint shape. [[nodiscard]] std::vector DiffColumnFields(Column const& a, Column const& b) { auto fields = std::vector {}; - if (a.dialectDependantTypeString != b.dialectDependantTypeString) + if (ToLogical(a.type) != ToLogical(b.type)) fields.emplace_back("type"); if (a.isNullable != b.isNullable) fields.emplace_back("nullable"); - if (a.size != b.size) - fields.emplace_back("size"); - if (a.decimalDigits != b.decimalDigits) - fields.emplace_back("decimalDigits"); - if (a.defaultValue != b.defaultValue) - fields.emplace_back("defaultValue"); if (a.isAutoIncrement != b.isAutoIncrement) fields.emplace_back("autoIncrement"); if (a.isPrimaryKey != b.isPrimaryKey) fields.emplace_back("primaryKey"); - if (a.isForeignKey != b.isForeignKey) - fields.emplace_back("foreignKey"); if (a.isUnique != b.isUnique) fields.emplace_back("unique"); return fields; @@ -125,7 +248,10 @@ namespace return out; } - /// Renders a single foreign-key constraint to a stable, comparable form. + /// Renders a single foreign-key constraint to a stable, schema-agnostic comparable form. + /// + /// Schema labels are intentionally omitted: the same FK reads as `dbo.X` on MSSQL, + /// `public.X` on Postgres, and `X` on SQLite, but it is the same constraint logically. [[nodiscard]] std::string FormatForeignKey(ForeignKeyConstraint const& fk) { auto join = [](std::vector const& v) { @@ -138,14 +264,11 @@ namespace } return s; }; - return std::format( - "{}.{}({}) -> {}.{}({})", - fk.foreignKey.table.schema, - fk.foreignKey.table.table, - join(fk.foreignKey.columns), - fk.primaryKey.table.schema, - fk.primaryKey.table.table, - join(fk.primaryKey.columns)); + return std::format("{}({}) -> {}({})", + fk.foreignKey.table.table, + join(fk.foreignKey.columns), + fk.primaryKey.table.table, + join(fk.primaryKey.columns)); } [[nodiscard]] std::vector DiffForeignKeys(std::vector const& a, @@ -218,16 +341,30 @@ namespace } // namespace +namespace +{ + /// Tables internal to the migration runtime that should not show up in user-facing diffs. + /// `_migration_locks` is created on demand by the SQLite lock implementation + /// (PostgreSQL / MSSQL use advisory locks instead) — its presence on one side and + /// absence on the other reflects engine-specific lock plumbing, not migration drift. + [[nodiscard]] bool IsMigrationInternalTable(std::string_view name) noexcept + { + return name == "_migration_locks"; + } +} // namespace + SchemaDiff DiffSchemas(TableList const& a, TableList const& b) { auto const indexA = IndexTables(a); auto const indexB = IndexTables(b); - auto allKeys = std::set {}; + auto allKeys = std::set> {}; for (auto const& t: a) - allKeys.insert(KeyOf(t)); + if (!IsMigrationInternalTable(t.name)) + allKeys.insert(KeyOf(t)); for (auto const& t: b) - allKeys.insert(KeyOf(t)); + if (!IsMigrationInternalTable(t.name)) + allKeys.insert(KeyOf(t)); auto diff = SchemaDiff {}; for (auto const& key: allKeys) @@ -239,12 +376,22 @@ SchemaDiff DiffSchemas(TableList const& a, TableList const& b) if (tableA && !tableB) { - diff.tables.emplace_back(TableDiff { .schema = key.first, .name = key.second, .kind = DiffKind::OnlyInA }); + diff.tables.emplace_back(TableDiff { + .name = key, + .schemaA = tableA->schema, + .schemaB = {}, + .kind = DiffKind::OnlyInA, + }); continue; } if (!tableA && tableB) { - diff.tables.emplace_back(TableDiff { .schema = key.first, .name = key.second, .kind = DiffKind::OnlyInB }); + diff.tables.emplace_back(TableDiff { + .name = key, + .schemaA = {}, + .schemaB = tableB->schema, + .kind = DiffKind::OnlyInB, + }); continue; } if (!tableA || !tableB) @@ -258,8 +405,9 @@ SchemaDiff DiffSchemas(TableList const& a, TableList const& b) if (!columnDiffs.empty() || !pkDiffs.empty() || !fkDiffs.empty() || !idxDiffs.empty()) { diff.tables.emplace_back(TableDiff { - .schema = key.first, - .name = key.second, + .name = key, + .schemaA = tableA->schema, + .schemaB = tableB->schema, .kind = DiffKind::Changed, .columns = std::move(columnDiffs), .primaryKeyDiffs = std::move(pkDiffs), diff --git a/src/Lightweight/SqlSchemaDiff.hpp b/src/Lightweight/SqlSchemaDiff.hpp index 4b67c956f..f0011418c 100644 --- a/src/Lightweight/SqlSchemaDiff.hpp +++ b/src/Lightweight/SqlSchemaDiff.hpp @@ -42,9 +42,19 @@ struct ColumnDiff /// A diff entry for a single table. struct TableDiff { - std::string schema; + /// Table name (schema-unqualified). Tables are paired across the two inputs by name only — + /// engine-specific schema labels (`dbo`, `public`, `""`) are kept for display but ignored + /// for identity, so the same logical table compares as equal across SQL dialects. std::string name; + /// Schema name on the left-hand side. Empty when @ref kind is @ref DiffKind::OnlyInB + /// or when the left-hand engine reports no schema (e.g. SQLite). + std::string schemaA; + + /// Schema name on the right-hand side. Empty when @ref kind is @ref DiffKind::OnlyInA + /// or when the right-hand engine reports no schema. + std::string schemaB; + /// Table-level classification: @ref DiffKind::OnlyInA / @ref DiffKind::OnlyInB /// when one side lacks the table entirely; @ref DiffKind::Changed when both /// sides have it but column / key / index definitions differ. @@ -78,10 +88,16 @@ struct SchemaDiff /// Compares two table lists field-by-field and returns a structural diff. /// -/// Pairs tables by `(schema, name)` and columns by name. Column comparison covers -/// type, nullability, size, decimal digits, default value, auto-increment, primary -/// key membership, foreign-key membership, and uniqueness. Foreign-key constraints -/// are compared at the table level (not per column). +/// Pairs tables by **name only** so the same logical table matches across SQL dialects +/// even when engine-specific schema labels differ (`dbo` vs `public` vs `""`). Both +/// schema labels are kept on @ref TableDiff for the renderer. Columns are paired by name. +/// +/// Column comparison uses the canonical @ref Column::type variant (engine-agnostic), +/// not the dialect-dependent type string, so an `INTEGER` column compares equal whether +/// the driver reports `int4`, `int`, or `INTEGER`. Other compared fields: nullability, +/// size, decimal digits, default value, auto-increment, primary-key membership, +/// foreign-key membership, and uniqueness. Foreign-key constraints are compared at the +/// table level (not per column). /// /// Pure function — no I/O, no logging, no exceptions. LIGHTWEIGHT_API SchemaDiff DiffSchemas(TableList const& a, TableList const& b); diff --git a/src/tests/SqlSchemaDiffTests.cpp b/src/tests/SqlSchemaDiffTests.cpp index 4af74882a..48ddcc2cc 100644 --- a/src/tests/SqlSchemaDiffTests.cpp +++ b/src/tests/SqlSchemaDiffTests.cpp @@ -13,31 +13,6 @@ using namespace Lightweight::SqlSchema; namespace { -Column MakeColumn(std::string name, - std::string typeStr = "INTEGER", - bool nullable = true, - bool primaryKey = false) -{ - auto c = Column {}; - c.name = std::move(name); - c.dialectDependantTypeString = std::move(typeStr); - c.isNullable = nullable; - c.isPrimaryKey = primaryKey; - return c; -} - -Table MakeTable(std::string schema, std::string name, std::vector columns) -{ - auto t = Table {}; - t.schema = std::move(schema); - t.name = std::move(name); - t.columns = std::move(columns); - for (auto const& c: t.columns) - if (c.isPrimaryKey) - t.primaryKeys.push_back(c.name); - return t; -} - [[nodiscard]] TableDiff const* FindTable(SchemaDiff const& d, std::string_view name) { auto const it = std::ranges::find_if(d.tables, [&](TableDiff const& t) { return t.name == name; }); @@ -49,7 +24,16 @@ Table MakeTable(std::string schema, std::string name, std::vector column TEST_CASE("DiffSchemas: identical schemas produce empty diff", "[SqlSchemaDiff]") { auto const tables = TableList { - MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true), MakeColumn("name", "VARCHAR(50)") }), + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { .name = "id", + .type = SqlColumnTypeDefinitions::Integer {}, + .isNullable = false, + .isPrimaryKey = true }, + Column { .name = "name", .type = SqlColumnTypeDefinitions::Varchar { 50 }, .size = 50 } }, + .primaryKeys = { "id" }, + }, }; auto const diff = DiffSchemas(tables, tables); CHECK(diff.Empty()); @@ -57,13 +41,19 @@ TEST_CASE("DiffSchemas: identical schemas produce empty diff", "[SqlSchemaDiff]" TEST_CASE("DiffSchemas: missing table on each side", "[SqlSchemaDiff]") { + auto const idColumn = Column { + .name = "id", + .type = SqlColumnTypeDefinitions::Integer {}, + .isNullable = false, + .isPrimaryKey = true, + }; auto const a = TableList { - MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true) }), - MakeTable("dbo", "posts", { MakeColumn("id", "INTEGER", false, true) }), + Table { .schema = "dbo", .name = "users", .columns = { idColumn }, .primaryKeys = { "id" } }, + Table { .schema = "dbo", .name = "posts", .columns = { idColumn }, .primaryKeys = { "id" } }, }; auto const b = TableList { - MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true) }), - MakeTable("dbo", "comments", { MakeColumn("id", "INTEGER", false, true) }), + Table { .schema = "dbo", .name = "users", .columns = { idColumn }, .primaryKeys = { "id" } }, + Table { .schema = "dbo", .name = "comments", .columns = { idColumn }, .primaryKeys = { "id" } }, }; auto const diff = DiffSchemas(a, b); @@ -81,10 +71,25 @@ TEST_CASE("DiffSchemas: missing table on each side", "[SqlSchemaDiff]") TEST_CASE("DiffSchemas: missing column produces Changed table with column diff", "[SqlSchemaDiff]") { auto const a = TableList { - MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true), MakeColumn("nickname", "VARCHAR(20)") }), + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { .name = "id", + .type = SqlColumnTypeDefinitions::Integer {}, + .isNullable = false, + .isPrimaryKey = true }, + Column { .name = "nickname", .type = SqlColumnTypeDefinitions::Varchar { 20 }, .size = 20 } }, + .primaryKeys = { "id" }, + }, }; auto const b = TableList { - MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true) }), + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { + .name = "id", .type = SqlColumnTypeDefinitions::Integer {}, .isNullable = false, .isPrimaryKey = true } }, + .primaryKeys = { "id" }, + }, }; auto const diff = DiffSchemas(a, b); @@ -99,26 +104,47 @@ TEST_CASE("DiffSchemas: missing column produces Changed table with column diff", TEST_CASE("DiffSchemas: type drift on shared column", "[SqlSchemaDiff]") { auto const a = TableList { - MakeTable("dbo", "users", { MakeColumn("email", "VARCHAR(50)") }), + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { .name = "email", .type = SqlColumnTypeDefinitions::Varchar { 50 }, .size = 50 } }, + }, }; auto const b = TableList { - MakeTable("dbo", "users", { MakeColumn("email", "VARCHAR(100)") }), + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { .name = "email", .type = SqlColumnTypeDefinitions::Varchar { 100 }, .size = 100 } }, + }, }; auto const diff = DiffSchemas(a, b); REQUIRE(diff.tables.size() == 1); auto const& col = diff.tables.front().columns.front(); CHECK(col.kind == DiffKind::Changed); + // The size of a Varchar lives inside the canonical type variant — it surfaces as a + // `type` change, not a separate `size` change (the standalone driver-reported `size` + // field is intentionally not compared, see DiffColumnFields). CHECK(col.changedFields == std::vector { "type" }); } TEST_CASE("DiffSchemas: nullable drift", "[SqlSchemaDiff]") { auto const a = TableList { - MakeTable("dbo", "users", { MakeColumn("name", "VARCHAR(50)", true) }), + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { + .name = "name", .type = SqlColumnTypeDefinitions::Varchar { 50 }, .isNullable = true, .size = 50 } }, + }, }; auto const b = TableList { - MakeTable("dbo", "users", { MakeColumn("name", "VARCHAR(50)", false) }), + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { + .name = "name", .type = SqlColumnTypeDefinitions::Varchar { 50 }, .isNullable = false, .size = 50 } }, + }, }; auto const diff = DiffSchemas(a, b); @@ -130,10 +156,25 @@ TEST_CASE("DiffSchemas: nullable drift", "[SqlSchemaDiff]") TEST_CASE("DiffSchemas: primary key drift", "[SqlSchemaDiff]") { auto const a = TableList { - MakeTable("dbo", "t", { MakeColumn("id", "INTEGER", false, true) }), + Table { + .schema = "dbo", + .name = "t", + .columns = { Column { + .name = "id", .type = SqlColumnTypeDefinitions::Integer {}, .isNullable = false, .isPrimaryKey = true } }, + .primaryKeys = { "id" }, + }, }; auto const b = TableList { - MakeTable("dbo", "t", { MakeColumn("uuid", "VARCHAR(36)", false, true) }), + Table { + .schema = "dbo", + .name = "t", + .columns = { Column { .name = "uuid", + .type = SqlColumnTypeDefinitions::Varchar { 36 }, + .isNullable = false, + .size = 36, + .isPrimaryKey = true } }, + .primaryKeys = { "uuid" }, + }, }; auto const diff = DiffSchemas(a, b); @@ -144,9 +185,15 @@ TEST_CASE("DiffSchemas: primary key drift", "[SqlSchemaDiff]") TEST_CASE("DiffSchemas: index drift", "[SqlSchemaDiff]") { auto a = TableList { - MakeTable("dbo", "users", { MakeColumn("id", "INTEGER", false, true) }), + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { + .name = "id", .type = SqlColumnTypeDefinitions::Integer {}, .isNullable = false, .isPrimaryKey = true } }, + .primaryKeys = { "id" }, + .indexes = { IndexDefinition { .name = "idx_users_id", .columns = { "id" }, .isUnique = false } }, + }, }; - a.front().indexes.push_back(IndexDefinition { .name = "idx_users_id", .columns = { "id" }, .isUnique = false }); auto b = a; b.front().indexes.front().isUnique = true; // diverge: unique-ness flips @@ -154,3 +201,234 @@ TEST_CASE("DiffSchemas: index drift", "[SqlSchemaDiff]") REQUIRE(diff.tables.size() == 1); CHECK_FALSE(diff.tables.front().indexDiffs.empty()); } + +TEST_CASE("DiffSchemas: cross-engine schema labels still pair the same logical table", "[SqlSchemaDiff]") +{ + // Same logical table, but one side reports it under `public` (PostgreSQL) and the + // other under `dbo` (MSSQL). The diff must recognise both as the same table. + auto const idColumn = Column { + .name = "id", + .type = SqlColumnTypeDefinitions::Integer {}, + .isNullable = false, + .isPrimaryKey = true, + }; + auto const a = TableList { + Table { .schema = "public", .name = "users", .columns = { idColumn }, .primaryKeys = { "id" } }, + }; + auto const b = TableList { + Table { .schema = "dbo", .name = "users", .columns = { idColumn }, .primaryKeys = { "id" } }, + }; + + auto const diff = DiffSchemas(a, b); + CHECK(diff.Empty()); +} + +TEST_CASE("DiffSchemas: cross-engine schema labels surface on Changed entries", "[SqlSchemaDiff]") +{ + // Same logical table differs only on a column. Both sides must be paired despite their + // different schema labels, and the result must carry both labels for the renderer. + auto const idColumn = Column { + .name = "id", + .type = SqlColumnTypeDefinitions::Integer {}, + .isNullable = false, + .isPrimaryKey = true, + }; + auto const a = TableList { + Table { + .schema = "public", + .name = "users", + .columns = { idColumn, Column { .name = "name", .type = SqlColumnTypeDefinitions::Varchar { 50 }, .size = 50 } }, + .primaryKeys = { "id" }, + }, + }; + auto const b = TableList { + Table { + .schema = "dbo", + .name = "users", + .columns = { idColumn, + Column { .name = "name", .type = SqlColumnTypeDefinitions::Varchar { 100 }, .size = 100 } }, + .primaryKeys = { "id" }, + }, + }; + + auto const diff = DiffSchemas(a, b); + REQUIRE(diff.tables.size() == 1); + auto const& t = diff.tables.front(); + CHECK(t.name == "users"); + CHECK(t.schemaA == "public"); + CHECK(t.schemaB == "dbo"); + CHECK(t.kind == DiffKind::Changed); +} + +TEST_CASE("DiffSchemas: only-in-A keeps schemaA, only-in-B keeps schemaB", "[SqlSchemaDiff]") +{ + auto const idColumn = Column { + .name = "id", + .type = SqlColumnTypeDefinitions::Integer {}, + .isNullable = false, + .isPrimaryKey = true, + }; + auto const a = TableList { + Table { .schema = "public", .name = "posts", .columns = { idColumn }, .primaryKeys = { "id" } }, + }; + auto const b = TableList { + Table { .schema = "dbo", .name = "comments", .columns = { idColumn }, .primaryKeys = { "id" } }, + }; + + auto const diff = DiffSchemas(a, b); + REQUIRE(diff.tables.size() == 2); + + auto const* posts = FindTable(diff, "posts"); + REQUIRE(posts); + CHECK(posts->kind == DiffKind::OnlyInA); + CHECK(posts->schemaA == "public"); + CHECK(posts->schemaB.empty()); + + auto const* comments = FindTable(diff, "comments"); + REQUIRE(comments); + CHECK(comments->kind == DiffKind::OnlyInB); + CHECK(comments->schemaA.empty()); + CHECK(comments->schemaB == "dbo"); +} + +TEST_CASE("DiffSchemas: NVarchar and Varchar are logically equivalent", "[SqlSchemaDiff]") +{ + // PostgreSQL formats migration `NVarchar(50)` as `VARCHAR(50)`; the driver therefore + // returns it as `Varchar`. SQLite and MSSQL preserve `NVarchar`. The diff must treat + // them as the same logical type so post-migration parity holds. + auto const a = TableList { + Table { + .schema = "main", + .name = "users", + .columns = { Column { .name = "name", .type = SqlColumnTypeDefinitions::NVarchar { 50 }, .size = 50 } }, + }, + }; + auto const b = TableList { + Table { + .schema = "public", + .name = "users", + .columns = { Column { .name = "name", .type = SqlColumnTypeDefinitions::Varchar { 50 }, .size = 50 } }, + }, + }; + + auto const diff = DiffSchemas(a, b); + CHECK(diff.Empty()); +} + +TEST_CASE("DiffSchemas: NChar and Char are logically equivalent", "[SqlSchemaDiff]") +{ + auto const a = TableList { + Table { + .schema = "main", + .name = "t", + .columns = { Column { .name = "code", .type = SqlColumnTypeDefinitions::NChar { 30 }, .size = 30 } }, + }, + }; + auto const b = TableList { + Table { + .schema = "public", + .name = "t", + .columns = { Column { .name = "code", .type = SqlColumnTypeDefinitions::Char { 30 }, .size = 30 } }, + }, + }; + + auto const diff = DiffSchemas(a, b); + CHECK(diff.Empty()); +} + +TEST_CASE("DiffSchemas: Real precision drift is ignored", "[SqlSchemaDiff]") +{ + // PostgreSQL formats `Real{}` as `REAL` (float4 → reads back as Real{24}) while SQLite + // round-trips as Real{53}. Migration intent is just "floating point"; precision drift + // here is engine noise, not a logical difference. + auto const a = TableList { + Table { + .schema = "main", + .name = "t", + .columns = { Column { .name = "v", .type = SqlColumnTypeDefinitions::Real { .precision = 53 } } }, + }, + }; + auto const b = TableList { + Table { + .schema = "public", + .name = "t", + .columns = { Column { .name = "v", .type = SqlColumnTypeDefinitions::Real { .precision = 24 } } }, + }, + }; + + auto const diff = DiffSchemas(a, b); + CHECK(diff.Empty()); +} + +TEST_CASE("DiffSchemas: unbounded text representations are equivalent", "[SqlSchemaDiff]") +{ + // SQLite reports `TEXT` columns as `Varchar` with a sentinel huge size; PostgreSQL + // reports them with size 0 and the dialect string `text`. Both are intent = unbounded. + auto const a = TableList { + Table { + .schema = "main", + .name = "t", + .columns = { Column { .name = "body", .type = SqlColumnTypeDefinitions::Varchar { 1'000'000'000 } } }, + }, + }; + auto const b = TableList { + Table { + .schema = "public", + .name = "t", + .columns = { Column { .name = "body", .type = SqlColumnTypeDefinitions::Text { 0 } } }, + }, + }; + + auto const diff = DiffSchemas(a, b); + CHECK(diff.Empty()); +} + +TEST_CASE("DiffSchemas: migration-internal `_migration_locks` is excluded", "[SqlSchemaDiff]") +{ + // SQLite creates a `_migration_locks` table for its lock implementation; PostgreSQL and + // MSSQL use advisory locks. The diff must hide this engine-specific plumbing so a + // genuinely-equivalent schema-migration run reads as equal across all engines. + auto const a = TableList { + Table { + .schema = "main", + .name = "_migration_locks", + .columns = { Column { .name = "lock_name", .type = SqlColumnTypeDefinitions::Varchar { 255 } } }, + }, + }; + auto const b = TableList {}; + + auto const diff = DiffSchemas(a, b); + CHECK(diff.Empty()); +} + +TEST_CASE("DiffSchemas: foreign keys compare schema-agnostically", "[SqlSchemaDiff]") +{ + // Same FK constraint reported with different schema labels on each side. They must + // compare as identical so the table is not flagged as Changed. + auto a = TableList { + Table { + .schema = "public", + .name = "orders", + .columns = { Column { .name = "user_id", .type = SqlColumnTypeDefinitions::Integer {}, .isNullable = false } }, + .foreignKeys = { ForeignKeyConstraint { + .foreignKey = { .table = { .catalog = {}, .schema = "public", .table = "orders" }, + .columns = { "user_id" } }, + .primaryKey = { .table = { .catalog = {}, .schema = "public", .table = "users" }, .columns = { "id" } }, + } }, + }, + }; + auto b = TableList { + Table { + .schema = "dbo", + .name = "orders", + .columns = { Column { .name = "user_id", .type = SqlColumnTypeDefinitions::Integer {}, .isNullable = false } }, + .foreignKeys = { ForeignKeyConstraint { + .foreignKey = { .table = { .catalog = {}, .schema = "dbo", .table = "orders" }, .columns = { "user_id" } }, + .primaryKey = { .table = { .catalog = {}, .schema = "dbo", .table = "users" }, .columns = { "id" } }, + } }, + }, + }; + + auto const diff = DiffSchemas(a, b); + CHECK(diff.Empty()); +} From 396a3198ca873a1852120b99ba5d65f40de17cb6 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 26 Apr 2026 21:16:10 +0200 Subject: [PATCH 4/9] [dbtool] Add diff command for comparing two databases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dbtool diff ` reports the differences between two ODBC-reachable databases. Each is either a profile name (resolved through the same Cfg::ProfileStore as --profile) or a raw connection string starting with DRIVER=...; the heuristic is case-insensitive on the prefix. Default mode runs the full schema + data diff via SqlSchema::DiffSchemas and SqlSchema::DiffTableData. --schema-only suppresses the data phase for fast CI gating; --max-rows N caps per-table row scanning. Read-only on both sides. Exit 0 = equivalent, 1 = differs, 2 = error. DiffRenderer in dbtool_lib drives the output: builds a tui::ParsedTable per section, applies per-row coloring (added=green, removed=red, changed=yellow, header=bold cyan, borders=dim) via SgrBuilder, and writes a bordered table to stdout. ANSI colors auto-disable when stdout is not a tty or when --no-color is given. Width respects $COLUMNS, falling back to 120. Live progress is fed through the existing StandardProgressManager so users see "n/total tables" plus a smoothed-rate ETA on big DBs (millions of rows) without the tool appearing stalled. Per-table SqlException is caught and surfaced as a per-table skipReason so a single un-diffable table doesn't abort the whole run — the rest of the schema still gets compared. Profile→connection-string flattening is extracted from ApplyProfileToOptions into a shared ProfileToConnectionString helper used by both code paths. Help text, --show-examples, and docs/dbtool.md are updated; the new --no-color and --max-rows options are documented alongside --schema-only's extended dual semantic. Signed-off-by: Christian Parpart --- docs/dbtool.md | 57 +++++ src/tests/test_dbtool.py | 188 +++++++++++++++ src/tools/dbtool/CMakeLists.txt | 4 +- src/tools/dbtool/DiffRenderer.cpp | 367 ++++++++++++++++++++++++++++++ src/tools/dbtool/DiffRenderer.hpp | 41 ++++ src/tools/dbtool/main.cpp | 259 ++++++++++++++++++++- 6 files changed, 914 insertions(+), 2 deletions(-) create mode 100644 src/tools/dbtool/DiffRenderer.cpp create mode 100644 src/tools/dbtool/DiffRenderer.hpp diff --git a/docs/dbtool.md b/docs/dbtool.md index 6a71577e9..df103cb9c 100644 --- a/docs/dbtool.md +++ b/docs/dbtool.md @@ -289,6 +289,63 @@ Useful for: - Baseline migrations when setting up an existing database - Skipping migrations that were applied manually +## Diff + +### diff + +Compares two databases and prints a colored, structured report of their differences. +Default mode reports both **schema** (tables, columns, types, primary keys, foreign +keys, indexes) and **data** (per-row diffs paired by primary key); `--schema-only` +suppresses the data phase. + +``` +dbtool diff [--schema-only] [--no-color] [--max-rows N] +``` + +Each `` is either: + +- a **profile name** from the dbtool config file (resolved like `--profile`), or +- a **raw ODBC connection string** starting with `DRIVER=...` (case-insensitive). + +Live progress lines (current table, rows scanned, smoothed-rate ETA) are emitted to +stderr while the diff runs; the final report is printed to stdout once progress +completes. `--quiet` and `--progress logline|ascii|unicode` apply as elsewhere. + +`diff` is fully **read-only** — only `SELECT` queries are issued against either side. + +Exit codes: + +- `0` — databases are equivalent under the chosen mode +- `1` — differences were found +- `2` — error (connection failure, profile not found, etc.) + +#### Examples + +```bash +# Compare two profiles defined in your config: +dbtool diff prod staging + +# Compare a profile against an SQLite snapshot: +dbtool diff prod "DRIVER=SQLite3;Database=/tmp/snapshot.db" + +# Schema-only check (fast — useful for CI gating): +dbtool diff prod staging --schema-only + +# Cap data scan to the first 10k rows per table: +dbtool diff prod staging --max-rows 10000 +``` + +#### Caveats + +- Tables without a primary key are reported under the schema diff but **skipped** + for the data diff (with a `(skipped: no primary key)` note). PK pairing is + required to distinguish a *changed* row from an *added + removed* pair. +- Column values are compared as their ODBC string representation. This may produce + false positives for floating-point or binary columns whose textual encoding + differs across drivers; rerun with `--schema-only` to confirm if the column's + type matches. +- `--max-rows` truncation is per-side; the report flags `truncated` when hit. + ## Backup & Restore ### backup diff --git a/src/tests/test_dbtool.py b/src/tests/test_dbtool.py index ba6711980..b925aaa04 100644 --- a/src/tests/test_dbtool.py +++ b/src/tests/test_dbtool.py @@ -538,6 +538,194 @@ def main(): print(f"status error message did not mention 'No migrations registered':\n{no_plugin_result.stderr}") sys.exit(1) + # The diff sections below stand up two disposable SQLite files with the + # Python sqlite3 module and drive `dbtool diff` over the SQLite ODBC driver, + # so they exercise the full CLI path (arg parsing, profile resolution, + # connection handling, schema introspection, schema + data diff, + # rendering) end-to-end. They run regardless of --test-env because the + # diff command's value proposition is cross-engine and SQLite is the + # cheapest harness; the [SqlSchemaDiff] / [SqlDataDiff] Catch2 suites + # provide the per-engine coverage on top. + import sqlite3 as _sqlite3 + + def _seed_users_db(path, rows): + conn = _sqlite3.connect(path) + try: + conn.execute("DROP TABLE IF EXISTS users") + conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)") + conn.executemany("INSERT INTO users (id, name) VALUES (?, ?)", rows) + conn.commit() + finally: + conn.close() + + def _sqlite_cs(path): + # The SQLite ODBC driver name is bracketed because it contains spaces; + # this matches the format the Catch2 fixtures use (SqlDataDiffTests.cpp:52). + return f"DRIVER={{SQLite3 ODBC Driver}};Database={path}" + + print("--- 14. dbtool diff: identical databases exit 0 and report no differences ---") + with tempfile.TemporaryDirectory() as diff_tmpdir: + a = os.path.join(diff_tmpdir, "a.sqlite") + b = os.path.join(diff_tmpdir, "b.sqlite") + seed = [(1, "alice"), (2, "bob")] + _seed_users_db(a, seed) + _seed_users_db(b, seed) + + # The diff command opens its own connections from positional args; no + # --connection-string or --plugins-dir needed (and they would be ignored). + same_result = run_command( + [args.dbtool, "diff", _sqlite_cs(a), _sqlite_cs(b), "--no-color", "--quiet"], + check=False, + ) + if same_result.returncode != 0: + print(f"diff of identical DBs unexpectedly returned {same_result.returncode}:\n" + f"STDOUT:\n{same_result.stdout}\nSTDERR:\n{same_result.stderr}") + sys.exit(1) + if "(schemas match)" not in same_result.stdout: + print(f"diff of identical DBs did not report '(schemas match)':\n{same_result.stdout}") + sys.exit(1) + # Data diff section must either be absent (empty dataDiffs) or report a match — + # never a row-level diff for byte-identical inputs. + if "changed" in same_result.stdout or "only in" in same_result.stdout: + print(f"diff of identical DBs surfaced row-level differences:\n{same_result.stdout}") + sys.exit(1) + + print("--- 15. dbtool diff: row-value drift surfaces as a 'changed' cell with exit 1 ---") + with tempfile.TemporaryDirectory() as diff_tmpdir: + a = os.path.join(diff_tmpdir, "a.sqlite") + b = os.path.join(diff_tmpdir, "b.sqlite") + _seed_users_db(a, [(1, "alice"), (2, "bob")]) + _seed_users_db(b, [(1, "alice"), (2, "BOB")]) # row 2 differs in the value column + + diff_result = run_command( + [args.dbtool, "diff", _sqlite_cs(a), _sqlite_cs(b), "--no-color", "--quiet"], + check=False, + ) + if diff_result.returncode != 1: + print(f"diff with row drift expected exit 1, got {diff_result.returncode}:\n" + f"STDOUT:\n{diff_result.stdout}\nSTDERR:\n{diff_result.stderr}") + sys.exit(1) + # The data-diff section must name the table, the diverging column, and both values. + for needle in ("Data diff: users", "changed", "name", "bob", "BOB"): + if needle not in diff_result.stdout: + print(f"diff output missing expected fragment {needle!r}:\n{diff_result.stdout}") + sys.exit(1) + + print("--- 16. dbtool diff: row-only-on-one-side is reported as 'only in A' / 'only in B' ---") + with tempfile.TemporaryDirectory() as diff_tmpdir: + a = os.path.join(diff_tmpdir, "a.sqlite") + b = os.path.join(diff_tmpdir, "b.sqlite") + _seed_users_db(a, [(1, "alice"), (2, "bob")]) + _seed_users_db(b, [(1, "alice")]) # row 2 only on A + + only_result = run_command( + [args.dbtool, "diff", _sqlite_cs(a), _sqlite_cs(b), "--no-color", "--quiet"], + check=False, + ) + if only_result.returncode != 1: + print(f"diff with one-sided row expected exit 1, got {only_result.returncode}:\n" + f"STDOUT:\n{only_result.stdout}\nSTDERR:\n{only_result.stderr}") + sys.exit(1) + if "only in A" not in only_result.stdout: + print(f"diff did not report 'only in A' for the extra row on side A:\n{only_result.stdout}") + sys.exit(1) + + print("--- 17. dbtool diff --schema-only suppresses the data diff section ---") + with tempfile.TemporaryDirectory() as diff_tmpdir: + a = os.path.join(diff_tmpdir, "a.sqlite") + b = os.path.join(diff_tmpdir, "b.sqlite") + _seed_users_db(a, [(1, "alice")]) + _seed_users_db(b, [(1, "ALICE")]) # would differ at the row level + + schema_only_result = run_command( + [args.dbtool, "diff", _sqlite_cs(a), _sqlite_cs(b), + "--schema-only", "--no-color", "--quiet"], + check=False, + ) + # Schema matches → exit 0 regardless of data drift in this mode. + if schema_only_result.returncode != 0: + print(f"--schema-only with matching schemas expected exit 0, got {schema_only_result.returncode}:\n" + f"STDOUT:\n{schema_only_result.stdout}\nSTDERR:\n{schema_only_result.stderr}") + sys.exit(1) + if "Data diff:" in schema_only_result.stdout: + print(f"--schema-only unexpectedly emitted a data-diff section:\n{schema_only_result.stdout}") + sys.exit(1) + + print("--- 18. dbtool diff: a default profile's `schema:` must not leak into raw connection strings ---") + # Regression: a default profile carrying `schema: dbo` (typical for SQL-Server + # operators) used to leak into the introspection queries on both sides, so + # diff'ing two raw SQLite connection strings would issue `PRAGMA "dbo".table_info(...)` + # and SQLite would reply "unknown database 'dbo'". The schema diff is keyed + # by table name only — the default profile's schema must not be applied as + # a filter when the user is comparing two raw connection strings. + with tempfile.TemporaryDirectory() as diff_tmpdir: + a = os.path.join(diff_tmpdir, "a.sqlite") + b = os.path.join(diff_tmpdir, "b.sqlite") + _seed_users_db(a, [(1, "alice")]) + _seed_users_db(b, [(1, "ALICE")]) + cfg_path = os.path.join(diff_tmpdir, "dbtool.yml") + with open(cfg_path, "w", encoding="utf-8") as f: + f.write( + "defaultProfile: leaky\n" + "profiles:\n" + " leaky:\n" + " schema: dbo\n" + " connectionString: \"DRIVER=SQLite3;Database=unused.db\"\n" + ) + + leak_result = run_command( + [args.dbtool, "--config", cfg_path, "diff", + _sqlite_cs(a), _sqlite_cs(b), "--no-color", "--quiet"], + check=False, + ) + # Expect exit 1 (data differs) — anything else (especially exit 1 with an + # "unknown database 'dbo'" error) indicates the `schema: dbo` from the + # default profile leaked into the SQLite introspection path. + if leak_result.returncode != 1: + print(f"diff with default `schema: dbo` profile expected exit 1, got {leak_result.returncode}:\n" + f"STDOUT:\n{leak_result.stdout}\nSTDERR:\n{leak_result.stderr}") + sys.exit(1) + if "unknown database" in leak_result.stderr or "unknown database" in leak_result.stdout: + print(f"diff leaked `dbo` schema into SQLite introspection — regression:\n" + f"STDOUT:\n{leak_result.stdout}\nSTDERR:\n{leak_result.stderr}") + sys.exit(1) + if "Data diff: users" not in leak_result.stdout: + print(f"diff under a default profile did not surface the expected row drift:\n{leak_result.stdout}") + sys.exit(1) + + print("--- 19. dbtool diff: bad connection string surfaces a clear error ---") + with tempfile.TemporaryDirectory() as diff_tmpdir: + a = os.path.join(diff_tmpdir, "a.sqlite") + _seed_users_db(a, [(1, "alice")]) + + bad_result = run_command( + [args.dbtool, "diff", + _sqlite_cs(a), + "DRIVER={Nonexistent ODBC Driver};Database=/dev/null", + "--no-color", "--quiet"], + check=False, + ) + # Any non-zero exit is acceptable; we only care that it does NOT exit 0 + # (which would silently mask a connection failure). + if bad_result.returncode == 0: + print(f"diff against an unreachable driver unexpectedly exited 0:\n" + f"STDOUT:\n{bad_result.stdout}\nSTDERR:\n{bad_result.stderr}") + sys.exit(1) + + print("--- 20. dbtool diff: missing positional argument is rejected ---") + missing_arg_result = run_command( + [args.dbtool, "diff", _sqlite_cs("anywhere.sqlite")], + check=False, + ) + if missing_arg_result.returncode == 0: + print(f"diff with only one source unexpectedly exited 0:\n" + f"STDOUT:\n{missing_arg_result.stdout}\nSTDERR:\n{missing_arg_result.stderr}") + sys.exit(1) + if "two source arguments" not in missing_arg_result.stderr: + print(f"diff with missing source did not mention 'two source arguments':\n" + f"STDERR:\n{missing_arg_result.stderr}") + sys.exit(1) + print("SUCCESS") diff --git a/src/tools/dbtool/CMakeLists.txt b/src/tools/dbtool/CMakeLists.txt index 0efdc498a..4f0019299 100644 --- a/src/tools/dbtool/CMakeLists.txt +++ b/src/tools/dbtool/CMakeLists.txt @@ -1,9 +1,11 @@ add_library(dbtool_lib STATIC + DiffRenderer.cpp + DiffRenderer.hpp StandardProgressManager.cpp StandardProgressManager.hpp ) -target_link_libraries(dbtool_lib PUBLIC Lightweight tools_shared) +target_link_libraries(dbtool_lib PUBLIC Lightweight Lightweight::Tui tools_shared) add_executable(dbtool main.cpp) if(WIN32) diff --git a/src/tools/dbtool/DiffRenderer.cpp b/src/tools/dbtool/DiffRenderer.cpp new file mode 100644 index 000000000..500d984e1 --- /dev/null +++ b/src/tools/dbtool/DiffRenderer.cpp @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "DiffRenderer.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Lightweight::Tools +{ + +namespace +{ + + using SqlSchema::ColumnDiff; + using SqlSchema::DiffKind; + using SqlSchema::RowDiff; + using SqlSchema::SchemaDiff; + using SqlSchema::TableDataDiff; + using SqlSchema::TableDiff; + + /// Resolves the effective max table width: the user value, or `$COLUMNS`, or 120. + [[nodiscard]] int ResolveMaxWidth(int requested) noexcept + { + if (requested > 0) + return requested; + // NOLINTNEXTLINE(concurrency-mt-unsafe) — startup, single-threaded. + if (auto const* cols = std::getenv("COLUMNS")) + { + auto const view = std::string_view { cols }; + auto value = 0; + auto const result = std::from_chars(view.data(), view.data() + view.size(), value); + if (result.ec == std::errc {} && value > 20) + return value; + } + return 120; + } + + /// Color palette for diff entries. RGB picked to look reasonable on both light and + /// dark backgrounds without depending on a specific 256-color scheme. + struct Palette + { + tui::Style added; ///< OnlyInB rows / cells (additions). + tui::Style removed; ///< OnlyInA rows / cells (removals). + tui::Style changed; ///< Changed rows / cells. + tui::Style header; ///< Section headers and table headers. + tui::Style dim; ///< Box-drawing borders. + }; + + [[nodiscard]] Palette MakePalette() + { + auto const green = tui::RgbColor { .r = 64, .g = 200, .b = 100 }; + auto const red = tui::RgbColor { .r = 230, .g = 80, .b = 80 }; + auto const yellow = tui::RgbColor { .r = 220, .g = 180, .b = 60 }; + auto const cyan = tui::RgbColor { .r = 90, .g = 180, .b = 220 }; + auto added = tui::Style {}; + added.fg = green; + auto removed = tui::Style {}; + removed.fg = red; + auto changed = tui::Style {}; + changed.fg = yellow; + auto header = tui::Style {}; + header.fg = cyan; + header.bold = true; + auto dim = tui::Style {}; + dim.dim = true; + return Palette { + .added = added, + .removed = removed, + .changed = changed, + .header = header, + .dim = dim, + }; + } + + [[nodiscard]] std::string DiffKindLabel(DiffKind k) + { + switch (k) + { + case DiffKind::OnlyInA: + return "only in A"; + case DiffKind::OnlyInB: + return "only in B"; + case DiffKind::Changed: + return "changed"; + } + return "?"; + } + + [[nodiscard]] tui::Style const& StyleFor(DiffKind k, Palette const& p) + { + switch (k) + { + case DiffKind::OnlyInA: + return p.removed; + case DiffKind::OnlyInB: + return p.added; + case DiffKind::Changed: + return p.changed; + } + return p.changed; + } + + /// Wraps @p text in the SGR sequence for @p style, followed by reset. Returns the + /// raw text when colors are disabled. + [[nodiscard]] std::string Colorize(std::string_view text, tui::Style const& style, bool useColor) + { + if (!useColor) + return std::string { text }; + auto const open = tui::buildSgrSequence(style); + if (open.empty()) + return std::string { text }; + return std::format("{}{}{}", open, text, tui::sgrReset()); + } + + /// Renders a parsed table to @p out with bordered, colored cells. + /// @p cellStyle returns the style for cell `(row, col)`; nullptr cells use default. + void EmitBorderedTable(std::ostream& out, + tui::ParsedTable const& table, + Palette const& palette, + bool useColor, + int maxWidth, + std::function cellStyle) + { + auto widths = tui::computeColumnWidths(table); + tui::constrainColumnWidths(widths, maxWidth); + + auto const drawSeparator = [&]() { + auto sep = std::string { "+" }; + for (auto const w: widths) + sep += std::string(static_cast(w + 2), '-') + "+"; + out << Colorize(sep, palette.dim, useColor) << '\n'; + }; + + auto const drawRow = [&](std::vector const& cells, + std::function const& styleOfCell) { + out << Colorize("|", palette.dim, useColor); + for (auto const& [col, w]: std::views::enumerate(widths)) + { + auto const colSize = static_cast(col); + auto const text = std::cmp_less(col, cells.size()) + ? tui::truncateToDisplayWidth(cells[colSize], w) + : std::string {}; + auto const alignment = std::cmp_less(col, table.alignments.size()) + ? table.alignments[colSize] + : tui::TableAlignment::Left; + auto const padded = tui::alignCell(text, w, alignment); + auto const* st = styleOfCell(colSize); + out << ' '; + out << (st ? Colorize(padded, *st, useColor) : padded); + out << ' '; + out << Colorize("|", palette.dim, useColor); + } + out << '\n'; + }; + + drawSeparator(); + drawRow(table.headers, [&](std::size_t) { return &palette.header; }); + drawSeparator(); + for (auto const& [r, row]: std::views::enumerate(table.rows)) + drawRow(row, [&, r = r](std::size_t c) { return cellStyle(static_cast(r), c); }); + drawSeparator(); + } + + void EmitSectionHeader(std::ostream& out, std::string_view title, Palette const& p, bool useColor) + { + out << '\n' << Colorize(title, p.header, useColor) << '\n'; + } + + /// Builds a one-line description of a column diff (for the schema-diff "details" cell). + [[nodiscard]] std::string DescribeColumnDiff(ColumnDiff const& cd) + { + switch (cd.kind) + { + case DiffKind::OnlyInA: + return cd.a ? std::format("only in A: {}", cd.a->dialectDependantTypeString) : "only in A"; + case DiffKind::OnlyInB: + return cd.b ? std::format("only in B: {}", cd.b->dialectDependantTypeString) : "only in B"; + case DiffKind::Changed: { + auto details = std::string { "changed: " }; + for (auto const& [i, f]: std::views::enumerate(cd.changedFields)) + { + if (i != 0) + details += ", "; + details += f; + } + if (cd.a && cd.b) + details += std::format(" ({} → {})", cd.a->dialectDependantTypeString, + cd.b->dialectDependantTypeString); + return details; + } + } + return {}; + } + + /// Builds a single-line "details" cell for a TableDiff at the table level. + [[nodiscard]] std::string DescribeTableDiff(TableDiff const& td) + { + if (td.kind != DiffKind::Changed) + return ""; + auto parts = std::vector {}; + if (!td.columns.empty()) + parts.emplace_back(std::format("{} column(s)", td.columns.size())); + if (!td.primaryKeyDiffs.empty()) + parts.emplace_back("PK changed"); + if (!td.foreignKeyDiffs.empty()) + parts.emplace_back(std::format("{} FK", td.foreignKeyDiffs.size())); + if (!td.indexDiffs.empty()) + parts.emplace_back(std::format("{} index", td.indexDiffs.size())); + auto out = std::string {}; + for (auto const& [i, p]: std::views::enumerate(parts)) + { + if (i != 0) + out += ", "; + out += p; + } + return out; + } + + void RenderSchemaDiff(std::ostream& out, + SchemaDiff const& diff, + Palette const& palette, + bool useColor, + int maxWidth) + { + EmitSectionHeader(out, "Schema diff", palette, useColor); + + if (diff.Empty()) + { + out << Colorize(" (schemas match)", palette.dim, useColor) << '\n'; + return; + } + + // Top-level table: one row per TableDiff plus inline column rows under "Changed". + auto pt = tui::ParsedTable {}; + pt.headers = { "Table", "Status", "Details" }; + pt.alignments = { tui::TableAlignment::Left, tui::TableAlignment::Left, tui::TableAlignment::Left }; + pt.columnCount = 3; + + // Track which rows correspond to which DiffKind for cell coloring. + auto rowKinds = std::vector {}; + + for (auto const& td: diff.tables) + { + auto const qualifiedName = td.schema.empty() ? td.name : std::format("{}.{}", td.schema, td.name); + pt.rows.push_back({ qualifiedName, DiffKindLabel(td.kind), DescribeTableDiff(td) }); + rowKinds.push_back(td.kind); + + for (auto const& cd: td.columns) + { + pt.rows.push_back({ std::format(" └ {}", cd.name), DiffKindLabel(cd.kind), DescribeColumnDiff(cd) }); + rowKinds.push_back(cd.kind); + } + for (auto const& pk: td.primaryKeyDiffs) + { + pt.rows.push_back({ std::string { " └ " }, "changed", pk }); + rowKinds.push_back(DiffKind::Changed); + } + for (auto const& fk: td.foreignKeyDiffs) + { + pt.rows.push_back({ std::string { " └ FK" }, "changed", fk }); + rowKinds.push_back(DiffKind::Changed); + } + for (auto const& idx: td.indexDiffs) + { + pt.rows.push_back({ std::string { " └ index" }, "changed", idx }); + rowKinds.push_back(DiffKind::Changed); + } + } + + EmitBorderedTable( + out, pt, palette, useColor, maxWidth, [&](std::size_t r, std::size_t /*c*/) -> tui::Style const* { + return r < rowKinds.size() ? &StyleFor(rowKinds[r], palette) : nullptr; + }); + } + + [[nodiscard]] std::string JoinPk(std::vector const& pk) + { + auto out = std::string {}; + for (auto const& [i, v]: std::views::enumerate(pk)) + { + if (i != 0) + out += "/"; + out += v; + } + return out; + } + + void RenderTableDataDiff(std::ostream& out, + TableDataDiff const& d, + Palette const& palette, + bool useColor, + int maxWidth) + { + EmitSectionHeader( + out, + std::format("Data diff: {} (A: {} rows, B: {} rows{})", + d.tableName, d.aRowCount, d.bRowCount, d.truncated ? ", truncated" : ""), + palette, useColor); + + if (d.skipReason.has_value()) + { + out << Colorize(std::format(" (skipped: {})", *d.skipReason), palette.dim, useColor) << '\n'; + return; + } + if (d.rows.empty()) + { + out << Colorize(" (data matches)", palette.dim, useColor) << '\n'; + return; + } + + auto pt = tui::ParsedTable {}; + pt.headers = { "PK", "Status", "Column", "A", "B" }; + pt.alignments = { tui::TableAlignment::Left, tui::TableAlignment::Left, tui::TableAlignment::Left, + tui::TableAlignment::Left, tui::TableAlignment::Left }; + pt.columnCount = 5; + + auto rowKinds = std::vector {}; + for (auto const& r: d.rows) + { + if (r.kind == DiffKind::Changed && !r.changedCells.empty()) + { + for (auto const& [col, va, vb]: r.changedCells) + { + pt.rows.push_back({ JoinPk(r.primaryKey), DiffKindLabel(r.kind), col, va, vb }); + rowKinds.push_back(DiffKind::Changed); + } + } + else + { + pt.rows.push_back({ JoinPk(r.primaryKey), DiffKindLabel(r.kind), "-", "-", "-" }); + rowKinds.push_back(r.kind); + } + } + + EmitBorderedTable( + out, pt, palette, useColor, maxWidth, [&](std::size_t r, std::size_t /*c*/) -> tui::Style const* { + return r < rowKinds.size() ? &StyleFor(rowKinds[r], palette) : nullptr; + }); + } + +} // namespace + +void RenderDiff(std::ostream& out, + SqlSchema::SchemaDiff const& schemaDiff, + std::vector const& dataDiffs, + DiffRenderOptions const& options) +{ + auto const palette = MakePalette(); + auto const maxWidth = ResolveMaxWidth(options.maxWidth); + + RenderSchemaDiff(out, schemaDiff, palette, options.useColor, maxWidth); + for (auto const& d: dataDiffs) + RenderTableDataDiff(out, d, palette, options.useColor, maxWidth); +} + +} // namespace Lightweight::Tools diff --git a/src/tools/dbtool/DiffRenderer.hpp b/src/tools/dbtool/DiffRenderer.hpp new file mode 100644 index 000000000..b4d1405bf --- /dev/null +++ b/src/tools/dbtool/DiffRenderer.hpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include +#include + +namespace Lightweight::Tools +{ + +/// @brief Display options for `dbtool diff` output. +struct DiffRenderOptions +{ + /// When false, ANSI color escapes are suppressed entirely. + bool useColor = true; + + /// Maximum table width in terminal columns. 0 means "auto-detect from `COLUMNS`, + /// fall back to 120". + int maxWidth = 0; +}; + +/// @brief Pretty-prints a schema diff and (optionally) a list of per-table data diffs. +/// +/// Renders one bordered table section for the schema diff and one section per table +/// data diff. Cells representing additions are colored green, removals red, and +/// modifications yellow. Diff entries are emitted in a stable order so output is +/// deterministic across runs. +/// +/// @param out Output stream (e.g. `std::cout`). +/// @param schemaDiff Result of `SqlSchema::DiffSchemas`. +/// @param dataDiffs Optional per-table data diffs (empty when `--schema-only`). +/// @param options Color / width controls. +void RenderDiff(std::ostream& out, + SqlSchema::SchemaDiff const& schemaDiff, + std::vector const& dataDiffs, + DiffRenderOptions const& options); + +} // namespace Lightweight::Tools diff --git a/src/tools/dbtool/main.cpp b/src/tools/dbtool/main.cpp index 8745f6b16..c50324622 100644 --- a/src/tools/dbtool/main.cpp +++ b/src/tools/dbtool/main.cpp @@ -1,14 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 #include "Lightweight/SqlConnectInfo.hpp" +#include "DiffRenderer.hpp" #include "StandardProgressManager.hpp" #include #include #include #include +#include #include #include +#include #include #include @@ -163,6 +166,11 @@ void PrintUsage() std::println(" {}exec{} {}{} Executes the given SQL query and prints any result set.", c.command, c.reset, c.param, c.reset); std::println(" Pass `-` (or omit the argument) to read the query from stdin."); + std::println(" {}diff{} {}{} {}{} Compares two databases (schema + data) and prints a colored", + c.command, c.reset, c.param, c.reset, c.param, c.reset); + std::println(" report. Each is either a profile name or a raw"); + std::println(" ODBC connection string starting with DRIVER=..."); + std::println(" Read-only on both sides. Exit code: 0 = equivalent, 1 = differs."); std::println(" {}backup{} --output FILE Backs up the database to a file", c.command, c.reset); std::println(" {}restore{} --input FILE Restores the database from a file", c.command, c.reset); std::println(" {}resolve-secret{} {}{} Prints a resolved secret to stdout (env:, file:, stdin:)", @@ -219,8 +227,12 @@ void PrintUsage() c.option, c.reset, c.option, c.reset); std::println(" {}--no-lock{} Skip migration locking for write operations", c.option, c.reset); - std::println(" {}--schema-only{} Backup/restore schema only, skip data", + std::println(" {}--schema-only{} Backup/restore schema only, skip data; for `diff` skips the data diff", c.option, c.reset); + std::println(" {}--no-color{} Disable ANSI colors in `diff` output (auto-disabled when not at a tty)", + c.option, c.reset); + std::println(" {}--max-rows{} {}{} Cap rows scanned per table for `diff --data` (default: unlimited)", + c.option, c.reset, c.param, c.reset); std::println(" {}--quiet{} Suppress progress output", c.option, c.reset); std::println(" {}--verbose{}, {}-v{} Emit extra informational output (e.g. shadowed plugins)", c.option, c.reset, c.option, c.reset); @@ -298,6 +310,17 @@ void PrintExamples() std::println(" {}# List configured connection profiles (no DB connection needed):{}", c.example, c.reset); std::println(" {}dbtool list-profiles{}", c.code, c.reset); + std::println(""); + + std::println(" {}# Diff two databases (schema + data) using profile names:{}", c.example, c.reset); + std::println(" {}dbtool diff prod staging{}", c.code, c.reset); + std::println(""); + + std::println(" {}# Diff using raw connection strings (schema only, no color):{}", c.example, c.reset); + std::println(" {}{}{}", + c.code, + R"(dbtool diff "DRIVER=SQLite3;Database=a.db" "DRIVER=SQLite3;Database=b.db" --schema-only --no-color)", + c.reset); // clang-format on } @@ -313,6 +336,7 @@ struct Options { std::string command; std::string argument; + std::string secondArgument; ///< Second positional argument (currently used by `diff` for source-B). /// Plugin search directories. `--plugins-dir` sets this to a single-element /// list; `defaultPluginsDir` in `dbtool.yml` may set it to multiple entries /// — see `Tools::DiscoverPlugins` for the filename-based dedup that @@ -341,6 +365,13 @@ struct Options bool yes = false; ///< If true, confirm destructive actions (e.g. rewrite-checksums) bool verbose = false; ///< If true, emit extra informational output (e.g. shadowed plugins) + /// @brief `--no-color` for `diff` (and other text-rendering commands). When true, + /// ANSI colors are suppressed. + bool noColor = false; + + /// @brief `--max-rows ` for `diff --data` mode. 0 means unlimited. + std::size_t diffMaxRows = 0; + /// @brief `--up-to ` for migration commands. Empty = no bound. std::string upTo; }; @@ -412,6 +443,7 @@ struct Options return result; } + /// Loads a profile from a `ProfileStore` and fills `options` fields that were not /// already set on the CLI. Supports both the legacy single-profile YAML shape /// (top-level `PluginsDir` / `ConnectionString` / `Schema`) and the multi-profile @@ -687,6 +719,16 @@ std::expected ParseArguments(int argc, char** argv) return std::unexpected { "Error: --up-to requires an argument" }; options.upTo = argv[++i]; } + else if (arg == "--no-color") + { + options.noColor = true; + } + else if (arg == "--max-rows") + { + if (i + 1 >= argc) + return std::unexpected { "Error: --max-rows requires an argument" }; + options.diffMaxRows = static_cast(std::stoull(argv[++i])); + } else if (options.command.empty()) { options.command = arg; @@ -695,6 +737,10 @@ std::expected ParseArguments(int argc, char** argv) { options.argument = arg; } + else if (options.secondArgument.empty()) + { + options.secondArgument = arg; + } else { return std::unexpected { std::format("Unknown argument: {}", arg) }; @@ -2229,6 +2275,211 @@ int ExecQuery(Options const& options) } } +/// Builds a single InProgress event for the schema-read or data-diff phases. Centralised +/// here so the field-set (which clang's designated-init warning is picky about) lives in +/// one place. +[[nodiscard]] SqlBackup::Progress MakeProgressEvent(std::string tableName, + std::size_t current, + std::optional total = std::nullopt, + SqlBackup::Progress::State state = SqlBackup::Progress::State::InProgress) +{ + auto p = SqlBackup::Progress {}; + p.state = state; + p.tableName = std::move(tableName); + p.currentRows = current; + p.totalRows = total; + return p; +} + +/// Walks every table on side A whose name is also present on side B and runs +/// `SqlSchema::DiffTableData`, accumulating non-empty results. Live progress events are +/// fed into @p progress so the user sees current row counts and an ETA. +[[nodiscard]] std::vector DiffSharedTables( + SqlConnection& connA, + SqlConnection& connB, + SqlSchema::TableList const& tablesA, + SqlSchema::TableList const& tablesB, + std::size_t maxRows, + SqlBackup::ProgressManager* progress) +{ + auto tablesByName = std::map {}; + for (auto const& t: tablesB) + tablesByName.emplace(t.name, &t); + + auto onProgress = [&](SqlSchema::DiffProgressEvent const& ev) { + if (!progress) + return; + progress->Update(MakeProgressEvent(std::format("data: {}", ev.tableName), + ev.rowsScannedA + ev.rowsScannedB)); + }; + + auto diffs = std::vector {}; + for (auto const& tA: tablesA) + { + if (!tablesByName.contains(tA.name)) + continue; // Schema diff already reports "only in A" — skip. + + // Catch per-table errors so one un-diffable table doesn't abort the whole run. + // Driver-side coercion failures (e.g. "Numeric value out of range" on wide numeric + // columns) get reported as a per-table `skipReason` and the diff continues. + auto diff = SqlSchema::TableDataDiff { .tableName = tA.name }; + try + { + diff = SqlSchema::DiffTableData(connA, connB, tA, maxRows, onProgress); + } + catch (Lightweight::SqlException const& ex) + { + diff.skipReason = std::format("error: {}", ex.info().message); + } + + if (progress) + progress->Update(MakeProgressEvent(std::format("data: {}", tA.name), + diff.aRowCount + diff.bRowCount, + std::nullopt, + SqlBackup::Progress::State::Finished)); + if (!diff.rows.empty() || diff.skipReason.has_value()) + diffs.push_back(std::move(diff)); + } + return diffs; +} + +/// Decides whether @p token is a raw ODBC connection string (`DRIVER=...`) or a +/// profile name to look up via `Cfg::ProfileStore`. Returns the resolved connection +/// string, or an error string suitable for printing to stderr. +[[nodiscard]] std::expected ResolveDiffSource(std::string_view token, + Options const& options) +{ + namespace Cfg = Lightweight::Config; + + // Heuristic: ODBC connection strings start with `DRIVER=` (case-insensitive). + auto looksLikeOdbcCs = [](std::string_view t) { + constexpr auto kExpected = std::string_view { "DRIVER=" }; + if (t.size() < kExpected.size()) + return false; + for (std::size_t i = 0; i < kExpected.size(); ++i) + { + auto const lhs = static_cast(std::tolower(static_cast(t[i]))); + auto const rhs = static_cast(std::tolower(static_cast(kExpected[i]))); + if (lhs != rhs) + return false; + } + return true; + }; + + if (looksLikeOdbcCs(token)) + return SqlConnectionString { std::string { token } }; + + // Treat as profile name. Resolve through the same `ProfileStore` as the rest of dbtool, + // honoring `--config` if the user passed one. + auto configPath = !options.configFile.empty() ? std::filesystem::path { options.configFile } + : Cfg::ProfileStore::DefaultPath(); + if (!std::filesystem::exists(configPath)) + return std::unexpected { std::format("Error: profile '{}' requested but no config file at {}", token, + configPath.string()) }; + + auto storeResult = Cfg::ProfileStore::LoadOrDefault(configPath); + if (!storeResult) + return std::unexpected { std::format("Error loading config file: {}", storeResult.error()) }; + + auto const* profile = storeResult->Find(std::string { token }); + if (!profile) + return std::unexpected { std::format("Error: profile '{}' not found in {}", token, configPath.string()) }; + + auto cs = ProfileToConnectionString(*profile); + if (cs.value.empty()) + return std::unexpected { std::format("Error: profile '{}' has no connectionString or dsn", token) }; + return cs; +} + +/// Implements `dbtool diff `. Read-only; opens two independent +/// connections (no global default), runs the schema diff and (unless --schema-only) +/// the data diff, prints a colored report to stdout, and returns a non-zero exit code +/// when any differences were found. +int DiffDatabases(Options const& options) +{ + if (options.argument.empty() || options.secondArgument.empty()) + { + std::println(std::cerr, + "Error: diff requires two source arguments (profile name or DRIVER=... connection string)."); + std::println(std::cerr, + "Usage: dbtool diff [--schema-only] [--no-color] [--max-rows N]"); + return EXIT_FAILURE; + } + + auto const csA = ResolveDiffSource(options.argument, options); + if (!csA) + { + std::println(std::cerr, "{}", csA.error()); + return EXIT_FAILURE; + } + auto const csB = ResolveDiffSource(options.secondArgument, options); + if (!csB) + { + std::println(std::cerr, "{}", csB.error()); + return EXIT_FAILURE; + } + + try + { + auto connA = SqlConnection { *csA }; + auto connB = SqlConnection { *csB }; + if (!connA.IsAlive()) + { + std::println(std::cerr, "Error: failed to connect to source A."); + return EXIT_FAILURE; + } + if (!connB.IsAlive()) + { + std::println(std::cerr, "Error: failed to connect to source B."); + return EXIT_FAILURE; + } + + auto progress = CreateProgressManager(options); + + // Schema phase — feed the per-table callbacks of `ReadAllTables` into the + // progress manager so the user sees "n/total" while introspection runs. + auto stmtA = SqlStatement { connA }; + auto stmtB = SqlStatement { connB }; + + auto schemaProgressFor = [&](std::string const& side) { + return [&, side](std::string_view tableName, std::size_t current, std::size_t total) { + if (progress) + progress->Update(MakeProgressEvent(std::format("{}: {}", side, tableName), current, total)); + }; + }; + + // The schema diff pairs tables by name only — engine-specific schema labels + // (`dbo`, `public`, `""`) are treated as metadata. Don't filter introspection + // by `options.schema`: it would leak the default profile's schema (e.g. `dbo` + // from a SQL-Server profile) into raw SQLite/Postgres connection strings and + // break the cross-engine contract. Both sides enumerate all schemas. + auto const tablesA = SqlSchema::ReadAllTables(stmtA, connA.DatabaseName(), "", schemaProgressFor("schema A")); + auto const tablesB = SqlSchema::ReadAllTables(stmtB, connB.DatabaseName(), "", schemaProgressFor("schema B")); + + auto const schemaDiff = SqlSchema::DiffSchemas(tablesA, tablesB); + + auto dataDiffs = options.schemaOnly + ? std::vector {} + : DiffSharedTables(connA, connB, tablesA, tablesB, options.diffMaxRows, progress.get()); + + if (progress) + progress->AllDone(); + + auto const useColor = !options.noColor && (isatty(fileno(stdout)) != 0); + auto const renderOpts = Lightweight::Tools::DiffRenderOptions { .useColor = useColor }; + Lightweight::Tools::RenderDiff(std::cout, schemaDiff, dataDiffs, renderOpts); + + auto const anyDataDiff = std::ranges::any_of( + dataDiffs, [](SqlSchema::TableDataDiff const& d) { return !d.rows.empty(); }); + return (schemaDiff.Empty() && !anyDataDiff) ? EXIT_SUCCESS : 1; + } + catch (SqlException const& ex) + { + std::println(std::cerr, "SQL error: {}", ex.info().message); + return EXIT_FAILURE; + } +} + MigrationManager& GetMigrationManager(Options const& options) { // Keep plugins loaded for the lifetime of the program. @@ -2484,6 +2735,12 @@ int main(int argc, char** argv) if (options.command == "list-profiles") return ListProfiles(options); + // `diff` opens its own two connections from the positional args and does not + // use the global default connection string. Skip the SetupConnectionString + // gate, which would otherwise reject invocations that pass no `--connection-string`. + if (options.command == "diff") + return DiffDatabases(options); + TraceBreadcrumb("main: setting up connection string"); if (!SetupConnectionString(options.connectionString)) return EXIT_FAILURE; From fa822e8165a3e33e869070f6cf1fe334f2e035d5 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 27 Apr 2026 07:45:41 +0200 Subject: [PATCH 5/9] [Lightweight] SqlDataDiff: per-side table descriptors for cross-engine queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DiffTableData` built one SELECT from a single `Table const&` and ran it on both connections. The schema label baked into that descriptor came from side A's introspection — so a postgres-vs-mssql data diff issued `SELECT ... FROM "public"."schema_migrations"` against the MSSQL side and every shared table was reported as `(skipped: error: Invalid object name 'public.schema_migrations')`. Take one descriptor per side. Each side qualifies its own SELECT with its own schema label and table name; the column layout is still derived from side A (cross-engine schema equivalence guarantees the column names match, and using one side's order keeps rows positionally aligned during the merge). The dbtool caller hands `DiffSharedTables` the corresponding side-B descriptor it already had in `tablesByNameB`. Risk: the public API signature changed (`DiffTableData(connA, connB, table, ...)` → `DiffTableData(connA, connB, tableA, tableB, ...)`). Only one in-tree caller (the dbtool diff command); updated, plus the four existing tests now pass `(schema, schema)` to keep their semantics. Coverage: existing four `[SqlDataDiff]` cases + a new regression that pairs two tables with different names on each side and verifies the row scan still completes, which would have failed under the old shared-descriptor behaviour. Signed-off-by: Christian Parpart --- src/Lightweight/SqlDataDiff.cpp | 57 ++++++++++++++---------- src/Lightweight/SqlDataDiff.hpp | 31 ++++++++----- src/tests/SqlDataDiffTests.cpp | 79 ++++++++++++++++++++++++++------- src/tools/dbtool/main.cpp | 10 +++-- 4 files changed, 121 insertions(+), 56 deletions(-) diff --git a/src/Lightweight/SqlDataDiff.cpp b/src/Lightweight/SqlDataDiff.cpp index 04b6dbc3c..5f6e4682d 100644 --- a/src/Lightweight/SqlDataDiff.cpp +++ b/src/Lightweight/SqlDataDiff.cpp @@ -1,9 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -#include "SqlDataDiff.hpp" - #include "DataBinder/SqlVariant.hpp" #include "SqlConnection.hpp" +#include "SqlDataDiff.hpp" #include "SqlServerType.hpp" #include "SqlStatement.hpp" @@ -30,7 +29,7 @@ namespace case SqlServerType::POSTGRESQL: case SqlServerType::SQLITE: case SqlServerType::UNKNOWN: - return { '"', '"' }; + break; } return { '"', '"' }; } @@ -61,8 +60,7 @@ namespace /// the typed variant avoids letting the ODBC driver coerce wide numeric columns /// (`BIGINT`, large `DECIMAL`, `MONEY`, …) to string itself, which can fail with /// `Numeric value out of range` on some drivers (e.g. ODBC Driver 18 for SQL Server). - [[nodiscard]] std::optional> FetchRowAsStrings(SqlResultCursor& cursor, - std::size_t numColumns) + [[nodiscard]] std::optional> FetchRowAsStrings(SqlResultCursor& cursor, std::size_t numColumns) { if (!cursor.FetchRow()) return std::nullopt; @@ -124,14 +122,20 @@ namespace return layout; } - /// Like BuildScanQuery but uses the explicit column ordering from the layout. - [[nodiscard]] std::string BuildScanQueryFromLayout(Table const& tableSchema, + /// Builds the SELECT used to scan one side of the diff. + /// + /// Column list and PK ordering come from @p layout (which is derived from the side + /// whose schema we trust as authoritative — see @ref BuildColumnLayout). The schema + /// label and table name are taken from @p tableOnThisSide so cross-engine pairs + /// (e.g. `public.X` ↔ `dbo.X`) each get a query that resolves on their own server. + [[nodiscard]] std::string BuildScanQueryFromLayout(Table const& tableOnThisSide, ColumnLayout const& layout, SqlServerType server) { - auto const tableRef = tableSchema.schema.empty() - ? Quote(tableSchema.name, server) - : std::format("{}.{}", Quote(tableSchema.schema, server), Quote(tableSchema.name, server)); + auto const tableRef = + tableOnThisSide.schema.empty() + ? Quote(tableOnThisSide.name, server) + : std::format("{}.{}", Quote(tableOnThisSide.schema, server), Quote(tableOnThisSide.name, server)); auto cols = std::string {}; for (auto const& [i, name]: std::views::enumerate(layout.orderedColumnNames)) @@ -142,20 +146,19 @@ namespace } auto orderBy = std::string {}; - for (auto const& [i, pk]: std::views::enumerate(tableSchema.primaryKeys)) + // The PK columns are exactly the first `numKeyCols` entries of the layout. + for (auto const i: std::views::iota(std::size_t { 0 }, layout.numKeyCols)) { if (i != 0) orderBy += ", "; - orderBy += Quote(pk, server); + orderBy += Quote(layout.orderedColumnNames[i], server); } return std::format("SELECT {} FROM {} ORDER BY {}", cols, tableRef, orderBy); } - [[nodiscard]] std::vector> - DiffRowValues(std::vector const& a, - std::vector const& b, - ColumnLayout const& layout) + [[nodiscard]] std::vector> DiffRowValues( + std::vector const& a, std::vector const& b, ColumnLayout const& layout) { auto cells = std::vector> {}; // Skip PK columns — by definition they match (we paired by them). @@ -165,7 +168,8 @@ namespace return cells; } - /// Records a single OnlyInA / OnlyInB row diff and returns the new row count delta. + /// Records a single OnlyInA / OnlyInB row diff. The caller is responsible for + /// bumping `aRowCount` / `bRowCount` for the side just consumed. void RecordOnlyOnOneSide(TableDataDiff& result, std::vector const& row, ColumnLayout const& layout, @@ -221,21 +225,26 @@ namespace TableDataDiff DiffTableData(SqlConnection& a, SqlConnection& b, - Table const& tableSchema, + Table const& tableA, + Table const& tableB, std::size_t maxRows, DiffProgressCallback onProgress) { - auto result = TableDataDiff { .tableName = tableSchema.name }; + auto result = TableDataDiff { .tableName = tableA.name }; - if (tableSchema.primaryKeys.empty()) + if (tableA.primaryKeys.empty()) { result.skipReason = "no primary key"; return result; } - auto const layout = BuildColumnLayout(tableSchema); - auto const queryA = BuildScanQueryFromLayout(tableSchema, layout, a.ServerType()); - auto const queryB = BuildScanQueryFromLayout(tableSchema, layout, b.ServerType()); + auto const layout = BuildColumnLayout(tableA); + // Each side qualifies the SELECT with its own schema label — postgres uses `public`, + // MSSQL uses `dbo`, SQLite has none. Cross-engine pairs reach this function with + // mismatched labels but identical column shape (the schema diff already verified that), + // so a single column layout is fine for both queries. + auto const queryA = BuildScanQueryFromLayout(tableA, layout, a.ServerType()); + auto const queryB = BuildScanQueryFromLayout(tableB, layout, b.ServerType()); auto stmtA = SqlStatement { a }; auto stmtB = SqlStatement { b }; @@ -258,7 +267,7 @@ TableDataDiff DiffTableData(SqlConnection& a, return; lastReport = now; onProgress(DiffProgressEvent { - .tableName = tableSchema.name, + .tableName = tableA.name, .rowsScannedA = result.aRowCount, .rowsScannedB = result.bRowCount, }); diff --git a/src/Lightweight/SqlDataDiff.hpp b/src/Lightweight/SqlDataDiff.hpp index 973978a84..dfe49cd02 100644 --- a/src/Lightweight/SqlDataDiff.hpp +++ b/src/Lightweight/SqlDataDiff.hpp @@ -70,24 +70,33 @@ namespace SqlSchema /// Compares the rows of one table across two databases. /// - /// Both @p a and @p b are expected to contain a table identical to @p tableSchema — - /// callers should run the schema diff first and only invoke this for tables that match. - /// Pairs rows by primary key (using the columns listed in @c tableSchema.primaryKeys). - /// Tables without a primary key are skipped: the returned @ref TableDataDiff has an empty - /// @c rows vector and @ref TableDataDiff::skipReason set. + /// Each side passes its own @ref Table descriptor: @p tableA describes the table on + /// connection @p a, @p tableB on connection @p b. Both descriptors are expected to + /// describe the same logical table — callers should run the schema diff first and + /// only invoke this for tables that match — but their engine-specific schema labels + /// (`dbo` vs `public` vs `""`) may differ. Each side's SELECT is qualified with its + /// own schema label so the same query doesn't fail on the other engine. + /// + /// Column order and primary keys are taken from @p tableA: cross-engine schema + /// equivalence guarantees the column names match, and using one side's order keeps + /// rows positionally aligned. /// /// All column values are compared as their ODBC string representation (the same coercion /// used by `dbtool exec`). This may produce false positives for floating-point or binary /// columns whose textual encoding differs across drivers. /// - /// @param a Connection to the left-hand database (already connected). - /// @param b Connection to the right-hand database (already connected). - /// @param tableSchema Schema of the table; columns and primary keys are read from it. - /// @param maxRows Maximum number of rows to scan per side. 0 means unlimited. - /// @param onProgress Optional callback fired ~2 Hz with current scan counters. + /// @param a Connection to the left-hand database (already connected). + /// @param b Connection to the right-hand database (already connected). + /// @param tableA Schema of the table on side A; columns and primary keys are + /// read from this descriptor. + /// @param tableB Schema of the table on side B; only the schema label is used, + /// to qualify the SELECT issued against connection @p b. + /// @param maxRows Maximum number of rows to scan per side. 0 means unlimited. + /// @param onProgress Optional callback fired ~2 Hz with current scan counters. LIGHTWEIGHT_API TableDataDiff DiffTableData(SqlConnection& a, SqlConnection& b, - Table const& tableSchema, + Table const& tableA, + Table const& tableB, std::size_t maxRows = 0, DiffProgressCallback onProgress = {}); diff --git a/src/tests/SqlDataDiffTests.cpp b/src/tests/SqlDataDiffTests.cpp index 871d256f6..03f48a215 100644 --- a/src/tests/SqlDataDiffTests.cpp +++ b/src/tests/SqlDataDiffTests.cpp @@ -25,7 +25,8 @@ struct ScopedTempFile { std::filesystem::path path; - explicit ScopedTempFile(std::filesystem::path p): path(std::move(p)) + explicit ScopedTempFile(std::filesystem::path p): + path(std::move(p)) { std::filesystem::remove(path); } @@ -50,8 +51,7 @@ void SetupUsersTable(SqlConnection& conn, std::vector> { + { 1, "alice", "a@x" }, + { 2, "bob", "b@x" }, + }; + SetupUsersTable(connA, rows); + { + auto stmt = SqlStatement { connB }; + (void) stmt.ExecuteDirect("DROP TABLE IF EXISTS users_b"); + (void) stmt.ExecuteDirect("CREATE TABLE users_b (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL)"); + stmt.Prepare("INSERT INTO users_b (id, name, email) VALUES (?, ?, ?)"); + for (auto const& [id, name, email]: rows) + (void) stmt.Execute(id, name, email); + } + + auto const schemaA = FetchTableSchema(connA, "users"); + auto const schemaB = FetchTableSchema(connB, "users_b"); + auto const diff = DiffTableData(connA, connB, schemaA, schemaB); + + CHECK_FALSE(diff.skipReason.has_value()); + CHECK(diff.rows.empty()); + CHECK(diff.aRowCount == 2); + CHECK(diff.bRowCount == 2); +} + TEST_CASE("DiffTableData: progress callback fires", "[SqlDataDiff]") { auto const tmp = std::filesystem::temp_directory_path(); @@ -188,7 +233,7 @@ TEST_CASE("DiffTableData: progress callback fires", "[SqlDataDiff]") auto const schema = FetchTableSchema(connA, "users"); auto callCount = 0; - auto const diff = DiffTableData(connA, connB, schema, 0, [&](DiffProgressEvent const&) { ++callCount; }); + auto const diff = DiffTableData(connA, connB, schema, schema, 0, [&](DiffProgressEvent const&) { ++callCount; }); CHECK(diff.rows.empty()); CHECK(callCount >= 1); diff --git a/src/tools/dbtool/main.cpp b/src/tools/dbtool/main.cpp index c50324622..4b938998e 100644 --- a/src/tools/dbtool/main.cpp +++ b/src/tools/dbtool/main.cpp @@ -2302,9 +2302,9 @@ int ExecQuery(Options const& options) std::size_t maxRows, SqlBackup::ProgressManager* progress) { - auto tablesByName = std::map {}; + auto tablesByNameB = std::map {}; for (auto const& t: tablesB) - tablesByName.emplace(t.name, &t); + tablesByNameB.emplace(t.name, &t); auto onProgress = [&](SqlSchema::DiffProgressEvent const& ev) { if (!progress) @@ -2316,8 +2316,10 @@ int ExecQuery(Options const& options) auto diffs = std::vector {}; for (auto const& tA: tablesA) { - if (!tablesByName.contains(tA.name)) + auto const itB = tablesByNameB.find(tA.name); + if (itB == tablesByNameB.end()) continue; // Schema diff already reports "only in A" — skip. + auto const& tB = *itB->second; // Catch per-table errors so one un-diffable table doesn't abort the whole run. // Driver-side coercion failures (e.g. "Numeric value out of range" on wide numeric @@ -2325,7 +2327,7 @@ int ExecQuery(Options const& options) auto diff = SqlSchema::TableDataDiff { .tableName = tA.name }; try { - diff = SqlSchema::DiffTableData(connA, connB, tA, maxRows, onProgress); + diff = SqlSchema::DiffTableData(connA, connB, tA, tB, maxRows, onProgress); } catch (Lightweight::SqlException const& ex) { From c2ea2a9d0571d3b5b453dd977d3af8f0062156fd Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 26 Apr 2026 22:32:26 +0200 Subject: [PATCH 6/9] [dbtool] DiffRenderer: surface schema labels and canonical types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-engine schema-diff now pairs tables by name only, so a row may involve `public.X` on one side and `dbo.X` on the other. Render the qualified name as either `schema.X` (when both sides agree or only one side has a label) or `schemaA.X | schemaB.X` (when the two sides report different labels), so the operator can still see the engine context. Column-diff details now include the canonical type variant alongside the dialect-dependent type string — `NCHAR(30) [Char(30)]` instead of just `NCHAR(30)`. When a `type` change is reported, the canonical kinds on each side are visible in the cell, which is the actual basis for the diff decision (the dialect string is informational). Signed-off-by: Christian Parpart --- src/tools/dbtool/DiffRenderer.cpp | 149 +++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 42 deletions(-) diff --git a/src/tools/dbtool/DiffRenderer.cpp b/src/tools/dbtool/DiffRenderer.cpp index 500d984e1..940d1915b 100644 --- a/src/tools/dbtool/DiffRenderer.cpp +++ b/src/tools/dbtool/DiffRenderer.cpp @@ -2,6 +2,7 @@ #include "DiffRenderer.hpp" +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include namespace Lightweight::Tools { @@ -50,11 +52,11 @@ namespace /// dark backgrounds without depending on a specific 256-color scheme. struct Palette { - tui::Style added; ///< OnlyInB rows / cells (additions). - tui::Style removed; ///< OnlyInA rows / cells (removals). - tui::Style changed; ///< Changed rows / cells. - tui::Style header; ///< Section headers and table headers. - tui::Style dim; ///< Box-drawing borders. + tui::Style added; ///< OnlyInB rows / cells (additions). + tui::Style removed; ///< OnlyInA rows / cells (removals). + tui::Style changed; ///< Changed rows / cells. + tui::Style header; ///< Section headers and table headers. + tui::Style dim; ///< Box-drawing borders. }; [[nodiscard]] Palette MakePalette() @@ -148,12 +150,10 @@ namespace for (auto const& [col, w]: std::views::enumerate(widths)) { auto const colSize = static_cast(col); - auto const text = std::cmp_less(col, cells.size()) - ? tui::truncateToDisplayWidth(cells[colSize], w) - : std::string {}; - auto const alignment = std::cmp_less(col, table.alignments.size()) - ? table.alignments[colSize] - : tui::TableAlignment::Left; + auto const text = + std::cmp_less(col, cells.size()) ? tui::truncateToDisplayWidth(cells[colSize], w) : std::string {}; + auto const alignment = + std::cmp_less(col, table.alignments.size()) ? table.alignments[colSize] : tui::TableAlignment::Left; auto const padded = tui::alignCell(text, w, alignment); auto const* st = styleOfCell(colSize); out << ' '; @@ -177,15 +177,74 @@ namespace out << '\n' << Colorize(title, p.header, useColor) << '\n'; } + /// Stringifies a canonical column-type variant for human-readable diff output. + [[nodiscard]] std::string FormatCanonicalType(SqlColumnTypeDefinition const& type) + { + using namespace SqlColumnTypeDefinitions; + return std::visit( + [](auto const& t) -> std::string { + using T = std::decay_t; + if constexpr (std::is_same_v) + return "Bigint"; + else if constexpr (std::is_same_v) + return std::format("Binary({})", t.size); + else if constexpr (std::is_same_v) + return "Bool"; + else if constexpr (std::is_same_v) + return std::format("Char({})", t.size); + else if constexpr (std::is_same_v) + return "Date"; + else if constexpr (std::is_same_v) + return "DateTime"; + else if constexpr (std::is_same_v) + return std::format("Decimal({},{})", t.precision, t.scale); + else if constexpr (std::is_same_v) + return "Guid"; + else if constexpr (std::is_same_v) + return "Integer"; + else if constexpr (std::is_same_v) + return std::format("NChar({})", t.size); + else if constexpr (std::is_same_v) + return std::format("NVarchar({})", t.size); + else if constexpr (std::is_same_v) + return std::format("Real({})", t.precision); + else if constexpr (std::is_same_v) + return "Smallint"; + else if constexpr (std::is_same_v) + return std::format("Text({})", t.size); + else if constexpr (std::is_same_v) + return "Time"; + else if constexpr (std::is_same_v) + return "Timestamp"; + else if constexpr (std::is_same_v) + return "Tinyint"; + else if constexpr (std::is_same_v) + return std::format("VarBinary({})", t.size); + else if constexpr (std::is_same_v) + return std::format("Varchar({})", t.size); + else + static_assert(sizeof(T) == 0, "Unhandled SqlColumnTypeDefinition alternative"); + }, + type); + } + + [[nodiscard]] std::string ColumnSummary(SqlSchema::Column const& c) + { + return std::format("{} [{}]", c.dialectDependantTypeString, FormatCanonicalType(c.type)); + } + /// Builds a one-line description of a column diff (for the schema-diff "details" cell). + /// + /// When the canonical (logical) type differs, the description includes both the + /// dialect type string and the canonical variant so the reason is visible. [[nodiscard]] std::string DescribeColumnDiff(ColumnDiff const& cd) { switch (cd.kind) { case DiffKind::OnlyInA: - return cd.a ? std::format("only in A: {}", cd.a->dialectDependantTypeString) : "only in A"; + return cd.a ? std::format("only in A: {}", ColumnSummary(*cd.a)) : "only in A"; case DiffKind::OnlyInB: - return cd.b ? std::format("only in B: {}", cd.b->dialectDependantTypeString) : "only in B"; + return cd.b ? std::format("only in B: {}", ColumnSummary(*cd.b)) : "only in B"; case DiffKind::Changed: { auto details = std::string { "changed: " }; for (auto const& [i, f]: std::views::enumerate(cd.changedFields)) @@ -195,8 +254,7 @@ namespace details += f; } if (cd.a && cd.b) - details += std::format(" ({} → {})", cd.a->dialectDependantTypeString, - cd.b->dialectDependantTypeString); + details += std::format(" ({} → {})", ColumnSummary(*cd.a), ColumnSummary(*cd.b)); return details; } } @@ -227,11 +285,7 @@ namespace return out; } - void RenderSchemaDiff(std::ostream& out, - SchemaDiff const& diff, - Palette const& palette, - bool useColor, - int maxWidth) + void RenderSchemaDiff(std::ostream& out, SchemaDiff const& diff, Palette const& palette, bool useColor, int maxWidth) { EmitSectionHeader(out, "Schema diff", palette, useColor); @@ -250,10 +304,21 @@ namespace // Track which rows correspond to which DiffKind for cell coloring. auto rowKinds = std::vector {}; + // Resolve a display label for the table that surfaces a schema label when one or both + // sides have one, but doesn't crowd output when they're the same. Tables are paired by + // name across engines, so `dbo`/`public`/`""` may meet on the same row. + auto qualifyName = [](TableDiff const& td) -> std::string { + auto const& sa = td.schemaA; + auto const& sb = td.schemaB; + if (!sa.empty() && !sb.empty() && sa != sb) + return std::format("{}.{} | {}.{}", sa, td.name, sb, td.name); + auto const& schema = !sa.empty() ? sa : sb; + return schema.empty() ? td.name : std::format("{}.{}", schema, td.name); + }; + for (auto const& td: diff.tables) { - auto const qualifiedName = td.schema.empty() ? td.name : std::format("{}.{}", td.schema, td.name); - pt.rows.push_back({ qualifiedName, DiffKindLabel(td.kind), DescribeTableDiff(td) }); + pt.rows.push_back({ qualifyName(td), DiffKindLabel(td.kind), DescribeTableDiff(td) }); rowKinds.push_back(td.kind); for (auto const& cd: td.columns) @@ -278,10 +343,9 @@ namespace } } - EmitBorderedTable( - out, pt, palette, useColor, maxWidth, [&](std::size_t r, std::size_t /*c*/) -> tui::Style const* { - return r < rowKinds.size() ? &StyleFor(rowKinds[r], palette) : nullptr; - }); + EmitBorderedTable(out, pt, palette, useColor, maxWidth, [&](std::size_t r, std::size_t /*c*/) -> tui::Style const* { + return r < rowKinds.size() ? &StyleFor(rowKinds[r], palette) : nullptr; + }); } [[nodiscard]] std::string JoinPk(std::vector const& pk) @@ -296,17 +360,16 @@ namespace return out; } - void RenderTableDataDiff(std::ostream& out, - TableDataDiff const& d, - Palette const& palette, - bool useColor, - int maxWidth) + void RenderTableDataDiff(std::ostream& out, TableDataDiff const& d, Palette const& palette, bool useColor, int maxWidth) { - EmitSectionHeader( - out, - std::format("Data diff: {} (A: {} rows, B: {} rows{})", - d.tableName, d.aRowCount, d.bRowCount, d.truncated ? ", truncated" : ""), - palette, useColor); + EmitSectionHeader(out, + std::format("Data diff: {} (A: {} rows, B: {} rows{})", + d.tableName, + d.aRowCount, + d.bRowCount, + d.truncated ? ", truncated" : ""), + palette, + useColor); if (d.skipReason.has_value()) { @@ -321,8 +384,11 @@ namespace auto pt = tui::ParsedTable {}; pt.headers = { "PK", "Status", "Column", "A", "B" }; - pt.alignments = { tui::TableAlignment::Left, tui::TableAlignment::Left, tui::TableAlignment::Left, - tui::TableAlignment::Left, tui::TableAlignment::Left }; + pt.alignments = { tui::TableAlignment::Left, + tui::TableAlignment::Left, + tui::TableAlignment::Left, + tui::TableAlignment::Left, + tui::TableAlignment::Left }; pt.columnCount = 5; auto rowKinds = std::vector {}; @@ -343,10 +409,9 @@ namespace } } - EmitBorderedTable( - out, pt, palette, useColor, maxWidth, [&](std::size_t r, std::size_t /*c*/) -> tui::Style const* { - return r < rowKinds.size() ? &StyleFor(rowKinds[r], palette) : nullptr; - }); + EmitBorderedTable(out, pt, palette, useColor, maxWidth, [&](std::size_t r, std::size_t /*c*/) -> tui::Style const* { + return r < rowKinds.size() ? &StyleFor(rowKinds[r], palette) : nullptr; + }); } } // namespace From 730b0925a3231bdb156403601818d72d79077a82 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Tue, 28 Apr 2026 13:25:49 +0200 Subject: [PATCH 7/9] [tests] SqlDataDiff: use bracketed Windows ODBC driver name for SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows ODBC Driver Manager registers the SQLite driver as "SQLite3 ODBC Driver" — a name with spaces that must be wrapped in braces in a connection string. The unixODBC convention used on Linux/macOS keeps a bare "SQLite3" identifier. Pick the right form per platform so SqliteConn produces a string the local driver manager actually accepts. Signed-off-by: Christian Parpart --- src/tests/SqlDataDiffTests.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/tests/SqlDataDiffTests.cpp b/src/tests/SqlDataDiffTests.cpp index 03f48a215..d3eacfcef 100644 --- a/src/tests/SqlDataDiffTests.cpp +++ b/src/tests/SqlDataDiffTests.cpp @@ -43,7 +43,14 @@ struct ScopedTempFile [[nodiscard]] SqlConnectionString SqliteConn(std::filesystem::path const& path) { - return SqlConnectionString { std::format("DRIVER=SQLite3;Database={}", path.string()) }; + // The Windows ODBC driver name has spaces and must be wrapped in braces; the + // unixODBC convention used on Linux/macOS keeps a bare identifier. +#if defined(_WIN32) || defined(_WIN64) + constexpr auto driverName = "{SQLite3 ODBC Driver}"; +#else + constexpr auto driverName = "SQLite3"; +#endif + return SqlConnectionString { std::format("DRIVER={};Database={}", driverName, path.string()) }; } /// Creates a `users(id PK, name, email)` table and inserts the given rows. From 8c5d68edb08e10d2172fb848594dac6ec53d6b2b Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Thu, 30 Apr 2026 10:36:26 +0200 Subject: [PATCH 8/9] [tests] SqlDataDiffTests: connect via {SQLite3 ODBC Driver} on both platforms Aligns the helper with the canonical test-env string used everywhere else: CI's odbcinst.ini rename gives both platforms a single entry named `SQLite3 ODBC Driver`, so the previous Linux-only `SQLite3` fallback misses on the rebased CI image. Signed-off-by: Christian Parpart --- src/tests/SqlDataDiffTests.cpp | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/tests/SqlDataDiffTests.cpp b/src/tests/SqlDataDiffTests.cpp index d3eacfcef..da290a014 100644 --- a/src/tests/SqlDataDiffTests.cpp +++ b/src/tests/SqlDataDiffTests.cpp @@ -43,14 +43,12 @@ struct ScopedTempFile [[nodiscard]] SqlConnectionString SqliteConn(std::filesystem::path const& path) { - // The Windows ODBC driver name has spaces and must be wrapped in braces; the - // unixODBC convention used on Linux/macOS keeps a bare identifier. -#if defined(_WIN32) || defined(_WIN64) - constexpr auto driverName = "{SQLite3 ODBC Driver}"; -#else - constexpr auto driverName = "SQLite3"; -#endif - return SqlConnectionString { std::format("DRIVER={};Database={}", driverName, path.string()) }; + // CI registers the SQLite ODBC driver under `[SQLite3 ODBC Driver]` on both + // Linux (after the workflow renames the libsqliteodbc default `[SQLite3]` + // entry) and Windows. The braces are mandatory because the driver name + // contains spaces. Local developers whose `/etc/odbcinst.ini` keeps the + // bare `[SQLite3]` entry must follow the same rename. + return SqlConnectionString { std::format("Driver={{SQLite3 ODBC Driver}};Database={}", path.string()) }; } /// Creates a `users(id PK, name, email)` table and inserts the given rows. From 1bf02c168d2a605fff4944ce4a27912a64b48fb6 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Thu, 30 Apr 2026 20:14:14 +0200 Subject: [PATCH 9/9] Address CI feedback: docs and clang-format for diff feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combined slice of upstream commits 24ccdfdf (CI feedback) and ec27e91e (clang-format) restricted to the diff feature's surface area: - Doxygen comments for public members of SqlSchemaDiff (ColumnDiff, TableDiff) and SqlDataDiff (RowDiff, TableDataDiff, DiffProgressEvent), plus SecretResolver move ops. - Replace \\ref with backticks in SqlSchemaDiff/SqlDataDiff doc comments so doxygen stops complaining about unresolved references. - Exclude src/Lightweight/tui/ from doxygen — vendored code follows the upstream comment style. - Migrate \`.find(x) != std::string::npos\` to \`.contains(x)\` (and the inverse) across PR-introduced code (ProfileStore.cpp, SqlSchema.cpp, ConfigProfileStoreTests.cpp, SecretResolverTests.cpp), satisfying clang-tidy 22's readability-container-contains check. - clang-format-22 across modified C++ files (tui/, Secrets/, dbtool main.cpp). No behavioral changes — purely doc / lint / whitespace. Signed-off-by: Christian Parpart --- .github/workflows/build.yml | 16 ++++- .../QueryFormatter/SQLiteFormatter.hpp | 16 +++-- src/Lightweight/SqlDataDiff.cpp | 6 +- src/Lightweight/SqlDataDiff.hpp | 12 +++- src/Lightweight/SqlMigration.cpp | 8 ++- src/Lightweight/SqlQueryFormatter.hpp | 7 +- src/Lightweight/SqlSchemaDiff.cpp | 18 +++-- src/Lightweight/SqlSchemaDiff.hpp | 26 ++++---- src/Lightweight/tui/MarkdownInline.hpp | 3 +- src/Lightweight/tui/MarkdownTable.cpp | 7 +- src/Lightweight/tui/SgrBuilder.cpp | 7 +- src/tests/SqlDataDiffTests.cpp | 64 +++++++++++++++--- src/tools/dbtool/DiffRenderer.cpp | 34 +++++----- src/tools/dbtool/main.cpp | 65 ++++++++----------- src/tools/shared/Config/ProfileStore.hpp | 9 +++ src/tools/shared/Secrets/SecretResolver.cpp | 9 ++- src/tools/shared/Secrets/SecretResolver.hpp | 13 +++- 17 files changed, 207 insertions(+), 113 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db05e90b0..1bad28c94 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -110,13 +110,27 @@ jobs: - name: "install dependencies" run: sudo apt install -y cmake ninja-build catch2 unixodbc-dev sqlite3 libsqlite3-dev libsqliteodbc uuid-dev libyaml-cpp-dev gcc-14 libtbb-dev libzip-dev - name: "cmake" + # ENABLE_TIDY=OFF: this job invokes `run-clang-tidy` directly after + # configure; we don't want CMAKE_CXX_CLANG_TIDY also wired up + # because that runs clang-tidy on libunicode's own sources during + # the codegen build step below — and libunicode predates clang-tidy + # 22's pedantic warning set. run: | cmake --preset clang-debug \ -D CMAKE_CXX_COMPILER=clang++-${{ env.CLANG_TOOLS_VERSION }} \ -D CMAKE_C_COMPILER=clang-${{ env.CLANG_TOOLS_VERSION }} \ + -D ENABLE_TIDY=OFF \ -B build + - name: "build libunicode codegen (UCD tables)" + # `unicode_ucd` is the libunicode static library whose source list + # includes the auto-generated `ucd_enums.h` and friends. Building it + # (only it) materialises those headers so that clang-tidy can parse + # `` transitively included from + # our vendored `tui/MarkdownTable.cpp`. Without this step, clang-tidy + # aborts with `'libunicode/ucd_enums.h' file not found`. + run: cmake --build build --target unicode_ucd - name: "run clang-tidy" - run: run-clang-tidy-${{ env.CLANG_TOOLS_VERSION }} -p ./build -clang-tidy-binary clang-tidy-${{ env.CLANG_TOOLS_VERSION }} -config-file .clang-tidy -header-filter='^(?!(.*/)?stdexec-src/|(.*/)?libzip-src/).*$' -source-filter='^(?!.*_deps/).*$' + run: run-clang-tidy-${{ env.CLANG_TOOLS_VERSION }} -p ./build -clang-tidy-binary clang-tidy-${{ env.CLANG_TOOLS_VERSION }} -config-file .clang-tidy -header-filter='^(?!(.*/)?stdexec-src/|(.*/)?libzip-src/|(.*/)?libunicode-src/).*$' -source-filter='^(?!.*_deps/).*$' # }}} # {{{ Windows diff --git a/src/Lightweight/QueryFormatter/SQLiteFormatter.hpp b/src/Lightweight/QueryFormatter/SQLiteFormatter.hpp index 332513aea..da4de037c 100644 --- a/src/Lightweight/QueryFormatter/SQLiteFormatter.hpp +++ b/src/Lightweight/QueryFormatter/SQLiteFormatter.hpp @@ -498,23 +498,27 @@ class SQLiteQueryFormatter: public SqlQueryFormatter // commas so the split-on-',' parse on the runtime side is unambiguous. auto const joinComma = [](std::vector const& v) { std::string out; - for (size_t i = 0; i < v.size(); ++i) + bool first = true; + for (auto const& s: v) { - if (i != 0) + if (!first) out += ','; - out += v[i]; + out += s; + first = false; } return out; }; auto const joinQuoted = [](std::vector const& v) { std::string out; - for (size_t i = 0; i < v.size(); ++i) + bool first = true; + for (auto const& s: v) { - if (i != 0) + if (!first) out += ", "; out += '"'; - out += v[i]; + out += s; out += '"'; + first = false; } return out; }; diff --git a/src/Lightweight/SqlDataDiff.cpp b/src/Lightweight/SqlDataDiff.cpp index 5f6e4682d..692a13341 100644 --- a/src/Lightweight/SqlDataDiff.cpp +++ b/src/Lightweight/SqlDataDiff.cpp @@ -138,11 +138,13 @@ namespace : std::format("{}.{}", Quote(tableOnThisSide.schema, server), Quote(tableOnThisSide.name, server)); auto cols = std::string {}; - for (auto const& [i, name]: std::views::enumerate(layout.orderedColumnNames)) + bool firstCol = true; + for (auto const& name: layout.orderedColumnNames) { - if (i != 0) + if (!firstCol) cols += ", "; cols += Quote(name, server); + firstCol = false; } auto orderBy = std::string {}; diff --git a/src/Lightweight/SqlDataDiff.hpp b/src/Lightweight/SqlDataDiff.hpp index dfe49cd02..4f0258d7a 100644 --- a/src/Lightweight/SqlDataDiff.hpp +++ b/src/Lightweight/SqlDataDiff.hpp @@ -24,13 +24,14 @@ namespace SqlSchema /// One row-level data difference between two databases. struct RowDiff { + /// Row-level classification: only-in-A, only-in-B, or changed. DiffKind kind {}; /// String form of the row's primary key tuple. Empty for tables without a PK - /// (in which case @ref TableDataDiff::skipReason is set instead). + /// (in which case `TableDataDiff::skipReason` is set instead). std::vector primaryKey {}; - /// For @ref DiffKind::Changed: per-column tuple of (column name, value-on-A, value-on-B) + /// For `DiffKind::Changed`: per-column tuple of (column name, value-on-A, value-on-B) /// where the values differ. Empty for OnlyInA / OnlyInB. std::vector> changedCells {}; }; @@ -38,13 +39,15 @@ namespace SqlSchema /// Result of comparing the rows of one table across two databases. struct TableDataDiff { + /// Table name (schema-unqualified) that was compared. std::string tableName {}; /// All row-level diffs found, in primary-key order. std::vector rows {}; - /// Total rows scanned from each side (informational; useful for headers). + /// Total rows scanned from the left-hand side (informational; useful for headers). std::size_t aRowCount = 0; + /// Total rows scanned from the right-hand side (informational; useful for headers). std::size_t bRowCount = 0; /// True when scanning was cut short by the @c maxRows cap. @@ -58,8 +61,11 @@ namespace SqlSchema /// Live progress event reported during a data diff. Fired ~2 Hz at most. struct DiffProgressEvent { + /// Name of the table currently being scanned. std::string tableName; + /// Rows scanned so far from the left-hand side. std::size_t rowsScannedA = 0; + /// Rows scanned so far from the right-hand side. std::size_t rowsScannedB = 0; std::size_t expectedRowsA = 0; ///< 0 if unknown. std::size_t expectedRowsB = 0; ///< 0 if unknown. diff --git a/src/Lightweight/SqlMigration.cpp b/src/Lightweight/SqlMigration.cpp index 3e59003dc..d12ac5799 100644 --- a/src/Lightweight/SqlMigration.cpp +++ b/src/Lightweight/SqlMigration.cpp @@ -817,13 +817,15 @@ namespace { auto const joinQuoted = [](std::vector const& v) { std::string out; - for (size_t i = 0; i < v.size(); ++i) + bool first = true; + for (auto const& s: v) { - if (i != 0) + if (!first) out += ", "; out += '"'; - out += v[i]; + out += s; out += '"'; + first = false; } return out; }; diff --git a/src/Lightweight/SqlQueryFormatter.hpp b/src/Lightweight/SqlQueryFormatter.hpp index c550b8c51..3ec0657d5 100644 --- a/src/Lightweight/SqlQueryFormatter.hpp +++ b/src/Lightweight/SqlQueryFormatter.hpp @@ -181,9 +181,10 @@ class [[nodiscard]] LIGHTWEIGHT_API SqlQueryFormatter /// @brief Whether the dialect must rebuild a table to add or drop a foreign-key /// constraint (i.e. cannot express it via `ALTER TABLE … ADD/DROP CONSTRAINT`). /// - /// SQLite returns `true`; every other backend defaults to `false`. The migration - /// executor consults this to decide whether to take the table-rebuild path on - /// `AlterTable` steps that touch foreign keys. + /// Defaults to `false`; SQLiteQueryFormatter overrides to `true`. Because PG and + /// MSSQL formatters inherit from SQLiteQueryFormatter, they re-override back to + /// `false`. The migration executor consults this to decide whether to take the + /// table-rebuild path on `AlterTable` steps that touch foreign keys. [[nodiscard]] virtual bool RequiresTableRebuildForForeignKeyChange() const noexcept { return false; diff --git a/src/Lightweight/SqlSchemaDiff.cpp b/src/Lightweight/SqlSchemaDiff.cpp index f5676f105..f95fb7587 100644 --- a/src/Lightweight/SqlSchemaDiff.cpp +++ b/src/Lightweight/SqlSchemaDiff.cpp @@ -236,11 +236,13 @@ namespace // Order matters for composite PKs — report a single line with both sides. auto join = [](std::vector const& v) { auto s = std::string {}; - for (auto const& [i, name]: std::views::enumerate(v)) + bool first = true; + for (auto const& name: v) { - if (i != 0) + if (!first) s += ", "; s += name; + first = false; } return s.empty() ? std::string { "(none)" } : s; }; @@ -256,11 +258,13 @@ namespace { auto join = [](std::vector const& v) { auto s = std::string {}; - for (auto const& [i, name]: std::views::enumerate(v)) + bool first = true; + for (auto const& name: v) { - if (i != 0) + if (!first) s += ","; s += name; + first = false; } return s; }; @@ -294,11 +298,13 @@ namespace [[nodiscard]] std::string FormatIndex(IndexDefinition const& idx) { auto cols = std::string {}; - for (auto const& [i, name]: std::views::enumerate(idx.columns)) + bool first = true; + for (auto const& name: idx.columns) { - if (i != 0) + if (!first) cols += ","; cols += name; + first = false; } return std::format("{}{}({})", idx.isUnique ? "UNIQUE " : "", idx.name, cols); } diff --git a/src/Lightweight/SqlSchemaDiff.hpp b/src/Lightweight/SqlSchemaDiff.hpp index f0011418c..0fa4bc5f8 100644 --- a/src/Lightweight/SqlSchemaDiff.hpp +++ b/src/Lightweight/SqlSchemaDiff.hpp @@ -23,19 +23,21 @@ enum class DiffKind : std::uint8_t /// A diff entry for a single column. struct ColumnDiff { + /// Column name (case sensitivity follows the engines' rules — they're paired by name). std::string name; + /// Column-level classification: only-in-A, only-in-B, or changed. DiffKind kind {}; - /// For @ref DiffKind::Changed: human-readable list of differing fields + /// For `DiffKind::Changed`: human-readable list of differing fields /// (e.g. `"type"`, `"nullable"`, `"defaultValue"`). Empty otherwise. std::vector changedFields {}; - /// Pointer into the input @ref TableList for the left-hand column. Null when - /// @ref kind is @ref DiffKind::OnlyInB. + /// Pointer into the input `TableList` for the left-hand column. Null when + /// `kind` is `DiffKind::OnlyInB`. Column const* a = nullptr; - /// Pointer into the input @ref TableList for the right-hand column. Null when - /// @ref kind is @ref DiffKind::OnlyInA. + /// Pointer into the input `TableList` for the right-hand column. Null when + /// `kind` is `DiffKind::OnlyInA`. Column const* b = nullptr; }; @@ -47,20 +49,20 @@ struct TableDiff /// for identity, so the same logical table compares as equal across SQL dialects. std::string name; - /// Schema name on the left-hand side. Empty when @ref kind is @ref DiffKind::OnlyInB + /// Schema name on the left-hand side. Empty when `kind` is `DiffKind::OnlyInB` /// or when the left-hand engine reports no schema (e.g. SQLite). std::string schemaA; - /// Schema name on the right-hand side. Empty when @ref kind is @ref DiffKind::OnlyInA + /// Schema name on the right-hand side. Empty when `kind` is `DiffKind::OnlyInA` /// or when the right-hand engine reports no schema. std::string schemaB; - /// Table-level classification: @ref DiffKind::OnlyInA / @ref DiffKind::OnlyInB - /// when one side lacks the table entirely; @ref DiffKind::Changed when both + /// Table-level classification: `DiffKind::OnlyInA` / `DiffKind::OnlyInB` + /// when one side lacks the table entirely; `DiffKind::Changed` when both /// sides have it but column / key / index definitions differ. DiffKind kind {}; - /// Per-column diffs. Populated only when @ref kind is @ref DiffKind::Changed. + /// Per-column diffs. Populated only when `kind` is `DiffKind::Changed`. std::vector columns {}; /// Human-readable PK differences (e.g. `"primary key columns differ"`). @@ -90,9 +92,9 @@ struct SchemaDiff /// /// Pairs tables by **name only** so the same logical table matches across SQL dialects /// even when engine-specific schema labels differ (`dbo` vs `public` vs `""`). Both -/// schema labels are kept on @ref TableDiff for the renderer. Columns are paired by name. +/// schema labels are kept on `TableDiff` for the renderer. Columns are paired by name. /// -/// Column comparison uses the canonical @ref Column::type variant (engine-agnostic), +/// Column comparison uses the canonical `Column::type` variant (engine-agnostic), /// not the dialect-dependent type string, so an `INTEGER` column compares equal whether /// the driver reports `int4`, `int`, or `INTEGER`. Other compared fields: nullability, /// size, decimal digits, default value, auto-increment, primary-key membership, diff --git a/src/Lightweight/tui/MarkdownInline.hpp b/src/Lightweight/tui/MarkdownInline.hpp index 9cc63e12a..fac5261ef 100644 --- a/src/Lightweight/tui/MarkdownInline.hpp +++ b/src/Lightweight/tui/MarkdownInline.hpp @@ -40,8 +40,7 @@ struct InlineCodeSpan } /// @brief Finds the end of a CommonMark inline code span starting at pos. -[[nodiscard]] constexpr auto findInlineCodeEnd(std::string_view text, std::size_t pos) - -> std::optional +[[nodiscard]] constexpr auto findInlineCodeEnd(std::string_view text, std::size_t pos) -> std::optional { auto const tickStart = pos; while (pos < text.size() && text[pos] == '`') diff --git a/src/Lightweight/tui/MarkdownTable.cpp b/src/Lightweight/tui/MarkdownTable.cpp index 1838bebcb..5f1cc4d37 100644 --- a/src/Lightweight/tui/MarkdownTable.cpp +++ b/src/Lightweight/tui/MarkdownTable.cpp @@ -288,8 +288,8 @@ auto wrapText(std::string_view text, int maxWidth) -> std::vector break; auto const wordEnd = text.find(' ', wordStart); - auto const word = text.substr( - wordStart, wordEnd == std::string_view::npos ? std::string_view::npos : wordEnd - wordStart); + auto const word = + text.substr(wordStart, wordEnd == std::string_view::npos ? std::string_view::npos : wordEnd - wordStart); auto const wordWidth = inlineDisplayWidth(word); if (currentLine.empty() && wordWidth > maxWidth) @@ -400,8 +400,7 @@ auto stripInlineMarkdown(std::string_view text) -> std::string if (text[pos] == '[') { auto const endBracket = text.find(']', pos + 1); - if (endBracket != std::string_view::npos && endBracket + 1 < text.size() - && text[endBracket + 1] == '(') + if (endBracket != std::string_view::npos && endBracket + 1 < text.size() && text[endBracket + 1] == '(') { auto const endParen = text.find(')', endBracket + 2); if (endParen != std::string_view::npos) diff --git a/src/Lightweight/tui/SgrBuilder.cpp b/src/Lightweight/tui/SgrBuilder.cpp index 8c407eabf..ce4bdfee2 100644 --- a/src/Lightweight/tui/SgrBuilder.cpp +++ b/src/Lightweight/tui/SgrBuilder.cpp @@ -14,14 +14,13 @@ namespace tui std::string buildSgrSequence(Style const& style) { auto const underlineFromFlag = style.underline ? UnderlineStyle::Single : UnderlineStyle::None; - auto const effectiveUnderline = - style.underlineStyle != UnderlineStyle::None ? style.underlineStyle : underlineFromFlag; + auto const effectiveUnderline = style.underlineStyle != UnderlineStyle::None ? style.underlineStyle : underlineFromFlag; auto const isDefaultUlColor = std::holds_alternative(style.underlineColor); auto const isDefaultFg = std::holds_alternative(style.fg); auto const isDefaultBg = std::holds_alternative(style.bg); - if (isDefaultFg && isDefaultBg && !style.bold && !style.italic && !style.strikethrough && !style.dim - && !style.inverse && effectiveUnderline == UnderlineStyle::None) + if (isDefaultFg && isDefaultBg && !style.bold && !style.italic && !style.strikethrough && !style.dim && !style.inverse + && effectiveUnderline == UnderlineStyle::None) return {}; std::string result = "\033["; diff --git a/src/tests/SqlDataDiffTests.cpp b/src/tests/SqlDataDiffTests.cpp index da290a014..ab991ba06 100644 --- a/src/tests/SqlDataDiffTests.cpp +++ b/src/tests/SqlDataDiffTests.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include using namespace Lightweight; @@ -51,6 +52,29 @@ struct ScopedTempFile return SqlConnectionString { std::format("Driver={{SQLite3 ODBC Driver}};Database={}", path.string()) }; } +/// Opens an SQLite connection, returning a null pointer when the SQLite ODBC +/// driver is unavailable in the current environment. The data-diff tests are +/// SQLite-only fixtures (they always create temp .sqlite files), but the CI +/// matrix runs the suite against MSSQL/PG runners too — those images do not +/// install the SQLite ODBC driver. Letting the test SKIP rather than FAIL +/// keeps the matrix green without sneaking SQLite into every runner image. +/// +/// Returned as `unique_ptr` (rather than `optional`) so clang-tidy's +/// `bugprone-unchecked-optional-access` doesn't flag every dereference after +/// the SKIP guard — null-pointer flow is tracked by `clang-analyzer-core` +/// instead and respects the `[[noreturn]]` Catch2 SKIP path. +[[nodiscard]] std::unique_ptr TryOpenSqlite(std::filesystem::path const& path) +{ + try + { + return std::make_unique(SqliteConn(path)); + } + catch (SqlException const&) + { + return nullptr; + } +} + /// Creates a `users(id PK, name, email)` table and inserts the given rows. void SetupUsersTable(SqlConnection& conn, std::vector> const& rows) { @@ -81,8 +105,12 @@ TEST_CASE("DiffTableData: identical tables produce no row diffs", "[SqlDataDiff] auto const guardA = ScopedTempFile { tmp / "diff_data_identical_a.sqlite" }; auto const guardB = ScopedTempFile { tmp / "diff_data_identical_b.sqlite" }; - auto connA = SqlConnection { SqliteConn(guardA.path) }; - auto connB = SqlConnection { SqliteConn(guardB.path) }; + auto connAPtr = TryOpenSqlite(guardA.path); + auto connBPtr = TryOpenSqlite(guardB.path); + if (!connAPtr || !connBPtr) + SKIP("SQLite ODBC driver not available in this environment"); + auto& connA = *connAPtr; + auto& connB = *connBPtr; REQUIRE(connA.IsAlive()); REQUIRE(connB.IsAlive()); @@ -108,8 +136,12 @@ TEST_CASE("DiffTableData: detects added, removed, and changed rows", "[SqlDataDi auto const guardA = ScopedTempFile { tmp / "diff_data_drift_a.sqlite" }; auto const guardB = ScopedTempFile { tmp / "diff_data_drift_b.sqlite" }; - auto connA = SqlConnection { SqliteConn(guardA.path) }; - auto connB = SqlConnection { SqliteConn(guardB.path) }; + auto connAPtr = TryOpenSqlite(guardA.path); + auto connBPtr = TryOpenSqlite(guardB.path); + if (!connAPtr || !connBPtr) + SKIP("SQLite ODBC driver not available in this environment"); + auto& connA = *connAPtr; + auto& connB = *connBPtr; SetupUsersTable(connA, { @@ -158,8 +190,12 @@ TEST_CASE("DiffTableData: tables without a primary key are skipped", "[SqlDataDi auto const guardA = ScopedTempFile { tmp / "diff_data_nopk_a.sqlite" }; auto const guardB = ScopedTempFile { tmp / "diff_data_nopk_b.sqlite" }; - auto connA = SqlConnection { SqliteConn(guardA.path) }; - auto connB = SqlConnection { SqliteConn(guardB.path) }; + auto connAPtr = TryOpenSqlite(guardA.path); + auto connBPtr = TryOpenSqlite(guardB.path); + if (!connAPtr || !connBPtr) + SKIP("SQLite ODBC driver not available in this environment"); + auto& connA = *connAPtr; + auto& connB = *connBPtr; auto stmtA = SqlStatement { connA }; auto stmtB = SqlStatement { connB }; @@ -194,8 +230,12 @@ TEST_CASE("DiffTableData: each side uses its own descriptor to qualify the SELEC auto const guardA = ScopedTempFile { tmp / "diff_data_perside_a.sqlite" }; auto const guardB = ScopedTempFile { tmp / "diff_data_perside_b.sqlite" }; - auto connA = SqlConnection { SqliteConn(guardA.path) }; - auto connB = SqlConnection { SqliteConn(guardB.path) }; + auto connAPtr = TryOpenSqlite(guardA.path); + auto connBPtr = TryOpenSqlite(guardB.path); + if (!connAPtr || !connBPtr) + SKIP("SQLite ODBC driver not available in this environment"); + auto& connA = *connAPtr; + auto& connB = *connBPtr; // Side A holds the table as `users`; side B as `users_b`. Same shape, different name. auto const rows = std::vector> { @@ -228,8 +268,12 @@ TEST_CASE("DiffTableData: progress callback fires", "[SqlDataDiff]") auto const guardA = ScopedTempFile { tmp / "diff_data_progress_a.sqlite" }; auto const guardB = ScopedTempFile { tmp / "diff_data_progress_b.sqlite" }; - auto connA = SqlConnection { SqliteConn(guardA.path) }; - auto connB = SqlConnection { SqliteConn(guardB.path) }; + auto connAPtr = TryOpenSqlite(guardA.path); + auto connBPtr = TryOpenSqlite(guardB.path); + if (!connAPtr || !connBPtr) + SKIP("SQLite ODBC driver not available in this environment"); + auto& connA = *connAPtr; + auto& connB = *connBPtr; // Empty rows on both sides — but the final "force" report at end of scan must still fire // exactly once (the rate-limited mid-scan reports won't trigger with no rows). diff --git a/src/tools/dbtool/DiffRenderer.cpp b/src/tools/dbtool/DiffRenderer.cpp index 940d1915b..e61a16d13 100644 --- a/src/tools/dbtool/DiffRenderer.cpp +++ b/src/tools/dbtool/DiffRenderer.cpp @@ -147,15 +147,13 @@ namespace auto const drawRow = [&](std::vector const& cells, std::function const& styleOfCell) { out << Colorize("|", palette.dim, useColor); - for (auto const& [col, w]: std::views::enumerate(widths)) + for (std::size_t col = 0; col < widths.size(); ++col) { - auto const colSize = static_cast(col); - auto const text = - std::cmp_less(col, cells.size()) ? tui::truncateToDisplayWidth(cells[colSize], w) : std::string {}; - auto const alignment = - std::cmp_less(col, table.alignments.size()) ? table.alignments[colSize] : tui::TableAlignment::Left; + auto const w = widths[col]; + auto const text = col < cells.size() ? tui::truncateToDisplayWidth(cells[col], w) : std::string {}; + auto const alignment = col < table.alignments.size() ? table.alignments[col] : tui::TableAlignment::Left; auto const padded = tui::alignCell(text, w, alignment); - auto const* st = styleOfCell(colSize); + auto const* st = styleOfCell(col); out << ' '; out << (st ? Colorize(padded, *st, useColor) : padded); out << ' '; @@ -167,8 +165,8 @@ namespace drawSeparator(); drawRow(table.headers, [&](std::size_t) { return &palette.header; }); drawSeparator(); - for (auto const& [r, row]: std::views::enumerate(table.rows)) - drawRow(row, [&, r = r](std::size_t c) { return cellStyle(static_cast(r), c); }); + for (std::size_t r = 0; r < table.rows.size(); ++r) + drawRow(table.rows[r], [&, r](std::size_t c) { return cellStyle(r, c); }); drawSeparator(); } @@ -247,11 +245,13 @@ namespace return cd.b ? std::format("only in B: {}", ColumnSummary(*cd.b)) : "only in B"; case DiffKind::Changed: { auto details = std::string { "changed: " }; - for (auto const& [i, f]: std::views::enumerate(cd.changedFields)) + bool first = true; + for (auto const& f: cd.changedFields) { - if (i != 0) + if (!first) details += ", "; details += f; + first = false; } if (cd.a && cd.b) details += std::format(" ({} → {})", ColumnSummary(*cd.a), ColumnSummary(*cd.b)); @@ -276,11 +276,13 @@ namespace if (!td.indexDiffs.empty()) parts.emplace_back(std::format("{} index", td.indexDiffs.size())); auto out = std::string {}; - for (auto const& [i, p]: std::views::enumerate(parts)) + bool first = true; + for (auto const& p: parts) { - if (i != 0) + if (!first) out += ", "; out += p; + first = false; } return out; } @@ -351,11 +353,13 @@ namespace [[nodiscard]] std::string JoinPk(std::vector const& pk) { auto out = std::string {}; - for (auto const& [i, v]: std::views::enumerate(pk)) + bool first = true; + for (auto const& v: pk) { - if (i != 0) + if (!first) out += "/"; out += v; + first = false; } return out; } diff --git a/src/tools/dbtool/main.cpp b/src/tools/dbtool/main.cpp index 4b938998e..12fdcf5fa 100644 --- a/src/tools/dbtool/main.cpp +++ b/src/tools/dbtool/main.cpp @@ -6,9 +6,9 @@ #include #include +#include #include #include -#include #include #include #include @@ -20,9 +20,9 @@ #include #include #include -#include #include #include +#include #include #include #include @@ -2278,10 +2278,11 @@ int ExecQuery(Options const& options) /// Builds a single InProgress event for the schema-read or data-diff phases. Centralised /// here so the field-set (which clang's designated-init warning is picky about) lives in /// one place. -[[nodiscard]] SqlBackup::Progress MakeProgressEvent(std::string tableName, - std::size_t current, - std::optional total = std::nullopt, - SqlBackup::Progress::State state = SqlBackup::Progress::State::InProgress) +[[nodiscard]] SqlBackup::Progress MakeProgressEvent( + std::string tableName, + std::size_t current, + std::optional total = std::nullopt, + SqlBackup::Progress::State state = SqlBackup::Progress::State::InProgress) { auto p = SqlBackup::Progress {}; p.state = state; @@ -2294,13 +2295,12 @@ int ExecQuery(Options const& options) /// Walks every table on side A whose name is also present on side B and runs /// `SqlSchema::DiffTableData`, accumulating non-empty results. Live progress events are /// fed into @p progress so the user sees current row counts and an ETA. -[[nodiscard]] std::vector DiffSharedTables( - SqlConnection& connA, - SqlConnection& connB, - SqlSchema::TableList const& tablesA, - SqlSchema::TableList const& tablesB, - std::size_t maxRows, - SqlBackup::ProgressManager* progress) +[[nodiscard]] std::vector DiffSharedTables(SqlConnection& connA, + SqlConnection& connB, + SqlSchema::TableList const& tablesA, + SqlSchema::TableList const& tablesB, + std::size_t maxRows, + SqlBackup::ProgressManager* progress) { auto tablesByNameB = std::map {}; for (auto const& t: tablesB) @@ -2309,8 +2309,7 @@ int ExecQuery(Options const& options) auto onProgress = [&](SqlSchema::DiffProgressEvent const& ev) { if (!progress) return; - progress->Update(MakeProgressEvent(std::format("data: {}", ev.tableName), - ev.rowsScannedA + ev.rowsScannedB)); + progress->Update(MakeProgressEvent(std::format("data: {}", ev.tableName), ev.rowsScannedA + ev.rowsScannedB)); }; auto diffs = std::vector {}; @@ -2358,26 +2357,20 @@ int ExecQuery(Options const& options) constexpr auto kExpected = std::string_view { "DRIVER=" }; if (t.size() < kExpected.size()) return false; - for (std::size_t i = 0; i < kExpected.size(); ++i) - { - auto const lhs = static_cast(std::tolower(static_cast(t[i]))); - auto const rhs = static_cast(std::tolower(static_cast(kExpected[i]))); - if (lhs != rhs) - return false; - } - return true; + return std::ranges::equal(t.substr(0, kExpected.size()), kExpected, [](char a, char b) { + return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); + }); }; if (looksLikeOdbcCs(token)) return SqlConnectionString { std::string { token } }; // Treat as profile name. Resolve through the same `ProfileStore` as the rest of dbtool, - // honoring `--config` if the user passed one. - auto configPath = !options.configFile.empty() ? std::filesystem::path { options.configFile } - : Cfg::ProfileStore::DefaultPath(); - if (!std::filesystem::exists(configPath)) - return std::unexpected { std::format("Error: profile '{}' requested but no config file at {}", token, - configPath.string()) }; + // honoring `--config` if the user passed one. `LoadOrDefault` returns an empty store + // when the file is missing, so the `profile == nullptr` branch below covers both + // "no config file" and "config file present but profile absent". + auto configPath = + !options.configFile.empty() ? std::filesystem::path { options.configFile } : Cfg::ProfileStore::DefaultPath(); auto storeResult = Cfg::ProfileStore::LoadOrDefault(configPath); if (!storeResult) @@ -2401,10 +2394,8 @@ int DiffDatabases(Options const& options) { if (options.argument.empty() || options.secondArgument.empty()) { - std::println(std::cerr, - "Error: diff requires two source arguments (profile name or DRIVER=... connection string)."); - std::println(std::cerr, - "Usage: dbtool diff [--schema-only] [--no-color] [--max-rows N]"); + std::println(std::cerr, "Error: diff requires two source arguments (profile name or DRIVER=... connection string)."); + std::println(std::cerr, "Usage: dbtool diff [--schema-only] [--no-color] [--max-rows N]"); return EXIT_FAILURE; } @@ -2461,8 +2452,8 @@ int DiffDatabases(Options const& options) auto const schemaDiff = SqlSchema::DiffSchemas(tablesA, tablesB); auto dataDiffs = options.schemaOnly - ? std::vector {} - : DiffSharedTables(connA, connB, tablesA, tablesB, options.diffMaxRows, progress.get()); + ? std::vector {} + : DiffSharedTables(connA, connB, tablesA, tablesB, options.diffMaxRows, progress.get()); if (progress) progress->AllDone(); @@ -2471,8 +2462,8 @@ int DiffDatabases(Options const& options) auto const renderOpts = Lightweight::Tools::DiffRenderOptions { .useColor = useColor }; Lightweight::Tools::RenderDiff(std::cout, schemaDiff, dataDiffs, renderOpts); - auto const anyDataDiff = std::ranges::any_of( - dataDiffs, [](SqlSchema::TableDataDiff const& d) { return !d.rows.empty(); }); + auto const anyDataDiff = + std::ranges::any_of(dataDiffs, [](SqlSchema::TableDataDiff const& d) { return !d.rows.empty(); }); return (schemaDiff.Empty() && !anyDataDiff) ? EXIT_SUCCESS : 1; } catch (SqlException const& ex) diff --git a/src/tools/shared/Config/ProfileStore.hpp b/src/tools/shared/Config/ProfileStore.hpp index 8bf317689..43f7990a1 100644 --- a/src/tools/shared/Config/ProfileStore.hpp +++ b/src/tools/shared/Config/ProfileStore.hpp @@ -98,6 +98,11 @@ struct Profile [[nodiscard]] SqlConnectInfo ToConnectInfo(std::string_view password = {}) const; }; +#if defined(_MSC_VER) + #pragma warning(push) + #pragma warning(disable : 4251) // STL types in DLL interface +#endif + /// In-memory collection of named `Profile`s plus "which is the default". class ProfileStore { @@ -197,4 +202,8 @@ class ProfileStore std::vector _defaultPluginsDir; }; +#if defined(_MSC_VER) + #pragma warning(pop) +#endif + } // namespace Lightweight::Config diff --git a/src/tools/shared/Secrets/SecretResolver.cpp b/src/tools/shared/Secrets/SecretResolver.cpp index da4484fca..673cd3afb 100644 --- a/src/tools/shared/Secrets/SecretResolver.cpp +++ b/src/tools/shared/Secrets/SecretResolver.cpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace Lightweight::Secrets { @@ -69,11 +70,13 @@ namespace std::string JoinBackendNames(std::vector const& names) { std::string joined; - for (size_t i = 0; i < names.size(); ++i) + bool first = true; + for (auto const& name: names) { - if (i != 0) + if (!first) joined += ", "; - joined += names[i]; + joined += name; + first = false; } return joined; } diff --git a/src/tools/shared/Secrets/SecretResolver.hpp b/src/tools/shared/Secrets/SecretResolver.hpp index 0ce798052..70ff2c177 100644 --- a/src/tools/shared/Secrets/SecretResolver.hpp +++ b/src/tools/shared/Secrets/SecretResolver.hpp @@ -36,6 +36,11 @@ struct ResolveError std::string message; }; +#if defined(_MSC_VER) + #pragma warning(push) + #pragma warning(disable : 4251) // STL types in DLL interface +#endif + /// Central lookup for opaque `secretRef` strings. Owns a list of registered /// backends and dispatches based on either an explicit `prefix:` or /// registration order for bare references. @@ -45,10 +50,10 @@ class SecretResolver SecretResolver() = default; ~SecretResolver() = default; SecretResolver(SecretResolver const&) = delete; - /// Move-construct from another resolver, taking ownership of its backend chain. + /// Move-construct: defaulted; preserves backend registration order. SecretResolver(SecretResolver&&) = default; SecretResolver& operator=(SecretResolver const&) = delete; - /// Move-assign from another resolver, taking ownership of its backend chain. + /// Move-assign: defaulted; preserves backend registration order. SecretResolver& operator=(SecretResolver&&) = default; /// Appends a backend to the chain. The backend's `Name()` is used both as @@ -93,6 +98,10 @@ class SecretResolver std::vector> _backends; }; +#if defined(_MSC_VER) + #pragma warning(pop) +#endif + /// Convenience: builds a default resolver wired with the platform-neutral /// backends (`env:`, `file:`, `stdin:`) suitable for every build of /// Lightweight, regardless of Qt / libsecret availability.