Skip to content

Commit 3e426f1

Browse files
authored
Add rowId to provisioned dataclass table (#7439)
1 parent 209ec7f commit 3e426f1

9 files changed

Lines changed: 346 additions & 68 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
SELECT core.executeJavaUpgradeCode('addRowIdToProvisionedDataClassTables');
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
EXEC core.executeJavaUpgradeCode 'addRowIdToProvisionedDataClassTables';

experiment/src/client/test/integration/DataClassCrud.ispec.ts

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
ExperimentCRUDUtils,
3-
generateFieldName,
3+
generateDomainName,
44
getEscapedNameExpression,
55
hookServer,
66
RequestOptions,
@@ -12,6 +12,7 @@ import {
1212
checkLackDesignerOrReaderPerm,
1313
createSource,
1414
deleteSourceType,
15+
generateFieldNameForImport,
1516
getDataClassRowIdByName,
1617
initProject,
1718
verifyRequiredLineageInsertUpdate,
@@ -518,7 +519,7 @@ describe('Multi Value Text Choice', () => {
518519
});
519520

520521
const dataType = 'MVTCReq Source Type';
521-
const fieldName = generateFieldName();
522+
const fieldName = generateFieldNameForImport();
522523
const fieldNameInExpression = getEscapedNameExpression((fieldName));
523524
console.log("Selected Required MVTC dataclass name: " + dataType + ", field name: " + fieldName);
524525

@@ -818,4 +819,144 @@ describe('Multi Value Text Choice', () => {
818819

819820
});
820821

821-
});
822+
});
823+
824+
describe('Data CRUD', () => {
825+
826+
it ("Update using different key fields", async () => {
827+
const dataType = generateDomainName(3) + "UpdateKeyFields";
828+
const fieldName = generateFieldNameForImport();
829+
console.log("Selected dataclass name: " + dataType + ", field name: " + fieldName);
830+
831+
// create data class with one field
832+
await server.post('property', 'createDomain', {
833+
kind: 'DataClass',
834+
domainDesign: { name: dataType, fields: [{ name: fieldName }] },
835+
options: { name: dataType }
836+
}, { ...topFolderOptions, ...designerReaderOptions }).expect(successfulResponse);
837+
838+
// insert 2 rows data, provide explicit names and a rowId = -1
839+
const dataName1 = 'KeyData1';
840+
const dataName2 = 'KeyData2';
841+
const inserted = await insertDataClassData([
842+
{ name: dataName1, description: 'original1', [fieldName]: 'val1', rowId: -1 },
843+
{ name: dataName2, description: 'original2', [fieldName]: 'val2', rowId: -1 },
844+
], dataType, topFolderOptions);
845+
846+
// verify both rows are inserted with correct name and rowId is not -1 for both rows, record the rowId and lsid for both rows
847+
expect(inserted[0].name).toBe(dataName1);
848+
expect(inserted[1].name).toBe(dataName2);
849+
expect(inserted[0].rowId).not.toBe(-1);
850+
expect(inserted[1].rowId).not.toBe(-1);
851+
const row1RowId = inserted[0].rowId;
852+
const row1Lsid = inserted[0].lsid;
853+
const row2RowId = inserted[1].rowId;
854+
const row2Lsid = inserted[1].lsid;
855+
856+
const findRow = (rows: any[], rowId: number) => rows.find(r => caseInsensitive(r, 'RowId') === rowId);
857+
858+
// update description and fieldName value for both rows using rowId as key, verify update is successful and data are updated correctly
859+
await ExperimentCRUDUtils.updateRows(server, [
860+
{ rowId: row1RowId, description: 'updByRowId1', [fieldName]: 'rowIdVal1' },
861+
{ rowId: row2RowId, description: 'updByRowId2', [fieldName]: 'rowIdVal2' },
862+
], 'exp.data', dataType, topFolderOptions, editorUserOptions);
863+
864+
let rows = await ExperimentCRUDUtils.getRows(server, [row1RowId, row2RowId], 'exp.data', dataType, '*', topFolderOptions, adminOptions);
865+
let row1 = findRow(rows, row1RowId);
866+
let row2 = findRow(rows, row2RowId);
867+
expect(caseInsensitive(row1, 'description')).toBe('updByRowId1');
868+
expect(caseInsensitive(row1, fieldName)).toBe('rowIdVal1');
869+
expect(caseInsensitive(row2, 'description')).toBe('updByRowId2');
870+
expect(caseInsensitive(row2, fieldName)).toBe('rowIdVal2');
871+
872+
// update description and fieldName value for both rows using lsid as key, verify update is successful and data are updated correctly
873+
await ExperimentCRUDUtils.updateRows(server, [
874+
{ lsid: row1Lsid, description: 'updByLsid1', [fieldName]: 'lsidVal1' },
875+
{ lsid: row2Lsid, description: 'updByLsid2', [fieldName]: 'lsidVal2' },
876+
], 'exp.data', dataType, topFolderOptions, editorUserOptions);
877+
878+
rows = await ExperimentCRUDUtils.getRows(server, [row1RowId, row2RowId], 'exp.data', dataType, '*', topFolderOptions, adminOptions);
879+
row1 = findRow(rows, row1RowId);
880+
row2 = findRow(rows, row2RowId);
881+
expect(caseInsensitive(row1, 'description')).toBe('updByLsid1');
882+
expect(caseInsensitive(row1, fieldName)).toBe('lsidVal1');
883+
expect(caseInsensitive(row2, 'description')).toBe('updByLsid2');
884+
expect(caseInsensitive(row2, fieldName)).toBe('lsidVal2');
885+
886+
// update description and fieldName value, one of the row use lsid as key, the other use rowId, verify update is successful and data are updated correctly
887+
await ExperimentCRUDUtils.updateRows(server, [
888+
{ lsid: row1Lsid, description: 'updMixed1', [fieldName]: 'mixedVal1' },
889+
{ rowId: row2RowId, description: 'updMixed2', [fieldName]: 'mixedVal2' },
890+
], 'exp.data', dataType, topFolderOptions, editorUserOptions);
891+
892+
rows = await ExperimentCRUDUtils.getRows(server, [row1RowId, row2RowId], 'exp.data', dataType, '*', topFolderOptions, adminOptions);
893+
row1 = findRow(rows, row1RowId);
894+
row2 = findRow(rows, row2RowId);
895+
expect(caseInsensitive(row1, 'description')).toBe('updMixed1');
896+
expect(caseInsensitive(row1, fieldName)).toBe('mixedVal1');
897+
expect(caseInsensitive(row2, 'description')).toBe('updMixed2');
898+
expect(caseInsensitive(row2, fieldName)).toBe('mixedVal2');
899+
900+
// update names of both rows using lsid as key, verify update is successful and names are updated correctly
901+
const newName1 = 'RenamedByLsid1';
902+
const newName2 = 'RenamedByLsid2';
903+
await ExperimentCRUDUtils.updateRows(server, [
904+
{ lsid: row1Lsid, name: newName1 },
905+
{ lsid: row2Lsid, name: newName2 },
906+
], 'exp.data', dataType, topFolderOptions, editorUserOptions);
907+
908+
rows = await ExperimentCRUDUtils.getRows(server, [row1RowId, row2RowId], 'exp.data', dataType, 'RowId,Name', topFolderOptions, adminOptions);
909+
row1 = findRow(rows, row1RowId);
910+
row2 = findRow(rows, row2RowId);
911+
expect(caseInsensitive(row1, 'Name')).toBe(newName1);
912+
expect(caseInsensitive(row2, 'Name')).toBe(newName2);
913+
914+
// update names of both rows using rowId as key, verify update is successful and names are updated correctly
915+
const newName3 = 'RenamedByRowId1';
916+
const newName4 = 'RenamedByRowId2';
917+
await ExperimentCRUDUtils.updateRows(server, [
918+
{ rowId: row1RowId, name: newName3 },
919+
{ rowId: row2RowId, name: newName4 },
920+
], 'exp.data', dataType, topFolderOptions, editorUserOptions);
921+
922+
rows = await ExperimentCRUDUtils.getRows(server, [row1RowId, row2RowId], 'exp.data', dataType, 'RowId,Name', topFolderOptions, adminOptions);
923+
row1 = findRow(rows, row1RowId);
924+
row2 = findRow(rows, row2RowId);
925+
expect(caseInsensitive(row1, 'Name')).toBe(newName3);
926+
expect(caseInsensitive(row2, 'Name')).toBe(newName4);
927+
928+
// update description and fieldName value from Import with update, the import columns contains name field, verify update is successful and data are updated correctly
929+
const importUpdateText = 'Name\tDescription\t' + fieldName + '\n' + newName3 + '\timportUpd1\timportVal1\n' + newName4 + '\timportUpd2\timportVal2';
930+
const updateResp = await ExperimentCRUDUtils.importData(server, importUpdateText, dataType, 'UPDATE', topFolderOptions, editorUserOptions);
931+
expect(updateResp.body.success).toBe(true);
932+
933+
rows = await ExperimentCRUDUtils.getRows(server, [row1RowId, row2RowId], 'exp.data', dataType, '*', topFolderOptions, adminOptions);
934+
row1 = findRow(rows, row1RowId);
935+
row2 = findRow(rows, row2RowId);
936+
expect(caseInsensitive(row1, 'description')).toBe('importUpd1');
937+
expect(caseInsensitive(row1, fieldName)).toBe('importVal1');
938+
expect(caseInsensitive(row2, 'description')).toBe('importUpd2');
939+
expect(caseInsensitive(row2, fieldName)).toBe('importVal2');
940+
941+
// update description and fieldName value from Import with merge. at the same time create a new data. the import columns contain name field, verify update and insert is successful
942+
const newDataName = 'MergedNewData';
943+
const importMergeText = 'Name\tDescription\t' + fieldName + '\n' + newName3 + '\tmergeUpd1\tmergeVal1\n' + newName4 + '\tmergeUpd2\tmergeVal2\n' + newDataName + '\tmergeNew\tmergeNewVal';
944+
const mergeResp = await ExperimentCRUDUtils.importData(server, importMergeText, dataType, 'MERGE', topFolderOptions, editorUserOptions);
945+
expect(mergeResp.body.success).toBe(true);
946+
947+
rows = await ExperimentCRUDUtils.getRows(server, [row1RowId, row2RowId], 'exp.data', dataType, '*', topFolderOptions, adminOptions);
948+
row1 = findRow(rows, row1RowId);
949+
row2 = findRow(rows, row2RowId);
950+
expect(caseInsensitive(row1, 'description')).toBe('mergeUpd1');
951+
expect(caseInsensitive(row1, fieldName)).toBe('mergeVal1');
952+
expect(caseInsensitive(row2, 'description')).toBe('mergeUpd2');
953+
expect(caseInsensitive(row2, fieldName)).toBe('mergeVal2');
954+
955+
// verify new data was created by merge
956+
const newDataRow = await getDataClassDataByName(newDataName, dataType, '*', topFolderOptions, adminOptions);
957+
expect(caseInsensitive(newDataRow, 'Name')).toBe(newDataName);
958+
expect(caseInsensitive(newDataRow, 'description')).toBe('mergeNew');
959+
expect(caseInsensitive(newDataRow, fieldName)).toBe('mergeNewVal');
960+
});
961+
962+
});

