Skip to content

Commit

Permalink
Add StringFormat.to() and StringFormat.template()
Browse files Browse the repository at this point in the history
  • Loading branch information
fluentfuture committed Nov 26, 2023
1 parent db2ac5f commit e37e717
Show file tree
Hide file tree
Showing 2 changed files with 258 additions and 7 deletions.
136 changes: 129 additions & 7 deletions mug/src/main/java/com/google/mu/util/StringFormat.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import java.util.AbstractList;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
Expand All @@ -27,6 +28,7 @@
import com.google.mu.function.Quinary;
import com.google.mu.function.Senary;
import com.google.mu.function.Ternary;
import com.google.mu.util.stream.BiStream;
import com.google.mu.util.stream.MoreStreams;

/**
Expand Down Expand Up @@ -150,6 +152,96 @@ private static Substring.Pattern spanInOrder(List<String> goalPosts) {
.reduce(Substring.first(goalPosts.get(0)), Substring.Pattern::extendTo);
}

/**
* Returns a factory of type {@code T} using {@code format} string as the template, whose
* curly-braced placeholders will be filled with the template arguments and then passed to the
* {@code creator} function to create the {@code T} instances.
*
* <p>A typical use case is to pre-create an exception template that can be used to create
* exceptions filled with different parameter values. For example:
*
* <pre>{@code
* private static final StringFormat.To<IOException> JOB_FAILED =
* StringFormat.to(
* IOException::new, "Job ({job_id}) failed with {error_code}, details: {details}");
*
* // 150 lines later.
* // Compile-time enforced that parameters are correct and in the right order.
* throw JOB_FAILED.with(jobId, errorCode, errorDetails);
* }</pre>
*
* @since 6.7
*/
public static <T> To<T> to(
Function<? super String, ? extends T> creator, String format) {
requireNonNull(creator);
StringFormat fmt = new StringFormat(format);
return new To<T>() {
@Override
@SuppressWarnings("StringFormatArgsCheck")
public T with(Object... params) {
return creator.apply(fmt.format(params));
}

@Override
public String toString() {
return format;
}
};
}

/**
* Returns a go/jep-430 style template of {@code T} produced by interpolating arguments into the
* {@code template} string, using the given {@code interpolator} function.
*
* <p>The {@code interpolator} function is an SPI. That is, instead of users creating the lambda
* in-line, you are expected to provide a canned implementation -- typically by wrapping it inside
* a convenient facade class. For example:
*
* <pre>{@code
* // Provided to the user:
* public final class BigQuery {
* public static StringFormat.To<QueryRequest> template(String template) {
* return StringFormat.template(template, (fragments, placeholders) -> ...);
* }
* }
*
* // At call site:
* private static final StringFormat.To<QueryRequest> GET_CASE_BY_ID = BigQuery.template(
* "SELECT CaseId, Description FROM tbl WHERE CaseId = '{case_id}'");
*
* ....
* QueryRequest query = GET_CASE_BY_ID.with(caseId); // automatically escape special chars
* }</pre>
*
* <p>This way, the StringFormat API provides compile-time safety, and the SPI plugs in custom
* interpolation logic.
*
* <p>Calling {@link To#with} with unexpected number of parameters will throw {@link
* IllegalArgumentException} without invoking {@code interpolator}.
*
* @since 6.7
*/
public static <T> To<T> template(String template, Interpolator<? extends T> interpolator) {
requireNonNull(interpolator);
StringFormat formatter = new StringFormat(template);
List<Substring.Match> placeholders =
PLACEHOLDERS.match(template).collect(toImmutableList());
return new To<T>() {
@Override
public T with(Object... params) {
formatter.checkFormatArgs(params);
return interpolator.interpolate(
formatter.fragments, BiStream.zip(placeholders.stream(), Arrays.stream(params)));
}

@Override
public String toString() {
return template;
}
};
}

