diff --git a/api/src/org/labkey/api/data/CompareType.java b/api/src/org/labkey/api/data/CompareType.java
index d9165ae67da..da2a92070c2 100644
--- a/api/src/org/labkey/api/data/CompareType.java
+++ b/api/src/org/labkey/api/data/CompareType.java
@@ -827,6 +827,39 @@ protected Collection getCollectionParam(Object value)
*
*
*/
+
+
+ public static final CompareType ARRAY_IS_EMPTY = new CompareType("Is Empty", "arrayisempty", "ARRAYISEMPTY", false, null, OperatorType.ARRAYISEMPTY)
+ {
+ @Override
+ public ArrayIsEmptyClause createFilterClause(@NotNull FieldKey fieldKey, Object value)
+ {
+ return new ArrayIsEmptyClause(fieldKey);
+ }
+
+ @Override
+ public boolean meetsCriteria(ColumnRenderProperties col, Object value, Object[] filterValues)
+ {
+ throw new UnsupportedOperationException("Conditional formatting not yet supported for Multi Choices");
+ }
+ };
+
+
+ public static final CompareType ARRAY_IS_NOT_EMPTY = new CompareType("Is Not Empty", "arrayisnotempty", "ARRAYISNOTEMPTY", false, null, OperatorType.ARRAYISNOTEMPTY)
+ {
+ @Override
+ public ArrayIsEmptyClause createFilterClause(@NotNull FieldKey fieldKey, Object value)
+ {
+ return new ArrayIsNotEmptyClause(fieldKey);
+ }
+
+ @Override
+ public boolean meetsCriteria(ColumnRenderProperties col, Object value, Object[] filterValues)
+ {
+ throw new UnsupportedOperationException("Conditional formatting not yet supported for Multi Choices");
+ }
+ };
+
public static final CompareType ARRAY_CONTAINS_ALL = new CompareType("Contains All", "arraycontainsall", "ARRAYCONTAINSALL", true, null, OperatorType.ARRAYCONTAINSALL)
{
@Override
@@ -934,7 +967,7 @@ public String getValueSeparator()
public static abstract class ArrayClause extends SimpleFilter.MultiValuedFilterClause
{
- public static final String ARRAY_VALUE_SEPARATOR = ",";
+ public static final String ARRAY_VALUE_SEPARATOR = ";";
public ArrayClause(@NotNull FieldKey fieldKey, CompareType comparison, Collection> params, boolean negated)
{
@@ -958,7 +991,7 @@ public SQLFragment[] getParamSQLFragments(SqlDialect dialect)
}
for (int i = 0; i < params.length; i++)
- fragments[i] = new SQLFragment().append(escapeLabKeySqlValue(params[i], type));
+ fragments[i] = SQLFragment.unsafe(escapeLabKeySqlValue(params[i], type));
return fragments;
}
@@ -981,6 +1014,68 @@ public Pair getSqlFragments(Map columnMap, SqlDialect dialect)
+ {
+ ColumnInfo colInfo = columnMap != null ? columnMap.get(_fieldKey) : null;
+ var alias = SimpleFilter.getAliasForColumnFilter(dialect, colInfo, _fieldKey);
+
+ SQLFragment columnFragment = new SQLFragment().appendIdentifier(alias);
+
+ SQLFragment sql = dialect.array_is_empty(columnFragment);
+ if (!_negated)
+ return sql;
+ return new SQLFragment(" NOT (").append(sql).append(")");
+ }
+
+ @Override
+ public String getLabKeySQLWhereClause(Map columnMap)
+ {
+ return "array_is_empty(" + getLabKeySQLColName(_fieldKey) + ")";
+ }
+
+ @Override
+ public void appendFilterText(StringBuilder sb, ColumnNameFormatter formatter)
+ {
+ sb.append("is empty");
+ }
+
+ }
+
+ private static class ArrayIsNotEmptyClause extends ArrayIsEmptyClause
+ {
+
+ public ArrayIsNotEmptyClause(@NotNull FieldKey fieldKey)
+ {
+ super(fieldKey, CompareType.ARRAY_IS_NOT_EMPTY, true);
+ }
+
+ @Override
+ public String getLabKeySQLWhereClause(Map columnMap)
+ {
+ return "NOT array_is_empty(" + getLabKeySQLColName(_fieldKey) + ")";
+ }
+
+ @Override
+ public void appendFilterText(StringBuilder sb, ColumnNameFormatter formatter)
+ {
+ sb.append("is not empty");
+ }
+
+ }
+
private static class ArrayContainsAllClause extends ArrayClause
{
diff --git a/api/src/org/labkey/api/data/MultiValuedRenderContext.java b/api/src/org/labkey/api/data/MultiValuedRenderContext.java
index 245b1016d9c..afb518560ba 100644
--- a/api/src/org/labkey/api/data/MultiValuedRenderContext.java
+++ b/api/src/org/labkey/api/data/MultiValuedRenderContext.java
@@ -1,231 +1,239 @@
-/*
- * Copyright (c) 2010-2019 LabKey Corporation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.labkey.api.data;
-
-import org.apache.commons.beanutils.ConvertUtils;
-import org.apache.commons.collections4.iterators.ArrayIterator;
-import org.junit.Assert;
-import org.junit.Test;
-import org.labkey.api.query.FieldKey;
-
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.Map;
-import java.util.Set;
-
-/**
- * Wrapper for another {@link RenderContext} that allows multiple values to be rendered for a single row's column.
- * Used in conjunction with {@link MultiValuedDisplayColumn}.
- * User: adam
- * Date: Sep 7, 2010
- * Time: 10:02:25 AM
- */
-public class MultiValuedRenderContext extends RenderContextDecorator
-{
- private final Map> _iterators = new HashMap<>();
- private final Map _currentValues = new HashMap<>();
-
- public static final String VALUE_DELIMITER = "{@~^";
- public static final String VALUE_DELIMITER_REGEX = "\\Q" + VALUE_DELIMITER + "\\E";
-
- public MultiValuedRenderContext(RenderContext ctx, Set requiredFieldKeys)
- {
- super(ctx);
-
- // For each required column (e.g., display value, rowId), retrieve the concatenated values, split them, and
- // stash away an iterator of those values.
- int length = -1;
- Set nullFieldKeys = new HashSet<>();
- for (FieldKey fieldKey : requiredFieldKeys)
- {
- Object value = ctx.get(fieldKey);
- if (value == null || "".equals(value))
- {
- nullFieldKeys.add(fieldKey);
- }
- else
- {
- // Use -1 as the limit so that we pick up back-to-back delimiters as empty strings in the returned array,
- // which lets us understand there were rows with nulls.
- String[] values = value.toString().split(VALUE_DELIMITER_REGEX, -1);
- if (length != -1 && values.length != length)
- {
- throw new IllegalStateException("Expected all columns to have the same number of values, but '" + fieldKey + "' has " + values.length + " and " + _iterators.keySet() + " had " + length);
- }
- length = values.length;
- _iterators.put(fieldKey, new ArrayIterator<>(values));
- }
-
- for (FieldKey nullFieldKey : nullFieldKeys)
- {
- _iterators.put(nullFieldKey, new ArrayIterator<>(new String[length == -1 ? 0 : length]));
- }
- }
- }
-
- // Advance all the iterators, if another value is present. Check that all iterators are in lock step.
- public boolean next()
- {
- Boolean previousHasNext = null;
-
- for (Map.Entry> entry : _iterators.entrySet())
- {
- Iterator iter = entry.getValue();
- boolean hasNext = iter.hasNext();
-
- if (hasNext)
- _currentValues.put(entry.getKey(), iter.next());
-
- if (null == previousHasNext)
- previousHasNext = hasNext;
- else
- assert previousHasNext == hasNext : "Mismatch in number of values for " + entry.getKey() + " compared with other fields: " + _iterators.keySet();
- }
-
- return null != previousHasNext && previousHasNext;
- }
-
- @Override
- public Object get(Object key)
- {
- Object value = _currentValues.get(key);
-
- if (null != value)
- {
- // empty string values map to null
- if ("".equals(value))
- value = null;
-
- if (getFieldMap() != null)
- {
- ColumnInfo columnInfo = getFieldMap().get(key);
- // 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))
- {
- value = ConvertUtils.convert(value.toString(), columnInfo.getJavaClass());
- }
- }
- }
- else
- {
- value = super.get(key);
- }
-
- return value;
- }
-
- public static class TestCase extends Assert
- {
- private final FieldKey _fk1 = FieldKey.fromParts("Parent", "Child");
- private final FieldKey _fk2 = FieldKey.fromParts("Standalone");
- private final FieldKey _otherFK = FieldKey.fromParts("NotInRow");
-
- @Test
- public void testMatchingValues()
- {
- Set fieldKeys = new HashSet<>();
- fieldKeys.add(_fk1);
- fieldKeys.add(_fk2);
- Map values = new HashMap<>();
- values.put(_fk1, "1" + VALUE_DELIMITER + "2" + VALUE_DELIMITER + "3");
- values.put(_fk2, "a" + VALUE_DELIMITER + "b" + VALUE_DELIMITER + "c");
- MultiValuedRenderContext mvContext = new MultiValuedRenderContext(new TestRenderContext(values), fieldKeys);
- assertTrue(mvContext.next());
- assertEquals(1, mvContext.get(_fk1));
- assertEquals("a", mvContext.get(_fk2));
- assertTrue(mvContext.next());
- assertEquals(2, mvContext.get(_fk1));
- assertEquals("b", mvContext.get(_fk2));
- assertTrue(mvContext.next());
- assertEquals(3, mvContext.get(_fk1));
- assertEquals("c", mvContext.get(_fk2));
- assertFalse(mvContext.next());
- }
-
- @Test
- public void testMissingColumn()
- {
- // Be sure that if there's a column that couldn't be found, we don't blow up
- Set fieldKeys = new HashSet<>();
- fieldKeys.add(_fk1);
- fieldKeys.add(_fk2);
- fieldKeys.add(_otherFK);
- Map values = new HashMap<>();
- values.put(_fk1, "1" + VALUE_DELIMITER + "2" + VALUE_DELIMITER + "3");
- values.put(_fk2, "a" + VALUE_DELIMITER + "b" + VALUE_DELIMITER + "c");
- MultiValuedRenderContext mvContext = new MultiValuedRenderContext(new TestRenderContext(values), fieldKeys);
- assertTrue(mvContext.next());
- assertEquals(1, mvContext.get(_fk1));
- assertEquals("a", mvContext.get(_fk2));
- assertNull(mvContext.get(_otherFK));
- assertTrue(mvContext.next());
- assertEquals(2, mvContext.get(_fk1));
- assertEquals("b", mvContext.get(_fk2));
- assertNull(mvContext.get(_otherFK));
- assertTrue(mvContext.next());
- assertEquals(3, mvContext.get(_fk1));
- assertEquals("c", mvContext.get(_fk2));
- assertNull(mvContext.get(_otherFK));
- assertFalse(mvContext.next());
- }
-
- @Test
- public void testMismatchedValues()
- {
- Set fieldKeys = new HashSet<>();
- fieldKeys.add(_fk1);
- fieldKeys.add(_fk2);
- Map values = new HashMap<>();
- values.put(_fk1, "1" + VALUE_DELIMITER + "2" + VALUE_DELIMITER + "3");
- values.put(_fk2, "a" + VALUE_DELIMITER + "b" + VALUE_DELIMITER + "c" + VALUE_DELIMITER + "d");
- try
- {
- new MultiValuedRenderContext(new TestRenderContext(values), fieldKeys);
- fail("Should have gotten an exception");
- }
- catch (IllegalStateException ignored) {}
- }
-
- private class TestRenderContext extends RenderContext
- {
- private final Map _values;
-
- public TestRenderContext(Map values)
- {
- _values = values;
- }
-
- @Override
- public Object get(Object key)
- {
- return _values.get(key);
- }
-
- @Override
- public Map getFieldMap()
- {
- Map result = new HashMap<>();
- ColumnInfo col1 = new BaseColumnInfo(_fk1, JdbcType.INTEGER);
- result.put(_fk1, col1);
- ColumnInfo col2 = new BaseColumnInfo(_fk2, JdbcType.VARCHAR);
- result.put(_fk2, col2);
- return result;
- }
- }
- }
-}
+/*
+ * Copyright (c) 2010-2019 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.labkey.api.data;
+
+import org.apache.commons.beanutils.ConvertUtils;
+import org.apache.commons.collections4.iterators.ArrayIterator;
+import org.junit.Assert;
+import org.junit.Test;
+import org.labkey.api.exp.PropertyType;
+import org.labkey.api.query.FieldKey;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Wrapper for another {@link RenderContext} that allows multiple values to be rendered for a single row's column.
+ * Used in conjunction with {@link MultiValuedDisplayColumn}.
+ * User: adam
+ * Date: Sep 7, 2010
+ * Time: 10:02:25 AM
+ */
+public class MultiValuedRenderContext extends RenderContextDecorator
+{
+ private final Map> _iterators = new HashMap<>();
+ private final Map _currentValues = new HashMap<>();
+
+ public static final String VALUE_DELIMITER = "{@~^";
+ public static final String VALUE_DELIMITER_REGEX = "\\Q" + VALUE_DELIMITER + "\\E";
+
+ public MultiValuedRenderContext(RenderContext ctx, Set requiredFieldKeys)
+ {
+ super(ctx);
+
+ // For each required column (e.g., display value, rowId), retrieve the concatenated values, split them, and
+ // stash away an iterator of those values.
+ int length = -1;
+ Set nullFieldKeys = new HashSet<>();
+ for (FieldKey fieldKey : requiredFieldKeys)
+ {
+ Object value = ctx.get(fieldKey);
+ if (value == null || "".equals(value))
+ {
+ nullFieldKeys.add(fieldKey);
+ }
+ else
+ {
+ // Use -1 as the limit so that we pick up back-to-back delimiters as empty strings in the returned array,
+ // which lets us understand there were rows with nulls.
+ String[] values = value.toString().split(VALUE_DELIMITER_REGEX, -1);
+ if (length != -1 && values.length != length)
+ {
+ throw new IllegalStateException("Expected all columns to have the same number of values, but '" + fieldKey + "' has " + values.length + " and " + _iterators.keySet() + " had " + length);
+ }
+ length = values.length;
+ _iterators.put(fieldKey, new ArrayIterator<>(values));
+ }
+
+ for (FieldKey nullFieldKey : nullFieldKeys)
+ {
+ _iterators.put(nullFieldKey, new ArrayIterator<>(new String[length == -1 ? 0 : length]));
+ }
+ }
+ }
+
+ // Advance all the iterators, if another value is present. Check that all iterators are in lock step.
+ public boolean next()
+ {
+ Boolean previousHasNext = null;
+
+ for (Map.Entry> entry : _iterators.entrySet())
+ {
+ Iterator iter = entry.getValue();
+ boolean hasNext = iter.hasNext();
+
+ if (hasNext)
+ _currentValues.put(entry.getKey(), iter.next());
+
+ if (null == previousHasNext)
+ previousHasNext = hasNext;
+ else
+ assert previousHasNext == hasNext : "Mismatch in number of values for " + entry.getKey() + " compared with other fields: " + _iterators.keySet();
+ }
+
+ return null != previousHasNext && previousHasNext;
+ }
+
+ @Override
+ public Object get(Object key)
+ {
+ Object value = _currentValues.get(key);
+
+ if (null != value)
+ {
+ // empty string values map to null
+ if ("".equals(value))
+ value = null;
+
+ 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 ConvertUtils.convert(strVal.substring(1, strVal.length() - 1), columnInfo.getJavaClass());
+ // TODO: 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))
+ {
+ value = ConvertUtils.convert(value.toString(), columnInfo.getJavaClass());
+ }
+ }
+ }
+ else
+ {
+ value = super.get(key);
+ }
+
+ return value;
+ }
+
+ public static class TestCase extends Assert
+ {
+ private final FieldKey _fk1 = FieldKey.fromParts("Parent", "Child");
+ private final FieldKey _fk2 = FieldKey.fromParts("Standalone");
+ private final FieldKey _otherFK = FieldKey.fromParts("NotInRow");
+
+ @Test
+ public void testMatchingValues()
+ {
+ Set fieldKeys = new HashSet<>();
+ fieldKeys.add(_fk1);
+ fieldKeys.add(_fk2);
+ Map values = new HashMap<>();
+ values.put(_fk1, "1" + VALUE_DELIMITER + "2" + VALUE_DELIMITER + "3");
+ values.put(_fk2, "a" + VALUE_DELIMITER + "b" + VALUE_DELIMITER + "c");
+ MultiValuedRenderContext mvContext = new MultiValuedRenderContext(new TestRenderContext(values), fieldKeys);
+ assertTrue(mvContext.next());
+ assertEquals(1, mvContext.get(_fk1));
+ assertEquals("a", mvContext.get(_fk2));
+ assertTrue(mvContext.next());
+ assertEquals(2, mvContext.get(_fk1));
+ assertEquals("b", mvContext.get(_fk2));
+ assertTrue(mvContext.next());
+ assertEquals(3, mvContext.get(_fk1));
+ assertEquals("c", mvContext.get(_fk2));
+ assertFalse(mvContext.next());
+ }
+
+ @Test
+ public void testMissingColumn()
+ {
+ // Be sure that if there's a column that couldn't be found, we don't blow up
+ Set fieldKeys = new HashSet<>();
+ fieldKeys.add(_fk1);
+ fieldKeys.add(_fk2);
+ fieldKeys.add(_otherFK);
+ Map values = new HashMap<>();
+ values.put(_fk1, "1" + VALUE_DELIMITER + "2" + VALUE_DELIMITER + "3");
+ values.put(_fk2, "a" + VALUE_DELIMITER + "b" + VALUE_DELIMITER + "c");
+ MultiValuedRenderContext mvContext = new MultiValuedRenderContext(new TestRenderContext(values), fieldKeys);
+ assertTrue(mvContext.next());
+ assertEquals(1, mvContext.get(_fk1));
+ assertEquals("a", mvContext.get(_fk2));
+ assertNull(mvContext.get(_otherFK));
+ assertTrue(mvContext.next());
+ assertEquals(2, mvContext.get(_fk1));
+ assertEquals("b", mvContext.get(_fk2));
+ assertNull(mvContext.get(_otherFK));
+ assertTrue(mvContext.next());
+ assertEquals(3, mvContext.get(_fk1));
+ assertEquals("c", mvContext.get(_fk2));
+ assertNull(mvContext.get(_otherFK));
+ assertFalse(mvContext.next());
+ }
+
+ @Test
+ public void testMismatchedValues()
+ {
+ Set fieldKeys = new HashSet<>();
+ fieldKeys.add(_fk1);
+ fieldKeys.add(_fk2);
+ Map values = new HashMap<>();
+ values.put(_fk1, "1" + VALUE_DELIMITER + "2" + VALUE_DELIMITER + "3");
+ values.put(_fk2, "a" + VALUE_DELIMITER + "b" + VALUE_DELIMITER + "c" + VALUE_DELIMITER + "d");
+ try
+ {
+ new MultiValuedRenderContext(new TestRenderContext(values), fieldKeys);
+ fail("Should have gotten an exception");
+ }
+ catch (IllegalStateException ignored) {}
+ }
+
+ private class TestRenderContext extends RenderContext
+ {
+ private final Map _values;
+
+ public TestRenderContext(Map values)
+ {
+ _values = values;
+ }
+
+ @Override
+ public Object get(Object key)
+ {
+ return _values.get(key);
+ }
+
+ @Override
+ public Map getFieldMap()
+ {
+ Map result = new HashMap<>();
+ ColumnInfo col1 = new BaseColumnInfo(_fk1, JdbcType.INTEGER);
+ result.put(_fk1, col1);
+ ColumnInfo col2 = new BaseColumnInfo(_fk2, JdbcType.VARCHAR);
+ result.put(_fk2, col2);
+ return result;
+ }
+ }
+ }
+}
diff --git a/api/src/org/labkey/api/data/SimpleFilter.java b/api/src/org/labkey/api/data/SimpleFilter.java
index cdedb78ce2c..1cb5380207e 100644
--- a/api/src/org/labkey/api/data/SimpleFilter.java
+++ b/api/src/org/labkey/api/data/SimpleFilter.java
@@ -620,7 +620,7 @@ public static abstract class MultiValuedFilterClause extends CompareType.Abstrac
public MultiValuedFilterClause(@NotNull FieldKey fieldKey, CompareType comparison, Collection> params, boolean negated)
{
super(fieldKey);
- params = new ArrayList<>(params); // possibly immutable
+ params = params == null ? new ArrayList<>() : new ArrayList<>(params); // possibly immutable
if (params.contains(null)) //params.size() == 0 ||
{
_includeNull = true;
diff --git a/api/src/org/labkey/api/data/TableChange.java b/api/src/org/labkey/api/data/TableChange.java
index 5e8c6a1245d..cf6da3a1018 100644
--- a/api/src/org/labkey/api/data/TableChange.java
+++ b/api/src/org/labkey/api/data/TableChange.java
@@ -20,6 +20,7 @@
import org.labkey.api.data.PropertyStorageSpec.Index;
import org.labkey.api.data.TableInfo.IndexDefinition;
import org.labkey.api.exp.PropertyDescriptor;
+import org.labkey.api.exp.PropertyType;
import org.labkey.api.exp.property.Domain;
import org.labkey.api.exp.property.DomainKind;
import org.labkey.api.util.logging.LogHelper;
@@ -58,6 +59,7 @@ public class TableChange
private Collection _constraints;
private Set _indicesToBeDroppedByName;
private IndexSizeMode _sizeMode = IndexSizeMode.Auto;
+ private Map _oldPropTypes;
/** In most cases, domain knows the storage table name **/
public TableChange(Domain domain, ChangeType changeType)
@@ -329,6 +331,11 @@ public void setForeignKeys(Collection foreignKey
_foreignKeys = foreignKeys;
}
+ public Map getOldPropTypes()
+ {
+ return _oldPropTypes;
+ }
+
public final List toSpecs(Collection columnNames)
{
final Domain domain = _domain;
@@ -349,6 +356,11 @@ public final List toSpecs(Collection columnNames)
.collect(Collectors.toList());
}
+ public void setOldPropertyTypes(Map oldPropTypes)
+ {
+ _oldPropTypes = oldPropTypes;
+ }
+
public enum ChangeType
{
CreateTable,
diff --git a/api/src/org/labkey/api/data/dialect/SqlDialect.java b/api/src/org/labkey/api/data/dialect/SqlDialect.java
index 85cc60ec933..77e7430d148 100644
--- a/api/src/org/labkey/api/data/dialect/SqlDialect.java
+++ b/api/src/org/labkey/api/data/dialect/SqlDialect.java
@@ -2200,6 +2200,12 @@ public SQLFragment array_construct(SQLFragment[] elements)
throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement");
}
+ public SQLFragment array_is_empty(SQLFragment a)
+ {
+ assert !supportsArrays();
+ throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement");
+ }
+
// element a is in array b
public SQLFragment element_in_array(SQLFragment a, SQLFragment b)
{
diff --git a/api/src/org/labkey/api/query/AbstractQueryChangeListener.java b/api/src/org/labkey/api/query/AbstractQueryChangeListener.java
index 57c76f78dd3..4e4eb2ac135 100644
--- a/api/src/org/labkey/api/query/AbstractQueryChangeListener.java
+++ b/api/src/org/labkey/api/query/AbstractQueryChangeListener.java
@@ -40,13 +40,13 @@ public void queryCreated(User user, Container container, ContainerFilter scope,
protected abstract void queryCreated(User user, Container container, ContainerFilter scope, SchemaKey schema, String query);
@Override
- public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @NotNull QueryProperty property, @NotNull Collection> changes)
+ public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, String queryName, @NotNull QueryProperty property, @NotNull Collection> changes)
{
for (QueryPropertyChange> change : changes)
- queryChanged(user, container, scope, schema, change);
+ queryChanged(user, container, scope, schema, queryName, change);
}
- protected abstract void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, QueryPropertyChange> change);
+ protected abstract void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, String queryName, QueryPropertyChange> change);
@Override
public void queryDeleted(User user, Container container, ContainerFilter scope, SchemaKey schema, @NotNull Collection queries)
diff --git a/api/src/org/labkey/api/query/QueryChangeListener.java b/api/src/org/labkey/api/query/QueryChangeListener.java
index 776c57392ff..a0a447c5a70 100644
--- a/api/src/org/labkey/api/query/QueryChangeListener.java
+++ b/api/src/org/labkey/api/query/QueryChangeListener.java
@@ -20,10 +20,17 @@
import org.labkey.api.data.Container;
import org.labkey.api.data.ContainerFilter;
import org.labkey.api.event.PropertyChange;
+import org.labkey.api.exp.PropertyDescriptor;
+import org.labkey.api.exp.PropertyType;
import org.labkey.api.security.User;
+import org.labkey.api.util.PageFlowUtil;
import java.util.Collection;
import java.util.Collections;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.labkey.api.data.ColumnRenderPropertiesImpl.TEXT_CHOICE_CONCEPT_URI;
/**
* Listener for table and query events that fires when the structure/schema changes, but not when individual data
@@ -58,10 +65,11 @@ public interface QueryChangeListener
* @param container The container the tables or queries are changed in.
* @param scope The scope of containers that the tables or queries affect.
* @param schema The schema of the tables or queries.
+ * @param queryName The query name if the change is specific to a single query.
* @param property The QueryProperty that has changed.
* @param changes The set of change events. Each QueryPropertyChange is associated with a single table or query.
*/
- void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @NotNull QueryProperty property, @NotNull Collection> changes);
+ void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @Nullable String queryName, @NotNull QueryProperty property, @NotNull Collection> changes);
/**
* This method is called when a set of tables or queries are deleted from the given container and schema.
@@ -94,7 +102,9 @@ enum QueryProperty
Description(String.class),
Inherit(Boolean.class),
Hidden(Boolean.class),
- SchemaName(String.class),;
+ SchemaName(String.class),
+ ColumnName(String.class),
+ ColumnType(PropertyType.class),;
private final Class> _klass;
@@ -112,7 +122,7 @@ public Class> getPropertyClass()
/**
* A change event for a single property of a single table or query.
* If multiple properties have been changed, QueryChangeListener will
- * fire {@link QueryChangeListener#queryChanged(User, Container, ContainerFilter, SchemaKey, QueryChangeListener.QueryProperty, Collection)}
+ * fire {@link QueryChangeListener#queryChanged(User, Container, ContainerFilter, SchemaKey, String, QueryChangeListener.QueryProperty, Collection)}
* for each property that has changed.
*
* @param The property type.
@@ -171,6 +181,22 @@ public static void handleSchemaNameChange(@NotNull String oldValue, String newVa
QueryProperty.SchemaName, Collections.singleton(change));
}
+ public static void handleColumnTypeChange(@NotNull PropertyDescriptor oldValue, PropertyDescriptor newValue, @NotNull SchemaKey schemaPath, @NotNull String queryName, User user, Container container)
+ {
+ if (oldValue.getPropertyType() == newValue.getPropertyType())
+ return;
+
+ QueryChangeListener.QueryPropertyChange change = new QueryChangeListener.QueryPropertyChange<>(
+ null,
+ QueryChangeListener.QueryProperty.ColumnType,
+ oldValue,
+ newValue
+ );
+
+ QueryService.get().fireQueryColumnChanged(user, container, schemaPath, queryName,
+ QueryProperty.ColumnType, Collections.singleton(change));
+ }
+
@Nullable public QueryDefinition getSource() { return _queryDef; }
@Override
@@ -185,4 +211,151 @@ public static void handleSchemaNameChange(@NotNull String oldValue, String newVa
@Nullable
public V getNewValue() { return _newValue; }
}
+
+ /**
+ * Utility to update encoded filter string when a column type changes from Multi_Choice to a non Multi_Choice.
+ * This method performs targeted replacements for the given column name (case-insensitive).
+ */
+ private static String getUpdatedFilterStrFromMVTC(String filterStr, String prefix, String columnName, @NotNull PropertyDescriptor oldType, @NotNull PropertyDescriptor newType)
+ {
+ if (filterStr == null || columnName == null)
+ return filterStr;
+
+ // Only act when changing away from MULTI_CHOICE
+ if (oldType.getPropertyType() != PropertyType.MULTI_CHOICE || newType.getPropertyType() == PropertyType.MULTI_CHOICE)
+ return filterStr;
+
+ String columnNameEncoded = PageFlowUtil.encodeURIComponent(columnName);
+
+ String colLower = columnNameEncoded.toLowerCase();
+ String sLower = filterStr.toLowerCase();
+
+ // No action if column doesn't match
+ if (!sLower.startsWith(prefix + "." + colLower + "~"))
+ return filterStr;
+
+ // drop arraycontainsall since there is no good match
+ if (sLower.startsWith(prefix + "." + colLower + "~arraycontainsall"))
+ return "";
+
+ String updated = filterStr;
+
+ if (TEXT_CHOICE_CONCEPT_URI.equals(newType.getConceptURI()))
+ {
+ // only keep arraymatches/arraynotmatches when converting to a TEXT_CHOICE since current values are guaranteed to be single value
+ if (containsOp(updated, prefix, columnNameEncoded, "arraymatches"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "arraymatches", "eq");
+ }
+ if (containsOp(updated, prefix, columnNameEncoded, "arraynotmatches"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "arraynotmatches", "neq");
+ }
+ }
+
+ if (containsOp(updated, prefix, columnNameEncoded, "arrayisempty"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "arrayisempty", "isblank");
+ }
+ if (containsOp(updated, prefix, columnNameEncoded, "arrayisnotempty"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "arrayisnotempty", "isnonblank");
+ }
+ if (containsOp(updated, prefix, columnNameEncoded, "arraycontainsany"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "arraycontainsany", "in");
+ }
+ if (containsOp(updated, prefix, columnNameEncoded, "arraycontainsnone"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "arraycontainsnone", "notin");
+ }
+
+ // No matching operator found for this column, drop the filter
+ return "";
+ }
+
+ /**
+ * Utility to update encoded filter string when a column type is changed to Multi_Choice (migrating operators to array equivalents).
+ */
+ private static String getUpdatedMVTCFilterStr(String filterStr, String prefix, String columnName, @NotNull PropertyDescriptor oldType, @NotNull PropertyDescriptor newType)
+ {
+ if (filterStr == null || columnName == null || oldType == null || newType == null)
+ return filterStr;
+
+ // Only act when changing to MULTI_CHOICE
+ if (oldType.getPropertyType() == PropertyType.MULTI_CHOICE || newType.getPropertyType() != PropertyType.MULTI_CHOICE)
+ return filterStr;
+
+ String columnNameEncoded = PageFlowUtil.encodeURIComponent(columnName);
+
+ String colLower = columnNameEncoded.toLowerCase();
+ String sLower = filterStr.toLowerCase();
+
+ // No action if column doesn't match
+ if (!sLower.startsWith(prefix + "." + colLower + "~"))
+ return filterStr;
+
+ String updated = filterStr;
+
+ // Return on first matching operator for this column
+ if (containsOp(updated, prefix, columnNameEncoded, "eq"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "eq", "arraymatches");
+ }
+ if (containsOp(updated, prefix, columnNameEncoded, "neq"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "neq", "arraycontainsnone");
+ }
+ if (containsOp(updated, prefix, columnNameEncoded, "isblank"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "isblank", "arrayisempty");
+ }
+ if (containsOp(updated, prefix, columnNameEncoded, "isnonblank"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "isnonblank", "arrayisnotempty");
+ }
+ if (containsOp(updated, prefix, columnNameEncoded, "in"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "in", "arraycontainsany");
+ }
+ if (containsOp(updated, prefix, columnNameEncoded, "notin"))
+ {
+ return replaceOp(updated, prefix, columnNameEncoded, "notin", "arraycontainsnone");
+ }
+
+ // No matching operator found for this column, drop the filter
+ return "";
+ }
+
+ static String getUpdatedFilterStrOnColumnTypeUpdate(String filterStr, String prefix, String columnName, @NotNull PropertyDescriptor oldType, @NotNull PropertyDescriptor newType)
+ {
+ if (oldType.getPropertyType() == PropertyType.MULTI_CHOICE)
+ return getUpdatedFilterStrFromMVTC(filterStr, prefix, columnName, oldType, newType);
+ else if (newType.getPropertyType() == PropertyType.MULTI_CHOICE)
+ return getUpdatedMVTCFilterStr(filterStr, prefix, columnName, oldType, newType);
+ else
+ return filterStr;
+ }
+
+ private static boolean containsOp(String filterStr, String prefix, String columnName, String op)
+ {
+ String regex = "(?i)" + prefix + "\\." + Pattern.quote(columnName) + "~" + Pattern.quote(op);
+ return Pattern.compile(regex).matcher(filterStr).find();
+ }
+
+ private static String replaceOp(String filterStr, String prefix, String columnName, String fromOp, String toOp)
+ {
+ String regex = "(?i)(" + prefix + "\\.)(" + Pattern.quote(columnName) + ")(~)" + Pattern.quote(fromOp);
+ Matcher m = Pattern.compile(regex).matcher(filterStr);
+ StringBuffer sb = new StringBuffer();
+ while (m.find())
+ {
+ // Preserve the literal 'filter.', 'columnName' and '~', but use the new operator
+ String replacement = m.group(1) + m.group(2) + m.group(3) + toOp;
+ m.appendReplacement(sb, Matcher.quoteReplacement(replacement));
+ }
+ m.appendTail(sb);
+ return sb.toString();
+ }
+
}
diff --git a/api/src/org/labkey/api/query/QueryService.java b/api/src/org/labkey/api/query/QueryService.java
index bb6d87b7614..942d6a79461 100644
--- a/api/src/org/labkey/api/query/QueryService.java
+++ b/api/src/org/labkey/api/query/QueryService.java
@@ -491,7 +491,7 @@ public String getDefaultCommentSummary()
void fireQueryCreated(User user, Container container, ContainerFilter scope, SchemaKey schema, Collection queries);
void fireQueryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, QueryChangeListener.QueryProperty property, Collection> changes);
void fireQueryDeleted(User user, Container container, ContainerFilter scope, SchemaKey schema, Collection queries);
-
+ void fireQueryColumnChanged(User user, Container container, @NotNull SchemaKey schemaPath, @NotNull String queryName, QueryChangeListener.QueryProperty property, Collection> changes);
/** OLAP **/
// could make this a separate service
diff --git a/core/package-lock.json b/core/package-lock.json
index c9c708e80a2..0c0fe83951b 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.13.0",
+ "@labkey/components": "7.14.0-fb-mvtc-convert.9",
"@labkey/themes": "1.5.0"
},
"devDependencies": {
@@ -3504,9 +3504,9 @@
}
},
"node_modules/@labkey/api": {
- "version": "1.45.0",
- "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.45.0.tgz",
- "integrity": "sha512-7KN2SvmcY46OtRBtlsUxlmGaE5LN/cg6OfPyc837pSGl+cIndPxOJMqFCvxO26h7c7Fd7cAK1/oOuAzAbvKHUw==",
+ "version": "1.45.1-fb-mvtc-convert.1",
+ "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.45.1-fb-mvtc-convert.1.tgz",
+ "integrity": "sha512-IlQwnZzi9whzKTBdAur3La0wJmIpFhMHpoQDIdKuo2NByygI+920EBGBiqjrSpTZYQbM0TnJjAZvIk0+5TtsWg==",
"license": "Apache-2.0"
},
"node_modules/@labkey/build": {
@@ -3547,13 +3547,13 @@
}
},
"node_modules/@labkey/components": {
- "version": "7.13.0",
- "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.13.0.tgz",
- "integrity": "sha512-+2o42no7q9IInKbvSd5XHDrnmLKucgudQ+7C2FD6ya+Da8mRu76GWG6L168iwbtMaguQZzFQmMGpD5VScWZiyQ==",
+ "version": "7.14.0-fb-mvtc-convert.9",
+ "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.14.0-fb-mvtc-convert.9.tgz",
+ "integrity": "sha512-Q2vID0lk3178iNbCmWqE9X1Yixpjo5PH4nfpxSC9dyy32mAj/O4SOtfYv3BLL1G3F9WHvD0Gpf0nokWTufRdMQ==",
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"@hello-pangea/dnd": "18.0.1",
- "@labkey/api": "1.45.0",
+ "@labkey/api": "1.45.1-fb-mvtc-convert.1",
"@testing-library/dom": "~10.4.1",
"@testing-library/jest-dom": "~6.9.1",
"@testing-library/react": "~16.3.0",
diff --git a/core/package.json b/core/package.json
index 2c1202f3221..b4215b8905f 100644
--- a/core/package.json
+++ b/core/package.json
@@ -53,7 +53,7 @@
}
},
"dependencies": {
- "@labkey/components": "7.13.0",
+ "@labkey/components": "7.14.0-fb-mvtc-convert.9",
"@labkey/themes": "1.5.0"
},
"devDependencies": {
diff --git a/core/src/org/labkey/core/dialect/PostgreSql92Dialect.java b/core/src/org/labkey/core/dialect/PostgreSql92Dialect.java
index c5fbcad586b..aadb283a813 100644
--- a/core/src/org/labkey/core/dialect/PostgreSql92Dialect.java
+++ b/core/src/org/labkey/core/dialect/PostgreSql92Dialect.java
@@ -43,6 +43,7 @@
import org.labkey.api.data.dialect.JdbcHelper;
import org.labkey.api.data.dialect.SqlDialect;
import org.labkey.api.data.dialect.StandardJdbcHelper;
+import org.labkey.api.exp.PropertyType;
import org.labkey.api.query.AliasManager;
import org.labkey.api.util.ConfigurationException;
import org.labkey.api.util.HtmlString;
@@ -630,6 +631,9 @@ private List getChangeColumnTypeStatement(TableChange change)
for (PropertyStorageSpec column : change.getColumns())
{
+ PropertyType oldPropertyType = null;
+ if (change.getOldPropTypes() != null)
+ oldPropertyType = change.getOldPropTypes().get(column.getName());
DatabaseIdentifier columnIdent = makePropertyIdentifier(column.getName());
if (column.getJdbcType().isDateOrTime())
{
@@ -661,6 +665,76 @@ private List getChangeColumnTypeStatement(TableChange change)
rename.append(" RENAME COLUMN ").appendIdentifier(tempColumnIdent).append(" TO ").appendIdentifier(columnIdent);
statements.add(rename);
}
+ else if (oldPropertyType == PropertyType.MULTI_CHOICE && column.getJdbcType().isText())
+ {
+ // Converting from text[] (array) to text requires an intermediate column and transformation
+ String tempColumnName = column.getName() + "~~temp~~";
+ DatabaseIdentifier tempColumnIdent = makePropertyIdentifier(tempColumnName);
+
+ // 1) ADD temp column of text type
+ SQLFragment addTemp = new SQLFragment("ALTER TABLE ");
+ addTemp.appendIdentifier(change.getSchemaName()).append(".").appendIdentifier(change.getTableName());
+ addTemp.append(" ADD COLUMN ").append(getSqlColumnSpec(column, tempColumnName));
+ statements.add(addTemp);
+
+ // 2) UPDATE: convert and copy value to temp column
+ // - NULL array -> NULL
+ // - empty array -> NULL
+ // - non-empty array -> concatenate array elements with comma (', ')
+ SQLFragment update = new SQLFragment("UPDATE ");
+ update.appendIdentifier(change.getSchemaName()).append(".").appendIdentifier(change.getTableName());
+ update.append(" SET ").appendIdentifier(tempColumnIdent).append(" = CASE ");
+ update.append(" WHEN ").appendIdentifier(columnIdent).append(" IS NULL THEN NULL ");
+ update.append(" WHEN COALESCE(array_length(").appendIdentifier(columnIdent).append(", 1), 0) = 0 THEN NULL ");
+ update.append(" ELSE array_to_string(").appendIdentifier(columnIdent).append(", ', ') END");
+ statements.add(update);
+
+ // 3) DROP original column
+ SQLFragment drop = new SQLFragment("ALTER TABLE ");
+ drop.appendIdentifier(change.getSchemaName()).append(".").appendIdentifier(change.getTableName());
+ drop.append(" DROP COLUMN ").appendIdentifier(columnIdent);
+ statements.add(drop);
+
+ // 4) RENAME temp column to original column name
+ SQLFragment rename = new SQLFragment("ALTER TABLE ");
+ rename.appendIdentifier(change.getSchemaName()).append(".").appendIdentifier(change.getTableName());
+ rename.append(" RENAME COLUMN ").appendIdentifier(tempColumnIdent).append(" TO ").appendIdentifier(columnIdent);
+ statements.add(rename);
+ }
+ else if (column.getJdbcType() == JdbcType.ARRAY)
+ {
+ // Converting from text to text[] requires an intermediate column and transformation
+ String tempColumnName = column.getName() + "~~temp~~";
+ DatabaseIdentifier tempColumnIdent = makePropertyIdentifier(tempColumnName);
+
+ // 1) ADD temp column of array type (e.g., text[])
+ SQLFragment addTemp = new SQLFragment("ALTER TABLE ");
+ addTemp.appendIdentifier(change.getSchemaName()).append(".").appendIdentifier(change.getTableName());
+ addTemp.append(" ADD COLUMN ").append(getSqlColumnSpec(column, tempColumnName));
+ statements.add(addTemp);
+
+ // 2) UPDATE: copy converted value to temp column as single-element array
+ // - NULL or blank ('') -> empty array []
+ // - otherwise -> single-element array [text]
+ SQLFragment update = new SQLFragment("UPDATE ");
+ update.appendIdentifier(change.getSchemaName()).append(".").appendIdentifier(change.getTableName());
+ update.append(" SET ").appendIdentifier(tempColumnIdent);
+ update.append(" = CASE WHEN ").appendIdentifier(columnIdent).append(" IS NULL OR ").appendIdentifier(columnIdent).append(" = '' THEN ARRAY[]::text[] ELSE ARRAY[");
+ update.appendIdentifier(columnIdent).append("]::text[] END");
+ statements.add(update);
+
+ // 3) DROP original column
+ SQLFragment drop = new SQLFragment("ALTER TABLE ");
+ drop.appendIdentifier(change.getSchemaName()).append(".").appendIdentifier(change.getTableName());
+ drop.append(" DROP COLUMN ").appendIdentifier(columnIdent);
+ statements.add(drop);
+
+ // 4) RENAME temp column to original column name
+ SQLFragment rename = new SQLFragment("ALTER TABLE ");
+ rename.appendIdentifier(change.getSchemaName()).append(".").appendIdentifier(change.getTableName());
+ rename.append(" RENAME COLUMN ").appendIdentifier(tempColumnIdent).append(" TO ").appendIdentifier(columnIdent);
+ statements.add(rename);
+ }
else
{
String dbType;
@@ -1085,6 +1159,12 @@ public SQLFragment array_construct(SQLFragment[] elements)
return ret;
}
+ @Override
+ public SQLFragment array_is_empty(SQLFragment a)
+ {
+ return new SQLFragment("(cardinality(").append(a).append(")=0)");
+ }
+
@Override
public SQLFragment array_all_in_array(SQLFragment a, SQLFragment b)
{
diff --git a/experiment/package-lock.json b/experiment/package-lock.json
index c3ed56aedca..60cc1d0d0d1 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.13.0"
+ "@labkey/components": "7.14.0-fb-mvtc-convert.9"
},
"devDependencies": {
"@labkey/build": "8.7.0",
@@ -3271,9 +3271,9 @@
}
},
"node_modules/@labkey/api": {
- "version": "1.45.0",
- "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.45.0.tgz",
- "integrity": "sha512-7KN2SvmcY46OtRBtlsUxlmGaE5LN/cg6OfPyc837pSGl+cIndPxOJMqFCvxO26h7c7Fd7cAK1/oOuAzAbvKHUw==",
+ "version": "1.45.1-fb-mvtc-convert.1",
+ "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.45.1-fb-mvtc-convert.1.tgz",
+ "integrity": "sha512-IlQwnZzi9whzKTBdAur3La0wJmIpFhMHpoQDIdKuo2NByygI+920EBGBiqjrSpTZYQbM0TnJjAZvIk0+5TtsWg==",
"license": "Apache-2.0"
},
"node_modules/@labkey/build": {
@@ -3314,13 +3314,13 @@
}
},
"node_modules/@labkey/components": {
- "version": "7.13.0",
- "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.13.0.tgz",
- "integrity": "sha512-+2o42no7q9IInKbvSd5XHDrnmLKucgudQ+7C2FD6ya+Da8mRu76GWG6L168iwbtMaguQZzFQmMGpD5VScWZiyQ==",
+ "version": "7.14.0-fb-mvtc-convert.9",
+ "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.14.0-fb-mvtc-convert.9.tgz",
+ "integrity": "sha512-Q2vID0lk3178iNbCmWqE9X1Yixpjo5PH4nfpxSC9dyy32mAj/O4SOtfYv3BLL1G3F9WHvD0Gpf0nokWTufRdMQ==",
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"@hello-pangea/dnd": "18.0.1",
- "@labkey/api": "1.45.0",
+ "@labkey/api": "1.45.1-fb-mvtc-convert.1",
"@testing-library/dom": "~10.4.1",
"@testing-library/jest-dom": "~6.9.1",
"@testing-library/react": "~16.3.0",
diff --git a/experiment/package.json b/experiment/package.json
index 6601245661c..923f70b43a4 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.13.0"
+ "@labkey/components": "7.14.0-fb-mvtc-convert.9"
},
"devDependencies": {
"@labkey/build": "8.7.0",
diff --git a/experiment/src/org/labkey/experiment/ExperimentQueryChangeListener.java b/experiment/src/org/labkey/experiment/ExperimentQueryChangeListener.java
index f0a6c6d5201..177dc34ab45 100644
--- a/experiment/src/org/labkey/experiment/ExperimentQueryChangeListener.java
+++ b/experiment/src/org/labkey/experiment/ExperimentQueryChangeListener.java
@@ -63,7 +63,7 @@ private List getRenamedDataClasses(Container container, String
}
@Override
- public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @NotNull QueryProperty property, @NotNull Collection> changes)
+ public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, String queryName, @NotNull QueryProperty property, @NotNull Collection> changes)
{
boolean isSamples = schema.toString().equalsIgnoreCase("samples");
boolean isData = schema.toString().equalsIgnoreCase("exp.data");
diff --git a/experiment/src/org/labkey/experiment/PropertyQueryChangeListener.java b/experiment/src/org/labkey/experiment/PropertyQueryChangeListener.java
index 65e879b53bd..e29ccc60340 100644
--- a/experiment/src/org/labkey/experiment/PropertyQueryChangeListener.java
+++ b/experiment/src/org/labkey/experiment/PropertyQueryChangeListener.java
@@ -61,7 +61,7 @@ private void updateLookupSchema(String newValue, String oldSchema, Container con
}
@Override
- public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @NotNull QueryProperty property, @NotNull Collection> changes)
+ public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, String queryName, @NotNull QueryProperty property, @NotNull Collection> changes)
{
if (!property.equals(QueryProperty.SchemaName) && !property.equals(QueryProperty.Name)) // Issue 53846
return;
diff --git a/experiment/src/org/labkey/experiment/api/property/DomainPropertyImpl.java b/experiment/src/org/labkey/experiment/api/property/DomainPropertyImpl.java
index 2b10c8c8718..12de3e7382a 100644
--- a/experiment/src/org/labkey/experiment/api/property/DomainPropertyImpl.java
+++ b/experiment/src/org/labkey/experiment/api/property/DomainPropertyImpl.java
@@ -26,6 +26,7 @@
import org.labkey.api.data.ColumnRenderPropertiesImpl;
import org.labkey.api.data.ConditionalFormat;
import org.labkey.api.data.Container;
+import org.labkey.api.data.ContainerFilter;
import org.labkey.api.data.ContainerManager;
import org.labkey.api.data.DatabaseIdentifier;
import org.labkey.api.data.JdbcType;
@@ -33,6 +34,7 @@
import org.labkey.api.data.SQLFragment;
import org.labkey.api.data.SqlExecutor;
import org.labkey.api.data.Table;
+import org.labkey.api.data.TableInfo;
import org.labkey.api.data.dialect.SqlDialect;
import org.labkey.api.exp.ChangePropertyDescriptorException;
import org.labkey.api.exp.DomainDescriptor;
@@ -52,6 +54,8 @@
import org.labkey.api.gwt.client.DefaultScaleType;
import org.labkey.api.gwt.client.DefaultValueType;
import org.labkey.api.gwt.client.FacetingBehaviorType;
+import org.labkey.api.query.QueryChangeListener;
+import org.labkey.api.query.SchemaKey;
import org.labkey.api.security.User;
import org.labkey.api.util.StringExpressionFactory;
import org.labkey.api.util.TestContext;
@@ -840,6 +844,11 @@ else if (newType.getJdbcType().isDateOrTime() && oldType.getJdbcType().isDateOrT
changedType = true;
_pd.setFormat(null);
}
+ else if (newType == PropertyType.MULTI_CHOICE || oldType == PropertyType.MULTI_CHOICE)
+ {
+ changedType = true;
+ _pd.setFormat(null);
+ }
else
{
throw new ChangePropertyDescriptorException("Cannot convert an instance of " + oldType.getJdbcType() + " to " + newType.getJdbcType() + ".");
@@ -873,13 +882,21 @@ else if (newType.getJdbcType().isDateOrTime() && oldType.getJdbcType().isDateOrT
if (changedType)
{
+ var domainKind = _domain.getDomainKind();
+ if (domainKind == null)
+ throw new ChangePropertyDescriptorException("Cannot change property type for domain, unknown domain kind.");
+
StorageProvisionerImpl.get().changePropertyType(this.getDomain(), this);
if (_pdOld.getJdbcType() == JdbcType.BOOLEAN && _pd.getJdbcType().isText())
{
updateBooleanValue(
- new SQLFragment().appendIdentifier(_domain.getDomainKind().getStorageSchemaName()).append(".").appendIdentifier(_domain.getStorageTableName()),
+ new SQLFragment().appendIdentifier(domainKind.getStorageSchemaName()).append(".").appendIdentifier(_domain.getStorageTableName()),
_pd.getLegalSelectName(dialect), _pdOld.getFormat(), null); // GitHub Issue #647
}
+
+ TableInfo table = domainKind.getTableInfo(user, getContainer(), _domain, ContainerFilter.getUnsafeEverythingFilter());
+ if (table != null && _pdOld.getPropertyType() != null)
+ QueryChangeListener.QueryPropertyChange.handleColumnTypeChange(_pdOld, _pd, SchemaKey.fromString(table.getUserSchema().getSchemaName()), table.getName(), user, getContainer());
}
else if (propResized)
StorageProvisionerImpl.get().resizeProperty(this.getDomain(), this, _pdOld.getScale());
diff --git a/experiment/src/org/labkey/experiment/api/property/StorageProvisionerImpl.java b/experiment/src/org/labkey/experiment/api/property/StorageProvisionerImpl.java
index 727423a1a70..a2e110b17ae 100644
--- a/experiment/src/org/labkey/experiment/api/property/StorageProvisionerImpl.java
+++ b/experiment/src/org/labkey/experiment/api/property/StorageProvisionerImpl.java
@@ -66,6 +66,7 @@
import org.labkey.api.exp.OntologyManager;
import org.labkey.api.exp.PropertyColumn;
import org.labkey.api.exp.PropertyDescriptor;
+import org.labkey.api.exp.PropertyType;
import org.labkey.api.exp.api.ExperimentUrls;
import org.labkey.api.exp.api.StorageProvisioner;
import org.labkey.api.exp.property.AbstractDomainKind;
@@ -112,6 +113,8 @@
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
+import static org.labkey.api.data.ColumnRenderPropertiesImpl.TEXT_CHOICE_CONCEPT_URI;
+
/**
* Creates and maintains "hard" tables in the underlying database based on dynamically configured data types.
* Will do CREATE TABLE and ALTER TABLE statements to make sure the table has the right set of requested columns.
@@ -573,9 +576,40 @@ public void changePropertyType(Domain domain, DomainProperty prop) throws Change
Set base = Sets.newCaseInsensitiveHashSet();
kind.getBaseProperties(domain).forEach(s -> base.add(s.getName()));
+ Map oldPropTypes = new HashMap<>();
if (!base.contains(prop.getName()))
+ {
+ if (prop instanceof DomainPropertyImpl dpi)
+ {
+ var oldPd = dpi._pdOld;
+ if (oldPd != null)
+ {
+ var newPd = dpi._pd;
+ if (oldPd.getPropertyType() == PropertyType.MULTI_CHOICE && TEXT_CHOICE_CONCEPT_URI.equals(newPd.getConceptURI()))
+ {
+ var selectColumnName = prop.getPropertyDescriptor().getLegalSelectName(scope.getSqlDialect());
+ SQLFragment sql = new SQLFragment("SELECT COUNT(*) FROM ")
+ .appendDottedIdentifiers(kind.getStorageSchemaName(), domain.getStorageTableName())
+ .append(" WHERE ")
+ .appendIdentifier(selectColumnName)
+ .append(" IS NOT NULL AND array_length(")
+ .appendIdentifier(selectColumnName)
+ .append(", 1) > 1");
+ long count = new SqlSelector(scope, sql).getObject(Long.class);
+ if (count > 0)
+ {
+ throw new ChangePropertyDescriptorException("Unable to change property type. There are rows with multiple values stored for '" + prop.getName() + "'.");
+ }
+ }
+ oldPropTypes.put(prop.getName(), oldPd.getPropertyType());
+ }
+
+ }
+
propChange.addColumn(prop.getPropertyDescriptor());
+ }
+ propChange.setOldPropertyTypes(oldPropTypes);
propChange.execute();
}
diff --git a/query/src/org/labkey/query/CustomViewQueryChangeListener.java b/query/src/org/labkey/query/CustomViewQueryChangeListener.java
index d1c16e0fee3..39335d738c8 100644
--- a/query/src/org/labkey/query/CustomViewQueryChangeListener.java
+++ b/query/src/org/labkey/query/CustomViewQueryChangeListener.java
@@ -20,6 +20,8 @@
import org.labkey.api.collections.CaseInsensitiveHashMap;
import org.labkey.api.data.Container;
import org.labkey.api.data.ContainerFilter;
+import org.labkey.api.exp.PropertyDescriptor;
+import org.labkey.api.exp.property.DomainProperty;
import org.labkey.api.query.CustomView;
import org.labkey.api.query.CustomViewChangeListener;
import org.labkey.api.query.CustomViewInfo;
@@ -28,6 +30,10 @@
import org.labkey.api.query.QueryService;
import org.labkey.api.query.SchemaKey;
import org.labkey.api.security.User;
+import org.labkey.api.exp.PropertyType;
+
+import java.util.regex.Pattern;
+import java.util.regex.Matcher;
import org.springframework.mock.web.MockHttpServletRequest;
import jakarta.servlet.http.HttpServletRequest;
@@ -55,7 +61,7 @@ public void queryCreated(User user, Container container, ContainerFilter scope,
}
@Override
- public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @NotNull QueryProperty property, @NotNull Collection> changes)
+ public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, String queryName, @NotNull QueryProperty property, @NotNull Collection> changes)
{
if (property.equals(QueryProperty.Name))
{
@@ -65,6 +71,66 @@ public void queryChanged(User user, Container container, ContainerFilter scope,
{
_updateCustomViewSchemaNameChange(user, container, changes);
}
+ if (property.equals(QueryProperty.ColumnType))
+ {
+ _updateCustomViewColumnTypeChange(user, container, schema, queryName, changes);
+ }
+ }
+
+
+ private void _updateCustomViewColumnTypeChange(User user, Container container, SchemaKey schema, String queryName, @NotNull Collection> changes)
+ {
+ for (QueryPropertyChange> qpc : changes)
+ {
+
+ PropertyDescriptor oldDp = (PropertyDescriptor) qpc.getOldValue();
+ PropertyDescriptor newDp = (PropertyDescriptor) qpc.getNewValue();
+
+ if (oldDp == null || newDp == null)
+ continue;
+
+ String columnName = newDp.getName() == null ? oldDp.getName() : newDp.getName();
+
+ List databaseCustomViews = QueryService.get().getDatabaseCustomViews(user, container, null, schema.toString(), queryName, false, false);
+
+ for (CustomView customView : databaseCustomViews)
+ {
+ try
+ {
+ // update custom view filter and sort based on column type change
+ String filterAndSort = customView.getFilterAndSort();
+ if (filterAndSort == null || filterAndSort.isEmpty())
+ continue;
+
+ /* Example:
+ * "/?filter.MCF2~arrayisnotempty=&filter.Name~in=S-5%3BS-6%3BS-8%3BS-9&filter.MCF~arraycontainsall=2%3B1%3B3&filter.sort=zz"
+ */
+ String prefix = filterAndSort.startsWith("/?") ? "/?" : (filterAndSort.startsWith("?") ? "?" : "");
+ String[] filterComponents = filterAndSort.substring(prefix.length()).split("&");
+ StringBuilder updatedFilterAndSort = new StringBuilder(prefix);
+ String sep = "";
+ for (String filterPart : filterComponents)
+ {
+ String updatedPart = QueryChangeListener.getUpdatedFilterStrOnColumnTypeUpdate(filterPart, "filter", columnName, oldDp, newDp);
+ updatedFilterAndSort.append(sep).append(updatedPart);
+ sep = "&";
+ }
+
+ String updatedFilterAndSortStr = updatedFilterAndSort.toString();
+ if (!updatedFilterAndSortStr.equals(filterAndSort))
+ {
+ customView.setFilterAndSort(updatedFilterAndSortStr);
+ HttpServletRequest request = new MockHttpServletRequest();
+ customView.save(customView.getModifiedBy(), request);
+ }
+ }
+ catch (Exception e)
+ {
+ LogManager.getLogger(CustomViewQueryChangeListener.class).error("An error occurred upgrading custom view properties: ", e);
+ }
+ }
+ }
+
}
@Override
diff --git a/query/src/org/labkey/query/QueryDefQueryChangeListener.java b/query/src/org/labkey/query/QueryDefQueryChangeListener.java
index ec74cb74dc3..8f9ae84eae9 100644
--- a/query/src/org/labkey/query/QueryDefQueryChangeListener.java
+++ b/query/src/org/labkey/query/QueryDefQueryChangeListener.java
@@ -20,7 +20,7 @@ public void queryCreated(User user, Container container, ContainerFilter scope,
{}
@Override
- public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @NotNull QueryProperty property, @NotNull Collection> changes)
+ public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, String queryName, @NotNull QueryProperty property, @NotNull Collection> changes)
{
if (property.equals(QueryProperty.Name))
{
diff --git a/query/src/org/labkey/query/QueryServiceImpl.java b/query/src/org/labkey/query/QueryServiceImpl.java
index d44943d4f2a..e361786fa82 100644
--- a/query/src/org/labkey/query/QueryServiceImpl.java
+++ b/query/src/org/labkey/query/QueryServiceImpl.java
@@ -309,6 +309,8 @@ public void moduleChanged(Module module)
CompareType.NONBLANK,
CompareType.MV_INDICATOR,
CompareType.NO_MV_INDICATOR,
+ CompareType.ARRAY_IS_EMPTY,
+ CompareType.ARRAY_IS_NOT_EMPTY,
CompareType.ARRAY_CONTAINS_ALL,
CompareType.ARRAY_CONTAINS_ANY,
CompareType.ARRAY_CONTAINS_NONE,
@@ -3268,7 +3270,13 @@ public void fireQueryCreated(User user, Container container, ContainerFilter sco
@Override
public void fireQueryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, QueryChangeListener.QueryProperty property, Collection> changes)
{
- QueryManager.get().fireQueryChanged(user, container, scope, schema, property, changes);
+ QueryManager.get().fireQueryChanged(user, container, scope, schema, null, property, changes);
+ }
+
+ @Override
+ public void fireQueryColumnChanged(User user, Container container, @NotNull SchemaKey schemaPath, @NotNull String queryName, QueryChangeListener.QueryProperty property, Collection> changes)
+ {
+ QueryManager.get().fireQueryChanged(user, container, null, schemaPath, queryName, property, changes);
}
@Override
diff --git a/query/src/org/labkey/query/QuerySnapshotQueryChangeListener.java b/query/src/org/labkey/query/QuerySnapshotQueryChangeListener.java
index ef497ad7be1..4d20461cb46 100644
--- a/query/src/org/labkey/query/QuerySnapshotQueryChangeListener.java
+++ b/query/src/org/labkey/query/QuerySnapshotQueryChangeListener.java
@@ -44,7 +44,7 @@ public void queryCreated(User user, Container container, ContainerFilter scope,
}
@Override
- public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @NotNull QueryProperty property, @NotNull Collection> changes)
+ public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, String queryName, @NotNull QueryProperty property, @NotNull Collection> changes)
{
if (property.equals(QueryProperty.Name))
{
diff --git a/query/src/org/labkey/query/QueryTestCase.jsp b/query/src/org/labkey/query/QueryTestCase.jsp
index 5cf3e7d3446..1831ba89ada 100644
--- a/query/src/org/labkey/query/QueryTestCase.jsp
+++ b/query/src/org/labkey/query/QueryTestCase.jsp
@@ -1954,6 +1954,10 @@ d,seven,twelve,day,month,date,duration,guid
SELECT 'f' as test, true as expected, array_contains_element( ARRAY['A','B'], 'B') as result
UNION ALL
SELECT 'g' as test, false as expected, array_contains_element( ARRAY['A','B'], 'X') as result
+ UNION ALL
+ SELECT 'h' as test, true as expected, array_contains_any( ARRAY['\"A','X'], ARRAY['\"A','B'] ) as result
+ UNION ALL
+ SELECT 'i' as test, true as expected, array_is_same( ARRAY['A;','X'], ARRAY['A;','X'] ) as result
""";
Container container = JunitUtil.getTestContainer();
diff --git a/query/src/org/labkey/query/persist/QueryManager.java b/query/src/org/labkey/query/persist/QueryManager.java
index 28e10d84736..c041d8c9a2d 100644
--- a/query/src/org/labkey/query/persist/QueryManager.java
+++ b/query/src/org/labkey/query/persist/QueryManager.java
@@ -518,12 +518,12 @@ public void fireQueryCreated(User user, Container container, ContainerFilter sco
l.queryCreated(user, container, scope, schema, queries);
}
- public void fireQueryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @NotNull QueryChangeListener.QueryProperty property, @NotNull Collection> changes)
+ public void fireQueryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @Nullable String queryName, @NotNull QueryChangeListener.QueryProperty property, @NotNull Collection> changes)
{
QueryService.get().updateLastModified();
assert checkChanges(property, changes);
for (QueryChangeListener l : QUERY_LISTENERS)
- l.queryChanged(user, container, scope, schema, property, changes);
+ l.queryChanged(user, container, scope, schema, queryName, property, changes);
}
// Checks all changes have the correct property and type.
diff --git a/query/src/org/labkey/query/reports/ReportQueryChangeListener.java b/query/src/org/labkey/query/reports/ReportQueryChangeListener.java
index 57ff5483034..f4c05a89536 100644
--- a/query/src/org/labkey/query/reports/ReportQueryChangeListener.java
+++ b/query/src/org/labkey/query/reports/ReportQueryChangeListener.java
@@ -75,7 +75,7 @@ public void queryCreated(User user, Container container, ContainerFilter scope,
}
@Override
- public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @NotNull QueryProperty property, @NotNull Collection> changes)
+ public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, String queryName, @NotNull QueryProperty property, @NotNull Collection> changes)
{
if (property.equals(QueryProperty.Name))
{
diff --git a/query/src/org/labkey/query/sql/Method.java b/query/src/org/labkey/query/sql/Method.java
index 77214c9d348..b6377660675 100644
--- a/query/src/org/labkey/query/sql/Method.java
+++ b/query/src/org/labkey/query/sql/Method.java
@@ -1573,6 +1573,34 @@ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments)
}
}
+ public static class ArrayIsEmptyMethod extends Method
+ {
+ ArrayIsEmptyMethod(String name)
+ {
+ super(name, JdbcType.BOOLEAN, 1, 1);
+ }
+
+ @Override
+ public MethodInfo getMethodInfo()
+ {
+ return new AbstractMethodInfo(JdbcType.BOOLEAN)
+ {
+ @Override
+ public JdbcType getJdbcType(JdbcType[] args)
+ {
+ if (1 == args.length && args[0] != JdbcType.ARRAY)
+ throw new QueryParseException(_name + " requires an argument of type ARRAY", null, -1, -1);
+ return super.getJdbcType(args);
+ }
+
+ @Override
+ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments)
+ {
+ return dialect.array_is_empty(arguments[0]);
+ }
+ };
+ }
+ }
final static Map postgresMethods = Collections.synchronizedMap(new CaseInsensitiveHashMap<>());
@@ -1650,6 +1678,8 @@ private static void addPostgresArrayMethods()
// not array_equals() because arrays are ordered, this is an unordered comparison
postgresMethods.put("array_is_same", new ArrayOperatorMethod("array_is_same", SqlDialect::array_same_array));
// Use "NOT array_is_same()" instead of something clumsy like "array_is_not_same()"
+
+ postgresMethods.put("array_is_empty", new ArrayIsEmptyMethod("array_is_empty"));
}
diff --git a/study/src/org/labkey/study/query/QueryDatasetQueryChangeListener.java b/study/src/org/labkey/study/query/QueryDatasetQueryChangeListener.java
index 043bf89c69d..fa4823fd99b 100644
--- a/study/src/org/labkey/study/query/QueryDatasetQueryChangeListener.java
+++ b/study/src/org/labkey/study/query/QueryDatasetQueryChangeListener.java
@@ -25,7 +25,7 @@ public void queryCreated(User user, Container container, ContainerFilter scope,
}
@Override
- public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, @NotNull QueryProperty property, @NotNull Collection> changes)
+ public void queryChanged(User user, Container container, ContainerFilter scope, SchemaKey schema, String queryName, @NotNull QueryProperty property, @NotNull Collection> changes)
{
if (property.equals(QueryProperty.Name))
{