Associations / Observation best practices

What are the best practices as it relates to observation of objects and their associations? Is the common approach for notifications of changes simply to observe both of the tables that might change and update accordingly or is there a better GRDB-esque approach? Riffing off of the Player example... Imagine the backend data model being something like:

struct Player: Codable, FetchableRecord, PersistableRecord {
    var id: Int64
    var name: String
}

struct Score: Codable, FetchableRecord, PersistableRecord {
    var playerId: Int64
    var score: Int
}

Of course at this point I could observe both Player and Score for a given playerId and update UI accordingly when needed. If I were to create PlayerInfo using associations (which are great!) though:

struct PlayerInfo {
    var player: Player
    var score: Score?
}

Would it be possible to observe a PlayerInfo object courtesy of GRDB or is it essentially a one-off convenience rollup for combined data? I suppose I have the same general question for objects created with raw SQL using Row / ScopeAdapters. Are they observable "out of the box" in any way?

Alternatively of course I could just create and observe the traditional "combined" Player database object locally:

struct Player: Codable, FetchableRecord, PersistableRecord {
    var id: Int64
    var name: String
    var score: Int
}

and update this combined model when the separate models from the backend (Player / Score) change. Ideally I'd like to maintain the same model as the backend when feasible I think, but if it complicates observation perhaps it's better to additionally have "combined" objects locally?

Apologies if these questions are naive... I understand that there's probably no "one" solution that's the "right" one in all cases, but I've been struggling with the observation concept for objects that have a lot of possible associations or require creating "composite" objects using Row / ScopeAdapaters and the like.

Thanks!

1 Like

Hi @mtp_mc,

Is the common approach for notifications of changes simply to observe both of the tables that might change and update accordingly or is there a better GRDB-esque approach?

The way I understand your question is whether merging the results of two distinct observations is the way to go (one for player, one for score), or if it is possible to observe both at the same time.

Merging the results of multiple observations is certainly possible, and I suppose you were able to do it.

However, beware: two distinct observations fetch their values at different points in time, and may not access the same state of the database. Indeed, between the two fetches, some write(s) can happen. Technically speaking, we say that observations do not fetch their values from the same snapshot of the database.

The bad consequence is that the merged values may not reflect the database invariants. Let's illustrate:

When you merge an observation of the player, and a distinct observation of the score, you may end up with a pair (Player?, Score?) where the player is nil, and the score is not nil: a score without any player! I assume that this pair is unrepresentable in the database file, and it is certainly not representable by your PlayerInfo type. But that's what you end up with if the app happens to 1. fetch a score, then 2. delete the player, then 3. fetch the player!

The only way to make sure database invariants are reflected in the fetched values is to perform all fetches in one stroke, from the same database access. This is easy: a database access is everything between { db in and }:

func fetchPlayerInfo(_ db: Database, forPlayerId playerId: Int64) {
  guard let player = try Player.fetchOne(db, id: playerId) else {
    return nil
  }
  let score = try Score.filter(Column("playerId") == playerId).fetchOne(db)
  return PlayerInfo(player: player, score: score)
}

// A plain read
let playerInfo = dbQueue.read { db in
  // In this database access, database invariants are guaranteed.
  try fetchPlayerInfo(db, forPlayerId: 42)
}

// An observation
let playerInfoObservation = ValueObservation.tracking { db in
  // In this database access, database invariants are guaranteed.
  try fetchPlayerInfo(db, forPlayerId: 42)
}

To sum up:

  • What you can do in dbQueue.read { db in ... }, you can do in ValueObservation.tracking { db in ... }. In those database accesses, you can perform as many fetch requests as you need.
  • It is recommended to fetch or observe all related values together, from the same database access, when you care about database invariants.
  • Only break the previous rule when some kind of optimization is needed - and then be ready for broken invariants.
  • See the Concurrency Guide if you are looking for a longer discussion.

If I were to create PlayerInfo using associations [...] Would it be possible to observe a PlayerInfo object courtesy of GRDB [...]?

Sure:

extension Player {
  // I assume a player has a single score
  static let score = hasOne(Score.self)
}

struct PlayerInfo: Codable, FetchableRecord {
    var player: Player
    var score: Score?
}

let playerInfoObservation = ValueObservation.tracking { db in
  try Player
    .filter(id: 42)
    .including(optional: Player.score)
    .asRequest(of: PlayerInfo.self)
    .fetchOne(db)
}

This way to fetch a PlayerInfo is strictly equivalent to the fetchPlayerInfo function seen above.

I suppose I have the same general question for objects created with raw SQL using Row / ScopeAdapters. Are they observable "out of the box" in any way?

Yes. Anything that is fetched inside ValueObservation.tracking { db in ... } is tracked. Including requests performed with raw SQL. You have a few examples in the documentation for ValueObservation.tracking(_:).

I've been struggling with the observation concept for objects that have a lot of possible associations or require creating "composite" objects using Row / ScopeAdapaters and the like.

RowAdapter is rather advanced and low-level… It certainly exists for a few reasons. One of them is to provide the foundations for associations. Another is to support hand-crafted SQL queries that join tables together. If you have questions about row adapters, maybe start a new thread.

Alternatively of course I could just create and observe the traditional "combined" Player database object locally:

struct Player: Codable, FetchableRecord, PersistableRecord {
    var id: Int64
    var name: String
    var score: Int
}

I would not recommend this if you have two distinct database tables, player and score. It will be difficult for such a Player type to correctly implement save or insert or update, because it would have to deal with the score table. The "official" recommendation is to keep persistable record types responsible for their tables (exclusively).

4 Likes