Lucid - CoreManager
CoreManager
is the key class that binds lucid together. It is the interface Lucid provides to read and write entities to the stores. When you send a get
, search
, set
, or delete
action to the CoreManager
, it will update your local stores and/or interact with the remote stores. In this sense, whether you are fetching data from memory, CoreData, or from your faraway server, the API your application interacts with is virtually identical, and the data returned is of a singular type. Saving/updating data can update your local stores and send out an http request to your server in a single expression.
CoreManager
is a generic object and there will be one per entity type in your application. The CoreManager
will contain a generic StoreStack
that by default will be populated by Stores
that are informed by the entity description. If you so choose, you can override the store implementation on an entity per entity basis.
Every method of CoreManager
takes a ReadContext<E>
/WriteContext<E>
, and returns an object of type AnyPublisher<QueryResult<E>, ManagerError>
.
-
AnyPublisher
comes from the libraryCombine
and allows you to subscribe to asynchronous events. For some operations, only one event is sent with a result; for some other operations, multiple events can be sent, so in a way, a publisher can be seen as a stream of results. -
QueryResult<E>
is an object containing the requested entities. Depending on the operation, results can be grouped, combined into a simple list or even a single entity.QueryResult
retrieves the data in whichever format is expected. -
ManagerError
is an enum for which every case represents an error that can happen when requesting entities. The errors are classified per domain to make it easier to understand the cause.
Context objects contain information about where entities should come from (data source), where they should be stored (target), and sometimes which endpoint should be reached and how the served data should be parsed.
ReadContext
is a context used with read operations.
To build it, the following parameters are passed:
-
Data Source: Describes where the data comes from (defaults to
.local
).There are few useful combinations of data source:
.local
: The data can only come from local stores..remote
: The data can only come from remote stores. By default, the endpoint to reach is derived from the entity type (endpoint: .derivedFromEntityType
), the served entities will automatically be saved to the local stores (persistenceStrategy: .persist(.retainExtraLocalData)
) without considering them as a complete set (trustRemoteFiltering: false
)..remote(endpoint: .request(APIRequestConfig(...), resultPayload: .myPayload))
: The data comes from the server's response to the specified request, usingmyPayload
for parsing..remoteOrLocal(...)
: The data come from the remote stores. If for any reason they aren't remotely accessible, Lucid fallsback to using the local stores..localThenRemote(...)
: The data come from the local stores, then from the remote stores. The second event can be observed using a continuous publisher.
-
Contract: Object in charge of validating the data coming through (defaults:
AlwaysValidContract()
). -
Access Validator: Object in charge of validating that the data coming through is accessible to the current user (optional).
WriteContext
is a context used with write operations.
To build it, the following parameters are passed:
-
Data Target: Describes where the data goes (required).
A target can be one of the following:
.local
: Saves to the local stores only..remote(endpoint: .request(APIRequestConfig(...)))
: Saves to the remote stores using the specified API request..remote(endpoint: .derivedFromPath({ ... }))
: Saves to the remote stores using the specified builder..remote(endpoint: .derivedFromEntityType)
: Saves to the remote stores by derivating the API request from the entity type..localAndRemote(endpoint: ...)
: Saves to both local and remote stores.
-
Access Validator: Object in charge of validating that the data target is accessible to the current user (optional).
Queries are objects used to filter, group, order or paginate entities. Lucid supports a number of functions and inline operators to make building queries more natural and readable. They are:
-
Equality:
Query<MyEntity>.filter(.myProperty == .string("my_property_value")) Query<MyEntity>.filter(.myProperty != .string("my_property_value")) Query<MyEntity>.filter(.identifier == .identifier(myEntityIdentifier)) Query<MyEntity>.filter(.identifier != .identifier(myEntityIdentifier))
-
Regex:
Query<MyEntity>.filter(.myProperty ~= .string("my_property_.*"))
-
Comparison:
Query<MyEntity>.filter(.myProperty > .string("my_property_value")) Query<MyEntity>.filter(.myProperty >= .string("my_property_value")) Query<MyEntity>.filter(.myProperty < .string("my_property_value")) Query<MyEntity>.filter(.myProperty <= .string("my_property_value"))
-
Or:
Query<MyEntity>.filter(.myProperty == .string("value_one") || .myProperty == .string("value_two"))
-
And:
Query<MyEntity>.filter(.myProperty > .int(0) && .myProperty < .int(10))
-
Not:
Query<MyEntity>.filter(!(.myProperty == .string("my_property_value")))
-
With a property:
Query<MyEntity>.filter(.myProperty >> ["my_property_value_one", "my_property_value_two"])
-
With an identifier:
Query<MyEntity>.filter(.identifier >> myEntityIdentifiers)
-
.asc
/.desc
:Query<MyEntity>.all.order([.asc(by: .myProperty)]) Query<MyEntity>.all.order([.desc(by: .myProperty)])
-
.natural
: Keeps the natural order served by the remote stores. Only makes sense for remote stores.Query<MyEntity>.all.order([.natural])
-
.identifiers
: Restitutes the passed identifiers' orderQuery<MyEntity>.all.order([.identifiers(myEntityIdentfiers)])
Query<MyEntity>.all.grouped(by: .myProperty)
Query<MyEntity>.all.with(offset: 42).with(limit: 10)
Query<MyEntity>
.filter(.myProperty ~= .string("my_property_.*") && .myProperty != .string("excluded_value"))
.order([.desc(.myProperty)])
.with(limit: 10)
Looking for entity using its identifier is the prefered way to fetch a unique entity. It is usually faster than using a search query.
manager
.get(byID: myEntityIdentifier, in: ReadContext<MyEntity>(dataSource: .local))
.sink(receiveCompletion: { ... }, receiveValue: { ... })
.store(in: cancellables)
Looking for entities is done through the search
operation.
let publishers = manager.search(
withQuery: .filter(.identifier << [myEntityIdentifierOne, myEntityIdentifierTwo]),
in: ReadContext<MyEntity>(dataSource: .local)
)
publishers
.once
.sink(receiveCompletion: { ... }, receiveValue: { ... }) // Receiving once.
.store(in: cancellables)
publishers
.continuous
.sink(receiveCompletion: { [weak self] ... }, receiveValue: { [weak self] ... }) // Receiving for every data change.
.store(in: cancellables)
The search
operation returns two publishers:
once
receives one unique result. It is usually used for operations which don't require to be reactive to changes.continuous
receives one result per data change. It is usually used for refreshing views or monitoring data in a reactive manner.
Important: When using a continuous publisher, make sure there isn't a possibility of retain cycle between the receive blocks and the cancellables store. Unlike for a once
publisher, CoreManager
retains continuous
publishers until they aren't in use anymore. If a retain cycle keeps the publisher alive, CoreManager
will keep track of it forever, which might become expensive over time. Use weak references for any calls back to the object holding the observer.
A mutable entity can be saved or updated using the set
operation.
let myEntity = MyEntity(...)
manager
.set(myEntity, in: WriteContext(dataTarget: .local))
.sink(receiveCompletion: { ... }, receiveValue: { ... })
.store(in: cancellables)
The same way one mutable entity can be set, a list of entities can be set using the same operation.
let myEntities = [
MyEntity(...),
MyEntity(...)
]
manager
.set(myEntities, in: WriteContext(dataTarget: .local))
.sink(receiveCompletion: { ... }, receiveValue: { ... })
.store(in: cancellables)
The most performant way to remove one single entity is to use the remove
operation with its identifier.
manager
.remove(at: myEntityIdentifier, in: WriteContext(dataTarget: .local))
.sink(receiveCompletion: { ... }, receiveValue: { ... })
.store(in: cancellables)
Multiple entities can be removed by passing a list of identifiers.
manager
.remove([myEntityIdentifierOne, myEntityIdentifierTwo], in: WriteContext(dataTarget: .local))
.sink(receiveCompletion: { ... }, receiveValue: { ... })
.store(in: cancellables)
Sometimes, fetching only one level of entities isn't enough and although it is possible to retrieve an entity's relationships manually, it can become tedious. This is why Lucid provides an easy way to fetch relationships.
When fetching relationships, Lucid aggregates all the different types of entities in the EntityGraph
. Once the EntityGraph
is built, retrieving an entity's relationships becomes easy (and synchronous).
For example:
guard let myEntity = entityGraph.myEntities.first else { return }
let relationships = myEntity.relationships.compactMap { entityGraph.myEntityRelationships[$0] }
In case the relationships were fetched from a list of entities, it is important to know how to retrieve that initial list from the graph.
Here is how to do so:
let myEntities = entityGraph.rootEntities.compactMap { entity in
switch entity {
case .myEntity(let entity):
return entity
default:
return nil
}
}
Note: The array rootEntities
will always contain the root level of the graph only, whereas the dictionary entityGraph.myEntities
will contain all of the objects at all nested levels of the graph. The former is where you can start parsing, and the latter is used for all relationship lookups.
manager
.rootEntity(
byID: myEntityIdentifier,
in: ReadContext<MyEntity>(dataSource: .local)
)
.including([.myRelationshipsProperty])
.perform()
.once
.sink(receiveCompletion: { ... }, receiveValue: { ... })
.store(in: cancellables)
manager
.rootEntities(
for: .all,
in: ReadContext<MyEntity>(dataSource: .local)
)
.including([.myRelationshipsProperty])
.perform()
.sink(receiveCompletion: { ... }, receiveValue: { ... })
.once
.store(in: cancellables)
It often happens that a relationship has another relationship, which itself has another relationship and so on. When it's the case, Lucid generates an appropriate structure of indices to help conveniently fetch relationships on more than one level.
For example:
manager
.rootEntities(
for: .all,
in: ReadContext<MyEntity>(dataSource: .local)
)
.including([
.firstRelationshipLevel([
.secondRelationshipLevel([
.thirdRelationshipLevel
])
])
])
.perform()
.sink(receiveCompletion: { ... }, receiveValue: { ... })
.once
.store(in: cancellables)