diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionHolder.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionHolder.java index a99851e4294..724603f5358 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionHolder.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionHolder.java @@ -16,12 +16,14 @@ */ package org.sonar.java.model.springcontext; +import com.google.gson.annotations.JsonAdapter; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nullable; import org.sonar.java.reporting.AnalyzerMessage; +import org.sonar.java.serialization.BeanDefinitionHolderTypeAdapter; import org.sonar.java.telemetry.SizeEstimable; import org.sonar.java.telemetry.SizeEstimator; @@ -233,6 +235,7 @@ public BeanDefinitionHolder build() { * @param dependencies The bean's dependencies, mapped by required type FQN to the injection points that require them. * @param typeHierarchy The fully-qualified names of the bean's own type and of all its ancestors and interfaces. */ + @JsonAdapter(BeanDefinitionHolderTypeAdapter.class) public record InputFileData( String beanName, String type, diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/InjectionPoint.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/InjectionPoint.java index 3102875ee6c..fbbee80702a 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/InjectionPoint.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/InjectionPoint.java @@ -16,7 +16,9 @@ */ package org.sonar.java.model.springcontext; +import com.google.gson.annotations.JsonAdapter; import org.sonar.java.reporting.AnalyzerMessage; +import org.sonar.java.serialization.InjectionPointTypeAdapter; import org.sonar.java.telemetry.SizeEstimable; import org.sonar.java.telemetry.SizeEstimator; @@ -51,6 +53,7 @@ public long estimateSize(SizeEstimator estimator) { * @param span the text span of the injection point within its own file * @param multiple whether all matching beans are collected here, as in {@link InjectionPoint#multiple()} */ + @JsonAdapter(InjectionPointTypeAdapter.class) public record InputFileData(String name, AnalyzerMessage.TextSpan span, boolean multiple) { } } diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextGatheringModel.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextGatheringModel.java index e763f7c9c34..907e32a9c2a 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextGatheringModel.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextGatheringModel.java @@ -16,6 +16,7 @@ */ package org.sonar.java.model.springcontext; +import com.google.gson.annotations.JsonAdapter; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -25,17 +26,20 @@ import java.util.Set; import javax.annotation.Nullable; import org.sonar.api.batch.fs.InputFile; +import org.sonar.java.serialization.SpringContextGatheringModelTypeAdapter; /** * Stores the data collected per-module, per-file during Spring context collection. */ +@JsonAdapter(SpringContextGatheringModelTypeAdapter.class) public class SpringContextGatheringModel { /** * The Spring data collected for a given file in a module. * - * @param beans The bean definitions gathered in the file. - * @param packages The packages covered by the file. + * @param inputFile The source file, absent after JSON deserialization. + * @param beans The bean definitions gathered in the file. + * @param packages The packages covered by the file. */ public record InputFileData(@Nullable InputFile inputFile, List beans, Set packages) { } @@ -45,6 +49,14 @@ public record InputFileData(@Nullable InputFile inputFile, List> filesData = new HashMap<>(); + public Map> filesData() { + return Map.copyOf(filesData); + } + + public void ensureModule(String moduleKey) { + filesData.computeIfAbsent(moduleKey, k -> new HashMap<>()); + } + public void collectBeans(String moduleKey, String fileKey, @Nullable InputFile inputFile, List beans) { filesData .computeIfAbsent(moduleKey, k -> new HashMap<>()) diff --git a/java-frontend/src/main/java/org/sonar/java/serialization/BeanDefinitionHolderTypeAdapter.java b/java-frontend/src/main/java/org/sonar/java/serialization/BeanDefinitionHolderTypeAdapter.java index 0ae9d7e76bb..86ca79f63fb 100644 --- a/java-frontend/src/main/java/org/sonar/java/serialization/BeanDefinitionHolderTypeAdapter.java +++ b/java-frontend/src/main/java/org/sonar/java/serialization/BeanDefinitionHolderTypeAdapter.java @@ -68,6 +68,8 @@ public static BeanDefinitionHolderTypeAdapter getInstance() { @Override public void write(JsonWriter out, BeanDefinitionHolder.InputFileData bean) throws IOException { + boolean serializeNulls = out.getSerializeNulls(); + out.setSerializeNulls(true); out.beginObject(); out.name(NAME).value(bean.beanName()); out.name(TYPE).value(bean.type()); @@ -82,6 +84,7 @@ public void write(JsonWriter out, BeanDefinitionHolder.InputFileData bean) throw out.name(TYPE_HIERARCHY); writeStrings(out, bean.typeHierarchy()); out.endObject(); + out.setSerializeNulls(serializeNulls); } @Override diff --git a/java-frontend/src/main/java/org/sonar/java/serialization/InjectionPointTypeAdapter.java b/java-frontend/src/main/java/org/sonar/java/serialization/InjectionPointTypeAdapter.java index 94fdbc5af48..7ec6b22fe50 100644 --- a/java-frontend/src/main/java/org/sonar/java/serialization/InjectionPointTypeAdapter.java +++ b/java-frontend/src/main/java/org/sonar/java/serialization/InjectionPointTypeAdapter.java @@ -45,12 +45,15 @@ public static InjectionPointTypeAdapter getInstance() { @Override public void write(JsonWriter out, InjectionPoint.InputFileData injectionPoint) throws IOException { + boolean serializeNulls = out.getSerializeNulls(); + out.setSerializeNulls(true); out.beginObject(); out.name(NAME).value(injectionPoint.name()); out.name(SPAN); TextSpanTypeAdapter.getInstance().write(out, injectionPoint.span()); out.name(MULTIPLE).value(injectionPoint.multiple()); out.endObject(); + out.setSerializeNulls(serializeNulls); } @Override diff --git a/java-frontend/src/main/java/org/sonar/java/serialization/JsonUtils.java b/java-frontend/src/main/java/org/sonar/java/serialization/JsonUtils.java index 4a8e709f491..e1e2dd015d8 100644 --- a/java-frontend/src/main/java/org/sonar/java/serialization/JsonUtils.java +++ b/java-frontend/src/main/java/org/sonar/java/serialization/JsonUtils.java @@ -42,6 +42,7 @@ public final class JsonUtils { private static final String VERSION = "version"; // Spring properties: + public static final String FILES_DATA = "filesData"; public static final String BEANS = "beans"; public static final String PACKAGES = "packages"; diff --git a/java-frontend/src/main/java/org/sonar/java/serialization/SpringContextGatheringModelTypeAdapter.java b/java-frontend/src/main/java/org/sonar/java/serialization/SpringContextGatheringModelTypeAdapter.java new file mode 100644 index 00000000000..5f2dfa1e381 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/serialization/SpringContextGatheringModelTypeAdapter.java @@ -0,0 +1,149 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.serialization; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.sonar.java.model.springcontext.BeanDefinitionHolder; +import org.sonar.java.model.springcontext.SpringContextGatheringModel; + +import static org.sonar.java.serialization.JsonUtils.BEANS; +import static org.sonar.java.serialization.JsonUtils.FILES_DATA; +import static org.sonar.java.serialization.JsonUtils.PACKAGES; +import static org.sonar.java.serialization.JsonUtils.missingProperty; +import static org.sonar.java.serialization.JsonUtils.readStrings; +import static org.sonar.java.serialization.JsonUtils.required; +import static org.sonar.java.serialization.JsonUtils.writeStrings; + +/** + * JSON representation of the Spring context data collected by module and file. Null properties in bean definitions + * remain explicit so the bean adapter can read them back with Gson's default settings. + */ +public final class SpringContextGatheringModelTypeAdapter extends TypeAdapter { + + private static final SpringContextGatheringModelTypeAdapter INSTANCE = new SpringContextGatheringModelTypeAdapter(); + + private SpringContextGatheringModelTypeAdapter() { + } + + public static SpringContextGatheringModelTypeAdapter getInstance() { + return INSTANCE; + } + + @Override + public void write(JsonWriter out, SpringContextGatheringModel model) throws IOException { + boolean serializeNulls = out.getSerializeNulls(); + out.setSerializeNulls(true); + try { + out.beginObject(); + out.name(FILES_DATA); + out.beginObject(); + for (Map.Entry> module : model.filesData().entrySet()) { + out.name(module.getKey()); + out.beginObject(); + for (Map.Entry file : module.getValue().entrySet()) { + out.name(file.getKey()); + writeInputFileData(out, file.getValue()); + } + out.endObject(); + } + out.endObject(); + out.endObject(); + } finally { + out.setSerializeNulls(serializeNulls); + } + } + + @Override + public SpringContextGatheringModel read(JsonReader in) throws IOException { + var model = new SpringContextGatheringModel(); + boolean filesDataRead = false; + in.beginObject(); + while (in.hasNext()) { + if (FILES_DATA.equals(in.nextName())) { + readFilesData(in, model); + filesDataRead = true; + } else { + in.skipValue(); + } + } + in.endObject(); + if (!filesDataRead) { + throw missingProperty(FILES_DATA); + } + return model; + } + + private static void writeInputFileData(JsonWriter out, SpringContextGatheringModel.InputFileData fileData) throws IOException { + out.beginObject(); + out.name(BEANS); + out.beginArray(); + for (BeanDefinitionHolder.InputFileData bean : fileData.beans()) { + BeanDefinitionHolderTypeAdapter.getInstance().write(out, bean); + } + out.endArray(); + out.name(PACKAGES); + writeStrings(out, fileData.packages()); + out.endObject(); + } + + private static void readFilesData(JsonReader in, SpringContextGatheringModel model) throws IOException { + in.beginObject(); + while (in.hasNext()) { + String moduleKey = in.nextName(); + model.ensureModule(moduleKey); + in.beginObject(); + while (in.hasNext()) { + readInputFileData(in, model, moduleKey, in.nextName()); + } + in.endObject(); + } + in.endObject(); + } + + private static void readInputFileData(JsonReader in, SpringContextGatheringModel model, String moduleKey, String fileKey) throws IOException { + List beans = null; + Set packages = null; + in.beginObject(); + while (in.hasNext()) { + switch (in.nextName()) { + case BEANS -> beans = readBeans(in); + case PACKAGES -> packages = readStrings(in); + default -> in.skipValue(); + } + } + in.endObject(); + model.collectBeans(moduleKey, fileKey, null, required(beans, BEANS)); + model.collectPackages(moduleKey, fileKey, null, required(packages, PACKAGES)); + } + + private static List readBeans(JsonReader in) throws IOException { + List beans = new ArrayList<>(); + in.beginArray(); + while (in.hasNext()) { + beans.add(BeanDefinitionHolderTypeAdapter.getInstance().read(in)); + } + in.endArray(); + return beans; + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/serialization/SpringContextGatheringModelTypeAdapterTest.java b/java-frontend/src/test/java/org/sonar/java/serialization/SpringContextGatheringModelTypeAdapterTest.java new file mode 100644 index 00000000000..660e6c820dc --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/serialization/SpringContextGatheringModelTypeAdapterTest.java @@ -0,0 +1,107 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.serialization; + +import com.google.gson.Gson; +import com.google.gson.JsonParser; +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.java.TestUtils; +import org.sonar.java.model.springcontext.BeanDefinitionHolder; +import org.sonar.java.model.springcontext.ProfileExpression; +import org.sonar.java.model.springcontext.SpringContextGatheringModel; +import org.sonar.java.reporting.AnalyzerMessage.TextSpan; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SpringContextGatheringModelTypeAdapterTest { + + private static final Gson GSON = new Gson(); + private static final InputFile INPUT_FILE = TestUtils.inputFile(new File("src/test/files/springcontext/SimpleComponent.java")); + + @Test + void round_trips_module_and_file_data_without_serializing_input_files() { + var bean = new BeanDefinitionHolder.InputFileData("bean", "example.Bean", "example", new TextSpan(1, 0, 1, 4), + false, ProfileExpression.UNCONDITIONAL, null, Map.of(), Set.of("example.Bean")); + var model = new SpringContextGatheringModel(); + model.collectBeans("module-a", "file-a", INPUT_FILE, List.of(bean)); + model.collectPackages("module-a", "file-a", INPUT_FILE, Set.of("example")); + model.collectBeans("module-a", "file-b", INPUT_FILE, List.of()); + model.collectPackages("module-b", "file-c", INPUT_FILE, Set.of("other")); + + String json = GSON.toJson(model); + + var filesData = JsonParser.parseString(json).getAsJsonObject().getAsJsonObject("filesData"); + assertThat(filesData.keySet()).containsExactlyInAnyOrder("module-a", "module-b"); + var file = filesData.getAsJsonObject("module-a").getAsJsonObject("file-a"); + assertThat(file.has("inputFile")).isFalse(); + assertThat(file.getAsJsonArray("beans")).hasSize(1); + assertThat(file.getAsJsonArray("packages").get(0).getAsString()).isEqualTo("example"); + + var restored = GSON.fromJson(json, SpringContextGatheringModel.class); + assertThat(restored.filesData().get("module-a")).containsOnlyKeys("file-a", "file-b"); + assertThat(restored.filesData().get("module-b")).containsOnlyKeys("file-c"); + assertThat(restored.filesData().get("module-a").get("file-a").beans()).containsExactly(bean); + assertThat(restored.filesData().get("module-a").get("file-a").packages()).containsExactly("example"); + assertThat(restored.filesData().get("module-a").get("file-b").beans()).isEmpty(); + assertThat(restored.filesData().get("module-b").get("file-c").packages()).containsExactly("other"); + restored.filesData().values().stream() + .flatMap(files -> files.values().stream()) + .forEach(data -> assertThat(data.inputFile()).isNull()); + } + + @Test + void ignores_input_file_property_when_reading_json() { + String json = """ + {"filesData":{"module":{"file":{"inputFile":{"key":"ignored"},"beans":[],"packages":[]}}}} + """; + + var restored = GSON.fromJson(json, SpringContextGatheringModel.class); + + assertThat(restored.filesData().get("module").get("file").inputFile()).isNull(); + } + + @Test + void preserves_an_empty_module() { + String json = "{\"filesData\":{\"module\":{}}}"; + + var restored = GSON.fromJson(json, SpringContextGatheringModel.class); + + assertThat(restored.filesData().get("module")).isEmpty(); + assertThat(GSON.toJson(restored)).isEqualTo(json); + } + + @Test + void rejects_missing_file_data() { + assertThatThrownBy(() -> GSON.fromJson("{}", SpringContextGatheringModel.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing JSON property 'filesData'"); + } + + @Test + void rejects_missing_InputFileData_field() { + String missingPackages = "{\"filesData\":{\"module\":{\"file\":{\"beans\":[]}}}}"; + assertThatThrownBy(() -> GSON.fromJson(missingPackages, SpringContextGatheringModel.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing JSON property 'packages'"); + } +}