From edad072a41d370d4be51800d0a8ef59fe604a359 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 02:24:59 +0100 Subject: [PATCH] refactor: decode sqlite rows by name --- Sources/IMsgCore/MessageStore+Chats.swift | 55 +++--- Sources/IMsgCore/MessageStore+Helpers.swift | 6 +- .../MessageStore+HistoryMetadata.swift | 46 +++-- Sources/IMsgCore/MessageStore+Messages.swift | 186 +++++++++--------- .../MessageStore+ReactionEvents.swift | 33 ++-- Sources/IMsgCore/MessageStore+SQLRow.swift | 26 +++ .../IMsgCore/MessageStore+SentMessages.swift | 28 +-- Sources/IMsgCore/MessageStore.swift | 43 ++-- Tests/IMsgCoreTests/MessageStoreTests.swift | 13 ++ 9 files changed, 252 insertions(+), 184 deletions(-) create mode 100644 Sources/IMsgCore/MessageStore+SQLRow.swift diff --git a/Sources/IMsgCore/MessageStore+Chats.swift b/Sources/IMsgCore/MessageStore+Chats.swift index 52159d6..29c6b8e 100644 --- a/Sources/IMsgCore/MessageStore+Chats.swift +++ b/Sources/IMsgCore/MessageStore+Chats.swift @@ -10,8 +10,9 @@ extension MessageStore { let sql: String if hasChatMessageJoinMessageDateColumn { sql = """ - SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, - c.service_name, MAX(cmj.message_date) AS last_date, + SELECT c.ROWID AS chat_rowid, IFNULL(c.display_name, c.chat_identifier) AS name, + c.chat_identifier AS chat_identifier, c.service_name AS service_name, + MAX(cmj.message_date) AS last_date, \(accountIDColumn) AS account_id, \(accountLoginColumn) AS account_login, \(lastAddressedHandleColumn) AS last_addressed_handle @@ -23,8 +24,9 @@ extension MessageStore { """ } else { sql = """ - SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, - c.service_name, MAX(m.date) AS last_date, + SELECT c.ROWID AS chat_rowid, IFNULL(c.display_name, c.chat_identifier) AS name, + c.chat_identifier AS chat_identifier, c.service_name AS service_name, + MAX(m.date) AS last_date, \(accountIDColumn) AS account_id, \(accountLoginColumn) AS account_login, \(lastAddressedHandleColumn) AS last_addressed_handle @@ -38,17 +40,18 @@ extension MessageStore { } return try withConnection { db in var chats: [Chat] = [] - for row in try db.prepare(sql, limit) { + let rows = try db.prepareRowIterator(sql, bindings: [limit]) + while let row = try rows.failableNext() { chats.append( Chat( - id: int64Value(row[0]) ?? 0, - identifier: stringValue(row[2]), - name: stringValue(row[1]), - service: stringValue(row[3]), - lastMessageAt: appleDate(from: int64Value(row[4])), - accountID: stringValue(row[5]).nilIfEmpty, - accountLogin: stringValue(row[6]).nilIfEmpty, - lastAddressedHandle: stringValue(row[7]).nilIfEmpty + id: try int64Value(row, "chat_rowid") ?? 0, + identifier: try stringValue(row, "chat_identifier"), + name: try stringValue(row, "name"), + service: try stringValue(row, "service_name"), + lastMessageAt: try appleDate(from: int64Value(row, "last_date")), + accountID: try stringValue(row, "account_id").nilIfEmpty, + accountLogin: try stringValue(row, "account_login").nilIfEmpty, + lastAddressedHandle: try stringValue(row, "last_addressed_handle").nilIfEmpty )) } return chats @@ -61,7 +64,7 @@ extension MessageStore { let lastAddressedHandleColumn = hasChatLastAddressedHandleColumn ? "IFNULL(c.last_addressed_handle, '')" : "''" let sql = """ - SELECT c.ROWID, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid, + SELECT c.ROWID AS chat_rowid, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid, IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service, \(accountIDColumn) AS account_id, \(accountLoginColumn) AS account_login, @@ -71,16 +74,17 @@ extension MessageStore { LIMIT 1 """ return try withConnection { db in - for row in try db.prepare(sql, chatID) { + let rows = try db.prepareRowIterator(sql, bindings: [chatID]) + while let row = try rows.failableNext() { return ChatInfo( - id: int64Value(row[0]) ?? 0, - identifier: stringValue(row[1]), - guid: stringValue(row[2]), - name: stringValue(row[3]), - service: stringValue(row[4]), - accountID: stringValue(row[5]).nilIfEmpty, - accountLogin: stringValue(row[6]).nilIfEmpty, - lastAddressedHandle: stringValue(row[7]).nilIfEmpty + id: try int64Value(row, "chat_rowid") ?? 0, + identifier: try stringValue(row, "identifier"), + guid: try stringValue(row, "guid"), + name: try stringValue(row, "name"), + service: try stringValue(row, "service"), + accountID: try stringValue(row, "account_id").nilIfEmpty, + accountLogin: try stringValue(row, "account_login").nilIfEmpty, + lastAddressedHandle: try stringValue(row, "last_addressed_handle").nilIfEmpty ) } return nil @@ -98,8 +102,9 @@ extension MessageStore { return try withConnection { db in var results: [String] = [] var seen = Set() - for row in try db.prepare(sql, chatID) { - let handle = stringValue(row[0]) + let rows = try db.prepareRowIterator(sql, bindings: [chatID]) + while let row = try rows.failableNext() { + let handle = try stringValue(row, "id") if handle.isEmpty { continue } if seen.insert(handle).inserted { results.append(handle) diff --git a/Sources/IMsgCore/MessageStore+Helpers.swift b/Sources/IMsgCore/MessageStore+Helpers.swift index 2bdf1fe..2cfe1f3 100644 --- a/Sources/IMsgCore/MessageStore+Helpers.swift +++ b/Sources/IMsgCore/MessageStore+Helpers.swift @@ -11,10 +11,10 @@ extension MessageStore { static func tableColumns(connection: Connection, table: String) -> Set { do { - let rows = try connection.prepare("PRAGMA table_info(\(table))") + let rows = try connection.prepareRowIterator("PRAGMA table_info(\(table))") var columns = Set() - for row in rows { - if let name = row[1] as? String { + while let row = try rows.failableNext() { + if let name = try row.get(Expression("name")) { columns.insert(name.lowercased()) } } diff --git a/Sources/IMsgCore/MessageStore+HistoryMetadata.swift b/Sources/IMsgCore/MessageStore+HistoryMetadata.swift index b0435e6..d45ebaa 100644 --- a/Sources/IMsgCore/MessageStore+HistoryMetadata.swift +++ b/Sources/IMsgCore/MessageStore+HistoryMetadata.swift @@ -18,7 +18,9 @@ extension MessageStore { let batch = Array(uniqueIDs[start.. 3006)" : "" var sql = """ - SELECT m.ROWID, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service, + SELECT m.ROWID AS message_rowid, m.handle_id AS handle_id, h.id AS sender, + IFNULL(m.text, '') AS text, m.date AS date, m.is_from_me AS is_from_me, + m.service AS service, \(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id, \(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type, (SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments, @@ -93,27 +95,28 @@ extension MessageStore { sql += " ORDER BY m.date DESC LIMIT ?" bindings.append(limit) let columns = MessageRowColumns( - rowID: 0, + rowID: "message_rowid", chatID: nil, - handleID: 1, - sender: 2, - text: 3, - date: 4, - isFromMe: 5, - service: 6, - isAudioMessage: 7, - destinationCallerID: 8, - guid: 9, - associatedGUID: 10, - associatedType: 11, - attachments: 12, - body: 13, - threadOriginatorGUID: 14 + handleID: "handle_id", + sender: "sender", + text: "text", + date: "date", + isFromMe: "is_from_me", + service: "service", + isAudioMessage: "is_audio_message", + destinationCallerID: "destination_caller_id", + guid: "guid", + associatedGUID: "associated_guid", + associatedType: "associated_type", + attachments: "attachments", + body: "body", + threadOriginatorGUID: "thread_originator_guid" ) return try withConnection { db in var messages: [Message] = [] - for row in try db.prepare(sql, bindings) { + let rows = try db.prepareRowIterator(sql, bindings: bindings) + while let row = try rows.failableNext() { let decoded = try decodeMessageRow(row, columns: columns, fallbackChatID: chatID) let replyToGUID = replyToGUID( associatedGuid: decoded.associatedGUID, @@ -181,7 +184,9 @@ extension MessageStore { } } var sql = """ - SELECT m.ROWID, cmj.chat_id, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service, + SELECT m.ROWID AS message_rowid, cmj.chat_id AS chat_id, m.handle_id AS handle_id, + h.id AS sender, IFNULL(m.text, '') AS text, m.date AS date, + m.is_from_me AS is_from_me, m.service AS service, \(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id, \(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type, (SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments, @@ -201,33 +206,32 @@ extension MessageStore { sql += " ORDER BY m.ROWID ASC LIMIT ?" bindings.append(limit) let columns = MessageRowColumns( - rowID: 0, - chatID: 1, - handleID: 2, - sender: 3, - text: 4, - date: 5, - isFromMe: 6, - service: 7, - isAudioMessage: 8, - destinationCallerID: 9, - guid: 10, - associatedGUID: 11, - associatedType: 12, - attachments: 13, - body: 14, - threadOriginatorGUID: 15 + rowID: "message_rowid", + chatID: "chat_id", + handleID: "handle_id", + sender: "sender", + text: "text", + date: "date", + isFromMe: "is_from_me", + service: "service", + isAudioMessage: "is_audio_message", + destinationCallerID: "destination_caller_id", + guid: "guid", + associatedGUID: "associated_guid", + associatedType: "associated_type", + attachments: "attachments", + body: "body", + threadOriginatorGUID: "thread_originator_guid" ) - let balloonBundleIDIndex = 16 - return try withConnection { db in var messages: [Message] = [] let urlBalloonProvider = "com.apple.messages.URLBalloonProvider" - for row in try db.prepare(sql, bindings) { + let rows = try db.prepareRowIterator(sql, bindings: bindings) + while let row = try rows.failableNext() { let decoded = try decodeMessageRow(row, columns: columns, fallbackChatID: chatID) - let balloonBundleID = stringValue(row[balloonBundleIDIndex]) + let balloonBundleID = try stringValue(row, "balloon_bundle_id") if balloonBundleID == urlBalloonProvider, shouldSkipURLBalloonDuplicate( chatID: decoded.chatID, @@ -296,7 +300,9 @@ extension MessageStore { let threadOriginatorColumn = hasThreadOriginatorGUIDColumn ? "m.thread_originator_guid" : "NULL" var sql = """ - SELECT m.ROWID, cmj.chat_id, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service, + SELECT m.ROWID AS message_rowid, cmj.chat_id AS chat_id, m.handle_id AS handle_id, + h.id AS sender, IFNULL(m.text, '') AS text, m.date AS date, + m.is_from_me AS is_from_me, m.service AS service, \(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id, \(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type, (SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments, @@ -317,26 +323,27 @@ extension MessageStore { sql += " ORDER BY m.date DESC, m.ROWID DESC LIMIT 1" let columns = MessageRowColumns( - rowID: 0, - chatID: 1, - handleID: 2, - sender: 3, - text: 4, - date: 5, - isFromMe: 6, - service: 7, - isAudioMessage: 8, - destinationCallerID: 9, - guid: 10, - associatedGUID: 11, - associatedType: 12, - attachments: 13, - body: 14, - threadOriginatorGUID: 15 + rowID: "message_rowid", + chatID: "chat_id", + handleID: "handle_id", + sender: "sender", + text: "text", + date: "date", + isFromMe: "is_from_me", + service: "service", + isAudioMessage: "is_audio_message", + destinationCallerID: "destination_caller_id", + guid: "guid", + associatedGUID: "associated_guid", + associatedType: "associated_type", + attachments: "attachments", + body: "body", + threadOriginatorGUID: "thread_originator_guid" ) return try withConnection { db in - guard let row = try db.prepare(sql, bindings).makeIterator().next() else { return nil } + let rows = try db.prepareRowIterator(sql, bindings: bindings) + guard let row = try rows.failableNext() else { return nil } let decoded = try decodeMessageRow(row, columns: columns, fallbackChatID: chatID) let replyToGUID = replyToGUID( associatedGuid: decoded.associatedGUID, @@ -365,26 +372,27 @@ extension MessageStore { } private func decodeMessageRow( - _ row: [Binding?], + _ row: Row, columns: MessageRowColumns, fallbackChatID: Int64? ) throws -> DecodedMessageRow { - let rowID = int64Value(row[columns.rowID]) ?? 0 - let resolvedChatID = columns.chatID.flatMap { int64Value(row[$0]) } ?? fallbackChatID ?? 0 - let handleID = int64Value(row[columns.handleID]) - let sender = stringValue(row[columns.sender]) - let text = stringValue(row[columns.text]) - let date = appleDate(from: int64Value(row[columns.date])) - let isFromMe = boolValue(row[columns.isFromMe]) - let service = stringValue(row[columns.service]) - let isAudioMessage = boolValue(row[columns.isAudioMessage]) - let destinationCallerID = stringValue(row[columns.destinationCallerID]) - let guid = stringValue(row[columns.guid]) - let associatedGUID = stringValue(row[columns.associatedGUID]) - let associatedType = intValue(row[columns.associatedType]) - let attachments = intValue(row[columns.attachments]) ?? 0 - let body = dataValue(row[columns.body]) - let threadOriginatorGUID = stringValue(row[columns.threadOriginatorGUID]) + let rowID = try int64Value(row, columns.rowID) ?? 0 + let resolvedChatID = + try columns.chatID.flatMap { try int64Value(row, $0) } ?? fallbackChatID ?? 0 + let handleID = try int64Value(row, columns.handleID) + let sender = try stringValue(row, columns.sender) + let text = try stringValue(row, columns.text) + let date = try appleDate(from: int64Value(row, columns.date)) + let isFromMe = try boolValue(row, columns.isFromMe) + let service = try stringValue(row, columns.service) + let isAudioMessage = try boolValue(row, columns.isAudioMessage) + let destinationCallerID = try stringValue(row, columns.destinationCallerID) + let guid = try stringValue(row, columns.guid) + let associatedGUID = try stringValue(row, columns.associatedGUID) + let associatedType = try intValue(row, columns.associatedType) + let attachments = try intValue(row, columns.attachments) ?? 0 + let body = try dataValue(row, columns.body) + let threadOriginatorGUID = try stringValue(row, columns.threadOriginatorGUID) var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text if isAudioMessage, let transcription = try audioTranscription(for: rowID) { diff --git a/Sources/IMsgCore/MessageStore+ReactionEvents.swift b/Sources/IMsgCore/MessageStore+ReactionEvents.swift index aade50f..45e651d 100644 --- a/Sources/IMsgCore/MessageStore+ReactionEvents.swift +++ b/Sources/IMsgCore/MessageStore+ReactionEvents.swift @@ -62,8 +62,11 @@ extension MessageStore { let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL" var sql = """ - SELECT m.ROWID, cmj.chat_id, m.associated_message_type, m.associated_message_guid, - m.handle_id, h.id, m.is_from_me, m.date, IFNULL(m.text, '') AS text, + SELECT m.ROWID AS reaction_rowid, cmj.chat_id AS chat_id, + m.associated_message_type AS associated_message_type, + m.associated_message_guid AS associated_message_guid, + m.handle_id AS handle_id, h.id AS sender, m.is_from_me AS is_from_me, + m.date AS date, IFNULL(m.text, '') AS text, \(destinationCallerColumn) AS destination_caller_id, \(bodyColumn) AS body, orig.ROWID AS orig_rowid @@ -87,19 +90,19 @@ extension MessageStore { return try withConnection { db in var events: [ReactionEvent] = [] - for row in try db.prepare(sql, bindings) { - let rowID = int64Value(row[0]) ?? 0 - let resolvedChatID = int64Value(row[1]) ?? chatID ?? 0 - let typeValue = intValue(row[2]) ?? 0 - let associatedGUID = stringValue(row[3]) - // let handleID = int64Value(row[4]) - var sender = stringValue(row[5]) - let isFromMe = boolValue(row[6]) - let date = appleDate(from: int64Value(row[7])) - let text = stringValue(row[8]) - let destinationCallerID = stringValue(row[9]) - let body = dataValue(row[10]) - let origRowID = int64Value(row[11]) + let rows = try db.prepareRowIterator(sql, bindings: bindings) + while let row = try rows.failableNext() { + let rowID = try int64Value(row, "reaction_rowid") ?? 0 + let resolvedChatID = try int64Value(row, "chat_id") ?? chatID ?? 0 + let typeValue = try intValue(row, "associated_message_type") ?? 0 + let associatedGUID = try stringValue(row, "associated_message_guid") + var sender = try stringValue(row, "sender") + let isFromMe = try boolValue(row, "is_from_me") + let date = try appleDate(from: int64Value(row, "date")) + let text = try stringValue(row, "text") + let destinationCallerID = try stringValue(row, "destination_caller_id") + let body = try dataValue(row, "body") + let origRowID = try int64Value(row, "orig_rowid") if sender.isEmpty && !destinationCallerID.isEmpty { sender = destinationCallerID diff --git a/Sources/IMsgCore/MessageStore+SQLRow.swift b/Sources/IMsgCore/MessageStore+SQLRow.swift new file mode 100644 index 0000000..1319bdd --- /dev/null +++ b/Sources/IMsgCore/MessageStore+SQLRow.swift @@ -0,0 +1,26 @@ +import Foundation +import SQLite + +extension MessageStore { + func stringValue(_ row: Row, _ column: String) throws -> String { + try row.get(Expression(column)) ?? "" + } + + func int64Value(_ row: Row, _ column: String) throws -> Int64? { + try row.get(Expression(column)) + } + + func intValue(_ row: Row, _ column: String) throws -> Int? { + guard let value = try int64Value(row, column) else { return nil } + return Int(value) + } + + func boolValue(_ row: Row, _ column: String) throws -> Bool { + try row.get(Expression(column)) ?? false + } + + func dataValue(_ row: Row, _ column: String) throws -> Data { + guard let blob = try row.get(Expression(column)) else { return Data() } + return Data(blob.bytes) + } +} diff --git a/Sources/IMsgCore/MessageStore+SentMessages.swift b/Sources/IMsgCore/MessageStore+SentMessages.swift index 9d53126..8ac8174 100644 --- a/Sources/IMsgCore/MessageStore+SentMessages.swift +++ b/Sources/IMsgCore/MessageStore+SentMessages.swift @@ -12,7 +12,7 @@ extension MessageStore { let lastAddressedHandleColumn = hasChatLastAddressedHandleColumn ? "IFNULL(c.last_addressed_handle, '')" : "''" let sql = """ - SELECT c.ROWID, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid, + SELECT c.ROWID AS chat_rowid, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid, IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service, \(accountIDColumn) AS account_id, \(accountLoginColumn) AS account_login, @@ -24,16 +24,17 @@ extension MessageStore { """ let bindings: [Binding?] = candidates + candidates return try withConnection { db in - guard let row = try db.prepare(sql, bindings).makeIterator().next() else { return nil } + let rows = try db.prepareRowIterator(sql, bindings: bindings) + guard let row = try rows.failableNext() else { return nil } return ChatInfo( - id: int64Value(row[0]) ?? 0, - identifier: stringValue(row[1]), - guid: stringValue(row[2]), - name: stringValue(row[3]), - service: stringValue(row[4]), - accountID: stringValue(row[5]).nilIfEmpty, - accountLogin: stringValue(row[6]).nilIfEmpty, - lastAddressedHandle: stringValue(row[7]).nilIfEmpty + id: try int64Value(row, "chat_rowid") ?? 0, + identifier: try stringValue(row, "identifier"), + guid: try stringValue(row, "guid"), + name: try stringValue(row, "name"), + service: try stringValue(row, "service"), + accountID: try stringValue(row, "account_id").nilIfEmpty, + accountLogin: try stringValue(row, "account_login").nilIfEmpty, + lastAddressedHandle: try stringValue(row, "last_addressed_handle").nilIfEmpty ) } } @@ -47,7 +48,7 @@ extension MessageStore { let placeholders = Array(repeating: "?", count: candidates.count).joined(separator: ",") let sql = """ - SELECT m.ROWID + SELECT m.ROWID AS message_rowid FROM message m LEFT JOIN chat_message_join cmj ON cmj.message_id = m.ROWID LEFT JOIN handle h ON h.ROWID = m.handle_id @@ -61,8 +62,9 @@ extension MessageStore { """ let bindings: [Binding?] = [MessageStore.appleEpoch(date)] + candidates return try withConnection { db in - guard let row = try db.prepare(sql, bindings).makeIterator().next() else { return nil } - return int64Value(row[0]) + let rows = try db.prepareRowIterator(sql, bindings: bindings) + guard let row = try rows.failableNext() else { return nil } + return try int64Value(row, "message_rowid") } } diff --git a/Sources/IMsgCore/MessageStore.swift b/Sources/IMsgCore/MessageStore.swift index 8ef9bd1..770cabf 100644 --- a/Sources/IMsgCore/MessageStore.swift +++ b/Sources/IMsgCore/MessageStore.swift @@ -210,20 +210,22 @@ extension MessageStore { options: AttachmentQueryOptions = .default ) throws -> [AttachmentMeta] { let sql = """ - SELECT a.filename, a.transfer_name, a.uti, a.mime_type, a.total_bytes, a.is_sticker + SELECT a.filename AS filename, a.transfer_name AS transfer_name, a.uti AS uti, + a.mime_type AS mime_type, a.total_bytes AS total_bytes, a.is_sticker AS is_sticker FROM message_attachment_join maj JOIN attachment a ON a.ROWID = maj.attachment_id WHERE maj.message_id = ? """ return try withConnection { db in var metas: [AttachmentMeta] = [] - for row in try db.prepare(sql, messageID) { - let filename = stringValue(row[0]) - let transferName = stringValue(row[1]) - let uti = stringValue(row[2]) - let mimeType = stringValue(row[3]) - let totalBytes = int64Value(row[4]) ?? 0 - let isSticker = boolValue(row[5]) + let rows = try db.prepareRowIterator(sql, bindings: [messageID]) + while let row = try rows.failableNext() { + let filename = try stringValue(row, "filename") + let transferName = try stringValue(row, "transfer_name") + let uti = try stringValue(row, "uti") + let mimeType = try stringValue(row, "mime_type") + let totalBytes = try int64Value(row, "total_bytes") ?? 0 + let isSticker = try boolValue(row, "is_sticker") metas.append( AttachmentResolver.metadata( filename: filename, @@ -249,8 +251,9 @@ extension MessageStore { LIMIT 1 """ return try withConnection { db in - for row in try db.prepare(sql, messageID) { - let info = dataValue(row[0]) + let rows = try db.prepareRowIterator(sql, bindings: [messageID]) + while let row = try rows.failableNext() { + let info = try dataValue(row, "user_info") guard !info.isEmpty else { continue } if let transcription = parseAudioTranscription(from: info) { return transcription @@ -295,7 +298,8 @@ extension MessageStore { // where X is the part index (0 for single-part messages) and GUID matches the original message's guid let bodyColumn = hasAttributedBody ? "r.attributedBody" : "NULL" let sql = """ - SELECT r.ROWID, r.associated_message_type, h.id, r.is_from_me, r.date, IFNULL(r.text, '') as text, + SELECT r.ROWID AS reaction_rowid, r.associated_message_type AS associated_message_type, + h.id AS sender, r.is_from_me AS is_from_me, r.date AS date, IFNULL(r.text, '') AS text, \(bodyColumn) AS body FROM message m JOIN message r ON r.associated_message_guid = m.guid @@ -311,14 +315,15 @@ extension MessageStore { return try withConnection { db in var reactions: [Reaction] = [] var reactionIndex: [ReactionKey: Int] = [:] - for row in try db.prepare(sql, messageID) { - let rowID = int64Value(row[0]) ?? 0 - let typeValue = intValue(row[1]) ?? 0 - let sender = stringValue(row[2]) - let isFromMe = boolValue(row[3]) - let date = appleDate(from: int64Value(row[4])) - let text = stringValue(row[5]) - let body = dataValue(row[6]) + let rows = try db.prepareRowIterator(sql, bindings: [messageID]) + while let row = try rows.failableNext() { + let rowID = try int64Value(row, "reaction_rowid") ?? 0 + let typeValue = try intValue(row, "associated_message_type") ?? 0 + let sender = try stringValue(row, "sender") + let isFromMe = try boolValue(row, "is_from_me") + let date = try appleDate(from: int64Value(row, "date")) + let text = try stringValue(row, "text") + let body = try dataValue(row, "body") let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text if ReactionType.isReactionRemove(typeValue) { diff --git a/Tests/IMsgCoreTests/MessageStoreTests.swift b/Tests/IMsgCoreTests/MessageStoreTests.swift index 8318ec9..7a9804b 100644 --- a/Tests/IMsgCoreTests/MessageStoreTests.swift +++ b/Tests/IMsgCoreTests/MessageStoreTests.swift @@ -48,6 +48,19 @@ func chatInfoReturnsMetadata() throws { #expect(info?.lastAddressedHandle == "+15551234567") } +@Test +func sqlRowDecodingThrowsWhenRequiredAliasIsMissing() throws { + let db = try Connection(.inMemory) + let store = try MessageStore(connection: db, path: ":memory:") + try store.withConnection { db in + let rows = try db.prepareRowIterator("SELECT 1 AS actual_value") + let row = try #require(try rows.failableNext()) + #expect(throws: (any Error).self) { + _ = try store.int64Value(row, "expected_value") + } + } +} + @Test func participantsReturnsUniqueHandles() throws { let db = try Connection(.inMemory)