diff --git a/api/src/org/labkey/api/data/MultiChoice.java b/api/src/org/labkey/api/data/MultiChoice.java index 7612d13fc1b..1e09ac40ac1 100644 --- a/api/src/org/labkey/api/data/MultiChoice.java +++ b/api/src/org/labkey/api/data/MultiChoice.java @@ -1,5 +1,6 @@ package org.labkey.api.data; +import org.apache.commons.beanutils.ConversionException; import org.apache.commons.beanutils.ConvertUtils; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; @@ -273,6 +274,17 @@ public static Array from(@NotNull String s) { if (isBlank(s)) return EMPTY; + if (s.startsWith("{") && s.endsWith("}")) + { + try + { + return parsePgArray(s); + } + catch (ConversionException ignore) + { + } + + } List split = PageFlowUtil.splitStringToValuesForImport(s); return from(split.toArray()); } @@ -302,6 +314,94 @@ public static Array from(@NotNull java.sql.Array sqlArray) } } + /** + * Parse a PostgreSQL array text representation, e.g. {@code {a,b,"c d"}}. + *

+ * PostgreSQL uses backslash escaping inside quoted elements ({@code \"} for a literal + * double-quote, {@code \\} for a literal backslash), unlike CSV which doubles quotes. + * Unquoted elements are trimmed of whitespace. + */ + public static Array parsePgArray(@NotNull String s) + { + if (isBlank(s)) + return EMPTY; + + s = s.trim(); + if (!s.startsWith("{") || !s.endsWith("}")) + throw new ConversionException("PostgreSQL array literal must be wrapped in {}: " + s); + + String inner = s.substring(1, s.length() - 1); + if (inner.isEmpty()) + return EMPTY; + + List values = new ArrayList<>(); + int len = inner.length(); + int i = 0; + + while (i < len) + { + // skip leading whitespace before element + while (i < len && Character.isWhitespace(inner.charAt(i))) + i++; + + if (i >= len) + break; + + if (inner.charAt(i) == '"') + { + // quoted element — backslash escaping + i++; // skip opening quote + StringBuilder sb = new StringBuilder(); + while (i < len) + { + char c = inner.charAt(i); + if (c == '\\' && i + 1 < len) + { + sb.append(inner.charAt(i + 1)); + i += 2; + } + else if (c == '"') + { + i++; // skip closing quote + break; + } + else + { + sb.append(c); + i++; + } + if (i >= len) + throw new ConversionException("Unterminated quoted string in PostgreSQL array literal"); + } + values.add(sb.toString()); + + // after closing quote, expect comma or end + while (i < len && Character.isWhitespace(inner.charAt(i))) + i++; + if (i < len) + { + if (inner.charAt(i) == ',') + i++; // consume delimiter + else + throw new ConversionException("Unexpected character after closing quote in PostgreSQL array literal at position " + i); + } + } + else + { + // unquoted element — read until comma + int start = i; + while (i < len && inner.charAt(i) != ',') + i++; + String token = inner.substring(start, i).trim(); + values.add(token); + if (i < len) + i++; // skip comma + } + } + + return from(values.toArray()); + } + // // implements List // @@ -587,6 +687,67 @@ public void testConvert() throws Exception assertEquals(0, _converter.convert(Array.class, null).size()); } + @Test + public void testParsePgArray() + { + // simple unquoted values + assertEquals(Array.from(new String[]{"a", "b", "c"}), Array.parsePgArray("{a,b,c}")); + + // quoted values with special chars: comma, escaped quote, escaped backslash + assertEquals(Array.from(new String[]{"a,b", "c\"d", "e\\f"}), Array.parsePgArray("{\"a,b\",\"c\\\"d\",\"e\\\\f\"}")); + + // empty array + assertEquals(Array.EMPTY, Array.parsePgArray("{}")); + + // blank/empty input + assertEquals(Array.EMPTY, Array.parsePgArray("")); + assertEquals(Array.EMPTY, Array.parsePgArray(" ")); + + // quoted empty string — filtered by Array constructor (trimToNull) + assertEquals(Array.from(new String[]{"a"}), Array.parsePgArray("{\"\",a}")); + + // whitespace in quoted elements — trimmed by Array constructor + assertEquals(Array.from(new String[]{"a", "b"}), Array.parsePgArray("{\" a \",b}")); + + // whitespace around braces + assertEquals(Array.from(new String[]{"x", "y"}), Array.parsePgArray(" {x,y} ")); + + // round-trip: values from testConvert + Array expected = Array.from(new String[]{"a,", "b\"", "c "}); + assertEquals(expected, Array.parsePgArray("{\"a,\",\"b\\\"\",\"c \"}")); + + + List specialCharArrays = Array.from(new String[]{ + "&^G'{\"И<2&)&]#~%:\uD83D\uDC7E*!안GaC;", + ",~-", + "<=0\\!41%d!By&]b", + "A)D'z:&", + "b$Dyf)D;C@", + "c_x-eИ", + "d[dF2cは=&G&1", + "e^\"#x" + }); + String expectedSpecialCharStr = "{\"&^G'{\\\"И<2&)&]#~%:\uD83D\uDC7E*!안GaC;\",\"\\,~-\",\"<=0\\\\!41%d!By&]b\",\"A)D'z:&\",\"b$Dyf)D;C@\",\"c_x-eИ\",\"d[dF2cは=&G&1\",\"e^\\\"#x\"}"; + assertEquals(specialCharArrays, Array.parsePgArray(expectedSpecialCharStr)); + + + // error: missing braces + try + { + Array.parsePgArray("a,b,c"); + fail("Expected ConversionException for missing braces"); + } + catch (ConversionException ignored) {} + + // error: unterminated quote + try + { + Array.parsePgArray("{\"abc}"); + fail("Expected ConversionException for unterminated quote"); + } + catch (ConversionException ignored) {} + } + @Test public void testCSV() throws Exception { diff --git a/api/src/org/labkey/api/data/MultiValuedRenderContext.java b/api/src/org/labkey/api/data/MultiValuedRenderContext.java index 2217b7f3af7..20746d971eb 100644 --- a/api/src/org/labkey/api/data/MultiValuedRenderContext.java +++ b/api/src/org/labkey/api/data/MultiValuedRenderContext.java @@ -114,12 +114,6 @@ public Object get(Object key) if (getFieldMap() != null) { ColumnInfo columnInfo = getFieldMap().get(key); - if (columnInfo != null && columnInfo.getPropertyType() == PropertyType.MULTI_CHOICE && value instanceof String strVal) - { - // Multi-choice values array is converted to string: "{value1,value2,...}", so strip off the braces before converting - if (strVal.startsWith("{") && strVal.endsWith("}")) - return columnInfo.convert(strVal.substring(1, strVal.length() - 1)); - } // The value was concatenated with others, so it's become a string. // Do conversion to switch it back to the expected type. if (value != null && columnInfo != null && !columnInfo.getJavaClass().isInstance(value)) diff --git a/api/src/org/labkey/api/util/PageFlowUtil.java b/api/src/org/labkey/api/util/PageFlowUtil.java index eb2ff42bb80..a39139f9d5f 100644 --- a/api/src/org/labkey/api/util/PageFlowUtil.java +++ b/api/src/org/labkey/api/util/PageFlowUtil.java @@ -3045,6 +3045,22 @@ public void testGoogleSheetMultiValue() ); for (List test : quickTests) assertEquals(test, splitStringToValuesForImport(joinValuesToStringForExport(test))); + + List specialCharArrays = Arrays.asList( + "&^G'{\"И<2&)&]#~%:\uD83D\uDC7E*!안GaC;", + ",~-", + "<=0\\!41%d!By&]b", + "A)D'z:&", + "b$Dyf)D;C@", + "c_x-eИ", + "d[dF2cは=&G&1", + "e^\"#x" + ); + + String specialCharStr = "\"&^G'{\"\"И<2&)&]#~%:\uD83D\uDC7E*!안GaC;\", \",~-\", <=0\\!41%d!By&]b, A)D'z:&, b$Dyf)D;C@, c_x-eИ, d[dF2cは=&G&1, \"e^\"\"#x\""; + + assertEquals(specialCharStr, joinValuesToStringForExport(specialCharArrays)); + assertEquals(specialCharArrays, splitStringToValuesForImport(specialCharStr)); } @Test diff --git a/core/package-lock.json b/core/package-lock.json index 24da56b166c..de79ab3c4a3 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,7 +8,7 @@ "name": "labkey-core", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.21.0", + "@labkey/components": "7.22.1", "@labkey/themes": "1.7.0" }, "devDependencies": { @@ -3760,9 +3760,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.21.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.21.0.tgz", - "integrity": "sha512-LK2ul66TvykGJk2/jWU3Gdq4w5maw8+qbElEol0eGAQAPQ9M0qCIzW75XVO8HMZ+VEXXXm6cKmbK1nF1m/pZRQ==", + "version": "7.22.1", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.22.1.tgz", + "integrity": "sha512-ksLTrQELG7Ct5/gNB3KcVkjg0Gu5KvnYoar6KKhw7vEu3oFGcPqz4lWTrRZWvoDEgH8Cgg2+POH+W/BE1dYoXw==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/core/package.json b/core/package.json index e3b8310db46..57c8ef59a80 100644 --- a/core/package.json +++ b/core/package.json @@ -53,7 +53,7 @@ } }, "dependencies": { - "@labkey/components": "7.21.0", + "@labkey/components": "7.22.1", "@labkey/themes": "1.7.0" }, "devDependencies": { diff --git a/experiment/package-lock.json b/experiment/package-lock.json index 08f4d14fd23..d917381f45b 100644 --- a/experiment/package-lock.json +++ b/experiment/package-lock.json @@ -8,7 +8,7 @@ "name": "experiment", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.21.0" + "@labkey/components": "7.22.1" }, "devDependencies": { "@labkey/build": "8.9.0", @@ -3611,9 +3611,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.21.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.21.0.tgz", - "integrity": "sha512-LK2ul66TvykGJk2/jWU3Gdq4w5maw8+qbElEol0eGAQAPQ9M0qCIzW75XVO8HMZ+VEXXXm6cKmbK1nF1m/pZRQ==", + "version": "7.22.1", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.22.1.tgz", + "integrity": "sha512-ksLTrQELG7Ct5/gNB3KcVkjg0Gu5KvnYoar6KKhw7vEu3oFGcPqz4lWTrRZWvoDEgH8Cgg2+POH+W/BE1dYoXw==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/experiment/package.json b/experiment/package.json index 2ec3b1510e5..dad240b9c34 100644 --- a/experiment/package.json +++ b/experiment/package.json @@ -13,7 +13,7 @@ "test-integration": "cross-env NODE_ENV=test jest --ci --runInBand -c test/js/jest.config.integration.js" }, "dependencies": { - "@labkey/components": "7.21.0" + "@labkey/components": "7.22.1" }, "devDependencies": { "@labkey/build": "8.9.0", diff --git a/study/src/org/labkey/study/model/DatasetDomainKind.java b/study/src/org/labkey/study/model/DatasetDomainKind.java index c55fb877f22..657d8a15a70 100644 --- a/study/src/org/labkey/study/model/DatasetDomainKind.java +++ b/study/src/org/labkey/study/model/DatasetDomainKind.java @@ -37,6 +37,7 @@ import org.labkey.api.di.DataIntegrationService; import org.labkey.api.exp.Lsid; import org.labkey.api.exp.PropertyDescriptor; +import org.labkey.api.exp.PropertyType; import org.labkey.api.exp.TemplateInfo; import org.labkey.api.exp.api.ExperimentService; import org.labkey.api.exp.api.StorageProvisioner; @@ -618,6 +619,12 @@ private void validateDatasetProperties(DatasetDomainKindProperties datasetProper if (!(rangeURI.endsWith("int") || rangeURI.endsWith("double") || rangeURI.endsWith("string"))) throw new IllegalArgumentException("If Additional Key Column is managed, the column type must be numeric or text-based."); } + else if (!isDemographicData && !useTimeKeyField && null != keyPropertyName) + { + String rangeURI = domain.getFieldByName(keyPropertyName).getRangeURI(); + if (PropertyType.MULTI_CHOICE.getTypeUri().equals(rangeURI)) + throw new IllegalArgumentException("Additional Key Column cannot be a multi-choice column."); + } // Other exception(s)