Skip to content

Fix sql LIKE usage to properly escape special characters #6837

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions api/src/org/labkey/api/data/dialect/SqlDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.labkey.api.collections.CsvSet;
import org.labkey.api.collections.Sets;
import org.labkey.api.data.ColumnInfo;
import org.labkey.api.data.CompareType;
import org.labkey.api.data.ConnectionWrapper;
import org.labkey.api.data.CoreSchema;
import org.labkey.api.data.DatabaseTableType;
Expand Down Expand Up @@ -535,6 +536,40 @@ public SQLFragment appendInClauseSql(SQLFragment sql, @NotNull Collection<?> par
return DEFAULT_GENERATOR.appendInClauseSql(sql, params);
}

public SQLFragment appendCaseInsensitiveLikeClause(SQLFragment sql, @NotNull String matchStr, @Nullable String wildcardPrefix, @Nullable String wildcardSuffix, char escapeChar)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: It would be nice to get ConvertType.QClause.toSQLFragment() converted over to using this.

{
String prefix = wildcardPrefix != null ? wildcardPrefix : "";
String suffix = wildcardSuffix != null ? wildcardSuffix : "";
String prefixLike = prefix + CompareType.escapeLikePattern(matchStr, escapeChar) + suffix;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be using concatenate() as furnished by the dialect?

Copy link
Contributor

@labkey-matthewb labkey-matthewb Jul 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If matchStr were not constant (e.g. SQLFragment) then yes, but since these are all constant strings, this makes more sense.

String escapeToken = " ESCAPE '" + escapeChar + "'";
sql.append(" ")
.append(getCaseInsensitiveLikeOperator())
.append(" ")
.appendValue(prefixLike)
.append(escapeToken);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think escapeToken is always !, but it would be better to use quoteStringLiteral()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For sql server, quoteStringLiteral adds a "N" prefix and that doesn't work for the escape character.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it

return sql;
}

public SQLFragment appendCaseInsensitiveLikeClause(SQLFragment sql, @NotNull String matchStr, @Nullable String wildcardPrefix, @Nullable String wildcardSuffix)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend unit tests for each of these incantations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

junit tests added

{
return appendCaseInsensitiveLikeClause(sql, matchStr, wildcardPrefix, wildcardSuffix, '!');
}

public SQLFragment appendCaseInsensitiveLikeClause(SQLFragment sql, @NotNull String matchStr)
{
return appendCaseInsensitiveLikeClause(sql, matchStr, "%", "%", '!');
}

public SQLFragment appendCaseInsensitiveStartsWith(SQLFragment sql, @NotNull String matchStr)
{
return appendCaseInsensitiveLikeClause(sql, matchStr, null, "%", '!');
}

public SQLFragment appendCaseInsensitiveEndsWith(SQLFragment sql, @NotNull String matchStr)
{
return appendCaseInsensitiveLikeClause(sql, matchStr, "%", null, '!');
}

public abstract boolean requiresStatementMaxRows();

/**
Expand Down Expand Up @@ -2110,6 +2145,7 @@ public void testScopes()
this.s = scope;
this.d = scope.getSqlDialect();
testDialectStringHandler();
testLikeOperator();
});
}

Expand All @@ -2136,5 +2172,17 @@ void testDialectStringHandler()
for (String v : Arrays.asList("\\b", "\\f", "\\n", "\\r", "\\t", "\\1", "\\22", "\\333", "\\xf", "\\x20", "\\1234", "\\U12345678"))
testEquals(v, new SQLFragment("SELECT ").appendStringLiteral(v, d));
}

void testLikeOperator()
{
String stringLiteralPrefix = d.isSqlServer() ? " N" : " ";
assertEquals("SELECT * FROM A WHERE Name " + d.getCaseInsensitiveLikeOperator() + stringLiteralPrefix + "'ABC%' ESCAPE '!'", d.appendCaseInsensitiveStartsWith(new SQLFragment("SELECT * FROM A WHERE Name"), "ABC").toDebugString());
assertEquals("SELECT * FROM A WHERE Name " + d.getCaseInsensitiveLikeOperator() + stringLiteralPrefix + "'a!%bc%' ESCAPE '!'", d.appendCaseInsensitiveStartsWith(new SQLFragment("SELECT * FROM A WHERE Name"), "a%bc").toDebugString());
assertEquals("SELECT * FROM A WHERE Name " + d.getCaseInsensitiveLikeOperator() + stringLiteralPrefix + "'%ab!_C' ESCAPE '!'", d.appendCaseInsensitiveEndsWith(new SQLFragment("SELECT * FROM A WHERE Name"), "ab_C").toDebugString());
assertEquals("SELECT * FROM A WHERE Name " + d.getCaseInsensitiveLikeOperator() + stringLiteralPrefix + "'%a![b]C%' ESCAPE '!'", d.appendCaseInsensitiveLikeClause(new SQLFragment("SELECT * FROM A WHERE Name"), "a[b]C").toDebugString());
assertEquals("SELECT * FROM A WHERE Name " + d.getCaseInsensitiveLikeOperator() + stringLiteralPrefix + "'a![b]C_' ESCAPE '!'", d.appendCaseInsensitiveLikeClause(new SQLFragment("SELECT * FROM A WHERE Name"), "a[b]C", null, "_").toDebugString());
assertEquals("SELECT * FROM A WHERE Name " + d.getCaseInsensitiveLikeOperator() + stringLiteralPrefix + "'_a!_![b]C%' ESCAPE '!'", d.appendCaseInsensitiveLikeClause(new SQLFragment("SELECT * FROM A WHERE Name"), "a_[b]C", "_", "%").toDebugString());
assertEquals("SELECT * FROM A WHERE Name " + d.getCaseInsensitiveLikeOperator() + stringLiteralPrefix + "'_a[_[[b]C!d%' ESCAPE '['", d.appendCaseInsensitiveLikeClause(new SQLFragment("SELECT * FROM A WHERE Name"), "a_[b]C!d", "_", "%", '[').toDebugString());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
package org.labkey.experiment.api;

import org.jetbrains.annotations.Nullable;
import org.labkey.api.data.CompareType;
import org.labkey.api.data.DbScope;
import org.labkey.api.data.SQLFragment;
import org.labkey.api.data.SqlSelector;
import org.labkey.api.data.Table;
import org.labkey.api.data.TableInfo;
import org.labkey.api.data.dialect.SqlDialect;
import org.labkey.api.exp.IdentifiableBase;
import org.labkey.api.exp.Lsid;
import org.labkey.api.exp.OntologyManager;
Expand Down Expand Up @@ -175,13 +177,13 @@ protected Function<String, Long> getMaxCounterWithPrefixFunction(TableInfo table

// Here we don't apply a container filter and instead rely on the "CpasType" of the associated data.
// This allows for us to process max counter from all matching results within the provided type.
String prefixLike = namePrefix.toLowerCase() + "%"; // case insensitive
SQLFragment sql = new SQLFragment()
.append("SELECT Name\n")
.append("FROM ").append(tableInfo, "i")
.append(" WHERE i.CpasType = ? AND LOWER(i.NAME) LIKE ?")
.add(dataTypeLsid)
.add(prefixLike);
.append(" WHERE i.CpasType = ? AND i.NAME")
.add(dataTypeLsid);

tableInfo.getSqlDialect().appendCaseInsensitiveStartsWith(sql, namePrefix);

List<String> names = new SqlSelector(tableInfo.getSchema(), sql).getArrayList(String.class);

Expand Down