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
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`, integers larger than 64 bits, and exponents that overflow a double like `1e400`, instead of emitting invalid or lossy JSON. A row whose `_id` is a decimal or exponent string is matched as that string, so delete and update target the right document. Update the MongoDB plugin to get the fix. (#1813)
- Reading a connection password from a command, 1Password, Vault, or AWS Secrets Manager no longer occasionally returns corrupted output from a race while reading the command's output. (#1841)
- The Structure tab filter and column sort now update the grid instead of leaving stale rows on screen.
- The row details inspector now shows the selected row's values, including JSON, when a column value filter is active, and a JSON or serialized value you open now follows the selected row as you move between rows. (#1837)
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 Double(value)?.isFinite ?? false
}

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
5 changes: 1 addition & 4 deletions Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift
Original file line number Diff line number Diff line change
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
51 changes: 50 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,55 @@ 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 quotes exponents that overflow Double instead of emitting Infinity")
func filterDocumentQuotesOutOfRangeExponent() {
for value in ["1e400", "-1e400", "1.5e400"] {
let doc = builder.buildFilterDocument(
from: [(column: "score", op: "=", value: value)]
)
let parsed = parseFilter(doc)
#expect(parsed?["score"] as? String == value)
}
}

@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
154 changes: 152 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,56 @@ 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("Delete keeps a decimal or exponent _id quoted so a string _id still matches")
func deleteQuotesNonIntegerId() {
let gen = MongoDBStatementGenerator(
collectionName: "users",
columns: ["_id", "name"]
)

for id in ["1.5", "1e3"] {
let change = PluginRowChange(
rowIndex: 0,
type: .delete,
cellChanges: [],
originalRow: [PluginCellValue.text(id), "Alice"]
)

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

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

@Test("Single delete without _id falls back to all-field match")
func singleDeleteNoIdFallback() {
let gen = MongoDBStatementGenerator(
Expand Down Expand Up @@ -760,3 +901,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