将 Java toString 结果表示为 Java 源代码

问题描述 投票:0回答:2

我有一个

String
值在运行时使用
User
方法生成的
toString
列表对象:

[User[firstName=John, lastName=Smith]]

有没有一种快速的方法来生成创建这样一个对象的 Java 源代码?理想情况下,它会生成以下源代码:

Arrays.asList(new User("John", "Smith")); // might also use getters-setters

这将使我更快地编写测试代码断言。

我的对象定义如下:

public record User (String firstName, String lastName) {}
java unit-testing code-generation
2个回答
0
投票

出于好奇,我对此进行了尝试,结果证明它相当简单,如果你能忍受一些限制的话。其中一些是基本的,其他的只需要更多的代码就可以解决

  • 这只处理:
    • 录制课程
    • 那些记录类的列表
    • 字符串、基元和它们的包装器
  • 它假设输入实际上是由默认的
    toString
    生成的并且是不宽容的(在错误的地方额外/缺少空格会破坏它)。
  • 可能出现的顶级类必须交给解析器
  • 记录被假定为不覆盖他们的
    toString
  • 使用每条记录的规范构造函数
  • 一些输入是简单的模棱两可的,它们将被解析为something,但不能保证是哪种解释
  • 错误检查有限
  • 没有对原始/包装类型的验证:无意义的输入将被安静地翻译。你的编译器会告诉你,'虽然。
  • 我正在使用新的开关功能,但如果需要的话,改变起来很简单
  • 我已经有一段时间没有手写解析器了。不要看这个以获得最佳实践。
  • 肯定有bug

添加对

Map
的支持将相当简单。并且由于这些解析器的性质,修改它以实际构建对象而不是对象构造代码也将相当简单。这两个都留给读者作为练习。

完整的测试如下,但作为示例,这里是如何使用它:

import java.util.List;

public class Foo {
  public static void main(String[] args) {
    var parser = new RecordToStringParser(List.of(User.class, UserContainer.class));
    System.out.println(parser.convert("User[firstName=John, lastName=Smith]"));
    System.out.println(parser.convert("User[firstName=John, Doe, lastName=Smith]"));
    System.out.println(parser.convert("UserContainer[user=User[firstName=John, lastName=Smith], flags=13]"));
    System.out.println(parser.convert("UserContainer[user=null, flags=42]"));
    System.out.println(parser.convert("[User[firstName=John, lastName=Smith], UserContainer[user=null, flags=1]]"));
    System.out.println(parser.convert("UserContainer[user=null, flags=what are you talking about]"));
  }
}

record User(String firstName, String lastName) {}
record UserContainer(User user, int flags) {}

这打印

new User("John", "Smith")
new User("John, Doe", "Smith")
new UserContainer(new User("John", "Smith"), 13)
new UserContainer(null, 42)
List.of(new User("John", "Smith"), new UserContainer(null, 1))
new UserContainer(null, what are you talking about)

注意最后一个是如何在不验证类型的情况下盲目转储值的? 🤷

import java.lang.reflect.RecordComponent;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

public class RecordToStringParser {

  private static final Pattern RECORD_NAME = Pattern.compile("^[A-Z][A-Za-z0-9]+");
  private static final Set<Class<?>> WRAPPER_CLASSES = Set.of(
      Boolean.class, Byte.class, Character.class, Short.class, Integer.class, Long.class, Float.class, Double.class);

  private final Map<String, Class<? extends Record>> classes;

  public RecordToStringParser(Class<? extends Record> c1) {
    this(List.of(c1));
  }

  public RecordToStringParser(List<Class<? extends Record>> inputClasses) {
    if (inputClasses.isEmpty()) {
      throw new IllegalArgumentException("No record classes specified!");
    }
    try {
      classes = inputClasses.stream()
          .collect(Collectors.toMap(Class::getSimpleName, Function.identity()));
    } catch (IllegalStateException e) {
      // this happens when simple names are not unique!
      throw new IllegalArgumentException("Colliding class names detected!", e);
    }
    for (String name : classes.keySet()) {
      if (!RECORD_NAME.matcher(name).matches()) {
        throw new IllegalArgumentException("Nonconforming class name found: " + name);
      }
    }
  }

  public String convert(String input) {
    return new Parse(input).parse();
  }

  private class Parse {
    String in;
    int pos = 0;
    StringBuilder out = new StringBuilder();

    Parse(String input) {
      this.in = input;
    }

    String parse() {
      parseNext();
      if (pos != in.length()) {
        throw err("Unexpected content after successful parse");
      }
      return out.toString();
    }

    private void parseNext() {
      if (isNext('[')) {
        parseList();
      } else {
        parseRecord();
      }
    }


