refactor: decode sqlite rows by name

This commit is contained in:
Peter Steinberger 2026-05-05 02:24:59 +01:00
parent 327829a819
commit edad072a41
No known key found for this signature in database
9 changed files with 252 additions and 184 deletions

View File

@ -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<String>()
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)

View File

@ -11,10 +11,10 @@ extension MessageStore {
static func tableColumns(connection: Connection, table: String) -> Set<String> {
do {
let rows = try connection.prepare("PRAGMA table_info(\(table))")
let rows = try connection.prepareRowIterator("PRAGMA table_info(\(table))")
var columns = Set<String>()
for row in rows {
if let name = row[1] as? String {
while let row = try rows.failableNext() {
if let name = try row.get(Expression<String?>("name")) {
columns.insert(name.lowercased())
}
}

View File

@ -18,7 +18,9 @@ extension MessageStore {
let batch = Array(uniqueIDs[start..<end])
let placeholders = Array(repeating: "?", count: batch.count).joined(separator: ",")
let sql = """
SELECT maj.message_id, a.filename, a.transfer_name, a.uti, a.mime_type, a.total_bytes, a.is_sticker
SELECT maj.message_id AS message_id, 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 IN (\(placeholders))
@ -26,14 +28,15 @@ extension MessageStore {
"""
let bindings: [Binding?] = batch.map { $0 }
try withConnection { db in
for row in try db.prepare(sql, bindings) {
let messageID = int64Value(row[0]) ?? 0
let filename = stringValue(row[1])
let transferName = stringValue(row[2])
let uti = stringValue(row[3])
let mimeType = stringValue(row[4])
let totalBytes = int64Value(row[5]) ?? 0
let isSticker = boolValue(row[6])
let rows = try db.prepareRowIterator(sql, bindings: bindings)
while let row = try rows.failableNext() {
let messageID = try int64Value(row, "message_id") ?? 0
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")
metasByMessageID[messageID, default: []].append(
AttachmentResolver.metadata(
filename: filename,
@ -88,8 +91,10 @@ extension MessageStore {
).joined(separator: " OR ")
let bodyColumn = hasAttributedBody ? "r.attributedBody" : "NULL"
let sql = """
SELECT r.ROWID, r.associated_message_guid, r.associated_message_type, h.id, r.is_from_me,
r.date, IFNULL(r.text, '') AS text, \(bodyColumn) AS body
SELECT r.ROWID AS reaction_rowid, r.associated_message_guid AS associated_message_guid,
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 r
LEFT JOIN handle h ON r.handle_id = h.ROWID
WHERE r.associated_message_guid IS NOT NULL
@ -105,18 +110,19 @@ extension MessageStore {
let bindings: [Binding?] = guids.map { $0 } + guids.map { "%/\($0)" }
try withConnection { db in
for row in try db.prepare(sql, bindings) {
let associatedGUID = stringValue(row[1])
let rows = try db.prepareRowIterator(sql, bindings: bindings)
while let row = try rows.failableNext() {
let associatedGUID = try stringValue(row, "associated_message_guid")
let baseGUID = baseAssociatedMessageGUID(from: associatedGUID)
guard let messageID = messageIDByGUID[baseGUID] else { continue }
let rowID = int64Value(row[0]) ?? 0
let typeValue = intValue(row[2]) ?? 0
let sender = stringValue(row[3])
let isFromMe = boolValue(row[4])
let date = appleDate(from: int64Value(row[5]))
let text = stringValue(row[6])
let body = dataValue(row[7])
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
var reactions = reactionsByMessageID[messageID, default: []]

View File

@ -2,22 +2,22 @@ import Foundation
import SQLite
private struct MessageRowColumns {
let rowID: Int
let chatID: Int?
let handleID: Int
let sender: Int
let text: Int
let date: Int
let isFromMe: Int
let service: Int
let isAudioMessage: Int
let destinationCallerID: Int
let guid: Int
let associatedGUID: Int
let associatedType: Int
let attachments: Int
let body: Int
let threadOriginatorGUID: Int
let rowID: String
let chatID: String?
let handleID: String
let sender: String
let text: String
let date: String
let isFromMe: String
let service: String
let isAudioMessage: String
let destinationCallerID: String
let guid: String
let associatedGUID: String
let associatedType: String
let attachments: String
let body: String
let threadOriginatorGUID: String
}
private struct DecodedMessageRow {
@ -56,7 +56,9 @@ extension MessageStore {
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 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) {

View File

@ -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

View File

@ -0,0 +1,26 @@
import Foundation
import SQLite
extension MessageStore {
func stringValue(_ row: Row, _ column: String) throws -> String {
try row.get(Expression<String?>(column)) ?? ""
}
func int64Value(_ row: Row, _ column: String) throws -> Int64? {
try row.get(Expression<Int64?>(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<Bool?>(column)) ?? false
}
func dataValue(_ row: Row, _ column: String) throws -> Data {
guard let blob = try row.get(Expression<Blob?>(column)) else { return Data() }
return Data(blob.bytes)
}
}

View File

@ -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")
}
}

View File

@ -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) {

View File

@ -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)