- It is a wrapper to CoreData which adds additional layer of adstraction. It has been created to add opportunity to use
CoreDatain easy and safe way. In opposite toCoreData,SwiftDatastoreis typed. It means that you can not have access to properties and entities by string keys. SwiftDatastorehas set ofPropertyWrapperswhich allow you for getting and setting value without converting types manually every time. You decide what type you want,SwiftDatastoredoes the rest for you.- You don't need to create xcdatamodel.
SwiftDatastorecreate model for you.
Just try it 😊!
- Installation
- Create DatastoreObject
- DatastoreObject Properties
- Setup
- SwiftDatastore’s operations
- Using ViewContext
- Using FetchedObjectsController
OrderByWhere- Observing DatastoreObject Properties
- Testing
- In your Xcode's project in navigation bar choose File -> Add Packages...
- Pase https://github.com/tomkuku/SwiftDatastore.git in search field.
- As Dependency Rule choose
Branchand typemain. - Click
Add Package. - Choose
SwiftDatastorePackage Product in the target which you want to use it. - Click
Add Package.
In Podfile in the target in which you want to use SwiftDatastore add:
pod 'SwiftDatastore',and then in Terminal run:
pod installTo 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 ...
}
}Each property is property wrapper.
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
}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?
}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 = .developerIt 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👌 Add info comment about the inverse to make code more readable.
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 = companyIt 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 = employeeFirstly 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")👌 You can create separate model and separate store for different project configurations.
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.
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.
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()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
successclosure is called when performing the closure will end without any exeptions. - The
failureclosure is called when performing the closure will be broken by an exception. You can handle an error you will get. In this case thesuccessclosure 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!
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()
}It deletes a single DatastoreObject object from datastore.
datastoreContext.perform { context in
context.deleteObject(employee)
}If context is a child context it saves changes into its parent. Otherwise it saves local changes into SQL database.
datastoreContext.perform { context in
try viewContext.saveChnages()
}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
}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)
}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.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.
ℹ️ 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
}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.
}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()
}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.
saveChanges() after call this method.
datastoreContext.perform { context
let numberOfDeleted = try context.deleteMany(Employee.self, where: (\.$surname ^= "Smith"))
}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.
saveChanges() after call this method.
datastoreContext.perform { context
let numberOfUpdated = try context.updateMany(Employee.self,
where: \.$surname |= "Smith",
propertiesToUpdate: [.init(\.$age,
.init(\.$name, "Jim")])
}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.
var savedChanges: SwiftCoredataSavedChanges!
datastoreContext1.perform { context in
savedChanges = try viewContext.saveChnages()
} success {
datastoreContext1.perform { context in
context.refresh(with: savedChanges)
}
}It's created to cowork with UI components.
ViewContext must be called on main queue (main thread).
To create Datastore's ViewContext instance you must:
let viewContext = swiftDatastore.sharedViewContextIt 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.
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()Returns number of fetched sections which are created by passed groupBy keyPath.
let numberOfSections = fetchedObjectsController.numberOfSectionsReturns 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)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)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)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)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.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".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.
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)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.