- 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 toCoreData
,SwiftDatastore
is typed. It means that you can not have access to properties and entities by string keys. SwiftDatastore
has set ofPropertyWrappers
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 😊!
- Installation
- Create DatastoreObject
- DatastoreObject Properties
- Setup
- SwiftDatastore’s operations
- Using ViewContext
- Using FetchedObjectsController
OrderBy
Where
- 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
Branch
and typemain
. - Click
Add Package
. - Choose
SwiftDatastore
Package 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 install
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 ...
}
}
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 = .developer
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
👌 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 = company
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
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")
👌 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
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 thesuccess
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!
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.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.
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.numberOfSections
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)
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.