We just finished a long series of episodes introducing everyone to what we like to call “Modern Persistence”. In this world persistence is done with SQLite, a fantastic technology that is probably the most widely deployed piece of software in history, and it has truly stood the test of time and aged wonderfully. Further, we leveraged some advanced querying aspects of SQL in order to efficiently fetch exactly the data we need from our database. This often required joining tables together to fetch data from multiple places at once, as well as performing aggregations so that we could perform many computations on our corpus of data at once.
And finally, we leveraged our modern tools built on top of SQLite in order to construct queries in a type-safe and schema-safe manner, and they allowed us to seamlessly fetch data from the database and observe changes to the database so that when the database changes, our views immediately update. And there are no restrictions to where we can use these tools. We can use them in a SwiftUI view, or an @Observable
model, and although we didn’t get a chance to show it off, we can even use these tools in a UIKit view controller.
So that series of episodes set the foundation of what we like to call “Modern Persistence”, but there is so, so much more we would like to cover on the topic. And so this week we are continuing the modern persistence series by discussing something known as “callbacks”. Callbacks are little hooks into the lifecycle of the database so that you can be notified or take action when something happens with your data. Perhaps the most canonical example of this is maintaining an “updated at” timestamp on your models. You want to have a timestamp that automatically updates each time the data is saved in the database. It would be nice if this could just happen automatically for you in the background so that you don’t have to remember to literally update the timestamp each time you are about save a record.
And callbacks have far-reaching applications beyond just “updated at” timestamps. You can use callbacks to perform an action when a table is emptied out, like if you wanted to make that a table always contains at least one row. You can also use callbacks to implement an undo management system so that each change to the database can be undone or redone. Callbacks also form the basis of how we are able to magically layer on CloudKit synchronization to an existing database.
So, we hope you can see that callbacks can be quite powerful, but what is the modern way to handle callbacks? Let’s start by really defining what a callback is, and then see what are some common approaches to callbacks before finally showing off what we think is the best way to deal with this concept.
Let’s begin!
To explore the concept of callbacks we are going to open up the project we built in our “Modern Persistence” series, which is a partial rebuild of Apple’s Reminders app. We are going to add two new columns to our Reminder
type, which is already quite complex:
@Table
struct Reminder: Identifiable {
let id: Int
var dueDate: Date?
var isCompleted = false
var isFlagged = false
var notes = ""
var priority: Priority?
var remindersListID: RemindersList.ID
var title = ""
enum Priority: Int, QueryBindable {
case low = 1
case medium
case high
}
}
This type holds onto a variety of fields, such as dates, integers, strings, a raw representable enum, and even a foreign key that points to the reminders list that the reminder belongs to.
We are going to add a new field for the date and time that the reminder was created, as well as the date and time the reminder was last updated:
@Table
struct Reminder: Identifiable {
let id: Int
var createdAt: Date?
…
var updatedAt: Date?
…
}
And we will be making these dates optional, since we are theoretically adding them to our application after it has already been released, and existing users may have already added a bunch of reminders to their app that do not have these timestamps, and simply defaulting to the moment they open the update doesn’t seem accurate. So we will allow these reminders to have nil
timestamps.
Now that we have added new fields to our model we need to add a migration to add these properties to the table. And this is another example of how our vision for “Modern Persistence” differs from SwiftData. SwiftData likes to make migrations seem magical by implicitly migrating the underlying database when the app first starts up. When it works, it definitely feels like magic. But when it doesn’t, it can crash your app. And then it’s up to you to manually migrate your schema, which is a multi-step process that is easy to get wrong and requires you to essentially copy-and-paste your models.
Well, luckily for us this is easy to accomplish in SQL with explicit migrations. We can go down to our appDatabase
helper, which is responsible for creating a database connection and migrating the schema, and we can add a new migration:
migrator.registerMigration(
"Add 'createdAt' and 'updatedAt' to 'reminders'"
) { db in
<#code#>
}
It is important to note that we do not want to perform this migration inside our existing “Create tables” migration. Once a migration has shipped to our users we should never edit it again. It is frozen in time, forever. Because if we did add more table alterations to “Create tables”, it would mean when our users install the update, locally the migrator will think it already ran “Create tables” and skip the migration, and then our columns would not be added to the table.
Inside this migration we want to add two columns to our reminders
table. One for createdAt
and one for “updatedAt”, and both will be TEXT
since they hold an ISO-8601-formatted string for the date and time, and both will be nullable since we are not using NOT NULL
:
try #sql(
"""
ALTER TABLE "reminders" ADD COLUMN "createdAt" TEXT
"""
)
.execute(db)
try #sql(
"""
ALTER TABLE "reminders" ADD COLUMN "updatedAt" TEXT
"""
)
.execute(db)
That’s all it takes to add new fields to a model. Everything is compiling, but we aren’t yet showing this data anywhere. Let’s go to the ReminderFormFeature.swift file, which holds a view that displays all the properties of a reminder, and let’s add this data at the bottom of the form:
} footer: {
VStack {
if let createdAt = reminder.createdAt {
Text(
"""
Created: \
\(createdAt.formatted(date: .long, time: .shortened))
"""
)
}
if let updatedAt = reminder.updatedAt {
Text(
"""Updated: \
\(reminder.updatedAt.formatted(date: .long, time: .shortened))
"""
)
}
}
}
Now we can update our preview mocks with some timestamps, load it up, and see when it was created and last updated.
Let’s launch things in the simulator and make an update to a reminder, like changing its name…
…then tap “Save”, and then re-open the reminder. Well, sadly the “Updated” time at the bottom does not actually hold the time the reminder was updated.
And this shouldn’t be too surprising. We haven’t done anything to update our reminder. We need to find in this file where we update the reminder in the data, and make sure to also set the updatedAt
field:
try database.write { db in
var reminder = reminder
reminder.updatedAt = Date()
try Reminder.upsert { reminder }.execute(db)
}
Now when we run the app, make an edit to a reminder, and then navigate back to the reminder, we correctly see when it was last updated.
However, there are other places we can edit this reminder. For example, from the details view we can swipe on a reminder and flag it…
But, if we now go back into the reminder we will see again it did not update its updatedAt
field.
So, we need to update that query also in order to set the updatedAt
state:
try database.write { db in
try Reminder
.find(reminder.id)
.update {
$0.isFlagged.toggle()
$0.updatedAt = Date()
}
.execute(db)
}
But we should also do it when completing a reminder:
try database.write { db in
try Reminder
.find(reminder.id)
.update {
$0.isCompleted.toggle()
$0.updatedAt = Date()
}
.execute(db)
}
And really, to be extra sure we’ve covered all situations, I guess we should search our entire codebase for each time we open a write transaction:
.write { db in
…and see if we are ever mutating any reminders in these transactions. A quick scan of these writes shows that we are not updating the reminders table, and so for the moment we seem to have covered all of our bases.
OK, we have now added createdAt
and updatedAt
timestamps to our Reminder
model, and we have seen the pain that comes with doing this naively. We are personally responsible for remembering to set the updatedAt
value on a reminder anytime we are about to make a change to the database, and inevitably we are going to forget to do this at some point. Whether it be because we have a new engineer working on the codebase that doesn’t know this needs to be done, or because this code is unchanged for a year and when we come back to it we forgot about this piece of arcane knowledge.
And so the problems we are seeing here are what motivated people to create what has become known as “callbacks”. They were popularized by the Rails framework in Ruby where the pattern is built directly into the “Active Record” framework. Callbacks allow you to install little closures directly into your model so that when it detects a save to the database is about to happen, or has just happened, the model will execute the closure. This seems like a perfect place to update our timestamps.
Let’s take a look at how Rails handles this, and then see how other frameworks have adopted this pattern.
I am going to hop over to terminal and create a new Rails app called “reminders”:
$ rails new reminders
We never thought we would be exploring Ruby code on Point-Free when we started this site over 7 years, but here we are.
Next we will switch into the new directly created, and generate a reminder model for our Rails app that has a “title” column:
$ cd reminders
$ rails generate model reminder title:string
That generates a whole bunch of scaffolding for our model, but in order to actually create the table in the database for this model we need to run the db:migrate
command:
$ rails db:migrate
Now Rails is a “batteries included” kind of framework. It does not shy away from including every feature you could possibly need, and baking in a lot of opinionated defaults. And in particular, every Rails model automatically comes with created_at
and updated_at
columns that automatically update when a record is updated in the database. But, that’s the exact thing we want to experiment with here in order to explore callbacks, and so we are going to jump over to the application.rb file and disable that functionality in rails:
config.active_record.record_timestamps = false
OK, so to see how callbacks work in Rails we will hop over to the Reminder
model and directly in the class we can declare a before_save
callback:
class Reminder < ApplicationRecord
before_save do
end
end
If you aren’t familiar with Ruby this may look a little weird, but here we are actually invoking a static method on Reminder
called before_save
that register this “do” block in the ActiveRecord system, which is basically a closure. Under the hood the model keeps track of all of these “do” blocks and will invoke them, one after another, just before saving the record to the database.
This do
block is handed the reminder
that is about to be saved, and so we can mutate it with the current datetime:
class Reminder < ApplicationRecord
before_save do |reminder|
reminder.updated_at = Time.now
end
end
Now just before a reminder is saved, Rails will invoke this do
block, allowing us to update the updated_at
column, and then the full record will be saved.
We can even give this a spin in a Rails console:
$ rails console
We can create a new reminder:
reminders(dev)> reminder = Reminder.new
=> #<Reminder:… id: nil, title: nil, created_at: nil, updated_at: nil>
Set the initial value of created_at
and updated_at
to right now:
reminders(dev)> reminder.created_at = reminder.updated_at = Time.now
And then hit save
on the reminder:
reminders(dev)> reminder.save
TRANSACTION (0.1ms) BEGIN immediate TRANSACTION /*application='Reminders'*/
Reminder Create (2.1ms) INSERT INTO "reminders" ("title", "created_at", "updated_at") VALUES (NULL, '2025-06-19 21:31:30.054478') RETURNING "id" /*application='Reminders'*/
TRANSACTION (1.5ms) COMMIT TRANSACTION /*application='Reminders'*/
=> true
That created a brand new reminder. In order to see the callbacks work we can change the title and hit save again:
reminders(dev)> reminder.title = "Get milk"
=> "Get milk"
reminders(dev)> reminder.save
TRANSACTION (1.7ms) BEGIN immediate TRANSACTION /*application='Reminders'*/
Reminder Create (2.1ms) UPDATE "reminders" SET "title" = 'Get milk', "updated_at" = '2025-06-19 21:32:08.781060' WHERE "reminders"."id" = 1 /*application='Reminders'*/
TRANSACTION (1.5ms) COMMIT TRANSACTION /*application='Reminders'*/
=> true
And in the query we can clearly see that Rails is also updating the updated_at
timestamp. This is only happening because of the before_save
callback we installed.
So this seems great, but there is a downside. These kinds of callbacks can only work at a local, per-record level. This means that the callbacks can only be invoked if we literally call the save
method on the record. If we ever execute SQL outside of going through the Reminder
model, callbacks will not be fired.
For example, ActiveRecord models have an update_all
method that allows us to perform batch updates in one single query. We could update all titles by appending the word “Copy” to the end:
reminders(dev)> Reminder.update_all "title = title || ' Copy'"
Reminder Update All (0.8ms) UPDATE "reminders" SET title = title || ' Copy' /*application='Reminders'*/
=> 1
This did update our reminder, but sadly it did not update the updated_at
column. Because we executed SQL directly, and did not go through the save
method on reminder, the callbacks had no chance to fire. And so you may think the fix is to never use batch update methods like this and only ever use save
. Well, then that means to update 1,000,000 rows in your database you need to literally load up all 1,000,000 records into memory, update then, and then save them in 1,000,000 individual queries. This is just us handling things that SQLite excels at in Ruby. We think it’s better to let SQLite handle the things that SQLite is good at.
So, this is how Rails approaches callbacks, which has been very influential for other persistence frameworks in how they design callbacks. Many JavaScript, Python, and PHP frameworks follow the basic pattern from ActiveRecord, as does GRDB in the Swift world.
For GRDB in particular, the library allows one to implement callbacks much like one does in Rails. Let’s copy-and-paste a slimmed down version of our Reminder
type that doesn’t use our @Table
macro and instead uses GRDB’s tools:
import GRDB
struct ReminderGRDB: Codable, FetchableRecord, MutablePersistableRecord {
var id: Int64?
var isCompleted = false
var title: String
var updatedAt: Date = Date()
enum Columns {
static let id = Column(CodingKeys.id)
static let isCompleted = Column(CodingKeys.isCompleted)
static let title = Column(CodingKeys.title)
static let updatedAt = Column(CodingKeys.updatedAt)
}
}
The MutablePersistableRecord
protocol allows us to implement a willSave
method that is invoked just before saving the record. So, we could add willSave
that sets the freshest updatedAt
value on the record:
mutating func willSave(_ db: Database) throws {
updatedAt = Date()
}
Then, we can create a reminder in Swift, and invoke the saved
method to get back a new reminder with the fresh updatedAt
applied:
func operation(_ db: Database) throws {
let reminder = ReminderGRDB(title: "Get milk")
let newReminder = try reminder.saved(db)
}
Or, if you have a mutable reminder
, then you can invoke the save
method and it will internally mutate the updatedAt
state with the freshest value:
func operation(_ db: Database) throws {
var reminder = ReminderGRDB(title: "Get milk")
try mutableReminder.save(db)
_ = reminder.updatedAt
}
This works similarly to ActiveRecord, and it can be useful, but it also unfortunately has the same problem that ActiveRecord has. The updatedAt
timestamp is refreshed only when updating the record directly through its save
method, and not by any other means. If you use one of the bulk update methods, such as updateAll
:
try ReminderGRDB
.updateAll(db, [ReminderGRDB.Columns.isCompleted.set(to: true)])
…then the updatedAt
field will not be set to the current date on any of those fields.
The same is true if you were to execute a raw SQL query too:
try db.execute(
literal: """
UPDATE "reminders"
SET "isCompleted" = 1
WHERE NOT "isCompleted"
"""
)
These kinds of queries are executed outside the purview of the callbacks system. And so that means you have to be very careful when executing certain kinds of queries because you may be responsible for setting the updatedAt
state yourself.
And it’s worth mentioning that these kinds of problems are a few of the reasons why our StructuredQueries library does not embrace the “active record” pattern for database models and queries. We feel that SQL is an amazing technology on its own, and that everyone should understand as an isolated concept. And further, we feel that one should not blur the lines between models and queries.
So, while it may seem handy at first to be able to do things like this:
try reminder.save(db)
try reminder.update(db)
try reminder.delete(db)
…it is also secretly hiding a whole world of SQL from you. It starts to force you into a corner where the Reminder
model encapsulates more and more of the database logic, but as we’ve just seen, it will never be able to encapsulate all of the database logic.
And so we think it is far better to provide first class tools for building SQL queries abstractly, and then leave it up to you to execute them however you want:
try Reminder.upsert { reminder }.execute(db)
try Reminder.find(reminder.id).delete().execute(db)
It’s only a few extra characters, but let’s us use SQL to its full potential and does not give us a false sense of database encapsulation.
OK, we have now seen one approach to listening for changes to the database so that we can perform a little bit of extra logic just before a record is updated. It can work in some cases, but it is not a holistic, complete solution. There are many holes in it that make it possible to update the database without the callbacks executing. And that just means we can’t really trust our code.
And luckily for us, SQL has an amazing tool for handling this kind of thing the right way. The tool is called “triggers”, and it allows you to set up a global, declarative rule for observing changes to your tables so that you can insert little bits of logic right as a row is being updated. And it is truly global. It doesn’t matter how the database is updated, we will always be notified of changes. You can even execute some queries with GRDB’s tools, some queries with our tools, or execute raw SQL string queries, and they will all be seen by the trigger. This can give us peace of mind that our code is working the way we expect.
Let’s see how it works.
Let’s start by deleting the code we know we don’t want to maintain. We don’t want to have to remember to explicitly set the updatedAt
field of a reminder anytime we update it in the database. This includes when we toggle the isCompleted
field:
.update {
$0.isCompleted.toggle()
// $0.updatedAt = Date()
}
As well as when toggling the isFlagged
field:
.update {
$0.isFlagged.toggle()
// $0.updatedAt = Date()
}
And when saving changes to the reminder from the form:
try database.write { db in
// var reminder = reminder
// reminder.updatedAt = Date()
try Reminder.upsert { reminder }.execute(db)
}
We would prefer that any change made to a reminder in the database causes the updatedAt
field to be automatically set. This should happen no matter what method we decide to use. Whether we are updating just a single reminder, or bulk updating many reminders.
And luckily for us SQLite has the perfect tool for this, and it’s called triggers. We are allowed to install global listeners on the database so that we can react to any change made to a table. And just as we have done many times in the past, we will hop over to the wonderful SQLite documentation to see how one constructs triggers:
We are immediately met with the following flow chart.
It seems a bit intimidating, but this is describing a very powerful concept. One starts first by specifying whether or not we are creating a temporary trigger. We will get more into that in a moment. As well if we should skip creating this trigger if it already exists. Then we give the trigger a name so that it can be referenced later, such as if we ever need to remove the trigger.
And then we get into the customization of the trigger. We can decide when we want the trigger to execute. It can be either before, after or “instead of” some other kind of statement, but if you read the docs in depth you will find that SQLite does not recommend using “before” because it can lead to undefined behavior, and “instead of” only applies to queries on virtual tables, which we will not be exploring yet.
Once you specify the when you then are left to choose what kind of action you want to monitor in the database. You can listen for deletions, insertions or updates on a particular table. And then once such an action is invoked on the table specified, you further get to execute your own action, whether it be an update, insert, delete, or select, for each row that was affected by the original action.
This flowchart right here gives us infinite flexibility in monitoring how the data changes in our tables and reacting to those changes. It is more powerful, more consistent, and more flexible than callbacks implemented directly in Swift, and this is the tool we will use to implement the updatedAt
behavior of our application.
First, where should we create this trigger? One option would be to install the trigger globally in the entire database, which is the option where you do not specify the TEMPORARY
option, and that would also mean we need to do this in a migration so that the trigger is created one single time rather than every time the app launches. But we think there is a better way.
There really is no need to install the trigger globally. Any updates to the database go through the one database pool connection we make when the app first starts up. And so we feel that temporary triggers are perfect for this situation. A temporary trigger is one that only exists for the duration of the connection we have to the database. So, when the app is killed and restarted we will form a whole new connection that does not have the trigger installed, and so we can just create it again from scratch.
This means we don’t have to use the migration system, and instead we can install the trigger as soon as the app starts up, every time. And we can do so right after we run migrations in a write
transaction on the database:
try database.write { db in
}
We need a write
transaction because we want our triggers to be able to perform writes to the database when they are invoked.
In here we want to execute some SQL to create our trigger. Just as with create our tables we can do so with our #sql
macro:
try #sql(
"""
"""
)
.execute(db)
And now we can follow the flow chart to create our trigger. We will start with by creating a temporary trigger, and giving it a unique name:
try #sql(
"""
CREATE TEMPORARY TRIGGER "reminders_updatedAt"
"""
)
.execute(db)
Next we need to specify AFTER
or BEFORE
, but as we mentioned a moment ago, the SQLite docs highly recommend always using AFTER
to avoid undefined behavior:
try #sql(
"""
CREATE TEMPORARY TRIGGER "reminders_updatedAt"
AFTER
"""
)
.execute(db)
Next we need to decide what actions in the database we want to listen for. In our situation we want to listen for updates, because that is precisely when the updatedAt
field should be updated:
try #sql(
"""
CREATE TEMPORARY TRIGGER "reminders_updatedAt"
AFTER UPDATE
"""
)
.execute(db)
And further we only want to listen for updates on the “reminders” table:
try #sql(
"""
CREATE TEMPORARY TRIGGER "reminders_updatedAt"
AFTER UPDATE ON "reminders"
"""
)
.execute(db)
In this case we don’t want to listen for updates on particular columns of the table. We want to listen to updates on all columns, and so there’s nothing more we need to specify in that branch of the flow chart.
Next we open up a scope into the logic we want to execute when the “reminders” table is updated by starting a FOR EACH ROW BEGIN
:
try #sql(
"""
CREATE TEMPORARY TRIGGER "reminders_updatedAt"
AFTER UPDATE ON "reminders"
FOR EACH ROW BEGIN
"""
)
.execute(db)
And inside here we can execute our update statement:
try #sql(
"""
CREATE TEMPORARY TRIGGER "reminders_updatedAt"
AFTER UPDATE ON "reminders"
FOR EACH ROW BEGIN
UPDATE "reminders"
"""
)
.execute(db)
Next we can specify what kind of update we want to perform, and that is to set the updatedAt
column to the current time:
try #sql(
"""
CREATE TEMPORARY TRIGGER "reminders_updatedAt"
AFTER UPDATE ON "reminders"
FOR EACH ROW BEGIN
UPDATE "reminders"
SET "updatedAt" = datetime('subsec')
"""
)
.execute(db)
And finally we need to end our statement with a semicolon, and then close the whole trigger with an explicit END
:
try #sql(
"""
CREATE TEMPORARY TRIGGER "reminders_updatedAt"
AFTER UPDATE ON "reminders"
FOR EACH ROW BEGIN
UPDATE "reminders"
SET "updatedAt" = datetime('subsec');
END
"""
)
.execute(db)
However, this is not quite right. This says to update all reminders when the action is executed. We only want to update the one single reminder that was updated, and we can do that by using special new
and old
symbols.
If we go back to the documentation we will find a passage that lets us know that inside the BEGIN
/END
scope we are given access to a few special keywords:
Note Both the WHEN clause and the trigger actions may access elements of the row being inserted, deleted or updated using references of the form
NEW.column-name
andOLD.column-name
, wherecolumn-name
is the name of a column from the table that the trigger is associated with.OLD
andNEW
references may only be used in triggers on events for which they are relevant, as follows:
INSERT
:NEW
references are valid
UPDATE
:NEW
andOLD
references are valid
DELETE
:OLD
references are valid
So we want to further scope our UPDATE
to only be on the row that was actually edited:
try #sql(
"""
CREATE TEMPORARY TRIGGER "reminders_updatedAt"
AFTER UPDATE ON "reminders"
FOR EACH ROW BEGIN
UPDATE "reminders"
SET "updatedAt" = datetime('subsec')
WHERE "reminders"."id" = "new"."id";
END
"""
)
.execute(db)
And it sounds too good to be true, but this is all it takes. Any SQL statement executed with our database connection that causes a reminder to be updated will automatically invoke this trigger and refresh its updatedAt
timestamp.
We can go into any reminder, change some of its properties, tap “Save”, go back into the reminder and we will see the updatedAt
field was updated. The same happens if we flag a reminder by swiping on it. Or if we complete it. Mutations to the rows in the “reminders” table are globally observed no matter how the mutation is made. We don’t have to remember to explicitly update the updatedAt
state when making changes to the database, nor do we have to change our data types to accommodate for callback logic. It all lives in the database.
And because it all lives in the database, it truly does not matter how the rows of our tables are mutated. Mutations can be made by looking up one single row and making changes. Or mutations can happen by performing a batch update across many rows.
For example, let’s add a button to the menu of the RemindersDetailView
that allows us to instantly flag all reminders showing on the screen right now:
Button("Flag all reminders") {
model.flagAllRemindersButtonTapped()
}
We are invoking a method on the model because that is where all of the logic of our feature lives, so let’s implement that method:
func flagAllRemindersButtonTapped() {
}
It’s quite straightforward to write a query that sets the isFlagged
state to true
for all reminders being displayed. We can first construct the part of the query that selects just the reminders being displayed:
Reminder
.where { $0.id.in(rows.map(\.reminder.id)) }
Then we can perform an UPDATE
statement on these reminders to set the isFlagged
state to true:
Reminder
.where { $0.id.in(rows.map(\.reminder.id)) }
.update { $0.isFlagged = true }
And then we can execute this query. But to do that we need access to the database, which we get from our dependency system:
@ObservationIgnored @Dependency(\.defaultDatabase) var database
And now we can execute the query:
func flagAllReminders() {
withErrorReporting {
try database.write { db in
try Reminder
.where { $0.id.in(rows.map(\.reminder.id)) }
.update { $0.isFlagged = true }
.execute(db)
}
}
}
This is a batch update that is capable of making changes to many reminders all at once. And magically this will update the updatedAt
field of each reminder changed. Let’s go to the personal list and flag all reminders. Now when we open any of these reminders we will see that their updatedAt
timestamp does indeed show the current time. This would not have been possible if we had implemented callbacks in our Swift code instead of the database.
I think this is pretty incredible, and I think it shows that SQLite has a ton of untapped power. When implementing far reaching behavior across our databases we should always first ask whether or not SQLite already provides a tool for this. If it does, then it is far better to delegate that responsibility to SQLite than for us to try to recreate it in our Swift application. After all, SQLite is the true arbiter of the data in our application.
This is looking really great, but we can make some improvements. Right now we are creating our trigger as a SQL string. On the one hand, this isn’t so bad. We have never said that all queries in your app should be written with our query builder. We think it’s incredibly important to be familiar with SQL syntax, and we never want to hide it from you.
But, the primary situation we advocate for writing SQL strings is in migrations, like what we have done when creating and altering our tables. The reason we advocate for SQL strings here is because migrations are little SQL fragments that are frozen in time. Once a migration has been shipped to users it should never be edited. Ever.
And so it’s not appropriate to use static symbols in creating those SQL statements because that would leave us open to accidentally changing those statements later on by doing something seemingly innocuous such as renaming a field in our data types.
However, our trigger is not being created in a migration. In fact, we are installing the trigger after all migrations have run, and it’s only temporary. The trigger will be removed when the app is killed and will be reinstalled when the app starts up again. This means it is perfectly OK to use static symbols in order to construct temporary triggers because they always have access to the most recent form of the schema. They never have to deal with older versions of the schema that are frozen in time.
And so we can actually add a bit of type-safety and schema-safety to the creation of our triggers by using the static symbols created for us by the @Table
macro. Let’s take a look…next time!