SqlGeneratorConfig.java

/*
 *    Copyright 2018-2022 the original author or authors.
 *
 *    Licensed 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
 *
 *       https://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.mybatis.scripting.thymeleaf;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.reflect.InvocationTargetException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Stream;

import org.mybatis.scripting.thymeleaf.PropertyAccessor.BuiltIn.StandardPropertyAccessor;
import org.mybatis.scripting.thymeleaf.processor.BindVariableRender;
import org.thymeleaf.util.ClassLoaderUtils;
import org.thymeleaf.util.StringUtils;

/**
 * Configuration class for {@link SqlGenerator}.
 *
 * @author Kazuki Shimizu
 *
 * @since 1.0.2
 */
public class SqlGeneratorConfig {

  private static class PropertyKeys {
    private static final String CONFIG_FILE = "mybatis-thymeleaf.config.file";
    private static final String CONFIG_ENCODING = "mybatis-thymeleaf.config.encoding";
  }

  private static class Defaults {
    private static final String PROPERTIES_FILE = "mybatis-thymeleaf.properties";
  }

  private static final Map<Class<?>, Function<String, Object>> TYPE_CONVERTERS;

  static {
    Map<Class<?>, Function<String, Object>> converters = new HashMap<>();
    converters.put(boolean.class, v -> Boolean.valueOf(v.trim()));
    converters.put(String.class, String::trim);
    converters.put(Character[].class, v -> Stream.of(v.split(",")).map(String::trim).filter(e -> e.length() == 1)
        .map(e -> e.charAt(0)).toArray(Character[]::new));
    converters.put(Character.class, v -> v.trim().charAt(0));
    converters.put(Charset.class, v -> Charset.forName(v.trim()));
    converters.put(Long.class, v -> Long.valueOf(v.trim()));
    converters.put(String[].class, v -> Stream.of(v.split(",")).map(String::trim).toArray(String[]::new));
    converters.put(Class.class, SqlGeneratorConfig::toClassForName);
    TYPE_CONVERTERS = Collections.unmodifiableMap(converters);
  }

  /**
   * Whether use the 2-way SQL feature.
   */
  private boolean use2way = true;

  /**
   * The instance for customizing a default TemplateEngine instanced by the mybatis-thymeleaf.
   */
  private TemplateEngineCustomizer customizer;

  /**
   * Template file configuration.
   */
  private final TemplateFileConfig templateFile = new TemplateFileConfig();

  /**
   * Dialect configuration.
   */
  private final DialectConfig dialect = new DialectConfig();

  /**
   * Get whether use the 2-way SQL feature.
   * <p>
   * Default is {@code true}.
   * </p>
   *
   * @return If use the 2-way SQL feature, return {@code true}
   */
  public boolean isUse2way() {
    return use2way;
  }

  /**
   * Set whether use the 2-way SQL feature.
   *
   * @param use2way
   *          If use the 2-way SQL feature, set {@code true}
   */
  public void setUse2way(boolean use2way) {
    this.use2way = use2way;
  }

  /**
   * Get the interface for customizing a default TemplateEngine instanced by the mybatis-thymeleaf.
   * <p>
   * Default is {@code null}.
   * </p>
   * This method exists for the backward compatibility.<br>
   * Use {@link #getCustomizerInstance()} instead
   *
   * @return the interface for customizing a default TemplateEngine
   */
  @Deprecated
  public Class<? extends TemplateEngineCustomizer> getCustomizer() {
    return customizer == null ? null : customizer.getClass();
  }

  /**
   * Set the interface for customizing a default TemplateEngine instanced by the mybatis-thymeleaf.
   *
   * @param customizer
   *          the interface for customizing a default TemplateEngine
   */
  @Deprecated
  public void setCustomizer(Class<? extends TemplateEngineCustomizer> customizer) {
    this.customizer = newInstanceForType(customizer);
  }

  public TemplateEngineCustomizer getCustomizerInstance() {
    return customizer;
  }

  public void setCustomizerInstance(TemplateEngineCustomizer customizer) {
    this.customizer = customizer;
  }

  /**
   * Get a template file configuration.
   *
   * @return a template file configuration
   */
  public TemplateFileConfig getTemplateFile() {
    return templateFile;
  }

  /**
   * Get a dialect configuration.
   *
   * @return a dialect configuration
   */
  public DialectConfig getDialect() {
    return dialect;
  }

  /**
   * Template file configuration.
   *
   * @since 1.0.0
   */
  public static class TemplateFileConfig {

    /**
     * The character encoding for reading template resource file.
     */
    private Charset encoding = StandardCharsets.UTF_8;

    /**
     * The base directory for reading template resource file.
     */
    private String baseDir = "";

    /**
     * The patterns for reading as template resource file. (Can specify multiple patterns using comma(",") as separator
     * character)
     */
    private String[] patterns = { "*.sql" };

    /**
     * Whether use the cache feature when load template resource file.
     */
    private boolean cacheEnabled = true;

    /**
     * The cache TTL(millisecond) for resolved templates.
     */
    private Long cacheTtl;

    /**
     * Get the character encoding for reading template resource file.
     * <p>
     * Default is {@code UTF-8}.
     * </p>
     *
     * @return the character encoding for reading template resource file
     */
    public Charset getEncoding() {
      return encoding;
    }

    /**
     * Set the character encoding for reading template resource file.
     *
     * @param encoding
     *          the character encoding for reading template resource file
     */
    public void setEncoding(Charset encoding) {
      this.encoding = encoding;
    }

    /**
     * Get the base directory for reading template resource file.
     * <p>
     * Default is {@code ""}(none).
     * </p>
     *
     * @return the base directory for reading template resource file
     */
    public String getBaseDir() {
      return baseDir;
    }

    /**
     * Set the base directory for reading template resource file.
     *
     * @param baseDir
     *          the base directory for reading template resource file
     */
    public void setBaseDir(String baseDir) {
      this.baseDir = baseDir;
    }

    /**
     * Get patterns for reading as template resource file.
     * <p>
     * Default is {@code "*.sql"}.
     * </p>
     *
     * @return patterns for reading as template resource file
     */
    public String[] getPatterns() {
      return patterns;
    }

    /**
     * Set patterns for reading as template resource file.
     *
     * @param patterns
     *          patterns for reading as template resource file
     */
    public void setPatterns(String... patterns) {
      this.patterns = patterns;
    }

    /**
     * Get whether use the cache feature when load template resource file.
     * <p>
     * Default is {@code true}.
     * </p>
     *
     * @return If use th cache feature, return {@code true}
     */
    public boolean isCacheEnabled() {
      return cacheEnabled;
    }

    /**
     * Set whether use the cache feature when load template resource file.
     *
     * @param cacheEnabled
     *          If use th cache feature, set {@code true}
     */
    public void setCacheEnabled(boolean cacheEnabled) {
      this.cacheEnabled = cacheEnabled;
    }

    /**
     * Get the cache TTL(millisecond) for resolved templates.
     * <p>
     * Default is {@code null}(indicate to use default value of Thymeleaf).
     * </p>
     *
     * @return the cache TTL(millisecond) for resolved templates
     */
    public Long getCacheTtl() {
      return cacheTtl;
    }

    /**
     * Set the cache TTL(millisecond) for resolved templates.
     *
     * @param cacheTtl
     *          the cache TTL(millisecond) for resolved templates
     */
    public void setCacheTtl(Long cacheTtl) {
      this.cacheTtl = cacheTtl;
    }

  }

  /**
   * Dialect configuration.
   *
   * @since 1.0.0
   */
  public static class DialectConfig {

    /**
     * The prefix name of dialect provided by this project.
     */
    private String prefix = "mb";

    /**
     * The escape character for wildcard of LIKE condition.
     */
    private Character likeEscapeChar = '\\';

    /**
     * The format of escape clause for LIKE condition (Can specify format that can be allowed by String#format method).
     */
    private String likeEscapeClauseFormat = "ESCAPE '%s'";

    /**
     * Additional escape target characters(custom wildcard characters) for LIKE condition. (Can specify multiple
     * characters using comma(",") as separator character)
     */
    private Character[] likeAdditionalEscapeTargetChars;

    /**
     * The bind variable render.
     */
    private BindVariableRender bindVariableRender;

    /**
     * Get the prefix name of dialect provided by this project.
     * <p>
     * Default is {@code "mb"}.
     * </p>
     *
     * @return the prefix name of dialect
     */
    public String getPrefix() {
      return prefix;
    }

    /**
     * Set the prefix name of dialect provided by this project.
     *
     * @param prefix
     *          the prefix name of dialect
     */
    public void setPrefix(String prefix) {
      this.prefix = prefix;
    }

    /**
     * Get the escape character for wildcard of LIKE condition.
     * <p>
     * Default is {@code '\'}.
     * </p>
     *
     * @return the escape character for wildcard
     */
    public Character getLikeEscapeChar() {
      return likeEscapeChar;
    }

    /**
     * Set the escape character for wildcard of LIKE condition.
     *
     * @param likeEscapeChar
     *          the escape character for wildcard
     */
    public void setLikeEscapeChar(Character likeEscapeChar) {
      this.likeEscapeChar = likeEscapeChar;
    }

    /**
     * Get the format of escape clause for LIKE condition.
     * <p>
     * Can specify format that can be allowed by String#format method. Default is {@code "ESCAPE '%s'"}.
     * </p>
     *
     * @return the format of escape clause for LIKE condition
     */
    public String getLikeEscapeClauseFormat() {
      return likeEscapeClauseFormat;
    }

    /**
     * Set the format of escape clause for LIKE condition.
     *
     * @param likeEscapeClauseFormat
     *          the format of escape clause for LIKE condition
     */
    public void setLikeEscapeClauseFormat(String likeEscapeClauseFormat) {
      this.likeEscapeClauseFormat = likeEscapeClauseFormat;
    }

    /**
     * Get additional escape target characters(custom wildcard characters) for LIKE condition.
     * <p>
     * Can specify multiple characters using comma(",") as separator character. Default is empty(none).
     * </p>
     *
     * @return additional escape target characters(custom wildcard characters)
     */
    public Character[] getLikeAdditionalEscapeTargetChars() {
      return likeAdditionalEscapeTargetChars;
    }

    /**
     * Set additional escape target characters(custom wildcard characters) for LIKE condition.
     *
     * @param likeAdditionalEscapeTargetChars
     *          additional escape target characters(custom wildcard characters)
     */
    public void setLikeAdditionalEscapeTargetChars(Character... likeAdditionalEscapeTargetChars) {
      this.likeAdditionalEscapeTargetChars = likeAdditionalEscapeTargetChars;
    }

    /**
     * Get a bind variable render.
     * <p>
     * Default is {@link BindVariableRender.BuiltIn#MYBATIS}
     * </p>
     * This method exists for the backward compatibility. <br>
     * Use {@link #getBindVariableRenderInstance()} instead
     *
     * @return a bind variable render
     */
    @Deprecated
    public Class<? extends BindVariableRender> getBindVariableRender() {
      return bindVariableRender == null ? null : bindVariableRender.getClass();
    }

    /**
     * This method exists for the backward compatibility.<br>
     * Use {@link #setBindVariableRenderInstance(BindVariableRender)} instead
     *
     * @param bindVariableRender
     *          bindVariableRender class
     */
    @Deprecated
    public void setBindVariableRender(Class<? extends BindVariableRender> bindVariableRender) {
      this.bindVariableRender = newInstanceForType(bindVariableRender);
    }

    public BindVariableRender getBindVariableRenderInstance() {
      return bindVariableRender;
    }

    public void setBindVariableRenderInstance(BindVariableRender bindVariableRender) {
      this.bindVariableRender = bindVariableRender;
    }
  }

  /**
   * Create an instance from default properties file. <br>
   * If you want to customize a default {@code TemplateEngine}, you can configure some property using
   * mybatis-thymeleaf.properties that encoded by UTF-8. Also, you can change the properties file that will read using
   * system property (-Dmybatis-thymeleaf.config.file=... -Dmybatis-thymeleaf.config.encoding=...). <br>
   * Supported properties are as follows:
   * <table border="1">
   * <caption>Supported properties</caption>
   * <tr>
   * <th>Property Key</th>
   * <th>Description</th>
   * <th>Default</th>
   * </tr>
   * <tr>
   * <th colspan="3">General configuration</th>
   * </tr>
   * <tr>
   * <td>use2way</td>
   * <td>Whether use the 2-way SQL</td>
   * <td>{@code true}</td>
   * </tr>
   * <tr>
   * <td>customizer</td>
   * <td>The implementation class for customizing a default {@code TemplateEngine} instanced by the MyBatis Thymeleaf
   * </td>
   * <td>None</td>
   * </tr>
   * <tr>
   * <th colspan="3">Template file configuration</th>
   * </tr>
   * <tr>
   * <td>template-file.cache-enabled</td>
   * <td>Whether use the cache feature</td>
   * <td>{@code true}</td>
   * </tr>
   * <tr>
   * <td>template-file.cache-ttl</td>
   * <td>The cache TTL for resolved templates</td>
   * <td>None(use default value of Thymeleaf)</td>
   * </tr>
   * <tr>
   * <td>template-file.encoding</td>
   * <td>The character encoding for reading template resources</td>
   * <td>{@code "UTF-8"}</td>
   * </tr>
   * <tr>
   * <td>template-file.base-dir</td>
   * <td>The base directory for reading template resources</td>
   * <td>None(just under class path)</td>
   * </tr>
   * <tr>
   * <td>template-file.patterns</td>
   * <td>The patterns for reading as template resources</td>
   * <td>{@code "*.sql"}</td>
   * </tr>
   * <tr>
   * <th colspan="3">Dialect configuration</th>
   * </tr>
   * <tr>
   * <td>dialect.prefix</td>
   * <td>The prefix name of dialect provided by this project</td>
   * <td>{@code "mb"}</td>
   * </tr>
   * <tr>
   * <td>dialect.like-escape-char</td>
   * <td>The escape character for wildcard of LIKE</td>
   * <td>{@code '\'} (backslash)</td>
   * </tr>
   * <tr>
   * <td>dialect.like-escape-clause-format</td>
   * <td>The format of escape clause</td>
   * <td>{@code "ESCAPE '%s'"}</td>
   * </tr>
   * <tr>
   * <td>dialect.like-additional-escape-target-chars</td>
   * <td>The additional escape target characters(custom wildcard characters) for LIKE condition</td>
   * <td>None</td>
   * </tr>
   * </table>
   *
   * @return a configuration instance
   */
  public static SqlGeneratorConfig newInstance() {
    SqlGeneratorConfig config = new SqlGeneratorConfig();
    applyDefaultProperties(config);
    return config;
  }

  /**
   * Create an instance from specified properties file. <br>
   * you can configure some property using specified properties file that encoded by UTF-8. Also, you can change file
   * encoding that will read using system property (-Dmybatis-thymeleaf.config.encoding=...).
   *
   * @param resourcePath
   *          A property file resource path
   *
   * @return a configuration instance
   *
   * @see #newInstance()
   */
  public static SqlGeneratorConfig newInstanceWithResourcePath(String resourcePath) {
    SqlGeneratorConfig config = new SqlGeneratorConfig();
    applyResourcePath(config, resourcePath);
    return config;
  }

  /**
   * Create an instance from specified properties.
   *
   * @param customProperties
   *          custom configuration properties
   *
   * @return a configuration instance
   *
   * @see #newInstance()
   */
  public static SqlGeneratorConfig newInstanceWithProperties(Properties customProperties) {
    SqlGeneratorConfig config = new SqlGeneratorConfig();
    applyProperties(config, customProperties);
    return config;
  }

  /**
   * Create an instance using specified customizer and override using a default properties file.
   *
   * @param customizer
   *          baseline customizer
   *
   * @return a configuration instance
   *
   * @see #newInstance()
   */
  public static SqlGeneratorConfig newInstanceWithCustomizer(Consumer<SqlGeneratorConfig> customizer) {
    SqlGeneratorConfig config = new SqlGeneratorConfig();
    customizer.accept(config);
    applyDefaultProperties(config);
    return config;
  }

