Skip to content

fix(plugin-mongodb): store shell numbers past 2^53 as the doubles they are, define every constructor autocomplete offers, and write stored regexes and decimals back unchanged - #3158

Open
datlechin wants to merge 2 commits into
fix/mongodb-views-and-index-orderfrom
fix/mongodb-shell-number-fidelity
Open

datlechin wants to merge 2 commits into
fix/mongodb-views-and-index-orderfrom
fix/mongodb-shell-number-fidelity

Conversation

@datlechin

Copy link
Copy Markdown
Member

Stacked on #3150, which adds Double(), BSONRegExp(), BSONSymbol() and __doubleText to the same prelude block. Retarget to main once #3150 merges.

Root cause

Three separate defects in the shell prelude (MongoScriptPrelude.swift), all measured against libbson 1.28.1, MongoDB 7.0.43 and mongosh 2.10.0:

  1. Numbers. serializeNumber sent every whole number outside int32 as {"$numberLong": String(value)}. String(value) is JavaScript's shortest round-trip spelling, not an integer:

    • 1e20 went out as "100000000000000000000" and 2^63 as "9223372036854776000", both of which libbson refuses, so insertOne({n: 1e20}) failed with "This is not a document MongoDB can read".
    • 2**62 went out as "4611686018427388000", which is a different integer.
    • -0 went out as int32 0.

    NumberLong(2**62) had the same problem.

  2. Constructors. Autocomplete offered Int32, Long, Decimal128 and BSONRegExp, and the prelude defined none of them, so each ended in a ReferenceError. Several other constructors misbehaved:

    • Timestamp({t, i}) produced a nested timestamp libbson refuses.
    • Timestamp(4294967296, 0) was stored as t = 0.
    • NumberInt used parseInt, so NumberInt("12abc") stored 12 and NumberInt(0.0000005) stored 5.
    • NumberDecimal("NaN") threw.
  3. Read-back. A cursor revived $numberDecimal through NumberDecimal, so a stored decimal NaN threw. It revived $regularExpression through new RegExp, so a stored x or l option or PCRE-only syntax threw SyntaxError partway through forEach. A regex JavaScript could compile was written back as source, which escapes / and spells an empty pattern (?:).

Fix

  • Number rules.
    • A whole number in int32 range is sent as int32, and a larger one up to Number.MAX_SAFE_INTEGER as int64, as before.
    • Past 2^53, a whole number is sent as the double it is. mongosh stores every such number as a double (measured), and past 2^53 a JavaScript number is not an exact integer.
    • -0, NaN, Infinity and fractions are sent as doubles.
  • Int32/NumberInt, Long/NumberLong, Decimal128/NumberDecimal. Each alias pair shares one prototype, so instanceof works either way round.
    • Long goes through BigInt. It is exact from a string, a Long or a bigint. From a number it gives the integer the script holds, so NumberLong(2**62) is 4611686018427387904, as in mongosh. Long(low, high) composes two 32-bit halves.
    • Out-of-range values throw where mongosh wraps or clamps them.
  • Timestamp.
    • Takes (t, i), (t), (), a {t, i} object whose parts are numbers, a Long or a bigint.
    • Each part must be 0 to 4294967295.
    • Everything mongosh refuses is refused here too, and nothing is zeroed silently.
  • Revive.
    • What the server sends is revived from its own text without the constructors' checks: a stored value is already one MongoDB holds, and 100,000 documents revive in 89 ms against 101 ms before.
    • What a script hands EJSON.parse or EJSON.deserialize goes through the same checks as the constructors, so {"$numberLong": "abc"}, an out-of-range $numberInt, a $numberDecimal or $numberDouble that is not a number, and a $regularExpression with an option outside ilmsux throw there, as they do in mongosh, instead of reaching libbson later as a document it cannot read.
    • A double revives as a plain number, as in mongosh.
    • A stored regex revives as a RegExp that carries the server's pattern and options. When JavaScript cannot compile it, it revives as a BSONRegExp. Either way it writes back unchanged.
  • Autocomplete offers exactly the constructors the prelude defines. It gains MinKey, MaxKey, Code, DBRef, BSONSymbol and the legacy UUID helpers, and a test pins the two sets as equal.
  • Docs: a new Values subsection covers the number rules, the constructors, and what === and == give on values read back. A new Limitations line covers Decimal128 precision and exponent range, each with its own remedy.

