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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- The AI chat panel stays inside the right panel when you drag it narrow. The model name, tool names, and code block headers now truncate, long chat messages and code wrap, wide tables scroll inside their own box, and the composer text follows the panel width instead of running under the editor. (#1956)
- BigQuery `REPEATED` columns (including repeated `STRUCT`) no longer show every element as `null`. (#1963)
- BigQuery `STRUCT` cells now show nested structs and arrays as real JSON instead of escaped text, and quotes, backslashes, tabs, and newlines inside values are escaped correctly. (#1963)

## [0.60.1] - 2026-07-25

Expand Down
26 changes: 13 additions & 13 deletions Plugins/BigQueryDriverPlugin/BigQueryConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ internal enum BQCellValue: Codable, Sendable {
case array([BQCellValue])

struct BQRecordValue: Codable, Sendable {
let f: [BQQueryResponse.BQCell]?
let f: [BQQueryResponse.BQCell]
}

init(from decoder: Decoder) throws {
Expand All @@ -206,16 +206,16 @@ internal enum BQCellValue: Codable, Sendable {
self = .null
return
}
if let str = try? container.decode(String.self) {
self = .string(str)
if let string = try? container.decode(String.self) {
self = .string(string)
return
}
if let record = try? container.decode(BQRecordValue.self) {
self = .record(record)
if let cells = try? container.decode([BQQueryResponse.BQCell].self) {
self = .array(cells.map { $0.v ?? .null })
return
}
if let array = try? container.decode([BQCellValue].self) {
self = .array(array)
if let record = try? container.decode(BQRecordValue.self) {
self = .record(record)
return
}
self = .null
Expand All @@ -224,14 +224,14 @@ internal enum BQCellValue: Codable, Sendable {
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let s):
try container.encode(s)
case .string(let string):
try container.encode(string)
case .null:
try container.encodeNil()
case .record(let r):
try container.encode(r)
case .array(let a):
try container.encode(a)
case .record(let record):
try container.encode(record)
case .array(let values):
try container.encode(values.map { BQQueryResponse.BQCell(v: $0) })
}
}
}
Expand Down
142 changes: 87 additions & 55 deletions Plugins/BigQueryDriverPlugin/BigQueryTypeMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,44 +41,49 @@ internal struct BigQueryTypeMapper {
private static func convertCellValue(_ value: BQCellValue?, field: BQTableFieldSchema) -> String? {
guard let value else { return nil }

let isRepeated = field.mode?.uppercased() == "REPEATED"

switch value {
case .null:
return nil

case .string(let str):
if isRepeated {
return "[\(str)]"
}
return convertScalarString(str, type: field.type)

case .record(let record):
if let subFields = field.fields, let cells = record.f {
let subRow = flattenRow(cells: cells, fields: subFields)
return structToJson(subRow, fields: subFields)
}
return nil
case .record, .array:
return jsonValue(for: value, field: field)?.serialized
}
}

private static func jsonValue(for value: BQCellValue, field: BQTableFieldSchema) -> BQJsonValue? {
switch value {
case .null:
return .null

case .string(let str):
guard let scalar = convertScalarString(str, type: field.type) else { return .null }
return isJsonLiteral(type: field.type) ? .literal(scalar) : .text(scalar)

case .array(let items):
let converted = items.map { item -> String in
switch item {
case .null:
return "null"
case .string(let s):
let converted = convertScalarString(s, type: field.type) ?? "null"
return jsonQuoteIfNeeded(converted, type: field.type)
case .record(let record):
if let subFields = field.fields, let cells = record.f {
let subRow = flattenRow(cells: cells, fields: subFields)
return structToJson(subRow, fields: subFields) ?? "null"
}
return "null"
case .array:
return "[]"
return .array(items.map { jsonValue(for: $0, field: field) ?? .null })

case .record(let record):
guard let subFields = field.fields else { return nil }
let members = subFields.enumerated().map { index, subField -> BQJsonValue.Member in
let cell: BQCellValue? = index < record.f.count ? record.f[index].v : nil
guard let cell, let json = jsonValue(for: cell, field: subField) else {
return BQJsonValue.Member(name: subField.name, value: .null)
}
return BQJsonValue.Member(name: subField.name, value: json)
}
return "[\(converted.joined(separator: ","))]"
return .object(members)
}
}

private static func isJsonLiteral(type: String) -> Bool {
switch type.uppercased() {
case "INT64", "FLOAT64", "NUMERIC", "BIGNUMERIC", "BOOLEAN", "BOOL":
return true
default:
return false
}
}

Expand Down Expand Up @@ -112,34 +117,6 @@ internal struct BigQueryTypeMapper {
}
}

private static func jsonQuoteIfNeeded(_ value: String, type: String) -> String {
let upper = type.uppercased()
if upper == "INT64" || upper == "FLOAT64" || upper == "NUMERIC" ||
upper == "BIGNUMERIC" || upper == "BOOLEAN" || upper == "BOOL"
{
return value
}
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}

private static func structToJson(_ row: [String?], fields: [BQTableFieldSchema]) -> String? {
var pairs: [String] = []
for (index, field) in fields.enumerated() {
let value = index < row.count ? row[index] : nil
let key = "\"\(field.name)\""
if let value {
let jsonVal = jsonQuoteIfNeeded(value, type: field.type)
pairs.append("\(key):\(jsonVal)")
} else {
pairs.append("\(key):null")
}
}
return "{\(pairs.joined(separator: ","))}"
}

// MARK: - Column Type Names

static func columnTypeNames(from schema: BQTableSchema) -> [String] {
Expand Down Expand Up @@ -178,3 +155,58 @@ internal struct BigQueryTypeMapper {
}
}
}

private enum BQJsonValue {
case null
case literal(String)
case text(String)
case array([BQJsonValue])
case object([Member])

struct Member {
let name: String
let value: BQJsonValue
}

var serialized: String {
switch self {
case .null:
return "null"
case .literal(let value):
return value
case .text(let value):
return "\"\(BQJsonValue.escaped(value))\""
case .array(let items):
return "[\(items.map(\.serialized).joined(separator: ","))]"
case .object(let members):
let pairs = members.map { "\"\(BQJsonValue.escaped($0.name))\":\($0.value.serialized)" }
return "{\(pairs.joined(separator: ","))}"
}
}

private static func escaped(_ text: String) -> String {
var result = ""
result.reserveCapacity((text as NSString).length)
for scalar in text.unicodeScalars {
switch scalar {
case "\"":
result.append("\\\"")
case "\\":
result.append("\\\\")
case "\n":
result.append("\\n")
case "\r":
result.append("\\r")
case "\t":
result.append("\\t")
default:
if scalar.value < 0x20 {
result.append(String(format: "\\u%04x", scalar.value))
} else {
result.unicodeScalars.append(scalar)
}
}
}
return result
}
}
143 changes: 143 additions & 0 deletions TableProTests/Plugins/BigQueryTypeMapperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,146 @@ struct BigQueryTypeMapperRowTests {
#expect(BigQueryTypeMapper.flattenRows(from: resp, schema: schema).isEmpty)
}
}

private func decodeResponse(_ json: String) throws -> BQQueryResponse {
try JSONDecoder().decode(BQQueryResponse.self, from: Data(json.utf8))
}

private func firstCell(_ json: String, schema: BQTableSchema) throws -> PluginCellValue {
let rows = BigQueryTypeMapper.flattenRows(from: try decodeResponse(json), schema: schema)
return try #require(rows.first?.first)
}

@Suite("BigQueryTypeMapper - Raw JSON Decoding")
struct BigQueryTypeMapperJSONDecodingTests {
@Test("REPEATED STRING unwraps each wrapped array element")
func repeatedStringFromRawJSON() throws {
let schema = BQTableSchema(fields: [field("tags", "STRING", mode: "REPEATED")])
let json = #"""
{"rows": [{"f": [{"v": [{"v": "red"}, {"v": "blue"}]}]}], "totalRows": "1"}
"""#
let value = try firstCell(json, schema: schema)
#expect(value.asText == #"["red","blue"]"#)
}

@Test("REPEATED INT64 emits JSON numbers, not quoted strings")
func repeatedIntegerFromRawJSON() throws {
let schema = BQTableSchema(fields: [field("scores", "INT64", mode: "REPEATED")])
let json = #"""
{"rows": [{"f": [{"v": [{"v": "1"}, {"v": "2"}]}]}], "totalRows": "1"}
"""#
let value = try firstCell(json, schema: schema)
#expect(value.asText == "[1,2]")
}

@Test("REPEATED RECORD unwraps each nested struct element")
func repeatedRecordFromRawJSON() throws {
let schema = BQTableSchema(fields: [
field("items", "RECORD", mode: "REPEATED", fields: [field("name", "STRING")])
])
let json = #"""
{"rows": [{"f": [{"v": [{"v": {"f": [{"v": "a"}]}}, {"v": {"f": [{"v": "b"}]}}]}]}], "totalRows": "1"}
"""#
let value = try firstCell(json, schema: schema)
#expect(value.asText == #"[{"name":"a"},{"name":"b"}]"#)
}

@Test("Empty REPEATED column renders an empty JSON array")
func emptyRepeatedFromRawJSON() throws {
let schema = BQTableSchema(fields: [field("tags", "STRING", mode: "REPEATED")])
let json = #"""
{"rows": [{"f": [{"v": []}]}], "totalRows": "1"}
"""#
let value = try firstCell(json, schema: schema)
#expect(value.asText == "[]")
}

@Test("Null array element renders as JSON null, not a dropped element")
func nullElementFromRawJSON() throws {
let schema = BQTableSchema(fields: [field("tags", "STRING", mode: "REPEATED")])
let json = #"""
{"rows": [{"f": [{"v": [{"v": "red"}, {"v": null}]}]}], "totalRows": "1"}
"""#
let value = try firstCell(json, schema: schema)
#expect(value.asText == #"["red",null]"#)
}

@Test("Non-repeated STRUCT still decodes as a record")
func scalarRecordFromRawJSON() throws {
let schema = BQTableSchema(fields: [
field("addr", "RECORD", fields: [field("city", "STRING"), field("zip", "INT64")])
])
let json = #"""
{"rows": [{"f": [{"v": {"f": [{"v": "NYC"}, {"v": "10001"}]}}]}], "totalRows": "1"}
"""#
let value = try firstCell(json, schema: schema)
#expect(value.asText == #"{"city":"NYC","zip":10001}"#)
}

@Test("STRUCT with a null member keeps the member as JSON null")
func recordWithNullMemberFromRawJSON() throws {
let schema = BQTableSchema(fields: [
field("addr", "RECORD", fields: [field("city", "STRING"), field("zip", "INT64")])
])
let json = #"""
{"rows": [{"f": [{"v": {"f": [{"v": "NYC"}, {"v": null}]}}]}], "totalRows": "1"}
"""#
let value = try firstCell(json, schema: schema)
#expect(value.asText == #"{"city":"NYC","zip":null}"#)
}

@Test("REPEATED member of a STRUCT nests as a JSON array")
func recordWithRepeatedMemberFromRawJSON() throws {
let schema = BQTableSchema(fields: [
field("post", "RECORD", fields: [field("tags", "STRING", mode: "REPEATED")])
])
let json = #"""
{"rows": [{"f": [{"v": {"f": [{"v": [{"v": "a"}, {"v": "b"}]}]}}]}], "totalRows": "1"}
"""#
let value = try firstCell(json, schema: schema)
#expect(value.asText == #"{"tags":["a","b"]}"#)
}

@Test("Nested STRUCT nests as a JSON object")
func nestedRecordFromRawJSON() throws {
let schema = BQTableSchema(fields: [
field("post", "RECORD", fields: [field("addr", "RECORD", fields: [field("city", "STRING")])])
])
let json = #"""
{"rows": [{"f": [{"v": {"f": [{"v": {"f": [{"v": "NYC"}]}}]}}]}], "totalRows": "1"}
"""#
let value = try firstCell(json, schema: schema)
#expect(value.asText == #"{"addr":{"city":"NYC"}}"#)
}

@Test("String elements escape quotes, backslashes, and control characters")
func escapedStringElementsFromRawJSON() throws {
let schema = BQTableSchema(fields: [field("tags", "STRING", mode: "REPEATED")])
let json = #"""
{"rows": [{"f": [{"v": [{"v": "he said \"hi\"\npath C:\\tmp\tend"}]}]}], "totalRows": "1"}
"""#
let text = try #require(try firstCell(json, schema: schema).asText)
#expect(text == #"["he said \"hi\"\npath C:\\tmp\tend"]"#)
let reparsed = try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String]
#expect(reparsed == ["he said \"hi\"\npath C:\\tmp\tend"])
}

@Test("Arrays survive a Codable round trip")
func arrayRoundTripsThroughCodable() throws {
let schema = BQTableSchema(fields: [
field("items", "RECORD", mode: "REPEATED", fields: [field("name", "STRING")])
])
let json = #"""
{"rows": [{"f": [{"v": [{"v": {"f": [{"v": "a"}]}}, {"v": {"f": [{"v": "b"}]}}]}]}], "totalRows": "1"}
"""#
let decoded = try decodeResponse(json)
let reencoded = try JSONEncoder().encode(decoded)
let roundTripped = try JSONDecoder().decode(BQQueryResponse.self, from: reencoded)
#expect(
BigQueryTypeMapper.flattenRows(from: roundTripped, schema: schema)
== BigQueryTypeMapper.flattenRows(from: decoded, schema: schema)
)
#expect(BigQueryTypeMapper.flattenRows(from: roundTripped, schema: schema)[0][0].asText
== #"[{"name":"a"},{"name":"b"}]"#)
}
}
Loading