Skip to content

Commit cd5c348

Browse files
committed
Add updated at support for postgres datetime column
1 parent 1007b7e commit cd5c348

1 file changed

Lines changed: 51 additions & 1 deletion

File tree

src/main/kotlin/dev/slne/surf/database/table/AuditableLongIdTable.kt

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,61 @@ package dev.slne.surf.database.table
33
import dev.slne.surf.database.columns.time.CurrentOffsetDateTime
44
import dev.slne.surf.database.columns.time.offsetDateTime
55
import org.jetbrains.exposed.v1.core.dao.id.ULongIdTable
6+
import org.jetbrains.exposed.v1.core.vendors.PostgreSQLDialect
7+
import org.jetbrains.exposed.v1.core.vendors.currentDialect
8+
import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager
69

710
open class AuditableLongIdTable(name: String = "") : ULongIdTable(name) {
811
val createdAt = offsetDateTime("created_at")
912
.defaultExpression(CurrentOffsetDateTime())
1013
val updatedAt = offsetDateTime("updated_at")
1114
.defaultExpression(CurrentOffsetDateTime(true))
12-
}
1315

16+
/**
17+
* Appends the PostgreSQL trigger that keeps [updatedAt] in sync on every `UPDATE`.
18+
*
19+
* MariaDB/MySQL express this inline via `ON UPDATE CURRENT_TIMESTAMP` (see
20+
* [dev.slne.surf.database.columns.time.CurrentTimestampBase]), but PostgreSQL has no such
21+
* column clause, so a `BEFORE UPDATE` trigger is required instead. Returning the extra DDL from
22+
* here means Exposed's `SchemaUtils.create`/`createMissingTablesAndColumns` create the trigger
23+
* automatically together with the table for every subclass. On any other dialect only the base
24+
* statements are returned.
25+
*/
26+
override fun createStatement(): List<String> {
27+
val statements = super.createStatement()
28+
if (currentDialect !is PostgreSQLDialect) return statements
29+
30+
val tr = TransactionManager.current()
31+
val tableIdentity = tr.identity(this)
32+
val updatedAtColumn = tr.identity(updatedAt)
33+
val triggerName = tr.db.identifierManager.cutIfNecessaryAndQuote(
34+
"${tableName.substringAfterLast('.')}_set_updated_at"
35+
)
36+
37+
// Shared, idempotent trigger function. Mirrors CurrentTimestampBase by storing the UTC
38+
// wall-clock so the value stays consistent with the UTC instants written by the app.
39+
// Every AuditableLongIdTable names the column `updated_at`, so the single shared function
40+
// is consistent across tables.
41+
val createFunction = """
42+
CREATE OR REPLACE FUNCTION $UPDATED_AT_FUNCTION() RETURNS trigger AS $$
43+
BEGIN
44+
NEW.$updatedAtColumn := (CURRENT_TIMESTAMP AT TIME ZONE 'UTC');
45+
RETURN NEW;
46+
END;
47+
$$ LANGUAGE plpgsql
48+
""".trimIndent()
49+
50+
// DROP + CREATE keeps it idempotent across re-runs without requiring PostgreSQL 14's
51+
// `CREATE OR REPLACE TRIGGER`.
52+
val dropTrigger = "DROP TRIGGER IF EXISTS $triggerName ON $tableIdentity"
53+
val createTrigger = "CREATE TRIGGER $triggerName BEFORE UPDATE ON $tableIdentity " +
54+
"FOR EACH ROW EXECUTE FUNCTION $UPDATED_AT_FUNCTION()"
55+
56+
return statements + createFunction + dropTrigger + createTrigger
57+
}
58+
59+
companion object {
60+
/** Name of the shared `plpgsql` function backing the `updated_at` trigger on PostgreSQL. */
61+
private const val UPDATED_AT_FUNCTION = "surf_set_updated_at"
62+
}
63+
}

0 commit comments

Comments
 (0)