diff --git a/CHANGELOG.md b/CHANGELOG.md index 853e2cc97..5c0803902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift b/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift index 88b6ac323..34545cb56 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift @@ -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 { @@ -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 @@ -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) }) } } } diff --git a/Plugins/BigQueryDriverPlugin/BigQueryTypeMapper.swift b/Plugins/BigQueryDriverPlugin/BigQueryTypeMapper.swift index 061a9d2d4..e6d73df79 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryTypeMapper.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryTypeMapper.swift @@ -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 } } @@ -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] { @@ -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 + } +} diff --git a/TableProTests/Plugins/BigQueryTypeMapperTests.swift b/TableProTests/Plugins/BigQueryTypeMapperTests.swift index 3d3e1f73a..f99ffac3f 100644 --- a/TableProTests/Plugins/BigQueryTypeMapperTests.swift +++ b/TableProTests/Plugins/BigQueryTypeMapperTests.swift @@ -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"}]"#) + } +}