Skip to content
academia.sh

Lesson 17 / 19

Object Persistence Frameworks

A measurement of how creating and saving objects in a context is repeat-sensitive, how saving with a unique constraint is order-sensitive, and how the constraint changes the class.

Published Updated

In this lesson

  • Separates that an object persistence framework works with objects instead of queries
  • Measures that creating and saving a new object in a context is repeat-sensitive
  • Verifies that saving with a unique constraint is robust with the same value and order-sensitive with a different value
  • Shows that the class of the same save changes when the constraint is removed
Contents

In the previous lesson we approached the database at the level of rows and primary keys. Most apps think not in rows but in objects of their own types: a note, an order, a person. An object persistence framework tracks these objects inside a context, accumulates the changes, and writes them to the store underneath when saved. The counterpart of a row is an object; the counterpart of a primary key is most often a unique constraint. Our question does not change: if this operation is repeated or reordered, does the result change?

// Replay.swift — classifies an operation's sensitivity to repetition and order
struct Trial<State: Equatable & CustomStringConvertible> {
    let label: String
    let once: State
    let twice: State
    let forward: State
    let backward: State

    var sensitivity: String {
        switch (once != twice, forward != backward) {
        case (false, false): return "robust"
        case (true, false): return "repeat-sensitive"
        case (false, true): return "order-sensitive"
        case (true, true): return "doubly-sensitive"
        }
    }

    func report() -> String {
        print("\(label) | once=\(once) twice=\(twice) ab=\(forward) ba=\(backward) -> \(sensitivity)")
        return sensitivity
    }
}

func replay<State>(_ label: String, from start: State, _ a: (State) -> State, then b: (State) -> State) -> Trial<State> {
    Trial(label: label, once: a(start), twice: a(a(start)), forward: b(a(start)), backward: a(b(start)))
}

func countSensitivities(_ results: [String]) {
    for name in ["robust", "repeat-sensitive", "order-sensitive", "doubly-sensitive"] {
        print("\(name): \(results.filter { $0 == name }.count)")
    }
}

once is the operation applied to the starting state one time, twice is two times; ab is a first and then b, and ba is the reverse order. The model is the pure Swift counterpart of a context. It is not the framework itself; it only builds the result of creating and saving an object.

Context, Object, and Save

A context is the manager of a set of objects living in memory. The app creates a new object, adds it to the context, and saves. Until it is saved, the change exists only in memory. There are two ways of writing it: the older framework uses a persistent container and a managed object context; the newer framework uses classes marked with @Model and a model context.

import SwiftData

@Model
final class Note {
    @Attribute(.unique) var id: String
    var body: String

    init(id: String, body: String) {
        self.id = id
        self.body = body
    }
}

let container = try ModelContainer(for: Note.self)
let context = ModelContext(container)
context.insert(Note(id: "k1", body: "a1"))
try context.save()

This fragment is SOURCE; it needs a model container and a disk, so it does not run here. @Attribute(.unique) makes an attribute unique: a second object that arrives with the same id does not open a new record, it updates the existing one. If the attribute is left without a constraint, every insertion becomes a new record. In the older framework the same purpose is served by a uniqueness constraint and a merge policy. The measurement of this lesson compares the two cases.

The Model of the Context

The model keeps a list of records. Each record is a key and a value. If the constraint is on, a record that arrives with the same key takes the place of the existing record; if it is off, it is added as a new record. For an object created without a key, the context supplies its own key.

// ContextModel.swift — a pure Swift model of a context
struct ContextBox: Equatable, CustomStringConvertible {
    struct Entry {
        let key: String
        var value: String
    }

    private(set) var entries: [Entry] = []
    private(set) var nextId = 1
    let enforcesUnique: Bool

    init(enforcesUnique: Bool = true) {
        self.enforcesUnique = enforcesUnique
    }

    var description: String {
        entries.isEmpty ? "-" : entries.map { "\($0.key)=\($0.value)" }.joined(separator: ",")
    }

    static func == (lhs: ContextBox, rhs: ContextBox) -> Bool {
        lhs.description == rhs.description
    }

    mutating func create(_ value: String) {
        entries.append(Entry(key: "obj\(nextId)", value: value))
        nextId += 1
    }

    mutating func save(_ value: String, key: String) {
        if enforcesUnique, let index = entries.firstIndex(where: { $0.key == key }) {
            entries[index].value = value
        } else {
            entries.append(Entry(key: key, value: value))
        }
    }
}

func creating(_ value: String) -> (ContextBox) -> ContextBox {
    { context in
        var next = context
        next.create(value)
        return next
    }
}

func saving(_ value: String, key: String) -> (ContextBox) -> ContextBox {
    { context in
        var next = context
        next.save(value, key: key)
        return next
    }
}

PS12. The model assumes that when the same unique key arrives, the last writer wins: the value of the existing record is replaced with the new one. The framework’s real merge policy and its settings were not measured.

PS13. The model builds every operation as adding and saving immediately. Unsaved changes, undo, and multiple contexts were not built.

Measuring Four Operations

