Skip to content

fix(wren): preserve wide MySQL and Doris decimals - #2657

Open
Ray0907 wants to merge 10 commits into
Canner:mainfrom
Ray0907:fix-mysql-decimal256
Open

fix(wren): preserve wide MySQL and Doris decimals#2657
Ray0907 wants to merge 10 commits into
Canner:mainfrom
Ray0907:fix-mysql-decimal256

Conversation

@Ray0907

@Ray0907 Ray0907 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Map MySQL and Doris DECIMAL metadata to decimal128 through precision 38 and
    decimal256 through precision 76.
  • Inspect fetched Decimal values and widen the Arrow type when expression metadata
    understates the actual precision.
  • Return exact strings when a concrete value exceeds Arrow Decimal256's 76-digit limit,
    including a safe schema fallback for empty or all-null over-wide results.
  • Run the native MySQL connector regression suite in Wren CI.

What failure does this repair?

Wide MySQL DECIMAL values were always assigned decimal128, so a value such as
DECIMAL(65,30) failed while constructing the Arrow table.

MySQL expression metadata can also disagree with the returned value. On MySQL 8.0.36:

SELECT CAST('<65-digit integer>' AS DECIMAL(65,0))
     * CAST('<12-digit integer>' AS DECIMAL(12,0)) AS x;

The driver reported precision 66 (which derived an Arrow precision of 65), but returned
a 77-digit Decimal. The connector then raised:

ArrowInvalid: Decimal type with precision 77 does not fit into precision inferred from first array element: 65

The same failure is reproducible with DECIMAL(40,0) * DECIMAL(40,0), which returns an
80-digit value. This change derives the final column type after fetching values: values
that fit within 76 digits remain exact Arrow decimals, while wider values are emitted as
exact strings instead of raising or rounding.

How is it tested?

  • tests/unit/test_mysql_helpers.py covers decimal128/decimal256 boundaries, unsigned
    metadata, value-aware integer and scale widening, over-wide string fallback, and
    empty/all-null schemas.
  • tests/connectors/test_mysql_connector.py runs against MySQL 8.0.36 and covers
    DECIMAL(65,30), 66-digit addition, 77/80-digit multiplication, and wide SUM
    metadata.
  • The Wren CI workflow now collects both MySQL connector suites.

Local verification:

  • 62 targeted value-aware tests passed.
  • 1,096 non-memory unit tests passed, with 1 skipped.
  • 37 tests across both MySQL connector suites passed.
  • Ruff format/lint and git diff --check passed.

Duplicate check

Searched open PRs in Canner/WrenAI for MySQL DECIMAL, Decimal256, and decimal
in titles; no duplicates were found.

Summary by CodeRabbit

  • Bug Fixes

    • Improved MySQL handling of high-precision decimal values, including wider numeric ranges.
    • Preserved numeric results when values fit supported precision and scale limits.
    • Returned oversized, invalid, or non-finite decimal results as exact strings without losing precision.
    • Improved precision handling for decimal calculations, including addition and multiplication.
  • Tests

    • Added coverage for wide decimals, boundary conditions, null values, empty results, and exact string fallbacks.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a578700b-4827-4af1-8607-c1543f8805af

📥 Commits

Reviewing files that changed from the base of the PR and between 44d2936 and 9cdd99a.

📒 Files selected for processing (1)
  • core/wren/tests/unit/test_mysql_helpers.py
💤 Files with no reviewable changes (1)
  • core/wren/tests/unit/test_mysql_helpers.py

Included review availability: Your plan includes up to 10 reviews per rolling hour; 9 remain after this review.


Walkthrough

The MySQL connector supports Arrow decimal256 values up to precision 76. It inspects metadata and fetched values to select Decimal128, Decimal256, or exact strings. Tests cover wide decimals, arithmetic results, fallback behavior, nulls, and CI execution.

Changes

MySQL decimal conversion