experiment/src/client/test/integration/utils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
ExperimentCRUDUtils,
3+
generateFieldName,
34
IntegrationTestServer,
45
RequestOptions,
56
selectRandomN,
@@ -903,3 +904,16 @@ export async function insertRowsExpectError(server: IntegrationTestServer, rows:
903904
export async function updateRowsExpectError(server: IntegrationTestServer, rows: any[], schemaName: string, queryName: string, error: string, folderOptions: RequestOptions, userOptions: RequestOptions) {
904905
return insertRowsExpectError(server, rows, schemaName, queryName, error, folderOptions, userOptions, true);
905906
}
907+
908+
export function canNameBeUsedInImport(name: string) {
909+
return name.indexOf(',') === -1 && name.indexOf('\t') === -1 && name.indexOf('"') === -1 && name.indexOf('\n') === -1;
910+
}
911+
912+
export function generateFieldNameForImport(length: number = 10, charset?: string) {
913+
let fieldName = generateFieldName(length, charset);
914+
while (!canNameBeUsedInImport(fieldName))
915+
{
916+
fieldName = generateFieldName(length, charset);
917+
}
918+
return fieldName;
919+
}

experiment/src/org/labkey/experiment/ExperimentModule.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ public String getName()
205205
@Override
206206
public Double getSchemaVersion()
207207
{
208-
return 26.002;
208+
return 26.003;
209209
}
210210

211211
@Nullable

experiment/src/org/labkey/experiment/ExperimentUpgradeCode.java

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.labkey.api.data.ContainerManager;
3131
import org.labkey.api.data.DbSchema;
3232
import org.labkey.api.data.DbScope;
33+
import org.labkey.api.data.DeferredUpgrade;
3334
import org.labkey.api.data.JdbcType;
3435
import org.labkey.api.data.Parameter;
3536
import org.labkey.api.data.ParameterMapStatement;
@@ -64,6 +65,9 @@
6465
import org.labkey.api.security.roles.SiteAdminRole;
6566
import org.labkey.api.settings.AppProps;
6667
import org.labkey.api.util.logging.LogHelper;
68+
import org.labkey.experiment.api.DataClass;
69+
import org.labkey.experiment.api.DataClassDomainKind;
70+
import org.labkey.experiment.api.ExpDataClassImpl;
6771
import org.labkey.experiment.api.ExpSampleTypeImpl;
6872
import org.labkey.experiment.api.ExperimentServiceImpl;
6973
import org.labkey.experiment.api.MaterialSource;
@@ -74,6 +78,7 @@
7478
import java.sql.Connection;
7579
import java.sql.SQLException;
7680
import java.util.ArrayList;
81+
import java.util.Collections;
7782
import java.util.HashMap;
7883
import java.util.HashSet;
7984
import java.util.List;
@@ -509,4 +514,146 @@ public static void fixContainerForMovedSampleFiles(ModuleContext context)
509514
}
510515
}
511516

