Swiftpack.co - tomkuku/SwiftDatastore as Swift Package

Swiftpack.co is a collection of thousands of indexed Swift packages. Search packages.
See all packages published by tomkuku.
tomkuku/SwiftDatastore 0.4.0
Elegant and easy way to store data in Swift
⭐️ 0
🕓 1 year ago
iOS
.package(url: "https://github.com/tomkuku/SwiftDatastore.git", from: "0.4.0")

SwiftDatastore

Elegeant and easy way to store data in Swift

Build Status Last Commit Code coverage Quality Gate


License Swift Versions Platforms


CocoaPods SMP


What is SwiftDatastore and why should I use it?

  • It is a wrapper to CoreData which adds additional layer of adstraction. It has been created to add opportunity to use CoreData in easy and safe way. In opposite to CoreData, SwiftDatastore is typed. It means that you can not have access to properties and entities by string keys.
  • SwiftDatastore has set of PropertyWrappers which allow you for getting and setting value without converting types manually every time. You decide what type you want, SwiftDatastore does the rest for you.
  • You don't need to create xcdatamodel. SwiftDatastore create model for you.

Just try it 😊!


Table of Content:



Installation

Swift Package Manager

  1. In your Xcode's project in navigation bar choose File -> Add Packages...
  2. Pase https://github.com/tomkuku/SwiftDatastore.git in search field.
  3. As Dependency Rule choose Branch and type main.
  4. Click Add Package.
  5. Choose SwiftDatastore Package Product in the target which you want to use it.
  6. Click Add Package.

CocoaPods

In Podfile in the target in which you want to use SwiftDatastore add:

pod 'SwiftDatastore',

and then in Terminal run:

pod install

Create DatastoreObject

To create DatastoreObject create class which inherites after DatastoreObject.

class Employee: DatastoreObject {
}

If you need to do something after the object is created, you can override the objectDidCreate method, which is only called after the object is created. This method does nothing by default.

class Employee: DatastoreObject {
    @Attribute.NotOptional var id: UUID

    override func objectDidCreate() {
        id = UUID()
    }
}

If you need to perform some operations after init use required init(managedObject: ManagedObjectLogic) but you need insert super.init(managedObject: managedObject) into it's body like on example below:

class Employee: DatastoreObject {
    // Properties

    required init(managedObject: ManagedObjectLogic) {
        super.init(managedObject: managedObject)

        // do something here ...
    }
}

DatastoreObject Properties

Each property is property wrapper.

Attributes

NotOptional

It represents single attribute which must not return nil value. Use this Attribute when you are sure that stored value is never nil.

⛔️ If this attribute returns nil value it will crash your app.

You can use it with all attribute value types. Full list of types which meet with AttributeValueType is below.

class Employee: DatastoreObject {
    @Attribute.NotOptional var id: UUID! // The exclamation mark at the end is not required.
    @Attribute.NotOptional var name: String
    @Attribute.NotOptional var dateOfBirth: Date
    @Attribute.NotOptional var age: Int // In data model this attribute may be: Integer 16, Integer 32, Integer 64.
}

👌 You can use objectDidCreate() method to set default value.

class Employee: DatastoreObject {
    @Attribute.NotOptional var id: UUID

    override func objectDidCreate() {
        id = UUID()
    }
}

👌 If you need to have constant (let) property of Attribute you can set it as private(set).

class Employee: DatastoreObject {
    @Attribute.NotOptional private(set) var id: UUID
}

Optional

It represents single attribute of entity which can return or store nil value.

class Employee: DatastoreObject {
    @Attribute.Optional var secondName: String? // The question mark at the end is required.
    @Attribute.Optional var profileImageData: Data?
}

Enum

It represents an enum value. This enum must meet the RawRepresentable and AttributeValueType protocol because it's RawValue is saved in SQLite database.

You can use it with all attribute value types. This type of Attribute is optional.

enum Position: Int16 {
    case developer
    case uiDesigner
    case productOwner
}

class Employee: DatastoreObject {
    @Attribute.Enum var position: Position?
}

// ...

employee.position = .developer

Relationships

ToOne

It represents one-to-one relationship beetwen SwiftDatastoreObjects.

There can be passed an optional and nonoptional Object.

class Office: DatastoreObject {
    @Relationship.ToOne(inverse: \.$office) var owner: Employee?
}

class Employee: DatastoreObject {
    @Relationship.ToOne var office: Office? // inverse: Office.owner
}

// ...

office.employee = employee

employee.office = office

⚠️ You must pass an inverse relationship in the declaration. Due Swift "Reference cycle" error, you can do it only one time.

👌 Add info comment about the inverse to make code more readable.

ToMany

It represents one-to-many relationship which is Set<Object>.

class Company: DatastoreObject {
    @Relationship.ToMany var emplyees: Set<Employee> // inverse: Employee.company
}