Verified

  • Test suites: 12 suites, 162 of 162 passing, including the new MongoShellValueTests (7 cases) and MongoVocabularyConstructorTests (1 case). The unchanged prelude fails each of them.
  • Builds: TablePro, MongoDBDriver and AllPlugins all pass.
  • Lint: 0 violations on the four changed Swift files.
  • Docs checks pass.
  • Numbers, live on 7.0.43: insertOne({a: 1e20, b: 9223372036854775807, c: -0, d: 2147483648, e: 5, f: 9007199254740993, j: 9007199254740991}) stored double, double, double (-0), long, int, double and long. mongosh stores the same values the same way except d and j, which it stores as doubles.
  • Read-back, live: find().forEach(d => replaceOne({_id: d._id}, d)) over a document holding a decimal NaN and x, l, (?i), a/b and empty regexes ran without error.
    • $regexFind on the server confirmed that the x option and the (?i) pattern survived the write-back.
    • mongosh on the same document throws SyntaxError: Invalid regular expression: /(?i)a/: Invalid group.
  • Constructors, live: the same insert in TablePro and in mongosh stored identical types and values, for example Long(5, 1) as 4294967301, Timestamp(NumberLong("8589934593")) as {t: 2, i: 1] and BSONRegExp("^a", "mi") with options im.
  • Refusal, live: Timestamp(4294967296, 0) throws Timestamp takes t from 0 to 4294967295 before anything is sent.
  • Codex review, round 1: a P2 that EJSON.parse accepted malformed wrappers once revival skipped the constructors, and a P3 that the Decimal128 limitation gave no remedy for an exponent out of range. Both fixed. ejsonParseChecksWrappers (7 cases) and ejsonParseReadsWellFormedWrappers fail against the round-0 prelude; serverRepliesAreNotRechecked pins that a stored legacy regex option still reads back.
  • Rebased onto fix(plugin-mongodb): list views as views, keep index key order and options, and add db.createView to the shell #3150's current head (aaf1c9545): MongoScriptPreludeTests 34 of 34, MongoVocabularyConstructorTests, MongoShellValueTests and the rest 41 of 41. The app and MongoDBDriver build, lint reports 0 violations, docs checks pass.
  • Performance: 1,000,000 NumberLong("123456789012") calls take 964 ms against 557 ms before, which is the cost of range-checking what the user types.

Deliberately not fixed here

  • Operator documents such as {$type: "binData"} and {$regex, $options}: that is fix(plugin-mongodb): send $type, $regex and $options objects as the operator documents they are written as #3156.
  • Whole doubles written back as int64. A whole double from 2^31 to 2^53, read and written back, becomes int64 where mongosh keeps a double. Sending those numbers as doubles would re-type the bare integers the grid's statement generator writes for int64 fields. This is documented, and Double() keeps a double.
  • Show DDL literals. Show DDL still writes a decimal NaN and a regex as canonical wrappers rather than NumberDecimal("NaN") and BSONRegExp(...).

No UI test: every path needs a connected MongoDB server, and CI has none. The prelude is covered in-process through MongoScriptContext, and the server side by the live checks listed here.

…y are, define every constructor autocomplete offers, and write stored regexes and decimals back unchanged
…r' into fix/mongodb-shell-number-fidelity

# Conflicts:
#	docs/databases/mongodb.mdx

This branch has not been deployed

No deployments
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