    private void parseRecord() {
      if (maybeConsume("null")) {
        out.append("null");
        return;
      }
      Matcher matcher = RECORD_NAME.matcher(in);
      matcher.region(pos, in.length());
      if (!matcher.find()) {
        throw err("Record expected.");
      }
      pos = matcher.end();
      consume('[');
      String className = matcher.group();
      out.append("new ").append(className).append("(");
      Class<? extends Record> recordClass = classes.get(className);
      if (recordClass == null) {
        throw err("Unknown record class " + className);
      }
      parseMembers(recordClass);
      out.append(")");
      consume(']');
    }

    private void parseRecord(Class<? extends Record> type) {
      if (maybeConsume("null")) {
        out.append("null");
        return;
      }
      consume(type.getSimpleName());
      consume('[');
      out.append("new ").append(type.getSimpleName()).append("(");
      parseMembers(type);
      out.append(")");
      consume(']');
    }

    private void parseMembers(Class<? extends Record> recordClass) {
      RecordComponent[] components = recordClass.getRecordComponents();
      for (int i = 0; i < components.length; i++) {
        RecordComponent component = components[i];
        boolean isLastComponent = i == components.length - 1;
        consume(component.getName());
        consume('=');
        Class<?> type = component.getType();
        if (type.isRecord()) {
          parseRecord(type.asSubclass(Record.class));
        } else if (List.class == type) {
          parseList();
        } else if (type == String.class || type.isPrimitive() || WRAPPER_CLASSES.contains(type)) {
          parseSimpleValues(components, i, isLastComponent, type);
        } else {
          throw err("Unsupported component type " + type);
        }
        if (!isLastComponent) {
          consume(", ");
          out.append(", ");
        }
      }

    }

    private void parseSimpleValues(RecordComponent[] components, int i, boolean isLastComponent, Class<?> type) {
      String expectedSuffix = isLastComponent ? "]" : ", " + components[i + 1].getName() + "=";
      isNext('\0'); // ensure we throw the right exception when we're at the end.
      int valueEnd = in.indexOf(expectedSuffix, pos);
      if (valueEnd == -1) {
        throw err("Failed to find end of value!");
      }
      String value = in.substring(pos, valueEnd);
      if (type == String.class && !value.equals("null")) {
        // interpret string values null as the value null and not the string "null"
        out.append('"');
        for (char c : value.toCharArray()) {
          switch (c) {
            case '"', '\\' -> out.append('\\').append(c);
            case '\n' -> out.append("\\n");
            case '\t' -> out.append("\\t");
            // maybe add some more?
            default -> out.append(c);
          }
        }
        out.append('"');
      } else {
        // maybe add verification?
        out.append(value);
      }
      pos = valueEnd;
    }

    private void parseList() {
      if (maybeConsume("null")) {
        out.append("null");
        return;
      }
      consume('[');
      out.append("List.of(");
      if (!isNext(']')) {
        parseNext();
        while (maybeConsume(", ")) {
          out.append(", ");
          parseRecord();
        }
      }
      consume(']');
      out.append(")");
    }

    private boolean isNext(char expected) {
      if (pos >= in.length()) {
        throw err("Unexpected end of string.");
      }
      return in.charAt(pos) == expected;
    }

    private boolean maybeConsume(String expected) {
      boolean matches = in.regionMatches(pos, expected, 0, expected.length());
      if (matches) {
        pos += expected.length();
      }
      return matches;
    }

    private void consume(char expected) {
      if (pos >= in.length()) {
        throw err("Unexpected end of string, expected " + expected);
      }
      if (in.charAt(pos) != expected) {
        throw err("Expected " + expected);
      }
      pos++;
    }

    private void consume(String expected) {
      if (!in.regionMatches(pos, expected, 0, expected.length())) {
        throw err("Expected " + expected);
      }
      pos += expected.length();
    }

    private IllegalArgumentException err(String msg) {
      return new IllegalArgumentException(String.format("Unexpected input at pos %d (%s): %s", pos, this, msg));
    }

    @Override
    public String toString() {
      // simple way to see parser state in the debugger ;-)
      return in.substring(0, pos) + "<|>" + in.substring(pos);
    }
  }
}

测试班:

import static org.junit.jupiter.api.Assertions.*;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

public class RecordToStringParserTest {
  private List<Class<? extends Record>> BASE_RECORD_CLASSES = List.of(
      Empty.class, Simple.class, WithNumber.class, NestingSimple.class, NestingListOfSimple.class, Recursive.class,
      TwoStrings.class, NestingRecord.class);

  @Test
  void mustProvideClasses() {
    final List<Class<? extends Record>> noClasses = List.of();
    assertThrows(IllegalArgumentException.class, () -> new RecordToStringParser(noClasses));
  }

  @Test
  void classNamesMustStartWithUpperCase() {
    assertThrows(IllegalArgumentException.class, () -> new RecordToStringParser(lowerRecordName.class));
  }

