Skip to content
Snippets Groups Projects
Commit cd5a07a6 authored by Dariusz Rybicki's avatar Dariusz Rybicki
Browse files

Merge branch 'fix/migrate-messages-with-marshaled-contact-as-sender-or-recipient' into 'main'

Fix messages migration

See merge request elixxir/client-ios-db!20
parents eb071f08 8aa3e0cd
No related branches found
No related tags found
1 merge request!20Fix messages migration
Showing
with 273 additions and 101 deletions
......@@ -97,7 +97,9 @@ let package = Package(
],
resources: [
.copy("Resources/legacy_database_1.sqlite"),
.copy("Resources/legacy_database_1_meMarshaled_base64.txt"),
.copy("Resources/legacy_database_2.sqlite"),
.copy("Resources/legacy_database_2_meMarshaled_base64.txt"),
],
swiftSettings: swiftSettings
),
......
......@@ -3,20 +3,24 @@ import GRDB
import XXModels
public struct MigrateMessage {
var run: (LegacyMessage, XXModels.Database) throws -> Void
var run: (LegacyMessage, XXModels.Database, Data, Data) throws -> Void
func callAsFunction(
_ message: Message,
to newDb: XXModels.Database
to newDb: XXModels.Database,
myContactId: Data,
meMarshaled: Data
) throws {
try run(.direct(message), newDb)
try run(.direct(message), newDb, myContactId, meMarshaled)
}
func callAsFunction(
_ message: GroupMessage,
to newDb: XXModels.Database
to newDb: XXModels.Database,
myContactId: Data,
meMarshaled: Data
) throws {
try run(.group(message), newDb)
try run(.group(message), newDb, myContactId, meMarshaled)
}
}
......@@ -24,7 +28,26 @@ extension MigrateMessage {
public struct ReplyMessageNotFound: Error, Equatable {}
public struct GroupNotFound: Error, Equatable {}
public static let live = MigrateMessage { message, newDb in
public static let live = MigrateMessage { message, newDb, myContactId, meMarshaled in
let message: LegacyMessage = {
switch message {
case .direct(var message):
if message.sender == meMarshaled {
message.sender = myContactId
}
if message.receiver == meMarshaled {
message.receiver = myContactId
}
return .direct(message)
case .group(var groupMessage):
if groupMessage.sender == meMarshaled {
groupMessage.sender = myContactId
}
return .group(groupMessage)
}
}()
let replyMessageId: Data?
if let id = message.payload.reply?.messageId, id != "".data(using: .utf8) {
replyMessageId = id
......
import Foundation
import XXModels
/// Legacy database migrator
///
/// Use it to migrate legacy database to new database.
public struct Migrator {
public var run: (LegacyDatabase, XXModels.Database) throws -> Void
public var run: (LegacyDatabase, XXModels.Database, Data, Data) throws -> Void
public func callAsFunction(
_ legacyDb: LegacyDatabase,
to newDb: XXModels.Database
to newDb: XXModels.Database,
myContactId: Data,
meMarshaled: Data
) throws {
try run(legacyDb, newDb)
try run(legacyDb, newDb, myContactId, meMarshaled)
}
}
extension Migrator {
/// Live migrator implementation
public static func live(
currentDate: @escaping () -> Date = Date.init,
migrateContact: MigrateContact = .live,
migrateGroup: MigrateGroup = .live,
migrateGroupMember: MigrateGroupMember = .live,
migrateMessage: MigrateMessage = .live
) -> Migrator {
Migrator { legacyDb, newDb in
Migrator { legacyDb, newDb, myContactId, meMarshaled in
if try newDb.fetchContacts(.init(id: [myContactId])).isEmpty {
try newDb.saveContact(.init(
id: myContactId,
marshaled: meMarshaled,
createdAt: currentDate()
))
}
try legacyDb.writer.read { db in
let contacts = try Contact.order(Contact.Column.createdAt).fetchCursor(db)
while let contact = try contacts.next() {
......@@ -41,12 +53,12 @@ extension Migrator {
let messages = try Message.order(Message.Column.timestamp).fetchCursor(db)
while let message = try messages.next() {
try migrateMessage(message, to: newDb)
try migrateMessage(message, to: newDb, myContactId: myContactId, meMarshaled: meMarshaled)
}
let groupMessages = try GroupMessage.order(GroupMessage.Column.timestamp).fetchCursor(db)
while let groupMessage = try groupMessages.next() {
try migrateMessage(groupMessage, to: newDb)
try migrateMessage(groupMessage, to: newDb, myContactId: myContactId, meMarshaled: meMarshaled)
}
}
}
......@@ -55,6 +67,6 @@ extension Migrator {
#if DEBUG
extension Migrator {
public static let failing = Migrator { _, _ in fatalError() }
public static let failing = Migrator { _, _, _, _ in fatalError() }
}
#endif
......@@ -35,7 +35,7 @@ final class MigrateMessageTests: XCTestCase {
]
try legacyMessages.forEach { message in
try migrate(message, to: newDb)
try migrate(message, to: newDb, myContactId: Data(), meMarshaled: Data())
}
let newMessages: [XXModels.Message] = try newDb.fetchMessages(.init())
......@@ -53,6 +53,45 @@ final class MigrateMessageTests: XCTestCase {
])
}
func testMigratingDirectMessagesWithMarshaledContactAsSenderOrRecipientId() throws {
let myContact = try newDb.saveContact(.stub(1))
let otherContact = try newDb.saveContact(.stub(2))
let legacyMessages: [XXLegacyDatabaseMigrator.Message] = [
.stub(1, from: myContact.marshaled!, to: otherContact.id, status: .read),
.stub(2, from: otherContact.id, to: myContact.marshaled!, status: .sent),
.stub(3, from: otherContact.id, to: myContact.marshaled!, status: .sending),
.stub(4, from: otherContact.id, to: myContact.marshaled!, status: .sendingAttachment),
.stub(5, from: myContact.marshaled!, to: otherContact.id, status: .receivingAttachment),
.stub(6, from: myContact.marshaled!, to: otherContact.id, status: .received, unread: true),
.stub(7, from: otherContact.id, to: myContact.marshaled!, status: .failedToSend),
.stub(8, from: otherContact.id, to: myContact.marshaled!, status: .timedOut),
]
try legacyMessages.forEach { message in
try migrate(
message,
to: newDb,
myContactId: myContact.id,
meMarshaled: myContact.marshaled!
)
}
let newMessages: [XXModels.Message] = try newDb.fetchMessages(.init())
.map { $0.withNilId() }
XCTAssertNoDifference(newMessages, [
.stub(1, from: myContact.id, to: otherContact.id, status: .received),
.stub(2, from: otherContact.id, to: myContact.id, status: .sent),
.stub(3, from: otherContact.id, to: myContact.id, status: .sending),
.stub(4, from: otherContact.id, to: myContact.id, status: .sending),
.stub(5, from: myContact.id, to: otherContact.id, status: .receiving),
.stub(6, from: myContact.id, to: otherContact.id, status: .received, isUnread: true),
.stub(7, from: otherContact.id, to: myContact.id, status: .sendingFailed),
.stub(8, from: otherContact.id, to: myContact.id, status: .sendingTimedOut),
])
}
func testMigratingGroupMessages() throws {
let contact1 = try newDb.saveContact(.stub(1))
let contact2 = try newDb.saveContact(.stub(2))
......@@ -69,7 +108,7 @@ final class MigrateMessageTests: XCTestCase {
]
try legacyGroupMessages.forEach { groupMessage in
try migrate(groupMessage, to: newDb)
try migrate(groupMessage, to: newDb, myContactId: Data(), meMarshaled: Data())
}
let newMessages: [XXModels.Message] = try newDb.fetchMessages(.init())
......@@ -84,6 +123,42 @@ final class MigrateMessageTests: XCTestCase {
])
}
func testMigratingGroupMessagesWithMarshaledContactAsSenderId() throws {
let myContact = try newDb.saveContact(.stub(1))
let contact2 = try newDb.saveContact(.stub(2))
let contact3 = try newDb.saveContact(.stub(3))
let group1 = try newDb.saveGroup(.stub(1, leaderId: myContact.id))
let group2 = try newDb.saveGroup(.stub(2, leaderId: contact2.id))
let legacyGroupMessages: [XXLegacyDatabaseMigrator.GroupMessage] = [
.stub(1, from: myContact.marshaled!, toGroup: group1.id, status: .sent),
.stub(2, from: contact2.id, toGroup: group1.id, status: .read),
.stub(3, from: contact3.id, toGroup: group1.id, status: .failed),
.stub(4, from: myContact.marshaled!, toGroup: group2.id, status: .sending),
.stub(5, from: contact2.id, toGroup: group2.id, status: .received, unread: true),
]
try legacyGroupMessages.forEach { groupMessage in
try migrate(
groupMessage,
to: newDb,
myContactId: myContact.id,
meMarshaled: myContact.marshaled!
)
}
let newMessages: [XXModels.Message] = try newDb.fetchMessages(.init())
.map { $0.withNilId() }
XCTAssertNoDifference(newMessages, [
.stub(1, from: myContact.id, toGroup: group1.id, status: .sent),
.stub(2, from: contact2.id, toGroup: group1.id, status: .received),
.stub(3, from: contact3.id, toGroup: group1.id, status: .sendingFailed),
.stub(4, from: myContact.id, toGroup: group2.id, status: .sending),
.stub(5, from: contact2.id, toGroup: group2.id, status: .received, isUnread: true),
])
}
func testMigratingReplyToUnknownMessage() throws {
var legacyMessage = XXLegacyDatabaseMigrator.Message.stub(1)
legacyMessage.payload.reply = .init(
......@@ -91,7 +166,9 @@ final class MigrateMessageTests: XCTestCase {
senderId: "unknown-contact-id".data(using: .utf8)!
)
XCTAssertThrowsError(try migrate(legacyMessage, to: newDb)) { error in
XCTAssertThrowsError(
try migrate(legacyMessage, to: newDb, myContactId: Data(), meMarshaled: Data())
) { error in
XCTAssertEqual(
error as? MigrateMessage.ReplyMessageNotFound,
MigrateMessage.ReplyMessageNotFound()
......@@ -110,7 +187,7 @@ final class MigrateMessageTests: XCTestCase {
status: .received
)
try migrate(legacyMessage, to: newDb)
try migrate(legacyMessage, to: newDb, myContactId: Data(), meMarshaled: Data())
let newMessages: [XXModels.Message] = try newDb.fetchMessages(.init())
.map { $0.withNilId() }
......@@ -135,7 +212,9 @@ final class MigrateMessageTests: XCTestCase {
status: .sent
)
XCTAssertThrowsError(try migrate(legacyMessage, to: newDb)) { error in
XCTAssertThrowsError(
try migrate(legacyMessage, to: newDb, myContactId: Data(), meMarshaled: Data())
) { error in
XCTAssertEqual(
error as? MigrateMessage.GroupNotFound,
MigrateMessage.GroupNotFound()
......@@ -164,7 +243,7 @@ final class MigrateMessageTests: XCTestCase {
)
)
try migrate(legacyMessage, to: newDb)
try migrate(legacyMessage, to: newDb, myContactId: Data(), meMarshaled: Data())
let newMessages: [XXModels.Message] = try newDb.fetchMessages(.init()).map {
$0.withNilId()
......@@ -197,7 +276,7 @@ final class MigrateMessageTests: XCTestCase {
)
)
try migrate(legacyMessage, to: newDb)
try migrate(legacyMessage, to: newDb, myContactId: Data(), meMarshaled: Data())
let newMessages: [XXModels.Message] = try newDb.fetchMessages(.init()).map {
$0.withNilId()
......@@ -236,7 +315,7 @@ final class MigrateMessageTests: XCTestCase {
]
try legacyMessages.forEach { message in
try migrate(message, to: newDb)
try migrate(message, to: newDb, myContactId: Data(), meMarshaled: Data())
}
let newMessages: [XXModels.Message] = try newDb.fetchMessages(.init())
......
......@@ -32,18 +32,33 @@ final class MigratorTests: XCTestCase {
_ = try XXLegacyDatabaseMigrator.GroupMessage.stub(3).saved(db)
}
// Mock up new database:
var didSaveContacts = [XXModels.Contact]()
var newDb = XXModels.Database.failing
newDb.fetchContacts = .init { _ in [] }
newDb.saveContact = .init(run: {
didSaveContacts.append($0)
return $0
})
// Perform migration:
enum Migrated: Equatable {
case contact(XXLegacyDatabaseMigrator.Contact)
case group(XXLegacyDatabaseMigrator.Group)
case groupMember(XXLegacyDatabaseMigrator.GroupMember)
case message(XXLegacyDatabaseMigrator.LegacyMessage)
case message(XXLegacyDatabaseMigrator.LegacyMessage, Data, Data)
}
let currentDate = Date()
var didMigrate = [Migrated]()
let migrate = Migrator.live(
currentDate: {
currentDate
},
migrateContact: .init { contact, _ in
didMigrate.append(.contact(contact))
},
......@@ -53,15 +68,22 @@ final class MigratorTests: XCTestCase {
migrateGroupMember: .init { groupMember, _ in
didMigrate.append(.groupMember(groupMember))
},
migrateMessage: .init { message, _ in
didMigrate.append(.message(message))
migrateMessage: .init { message, _, myContactId, meMarshaled in
didMigrate.append(.message(message, myContactId, meMarshaled))
}
)
try migrate(legacyDb, to: .failing)
let myContactId = "my-contact-id".data(using: .utf8)!
let meMarshaled = "me-marshaled".data(using: .utf8)!
try migrate(legacyDb, to: newDb, myContactId: myContactId, meMarshaled: meMarshaled)
// Assert migration:
XCTAssertNoDifference(didSaveContacts, [
.init(id: myContactId, marshaled: meMarshaled, createdAt: currentDate)
])
XCTAssertNoDifference(didMigrate, try legacyDb.writer.read { db in
[
try XXLegacyDatabaseMigrator.Contact
......@@ -83,13 +105,13 @@ final class MigratorTests: XCTestCase {
.order(XXLegacyDatabaseMigrator.Message.Column.timestamp)
.fetchAll(db)
.map(XXLegacyDatabaseMigrator.LegacyMessage.direct)
.map(Migrated.message),
.map { Migrated.message($0, myContactId, meMarshaled) },
try XXLegacyDatabaseMigrator.GroupMessage
.order(XXLegacyDatabaseMigrator.GroupMessage.Column.timestamp)
.fetchAll(db)
.map(XXLegacyDatabaseMigrator.LegacyMessage.group)
.map(Migrated.message),
.map { Migrated.message($0, myContactId, meMarshaled) },
].flatMap { $0 }
})
......@@ -100,9 +122,18 @@ final class MigratorTests: XCTestCase {
let legacyDb = try LegacyDatabase(path: path)
let newDbQueue = DatabaseQueue()
let newDb = try XXModels.Database.grdb(writer: newDbQueue)
let migrate = Migrator.live()
let currentDate = Date(timeIntervalSince1970: 1234)
let migrate = Migrator.live(currentDate: { currentDate })
try migrate(legacyDb, to: newDb)
let myContactId = "my-contact-id".data(using: .utf8)!
let meMarshaled = Data(base64Encoded: try String(
contentsOfFile: Bundle.module.path(
forResource: "legacy_database_1_meMarshaled_base64",
ofType: "txt"
)!
).trimmingCharacters(in: .whitespacesAndNewlines))!
try migrate(legacyDb, to: newDb, myContactId: myContactId, meMarshaled: meMarshaled)
assertSnapshot(matchingContactsIn: newDbQueue)
assertSnapshot(matchingGroupsIn: newDbQueue)
......@@ -116,9 +147,18 @@ final class MigratorTests: XCTestCase {
let legacyDb = try LegacyDatabase(path: path)
let newDbQueue = DatabaseQueue()
let newDb = try XXModels.Database.grdb(writer: newDbQueue)
let migrate = Migrator.live()
try migrate(legacyDb, to: newDb)
let currentDate = Date(timeIntervalSince1970: 1234)
let migrate = Migrator.live(currentDate: { currentDate })
let myContactId = "my-contact-id".data(using: .utf8)!
let meMarshaled = Data(base64Encoded: try String(
contentsOfFile: Bundle.module.path(
forResource: "legacy_database_2_meMarshaled_base64",
ofType: "txt"
)!
).trimmingCharacters(in: .whitespacesAndNewlines))!
try migrate(legacyDb, to: newDb, myContactId: myContactId, meMarshaled: meMarshaled)
assertSnapshot(matchingContactsIn: newDbQueue)
assertSnapshot(matchingGroupsIn: newDbQueue)
......
PHh4YygyKUd4Q1pwL0d2NW1UUDM1Q1FjUTEyNHdsbnZxVCtiMjBMZnRacDJjby84V2NEa0FaaUI5a1pvK0RsM1VqTVB1SlRwcHc4TDBPcnI3NnpTSlNyTG1mZ0kxRS9yNTQwRS9Ic3A3MXJaYW1HSWVkVitDVW5VVWgrVWVkOWRFQ3FxbnBnMjBLZVBKcUozMXlSN1ZQM0tWbXAza1NwMk0zM1dUOGgrUkU4TTJmcnVRRmFIQXg1MmY0QVhCalVhRlhYY25LK3J5WmJIemhRL0s2Q0w3NFJrbzdPdERoWDN0VXVna0kxWTdpekRaVDJ4RmZRZEN5T1lDbW96aUxQNm5UTDB3d2FycHFxSWxLeHl4TXl2QWErRWl4S3NzcGRPMktJNHNxTkkzYURsS2xZSzMrMUhrdVZSTENvNG90Y2xaMHRaY3VJb0Jkclc2UURwK2U5N216Wi93djN5UWNYSEIweWVxOFZsV0NidmJvelg3cWpjRUFPM3hNYWpSMEJnckVCSDJidDI0dEVGdm5VNFRwcW9jaWVveTdlM1c1RVZUaHhlWk5WTk1Zd01sM1pFb3R1VnBmQVN2NVJORkFJN0w5MTU1d2pmaStId0xrOEQrMjdmcEJ0STN1Q2tWa3JzY0tYMnNjY05VU1BKQ0JEQnlNRjVZZUNDTGF0Z01WY2VHWVZIV2VvcGxyZTdhY2Rrb2p6RC9wa1Bha1VwVGlsYURTQllRVVpQc3pVVS9wRkpNNURXRDBpdUI5NldzL0h4UUFBQWdBN1VpMTk2a0VIWE1WV0l2VkZvMjEwdEE9PXh4Yz4=
PHh4YygyKWl3ZmpTRVFoc3VwL3I5TythSDE5bUMrNjdPOW1ZTHlkQzJqNUx0eVc0aVlEa0FaaUI5a1pvK0RsM1o1TzREU3VCMDN3TVAxZzZqWGtTZUxrTUtTRitQNEZmSkVKTnl5WTNjN0FMdXh5cStScUVIWkRUcnRpb3NJemhkYisrTmZ2dnNUVVVzZC9CK1lSU2pEVHcrbGg2NHE0cHpCaXlTNlNJRFI0K0lBZTJHdXpzTGZYejkzMmg2OWQzY1JlQi9HQ3RGWnlUekNIRUYrQ1RUQ29ILys0MUcydE0wc2UvQlF6UWpPNi9NUVd5cTlHWlZPR016aGFYNklHZkVWdmNRczBvTVdiWTdFdjFmeWVyMGxIL3dmdFhLR2NsQ0l2TGc2aStjRG1qd0gyeDlTQ09MUEFQSjF1L2kyM29lYWlCQ0poTWREMTZzOUhGUXpMTmJUR3FHYlh6bVR3RU5rR0JlK2JwdDRxRnB4SUxGQWI4NUdxeGdwSHkrNFp3OGhicExKMGhlY0NxSFpKTXExbFVjVzJmWldWeHVhcEUwdGlTb0RXNVE1YW5XT2VtYUVYRE1jZTZ2OFhNUkFkQmUvdlliNk8rT0xPcjdvT0xWRjQxOTE3WFRJWEZVcEZ1MmFmRGhXbkRXZlVQclNDOElNTlVWODBmNlYvZVl2bDcyWThBTWNneGZIeUZCZ0NvUG5kWUIrRldaTEh5c3dKa0g0eGpmWHh4Z2M2Uk5sN0Q2RWhNcHFSeDVweFdnUHdjUUFBQWdBN1c1a2pDb0NWWlFZSTRWWEhUTXhUeEE9PXh4Yz4=
[
{
"authStatus" : "stranger",
"createdAt" : "1970-01-01T00:20:34Z",
"id" : "bXktY29udGFjdC1pZA==",
"isRecent" : false,
"marshaled" : "PHh4YygyKUd4Q1pwL0d2NW1UUDM1Q1FjUTEyNHdsbnZxVCtiMjBMZnRacDJjby84V2NEa0FaaUI5a1pvK0RsM1VqTVB1SlRwcHc4TDBPcnI3NnpTSlNyTG1mZ0kxRS9yNTQwRS9Ic3A3MXJaYW1HSWVkVitDVW5VVWgrVWVkOWRFQ3FxbnBnMjBLZVBKcUozMXlSN1ZQM0tWbXAza1NwMk0zM1dUOGgrUkU4TTJmcnVRRmFIQXg1MmY0QVhCalVhRlhYY25LK3J5WmJIemhRL0s2Q0w3NFJrbzdPdERoWDN0VXVna0kxWTdpekRaVDJ4RmZRZEN5T1lDbW96aUxQNm5UTDB3d2FycHFxSWxLeHl4TXl2QWErRWl4S3NzcGRPMktJNHNxTkkzYURsS2xZSzMrMUhrdVZSTENvNG90Y2xaMHRaY3VJb0Jkclc2UURwK2U5N216Wi93djN5UWNYSEIweWVxOFZsV0NidmJvelg3cWpjRUFPM3hNYWpSMEJnckVCSDJidDI0dEVGdm5VNFRwcW9jaWVveTdlM1c1RVZUaHhlWk5WTk1Zd01sM1pFb3R1VnBmQVN2NVJORkFJN0w5MTU1d2pmaStId0xrOEQrMjdmcEJ0STN1Q2tWa3JzY0tYMnNjY05VU1BKQ0JEQnlNRjVZZUNDTGF0Z01WY2VHWVZIV2VvcGxyZTdhY2Rrb2p6RC9wa1Bha1VwVGlsYURTQllRVVpQc3pVVS9wRkpNNURXRDBpdUI5NldzL0h4UUFBQWdBN1VpMTk2a0VIWE1WV0l2VkZvMjEwdEE9PXh4Yz4="
},
{
"authStatus" : "friend",
"createdAt" : "2022-06-21T11:37:56Z",
......
[
{
"authStatus" : "stranger",
"createdAt" : "1970-01-01T00:20:34Z",
"id" : "bXktY29udGFjdC1pZA==",
"isRecent" : false,
"marshaled" : "PHh4YygyKWl3ZmpTRVFoc3VwL3I5TythSDE5bUMrNjdPOW1ZTHlkQzJqNUx0eVc0aVlEa0FaaUI5a1pvK0RsM1o1TzREU3VCMDN3TVAxZzZqWGtTZUxrTUtTRitQNEZmSkVKTnl5WTNjN0FMdXh5cStScUVIWkRUcnRpb3NJemhkYisrTmZ2dnNUVVVzZC9CK1lSU2pEVHcrbGg2NHE0cHpCaXlTNlNJRFI0K0lBZTJHdXpzTGZYejkzMmg2OWQzY1JlQi9HQ3RGWnlUekNIRUYrQ1RUQ29ILys0MUcydE0wc2UvQlF6UWpPNi9NUVd5cTlHWlZPR016aGFYNklHZkVWdmNRczBvTVdiWTdFdjFmeWVyMGxIL3dmdFhLR2NsQ0l2TGc2aStjRG1qd0gyeDlTQ09MUEFQSjF1L2kyM29lYWlCQ0poTWREMTZzOUhGUXpMTmJUR3FHYlh6bVR3RU5rR0JlK2JwdDRxRnB4SUxGQWI4NUdxeGdwSHkrNFp3OGhicExKMGhlY0NxSFpKTXExbFVjVzJmWldWeHVhcEUwdGlTb0RXNVE1YW5XT2VtYUVYRE1jZTZ2OFhNUkFkQmUvdlliNk8rT0xPcjdvT0xWRjQxOTE3WFRJWEZVcEZ1MmFmRGhXbkRXZlVQclNDOElNTlVWODBmNlYvZVl2bDcyWThBTWNneGZIeUZCZ0NvUG5kWUIrRldaTEh5c3dKa0g0eGpmWHh4Z2M2Uk5sN0Q2RWhNcHFSeDVweFdnUHdjUUFBQWdBN1c1a2pDb0NWWlFZSTRWWEhUTXhUeEE9PXh4Yz4="
},
{
"authStatus" : "friend",
"createdAt" : "2022-06-21T12:07:20Z",
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment