ConfigService.java
/*
* SPDX-FileCopyrightText: 2025 kaumei.io
* SPDX-License-Identifier: Apache-2.0
*/
package io.kaumei.jdbc.anno.ctx;
import io.kaumei.jdbc.anno.Processor;
import io.kaumei.jdbc.anno.ProcessorEnvironment;
import io.kaumei.jdbc.anno.ProcessorException;
import io.kaumei.jdbc.anno.ProcessorSteps;
import io.kaumei.jdbc.anno.model.AnnoMap;
import io.kaumei.jdbc.anno.model.JavaAnnoType;
import io.kaumei.jdbc.anno.model.SourceMethod;
import io.kaumei.jdbc.anno.msg.JdbcMsg;
import io.kaumei.jdbc.annotation.JdbcUpdate;
import io.kaumei.jdbc.annotation.config.*;
import org.jspecify.annotations.Nullable;
import javax.lang.model.element.*;
import java.lang.annotation.Annotation;
import java.nio.file.Path;
import java.util.*;
import static io.kaumei.jdbc.anno.Processor.OPTION_KEY_CONFIG;
import static io.kaumei.jdbc.anno.Processor.OPTION_KEY_DEBUG_FOLDER;
import static java.util.Objects.requireNonNull;
public class ConfigService implements ProcessorSteps {
public final Option<Integer> BATCH_SIZE;
public final Option<JdbcFetchDirection.Kind> FETCH_DIRECTION;
public final Option<Integer> FETCH_SIZE;
public final Option<Integer> MAX_ROWS;
public final Option<JdbcNoMoreRows.Kind> NO_MORE_ROWS;
public final Option<JdbcNoRows.Kind> NO_ROWS;
public final Option<Integer> QUERY_TIMEOUT;
public final Option<JdbcResultSetConcurrency.Kind> RESULT_SET_CONCURRENCY;
public final Option<JdbcResultSetType.Kind> RESULT_SET_TYPE;
public final Option[] ALL_OPTIONS;
// ----- services
private final Context ctx;
private final JavaMessenger logger;
// ----- state
private final @Nullable String configKey;
private final @Nullable String debugFolder;
private JdbcUpdate.GeneratedValues jdbcReturnGeneratedValues = JdbcUpdate.GeneratedValues.GENERATED_KEYS;
private String generatedClassSuffix = "Jdbc";
private int maxCollectionPlaceholders = 1_000;
private int maxTotalPlaceholders = 2_000;
private final Map<Option<?>, Object> option2default = new HashMap<>();
private final Set<ExecutableElement> jdbcToJava = new HashSet<>();
private final Set<ExecutableElement> javaToJdbc = new HashSet<>();
private final Set<Name> parentCycleDetection = new LinkedHashSet<>();
// ------------------------------------------------------------------------
public ConfigService(Context ctx, Map<String, String> options) {
this.ctx = requireNonNull(ctx);
this.logger = requireNonNull(ctx.logger);
this.configKey = options.get(OPTION_KEY_CONFIG);
this.debugFolder = options.get(OPTION_KEY_DEBUG_FOLDER);
BATCH_SIZE = create(JdbcBatchSize.class, false, 1);
FETCH_DIRECTION = create(JdbcFetchDirection.class, JdbcFetchDirection.Kind.class, JdbcFetchDirection.Kind.UNSPECIFIED);
FETCH_SIZE = create(JdbcFetchSize.class, 0);
MAX_ROWS = create(JdbcMaxRows.class, 0);
NO_MORE_ROWS = create(JdbcNoMoreRows.class, JdbcNoMoreRows.Kind.class, JdbcNoMoreRows.Kind.UNSPECIFIED, false);
NO_ROWS = create(JdbcNoRows.class, JdbcNoRows.Kind.class, JdbcNoRows.Kind.UNSPECIFIED, false);
QUERY_TIMEOUT = create(JdbcQueryTimeout.class, 0);
RESULT_SET_CONCURRENCY = create(JdbcResultSetConcurrency.class, JdbcResultSetConcurrency.Kind.class, JdbcResultSetConcurrency.Kind.UNSPECIFIED);
RESULT_SET_TYPE = create(JdbcResultSetType.class, JdbcResultSetType.Kind.class, JdbcResultSetType.Kind.UNSPECIFIED);
ALL_OPTIONS = new Option[]{BATCH_SIZE, FETCH_DIRECTION, FETCH_SIZE, MAX_ROWS, NO_MORE_ROWS, NO_ROWS, QUERY_TIMEOUT,
RESULT_SET_CONCURRENCY, RESULT_SET_TYPE};
}
// ------------------------------------------------------------------------
@Override
public void process(ProcessorEnvironment roundEnv) {
this.logger.info("Process Kaumei JDBC processor configuration.");
checkJdbcConfigNotOnJdbcInterface(roundEnv);
var configByAnno = configByAnno(roundEnv);
var configByOptions = configByOption();
if (configByAnno != null && configByOptions != null && !configByAnno.equals(configByOptions)) {
var msg = "Given config by annotation and by processor option are not equal.";
this.logger.error(configByAnno, msg);
this.logger.error(configByOptions, msg);
}
loadConfig(configByAnno != null ? configByAnno : configByOptions);
this.logger.always("option2default:", option2default);
this.logger.always("jdbcToJava....:", jdbcToJava);
this.logger.always("javaToJdbc....:", javaToJdbc);
parentCycleDetection.clear();
}
private void checkJdbcConfigNotOnJdbcInterface(ProcessorEnvironment roundEnv) {
for (var config : roundEnv.jdbcConfig()) {
if (config.getKind() == ElementKind.INTERFACE
&& config instanceof TypeElement te
&& roundEnv.jdbcInterfaces().contains(te)) {
this.logger.error(config,
"@JdbcConfig must not be used on an interface that declares @Jdbc methods.");
}
}
}
private void loadConfig(@Nullable TypeElement jdbcConfig) {
// ----- put the default values
for (var option : ALL_OPTIONS) {
option2default.put(option, option.defaultValue());
}
// overwrite some defaults
option2default.put(BATCH_SIZE, 1000);
option2default.put(NO_MORE_ROWS, JdbcNoMoreRows.Kind.THROW_EXCEPTION);
option2default.put(NO_ROWS, JdbcNoRows.Kind.THROW_EXCEPTION);
if (jdbcConfig == null) {
this.logger.debug("No config found.");
return;
}
processConfig(jdbcConfig);
}
private void processConfig(TypeElement configType) {
if (!parentCycleDetection.add(configType.getQualifiedName())) {
this.logger.warn("Encounter cycle in parent definition.", parentCycleDetection);
return;
}
var annoMap = AnnoMap.of(ctx, configType);
var cfg = annoMap.getOpt(ctx.JDBC_CONFIG_PROPS);
if (cfg != null && cfg.parent() != null) {
var elem = ctx.asElement(cfg.parent());
if (elem instanceof TypeElement te) {
processConfig(te);
} else {
this.logger.warn("Ignore parent.", elem);
}
}
this.logger.debug("Process config:", configType);
// ----- by default we search for converter in the config type
processConverter(configType);
// ----- next we check all other referenced types
if (cfg != null) {
jdbcReturnGeneratedValues = switch (cfg.returnGeneratedValues()) {
case GENERATED_KEYS -> JdbcUpdate.GeneratedValues.GENERATED_KEYS;
case EXECUTE_QUERY -> JdbcUpdate.GeneratedValues.EXECUTE_QUERY;
default -> jdbcReturnGeneratedValues;
};
if (isValidGeneratedClassSuffix(cfg.generatedClassSuffix())) {
generatedClassSuffix = cfg.generatedClassSuffix();
} else {
logger.error(configType, "@JdbcConfig.generatedClassSuffix must be a non-blank Java class name suffix.");
}
if (cfg.maxCollectionPlaceholders() >= 1) {
maxCollectionPlaceholders = cfg.maxCollectionPlaceholders();
} else {
logger.error(configType, "@JdbcConfig.maxCollectionPlaceholders must be >= 1.");
}
if (cfg.maxTotalPlaceholders() >= 1) {
maxTotalPlaceholders = cfg.maxTotalPlaceholders();
} else {
logger.error(configType, "@JdbcConfig.maxTotalPlaceholders must be >= 1.");
}
for (var converterType : cfg.converter()) {
processConverter(ctx.asElement(converterType));
}
}
if (annoMap.getOpt(ctx.JDBC_LOG_LEVEL) != null) {
logger.warn("Not supported by now. Use annotation option " + Processor.OPTION_KEY_LOG_LEVEL);
}
for (var option : ALL_OPTIONS) {
var value = getOption(annoMap, option, configType);
if (value != null) {
option2default.put(option, value);
}
}
if (annoMap.hasUnused()) {
throw new ProcessorException(JdbcMsg.unusedAnnotations(configType, annoMap.unused()).toString(), configType);
}
}
private void processConverter(Element element) {
if (element instanceof ExecutableElement executable) {
var hasJdbcToJava = ctx.JDBC_TO_JAVA.hasAnno(executable);
var hasJavaToJdbc = ctx.JAVA_TO_JDBC.hasAnno(executable);
if (hasJdbcToJava && hasJavaToJdbc) {
this.logger.error(executable, "Could not have both annotations @JdbcToJava and @JavaToJdbc present at an element.");
} else if (hasJdbcToJava) {
jdbcToJava.add(executable);
} else if (hasJavaToJdbc) {
javaToJdbc.add(executable);
}
} else if (element instanceof TypeElement type) {
for (var child : type.getEnclosedElements()) {
processConverter(child);
}
} else {
logger.warn(element, "Unsupported element type for annotations. Annotation is ignored.", "kind", element.getKind());
}
}
/**
* Search for a @JdbcConfig annotated class. there must be none or exactly one.
* @return found type or null
*/
private @Nullable TypeElement configByAnno(ProcessorEnvironment roundEnv) {
if (roundEnv.jdbcConfig().size() == 1) {
var jdbcConfig = roundEnv.jdbcConfig().iterator().next();
if (jdbcConfig instanceof TypeElement te) {
return te;
}
this.logger.error(jdbcConfig, "Incompatible type for configuration.");
} else if (roundEnv.jdbcConfig().size() > 1) {
this.logger.error("To many configurations found.", "configs", roundEnv.jdbcConfig());
}
return null;
}
/**
* Search for a @JdbcConfig defined class by processor options.
* @return found type or null
*/
private @Nullable TypeElement configByOption() {
if (configKey != null) {
var jdbcConfig = this.ctx.getTypeElementOpt(configKey);
if (jdbcConfig == null) {
this.logger.warn("Given config could not be found.", "config", configKey);
} else if (!ctx.JDBC_CONFIG_PROPS.hasAnno(jdbcConfig)) {
this.logger.warn(jdbcConfig, "Given config is missing @JdbcConfig annotation.");
} else {
return jdbcConfig;
}
}
return null;
}
// ------------------------------------------------------------------------
public boolean dump() {
return debugFolder != null;
}
public Path dumpFolder() {
return Path.of(requireNonNull(debugFolder));
}
// ------------------------------------------------------------------------
public Set<ExecutableElement> jdbcToJava() {
return jdbcToJava;
}
public Set<ExecutableElement> javaToJdbc() {
return javaToJdbc;
}
// ------------------------------------------------------------------------
public JdbcUpdate.GeneratedValues jdbcReturnGeneratedValues() {
return this.jdbcReturnGeneratedValues;
}
public String generatedClassSuffix() {
return generatedClassSuffix;
}
public int maxCollectionPlaceholders() {
return maxCollectionPlaceholders;
}
public int maxTotalPlaceholders() {
return maxTotalPlaceholders;
}
private static boolean isValidGeneratedClassSuffix(String suffix) {
if (suffix.isBlank()) {
return false;
}
for (int i = 0; i < suffix.length(); i++) {
if (!Character.isJavaIdentifierPart(suffix.charAt(i))) {
return false;
}
}
return true;
}
@Nullable
public <T> OptionValue<T> search(Option<T> option, SourceMethod method) {
var name = method.parameter().paramNameFor(option);
if (name != null) {
return new OptionValue.Dynamic<>(option, name);
}
T value = this.searchOnMethod(option, method);
return option.isNotDefaultValue(value)
? new OptionValue.Static<>(option, value)
: null;
}
@SuppressWarnings("unchecked")
public <T> T searchOnMethod(Option<T> option, SourceMethod method) {
var value = getOption(method.annoMap(), option, method.method());
if (value != null) {
return value;
}
value = option.anno().getAnnoOpt(method.parent());
if (value != null && (option.unsetAllowed() || option.isNotDefaultValue(value))) {
validateStaticOptionValue(method.parent(), option, value);
return value;
}
return requireNonNull((T) option2default.get(option));
}
private <T> @Nullable T getOption(AnnoMap annoMap, Option<T> option, Element element) {
var value = option.unsetAllowed()
? annoMap.getOpt(option)
: annoMap.getIfNotDefault(option);
if (value != null) {
validateStaticOptionValue(element, option, value);
}
return value;
}
private <T> void validateStaticOptionValue(Element element, Option<T> option, T value) {
if (option.isInvalidValue(value)) {
logger.error(element, invalidValueMessage(option, value));
}
}
public static <T> String invalidValueMessage(Option<T> option, T value) {
return "Invalid value for @" + option.anno().typeElement().getSimpleName() + ": " + value;
}
// ------------------------------------------------------------------------
// new code
// ------------------------------------------------------------------------
private Option<Integer> create(Class<? extends Annotation> anno, int minValue) {
return create(anno, true, minValue);
}
private Option<Integer> create(Class<? extends Annotation> anno, boolean unsetAllowed, int minValue) {
var annoType = new JavaAnnoType<>(this.ctx, anno, Integer.class, map -> map.getInteger("value", -1));
return new Option<>(annoType, -1, unsetAllowed, minValue);
}
private <T extends Enum<T>> Option<T> create(Class<? extends Annotation> anno, Class<T> cls, T defaultValue) {
return create(anno, cls, defaultValue, true);
}
private <T extends Enum<T>> Option<T> create(Class<? extends Annotation> anno, Class<T> cls, T defaultValue, boolean unsetAllowed) {
var annoType = new JavaAnnoType<>(this.ctx, anno, cls, map -> map.getEnum("value", cls, defaultValue));
return new Option<>(annoType, defaultValue, unsetAllowed, null);
}
// ------------------------------------------------------------------------
public record Option<T>(JavaAnnoType<T> anno, T defaultValue, boolean unsetAllowed, @Nullable Integer minValue) {
public T cast(Object value) {
return anno.cast(value);
}
public boolean isInvalidValue(Object value) {
return minValue != null
&& value instanceof Integer integer
&& !defaultValue.equals(value)
&& integer < minValue;
}
public boolean isDefaultValue(@Nullable Object value) {
return defaultValue.equals(value);
}
public boolean isNotDefaultValue(@Nullable Object value) {
return value != null
&& defaultValue.getClass().equals(value.getClass())
&& !defaultValue.equals(value);
}
}
public sealed interface OptionValue<T> permits OptionValue.Static, OptionValue.Dynamic {
Option<T> option();
record Static<T>(Option<T> option, T value) implements OptionValue<T> {
}
record Dynamic<T>(Option<T> option, String paramName) implements OptionValue<T> {
}
}
}