Skip to content
21 changes: 19 additions & 2 deletions api/src/org/labkey/api/audit/AuditHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,23 @@
import org.labkey.api.exp.api.ExpMaterial;
import org.labkey.api.exp.api.ExperimentService;
import org.labkey.api.gwt.client.AuditBehaviorType;
import org.labkey.api.query.QueryKey;
import org.labkey.api.query.QueryService;
import org.labkey.api.security.User;
import org.labkey.api.util.Pair;

import java.io.IOException;
import java.text.NumberFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;


public interface AuditHandler
Expand Down Expand Up @@ -104,7 +106,7 @@ static Pair<Map<String, Object>, Map<String, Object>> getOldAndNewRecordForMerge
}
String lcName = nameFromAlias.toLowerCase();
// Preserve casing of inputs so we can show the names properly
boolean isExpInput = false;
boolean isExpInput = false; // TODO: extract lineage handling out of this generic method
String encodedInputColumn = ExperimentService.getEncodedLineageKey(lcName);
if (encodedInputColumn != null)
{
Expand Down Expand Up @@ -172,6 +174,21 @@ else if (!Objects.equals(oldValue, newValue) || isExtraAuditField)
}
else
{
if (isExpInput && oldValue != null && newValue != null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isExpInput related logic does not seem appropriate for the AuditHandler interface. Is there a way that this can be registered as a part of a current or new audit handler that is only invoked for the necessary tables?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. This has been brought up a few times but hasn't been done due to difficulty. I added a TODO comment as a reminder.

{
// For parent inputs, the order of the values does not matter, so compare as sets
try
{
Set<String> oldSet = Arrays.stream(ExperimentService.getParentValues(oldValue.toString())).collect(Collectors.toSet());
Set<String> newSet = Arrays.stream(ExperimentService.getParentValues(newValue.toString())).collect(Collectors.toSet());
if (oldSet.equals(newSet) && !isExtraAuditField)
continue;
}
catch (IOException ignore)
{
}
}

originalRow.put(nameFromAlias, oldValue);
modifiedRow.put(nameFromAlias, newValue);
}
Expand Down
30 changes: 10 additions & 20 deletions api/src/org/labkey/api/data/NameGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -745,26 +745,16 @@ public static Stream<String> parentNames(Object value, String parentColName, TSV
// using TabLoader instead of just splitting on the comma.
boolean likelyAlreadyQuoted = valueStr.contains(",") || valueStr.contains("\n") || valueStr.contains("\r") || (valueStr.startsWith("\"") && valueStr.endsWith("\""));
String quotedStr = likelyAlreadyQuoted ? valueStr : tsvWriter.quoteValue(valueStr); // if value contains comma, no need to quote again
try (TabLoader tabLoader = new TabLoader(quotedStr))
{
tabLoader.setDelimiterCharacter(',');
tabLoader.setUnescapeBackslashes(false);
// Issue 50924: LKSM: Importing samples using naming expression referencing parent inputs with # result in error
tabLoader.setIncludeComments(true);
// Issue 51056 Samples with single double quotes in the name will not resolve if added as parent samples.
tabLoader.setParseEnclosedQuotes(true);
try
{
String[][] parsedValues = tabLoader.getFirstNLines(1);
values = Arrays.stream(parsedValues[0]);
}
catch (IOException e)
{
if (errors != null)
errors.addRowError(new ValidationException("Unable to parse parent names from " + value, parentColName));
else
throw new IllegalStateException("Unable to parse parent names from " + valueStr, e);
}
try
{
values = Arrays.stream(ExperimentService.getParentValues(quotedStr));
}
catch (IOException e)
{
if (errors != null)
errors.addRowError(new ValidationException("Unable to parse parent names from " + value, parentColName));
else
throw new IllegalStateException("Unable to parse parent names from " + valueStr, e);
}
}
else if (value instanceof Collection<?> coll)
Expand Down
45 changes: 18 additions & 27 deletions api/src/org/labkey/api/exp/api/ExperimentService.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
import org.labkey.api.query.QueryViewProvider;
import org.labkey.api.query.UserSchema;
import org.labkey.api.query.ValidationException;
import org.labkey.api.reader.TabLoader;
import org.labkey.api.security.User;
import org.labkey.api.services.ServiceRegistry;
import org.labkey.api.util.Pair;
Expand Down Expand Up @@ -521,6 +522,23 @@ static boolean isInputOutputColumn(String columnName)
return null;
}

static @NotNull String[] getParentValues(String valueStr) throws IOException
{
if (StringUtils.isEmpty(valueStr.trim()))
return new String[0];
try (TabLoader tabLoader = new TabLoader(valueStr))
{
tabLoader.setDelimiterCharacter(',');
tabLoader.setUnescapeBackslashes(false);
// Issue 50924: LKSM: Importing samples using naming expression referencing parent inputs with # result in error
tabLoader.setIncludeComments(true);
// Issue 51056 Samples with single double quotes in the name will not resolve if added as parent samples.
tabLoader.setParseEnclosedQuotes(true);
String[][] parsedValues = tabLoader.getFirstNLines(1);
return parsedValues[0];
}
}

static Pair<String, String> parseInputOutputAlias(String columnName)
{
if (!isInputOutputColumn(columnName))
Expand Down Expand Up @@ -593,33 +611,6 @@ static void validateParentAlias(Map<String, String> aliasMap, Set<String> reserv
* ignoring any sample children derived from ExpData children.
*/
Set<ExpMaterial> getRelatedChildSamples(Container c, User user, ExpData start);

/**
* Find the ExpData objects, if any, that are parents of the <code>start</code> ExpMaterial.
*/
@NotNull
Set<ExpData> getParentDatas(Container c, User user, ExpMaterial start);

/**
* Find the ExpMaterial objects, if any, that are parents of the <code>start</code> ExpMaterial.
*/
@NotNull
Set<ExpMaterial> getParentMaterials(Container c, User user, ExpMaterial start);

/**
* Find all parent ExpData that are parents of the <code>start</code> ExpMaterial,
* stopping at the first parent generation (no grandparents.)
*/
@NotNull
Set<ExpData> getNearestParentDatas(Container c, User user, ExpMaterial start);

/**
* Find all parent ExpMaterial that are parents of the <code>start</code> ExpMaterial,
* stopping at the first parent generation (no grandparents.)
*/
@NotNull
Set<ExpMaterial> getNearestParentMaterials(Container c, User user, ExpMaterial start);

/**
* Get the lineage for the seed Identifiable object. Typically, the seed object is a ExpMaterial,
* a ExpData (in a DataClass), or an ExpRun.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.collections4.ListUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.apache.logging.log4j.Level;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
Expand Down Expand Up @@ -1231,12 +1232,66 @@ public List<Map<String, Object>> insertRows(User user, Container container, List
return super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext);
}

@Override
public Map<Integer, Map<String, Object>> getExistingRows(User user, Container container, Map<Integer, Map<String, Object>> keys, boolean verifyNoCrossFolderData, boolean verifyExisting, @Nullable Set<String> columns) throws SQLException, QueryUpdateServiceException, InvalidKeyException
{
Map<Integer, Map<String, Object>> dataRows = super.getExistingRows(user, container, keys, verifyNoCrossFolderData, verifyExisting, columns);
boolean hasParentInput = false;
if (_dataClass != null && columns != null && !columns.isEmpty())
{
try
{
Map<String, String> importAliases = _dataClass.getImportAliases();
for (String col : columns)
{
if (ExperimentService.isInputOutputColumn(col) || Strings.CI.equals("parent",col) || importAliases.containsKey(col))
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method call Strings.CI.equals("parent",col) has the parameters in an unusual order. Typically the expected value comes first: Strings.CI.equals(col, "parent"). This current order could be confusing and inconsistent with common patterns.

Suggested change
if (ExperimentService.isInputOutputColumn(col) || Strings.CI.equals("parent",col) || importAliases.containsKey(col))
if (ExperimentService.isInputOutputColumn(col) || Strings.CI.equals(col, "parent") || importAliases.containsKey(col))

Copilot uses AI. Check for mistakes.
{
hasParentInput = true;
break;
}
}
}
catch (IOException ignored)
{
}
}

if (!hasParentInput)
return dataRows;

Set<String> lsids = new HashSet<>();
for (Map<String, Object> dataRow : dataRows.values())
lsids.add((String) dataRow.get("lsid"));
List<ExpDataImpl> seeds = ExperimentServiceImpl.get().getExpDatasByLSID(lsids);

ExperimentServiceImpl.get().addRowsParentsFields(new HashSet<>(seeds), dataRows, user, container);

return dataRows;
}

@Override
protected Map<String, Object> getRow(User user, Container container, Map<String, Object> keys) throws InvalidKeyException, SQLException
{
return getRow(user, container, keys, false);
}

@Override
public List<Map<String, Object>> getRows(User user, Container container, List<Map<String, Object>> keys)
throws SQLException
{
if (!hasPermission(user, ReadPermission.class))
throw new UnauthorizedException("You do not have permission to read data from this table.");

List<Map<String, Object>> result = new ArrayList<>(keys.size());
for (Map<String, Object> row : keys)
{
Map<String, Object> materialMap = getDataMap(row, user, container, true, false);
if (materialMap != null)
result.add(materialMap);
}
return result;
}

/* This class overrides getRow() in order to support getRow() using "rowid" or "lsid" */
@Override
protected Map<String, Object> getRow(User user, Container container, Map<String, Object> keys, boolean allowCrossContainer) throws InvalidKeyException, SQLException
Expand All @@ -1246,15 +1301,11 @@ protected Map<String, Object> getRow(User user, Container container, Map<String,
Long rowId = (Long)JdbcType.BIGINT.convert(keys.get(Column.RowId.name()));
String lsid = (String)JdbcType.VARCHAR.convert(keys.get(Column.LSID.name()));
String name = (String)JdbcType.VARCHAR.convert(keys.get(Name.name()));
Long classId = (Long)JdbcType.BIGINT.convert(keys.get(Column.ClassId.name()));

if (classId == null)
classId = _dataClass.getRowId();

if (null == rowId && null == lsid && null == name)
throw new InvalidKeyException("Value must be supplied for key field 'rowid' or 'lsid' or 'name'", keys);

return _select(container, rowId, lsid, name, classId, allowCrossContainer);
return getDataMap(keys, user, container, allowCrossContainer, true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

classId no longer needs to be computed since it is not being handed through.

}

@Override
Expand All @@ -1263,9 +1314,17 @@ protected Map<String, Object> _select(Container container, Object[] keys) throws
throw new IllegalStateException();
}

protected Map<String, Object> _select(Container container, Long rowId, String lsid, String name, Long classId, boolean allowCrossContainer) throws SQLException
@Nullable protected Map<String, Object> getDataMap(Map<String, Object> keys, User user, Container container, boolean allowCrossContainer, boolean addInputs) throws SQLException
{
if (null == rowId && null == lsid && (null == name || null == classId))
Long rowId = (Long)JdbcType.BIGINT.convert(keys.get(Column.RowId.name()));
String lsid = (String)JdbcType.VARCHAR.convert(keys.get(Column.LSID.name()));
String name = (String)JdbcType.VARCHAR.convert(keys.get(Name.name()));
Long classId = (Long)JdbcType.BIGINT.convert(keys.get(Column.ClassId.name()));

if (classId == null)
classId = _dataClass.getRowId();

if (null == rowId && null == lsid && null == name)
return null;

// Issue 52886: Use queryTable here, not raw database table, so the rows are from the user schema with names
Expand All @@ -1284,13 +1343,31 @@ else if (null != lsid)
TableInfo queryTable = getQueryTable();
TableSelector selector = new TableSelector(queryTable, filter, null);

Map<String, Object> dataRow = null;
try (var results = selector.getResults()) {
if (results.next())
{
return FieldKeyRowMap.toNameMap(results.getFieldKeyRowMap());
dataRow = FieldKeyRowMap.toNameMap(results.getFieldKeyRowMap());
}
}
return null;

if (!addInputs)
return dataRow;

ExperimentService experimentService = ExperimentService.get();
ExpData seed = null;
if (lsid != null && (rowId == null || rowId <= 0))
seed = experimentService.getExpData(lsid);
else if (rowId != null && rowId > 0)
seed = experimentService.getExpData(_dataClass, rowId);
else if (name != null)
seed = experimentService.getExpData(_dataClass, name);
if (null == seed)
return dataRow;

ExperimentServiceImpl.get().addParentsFields(seed, dataRow, user, container);

return dataRow;
}

@Override
Expand Down
Loading