/**
* Constructs a StringFormat with placeholders in the syntax of {@code "{foo}"}. For example:
*
Expand Down Expand Up @@ -783,13 +875,7 @@ public <R> Stream<R> scan(String input, Senary<? super String, ? extends R> mapp
* @throws IllegalArgumentException if the number of arguments doesn't match that of the placeholders
*/
public String format(Object... args) {
if (args.length != numPlaceholders()) {
throw new IllegalArgumentException(
String.format(
"format string expects %s placeholders, %s provided",
numPlaceholders(),
args.length));
}
checkFormatArgs(args);
StringBuilder builder = new StringBuilder().append(fragments.get(0));
for (int i = 0; i < args.length; i++) {
builder.append(args[i]).append(fragments.get(i + 1));
Expand All @@ -802,6 +888,32 @@ public String format(Object... args) {
return format;
}

/**
* A view of the {@code StringFormat} that returns an instance of {@code T}, after filling the
* format with the given variadic parameters.
*
* @since 6.7
*/
public interface To<T> {
/** Returns an instance of {@code T} from the string format filled with {@code params}. */
T with(Object... params);

/** Returns the string representation of the format. */
@Override
public abstract String toString();
}

/** A functional SPI interface for custom interpolation. */
public interface Interpolator<T> {
/**
* Interpolates with {@code fragments} of size {@code N + 1} and {@code placeholders} of size
* {@code N}. The {@code placeholders} BiStream includes pairs of placeholder names in the form
* of "{foo}" and their corresponding values passed through the varargs parameter of {@link
* To#with}.
*/
T interpolate(List<String> fragments, BiStream<Substring.Match, Object> placeholders);
}

private <R> Optional<R> parseGreedyExpecting(
int cardinality, String input, Collector<? super String, ?, R> collector) {
requireNonNull(input);
Expand Down Expand Up @@ -888,6 +1000,16 @@ private boolean isValidPlaceholderValue(CharSequence chars) {
return requiredChars == null || (chars.length() > 0 && requiredChars.matchesAllOf(chars));
}

private void checkFormatArgs(Object[] args) {
if (args.length != numPlaceholders()) {
throw new IllegalArgumentException(
String.format(
"format string expects %s placeholders, %s provided",
numPlaceholders(),
args.length));
}
}

static String reverse(String s) {
if (s.length() <= 1) {
return s;
Expand Down
129 changes: 129 additions & 0 deletions mug/src/test/java/com/google/mu/util/StringFormatTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@
import static java.util.Arrays.asList;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.Iterator;
import java.util.List;

import org.junit.Test;
import org.junit.runner.RunWith;

import com.google.common.base.CharMatcher;
import com.google.common.collect.ImmutableList;
import com.google.common.testing.ClassSanityTester;
import com.google.common.truth.OptionalSubject;
import com.google.errorprone.annotations.CompileTimeConstant;
import com.google.mu.util.stream.BiStream;
import com.google.testing.junit.testparameterinjector.TestParameter;
import com.google.testing.junit.testparameterinjector.TestParameterInjector;

Expand Down Expand Up @@ -974,6 +977,88 @@ public void format_tooManyArgs(@TestParameter Mode mode) {
assertThrows(
IllegalArgumentException.class, () -> mode.formatOf("{foo}:{bar}").format(1, 2, 3));
}

@Test
public void to_noPlaceholder() {
StringFormat.To<IllegalArgumentException> template =
StringFormat.to(IllegalArgumentException::new, "foo");
assertThat(template.with()).hasMessageThat().isEqualTo("foo");
}

@Test
public void to_withPlaceholders() {
StringFormat.To<IllegalArgumentException> template =
StringFormat.to(IllegalArgumentException::new, "bad user id: {id}, name: {name}");
assertThat(template.with(/*id*/ 123, /*name*/ "Tom"))
.hasMessageThat()
.isEqualTo("bad user id: 123, name: Tom");
}

@Test
public void to_withPlaceholders_multilines() {
StringFormat.To<IllegalArgumentException> template =
StringFormat.to(IllegalArgumentException::new, "id: {id}, name: {name}, alias: {alias}");
assertThat(
template.with(
/* id */ 1234567890,
/* name */ "TomTomTomTomTomTom",
/* alias */ "AtomAtomAtomAtomAtomAtom"))
.hasMessageThat()
.isEqualTo("id: 1234567890, name: TomTomTomTomTomTom, alias: AtomAtomAtomAtomAtomAtom");
}

@Test
@SuppressWarnings("StringFormatArgsCheck")
public void to_tooFewArguments() {
StringFormat.To<IllegalArgumentException> template =
StringFormat.to(IllegalArgumentException::new, "bad user id: {id}, name: {name}");
assertThrows(IllegalArgumentException.class, () -> template.with(123));
}

@Test
@SuppressWarnings("StringFormatArgsCheck")
public void to_tooManyArguments() {
StringFormat.To<IllegalArgumentException> template =
StringFormat.to(IllegalArgumentException::new, "bad user id: {id}");
assertThrows(IllegalArgumentException.class, () -> template.with(123, "Tom"));
}

@Test
public void template_noPlaceholder() {
StringFormat.To<BigQuery> template = BigQuery.template("SELECT *");
assertThat(template.with().toString()).isEqualTo("SELECT *");
}

@Test
public void template_withPlaceholders() {
StringFormat.To<BigQuery> template =
BigQuery.template("SELECT * FROM tbl WHERE id = '{id}' AND timestamp > '{time}'");
assertThat(template.with(/* id */ "a'b", /* time */ "2023-10-01").toString())
.isEqualTo("SELECT * FROM tbl WHERE id = 'a\\'b' AND timestamp > '2023-10-01'");
}

@Test
public void template_withNullPlaceholderValue() {
StringFormat.To<BigQuery> template = BigQuery.template("id = {id}");
String id = null;
assertThat(template.with(id).toString()).isEqualTo("id = null");
}

@SuppressWarnings("StringFormatArgsCheck")
@Test
public void template_tooFewArguments() {
StringFormat.To<BigQuery> template =
BigQuery.template("SELECT * FROM tbl WHERE id in ({id1}, {id2})");
assertThrows(IllegalArgumentException.class, () -> template.with(123));
}

@SuppressWarnings("StringFormatArgsCheck")
@Test
public void template_tooManyArguments() {
StringFormat.To<BigQuery> template = BigQuery.template("SELECT * FROM tbl WHERE id = {id}");
assertThrows(IllegalArgumentException.class, () -> template.with(123, "Tom"));
}

@Test
public void span_emptyFormatString() {
assertPatternMatch(StringFormat.span(""), "foo").hasValue("[]foo");
Expand Down Expand Up @@ -1113,5 +1198,49 @@ StringFormat formatOf(String format) {

abstract StringFormat formatOf(@CompileTimeConstant String format);
}

/** How we expect SPI providers to use {@link StringFormat#template}. */
private static final class BigQuery {
private final String query;

static StringFormat.To<BigQuery> template(@CompileTimeConstant String template) {
return StringFormat.template(template, BigQuery::safeInterpolate);
}

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

// Some custom interpolation logic. For testing purpose, naively quote the args.
// Real BQ interpolation should also handle double quotes.
private static BigQuery safeInterpolate(
List<String> fragments, BiStream<Substring.Match, ?> placeholders) {
Iterator<String> it = fragments.iterator();
return new BigQuery(
placeholders
.collect(
new StringBuilder(),
(b, p, v) -> b.append(it.next()).append(escapeIfNeeded(p, v)))
.append(it.next())
.toString());
}

private static String escapeIfNeeded(Substring.Match placeholder, Object value) {
return placeholder.isImmediatelyBetween("'", "'")
? escape(String.valueOf(value))
: String.valueOf(value);
}

private static String escape(String s) {
return Substring.first(CharMatcher.anyOf("\\'")::matches)
.repeatedly()
.replaceAllFrom(s, c -> "\\" + c);
}

@Override
public String toString() {
return query;
}
}
}

0 comments on commit e37e717

Please sign in to comment.