Skip to content

Commit

Permalink
Add SafeQuery
Browse files Browse the repository at this point in the history
  • Loading branch information
fluentfuture committed Nov 29, 2023
1 parent e37e717 commit 1a6cb38
Show file tree
Hide file tree
Showing 2 changed files with 764 additions and 0 deletions.
256 changes: 256 additions & 0 deletions mug-guava/src/main/java/com/google/mu/safesql/SafeQuery.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
package com.google.mu.safesql;

import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.mu.util.Substring.first;
import static com.google.mu.util.Substring.prefix;
import static com.google.mu.util.Substring.suffix;
import static java.util.stream.Collectors.collectingAndThen;
import static java.util.stream.Collectors.mapping;

import java.util.Iterator;
import java.util.stream.Collector;
import java.util.stream.Collectors;

import com.google.common.base.CharMatcher;
import com.google.common.collect.Iterables;
import com.google.errorprone.annotations.CompileTimeConstant;
import com.google.errorprone.annotations.Immutable;
import com.google.mu.util.CharPredicate;
import com.google.mu.util.StringFormat;
import com.google.mu.util.Substring;

/**
* Facade class used to generate SQL based on a string template in the syntax of {@link StringFormat},
* and with the same compile-time protection. Special characters of string expressions are
* automatically escaped to prevent SQL injection errors.
*
* <p>A SafeQuery encapsulates the query string. You can use {@link #toString} to access the query
* string.
*
* <p>In addition, a SafeQuery may represent a subquery, and can be passed through {@link
* StringFormat.To#with} to compose larger queries.
*
* <p>This class is Android and J2CL compatible.
*/
@Immutable
public final class SafeQuery {
private static final String TRUSTED_SQL_TYPE_NAME = firstNonNull(
System.getProperty("com.google.mu.safesql.SafeQuery.trusted_sql_type"),
"com.google.storage.googlesql.safesql.TrustedSqlString");

private final String query;

private SafeQuery(String query) {
this.query = query;
}

/** Returns a query using a string constant. */
public static SafeQuery of(@CompileTimeConstant String query) {
return new SafeQuery(checkNotNull(query));
}

/**
* Returns a collector that can join {@link SafeQuery} objects using {@code delim} as the
* delimiter.
*/
public static Collector<SafeQuery, ?, SafeQuery> joining(@CompileTimeConstant String delim) {
return collectingAndThen(
mapping(SafeQuery::toString, Collectors.joining(checkNotNull(delim))), SafeQuery::new);
}

/**
* Creates a GoogleSQL template with the placeholders to be filled by subsequent {@link
* StringFormat.To#with} calls. For example:
*
* <pre>{@code
* private static final StringFormat.To<SafeQuery> FIND_CASE_BY_ID =
* SafeQuery.template(
* "SELECT * FROM `{project_id}.{dataset}.Cases` WHERE CaseId = '{case_id}'");
*
* // ...
* SafeQuery query = FIND_CASE_BY_ID.with(projectId, dataset, caseId);
* }</pre>
*
* <p>Placeholders must follow the following rules:
*
* <ul>
* <li>String placeholder values can only be used when the corresponding placeholder is quoted
* in the template string (such as '{case_id}').
* <ul>
* <li>If quoted by single quotes (') or double quotes ("), the placeholder value will be
* auto-escaped.
* <li>If quoted by backticks (`), the placeholder value cannot contain backtick, quotes
* and other special characters.
* </ul>
* <li>Unquoted placeholders are assumed to be symbols or subqueries. Strings (CharSequence) and
* characters cannot be used as placeholder value. If you have to pass a string, wrap it
* inside a {@link TrustedSqlString} (you can configure the type you trust by setting the
* "com.google.mu.safesql.SafeQuery.trusted_sql_type" system property).
* <li>An Iterable's elements are auto expanded and joined by a comma and space (", ").
* <li>If the iterable's placeholder is quoted as in {@code WHERE id IN ('{ids}')}, every
* expanded element is quoted (and auto-escaped). For example, passing {@code asList("foo",
* "bar")} as the value for placeholder {@code '{ids}'} will result in {@code WHERE id IN
* ('foo', 'bar')}.
* <li>If the Iterable's placeholder is backtick-quoted, every expanded element is
* backtick-quoted. For example, passing {@code asList("col1", "col2")} as the value for
* placeholder {@code `{cols}`} will result in {@code `col`, `col2`}.
* <li>Subqueries can be composed by using {@link SafeQuery} as the placeholder value.
* </ul>
*
* <p>The placeholder values passed through {@link StringFormat.To#with} are checked by ErrorProne
* to ensure the correct number of arguments are passed, and in the expected order.
*
* <p>Supported expression types:
*
* <ul>
* <li>null (NULL)
* <li>Boolean
* <li>Numeric
* <li>Enum
* <li>String (must be quoted in the template)
* <li>{@link TrustedSqlString}
* <li>{@link SafeQuery}
* <li>Iterable
* </ul>
*/
public static StringFormat.To<SafeQuery> template(@CompileTimeConstant String template) {
return StringFormat.template(
template,
(fragments, placeholders) -> {
Iterator<String> it = fragments.iterator();
return new SafeQuery(
placeholders
.collect(
new StringBuilder(),
(b, p, v) -> b.append(it.next()).append(fillInPlaceholder(p, v)))
.append(it.next())
.toString());
});
}

/** Returns the encapsulated SQL query. */
@Override
public String toString() {
return query;
}

@Override
public boolean equals(Object obj) {
if (obj instanceof SafeQuery) {
return query.equals(((SafeQuery) obj).query);
}
return false;
}

@Override
public int hashCode() {
return query.hashCode();
}

private static String fillInPlaceholder(Substring.Match placeholder, Object value) {
validatePlaceholder(placeholder);
if (value instanceof Iterable) {
Iterable<?> iterable = (Iterable<?>) value;
if (placeholder.isImmediatelyBetween("`", "`")) { // If backquoted, it's a list of symbols
return String.join("`, `", Iterables.transform(iterable, v -> backquoted(placeholder, v)));
}
if (placeholder.isImmediatelyBetween("'", "'")) {
return String.join(
"', '", Iterables.transform(iterable, v -> quotedBy('\'', placeholder, v)));
}
if (placeholder.isImmediatelyBetween("\"", "\"")) {
return String.join(
"\", \"", Iterables.transform(iterable, v -> quotedBy('"', placeholder, v)));
}
return String.join(", ", Iterables.transform(iterable, v -> unquoted(placeholder, v)));
}
if (placeholder.isImmediatelyBetween("'", "'")) {
return quotedBy('\'', placeholder, value);
}
if (placeholder.isImmediatelyBetween("\"", "\"")) {
return quotedBy('"', placeholder, value);
}
if (placeholder.isImmediatelyBetween("`", "`")) {
return backquoted(placeholder, value);
}
return unquoted(placeholder, value);
}

private static String unquoted(Substring.Match placeholder, Object value) {
if (value == null) {
return "NULL";
}
if (value instanceof Boolean) {
return value.equals(Boolean.TRUE) ? "TRUE" : "FALSE";
}
if (value instanceof Number) {
return value.toString();
}
if (value instanceof Enum) {
return ((Enum<?>) value).name();
}
if (isTrusted(value)) {
return value.toString();
}
checkArgument(
!(value instanceof CharSequence || value instanceof Character),
"Symbols should be wrapped inside %s;\n"
+ "subqueries must be wrapped in another SafeQuery object;\n"
+ "and string literals must be quoted like '%s'",
TRUSTED_SQL_TYPE_NAME, placeholder);
throw new IllegalArgumentException("Unsupported argument type: " + value.getClass().getName());
}

private static String quotedBy(char quoteChar, Substring.Match placeholder, Object value) {
checkNotNull(value, "Quoted placeholder cannot be null: '%s'", placeholder);
checkArgument(
!isTrusted(value),
"placeholder of type %s should not be quoted: %s%s%s",
value.getClass().getSimpleName(),
quoteChar,
placeholder,
quoteChar);
return first(CharPredicate.is('\\').or(quoteChar))
.repeatedly()
.replaceAllFrom(value.toString(), c -> "\\" + c);
}

private static String backquoted(Substring.Match placeholder, Object value) {
if (value instanceof Enum) {
return ((Enum<?>) value).name();
}
String name = removeQuotes('`', value.toString(), '`'); // ok if already backquoted
// Make sure the backquoted string doesn't contain some special chars that may cause trouble.
checkArgument(
CharMatcher.anyOf("'\"`()[]{}\\~!@$^*,/?;").matchesNoneOf(name),
"placeholder value for `%s` (%s) contains illegal character",
placeholder,
name);
return name;
}

private static boolean isTrusted(Object value) {
return value instanceof SafeQuery
|| value
.getClass()
.getName()
.equals(TRUSTED_SQL_TYPE_NAME);
}

private static void validatePlaceholder(Substring.Match placeholder) {
checkArgument(
!placeholder.isImmediatelyBetween("`", "'"),
"Incorrectly quoted placeholder: `%s'",
placeholder);
checkArgument(
!placeholder.isImmediatelyBetween("'", "`"),
"Incorrectly quoted placeholder: '%s`",
placeholder);
}

private static String removeQuotes(char left, String s, char right) {
return Substring.between(prefix(left), suffix(right)).from(s).orElse(s);
}
}
Loading

0 comments on commit 1a6cb38

Please sign in to comment.