diff --git a/pom.xml b/pom.xml index 1df303d7..d0841c39 100644 --- a/pom.xml +++ b/pom.xml @@ -54,6 +54,11 @@ com.github.ben-manes.caffeine caffeine + + org.freemarker + freemarker + 2.3.32 + org.apache.poi poi diff --git a/src/main/java/cn/lihongjie/coal/base/dto/CommonQuery.java b/src/main/java/cn/lihongjie/coal/base/dto/CommonQuery.java index d6862b9f..e3052a4e 100644 --- a/src/main/java/cn/lihongjie/coal/base/dto/CommonQuery.java +++ b/src/main/java/cn/lihongjie/coal/base/dto/CommonQuery.java @@ -149,6 +149,13 @@ public class CommonQuery { parseKey(root, x.key), c.convert(x.value, String.class)); }); + map.put( + Tuple.of("eq", Boolean.class), + (Root root, CriteriaBuilder criteriaBuilder, QueryItem x, ConversionService c) -> { + return criteriaBuilder.equal( + parseKey(root, x.key), c.convert(x.value, Boolean.class)); + }); + map.put( Tuple.of("eq", Integer.class), (Root root, CriteriaBuilder criteriaBuilder, QueryItem x, ConversionService c) -> { @@ -207,6 +214,12 @@ public class CommonQuery { return criteriaBuilder.notEqual( parseKey(root, x.key), c.convert(x.value, String.class)); }); + map.put( + Tuple.of("neq", Boolean.class), + (Root root, CriteriaBuilder criteriaBuilder, QueryItem x, ConversionService c) -> { + return criteriaBuilder.notEqual( + parseKey(root, x.key), c.convert(x.value, Boolean.class)); + }); map.put( Tuple.of("neq", Integer.class), diff --git a/src/main/java/cn/lihongjie/coal/common/FreeMakerUtils.java b/src/main/java/cn/lihongjie/coal/common/FreeMakerUtils.java new file mode 100644 index 00000000..28ae1f9b --- /dev/null +++ b/src/main/java/cn/lihongjie/coal/common/FreeMakerUtils.java @@ -0,0 +1,338 @@ +package cn.lihongjie.coal.common; + +import static java.util.regex.Pattern.CASE_INSENSITIVE; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.common.base.Splitter; + +import freemarker.core.Environment; +import freemarker.template.*; +import freemarker.template.utility.DeepUnwrap; + +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; + +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.RegExUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.util.Assert; +import org.springframework.util.DigestUtils; + +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.regex.Pattern; + +@UtilityClass +public class FreeMakerUtils { + + private static final Configuration cfg; + + private static final Cache templateCache = + Caffeine.newBuilder().maximumSize(10_000).build(); + + static { + + // Create your Configuration instance, and specify if up to what FreeMarker + // version (here 2.3.32) do you want to apply the fixes that are not 100% + // backward-compatible. See the Configuration JavaDoc for details. + cfg = new Configuration(Configuration.VERSION_2_3_32); + + // Specify the source where the template files come from. Here I set a + // plain directory for it, but non-file-system sources are possible too: + + // From here we will set the settings recommended for new projects. These + // aren't the defaults for backward compatibilty. + + // Set the preferred charset template files are stored in. UTF-8 is + // a good choice in most applications: + cfg.setDefaultEncoding("UTF-8"); + + // Sets how errors will appear. + // During web page *development* TemplateExceptionHandler.HTML_DEBUG_HANDLER is better. + cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); + + // Don't log exceptions inside FreeMarker that it will thrown at you anyway: + cfg.setLogTemplateExceptions(false); + + // Wrap unchecked exceptions thrown during template processing into TemplateException-s: + cfg.setWrapUncheckedExceptions(true); + + // Do not fall back to higher scopes when reading a null loop variable: + cfg.setFallbackOnNullLoopVariable(false); + + // To accomodate to how JDBC returns values; see Javadoc! + cfg.setSQLDateAndTimeTimeZone(TimeZone.getDefault()); + + cfg.setNumberFormat("c"); + + cfg.setSharedVariable("if", new MyBatisIf()); + cfg.setSharedVariable("trim", new MyBatisTrim()); + cfg.setSharedVariable("where", new MyBatisTrim("where", "AND |OR ")); + cfg.setSharedVariable("set", new MyBatisTrim("set", ",")); + cfg.setSharedVariable("foreach", new MyBatisForeach()); + } + + private static String getString(Map params, String key) throws TemplateModelException { + return getString(params, key, true, null); + } + + private static String getString(Map params, String key, String defaultVal) + throws TemplateModelException { + return getString(params, key, false, defaultVal); + } + + private static String getString(Map params, String key, boolean required, String defaultVal) + throws TemplateModelException { + + Object o = params.get(key); + + if (o == null && required) { + throw new TemplateModelException(key + " is required"); + } + + if (o == null) { + return defaultVal; + } + + if (!(o instanceof SimpleScalar s)) { + throw new TemplateModelException(key + " must be string"); + } + return StringUtils.defaultIfEmpty(s.getAsString(), defaultVal); + } + + private static Boolean getBoolean(Map params, String key, boolean required, Boolean defaultVal) + throws TemplateModelException { + + Object o = params.get(key); + + if (o == null && required) { + throw new TemplateModelException(key + " is required"); + } + + if (o == null) { + return defaultVal; + } + + if ((o instanceof SimpleScalar s)) { + return Boolean.parseBoolean(s.getAsString()); + } else if (o instanceof TemplateBooleanModel s) { + return s.getAsBoolean(); + } else { + + throw new TemplateModelException(key + " must be boolean"); + } + } + + @SneakyThrows + public static String render( String template, Object model) { + + if (StringUtils.isBlank(template)) { + return ""; + } + + if (model == null) { + model = new HashMap<>(); + } + + String hex = DigestUtils.md5DigestAsHex(template.getBytes(StandardCharsets.UTF_8)); + Template ft = + templateCache + .asMap() + .computeIfAbsent( + hex, + k -> { + try { + return new Template(hex, template, cfg); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + StringWriter out = new StringWriter(); + ft.process(model, out); + + return out.toString(); + } + + public static class MyBatisIf implements TemplateDirectiveModel { + + @Override + public void execute( + Environment env, Map params, TemplateModel[] loopVars, TemplateDirectiveBody body) + throws TemplateException, IOException { + + Object o = params.get("test"); + if (o == null) { + throw new TemplateException("test is required", env); + } + + if (o instanceof TemplateBooleanModel b) { + + if (b.getAsBoolean() && body != null) { + body.render(env.getOut()); + } + } else { + throw new TemplateException("test must be boolean", env); + } + } + } + + /** + * #{item} + */ + public static class MyBatisForeach implements TemplateDirectiveModel { + + @Override + public void execute( + Environment env, Map params, TemplateModel[] loopVars, TemplateDirectiveBody body) + throws TemplateException, IOException { + + String item = getString(params, "item", "__item"); + String index = getString(params, "index", "__index"); + String open = getString(params, "open", ""); + String separator = getString(params, "separator", ""); + String close = getString(params, "close", ""); + Boolean nullable = getBoolean(params, "nullable", false, true); + + Object collection = params.get("collection"); + + if (nullable && collection == null) { + return; + } + + if (collection == null) { + throw new TemplateException("collection is null and nullable set to false", env); + } + + Object object = DeepUnwrap.permissiveUnwrap((TemplateModel) collection); + + + + if (object instanceof String){ + object = + DeepUnwrap.permissiveUnwrap( + env.getDataModelOrSharedVariable((String) object)); + + }else if (object instanceof TemplateModelAdapter adapter){ + object = DeepUnwrap.unwrap(adapter.getTemplateModel()); + } + + + env.getOut().write(open); + + if (object instanceof Iterable iterable) { + + int idx = 0; + for (Object o : iterable) { + + env.setVariable(item, env.getObjectWrapper().wrap(o)); + env.setVariable(index, env.getObjectWrapper().wrap(idx)); + idx++; + + body.render(env.getOut()); + + env.getOut().write(separator); + } + + } else if (object instanceof Map map) { + + for (Map.Entry entry : map.entrySet()) { + env.setVariable(item, env.getObjectWrapper().wrap(entry.getValue())); + env.setVariable(index, env.getObjectWrapper().wrap(entry.getKey())); + + body.render(env.getOut()); + + env.getOut().write(separator); + } + + } else if (object.getClass().isArray()) { + + Object[] array = (Object[]) object; + + for (int i = 0; i < array.length; i++) { + env.setVariable(item, env.getObjectWrapper().wrap(array[i])); + env.setVariable(index, env.getObjectWrapper().wrap(i)); + + body.render(env.getOut()); + + env.getOut().write(separator); + } + + } else { + throw new TemplateException("collection must be iterable/map/array", env); + } + + env.getOut().write(close); + } + } + + public static class MyBatisTrim implements TemplateDirectiveModel { + + private SimpleScalar defaultPrefix; + + private SimpleScalar defaultPrefixOverrides; + + public MyBatisTrim() {} + + public MyBatisTrim(String defaultPrefix, String defaultPrefixOverrides) { + + Assert.hasLength(defaultPrefix, "defaultPrefix is required"); + Assert.hasLength(defaultPrefixOverrides, "defaultPrefixOverrides is required"); + + this.defaultPrefix = new SimpleScalar(defaultPrefix); + this.defaultPrefixOverrides = new SimpleScalar(defaultPrefixOverrides); + } + + @Override + public void execute( + Environment env, Map params, TemplateModel[] loopVars, TemplateDirectiveBody body) + throws TemplateException, IOException { + + Object p = ObjectUtils.defaultIfNull(params.get("prefix"), defaultPrefix); + Object po = + ObjectUtils.defaultIfNull( + params.get("prefixOverrides"), defaultPrefixOverrides); + + try { + + Assert.isInstanceOf(SimpleScalar.class, p, "prefix must be string"); + Assert.isInstanceOf(SimpleScalar.class, po, "prefixOverrides must be string"); + + String prefix = ((SimpleScalar) p).getAsString(); + String prefixOverrides = ((SimpleScalar) po).getAsString(); + + if (body != null) { + StringWriter out = new StringWriter(); + body.render(out); + + String bodyStr = out.toString(); + + if (StringUtils.isNotBlank(bodyStr)) { + + Iterable iterable = + Splitter.on("|").omitEmptyStrings().split(prefixOverrides); + + for (String s : iterable) { + + bodyStr = + RegExUtils.replaceFirst( + bodyStr, + Pattern.compile( + "^(\\s*)" + Pattern.quote(s), CASE_INSENSITIVE), + "$1"); + } + } + bodyStr = prefix + " " + bodyStr; + + env.getOut().write(bodyStr); + } + + } catch (Exception e) { + throw new TemplateException(e.getMessage(), e, env); + } + } + } +} diff --git a/src/main/java/cn/lihongjie/coal/common/JpaUtils.java b/src/main/java/cn/lihongjie/coal/common/JpaUtils.java index dd8764e6..fa57d5f3 100644 --- a/src/main/java/cn/lihongjie/coal/common/JpaUtils.java +++ b/src/main/java/cn/lihongjie/coal/common/JpaUtils.java @@ -1,19 +1,33 @@ package cn.lihongjie.coal.common; +import static org.springframework.data.jpa.repository.query.QueryUtils.toOrders; import com.google.common.base.CaseFormat; -import jakarta.persistence.Tuple; -import jakarta.persistence.TupleElement; +import jakarta.persistence.*; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.poi.ss.formula.functions.T; import org.springframework.core.convert.ConversionService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.support.PageableUtils; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -26,11 +40,15 @@ public class JpaUtils { (String name) -> CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, name); public static List convertTuplesToRawMap(List tuples) { - return convertTuplesToMap(tuples, Function.identity(), Function.identity()).stream().map(x -> (Map) x).collect(Collectors.toList()); + return convertTuplesToMap(tuples, Function.identity(), Function.identity()).stream() + .map(x -> (Map) x) + .collect(Collectors.toList()); } + public static List> convertTuplesToMap(List tuples) { return convertTuplesToMap(tuples, Function.identity(), Function.identity()); } + public static Function handleArray = (Object value) -> { if (value == null) { @@ -51,14 +69,14 @@ public class JpaUtils { }; public static void mergeMapToPojo( - Iterable pojos, - Iterable maps, - ConversionService conversionService) { - - - mergeMapToPojo(pojos, x -> ((Function) ReflectUtils::getId).apply(x), maps, "id", conversionService); - + Iterable pojos, Iterable maps, ConversionService conversionService) { + mergeMapToPojo( + pojos, + x -> ((Function) ReflectUtils::getId).apply(x), + maps, + "id", + conversionService); } @SneakyThrows @@ -93,12 +111,11 @@ public class JpaUtils { continue; } - Map map = mapMap.get(id); for (Object key : map.keySet()) { - if (StringUtils.equals(key.toString(), mapKey)){ + if (StringUtils.equals(key.toString(), mapKey)) { continue; } @@ -121,13 +138,154 @@ public class JpaUtils { Field field = ReflectUtils.getField(pojo.getClass(), fieldName).get(); - ReflectUtils.writeField(pojo, fieldName, conversionService.convert(value, field.getType())); - - + ReflectUtils.writeField( + pojo, fieldName, conversionService.convert(value, field.getType())); } } } + public static List execNativeQuery( + EntityManager entityManager, String sql, Object parameter, Class clazz) { + + if (clazz.isAssignableFrom(Map.class)) { + Query query = entityManager.createNativeQuery(sql, Tuple.class); + setQueryParameter(query, parameter); + return convertTuplesToMap(query.getResultList(), underscoreToCamelCase); + } else { + + Query query = entityManager.createNativeQuery(sql, clazz); + + setQueryParameter(query, parameter); + + return query.getResultList(); + } + } + + public static void setQueryParameter(Query query, Object parameter) { + + if (query == null || parameter == null) { + return; + } + + if (parameter instanceof Map) { + Map map = (Map) parameter; + for (String key : map.keySet()) { + query.setParameter(key, map.get(key)); + } + } else if (parameter instanceof Iterable it) { + + query.setParameter("param", it); + + int index = 0; + for (Object o : it) { + + query.setParameter("param" + index, o); + } + } else { + + ReflectUtils.getAllFieldsList(parameter.getClass()).stream() + .filter(x -> !Modifier.isStatic(x.getModifiers())) + .forEach( + x -> { + Object value = ReflectUtils.getFieldValue(parameter, x.getName()); + query.setParameter(x.getName(), value); + }); + } + } + + public static TypedQuery getQuery( + EntityManager entityManager, Specification spec, Class domainClass, Sort sort) { + + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(domainClass); + + Root root = applySpecificationToCriteria(entityManager, spec, domainClass, query); + query.select(root); + + if (sort.isSorted()) { + query.orderBy(toOrders(sort, root, builder)); + } + + return (entityManager.createQuery(query)); + } + + public static Page readPage( + EntityManager entityManager, + TypedQuery query, + final Class domainClass, + Pageable pageable, + @Nullable Specification spec) { + + if (pageable.isPaged()) { + query.setFirstResult(PageableUtils.getOffsetAsInteger(pageable)); + query.setMaxResults(pageable.getPageSize()); + } + + return PageableExecutionUtils.getPage( + query.getResultList(), + pageable, + () -> executeCountQuery(getCountQuery(entityManager, spec, domainClass))); + } + + private static long executeCountQuery(TypedQuery query) { + + Assert.notNull(query, "TypedQuery must not be null"); + + List totals = query.getResultList(); + long total = 0L; + + for (Long element : totals) { + total += element == null ? 0 : element; + } + + return total; + } + + public static TypedQuery getCountQuery( + EntityManager entityManager, @Nullable Specification spec, Class domainClass) { + + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(Long.class); + + Root root = applySpecificationToCriteria(entityManager, spec, domainClass, query); + + if (query.isDistinct()) { + query.select(builder.countDistinct(root)); + } else { + query.select(builder.count(root)); + } + + // Remove all Orders the Specifications might have applied + query.orderBy(Collections.emptyList()); + + return (entityManager.createQuery(query)); + } + + private static Root applySpecificationToCriteria( + EntityManager entityManager, + @Nullable Specification spec, + Class domainClass, + CriteriaQuery query) { + + Assert.notNull(domainClass, "Domain class must not be null"); + Assert.notNull(query, "CriteriaQuery must not be null"); + + Root root = query.from(domainClass); + + if (spec == null) { + return root; + } + + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + Predicate predicate = spec.toPredicate(root, query, builder); + + if (predicate != null) { + query.where(predicate); + } + + return root; + } + public static List> convertTuplesToMap( List tuples, Function keyMapper) { return convertTuplesToMap(tuples, keyMapper, Function.identity()); diff --git a/src/main/java/cn/lihongjie/coal/common/ReflectUtils.java b/src/main/java/cn/lihongjie/coal/common/ReflectUtils.java index 5c387347..2ee94289 100644 --- a/src/main/java/cn/lihongjie/coal/common/ReflectUtils.java +++ b/src/main/java/cn/lihongjie/coal/common/ReflectUtils.java @@ -16,12 +16,14 @@ import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.reflect.FieldUtils; import org.apache.commons.lang3.reflect.MethodUtils; +import org.jetbrains.annotations.NotNull; import org.springframework.util.ReflectionUtils; import java.lang.reflect.Field; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ExecutionException; @UtilityClass public class ReflectUtils { @@ -94,12 +96,23 @@ public class ReflectUtils { return getFieldCache.get( new Tuple2<>(cls, fieldName), () -> { - return fieldCache.get(cls, () -> FieldUtils.getAllFieldsList(cls)).stream() + return doGetAllFields(cls).stream() .filter(x -> x.getName().equals(fieldName)) .findFirst(); }); } + private static @NotNull List doGetAllFields(Class cls) throws ExecutionException { + return fieldCache.get(cls, () -> FieldUtils.getAllFieldsList(cls)); + } + + + @SneakyThrows + public static List getAllFieldsList(Class cls){ + + return doGetAllFields(cls); + } + @SneakyThrows public static Object invokeMethod(Object object, String methodName) { return MethodUtils.invokeMethod(object, methodName); diff --git a/src/main/java/cn/lihongjie/coal/weightDeviceData/entity/WeightDeviceDataEntity.java b/src/main/java/cn/lihongjie/coal/weightDeviceData/entity/WeightDeviceDataEntity.java index e8c0e1bc..380dea80 100644 --- a/src/main/java/cn/lihongjie/coal/weightDeviceData/entity/WeightDeviceDataEntity.java +++ b/src/main/java/cn/lihongjie/coal/weightDeviceData/entity/WeightDeviceDataEntity.java @@ -462,6 +462,11 @@ public class WeightDeviceDataEntity extends OrgCommonEntity { private LocalDateTime minTime; + + private Boolean invalid = false; + + private Boolean finished = true; + @Override public void prePersist() { super.prePersist(); @@ -481,5 +486,9 @@ public class WeightDeviceDataEntity extends OrgCommonEntity { .filter(Objects::nonNull) .min(LocalDateTime::compareTo) .orElse(null); + + + this.finished = this.pzTime!=null && this.mzTime!=null && this.ecgbTime!=null && this.ycgbTIme!=null; + } } diff --git a/src/main/java/cn/lihongjie/coal/weightDeviceData/service/WeightDeviceDataService.java b/src/main/java/cn/lihongjie/coal/weightDeviceData/service/WeightDeviceDataService.java index b013586a..2ba50121 100644 --- a/src/main/java/cn/lihongjie/coal/weightDeviceData/service/WeightDeviceDataService.java +++ b/src/main/java/cn/lihongjie/coal/weightDeviceData/service/WeightDeviceDataService.java @@ -188,6 +188,11 @@ public class WeightDeviceDataService where += " and d.specification = :specification "; } + where += " and ( d.invalid is null or !d.invalid ) "; + where += " and ( d.finished is null or d.finished ) "; + + + var sql = "select DATE_TRUNC('" + request.getTimeDimension() @@ -259,6 +264,8 @@ public class WeightDeviceDataService countQuery.setParameter("specification", "%" + request.getSpecification() + "%"); } + + var resultList = JpaUtils.convertTuplesToMap(selectQuery.getResultList()); var ans = diff --git a/src/main/java/freemarker_implicit.ftl b/src/main/java/freemarker_implicit.ftl new file mode 100644 index 00000000..37d704f4 --- /dev/null +++ b/src/main/java/freemarker_implicit.ftl @@ -0,0 +1,9 @@ +[#ftl] +[#-- @implicitly included --] + +[#macro foreach item index collection open separator close nullable][/#macro] +[#macro where][/#macro] +[#macro set][/#macro] + +[#macro trim prefix prefixOverrides][/#macro] +[#macro if test][/#macro] \ No newline at end of file diff --git a/src/test/java/cn/lihongjie/coal/common/FreeMakerUtilsTest.java b/src/test/java/cn/lihongjie/coal/common/FreeMakerUtilsTest.java new file mode 100644 index 00000000..144840e6 --- /dev/null +++ b/src/test/java/cn/lihongjie/coal/common/FreeMakerUtilsTest.java @@ -0,0 +1,165 @@ +package cn.lihongjie.coal.common; + +import static org.junit.jupiter.api.Assertions.*; + +import freemarker.template.TemplateException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.*; + +class FreeMakerUtilsTest { + + @Test + void testIf() { + + Assertions.assertEquals( + "t", + FreeMakerUtils.render( + """ + <@if test=b> + + t + + + """, + Map.of("b", true)) + .trim()); + + Assertions.assertThrows( + TemplateException.class, + () -> { + FreeMakerUtils.render( + """ + <@if test=b> + + t + + + """, + Map.of("b", "1")) + .trim(); + }); + } + + @Test + void testTrim() { + + Assertions.assertEquals( + "where 1=1", + FreeMakerUtils.render( + """ + <@trim prefix="where" prefixOverrides="AND |OR "> + and 1=1 + + + """, + Map.of("b", true)) + .trim()); + + } + + @Test + void testWhere() { + + Assertions.assertEquals( + "where 1=1", + FreeMakerUtils.render( + """ + <@where > + and 1=1 + + + """, + Map.of("b", true)) + .trim()); + + } + + @Test + void testSet() { + + Assertions.assertEquals( + "set a=1", + FreeMakerUtils.render( + """ + <@set > + ,a=1 + + + """, + Map.of("b", true)) + .trim()); + + } + + @Test + void testObject() { + Assertions.assertEquals( + "set a=1", + FreeMakerUtils.render( + """ + <@set > + ,a=1 + + + """, + new Demo("1") ) + .trim()); + + + + } + + @Test + void testForeach() { + Assertions.assertEquals( + """ +where id in ( 1 +, 2 +, 3 +,) +""".trim(), + FreeMakerUtils.render( + """ + <@where> + <@foreach item="item" index="index" collection="ids" + open="id in (" separator="," close=")" nullable="true"> + ${item} + + + + """, + Map.of("ids", List.of(1, 2, 3))) + .trim()); + } + + @Test + void testForeach2() { + Assertions.assertEquals( + """ +where id in ( :item_0 +, :item_1 +, :item_2 +,) +""" + .trim(), + FreeMakerUtils.render( + """ + <@where> + <@foreach item="item" index="index" collection="ids" + open="id in (" separator="," close=")" nullable="true"> + :item_${index} + + + + """, + Map.of("ids", List.of(1, 2, 3))) + .trim()); + } + + record Demo(String k){ + +} +}