Skip to content

Commit 4cb7e58

Browse files
committed
fix: add explicit access control, preserve decimal precision, trim JSON whitespace
1 parent 9ef2b44 commit 4cb7e58

2 files changed

Lines changed: 77 additions & 6 deletions

File tree

TablePro/Core/Utilities/SQL/JsonRowConverter.swift

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55

66
import Foundation
77

8-
struct JsonRowConverter {
9-
let columns: [String]
10-
let columnTypes: [ColumnType]
8+
internal struct JsonRowConverter {
9+
internal let columns: [String]
10+
internal let columnTypes: [ColumnType]
1111

1212
private static let maxRows = 50_000
1313

@@ -96,12 +96,67 @@ struct JsonRowConverter {
9696
}
9797

9898
private func formatDecimal(_ value: String) -> String {
99+
// Emit verbatim if already a valid JSON number — preserves full database precision
100+
if isValidJsonNumber(value) {
101+
return value
102+
}
103+
// Fallback for non-standard formats (e.g., "1.0E5" with leading +)
99104
if let doubleVal = Double(value), !doubleVal.isInfinite, !doubleVal.isNaN {
100-
return String(format: "%g", doubleVal)
105+
return String(doubleVal)
101106
}
102107
return quotedEscaped(value)
103108
}
104109

110+
/// Checks whether a string conforms to JSON number grammar (RFC 8259 §6)
111+
private func isValidJsonNumber(_ value: String) -> Bool {
112+
let scalars = value.unicodeScalars
113+
var iter = scalars.makeIterator()
114+
guard var ch = iter.next() else { return false }
115+
116+
// Optional leading minus
117+
if ch == "-" { guard let next = iter.next() else { return false }; ch = next }
118+
119+
// Integer part: "0" or [1-9][0-9]*
120+
guard ch >= "0" && ch <= "9" else { return false }
121+
if ch == "0" {
122+
// "0" must not be followed by another digit
123+
if let next = iter.next() { ch = next } else { return true }
124+
} else {
125+
while true {
126+
guard let next = iter.next() else { return true }
127+
ch = next
128+
guard ch >= "0" && ch <= "9" else { break }
129+
}
130+
}
131+
132+
// Optional fractional part
133+
if ch == "." {
134+
guard let next = iter.next(), next >= "0" && next <= "9" else { return false }
135+
while true {
136+
guard let next = iter.next() else { return true }
137+
ch = next
138+
guard ch >= "0" && ch <= "9" else { break }
139+
}
140+
}
141+
142+
// Optional exponent
143+
if ch == "e" || ch == "E" {
144+
guard var next = iter.next() else { return false }
145+
if next == "+" || next == "-" {
146+
guard let signed = iter.next() else { return false }
147+
next = signed
148+
}
149+
guard next >= "0" && next <= "9" else { return false }
150+
for remaining in IteratorSequence(iter) {
151+
guard remaining >= "0" && remaining <= "9" else { return false }
152+
}
153+
} else {
154+
return false // Unexpected trailing character
155+
}
156+
157+
return true
158+
}
159+
105160
private func formatBoolean(_ value: String) -> String {
106161
switch value.lowercased() {
107162
case "true", "1", "yes", "on":
@@ -114,12 +169,13 @@ struct JsonRowConverter {
114169
}
115170

116171
private func formatJson(_ value: String) -> String {
117-
guard let data = value.data(using: .utf8) else {
172+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
173+
guard let data = trimmed.data(using: .utf8) else {
118174
return quotedEscaped(value)
119175
}
120176
do {
121177
_ = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
122-
return value
178+
return trimmed
123179
} catch {
124180
return quotedEscaped(value)
125181
}

TableProTests/Core/Utilities/JsonRowConverterTests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ struct JsonRowConverterTests {
5656
#expect(!result.contains("\"3.14\""))
5757
}
5858

59+
@Test("Decimal preserves full precision for high-precision values")
60+
func decimalPrecision() {
61+
let converter = makeConverter(columns: ["amount"], columnTypes: [.decimal(rawType: nil)])
62+
let result = converter.generateJson(rows: [["123456.789"]])
63+
#expect(result.contains(": 123456.789"))
64+
}
65+
5966
@Test("Decimal infinity and NaN produce quoted strings")
6067
func decimalInfinityNaN() {
6168
let converter = makeConverter(columns: ["a", "b"], columnTypes: [.decimal(rawType: nil), .decimal(rawType: nil)])
@@ -112,6 +119,14 @@ struct JsonRowConverterTests {
112119
#expect(result.contains("\"{broken\""))
113120
}
114121

122+
@Test("JSON column with trailing whitespace is trimmed before embedding")
123+
func jsonColumnTrimmed() {
124+
let converter = makeConverter(columns: ["data"], columnTypes: [.json(rawType: nil)])
125+
let result = converter.generateJson(rows: [["{\"k\":1}\n"]])
126+
#expect(result.contains(": {\"k\":1}"))
127+
#expect(!result.contains(": {\"k\":1}\n\n"))
128+
}
129+
115130
// MARK: - String escaping
116131

117132
@Test("Text with double quotes is escaped")

0 commit comments

Comments
 (0)