SwiftSerialization

2.0.1

A package used for serializing and restoring Swift objects.
Andre-Pham/SwiftSerialization

What's New

Release 2.0.1

2024-03-27T22:13:18Z

Changelog

  • Implemented thread safety when accessing the database from multiple concurrent threads. Now if you start two or more threads, and they both access the database simultaneously, the operations are queued and no longer throw an error.
  • Made transactionActive publicly accessible on DatabaseTarget (and by extension, SerializationDatabase)

The README has been updated accordingly.

Version number guide.

SwiftSerialization

A package used for serializing and restoring Swift objects.

Usage Examples

Any object that conforms to Storable can easily be written to and read from the database.

// Lets define a database first
// Realistically this would be managed at the application level
let database = SerializationDatabase()

// Here's the object we want to read/write
let person = Person(name: "Andre", height: 188.0)

Writing to the database. Returns true on success and false on failure.

// Writes our person object to the database
// Generates a random record ID
database.write(Record(data: person))

// You can also provide your own record ID
// (Overrides any record with the same ID)
database.write(Record(id: "myID", data: person))

There's three ways to read from the database.

// Reads a specific Person object by their record ID
let readPerson: Person? = database.read(id: "myID")

// Reads all saved Person objects
let readPeople: [Person] = database.read()

// Reads all saved Person record IDs
let readPeopleIDs: [String] = database.readIDs(Person.self)

We can count records. If we wish we can specify a specific object type.

// Counts all records in the database
let count = database.count()

// Counts the number of People records
let peopleCount = database.count(Person.self)

We can delete records, or even clear the database.

// Delete a record with specific record ID
// (Returns true if a record was deleted)
database.delete(id: "myID")

// Delete all Person records
// (Returns the number of records deleted)
database.delete(Person.self)

// Delete everything
// (Returns the number of records deleted)
database.clearDatabase()

We can also use transactions if we wish. So long as we haven't committed yet, all changes made during a transaction can be rolled back.

database.startTransaction()
let person1Saved = database.write(Record(data: person1))
let person2Saved = database.write(Record(data: person2))
if person1Saved && person2Saved {
    // We can commit the transaction to finalise the changes
    database.commitTransaction()
} else {
    // Or we can rollback to undo all changes made during the transaction
    database.rollbackTransaction()
}

Everything works asynchronously - even from multiple concurrent threads.

DispatchQueue.global().async {
    let readPerson: Person? = database.read(id: "myID")
    DispatchQueue.main.async {
        // Update UI when done
    }
}

Storable Class Examples

Class definitions, and how you would conform them to Storable to be serialized.

class Person: Storable {
    
    private(set) var firstName: String
    private(set) var lastName: String
    
    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
    
    // MARK: - Serialization
    
    private enum Field: String {
        case firstName
        case lastName
    }
    
    required init(dataObject: DataObject) {
        self.firstName = dataObject.get(Field.firstName.rawValue)
        self.lastName = dataObject.get(Field.lastName.rawValue)
        // NOTE: You can also override the default return value if the value can't be found
        // self.lastName = dataObject.get(Field.lastName.rawValue, onFail: "MISSING")
    }
    
    func toDataObject() -> DataObject {
        return DataObject(self)
            .add(key: Field.firstName.rawValue, value: self.firstName)
            .add(key: Field.lastName.rawValue, value: self.lastName)
    }
    
}

class Student: Person {
    
    private(set) var homework = [Homework]()
    private(set) var debt: Double
    private(set) var teacher: Teacher
    private(set) var subjectNames: [String]
    
    init(firstName: String, lastName: String, debt: Double, teacher: Teacher, subjectNames: [String]) {
        self.debt = debt
        self.teacher = teacher
        self.subjectNames = subjectNames
        super.init(firstName: firstName, lastName: lastName)
    }
    
    func giveHomework(_ homework: Homework) {
        self.homework.append(homework)
    }
    
    // MARK: - Serialization
    
    private enum Field: String {
        case debt
        case homework
        case teacher
        case subjectNames
    }
    
    required init(dataObject: DataObject) {
        self.debt = dataObject.get(Field.debt.rawValue)
        self.homework = dataObject.getObjectArray(Field.homework.rawValue, type: Homework.self)
        self.teacher = dataObject.getObject(Field.teacher.rawValue, type: Teacher.self)
        self.subjectNames = dataObject.get(Field.subjectNames.rawValue)
        super.init(dataObject: dataObject)
    }
    
    override func toDataObject() -> DataObject {
        return super.toDataObject()
            .add(key: Field.debt.rawValue, value: self.debt)
            .add(key: Field.homework.rawValue, value: self.homework)
            .add(key: Field.teacher.rawValue, value: self.teacher)
            .add(key: Field.subjectNames.rawValue, value: self.subjectNames)
    }
    
}

class Teacher: Person {
    
    private(set) var salary: Double
    
    init(firstName: String, lastName: String, salary: Double) {
        self.salary = salary
        super.init(firstName: firstName, lastName: lastName)
    }
    
