Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions api/src/org/labkey/api/data/MultiChoice.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<String> split = PageFlowUtil.splitStringToValuesForImport(s);
return from(split.toArray());
}
Expand Down Expand Up @@ -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"}}.
* <p>
* 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<Object> 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
//
Expand Down Expand Up @@ -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<String> 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
{
Expand Down
6 changes: 0 additions & 6 deletions api/src/org/labkey/api/data/MultiValuedRenderContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 16 additions & 0 deletions api/src/org/labkey/api/util/PageFlowUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -3045,6 +3045,22 @@ public void testGoogleSheetMultiValue()
);
for (List<String> test : quickTests)
assertEquals(test, splitStringToValuesForImport(joinValuesToStringForExport(test)));

List<String> 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
Expand Down
8 changes: 4 additions & 4 deletions core/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
}
},
"dependencies": {
"@labkey/components": "7.21.0",
"@labkey/components": "7.22.0-fb-mvtcMVFK.1",
"@labkey/themes": "1.7.0"
},
"devDependencies": {
Expand Down
8 changes: 4 additions & 4 deletions experiment/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion experiment/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.0-fb-mvtcMVFK.1"
},
"devDependencies": {
"@labkey/build": "8.9.0",
Expand Down
7 changes: 7 additions & 0 deletions study/src/org/labkey/study/model/DatasetDomainKind.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)

Expand Down