Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Import into Table** from a data file window, into an open connection's import sheet.
- **Text Encoding** in a data file's Save As panel.
- `BSONSymbol()` in the MongoDB shell.
- `MinKey`, `MaxKey`, `Code`, `DBRef` and the legacy UUID helpers in MongoDB shell autocomplete.

### Changed

Expand Down Expand Up @@ -120,6 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Large data files opened, filtered, sorted and searched in the background, with progress and Cancel.
- `.json` and `.ndjson` files opened in the Data Files window rather than as a DuckDB connection.
- `$regex` and `$options` objects in MongoDB scripts and **Raw Filter** sent as operator documents, as in mongosh.
- Whole numbers past 2^53 and `-0` stored as doubles by the MongoDB shell, as mongosh stores them.

### Removed

Expand Down Expand Up @@ -229,6 +231,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `NumberDecimal("NaN")` and `NumberDecimal("Infinity")` refused by the MongoDB shell.
- Fields named `__proto__` dropped from documents a MongoDB script writes or reads.
- Error text after a carriage return or line separator left uncommented in Edit View Definition's fallback.
- Whole numbers from 2^63 up, such as `1e20`, refused by the MongoDB shell as a document MongoDB cannot read.
- `NumberLong` given a number past 2^53 storing a different integer in the MongoDB shell.
- Wrong values stored by `NumberInt` and `Timestamp` in the MongoDB shell, such as 12 for `NumberInt("12abc")`.
- `Int32`, `Long`, `Decimal128` and `Timestamp({ t, i })` missing from the MongoDB shell although autocomplete offers them.
- MongoDB scripts stopping on a stored decimal `NaN` or a regular expression JavaScript cannot compile.
- Regular expressions changed when a MongoDB script writes back a document it read.
- Capped MongoDB collection size shown as 0 in DDL.
- **Save** permanently dim on a Custom provider for an OpenAI-compatible server that wants no API key.
- Model list not reloading when the API key changes, leaving the picker empty with no way to retry.
Expand Down
300 changes: 241 additions & 59 deletions Plugins/MongoDBDriverPlugin/MongoScriptPrelude.swift

Large diffs are not rendered by default.

21 changes: 17 additions & 4 deletions TablePro/Core/Autocomplete/Mongo/MongoVocabulary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -349,17 +349,30 @@ enum MongoVocabulary {
("ObjectId", "12-byte ObjectId, from a 24-character hex string"),
("ISODate", "Date from an ISO 8601 string"),
("UUID", "UUID from a 36-character string, BSON binary subtype 4"),
("LegacyJavaUUID", "UUID in the Java driver's legacy byte order, BSON binary subtype 3"),
("LegacyCSharpUUID", "UUID in the C# driver's legacy byte order, BSON binary subtype 3"),
("LegacyPythonUUID", "UUID in the Python driver's legacy byte order, BSON binary subtype 3"),
("JUUID", "LegacyJavaUUID, legacy shell name"),
("CSUUID", "LegacyCSharpUUID, legacy shell name"),
("NUUID", "LegacyCSharpUUID, legacy shell name"),
("PYUUID", "LegacyPythonUUID, legacy shell name"),
("LUUID", "LegacyPythonUUID, legacy shell name"),
("BinData", "Binary value from a subtype and a base64 string"),
("HexData", "Binary value from a subtype and a hex string"),
("Timestamp", "BSON timestamp from { t, i }"),
("Timestamp", "BSON timestamp from (t, i), { t, i } or a Long"),
("Int32", "32-bit integer"),
("Long", "64-bit integer, from a string"),
("Long", "64-bit integer, from a string, a whole number or (low, high)"),
("Double", "64-bit floating point number"),
("Decimal128", "IEEE 754 decimal128, from a string"),
("NumberInt", "32-bit integer, legacy shell name"),
("NumberLong", "64-bit integer, legacy shell name, string argument only"),
("NumberLong", "64-bit integer, legacy shell name"),
("NumberDecimal", "Decimal128, legacy shell name"),
("BSONRegExp", "Regular expression with options")
("BSONRegExp", "Regular expression from a pattern and options i, l, m, s, u, x"),
("BSONSymbol", "Deprecated BSON symbol, read back as a string"),
("MinKey", "Compares lower than every other BSON value"),
("MaxKey", "Compares higher than every other BSON value"),
("Code", "JavaScript code, with an optional scope document"),
("DBRef", "Reference to a document by collection, _id and database")
]