    // MARK: - Serialization
    
    private enum Field: String {
        case salary
    }
    
    required init(dataObject: DataObject) {
        self.salary = dataObject.get(Field.salary.rawValue)
        super.init(dataObject: dataObject)
    }
    
    override func toDataObject() -> DataObject {
        return super.toDataObject()
            .add(key: Field.salary.rawValue, value: self.salary)
    }
    
}

class Homework: Storable {
    
    public let answers: String
    private(set) var grade: Int?
    
    init(answers: String, grade: Int?) {
        self.answers = answers
        self.grade = grade
    }
    
    // MARK: - Serialization
    
    private enum Field: String {
        case answers
        case grade
    }
    
    required init(dataObject: DataObject) {
        self.answers = dataObject.get(Field.answers.rawValue)
        self.grade = dataObject.get(Field.grade.rawValue)
    }
    
    func toDataObject() -> DataObject {
        return DataObject(self)
            .add(key: Field.answers.rawValue, value: self.answers)
            .add(key: Field.grade.rawValue, value: self.grade)
    }
    
}

Serialization works with typealias definitions, but the syntax is a little different.

class Person // ...
protocol HasDebt // ...

typealias Student = Person & HasDebt

// ...

// In init(dataObject: DataObject)
self.students = dataObject.getObjectArray(Field.students.rawValue, type: Person.self) as! [any Student]

// In toDataObject() -> DataObject
.add(key: Field.students.rawValue, value: self.students as [Person])

Handling Property Addition/Removal

Your classes will change over time.

If you remove properties from your class but had saved it previously, just don't read it from the DataObject.

If you had previously saved your objects then later added new properties to their class definition, you define within the class initialiser how the class handles the missing data.

private var firstName: String

// ...

// By default, self.firstName will be set to "" if no value is returned
self.firstName = dataObject.get(Field.firstName.rawValue)

// self.firstName will be set to "MISSING" if no value is returned
self.firstName = dataObject.get(Field.firstName.rawValue, onFail: "MISSING")

If the property is optional and no value is returned, it will be set to nil.

private var firstName: String?

// ...

// self.firstName will be set to nil if no value is returned
self.firstName = dataObject.get(Field.firstName.rawValue)

Handling Refactoring

You may change the names of your classes and properties. You have to account for these refactors.

Here's how your class may look beforehand:

class Person: Storable {
    
    private(set) var name: String
    
    init(name: String) {
        self.name = name
    }
    
    // MARK: - Serialization
    
    private enum Field: String {
        case name
    }
    
    required init(dataObject: DataObject) {
        self.name = dataObject.get(Field.name.rawValue)
    }
    
    func toDataObject() -> DataObject {
        return DataObject(self)
            .add(key: Field.name.rawValue, value: self.name)
    }
    
}

The rules to follow are basically:

  • For class name refactors, call Legacy.addClassRefactor with the new name.
  • For property name refactors, assuming you change the Field case, include the legacyKeys parameter in the .get method call for that property.

If you refactor Person to Human, and name to firstName, the result should look as such:

// On application startup
Legacy.addClassRefactor(old: "Person", new: "Human")

// ...

class Human: Storable {
    
    private(set) var firstName: String
    
    init(firstName: String) {
        self.firstName = firstName
    }
    
    // MARK: - Serialization
    
    private enum Field: String {
        case firstName
    }
    
    required init(dataObject: DataObject) {
        self.firstName = dataObject.get(Field.firstName.rawValue, legacyKeys: ["name"])
    }
    
    func toDataObject() -> DataObject {
        return DataObject(self)
            .add(key: Field.firstName.rawValue, value: self.firstName)
    }
    
}

SerializationDatabase Documentation

Initializers

init()

Instance Properties

/// True if a transaction is ongoing
var transactionActive: Bool

Instance Methods

/// Write a record to the database. If the id already exists, replace it.
/// - Parameters:
///   - record: The record to be written
/// - Returns: If the write was successful
func write<T: Storable>(_ record: Record<T>) -> Bool
/// Retrieve all storable objects of a specified type.
/// - Returns: All saved objects of the specified type
func read<T: Storable>() -> [T]
/// Retrieve the storable object with the matching id.
/// - Parameters:
///   - id: The id of the stored record
/// - Returns: The storable object with the matching id
func read<T: Storable>(id: String) -> T?
/// Retrieve all the record IDs of all objects of a specific type.
/// - Parameters:
///   - allOf: The type to retrieve the ids from
/// - Returns: All stored record ids of the provided type
func readIDs<T: Storable>(_ allOf: T.Type) -> [String]
/// Delete all instances of an object
/// - Parameters:
///   - allOf: The type to delete
/// - Returns: The number of records deleted
func delete<T: Storable>(_ allOf: T.Type) -> Int
/// Delete the record with the matching id.
/// - Parameters:
///   - id: The id of the stored record to delete
/// - Returns: If any record was successfully deleted
func delete(id: String) -> Bool
/// Clear the entire database.
/// - Returns: The number of records deleted
func clearDatabase() -> Int
/// Count the number of records saved.
/// - Returns: The number of records
func count() -> Int
/// Count the number of records of a certain type saved.
/// - Parameters:
///   - allOf: The type to count
/// - Returns: The number of records of the provided type currently saved
func count<T: Storable>(_ allOf: T.Type) -> Int
/// Begin a database transaction.
/// Changes are still made immediately, however to finalise the transaction, `commitTransaction` should be executed.
/// All changes made during the transaction are cancelled if `rollbackTransaction` is executed.
/// If a new transaction is started before this one is committed, this transaction's changes are rolled back.
/// - Parameters:
///   - override: Override (roll back) the current transaction if one is currently active already - true by default
/// - Returns: True if the transaction was successfully started
func startTransaction(override: Bool) -> Bool
/// Commit the current transaction. All changes made during the transaction are finalised.
/// - Returns: True if there was an active transaction and it was committed
func commitTransaction() -> Bool
/// Rollback the current transaction. All changes made during the transaction are undone.
/// - Returns: True if there was an active transaction and it was rolled back
func rollbackTransaction() -> Bool

DataObject Documentation

Initializers

/// Constructor.
/// - Parameters:
///   - object: The Storable object this will represent
init(_ object: Storable)
/// Constructor.
/// - Parameters:
///   - rawString: The raw JSON string to populate this with, generated from another DataObject
init(rawString: String)

Instance Properties

/// This DataObject's raw data
var rawData: Data

Instance Methods

func add(key: String, value: String) -> Self
func add(key: String, value: String?) -> Self
func add(key: String, value: [String]) -> Self
func add(key: String, value: [String?]) -> Self
func add(key: String, value: Int) -> Self
func add(key: String, value: Int?) -> Self
func add(key: String, value: [Int]) -> Self
func add(key: String, value: [Int?]) -> Self
func add(key: String, value: Double) -> Self
func add(key: String, value: Double?) -> Self
func add(key: String, value: [Double]) -> Self
func add(key: String, value: [Double?]) -> Self
func add(key: String, value: Bool) -> Self
func add(key: String, value: Bool?) -> Self
func add(key: String, value: [Bool]) -> Self
func add(key: String, value: [Bool?]) -> Self
func add(key: String, value: Date) -> Self
func add(key: String, value: Date?) -> Self
func add(key: String, value: [Date]) -> Self
func add(key: String, value: [Date?]) -> Self
func add<T: Storable>(key: String, value: T) -> Self
func add<T: Storable>(key: String, value: T?) -> Self
func add<T: Storable>(key: String, value: [T]) -> Self
func add<T: Storable>(key: String, value: [T?]) -> Self
func get(_ key: String, onFail: String = "", legacyKeys: [String] = []) -> String
func get(_ key: String, legacyKeys: [String] = []) -> String?
func get(_ key: String, legacyKeys: [String] = []) -> [String]
func get(_ key: String, legacyKeys: [String] = []) -> [String?]
func get(_ key: String, onFail: Int = 0, legacyKeys: [String] = []) -> Int
func get(_ key: String, legacyKeys: [String] = []) -> Int?
func get(_ key: String, legacyKeys: [String] = []) -> [Int]
func get(_ key: String, legacyKeys: [String] = []) -> [Int?]
func get(_ key: String, onFail: Double = 0.0, legacyKeys: [String] = []) -> Double
func get(_ key: String, legacyKeys: [String] = []) -> Double?
func get(_ key: String, legacyKeys: [String] = []) -> [Double]
func get(_ key: String, legacyKeys: [String] = []) -> [Double?]
func get(_ key: String, onFail: Bool, legacyKeys: [String] = []) -> Bool
func get(_ key: String, legacyKeys: [String] = []) -> Bool?
func get(_ key: String, legacyKeys: [String] = []) -> [Bool]
func get(_ key: String, legacyKeys: [String] = []) -> [Bool?]
func get(_ key: String, onFail: Date = Date(), legacyKeys: [String] = []) -> Date
func get(_ key: String, legacyKeys: [String] = []) -> Date?
func get(_ key: String, legacyKeys: [String] = []) -> [Date]
func get(_ key: String, legacyKeys: [String] = []) -> [Date?]
func getObject<T>(_ key: String, type: T.Type, legacyKeys: [String] = []) -> T where T: Storable
func getObjectOptional<T>(_ key: String, type: T.Type, legacyKeys: [String] = []) -> T? where T: Storable
func getObjectArray<T>(_ key: String, type: T.Type, legacyKeys: [String] = []) -> [T] where T: Storable
func toRawString() -> String?

Description

  • Swift Tools 5.7.0
View More Packages from this Author

Dependencies

Last updated: Sun Mar 31 2024 15:54:16 GMT-0900 (Hawaii-Aleutian Daylight Time)