@@ -3,11 +3,61 @@ package dev.slne.surf.database.table
33import dev.slne.surf.database.columns.time.CurrentOffsetDateTime
44import dev.slne.surf.database.columns.time.offsetDateTime
55import 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
710open 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