content: /*
   * Licensed to the Apache Software Foundation (ASF) under one or more
   * contributor license agreements.  See the NOTICE file distributed with
   * this work for additional information regarding copyright ownership.
   * The ASF licenses this file to you under the Apache License, Version 2.0
   * (the "License"); you may not use this file except in compliance with
   * the License.  You may obtain a copy of the License at
   *
   * http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  package org.apache.calcite.test;
  
  import org.apache.calcite.adapter.enumerable.EnumerableRules;
  import org.apache.calcite.avatica.AvaticaUtils;
  import org.apache.calcite.config.CalciteConnectionProperty;
  import org.apache.calcite.jdbc.CalciteConnection;
  import org.apache.calcite.plan.Contexts;
  import org.apache.calcite.plan.RelOptPlanner;
  import org.apache.calcite.plan.RelOptRule;
  import org.apache.calcite.prepare.Prepare;
  import org.apache.calcite.rel.rules.CoreRules;
  import org.apache.calcite.rel.type.RelDataType;
  import org.apache.calcite.rel.type.RelDataTypeFactory;
  import org.apache.calcite.runtime.Hook;
  import org.apache.calcite.schema.Schema;
  import org.apache.calcite.schema.SchemaPlus;
  import org.apache.calcite.schema.impl.AbstractSchema;
  import org.apache.calcite.schema.impl.AbstractTable;
  import org.apache.calcite.sql.SqlNode;
  import org.apache.calcite.sql.SqlWriter;
  import org.apache.calcite.sql.parser.SqlParser;
  import org.apache.calcite.sql.parser.SqlParserImplFactory;
  import org.apache.calcite.sql.pretty.SqlPrettyWriter;
  import org.apache.calcite.sql.type.SqlTypeName;
  import org.apache.calcite.test.schemata.catchall.CatchallSchema;
  import org.apache.calcite.tools.Frameworks;
  import org.apache.calcite.tools.Planner;
  import org.apache.calcite.util.Bug;
  import org.apache.calcite.util.Closer;
  import org.apache.calcite.util.Sources;
  import org.apache.calcite.util.Util;
  
  import com.google.common.collect.ImmutableList;
  import com.google.common.io.PatternFilenameFilter;
  
  import net.hydromatic.quidem.AbstractCommand;
  import net.hydromatic.quidem.CommandHandler;
  import net.hydromatic.quidem.Quidem;
  
  import org.checkerframework.checker.nullness.qual.Nullable;
  import org.junit.jupiter.api.TestInstance;
  import org.junit.jupiter.params.ParameterizedTest;
  import org.junit.jupiter.params.provider.MethodSource;
  
  import java.io.File;
  import java.io.FilenameFilter;
  import java.io.Reader;
  import java.io.Writer;
  import java.lang.reflect.Field;
  import java.lang.reflect.InvocationTargetException;
  import java.lang.reflect.Method;
  import java.math.BigDecimal;
  import java.net.URL;
  import java.sql.Connection;
  import java.sql.DriverManager;
  import java.util.ArrayList;
  import java.util.Collection;
  import java.util.List;
  import java.util.Set;
  import java.util.function.Consumer;
  import java.util.function.Function;
  import java.util.regex.Matcher;
  import java.util.regex.Pattern;
  
  import static org.junit.jupiter.api.Assertions.fail;
  
  import static java.util.Objects.requireNonNull;
  
  /**
   * Test that runs every Quidem file as a test.
   */
  @TestInstance(TestInstance.Lifecycle.PER_CLASS)
  public abstract class QuidemTest {
    /** Command that prints the validated parse tree of a SQL statement. */
    public static class ExplainValidatedCommand extends AbstractCommand {
      private final ImmutableList<String> lines;
      private final ImmutableList<String> content;
      private final SqlParserImplFactory parserFactory;
  
      ExplainValidatedCommand(SqlParserImplFactory parserFactory,
          List<String> lines, List<String> content,
          Set<String> unusedProductSet) {
        this.lines = ImmutableList.copyOf(lines);
        this.content = ImmutableList.copyOf(content);
        this.parserFactory = parserFactory;
      }
  
      @Override public void execute(Context x, boolean execute) throws Exception {
        if (execute) {
          // use Babel parser
          final SqlParser.Config parserConfig =
              SqlParser.config().withParserFactory(parserFactory);
  
          // extract named schema from connection and use it in planner
          final CalciteConnection calciteConnection =
              x.connection().unwrap(CalciteConnection.class);
          final String schemaName = calciteConnection.getSchema();
          final SchemaPlus schema =
              schemaName != null
                  ? calciteConnection.getRootSchema().subSchemas().get(schemaName)
                  : calciteConnection.getRootSchema();
          final Frameworks.ConfigBuilder config =
              Frameworks.newConfigBuilder()
                  .defaultSchema(schema)
                  .parserConfig(parserConfig)
                  .context(Contexts.of(calciteConnection.config()));
  
          // parse, validate and un-parse
          final Quidem.SqlCommand sqlCommand = x.previousSqlCommand();
          final Planner planner = Frameworks.getPlanner(config.build());
          final SqlNode node = planner.parse(sqlCommand.sql);
          final SqlNode validateNode = planner.validate(node);
          final SqlWriter sqlWriter = new SqlPrettyWriter();
          validateNode.unparse(sqlWriter, 0, 0);
          x.echo(ImmutableList.of(sqlWriter.toSqlString().getSql()));
        } else {
          x.echo(content);
        }
        x.echo(lines);
      }
    }
  
    private static final Pattern PATTERN = Pattern.compile("\\.iq$");
  
    // Saved original planner rules
    private static @Nullable List<RelOptRule> originalRules;
  
    private static @Nullable Object getEnv(String varName) {
      switch (varName) {
      case "jdk18":
        return System.getProperty("java.version").startsWith("1.8");
      case "fixed":
        // Quidem requires a Java 8 function
        return (Function<String, Object>) v -> {
          switch (v) {
          case "calcite1045":
            return Bug.CALCITE_1045_FIXED;
          case "calcite1048":
            return Bug.CALCITE_1048_FIXED;
          }
          return null;
        };
      case "not":
        return (Function<String, Object>) v -> {
          final Object o = getEnv(v);
          if (o instanceof Function) {
            @SuppressWarnings("unchecked") final Function<String, Object> f =
                (Function<String, Object>) o;
            return (Function<String, Object>) v2 -> !((Boolean) f.apply(v2));
          }
          return null;
        };
      default:
        return null;
      }
    }
  
    private @Nullable Method findMethod(String path) {
      // E.g. path "sql/agg.iq" gives method "testSqlAgg"
      final String path1 = path.replace(File.separatorChar, '_');
      final String path2 = PATTERN.matcher(path1).replaceAll("");
      String methodName = AvaticaUtils.toCamelCase("test_" + path2);
      Method m;
      try {
        m = getClass().getMethod(methodName, String.class);
      } catch (NoSuchMethodException e) {
        m = null;
      }
      return m;
    }
  
    protected static Collection<String> data(String first) {
      // inUrl = "file:/home/fred/calcite/core/target/test-classes/sql/agg.iq"
      final URL inUrl = QuidemTest.class.getResource("/" + n2u(first));
      final File firstFile = Sources.of(requireNonNull(inUrl, "inUrl")).file();
      final int commonPrefixLength = firstFile.getAbsolutePath().length() - first.length();
      final File dir = firstFile.getParentFile();
      final List<String> paths = new ArrayList<>();
      final FilenameFilter filter = new PatternFilenameFilter(".*\\.iq$");
      for (File f : Util.first(dir.listFiles(filter), new File[0])) {
        paths.add(f.getAbsolutePath().substring(commonPrefixLength));
      }
      return paths;
    }
  
    protected void checkRun(String path) throws Exception {
      final File inFile;
      final File outFile;
      final File f = new File(path);
      if (f.isAbsolute()) {
        // e.g. path = "/tmp/foo.iq"
        inFile = f;
        outFile = new File(path + ".out");
      } else {
        // e.g. path = "sql/agg.iq"
        // inUrl = "file:/home/fred/calcite/core/build/resources/test/sql/agg.iq"
        // inFile = "/home/fred/calcite/core/build/resources/test/sql/agg.iq"
        // outDir = "/home/fred/calcite/core/build/quidem/test/sql"
        // outFile = "/home/fred/calcite/core/build/quidem/test/sql/agg.iq"
        final URL inUrl = QuidemTest.class.getResource("/" + n2u(path));
        inFile = Sources.of(requireNonNull(inUrl, "inUrl")).file();
        outFile = replaceDir(inFile, "resources", "quidem");
      }
      Util.discard(outFile.getParentFile().mkdirs());
      try (Reader reader = Util.reader(inFile);
           Writer writer = Util.printWriter(outFile);
           Closer closer = new Closer()) {
        final Quidem.Config config = Quidem.configBuilder()
            .withReader(reader)
            .withWriter(writer)
            .withConnectionFactory(createConnectionFactory())
            .withCommandHandler(createCommandHandler())
            .withPropertyHandler((propertyName, value) -> {
              if (propertyName.equals("bindable")) {
                final boolean b = value instanceof Boolean
                    && (Boolean) value;
                closer.add(Hook.ENABLE_BINDABLE.addThread(Hook.propertyJ(b)));
              }
              if (propertyName.equals("expand")) {
                final boolean b = value instanceof Boolean
                    && (Boolean) value;
                closer.add(Prepare.THREAD_EXPAND.push(b));
              }
              if (propertyName.equals("insubquerythreshold")) {
                int thresholdValue = ((BigDecimal) value).intValue();
                closer.add(Prepare.THREAD_INSUBQUERY_THRESHOLD.push(thresholdValue));
              }
              // Configures query planner rules via "!set planner-rules" command.
              // The value can be set as follows:
              // - Add rule:       "+EnumerableRules.ENUMERABLE_INTERSECT_RULE"
              // - Remove rule:    "-CoreRules.INTERSECT_TO_DISTINCT"
              // - Short form:     "+INTERSECT_TO_DISTINCT" (CoreRules prefix may be omitted)
              // - Reset defaults: "original"
              if (propertyName.equals("planner-rules")) {
                if (value.equals("original")) {
                  closer.add(Hook.PLANNER.addThread(QuidemTest::resetPlanner));
                } else {
                  closer.add(
                      Hook.PLANNER.addThread((Consumer<RelOptPlanner>)
                          planner -> {
                            if (originalRules == null) {
                              originalRules = planner.getRules();
                            }
                            updatePlanner(planner, (String) value);
                          }));
                }
              }
            })
            .withEnv(QuidemTest::getEnv)
            .build();
        new Quidem(config).execute();
      }
      // Sanity check: we do not allow an empty input file, it may indicate that it was overwritten
      if (inFile.length() == 0) {
        fail("Input file was empty: " + inFile + "\n");
      }
      final String diff = DiffTestCase.diff(inFile, outFile);
      if (!diff.isEmpty()) {
        fail("Files differ:\ndiff " + inFile + " " + outFile + "\n"
            + diff);
      }
    }
  
    private static void updatePlanner(RelOptPlanner planner, String value) {
      List<RelOptRule> rulesAdd = new ArrayList<>();
      List<RelOptRule> rulesRemove = new ArrayList<>();
      parseRules(value, rulesAdd, rulesRemove);
      rulesRemove.forEach(planner::removeRule);
      rulesAdd.forEach(planner::addRule);
    }
  
    private static void resetPlanner(RelOptPlanner planner) {
      if (originalRules != null) {
        planner.getRules().forEach(planner::removeRule);
        originalRules.forEach(planner::addRule);
      }
    }
  
    private static void parseRules(String value, List<RelOptRule> rulesAdd,
        List<RelOptRule> rulesRemove) {
      Pattern pattern = Pattern.compile("([+-])((CoreRules|EnumerableRules)\\.)?(\\w+)");
      Matcher matcher = pattern.matcher(value);
  
      while (matcher.find()) {
        char operation = matcher.group(1).charAt(0);
        String ruleSource = matcher.group(3);
        String ruleName = matcher.group(4);
  
        try {
          if (ruleSource == null || ruleSource.equals("CoreRules")) {
            setRules(operation, getCoreRule(ruleName), rulesAdd, rulesRemove);
          } else if (ruleSource.equals("EnumerableRules")) {
            Object rule = EnumerableRules.class.getField(ruleName).get(null);
            setRules(operation, (RelOptRule) rule, rulesAdd, rulesRemove);
          } else {
            throw new RuntimeException("Unknown rule: " + ruleName);
          }
        } catch (NoSuchFieldException | IllegalAccessException e) {
          throw new RuntimeException("set rules failed: " + e.getMessage(), e);
        }
      }
    }
  
    public static RelOptRule getCoreRule(String ruleName) {
      try {
        Field ruleField = CoreRules.class.getField(ruleName);
        Object o = ruleField.get(null);
  
        if (o instanceof RelOptRule) {
          return (RelOptRule) o;
        }
        throw new IllegalArgumentException(ruleName + " is not of type RelOptRule");
      } catch (NoSuchFieldException | IllegalAccessException e) {
        throw new RuntimeException("Failed to get rule '" + ruleName + "': " + e.getMessage());
      }
    }
  
    private static void setRules(char operation, RelOptRule rule,
        List<RelOptRule> rulesAdd, List<RelOptRule> rulesRemove) {
      if (operation == '+') {
        rulesAdd.add(rule);
      } else if (operation == '-') {
        rulesRemove.add(rule);
      } else {
        throw new RuntimeException("unknown operation '" + operation + "'");
      }
    }
  
    /** Returns a file, replacing one directory with another.
     *
     * <p>For example, {@code replaceDir("/abc/str/astro.txt", "str", "xyz")}
     * returns "{@code "/abc/xyz/astro.txt}".
     * Note that the file name "astro.txt" does not become "axyzo.txt".
     */
    private static File replaceDir(File file, String target, String replacement) {
      return new File(
          n2u(file.getAbsolutePath()).replace(n2u('/' + target + '/'),
              n2u('/' + replacement + '/')));
    }
  
    /** Creates a command handler. */
    protected CommandHandler createCommandHandler() {
      return Quidem.EMPTY_COMMAND_HANDLER;
    }
  
    /** Creates a connection factory. */
    protected Quidem.ConnectionFactory createConnectionFactory() {
      return new QuidemConnectionFactory();
    }
  
    /** Converts a path from native to Unix. On Windows, converts
     * back-slashes to forward-slashes; on Linux, does nothing. */
    private static String n2u(String s) {
      return File.separatorChar == '\\'
          ? s.replace('\\', '/')
          : s;
    }
  
    @ParameterizedTest
    @MethodSource("getPath")
    public void test(String path) throws Exception {
      final Method method = findMethod(path);
      if (method != null) {
        try {
          method.invoke(this, path);
        } catch (InvocationTargetException e) {
          Throwable cause = e.getCause();
          if (cause instanceof Exception) {
            throw (Exception) cause;
          }
          if (cause instanceof Error) {
            throw (Error) cause;
          }
          throw e;
        }
      } else {
        checkRun(path);
      }
    }
  
    /** Factory method for {@link QuidemTest#test(String)} parameters. */
    protected abstract Collection<String> getPath();
  
    /** Quidem connection factory for Calcite's built-in test schemas. */
    protected static class QuidemConnectionFactory
        implements Quidem.ConnectionFactory {
      public Connection connect(String name) throws Exception {
        return connect(name, false);
      }
  
      @Override public Connection connect(String name, boolean reference)
          throws Exception {
        if (reference) {
          if (name.equals("foodmart")) {
            final ConnectionSpec db =
                CalciteAssert.DatabaseInstance.HSQLDB.foodmart;
            final Connection connection =
                DriverManager.getConnection(db.url, db.username,
                    db.password);
            connection.setSchema("foodmart");
            return connection;
          }
          return null;
        }
        switch (name) {
        case "hr":
          return CalciteAssert.hr()
              .connect();
        case "aux":
          return CalciteAssert.hr()
              .with(CalciteAssert.Config.AUX)
              .connect();
        case "foodmart":
          return CalciteAssert.that()
              .with(CalciteAssert.Config.FOODMART_CLONE)
              .connect();
        case "geo":
          return CalciteAssert.that()
              .with(CalciteAssert.Config.GEO)
              .connect();
        case "scott":
          return CalciteAssert.that()
              .with(CalciteAssert.Config.SCOTT)
              .connect();
        case "jdbc_scott":
          return CalciteAssert.that()
              .with(CalciteAssert.Config.JDBC_SCOTT)
              .connect();
        case "steelwheels":
          return CalciteAssert.that()
              .with(CalciteAssert.SchemaSpec.STEELWHEELS)
              .connect();
        case "jdbc_steelwheels":
          return CalciteAssert.that()
              .with(CalciteAssert.SchemaSpec.JDBC_STEELWHEELS)
              .connect();
        case "post":
          return CalciteAssert.that()
              .with(CalciteAssert.Config.REGULAR)
              .with(CalciteAssert.SchemaSpec.POST)
              .connect();
        case "post-postgresql":
          return CalciteAssert.that()
              .with(CalciteConnectionProperty.FUN, "standard,postgresql")
              .with(CalciteAssert.Config.REGULAR)
              .with(CalciteAssert.SchemaSpec.POST)
              .connect();
        case "post-big-query":
          return CalciteAssert.that()
              .with(CalciteConnectionProperty.FUN, "standard,bigquery")
              .with(CalciteAssert.Config.REGULAR)
              .with(CalciteAssert.SchemaSpec.POST)
              .connect();
        case "mysqlfunc":
          return CalciteAssert.that()
              .with(CalciteConnectionProperty.FUN, "mysql")
              .with(CalciteAssert.Config.REGULAR)
              .with(CalciteAssert.SchemaSpec.POST)
              .connect();
        case "sparkfunc":
          return CalciteAssert.that()
              .with(CalciteConnectionProperty.FUN, "spark")
              .with(CalciteAssert.Config.REGULAR)
              .with(CalciteAssert.SchemaSpec.POST)
              .connect();
        case "oraclefunc":
          return CalciteAssert.that()
              .with(CalciteConnectionProperty.FUN, "oracle")
              .with(CalciteAssert.Config.REGULAR)
              .connect();
        case "mssqlfunc":
          return CalciteAssert.that()
              .with(CalciteConnectionProperty.FUN, "mssql")
              .with(CalciteAssert.Config.REGULAR)
              .connect();
        case "catchall":
          return CalciteAssert.that()
              .with(CalciteConnectionProperty.TIME_ZONE, "UTC")
              .withSchema("s",
                  new ReflectiveSchemaWithoutRowCount(
                      new CatchallSchema()))
              .connect();
        case "orinoco":
          return CalciteAssert.that()
              .with(CalciteAssert.SchemaSpec.ORINOCO)
              .connect();
        case "seq":
          final Connection connection = CalciteAssert.that()
              .withSchema("s", new AbstractSchema())
              .connect();
          connection.unwrap(CalciteConnection.class).getRootSchema()
              .subSchemas().get("s")
              .add("my_seq",
                  new AbstractTable() {
                    @Override public RelDataType getRowType(
                        RelDataTypeFactory typeFactory) {
                      return typeFactory.builder()
                          .add("$seq", SqlTypeName.BIGINT).build();
                    }
  
                    @Override public Schema.TableType getJdbcTableType() {
                      return Schema.TableType.SEQUENCE;
                    }
                  });
          return connection;
        case "bookstore":
          return CalciteAssert.that()
              .with(CalciteAssert.SchemaSpec.BOOKSTORE)
              .connect();
        default:
          throw new RuntimeException("unknown connection '" + name + "'");
        }
      }
    }
  
  }
  