Converting Trigger NEW/OLD Rows to JSON in SQLite Without Explicit Column References
Issue Overview: Trigger-Based Row Serialization to JSON Without Column Enumeration
The core challenge involves capturing changes to rows in an SQLite table via triggers and serializing the affected row data (NEW or OLD) into JSON format for storage in an audit/logging table. The primary obstacle is SQLite’s lack of native syntax to convert entire row objects into JSON without explicitly referencing each column. A typical trigger definition might attempt to use json(NEW)
or similar shorthand, but this fails because SQLite’s JSON functions require explicit column references. This limitation becomes particularly acute when dealing with tables containing many columns or schemas that evolve over time, as manual column enumeration in triggers becomes error-prone and difficult to maintain.
The problem intersects three SQLite features: triggers, JSON functions, and dynamic SQL generation. Triggers execute procedural logic when data changes occur, but their scope is limited to accessing column values via NEW.column_name
or OLD.column_name
. The JSON1 extension provides functions like json_object()
and json_array()
, which require explicit key-value pairs. Dynamic SQL generation via EXECUTE
or application-layer code offers a workaround but introduces complexity in trigger definitions and execution context management.
Possible Causes: Syntax Limitations and Trigger Context Constraints
Invalid JSON Function Usage: Attempting to pass a row object (NEW/OLD) directly to
json()
orjson_object()
without specifying individual columns violates SQLite’s syntax rules. The JSON functions expect discrete arguments, not row references.Lack of Row-to-JSON Casting: SQLite does not support implicit or explicit casting of row objects to JSON strings. Unlike some ORMs or application-layer frameworks, there is no built-in mechanism to serialize entire rows.
Static Trigger Definitions: Triggers are parsed and optimized at creation time, preventing dynamic column iteration. A trigger’s SQL statements must explicitly reference columns by name, making it impossible to loop through columns programmatically within the trigger body.
Pragma Limitations: While the
table_info
pragma can retrieve column metadata, using it within a trigger to dynamically construct JSON requires intermediate steps (e.g., temporary tables or application-layer code) that complicate the process.Custom Function Integration: The user’s reference to SQLiteNIO (a Swift library) suggests a desire to call external functions from triggers. However, SQLite’s trigger execution environment cannot directly invoke application-layer code unless user-defined functions (UDFs) are registered beforehand, which requires careful session management.
Troubleshooting Steps, Solutions & Fixes
Solution 1: Explicit Column Enumeration with json_object()
While tedious, explicitly listing columns in json_object()
is the most straightforward approach for small or static schemas. For the Person
table:
CREATE TRIGGER person_trigger_manual_json
AFTER UPDATE ON Person
BEGIN
INSERT INTO Event (message)
VALUES (json_object(
'ID', NEW.ID,
'name', NEW.name,
'age', NEW.age
));
END;
Pros:
- Direct and unambiguous.
- Leverages SQLite’s native JSON1 functions without external dependencies.
Cons:
- Requires manual updates when the schema changes.
- Error-prone with many columns.
Automation via Code Generation:
Use SQLite’s PRAGMA table_info('Person')
to retrieve column names and generate the trigger SQL programmatically. For example, in Swift using SQLiteNIO:
let columns = try await db.query("PRAGMA table_info('Person')").map { row -> String in
let name = row.column("name").stringValue
return "'\(name)', NEW.\(name)"
}.joined(separator: ", ")
let triggerSQL = """
CREATE TRIGGER person_trigger_auto_json
AFTER UPDATE ON Person
BEGIN
INSERT INTO Event (message)
VALUES (json_object(\(columns)));
END;
"""
try await db.exec(raw: triggerSQL)
This approach dynamically constructs the trigger SQL whenever the application starts or the schema changes.
Solution 2: Dynamic SQL Generation with EXECUTE
and Temporary Tables
SQLite’s EXECUTE
command allows for runtime SQL construction but requires careful handling within triggers. This method uses a temporary table to store column metadata and iterates over it to build the JSON string:
-- Temporary table to hold column names
CREATE TEMP TABLE IF NOT EXISTS person_columns (name TEXT);
-- Populate once during initialization
INSERT INTO person_columns
SELECT name FROM PRAGMA_table_info('Person');
CREATE TRIGGER person_trigger_dynamic_json
AFTER UPDATE ON Person
BEGIN
-- Build JSON string dynamically
WITH RECURSIVE json_builder(key, value, idx) AS (
SELECT '', '', 0
UNION ALL
SELECT
CASE WHEN key = '' THEN '' ELSE key || ',' END ||
json_quote((SELECT name FROM person_columns WHERE rowid = idx + 1)),
CASE WHEN value = '' THEN '' ELSE value || ',' END ||
json_quote(NEW.(SELECT name FROM person_columns WHERE rowid = idx + 1)),
idx + 1
FROM json_builder
WHERE idx < (SELECT COUNT(*) FROM person_columns)
)
INSERT INTO Event (message)
SELECT json_object(key, value) FROM json_builder
WHERE idx = (SELECT COUNT(*) FROM person_columns);
END;
Breakdown:
- Temporary Table: Stores column names for the target table.
- Recursive CTE: Iterates over columns, accumulating key-value pairs.
- Dynamic Column Access: Uses
NEW.(SELECT ...)
syntax to reference columns by name retrieved from metadata.
Caveats:
- Requires initializing the temporary table.
- Recursive CTEs can be slow for large tables.
- SQLite’s
NEW.(subquery)
syntax is undocumented and may not work in all contexts.
Solution 3: Custom JSON Serialization Function via SQLiteNIO
Register a Swift function as a SQLite UDF to handle JSON serialization. This moves complexity out of SQL and into the application layer:
import SQLiteNIO
// Register UDF
db.addFunction("swift_json_serialize", argumentCount: .any) { args in
let row = args[0].rowValue! // Assumes NEW is passed as a row
var dict = [String: SQLiteData]()
for (column, value) in row {
dict[column] = value
}
let jsonData = try JSONSerialization.data(withJSONObject: dict)
return .text(String(data: jsonData, encoding: .utf8)!)
}
// Create trigger using UDF
try await db.exec(raw: """
CREATE TRIGGER person_trigger_udf
AFTER UPDATE ON Person
BEGIN
INSERT INTO Event (message)
VALUES (swift_json_serialize(NEW));
END;
""")
Implementation Notes:
- Row Type Handling: SQLiteNIO represents rows as
SQLiteRow
objects, which can be iterated to extract column-value pairs. - Performance: UDFs execute in-process, minimizing latency, but require careful memory management.
- Schema Awareness: The UDF must be re-registered whenever the connection is reestablished.
Limitations:
- UDFs are connection-specific. If the application uses connection pooling, the function must be registered on each new connection.
- Passing
NEW
as a row to a UDF is not natively supported in SQLite; this requires a custom extension to SQLiteNIO to bind row objects as parameters.
Solution 4: Hybrid Approach – Stored Column Metadata and JSON Construction
For environments where UDFs are impractical, precompute the column list and store it in a utility table:
-- Metadata table
CREATE TABLE IF NOT EXISTS json_columns (
table_name TEXT PRIMARY KEY,
column_list TEXT
);
-- Initialize for 'Person'
INSERT INTO json_columns (table_name, column_list)
VALUES (
'Person',
(SELECT json_group_array(name) FROM PRAGMA_table_info('Person'))
);
-- Trigger using stored column list
CREATE TRIGGER person_trigger_hybrid
AFTER UPDATE ON Person
BEGIN
INSERT INTO Event (message)
WITH split_columns AS (
SELECT value AS name
FROM json_each((SELECT column_list FROM json_columns WHERE table_name = 'Person'))
)
SELECT json_group_object(name, NEW.(name))
FROM split_columns;
END;
Mechanics:
- Column List Storage:
json_columns
holds a JSON array of column names for each table. - Dynamic Access:
NEW.(name)
uses the column name from the JSON array to reference values.
Drawbacks:
- Relies on SQLite’s
NEW.(column_name)
syntax, which may not be portable. - Requires manual synchronization between the
json_columns
table and the actual schema.
Solution 5: Shadow Tables and Row Snapshots
Create a shadow table that mirrors the structure of the audited table, then use triggers to capture before/after snapshots:
-- Shadow table for Person
CREATE TABLE Person_shadow (
ID INTEGER,
name TEXT,
age INTEGER,
updated_at TIMESTAMP
);
-- Trigger to capture NEW as JSON
CREATE TRIGGER person_trigger_shadow
AFTER UPDATE ON Person
BEGIN
INSERT INTO Person_shadow
SELECT NEW.*, CURRENT_TIMESTAMP;
INSERT INTO Event (message)
SELECT json_object(
'ID', ID,
'name', name,
'age', age
) FROM Person_shadow
WHERE rowid = last_insert_rowid();
END;
Advantages:
- Decouples audit logging from JSON serialization.
- Allows for batch processing of shadow data into JSON.
Disadvantages:
- Doubles storage requirements.
- Adds latency due to secondary insertions.
General Considerations and Best Practices
- Schema Volatility: Prefer dynamic methods (Solutions 2, 4) when tables frequently change. For static schemas, explicit enumeration (Solution 1) suffices.
- Performance: UDF-based serialization (Solution 3) is optimal for high-throughput systems but ties the solution to a specific programming environment.
- Portability: Solutions relying on SQLiteNIO or recursive CTEs may not work in environments with restricted SQL extensions.
- Testing: Validate trigger behavior after schema changes using
PRAGMA integrity_check
andEXPLAIN
to profile query plans.
By systematically evaluating these approaches against the application’s requirements for maintainability, performance, and portability, developers can implement robust row-to-JSON serialization in SQLite triggers.