Files
scadaproj/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Registration/TriggerSqlGenerator.cs
T

125 lines
6.0 KiB
C#

using System.Text;
namespace ZB.MOM.WW.LocalDb.Registration;
/// <summary>
/// Emits the three AFTER triggers (insert/update/delete) that capture every row change on a
/// registered table into <c>__localdb_oplog</c> + <c>__localdb_row_version</c> within the
/// consumer's own write transaction. The <c>WHEN COALESCE(applying, 0) = 0</c> guard skips
/// replicated applies (COALESCE so a missing guard row captures loudly rather than silently
/// disabling CDC); the <c>last_insert_rowid()</c> back-reference binds each row-version row to
/// the SAME <c>zb_hlc_next()</c> stamp as its oplog entry (one stamp per row change). A
/// PK-changing UPDATE additionally tombstones the OLD key first — delete+insert semantics.
/// </summary>
internal static class TriggerSqlGenerator
{
private const string ApplyingGuard =
"COALESCE((SELECT applying FROM __localdb_applying WHERE id = 1), 0) = 0";
private const string TombstoneTail = "1, strftime('%Y-%m-%dT%H:%M:%fZ','now')";
// Explicit upsert, NOT `INSERT OR REPLACE`: SQLite replaces a trigger-body statement's
// OR-conflict algorithm with the OUTER statement's conflict handling, so under a consumer
// `INSERT ... ON CONFLICT DO UPDATE` an OR REPLACE here degraded to a plain INSERT and hit
// SQLITE_CONSTRAINT_PRIMARYKEY on the existing row_version row. An explicit ON CONFLICT
// clause is not overridden. (The SELECT's WHERE clause also disambiguates the upsert parse.)
private const string RowVersionConflictClause =
"ON CONFLICT(table_name, pk_json) DO UPDATE SET hlc = excluded.hlc, node_id = excluded.node_id, " +
"is_tombstone = excluded.is_tombstone, tombstone_utc = excluded.tombstone_utc";
public static string TriggerName(string table, string suffix) => $"__localdb_{table}_{suffix}";
/// <summary>DROP IF EXISTS + CREATE for all three triggers, ready to run in one transaction.</summary>
public static string BuildInstallScript(ReplicatedTable table)
{
var sb = new StringBuilder();
foreach (var suffix in new[] { "ai", "au", "ad" })
sb.Append("DROP TRIGGER IF EXISTS ").Append(Ident(TriggerName(table.Name, suffix))).Append(";\n");
sb.Append(BuildInsert(table)).Append('\n');
sb.Append(BuildUpdate(table)).Append('\n');
sb.Append(BuildDelete(table)).Append('\n');
return sb.ToString();
}
private static string BuildInsert(ReplicatedTable t) =>
Wrap(t, "ai", "INSERT", NewRowCapture(t));
private static string BuildUpdate(ReplicatedTable t) =>
Wrap(t, "au", "UPDATE", OldPkTombstoneIfChanged(t) + NewRowCapture(t));
private static string BuildDelete(ReplicatedTable t) =>
Wrap(t, "ad", "DELETE",
$"""
INSERT INTO __localdb_oplog (table_name, pk_json, row_json, hlc, node_id, is_tombstone)
VALUES ({Literal(t.Name)},
{JsonObject("OLD", t.PkColumns)},
NULL,
zb_hlc_next(),
(SELECT node_id FROM __localdb_meta WHERE id = 1),
1);
INSERT INTO __localdb_row_version (table_name, pk_json, hlc, node_id, is_tombstone, tombstone_utc)
SELECT table_name, pk_json, hlc, node_id, {TombstoneTail} FROM __localdb_oplog WHERE seq = last_insert_rowid()
{RowVersionConflictClause};
""");
private static string Wrap(ReplicatedTable t, string suffix, string verb, string body) =>
$"""
CREATE TRIGGER {Ident(TriggerName(t.Name, suffix))} AFTER {verb} ON {Ident(t.Name)}
WHEN {ApplyingGuard}
BEGIN
{body.TrimEnd('\n')}
END;
""";
private static string NewRowCapture(ReplicatedTable t) =>
$"""
INSERT INTO __localdb_oplog (table_name, pk_json, row_json, hlc, node_id, is_tombstone)
VALUES ({Literal(t.Name)},
{JsonObject("NEW", t.PkColumns)},
{JsonObject("NEW", t.Columns.Select(c => c.Name))},
zb_hlc_next(),
(SELECT node_id FROM __localdb_meta WHERE id = 1),
0);
INSERT INTO __localdb_row_version (table_name, pk_json, hlc, node_id, is_tombstone, tombstone_utc)
SELECT table_name, pk_json, hlc, node_id, 0, NULL FROM __localdb_oplog WHERE seq = last_insert_rowid()
{RowVersionConflictClause};
""";
// Both statements carry the SAME pk-changed condition: when the tombstone insert did not fire,
// the row-version statement must also be a no-op, or a stale last_insert_rowid would mis-correlate.
private static string OldPkTombstoneIfChanged(ReplicatedTable t)
{
var changed = string.Join(" OR ", t.PkColumns.Select(p => $"OLD.{Ident(p)} IS NOT NEW.{Ident(p)}"));
return
$"""
INSERT INTO __localdb_oplog (table_name, pk_json, row_json, hlc, node_id, is_tombstone)
SELECT {Literal(t.Name)},
{JsonObject("OLD", t.PkColumns)},
NULL,
zb_hlc_next(),
(SELECT node_id FROM __localdb_meta WHERE id = 1),
1
WHERE {changed};
INSERT INTO __localdb_row_version (table_name, pk_json, hlc, node_id, is_tombstone, tombstone_utc)
SELECT table_name, pk_json, hlc, node_id, {TombstoneTail} FROM __localdb_oplog
WHERE seq = last_insert_rowid() AND ({changed})
{RowVersionConflictClause};
""";
}
// json_object('col', ALIAS."col", ...) — natively handles NULLs and SQLite scalar types.
private static string JsonObject(string alias, IEnumerable<string> columns)
{
var args = columns.Select(c => $"{Literal(c)}, {alias}.{Ident(c)}");
return $"json_object({string.Join(", ", args)})";
}
private static string Ident(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";
private static string Literal(string value) => "'" + value.Replace("'", "''") + "'";
}