  @Test
  void classNamesMustBeAlphaNumeric() {
    assertThrows(IllegalArgumentException.class, () -> new RecordToStringParser(NonAlphaNum_.class));
  }

  @Test
  void simpleClassNamesMustBeUnique() {
    List<Class<? extends Record>> collidingClasses = List.of(NS1.Collision.class, NS2.Collision.class);
    assertThrows(IllegalArgumentException.class,
        () -> new RecordToStringParser(collidingClasses));
  }

  @SuppressWarnings("unchecked")
  public static Object[][] realToStringSources() {
    return new Object[][] {
        {new Empty(), "new Empty()"},
        {new Simple("bar"), "new Simple(\"bar\")"},
        {new Simple(""), "new Simple(\"\")"},
        {new Simple(null), "new Simple(null)"},
        {new Simple("\n"), "new Simple(\"\\n\")"},
        {new Simple("\\"), "new Simple(\"\\\\\")"},
        {new Simple("\"\\\""), "new Simple(\"\\\"\\\\\\\"\")"}, // thanks, I hate it
        {new WithNumber(1), "new WithNumber(1)"},
        {new NestingSimple(new Simple("bar")), "new NestingSimple(new Simple(\"bar\"))"},
        {new NestingSimple(null), "new NestingSimple(null)"},
        {new Recursive(null), "new Recursive(null)"},
        {new Recursive(new Recursive(null)), "new Recursive(new Recursive(null))"},
        {List.of(), "List.of()"},
        {List.of(new Empty()), "List.of(new Empty())"},
        {List.of(new Empty(), new Empty()), "List.of(new Empty(), new Empty())"},
        {List.of(List.of(new Empty())), "List.of(List.of(new Empty()))"},
        {new NestingListOfSimple(List.of(new Simple("bar"))), "new NestingListOfSimple(List.of(new Simple(\"bar\")))"},
        {new TwoStrings("foo", "bar"), "new TwoStrings(\"foo\", \"bar\")"},
        {new TwoStrings("foo, bar", "bar"), "new TwoStrings(\"foo, bar\", \"bar\")"},
        {new TwoStrings("John", "Smith"), "new TwoStrings(\"John\", \"Smith\")"},
        // "WRONG" results start here
        // List.of() can't contain null values, but we don't check for that, so we tolerate it
        {new ArrayList<>(Arrays.asList(null, new Empty())), "List.of(null, new Empty())"},
        // Simple[foo=null] is ambiguous, we decided to interpret it as null and not the string value "null"
        {new Simple("null"), "new Simple(null)"},
        // We don't check for insanity/generics breakage
        {new NestingListOfSimple((List<Simple>) ((List<?>) List.of(new WithNumber(1)))),
            "new NestingListOfSimple(List.of(new WithNumber(1)))"},
        // Well, that's just ambiguous!
        {new TwoStrings("foo, bar=", "bar"), "new TwoStrings(\"foo\", \", bar=bar\")"},
        // We don't verify simple types, so it'll blindly output nonsense
        {"WithNumber[foo=bar]", "new WithNumber(bar)"},
    };
  }

  @ParameterizedTest
  @MethodSource("realToStringSources")
  void parseRealToString(Object record, String expectedOutput) {
    String input = record.toString();
    RecordToStringParser parser = new RecordToStringParser(BASE_RECORD_CLASSES);
    String output = parser.convert(input);
    assertEquals(expectedOutput, output);
  }

  public static String[] unparseableStrings() {
    return new String[] {
        "",
        "]",
        "[",
        "Empty",
        "Empty[",
        "Simple[foo=",
        "NestingSimple[foo=10]",
        "UnknownClass[]",
        // "WithNumber[foo=bar]", // this isn't validated
        "[]]",
        "[bar]", // non-record values in lists are not supported
        "{}", // maps are not supported, but wouldn't be too hard
        "NestingRecord[r=Empty[]]", // this could easily be supported, but why?
    };
  }

  @ParameterizedTest
  @MethodSource("unparseableStrings")
  void failToParse(String input) {
    RecordToStringParser parser = new RecordToStringParser(BASE_RECORD_CLASSES);
    assertThrows(IllegalArgumentException.class, () -> parser.convert(input));
  }

  record lowerRecordName(String foo) {}

  record NonAlphaNum_(String foo) {}

  record Empty() {}

  record Simple(String foo) {}

  record NestingSimple(Simple foo) {}

  record NestingListOfSimple(List<Simple> foo) {}

  record Recursive(Recursive foo) {}

  record WithNumber(int foo) {}

  record TwoStrings(String foo, String bar) {}

  record NestingRecord(Record r) {}

  static class NS1 {
    record Collision(String foo) {}
  }

  static class NS2 {
    record Collision(String foo) {}
  }
}

0
投票

不,没有。

. . . . . . . . . . . . . . . . . . . . . . . . . . .

© www.soinside.com 2019 - 2024. All rights reserved.