Four operations are built. The first is creating a new object with the same value. The second is creating two objects with two different values. The third is saving the same value twice to a unique key. The fourth is saving two different values to the same key.

// main.swift — object creation and unique save
let createSame = replay("create-same-value", from: ContextBox(), creating("a1"), then: creating("a1")).report()
let createTwo = replay("create-two-values", from: ContextBox(), creating("a1"), then: creating("b2")).report()
let uniqueSame = replay("constrained-same-value", from: ContextBox(), saving("a1", key: "k1"), then: saving("a1", key: "k1")).report()
let uniqueTwo = replay("constrained-two-values", from: ContextBox(), saving("a1", key: "k1"), then: saving("b2", key: "k1")).report()

countSensitivities([createSame, createTwo, uniqueSame, uniqueTwo])
create-same-value | once=obj1=a1 twice=obj1=a1,obj2=a1 ab=obj1=a1,obj2=a1 ba=obj1=a1,obj2=a1 -> repeat-sensitive
create-two-values | once=obj1=a1 twice=obj1=a1,obj2=a1 ab=obj1=a1,obj2=b2 ba=obj1=b2,obj2=a1 -> doubly-sensitive
constrained-same-value | once=k1=a1 twice=k1=a1 ab=k1=a1 ba=k1=a1 -> robust
constrained-two-values | once=k1=a1 twice=k1=a1 ab=k1=b2 ba=k1=a1 -> order-sensitive
robust: 1
repeat-sensitive: 1
order-sensitive: 1
doubly-sensitive: 1

When creating a new object with the same value is repeated, it leaves two objects: obj1=a1 and obj1=a1,obj2=a1. The order is meaningless. The class is repeat-sensitive. Creation code that runs a second time because a screen appears again or because of a retry shows up to the user as a duplicate record.

Creation with two different values separates both repetition and order: the identities are distributed to the values differently. The class is doubly sensitive.

Saving the same value twice to a unique key changes nothing. The constraint makes repetition robust. The class is robust.

When two different values are saved to the same key, the order decides the result: ab is b2, ba is a1. The constraint prevents a duplicate record, but which value wins in a collision is left to the order. The class is order-sensitive.

The count is one of each class.

Schema Migration

The object type of an app changes between versions: an attribute is added, a name changes, two types merge. On the user’s device, meanwhile, the data written by the old version sits there. Schema migration is the step that carries data written with the old schema to the new schema. Simple changes, for example adding an attribute, can be done by the framework automatically; changes such as renaming or merging two fields need an explicit mapping.

Schema migration was not built in the model of this lesson. This is because its result depends on the old data in the store and on the order of versions; it cannot be read from the in-memory state of a context. Here it stays as SOURCE and prose. The practical rule is simple: no version that changes the schema is released without being tried with data written by the old version.

Common Mistakes

  • Tying creation code to the appear event of a screen. A new object is born every time the screen appears.
  • Not making a natural identity unique when the record has one. A retry is duplicated.
  • Assuming that a uniqueness constraint resolves a collision. In a collision, the order decides the winner.
  • Forgetting to save. The change is in memory and is lost when the app closes.
  • Sharing a context across threads. The framework does not support this; each thread uses its own context.
  • Changing the schema and releasing it without trying it with old data.

The Bounding Measurement

To isolate the contribution of the constraint, we measure the same pair of records with the constraint off. If the constraint is off, a record that arrives with the same key does not take the place of the existing record; it becomes a new record.

// main.swift — constraint on and off
let openSame = replay("unconstrained-same-value", from: ContextBox(enforcesUnique: false), saving("a1", key: "k1"), then: saving("a1", key: "k1")).report()
let openTwo = replay("unconstrained-two-values", from: ContextBox(enforcesUnique: false), saving("a1", key: "k1"), then: saving("b2", key: "k1")).report()
let closedSame = replay("constrained-same-value-again", from: ContextBox(), saving("a1", key: "k1"), then: saving("a1", key: "k1")).report()

countSensitivities([openSame, openTwo, closedSame])
unconstrained-same-value | once=k1=a1 twice=k1=a1,k1=a1 ab=k1=a1,k1=a1 ba=k1=a1,k1=a1 -> repeat-sensitive
unconstrained-two-values | once=k1=a1 twice=k1=a1,k1=a1 ab=k1=a1,k1=b2 ba=k1=b2,k1=a1 -> doubly-sensitive
constrained-same-value-again | once=k1=a1 twice=k1=a1 ab=k1=a1 ba=k1=a1 -> robust
robust: 1
repeat-sensitive: 1
order-sensitive: 0
doubly-sensitive: 1

The same pair of records came out repeat-sensitive with the constraint off and robust with it on. The pair with two values was doubly sensitive without the constraint and order-sensitive with it. The constraint solved repetition and brought two rows down to one row; it did not solve order.

The limit of this measurement is PS12. The model assumes that the last writer wins in a collision. In the real framework the merge policy can be set, and some policies keep the existing record; none was measured in this lesson. If the policy changes, ab and ba can swap places, but the conclusion that the two will not be equal does not change.

Where It Shows Up in a Real App

