diff --git a/README.md b/README.md index 811544a..d674372 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,17 @@ # MIOEntityCore -A description of this package. -# MIOEntityCore +Keep track of a batch of objects: which ones you have, and what type each one is. + +## Overview + +`MIOEntityCore` gives you two small in-memory indexes, both keyed by a type name and a UUID, for the +questions that come up every time you work through a batch: what is in it, and do I already have this +particular one. Both understand that one type can be based on another, so you can store a `MenuItem` +and find it again by asking for a `Product`, which a plain dictionary cannot do. Neither one writes +anything anywhere, and what you put in is gone once you let go of it. + +Which one you want depends on the question you are answering. `MECEntityCache` sorts a mixed batch +into groups, so you can act on it one type at a time rather than one object at a time. `MECCache` +holds several sets at once and answers "is this one in that set", keeping a version number per +object. They share no code, and there is one difference worth knowing before you start: storing +something you already hold replaces it in `MECEntityCache` and is ignored by `MECCache`. diff --git a/Sources/MIOEntityCore/MECCache.swift b/Sources/MIOEntityCore/MECCache.swift index 61777aa..9856932 100644 --- a/Sources/MIOEntityCore/MECCache.swift +++ b/Sources/MIOEntityCore/MECCache.swift @@ -9,8 +9,22 @@ import Foundation import MIOCoreLogger +/// The closure ``MECCache/update(entity:_:version:updateBlock:)`` calls to rewrite a cached body. +/// +/// You are handed the current body and return the new one. Returning the value you were given leaves +/// the body untouched while the object's version is still set to the one you passed. +/// +/// The body is typed `Any` rather than the cache's `T` because ``MECCacheObject`` stores it untyped; +/// cast it to your body type inside the closure. public typealias MECCacheObjectUpdateBlock = ( _ body:Any ) -> Any +/// A single object held by a ``MECCache``: its entity, id, version and body. +/// +/// ``MECCache/insert(entity:id:body:version:)`` and ``MECCache/fetch(entity:id:)`` hand these back. +/// +/// - Important: Every member of this class is internal, so from outside `MIOEntityCore` the object is +/// an opaque handle with nothing readable on it. To get at the contents, use +/// ``MECCache/value(entity:id:version:)``, which returns the typed body directly. public class MECCacheObject { let hash:String = UUID().uuidString @@ -36,6 +50,47 @@ public class MECCacheObject } } +/// An index that answers "is this one in this set", inheritance included. +/// +/// Keyed by ``MECEntity`` and UUID, with a version per object. Reach for this when one pass needs to +/// hold several sets of the same objects and ask which set a given id is in: what you already had, +/// what a batch is asking to change, the two combined. There is no separate set type here, so when +/// you have nothing to keep alongside the ids, use a `MECCache` and store `true`. +/// +/// ``MECEntityCache`` answers the other question, "which entities and ids are in this batch", and is +/// the better fit when you want to group a batch and act on it a group at a time. The two share no +/// code and neither sees the other's contents. +/// +/// ## What it gives you over a dictionary +/// +/// - **Identity.** Objects are keyed by `(entity, id)`, and ``insert(entity:id:body:version:)`` is +/// idempotent, so re-inserting an id you already hold returns what you already have instead of +/// duplicating it. That is what you want when overlapping batches keep arriving. +/// - **Versions.** Every object carries an `Int64` version that you set and read. Nothing in the +/// cache interprets it. +/// - **Missing ids.** ``diffIDs(entity:ids:)`` answers "which of these ids am I missing", so you can +/// go and get only the ones you lack. It compares ids alone, never versions. +/// - **Inheritance.** An object inserted as a subentity is also findable by its concrete +/// superentities, which a plain `[String: [UUID: T]]` cannot do. +/// +/// ```swift +/// let product = MECEntity( name: "Product" ) +/// let menu_item = MECEntity( name: "MenuItem" ) +/// menu_item.setParent( product ) +/// +/// let cache = MECCache<[String:Any]>( ) +/// let obj = cache.insert( entity: menu_item, id: item_id, body: [ "name": "Latte" ], version: 3 ) +/// +/// // found through the superentity, because Product is concrete +/// let body = cache.value( entity: product, id: item_id ) +/// let missing = cache.diffIDs( entity: menu_item, ids: wanted_ids ) +/// ``` +/// +/// - Warning: This type is **not thread safe**. It is a class holding plain mutable dictionaries with +/// no locking, so confine an instance to a single thread or serialise access yourself. +/// +/// - Note: Insertions and removals emit `Log.debug` lines of the form `Inserting REFID: Name://uuid`, +/// which is why the package depends on `MIOCoreLogger`. public class MECCache { var _entity_graph:[String:String] @@ -51,6 +106,10 @@ public class MECCache return UUID(uuidString: str )! } + /// Creates an empty cache. + /// + /// The cache holds no reference to a ``MECModel``. You pass the entities in on every call, so it + /// is up to you to keep handing it entities from the same model. public init() { _entity_graph = [:] _entities_by_hash = [:] @@ -75,15 +134,54 @@ public class MECCache } } + /// The names of the entities objects have been inserted as, in no particular order. + /// + /// This reports each object's own concrete entity, not the superentities it was additionally + /// indexed under. For the ids held for a given name, including inherited registrations, use + /// ``ids(fromEntityName:)``. + /// + /// - Note: A name appears here once anything has been inserted under it, and keeps appearing + /// after ``remove(entity:id:)`` takes its last object away. Use ``ids(fromEntityName:)`` when + /// you need the ids actually held, and ``contains(entity:id:)`` to test a single object; both + /// reflect removals immediately. public var entitiesByName: [String] { let entities_names = Set( _objects.map { $0.entity.name } ) return Array( entities_names ) } - + + /// The ids currently held for an entity name. + /// + /// Includes ids registered under this name through inheritance, so asking for a concrete + /// superentity returns the ids of its subentities' objects too. + /// + /// - Parameter entityName: The entity name to look up. + /// - Returns: The ids held, or an empty array if nothing is cached for that name. public func ids( fromEntityName entityName: String) -> [UUID] { return _entities_by_name[ entityName ] ?? [] } - + + /// Caches an object, indexing it under its entity and its concrete superentities. + /// + /// Idempotent: if the `(entity, id)` pair is already cached, the existing object is returned + /// untouched and `body` and `version` are ignored. To overwrite a cached body, use + /// ``update(entity:_:version:updateBlock:)``. + /// + /// - Parameters: + /// - entity: The object's concrete entity. + /// - id: The object id, as either a `UUID` or a UUID `String`. + /// - body: The value to cache. + /// - version: A version number of your own choosing, stored alongside the object. Defaults + /// to `0`. Nothing in the cache interprets it. + /// - Returns: The newly cached object, or the one already held for this `(entity, id)`. + /// + /// - Warning: `id` is force-converted. A `String` that is not a valid UUID, or any type other than + /// `UUID` and `String`, is a **crash**, not an error. + /// + /// - Warning: The result is not marked `@discardableResult`, so callers are forced to bind a value + /// whose members are all internal. Assign to `_` when you only want the side effect. + /// + /// - Note: The object is indexed under every **concrete** superentity, so it can later be fetched + /// by a parent entity. Abstract superentities are skipped, see ``MECEntity/init(name:isAbstract:)``. public func insert( entity: MECEntity, id: Any, body: T, version:Int64 = 0 ) -> MECCacheObject { let uuid = id is String ? _uuid_from_string( id as! String ) : id as! UUID @@ -95,6 +193,12 @@ public class MECCache _entities_by_hash[ obj!.hash ] = obj! _objects.append( obj! ) + // Abstract superentities are never indexed: the walk advances to the parent and then breaks + // *before* registering it when that parent is abstract. The same pattern repeats in + // remove/fetch/update below, so a lookup by an abstract entity always misses. + // + // All four walks in this type agree on this, so an abstract entity is never a valid + // lookup key. var parent:MECEntity? = entity while parent != nil { let hash = _hash_key( parent!, obj!.id ) @@ -108,6 +212,19 @@ public class MECCache return obj! } + /// Drops a cached object, unindexing it from its entity and its concrete superentities. + /// + /// Does nothing if the `(entity, id)` pair is not cached. + /// + /// - Parameters: + /// - entity: The entity the object was indexed under. + /// - id: The object id, as either a `UUID` or a UUID `String`. + /// + /// - Warning: `id` is force-converted, see ``insert(entity:id:body:version:)``. + /// + /// - Note: Registrations under subentities are left alone, so removing an object by one of its + /// parent types leaves the entry under its own type behind. ``fetch(entity:id:)`` clears that up + /// by itself the next time it runs into it. public func remove( entity: MECEntity, id: Any ) { let uuid = id is String ? _uuid_from_string( id as! String ) : id as! UUID @@ -143,6 +260,24 @@ public class MECCache } + /// Looks up a cached object by entity and id, walking up the inheritance chain. + /// + /// The lookup tries the entity you pass, then each concrete superentity in turn, so an object + /// inserted as a subentity is found when you ask for a parent. + /// + /// - Parameters: + /// - entity: The entity to look under. May be the object's own entity or a concrete superentity. + /// - id: The object id, as either a `UUID` or a UUID `String`. + /// - Returns: The cached object, or `nil` if nothing is held for this pair. + /// + /// - Important: The returned ``MECCacheObject`` has no public members, so outside this module it + /// is an opaque handle. Use ``value(entity:id:version:)`` when you want the body. + /// + /// - Warning: Returns `nil` for an **abstract** entity even when the object is cached, because + /// abstract entities are never indexed. See ``MECEntity/init(name:isAbstract:)``. + /// + /// - Note: Self-healing. If the index still points at an object that is no longer held, the stale + /// entry is removed and `nil` is returned. public func fetch( entity: MECEntity, id: Any) -> MECCacheObject? { let uuid = id is String ? _uuid_from_string( id as! String ) : id as! UUID @@ -169,11 +304,46 @@ public class MECCache return nil } + /// The typed body of a cached object, looked up the same way as ``fetch(entity:id:)``. + /// + /// - Parameters: + /// - entity: The entity to look under. May be the object's own entity or a concrete superentity. + /// - id: The object id, as either a `UUID` or a UUID `String`. + /// - version: Not consulted. See the note below. + /// - Returns: The body cast to `T`, or `nil` if nothing is cached for this pair or the stored body + /// is not a `T`. + /// + /// - Note: The cached object is returned whatever its version, so this does not filter out + /// objects older than the version you pass. ``MECCache/update(entity:_:version:updateBlock:)`` + /// is what sets an object's version. public func value( entity: MECEntity, id: Any, version: Int64 = 0 ) -> T? { let obj = fetch( entity: entity, id: id ) return obj?.body as? T } + /// Rewrites a cached object's body and sets its version. + /// + /// If the object is not indexed under `entity` directly, its superentities are searched. When it + /// is found that way, the object is re-pointed at `entity` and the index is extended to cover the + /// new chain, which is how an object promoted from a parent entity to a more specific subentity + /// gets re-linked. + /// + /// ```swift + /// cache.update( entity: menu_item, item_id, version: 4 ) { body in + /// var updated = body as! [String:Any] + /// updated[ "price" ] = 2.40 + /// return updated + /// } + /// ``` + /// + /// - Parameters: + /// - entity: The entity the object should be indexed under after the update. + /// - id: The object id, as either a `UUID` or a UUID `String`. + /// - version: The new version. Unlike ``value(entity:id:version:)``, this one is stored. + /// - updateBlock: Receives the current body, returns the replacement. + /// + /// - Note: Does nothing but log when the object is not cached. There is no return value and no + /// error, so the call is silent either way. public func update( entity: MECEntity, _ id: Any, version:Int64, updateBlock:@escaping MECCacheObjectUpdateBlock ) { let uuid = id is String ? _uuid_from_string( id as! String ) : id as! UUID @@ -218,10 +388,37 @@ public class MECCache } } + /// Whether an object is cached for this entity and id. + /// + /// Uses the same inheritance-aware lookup as ``fetch(entity:id:)``, and shares its behaviour for + /// abstract entities: `false` even when the object is present. + /// + /// - Parameters: + /// - entity: The entity to look under. May be the object's own entity or a concrete superentity. + /// - id: The object id, as either a `UUID` or a UUID `String`. + /// - Returns: `true` if an object is held for this pair. public func contains ( entity: MECEntity, id: Any ) -> Bool { return fetch( entity: entity, id: id ) != nil ? true : false } - + + /// The ids from `ids` that are **not** already cached for this entity. + /// + /// Hand it the ids you are interested in and get back only the ones you still need to go and get. + /// + /// ```swift + /// let missing = cache.diffIDs( entity: menu_item, ids: wanted_ids ) + /// // ... then fetch only `missing` + /// ``` + /// + /// - Parameters: + /// - entity: The entity whose cached ids to subtract. Matched by name, and the name's id set + /// includes objects registered through inheritance. + /// - ids: The ids you are interested in. + /// - Returns: `ids` minus everything already cached for `entity`. Returns `ids` unchanged when + /// nothing is cached for that entity. + /// + /// - Note: Compares ids only, never versions. An id you hold at an older version counts as present + /// and will not be reported as missing, so this finds *new* objects, not *changed* ones. public func diffIDs ( entity: MECEntity, ids: Set ) -> Set { guard let array = _entities_by_name[ entity.name ] else { return ids } diff --git a/Sources/MIOEntityCore/MECModel.swift b/Sources/MIOEntityCore/MECModel.swift index d9ac2b0..0a27b2e 100644 --- a/Sources/MIOEntityCore/MECModel.swift +++ b/Sources/MIOEntityCore/MECModel.swift @@ -5,13 +5,47 @@ // Created by Javier Segura Perez on 17/6/25. // +/// A minimal description of your entities and how they inherit from each other. +/// +/// ``MECCache`` is driven by a model. You build one ``MECEntity`` per type, wire the inheritance with +/// ``MECEntity/setParent(_:)``, and then hand the entities to the cache. +/// +/// This is not a schema. It carries no attributes, no relationships and no validation, only the names +/// and the inheritance graph, which is all the cache needs in order to index an object under its own +/// entity and its superentities. +/// +/// ```swift +/// let product = MECEntity( name: "Product" ) +/// let menu_item = MECEntity( name: "MenuItem" ) +/// menu_item.setParent( product ) +/// +/// let model = MECModel( entities: [ product, menu_item ] ) +/// ``` public class MECModel { var entities: [MECEntity] - + + /// The backing store for ``entitiesByName``, kept in step by ``init(entities:)`` and + /// ``addEntity(_:)``. + /// + /// - Note: Declared `open`, so it can be replaced wholesale from outside the module. Prefer + /// ``entitiesByName`` for reading and ``addEntity(_:)`` for writing. open var _entities_by_name: [String: MECEntity] + + /// The model's entities, keyed by ``MECEntity`` name. + /// + /// Use this to look up the entity value you need to pass to ``MECCache`` when all you have is a + /// name. public var entitiesByName: [String: MECEntity] { return _entities_by_name } - + + /// Creates a model from a set of entities. + /// + /// The entities are indexed by name as they are added. Their inheritance links are read from the + /// entities themselves, so call ``MECEntity/setParent(_:)`` before or after this, whichever suits, + /// as long as it happens before you insert anything into a cache. + /// + /// - Parameter entities: The entities making up the model. Names are expected to be unique; a + /// repeated name overwrites the earlier entry in ``entitiesByName``. public init( entities: [MECEntity] ) { self.entities = entities self._entities_by_name = [:] @@ -19,26 +53,65 @@ public class MECModel _entities_by_name[e.name] = e } } - + + /// Adds one more entity to the model, indexing it by name. + /// + /// - Parameter entity: The entity to add. If an entity with the same name is already present, this + /// one replaces it in ``entitiesByName``, and both remain in the underlying list. public func addEntity(_ entity: MECEntity) { entities.append( entity ) _entities_by_name[entity.name] = entity } } +/// One entity in a ``MECModel``: a name, an optional superentity, and whether it is abstract. +/// +/// You create entities up front, wire the inheritance with ``setParent(_:)``, then pass the entities +/// themselves to ``MECCache`` rather than passing names around. +/// +/// ```swift +/// let product = MECEntity( name: "Product" ) +/// let menu_item = MECEntity( name: "MenuItem" ) +/// menu_item.setParent( product ) +/// ``` +/// +/// - Note: `name`, `isAbstract`, `superEntity` and `subEntities` are all internal, so from outside the +/// module an entity is opaque. You set it up through ``init(name:isAbstract:)`` and +/// ``setParent(_:)``, but you cannot read any of it back. public final class MECEntity { var name: String var isAbstract: Bool - + var superEntity: MECEntity? = nil var subEntities: [MECEntity] = [] - + + /// Creates an entity. + /// + /// - Parameters: + /// - name: The entity name. ``MECCache`` builds its index keys from it, so it should be unique + /// within a model. + /// - isAbstract: Marks the entity abstract. Nothing enforces it, and inserting an object as + /// an abstract entity works normally. The flag only matters when this entity is someone + /// else's parent, because the inheritance walk stops when it reaches it. + /// + /// - Warning: An abstract entity is **not usable as a lookup key** in ``MECCache``. The cache's + /// inheritance walk stops when it reaches an abstract parent and stops *without* indexing it, so + /// `cache.fetch( entity: abstractParent, id: someID )` always returns `nil` even though the + /// object is in the cache. Use `isAbstract: false` for any entity you intend to fetch by. public init(name: String, isAbstract: Bool = false) { self.name = name self.isAbstract = isAbstract } - + + /// Makes `parent` the superentity of this entity, and adds this entity to the parent's subentities. + /// + /// Wire up the whole hierarchy before inserting anything into a ``MECCache``. The cache reads the + /// chain at insert time to index the object under its superentities, and it does not go back and + /// re-index when the hierarchy changes afterwards. + /// + /// - Parameter parent: The superentity, or `nil` to detach. Passing `nil` clears this entity's own + /// parent link, but does not remove it from the previous parent's subentities. public func setParent(_ parent: MECEntity?) { superEntity = parent parent?.subEntities.append( self ) diff --git a/Sources/MIOEntityCore/MIOEntityCache.swift b/Sources/MIOEntityCore/MIOEntityCache.swift index 3b46c99..29ad4b3 100644 --- a/Sources/MIOEntityCore/MIOEntityCache.swift +++ b/Sources/MIOEntityCore/MIOEntityCache.swift @@ -15,6 +15,51 @@ extension UUID { } +/// An index that groups a batch by entity name, so you can act on it a group at a time. +/// +/// Keyed by entity **name**, with ids sharded four ways and an optional subclass map for inheritance +/// lookups. +/// +/// The shape it is built for: insert a heterogeneous batch, then walk ``entities_name()`` and +/// ``entity_ids(_:)`` to issue one operation per entity type rather than one per object. +/// +/// ```swift +/// let batch = MECEntityCache( ) +/// for row in incoming { batch.insert( row.entityName, row.id, row ) } +/// +/// for name in batch.entities_name( ) { +/// let ids = batch.entity_ids( name ) +/// // act on all of this type's ids at once, instead of one at a time +/// } +/// ``` +/// +/// Used that way you read back under the exact name you inserted under, so the subclass map never +/// comes into it. That is why the no-argument initializer is the common case for this shape. +/// +/// - Important: ``MECCache`` answers the other question, "is this one present in this set", and +/// indexes inheritance in the opposite direction. The two are independent and share no code or base +/// type. Note that re-inserting a known id **overwrites** here and is **ignored** there, so moving +/// code between them changes refresh behaviour. +/// +/// How it differs from ``MECCache``: +/// +/// - Entities are plain `String` names, so there is no ``MECModel``/``MECEntity`` to set up. +/// - Inheritance comes from a `superClasses` map you pass to ``init(_:)``, not from an entity graph. +/// - **There is no version tracking at all.** Objects are just bodies in a dictionary, so the +/// version tracking that ``MECCache`` provides has no equivalent here. +/// - Ids are bucketed into four `Set`s by the first byte of the UUID. +/// +/// ```swift +/// let cache = MECEntityCache<[String:Any]>( [ "MenuItem": [ "Product" ] ] ) +/// cache.insert( "MenuItem", item_id, [ "name": "Latte" ] ) +/// +/// cache.contains( "Product", item_id ) // true, checks child classes +/// let fresh = cache.diff_ids( "MenuItem", wanted_ids ) +/// ``` +/// +/// - Warning: Not thread safe. Plain mutable dictionaries with no locking. +/// +/// - Note: `clone`, `batch_insert` and a `filter` helper exist in the source but are commented out. public class MECEntityCache { var body : [ String: [ UUID: T ] ] = [:] @@ -22,6 +67,19 @@ public class MECEntityCache // var parent_class: [ String: [String] ] = [:] // for a given entity A, returns all the super entities of A var child_class : [ String: Set ] = [:] + /// Creates a cache, optionally teaching it your entity inheritance. + /// + /// The map is inverted on the way in, from child to parents into parent to children, which is the + /// direction `contains` and ``value(_:_:)`` need in order to widen a lookup on a parent name to + /// its children. + /// + /// - Parameter superClasses: Entity name to the names of **all** its ancestors. + /// + /// - Warning: The map must be **fully flattened**. Lookups descend exactly one level, so for + /// `Refund` inheriting `Ticket` inheriting `Document` you must pass + /// `[ "Refund": [ "Ticket", "Document" ] ]`. Passing direct parents only, + /// `[ "Refund": [ "Ticket" ], "Ticket": [ "Document" ] ]`, makes `contains( "Document", refundID )` + /// return `false`. public init ( _ superClasses: [ String: [String] ] = [:] ) { // parent_class = superClasses @@ -49,10 +107,27 @@ public class MECEntityCache // return copy // } + /// Whether an object is cached under this entity name or any of its known child classes. + /// + /// - Parameters: + /// - entityName: The entity name to check, which may be a parent name. + /// - entityID: The object id as a UUID `String`. + /// - Returns: `true` if the object is held under this name or one of its children. + /// + /// - Warning: Force-unwraps the id. A string that is not a valid UUID is a **crash**. public func contains ( _ entityName: String, _ entityID: String ) -> Bool { return contains( entityName, UUID( uuidString: entityID )! ) } - + + /// Whether an object is cached under this entity name or any of its known child classes. + /// + /// Checks the exact name first, then each child class from the `superClasses` map given to + /// ``init(_:)``, which is why that map has to be fully flattened. + /// + /// - Parameters: + /// - entityName: The entity name to check, which may be a parent name. + /// - entityID: The object id. + /// - Returns: `true` if the object is held under this name or one of its children. public func contains ( _ entityName: String, _ entityID: UUID ) -> Bool { if contains_entity( entityName, entityID ) { return true @@ -79,6 +154,16 @@ public class MECEntityCache } + /// The cached body for an id, looked up under this entity name or any of its child classes. + /// + /// - Parameters: + /// - entityName: The entity name to look under, which may be a parent name. + /// - entityID: The object id. + /// - Returns: The cached body, or `nil` if it is held under neither this name nor a child class. + /// + /// - Note: Child classes are tried in `Set` order, so if the same id somehow sits under two + /// sibling classes, which of them wins is not defined. Use ``value_entity(_:_:)`` when you need + /// the exact class. public func value ( _ entityName: String, _ entityID: UUID ) -> T? { if let v = value_entity( entityName, entityID ) { return v @@ -95,11 +180,21 @@ public class MECEntityCache return nil } + /// The cached body for an id under **exactly** this entity name, ignoring child classes. + /// + /// - Parameters: + /// - entityName: The exact entity name the object was inserted under. + /// - entityID: The object id. + /// - Returns: The cached body, or `nil`. public func value_entity ( _ entityName: String, _ entityID: UUID ) -> T? { return body[ entityName ]?[ entityID ] } - + + /// Every cached body inserted under **exactly** this entity name, in no particular order. + /// + /// - Parameter entityName: The exact entity name. Child classes are not included. + /// - Returns: The bodies held, or an empty array. public func values ( _ entityName: String ) -> [T] { if let dict = body[ entityName ] { return Array( dict.values ) @@ -109,6 +204,16 @@ public class MECEntityCache } + /// The ids from `ids` that are **not** already cached under this entity name. + /// + /// Hand it the ids you are interested in, get back only the ones you do not already hold. + /// + /// - Parameters: + /// - entityName: The exact entity name. Child classes are **not** consulted, unlike + /// ``value(_:_:)``, so ids held under a subclass are reported as missing. + /// - ids: The candidate ids. + /// - Returns: `ids` minus everything cached under that name, or `ids` unchanged if the name is + /// unknown. public func diff_ids ( _ entityName: String, _ ids: Set ) -> Set { if entities[ entityName ] == nil { return ids } @@ -127,6 +232,16 @@ public class MECEntityCache // } // } + /// Caches a body under an entity name and id, creating the name's buckets on first use. + /// + /// - Parameters: + /// - entityName: The exact entity name to file the object under. Superclass names are not + /// written to; inheritance is resolved at read time instead. + /// - uuid: The object id. + /// - entityBody: The value to cache. + /// + /// - Note: Not idempotent, unlike ``MECCache/insert(entity:id:body:version:)``. Inserting an id + /// you already hold overwrites the stored body. public func insert ( _ entityName: String, _ uuid: UUID, _ entityBody: T ) { assert_insert( entityName ) insert_entity( entityName, uuid, entityBody ) @@ -145,6 +260,14 @@ public class MECEntityCache } + /// Drops a cached object from **exactly** this entity name. + /// + /// Does nothing if the name is unknown. Child classes are not touched, so an object filed under a + /// subclass survives a removal aimed at its parent name. + /// + /// - Parameters: + /// - entityName: The exact entity name the object was inserted under. + /// - uuid: The object id. public func remove ( _ entityName: String, _ uuid: UUID ) { if entities[ entityName ] == nil { return @@ -176,11 +299,21 @@ public class MECEntityCache // } + /// The entity names this cache has buckets for, in no particular order. + /// + /// A name appears once anything has been inserted under it, and keeps appearing after its objects + /// are removed, because ``remove(_:_:)`` empties the buckets rather than deleting them. + /// + /// - Returns: The known entity names. public func entities_name ( ) -> [String] { return Array( entities.keys ) } - - + + + /// Every id cached under **exactly** this entity name, recombined from the four shards. + /// + /// - Parameter entity_name: The exact entity name. Child classes are not included. + /// - Returns: The ids held, or an empty array if the name is unknown. public func entity_ids ( _ entity_name: String ) -> [UUID] { if let cache = entities[ entity_name ] { return Array( cache[ 0 ].union( cache[ 1 ].union( cache[ 2 ].union( cache[ 3 ] ) ) ) ) diff --git a/Sources/MIOEntityCore/MIOEntityCore.docc/MIOEntityCore.md b/Sources/MIOEntityCore/MIOEntityCore.docc/MIOEntityCore.md new file mode 100644 index 0000000..5f0ae1a --- /dev/null +++ b/Sources/MIOEntityCore/MIOEntityCore.docc/MIOEntityCore.md @@ -0,0 +1,297 @@ +# ``MIOEntityCore`` + +Keep track of a batch of objects: which ones you have, and what type each one is. + +## Overview + +Say a batch of changes arrives and you have to apply it. Some are products, some are orders, some are +order lines, all mixed together. Before you can do anything useful you keep needing the same two +answers: + +- **What is in this batch?** You would rather handle all the products at once than go one at a time. +- **Do I already have this particular one?** So you can tell something new from something you are + updating. + +`MIOEntityCore` gives you two small helpers for exactly that. You put things in, then ask questions +about what you put in. + +Three words show up throughout. An **entity** is a type name, like `Product`. An **id** is the UUID +identifying one particular object. A **body** is whatever you want to keep alongside it: a database +row, a decoded payload, a plain `UUID`, or just `true` when all you want is a list of ids. The +examples below use database rows, but any type will do. + +Both helpers live entirely in memory. Nothing is written anywhere, and everything you put in is gone +once you let go of the helper. + +The useful part is that they understand your **type hierarchy**. If a `MenuItem` is a kind of +`Product`, you can store a `MenuItem` and later find it by asking for a `Product`. A plain dictionary +cannot do that for you. + +> Important: Neither one is safe to use from several threads at once. Use one from a single thread, or +> add your own locking around it. + +## Which one do I use? + +Pick by the question you need answered. + +**Use ``MECEntityCache`` to sort a batch into groups.** You have a mixed pile and you want to handle +it one type at a time. + +**Use ``MECCache`` to keep track of several piles at once.** You are comparing sets against each +other, asking "is this one in that pile?" + +The two do not share anything. Storing something in one will **not** make it show up in the other, so +choose one for a given job and stay with it. + +| | ``MECEntityCache`` | ``MECCache`` | +|---|---|---| +| Good for | sorting a batch into groups | comparing several sets against each other | +| You name types with | a `String` | a ``MECEntity`` you make first | +| Storing something you already have | replaces it with the new one | keeps the old one, ignores the new one | +| Keeps a version number | no | yes | + +That third line is the one to watch, because the two behave in **opposite** ways. +``MECEntityCache`` takes the newer copy. ``MECCache`` keeps the first one and quietly discards what +you just handed it, on the assumption that you already have it. Changing a stored body in +``MECCache`` is what ``MECCache/update(entity:_:version:updateBlock:)`` is for. + +Get that backwards and nothing crashes. You just keep reading old data, and that is hard to trace +back to its cause. + +## Sorting a batch into groups + +You have a mixed batch and want to handle each type in one go, instead of one at a time. That is +what ``MECEntityCache`` is for. + +```swift +// A batch that arrived from somewhere: products, orders and order lines, all mixed together. +let changedRows: [Row] = loadChangedRows( ) + +let batch = MECEntityCache( ) + +for row in changedRows { + batch.insert( row.entityName, row.id, row ) // "Product", the row's id, the row itself +} + +// Now walk it one type at a time. +for typeName in batch.entities_name( ) { // "Product", "Order", "OrderLine" + let ids = batch.entity_ids( typeName ) // every id stored under that name + + // One trip to the database per type, rather than one per row. + let rows = try db.fetch( table: typeName, ids: ids ) +} +``` + +If the batch held 500 rows across 3 types, that is 3 queries instead of 500. + +`Row` there is just a type from the example. Neither helper puts any requirement on what you store: +it does not have to conform to a protocol, and it can be as simple as a `Bool`. The `entityName` and +`id` in the loop come from the example's own type, not from the package. + +Notice you get things back out under the same name you put them in under. That is the ordinary way to +use this one, and it is why ``MECEntityCache/init(_:)`` is usually called with no arguments: the type +hierarchy never comes up. You only need to describe your hierarchy if you intend to store a `MenuItem` +and then go looking for it as a `Product`, which the next section but one covers. + +## Comparing several sets at once + +The other helper is for a trickier situation. A batch of changes has arrived, and for each one you +need to work out whether it is new, an edit, or a deletion. That means holding **several sets at the same time** and asking which set +a given id is in. That is what ``MECCache`` is for. + +```swift +let product = MECEntity( name: "Product" ) +let model = MECModel( entities: [ product ] ) + +let onFile = MECCache( ) // rows the database already has +let incoming = MECCache( ) // rows this batch wants to change +let toDelete = MECCache( ) // just a list of ids, nothing to store with them + +// ... you fill those three in, from the database and from the batch ... + +// Then, for any id, you can ask which sets it is in: +if incoming.contains( entity: product, id: rowID ) == false { + // The batch does not mention it, so leave it alone. +} +else if onFile.contains( entity: product, id: rowID ) == false { + // The batch mentions it but the database has never seen it, so it is new. +} +else { + // Both know about it, so it is an edit. +} +``` + +`toDelete` there is a small trick worth knowing. The package has no separate "set" type, so when you +only care about *which ids are in the list* and have nothing to keep alongside them, use +`MECCache` and store `true` as a placeholder body. You get a set that still understands your +type hierarchy. + +## Building the model + +``MECCache`` needs a ``MECModel``, and the example above made one with a single entity. Real models +have more than that, and you will be building one every time you start a pass, so it is worth wrapping +up once. + +Nothing here ships with the package. Your types are yours to describe, so this is an extension you +write in your own project: + +```swift +extension MECModel +{ + /// Builds a model from a name-to-parent description of your types. + convenience init ( _ hierarchy: [String: String?], abstract: Set = [] ) { + var entities: [String: MECEntity] = [:] + + // First pass: every entity has to exist before anything can point at it. + for (name, parent) in hierarchy { + entities[ name ] = entities[ name ] + ?? MECEntity( name: name, isAbstract: abstract.contains( name ) ) + + if let parent { + entities[ parent ] = entities[ parent ] + ?? MECEntity( name: parent, isAbstract: abstract.contains( parent ) ) + } + } + + // Second pass: now every parent exists, so the links can be made. + for (name, parent) in hierarchy { + guard let parent, + let child = entities[ name ], + let parentEntity = entities[ parent ] else { continue } + + child.setParent( parentEntity ) + } + + self.init( entities: Array( entities.values ) ) + } +} +``` + +The two passes are the part worth keeping whatever shape you give this. You cannot point at an entity +that does not exist yet, so everything gets created before anything gets linked. + +After that, building a model is one line, and a name that only ever appears as a parent still gets +created: + +```swift +let model = MECModel( [ "MenuItem": "Product", "Combo": "MenuItem" ] ) + +model.entitiesByName[ "Product" ] // exists, even though it was never a key +``` + +Mark abstract types as you go, with `MECModel( hierarchy, abstract: [ "Document" ] )`. It changes what +you are able to look up later, which the next section covers. + +Keep the model and reuse it. ``MECCache`` does not hold on to it for you: you pass entity values in on +every call, so it is on you to keep handing it entities from the same model. + +## How the type hierarchy works + +Both helpers know that a `MenuItem` is a kind of `Product`, so that storing one and asking for the +other works. They get there differently, and the difference shows up in which methods respect it. + +**``MECCache`` files an object under every type it belongs to, the moment you store it.** Store a +`MenuItem` and it is filed under `MenuItem` *and* `Product` right away, so looking for a `Product` +finds it later. + +Because looking up also walks upward, asking for a `MenuItem` finds something that was only ever +stored as a `Product`: + +```swift +_ = cache.insert( entity: product, id: rowID, body: row ) // stored as a plain Product + +cache.contains( entity: menuItem, id: rowID ) // true, even though it is not a MenuItem +cache.ids( fromEntityName: "MenuItem" ) // empty: nothing was stored as a MenuItem +``` + +So read ``MECCache/contains(entity:id:)`` as "have I got this id, at this type **or anything above +it**". When you want a straight answer about one exact type, use ``MECCache/ids(fromEntityName:)``. + +**``MECEntityCache`` does the opposite: it files an object under one name only, and works the +hierarchy out when you ask.** For that it needs you to describe the hierarchy up front: + +```swift +let cache = MECEntityCache( [ "MenuItem": [ "Product" ] ] ) +cache.insert( "MenuItem", rowID, row ) + +cache.contains( "Product", rowID ) // true +``` + +Two things to watch: + +**Write out every ancestor, not just the immediate parent.** If a `Refund` is a `Ticket` and a +`Ticket` is a `Document`, write `[ "Refund": [ "Ticket", "Document" ] ]`. Listing only each type's +parent means asking for a `Document` will not find your `Refund`. + +**The hierarchy applies when you look something up, and nowhere else.** Asking with +``MECEntityCache/contains(_:_:)-(String,UUID)`` or ``MECEntityCache/value(_:_:)`` will search the +parent types you listed. Everything else, including ``MECEntityCache/diff_ids(_:_:)``, +``MECEntityCache/entity_ids(_:)`` and ``MECEntityCache/remove(_:_:)``, only ever looks at the exact +name you pass. In practice that means: + +```swift +let cache = MECEntityCache( [ "MenuItem": [ "Product" ] ] ) +cache.insert( "MenuItem", rowID, row ) + +cache.contains( "Product", rowID ) // true, it searched MenuItem too +cache.diff_ids( "Product", [ rowID ] ) // [ rowID ], reported as one you are missing +cache.remove( "Product", rowID ) // does nothing, it is filed under MenuItem +``` + +So when you are relying on the hierarchy, stay with `contains` and `value`. + +## Worth paying attention to + +All of it is intended behaviour. It is here because none of it is guessable from the method names. + +**An abstract type cannot be used to look things up.** Mark a type abstract and ``MECCache`` skips it +when filing, so searching by it comes up empty even though the object is right there. Use +`isAbstract: false` for anything you intend to search by. See ``MECEntity/init(name:isAbstract:)``. + +**Storing the same thing twice does opposite things** in the two helpers. Covered above, and repeated +here because it is the one that costs real time. + +**Asking for a version does not filter anything.** ``MECCache/value(entity:id:version:)`` takes a +version number and then ignores it. You get the stored body back whatever you pass. + +**Storing something hands you back a value you cannot read.** +``MECCache/insert(entity:id:body:version:)`` returns a ``MECCacheObject`` whose contents are private +to the package, and Swift warns you for not using a returned value. Write `_ = cache.insert( ... )` +when you only want it stored, and use ``MECCache/value(entity:id:version:)`` to read the body back. + +**Type names hang around after their objects are gone.** ``MECCache/entitiesByName`` and +``MECEntityCache/entities_name()`` keep listing a name after you remove the last object under it. The methods that +return ids stay accurate, so use those when the difference matters. + +**A bad id stops your program rather than returning nil.** Ids are taken as `Any`, and anything that +is not a `UUID` or a valid UUID string will crash. Check ids before handing them over. + +**Removing by a parent type leaves a loose end.** In ``MECCache``, removing an object by one of its +parent types leaves the entry under its own type behind. ``MECCache/fetch(entity:id:)`` tidies that up by +itself next time it runs into it, so it is rarely something you notice. + +## Topics + +### Describing your types + +A short list of type names and what each one is based on. Only ``MECCache`` needs this, because it +asks you for a ``MECEntity`` rather than a name. ``MECEntityCache`` takes plain strings and does not +use these at all. + +- ``MECModel`` +- ``MECEntity`` + +### Sorting a batch into groups + +Stores objects under a type name, and hands them back grouped by that name. + +- ``MECEntityCache`` + +### Comparing several sets against each other + +Stores objects under a ``MECEntity``, keeps a version number for each, and can tell you which ids +from a list you are missing. + +- ``MECCache`` +- ``MECCacheObject`` +- ``MECCacheObjectUpdateBlock`` diff --git a/Tests/MIOEntityCoreTests/MECCacheTests.swift b/Tests/MIOEntityCoreTests/MECCacheTests.swift new file mode 100644 index 0000000..76ac93a --- /dev/null +++ b/Tests/MIOEntityCoreTests/MECCacheTests.swift @@ -0,0 +1,294 @@ +// +// MECCacheTests.swift +// +// Created by MIO Research Labs on 2026. +// + +import XCTest +@testable import MIOEntityCore + +final class MECCacheTests: XCTestCase { + // Document (abstract) <- Product (concrete) <- MenuItem (concrete) + private struct Model { + let document = MECEntity( name: "Document", isAbstract: true ) + let product = MECEntity( name: "Product" ) + let menuItem = MECEntity( name: "MenuItem" ) + + init ( ) { + product.setParent( document ) + menuItem.setParent( product ) + } + } + + // MARK: - insert and fetch + + func testInsertThenFetchByOwnEntity ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ "name": "Latte" ] ) + + let body = cache.value( entity: m.menuItem, id: id ) + + XCTAssertNotNil( cache.fetch( entity: m.menuItem, id: id ) ) + XCTAssertEqual( body?[ "name" ] as? String, "Latte" ) + } + + func testInsertAcceptsStringID ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id.uuidString, body: [ "name": "Latte" ] ) + + XCTAssertTrue( cache.contains( entity: m.menuItem, id: id ) ) + XCTAssertTrue( cache.contains( entity: m.menuItem, id: id.uuidString ) ) + } + + func testInsertIsIdempotent ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ "name": "first" ], version: 1 ) + _ = cache.insert( entity: m.menuItem, id: id, body: [ "name": "second" ], version: 9 ) + + let body = cache.value( entity: m.menuItem, id: id ) + + XCTAssertEqual( body?[ "name" ] as? String, "first" ) + XCTAssertEqual( cache.ids( fromEntityName: "MenuItem" ).count, 1 ) + } + + func testFetchMissingReturnsNil ( ) throws { + let m = Model( ) + let cache = MECCache<[String:Any]>( ) + + XCTAssertNil( cache.fetch( entity: m.menuItem, id: UUID( ) ) ) + XCTAssertFalse( cache.contains( entity: m.menuItem, id: UUID( ) ) ) + } + + // MARK: - inheritance + + func testFetchByConcreteSuperentity ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ "name": "Latte" ] ) + + let body = cache.value( entity: m.product, id: id ) + + XCTAssertTrue( cache.contains( entity: m.product, id: id ) ) + XCTAssertEqual( body?[ "name" ] as? String, "Latte" ) + } + + func testIDsFromEntityNameIncludesInheritedRegistration ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ : ] ) + + XCTAssertEqual( cache.ids( fromEntityName: "MenuItem" ), [ id ] ) + XCTAssertEqual( cache.ids( fromEntityName: "Product" ), [ id ] ) + } + + func testFetchByAbstractSuperentityMisses ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ "name": "Latte" ] ) + + XCTAssertFalse( cache.contains( entity: m.document, id: id ) ) + XCTAssertNil( cache.value( entity: m.document, id: id ) ) + XCTAssertEqual( cache.ids( fromEntityName: "Document" ), [ ] ) + } + + func testInsertingAsAnAbstractEntityDoesIndexIt ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.document, id: id, body: [ "name": "raw" ] ) + + XCTAssertTrue( cache.contains( entity: m.document, id: id ) ) + } + + // MARK: - value + + func testValueDoesNotFilterByVersion ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ "name": "Latte" ], version: 3 ) + + XCTAssertNotNil( cache.value( entity: m.menuItem, id: id, version: 999 ) ) + } + + func testValueReturnsNilWhenBodyIsNotT ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: "a string" ) + + XCTAssertEqual( cache.value( entity: m.menuItem, id: id ), "a string" ) + XCTAssertNotNil( cache.fetch( entity: m.menuItem, id: id ) ) + } + + // MARK: - update + + func testUpdateRewritesBodyAndVersion ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ "price": 2.10 ], version: 1 ) + + cache.update( entity: m.menuItem, id, version: 4 ) { body in + guard var updated = body as? [String:Any] else { return body } + updated[ "price" ] = 2.40 + return updated + } + + let body = cache.value( entity: m.menuItem, id: id ) + let object = cache.fetch( entity: m.menuItem, id: id ) + + XCTAssertEqual( body?[ "price" ] as? Double, 2.40 ) + XCTAssertEqual( object?.version, 4 ) + } + + func testContainsWalksUpSoASubentityLookupFindsAParentsObject ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.product, id: id, body: [ "name": "Latte" ] ) + + XCTAssertTrue( cache.contains( entity: m.menuItem, id: id ) ) + XCTAssertEqual( cache.ids( fromEntityName: "MenuItem" ), [ ] ) + XCTAssertEqual( cache.ids( fromEntityName: "Product" ), [ id ] ) + } + + func testUpdatePromotesFromSuperentityToSubentity ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.product, id: id, body: [ "name": "Latte" ], version: 1 ) + + let beforeUpdate = cache.fetch( entity: m.menuItem, id: id ) + + XCTAssertEqual( beforeUpdate?.entity.name, "Product" ) + XCTAssertEqual( cache.ids( fromEntityName: "MenuItem" ), [ ] ) + + cache.update( entity: m.menuItem, id, version: 2 ) { $0 } + + let afterUpdate = cache.fetch( entity: m.menuItem, id: id ) + + XCTAssertEqual( afterUpdate?.entity.name, "MenuItem" ) + XCTAssertEqual( afterUpdate?.version, 2 ) + XCTAssertEqual( cache.ids( fromEntityName: "MenuItem" ), [ id ] ) + XCTAssertTrue( cache.contains( entity: m.product, id: id ) ) + } + + func testUpdateOnMissingObjectIsASilentNoOp ( ) throws { + let m = Model( ) + let cache = MECCache<[String:Any]>( ) + + var blockRan = false + + cache.update( entity: m.menuItem, UUID( ), version: 1 ) { body in + blockRan = true + return body + } + + XCTAssertFalse( blockRan ) + } + + // MARK: - remove + + func testRemoveUnindexesFromOwnEntityAndSuperentities ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ : ] ) + cache.remove( entity: m.menuItem, id: id ) + + XCTAssertFalse( cache.contains( entity: m.menuItem, id: id ) ) + XCTAssertFalse( cache.contains( entity: m.product, id: id ) ) + XCTAssertEqual( cache.ids( fromEntityName: "MenuItem" ), [ ] ) + XCTAssertEqual( cache.ids( fromEntityName: "Product" ), [ ] ) + } + + func testRemoveOfAnAbsentObjectIsANoOp ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ : ] ) + cache.remove( entity: m.menuItem, id: UUID( ) ) + + XCTAssertTrue( cache.contains( entity: m.menuItem, id: id ) ) + } + + func testEntitiesByNameKeepsANameAfterItsLastObjectIsRemoved ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ : ] ) + XCTAssertEqual( cache.entitiesByName, [ "MenuItem" ] ) + + cache.remove( entity: m.menuItem, id: id ) + + XCTAssertEqual( cache.entitiesByName, [ "MenuItem" ] ) + XCTAssertEqual( cache.ids( fromEntityName: "MenuItem" ), [ ] ) + XCTAssertFalse( cache.contains( entity: m.menuItem, id: id ) ) + } + + // MARK: - diffIDs + + func testDiffIDsReturnsOnlyTheMissingIDs ( ) throws { + let m = Model( ) + let held = UUID( ) + let missing = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: held, body: [ : ] ) + + XCTAssertEqual( cache.diffIDs( entity: m.menuItem, ids: [ held, missing ] ), [ missing ] ) + } + + func testDiffIDsReturnsEverythingForAnUnknownEntity ( ) throws { + let m = Model( ) + let ids = Set( [ UUID( ), UUID( ) ] ) + let cache = MECCache<[String:Any]>( ) + + XCTAssertEqual( cache.diffIDs( entity: m.menuItem, ids: ids ), ids ) + } + + func testDiffIDsComparesIDsNotVersions ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ : ], version: 1 ) + + XCTAssertEqual( cache.diffIDs( entity: m.menuItem, ids: [ id ] ), [ ] ) + } + + func testDiffIDsOnASuperentitySeesSubentityRegistrations ( ) throws { + let m = Model( ) + let id = UUID( ) + let cache = MECCache<[String:Any]>( ) + + _ = cache.insert( entity: m.menuItem, id: id, body: [ : ] ) + + XCTAssertEqual( cache.diffIDs( entity: m.product, ids: [ id ] ), [ ] ) + } +} diff --git a/Tests/MIOEntityCoreTests/MECModelTests.swift b/Tests/MIOEntityCoreTests/MECModelTests.swift new file mode 100644 index 0000000..c6377ad --- /dev/null +++ b/Tests/MIOEntityCoreTests/MECModelTests.swift @@ -0,0 +1,107 @@ +// +// MECModelTests.swift +// +// Created by MIO Research Labs on 2026. +// + +import XCTest +@testable import MIOEntityCore + +final class MECModelTests: XCTestCase { + // MARK: - MECModel + + func testInitIndexesEntitiesByName ( ) throws { + let product = MECEntity( name: "Product" ) + let menuItem = MECEntity( name: "MenuItem" ) + let model = MECModel( entities: [ product, menuItem ] ) + + XCTAssertEqual( Set( model.entitiesByName.keys ), [ "Product", "MenuItem" ] ) + XCTAssertTrue( model.entitiesByName[ "Product" ] === product ) + } + + func testAddEntityIndexesTheNewEntity ( ) throws { + let model = MECModel( entities: [ ] ) + let product = MECEntity( name: "Product" ) + + XCTAssertTrue( model.entitiesByName.isEmpty ) + model.addEntity( product ) + + XCTAssertTrue( model.entitiesByName[ "Product" ] === product ) + XCTAssertEqual( model.entities.count, 1 ) + } + + func testRepeatedNameReplacesInTheIndexButBothStayInTheList ( ) throws { + let first = MECEntity( name: "Product" ) + let second = MECEntity( name: "Product" ) + let model = MECModel( entities: [ first ] ) + + model.addEntity( second ) + + XCTAssertTrue( model.entitiesByName[ "Product" ] === second ) + XCTAssertEqual( model.entities.count, 2 ) + } + + func testEntitiesByNameIndexIsReplaceable ( ) throws { + let model = MECModel( entities: [ MECEntity( name: "Product" ) ] ) + + model._entities_by_name = [ : ] + + XCTAssertTrue( model.entitiesByName.isEmpty ) + } + + // MARK: - MECEntity + + func testEntityIsConcreteByDefault ( ) throws { + XCTAssertFalse( MECEntity( name: "Product" ).isAbstract ) + XCTAssertTrue( MECEntity( name: "Document", isAbstract: true ).isAbstract ) + } + + func testSetParentWiresBothDirections ( ) throws { + let product = MECEntity( name: "Product" ) + let menuItem = MECEntity( name: "MenuItem" ) + + menuItem.setParent( product ) + + XCTAssertTrue( menuItem.superEntity === product ) + XCTAssertEqual( product.subEntities.count, 1 ) + XCTAssertTrue( product.subEntities.first === menuItem ) + } + + func testSetParentNilLeavesTheOldParentsSubEntities ( ) throws { + let product = MECEntity( name: "Product" ) + let menuItem = MECEntity( name: "MenuItem" ) + + menuItem.setParent( product ) + menuItem.setParent( nil ) + + XCTAssertNil( menuItem.superEntity ) + XCTAssertEqual( product.subEntities.count, 1 ) + } + + func testSetParentTwiceAppendsToBothParents ( ) throws { + let document = MECEntity( name: "Document" ) + let product = MECEntity( name: "Product" ) + let menuItem = MECEntity( name: "MenuItem" ) + + menuItem.setParent( document ) + menuItem.setParent( product ) + + XCTAssertTrue( menuItem.superEntity === product ) + XCTAssertEqual( document.subEntities.count, 1 ) + XCTAssertEqual( product.subEntities.count, 1 ) + } + + func testHierarchyDrivesCacheInheritance ( ) throws { + let product = MECEntity( name: "Product" ) + let menuItem = MECEntity( name: "MenuItem" ) + let model = MECModel( entities: [ product, menuItem ] ) + let id = UUID( ) + + menuItem.setParent( product ) + + let cache = MECCache<[String:Any]>( ) + _ = cache.insert( entity: model.entitiesByName[ "MenuItem" ]!, id: id, body: [ : ] ) + + XCTAssertTrue( cache.contains( entity: model.entitiesByName[ "Product" ]!, id: id ) ) + } +} diff --git a/Tests/MIOEntityCoreTests/MIOEntityCacheTests.swift b/Tests/MIOEntityCoreTests/MIOEntityCacheTests.swift index 1908a0b..afac1e3 100644 --- a/Tests/MIOEntityCoreTests/MIOEntityCacheTests.swift +++ b/Tests/MIOEntityCoreTests/MIOEntityCacheTests.swift @@ -1,3 +1,9 @@ +// +// MIOEntityCacheTests.swift +// +// Created by MIO Research Labs on 2026. +// + import XCTest @testable import MIOEntityCore @@ -28,4 +34,112 @@ final class MIOEntityCacheTests: XCTestCase { XCTAssertEqual( cache.value( "B", entity1_id )?[ "hello" ] as? String, "world" ) XCTAssertEqual( cache.value( "C", entity1_id )?[ "hello" ] as? String, "world" ) } + + func testNoArgumentInitMeansNoInheritanceWidening ( ) throws { + let entity1_id = UUID( ) + let cache = MECEntityCache<[String:Any]>( ) + + cache.insert( "C", entity1_id, [ "hello": "world" ] ) + + XCTAssertTrue( cache.contains( "C", entity1_id ) ) + XCTAssertFalse( cache.contains( "B", entity1_id ) ) + XCTAssertFalse( cache.contains( "A", entity1_id ) ) + XCTAssertNil( cache.value( "A", entity1_id ) ) + } + + func testDirectParentsOnlyMapMissesTheGrandparent ( ) throws { + let entity1_id = UUID( ) + let cache = MECEntityCache<[String:Any]>( [ "C": [ "B" ], "B": [ "A" ] ] ) + + cache.insert( "C", entity1_id, [ "hello": "world" ] ) + + XCTAssertTrue( cache.contains( "B", entity1_id ) ) + XCTAssertFalse( cache.contains( "A", entity1_id ) ) + } + + // MARK: - exact-name reads + + func testValueEntityIgnoresChildClasses ( ) throws { + let entity1_id = UUID( ) + let cache = MECEntityCache<[String:Any]>( [ "C": [ "B", "A" ] ] ) + + cache.insert( "C", entity1_id, [ "hello": "world" ] ) + + XCTAssertNotNil( cache.value( "A", entity1_id ) ) + XCTAssertNil( cache.value_entity( "A", entity1_id ) ) + XCTAssertNotNil( cache.value_entity( "C", entity1_id ) ) + } + + func testDiffIDsIsNotInheritanceAware ( ) throws { + let entity1_id = UUID( ) + let cache = MECEntityCache<[String:Any]>( [ "C": [ "B", "A" ] ] ) + + cache.insert( "C", entity1_id, [ "hello": "world" ] ) + + XCTAssertTrue( cache.contains( "A", entity1_id ) ) + XCTAssertEqual( cache.diff_ids( "A", [ entity1_id ] ), [ entity1_id ] ) + XCTAssertEqual( cache.diff_ids( "C", [ entity1_id ] ), [ ] ) + } + + // MARK: - insert, remove, enumeration + + func testInsertIsNotIdempotentAndOverwrites ( ) throws { + let entity1_id = UUID( ) + let cache = MECEntityCache<[String:Any]>( ) + + cache.insert( "A", entity1_id, [ "hello": "first" ] ) + cache.insert( "A", entity1_id, [ "hello": "second" ] ) + + let body = cache.value( "A", entity1_id ) + + XCTAssertEqual( body?[ "hello" ] as? String, "second" ) + XCTAssertEqual( cache.entity_ids( "A" ).count, 1 ) + } + + func testRemoveOnlyTouchesTheExactName ( ) throws { + let entity1_id = UUID( ) + let cache = MECEntityCache<[String:Any]>( [ "C": [ "B", "A" ] ] ) + + cache.insert( "C", entity1_id, [ "hello": "world" ] ) + cache.remove( "A", entity1_id ) + + XCTAssertTrue( cache.contains( "C", entity1_id ) ) + + cache.remove( "C", entity1_id ) + XCTAssertFalse( cache.contains( "C", entity1_id ) ) + } + + func testEntitiesNameKeepsANameAfterItsObjectsAreGone ( ) throws { + let entity1_id = UUID( ) + let cache = MECEntityCache<[String:Any]>( ) + + cache.insert( "A", entity1_id, [ "hello": "world" ] ) + cache.remove( "A", entity1_id ) + + XCTAssertEqual( cache.entities_name( ), [ "A" ] ) + XCTAssertEqual( cache.entity_ids( "A" ), [ ] ) + } + + func testValuesReturnsEveryBodyUnderTheExactName ( ) throws { + let cache = MECEntityCache<[String:Any]>( ) + let ids = [ UUID( ), UUID( ), UUID( ) ] + + for (i, id) in ids.enumerated( ) { + cache.insert( "A", id, [ "n": i ] ) + } + + XCTAssertEqual( cache.values( "A" ).count, 3 ) + XCTAssertEqual( Set( cache.entity_ids( "A" ) ), Set( ids ) ) + XCTAssertEqual( cache.values( "B" ).count, 0 ) + } + + func testIDsAreShardedAcrossFourBucketsButReadBackWhole ( ) throws { + let cache = MECEntityCache<[String:Any]>( ) + let ids = ( 0 ..< 64 ).map { _ in UUID( ) } + + for id in ids { cache.insert( "A", id, [ : ] ) } + + XCTAssertEqual( Set( cache.entity_ids( "A" ) ), Set( ids ) ) + XCTAssertEqual( cache.diff_ids( "A", Set( ids ) ), [ ] ) + } }