  /**
   * Apply properties that read from default properties file. <br>
   * If you want to customize a default {@code TemplateEngine}, you can configure some property using
   * mybatis-thymeleaf.properties that encoded by UTF-8. Also, you can change the properties file that will read using
   * system property (-Dmybatis-thymeleaf.config.file=... -Dmybatis-thymeleaf.config.encoding=...).
   */
  static <T extends SqlGeneratorConfig> void applyDefaultProperties(T config) {
    applyProperties(config, loadDefaultProperties());
  }

  /**
   * Apply properties that read from specified properties file. <br>
   * you can configure some property using specified properties file that encoded by UTF-8. Also, you can change file
   * encoding that will read using system property (-Dmybatis-thymeleaf.config.encoding=...).
   *
   * @param resourcePath
   *          A property file resource path
   */
  static <T extends SqlGeneratorConfig> void applyResourcePath(T config, String resourcePath) {
    Properties properties = loadDefaultProperties();
    properties.putAll(loadProperties(resourcePath));
    applyProperties(config, properties);
  }

  /**
   * Apply properties from specified properties.
   *
   * @param config
   *          a configuration instance
   * @param customProperties
   *          custom configuration properties
   */
  static <T extends SqlGeneratorConfig> void applyProperties(T config, Properties customProperties) {
    Properties properties = loadDefaultProperties();
    Optional.ofNullable(customProperties).ifPresent(properties::putAll);
    override(config, properties);
  }

  /**
   * Create new instance using default constructor with specified type.
   *
   * @param type
   *          a target type
   * @param <T>
   *          a target type
   *
   * @return new instance of target type
   */
  static <T> T newInstanceForType(Class<T> type) {
    try {
      return type.getConstructor().newInstance();
    } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
      throw new IllegalStateException("Cannot create an instance for class: " + type, e);
    }
  }

  private static void override(SqlGeneratorConfig config, Properties properties) {
    PropertyAccessor standardPropertyAccessor = PropertyAccessor.BuiltIn.STANDARD;
    try {
      properties.forEach((key, value) -> {
        String propertyPath = StringUtils.unCapitalize(StringUtils.capitalizeWords(key, "-").replaceAll("-", ""));
        try {
          Object target = config;
          String propertyName;
          if (propertyPath.indexOf('.') != -1) {
            String[] propertyPaths = StringUtils.split(propertyPath, ".");
            propertyName = propertyPaths[propertyPaths.length - 1];
            for (String path : Arrays.copyOf(propertyPaths, propertyPaths.length - 1)) {
              target = standardPropertyAccessor.getPropertyValue(target, path);
            }
          } else {
            propertyName = propertyPath;
          }
          Object convertedValue = TYPE_CONVERTERS
              .getOrDefault(standardPropertyAccessor.getPropertyType(target.getClass(), propertyName), v -> v)
              .apply(value.toString());
          standardPropertyAccessor.setPropertyValue(target, propertyName, convertedValue);
        } catch (IllegalArgumentException e) {
          throw new IllegalArgumentException(
              String.format("Detected an invalid property. key='%s' value='%s'", key, value), e);
        }
      });
    } finally {
      StandardPropertyAccessor.clearCache();
    }
  }

  private static Properties loadDefaultProperties() {
    return loadProperties(System.getProperty(PropertyKeys.CONFIG_FILE, Defaults.PROPERTIES_FILE));
  }

  private static Properties loadProperties(String resourcePath) {
    Properties properties = new Properties();
    Optional.ofNullable(ClassLoaderUtils.findResourceAsStream(resourcePath)).ifPresent(in -> {
      Charset encoding = Optional.ofNullable(System.getProperty(PropertyKeys.CONFIG_ENCODING)).map(Charset::forName)
          .orElse(StandardCharsets.UTF_8);
      try (InputStreamReader inReader = new InputStreamReader(in, encoding);
          BufferedReader bufReader = new BufferedReader(inReader)) {
        properties.load(bufReader);
      } catch (IOException e) {
        throw new IllegalStateException(e);
      }
    });
    return properties;
  }

  private static Class<?> toClassForName(String value) {
    try {
      return ClassLoaderUtils.loadClass(value.trim());
    } catch (ClassNotFoundException e) {
      throw new IllegalStateException(e);
    }
  }

}