Layer / File(s) Summary
Decimal metadata and type selection
core/wren/src/wren/connector/mysql.py
The connector derives precision and scale from MySQL metadata. Fetched values determine whether to use decimal128, decimal256, or exact strings.
Result-table decimal wiring
core/wren/src/wren/connector/mysql.py
Result-table construction passes fetched decimal values and unsigned metadata into adaptive type selection.
Wide decimal validation
core/wren/tests/unit/test_mysql_helpers.py, core/wren/tests/connectors/test_mysql_connector.py, .github/workflows/wren-ci.yml
Tests cover Decimal256 boundaries, widened arithmetic, exact-string fallback, fitting values, nulls, and execution of both MySQL test files in CI.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 9cdd9

This change preserves wide MySQL and Doris decimal values through exact Arrow decimals or string fallback, with targeted regression coverage and CI updates. No actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant MySQLCursor
  participant _build_mysql_arrow_table
  participant _mysql_decimal_type_for_values
  participant PyArrow
  MySQLCursor->>_build_mysql_arrow_table: metadata and fetched rows
  _build_mysql_arrow_table->>_mysql_decimal_type_for_values: decimal metadata, unsigned flag, and values
  _mysql_decimal_type_for_values->>PyArrow: decimal128 or decimal256 column
  _mysql_decimal_type_for_values->>PyArrow: exact string column for unsupported values
Loading

Poem

