Skip to content

Commit db3da1a

Browse files
committed
Triggers: support managing columns for data iteration
1 parent 49d8eb3 commit db3da1a

4 files changed

Lines changed: 205 additions & 39 deletions

File tree

api/src/org/labkey/api/data/AbstractTableInfo.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.labkey.api.collections.CaseInsensitiveMapWrapper;
3131
import org.labkey.api.collections.CaseInsensitiveTreeSet;
3232
import org.labkey.api.collections.NamedObjectList;
33+
import org.labkey.api.collections.Sets;
3334
import org.labkey.api.data.dialect.SqlDialect;
3435
import org.labkey.api.data.triggers.ScriptTriggerFactory;
3536
import org.labkey.api.data.triggers.Trigger;
@@ -1884,6 +1885,24 @@ public final boolean canStreamTriggers(Container c)
18841885
return true;
18851886
}
18861887

1888+
@Override
1889+
public @Nullable Set<String> getTriggerManagedColumns(@Nullable Container c)
1890+
{
1891+
var triggers = getTriggers(c);
1892+
if (triggers.isEmpty())
1893+
return null;
1894+
1895+
var columns = triggers.stream()
1896+
.map(Trigger::getManagedColumns)
1897+
.filter(Objects::nonNull)
1898+
.flatMap(Collection::stream)
1899+
.toList();
1900+
1901+
if (columns.isEmpty())
1902+
return null;
1903+
1904+
return Sets.newCaseInsensitiveHashSet(columns);
1905+
}
18871906

18881907
private Collection<Trigger> _triggers = null;
18891908

