Repository navigation
fix(adapters): bind and read every double exactly so find, count, cursors and deletes agree - #1036
Conversation
…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>
|
Warning Review limit reachedYou'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. View limit details
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
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>
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 wheref = 0with a total of 10.orderAsc("f")withcursorAfterat a5e-324row looped forever. Descending skipped rows.0.30000000000000004was stored as0.3and pi as3.1415926535898.Root causes
SQL::executeResult()bound a double infind()and bulk deletes assprintf('%.17F'). Fixed point rounds5e-324to0.00000000000000000. Every other statement (writes,count(),sum(), operators, raw queries) let PDO stringify the double at theprecisionini, which is 14 digits by default. So a single filter matched different rows infind()andcount(), and a write lost digits.ATTR_STRINGIFY_FETCHES, which Appwrite and Cloud set, pdo_pgsql and pdo_sqlite stringify a fetchedfloat8/REALat the same 14-digitprecision. So Postgres and SQLite read back0.3even for a row that held0.30000000000000004. A cursor built from that document then comparedf > 0.3and returned the same rows again.Fix
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%Hare locale-independent) and never on the ini (every candidate is verified, and%.17Halways round-trips). Denormals, 17-digit values and±maxsurvive.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 andrawQuery) withprecision = -1. That is PHP's shortest round-trip mode, so a stringified fetch keeps every digit. The previous value is restored infinally.INF,-INF,NAN, or JSON1e400) is now refused withException\Query. This applies toequal,notEqual, comparisons andbetweenon float or double attributes, and tohavingvalues, through the newValidator\Finite. Writes already refused it withException\Structure(theRangecheck); the e2e test pins that too.Bind before and after (MySQL / Postgres; the SQL text is unchanged)
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'0.30000000000000004'0.3''0.30000000000000004'Hot path cost (PHP 8.5, per bind, 1M iterations):
12.5)%.17F)fetchExactly()wrapperAll 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):testAFloatFilterMatchesTheSameRowsInFindCountSumAndDeleteis the exact repro: 20 ×0.1+0.2, 20 ×0.0, 10 ×5e-324. It checks thatfindandcountagree forequal,notEqual,>,<,between, and the zero-to-denormal range. It also checkssum, and thatdeleteDocuments(equal 5e-324)deletes exactly 10 rows and keeps the zeros.testACursorAlongAFloatOrderVisitsEveryRowOncewalks 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 a5e-324row. Every row must be visited once and in order.testFloatsKeepEveryDigitround-trips0.1+0.2, pi, 17-digit values, min normal,±5e-324,±maxand others. The write paths covered are create, bulk create, update, bulk update, upsert, increase, decrease,Operator::incrementandOperator::multiply. Each value is checked bygetDocumentand by anequalfind, for scalars and for arrays.testANonFiniteFloatIsRefused:INF/-INF/1e400/NANin find, count, sum and bulk-delete filters →Exception\Query; in create and update →Exception\Structure.find equal sum0 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 INFacceptedfind equal INFacceptedfind equal INFacceptedfind equal INFacceptedUnit tests (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)FiniteTestQueryShapeHavingTest(non-finite having values)DeleteDocumentsRaceTest::testAFloatFilterDeletesTheDocumentsTheFindMatched@sqlite: the delete now matches onlylarge, as Memory does, instead of the1e-21row that fixed point rounded to 0testFloatsAreStoredAs7xBoundThem, 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 back123456789.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 as0. 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
twith float columnf, holding 20 rowsf=0.30000000000000004, 20 rowsf=0and 10 rowsf=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;orderDescpreviously skipped rows.POST /v1/tablesdb/{db}/tables/t/rows{"rowId":"unique()","data":{"f":0.30000000000000004}}, thenGETthat row →fis0.30000000000000004(was0.3). Pi reads back as3.141592653589793.GET ...?queries[]={"method":"lessThan","attribute":"f","values":[1e400]}→400 general_query_invalid(JSON1e400decodes to INF).🤖 Generated with Claude Code