A rabbit checks each decimal place,
Then widens types with careful pace.
Wide sums fit within the tray,
Exact strings guard the rest today.
MySQL values stay precise.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the fix for wide MySQL and Doris decimal handling.
Description check ✅ Passed The description includes the required sections, concrete failure output, testing details, CI coverage, and duplicate check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added python Pull requests that update Python code core ci labels Aug 10, 2026
@Ray0907
Ray0907 marked this pull request as ready for review August 10, 2026 05:03

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/wren/src/wren/connector/mysql.py`:
- Around line 359-373: Update _mysql_decimal_type_for_values so precision
overflow at the 76-digit boundary is resolved by reducing target_scale while
retaining enough integer digits for observed values, returning pa.decimal256(76,
adjusted_scale) when representable. For cases such as display_length=78,
scale=75, and value 12, produce DECIMAL(76,74); return pa.string() only when the
minimum required integer and scale digits still exceed 76.

In `@core/wren/tests/connectors/test_mysql_connector.py`:
- Around line 151-168: Update
test_decimal_multiplication_above_arrow_limit_uses_exact_string to avoid relying
on MySQL DECIMAL arithmetic for products exceeding its 65-digit precision. Use
the existing fake-f coverage for the string fallback, or switch this test to a
backend that preserves the full product precision, while retaining assertions
for the exact string result and Arrow-limit behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cab54815-02ba-4b68-ac98-6b38b09cf101

📥 Commits

Reviewing files that changed from the base of the PR and between d234608 and f6177fd.

📒 Files selected for processing (4)
  • .github/workflows/wren-ci.yml
  • core/wren/src/wren/connector/mysql.py
  • core/wren/tests/connectors/test_mysql_connector.py
  • core/wren/tests/unit/test_mysql_helpers.py

Comment thread core/wren/src/wren/connector/mysql.py Outdated
Comment thread core/wren/tests/connectors/test_mysql_connector.py Outdated
@goldmedal

Copy link
Copy Markdown
Collaborator

Reviewed the branch locally. The core fix looks correct — I fuzzed _mysql_decimal_type_for_values + _build_mysql_column over 30,000 random combinations of metadata (M/D/unsigned) and 0–3 concrete Decimal values and saw no exceptions and no precision loss on either the decimal path or the exact-string path. tests/unit/test_mysql_helpers.py passes against this branch (35 passed, 1 skipped). Mirroring the value-aware approach already used in postgres.py is the right call.

Three things worth addressing:

1. The empty / all-NULL fallback to pa.string() is unnecessary and makes the schema data-dependent (design decision)

core/wren/src/wren/connector/mysql.py:347

if not shapes:
    if precision > _ARROW_DECIMAL256_MAX_PRECISION:
        return pa.string()

Observed on this branch:

empty rows, metadata precision 88 -> string
all NULL,   metadata precision 88 -> string
one fitting value                 -> decimal256(76, 0)

When there is no non-NULL value there is nothing to convert, so a numeric type cannot overflow — the string fallback buys no safety here, but it does make the same query return a different Arrow type depending on how many rows come back. Concrete case: with amount DECIMAL(65, 0), SELECT SUM(amount) AS total FROM orders WHERE <predicate> yields a string column when the predicate matches nothing and decimal256(76, 0) when it matches one row. Consumers that key off the Arrow schema (formatting, downstream aggregation, type checks) see the column flip type.

Suggestion: return _arrow_decimal_type(_ARROW_DECIMAL256_MAX_PRECISION, min(scale, _ARROW_DECIMAL256_MAX_PRECISION)) for the no-values case, matching the "values present and representable" branch.

Note this behaviour is currently pinned by test_decimal_metadata_above_arrow_limit_without_values_uses_string, so it reads as deliberate — if it is, it would help to say why in the comment, since the safety argument in the current comment ("no concrete value proves that a narrower numeric schema would be safe") does not apply when there are no values to represent.

2. _mysql_decimal_value_shape raises on a non-Decimal value instead of falling back

core/wren/src/wren/connector/mysql.py:322

value = value if isinstance(value, PyDecimal) else PyDecimal(str(value))

A bytes value raises decimal.InvalidOperation (verified: b"1.5"str() gives "b'1.5'"ConversionSyntax). _build_mysql_arrow_table explicitly documents that it may run against "older / non-MySQLdb cursors", and the postgres sibling _infer_pg_decimal_type handles this by returning None (which degrades to a string column) rather than raising.

Suggestion: treat an unparseable or non-Decimal value the same way as a non-finite one — return None, so the column degrades to the exact-string path. _build_mysql_column's string branch already decodes bytes correctly, so this turns a crash into a correct result.

(The same PyDecimal(str(v)) pattern pre-exists in _build_mysql_column, so this is not a regression from this PR — just the cheapest place to fix it.)

3. The metadata-only decimal path is now unreachable in production

_build_mysql_arrow_table intercepts decimal type codes before calling _mysql_field_arrow_type, so the if type_code in decimal_codes: branch at core/wren/src/wren/connector/mysql.py:403 — and therefore _arrow_decimal_from_mysql_field at line 265 — is dead for the only caller in src/. That leaves two decimal-typing implementations that can drift, with the unit tests covering mostly the one that no longer runs, so a future regression in the live path would not be caught there.

Suggestion: pick one entry point — either pass values through _mysql_field_arrow_type so all field typing still flows through it, or drop the decimal branch from _mysql_field_arrow_type and keep _arrow_decimal_from_mysql_field only if something else needs metadata-only typing.

@goldmedal goldmedal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified locally rather than by reading: fetched the branch, ran tests/unit/test_mysql_helpers.py (37 passed), and exercised _build_mysql_arrow_table / _mysql_decimal_type_for_values directly with a stub cursor. All 9 CI checks are green.

The value-aware approach fixes more than the reported case. On main, metadata that understates scale raises ArrowInvalid: Rescaling Decimal value would cause data loss; this branch handles it correctly. Nice catch on the M = length - (0 if unsigned else 1) - ... docstring too, the previous formula contradicted the code.

Four things I'd like addressed before merge, left inline: a measured hot-path regression, gratuitous escalation to decimal256, an undocumented result-dependent column type, and a path that is now unreachable in production.

Comment thread core/wren/src/wren/connector/mysql.py Outdated
flags,
precision=precision,
scale=scale,
values=[row[i] for row in rows],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hot-path regression: values is materialized for every column (Major)

This builds [row[i] for row in rows] for every column, and then _build_mysql_column builds the same per-column list a second time below. Only DECIMAL columns consume values, so every non-decimal column pays for a wasted full-result copy.

Measured on 20 columns x 200k rows, best of 3 runs, stub cursor so only this function is timed:

column type before after
INT 71 ms 108 ms (+52%)
DECIMAL(12,4) 483 ms 1738 ms (+260%)
VARCHAR 448 ms 481 ms

The decimal cost is _mysql_decimal_value_shape calling Decimal.as_tuple() on 4M values. For a semantic layer, decimal-heavy result sets are the common case, not the exception.

Suggestions, cheapest first:

  • Materialize the columns once (columns = list(zip(*rows))) and reuse them for both the type derivation and _build_mysql_column.
  • Pass values only when type_code in _mysql_decimal_codes(), so non-decimal columns are untouched.
  • Consider optimistic-then-fallback: try pa.array(values, type=<metadata type>) first and run the value-shape scan only when it raises ArrowInvalid. That keeps the common path at the previous cost while staying exact for the pathological cases this PR targets.

Comment thread core/wren/src/wren/connector/mysql.py Outdated
return pa.decimal256(_ARROW_DECIMAL256_MAX_PRECISION, target_scale)

target_scale = max(scale, value_scale)
target_integer_digits = max(precision - scale, integer_digits)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary escalation past the decimal128 boundary (Major)

Reserving the declared integer capacity even when no fetched value needs it lets a widened scale push the column into decimal256 gratuitously:

_mysql_decimal_type_for_values(40, 4, False, [Decimal("1.234567")])
# -> decimal256(40, 6)

That is a declared DECIMAL(38, 4) whose observed value needs 1 integer digit and scale 6. decimal128(38, 6) holds it comfortably, and decimal128 is considerably better supported downstream than decimal256 (polars and several engines handle decimal256 poorly or not at all).

Please trim unused integer capacity to stay within precision 38 when the observed values fit, and escalate to decimal256 only when they genuinely do not.

integer_digits = max(shape[0] for shape in shapes if shape is not None)
value_scale = max(shape[1] for shape in shapes if shape is not None)
if integer_digits + value_scale > _ARROW_DECIMAL256_MAX_PRECISION:
return pa.string()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result-dependent column type deserves an explicit contract (Major)

The derived type depends on which rows come back, and MySqlConnector.query() applies a LIMIT via _apply_limit, with the MCP path probing at limit + 1:

_mysql_decimal_type_for_values(67, 0, False, [D("9"*70)])             # decimal256(70, 0)
_mysql_decimal_type_for_values(67, 0, False, [D("9"*70), D("9"*77)])  # pa.string()

So the same query at LIMIT 1 and LIMIT 2 can return a numeric column and a string column, and one outlier row silently degrades the whole column to text. That may well be the right trade-off versus raising, but today it is both silent and undocumented. Please:

  • log a warning when a decimal column falls back to pa.string(), naming the column, and
  • state the contract in the _build_mysql_arrow_table / _mysql_decimal_type_for_values docstring, so consumers know the decimal type is derived per result set rather than per column.

is_unsigned=bool(flags & FLAG.UNSIGNED),
values=values,
)
return _arrow_decimal_from_mysql_field(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor

  • This branch is now unreachable in production: the only caller of _mysql_field_arrow_type always passes a list for values, so values is not None is always true and _arrow_decimal_from_mysql_field survives as a test-only helper with 7 unit tests behind it. Fixing the first comment by passing values only for decimal type codes makes this branch live again; otherwise please drop it so the tests describe real behaviour.
  • tests/connectors/test_mysql_connector.py:166assert len(expected) in {77, 80} is loose for a parametrized test; carry the expected digit count in the parameters so each case asserts its own value.
  • No coverage for negative over-wide values. I checked and the behaviour is correct (-9...9 at 77 digits becomes a string, at 76 digits stays decimal256(76, 0)), so it is worth pinning down next to tests/unit/test_mysql_helpers.py:295.

@goldmedal

Copy link
Copy Markdown
Collaborator

Hi @Ray0907, to clear my review queue, I converted this PR to a draft. After addressing the comment, feel free to request my review.

@goldmedal
goldmedal marked this pull request as draft August 25, 2026 02:03
@Ray0907
Ray0907 force-pushed the fix-mysql-decimal256 branch from 9cdd99a to 60fe177 Compare August 25, 2026 04:21
@Ray0907
Ray0907 marked this pull request as ready for review August 25, 2026 04:21
@Ray0907

Ray0907 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@goldmedal Ready for re-review.

Addressed all remaining comments:

  • use metadata-first decimal conversion and scan concrete values only after Arrow conversion fails
  • materialize result columns once and keep Decimal128 when observed values fit
  • document result-dependent decimal types and warn with the column name on exact-string fallback
  • restore the metadata-only production path
  • tighten digit-count assertions and cover negative 76/77-digit boundaries

Rebased onto the latest main. Local validation: 1,227 non-memory unit tests passed (2 skipped), 37 MySQL connector tests passed, and Ruff format/lint passed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci core python Pull requests that update Python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants