Skip to content

Latest commit

 

History

212 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CorePersistence

A protocol-oriented, batteries-included foundation for persistence in Swift.

Goals

This library has ambitious goals:

  • Provide a protocol-oriented foundation for all the critical aspects of a typical, modern Swift application's persistence layer.
  • Provide standard, high performance primitives for the most common data formats (JSON, CSV, XML etc.).
  • Unf*** Codable.

Features

  • An opinionated, protocol-oriented encapsulated of persistent identifiers (both type identifiers and instance identifiers).
  • A modular plugin system for Codable (achieved by custom encoders & decoders that can wrap existing ones, macros, and a suite of protocols).
  • Better diagnostics for Codable errors (EncodingError and DecodingError are subpar).
  • Essential data storage primitives (see @FileStorage and @FolderStorage – similar to SwiftUI's @AppStorage but for the application's persistence layer.)
  • A high performance JSON primitive.
  • A high performance CSV primitive.
  • A high performance XML primitive (backed by the excellent XMLCoder library for now).

Examples:

JSONSchema

The JSONSchema product provides a language-independent representation of the commonly used JSON Schema Draft 2020-12 core, applicator, validation, and metadata vocabulary. It preserves Boolean schemas, $schema, $id, $defs, single and union type declarations, object/array/string/number constraints, and schema composition and conditionals during Codable round trips.

import JSONSchema

JSONSchema.validate(_:) validates Codable values or CorePersistence's JSON representation. It resolves local #/$defs/... references. Schema retrieval for external references and assertion of annotation-only keywords such as format, readOnly, and writeOnly are deliberately left to callers.

let restaurantBookingSchema = JSONSchema(
    type: .object,
    description: "Information required to make a restaurant booking",
    properties: [
        "name": JSONSchema(
            type: .string,
            description: "The name of the restaurant"
        ),
        "date" : JSONSchema(
            type: .string,
            description: "The date of the restaurant booking in yyyy-MM-dd format. Should be a date with a year, month, day."
        ),
        "time" : JSONSchema(
            type: .string,
            description: "The time of the reservation in HH:mm format. Should include hours and minutes."
        ),
        "number_of_people" : JSONSchema(
            type: .integer,
            description: "The total number of people the reservation is for"
        )
    ],
    // the required parameter specifies whether all properties listed are required or not
    // note that you can also pass in an array of strings specifying the properties that are required as follows:
    // required: ["name", "date", "time"] - "number_of_people" not required
    required: false 
)

You can also create a JSONSchema based on your object as follows:

struct RestaurantBooking: Codable, Hashable, Sendable {
    let name: String?
    let date: String?
    let time: String?
    let numberOfPeople: Int?
}

func createRestaurantBookingSchema() -> JSONSchema? {
    do {
        let restaurantBookingSchema: JSONSchema = try JSONSchema(
            type: RestaurantBooking.self,
            description: "Information required to make a restaurant booking",
            propertyDescriptions: [
                "name": "The name of the restaurant",
                "date": "The date of the restaurant booking in yyyy-MM-dd format. Should be a date with a year, month, day.",
                "time": "The time of the reservation in HH:mm format. Should include hours and minutes.",
                "number_of_people": "The total number of people the reservation is for"
            ],
            required: false
        )
        return restaurantBookingSchema
    } catch {
        print(error)
        return nil
    }
}

@FileStorage

The @FileStorage property wrapper is a tool designed to simplify data persistence by automatically handling the reading and writing of data to files. Features include:

  • Automatic Data Handling: @FileStorage automates the process of storing and retrieving data. Data is automatically written to a file whenever it changes, and read from the file when needed.
  • Configurable Storage Location: You can specify where the data should be stored, such as in the application's documents directory (.appDocuments) or other specific locations (e.g. .desktop, .downloads, .musicDirectory) that can be configured based on permissions and user settings. This flexibility ensures that data storage can be adapted to different application needs and environments.
  • Customizable Serialization: It supports different data coders like JSON or property list, allowing for easy serialization and deserialization of complex data types. This is useful for storing custom objects, as long as they conform to the Codable protocol.
  • Error Handling Strategies: @FileStorage offers customizable error handling strategies, such as discarding corrupted data and resetting to default values, or even halting the application on errors. This is critical for maintaining data integrity and application stability.

Using @FileStorage can be used as an alternative to SwiftData for simpler, smaller-scale applications because it offers a more straightforward and lightweight approach to data persistence. It seamlessly integrates with SwiftUI, providing an easy-to-use, declarative syntax that minimizes boilerplate code and automatically handles serialization of Codable objects. This eliminates the need for complex database setup, schema management, and migrations, making it ideal for applications that don't require the advanced features and overhead of a full-fledged database system. Additionally, @FileStorage allows for customizable error handling strategies, ensuring data integrity without the complexity of managing a relational database.