class Employee: DatastoreObject {
    @Relationship.ToOne(inverse: \.$emplyees) var company: Company
}

// ...

company.employees = [employee1, employee2, ...]
company.employess.insert(employee3)

employee.company = company

⚠️ This type of property must not be nil in saving moment.

ToMany.Ordered

It represents one-to-many relationship where objects are stored in ordered which is Array<Object>.

Whay Array instead of OrederedSet? By default Swift doesn't have OrderedSet collection. You can use it by adding other frameworks which supply ordered collections.

class Employee: DatastoreObject {
    @Relationship.ToMany.Ordered var tasks: [Task]
}

class Task: DatastoreObject {
    @Relationship.ToOne var employee: Employee?
}

// ...

company.tasks = [task1, task2, ...]
company.employee = employee

⚠️ Please, remember that this array can not have repetitions.

⚠️ This type of property must not be nil in saving moment.


Setup

Firstly you must create dataModel by passing types of DatastoreObjects which you want to use within it.

let dataModel = SwiftDatastoreModel(from: Employee.self, Company.self, Office.self)

⛔️ If you don't pass all required objects for relationships, you will get fatal error with information about a lack of objects.

It creates SQLite file with name: "myapp.store" which stores objects from passed model.

let datastore = try SwiftDatastore(dataModel: dataModel, storeName: "myapp.store")

⚠️ Creating SwiftDatastore instance may throw an exception.

👌 You can create separate model and separate store for different project configurations.


SwiftDatastoreContext

To create SwiftDatastoreContext instance you must:

let datastoreContext = swiftDatastore.newContext()

ℹ️ Each Context works on copy of objects which are saved in database. If you create two contexts in empty datastore and on the first context create an object, this object will be availiabe on the second context only when you perfrom save changes on the first context.

⚠️ Every operation on Context must be called on it's private queue like privateQueueConcurrencyType of ManagedObjectContext. Because of that, each Context allows to call methods only inside closure the perform method what guarantees performing operations on context's private queue. Performing methods or modify objects outside of closures of these methods may cause runs what even may crash your app.

Child Context

SwiftDatastore is based on the CoreData framework. For this reason it apply CoreData's parent-child concurrency mechanism. It allows you to make changes in child context like: update, delete, insert objects. Then you save changes into parent as all. Because of that saving changes is more safly then making a lot of changes on one context.

To create SwiftDatastoreContext instance you must:

let parentContext = swiftDatastore.newContext()
let childContext = parentContext.createNewChildContext()

SwiftDatastore's operations

perform

Code below this method is executing immediately without waiting for code inside the closure finish executing.

This method perform block of code you pass in first closure. Becasue of some opertaions may throw an exception:

  • The success closure is called when performing the closure will end without any exeptions.
  • The failure closure is called when performing the closure will be broken by an exception. You can handle an error you will get. In this case the success closure isn't called.
datastoreContext.perform { context in
    // code inside the closure
} success {

} failure { error in

}

You can also use method with completion. This construction is intended only for safe operations like: getting and setting values of DatastoreObjcet's properties because it can perform only opertaions which don't throw exceptions.

datastoreContext.perform { context in
    // code inside the closure
} completion {

}

⛔️ Never use try! to perform throwing methods. In a case of any exception it may crash your app!

createObject

Creates and returns a new instance of DatastoreObject.

You can create a new object only using this method.

This method is generic so you need to pass Type of object you want to create.

This method may throw an exception when you try to create DatastoreObject which entity name is invalid.

datastoreContext.perform { context in
    let employee: Employee = try context.createObject()
}

deleteObject

It deletes a single DatastoreObject object from datastore.

datastoreContext.perform { context in
    context.deleteObject(employee)
}

saveChanges

If context is a child context it saves changes into its parent. Otherwise it saves local changes into SQL database.

⚠️ You are responsible of saving changes. If you don't save changes and terminate your app, changes will disappear.

datastoreContext.perform { context in
    try viewContext.saveChnages()
}

revertChanges

This method reverts unsaved changes.

datastoreContext.perform { context in
    let employee = try context.createObject()

    employee.name = "Tom"
    employee.salary = 3000

    try context.save()

    employee.salary = 4000

    context.revertChnages()

    print(employee.salary) // output: 3000
}

fetch

It fetches objects from datastore.

This method is generic so must pass type of object you want to fetch.

datastoreContext.perform { context in
    let employees: [Employee] = try context.fetch(where: (\.$age > 30),
                                                  orderBy: [.asc(\.$name), .desc(\.$salary)],
                                                  offset: 10,
                                                  limit: 20)
}

fetchFirst

Fetches the first object which meets conditions.

You can use this method to find e.g. max, min of value in datastore using the orderBy parameter.

This method is generic so you have to pass type of object you want to fetch.

