Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- MongoDB filters and generated edit statements now quote numeric-looking values that would not round-trip, such as `.5`, `1.`, `+7`, `01`, and integers larger than 64 bits, instead of emitting invalid or lossy JSON.
- Switching schemas on an Oracle connection no longer hangs on an infinite loading spinner. Oracle now switches by schema like BigQuery, the sidebar lists every schema with its tables loading on expand, Oracle queries respect the query timeout setting and reconnect automatically after a timeout, and a schema load that fails shows an error with a Retry button instead of spinning forever. Works with an already-installed Oracle plugin; updating the plugin adds the query timeout enforcement. (#1807)

## [0.55.0] - 2026-07-04
Expand Down
29 changes: 29 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// MongoDBJsonNumber.swift
// MongoDBDriverPlugin
//

import Foundation

enum MongoDBJsonNumber {
private static let regex = try? NSRegularExpression(
pattern: #"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$"#
)

static func isValid(_ value: String) -> Bool {
guard let regex else { return false }
let nsValue = value as NSString
let range = NSRange(location: 0, length: nsValue.length)
guard let match = regex.firstMatch(in: value, range: range), match.range == range else {
return false
}
if isPlainInteger(value) {
return Int64(value) != nil
}
return true
}

private static func isPlainInteger(_ value: String) -> Bool {
!value.contains(".") && !value.contains("e") && !value.contains("E")
}
}
3 changes: 1 addition & 2 deletions Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,7 @@ struct MongoDBQueryBuilder {
private func jsonValue(_ value: String) -> String {
if value == "true" || value == "false" { return value }
if value == "null" { return value }
if Int64(value) != nil { return value }
if Double(value) != nil, value.contains(".") { return value }
if MongoDBJsonNumber.isValid(value) { return value }
return "\"\(Self.escapeJsonString(value))\""
}

Expand Down
9 changes: 3 additions & 6 deletions Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ struct MongoDBStatementGenerator {
}
if isObjectIdString(idValue) {
idValues.append("{\"$oid\": \"\(idValue)\"}")
} else if Int64(idValue) != nil {
} else if MongoDBJsonNumber.isValid(idValue) {
idValues.append(idValue)
} else {
idValues.append("\"\(escapeJsonString(idValue))\"")
Expand Down Expand Up @@ -218,7 +218,7 @@ struct MongoDBStatementGenerator {
if isObjectIdString(idValue) {
return "{\"_id\": {\"$oid\": \"\(idValue)\"}}"
}
if Int64(idValue) != nil {
if MongoDBJsonNumber.isValid(idValue) {
return "{\"_id\": \(idValue)}"
}
return "{\"_id\": \"\(escapeJsonString(idValue))\"}"
Expand Down Expand Up @@ -247,10 +247,7 @@ struct MongoDBStatementGenerator {
if value == "null" {
return "null"
}
if Int64(value) != nil {
return value
}
if Double(value) != nil, value.contains(".") {
if MongoDBJsonNumber.isValid(value) {
return value
}
// JSON object or array
Expand Down
1 change: 1 addition & 0 deletions TableProTests/PluginTestSources/MongoDBJsonNumber.swift
40 changes: 39 additions & 1 deletion TableProTests/Plugins/MongoDBQueryBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
//

import Foundation
import Testing
import TableProPluginKit
import Testing

@Suite("MongoDB Query Builder")
struct MongoDBQueryBuilderTests {
Expand Down Expand Up @@ -366,6 +366,44 @@ struct MongoDBQueryBuilderTests {
#expect(doc.contains("\"price\": 19.99"))
}

@Test("Filter document emits JSON-valid scientific numbers")
func filterDocumentScientificNumber() {
let doc = builder.buildFilterDocument(
from: [(column: "score", op: "=", value: "1.5e-3")]
)
let parsed = parseFilter(doc)
#expect(parsed?["score"] as? Double == 0.0015)
}

@Test("Filter document quotes non-JSON numeric spellings")
func filterDocumentQuotesNonJsonNumericSpellings() {
let values = [".5", "1.", "+7", "01", "NaN", "Infinity"]
for value in values {
let doc = builder.buildFilterDocument(
from: [(column: "score", op: "=", value: value)]
)
let parsed = parseFilter(doc)
#expect(parsed?["score"] as? String == value)
}
}

@Test("Filter document quotes integers that overflow Int64 to preserve precision")
func filterDocumentQuotesInt64Overflow() {
let doc = builder.buildFilterDocument(
from: [(column: "code", op: "=", value: "12345678901234567890")]
)
let parsed = parseFilter(doc)
#expect(parsed?["code"] as? String == "12345678901234567890")
}

@Test("Filter document emits the largest Int64 integer unquoted")
func filterDocumentEmitsMaxInt64() {
let doc = builder.buildFilterDocument(
from: [(column: "code", op: "=", value: "9223372036854775807")]
)
#expect(doc.contains("\"code\": 9223372036854775807"))
}

@Test("Filter document with null literal")
func filterDocumentNullLiteral() {
let doc = builder.buildFilterDocument(
Expand Down
128 changes: 126 additions & 2 deletions TableProTests/Plugins/MongoDBStatementGeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@
//

import Foundation
import Testing
import TableProPluginKit
import Testing

@Suite("MongoDB Statement Generator")
struct MongoDBStatementGeneratorTests {

// MARK: - INSERT

@Test("Simple insert generates insertOne, skipping _id")
Expand Down Expand Up @@ -194,6 +193,98 @@ struct MongoDBStatementGeneratorTests {
#expect(results[0].statement.contains("\"count\": 42"))
}

@Test("Insert emits JSON-valid decimal and exponent numbers")
func insertEmitsJsonValidNumbers() {
let gen = MongoDBStatementGenerator(
collectionName: "users",
columns: ["_id", "decimal", "exponent"]
)

let change = PluginRowChange(
rowIndex: 0,
type: .insert,
cellChanges: [],
originalRow: nil
)

let insertedData: [Int: [PluginCellValue]] = [
0: [nil, "0.5", "1e3"]
]

let results = gen.generateStatements(
from: [change],
insertedRowData: insertedData,
deletedRowIndices: [],
insertedRowIndices: [0]
)

let document = firstArgumentObject(in: results[0].statement)
#expect(document?["decimal"] as? Double == 0.5)
#expect(document?["exponent"] as? Double == 1_000)
}

@Test("Insert quotes non-JSON numeric spellings")
func insertQuotesNonJsonNumericSpellings() {
let gen = MongoDBStatementGenerator(
collectionName: "users",
columns: ["_id", "leadingDecimal", "trailingDecimal", "leadingPlus", "leadingZero"]
)

let change = PluginRowChange(
rowIndex: 0,
type: .insert,
cellChanges: [],
originalRow: nil
)

let insertedData: [Int: [PluginCellValue]] = [
0: [nil, ".5", "1.", "+7", "01"]
]

let results = gen.generateStatements(
from: [change],
insertedRowData: insertedData,
deletedRowIndices: [],
insertedRowIndices: [0]
)

let document = firstArgumentObject(in: results[0].statement)
#expect(document?["leadingDecimal"] as? String == ".5")
#expect(document?["trailingDecimal"] as? String == "1.")
#expect(document?["leadingPlus"] as? String == "+7")
#expect(document?["leadingZero"] as? String == "01")
}

@Test("Insert quotes integers that overflow Int64 but keeps in-range integers numeric")
func insertQuotesInt64Overflow() {
let gen = MongoDBStatementGenerator(
collectionName: "users",
columns: ["_id", "overflow", "maxInt64"]
)

let change = PluginRowChange(
rowIndex: 0,
type: .insert,
cellChanges: [],
originalRow: nil
)

let insertedData: [Int: [PluginCellValue]] = [
0: [nil, "12345678901234567890", "9223372036854775807"]
]

let results = gen.generateStatements(
from: [change],
insertedRowData: insertedData,
deletedRowIndices: [],
insertedRowIndices: [0]
)

let document = firstArgumentObject(in: results[0].statement)
#expect(document?["overflow"] as? String == "12345678901234567890")
#expect(document?["maxInt64"] as? Int64 == 9_223_372_036_854_775_807)
}

@Test("Insert not in insertedRowIndices is skipped")
func insertNotInIndicesSkipped() {
let gen = MongoDBStatementGenerator(
Expand Down Expand Up @@ -505,6 +596,30 @@ struct MongoDBStatementGeneratorTests {
#expect(stmt.contains("\"$in\": [1, 2]"))
}

@Test("Delete quotes an _id that overflows Int64 to preserve precision")
func deleteQuotesInt64OverflowId() {
let gen = MongoDBStatementGenerator(
collectionName: "users",
columns: ["_id", "name"]
)

let change = PluginRowChange(
rowIndex: 0,
type: .delete,
cellChanges: [],
originalRow: ["12345678901234567890", "Alice"]
)

let results = gen.generateStatements(
from: [change],
insertedRowData: [:],
deletedRowIndices: [0],
insertedRowIndices: []
)

#expect(results[0].statement.contains("{\"_id\": \"12345678901234567890\"}"))
}

@Test("Single delete without _id falls back to all-field match")
func singleDeleteNoIdFallback() {
let gen = MongoDBStatementGenerator(
Expand Down Expand Up @@ -760,3 +875,12 @@ struct MongoDBStatementGeneratorTests {
#expect(results[0].statement.contains("\"tags\": [1, 2, 3]"))
}
}

private func firstArgumentObject(in statement: String) -> [String: Any]? {
guard let openParen = statement.firstIndex(of: "("),
let closeParen = statement.lastIndex(of: ")"),
openParen < closeParen else { return nil }
let json = String(statement[statement.index(after: openParen) ..< closeParen])
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}
Loading