From e965365cba988be98432e12c4aa66836b83dce43 Mon Sep 17 00:00:00 2001 From: Shihyu Ho Date: Mon, 20 Jul 2026 17:12:35 +0800 Subject: [PATCH] perf(mapper): skip AST build when the writer is not listening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The debug AST was built and stringified on every toSpec call, then thrown away whenever the logger sat above DEBUG — pure allocation on the hot path. ASTWriterFactory gains a default isEnabled(rootObject) hook so the gate can live where the knowledge does: domain() checks SpecMapper's logger, impersonation() checks the mapped object's logger. Custom factories keep the true default, so their behavior is unchanged. When disabled, SpecMapper hands resolvers a no-op AST and skips the writer entirely. Also document that to-one joins can set @Join(distinct = false); the default stays true since flipping it would be a breaking behavior change. Co-authored-by: Claude Opus 4.8 (1M context) --- .../data/jpa/spec/ASTWriterFactory.java | 34 +++- .../softleader/data/jpa/spec/SpecMapper.java | 27 ++- .../data/jpa/spec/annotation/Join.java | 4 + .../data/jpa/spec/ASTWriterFactoryTest.java | 160 ++++++++++++++++++ 4 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 mapper/src/test/java/tw/com/softleader/data/jpa/spec/ASTWriterFactoryTest.java diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/ASTWriterFactory.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/ASTWriterFactory.java index 7c19e5d8..23cdf3b4 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/ASTWriterFactory.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/ASTWriterFactory.java @@ -24,6 +24,7 @@ import static org.slf4j.LoggerFactory.getLogger; import java.io.Writer; +import java.util.function.Function; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -45,14 +46,43 @@ public interface ASTWriterFactory { */ Writer createWriter(@NonNull Object rootObject, @Nullable Specification spec); + /** + * Whether the AST should be written at all for the given root object. + * + *

{@link SpecMapper} consults this before mapping and, when it returns {@code false}, skips + * building and stringifying the AST entirely, so nothing is allocated for an output that would be + * thrown away. Defaults to {@code true}, meaning every AST is handed to {@link #createWriter}. + * + * @param rootObject The target object to be mapped, never null + */ + default boolean isEnabled(@NonNull Object rootObject) { + return true; + } + /** Create a writer that using {@link SpecMapper}'s logger */ static ASTWriterFactory domain() { - return (rootObject, spec) -> new Slf4jDebugWriter(getLogger(SpecMapper.class)); + return new Slf4jDebugWriterFactory(rootObject -> getLogger(SpecMapper.class)); } /** Create a writer that using the mapped object's logger */ static ASTWriterFactory impersonation() { - return (rootObject, spec) -> new Slf4jDebugWriter(getLogger(rootObject.getClass())); + return new Slf4jDebugWriterFactory(rootObject -> getLogger(rootObject.getClass())); + } +} + +@RequiredArgsConstructor +class Slf4jDebugWriterFactory implements ASTWriterFactory { + + @NonNull private final Function loggerFactory; + + @Override + public Writer createWriter(@NonNull Object rootObject, @Nullable Specification spec) { + return new Slf4jDebugWriter(loggerFactory.apply(rootObject)); + } + + @Override + public boolean isEnabled(@NonNull Object rootObject) { + return loggerFactory.apply(rootObject).isDebugEnabled(); } } diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SpecMapper.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SpecMapper.java index 2db59091..e7d5728d 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SpecMapper.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SpecMapper.java @@ -82,10 +82,15 @@ public Specification toSpec(Object rootObject) { } var context = new SpecContext(); context.put(CTX_JOIN, new SpecJoinContext()); - var ast = new SpecAST(); var depth = 0; - context.put(CTX_AST, ast); context.put(CTX_DEPTH, depth); + if (!astWriterFactory.isEnabled(rootObject)) { + // AST 純粹服務於 debug log, 沒人要收的話就完全不建構, 省下 hot path 上的無謂配置 + context.put(CTX_AST, NoopAST.INSTANCE); + return toSpec(context, rootObject); + } + var ast = new SpecAST(); + context.put(CTX_AST, ast); ast.add( depth, "+-[%s]: %s", @@ -140,6 +145,24 @@ Specification resolveSpec( return resolved; } + /** + * An {@link AST} that discards everything, used when the configured {@link ASTWriterFactory} has + * no interest in the AST, so resolvers can keep reporting without anything being built. + */ + private enum NoopAST implements AST { + INSTANCE; + + @Override + public void add(int depth, @NonNull String message, Object... args) { + // no-op + } + + @Override + public String print() { + return ""; + } + } + /** * Builder for {@link SpecMapper} instances. * diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/annotation/Join.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/annotation/Join.java index 714ed514..b4c4cc37 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/annotation/Join.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/annotation/Join.java @@ -68,6 +68,10 @@ /** * Whether the query should return distinct results. Defaults to {@code true} to prevent duplicate * records. + * + *

Only a to-many association can multiply rows, so joining a to-one association never produces + * duplicates. Set {@code distinct = false} on such joins to skip the {@code SELECT DISTINCT} and + * spare the database a needless sort/dedup pass. */ boolean distinct() default true; diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/ASTWriterFactoryTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/ASTWriterFactoryTest.java new file mode 100644 index 00000000..44d39a26 --- /dev/null +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/ASTWriterFactoryTest.java @@ -0,0 +1,160 @@ +/* + * Copyright © 2022 SoftLeader + * + * 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 tw.com.softleader.data.jpa.spec; + +import static ch.qos.logback.classic.Level.DEBUG; +import static ch.qos.logback.classic.Level.INFO; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static tw.com.softleader.data.jpa.spec.AST.CTX_AST; + +import java.io.Writer; +import lombok.Builder; +import lombok.NonNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.data.jpa.domain.Specification; +import tw.com.softleader.data.jpa.spec.annotation.Spec; +import tw.com.softleader.data.jpa.spec.domain.Context; +import tw.com.softleader.data.jpa.spec.usecase.Gender; + +/** + * AST 只服務於 debug log, 因此 {@link ASTWriterFactory#isEnabled} 為 false 時, 整棵樹都不該被建構或輸出 + * + * @author Matt Ho + */ +class ASTWriterFactoryTest { + + static final ch.qos.logback.classic.Logger mapperLogger = logger(SpecMapper.class); + static final ch.qos.logback.classic.Logger criteriaLogger = logger(MyCriteria.class); + + static ch.qos.logback.classic.Logger logger(Class type) { + return (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(type); + } + + final MyCriteria criteria = MyCriteria.builder().name("matt").gender(Gender.MALE).build(); + + @AfterEach + void tearDown() { + mapperLogger.setLevel(null); + criteriaLogger.setLevel(null); + } + + @DisplayName("DEBUG 關閉時, 完全不建構 AST 也不建立 Writer") + @Test + void noAstWhenDebugDisabled() { + mapperLogger.setLevel(INFO); + var factory = spy(ASTWriterFactory.domain()); + var capturing = new AstCapturingResolver(); + + var spec = mapperWith(factory, capturing).toSpec(criteria); + + assertThat(spec).isNotNull(); + verify(factory, never()).createWriter(any(), any()); + assertThat(capturing.ast).isNotNull(); + assertThat(capturing.ast.print()).isEmpty(); + } + + @DisplayName("DEBUG 開啟時, AST 照常建構並輸出") + @Test + void astWrittenWhenDebugEnabled() { + mapperLogger.setLevel(DEBUG); + var factory = spy(ASTWriterFactory.domain()); + var capturing = new AstCapturingResolver(); + + var spec = mapperWith(factory, capturing).toSpec(criteria); + + assertThat(spec).isNotNull(); + verify(factory, times(1)).createWriter(any(), any()); + assertThat(capturing.ast.print()).contains(MyCriteria.class.getSimpleName()); + } + + @DisplayName("impersonation 看的是被 map 物件的 logger, 而非 SpecMapper 的") + @Test + void impersonationHonoursMappedObjectLogger() { + mapperLogger.setLevel(INFO); + criteriaLogger.setLevel(DEBUG); + var factory = spy(ASTWriterFactory.impersonation()); + + mapperWith(factory, new AstCapturingResolver()).toSpec(criteria); + + verify(factory, times(1)).createWriter(any(), any()); + } + + @DisplayName("客製 ASTWriterFactory 未覆寫 isEnabled 時, 行為不變") + @Test + void customFactoryKeepsReceivingAstByDefault() { + mapperLogger.setLevel(INFO); + criteriaLogger.setLevel(INFO); + var factory = spy(new AlwaysOnASTWriterFactory()); + var capturing = new AstCapturingResolver(); + + mapperWith(factory, capturing).toSpec(criteria); + + verify(factory, times(1)).createWriter(any(), any()); + assertThat(capturing.ast.print()).isNotEmpty(); + } + + SpecMapper mapperWith(ASTWriterFactory factory, SpecificationResolver extra) { + return SpecMapper.builder() + .defaultResolvers() + .resolver(extra) + .astWriterFactory(factory) + .build(); + } + + /** 攔下 resolver 拿到的 AST, 用來確認樹本身有沒有被建構 */ + static class AstCapturingResolver implements SpecificationResolver { + + AST ast; + + @Override + public boolean supports(@NonNull Databind databind) { + return true; + } + + @Override + public Specification buildSpecification(Context context, Databind databind) { + ast = context.getAs(CTX_AST, AST.class); + return null; + } + } + + /** 沒有覆寫 {@link ASTWriterFactory#isEnabled} 的客製 factory */ + static class AlwaysOnASTWriterFactory implements ASTWriterFactory { + + @Override + public Writer createWriter(@NonNull Object rootObject, Specification spec) { + return Writer.nullWriter(); + } + } + + @Builder + static class MyCriteria { + + @Spec String name; + + @Spec Gender gender; + } +}