@cap-js/change-tracking 1.1.4 → 2.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +45 -71
  3. package/_i18n/i18n.properties +12 -20
  4. package/_i18n/i18n_ar.properties +3 -3
  5. package/_i18n/i18n_bg.properties +3 -3
  6. package/_i18n/i18n_cs.properties +3 -3
  7. package/_i18n/i18n_da.properties +3 -3
  8. package/_i18n/i18n_de.properties +3 -3
  9. package/_i18n/i18n_el.properties +3 -3
  10. package/_i18n/i18n_en.properties +3 -3
  11. package/_i18n/i18n_en_US_saptrc.properties +3 -32
  12. package/_i18n/i18n_es.properties +3 -3
  13. package/_i18n/i18n_es_MX.properties +3 -3
  14. package/_i18n/i18n_fi.properties +3 -3
  15. package/_i18n/i18n_fr.properties +3 -3
  16. package/_i18n/i18n_he.properties +3 -3
  17. package/_i18n/i18n_hr.properties +3 -3
  18. package/_i18n/i18n_hu.properties +3 -3
  19. package/_i18n/i18n_it.properties +3 -3
  20. package/_i18n/i18n_ja.properties +3 -3
  21. package/_i18n/i18n_kk.properties +3 -3
  22. package/_i18n/i18n_ko.properties +3 -3
  23. package/_i18n/i18n_ms.properties +3 -3
  24. package/_i18n/i18n_nl.properties +3 -3
  25. package/_i18n/i18n_no.properties +3 -3
  26. package/_i18n/i18n_pl.properties +3 -3
  27. package/_i18n/i18n_pt.properties +3 -3
  28. package/_i18n/i18n_ro.properties +3 -3
  29. package/_i18n/i18n_ru.properties +3 -3
  30. package/_i18n/i18n_sh.properties +3 -3
  31. package/_i18n/i18n_sk.properties +3 -3
  32. package/_i18n/i18n_sl.properties +3 -3
  33. package/_i18n/i18n_sv.properties +3 -3
  34. package/_i18n/i18n_th.properties +3 -3
  35. package/_i18n/i18n_tr.properties +3 -3
  36. package/_i18n/i18n_uk.properties +3 -3
  37. package/_i18n/i18n_vi.properties +3 -3
  38. package/_i18n/i18n_zh_CN.properties +3 -3
  39. package/_i18n/i18n_zh_TW.properties +3 -3
  40. package/cds-plugin.js +15 -264
  41. package/index.cds +199 -75
  42. package/lib/TriggerCQN2SQL.js +42 -0
  43. package/lib/h2/java-codegen.js +843 -0
  44. package/lib/h2/register.js +27 -0
  45. package/lib/h2/triggers.js +42 -0
  46. package/lib/hana/composition.js +250 -0
  47. package/lib/hana/register.js +28 -0
  48. package/lib/hana/sql-expressions.js +224 -0
  49. package/lib/hana/triggers.js +254 -0
  50. package/lib/localization.js +53 -117
  51. package/lib/model-enhancer.js +266 -0
  52. package/lib/postgres/composition.js +191 -0
  53. package/lib/postgres/register.js +44 -0
  54. package/lib/postgres/sql-expressions.js +271 -0
  55. package/lib/postgres/triggers.js +115 -0
  56. package/lib/skipHandlers.js +34 -0
  57. package/lib/sqlite/composition.js +235 -0
  58. package/lib/sqlite/register.js +28 -0
  59. package/lib/sqlite/sql-expressions.js +238 -0
  60. package/lib/sqlite/triggers.js +164 -0
  61. package/lib/utils/change-tracking.js +394 -0
  62. package/lib/utils/composition-helpers.js +67 -0
  63. package/lib/utils/entity-collector.js +297 -0
  64. package/lib/utils/session-variables.js +278 -0
  65. package/lib/utils/trigger-utils.js +94 -0
  66. package/package.json +17 -7
  67. package/lib/change-log.js +0 -538
  68. package/lib/entity-helper.js +0 -217
  69. package/lib/format-options.js +0 -66
  70. package/lib/template-processor.js +0 -115
