diff --git a/api/src/org/labkey/api/data/ColumnInfo.java b/api/src/org/labkey/api/data/ColumnInfo.java index c2eb03e754a..77da3bd84a3 100644 --- a/api/src/org/labkey/api/data/ColumnInfo.java +++ b/api/src/org/labkey/api/data/ColumnInfo.java @@ -31,7 +31,6 @@ import org.labkey.api.util.StringExpression; import org.labkey.data.xml.ColumnType; -import java.beans.Introspector; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; @@ -56,6 +55,10 @@ else if (colInfo.getPropertyType() == PropertyType.ATTACHMENT) { return new AttachmentDisplayColumn(colInfo); } + if (JdbcType.ARRAY == colInfo.getJdbcType() && PropertyType.MULTI_CHOICE == colInfo.getPropertyType()) + { + return new MultiChoice.DisplayColumn(colInfo); + } DataColumn dataColumn = new DataColumn(colInfo); if (colInfo.getPropertyType() == PropertyType.MULTI_LINE) diff --git a/api/src/org/labkey/api/data/ColumnRenderProperties.java b/api/src/org/labkey/api/data/ColumnRenderProperties.java index 57b2a8ec140..a282ddfa5bf 100644 --- a/api/src/org/labkey/api/data/ColumnRenderProperties.java +++ b/api/src/org/labkey/api/data/ColumnRenderProperties.java @@ -35,6 +35,7 @@ import java.math.BigDecimal; import java.text.DecimalFormatSymbols; import java.util.Date; +import java.util.List; import java.util.Set; import java.util.function.Function; @@ -168,8 +169,9 @@ else if (java.sql.Date.class.isAssignableFrom(javaClass)) return "Date"; else if (Date.class.isAssignableFrom(javaClass)) return "Date and Time"; - else - return "Other"; + else if (List.class.isAssignableFrom(javaClass) || javaClass.isArray()) + return "Array"; + return "Other"; } /** Don't return TYPEs just real java objects */ @@ -381,18 +383,20 @@ static Function getDefaultConvertFn(ColumnRenderProperties col) final var defaultUnit = col.getDisplayUnit(); final @NotNull var jdbcType = col.getJdbcType(); - if (null == defaultUnit) + if (null != defaultUnit) + return defaultUnit::convert; + + if (PropertyType.MULTI_CHOICE == col.getPropertyType()) + return MultiChoice.Converter.getInstance(); + + return (value) -> { - return (value) -> - { - // quick check for unnecessary conversion - if (value == null || javaClass == value.getClass()) - return value; - if (value instanceof CharSequence) - ConvertUtils.convert(value.toString(), javaClass); - return jdbcType.convert(value); - }; - } - return defaultUnit::convert; + // quick check for unnecessary conversion + if (value == null || javaClass == value.getClass()) + return value; + if (value instanceof CharSequence) + return ConvertUtils.convert(value.toString(), javaClass); + return jdbcType.convert(value); + }; } } diff --git a/api/src/org/labkey/api/data/ColumnRenderPropertiesImpl.java b/api/src/org/labkey/api/data/ColumnRenderPropertiesImpl.java index f6c72e9d37a..aa30afeb5d1 100644 --- a/api/src/org/labkey/api/data/ColumnRenderPropertiesImpl.java +++ b/api/src/org/labkey/api/data/ColumnRenderPropertiesImpl.java @@ -791,19 +791,22 @@ public Class getJavaClass(boolean isNullable) public static Class defaultJavaClass(ColumnRenderProperties col, boolean isNullable) { - Class ret; - boolean isNumeric; PropertyType pt = col.getPropertyType(); + JdbcType jdbcType = col.getJdbcType(); + boolean isNumeric; + Class ret; + if (pt != null) { - ret = pt.getJavaType(); + if (JdbcType.ARRAY == jdbcType && PropertyType.MULTI_CHOICE == pt) + return MultiChoice.Array.class; isNumeric = pt.getJdbcType().isNumeric(); + ret = pt.getJavaType(); } else { - JdbcType jdbcType = col.getJdbcType(); - ret = jdbcType.getJavaClass(isNullable); isNumeric = jdbcType.isNumeric(); + ret = jdbcType.getJavaClass(isNullable); } if (isNumeric) { diff --git a/api/src/org/labkey/api/data/DataColumn.java b/api/src/org/labkey/api/data/DataColumn.java index 7245fbbdb25..377392d4664 100644 --- a/api/src/org/labkey/api/data/DataColumn.java +++ b/api/src/org/labkey/api/data/DataColumn.java @@ -61,6 +61,8 @@ import java.util.Collection; import java.util.Collections; import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Objects; @@ -710,7 +712,7 @@ else if (_inputType.equalsIgnoreCase("checkbox")) { IPropertyValidator textChoiceValidator = PropertyService.get().getValidatorForColumn(_boundColumn, PropertyValidatorType.TextChoice); if (textChoiceValidator != null) - renderTextChoiceFormInput(out, formFieldName, value, strVal, disabledInput, textChoiceValidator); + renderTextChoiceFormInput(out, formFieldName, value, List.of(strVal), disabledInput, textChoiceValidator); else renderTextFormInput(out, formFieldName, value, strVal, disabledInput); } @@ -735,7 +737,8 @@ else if (_inputType.equalsIgnoreCase("checkbox")) return ctx.getForm() == null || col == null ? HtmlString.EMPTY_STRING : ctx.getErrors(col); } - private void renderSelectFormInput(HtmlWriter out, String formFieldName, Object value, String strVal, boolean disabledInput, NamedObjectList entryList) + + private void renderSelectFormInput(HtmlWriter out, String formFieldName, Object value, List strValues, boolean disabledInput, NamedObjectList entryList) { SelectBuilder select = new SelectBuilder() .disabled(disabledInput) @@ -747,12 +750,15 @@ private void renderSelectFormInput(HtmlWriter out, String formFieldName, Object // add empty option options.add(new OptionBuilder().build()); + Set selectedValues = strValues.isEmpty() ? Set.of() : + strValues.size()==1 ? (null == strValues.get(0) ? Set.of() : Set.of(strValues.get(0))) : + new HashSet<>(strValues); for (NamedObject entry : entryList) { String entryName = entry.getName(); OptionBuilder option = new OptionBuilder() - .selected(isSelectInputSelected(entryName, value, strVal)) - .value(entryName); + .selected(selectedValues.contains(entryName)) + .value(entryName); if (null != entry.getObject()) option.label(getSelectInputDisplayValue(entry)); @@ -767,22 +773,18 @@ private void renderSelectFormInput(HtmlWriter out, String formFieldName, Object renderHiddenFormInput(out, formFieldName, value); } - private void renderTextChoiceFormInput(HtmlWriter out, String formFieldName, Object value, String strVal, boolean disabledInput, IPropertyValidator textChoiceValidator) + protected void renderTextChoiceFormInput(HtmlWriter out, String formFieldName, Object value, List strValues, boolean disabledInput, IPropertyValidator textChoiceValidator) { - NamedObjectList options = new NamedObjectList(); - List choices = PropertyService.get().getTextChoiceValidatorOptions(textChoiceValidator); + LinkedHashSet choices = new LinkedHashSet<>(PropertyService.get().getTextChoiceValidatorOptions(textChoiceValidator)); // if the already saved strVal is not in the current choice set, add it (as it seems wrong to remove a value that the user hasn't explicitly touched) - if (!StringUtils.isEmpty(strVal) && !choices.contains(strVal)) - { - choices = new ArrayList<>(choices); - choices.add(strVal); - } + choices.addAll(strValues); + NamedObjectList options = new NamedObjectList(); for (String choice : choices) options.put(new SimpleNamedObject(choice, choice)); - renderSelectFormInput(out, formFieldName, value, strVal, disabledInput, options); + renderSelectFormInput(out, formFieldName, value, strValues, disabledInput, options); } protected void renderSelectFormInputFromFk(RenderContext ctx, HtmlWriter out, String formFieldName, Object value, String strVal, boolean disabledInput) @@ -807,7 +809,7 @@ protected void renderSelectFormInputFromFk(RenderContext ctx, HtmlWriter out, St } else { - renderSelectFormInput(out, formFieldName, value, strVal, disabledInput, entryList); + renderSelectFormInput(out, formFieldName, value, List.of(Objects.toString(value)), disabledInput, entryList); } } diff --git a/api/src/org/labkey/api/data/DisplayColumn.java b/api/src/org/labkey/api/data/DisplayColumn.java index 2d673af6256..a9918cf3cd5 100644 --- a/api/src/org/labkey/api/data/DisplayColumn.java +++ b/api/src/org/labkey/api/data/DisplayColumn.java @@ -58,6 +58,7 @@ import java.text.DecimalFormatSymbols; import java.text.Format; import java.util.ArrayList; +import java.util.Collection; import java.util.Date; import java.util.HashSet; import java.util.LinkedHashSet; @@ -631,7 +632,11 @@ public String getJsonTypeName() public static String getJsonTypeName(Class valueClass) { - if (String.class.isAssignableFrom(valueClass)) + if (Map.class.isAssignableFrom(valueClass)) + return "object"; + else if (valueClass.isArray() || List.class.isAssignableFrom(valueClass)) + return "array"; + else if (String.class.isAssignableFrom(valueClass)) return "string"; else if (Boolean.class.isAssignableFrom(valueClass) || boolean.class.isAssignableFrom(valueClass)) return "boolean"; @@ -1166,7 +1171,7 @@ protected Object getInputValue(RenderContext ctx) val = viewForm.getAsString(formFieldName); } else if (ctx.getRow() != null) - val = col.getValue(ctx); + val = getValue(ctx); } return val; diff --git a/api/src/org/labkey/api/data/JdbcType.java b/api/src/org/labkey/api/data/JdbcType.java index 361456f21c1..e427d25382a 100644 --- a/api/src/org/labkey/api/data/JdbcType.java +++ b/api/src/org/labkey/api/data/JdbcType.java @@ -30,6 +30,7 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.nio.ByteBuffer; +import java.sql.Array; import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; @@ -299,6 +300,8 @@ protected Collection getSqlTypes() } }, + ARRAY(Types.ARRAY, Array.class), + NULL(Types.NULL, Object.class), OTHER(Types.OTHER, Object.class); @@ -398,7 +401,6 @@ protected void addSqlTypes(Collection sqlTypes) public static JdbcType valueOf(int type) { JdbcType jt = sqlTypeMap.get(type); - return null != jt ? jt : OTHER; } diff --git a/api/src/org/labkey/api/data/MultiChoice.java b/api/src/org/labkey/api/data/MultiChoice.java new file mode 100644 index 00000000000..a694c3f53bd --- /dev/null +++ b/api/src/org/labkey/api/data/MultiChoice.java @@ -0,0 +1,586 @@ +package org.labkey.api.data; + +import org.apache.commons.beanutils.ConvertUtils; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.json.JSONArray; +import org.junit.Assert; +import org.junit.Test; +import org.labkey.api.collections.CaseInsensitiveHashSet; +import org.labkey.api.exp.property.IPropertyValidator; +import org.labkey.api.exp.property.PropertyService; +import org.labkey.api.gwt.client.model.PropertyValidatorType; +import org.labkey.api.ontology.Unit; +import org.labkey.api.reader.TabLoader; +import org.labkey.api.util.DOM; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.writer.HtmlWriter; + +import java.io.StringBufferInputStream; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collector; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import static org.labkey.api.util.DOM.Attribute.style; +import static org.labkey.api.util.DOM.DIV; +import static org.labkey.api.util.DOM.SPAN; +import static org.labkey.api.util.DOM.at; + +public class MultiChoice +{ + public static final String ARRAY_MARKER = "[]"; + public static class DisplayColumn extends DataColumn + { + public DisplayColumn(ColumnInfo col) + { + super(col, false); + } + + @Override + public Object getValue(RenderContext ctx) + { + Object v = super.getValue(ctx); + if (!(v instanceof java.sql.Array array)) + return Array.from(new Object[]{v}); + return Array.from(array); + } + + @Override + public Object getDisplayValue(RenderContext ctx) + { + return getValue(ctx); + } + + @Override + protected Object getInputValue(RenderContext ctx) + { + return super.getInputValue(ctx); + } + + @Override + protected String getStringValue(Object value, Unit unit, boolean disabledInput) + { + // Because MultiChoice.Array implements Collection ConvertUtils will return convert(Array.get(0).toString())) + if (value instanceof Array) + return value.toString(); + return super.getStringValue(value, unit, disabledInput); + } + + @Override + public void renderInputHtml(RenderContext ctx, HtmlWriter out, Object value) + { + MultiChoice.Array array; + if (null == value) + array = new MultiChoice.Array(new String[]{}); + else if (value instanceof MultiChoice.Array mca) + array = mca; + else + array = _converter.convert(MultiChoice.Array.class, value); + + boolean disabledInput = isDisabledInput(ctx); + String formFieldName = getFormFieldName(ctx); + ColumnInfo boundColumn = getBoundColumn(); + IPropertyValidator textChoiceValidator = PropertyService.get().getValidatorForColumn(boundColumn, PropertyValidatorType.TextChoice); + + if (textChoiceValidator != null) + { + setInputType("select.multiple"); + renderTextChoiceFormInput(out, formFieldName+ARRAY_MARKER, array, array, disabledInput, textChoiceValidator); + } + else + { + renderTextFormInput(out, formFieldName, array, array.toString(), disabledInput); + } + } + + @Override + public void renderInputCell(RenderContext ctx, HtmlWriter out) + { + super.renderInputCell(ctx, out); + } + + @Override + public @NotNull HtmlString getFormattedHtml(RenderContext ctx) + { + Array array = (Array) getValue(ctx); + + if (null == array || array.isEmpty()) + return HtmlString.EMPTY_STRING; + + return HtmlString.of( + DIV(array.stream().map(v -> SPAN(at(style,"border:solid 1px black; border-radius:3px;"), v)) + .collect(new JoinRenderable(HtmlString.SP)))); + } + } + + + /* could also use .flatMap() */ + static class JoinRenderable implements Collector,List> + { + private final DOM.Renderable separator; + + JoinRenderable(DOM.Renderable separator) + { + this.separator = separator; + } + + @Override + public BiConsumer, DOM.Renderable> accumulator() + { + return (l, r) -> { + if (null == r) + return; + if (!l.isEmpty()) + l.add(separator); + l.add(r);}; + } + + @Override + public Supplier> supplier() + { + return ArrayList::new; + } + + @Override + public BinaryOperator> combiner() + { + return (l1,l2) -> {l1.addAll(l2); return l1;}; + } + + @Override + public Function, List> finisher() + { + return l->l; + } + + @Override + public Set characteristics() + { + return Set.of(); + } + } + + + // LK impl to help with conversions + public static class Array implements List, java.sql.Array + { + final String[] array; + List list = null; + + protected Array(Stream str) + { + CaseInsensitiveHashSet set = new CaseInsensitiveHashSet(); + array = str.filter(Objects::nonNull) + .map(s -> StringUtils.trimToNull(s.toString())) + .filter(Objects::nonNull) + .filter(set::add) + .toArray(String[]::new); + } + + protected Array(Object[] array) + { + this(Stream.of(array)); + } + + public String[] getStringArray() + { + return array; + } + + private List getList() + { + if (null == list) + list = Collections.unmodifiableList(Arrays.asList(array)); + return list; + } + + @Override + public boolean equals(Object obj) + { + if (!(obj instanceof Array arr)) + return false; + return Arrays.equals(array, arr.array); + } + + @Override + public int hashCode() + { + return Arrays.hashCode(array); + } + + @Override + public String toString() + { + return PageFlowUtil.joinValuesToStringForExport(this); + } + + public static Array from(Object @NotNull [] values) + { + return new Array(Arrays.stream(values)); + } + + public static Array from(@NotNull org.json.JSONArray array) + { + return new Array(StreamSupport.stream(array.spliterator(), false)); + } + + public static Array from(@NotNull String s) + { + List split = PageFlowUtil.splitStringToValuesForImport(s); + return from(split.toArray()); + } + + public static Array from(@NotNull java.sql.Array sqlArray) + { + try + { + // JDBC spec says java.sql.Array can return a primitive array! + Object o = sqlArray.getArray(); + Object[] array; + if (!o.getClass().getComponentType().isPrimitive()) + { + array = (Object[]) o; + } + else + { + array = new Object[java.lang.reflect.Array.getLength(o)]; + for (int i = 0; i < array.length; i++) + array[i] = java.lang.reflect.Array.get(o, i); + } + return new Array(array); + } + catch (SQLException x) + { + throw new RuntimeException(x); + } + } + + // + // implements List + // + + @Override + public boolean add(String s) + { + throw new UnsupportedOperationException(); + } + + @Override + public int size() + { + return array.length; + } + + @Override + public boolean isEmpty() + { + return array.length == 0; + } + + @Override + public boolean contains(Object o) + { + return getList().contains(o); + } + + @Override + public @NotNull Iterator iterator() + { + return getList().iterator(); + } + + @Override + public @NotNull Object[] toArray() + { + return array; + } + + @Override + public @NotNull T[] toArray(@NotNull T[] a) + { + if (a.length == 0 && a.getClass().getComponentType().isAssignableFrom(String.class)) + return (T[])array.clone(); + return getList().toArray(a); + } + + @Override + public boolean remove(Object o) + { + throw new UnsupportedOperationException(); + } + + @Override + public boolean containsAll(@NotNull Collection c) + { + return getList().containsAll(c); + } + + @Override + public boolean addAll(@NotNull Collection c) + { + throw new UnsupportedOperationException(); + } + + @Override + public boolean addAll(int index, @NotNull Collection c) + { + throw new UnsupportedOperationException(); + } + + @Override + public boolean removeAll(@NotNull Collection c) + { + throw new UnsupportedOperationException(); + } + + @Override + public boolean retainAll(@NotNull Collection c) + { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() + { + throw new UnsupportedOperationException(); + } + + @Override + public String get(int index) + { + return array[index]; + } + + @Override + public String set(int index, String element) + { + throw new UnsupportedOperationException(); + } + + @Override + public void add(int index, String element) + { + throw new UnsupportedOperationException(); + } + + @Override + public String remove(int index) + { + throw new UnsupportedOperationException(); + } + + @Override + public int indexOf(Object o) + { + return getList().indexOf(o); + } + + @Override + public int lastIndexOf(Object o) + { + return getList().lastIndexOf(o); + } + + @Override + public @NotNull ListIterator listIterator() + { + return getList().listIterator(); + } + + @Override + public @NotNull ListIterator listIterator(int index) + { + return getList().listIterator(index); + } + + @Override + public @NotNull List subList(int fromIndex, int toIndex) + { + return getList().subList(fromIndex, toIndex); + } + + + // + // implements Array + // + + @Override + public void free() throws SQLException + { + + } + + @Override + public String getBaseTypeName() throws SQLException + { + return "VARCHAR"; + } + + @Override + public int getBaseType() throws SQLException + { + return Types.VARCHAR; + } + + @Override + public Object getArray() throws SQLException + { + return toArray(new String[size()]); + } + + @Override + public Object getArray(Map> map) throws SQLException + { + return toArray(new String[size()]); + } + + @Override + public Object getArray(long index, int count) throws SQLException + { + return subList((int) index, (int) index + count).toArray(new String[0]); + } + + @Override + public Object getArray(long index, int count, Map> map) throws SQLException + { + return subList((int) index, (int) index + count).toArray(new String[0]); + } + + @Override + public ResultSet getResultSet() throws SQLException + { + throw new UnsupportedOperationException(); + } + + @Override + public ResultSet getResultSet(Map> map) throws SQLException + { + throw new UnsupportedOperationException(); + } + + @Override + public ResultSet getResultSet(long index, int count) throws SQLException + { + throw new UnsupportedOperationException(); + } + + @Override + public ResultSet getResultSet(long index, int count, Map> map) throws SQLException + { + throw new UnsupportedOperationException(); + } + } + + private static final Converter _converter = new Converter(); + static + { + ConvertUtils.register(_converter, Array.class); + } + + + public static class Converter implements org.apache.commons.beanutils.Converter, Function + { + private Converter() + { + } + + public static Converter getInstance() + { + return _converter; + } + + @Override + public T convert(Class aClass, Object o) + { + if (null == o) + return (T) Array.from(new String[]{}); + if (o instanceof MultiChoice.Array arr) + return (T)arr; + if (o instanceof String s) + return (T) Array.from(s); + if (o.getClass().isArray()) + return (T) Array.from((Object[]) o); + if (o instanceof org.json.JSONArray json) + return (T) Array.from(json); + if (o instanceof List list) + return (T) new Array(list.stream()); + return (T) Array.from(o.toString()); + } + + @Override + final public Object apply(Object o) + { + return convert(MultiChoice.Array.class, o); + } + } + + + + public static class TestCase extends Assert + { + @Test + public void testConvert() throws Exception + { + Array expected = Array.from(new String[]{"a,","b\"","c "}); + + assertEquals(expected, _converter.convert(Array.class, expected)); + assertEquals(expected, _converter.convert(Array.class, "\"a,\",\"b\"\"\",\"c \"")); + assertEquals(expected, _converter.convert(Array.class, new String[]{"a,","b\"","c "})); + assertEquals(expected, _converter.convert(Array.class, List.of("a,","b\"","c "))); + assertEquals(expected, _converter.convert(Array.class, new JSONArray(List.of("a,","b\"","c ")))); + } + + @Test + public void testCSV() throws Exception + { + Array expected = Array.from(new String[]{"a,", "b\\", "c,d", "e\"f"}); + // toString() == "a,", b\, "c,d", "e""f" + assertEquals("\"a,\", b\\, \"c,d\", \"e\"\"f\"", expected.toString()); + + // csv/tsv with double double-quote escaping + // add " around entire value, and double the " + // (need to use some \" to avoid ending the """ """ block) + String oneMultiValueColumn = """ + column + ""\"a,"", b\\, ""c,d"", ""e""\""f""\" + """; + + try (var csvLoader = new TabLoader.CsvFactory().createLoader(new StringBufferInputStream(oneMultiValueColumn), true)) + { + var maps = csvLoader.load(); + assertEquals(1, maps.size()); + Map map = maps.get(0); + assertTrue(map.get("column") instanceof String); + String value = (String) map.get("column"); + assertEquals(expected, Array.from(value)); + } + try (var tsvLoader = new TabLoader.TsvFactory().createLoader(new StringBufferInputStream(oneMultiValueColumn), true)) + { + var maps = tsvLoader.load(); + assertEquals(1, maps.size()); + Map map = maps.get(0); + assertTrue(map.get("column") instanceof String); + String value = (String) map.get("column"); + assertEquals(expected, Array.from(value)); + } + } + } +} \ No newline at end of file diff --git a/api/src/org/labkey/api/data/SQLFragment.java b/api/src/org/labkey/api/data/SQLFragment.java index f0fe937f876..acde96b77c6 100644 --- a/api/src/org/labkey/api/data/SQLFragment.java +++ b/api/src/org/labkey/api/data/SQLFragment.java @@ -684,6 +684,11 @@ public SQLFragment add(Object p) return this; } + public SQLFragment add(Object p, JdbcType type) + { + getMutableParams().add(new Parameter.TypedValue(p, type)); + return this; + } /** Adds the objects as JDBC parameter values */ public SQLFragment addAll(Collection l) diff --git a/api/src/org/labkey/api/data/Table.java b/api/src/org/labkey/api/data/Table.java index e9bf16703fc..5c1a60e18d9 100644 --- a/api/src/org/labkey/api/data/Table.java +++ b/api/src/org/labkey/api/data/Table.java @@ -1015,7 +1015,7 @@ else if (pkVals instanceof Map) whereSQL.append(whereAND); whereSQL.appendIdentifier(col.getSelectIdentifier()); whereSQL.append("=?"); - whereSQL.add(keys.get(col.getName())); + whereSQL.add(keys.get(col.getName()), col.getJdbcType()); whereAND = " AND "; } diff --git a/api/src/org/labkey/api/data/TableViewForm.java b/api/src/org/labkey/api/data/TableViewForm.java index 229525bbda2..c4e85e60fd8 100644 --- a/api/src/org/labkey/api/data/TableViewForm.java +++ b/api/src/org/labkey/api/data/TableViewForm.java @@ -65,6 +65,9 @@ import java.util.Set; import java.util.stream.Collectors; +import static org.labkey.api.action.SpringActionController.FIELD_MARKER; +import static org.labkey.api.data.MultiChoice.ARRAY_MARKER; + /** * Basic form for handling posts into views. * Supports insert, update, delete functionality with a minimum of fuss @@ -652,7 +655,6 @@ public void setTypedValues(Map values, boolean merge) if (Character.isUpperCase(propName.charAt(0))) propName = Introspector.decapitalize(propName); setTypedValue(propName, e.getValue()); - // TODO MultiChoice To convert or not to convert??? _stringValues.put(propName, e.getValue()); } } @@ -821,7 +823,7 @@ public void setViewContext(@NotNull ViewContext context) public static PropertyValues preprocessPropertyValues(PropertyValues params) { // we can usually just return params - if (params.stream().noneMatch(e -> e.getName().endsWith("[]") || e.getName().startsWith(SpringActionController.FIELD_MARKER))) + if (params.stream().noneMatch(e -> e.getName().endsWith(ARRAY_MARKER) || e.getName().startsWith(FIELD_MARKER))) return params; Set names = params.stream().map(PropertyValue::getName).collect(Collectors.toSet()); @@ -829,13 +831,13 @@ public static PropertyValues preprocessPropertyValues(PropertyValues params) for (var orig : params) { var copy = orig; - if (orig.getName().startsWith(SpringActionController.FIELD_MARKER)) + if (orig.getName().startsWith(FIELD_MARKER)) { if (names.contains(orig.getName().substring(1))) continue; copy = new PropertyValue(orig.getName().substring(1), "0"); } - else if (orig.getName().endsWith("[]") && orig.getValue()!=null) + else if (orig.getName().endsWith(ARRAY_MARKER) && orig.getValue()!=null) { var value = orig.getValue(); var convertedValue = value; diff --git a/api/src/org/labkey/api/exp/PropertyType.java b/api/src/org/labkey/api/exp/PropertyType.java index 9fa928cfac4..a30ac08695f 100644 --- a/api/src/org/labkey/api/exp/PropertyType.java +++ b/api/src/org/labkey/api/exp/PropertyType.java @@ -26,6 +26,7 @@ import org.labkey.api.attachments.AttachmentFile; import org.labkey.api.collections.CaseInsensitiveHashMap; import org.labkey.api.data.JdbcType; +import org.labkey.api.data.MultiChoice; import org.labkey.api.data.NameGenerator; import org.labkey.api.exp.OntologyManager.PropertyRow; import org.labkey.api.reader.ExcelFactory; @@ -41,6 +42,7 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.TimeZone; @@ -155,6 +157,7 @@ public Object getPreviewValue(@Nullable String prefix) return prefix + "Value"; } }, + // NOT an XMLSchema type uri??? MULTI_LINE("http://www.w3.org/2001/XMLSchema#multiLine", "MultiLine", 's', JdbcType.VARCHAR, 4000, "textarea", CellType.STRING, String.class) { @Override @@ -202,6 +205,50 @@ public Object getPreviewValue(@Nullable String prefix) return prefix + "Value"; } }, + MULTI_CHOICE("http://cpas.fhcrc.org/exp/xml#multiChoice", "MultiChoice", '?' /* unsupported in exp.PropertyValues */, JdbcType.ARRAY, 0, "textarea", CellType.STRING, List.class) + { + @Override + protected Object convertExcelValue(Cell cell) throws ConversionException + { + return ConvertUtils.convert(cell.getStringCellValue(), MultiChoice.Array.class); + } + + @Override + public Object convert(Object value) throws ConversionException + { + return MultiChoice.Converter.getInstance().convert(MultiChoice.Array.class, value); + } + + @Override + public SimpleTypeNames.Enum getXmlBeanType() + { + return SimpleTypeNames.STRING; + } + + @Override + protected void init(PropertyRow row, Object value) + { + throw new UnsupportedOperationException("TODO MultiChoice"); + } + + @Override + protected void setValue(ObjectProperty property, Object value) + { + throw new UnsupportedOperationException("TODO MultiChoice"); + } + + @Override + protected Object getValue(ObjectProperty property) + { + throw new UnsupportedOperationException("TODO MultiChoice"); + } + + @Override + public Object getPreviewValue(@Nullable String prefix) + { + return "Option 1, Option 2"; + } + }, RESOURCE("http://www.w3.org/2000/01/rdf-schema#Resource", "PropertyURI", 's', JdbcType.VARCHAR, 4000, null, CellType.STRING, Identifiable.class) { @Override @@ -372,6 +419,7 @@ public Object getPreviewValue(@Nullable String prefix) return Integer.valueOf(3); } }, + // NOT an XMLSchema type uri??? BINARY("http://www.w3.org/2001/XMLSchema#binary", "Binary", 'f', JdbcType.BINARY, 10, null, CellType.NUMERIC, ByteBuffer.class) { @Override diff --git a/api/src/org/labkey/api/exp/property/DomainKind.java b/api/src/org/labkey/api/exp/property/DomainKind.java index 508ef06f025..f2a8ff5aad9 100644 --- a/api/src/org/labkey/api/exp/property/DomainKind.java +++ b/api/src/org/labkey/api/exp/property/DomainKind.java @@ -323,6 +323,7 @@ public boolean matchesTemplateXML(String templateName, DomainTemplateType templa public boolean allowAttachmentProperties() { return false; } public boolean allowFlagProperties() { return true; } public boolean allowTextChoiceProperties() { return true; } + public boolean allowMultiTextChoiceProperties() { return false; } public boolean allowSampleSubjectProperties() { return true; } public boolean allowTimepointProperties() { return false; } public boolean allowUniqueConstraintProperties() { return false; } diff --git a/api/src/org/labkey/api/util/HtmlString.java b/api/src/org/labkey/api/util/HtmlString.java index 54943a4d771..2075039ac44 100644 --- a/api/src/org/labkey/api/util/HtmlString.java +++ b/api/src/org/labkey/api/util/HtmlString.java @@ -30,6 +30,7 @@ public final class HtmlString implements SafeToRender, DOM.Renderable, Comparabl { // Helpful constants for convenience (and efficiency) public static final HtmlString EMPTY_STRING = HtmlString.of(""); + public static final HtmlString SP = HtmlString.unsafe(" "); public static final HtmlString NBSP = HtmlString.unsafe(" "); public static final HtmlString NDASH = HtmlString.unsafe("–"); public static final HtmlString BR = HtmlString.unsafe("
"); diff --git a/core/src/org/labkey/core/CoreModule.java b/core/src/org/labkey/core/CoreModule.java index e35275d1395..6b1629758a4 100644 --- a/core/src/org/labkey/core/CoreModule.java +++ b/core/src/org/labkey/core/CoreModule.java @@ -60,6 +60,7 @@ import org.labkey.api.data.DbSchema; import org.labkey.api.data.DbScope; import org.labkey.api.data.FileSqlScriptProvider; +import org.labkey.api.data.MultiChoice; import org.labkey.api.data.MvUtil; import org.labkey.api.data.NormalContainerType; import org.labkey.api.data.OutOfRangeDisplayColumn; @@ -1454,6 +1455,7 @@ public TabDisplayMode getTabDisplayMode() ApiJsonWriter.TestCase.class, ClassLoaderTestCase.class, CopyFileRootPipelineJob.TestCase.class, + MultiChoice.TestCase.class, OutOfRangeDisplayColumn.TestCase.class, PostgreSqlVersion.TestCase.class, ScriptEngineManagerImpl.TestCase.class, diff --git a/experiment/src/org/labkey/experiment/api/property/TextChoiceValidator.java b/experiment/src/org/labkey/experiment/api/property/TextChoiceValidator.java index 603982a5a3d..da946740ab8 100644 --- a/experiment/src/org/labkey/experiment/api/property/TextChoiceValidator.java +++ b/experiment/src/org/labkey/experiment/api/property/TextChoiceValidator.java @@ -17,6 +17,7 @@ import org.jetbrains.annotations.NotNull; import org.labkey.api.data.ColumnRenderProperties; +import org.labkey.api.data.MultiChoice; import org.labkey.api.exp.property.IPropertyValidator; import org.labkey.api.exp.property.PropertyService; import org.labkey.api.exp.property.ValidatorContext; @@ -24,7 +25,11 @@ import org.labkey.api.gwt.client.model.PropertyValidatorType; import org.labkey.api.query.ValidationError; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; +import java.util.Set; public class TextChoiceValidator extends RegExValidator implements ValidatorKind { @@ -52,18 +57,39 @@ public boolean validate(IPropertyValidator validator, ColumnRenderProperties fie { assert value != null : "Shouldn't be validating a null value"; - List validValues = (List)validatorCache.get(TextChoiceValidator.class, validator.getExpressionValue()); + Set validValues = (Set)validatorCache.get(TextChoiceValidator.class, validator.getExpressionValue()); if (validValues == null) { - validValues = PropertyService.get().getTextChoiceValidatorOptions(validator); + validValues = new LinkedHashSet<>(PropertyService.get().getTextChoiceValidatorOptions(validator)); // Cache the validValues so that it can be reused validatorCache.put(TextChoiceValidator.class, validator.getExpressionValue(), validValues); } - if (validValues.contains(value)) - return true; + Object errorValue = null; - createErrorMessage(validator, field, value, errors); - return false; + if (value instanceof String) + { + if (!validValues.contains(value)) + errorValue = value; + } + else if (value instanceof Collection col) + { + for (Object item : col) + { + if (null == item || !validValues.contains(Objects.toString(item))) + { + errorValue = item; + break; + } + } + } + else + { + errorValue = value; + } + + if (null != errorValue) + createErrorMessage(validator, field, value, errors); + return null == errorValue; } } \ No newline at end of file