Skip to content

[Issue #75] fix: normalise whole-number JSON floats to integers on round-trip - #76

Closed
McNultyyy wants to merge 17 commits into
mainfrom
mcnultyyy/fix-issue-75-decimal-scale-round-trip
Closed

[Issue #75] fix: normalise whole-number JSON floats to integers on round-trip#76
McNultyyy wants to merge 17 commits into
mainfrom
mcnultyyy/fix-issue-75-decimal-scale-round-trip

Conversation

@McNultyyy

@McNultyyy McNultyyy commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

Real Cosmos DB uses a JavaScript engine (IEEE 754 doubles). This normalises numbers on round-trip in ways our emulator wasn't replicating:

Input Real Cosmos DB Emulator (before)
1500m (whole-number decimal) 1500 1500.0
0m (zero decimal) 0 0.0
SUM(250, 500) 750 750 (Windows) / 750.0 (Linux) ❌
100.50m 100.5 100.5 ✅ (already correct)

Root Cause

Newtonsoft.Json's EnsureDecimalPlace() serialises whole-number decimal values with a .0 suffix (e.g. 1500m"1500.0"). The emulator stored this literal string and returned it, whereas real Cosmos DB normalises all numbers through its JavaScript engine which converts whole-number floats to integers.

Fix

JsonParseHelpers.cs — added NormalizeNumbers(JToken) called after every JObject.Load / JToken.Load. It traverses the token tree and converts any JTokenType.Float value where d == Math.Truncate(d) to a JValue(long) (integer token). double naturally strips trailing zeros (e.g. 100.50 → 100.5), so fractional values need no special handling.

InMemoryContainer.cs — at the UDF C# handler call site, long args are coerced to double before being passed to the user's handler. JavaScript has no int/float distinction (all numbers are IEEE 754 doubles), so UDF handlers may reasonably cast args as double. The NormalizeNumbers change converts stored whole-number floats to long; this coercion restores the expected double type at the UDF boundary.

The existing NormalizeNumericResult helper (applied to SUM/AVG aggregates) already covered Bug 3 and remains in place.

Tests

8 integration tests in Issue75DecimalScaleTests.cs covering:

  • Whole-number decimal round-trip (1500m"1500")
  • Zero decimal round-trip (0m"0")
  • Fractional decimal trailing-zero stripping (100.50m"100.5")
  • SUM aggregate cross-platform consistency

All 8 tests pass (net8 + net10). Integration test suite: 345/345 pass on both targets. No new unit test failures introduced.

Version

Bumped to 4.0.21.

McNultyyy and others added 16 commits May 18, 2026 23:03
… round-trip (#64)

ExprToString did not wrap TernaryExpression or CoalesceExpression in
parentheses when they appeared as operands of higher-precedence binary,
unary, BETWEEN, IN, or LIKE operators. When the SDK's transformed query
was round-tripped through SimplifySdkQuery → re-parse, the missing
parentheses produced a different AST — causing COUNT(expr) to evaluate
the wrong condition and miscount documents.

Added WrapIfLowPrecedence helper that wraps ternary/coalesce nodes in
parens when they appear in operator positions that would otherwise
re-parse with different associativity.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The EmulatorFlaky trait was blanket-excluding 12 tests from all emulator
runs, creating a regression gap. The root cause was a single test
(ConcurrentReadsOfNonExistent) overwhelming the emulator with 50 parallel
requests.

Instead of excluding the entire class, make the concurrency adaptive:
- 50 concurrent reads for in-memory (fast, no resource constraints)
- 10 concurrent reads for emulator targets (same assertion, lower volume)

Changes:
- Remove [Trait(TestTraits.Target, TestTraits.EmulatorFlaky)] from
  Issue18EdgeCaseIntegrationTests
- Remove EmulatorFlaky constant from TestTraits.cs
- Remove Target!=EmulatorFlaky filter from scripts/run-tests.ps1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Windows emulator can return 404 with substatus 0 during an
intermediate startup state (between 403/1008 and fully ready). This was
causing the EmulatorWarmup tool and EmulatorRetry to treat it as fatal,
failing the parity workflow before tests even ran.

Add 404/0 to the transient error set in both:
- tests/EmulatorWarmup/Program.cs (warmup tool)
- tests/.../EmulatorSession.cs (shared retry helper)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Documents how to publish beta/prerelease and stable packages via the
existing release.yml workflow (tag push convention) for AI/LLM agent
discoverability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
String literal aliases (e.g. SELECT 'Settlement' AS Label, COUNT(1) ...)
returned null on Linux because ProjectAggregateFields treated them as
property paths via SelectToken. On Windows the bug was masked by
ServiceInterop's native aggregate pipeline.

The fix checks for non-identifier SqlExpr nodes in the else branch and
evaluates them via EvaluateSqlExpression, matching the existing pattern
used in the GROUP BY aggregate path.

Covers string, numeric, boolean, and null literal aliases with
integration tests.

Bumps version to 4.0.19.

Closes #67

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…gences

ServiceInterop.dll is only available on Windows, causing fundamentally
different code paths to execute. Adding a windows-latest integration
test job ensures both paths are tested on every PR.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ases-linux' into mcnultyyy/merge-prs-65-66-68
)

In real Cosmos DB, FilterPredicate on patch operations treats missing
properties as null, so 'FROM c WHERE c.prop = null' matches documents
where the property is absent (e.g. due to NullValueHandling.Ignore).

Add treatUndefinedAsNull parameter to the WHERE evaluation chain,
passed as true only from the two FilterPredicate call sites. This
preserves existing query semantics (undefined != null) while fixing
the FilterPredicate behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Queries without ORDER BY now return documents in insertion order,
matching real Cosmos DB behavior. Previously documents were returned
in hash-map order due to ConcurrentDictionary enumeration.

Added insertion-order tracking list to InMemoryContainer that maintains
document position across create, replace, upsert, and delete operations.
GetAllItemsForPartition() now iterates this ordered list instead of the
dictionary directly.

Fixes #72

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comprehensive edge case tests covering:
- Large batch (55 docs) insertion order
- Multi-partition interleaved insertion order
- Repeated replaces preserving position
- Mixed operations sequence (create/delete/replace/upsert)
- SELECT projections maintaining order
- WHERE filter preserving relative order
- TOP and OFFSET/LIMIT pagination order
- Empty container and single document edge cases
- Delete all + recreate ordering
- Upsert-only creates ordering
- DISTINCT VALUE preserving first occurrence order
- COUNT and SUM aggregates not crashing
- Cross-partition vs single-partition order
- Rapid sequential creates order
- ORDER BY overriding insertion order
- Range filter preserving relative order
- Multiple non-consecutive deletes preserving remaining order

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…round-trip

Adds Issue75DecimalScaleTests covering three bugs:
1. Whole-number decimals (1500m) stored as float 1500.0 instead of integer 1500
2. Fractional decimals with trailing zeros (100.50m) losing trailing zero on round-trip
3. SUM aggregate returning 750.0 on Linux vs 750 on Windows (platform inconsistency)

All round-trip tests currently fail. SUM tests pass on Windows but would fail on Linux.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…sue #75)

Real Cosmos DB uses a JavaScript engine (IEEE 754 doubles) which normalises
whole-number JSON floats to integers on round-trip:
  - '1500.0' → 1500  (not 1500.0)
  - '0.0'    → 0     (not 0.0)
  - '100.50' → 100.5 (double strips trailing zero naturally)
  - SUM(250, 500) → 750 (not platform-dependent '750.0')

Changes:
- JsonParseHelpers.cs: add NormalizeNumbers() that converts JTokenType.Float
  values where d == Math.Truncate(d) to JValue(long) after parsing
- InMemoryContainer.cs: apply long→double coercion for UDF args (JavaScript
  has no int/float distinction); fix pre-existing format issues in test files
- src/Directory.Build.props: bump version to 4.0.21

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@McNultyyy
McNultyyy marked this pull request as ready for review May 26, 2026 17:18
@McNultyyy
McNultyyy requested a review from lemonlion as a code owner May 26, 2026 17:18
@McNultyyy McNultyyy changed the title fix: decimal scale not preserved on round-trip and SUM aggregate inconsistency (Issue #75) [Issue #75] fix: normalise whole-number JSON floats to integers on round-trip May 26, 2026
@McNultyyy
McNultyyy marked this pull request as draft May 27, 2026 06:44
Update QueryPlanTests and QueryPlanDeepDiveTests to reflect that single-aggregate
projection queries (e.g. SELECT SUM(...) AS alias FROM c) now have their aggregates
cleared via isSingleAggregateProjectionBypass. This prevents the SDK's
AggregateQueryPipelineStage from crashing on Linux with a missing 'payload' field.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@McNultyyy McNultyyy closed this May 27, 2026
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