@@ -0,0 +1,238 @@
1
+ const utils = require('../utils/change-tracking.js');
2
+ const { CT_SKIP_VAR, getEntitySkipVarName, getElementSkipVarName } = require('../utils/session-variables.js');
3
+ const { createTriggerCQN2SQL } = require('../TriggerCQN2SQL.js');
4
+
5
+ const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
6
+ let SQLiteCQN2SQL;
7
+ let model;
8
+
9
+ function setModel(m) {
10
+ model = m;
11
+ SQLiteCQN2SQL = null;
12
+ }
13
+
14
+ function getModel() {
15
+ return model;
16
+ }
17
+
18
+ function toSQL(query) {
19
+ if (!SQLiteCQN2SQL) {
20
+ const SQLiteService = require('@cap-js/sqlite');
21
+ const TriggerCQN2SQL = createTriggerCQN2SQL(SQLiteService.CQN2SQL);
22
+ SQLiteCQN2SQL = new TriggerCQN2SQL({ model: model });
23
+ }
24
+ const sqlCQN = cqn4sql(query, model);
25
+ return SQLiteCQN2SQL.SELECT(sqlCQN);
26
+ }
27
+
28
+ /**
29
+ * Builds WHERE clause for CQN query from entity keys
30
+ * Maps each key to a trigger row reference (e.g., new.ID, old.name)
31
+ */
32
+ function buildKeyWhere(keys, refRow) {
33
+ return keys.reduce((acc, k) => {
34
+ acc[k] = { val: `${refRow}.${k}` };
35
+ return acc;
36
+ }, {});
37
+ }
38
+
39
+ function getSkipCheckCondition(entityName) {
40
+ const entitySkipVar = getEntitySkipVarName(entityName);
41
+ return `(COALESCE(session_context('${CT_SKIP_VAR}'), 'false') != 'true' AND COALESCE(session_context('${entitySkipVar}'), 'false') != 'true')`;
42
+ }
43
+
44
+ function getElementSkipCondition(entityName, elementName) {
45
+ const varName = getElementSkipVarName(entityName, elementName);
46
+ return `COALESCE(session_context('${varName}'), 'false') != 'true'`;
47
+ }
48
+
49
+ function compositeKeyExpr(parts) {
50
+ if (parts.length <= 1) return parts[0];
51
+ return parts.map((p) => `LENGTH(CAST(${p} AS TEXT)) || ',' || CAST(${p} AS TEXT)`).join(" || ';' || ");
52
+ }
53
+
54
+ /**
55
+ * Truncates large strings in SQL: CASE WHEN LENGTH(val) > 5000 THEN SUBSTR(val, 1, 4997) || '...' ELSE val END
56
+ */
57
+ function wrapLargeString(val) {
58
+ return val === 'NULL' ? 'NULL' : `CASE WHEN LENGTH(${val}) > 5000 THEN SUBSTR(${val}, 1, 4997) || '...' ELSE ${val} END`;
59
+ }
60
+
61
+ /**
62
+ * Returns SQL expression for a column's raw value
63
+ */
64
+ function getValueExpr(col, refRow) {
65
+ if (col.type === 'cds.Boolean') {
66
+ return `CASE ${refRow}.${col.name} WHEN 0 THEN 'false' WHEN 1 THEN 'true' ELSE NULL END`;
67
+ }
68
+ if (col.target && col.foreignKeys?.length) {
69
+ return col.foreignKeys.map((fk) => `${refRow}.${col.name}_${fk}`).join(" || '||' || ");
70
+ }
71
+ if (col.target && col.on?.length) {
72
+ return col.on.map((m) => `${refRow}.${m.foreignKeyField}`).join(" || '||' || ");
73
+ }
74
+ let raw = `${refRow}.${col.name}`;
75
+ if (col.type === 'cds.String' || col.type === 'cds.LargeString') raw = wrapLargeString(raw);
76
+ return raw;
77
+ }
78
+
79
+ /**
80
+ * Returns SQL WHERE condition for detecting column changes
81
+ */
82
+ function getWhereCondition(col, modification) {
83
+ if (modification === 'update') {
84
+ if (col.target && col.foreignKeys?.length) {
85
+ return col.foreignKeys.map((fk) => `old.${col.name}_${fk} IS NOT new.${col.name}_${fk}`).join(' OR ');
86
+ }
87
+ if (col.target && col.on?.length) {
88
+ return col.on.map((m) => `old.${m.foreignKeyField} IS NOT new.${m.foreignKeyField}`).join(' OR ');
89
+ }
90
+ return `old.${col.name} IS NOT new.${col.name}`;
91
+ }
92
+ // CREATE or DELETE: check value is not null
93
+ const rowRef = modification === 'create' ? 'new' : 'old';
94
+ if (col.target && col.foreignKeys?.length) {
95
+ return col.foreignKeys.map((fk) => `${rowRef}.${col.name}_${fk} IS NOT NULL`).join(' OR ');
96
+ }
97
+ if (col.target && col.on?.length) {
98
+ return col.on.map((m) => `${rowRef}.${m.foreignKeyField} IS NOT NULL`).join(' OR ');
99
+ }
100
+ return `${rowRef}.${col.name} IS NOT NULL`;
101
+ }
102
+
103
+ /**
104
+ * Builds scalar subselect for association label lookup with locale awareness
105
+ */
106
+ function buildAssocLookup(column, refRow, entityKey) {
107
+ const where = column.foreignKeys
108
+ ? column.foreignKeys.reduce((acc, k) => {
109
+ acc[k] = { val: `${refRow}.${column.name}_${k}` };
110
+ return acc;
111
+ }, {})
112
+ : column.on?.reduce((acc, k) => {
113
+ // Composition of aspect has a targetKey object
114
+ acc[k.targetKey ?? k] = { val: entityKey };
115
+ return acc;
116
+ }, {});
117
+
118
+ // Drop the first part of column.alt (association name)
119
+ const alt = column.alt.map((s) => s.split('.').slice(1).join('.'));
120
+ const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
121
+
122
+ // Check for localization
123
+ const localizedInfo = utils.getLocalizedLookupInfo(column.target, column.alt, model);
124
+ if (localizedInfo) {
125
+ const textsWhere = { ...where, locale: { func: 'session_context', args: [{ val: '$user.locale' }] } };
126
+ const textsQuery = SELECT.one.from(localizedInfo.textsEntity).columns(columns).where(textsWhere);
127
+ const baseQuery = SELECT.one.from(column.target).columns(columns).where(where);
128
+ return `(SELECT COALESCE((${toSQL(textsQuery)}), (${toSQL(baseQuery)})))`;
129
+ }
130
+
131
+ const query = SELECT.one.from(column.target).columns(columns).where(where);
132
+ return `(${toSQL(query)})`;
133
+ }
134
+
135
+ /**
136
+ * Returns SQL expression for a column's label (looked-up value for associations)
137
+ */
138
+ function getLabelExpr(col, refRow, entityKey) {
139
+ if (col.target && col.alt) {
140
+ return buildAssocLookup(col, refRow, entityKey);
141
+ }
142
+ return 'NULL';
143
+ }
144
+
145
+ /**
146
+ * Builds SQL expression for objectID (entity display name)
147
+ * Uses @changelog annotation fields, falling back to entity name
148
+ */
149
+ function buildObjectIDSelect(objectIDs, entityName, entityKeys, refRow) {
150
+ if (objectIDs.length === 0) return `'${entityName}'`;
151
+
152
+ for (const objectID of objectIDs) {
153
+ if (objectID.included) continue;
154
+ const where = buildKeyWhere(entityKeys, refRow);
155
+ const query = SELECT.one.from(entityName).columns(objectID.name).where(where);
156
+ objectID.selectSQL = toSQL(query);
157
+ }
158
+
159
+ const unionParts = objectIDs.map((id) => (id.included ? `SELECT ${refRow}.${id.name} AS value WHERE ${refRow}.${id.name} IS NOT NULL` : `SELECT (${id.selectSQL}) AS value`));
160
+
161
+ return `(SELECT GROUP_CONCAT(value, ', ') FROM (${unionParts.join('\nUNION ALL\n')}))`;
162
+ }
163
+
164
+ function buildTriggerContext(entity, objectIDs, refRow, compositionParentInfo = null) {
165
+ const keys = utils.extractKeys(entity.keys);
166
+ const entityKey = compositeKeyExpr(keys.map((k) => `${refRow}.${k}`));
167
+ const objectID = buildObjectIDSelect(objectIDs, entity.name, keys, refRow) ?? entityKey;
168
+ const parentLookupExpr = compositionParentInfo ? 'PARENT_LOOKUP_PLACEHOLDER' : null;
169
+
170
+ return { keys, entityKey, objectID, parentLookupExpr };
171
+ }
172
+
173
+ function buildInsertSQL(entity, columns, modification, ctx) {
174
+ const unionQuery = columns
175
+ .map((col) => {
176
+ const whereCondition = getWhereCondition(col, modification);
177
+ const elementSkipCondition = getElementSkipCondition(entity.name, col.name);
178
+ let fullWhere = `(${whereCondition}) AND ${elementSkipCondition}`;
179
+
180
+ // For composition-of-one columns, add deduplication check to prevent duplicate entries
181
+ // when child trigger has already created a composition entry for this transaction
182
+ if (col.type === 'cds.Composition' && ctx.entityKey) {
183
+ fullWhere += ` AND NOT EXISTS (
184
+ SELECT 1 FROM sap_changelog_Changes
185
+ WHERE entity = '${entity.name}'
186
+ AND entityKey = ${ctx.entityKey}
187
+ AND attribute = '${col.name}'
188
+ AND valueDataType = 'cds.Composition'
189
+ AND createdAt = session_context('$now')
190
+ AND createdBy = session_context('$user.id')
191
+ )`;
192
+ }
193
+
194
+ const oldVal = modification === 'create' ? 'NULL' : getValueExpr(col, 'old');
195
+ const newVal = modification === 'delete' ? 'NULL' : getValueExpr(col, 'new');
196
+ const oldLabel = modification === 'create' ? 'NULL' : getLabelExpr(col, 'old', ctx.entityKey);
197
+ const newLabel = modification === 'delete' ? 'NULL' : getLabelExpr(col, 'new', ctx.entityKey);
198
+
199
+ return `SELECT '${col.name}' AS attribute, ${oldVal} AS valueChangedFrom, ${newVal} AS valueChangedTo, ${oldLabel} AS valueChangedFromLabel, ${newLabel} AS valueChangedToLabel, '${col.type}' AS valueDataType WHERE ${fullWhere}`;
200
+ })
201
+ .join('\nUNION ALL\n');
202
+
203
+ return `INSERT INTO sap_changelog_Changes (ID, parent_ID, attribute, valueChangedFrom, valueChangedTo, valueChangedFromLabel, valueChangedToLabel, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
204
+ SELECT
205
+ hex(randomblob(16)),
206
+ ${ctx.parentLookupExpr ?? 'NULL'},
207
+ attribute,
208
+ valueChangedFrom,
209
+ valueChangedTo,
210
+ valueChangedFromLabel,
211
+ valueChangedToLabel,
212
+ '${entity.name}',
213
+ ${ctx.entityKey},
214
+ ${ctx.objectID},
215
+ session_context('$now'),
216
+ session_context('$user.id'),
217
+ valueDataType,
218
+ '${modification}',
219
+ session_context('$now')
220
+ FROM (
221
+ ${unionQuery}
222
+ );`;
223
+ }
224
+
225
+ module.exports = {
226
+ setModel,
227
+ getModel,
228
+ toSQL,
229
+ getSkipCheckCondition,
230
+ getElementSkipCondition,
231
+ compositeKeyExpr,
232
+ getValueExpr,
233
+ getWhereCondition,
234
+ getLabelExpr,
235
+ buildObjectIDSelect,
236
+ buildTriggerContext,
237
+ buildInsertSQL
238
+ };
@@ -0,0 +1,164 @@
1
+ const utils = require('../utils/change-tracking.js');
2
+ const config = require('@sap/cds').env.requires['change-tracking'];
3
+ const { getCompositionParentInfo, getGrandParentCompositionInfo } = require('../utils/composition-helpers.js');
4
+ const { setModel, getSkipCheckCondition, buildTriggerContext, buildInsertSQL } = require('./sql-expressions.js');
5
+ const { buildCompositionParentContext } = require('./composition.js');
6
+
7
+ function generateCreateTrigger(entity, columns, objectIDs, rootObjectIDs, compositionParentInfo = null, grandParentCompositionInfo = null) {
8
+ const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'create', 'new', grandParentCompositionInfo) : null;
9
+ const ctx = buildTriggerContext(entity, objectIDs, 'new', compositionParentInfo);
10
+
11
+ // Replace placeholder with actual parent lookup expression if needed
12
+ if (compositionParentContext && ctx.parentLookupExpr) {
13
+ ctx.parentLookupExpr = compositionParentContext.parentLookupExpr;
14
+ }
15
+
16
+ // Handle composition-only triggers (no tracked columns, only composition parent entry)
17
+ let bodySQL;
18
+ if (columns.length === 0 && compositionParentContext) {
19
+ bodySQL = compositionParentContext.insertSQL;
20
+ } else if (compositionParentContext) {
21
+ const insertSQL = buildInsertSQL(entity, columns, 'create', ctx);
22
+ bodySQL = `${compositionParentContext.insertSQL}\n ${insertSQL}`;
23
+ } else {
24
+ bodySQL = buildInsertSQL(entity, columns, 'create', ctx);
25
+ }
26
+
27
+ return `CREATE TRIGGER IF NOT EXISTS ${utils.transformName(entity.name)}_ct_create AFTER INSERT
28
+ ON ${utils.transformName(entity.name)}
29
+ WHEN ${getSkipCheckCondition(entity.name)}
30
+ BEGIN
31
+ ${bodySQL}
32
+ END;`;
33
+ }
34
+
35
+ function generateUpdateTrigger(entity, columns, objectIDs, rootObjectIDs, compositionParentInfo = null, grandParentCompositionInfo = null) {
36
+ const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'update', 'new', grandParentCompositionInfo) : null;
37
+ const ctx = buildTriggerContext(entity, objectIDs, 'new', compositionParentInfo);
38
+
39
+ // Replace placeholder with actual parent lookup expression if needed
40
+ if (compositionParentContext && ctx.parentLookupExpr) {
41
+ ctx.parentLookupExpr = compositionParentContext.parentLookupExpr;
42
+ }
43
+
44
+ // Handle composition-only triggers (no tracked columns, only composition parent entry)
45
+ let bodySQL;
46
+ if (columns.length === 0 && compositionParentContext) {
47
+ bodySQL = compositionParentContext.insertSQL;
48
+ } else if (compositionParentContext) {
49
+ const insertSQL = buildInsertSQL(entity, columns, 'update', ctx);
50
+ bodySQL = `${compositionParentContext.insertSQL}\n ${insertSQL}`;
51
+ } else {
52
+ bodySQL = buildInsertSQL(entity, columns, 'update', ctx);
53
+ }
54
+
55
+ // Build OF clause for targeted update trigger
56
+ const ofColumns = columns.flatMap((c) => {
57
+ if (!c.target) return [c.name];
58
+ if (c.foreignKeys) return c.foreignKeys.map((k) => `${c.name}_${k}`);
59
+ if (c.on) return c.on.map((m) => `${c.name}_${m.foreignKeyField}`);
60
+ return [];
61
+ });
62
+ const ofClause = columns.length > 0 ? `OF ${ofColumns.join(', ')} ` : '';
63
+
64
+ return `CREATE TRIGGER IF NOT EXISTS ${utils.transformName(entity.name)}_ct_update AFTER UPDATE ${ofClause}
65
+ ON ${utils.transformName(entity.name)}
66
+ WHEN ${getSkipCheckCondition(entity.name)}
67
+ BEGIN
68
+ ${bodySQL}
69
+ END;`;
70
+ }
71
+
72
+ function generateDeleteTriggerPreserve(entity, columns, objectIDs, rootObjectIDs, compositionParentInfo = null, grandParentCompositionInfo = null) {
73
+ const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'delete', 'old', grandParentCompositionInfo) : null;
74
+ const ctx = buildTriggerContext(entity, objectIDs, 'old', compositionParentInfo);
75
+
76
+ // Replace placeholder with actual parent lookup expression if needed
77
+ if (compositionParentContext && ctx.parentLookupExpr) {
78
+ ctx.parentLookupExpr = compositionParentContext.parentLookupExpr;
79
+ }
80
+
81
+ // Handle composition-only triggers (no tracked columns, only composition parent entry)
82
+ let bodySQL;
83
+ if (columns.length === 0 && compositionParentContext) {
84
+ bodySQL = compositionParentContext.insertSQL;
85
+ } else if (compositionParentContext) {
86
+ const insertSQL = buildInsertSQL(entity, columns, 'delete', ctx);
87
+ bodySQL = `${compositionParentContext.insertSQL}\n ${insertSQL}`;
88
+ } else {
89
+ bodySQL = buildInsertSQL(entity, columns, 'delete', ctx);
90
+ }
91
+
92
+ return `CREATE TRIGGER IF NOT EXISTS ${utils.transformName(entity.name)}_ct_delete AFTER DELETE
93
+ ON ${utils.transformName(entity.name)}
94
+ WHEN ${getSkipCheckCondition(entity.name)}
95
+ BEGIN
96
+ ${bodySQL}
97
+ END;`;
98
+ }
99
+
100
+ function generateDeleteTrigger(entity, columns, objectIDs, rootObjectIDs, compositionParentInfo = null, grandParentCompositionInfo = null) {
101
+ const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'delete', 'old', grandParentCompositionInfo) : null;
102
+ const ctx = buildTriggerContext(entity, objectIDs, 'old', compositionParentInfo);
103
+
104
+ // Replace placeholder with actual parent lookup expression if needed
105
+ if (compositionParentContext && ctx.parentLookupExpr) {
106
+ ctx.parentLookupExpr = compositionParentContext.parentLookupExpr;
107
+ }
108
+
109
+ const deleteSQL = `DELETE FROM ${utils.transformName('sap.changelog.Changes')} WHERE entity = '${entity.name}' AND entityKey = ${ctx.entityKey};`;
110
+
111
+ // Handle composition-only triggers (no tracked columns, only composition parent entry)
112
+ let bodySQL;
113
+ if (columns.length === 0 && compositionParentContext) {
114
+ bodySQL = `${deleteSQL}\n ${compositionParentContext.insertSQL}`;
115
+ } else if (compositionParentContext) {
116
+ const insertSQL = buildInsertSQL(entity, columns, 'delete', ctx);
117
+ bodySQL = `${deleteSQL}\n ${compositionParentContext.insertSQL}\n ${insertSQL}`;
118
+ } else {
119
+ const insertSQL = buildInsertSQL(entity, columns, 'delete', ctx);
120
+ bodySQL = `${deleteSQL}\n ${insertSQL}`;
121
+ }
122
+
123
+ return `CREATE TRIGGER IF NOT EXISTS ${utils.transformName(entity.name)}_ct_delete AFTER DELETE
124
+ ON ${utils.transformName(entity.name)}
125
+ WHEN ${getSkipCheckCondition(entity.name)}
126
+ BEGIN
127
+ ${bodySQL}
128
+ END;`;
129
+ }
130
+
131
+ function generateSQLiteTrigger(csn, entity, rootEntity, mergedAnnotations = null, rootMergedAnnotations = null, grandParentContext = {}) {
132
+ setModel(csn);
133
+ const triggers = [];
134
+ const { columns: trackedColumns } = utils.extractTrackedColumns(entity, csn, mergedAnnotations);
135
+ const objectIDs = utils.getObjectIDs(entity, csn, mergedAnnotations?.entityAnnotation);
136
+ const rootObjectIDs = utils.getObjectIDs(rootEntity, csn, rootMergedAnnotations?.entityAnnotation);
137
+
138
+ // Check if this entity is a tracked composition target (composition-of-many)
139
+ const compositionParentInfo = getCompositionParentInfo(entity, rootEntity, rootMergedAnnotations);
140
+
141
+ // Get grandparent info for deep linking (e.g., OrderItemNote -> OrderItem.notes -> Order.orderItems)
142
+ const { grandParentEntity, grandParentMergedAnnotations, grandParentCompositionField } = grandParentContext;
143
+ const grandParentCompositionInfo = getGrandParentCompositionInfo(rootEntity, grandParentEntity, grandParentMergedAnnotations, grandParentCompositionField);
144
+
145
+ // Generate triggers if we have tracked columns OR if this is a composition target
146
+ const shouldGenerateTriggers = trackedColumns.length > 0 || compositionParentInfo;
147
+
148
+ if (shouldGenerateTriggers) {
149
+ if (!config?.disableCreateTracking) {
150
+ triggers.push(generateCreateTrigger(entity, trackedColumns, objectIDs, rootObjectIDs, compositionParentInfo, grandParentCompositionInfo));
151
+ }
152
+ if (!config?.disableUpdateTracking) {
153
+ triggers.push(generateUpdateTrigger(entity, trackedColumns, objectIDs, rootObjectIDs, compositionParentInfo, grandParentCompositionInfo));
154
+ }
155
+ if (!config?.disableDeleteTracking) {
156
+ const generateDeleteTriggerFunc = config?.preserveDeletes ? generateDeleteTriggerPreserve : generateDeleteTrigger;
157
+ triggers.push(generateDeleteTriggerFunc(entity, trackedColumns, objectIDs, rootObjectIDs, compositionParentInfo, grandParentCompositionInfo));
158
+ }
159
+ }
160
+
161
+ return triggers.length === 1 ? triggers[0] : triggers.length > 0 ? triggers : null;
162
+ }
163
+
164
+ module.exports = { generateSQLiteTrigger };