diff --git a/api/src/org/labkey/api/exp/query/ExpMaterialTable.java b/api/src/org/labkey/api/exp/query/ExpMaterialTable.java index 6c856381460..0956af9d020 100644 --- a/api/src/org/labkey/api/exp/query/ExpMaterialTable.java +++ b/api/src/org/labkey/api/exp/query/ExpMaterialTable.java @@ -52,6 +52,9 @@ enum Column Properties, Property, QueryableInputs, + RawAliquotUnit(false, "Raw Aliquot Unit"), + RawAliquotVolume(false, "Raw Aliquot Total Amount"), + RawAvailableAliquotVolume(false, "Raw Available Aliquot Amount"), RawAmount(true), RawUnits, RootMaterialRowId, diff --git a/api/src/org/labkey/api/ontology/KindOfQuantity.java b/api/src/org/labkey/api/ontology/KindOfQuantity.java index cd51aeb5e6b..e61bb42169a 100644 --- a/api/src/org/labkey/api/ontology/KindOfQuantity.java +++ b/api/src/org/labkey/api/ontology/KindOfQuantity.java @@ -29,7 +29,7 @@ public List getCommonUnits() @Override public List getCommonUnits() { - return List.of(Unit.kg, Unit.g, Unit.mg); + return List.of(Unit.kg, Unit.g, Unit.mg, Unit.ug, Unit.ng); } }, @@ -39,7 +39,7 @@ public List getCommonUnits() @Override public List getCommonUnits() { - return List.of(Unit.unit); + return List.of(Unit.blocks, Unit.bottles, Unit.boxes, Unit.cells, Unit.kits, Unit.packs, Unit.pieces, Unit.slides, Unit.tests, Unit.unit); } }; diff --git a/api/src/org/labkey/api/ontology/Quantity.java b/api/src/org/labkey/api/ontology/Quantity.java index ccbb26db1d3..54e5f33ae48 100644 --- a/api/src/org/labkey/api/ontology/Quantity.java +++ b/api/src/org/labkey/api/ontology/Quantity.java @@ -465,6 +465,10 @@ public void testParse() assertEquals(Quantity.of(0, Unit.count), parse("0 units")); assertEquals(Quantity.of(0, Unit.count), parse("0count")); + assertEquals(Quantity.of(1, Unit.count), parse("1", Unit.boxes)); + assertEquals(Quantity.of(1, Unit.unit), parse("1", Unit.blocks)); + assertEquals(Quantity.of(1, Unit.cells), parse("1", Unit.tests)); + assertEquals(parse("1000mg", Unit.g), parse("0.001kg", Unit.g)); assertEquals(parse(" 1000mg", Unit.g), parse("0.001kg", Unit.g)); assertEquals(parse("1000mg ", Unit.g), parse("0.001kg", Unit.g)); diff --git a/api/src/org/labkey/api/ontology/Unit.java b/api/src/org/labkey/api/ontology/Unit.java index 1cb963687a7..b2872362cb7 100644 --- a/api/src/org/labkey/api/ontology/Unit.java +++ b/api/src/org/labkey/api/ontology/Unit.java @@ -19,6 +19,33 @@ public enum Unit count(KindOfQuantity.Count, unit, 1.0, 2, "count", Quantity.class, "count", "count"), + pieces(KindOfQuantity.Count, unit, 1.0, 2, "pieces", + Quantity.class, + "piece", "pieces"), + packs(KindOfQuantity.Count, unit, 1.0, 2, "packs", + Quantity.class, + "pack", "packs"), + blocks(KindOfQuantity.Count, unit, 1.0, 2, "blocks", + Quantity.class, + "block", "blocks"), + slides(KindOfQuantity.Count, unit, 1.0, 2, "slides", + Quantity.class, + "slide", "slides"), + cells(KindOfQuantity.Count, unit, 1.0, 2, "cells", + Quantity.class, + "cell", "cells"), + boxes(KindOfQuantity.Count, unit, 1.0, 2, "boxes", + Quantity.class, + "box", "boxes"), + kits(KindOfQuantity.Count, unit, 1.0, 2, "kits", + Quantity.class, + "kit", "kits"), + tests(KindOfQuantity.Count, unit, 1.0, 2, "tests", + Quantity.class, + "test", "tests"), + bottles(KindOfQuantity.Count, unit, 1.0, 2, "bottles", + Quantity.class, + "bottle", "bottles"), mL(KindOfQuantity.Volume, null, 1e0, 6, "mL", Quantity.Volume_ml.class, @@ -51,27 +78,27 @@ public enum Unit "picoliter", "picoliters", "pl", "picolitre", "picolitres"), - g(KindOfQuantity.Mass, null, 1e0, 9, "g", + g(KindOfQuantity.Mass, null, 1e0, 12, "g", Quantity.Mass_g.class, "gram", "grams"), - Mg(KindOfQuantity.Mass, g, 1e6, 12, "Mg", + Mg(KindOfQuantity.Mass, g, 1e6, 15, "Mg", Quantity.Mass_Megag.class, "megagram", "megagrams", "tonne", "tonnes"), - kg(KindOfQuantity.Mass, g, 1e3, 12, "kg", + kg(KindOfQuantity.Mass, g, 1e3, 15, "kg", Quantity.Mass_kg.class, "kilogram", "kilograms"), - mg(KindOfQuantity.Mass, g, 1e-3, 6, "mg", + mg(KindOfQuantity.Mass, g, 1e-3, 9, "mg", Quantity.Mass_mg.class, "milligram", "milligrams"), - ug(KindOfQuantity.Mass, g, 1e-6, 3, "ug", + ug(KindOfQuantity.Mass, g, 1e-6, 6, "ug", Quantity.Mass_ug.class, "microgram", "micrograms", "μg"), ng(KindOfQuantity.Mass, g, 1e-9, 3, "ng", Quantity.Mass_ng.class, "nanogram", "nanograms"), - pg(KindOfQuantity.Mass, g, 1e-12, 3, "pg", + pg(KindOfQuantity.Mass, g, 1e-12, 0, "pg", Quantity.Mass_pg.class, "picogram", "picograms"); @@ -206,6 +233,7 @@ public void testIsBase() assertFalse(Unit.kg.isBase()); assertTrue(Unit.unit.isBase()); assertFalse(Unit.count.isBase()); + assertFalse(Unit.bottles.isBase()); } @Test @@ -217,7 +245,16 @@ public void testIsCompatible() assertTrue(Unit.g.isCompatible(Unit.mg)); assertFalse(Unit.g.isCompatible(Unit.mL)); assertTrue(Unit.unit.isCompatible(Unit.count)); + assertTrue(Unit.unit.isCompatible(Unit.pieces)); + assertTrue(Unit.unit.isCompatible(Unit.packs)); + assertTrue(Unit.unit.isCompatible(Unit.bottles)); + assertTrue(Unit.unit.isCompatible(Unit.blocks)); + assertTrue(Unit.unit.isCompatible(Unit.boxes)); + assertTrue(Unit.unit.isCompatible(Unit.slides)); + assertTrue(Unit.cells.isCompatible(Unit.slides)); + assertTrue(Unit.cells.isCompatible(Unit.unit)); assertFalse(Unit.unit.isCompatible(Unit.mL)); + assertFalse(Unit.bottles.isCompatible(Unit.mL)); assertFalse(Unit.mL.isCompatible(null)); } diff --git a/experiment/src/client/test/integration/SampleTypeCrud.ispec.ts b/experiment/src/client/test/integration/SampleTypeCrud.ispec.ts index a600c34d2b4..46c70b1059f 100644 --- a/experiment/src/client/test/integration/SampleTypeCrud.ispec.ts +++ b/experiment/src/client/test/integration/SampleTypeCrud.ispec.ts @@ -974,6 +974,12 @@ describe('Amount/Unit CRUD', () => { expect(errorMsg.text).toContain(NO_AMOUNT_ERROR); errorMsg = await ExperimentCRUDUtils.importSample(server, "Name\tStoredAmount\tUnits\nData1\t1.1\tL", dataType, "INSERT", topFolderOptions, editorUserOptions); expect(errorMsg.text).toContain(INCOMPATIBLE_ERROR); + errorMsg = await ExperimentCRUDUtils.importSample(server, "Name\tStoredAmount\tUnits\nData1\t1.1\tunit", dataType, "INSERT", topFolderOptions, editorUserOptions); + expect(errorMsg.text).toContain('Units value (unit) is not compatible with the ' + dataType + ' display units (g).'); + errorMsg = await ExperimentCRUDUtils.importSample(server, "Name\tStoredAmount\tUnits\nData1\t1.1\tcells", dataType, "INSERT", topFolderOptions, editorUserOptions); + expect(errorMsg.text).toContain('Units value (cells) is not compatible with the ' + dataType + ' display units (g).'); + errorMsg = await ExperimentCRUDUtils.importSample(server, "Name\tStoredAmount\tUnits\nData1\t1.1\tbogus", dataType, "INSERT", topFolderOptions, editorUserOptions); + expect(errorMsg.text).toContain('Unsupported Units value (bogus). Supported values are: kg, g, mg, ug, ng.'); errorMsg = await ExperimentCRUDUtils.importSample(server, "Name\tStoredAmount\tUnits\nData1\t-1.1\tkg", dataType, "INSERT", topFolderOptions, editorUserOptions); expect(errorMsg.text).toContain(NEGATIVE_ERROR); errorMsg = await ExperimentCRUDUtils.importCrossTypeData(server, "Name\tStoredAmount\tUnits\tSampleType\nData1\t-1.1\tkg\t" + dataType ,'IMPORT', topFolderOptions, adminOptions, true); @@ -1085,5 +1091,235 @@ describe('Amount/Unit CRUD', () => { }); + it ("Test units conversion on insert/update", async () => { + const sampleTypeMass = 'SampleTypeWithMassUnits'; + const sampleTypeVolume = 'SampleTypeWithVolumeUnits'; + const sampleTypeCount = 'SampleTypeWithCountUnits'; + + const sampleTypeUnits = { + [sampleTypeMass]: 'ug', + [sampleTypeVolume]: 'L', + [sampleTypeCount]: 'unit' + }; + + for (const [dataType, unit] of Object.entries(sampleTypeUnits)) { + const createPayload = { + kind: 'SampleSet', + domainDesign: { name: dataType, fields: [{ name: 'Name' }] }, + options: { + name: dataType, + metricUnit: unit + } + }; + await server.post('property', 'createDomain', createPayload, {...topFolderOptions, ...designerReaderOptions}).expect(successfulResponse); + } + + let sampleRowsWithUnits = await ExperimentCRUDUtils.insertRows(server, [ + {name: 'S-ng', amount: 4.56, units: 'ng'}, + {name: 'S-ug', amount: 4.56, units: 'ug'}, + {name: 'S-mg', amount: 4.56, units: 'mg'}, + {name: 'S-g', amount: 4.56, units: 'g'}, + {name: 'S-kg', amount: 4.56, units: 'kg'}, + ], 'samples', sampleTypeMass, topFolderOptions, editorUserOptions); + + // check for raw amount in g and display amount in ug + let expectedRawAmounts : {} = { + 'S-ng': 4.56e-9, + 'S-ug': 4.56e-6, + 'S-mg': 0.00456, + 'S-g': 4.56, + 'S-kg': 4560, + }; + let expectedStoredAmounts : {} = { + 'S-ng': 4.56e-3, + 'S-ug': 4.56, + 'S-mg': 4560, + 'S-g': 4.56e6, + 'S-kg': 4.56e9, + }; + + for (const sampleRow of sampleRowsWithUnits) { + const sampleName = caseInsensitive(sampleRow, 'name'); + let sampleData = await ExperimentCRUDUtils.getSampleDataByName(server, sampleName, sampleTypeMass, 'StoredAmount,Units,RawAmount,RawUnits', topFolderOptions, readerUserOptions); + expect(caseInsensitive(sampleData, 'RawAmount')).toBeCloseTo(expectedRawAmounts[sampleName]); + expect(caseInsensitive(sampleData, 'StoredAmount')).toBeCloseTo(expectedStoredAmounts[sampleName]); + expect(caseInsensitive(sampleData, 'RawUnits')).toEqual('g'); + expect(caseInsensitive(sampleData, 'Units')).toEqual('ug'); + await server.post('query', 'updateRows', { + schemaName: 'samples', + queryName: sampleTypeMass, + rows: [{ + amount: 6.54, + units: sampleName.substring(2), + rowId: caseInsensitive(sampleRow, 'rowId') + }] + }, { ...topFolderOptions, ...editorUserOptions }).expect(successfulResponse); + } + + expectedRawAmounts = { + 'S-ng': 6.54e-9, + 'S-ug': 6.54e-6, + 'S-mg': 0.00654, + 'S-g': 6.54, + 'S-kg': 6540, + }; + expectedStoredAmounts = { + 'S-ng': 6.54e-3, + 'S-ug': 6.54, + 'S-mg': 6540, + 'S-g': 6.54e6, + 'S-kg': 6.54e9, + }; + for (const sampleRow of sampleRowsWithUnits) { + const sampleName = caseInsensitive(sampleRow, 'name'); + let sampleData = await ExperimentCRUDUtils.getSampleDataByName(server, sampleName, sampleTypeMass, 'StoredAmount,Units,RawAmount,RawUnits', topFolderOptions, readerUserOptions); + expect(caseInsensitive(sampleData, 'RawAmount')).toBeCloseTo(expectedRawAmounts[sampleName]); + expect(caseInsensitive(sampleData, 'StoredAmount')).toBeCloseTo(expectedStoredAmounts[sampleName]); + expect(caseInsensitive(sampleData, 'RawUnits')).toEqual('g'); + expect(caseInsensitive(sampleData, 'Units')).toEqual('ug'); + } + + sampleRowsWithUnits = await ExperimentCRUDUtils.insertRows(server, [ + {name: 'S-L', amount: 4.56, units: 'L'}, + {name: 'S-mL', amount: 4.56, units: 'mL'}, + {name: 'S-uL', amount: 4.56, units: 'uL'}, + ], 'samples', sampleTypeVolume, topFolderOptions, editorUserOptions); + + // check for storedamount in mL + expectedRawAmounts = { + 'S-L': 4560, + 'S-mL': 4.56, + 'S-uL': 0.00456, + }; + // stored amount is in L + expectedStoredAmounts = { + 'S-L': 4.56, + 'S-mL': 0.00456, + 'S-uL': 4.56e-6, + } + for (const sampleRow of sampleRowsWithUnits) { + const sampleName = caseInsensitive(sampleRow, 'name'); + const sampleData = await ExperimentCRUDUtils.getSampleDataByName(server, sampleName, sampleTypeVolume, 'StoredAmount,Units,RawAmount,RawUnits', topFolderOptions, readerUserOptions); + expect(caseInsensitive(sampleData, 'RawAmount')).toBeCloseTo(expectedRawAmounts[sampleName]); + expect(caseInsensitive(sampleData, 'StoredAmount')).toBeCloseTo(expectedStoredAmounts[sampleName]); + expect(caseInsensitive(sampleData, 'RawUnits')).toEqual('mL'); + expect(caseInsensitive(sampleData, 'Units')).toEqual('L'); + } + + const countRows = [ + {name: 'S-unit', amount: 4.56, units: 'unit'}, + {name: 'S-pieces', amount: 4.56, units: 'pieces'}, + {name: 'S-kits', amount: 4.56, units: 'kits'}, + {name: 'S-cells', amount: 4.56, units: 'cells'} + ] + sampleRowsWithUnits = await ExperimentCRUDUtils.insertRows(server, countRows, 'samples', sampleTypeCount, topFolderOptions, editorUserOptions); + + for (const sampleRow of sampleRowsWithUnits) { + const sampleName = caseInsensitive(sampleRow, 'name'); + const usedUnit = sampleName.substring(2); + const sampleData = await ExperimentCRUDUtils.getSampleDataByName(server, sampleName, sampleTypeCount, 'StoredAmount,Units,RawAmount,RawUnits', topFolderOptions, readerUserOptions); + expect(caseInsensitive(sampleData, 'RawAmount')).toBeCloseTo(4.56); + expect(caseInsensitive(sampleData, 'StoredAmount')).toBeCloseTo(4.56); + expect(caseInsensitive(sampleData, 'RawUnits')).toEqual(usedUnit); + expect(caseInsensitive(sampleData, 'Units')).toEqual(usedUnit); + + await server.post('query', 'updateRows', { + schemaName: 'samples', + queryName: sampleTypeCount, + rows: [{ + amount: 6.54, + units: usedUnit, + rowId: caseInsensitive(sampleRow, 'rowId') + }] + }, { ...topFolderOptions, ...editorUserOptions }).expect(successfulResponse); + } + + for (const sampleRow of sampleRowsWithUnits) { + const sampleName = caseInsensitive(sampleRow, 'name'); + const usedUnit = sampleName.substring(2); + const sampleData = await ExperimentCRUDUtils.getSampleDataByName(server, sampleName, sampleTypeCount, 'StoredAmount,Units,RawAmount,RawUnits', topFolderOptions, readerUserOptions); + expect(caseInsensitive(sampleData, 'RawAmount')).toBeCloseTo(6.54); + expect(caseInsensitive(sampleData, 'StoredAmount')).toBeCloseTo(6.54); + expect(caseInsensitive(sampleData, 'RawUnits')).toEqual(usedUnit); + expect(caseInsensitive(sampleData, 'Units')).toEqual(usedUnit); + } + + }) + + async function verifyCountTypeAliquotRollup(sampleTypeName: string, hasSampleTypeDisplayUnit: boolean) { + const dataRows = [ + {name: 'S-no-amount'}, + {AliquotedFrom: 'S-no-amount', name: 'S-no-pcs1', amount: 2, units: 'pieces'}, + {AliquotedFrom: 'S-no-amount', name: 'S-no-pcs2', amount: 2, units: 'pieces'}, + {name: 'S-unit', amount: 1, units: 'unit'}, + {AliquotedFrom: 'S-unit', name: 'S-unit-unit1', amount: 2, units: 'unit'}, + {AliquotedFrom: 'S-unit', name: 'S-unit-unit2', amount: 2, units: 'unit'}, + {name: 'S-pieces', amount: 1, units: 'pieces'}, + {AliquotedFrom: 'S-pieces', name: 'S-pcs-pcs1', amount: 2, units: 'pieces'}, + {AliquotedFrom: 'S-pieces', name: 'S-pcs-pcs2', amount: 2, units: 'pieces'}, + {name: 'S-kits', amount: 1, units: 'kits'}, + {AliquotedFrom: 'S-kits', name: 'S-kit-pcs1', amount: 2, units: 'pieces'}, + {AliquotedFrom: 'S-kits', name: 'S-kit-pcs2', amount: 2, units: 'pieces'}, + {name: 'S-cells', amount: 1, units: 'cells'}, + {AliquotedFrom: 'S-cells', name: 'S-cells-pcs1', amount: 2, units: 'pieces'}, + {AliquotedFrom: 'S-cells', name: 'S-cells-cells2', amount: 2, units: 'cells'}, + ] + + const insertedResults = await ExperimentCRUDUtils.insertRows(server, dataRows, 'samples', sampleTypeName, topFolderOptions, editorUserOptions); + const insertedMap = {}; + for (const row of insertedResults) { + insertedMap[caseInsensitive(row, 'name')] = row; + } + + let expectedAliquotUnit = { + 'S-no-amount': 'pieces', + 'S-unit': 'unit', + 'S-pieces': 'pieces', + 'S-kits': 'pieces', + 'S-cells': hasSampleTypeDisplayUnit ? 'unit' : 'cells', + }; + + // for each expectedRollupAmounts + for (const [sampleName, expectedAliquotUnitValue] of Object.entries(expectedAliquotUnit)) { + let parentUnit = sampleName.substring(2); + if (parentUnit === 'no-amount') { + parentUnit = null; + } + const sampleData = await ExperimentCRUDUtils.getSampleDataByName(server, sampleName, sampleTypeName, 'Units,RawUnits,AliquotVolume,AliquotCount,AliquotUnit', topFolderOptions, readerUserOptions); + expect(caseInsensitive(sampleData, 'RawUnits')).toEqual(parentUnit); + expect(caseInsensitive(sampleData, 'Units')).toEqual(parentUnit); + expect(caseInsensitive(sampleData, 'AliquotVolume')).toEqual(4); + expect(caseInsensitive(sampleData, 'AliquotCount')).toEqual(2); + expect(caseInsensitive(sampleData, 'AliquotUnit')).toEqual(expectedAliquotUnitValue); + + } + } + + it ("Test aliquot rollup for count display unit", async () => { + let dataType = 'SampleTypeAliquotWithCountUnit'; + let createPayload : {} = { + kind: 'SampleSet', + domainDesign: { name: dataType, fields: [{ name: 'Name' }] }, + options: { + name: dataType, + metricUnit: 'unit' + } + }; + await server.post('property', 'createDomain', createPayload, {...topFolderOptions, ...designerReaderOptions}).expect(successfulResponse); + await verifyCountTypeAliquotRollup(dataType, true); + + dataType = 'SampleTypeAliquoNoDisplayUnit'; + createPayload = { + kind: 'SampleSet', + domainDesign: { name: dataType, fields: [{ name: 'Name' }] }, + options: { + name: dataType, + } + }; + await server.post('property', 'createDomain', createPayload, {...topFolderOptions, ...designerReaderOptions}).expect(successfulResponse); + await verifyCountTypeAliquotRollup(dataType, false); + + }) + }); diff --git a/experiment/src/org/labkey/experiment/ExperimentModule.java b/experiment/src/org/labkey/experiment/ExperimentModule.java index 8fa14b60315..0b4da589654 100644 --- a/experiment/src/org/labkey/experiment/ExperimentModule.java +++ b/experiment/src/org/labkey/experiment/ExperimentModule.java @@ -740,6 +740,9 @@ SELECT COUNT(DISTINCT DD.DomainURI) FROM results.put("sampleNegativeAmountCount", new SqlSelector(schema, "SELECT COUNT(*) FROM exp.material WHERE storedamount < 0").getObject(Long.class)); results.put("sampleUnitsDifferCount", new SqlSelector(schema, "SELECT COUNT(*) from exp.material m JOIN exp.materialSource s ON m.materialsourceid = s.rowid WHERE m.units != s.metricunit").getObject(Long.class)); results.put("sampleTypesWithoutUnitsCount", new SqlSelector(schema, "SELECT COUNT(*) from exp.materialSource WHERE category IS NULL AND metricunit IS NULL").getObject(Long.class)); + results.put("sampleTypesWithMassTypeUnit", new SqlSelector(schema, "SELECT COUNT(*) from exp.materialSource WHERE category IS NULL AND metricunit IN ('kg', 'g', 'mg', 'ug', 'ng')").getObject(Long.class)); + results.put("sampleTypesWithVolumeTypeUnit", new SqlSelector(schema, "SELECT COUNT(*) from exp.materialSource WHERE category IS NULL AND metricunit IN ('L', 'mL', 'uL')").getObject(Long.class)); + results.put("sampleTypesWithCountTypeUnit", new SqlSelector(schema, "SELECT COUNT(*) from exp.materialSource WHERE category IS NULL AND metricunit = ?", "unit").getObject(Long.class)); results.put("duplicateSampleMaterialNameCount", new SqlSelector(schema, "SELECT COUNT(*) as duplicateCount FROM " + "(SELECT name, cpastype FROM exp.material WHERE cpastype <> 'Material' GROUP BY name, cpastype HAVING COUNT(*) > 1) d").getObject(Long.class)); diff --git a/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java b/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java index 3843fc1b6c0..89f4954b056 100644 --- a/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java +++ b/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java @@ -149,9 +149,13 @@ import static org.labkey.api.exp.api.SampleTypeDomainKind.AVAILABLE_ALIQUOT_VOLUME_LABEL; import static org.labkey.api.exp.api.SampleTypeDomainKind.SAMPLETYPE_FILE_DIRECTORY; import static org.labkey.api.exp.query.ExpMaterialTable.Column.AliquotCount; +import static org.labkey.api.exp.query.ExpMaterialTable.Column.AliquotUnit; import static org.labkey.api.exp.query.ExpMaterialTable.Column.AliquotVolume; import static org.labkey.api.exp.query.ExpMaterialTable.Column.AvailableAliquotCount; import static org.labkey.api.exp.query.ExpMaterialTable.Column.AvailableAliquotVolume; +import static org.labkey.api.exp.query.ExpMaterialTable.Column.RawAliquotUnit; +import static org.labkey.api.exp.query.ExpMaterialTable.Column.RawAliquotVolume; +import static org.labkey.api.exp.query.ExpMaterialTable.Column.RawAvailableAliquotVolume; import static org.labkey.api.exp.query.ExpMaterialTable.Column.StoredAmount; import static org.labkey.api.exp.query.ExpMaterialTable.Column.Units; import static org.labkey.api.util.StringExpressionFactory.AbstractStringExpression.NullValueBehavior.NullResult; @@ -566,30 +570,93 @@ public StringExpression getURL(ColumnInfo parent) ret.setLabel(ALIQUOT_COUNT_LABEL); return ret; } - case AliquotVolume -> + case RawAliquotVolume -> { var ret = wrapColumn(alias, _rootTable.getColumn(AliquotVolume.name())); - ret.setLabel(ALIQUOT_VOLUME_LABEL); + ret.setLabel(RawAliquotVolume.label()); + ret.setShownInDetailsView(false); return ret; } - case AvailableAliquotVolume -> + case AliquotVolume -> + { + Unit typeUnit = getSampleTypeUnit(); + if (typeUnit != null) + { + SampleTypeAmountDisplayColumn columnInfo = new SampleTypeAmountDisplayColumn(this, Column.AliquotVolume.name(), AliquotUnit.name(), ALIQUOT_VOLUME_LABEL, Collections.emptySet(), typeUnit); + columnInfo.setDisplayColumnFactory(colInfo -> new SampleTypeAmountPrecisionDisplayColumn(colInfo, typeUnit)); + columnInfo.setDescription("The total amount of this sample's aliquots, in the display unit for the sample type, currently on hand."); + return columnInfo; + } + else + { + var ret = wrapColumn(alias, _rootTable.getColumn(AliquotVolume.name())); + ret.setLabel(ALIQUOT_VOLUME_LABEL); + return ret; + } + } + case RawAvailableAliquotVolume -> { var ret = wrapColumn(alias, _rootTable.getColumn(AvailableAliquotVolume.name())); - ret.setLabel(AVAILABLE_ALIQUOT_VOLUME_LABEL); + ret.setLabel(RawAvailableAliquotVolume.label()); + ret.setShownInDetailsView(false); return ret; } + case AvailableAliquotVolume -> + { + Unit typeUnit = getSampleTypeUnit(); + if (typeUnit != null) + { + SampleTypeAmountDisplayColumn columnInfo = new SampleTypeAmountDisplayColumn(this, Column.AvailableAliquotVolume.name(), AliquotUnit.name(), AVAILABLE_ALIQUOT_VOLUME_LABEL, Collections.emptySet(), typeUnit); + columnInfo.setDisplayColumnFactory(colInfo -> new SampleTypeAmountPrecisionDisplayColumn(colInfo, typeUnit)); + columnInfo.setDescription("The total amount of this sample's available aliquots currently on hand, in the display unit for the sample type."); + return columnInfo; + } + else + { + var ret = wrapColumn(alias, _rootTable.getColumn(AvailableAliquotVolume.name())); + ret.setLabel(AVAILABLE_ALIQUOT_VOLUME_LABEL); + return ret; + } + } case AvailableAliquotCount -> { var ret = wrapColumn(alias, _rootTable.getColumn(AvailableAliquotCount.name())); ret.setLabel(AVAILABLE_ALIQUOT_COUNT_LABEL); return ret; } - case AliquotUnit -> + case RawAliquotUnit -> { var ret = wrapColumn(alias, _rootTable.getColumn("AliquotUnit")); ret.setShownInDetailsView(false); + ret.setLabel(RawAliquotUnit.label()); return ret; } + case AliquotUnit -> + { + ForeignKey fk = new LookupForeignKey("Value", "Value") + { + @Override + public @Nullable TableInfo getLookupTableInfo() + { + return getExpSchema().getTable(ExpSchema.MEASUREMENT_UNITS_TABLE); + } + }; + + Unit typeUnit = getSampleTypeUnit(); + if (typeUnit != null) + { + SampleTypeUnitDisplayColumn columnInfo = new SampleTypeUnitDisplayColumn(this, AliquotUnit.name(), typeUnit); + columnInfo.setFk(fk); + columnInfo.setDescription("The sample type display units associated with the AliquotAmount for this sample."); + return columnInfo; + } + else + { + var ret = wrapColumn(alias, _rootTable.getColumn("AliquotUnit")); + ret.setShownInDetailsView(false); + return ret; + } + } case MaterialExpDate -> { var ret = wrapColumn(alias, _rootTable.getColumn("MaterialExpDate")); @@ -825,7 +892,7 @@ protected ContainerFilter getLookupContainerFilter() addColumn(Column.AliquotCount); addColumn(Column.AliquotVolume); - addColumn(Column.AliquotUnit); + addColumn(AliquotUnit); addColumn(Column.AvailableAliquotCount); addColumn(Column.AvailableAliquotVolume); @@ -881,6 +948,75 @@ public void addQueryFieldKeys(Set keys) rawUnitsColumn.setShownInInsertView(false); rawUnitsColumn.setShownInUpdateView(false); + var rawAliquotVolumeColumn = addColumn(RawAliquotVolume); + rawAliquotVolumeColumn.setDisplayColumnFactory(new DisplayColumnFactory() + { + @Override + public DisplayColumn createRenderer(ColumnInfo colInfo) + { + return new DataColumn(colInfo) + { + @Override + public void addQueryFieldKeys(Set keys) + { + super.addQueryFieldKeys(keys); + keys.add(AliquotVolume.fieldKey()); + + } + }; + } + }); + rawAliquotVolumeColumn.setHidden(true); + rawAliquotVolumeColumn.setShownInDetailsView(false); + rawAliquotVolumeColumn.setShownInInsertView(false); + rawAliquotVolumeColumn.setShownInUpdateView(false); + + var rawAvailableAliquotVolumeColumn = addColumn(RawAvailableAliquotVolume); + rawAvailableAliquotVolumeColumn.setDisplayColumnFactory(new DisplayColumnFactory() + { + @Override + public DisplayColumn createRenderer(ColumnInfo colInfo) + { + return new DataColumn(colInfo) + { + @Override + public void addQueryFieldKeys(Set keys) + { + super.addQueryFieldKeys(keys); + keys.add(AvailableAliquotVolume.fieldKey()); + + } + }; + } + }); + rawAvailableAliquotVolumeColumn.setHidden(true); + rawAvailableAliquotVolumeColumn.setShownInDetailsView(false); + rawAvailableAliquotVolumeColumn.setShownInInsertView(false); + rawAvailableAliquotVolumeColumn.setShownInUpdateView(false); + + var rawAliquotUnitColumn = addColumn(RawAliquotUnit); + rawAliquotUnitColumn.setDisplayColumnFactory(new DisplayColumnFactory() + { + @Override + public DisplayColumn createRenderer(ColumnInfo colInfo) + { + return new DataColumn(colInfo) + { + @Override + public void addQueryFieldKeys(Set keys) + { + super.addQueryFieldKeys(keys); + keys.add(AliquotUnit.fieldKey()); + + } + }; + } + }); + rawAliquotUnitColumn.setHidden(true); + rawAliquotUnitColumn.setShownInDetailsView(false); + rawAliquotUnitColumn.setShownInInsertView(false); + rawAliquotUnitColumn.setShownInUpdateView(false); + if (InventoryService.get() != null && (st == null || !st.isMedia())) defaultCols.addAll(InventoryService.get().addInventoryStatusColumns(st == null ? null : st.getMetricUnit(), this, getContainer(), _userSchema.getUser())); @@ -1132,6 +1268,8 @@ private void addSampleTypeColumns(ExpSampleType st, List visibleColumn selectedColumns.add(new FieldKey(null, Column.AliquotedFromLSID.name())); if (selectedColumns.contains(new FieldKey(null, Column.IsAliquot.name()))) selectedColumns.add(new FieldKey(null, Column.RootMaterialRowId.name())); + if (selectedColumns.contains(new FieldKey(null, AliquotVolume.name())) || selectedColumns.contains(new FieldKey(null, AvailableAliquotVolume.name()))) + selectedColumns.add(new FieldKey(null, AliquotUnit.name())); selectedColumns.addAll(wrappedFieldKeys); if (null != getFilter()) selectedColumns.addAll(getFilter().getAllFieldKeys()); @@ -1616,7 +1754,7 @@ private static class SampleTypeUnitDisplayColumn extends ExprColumn { public SampleTypeUnitDisplayColumn(TableInfo parent, String unitFieldName, Unit typeUnit) { - super(parent, FieldKey.fromParts(Column.Units.name()), new SQLFragment( + super(parent, FieldKey.fromParts(unitFieldName), new SQLFragment( "(CASE WHEN ").append(ExprColumn.STR_TABLE_ALIAS + ".").append(unitFieldName) .append(" = ? THEN ? ELSE ").append(ExprColumn.STR_TABLE_ALIAS + ".").append(unitFieldName) .append(" END)") diff --git a/experiment/src/org/labkey/experiment/api/SampleTypeServiceImpl.java b/experiment/src/org/labkey/experiment/api/SampleTypeServiceImpl.java index 013bf5d01ac..17227bfb785 100644 --- a/experiment/src/org/labkey/experiment/api/SampleTypeServiceImpl.java +++ b/experiment/src/org/labkey/experiment/api/SampleTypeServiceImpl.java @@ -41,6 +41,7 @@ import org.labkey.api.collections.LongHashMap; import org.labkey.api.collections.LongHashSet; import org.labkey.api.data.AuditConfigurable; +import org.labkey.api.data.CompareType; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerFilter; import org.labkey.api.data.ContainerManager; @@ -253,7 +254,9 @@ else if (u.getKindOfQuantity() != defaultUnits.getKindOfQuantity()) List commonUnits = getSupportedUnits(); if (mUnit == null || !commonUnits.contains(mUnit)) { - throw new ConversionExceptionWithMessage("Unsupported Units value (" + rawUnitsString + "). Supported values are: " + StringUtils.join(commonUnits, ", ") + "."); + if (defaultUnits != null) + commonUnits = commonUnits.stream().filter(u -> u.getKindOfQuantity() == defaultUnits.getKindOfQuantity()).collect(Collectors.toList()); + throw new ConversionExceptionWithMessage("Unsupported Units value (" + rawUnitsString + "). Supported values are: " + StringUtils.join(commonUnits, ", ") + "."); } if (defaultUnits != null && mUnit.getKindOfQuantity() != defaultUnits.getKindOfQuantity()) throw new ConversionExceptionWithMessage(String.format(CONVERSION_EXCEPTION_MESSAGE, rawUnits, sampleTypeName == null ? "" : sampleTypeName, defaultUnits)); @@ -1058,6 +1061,42 @@ public void validateSampleTypeName(Container container, User user, String name, throw new ApiUsageException(reservedError); } + private boolean hasIncompatibleUnits(ExpSampleTypeImpl st, String newUnitStr) + { + if (StringUtils.isEmpty(newUnitStr) || newUnitStr.equalsIgnoreCase(st.getMetricUnit())) + return false; + + boolean hasToValidateUnit = true; + Unit newUnit = Unit.fromName(newUnitStr); + if (!StringUtils.isEmpty(st.getMetricUnit())) + { + Unit oldUnit = Unit.fromName(st.getMetricUnit()); + if (oldUnit != null && newUnit != null) + hasToValidateUnit = !oldUnit.getBase().equals(newUnit.getBase()); + } + + if (hasToValidateUnit) + { + SimpleFilter filter = new SimpleFilter(); + filter.addCondition(FieldKey.fromParts("CpasType"), st.getLSID()); + filter.addCondition(FieldKey.fromParts("StoredAmount"), null, CompareType.NONBLANK); + if (newUnit != null && newUnit.getBase() == Unit.unit.getBase()) + { + List compatibleUnits = KindOfQuantity.Count.getCommonUnits().stream().map(Unit::name).collect(Collectors.toList()); + filter.addCondition(FieldKey.fromParts("Units"), compatibleUnits, CompareType.NOT_IN); + } + else if (newUnit != null) + filter.addCondition(FieldKey.fromParts("Units"), newUnit.getBase().name(), CompareType.NEQ); + else + filter.addCondition(FieldKey.fromParts("Units"), newUnitStr, CompareType.NEQ); + + TableSelector ts = new TableSelector(getTinfoMaterial(), filter, null); + return ts.exists(); + } + + return false; + } + @Override public ValidationException updateSampleType(GWTDomain original, GWTDomain update, SampleTypeDomainKindProperties options, Container container, User user, boolean includeWarnings, @Nullable String auditUserComment) { @@ -1115,6 +1154,10 @@ public ValidationException updateSampleType(GWTDomain parents, Collection withAmountsParents, String sampleTypeUnit, Container container) throws IllegalStateException, SQLException { - return recomputeSamplesRollup(parents, null, withAmountsParents, sampleTypeUnit, container, false); + return recomputeSamplesRollup(parents, null, withAmountsParents, sampleTypeUnit, container); } /** This method updates exp.material, caller should call {@link SampleTypeServiceImpl#refreshSampleTypeMaterializedView} as appropriate. */ @@ -1424,8 +1467,7 @@ public int recomputeSamplesRollup( @Nullable Collection availableParents, Collection withAmountsParents, String sampleTypeUnit, - Container container, - boolean useRootMaterialLSID + Container container ) throws IllegalStateException, SQLException { Map sampleUnits = new LongHashMap<>(); @@ -1445,7 +1487,7 @@ public int recomputeSamplesRollup( if (!parents.isEmpty()) { - Map> sampleAliquotCounts = getSampleAliquotCounts(parents, useRootMaterialLSID); + Map> sampleAliquotCounts = getSampleAliquotCounts(parents); try (Connection c = scope.getConnection()) { Parameter rowid = new Parameter("rowid", JdbcType.INTEGER); @@ -1480,7 +1522,7 @@ public int recomputeSamplesRollup( if (!parents.isEmpty() || (availableParents != null && !availableParents.isEmpty())) { - Map> sampleAliquotCounts = getSampleAvailableAliquotCounts(availableParents == null ? parents : availableParents, availableSampleStates, useRootMaterialLSID); + Map> sampleAliquotCounts = getSampleAvailableAliquotCounts(availableParents == null ? parents : availableParents, availableSampleStates); try (Connection c = scope.getConnection()) { Parameter rowid = new Parameter("rowid", JdbcType.INTEGER); @@ -1515,43 +1557,127 @@ public int recomputeSamplesRollup( if (!withAmountsParents.isEmpty()) { - Map> samplesAliquotAmounts = getSampleAliquotAmounts(withAmountsParents, availableSampleStates, useRootMaterialLSID); - - try (Connection c = scope.getConnection()) + if (!StringUtils.isEmpty(sampleTypeUnit)) { - Parameter rowid = new Parameter("rowid", JdbcType.INTEGER); - Parameter amount = new Parameter("amount", JdbcType.DOUBLE); - Parameter unit = new Parameter("unit", JdbcType.VARCHAR); - Parameter availableAmount = new Parameter("availableAmount", JdbcType.DOUBLE); + Unit sampleTypeDisplayUnit = Unit.valueOf(sampleTypeUnit); + // if sample type has unit, use it for simple rollup without need for conversion + Unit sampleTypeBaseUnit = sampleTypeDisplayUnit.getBase(); + String baseUnit = sampleTypeBaseUnit.name(); - ParameterMapStatement pm = new ParameterMapStatement(scope, c, - new SQLFragment("UPDATE ").append(materialTable).append(" SET AliquotVolume = ?, AliquotUnit = ? , AvailableAliquotVolume = ? WHERE RowId = ? ").addAll(amount, unit, availableAmount, rowid), null); - - List>> sampleAliquotAmountsList = new ArrayList<>(samplesAliquotAmounts.entrySet()); + TableInfo tableInfo = ExperimentService.get().getTinfoMaterial(); - ListUtils.partition(sampleAliquotAmountsList, 1000).forEach(sublist -> + ListUtils.partition(new ArrayList<>(withAmountsParents), 1000).forEach(sublist -> { - for (Map.Entry> sampleAliquotAmounts: sublist) + if (sublist.isEmpty()) + return; + + int precisionScale = sampleTypeBaseUnit.getPrecisionScale(); + if (precisionScale > 9 && sampleTypeDisplayUnit.getValue() > 1e-9) { - Long sampleId = sampleAliquotAmounts.getKey(); - List aliquotAmounts = sampleAliquotAmounts.getValue(); + // reserve higher precisionScale for when display units are very small, like ng or pg + precisionScale = 9; + } - if (aliquotAmounts == null || aliquotAmounts.isEmpty()) - continue; - AliquotAvailableAmountUnit amountUnit = computeAliquotTotalAmounts(aliquotAmounts, sampleTypeUnit, sampleUnits.get(sampleId)); - rowid.setValue(sampleId); - amount.setValue(amountUnit.amount); - unit.setValue(amountUnit.unit); - availableAmount.setValue(amountUnit.availableAmount); + boolean isCountUnitType = sampleTypeBaseUnit.getKindOfQuantity() == KindOfQuantity.Count; + String aliquotUnitSql = isCountUnitType ? "CASE WHEN MIN(im.units) = MAX(im.units) THEN MIN(im.units) ELSE ? END" : "?"; - pm.addBatch(); + SQLFragment statsSql = new SQLFragment("SELECT im.rootmaterialrowid, SUM(im.storedamount) AS total_volume, \n") + .append("SUM(CASE WHEN im.samplestate ").appendInClause(availableSampleStates, tableInfo.getSqlDialect()).append(" THEN im.storedamount ELSE 0 END) AS avail_volume, \n") + .append(aliquotUnitSql) + .append(" AS common_unit \n").add(baseUnit) + .append("FROM exp.material im\n") + .append("WHERE im.rootmaterialrowid ") + .appendInClause(sublist, tableInfo.getSqlDialect()) + .append(" AND im.rowid != im.rootmaterialrowid\n") + .append(" GROUP BY im.rootmaterialrowid\n"); + + SQLFragment quickRollUpSql = null; + + if (tableInfo.getSchema().getSqlDialect().isSqlServer()) + { + /* + * SqlServer needs to specify the alias in the FROM clause, and use that alias as the target of the update. + */ + quickRollUpSql = new SQLFragment("UPDATE exp.material SET \n") + .append("aliquotvolume = ROUND(CAST(COALESCE(stats.total_volume, 0) AS NUMERIC(38,12)) , ?),\n").add(precisionScale) + .append("aliquotunit = stats.common_unit,\n") + .append("availablealiquotvolume = ROUND(CAST(COALESCE(stats.avail_volume, 0) AS NUMERIC(38,12)), ?)\n").add(precisionScale) + .append("FROM exp.material m INNER JOIN (") + .append(statsSql) + .append(") AS stats\n") + .append("ON m.rowid = stats.rootmaterialrowid" + ); } - pm.executeBatch(); + else + { + /* + * Alias usage: PostgreSQL allows you to use an alias in the UPDATE clause itself + * Type casting: PostgreSQL uses ::NUMERIC for type casting. + * JOIN condition: The WHERE clause is used for joining the tables instead of an INNER JOIN with ON. + */ + quickRollUpSql = new SQLFragment("UPDATE exp.material AS m SET \n") + .append("aliquotvolume = ROUND(COALESCE(stats.total_volume, 0)::NUMERIC, ?),\n").add(precisionScale) + .append("aliquotunit = stats.common_unit,\n") + .append("availablealiquotvolume = ROUND(COALESCE(stats.avail_volume, 0)::NUMERIC, ?)\n").add(precisionScale) + .append("FROM (") + .append(statsSql) + .append(") AS stats\n") + .append("WHERE m.rowid = stats.rootmaterialrowid" + ); + } + + new SqlExecutor(tableInfo.getSchema()).execute(quickRollUpSql); + + // Now clear out rollups for samples that have zero aliquots + SQLFragment quickClearRollupSql = new SQLFragment("UPDATE exp.material SET \n") + .append("aliquotvolume = 0, availablealiquotvolume = 0, ") + .append("aliquotunit = ?\n").add(baseUnit) + .append("WHERE rowid = rootmaterialrowid AND AliquotCount = 0 AND rowid ") + .appendInClause(sublist, tableInfo.getSqlDialect()); + new SqlExecutor(tableInfo.getSchema()).execute(quickClearRollupSql); + }); } - catch (SQLException x) + else { - throw new RuntimeSQLException(x); + Map> samplesAliquotAmounts = getSampleAliquotAmounts(withAmountsParents, availableSampleStates); + + try (Connection c = scope.getConnection()) + { + Parameter rowid = new Parameter("rowid", JdbcType.INTEGER); + Parameter amount = new Parameter("amount", JdbcType.DOUBLE); + Parameter unit = new Parameter("unit", JdbcType.VARCHAR); + Parameter availableAmount = new Parameter("availableAmount", JdbcType.DOUBLE); + + ParameterMapStatement pm = new ParameterMapStatement(scope, c, + new SQLFragment("UPDATE ").append(materialTable).append(" SET AliquotVolume = ?, AliquotUnit = ? , AvailableAliquotVolume = ? WHERE RowId = ? ").addAll(amount, unit, availableAmount, rowid), null); + + List>> sampleAliquotAmountsList = new ArrayList<>(samplesAliquotAmounts.entrySet()); + + ListUtils.partition(sampleAliquotAmountsList, 1000).forEach(sublist -> + { + for (Map.Entry> sampleAliquotAmounts: sublist) + { + Long sampleId = sampleAliquotAmounts.getKey(); + List aliquotAmounts = sampleAliquotAmounts.getValue(); + + if (aliquotAmounts == null || aliquotAmounts.isEmpty()) + continue; + AliquotAvailableAmountUnit amountUnit = computeAliquotTotalAmounts(aliquotAmounts, sampleTypeUnit, sampleUnits.get(sampleId)); + rowid.setValue(sampleId); + amount.setValue(amountUnit.amount); + unit.setValue(amountUnit.unit); + availableAmount.setValue(amountUnit.availableAmount); + + pm.addBatch(); + } + pm.executeBatch(); + }); + } + catch (SQLException x) + { + throw new RuntimeSQLException(x); + } } } @@ -1592,10 +1718,15 @@ private AliquotAvailableAmountUnit computeAliquotTotalAmounts(List uniqueAliquotUnits = volumeUnits.stream().map(AliquotAmountUnitResult::unit).collect(Collectors.toSet()); + boolean hasSameAliquotUnit = uniqueAliquotUnits.size() <= 1; + Unit totalUnit = null; String totalUnitsStr; if (!StringUtils.isEmpty(sampleTypeUnitsStr)) totalUnitsStr = sampleTypeUnitsStr; + else if (hasSameAliquotUnit && !StringUtils.isEmpty(volumeUnits.get(0).unit)) // if all aliquots have the same unit, prefer it over parent's unit + totalUnitsStr = volumeUnits.get(0).unit; else if (!StringUtils.isEmpty(sampleItemUnitsStr)) totalUnitsStr = sampleItemUnitsStr; else // use the unit of the first aliquot if there are no other indications @@ -1633,6 +1764,7 @@ else if (!StringUtils.isEmpty(sampleItemUnitsStr)) } catch (IllegalArgumentException ignore) { + // if aliquot units are incompatible, skip } double convertedAmount = 0; @@ -1723,16 +1855,13 @@ private Collection getAliquotParents(String sampleTypeLsid, boolean withAm return parentIds; } - private Map> getSampleAliquotCounts(Collection sampleIds, boolean useRootMaterialLSID) throws SQLException + private Map> getSampleAliquotCounts(Collection sampleIds) throws SQLException { DbSchema dbSchema = getExpSchema(); SqlDialect dialect = dbSchema.getSqlDialect(); - // Issue 49150: In 23.12 we migrated from RootMaterialLSID to RootMaterialRowID, however, there is still an - // upgrade path that requires these queries be done with RootMaterialLSID since the 23.12 upgrade will not - // have run yet. SQLFragment sql = new SQLFragment("SELECT m.RowId as SampleId, m.Units, (SELECT COUNT(*) FROM exp.material a WHERE ") - .append(useRootMaterialLSID ? "a.rootMaterialLsid = m.lsid" : "a.rootMaterialRowId = m.rowId") + .append("a.rootMaterialRowId = m.rowId") .append(")-1 AS CreatedAliquotCount FROM exp.material AS m WHERE m.rowid "); dialect.appendInClauseSql(sql, sampleIds); @@ -1752,49 +1881,25 @@ private Map> getSampleAliquotCounts(Collection return sampleAliquotCounts; } - private Map> getSampleAvailableAliquotCounts(Collection sampleIds, Collection availableSampleStates, boolean useRootMaterialLSID) throws SQLException + private Map> getSampleAvailableAliquotCounts(Collection sampleIds, Collection availableSampleStates) throws SQLException { DbSchema dbSchema = getExpSchema(); SqlDialect dialect = dbSchema.getSqlDialect(); - // Issue 49150: In 23.12 we migrated from RootMaterialLSID to RootMaterialRowID, however, there is still an - // upgrade path that requires these queries be done with RootMaterialLSID since the 23.12 upgrade will not - // have run yet. - SQLFragment sql; - if (useRootMaterialLSID) - { - sql = new SQLFragment( - """ - SELECT m.RowId as SampleId, m.Units, - (CASE WHEN c.aliquotCount IS NULL THEN 0 ELSE c.aliquotCount END) as CreatedAliquotCount - FROM exp.material AS m - LEFT JOIN ( - SELECT RootMaterialLSID as rootLsid, COUNT(*) as aliquotCount - FROM exp.material - WHERE RootMaterialLSID <> LSID AND SampleState\s""") - .appendInClause(availableSampleStates, dialect) - .append(""" - GROUP BY RootMaterialLSID - ) AS c ON m.lsid = c.rootLsid - WHERE m.rootmateriallsid = m.LSID AND m.rowid\s"""); - } - else - { - sql = new SQLFragment( - """ - SELECT m.RowId as SampleId, m.Units, - (CASE WHEN c.aliquotCount IS NULL THEN 0 ELSE c.aliquotCount END) as CreatedAliquotCount - FROM exp.material AS m - LEFT JOIN ( - SELECT RootMaterialRowId as rootRowId, COUNT(*) as aliquotCount - FROM exp.material - WHERE RootMaterialRowId <> RowId AND SampleState\s""") + SQLFragment sql = new SQLFragment( + """ + SELECT m.RowId as SampleId, m.Units, + (CASE WHEN c.aliquotCount IS NULL THEN 0 ELSE c.aliquotCount END) as CreatedAliquotCount + FROM exp.material AS m + LEFT JOIN ( + SELECT RootMaterialRowId as rootRowId, COUNT(*) as aliquotCount + FROM exp.material + WHERE RootMaterialRowId <> RowId AND SampleState\s""") .appendInClause(availableSampleStates, dialect) .append(""" GROUP BY RootMaterialRowId ) AS c ON m.rowId = c.rootRowId WHERE m.rootmaterialrowid = m.rowid AND m.rowid\s"""); - } dialect.appendInClauseSql(sql, sampleIds); Map> sampleAliquotCounts = new TreeMap<>(); // Order by rowId to reduce deadlock with search indexer @@ -1813,19 +1918,16 @@ SELECT RootMaterialRowId as rootRowId, COUNT(*) as aliquotCount return sampleAliquotCounts; } - private Map> getSampleAliquotAmounts(Collection sampleIds, List availableSampleStates, boolean useRootMaterialLSID) throws SQLException + private Map> getSampleAliquotAmounts(Collection sampleIds, List availableSampleStates) throws SQLException { DbSchema exp = getExpSchema(); SqlDialect dialect = exp.getSqlDialect(); - // Issue 49150: In 23.12 we migrated from RootMaterialLSID to RootMaterialRowID, however, there is still an - // upgrade path that requires these queries be done with RootMaterialLSID since the 23.12 upgrade will not - // have run yet. SQLFragment sql = new SQLFragment("SELECT parent.rowid AS parentSampleId, aliquot.StoredAmount, aliquot.Units, aliquot.samplestate\n") .append("FROM exp.material AS aliquot JOIN exp.material AS parent ON ") - .append(useRootMaterialLSID ? "parent.lsid = aliquot.rootmateriallsid" : "parent.rowid = aliquot.rootmaterialrowid") + .append("parent.rowid = aliquot.rootmaterialrowid") .append(" WHERE ") - .append(useRootMaterialLSID ? "aliquot.rootmateriallsid <> aliquot.lsid" : "aliquot.rootmaterialrowid <> aliquot.rowid") + .append("aliquot.rootmaterialrowid <> aliquot.rowid") .append(" AND parent.rowid "); dialect.appendInClauseSql(sql, sampleIds); @@ -2324,6 +2426,7 @@ public void testGetValidatedUnit() service.getValidatedUnit("g", Unit.mg, "Sample Type"); service.getValidatedUnit("g ", Unit.mg, "Sample Type"); service.getValidatedUnit(" g ", Unit.mg, "Sample Type"); + service.getValidatedUnit("box", Unit.unit, "Sample Type"); } catch (ConversionExceptionWithMessage e) { diff --git a/experiment/src/org/labkey/experiment/api/SampleTypeUpdateServiceDI.java b/experiment/src/org/labkey/experiment/api/SampleTypeUpdateServiceDI.java index a9691bc3f55..d9e5a25995c 100644 --- a/experiment/src/org/labkey/experiment/api/SampleTypeUpdateServiceDI.java +++ b/experiment/src/org/labkey/experiment/api/SampleTypeUpdateServiceDI.java @@ -87,6 +87,7 @@ import org.labkey.api.exp.query.SamplesSchema; import org.labkey.api.gwt.client.AuditBehaviorType; import org.labkey.api.inventory.InventoryService; +import org.labkey.api.ontology.KindOfQuantity; import org.labkey.api.ontology.Quantity; import org.labkey.api.ontology.Unit; import org.labkey.api.qc.DataState; @@ -2074,6 +2075,11 @@ public static Object getValue(Object o, Object amountObj, boolean haveAmountCol, Unit validatedUnit = SampleTypeService.get().getValidatedUnit(o, baseUnit, sampleTypeName); + if (validatedUnit != null && baseUnit != null && KindOfQuantity.Count == validatedUnit.getKindOfQuantity() && validatedUnit.getValue() == baseUnit.getValue()) + { + // if both units are 'count' units and have the same value, prefer returning provided unit name + return validatedUnit.name(); + } // if there's a base unit, return the base unit name otherwise return the name of the given unit return validatedUnit == null ? null : baseUnit != null ? baseUnit.name() : validatedUnit.name(); }