@@ -1959,9 +1978,65 @@ public void fireRowTrigger(Container c, User user, TriggerType type, boolean bef
19591978

19601979
for (Trigger script : triggers)
19611980
{
1981+
var managed = before ? script.getManagedColumns() : null;
1982+
1983+
// Inject sentinel for each declared column absent from the row; the trigger must handle it
1984+
if (newRow != null && managed != null && before)
1985+
{
1986+
for (var col : managed)
1987+
newRow.putIfAbsent(col, Trigger.COLUMN_SENTINEL);
1988+
}
1989+
1990+
// Snapshot keys after injection — trigger may update values but must not alter the key set
1991+
var keysBeforeTrigger = newRow != null && before ? Set.copyOf(newRow.keySet()) : null;
1992+
19621993
script.rowTrigger(this, c, user, type, before, rowNumber, newRow, oldRow, errors, extraContext, existingRecord);
19631994
if (errors.hasErrors())
19641995
break;
1996+
1997+
if (newRow != null && before)
1998+
{
1999+
// Verify the trigger did not add or remove columns that are not managed
2000+
if (!newRow.keySet().equals(keysBeforeTrigger))
2001+
{
2002+
var added = Sets.newCaseInsensitiveHashSet(newRow.keySet());
2003+
added.removeAll(keysBeforeTrigger);
2004+
2005+
var removed = Sets.newCaseInsensitiveHashSet(keysBeforeTrigger);
2006+
removed.removeAll(newRow.keySet());
2007+
2008+
// managed column removals are intentional
2009+
if (managed != null)
2010+
{
2011+
added.removeAll(managed);
2012+
removed.removeAll(managed);
2013+
}
2014+
2015+
if (!added.isEmpty() || !removed.isEmpty())
2016+
{
2017+
var diffs = new ArrayList<String>();
2018+
if (!added.isEmpty())
2019+
diffs.add("add: " + String.join(", ", added));
2020+
if (!removed.isEmpty())
2021+
diffs.add("remove: " + String.join(", ", removed));
2022+
2023+
errors.addGlobalError("Trigger '" + script.getName() + "' attempted to " + String.join(", ", diffs) + ". Declare columns via getManagedColumns() to include them in the column set.");
2024+
}
2025+
}
2026+
2027+
// Verify the trigger handled every declared column it was responsible for
2028+
if (managed != null)
2029+
{
2030+
for (var col : managed)
2031+
{
2032+
if (newRow.get(col) == Trigger.COLUMN_SENTINEL)
2033+
errors.addFieldError(col, "Trigger '" + script.getName() + "' declared column '" + col + "' in getManagedColumns() but did not set a value for it. Set null to clear or provide a value.");
2034+
}
2035+
}
2036+
}
2037+
2038+
if (errors.hasErrors())
2039+
break;
19652040
}
19662041

19672042
if (errors.hasErrors())

api/src/org/labkey/api/data/TableInfo.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,18 @@ void fireRowTrigger(Container c, User user, TriggerType type, boolean before, in
619619
/**
620620
* Return true if all trigger scripts support streaming.
621621
*/
622-
default boolean canStreamTriggers(Container c) { return false; }
622+
default boolean canStreamTriggers(Container c)
623+
{
624+
return false;
625+
}
626+
627+
/**
628+
* Returns the full set of columns managed by triggers for this TableInfo.
629+
*/
630+
default @Nullable Set<String> getTriggerManagedColumns(@Nullable Container c)
631+
{
632+
return null;
633+
}
623634

624635
/**
625636
* Reset the trigger script context by reloading them. Note there could still be caches that need to be reset
@@ -632,9 +643,12 @@ void fireRowTrigger(Container c, User user, TriggerType type, boolean before, in
632643
/**
633644
* Returns true if the underlying database table has triggers.
634645
*/
635-
default boolean hasDbTriggers() { return false; }
646+
default boolean hasDbTriggers()
647+
{
648+
return false;
649+
}
636650

637-
/* for asserting that tableinfo is not changed unexpectedly */
651+
/* for asserting that the TableInfo is not changed unexpectedly */
638652
void setLocked(boolean b);
639653
boolean isLocked();
640654

api/src/org/labkey/api/data/triggers/Trigger.java

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import java.util.List;
3030
import java.util.Map;
3131
import java.util.Objects;
32+
import java.util.Set;
3233
import java.util.stream.Collectors;
3334

3435
/**
@@ -37,20 +38,46 @@
3738
*/
3839
public interface Trigger
3940
{
41+
/**
42+
* Sentinel value placed into the row map for each column declared by {@link #getManagedColumns()}
43+
* when that column is absent from the incoming row. The trigger must replace this with a real value
44+
* or {@code null} before returning; leaving the sentinel in place is a validation error.
45+
*/
46+
Object COLUMN_SENTINEL = new Object()
47+
{
48+
@Override
49+
public String toString()
50+
{
51+
return "<trigger managed column - not yet handled>";
52+
}
53+
};
54+
4055
/** The trigger name. */
41-
default String getName() { return getClass().getSimpleName(); }
56+
default String getName()
57+
{
58+
return getClass().getSimpleName();
59+
}
4260

4361
/** Short description of the trigger. */
44-
default String getDescription() { return null; }
62+
default String getDescription()
63+
{
64+
return null;
65+
}
4566

46-
/** Name of module that defines this trigger. */
47-
default String getModuleName() { return null; }
67+
/** Name of the module that defines this trigger. */
68+
default String getModuleName()
69+
{
70+
return null;
71+
}
4872

4973
/**
5074
* For script triggers, this is the path to the trigger script.
5175
* For java triggers, this is the class name.
5276
*/
53-
default String getSource() { return getClass().getName(); }
77+
default String getSource()
78+
{
79+
return getClass().getName();
80+
}
5481

5582
/**
5683
* The set of events that this trigger implements.
@@ -78,9 +105,49 @@ default List<TableInfo.TriggerMethod> getEvents()
78105
}
79106

80107
/**
81-
* True if this TriggerScript can be used in a streaming context; triggers will be called without old row values.
108+
* Returns the set of column names this trigger will read or write during row processing.
109+
* <p>
110+
* For each row where a declared column is absent from the input, the data iterator initializes
111+
* that column to {@link #COLUMN_SENTINEL} before the trigger fires. The trigger must then
112+
* explicitly set each such column to {@code null} or a real value; failure to do so produces
113+
* a validation error naming this trigger and the unhandled column.
114+
* <p>
115+
* Columns that do not exist in the target table's schema (virtual/passthrough columns) may be
116+
* declared here and will work correctly — the database writer ignores them.
117+
*/
118+
default @Nullable Set<String> getManagedColumns()
119+
{
120+
return null;
121+
}
122+
123+
/**
124+
* Convenience method for trigger implementations: replaces any {@link #COLUMN_SENTINEL} values
125+
* in managed columns with {@code null}. Call this in early-return paths where the trigger will
126+
* not produce a value for one or more of its declared columns.
127+
*/
128+
default void clearManagedColumns(@Nullable Map<String, Object> row)
129+
{
130+
if (row == null)
131+
return;
132+
133+
var managedColumns = getManagedColumns();
134+
if (managedColumns == null || managedColumns.isEmpty())
135+
return;
136+
137+
for (String col : managedColumns)
138+
{
139+
if (row.get(col) == COLUMN_SENTINEL)
140+
row.remove(col);
141+
}
142+
}
143+
144+
/**
145+
* Returns true if this TriggerScript can be used in a streaming context; triggers will be called without old row values.
82146
*/
83-
default boolean canStream() { return false; }
147+
default boolean canStream()
148+
{
149+
return false;
150+
}
84151

85152
default void batchTrigger(TableInfo table, Container c, User user, TableInfo.TriggerType event, boolean before, BatchValidationException errors, Map<String, Object> extraContext)
86153
{

api/src/org/labkey/api/dataiterator/TriggerDataBuilderHelper.java

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
*/
1616
package org.labkey.api.dataiterator;
1717

18-
import org.apache.commons.collections4.CollectionUtils;
1918
import org.labkey.api.data.AbstractTableInfo;
2019
import org.labkey.api.data.Container;
2120
import org.labkey.api.data.TableInfo;
@@ -26,10 +25,11 @@
2625
import org.labkey.api.query.ValidationException;
2726
import org.labkey.api.security.User;
2827

29-
import java.util.ArrayList;
3028
import java.util.Collection;
3129
import java.util.Map;
30+
import java.util.Objects;
3231
import java.util.Set;
32+
import java.util.function.Supplier;
3333

3434
import static org.labkey.api.admin.FolderImportContext.IS_NEW_FOLDER_IMPORT_KEY;
3535
import static org.labkey.api.util.IntegerUtils.asInteger;
@@ -115,21 +115,46 @@ protected Map<String, Object> getOldRow()
115115

116116
class Before implements DataIteratorBuilder
117117
{
118-
final DataIteratorBuilder _pre;
118+
final DataIteratorBuilder _in;
119119

120120
Before(DataIteratorBuilder in)
121121
{
122-
_pre = in;
122+
_in = in;
123123
}
124124

125125
@Override
126126
public DataIterator getDataIterator(DataIteratorContext context)
127127
{
128-
DataIterator di = _pre.getDataIterator(context);
128+
DataIterator di = _in.getDataIterator(context);
129+
if (di == null)
130+
return null; // can happen if context has errors
131+
129132
if (!_target.hasTriggers(_c))
130133
return di;
131134
di = LoggingDataIterator.wrap(di);
132135

136+
// Incorporate columns managed by triggers that may not overlap with the requested column set
137+
var triggerColumns = _target.getTriggerManagedColumns(_c);
138+
if (triggerColumns != null && !triggerColumns.isEmpty())
139+
{
140+
var columns = triggerColumns.stream().map(_target::getColumn).filter(Objects::nonNull).toList();
141+
if (!columns.isEmpty())
142+
{
143+
var translator = new SimpleTranslator(di, context);
144+
translator.setDebugName("TriggerDataBuilderHelper.Before.translator");
145+
translator.selectAll();
146+
147+
var columnNameMap = translator.getColumnNameMap();
148+
149+
for (var column : columns)
150+
{
151+
if (!columnNameMap.containsKey(column.getName()))
152+
translator.addColumn(column, (Supplier<Object>) () -> null);
153+
}
154+
di = translator.getDataIterator(context);
155+
}
156+
}
157+
133158
Set<String> existingRecordKeyColumnNames = null;
134159
Set<String> sharedKeys = null;
135160
boolean isMergeOrUpdate = context.getInsertOption().allowUpdate;
@@ -167,7 +192,6 @@ class BeforeIterator extends TriggerDataIterator
167192
{
168193
boolean _firstRow = true;
169194
Map<String, Object> _currentRow = null;
170-
Set<String> _currentColumns = null;
171195

172196
BeforeIterator(DataIterator di, DataIteratorContext context)
173197
{
@@ -199,25 +223,7 @@ public boolean next() throws BatchValidationException
199223
_currentRow = getInput().getMap();
200224
try
201225
{
202-
if (_currentRow != null && _currentColumns == null)
203-
_currentColumns = Set.copyOf(_currentRow.keySet());
204-
205226
_target.fireRowTrigger(_c, _user, triggerType, true, rowNumber, _currentRow, getOldRow(), _extraContext, getExistingRecord());
206-
207-
if (_currentRow != null && _currentColumns != null && !_currentColumns.equals(_currentRow.keySet()))
208-
{
209-
var added = CollectionUtils.subtract(_currentRow.keySet(), _currentColumns);
210-
var removed = CollectionUtils.subtract(_currentColumns, _currentRow.keySet());
211-
212-
var diffs = new ArrayList<String>();
213-
if (!added.isEmpty())
214-
diffs.add("add: " + String.join(", ", added));
215-
if (!removed.isEmpty())
216-
diffs.add("remove: " + String.join(", ", removed));
217-
218-
throw new ValidationException("Columns are not modifiable by triggers. Triggers attempted to " + String.join(", ", diffs));
219-
}
220-
221227
return true;
222228
}
223229
catch (ValidationException vex)
@@ -243,20 +249,24 @@ public Object get(int i)
243249

244250
class After implements DataIteratorBuilder
245251
{
246-
final DataIteratorBuilder _post;
252+
final DataIteratorBuilder _in;
247253

248254
After(DataIteratorBuilder in)
249255
{
250-
_post = in;
256+
_in = in;
251257
}
252258

253259
@Override
254260
public DataIterator getDataIterator(DataIteratorContext context)
255261
{
256-
DataIterator it = _post.getDataIterator(context);
262+
DataIterator di = _in.getDataIterator(context);
263+
if (di == null)
264+
return null; // can happen if context has errors
265+
257266
if (!_target.hasTriggers(_c))
258-
return it;
259-
return new AfterIterator(LoggingDataIterator.wrap(it), context);
267+
return di;
268+
269+
return new AfterIterator(LoggingDataIterator.wrap(di), context);
260270
}
261271
}
262272

0 commit comments

Comments
 (0)