Object persistence frameworks turn an object graph into rows on your behalf; this convenience is also the reason repetition becomes invisible. When the user creates a note, the app makes a new object and saves the context. If the same code path runs again in a retry or a screen refresh, the framework produces a new object each time. This is the duplication we saw in the create-same-value line: two calls, two objects. The same note stands twice on the list screen, and no error was given.

A uniqueness constraint prevents this duplication. Once an attribute is marked unique, a second object with the same value cannot be added; the framework either merges the new object with the existing one or rejects the save. In the constrained-same-value line, two calls give a single object and the class is robust. But the constraint does not resolve two different values arriving with the same unique key: in the constrained-two-values line the remaining value depends on the order. The constraint solves repetition, not order.

In a real app this shows up when records coming from the server are matched with the local copy. The server gives a stable identity for each record; if the local record is made unique with that identity, processing the same response twice produces one record. Without the identity, the local object is created again and two copies are born.

A Wrong Setup and Its Fix

The common wrong setup is to check uniqueness in code: “is there an object with this value, otherwise create it.” The gap between the check and the creation is where two operations can both get the answer “no.” Besides, the check looks only at the objects in the context; an object in another context or one not yet saved is missed.

The fix is to make uniqueness a part of the schema. The constraint sits in the storage layer and is applied on every write; it does not matter which path the code came from. The merge policy of the framework is also decisive here. The policy says which value stays in a collision. Our model assumed that the last writer wins, and this is recorded as PS12. If another policy keeps the existing record, ab and ba swap places; but the fact that the two orders do not give the same result does not change.

The second mistake is forgetting to save. Code that creates the object and does not save the context assumes the change is permanent. When the app closes, the object is lost. Our model adds and saves every operation immediately; PS13 records this. In real code, who makes the save call must be determined explicitly everywhere work is done.

Reading the SOURCE Fragment

The unnamed fragment at the start of the lesson defines an object type, opens a context, creates the object, and saves. The object type specifies the attributes that will be persistent, and the framework turns them into a table. What the fragment really shows is that the call you make with “save” is in fact a transaction boundary: the changes made up to this call stay in memory, and with the call they go to disk.

The model reduced this to a single step and did not build unsaved changes, undo, and multiple contexts. These are important situations in a real app; but the question we measured, that is, repeating the same operation and changing its order, stays meaningful with this simplification too. The version change described in the schema migration section is a separate topic, and in this lesson it is only described, not measured.

From Measurement to Design

The measurement of the object framework separates what the constraint does from what it does not do. Without the constraint, repeated creation creates duplication; with the constraint, the same calls give a single object. But the constraint resolves only equality. Which of two different values that arrive with the same key stays depends on the merge policy of the framework. So the design requires making two decisions separately: which attribute determines identity, and which value stays in a collision.

The choice of identity is a data model decision. For records coming from the server, the identity given by the server is the natural candidate. For records the user creates, the app produces an identity and writes this identity into the record itself at the moment of creation, once. Producing the identity again on every call makes the constraint meaningless, because every call carries a different identity and no collision ever arises.

The policy decision is a product decision. Letting the last writer win is simple and carries a risk of data loss; keeping the existing record is safe but throws away the new information; merging field by field gives the best result and needs the most work. The measurement does not say which is right. The measurement only shows which class the chosen policy leads to.

There is also schema migration. The data type changes between versions, and the data written by the old version must be carried to the new schema. Simple additions can be done automatically by the framework; renaming and merging want an explicit mapping. A migration is an operation that runs once in each version. If it is repeated or cut halfway, the data must remain either in the old or in the new form; a half-finished migration is the most expensive form of the repeat sensitivity we measured.

A situation outside the measurement must also be noted: related objects. If there is a relation between a note and its tags, what does deleting the note do to the tags, and what does deleting a tag do to the note? The framework asks for the answer to this question as a delete rule: break the relation, delete the connected objects too, or refuse the deletion. If no rule is given, the default behavior takes effect, and most often orphan objects remain. The model of this lesson does not build relations; but in a real schema this decision is as important as the uniqueness constraint and should be written with the same care.

The connection of the delete rule to the measurement is direct. If a delete operation is repeated, the second deletion targets an object that no longer exists; whether the framework gives an error in this case decides whether the deletion is robust against repetition. Not assuming this without measuring it is the general habit of this lesson.

Summary

  • An object persistence framework tracks objects in a context and writes them to the store when saved.
  • When creating a new object with the same value is repeated, it leaves two objects (obj1=a1 then obj1=a1,obj2=a1); the class is repeat-sensitive.
  • A save with a unique constraint is robust with the same value and order-sensitive with a different value for the same key (ab is b2, ba is a1).
  • If the constraint is removed, the same pair of records becomes repeat-sensitive; the pair with two values becomes doubly sensitive.
  • Schema migration and the merge policy were not measured; they stayed as SOURCE and prose.
  • This lesson’s sensitivity: repeat-sensitive.

Next Step

Until now all the data stayed on a single device. In the next lesson, Cloud Sync, we will measure the case where two devices write the same record, and see how the values of the devices arriving in a different order changes the result.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close