refactor: consolidate schema detection

This commit is contained in:
Peter Steinberger 2026-02-15 14:31:14 +01:00
parent f9258472c8
commit 5b5c8bcc50
4 changed files with 139 additions and 230 deletions

View File

@ -2,84 +2,50 @@ import Foundation
import SQLite
extension MessageStore {
static func detectThreadOriginatorGUIDColumn(connection: Connection) -> Bool {
static func tableColumns(connection: Connection, table: String) -> Set<String> {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
let rows = try connection.prepare("PRAGMA table_info(\(table))")
var columns = Set<String>()
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("thread_originator_guid") == .orderedSame
{
return true
if let name = row[1] as? String {
columns.insert(name.lowercased())
}
}
return columns
} catch {
return false
return []
}
return false
}
static func reactionColumnsPresent(in columns: Set<String>) -> Bool {
return columns.contains("guid")
&& columns.contains("associated_message_guid")
&& columns.contains("associated_message_type")
}
static func detectReactionColumns(connection: Connection) -> Bool {
let columns = tableColumns(connection: connection, table: "message")
return reactionColumnsPresent(in: columns)
}
static func detectThreadOriginatorGUIDColumn(connection: Connection) -> Bool {
return tableColumns(connection: connection, table: "message").contains("thread_originator_guid")
}
static func detectAttributedBody(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("attributedBody") == .orderedSame
{
return true
}
}
} catch {
return false
}
return false
return tableColumns(connection: connection, table: "message").contains("attributedbody")
}
static func detectDestinationCallerID(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("destination_caller_id") == .orderedSame
{
return true
}
}
} catch {
return false
}
return false
return tableColumns(connection: connection, table: "message").contains("destination_caller_id")
}
static func detectAudioMessageColumn(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("is_audio_message") == .orderedSame
{
return true
}
}
} catch {
return false
}
return false
return tableColumns(connection: connection, table: "message").contains("is_audio_message")
}
static func detectAttachmentUserInfo(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(attachment)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("user_info") == .orderedSame
{
return true
}
}
} catch {
return false
}
return false
return tableColumns(connection: connection, table: "attachment").contains("user_info")
}
static func enhance(error: Error, path: String) -> Error {

View File

@ -60,24 +60,40 @@ extension MessageStore {
return try withConnection { db in
var messages: [Message] = []
for row in try db.prepare(sql, bindings) {
let rowID = int64Value(row[0]) ?? 0
let handleID = int64Value(row[1])
var sender = stringValue(row[2])
let text = stringValue(row[3])
let date = appleDate(from: int64Value(row[4]))
let isFromMe = boolValue(row[5])
let service = stringValue(row[6])
let isAudioMessage = boolValue(row[7])
let destinationCallerID = stringValue(row[8])
let colRowID = 0
let colHandleID = 1
let colSender = 2
let colText = 3
let colDate = 4
let colIsFromMe = 5
let colService = 6
let colIsAudioMessage = 7
let colDestinationCallerID = 8
let colGUID = 9
let colAssociatedGUID = 10
let colAssociatedType = 11
let colAttachments = 12
let colBody = 13
let colThreadOriginatorGUID = 14
let rowID = int64Value(row[colRowID]) ?? 0
let handleID = int64Value(row[colHandleID])
var sender = stringValue(row[colSender])
let text = stringValue(row[colText])
let date = appleDate(from: int64Value(row[colDate]))
let isFromMe = boolValue(row[colIsFromMe])
let service = stringValue(row[colService])
let isAudioMessage = boolValue(row[colIsAudioMessage])
let destinationCallerID = stringValue(row[colDestinationCallerID])
if sender.isEmpty && !destinationCallerID.isEmpty {
sender = destinationCallerID
}
let guid = stringValue(row[9])
let associatedGuid = stringValue(row[10])
let associatedType = intValue(row[11])
let attachments = intValue(row[12]) ?? 0
let body = dataValue(row[13])
let threadOriginatorGUID = stringValue(row[14])
let guid = stringValue(row[colGUID])
let associatedGuid = stringValue(row[colAssociatedGUID])
let associatedType = intValue(row[colAssociatedType])
let attachments = intValue(row[colAttachments]) ?? 0
let body = dataValue(row[colBody])
let threadOriginatorGUID = stringValue(row[colThreadOriginatorGUID])
var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
if isAudioMessage, let transcription = try audioTranscription(for: rowID) {
resolvedText = transcription
@ -142,25 +158,42 @@ extension MessageStore {
return try withConnection { db in
var messages: [Message] = []
for row in try db.prepare(sql, bindings) {
let rowID = int64Value(row[0]) ?? 0
let resolvedChatID = int64Value(row[1]) ?? chatID ?? 0
let handleID = int64Value(row[2])
var sender = stringValue(row[3])
let text = stringValue(row[4])
let date = appleDate(from: int64Value(row[5]))
let isFromMe = boolValue(row[6])
let service = stringValue(row[7])
let isAudioMessage = boolValue(row[8])
let destinationCallerID = stringValue(row[9])
let colRowID = 0
let colChatID = 1
let colHandleID = 2
let colSender = 3
let colText = 4
let colDate = 5
let colIsFromMe = 6
let colService = 7
let colIsAudioMessage = 8
let colDestinationCallerID = 9
let colGUID = 10
let colAssociatedGUID = 11
let colAssociatedType = 12
let colAttachments = 13
let colBody = 14
let colThreadOriginatorGUID = 15
let rowID = int64Value(row[colRowID]) ?? 0
let resolvedChatID = int64Value(row[colChatID]) ?? chatID ?? 0
let handleID = int64Value(row[colHandleID])
var sender = stringValue(row[colSender])
let text = stringValue(row[colText])
let date = appleDate(from: int64Value(row[colDate]))
let isFromMe = boolValue(row[colIsFromMe])
let service = stringValue(row[colService])
let isAudioMessage = boolValue(row[colIsAudioMessage])
let destinationCallerID = stringValue(row[colDestinationCallerID])
if sender.isEmpty && !destinationCallerID.isEmpty {
sender = destinationCallerID
}
let guid = stringValue(row[10])
let associatedGuid = stringValue(row[11])
let associatedType = intValue(row[12])
let attachments = intValue(row[13]) ?? 0
let body = dataValue(row[14])
let threadOriginatorGUID = stringValue(row[15])
let guid = stringValue(row[colGUID])
let associatedGuid = stringValue(row[colAssociatedGUID])
let associatedType = intValue(row[colAssociatedType])
let attachments = intValue(row[colAttachments]) ?? 0
let body = dataValue(row[colBody])
let threadOriginatorGUID = stringValue(row[colThreadOriginatorGUID])
var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
if isAudioMessage, let transcription = try audioTranscription(for: rowID) {
resolvedText = transcription

View File

@ -31,20 +31,17 @@ public final class MessageStore: @unchecked Sendable {
let location = Connection.Location.uri(uri, parameters: [.mode(.readOnly)])
self.connection = try Connection(location, readonly: true)
self.connection.busyTimeout = 5
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: self.connection)
self.hasReactionColumns = MessageStore.detectReactionColumns(connection: self.connection)
self.hasThreadOriginatorGUIDColumn = MessageStore.detectThreadOriginatorGUIDColumn(
connection: self.connection
)
self.hasDestinationCallerID = MessageStore.detectDestinationCallerID(
connection: self.connection
)
self.hasAudioMessageColumn = MessageStore.detectAudioMessageColumn(
connection: self.connection
)
self.hasAttachmentUserInfo = MessageStore.detectAttachmentUserInfo(
connection: self.connection
let messageColumns = MessageStore.tableColumns(connection: self.connection, table: "message")
let attachmentColumns = MessageStore.tableColumns(
connection: self.connection,
table: "attachment"
)
self.hasAttributedBody = messageColumns.contains("attributedbody")
self.hasReactionColumns = MessageStore.reactionColumnsPresent(in: messageColumns)
self.hasThreadOriginatorGUIDColumn = messageColumns.contains("thread_originator_guid")
self.hasDestinationCallerID = messageColumns.contains("destination_caller_id")
self.hasAudioMessageColumn = messageColumns.contains("is_audio_message")
self.hasAttachmentUserInfo = attachmentColumns.contains("user_info")
} catch {
throw MessageStore.enhance(error: error, path: normalized)
}
@ -65,37 +62,37 @@ public final class MessageStore: @unchecked Sendable {
self.queue.setSpecific(key: queueKey, value: ())
self.connection = connection
self.connection.busyTimeout = 5
let messageColumns = MessageStore.tableColumns(connection: connection, table: "message")
let attachmentColumns = MessageStore.tableColumns(connection: connection, table: "attachment")
if let hasAttributedBody {
self.hasAttributedBody = hasAttributedBody
} else {
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: connection)
self.hasAttributedBody = messageColumns.contains("attributedbody")
}
if let hasReactionColumns {
self.hasReactionColumns = hasReactionColumns
} else {
self.hasReactionColumns = MessageStore.detectReactionColumns(connection: connection)
self.hasReactionColumns = MessageStore.reactionColumnsPresent(in: messageColumns)
}
if let hasThreadOriginatorGUIDColumn {
self.hasThreadOriginatorGUIDColumn = hasThreadOriginatorGUIDColumn
} else {
self.hasThreadOriginatorGUIDColumn = MessageStore.detectThreadOriginatorGUIDColumn(
connection: connection
)
self.hasThreadOriginatorGUIDColumn = messageColumns.contains("thread_originator_guid")
}
if let hasDestinationCallerID {
self.hasDestinationCallerID = hasDestinationCallerID
} else {
self.hasDestinationCallerID = MessageStore.detectDestinationCallerID(connection: connection)
self.hasDestinationCallerID = messageColumns.contains("destination_caller_id")
}
if let hasAudioMessageColumn {
self.hasAudioMessageColumn = hasAudioMessageColumn
} else {
self.hasAudioMessageColumn = MessageStore.detectAudioMessageColumn(connection: connection)
self.hasAudioMessageColumn = messageColumns.contains("is_audio_message")
}
if let hasAttachmentUserInfo {
self.hasAttachmentUserInfo = hasAttachmentUserInfo
} else {
self.hasAttachmentUserInfo = MessageStore.detectAttachmentUserInfo(connection: connection)
self.hasAttachmentUserInfo = attachmentColumns.contains("user_info")
}
}
@ -379,23 +376,6 @@ extension MessageStore {
return nil
}
private static func detectReactionColumns(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
var columns = Set<String>()
for row in rows {
if let name = row[1] as? String {
columns.insert(name.lowercased())
}
}
return columns.contains("guid")
&& columns.contains("associated_message_guid")
&& columns.contains("associated_message_type")
} catch {
return false
}
}
private struct ReactionKey: Hashable {
let sender: String
let isFromMe: Bool

View File

@ -4,6 +4,32 @@ import Testing
@testable import IMsgCore
private func makeInMemoryMessageDB(includeThreadOriginatorGUID: Bool = false) throws -> Connection {
let db = try Connection(.inMemory)
let threadOriginatorColumn = includeThreadOriginatorGUID ? "thread_originator_guid TEXT," : ""
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
\(threadOriginatorColumn)
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
return db
}
@Test
func listChatsReturnsChat() throws {
let store = try TestDatabase.makeStore()
@ -113,26 +139,7 @@ func messagesAfterReturnsMessages() throws {
@Test
func messagesAfterExcludesReactionRows() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -172,26 +179,7 @@ func messagesAfterExcludesReactionRows() throws {
@Test
func messagesExcludeReactionRows() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -220,26 +208,7 @@ func messagesExcludeReactionRows() throws {
@Test
func messagesExposeReplyToGuid() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -269,26 +238,7 @@ func messagesExposeReplyToGuid() throws {
@Test
func messagesReplyToGuidHandlesNoPrefix() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -317,27 +267,7 @@ func messagesReplyToGuidHandlesNoPrefix() throws {
@Test
func messagesExposeThreadOriginatorGuidWhenAvailable() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
thread_originator_guid TEXT,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
let db = try makeInMemoryMessageDB(includeThreadOriginatorGUID: true)
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")