Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -45,14 +46,43 @@ public interface ASTWriterFactory {
*/
Writer createWriter(@NonNull Object rootObject, @Nullable Specification<Object> spec);

/**
* Whether the AST should be written at all for the given root object.
*
* <p>{@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<Object, Logger> loggerFactory;

@Override
public Writer createWriter(@NonNull Object rootObject, @Nullable Specification<Object> spec) {
return new Slf4jDebugWriter(loggerFactory.apply(rootObject));
}

@Override
public boolean isEnabled(@NonNull Object rootObject) {
return loggerFactory.apply(rootObject).isDebugEnabled();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,15 @@ public Specification<Object> 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",
Expand Down Expand Up @@ -140,6 +145,24 @@ Specification<Object> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@
/**
* Whether the query should return distinct results. Defaults to {@code true} to prevent duplicate
* records.
*
* <p>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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Object> 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<Object> spec) {
return Writer.nullWriter();
}
}

@Builder
static class MyCriteria {

@Spec String name;

@Spec Gender gender;
}
}