InjectorSnapshotTest.java
/*
* 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 org.apache.maven.di.impl;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.annotation.Retention;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import org.apache.maven.api.di.Inject;
import org.apache.maven.api.di.Named;
import org.apache.maven.api.di.Provides;
import org.apache.maven.api.di.Qualifier;
import org.apache.maven.api.di.SessionScoped;
import org.apache.maven.api.di.Singleton;
import org.apache.maven.di.Key;
import org.apache.maven.di.Scope;
import org.apache.maven.di.impl.InjectorDiagnosticsFixture.Hostile;
import org.apache.maven.di.impl.InjectorDiagnosticsFixture.RecordingInjector;
import org.apache.maven.di.impl.InjectorDiagnosticsFixture.Unconstructed;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.parallel.ResourceLock;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import static org.apache.maven.di.impl.InjectorDiagnosticsFixture.bindingRows;
import static org.apache.maven.di.impl.InjectorDiagnosticsFixture.body;
import static org.apache.maven.di.impl.InjectorDiagnosticsFixture.newRealmType;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.parallel.Resources.SYSTEM_OUT;
import static org.junit.jupiter.api.parallel.Resources.SYSTEM_PROPERTIES;
@ResourceLock(SYSTEM_PROPERTIES)
@ResourceLock(SYSTEM_OUT)
@Timeout(30)
class InjectorSnapshotTest {
private InjectorDiagnosticsFixture environment;
@BeforeEach
void enableDiagnostics() {
environment = new InjectorDiagnosticsFixture();
}
@AfterEach
void restoreEnvironment() {
environment.close();
}
@Test
void snapshotDoesNotProvisionOrInvokeUserToString() {
RecordingInjector injector = new RecordingInjector();
injector.bindInstance(Hostile.class, new Hostile());
injector.bindInstance(CharSequence.class, "secret-value");
Key<Long> initialized = Key.of(Long.class);
injector.bind(
initialized,
new Binding<Long>(initialized, Set.of()) {
@Override
public Supplier<Long> compile(Function<Dependency<?>, Supplier<?>> compiler) {
throw new AssertionError("binding compiled for diagnostics");
}
@Override
public String toString() {
throw new AssertionError("binding toString");
}
}.initializeWith(new BindingInitializer<>(Set.of()) {
@Override
public Consumer<Long> compile(Function<Dependency<?>, Supplier<?>> compiler) {
throw new AssertionError("initializer compiled for diagnostics");
}
}));
injector.bindSupplier(String.class, new Supplier<String>() {
@Override
public String get() {
throw new AssertionError("supplier called for diagnostics");
}
@Override
public String toString() {
throw new AssertionError("supplier toString");
}
});
injector.bindScope(SessionScoped.class, () -> {
throw new AssertionError("scope provisioned");
});
Unconstructed.CALLS.set(0);
injector.bindImplicit(Unconstructed.class);
injector.bindImplicit(Provider.class);
String snapshot = injector.dumpBindings();
injector.checkpoint();
assertEquals(0, Unconstructed.CALLS.get());
assertTrue(snapshot.contains("initialized by"));
assertTrue(snapshot.contains("Provider.provide"));
assertTrue(snapshot.contains(" scopes:\n") && snapshot.contains(SessionScoped.class.getName()));
assertTrue(snapshot.contains("supplier "));
assertFalse(snapshot.contains("secret-value"));
assertEquals(body(snapshot), body(injector.dumps.get(0)));
}
@Test
void snapshotGroupsCandidatesAndListsDependenciesAndClassLoaders() {
RecordingInjector injector = new RecordingInjector();
Key<String> key = Key.of(String.class);
Binding<String> factory = Binding.to(key, args -> "unused", new Dependency<?>[] {
new Dependency<>(Key.of(Integer.class), false), new Dependency<>(Key.of(Long.class), true)
})
.prioritize(10);
injector.bind(key, factory);
injector.bind(key, Binding.toInstance("other").withKey(key));
injector.bind(Key.of(String.class, "alias"), factory);
String snapshot = injector.dumpBindings();
assertEquals(
1,
snapshot.lines()
.filter(line -> line.equals(" key: java.lang.String [loader=bootstrap]"))
.count());
assertEquals(3, bindingRows(snapshot).size());
assertTrue(snapshot.contains(" key: java.lang.String [loader=bootstrap]\n"
+ " candidate: java.lang.String [loader=bootstrap]\n priority: 10\n"));
assertTrue(snapshot.contains(" dependencies:\n"
+ " - ?java.lang.Long [loader=bootstrap]\n"
+ " - java.lang.Integer [loader=bootstrap]"));
assertTrue(snapshot.contains(
"\n class loaders:\n L1: " + Binding.identityString(Singleton.class.getClassLoader())));
assertTrue(snapshot.contains(" " + Singleton.class.getName() + " [loader=L1]\n supplier: "));
}
@Test
void snapshotSeparatesSourceParametersAndExceptions() {
RecordingInjector injector = new RecordingInjector();
injector.bindImplicit(DetailedSource.class);
injector.bindImplicit(Provider.class);
String snapshot = injector.dumpBindings();
assertTrue(
snapshot.contains(" source:\n " + DetailedSource.class.getName() + "(\n"
+ " java.lang.String [loader=bootstrap],\n"
+ " int [loader=bootstrap]\n"
+ " ) [loader=L1]\n"
+ " throws java.io.IOException [loader=bootstrap]"),
snapshot);
assertTrue(
snapshot.contains(" source:\n"
+ " java.lang.String [loader=bootstrap]\n"
+ " " + Provider.class.getName() + ".provide() [loader=L1]\n"),
snapshot);
}
@Test
void snapshotPreservesDefaultMethodModifier() throws Exception {
RecordingInjector injector = new RecordingInjector();
injector.bind(
Key.of(String.class),
ReflectionUtils.bindingFromMethod(DefaultProvider.class.getDeclaredMethod("provideDefault")));
String snapshot = injector.dumpBindings();
assertTrue(
snapshot.contains(" public default java.lang.String [loader=bootstrap]\n" + " "
+ DefaultProvider.class.getName() + ".provideDefault() [loader=L1]"),
snapshot);
}
@Test
void snapshotsRenderWildcardsInOwnerTypes() {
class Outer<T> {
class Inner<U> {}
}
// Key simplifies its type arguments, but preserves wildcards in parameterized owners.
String bound = Number.class.getName() + " [loader=bootstrap]";
Map<Key<?>, String> cases = Map.of(
new Key<Outer<?>.Inner<String>>() {}, "<?>",
new Key<Outer<? extends Number>.Inner<String>>() {}, "<? extends " + bound + ">",
new Key<Outer<? super Number>.Inner<String>>() {}, "<? super " + bound + ">");
cases.forEach((key, expected) -> {
InjectorImpl injector = new InjectorImpl();
injector.getBindings().put(key, Set.of());
String dump = injector.dumpBindings();
assertTrue(dump.contains(expected + ".Inner [loader="), dump);
assertFalse(
dump.contains("$Inner"), "Inner class should be rendered without enclosing owner prefix: " + dump);
});
}
@Test
void lateRegistrationsPreserveAliasesAndEqualDescriptions() {
RecordingInjector injector = new RecordingInjector();
Key<String> key = Key.of(String.class);
Binding.TupleConstructorN<String> factory = args -> "created";
Binding<String> first = Binding.to(key, factory, new Class<?>[0]);
Binding<String> second = Binding.to(key, factory, new Class<?>[0]);
injector.bind(key, first);
injector.bind(key, second);
injector.bind(Key.of(String.class, "a\nb\r\tc"), first);
injector.checkpoint();
assertEquals(3, bindingRows(injector.dumps.get(0)).size());
assertTrue(injector.dumps.get(0).contains("a\\u000ab\\u000d\\u0009c"));
injector.bind(key, second);
injector.checkpoint();
assertEquals(1, injector.dumps.size());
injector.bind(key, Binding.to(key, factory, new Class<?>[0]));
injector.bind(Key.of(Integer.class), Binding.toInstance(5).withKey(Key.of(Integer.class)));
assertThrows(DIException.class, () -> injector.getInstance(String.class));
assertEquals(2, bindingRows(injector.dumps.get(1)).size());
assertTrue(injector.dumps.get(1).contains(", delta"));
assertFalse(injector.dumps.get(1).contains("@Named(\"a\\u000ab\\u000d\\u0009c\")"));
List<String> keys = body(injector.dumpBindings())
.lines()
.filter(line -> line.startsWith(" key: "))
.toList();
assertEquals(keys.stream().sorted().toList(), keys);
injector.checkpoint();
assertEquals(2, injector.dumps.size());
first.prioritize(42);
assertTrue(injector.dumpBindings().contains("priority: 42"));
assertFalse(injector.dumps.get(1).contains("priority: 42"));
}
@Test
void snapshotsDoNotInvokeAnnotationMembers() {
AtomicInteger memberCalls = new AtomicInteger();
RecordingInjector injector = new RecordingInjector();
// Equal hashes force qualifier equality during registry lookup.
assertEquals("Aa".hashCode(), "BB".hashCode());
for (String value : List.of("Aa", "BB")) {
Key<Object> key = Key.of(Object.class, new ObservedFlavor(value, memberCalls));
injector.bind(key, Binding.toInstance(new Object()).withKey(key));
}
assertTrue(memberCalls.get() > 0);
memberCalls.set(0);
injector.dumpBindings();
injector.checkpoint();
assertEquals(0, memberCalls.get());
Key<Object> first = Key.of(Object.class, new ObservedFlavor("same", memberCalls));
injector.bind(first, Binding.toInstance(new Object()).withKey(first));
injector.checkpoint();
Key<Object> second = Key.of(Object.class, new ObservedFlavor("same", memberCalls));
injector.bind(second, Binding.toInstance(new Object()).withKey(second));
assertTrue(memberCalls.get() > 0);
memberCalls.set(0);
String explicit = injector.dumpBindings();
injector.checkpoint();
assertEquals(0, memberCalls.get());
assertTrue(explicit.contains("[qualifier="));
assertTrue(injector.dumps.get(2).contains("+ candidate:"));
}
@Test
void snapshotsReflectRegistryChanges() {
RecordingInjector injector = new RecordingInjector();
injector.bindImplicit(Runnable.class);
injector.checkpoint();
assertTrue(injector.dumps.get(0).contains("<no candidates>"));
injector.bindInstance(Runnable.class, () -> {});
injector.checkpoint();
assertFalse(injector.dumps.get(1).contains("<no candidates>"));
assertTrue(injector.dumps.get(1).contains("+ candidate: java.lang.Runnable"));
assertTrue(injector.dumps.get(1).contains(", delta"));
injector.bindScope(SessionScoped.class, new Scope() {
@Override
public <T> Supplier<T> scope(Key<T> key, Supplier<T> supplier) {
throw new AssertionError("scope called");
}
});
injector.checkpoint();
assertTrue(injector.dumps.get(2).contains("+ scopes:"));
assertFalse(injector.dumps.get(2).contains("candidate:"));
injector.dispose();
injector.checkpoint();
assertEquals("", body(injector.dumps.get(3)));
injector.bindInstance(Integer.class, 1);
injector.checkpoint();
assertTrue(injector.dumps.get(4).contains("+ key: java.lang.Integer"));
assertTrue(injector.dumps.get(4).contains(", delta"));
}
@Test
void separateRealmsRemainDistinctRegardlessOfDependencyOrder() throws Exception {
Class<?> first = newRealmType();
Class<?> second = newRealmType();
RecordingInjector injector = new RecordingInjector();
for (Class<?> type : List.of(first, second)) {
Key<Object> key = Key.ofType(type);
injector.bind(key, Binding.toInstance(new Object()).withKey(key));
}
List<String> rows = bindingRows(injector.dumpBindings());
assertEquals(2, rows.size());
assertNotEquals(rows.get(0), rows.get(1));
Dependency<?> firstDependency = new Dependency<>(Key.ofType(first), false);
Dependency<?> secondDependency = new Dependency<>(Key.ofType(second), false);
Set<Dependency<?>> dependencies = new LinkedHashSet<>(List.of(firstDependency, secondDependency));
RecordingInjector dependent = new RecordingInjector();
Key<String> key = Key.of(String.class);
dependent.bind(key, new Binding<String>(key, dependencies) {
@Override
public Supplier<String> compile(Function<Dependency<?>, Supplier<?>> compiler) {
throw new AssertionError("binding compiled for diagnostics");
}
});
String before = dependent.dumpBindings();
dependencies.clear();
dependencies.add(secondDependency);
dependencies.add(firstDependency);
assertEquals(before, dependent.dumpBindings());
}
@Test
void initializerSourcesSurviveCombinationWithoutExecutingUserCode() {
RecordingInjector injector = new RecordingInjector();
injector.bindImplicit(InjectedMembers.class);
String dump = injector.dumpBindings();
assertTrue(dump.contains("initialized by field injector:"), dump);
assertTrue(dump.contains(InjectedMembers.class.getName() + ".field"), dump);
assertTrue(dump.contains("initialized by method injector:"), dump);
assertTrue(dump.contains(InjectedMembers.class.getName() + ".initialize("), dump);
long candidates = dump.lines()
.filter(line -> line.contains("candidate: " + InjectedMembers.class.getName()))
.count();
assertEquals(
candidates,
dump.lines()
.filter(line -> line.contains("initialized by field injector:"))
.count());
assertEquals(
candidates,
dump.lines()
.filter(line -> line.contains("initialized by method injector:"))
.count());
assertFalse(dump.contains("initialized by method injector: )"), dump);
assertTrue(dump.contains("\n java.lang.Integer [loader=bootstrap]\n"), dump);
}
@Qualifier
@Retention(RUNTIME)
@interface Flavor {
String value();
}
static final class ObservedFlavor implements Flavor {
private final String value;
private final AtomicInteger memberCalls;
ObservedFlavor(String value, AtomicInteger memberCalls) {
this.value = value;
this.memberCalls = memberCalls;
}
@Override
public String value() {
memberCalls.incrementAndGet();
return value;
}
@Override
public Class<? extends Annotation> annotationType() {
return Flavor.class;
}
@Override
public boolean equals(Object other) {
return other instanceof Flavor flavor && value.equals(flavor.value());
}
@Override
public int hashCode() {
return (127 * "value".hashCode()) ^ value.hashCode();
}
}
static class Provider {
@Provides
@Named("provided")
String provide() {
throw new AssertionError("provider called for diagnostics");
}
}
static class InjectedMembers {
@Inject
String field;
@Inject
void initialize(Integer count) {
throw new AssertionError("initializer executed for diagnostics");
}
}
static class DetailedSource {
DetailedSource(String text, int count) throws IOException {
throw new AssertionError("constructor called for diagnostics");
}
}
interface DefaultProvider {
default String provideDefault() {
throw new AssertionError("default method called for diagnostics");
}
}
}