diff --git a/api/src/org/labkey/api/query/QueryChangeListener.java b/api/src/org/labkey/api/query/QueryChangeListener.java index 72cda199c3f..aa6c83abbe1 100644 --- a/api/src/org/labkey/api/query/QueryChangeListener.java +++ b/api/src/org/labkey/api/query/QueryChangeListener.java @@ -211,10 +211,41 @@ public static void handleColumnTypeChange(@NotNull PropertyDescriptor oldValue, public V getNewValue() { return _newValue; } } + // URL-encoded fragments used when a single filter value containing ";" is stored as a JSON array for MVTC + String JSON_ARRAY_FILTER_PREFIX = "%7Bjson%3A%5B%22"; // {json:[" + String JSON_ARRAY_FILTER_SUFFIX = "%22%5D%7D"; // "]} + String ENCODED_SEMICOLON = "%3B"; // ; + /** * 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). */ + /** + * Resolves the URL-encoded form of {@code columnName} that matches the given filter string. + *
+ * Tries first with the single quote left unencoded (Issue 943), then with it encoded as {@code %27}
+ * (Issue 1092 — Chrome and some browsers do encode {@code '} to {@code %27}).
+ *
+ * @return the encoded column name whose lowercase form matches the filter prefix, or {@code null} if neither encoding matches
+ */
+ @Nullable
+ private static String resolveEncodedColumnName(String filterStr, String prefix, String columnName)
+ {
+ String sLower = filterStr.toLowerCase();
+
+ // GitHub Issue 943: single quote should decode to match url filter
+ String encoded = PageFlowUtil.encodeURIComponent(QueryKey.encodePart(columnName), true);
+ if (sLower.startsWith(prefix + "." + encoded.toLowerCase() + "~"))
+ return encoded;
+
+ // GitHub Issue 1092: some browsers (e.g. Chrome) encode ' to %27
+ encoded = PageFlowUtil.encodeURIComponent(QueryKey.encodePart(columnName), false);
+ if (sLower.startsWith(prefix + "." + encoded.toLowerCase() + "~"))
+ return encoded;
+
+ return null;
+ }
+
private static String getUpdatedFilterStrFromMVTC(String filterStr, String prefix, String columnName, @NotNull PropertyDescriptor oldType, @NotNull PropertyDescriptor newType)
{
if (filterStr == null || columnName == null)
@@ -224,33 +255,30 @@ private static String getUpdatedFilterStrFromMVTC(String filterStr, String prefi
if (oldType.getPropertyType() != PropertyType.MULTI_CHOICE || newType.getPropertyType() == PropertyType.MULTI_CHOICE)
return filterStr;
- // GitHub Issue 943: Converting between TC and MVTC results in bad saved views
- // single quote should decode to match url filter
- String columnNameEncoded = PageFlowUtil.encodeURIComponent(QueryKey.encodePart(columnName), true);
-
- String colLower = columnNameEncoded.toLowerCase();
- String sLower = filterStr.toLowerCase();
-
- // No action if column doesn't match
- if (!sLower.startsWith(prefix + "." + colLower + "~"))
+ String columnNameEncoded = resolveEncodedColumnName(filterStr, prefix, columnName);
+ if (columnNameEncoded == null)
return filterStr;
// drop arraycontainsall since there is no good match
- if (sLower.startsWith(prefix + "." + colLower + "~arraycontainsall"))
+ if (filterStr.toLowerCase().startsWith(prefix + "." + columnNameEncoded.toLowerCase() + "~arraycontainsall"))
return "";
String updated = filterStr;
+ // GitHub Issue 1096: Filters in saved views break after MVTC → TC conversion if column value contains semicolon
+ // if the single value contains ";", the filter is saved as {json:[]} for MVTC, needs to convert to multi value filter types (for example, 'in' instead of 'eq').
+ boolean isJsonArrayFilterValue = filterStr.indexOf(JSON_ARRAY_FILTER_PREFIX) > 0 && filterStr.endsWith(JSON_ARRAY_FILTER_SUFFIX);
+
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");
+ return replaceOp(updated, prefix, columnNameEncoded, "arraymatches", isJsonArrayFilterValue ? "in" : "eq");
}
if (containsOp(updated, prefix, columnNameEncoded, "arraynotmatches"))
{
- return replaceOp(updated, prefix, columnNameEncoded, "arraynotmatches", "neqornull");
+ return replaceOp(updated, prefix, columnNameEncoded, "arraynotmatches", isJsonArrayFilterValue ? "notin" : "neqornull");
}
}
@@ -287,30 +315,13 @@ private static String getUpdatedMVTCFilterStr(String filterStr, String prefix, S
if (oldType.getPropertyType() == PropertyType.MULTI_CHOICE || newType.getPropertyType() != PropertyType.MULTI_CHOICE)
return filterStr;
- String columnNameEncoded = PageFlowUtil.encodeURIComponent(QueryKey.encodePart(columnName), true);
-
- String colLower = columnNameEncoded.toLowerCase();
- String sLower = filterStr.toLowerCase();
-
- // No action if column doesn't match
- if (!sLower.startsWith(prefix + "." + colLower + "~"))
+ String columnNameEncoded = resolveEncodedColumnName(filterStr, prefix, columnName);
+ if (columnNameEncoded == null)
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, "neqornull"))
- {
- return replaceOp(updated, prefix, columnNameEncoded, "neqornull", "arraycontainsnone");
- }
- 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");
@@ -328,10 +339,45 @@ private static String getUpdatedMVTCFilterStr(String filterStr, String prefix, S
return replaceOp(updated, prefix, columnNameEncoded, "notin", "arraycontainsnone");
}
+ if (containsOp(updated, prefix, columnNameEncoded, "eq"))
+ {
+ updated = replaceOp(updated, prefix, columnNameEncoded, "eq", "arraymatches");
+ return toSafeArrayFilter(updated, "arraymatches");
+ }
+ if (containsOp(updated, prefix, columnNameEncoded, "neqornull"))
+ {
+ updated = replaceOp(updated, prefix, columnNameEncoded, "neqornull", "arraycontainsnone");
+ return toSafeArrayFilter(updated, "arraycontainsnone");
+ }
+ if (containsOp(updated, prefix, columnNameEncoded, "neq"))
+ {
+ updated = replaceOp(updated, prefix, columnNameEncoded, "neq", "arraycontainsnone");
+ return toSafeArrayFilter(updated, "arraycontainsnone");
+ }
+
+
// No matching operator found for this column, drop the filter
return "";
}
+ private static String toSafeArrayFilter(String filterStr, String updatedOp)
+ {
+ // GitHub Issue 1096: Filters in saved views break after TC → MVTC conversion if column value contains semicolon
+ String filter = "~" + updatedOp + "=";
+ String[] parts = filterStr.split(filter);
+ if (parts.length < 2)
+ return filterStr;
+
+ String valuePart = parts[1];
+
+ boolean isJsonArrayFilterValue = valuePart.startsWith(JSON_ARRAY_FILTER_PREFIX) && valuePart.endsWith(JSON_ARRAY_FILTER_SUFFIX);
+ // if the single value filter value contains ";", drop the filter after converting to array type filter since the filter value is no longer valid
+ if (!isJsonArrayFilterValue && valuePart.contains(ENCODED_SEMICOLON))
+ return "";
+
+ return filterStr;
+ }
+
static String getUpdatedFilterStrOnColumnTypeUpdate(String filterStr, String prefix, String columnName, @NotNull PropertyDescriptor oldType, @NotNull PropertyDescriptor newType)
{
if (oldType.getPropertyType() == PropertyType.MULTI_CHOICE)
diff --git a/query/src/org/labkey/query/QueryServiceImpl.java b/query/src/org/labkey/query/QueryServiceImpl.java
index 586567167b6..fde24951ed3 100644
--- a/query/src/org/labkey/query/QueryServiceImpl.java
+++ b/query/src/org/labkey/query/QueryServiceImpl.java
@@ -2105,7 +2105,8 @@ private ColumnInfo resolveFieldKey(FieldKey fieldKey, TableInfo table, Map