ℹ️ Return value is always optional.

datastoreContext.perform { context in
    let employee: Employee? = try context.fetchFirst(where: (\.$age > 30), 
                                                     orderBy: desc(\.$salary)])
}
// It returns Employee who has the highest (max) salary.

fetchProperties

Fetches only properties which keyPaths you passed as method's paramters.

Return type is an array of Dictionary<String, Any?>.

ℹ️ Parameter properties is required and is an array of PropertyToFetch. PropertyToFetch struct ensures that entered property is stored by DatastoreObject.

⚠️ You can fetch only values types like: String, Int, Data, Bool etc. You can not fetch any Relationships.

ℹ️ If you pass empty propertiesToFetch array this method will do nothing and return empty array.

ℹ️ This method returns properties values which objects has been saved in SQLite database.

var properties: [[String: Any?]] = []

datastoreContext.perform { context in
    properties = try context.fetch(Employee.self,
                                   properties: [.init(\.$salary), .init(\.$id)],
                                   orderBy: [.asc(\.$salary)])
    let firstSalary = fetchedProperties.first?["salary"] as? Float
}

count

Returns the number of objects which meet conditions in datastore.

datastoreContext.perform { context in   
    let numberOfEmployees = try context.count(where: (\.$age > 30))
    // It returns number of Employees where age > 30.
}

convert existing object

This method converts object between Datastore's Contexts.

You should use this method when you need to use object on different datastore then this which created or fetched this object.

⛔️ This method needs object which has been saved in SQLite database. When you try convert unsaved object this method throws a exception.

Example below shows how you can convert object from ViewContext into Context and update its property.

var carOnViewContext: Car = try! viewContext.fetchFirst(orderBy: [.asc(\.$salary)])

datastoreContext.perform { context
    let car = try context.convert(existingObject: carOnViewContext)

    car.numberOfOwners += 1

    try context.saveChanges()
}

deleteMany

It deletes many objects and optionally returns number of deleted objects.

As the first parameter you have to pass object's type you want to delete.

Parameter where is required.

After calling this method, property willBeDeleted returns ture. If you call saveChanges after that it returns false.

⚠️ This method deletes objects directly from SQLite database so it doesn't delete objects which have not saved yet. For the same reason you don't need to call saveChanges() after call this method.

datastoreContext.perform { context
    let numberOfDeleted = try context.deleteMany(Employee.self, where: (\.$surname ^= "Smith"))
}

updateMany

It updates many objects and optionally returns number of updated objects.

As the first parameter you have to pass object's type you want to update.

Parameter where is not required.

Parameter propertiesToUpdate is required and is an array of PropertyToUpdate. PropertyToUpdate struct ensures that entered value is the same type as it's key.

If you pass empty propertiesToUpdate array this method will do nothing and return 0.

⚠️ This method updates objects directly in SQLite database so it doesn't update objects which have not saved yet. For the same reason you don't need to call saveChanges() after call this method.