static let shellCommands: [(name: String, detail: String)] = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// MongoVocabularyConstructorTests.swift
// TableProTests
//

import Foundation
import JavaScriptCore
@testable import TablePro
import Testing

/// Autocomplete offered `Int32`, `Long`, `Decimal128` and `BSONRegExp` while the shell defined none
/// of them, so accepting the suggestion ended the statement in a ReferenceError, and it never
/// offered `MinKey`, `Code` or the legacy UUID helpers the shell does define.
struct MongoVocabularyConstructorTests {
private static let shellObjectTypes: Set<String> = ["Cursor", "DB", "DBCollection"]

private func globalNames(of context: JSContext) -> Set<String> {
let names = context.evaluateScript("Object.getOwnPropertyNames(this)")?.toArray() as? [String]
return Set(names ?? [])
}

@Test("Autocomplete offers exactly the value constructors the shell defines")
func offeredConstructorsAreTheShellsOwn() throws {
let plain = try #require(JSContext())
let shell = try MongoScriptContext.make(execute: { _ in #"{"ok":true,"v":"shop"}"# }, emit: { _ in true })

let defined = globalNames(of: shell).subtracting(globalNames(of: plain)).filter { name in
guard name.first?.isUppercase == true, !Self.shellObjectTypes.contains(name) else { return false }
return shell.evaluateScript("typeof \(name)")?.toString() == "function"
}
let offered = Set(MongoVocabulary.bsonConstructors.map(\.name))

#expect(defined.count > 20)
#expect(offered == defined)
}
}
56 changes: 56 additions & 0 deletions TableProTests/Core/MongoDB/MongoScriptPreludeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,62 @@ struct MongoScriptPreludeTests {
#expect(value?.toString() == "507f1f77bcf86cd799439011")
}

@Test(
"EJSON.parse refuses a wrapper its constructor would refuse",
arguments: [
#"{"n": {"$numberLong": "abc"}}"#,
#"{"n": {"$numberLong": "9223372036854775808"}}"#,
#"{"n": {"$numberDecimal": "abc"}}"#,
#"{"n": {"$numberInt": "12abc"}}"#,
#"{"n": {"$numberInt": "2147483648"}}"#,
#"{"n": {"$numberDouble": "abc"}}"#,
#"{"r": {"$regularExpression": {"pattern": "a", "options": "z"}}}"#
]
)
func ejsonParseChecksWrappers(json: String) throws {
let host = RecordingHost()
let context = try makeContext(host)

context.evaluateScript("EJSON.parse(\(Self.javaScriptString(json)))")
#expect(context.exception != nil)
}

@Test("EJSON.parse still reads every well-formed wrapper, NaN and Infinity included")
func ejsonParseReadsWellFormedWrappers() throws {
let host = RecordingHost()
let context = try makeContext(host)
let json = #"{"l": {"$numberLong": "9007199254740993"}, "d": {"$numberDecimal": "NaN"}, "#
+ #""i": {"$numberInt": "-7"}, "f": {"$numberDouble": "-Infinity"}, "#
+ #""r": {"$regularExpression": {"pattern": "^a", "options": "xi"}}}"#

let value = context.evaluateScript("""
var parsed = EJSON.parse(\(Self.javaScriptString(json)));
[String(parsed.l), String(parsed.d), parsed.i, parsed.f, parsed.r.options].join("|")
""")
#expect(context.exception == nil)
#expect(value?.toString() == "9007199254740993|NaN|-7|-Infinity|ix")
}

@Test("A value the server sends is read back as stored, without the parse-time checks")
func serverRepliesAreNotRechecked() throws {
let host = RecordingHost()
host.replies = [
"1",
#"{"docs": [{"_id": 1, "r": {"$regularExpression": {"pattern": "a", "options": "g"}}}], "done": true}"#
]
let context = try makeContext(host)

let value = context.evaluateScript("db.orders.findOne({}).r.options")
#expect(context.exception == nil)
#expect(value?.toString() == "g")
}

private static func javaScriptString(_ text: String) -> String {
let data = (try? JSONSerialization.data(withJSONObject: [text])) ?? Data()
let array = String(data: data, encoding: .utf8) ?? "[]"
return String(array.dropFirst().dropLast())
}

@Test("A write with no document is refused rather than sent as null")
func writesNeedADocument() throws {
let host = RecordingHost()
Expand Down
Loading
Loading