// Making DataStore an ObservableObject allows to receive notifications when values change
// In View: @StateObject var dataStore: DataStore = .shared
public final class DataStore: ObservableObject {
    
    @MainActor
    // the DataStore should be a singleton
    public static let shared = DataStore()
    
    @FileStorage(
        // directory of the app docuemnts set to .appDocuments means that the file will be stored in the app sandbox. User permissions dialog will not show up
        // if you use other options (e.g. .documents, .desktop, .downloads, .musicDirectory, etc), make sure to enable app permissions to access those folders. The user will have to grant permissions.
        .appDocuments,
        path: "path.json",
        coder: .json, // .propertyList is also supported
        // In case of read error, discard existing data (if any) and reset with the initial value.
        options: .init(readErrorRecoveryStrategy: .discardAndReset)
    )
    
    // the file will be auto-updated as objects are changed
    var objects: IdentifierIndexingArrayOf<MyIdentifiableObject> = []

    @MainActor
    private init() {
        if objects.isEmpty {
            objects = [.init(someText: "Hello World")]
        }
    }
}

// must conform to Identifiable, Hashable, and Codable
public struct MyIdentifiableObject: Identifiable, Hashable, Codable {
    public var id = UUID()
    public var someText: String?
    
    init(someText: String?) {
        self.someText = someText
    }
}

Other custom options for initializing @FileStorage:

// For Application Groups
@FileStorage(
    location: {
        return try! URL(
            directory: .securityApplicationGroup("group.com.yourgroupname.Shared")
        )
        .appending(path: "DirectoryPath", directoryHint: .isDirectory)
        .appending(path: "data.json")
    },
    coder: JSONCoder(), // TOMLCoder() also supported
    options: .init(readErrorRecoveryStrategy: .fatalError)
)

// Storing in Home Directory using URL
// This will require to updated settings to allow Read/Write access to the directory
@FileStorage(
    url: URL.homeDirectory.appending(path: "data.json"),
    coder: JSONCoder()
)

// Specifying Path & Filename
@FileStorage(
    directory: .appDocuments,
    path: "ProjectName",
    filename: UUID.self,
    coder: HadeanTopLevelCoder(coder: JSONCoder()),
    options: .init(readErrorRecoveryStrategy: .discardAndReset)
)

@HadeanIdentifier

@HadeanIdentifier is a general purpose persistent identifier to identify distinct objects. Usage:

@HadeanIdentifier("guvol-haboz-motiz-povag")
struct MyObject {
// your object code here
}

It is recommended to use a Proquint - Identifiers that are not real words but are as Readable, Spellable, and Pronounceable as words.

Python scrip to generate a proquint:

from proquint import uint2quint
import random

# Generate a unique string of 4 proquint words
unique_string = '-'.join(uint2quint(random.getrandbits(32)) for _ in range(4))
print(unique_string)

TopLevelDecoder._modular()

Sample JSON Data:

let jsonData = """
{
    "id": 1,
    "name": "John Doe",
    "email": "john@example.com"
}
""".data(using: .utf8)!

Using regular JSONDecoder():

do {
    // using regular JSONDecoder()
    let decoder = JSONDecoder()
    let user = try decoder.decode(User.self, from: jsonData)
} catch {
    print(error)
}

Printed Error when using JSONDecoder():

keyNotFound(CodingKeys(stringValue: "wrongKey", intValue: nil),
Swift.DecodingError.Context(codingPath: [],
debugDescription: "No value associated with key CodingKeys(stringValue: \"wrongKey\", intValue: nil) (\"wrongKey\").",
underlyingError: nil))

Using JSONDecoder()._modular():

do {
    // using regular JSONDecoder()._modular()
    let decoder = JSONDecoder()._modular()
    let user = try decoder.decode(User.self, from: jsonData)
} catch {
    print(error)
}

Printed Error when using JSONDecoder()._modular() (actual JSON Data printed out):

keyNotFound("wrongKey",
context for User: (coding path: []),
Optional(["id": 1.0, "email": "john@example.com", "name": "John Doe"]))

License

CorePersistence is licensed under the MIT License.

Acknowledgments

XMLCoder

About

A protocol-oriented foundation for your app's persistence layer.

Resources

Stars

9 stars

Watchers

3 watching

Forks

Releases

Packages

Contributors

Languages