How to Recursively Delete Foreign Keys in Mirage

My current project uses Mirage to implement an offline demo version of our Ember web app. Recently, we tackled a story to add some newly-implemented features to the demo version. Implementing the mocked version of the feature in Mirage gave us an opportunity to write a nice recursive function. The function was both generic to future use cases and easier to implement than the naive, one-off approach.

The problem

The new feature in question was the ability to delete a set of entities. In domain-driven design parlance, the entities were important aggregates in our system. This means that in the real database as well as in our mocked Mirage backend, these entities had foreign key relationships with several other entities. Those entities, in turn, had more relationships, and so on.

Our first crack at implementing the mocked feature was to simply delete the models in question from our Mirage database. However, deleting top-level models without cleaning up their children left our app in a broken state. A page that looked up child models would render, and then Mirage would throw 500s when trying to look up the now-deleted parents.

We knew we needed to get the state of our mocked backend cleaned up. Our very first spike started with the question, “What models are children?” Okay, let’s clean those up. “Now, what models are children of those?” The implementation immediately turned crufty and unpleasant. It was also sure to break down the road when relationships would change.

The Solution

Taking a step back, it was clear that what we were starting to spell out statically in code was really a recursive operation on models in our Mirage database. Given a top-level model and a set of IDs, we needed to look up which models were children, and then find the IDs of those models and recurse on each child.

It turns out that Mirage keeps track of all the information necessary to implement such an algorithm in a tidy way. Here’s how:


function recursiveDelete(schema, model, ids) {
  if (ids.length === 0) return;

  const collection = schema.find(model, ids);

  Object.values(schema.associationsFor(model))
    .filter((a) => a instanceof HasMany)
    .forEach((hasMany) => {
      const foreignKey = hasMany.getForeignKey();
      const ids = collection.models.flatMap((m) => m[foreignKey]);
      recursiveDelete(schema, hasMany.modelName, ids);
    });

  collection.destroy();
}

We start right away with our base case check. If we’re handed an empty set of IDs, we don’t need to do any work to delete them. Then, we use Mirage’s schema.find method, which works like SELECT * FROM model WHERE Id in ids, to get all the instances of our model. Next, we use another top-level Mirage method, schema.associationsFor, to get the set of models that are children of our current model.

We turn each child model, along with the instances of the current model, into a set of child IDs to delete, then recurse. Once we’ve taken our “recursive leap of faith” and returned from the recursion, we know that all of the children of this model have been deleted. In turn, that means we’re free to delete all of our instances.

For anyone getting flashbacks to their algorithms classes, this is a depth-first search of the tree of models, with post-order deletion of instances of those models. Implementing the algorithm this way has the nice property that it only calls delete on a set of IDs for each model once. It took a while to get the details of Mirage’s API right to implement it like this. Our first attempt was still a recursive solution, but it was a depth-first search of the tree of instances with a post-order delete. This meant we were calling delete with smaller sets of IDs multiple times per model.

The Takeaway

In our case, writing up this recursive solution was easier than statically tracking down all the entity relationships in our Mirage database and deleting them by hand. (This involved being careful to do things in the right order, given that foreign keys were involved).

Furthermore, the extra work to do it that way would have only applied to this one case. Such an approach would have been brittle to future changes in the data model of the entities in question. Any other entities we want to completely delete down the road would need their own error-prone, one-off solutions as well.

Because we recognized the recursive nature of the problem at hand, we were able to tackle it in a way that saved development effort. It also future-proofed our solution to changes in the data model and was reusable in other contexts.