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/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/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/Lightweight/CMakeLists.txt b/src/Lightweight/CMakeLists.txt index 1def367d5..dbb4282f2 100644 --- a/src/Lightweight/CMakeLists.txt +++ b/src/Lightweight/CMakeLists.txt @@ -93,12 +93,20 @@ set(HEADER_FILES SqlMigration.hpp SqlOdbcWide.hpp SqlQueryFormatter.hpp + SqlDataDiff.hpp SqlSchema.hpp + SqlSchemaDiff.hpp SqlScopedLock.hpp SqlScopedTraceLogger.hpp SqlServerType.hpp SqlStatement.hpp TracyProfiler.hpp + + tui/MarkdownInline.hpp + tui/MarkdownTable.hpp + tui/SgrBuilder.hpp + tui/Style.hpp + tui/Unicode.hpp ) set(SOURCE_FILES @@ -131,7 +139,9 @@ set(SOURCE_FILES SqlQueryFormatter.cpp SqlScopedLock.cpp + SqlDataDiff.cpp SqlSchema.cpp + SqlSchemaDiff.cpp SqlStatement.cpp SqlTransaction.cpp Utils.cpp @@ -175,14 +185,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/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 new file mode 100644 index 000000000..692a13341 --- /dev/null +++ b/src/Lightweight/SqlDataDiff.cpp @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "DataBinder/SqlVariant.hpp" +#include "SqlConnection.hpp" +#include "SqlDataDiff.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: + break; + } + 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; + } + + /// 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 = + tableOnThisSide.schema.empty() + ? Quote(tableOnThisSide.name, server) + : std::format("{}.{}", Quote(tableOnThisSide.schema, server), Quote(tableOnThisSide.name, server)); + + auto cols = std::string {}; + bool firstCol = true; + for (auto const& name: layout.orderedColumnNames) + { + if (!firstCol) + cols += ", "; + cols += Quote(name, server); + firstCol = false; + } + + auto orderBy = std::string {}; + // 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(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) + { + 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. The caller is responsible for + /// bumping `aRowCount` / `bRowCount` for the side just consumed. + 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& tableA, + Table const& tableB, + std::size_t maxRows, + DiffProgressCallback onProgress) +{ + auto result = TableDataDiff { .tableName = tableA.name }; + + if (tableA.primaryKeys.empty()) + { + result.skipReason = "no primary key"; + return result; + } + + 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 }; + + 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 = tableA.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..4f0258d7a --- /dev/null +++ b/src/Lightweight/SqlDataDiff.hpp @@ -0,0 +1,111 @@ +// 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 + { + /// 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 `TableDataDiff::skipReason` is set instead). + std::vector primaryKey {}; + + /// 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 {}; + }; + + /// 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 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. + 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 + { + /// 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. + }; + + /// Callback invoked during a data diff to report progress. + using DiffProgressCallback = std::function; + + /// Compares the rows of one table across two databases. + /// + /// 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 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& tableA, + Table const& tableB, + std::size_t maxRows = 0, + DiffProgressCallback onProgress = {}); + +} // namespace SqlSchema + +} // namespace Lightweight 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 new file mode 100644 index 000000000..f95fb7587 --- /dev/null +++ b/src/Lightweight/SqlSchemaDiff.cpp @@ -0,0 +1,428 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "SqlColumnTypeDefinitions.hpp" +#include "SqlSchemaDiff.hpp" + +#include +#include +#include +#include +#include + +namespace Lightweight::SqlSchema +{ + +namespace +{ + + /// 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.name; + } + + /// Indexes a TableList by 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, 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 (ToLogical(a.type) != ToLogical(b.type)) + fields.emplace_back("type"); + if (a.isNullable != b.isNullable) + fields.emplace_back("nullable"); + if (a.isAutoIncrement != b.isAutoIncrement) + fields.emplace_back("autoIncrement"); + if (a.isPrimaryKey != b.isPrimaryKey) + fields.emplace_back("primaryKey"); + 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 {}; + bool first = true; + for (auto const& name: v) + { + if (!first) + s += ", "; + s += name; + first = false; + } + 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, 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) { + auto s = std::string {}; + bool first = true; + for (auto const& name: v) + { + if (!first) + s += ","; + s += name; + first = false; + } + return s; + }; + 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, + 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 {}; + bool first = true; + for (auto const& name: idx.columns) + { + if (!first) + cols += ","; + cols += name; + first = false; + } + 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 + +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> {}; + for (auto const& t: a) + if (!IsMigrationInternalTable(t.name)) + allKeys.insert(KeyOf(t)); + for (auto const& t: b) + if (!IsMigrationInternalTable(t.name)) + 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 { + .name = key, + .schemaA = tableA->schema, + .schemaB = {}, + .kind = DiffKind::OnlyInA, + }); + continue; + } + if (!tableA && tableB) + { + diff.tables.emplace_back(TableDiff { + .name = key, + .schemaA = {}, + .schemaB = tableB->schema, + .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 { + .name = key, + .schemaA = tableA->schema, + .schemaB = tableB->schema, + .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..0fa4bc5f8 --- /dev/null +++ b/src/Lightweight/SqlSchemaDiff.hpp @@ -0,0 +1,107 @@ +// 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 +{ + /// 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 `DiffKind::Changed`: human-readable list of differing fields + /// (e.g. `"type"`, `"nullable"`, `"defaultValue"`). Empty otherwise. + std::vector changedFields {}; + + /// Pointer into the input `TableList` for the left-hand column. Null when + /// `kind` is `DiffKind::OnlyInB`. + Column const* a = nullptr; + + /// Pointer into the input `TableList` for the right-hand column. Null when + /// `kind` is `DiffKind::OnlyInA`. + Column const* b = nullptr; +}; + +/// A diff entry for a single table. +struct TableDiff +{ + /// 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 `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 `kind` is `DiffKind::OnlyInA` + /// or when the right-hand engine reports no schema. + std::string schemaB; + + /// 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 `kind` is `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 **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 `TableDiff` for the renderer. Columns are paired by name. +/// +/// 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, +/// 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/Lightweight/tui/MarkdownInline.hpp b/src/Lightweight/tui/MarkdownInline.hpp new file mode 100644 index 000000000..fac5261ef --- /dev/null +++ b/src/Lightweight/tui/MarkdownInline.hpp @@ -0,0 +1,74 @@ +// 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..5f1cc4d37 --- /dev/null +++ b/src/Lightweight/tui/MarkdownTable.cpp @@ -0,0 +1,462 @@ +// 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..ce4bdfee2 --- /dev/null +++ b/src/Lightweight/tui/SgrBuilder.cpp @@ -0,0 +1,100 @@ +// 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 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..ab991ba06 --- /dev/null +++ b/src/tests/SqlDataDiffTests.cpp @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#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) +{ + // 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()) }; +} + +/// 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) +{ + 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 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()); + + 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, 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 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, + { + { 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, 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 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 }; + (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, schema); + REQUIRE(diff.skipReason.has_value()); + CHECK(diff.skipReason.value_or("") == "no primary key"); + CHECK(diff.rows.empty()); +} + +TEST_CASE("DiffTableData: each side uses its own descriptor to qualify the SELECT", "[SqlDataDiff]") +{ + // Regression for cross-engine diff: a postgres-vs-mssql diff failed with + // "Invalid object name 'public.schema_migrations'" because the data-diff SELECT + // was built once with side A's schema label and reused on side B. The fix takes + // one descriptor per side and qualifies each query with its own schema/name. + // + // We pin this with two SQLite databases that store the same logical table under + // different names. With the old single-descriptor API, side B's query would have + // referenced side A's name and failed; with the per-side API, each query resolves + // on its own connection. + auto const tmp = std::filesystem::temp_directory_path(); + auto const guardA = ScopedTempFile { tmp / "diff_data_perside_a.sqlite" }; + auto const guardB = ScopedTempFile { tmp / "diff_data_perside_b.sqlite" }; + + 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> { + { 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(); + auto const guardA = ScopedTempFile { tmp / "diff_data_progress_a.sqlite" }; + auto const guardB = ScopedTempFile { tmp / "diff_data_progress_b.sqlite" }; + + 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). + SetupUsersTable(connA, {}); + SetupUsersTable(connB, {}); + + auto const schema = FetchTableSchema(connA, "users"); + auto callCount = 0; + auto const diff = DiffTableData(connA, connB, schema, 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..48ddcc2cc --- /dev/null +++ b/src/tests/SqlSchemaDiffTests.cpp @@ -0,0 +1,434 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +using namespace Lightweight; +using namespace Lightweight::SqlSchema; + +namespace +{ + +[[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 { + 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()); +} + +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 { + Table { .schema = "dbo", .name = "users", .columns = { idColumn }, .primaryKeys = { "id" } }, + Table { .schema = "dbo", .name = "posts", .columns = { idColumn }, .primaryKeys = { "id" } }, + }; + auto const b = TableList { + Table { .schema = "dbo", .name = "users", .columns = { idColumn }, .primaryKeys = { "id" } }, + 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); + + 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 { + 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 { + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { + .name = "id", .type = SqlColumnTypeDefinitions::Integer {}, .isNullable = false, .isPrimaryKey = true } }, + .primaryKeys = { "id" }, + }, + }; + + 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 { + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { .name = "email", .type = SqlColumnTypeDefinitions::Varchar { 50 }, .size = 50 } }, + }, + }; + auto const b = TableList { + 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 { + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { + .name = "name", .type = SqlColumnTypeDefinitions::Varchar { 50 }, .isNullable = true, .size = 50 } }, + }, + }; + auto const b = TableList { + Table { + .schema = "dbo", + .name = "users", + .columns = { Column { + .name = "name", .type = SqlColumnTypeDefinitions::Varchar { 50 }, .isNullable = false, .size = 50 } }, + }, + }; + + 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 { + Table { + .schema = "dbo", + .name = "t", + .columns = { Column { + .name = "id", .type = SqlColumnTypeDefinitions::Integer {}, .isNullable = false, .isPrimaryKey = true } }, + .primaryKeys = { "id" }, + }, + }; + auto const b = TableList { + 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); + REQUIRE(diff.tables.size() == 1); + CHECK_FALSE(diff.tables.front().primaryKeyDiffs.empty()); +} + +TEST_CASE("DiffSchemas: index drift", "[SqlSchemaDiff]") +{ + auto a = TableList { + 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 } }, + }, + }; + 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()); +} + +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()); +} 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..e61a16d13 --- /dev/null +++ b/src/tools/dbtool/DiffRenderer.cpp @@ -0,0 +1,436 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "DiffRenderer.hpp" + +#include +#include +#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 (std::size_t col = 0; col < widths.size(); ++col) + { + 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(col); + 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 (std::size_t r = 0; r < table.rows.size(); ++r) + drawRow(table.rows[r], [&, r](std::size_t c) { return cellStyle(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'; + } + + /// 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: {}", ColumnSummary(*cd.a)) : "only in A"; + case DiffKind::OnlyInB: + return cd.b ? std::format("only in B: {}", ColumnSummary(*cd.b)) : "only in B"; + case DiffKind::Changed: { + auto details = std::string { "changed: " }; + bool first = true; + for (auto const& f: cd.changedFields) + { + if (!first) + details += ", "; + details += f; + first = false; + } + if (cd.a && cd.b) + details += std::format(" ({} → {})", ColumnSummary(*cd.a), ColumnSummary(*cd.b)); + 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 {}; + bool first = true; + for (auto const& p: parts) + { + if (!first) + out += ", "; + out += p; + first = false; + } + 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 {}; + + // 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) + { + pt.rows.push_back({ qualifyName(td), 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 {}; + bool first = true; + for (auto const& v: pk) + { + if (!first) + out += "/"; + out += v; + first = false; + } + 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..12fdcf5fa 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 @@ -17,9 +20,9 @@ #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,204 @@ 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 tablesByNameB = std::map {}; + for (auto const& t: tablesB) + tablesByNameB.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) + { + 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 + // 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, tB, 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; + 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. `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) + 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 +2728,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; 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.