datastoreContext.perform { context
    let numberOfUpdated = try context.updateMany(Employee.self,
                                                 where: \.$surname |= "Smith",
                                                 propertiesToUpdate: [.init(\.$age,
                                                                      .init(\.$name, "Jim")])
}

refresh

This method causes refreshing any objects which have been updated and deleted on the context from changes has made. Values of these objects is revering to last state from SQLite database or from context's parent if exists.

⚠️ This method doesn't apply changes made on another context.

var savedChanges: SwiftCoredataSavedChanges!

datastoreContext1.perform { context in
    savedChanges = try viewContext.saveChnages()
} success {

    datastoreContext1.perform { context in
       context.refresh(with: savedChanges)
    }
}

Using ViewContext

It's created to cowork with UI components.

⚠️ Every operation on ViewContext must be called on main queue (main thread).

To create Datastore's ViewContext instance you must:

let viewContext = swiftDatastore.sharedViewContext

It allows you to call operations and get objects values without using closures when it's not neccessary. But it's you are responsible to call methods on mainQueue using DispatchQueue.main.

Example how to use methods:

// mainQueue

let employees: [Employee] = try viewContext.fetch(where: (\.$age > 30),
                                                  orderBy: [.asc(\.$name), .desc(\.$salary)],
                                                  offset: 10,
                                                  limit: 20)

nameLabel.text = employee[0].name 

ℹ️ It's highly recommended to use offset and limit to increase performance.


Using FetchedObjectsController

⚠️ Every operation on ViewContext must be called on main queue (main thread).

Configuration is simillar to initialization NSFetchedResultsController. You need to pass viewContext, where, orderBy, groupBy. Then call performFetch method.

Parameter orderBy is required.

ℹ️ If you don't pass groupBy, you will get a single section with all fetched objects.

let fetchedObjectsController = FetchedObjectsController<Employee>(
    context: managedObjectContext,
    where: \.$age > 22 || \.$age <= 60,
    orderBy: [.desc(\.$name), .asc(\.$age)],
    groupBy: \.$salary)

fetchedObjectsController.performFetch()

numberOfSections

Returns number of fetched sections which are created by passed groupBy keyPath.

let numberOfSections = fetchedObjectsController.numberOfSections

numberOfObjects

Returns number of fetched objects in specific section.

ℹ️ If you pass sections index which doesn't exist this method returns 0.

let numberOfObjects = fetchedObjectsController.numberOfObjects(inSection: 1)

getObject

Returns object at specific IndexPath.

⛔️ If you pass IndexPath which doesn't exist this method runs fatalError.

let indexPath = IndexPath(row: 1, section: 3)

let objectAtIndexPath = fetchedObjectsController.getObject(at indexPath: indexPath)

sectionName

This method returns section name for passed section index. Because of gorupBy parameter may be any type, this method returns section name as String. You can convert it to type you need.

let sectionName = fetchedObjectsController.sectionName(inSection: 0)

observeChanges

This method is called every time when object which you passed as FetchedObjectsController's Generic Type has changed.

Change type:

  • inserted - when object has been inserted.
  • updated - when object has been updated.
  • deleted - when object has been deleted.
  • moved - when object has changed its position in fetched section.

ℹ️ This method informs about one change. For example: when object of type Employee will be inserted and than deleted this method is called twice.

fetchedObjectsController.observeChanges { change in
    switch change {
    case .inserted(employee, indexPath):
        // do something after insert
    case .updated(employee, indexPath):
        // do something after update
    case .deleted(indexPath):
        // do something after delete
    case .moved(employee, sourceIndexPath, destinationIndexPath):
        // do something after move
    }
}

You can aso Use Combine's changesPublisher and subscribe changes.

fetchedObjectsController.
    .changesPublisher
    .sink { change
        switch change {
        case .inserted(employee, indexPath):
            // do something after insert
        case .updated(employee, indexPath):
            // do something after update
        case .deleted(indexPath):
            // do something after delete
        case .moved(employee, sourceIndexPath, destinationIndexPath):
            // do something after move
        }
    }
    .store(in: &cancellable)

OrderBy

It's a wrapper to CoreData's NSSortDescriptor. It's enum which contains two cases:

  • asc - ascending,
  • desc - descending.

To use it you have to pass keyPath to DatastoreObject's ManagedObjectType.

extension PersonManagedObject {
    @NSManaged public var age: Int16
    @NSManaged public var name: String
}

final class Person: DatastoreObject {
    @Attribute.Optional var age: Int?
    @Attribute.NotOptional var name: String
}

let persons: [Person] = context.fetch(orderBy: [.asc(\.$name), .desc(\.$age)])
// It returns array of Persosns where age is ascending and age is descending.

Where

It's a wrapper to CoreData's NSPredicate.. You can use prepared operators:

  • > - greater than
  • >= - greater than or equal to
  • < - less than
  • <= - less than or equal to
  • == - equal to
  • != - not equal to
  • ?= - contains (string)
  • ^= - begins with (string)
  • |= - ends with (string)
  • && - and
  • || - or
extension PersonManagedObject {
    @NSManaged public var age: Int16
    @NSManaged public var name: String
}

final class Person: DatastoreObject {
    @Attribute.Optional var age: Int?
    @Attribute.NotOptional var name: String
}

let persons: [Person] = context.fetch(where: \.$age >= 18 && (\.$name ^= "T") || (\.$name |= "e"))
// It returns array of Persosns where age is great than 18 and name begins with "T" or ends with "e".

Observing DatastoreObject Properties

You can observe changes of any Attribute and Relationship. The closure is performed every time when a value of observed property changes no matter either the change is done on observed instance of DatastoreObject or on another instance but with the same DatastoreObjectID in the same SwiftDatastoreContext.

⚠️ You can add only one observer per one instance of DatastoreObject. If you add more then one only the last one will be performed.

employee.$name.observe { newValue in
    // New value of Optional Attribute may be nil or specific value.
}

You can also use Combine's newValuePublisher to subscribe any newValue.

employee.$position
    .newValuePublisher
    .sink { newValue in
        // do something after change
    }
    .store(in: &cancellable)

Testing

You can use SwiftDatastore in your tests. All what you need to do is set storingType to test as example below:

let datastore = try SwiftDatastore(dataModel: dataModel, storeName: "myapp.store.test", storingType: .test)

In that configuration SwiftDatastore create normal sqlite file but it will delete it when your test will end or when you call test again (eg. in the case when you got crash). As a result, in every test you work on totally new data.

GitHub

link
Stars: 0
Last commit: 1 year ago
Advertisement: IndiePitcher.com - Cold Email Software for Startups

Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API | Analytics