diff --git a/App/CoreDataDabbi/Editing/EditingSession.swift b/App/CoreDataDabbi/Editing/EditingSession.swift index e158336..bebbb5a 100644 --- a/App/CoreDataDabbi/Editing/EditingSession.swift +++ b/App/CoreDataDabbi/Editing/EditingSession.swift @@ -131,6 +131,30 @@ final class EditingSession { } } + /// Unlinks `objects` from `object`'s relationship `relationship`: out of a to-many, or a to-one emptied. The + /// objects themselves stay (EDT-3). + func unlink(_ objects: [PendingObjectID], from object: PendingObjectID, through relationship: String) { + guard !objects.isEmpty else { return } + let name = String(localized: "Unlink \(relationship)") + stage { try await $0.unlink(objects, from: object, through: relationship, actionName: name) } + } + + /// Stages a new object of `entity` already linked to `object` through `relationship`, as one edit, and hands + /// `inserted` the identity it is staged under — once it is, and only if this session is still the one + /// attached. + func insertRelatedObject( + of entity: String, to object: PendingObjectID, through relationship: String, + inserted: (@MainActor (PendingObjectID) -> Void)? = nil + ) { + let name = String(localized: "New \(entity)") + stage { [weak self] session in + let (created, changes) = try await session.insertRelatedObject( + to: object, through: relationship, entity: entity, actionName: name) + if self?.session === session { inserted?(created) } + return changes + } + } + /// Throws away everything staged. The file is not touched. func discard() { undoManager.removeAllActions(withTarget: self) diff --git a/App/CoreDataDabbi/Relationships/RelationshipsModel.swift b/App/CoreDataDabbi/Relationships/RelationshipsModel.swift index c6d87f9..89d80c8 100644 --- a/App/CoreDataDabbi/Relationships/RelationshipsModel.swift +++ b/App/CoreDataDabbi/Relationships/RelationshipsModel.swift @@ -58,15 +58,20 @@ final class RelationshipsModel { /// What names the selected object in a breadcrumb (REL-3). private(set) var sourceLabel: String? - /// An object and one of its relationships: what a list of related objects was read for. + /// An object and one of its relationships, and the staged edits they were read under: what a list of related + /// objects was read for. private struct Followed: Equatable { var object: ObjectRef var relationship: String + var revision: Int } @ObservationIgnored private var rowsTask: Task? @ObservationIgnored private var itemsTask: Task? @ObservationIgnored private var loadedObject: ObjectRef? + /// The staged edits the object was read under: a new revision reads it again, so that what is linked and + /// unlinked shows here as it is staged (EDT-3, EDT-8). + @ObservationIgnored private var loadedRevision = 0 @ObservationIgnored private var followed: Followed? @ObservationIgnored private var loadedFrom: ObjectIdentifier? @@ -79,10 +84,16 @@ final class RelationshipsModel { var source: ObjectRef? { context.navigation.current?.focus } var sessionIdentity: ObjectIdentifier? { context.session.map(ObjectIdentifier.init) } - /// The related object being looked at (REL-1). It is not held here but read back from what the inspector and - /// the content viewer are showing, so that a click back in the grid takes the highlight off it by itself. - var selectedItem: ObjectRef? { - guard let source, let inspected = context.inspectedRef, inspected != source else { return nil } + /// Bumped whenever what is staged may have changed. + var editRevision: Int { context.editing.revision } + + /// The related object being looked at (REL-1): a saved one, or one only inserted and linked here. It is not + /// held here but read back from what the inspector and the content viewer are showing, so that a click back + /// in the grid takes the highlight off it by itself. + var selectedItem: PendingObjectID? { + guard let source, let inspected = context.inspectedObject, inspected != PendingObjectID(source) else { + return nil + } return inspected } @@ -113,7 +124,11 @@ final class RelationshipsModel { forget() return } - if source != loadedObject { loadRows(of: source, from: session) } + let revision = context.editing.revision + if source != loadedObject || revision != loadedRevision { + // The same object read again for what is staged keeps showing what it had until the new read is in. + loadRows(of: source, from: session, revision: revision, keepShowing: source == loadedObject) + } loadItems(from: session) } @@ -124,12 +139,15 @@ final class RelationshipsModel { sourceLabel = nil } - private func loadRows(of ref: ObjectRef, from session: StoreSession) { + private func loadRows(of ref: ObjectRef, from session: StoreSession, revision: Int, keepShowing: Bool) { loadedObject = ref + loadedRevision = revision rowsTask?.cancel() - state = .loading(ref) - related = nil - relatedError = nil + if !keepShowing { + state = .loading(ref) + related = nil + relatedError = nil + } rowsTask = Task { [weak self] in let result: Result do { @@ -199,12 +217,16 @@ final class RelationshipsModel { relatedError = nil return } - let wanted = Followed(object: ref, relationship: name) + let wanted = Followed(object: ref, relationship: name, revision: loadedRevision) guard wanted != followed else { return } + // The same relationship read again for what is staged keeps its list up until the new one is in. + let isAnotherList = followed.map { $0.object != ref || $0.relationship != name } ?? true followed = wanted itemsTask?.cancel() - related = nil - relatedError = nil + if isAnotherList { + related = nil + relatedError = nil + } itemsTask = Task { [weak self] in let result: Result do { @@ -242,16 +264,17 @@ final class RelationshipsModel { /// Looks at one of the related objects: the inspector and the content viewer follow it, the grid does not /// (REL-1). `nil` gives them the grid's row back. - func selectItem(_ ref: ObjectRef?) { - context.inspect((ref ?? source).map(PendingObjectID.init)) + func selectItem(_ object: PendingObjectID?) { + context.inspect(object ?? source.map(PendingObjectID.init)) } /// Whether there is a related object to jump to — what the Reveal button and the menu item go by (REL-3). - var canReveal: Bool { selected != nil && selectedItem != nil } + /// One only inserted is in no grid until it is committed. + var canReveal: Bool { selected != nil && selectedItem?.ref != nil } /// Reveals what is picked, for a command that comes from the menu rather than from a row (§8.4). func revealSelected() { - guard let ref = selectedItem else { return } + guard let ref = selectedItem?.ref else { return } reveal(ref) } @@ -262,6 +285,39 @@ final class RelationshipsModel { ref, from: source, labelled: sourceLabel ?? source?.description ?? ref.entity, through: relationship) } + // MARK: Editing (EDT-3) + + /// Whether the relationship being followed can be changed from here: the store is open for editing. + var canEdit: Bool { context.editing.isEditable && source != nil && selectedRow != nil } + + /// What a new related object can be: the followed to-many's destination and its sub-entities, leaving out + /// the abstract ones. Empty for a to-one, whose new object is made with the picker that sets it. + var insertableEntities: [String] { + guard let relationship = selectedRow?.relationship, relationship.isToMany, let model = context.model else { + return [] + } + return model.entityAndDescendants(of: relationship.destinationEntity).filter { !$0.isAbstract }.map(\.name) + } + + /// Stages a new object of `entity`, linked to the selected object through the followed relationship, and + /// shows it in the inspector to be filled in. + func insertRelated(_ entity: String) { + guard canEdit, insertableEntities.contains(entity), let source, let name = selected else { return } + context.editing.insertRelatedObject(of: entity, to: PendingObjectID(source), through: name) { + [weak self] object in self?.context.inspect(object) + } + } + + /// Takes `item` out of the followed relationship; a to-one is emptied. The object itself stays. Only what the + /// list shows is taken out: the inspector may be showing an object from somewhere else. + func unlink(_ item: PendingObjectID) { + guard canEdit, let source, let name = selected, related?.items.contains(where: { $0.object == item }) == true + else { return } + // It is no longer on this side to be looked at. + if selectedItem == item { selectItem(nil) } + context.editing.unlink([item], from: PendingObjectID(source), through: name) + } + /// Returns once the panel has read what it is showing. For the tests. func whenSettled() async { await rowsTask?.value diff --git a/App/CoreDataDabbi/Relationships/RelationshipsView.swift b/App/CoreDataDabbi/Relationships/RelationshipsView.swift index a161007..68790d3 100644 --- a/App/CoreDataDabbi/Relationships/RelationshipsView.swift +++ b/App/CoreDataDabbi/Relationships/RelationshipsView.swift @@ -21,11 +21,13 @@ struct RelationshipsView: View { private struct Trigger: Equatable { var source: ObjectRef? var session: ObjectIdentifier? + var edits: Int @MainActor init(model: RelationshipsModel) { source = model.source session = model.sessionIdentity + edits = model.editRevision } } @@ -184,6 +186,7 @@ struct RelationshipsView: View { .foregroundStyle(.secondary) .help(String(localized: "The first \(RelationshipsModel.pageLimit) are listed.")) } + if model.canEdit { newRelated } Button(String(localized: "Reveal"), action: model.revealSelected) .disabled(!model.canReveal) .controlSize(.small) @@ -193,12 +196,36 @@ struct RelationshipsView: View { .padding(.vertical, 6) } - private var itemSelection: Binding { + /// A new object on the far side, linked already (EDT-3): one button for a destination that is one entity, a + /// menu for one with sub-entities to choose from. + @ViewBuilder + private var newRelated: some View { + let entities = model.insertableEntities + if entities.count == 1, let entity = entities.first { + Button(String(localized: "New Related Object"), systemImage: "plus") { model.insertRelated(entity) } + .labelStyle(.iconOnly) + .controlSize(.small) + .help(String(localized: "New \(entity), linked to this object")) + } else if entities.count > 1 { + Menu(String(localized: "New Related Object"), systemImage: "plus") { + ForEach(entities, id: \.self) { entity in + Button(entity) { model.insertRelated(entity) } + } + } + .labelStyle(.iconOnly) + .menuIndicator(.hidden) + .fixedSize() + .controlSize(.small) + .help(String(localized: "New related object, linked to this object")) + } + } + + private var itemSelection: Binding { Binding(get: { model.selectedItem }, set: { model.selectItem($0) }) } private func items(of related: RelatedObjects) -> some View { - List(Array(related.items.enumerated()), id: \.element.ref, selection: itemSelection) { index, item in + List(Array(related.items.enumerated()), id: \.element.object, selection: itemSelection) { index, item in HStack(spacing: 6) { // An ordered relationship keeps the order it was given, and the position is part of the data // (REL-2). @@ -211,29 +238,49 @@ struct RelationshipsView: View { Text(item.label) .lineLimit(1) Spacer(minLength: 4) + // Only inserted: in no grid, and with no URI worth copying, until it is committed. + if item.ref == nil { + Text(String(localized: "New")) + .font(.caption) + .foregroundStyle(.secondary) + } // Which sub-entity it turned out to be, where that is not the destination itself. - if item.ref.entity != related.destinationEntity { - Text(item.ref.entity) + if item.object.entity != related.destinationEntity { + Text(item.object.entity) .font(.caption) .foregroundStyle(.secondary) } } - .tag(item.ref) - .help(item.ref.description) + .tag(item.object) + .help(item.object.description) .contentShape(Rectangle()) // The list's own click keeps working; this only adds the second one. - .simultaneousGesture(TapGesture(count: 2).onEnded { model.reveal(item.ref) }) + .simultaneousGesture(TapGesture(count: 2).onEnded { if let ref = item.ref { model.reveal(ref) } }) .contextMenu { - Button(String(localized: "Reveal in Entity")) { model.reveal(item.ref) } - Button(String(localized: "Copy Object ID URI")) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(item.ref.uri.absoluteString, forType: .string) + if let ref = item.ref { + Button(String(localized: "Reveal in Entity")) { model.reveal(ref) } + Button(String(localized: "Copy Object ID URI")) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(ref.uri.absoluteString, forType: .string) + } + } + if model.canEdit { + Divider() + Button(String(localized: "Unlink")) { model.unlink(item.object) } } } .accessibilityElement(children: .combine) - .accessibilityLabel("\(item.label), \(item.ref.entity)") + .accessibilityLabel("\(item.label), \(item.object.entity)") + .accessibilityActions { + if model.canEdit { Button(String(localized: "Unlink")) { model.unlink(item.object) } } + } } .listStyle(.inset) + // Delete takes the selected object out of the relationship, as Delete Rows does in the grid; the object + // itself stays. + .onDeleteCommand { + if let item = model.selectedItem { model.unlink(item) } + } .accessibilityLabel(String(localized: "Related objects")) } } diff --git a/App/CoreDataDabbi/Resources/Localizable.xcstrings b/App/CoreDataDabbi/Resources/Localizable.xcstrings index 77e74f2..f56dea9 100644 --- a/App/CoreDataDabbi/Resources/Localizable.xcstrings +++ b/App/CoreDataDabbi/Resources/Localizable.xcstrings @@ -779,12 +779,21 @@ }, "New %@" : { + }, + "New %@, linked to this object" : { + }, "New Object" : { }, "New Predicate" : { + }, + "New Related Object" : { + + }, + "New related object, linked to this object" : { + }, "New — not in the store until it is committed" : { @@ -1418,6 +1427,12 @@ }, "Unknown App" : { + }, + "Unlink" : { + + }, + "Unlink %@" : { + }, "Unlinked, by Nullify rules: %@" : { diff --git a/App/CoreDataDabbiTests/EditingSessionTests.swift b/App/CoreDataDabbiTests/EditingSessionTests.swift index b4bcc9e..c7b2bf6 100644 --- a/App/CoreDataDabbiTests/EditingSessionTests.swift +++ b/App/CoreDataDabbiTests/EditingSessionTests.swift @@ -323,6 +323,54 @@ import Testing context.shutDown() } + @Test func theRelationshipsPanelMakesRelatedObjectsAndUnlinksThem() async throws { + let recorder = Recorder() + let (context, _) = try await editableContext(recorder, fixture: .company) + context.select(entity: "Department") + let session = try #require(context.session) + let department = try #require(try await session.references(FetchSpec(entity: "Department"), limit: 1).first) + context.focus(on: department) + let panel = RelationshipsModel(context: context) + panel.refresh() + await panel.whenSettled() + panel.select("employees") + await panel.whenSettled() + let before = try #require(panel.related).items.map(\.object) + #expect(panel.canEdit) + #expect(panel.insertableEntities == ["Employee", "Manager"]) + + // A new object on the far side, linked already, and shown in the inspector to be filled in (EDT-3). + panel.insertRelated("Employee") + await context.whenSettled() + let created = try #require(context.inspectedObject) + #expect(created.isInserted && created.entity == "Employee") + #expect(context.editing.undoManager.undoActionName == "New Employee") + panel.refresh() + await panel.whenSettled() + #expect(panel.related?.items.map(\.object) == before + [created]) + #expect(panel.selectedItem == created) + // No grid lists it until it is committed. + #expect(!panel.canReveal) + + // Unlinking takes it out of the relationship, not out of what is staged; the inspector goes back to the + // grid's row. + panel.unlink(created) + await context.whenSettled() + #expect(context.inspectedObject == PendingObjectID(department)) + #expect(context.editing.undoManager.undoActionName == "Unlink employees") + #expect(context.editing.changes.change(for: created)?.kind == .inserted) + panel.refresh() + await panel.whenSettled() + #expect(panel.related?.items.map(\.object) == before) + + // A to-one's object is chosen with the picker, not made here. + panel.select("head") + await panel.whenSettled() + #expect(panel.insertableEntities.isEmpty) + #expect(recorder.errors.isEmpty) + context.shutDown() + } + @Test func anAbstractEntityHasNoNewObject() async throws { let recorder = Recorder() let (context, _) = try await editableContext(recorder, fixture: .company) diff --git a/App/CoreDataDabbiTests/ProjectWindowTests.swift b/App/CoreDataDabbiTests/ProjectWindowTests.swift index 72c7a55..6bc7fff 100644 --- a/App/CoreDataDabbiTests/ProjectWindowTests.swift +++ b/App/CoreDataDabbiTests/ProjectWindowTests.swift @@ -321,7 +321,7 @@ import Testing #expect(!model.canReveal) // Picking one shows it in the inspector without moving the grid (REL-1). - model.selectItem(employee.ref) + model.selectItem(employee.object) for _ in 0..<15 { if case .object(let shown, _) = inspector.model.details, shown.ref == employee.ref { break } try await Task.sleep(for: .milliseconds(20)) @@ -347,8 +347,8 @@ import Testing for _ in 0..<5 { await Task.yield() } await grid.whenSettled() for _ in 0..<5 { await Task.yield() } - #expect(document.context.navigation.current?.entity == employee.ref.entity) - #expect(window.subtitle == employee.ref.entity) + #expect(document.context.navigation.current?.entity == employee.object.entity) + #expect(window.subtitle == employee.object.entity) #expect(document.context.navigation.current?.trail.count == 2) // The grid found the row it was sent to and highlighted it. #expect(grid.tableView.selectedRow >= 0) diff --git a/App/CoreDataDabbiTests/RelationshipsModelTests.swift b/App/CoreDataDabbiTests/RelationshipsModelTests.swift index 4a4df7c..df939b8 100644 --- a/App/CoreDataDabbiTests/RelationshipsModelTests.swift +++ b/App/CoreDataDabbiTests/RelationshipsModelTests.swift @@ -68,7 +68,7 @@ import Testing let related = try #require(model.related) #expect(related.relationship == "employees") #expect(!related.items.isEmpty) - #expect(related.items.allSatisfy { ["Employee", "Manager"].contains($0.ref.entity) }) + #expect(related.items.allSatisfy { ["Employee", "Manager"].contains($0.object.entity) }) #expect(model.relatedError == nil) } @@ -81,7 +81,7 @@ import Testing let related = try #require(model.related) #expect(related.relationship == "organisation") #expect(related.items.count == 1) - #expect(related.items.first?.ref.entity == "Organisation") + #expect(related.items.first?.object.entity == "Organisation") // The choice is the project's, so the panel opens on the same relationship next time. #expect(context.local.selection.relationship == "organisation") } @@ -92,7 +92,7 @@ import Testing let source = try #require(context.navigation.current?.focus) let item = try #require(model.related?.items.first) - model.selectItem(item.ref) + model.selectItem(item.object) // The inspector and the content viewer follow the panel; the grid stays on the row it had (REL-1). #expect(context.inspectedRef == item.ref) @@ -111,9 +111,9 @@ import Testing let label = try #require(model.sourceLabel) #expect(label.hasPrefix("Department")) let item = try #require(model.related?.items.first) - model.reveal(item.ref) + model.reveal(try #require(item.ref)) - #expect(context.navigation.current?.entity == item.ref.entity) + #expect(context.navigation.current?.entity == item.object.entity) #expect(context.navigation.current?.focus == item.ref) #expect(context.inspectedRef == item.ref) #expect(context.navigation.current?.trail == [label, "employees"]) @@ -130,7 +130,7 @@ import Testing let department = try #require(model.sourceLabel) let employee = try #require(model.related?.items.first) - model.reveal(employee.ref) + model.reveal(try #require(employee.ref)) model.refresh() await model.whenSettled() @@ -138,7 +138,7 @@ import Testing model.select("department") await model.whenSettled() let back = try #require(model.related?.items.first) - model.reveal(back.ref) + model.reveal(try #require(back.ref)) #expect(context.navigation.current?.trail == [department, "employees", "department"]) // The first crumb names where the drilling started, and clicking it goes back there. @@ -152,7 +152,7 @@ import Testing defer { context.shutDown() } let item = try #require(model.related?.items.first) - model.reveal(item.ref) + model.reveal(try #require(item.ref)) #expect(context.navigation.current?.trail.count == 2) // The sidebar is not a relationship: landing on an entity leaves no trail behind it. diff --git a/Sources/DabbiStore/RelatedObjects.swift b/Sources/DabbiStore/RelatedObjects.swift index 48a40f9..f293dd5 100644 --- a/Sources/DabbiStore/RelatedObjects.swift +++ b/Sources/DabbiStore/RelatedObjects.swift @@ -7,24 +7,57 @@ import Foundation /// the rest. `count` is the whole relationship; `items` is as much of it as was asked for. public struct RelatedObjects: Sendable, Hashable, Codable { public struct Item: Sendable, Hashable, Codable { - public var ref: ObjectRef + /// The object: a saved one by its reference, or one only inserted — linked in an editable session and + /// not committed yet — by the identity it was staged under (EDT-3). + public var object: PendingObjectID /// The destination entity's display attribute, when it has one and the object has a value for it. public var display: String? - public init(ref: ObjectRef, display: String? = nil) { - self.ref = ref + public init(object: PendingObjectID, display: String? = nil) { + self.object = object self.display = display } + public init(ref: ObjectRef, display: String? = nil) { + self.init(object: PendingObjectID(ref), display: display) + } + + /// The saved object's reference; `nil` for one only inserted. + public var ref: ObjectRef? { object.ref } + /// What to show for this object: its label, or its identity when it has none. - public var label: String { display ?? ref.description } + public var label: String { display ?? object.description } + + // The encoded form keeps `ref` next to `object`. Until objects only inserted could be listed, an item was + // a `ref` and a `display`: a reader of that form still reads every saved object, and a payload in it + // still decodes. An object only inserted has no `ref` for such a reader to find. + private enum CodingKeys: String, CodingKey { + case object, ref, display + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let object = try container.decodeIfPresent(PendingObjectID.self, forKey: .object) { + self.object = object + } else { + object = PendingObjectID(try container.decode(ObjectRef.self, forKey: .ref)) + } + display = try container.decodeIfPresent(String.self, forKey: .display) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(object, forKey: .object) + try container.encodeIfPresent(ref, forKey: .ref) + try container.encodeIfPresent(display, forKey: .display) + } } public var relationship: String public var destinationEntity: String public var isToMany: Bool /// An ordered to-many keeps the order it was given; everything else is listed in object-ID order, which is - /// the order the grid shows. + /// the order the grid shows, with objects only inserted after the saved ones. public var isOrdered: Bool /// How many there are altogether, which can be more than `items` holds. public var count: Int diff --git a/Sources/DabbiStore/StagedEdits.swift b/Sources/DabbiStore/StagedEdits.swift index baa150e..67a32cb 100644 --- a/Sources/DabbiStore/StagedEdits.swift +++ b/Sources/DabbiStore/StagedEdits.swift @@ -210,7 +210,7 @@ extension StoreSession { case toOne(NSManagedObjectID?) } - private func entityDescription(_ name: String) throws -> EntityDescription { + func entityDescription(_ name: String) throws -> EntityDescription { guard let entity = info.model.entity(named: name) else { throw DabbiError( .unknownEntity, "The model has no entity named “\(name)”.", arguments: ["entity": name]) @@ -218,7 +218,7 @@ extension StoreSession { return entity } - private func editableObjectID(for object: PendingObjectID) throws -> NSManagedObjectID { + func editableObjectID(for object: PendingObjectID) throws -> NSManagedObjectID { try ensureOpen() guard stack.isEditable else { throw CoreDataStack.notEditable } return try objectID(for: object) @@ -252,7 +252,8 @@ extension StoreSession { } } - private static func existingObject( + /// A staged edit's object, refused when it is gone or staged for deletion. + static func existingObject( _ id: NSManagedObjectID, object: PendingObjectID?, in context: NSManagedObjectContext ) throws -> NSManagedObject { guard let found = try? context.existingObject(with: id), !found.isDeleted else { diff --git a/Sources/DabbiStore/StagedLinks.swift b/Sources/DabbiStore/StagedLinks.swift new file mode 100644 index 0000000..b3edb5b --- /dev/null +++ b/Sources/DabbiStore/StagedLinks.swift @@ -0,0 +1,149 @@ +@preconcurrency import CoreData +import DabbiBase +import DabbiModel +import Foundation + +/// Staged relationship edits (EDT-3, EDT-8): linking objects, unlinking them, and making a new object already +/// linked. Each call is one undoable edit, like the value edits in `StagedEdits.swift`, and returns the whole of +/// what is staged afterwards. Core Data keeps the inverse, so linking from either end reads the same from both. +/// +/// Objects on either side may be ones only inserted, named by the identity they were staged under. +extension StoreSession { + /// Links `objects` to `object` through its relationship `name`: added to a to-many (at the end of an ordered + /// one), or set as a to-one, replacing what it held. A to-one takes exactly one object. + /// + /// Objects already linked are left where they are, and an edit that links nothing new stages nothing. Throws + /// `.unknownProperty` for a name the entity has no relationship by, and `.invalidValue` for an object the + /// relationship cannot lead to. + @discardableResult + public func link( + _ objects: [PendingObjectID], to object: PendingObjectID, through name: String, actionName: String? = nil + ) async throws -> PendingChanges { + let relationship = try editableRelationship(name, of: object) + if !relationship.isToMany, objects.count != 1 { + throw DabbiError( + .invalidValue, "\(object.entity).\(name) is a to-one relationship; link one object to it.", + arguments: ["entity": object.entity, "property": name]) + } + let target = try editableObjectID(for: object) + let destinations = try destinationIDs(of: objects, through: relationship, of: object) + return try await stack.edit(actionName: actionName ?? "Link \(name)") { context in + let source = try Self.existingObject(target, object: object, in: context) + let linked = try destinations.map { try Self.existingObject($0.id, object: $0.object, in: context) } + Self.connect(linked, to: source, through: relationship) + }.1 + } + + /// Unlinks `objects` from `object`'s relationship `name`: removed from a to-many, or, for a to-one that holds + /// one of them, emptied. The objects themselves stay; no delete rule is involved. An object that was not + /// linked is passed over, and an edit that unlinks nothing stages nothing. + @discardableResult + public func unlink( + _ objects: [PendingObjectID], from object: PendingObjectID, through name: String, actionName: String? = nil + ) async throws -> PendingChanges { + let relationship = try editableRelationship(name, of: object) + let target = try editableObjectID(for: object) + let destinations = try destinationIDs(of: objects, through: relationship, of: object) + return try await stack.edit(actionName: actionName ?? "Unlink \(name)") { context in + let source = try Self.existingObject(target, object: object, in: context) + let unlinked = try destinations.map { try Self.existingObject($0.id, object: $0.object, in: context) } + if relationship.isOrdered { + let set = source.mutableOrderedSetValue(forKey: relationship.name) + for destination in unlinked where set.contains(destination) { set.remove(destination) } + } else if relationship.isToMany { + let set = source.mutableSetValue(forKey: relationship.name) + for destination in unlinked where set.contains(destination) { set.remove(destination) } + } else if let current = source.value(forKey: relationship.name) as? NSManagedObject, + unlinked.contains(current) + { + source.setValue(nil, forKey: relationship.name) + } + }.1 + } + + /// Stages a new object at the far end of `object`'s relationship `name`, linked to it, as one edit: undoing + /// it takes both back. The new object is of the relationship's destination entity, or of `entity` when given, + /// which has to be that entity or one of its sub-entities. Abstract entities are refused, as by + /// `insertObject(entity:)`. + public func insertRelatedObject( + to object: PendingObjectID, through name: String, entity: String? = nil, actionName: String? = nil + ) async throws -> (object: PendingObjectID, changes: PendingChanges) { + let relationship = try editableRelationship(name, of: object) + let entityName = entity ?? relationship.destinationEntity + let allowed = Set(info.model.entityAndDescendants(of: relationship.destinationEntity).map(\.name)) + guard allowed.contains(entityName) else { + throw DabbiError( + .invalidValue, + "\(object.entity).\(name) leads to \(relationship.destinationEntity), not \(entityName).", + arguments: ["entity": object.entity, "property": name]) + } + let description = try entityDescription(entityName) + guard !description.isAbstract else { + throw DabbiError( + .invalidValue, "\(entityName) is abstract and cannot have objects of its own.", + arguments: ["entity": entityName], + recovery: ["Make one of its sub-entities: " + description.subentities.joined(separator: ", ")]) + } + let target = try editableObjectID(for: object) + let converter = stack.converter + let undoName = actionName ?? "New \(entityName)" + let ((id, inserted), changes) = try await stack.edit(actionName: undoName) { context in + let source = try Self.existingObject(target, object: object, in: context) + let created = NSEntityDescription.insertNewObject(forEntityName: entityName, into: context) + Self.connect([created], to: source, through: relationship) + return (created.objectID, converter.pendingID(of: created)) + } + insertedObjectIDs[inserted.uri] = id + return (inserted, changes) + } + + // MARK: Helpers + + /// The relationship `name` of `object`'s own entity, in a session open for editing. + private func editableRelationship(_ name: String, of object: PendingObjectID) throws -> RelationshipDescription { + try ensureOpen() + guard stack.isEditable else { throw CoreDataStack.notEditable } + let entity = try entityDescription(object.entity) + guard let relationship = entity.relationship(named: name), !relationship.isTransient else { + throw DabbiError( + .unknownProperty, "\(entity.name) has no relationship named “\(name)”.", + arguments: ["entity": entity.name, "property": name]) + } + return relationship + } + + /// The objects `relationship` is to lead to, each checked against its destination entity. + private func destinationIDs( + of objects: [PendingObjectID], through relationship: RelationshipDescription, of object: PendingObjectID + ) throws -> [(id: NSManagedObjectID, object: PendingObjectID)] { + let allowed = Set(info.model.entityAndDescendants(of: relationship.destinationEntity).map(\.name)) + return try objects.map { destination in + guard allowed.contains(destination.entity) else { + throw DabbiError( + .invalidValue, + "\(object.entity).\(relationship.name) leads to \(relationship.destinationEntity), " + + "not \(destination.entity).", + arguments: ["entity": object.entity, "property": relationship.name]) + } + return (try editableObjectID(for: destination), destination) + } + } + + /// Links on the context's queue. What is already linked stays where it is, so that linking it again changes + /// nothing and leaves no edit behind. + private static func connect( + _ destinations: [NSManagedObject], to source: NSManagedObject, through relationship: RelationshipDescription + ) { + if relationship.isOrdered { + let set = source.mutableOrderedSetValue(forKey: relationship.name) + for destination in destinations where !set.contains(destination) { set.add(destination) } + } else if relationship.isToMany { + let set = source.mutableSetValue(forKey: relationship.name) + for destination in destinations where !set.contains(destination) { set.add(destination) } + } else if let destination = destinations.first, + (source.value(forKey: relationship.name) as? NSManagedObject) != destination + { + source.setValue(destination, forKey: relationship.name) + } + } +} diff --git a/Sources/DabbiStore/StoreSession.swift b/Sources/DabbiStore/StoreSession.swift index d2e11e8..c0c4f0e 100644 --- a/Sources/DabbiStore/StoreSession.swift +++ b/Sources/DabbiStore/StoreSession.swift @@ -364,10 +364,15 @@ public actor StoreSession { // A set has no order of its own; object-ID order is what the grid shows, so the panel shows it // too — and it means the first page of a relationship is the same every time it is opened. // Everything a relationship can point at shares one table, so the key alone orders them all. + // Objects only inserted have no key yet, and come after the saved ones, in the order of their + // temporary URIs: a set's own order can change between reads, and the list with it. objects = set.compactMap { $0 as? NSManagedObject } - .map { (object: $0, pk: ObjectRef(uri: $0.objectID.uriRepresentation())?.pk ?? 0) } - .sorted { $0.pk < $1.pk } + .map { object -> (object: NSManagedObject, pk: Int64, uri: String) in + let uri = object.objectID.uriRepresentation() + return (object, ObjectRef(uri: uri)?.pk ?? .max, uri.absoluteString) + } + .sorted { ($0.pk, $0.uri) < ($1.pk, $1.uri) } .prefix(limit) .map(\.object) default: @@ -376,7 +381,7 @@ public actor StoreSession { return RelatedObjects( relationship: name, destinationEntity: relationship.destinationEntity, isToMany: relationship.isToMany, isOrdered: relationship.isOrdered, count: count, - items: objects.compactMap(converter.item), generation: generation) + items: objects.map(converter.item), generation: generation) } } diff --git a/Sources/DabbiStore/ValueConverter.swift b/Sources/DabbiStore/ValueConverter.swift index 9e517e4..fff796e 100644 --- a/Sources/DabbiStore/ValueConverter.swift +++ b/Sources/DabbiStore/ValueConverter.swift @@ -120,11 +120,10 @@ struct ValueConverter: Sendable { /// /// Reading the identity does not fire the object's fault; reading the label does, which is why this is /// only ever called for the objects actually shown. - func item(of object: NSManagedObject) -> RelatedObjects.Item? { - guard let ref = ObjectRef(uri: object.objectID.uriRepresentation()) else { return nil } + func item(of object: NSManagedObject) -> RelatedObjects.Item { let display = object.entity.name.flatMap { layouts[$0]?.displayAttribute } .flatMap { object.value(forKey: $0) as? String } - return RelatedObjects.Item(ref: ref, display: display.flatMap { $0.isEmpty ? nil : $0 }) + return RelatedObjects.Item(object: pendingID(of: object), display: display.flatMap { $0.isEmpty ? nil : $0 }) } func toOne(_ raw: Any?) -> Value { diff --git a/Tests/DabbiStoreTests/RelatedObjectsTests.swift b/Tests/DabbiStoreTests/RelatedObjectsTests.swift index 0d5266f..36d3c3a 100644 --- a/Tests/DabbiStoreTests/RelatedObjectsTests.swift +++ b/Tests/DabbiStoreTests/RelatedObjectsTests.swift @@ -58,9 +58,9 @@ import Testing #expect(!employees.isTruncated) // The fixture spreads 25 employees over 4 departments; every one of them is an Employee or a Manager. #expect(employees.count > 0) - #expect(employees.items.allSatisfy { ["Employee", "Manager"].contains($0.ref.entity) }) + #expect(employees.items.allSatisfy { ["Employee", "Manager"].contains($0.object.entity) }) // Listed in object-ID order, so the same relationship reads the same way twice. - #expect(employees.items.map(\.ref.pk) == employees.items.map(\.ref.pk).sorted()) + #expect(employees.items.compactMap(\.ref?.pk) == employees.items.compactMap(\.ref?.pk).sorted()) // Every Party has a name, which is what labels it wherever it is pointed at. #expect(employees.items.allSatisfy { $0.display?.isEmpty == false }) #expect(employees.items.first?.label == employees.items.first?.display) @@ -76,7 +76,7 @@ import Testing #expect(organisation.destinationEntity == "Organisation") #expect(organisation.count == 1) #expect(organisation.items.count == 1) - #expect(organisation.items.first?.ref.entity == "Organisation") + #expect(organisation.items.first?.object.entity == "Organisation") } @Test func readsBothEndsOfAManyToMany() async throws { @@ -84,13 +84,36 @@ import Testing defer { Task { await session.close() } } let person = try await first("Person", in: session) let tags = try await session.related(to: person, through: "tags") - guard let tag = tags.items.first else { return } // Not every person is tagged. + guard let tag = tags.items.first?.ref else { return } // Not every person is tagged. - let people = try await session.related(to: tag.ref, through: "people") + let people = try await session.related(to: tag, through: "people") #expect(people.destinationEntity == "Person") #expect(people.items.contains { $0.ref == person }) } + /// An item was a `ref` and a `display` until objects only inserted could be listed; that form still reads + /// and is still written for every saved object. + @Test func anItemKeepsTheFormItHadForSavedObjects() throws { + struct EarlierItem: Codable, Equatable { + var ref: ObjectRef + var display: String? + } + let uri = URL(string: "x-coredata://4B1D5E9A-0000-4000-8000-000000000001/Employee/p7")! + let ref = try #require(ObjectRef(uri: uri)) + let item = RelatedObjects.Item(ref: ref, display: "Ada") + + let written = try JSONEncoder().encode(item) + #expect(try JSONDecoder().decode(EarlierItem.self, from: written) == EarlierItem(ref: ref, display: "Ada")) + let earlier = try JSONEncoder().encode(EarlierItem(ref: ref, display: "Ada")) + #expect(try JSONDecoder().decode(RelatedObjects.Item.self, from: earlier) == item) + + // One only inserted has no reference, and round-trips by the identity it was staged under. + let staged = PendingObjectID(uri: URL(string: "x-coredata:///Employee/t0A1B2C3D")!, entity: "Employee") + let inserted = RelatedObjects.Item(object: staged) + let decoded = try JSONDecoder().decode(RelatedObjects.Item.self, from: JSONEncoder().encode(inserted)) + #expect(decoded == inserted && decoded.ref == nil) + } + @Test func saysHowManyThereAreWhenItShowsFewer() async throws { let session = try await open(.company) defer { Task { await session.close() } } diff --git a/Tests/DabbiStoreTests/StagedLinksTests.swift b/Tests/DabbiStoreTests/StagedLinksTests.swift new file mode 100644 index 0000000..a3d649a --- /dev/null +++ b/Tests/DabbiStoreTests/StagedLinksTests.swift @@ -0,0 +1,177 @@ +import DabbiBase +import DabbiTestSupport +import FixtureKit +import Foundation +import Testing + +@testable import DabbiStore + +/// EDT-3: relationships edited by linking and unlinking. Each is one staged edit — undone and redone on its own — +/// Core Data keeps the other end in step, and nothing is written before the commit. +@Suite struct StagedLinksTests { + private let access = StoreAccess.editable(WriteAuthorization(author: "Tests")) + + private func open(_ fixture: Fixture) async throws -> (StoreSession, FixtureLocation) { + let location = try TestFixtures.scratchCopy(fixture) + let session = try await StoreSession.open( + storeURL: location.storeURL, modelURL: location.modelURL, access: access) + return (session, location) + } + + private func refs(_ session: StoreSession, _ entity: String) async throws -> [ObjectRef] { + try await session.references(FetchSpec(entity: entity, includeSubentities: false)) + } + + private func linked(_ session: StoreSession, _ ref: ObjectRef, _ name: String) async throws -> [PendingObjectID] { + try await session.related(to: ref, through: name).items.map(\.object) + } + + @Test func aToManyIsLinkedAndUnlinkedAndTheOtherEndFollows() async throws { + let (session, location) = try await open(.company) + let person = try #require(try await refs(session, "Person").first) + let tags = try await linked(session, person, "tags") + let tag = try #require(try await refs(session, "Tag").first { !tags.contains(PendingObjectID($0)) }) + + let changes = try await session.link([PendingObjectID(tag)], to: PendingObjectID(person), through: "tags") + #expect(changes.undoActionName == "Link tags" && changes.undoDepth == 1) + #expect(try await linked(session, person, "tags").contains(PendingObjectID(tag))) + // Core Data keeps the inverse: the tag lists the person without being told. + #expect(try await linked(session, tag, "people").contains(PendingObjectID(person))) + + // Linking what is already linked is no edit. + let again = try await session.link([PendingObjectID(tag)], to: PendingObjectID(person), through: "tags") + #expect(again.undoDepth == 1) + + let unlinked = try await session.unlink( + [PendingObjectID(tag)], from: PendingObjectID(person), through: "tags") + #expect(unlinked.undoActionName == "Unlink tags" && unlinked.undoDepth == 2) + #expect(try await linked(session, person, "tags") == tags) + let people = try await linked(session, tag, "people") + #expect(!people.contains(PendingObjectID(person))) + // The tag itself stays: unlinking deletes nothing. + #expect(try await refs(session, "Tag").contains(tag)) + + try await session.undo() + #expect(try await linked(session, person, "tags").contains(PendingObjectID(tag))) + try await session.redo() + #expect(try await linked(session, person, "tags") == tags) + + // Nothing reached the file. + try await session.discardChanges() + await session.close() + let reader = try await StoreSession.open(storeURL: location.storeURL, modelURL: location.modelURL) + #expect(try await linked(reader, person, "tags") == tags) + await reader.close() + } + + @Test func anOrderedToManyTakesNewObjectsAtTheEnd() async throws { + let (session, _) = try await open(.ordered) + let playlist = try #require(try await refs(session, "Playlist").first) + let featured = try await linked(session, playlist, "featured") + #expect(featured.count == 3) + let track = try #require(try await refs(session, "Track").first { !featured.contains(PendingObjectID($0)) }) + + try await session.link([PendingObjectID(track)], to: PendingObjectID(playlist), through: "featured") + #expect(try await linked(session, playlist, "featured") == featured + [PendingObjectID(track)]) + + // Taking one out keeps the order of the rest. + try await session.unlink([featured[0]], from: PendingObjectID(playlist), through: "featured") + let rest = try await linked(session, playlist, "featured") + #expect(rest == Array(featured.dropFirst()) + [PendingObjectID(track)]) + await session.close() + } + + @Test func aToOneTakesOneObjectAndReplacesWhatItHeld() async throws { + let (session, _) = try await open(.company) + let people = try await refs(session, "Person") + #expect(people.count >= 3) + let (person, first, second) = (people[0], people[1], people[2]) + let object = PendingObjectID(person) + + try await session.link([PendingObjectID(first)], to: object, through: "boss") + #expect(try await linked(session, person, "boss") == [PendingObjectID(first)]) + try await session.link([PendingObjectID(second)], to: object, through: "boss") + #expect(try await linked(session, person, "boss") == [PendingObjectID(second)]) + // The first boss no longer has this person reporting to them. + let reports = try await linked(session, first, "reports") + #expect(!reports.contains(object)) + + let error = await #expect(throws: DabbiError.self) { + try await session.link([PendingObjectID(first), PendingObjectID(second)], to: object, through: "boss") + } + #expect(error?.code == .invalidValue) + + // Unlinking what it does not hold changes nothing; unlinking what it does empties it. + let depth = try await session.pendingChanges().undoDepth + let unchanged = try await session.unlink([PendingObjectID(first)], from: object, through: "boss") + #expect(unchanged.undoDepth == depth) + try await session.unlink([PendingObjectID(second)], from: object, through: "boss") + #expect(try await linked(session, person, "boss").isEmpty) + await session.close() + } + + @Test func aNewRelatedObjectIsInsertedAndLinkedAsOneEdit() async throws { + let (session, _) = try await open(.company) + let department = try #require(try await refs(session, "Department").first) + let before = try await linked(session, department, "employees") + + let (employee, changes) = try await session.insertRelatedObject( + to: PendingObjectID(department), through: "employees") + #expect(employee.isInserted && employee.entity == "Employee") + #expect(changes.undoActionName == "New Employee" && changes.undoDepth == 1) + #expect(changes.change(for: employee)?.kind == .inserted) + + // Listed on the department's side after the saved objects, and pointing back at it from its own. + let related = try await session.related(to: department, through: "employees") + #expect(related.count == before.count + 1) + #expect(related.items.map(\.object) == before + [employee]) + #expect(related.items.last?.ref == nil) + guard case .toOne(let back, _)? = try await session.stagedObject(employee)["department"] else { + Issue.record("the new employee has no department") + await session.close() + return + } + #expect(back == department) + + // A sub-entity of the destination may be asked for; anything else may not. + let (manager, _) = try await session.insertRelatedObject( + to: PendingObjectID(department), through: "employees", entity: "Manager") + #expect(manager.entity == "Manager") + let error = await #expect(throws: DabbiError.self) { + try await session.insertRelatedObject(to: PendingObjectID(department), through: "employees", entity: "Tag") + } + #expect(error?.code == .invalidValue) + + // One undo takes the manager back, another the employee and its link. + try await session.undo() + try await session.undo() + #expect(try await linked(session, department, "employees") == before) + await #expect(throws: DabbiError.self) { try await session.stagedObject(employee) } + await session.close() + } + + @Test func whatARelationshipCannotHoldIsRefusedBeforeAnythingIsStaged() async throws { + let (session, _) = try await open(.company) + let department = PendingObjectID(try #require(try await refs(session, "Department").first)) + let tag = PendingObjectID(try #require(try await refs(session, "Tag").first)) + + let wrongEntity = await #expect(throws: DabbiError.self) { + try await session.link([tag], to: department, through: "employees") + } + #expect(wrongEntity?.code == .invalidValue) + let noSuchRelationship = await #expect(throws: DabbiError.self) { + try await session.link([tag], to: department, through: "tags") + } + #expect(noSuchRelationship?.code == .unknownProperty) + #expect(try await session.pendingChanges() == .none) + await session.close() + + let location = try TestFixtures.scratchCopy(.company) + let reader = try await StoreSession.open(storeURL: location.storeURL, modelURL: location.modelURL) + let readOnly = await #expect(throws: DabbiError.self) { + try await reader.unlink([tag], from: department, through: "employees") + } + #expect(readOnly?.code == .notEditable) + await reader.close() + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 04b097d..8e92567 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -280,7 +280,7 @@ Also here: **model diff** (two `ModelDescription`s → added/removed/changed ent **Staged edits (EDT-8)** — ADR-11: -- Edits mutate objects in the `edit` context. *Pending Changes* = `insertedObjects` / `updatedObjects` (with `changedValues()` vs `committedValues(forKeys:)`) / `deletedObjects`, exposed as `[PendingChange]`. (As built, M3-06: an inserted object has no `ObjectRef` until the commit gives it a primary key. `StoreSession.stagedObject(_:)` reads any object by its `PendingObjectID`, inserted ones included, and `CommitSummary.insertedRefs` gives the reference each inserted object got, so a front end showing one goes on showing it.) +- Edits mutate objects in the `edit` context. *Pending Changes* = `insertedObjects` / `updatedObjects` (with `changedValues()` vs `committedValues(forKeys:)`) / `deletedObjects`, exposed as `[PendingChange]`. (As built, M3-06: an inserted object has no `ObjectRef` until the commit gives it a primary key. `StoreSession.stagedObject(_:)` reads any object by its `PendingObjectID`, inserted ones included, and `CommitSummary.insertedRefs` gives the reference each inserted object got, so a front end showing one goes on showing it. Relationships are edited with `link`, `unlink` and `insertRelatedObject`, each one undoable edit whose inverse Core Data keeps; `RelatedObjects.Item` names its object by `PendingObjectID`, so a linked object only inserted is listed as well.) - Undo/redo = the context's `UndoManager`, bridged to the window's undo manager. (As built, M3-04: the edit context's manager does not group by event — one group per staged edit — and its depth is reported with every answer as `PendingChanges.undoDepth`; the window's manager registers one mirrored entry only when that depth grew, as a top-level group of its own, since answers arrive in task continuations that would otherwise share one event group.) - After every staged edit the touched objects run `validateForInsert/Update/Delete`; `NSError`s (incl. `NSDetailedErrorsKey`) are mapped by `ValidationTranslator` to per-field, plain-language `ValidationIssue`s (EDT-2). (As built, M3-05: every changed object is validated whenever what is staged is reported — each edit, undo and redo — and the issues travel as `PendingChanges.issues`; the rule comes from the error code when it names one and otherwise from the shape of the failed predicate, `NSValidationPredicateErrorKey`, since a model built in code reports the generic code; the value, `NSValidationValueErrorKey`, is never read. A refused commit's diagnosis is the same issues. `StoreSession.deletePreview(of:)` follows the delete rules by reading rather than by staging and undoing, so that a cancelled preview leaves the redo stack alone, and finds what a relationship without an inverse leaves dangling by a fetch.) - **Commit pipeline:** mint-check `WriteAuthorization` → guards (CloudKit EDT-11, live process EDT-10) → if first commit this session: backup via `DabbiSnapshots`, then *verify* it (`integrity_check` + row-count spot check) → `save()` → on optimistic-lock conflict (the app changed the same row) show mine/theirs per object; merge policy is `NSErrorMergePolicy` so nothing is silently overwritten → bump generation. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index f93cb1d..a74c330 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -335,7 +335,8 @@ Every spike ends as either a permanent *format canary* test or an ADR amendment. - Tests — engine: `ValueTextTests` (7; they also run on Linux, being platform-neutral). App: `EditingSessionTests` +2 (the inspector stages what is typed and explains what it cannot read; a read-only store offers nothing to edit). - Slice 2 — inline grid editing. Double-clicking an attribute cell of an editable store opens `CellEditor`, a popover anchored to the cell with the same text field, refusal and *Set to Nil* as the inspector's; Return stages and closes it, Escape or a click elsewhere gives it up. A popover rather than the cell's own label, because the grid reloads and reuses its cells as pages arrive and edits are staged, and a label being typed into would be written over. What can be edited, how text is read and how it is staged moved from the inspector to `ProjectContext` (`ValueEditing.swift`), so both places follow one set of rules. Tests: `EditingWindowTests` +1 (a read-only store and the grid's own columns offer nothing; an attribute cell's editor explains what it cannot read and stages what it can, which the grid then shows). - Slice 3 — New Object. *Data › New Object* (⌘N, as the PRD has it) stages an object of the grid's entity, opens the inspector on its Details tab, and shows the new object there to be filled in; an abstract entity has no New Object. The inspector now follows a `PendingObjectID` rather than an `ObjectRef`, and reads through `StoreSession.stagedObject(_:)` (`StagedObject`, `DabbiBase`), which reads a saved object as `object(_:)` does and an inserted one by the temporary identity it was staged under. The header says the object is new instead of showing a temporary URI that means nothing outside the window. Clicking an inserted object in the Pending Changes panel inspects it too. On commit, `CommitSummary.insertedRefs` gives the reference each inserted object got, and the inspector goes on showing it by that reference. An undone insert reads as gone. The relationships panel and the content viewer read saved objects only (`inspectedRef`): an object with no key has no rows to follow and no stored bytes. Type names in the Details tab now come from the inspected object's own entity, not the grid's, which matters for an object picked in the relationships panel. Tests — engine: `StagedEditsTests` +1 (an inserted object read as staged, then by the reference its commit gave it; a saved one read as `object(_:)` reads it) and an undone insert no longer readable. App: `EditingSessionTests` +2 (a new object filled in in the inspector and followed through the commit; an abstract entity has no New Object). - - Next: the grid does not list inserted objects until they are committed, and a fetch with a limit (`references(limit:)`, a pager's window) counts inserted objects against it and then drops them, having no reference to return; to-one and to-many editing, which need link/unlink in the engine and an object picker; the detail window. A keyboard way into the grid's editor (the inspector's pencil and VoiceOver action are the keyboard path meanwhile). + - Slice 4a — relationship editing in the relationships panel. The engine links and unlinks: `StoreSession.link(_:to:through:)` adds to a to-many (at the end of an ordered one) or sets a to-one, replacing what it held; `unlink(_:from:through:)` takes objects out, or empties a to-one that holds one of them; `insertRelatedObject(to:through:entity:)` makes an object of the destination (or one of its sub-entities) already linked, as one edit. Each is one undoable edit; what is already linked, or not linked, is passed over and leaves no edit behind; Core Data keeps the inverse; either side may be an object only inserted. `RelatedObjects.Item` now names its object by `PendingObjectID`, so a linked object that is only inserted is listed too (after the saved ones, marked *New*). In the app, the panel re-reads whenever what is staged changes. When the store is editable, the followed to-many has a **+** (a menu of the concrete entities when the destination has sub-entities) that makes a related object and shows it in the inspector, and every listed object has *Unlink* in its context menu, as a VoiceOver action and on the Delete key; the object itself stays. A to-one's new object is left for the picker (next slice), since a to-one pointing at an object only inserted reads as empty in the grid and the inspector until the commit. Tests — engine: `StagedLinksTests` (5: a to-many linked and unlinked with the inverse following and the file untouched; an ordered to-many taking new objects at the end and keeping the order of the rest; a to-one replaced, refused two objects, emptied only by what it holds; a related object inserted and linked as one edit, sub-entity allowed, another entity refused; refusals before anything is staged, and a read-only session). App: `EditingSessionTests` +1 (the panel makes a related object, shows it, and unlinks it). + - Next: an object picker to link existing objects and set to-ones (and a to-one's new object); the detail window; the grid does not list inserted objects until they are committed, and a fetch with a limit (`references(limit:)`, a pager's window) counts inserted objects against it and then drops them, having no reference to return. A keyboard way into the grid's editor (the inspector's pencil and VoiceOver action are the keyboard path meanwhile). ---