Skip to content

fix(adapters): bind and read every double exactly so find, count, cursors and deletes agree - #1036

Merged
abnegate merged 5 commits into
mainfrom
fix/float-precision
Oct 16, 2026
Merged

abnegate merged 5 commits into
mainfrom
fix/float-precision

Conversation

@abnegate

@abnegate abnegate commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

What

Staging bug: double filters, cursors and writes disagreed about which value a double holds.

  • equal("f", [0.30000000000000004]) returned 0 rows with a total of 20. equal("f", [5e-324]) returned the 20 rows where f = 0 with a total of 10.
  • A cursor walk along orderAsc("f") with cursorAfter at a 5e-324 row looped forever. Descending skipped rows.
  • Writes kept 14 significant digits: 0.30000000000000004 was stored as 0.3 and pi as 3.1415926535898.

Root causes

  1. Binds. SQL::executeResult() bound a double in find() and bulk deletes as sprintf('%.17F'). Fixed point rounds 5e-324 to 0.00000000000000000. Every other statement (writes, count(), sum(), operators, raw queries) let PDO stringify the double at the precision ini, which is 14 digits by default. So a single filter matched different rows in find() and count(), and a write lost digits.
  2. Reads. With ATTR_STRINGIFY_FETCHES, which Appwrite and Cloud set, pdo_pgsql and pdo_sqlite stringify a fetched float8/REAL at the same 14-digit precision. So Postgres and SQLite read back 0.3 even for a row that held 0.30000000000000004. A cursor built from that document then compared f > 0.3 and returned the same rows again.

Fix

  • Every SQL bind goes through a new SQL::bind(). For a double, formatFloat() writes a decimal that reads back as exactly that double. It tries PHP's own cast first, then %.16H, then %.17H. The check is (float) $s === $value. It never depends on the locale (casts and %H are locale-independent) and never on the ini (every candidate is verified, and %.17H always round-trips). Denormals, 17-digit values and ±max survive. find, count, sum, cursors, bulk deletes, writes, upserts, increments and operators now bind identical strings. $floatPrecision/getFloatPrecision() are removed; nothing in appwrite or cloud uses them.
  • SQL::fetchExactly() runs document fetches (getDocument, find/aggregates, the bulk-write existence read, the sequence-ordered page and rawQuery) with precision = -1. That is PHP's shortest round-trip mode, so a stringified fetch keeps every digit. The previous value is restored in finally.
  • A non-finite filter value (INF, -INF, NAN, or JSON 1e400) is now refused with Exception\Query. This applies to equal, notEqual, comparisons and between on float or double attributes, and to having values, through the new Validator\Finite. Writes already refused it with Exception\Structure (the Range check); the e2e test pins that too.

Bind before and after (MySQL / Postgres; the SQL text is unchanged)

Statement origin/main binds this PR binds
find(equal f 0.30000000000000004) '0.30000000000000004' '0.30000000000000004'
count(equal f 0.30000000000000004) '0.3' '0.30000000000000004'
find(equal f 5e-324) '0.00000000000000000' '4.9406564584125E-324'
count(equal f 5e-324) '4.9406564584125E-324' '4.9406564584125E-324'
createDocument(f = pi) '3.1415926535898' '3.141592653589793'
createDocument(f = 12.5) '12.5' '12.5'
Postgres fetch of 0.30000000000000004 '0.3' '0.30000000000000004'

Hot path cost (PHP 8.5, per bind, 1M iterations):

Value Before After
Short double (12.5) ~40 ns (PDO cast) or ~130 ns (%.17F) ~100 ns
16–17-digit doubles ~0.5 µs
fetchExactly() wrapper ~140 ns per statement, not per row

All of these are negligible next to a round trip. No query plan or SQL text changes.

Regression tests