517+
/**
518+
* Called from exp-26.002-26.003.sql
519+
* Drop the classid column and add a rowId column to existing provisioned DataClass tables.
520+
*/
521+
@SuppressWarnings("unused")
522+
public static void addRowIdToProvisionedDataClassTables(ModuleContext context)
523+
{
524+
if (context.isNewInstall())
525+
return;
526+
527+
try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction())
528+
{
529+
TableInfo source = ExperimentServiceImpl.get().getTinfoDataClass();
530+
new TableSelector(source, null, null).stream(DataClass.class)
531+
.map(ExpDataClassImpl::new)
532+
.forEach(ExperimentUpgradeCode::upgradeProvisionedDataClassTable);
533+
534+
tx.commit();
535+
}
536+
}
537+
538+
private static void upgradeProvisionedDataClassTable(ExpDataClassImpl dc)
539+
{
540+
Domain domain = dc.getDomain();
541+
DataClassDomainKind kind = null;
542+
try
543+
{
544+
kind = (DataClassDomainKind) domain.getDomainKind();
545+
}
546+
catch (IllegalArgumentException e)
547+
{
548+
// pass
549+
}
550+
if (null == kind || null == kind.getStorageSchemaName())
551+
{
552+
LOG.error("DataClass '" + dc.getName() + "' (" + dc.getRowId() + ") has no provisioned storage schema.");
553+
return;
554+
}
555+
556+
DbSchema schema = DataClassDomainKind.getSchema();
557+
DbScope scope = schema.getScope();
558+
559+
StorageProvisioner storageProvisioner = StorageProvisioner.get();
560+
561+
storageProvisioner.ensureStorageTable(domain, kind, scope);
562+
domain = PropertyService.get().getDomain(domain.getTypeId());
563+
assert (null != domain && null != domain.getStorageTableName());
564+
565+
SchemaTableInfo provisionedTable = schema.getTable(domain.getStorageTableName());
566+
if (provisionedTable == null)
567+
{
568+
LOG.error("DataClass '" + dc.getName() + "' (" + dc.getRowId() + ") has no provisioned table.");
569+
return;
570+
}
571+
572+
// Drop classid column if present
573+
String classIdColumnName = "classid";
574+
ColumnInfo classIdColumn = provisionedTable.getColumn(FieldKey.fromParts(classIdColumnName));
575+
if (classIdColumn != null)
576+
{
577+
Set<String> indicesToRemove = new HashSet<>();
578+
for (var index : provisionedTable.getAllIndices())
579+
{
580+
if (index.columns().contains(classIdColumn))
581+
indicesToRemove.add(index.name());
582+
}
583+
584+
if (!indicesToRemove.isEmpty())
585+
StorageProvisionerImpl.get().dropTableIndices(domain, indicesToRemove);
586+
587+
// Remanufacture a property descriptor that matches the original classid property descriptor.
588+
var spec = new PropertyStorageSpec(classIdColumnName, JdbcType.INTEGER);
589+
PropertyDescriptor pd = new PropertyDescriptor();
590+
pd.setContainer(dc.getContainer());
591+
pd.setDatabaseDefaultValue(spec.getDefaultValue());
592+
pd.setName(spec.getName());
593+
pd.setJdbcType(spec.getJdbcType(), spec.getSize());
594+
pd.setNullable(spec.isNullable());
595+
pd.setMvEnabled(spec.isMvEnabled());
596+
pd.setPropertyURI(DomainUtil.createUniquePropertyURI(domain.getTypeURI(), null, new CaseInsensitiveHashSet()));
597+
pd.setDescription(spec.getDescription());
598+
pd.setImportAliases(spec.getImportAliases());
599+
pd.setScale(spec.getSize());
600+
DomainPropertyImpl dp = new DomainPropertyImpl((DomainImpl) domain, pd);
601+
602+
LOG.debug("Dropping classid column from table '{}' for data class '{}' in folder {}.", provisionedTable.getName(), dc.getName(), dc.getContainer().getPath());
603+
StorageProvisionerImpl.get().dropProperties(domain, Set.of(dp));
604+
}
605+
606+
// Add rowId column if not present
607+
ColumnInfo rowIdCol = provisionedTable.getColumn("rowId");
608+
if (rowIdCol != null)
609+
{
610+
LOG.info("DataClass '{}' ({}) already has 'rowId' column. Skipping.", dc.getName(), dc.getRowId());
611+
return;
612+
}
613+
614+
PropertyStorageSpec rowIdProp = new PropertyStorageSpec("rowId", JdbcType.INTEGER); // nullable for initial add
615+
storageProvisioner.addStorageProperties(domain, Collections.singletonList(rowIdProp), true);
616+
LOG.info("DataClass '{}' ({}) added 'rowId' column", dc.getName(), dc.getRowId());
617+
618+
// Populate rowId from exp.data using lsid join
619+
fillRowId(dc, domain, scope);
620+
621+
// Set NOT NULL constraint
622+
SqlExecutor executor = new SqlExecutor(scope);
623+
boolean isPostgreSQL = scope.getSqlDialect().isPostgreSQL();
624+
if (isPostgreSQL)
625+
executor.execute(new SQLFragment("ALTER TABLE expdataclass.").append(provisionedTable).append(" ALTER COLUMN rowId SET NOT NULL"));
626+
else
627+
executor.execute(new SQLFragment("ALTER TABLE expdataclass.").append(provisionedTable).append(" ALTER COLUMN rowId INT NOT NULL"));
628+
629+
// Add indexes back via StorageProvisioner
630+
storageProvisioner.ensureTableIndices(domain);
631+
LOG.info("DataClass '{}' ({}) added unique index on 'rowId'", dc.getName(), dc.getRowId());
632+
633+
// Add FK constraint (no StorageProvisioner API for FKs on existing tables)
634+
String fkName = "fk_rowid_" + domain.getStorageTableName() + "_data";
635+
executor.execute(new SQLFragment("ALTER TABLE expdataclass.").append(provisionedTable)
636+
.append(" ADD CONSTRAINT ").append(fkName)
637+
.append(" FOREIGN KEY (rowId) REFERENCES exp.Data(RowId)"));
638+
LOG.info("DataClass '{}' ({}) added FK constraint on 'rowId'", dc.getName(), dc.getRowId());
639+
}
640+
641+
private static void fillRowId(ExpDataClassImpl dc, Domain domain, DbScope scope)
642+
{
643+
String tableName = domain.getStorageTableName();
644+
SQLFragment update = new SQLFragment()
645+
.append("UPDATE expdataclass.").append(tableName).append("\n")
646+
.append("SET rowId = i.rowid\n")
647+
.append("FROM (\n")
648+
.append(" SELECT d.lsid, d.RowId\n")
649+
.append(" FROM exp.data d\n")
650+
.append(" WHERE d.cpasType = ?\n").add(domain.getTypeURI())
651+
.append(") AS i\n")
652+
.append("WHERE i.lsid = ").append(tableName).append(".lsid");
653+
654+
int count = new SqlExecutor(scope).execute(update);
655+
LOG.info("DataClass '{}' ({}) populated 'rowId' column, count={}", dc.getName(), dc.getRowId(), count);
656+
}
657+
658+
512659
}

0 commit comments

Comments
 (0)