DocumentTests (every adapter, including Pool/Mirror/SharedTables through the shared trait):

  • testAFloatFilterMatchesTheSameRowsInFindCountSumAndDelete is the exact repro: 20 × 0.1+0.2, 20 × 0.0, 10 × 5e-324. It checks that find and count agree for equal, notEqual, >, <, between, and the zero-to-denormal range. It also checks sum, and that deleteDocuments(equal 5e-324) deletes exactly 10 rows and keeps the zeros.
  • testACursorAlongAFloatOrderVisitsEveryRowOnce walks asc and desc by 1, 2 and 4 across -5e-324, 0, 5e-324, 1e-300, 0.3, 0.1+0.2, pi, three copies each. Pages end on a 5e-324 row. Every row must be visited once and in order.
  • testFloatsKeepEveryDigit round-trips 0.1+0.2, pi, 17-digit values, min normal, ±5e-324, ±max and others. The write paths covered are create, bulk create, update, bulk update, upsert, increase, decrease, Operator::increment and Operator::multiply. Each value is checked by getDocument and by an equal find, for scalars and for arrays.
  • testANonFiniteFloatIsRefused: INF/-INF/1e400/NAN in find, count, sum and bulk-delete filters → Exception\Query; in create and update → Exception\Structure.
Adapter origin/main (8.3.4) this PR
MySQL 4/4 fail. filter: find equal sum 0 rows vs count 20. cursor: asc by 1 visited 22 rows of 21 (stopped by the test's cap). digits: 0.3 !== 0.30000000000000004. non-finite: find equal INF accepted pass
MariaDB 4/4 fail (same as MySQL) pass
Postgres 4/4 fail (same as MySQL) pass
SQLite 4/4 fail (same as MySQL) pass
MongoDB 1/4 fail: find equal INF accepted pass
Memory 1/4 fail: find equal INF accepted pass
Redis 1/4 fail: find equal INF accepted pass

Unit tests (origin/main → this PR):

Test origin/main this PR
FloatBindingTest (MariaDB, MySQL, Postgres, SQLite × 8 values: create, bulk update, increase, increment operator, find/count/sum binds alike, fetch at round-trip precision and restore on throw) 160 fail, 4 error pass
FiniteTest 13 error (class missing) pass
QueryShapeHavingTest (non-finite having values) 3 fail pass
DeleteDocumentsRaceTest::testAFloatFilterDeletesTheDocumentsTheFindMatched@sqlite: the delete now matches only large, as Memory does, instead of the 1e-21 row that fixed point rounded to 0 1 fail pass

testFloatsAreStoredAs7xBoundThem, which pinned the lossy 7.x binding, is replaced.

Known engine limit, not changed here: MySQL's JSON parser reads a 17-digit number only to within one ulp ([123456789.12345679] reads back 123456789.1234568), so a double inside a MySQL array attribute can differ in its last digit. The array assertion allows one ulp on MySQL only. Scalars are exact on every engine. At the engine level, MySQL and Postgres keep the sign of a bound -0, while MariaDB and SQLite store it as 0. PHP compares -0.0 === 0.0, so filters are unaffected.

Run locally against Docker: the full MySQL, MariaDB, Postgres, SQLite, MongoDB, Memory and Redis suites, the full unit suite, Pint and PHPStan (both configs).

API repro (Appwrite)

Table t with float column f, holding 20 rows f=0.30000000000000004, 20 rows f=0 and 10 rows f=5e-324:

  • GET /v1/tablesdb/{db}/tables/t/rows?queries[]={"method":"equal","attribute":"f","values":[0.30000000000000004]} → 200, rows.length = 20, total = 20 (was 0 rows, total 20).
  • GET ...?queries[]={"method":"equal","attribute":"f","values":[5e-324]} → 200, 10 rows, total = 10 (was 20 rows, total 10).
  • GET ...?queries[]={"method":"orderAsc","attribute":"f"}&queries[]={"method":"cursorAfter","values":["<a 5e-324 rowId>"]}&queries[]={"method":"limit","values":[2]}, followed page by page, visits every row exactly once. It previously looped forever; orderDesc previously skipped rows.
  • POST /v1/tablesdb/{db}/tables/t/rows {"rowId":"unique()","data":{"f":0.30000000000000004}}, then GET that row → f is 0.30000000000000004 (was 0.3). Pi reads back as 3.141592653589793.
  • GET ...?queries[]={"method":"lessThan","attribute":"f","values":[1e400]} → 400 general_query_invalid (JSON 1e400 decodes to INF).

🤖 Generated with Claude Code

abnegate and others added 3 commits October 16, 2026 20:01
…sors and deletes agree

find() and bulk deletes bound a double as %.17F, which rounds 5e-324 to 0, while every other statement let PDO
write it at the precision ini, 14 significant digits, which stores 0.30000000000000004 as 0.3. A filter then
matched different rows in find() and count(), a cursor along a double order repeated or skipped rows, and a
write lost digits. Every SQL bind now writes a double as the shortest locale-independent decimal that reads
back as exactly that double, and every document fetch runs at the round-trip precision, because PDO stringifies
a fetched double from pgsql and sqlite at the precision ini too.

A filter or having value that is infinite or NaN is refused as an invalid query, as writes already were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ys-true assertion

A double that PHP's own cast already writes exactly binds as that string, so a short value costs what PDO's
conversion cost before; longer ones fall back to 16 and then 17 significant digits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 34 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: utopia-php/database/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f70512d3-6016-403c-a0a0-58595d46cf7f
📥 Commits

Reviewing files that changed from the base of the PR and between 59f5a06 and 0043660.

📒 Files selected for processing: 1
  • tests/e2e/Adapter/Scopes/DocumentTests.php

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: utopia-php/database/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 339203cd-808f-4498-b958-f5d631ef89dc
📥 Commits

Reviewing files that changed from the base of the PR and between 7154053 and 59f5a06.

📒 Files selected for processing: 11
  • src/Database/Adapter/SQL.php
  • src/Database/Adapter/SQLite.php
  • src/Database/Validator/Finite.php
  • src/Database/Validator/Query/Filter.php
  • src/Database/Validator/Query/Having.php
  • tests/e2e/Adapter/Scopes/DocumentTests.php
  • tests/unit/Adapter/BuildsAggregates.php
  • tests/unit/Adapter/FloatBindingTest.php
  • tests/unit/Documents/DeleteDocumentsRaceTest.php
  • tests/unit/Validator/FiniteTest.php
  • tests/unit/Validator/QueryShapeHavingTest.php

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

SQL adapters now bind floats as round-trip-safe decimal strings and fetch rows with temporary round-trip precision. Float and double query validation rejects non-finite values. Tests cover binding, retrieval, mutations, filtering, cursor paging, and non-finite inputs.

Changes: Float handling

Layer / File(s) Summary
Round-trip-safe SQL binding and fetching
src/Database/Adapter/SQL.php, src/Database/Adapter/SQLite.php, tests/unit/Adapter/*
SQL adapters use shared helpers to bind floats as round-trip-safe decimal strings and fetch results at round-trip precision. Unit tests cover binding and fetch behavior across adapters.
Finite float validation
src/Database/Validator/Finite.php, src/Database/Validator/Query/*, tests/unit/Validator/*
A new validator rejects values that are not finite floats. Float and double filters and non-extrema aggregate values use it. Tests cover finite and non-finite values.
Adapter-wide float behavior
tests/e2e/Adapter/Scopes/DocumentTests.php, tests/unit/Documents/DeleteDocumentsRaceTest.php
End-to-end tests cover float filters, cursor paging, storage, mutations, and non-finite values. The SQLite race-test expectation now includes only the large value.

Priority: ➖ Normal

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

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 59f5a

This change makes float binding and fetching consistent across SQL adapters and rejects non-finite values in queries. No concrete merge-blocking risk was found in the supplied context.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 50 functions across 11 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
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.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely summarizes the main change: exact double binding and reading to keep find, count, cursor, and delete results consistent.
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

abnegate and others added 2 commits October 16, 2026 21:51
Two documents created in the same millisecond share $createdAt, so a distinct read of k and $createdAt
returned three rows instead of four on fast runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abnegate
abnegate merged commit 518ec86 into main Oct 16, 2026
24 checks passed
@abnegate
abnegate deleted the fix/float-precision branch October 16, 2026 09:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant