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
@@ -0,0 +1,298 @@
/*
* Copyright 2026 the original author or authors.
* <p>
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* https://docs.moderne.io/licensing/moderne-source-available-license
* <p>
* 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.openrewrite.java.spring.data.search;

import lombok.Getter;
import lombok.Value;
import lombok.With;
import org.jspecify.annotations.Nullable;
import org.openrewrite.ExecutionContext;
import org.openrewrite.ScanningRecipe;
import org.openrewrite.SourceFile;
import org.openrewrite.Tree;
import org.openrewrite.TreeVisitor;
import org.openrewrite.java.marker.JavaProject;
import org.openrewrite.java.spring.table.MongoValueRepresentationFields;
import org.openrewrite.marker.Marker;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;

/**
* Find explicitly MongoDB-mapped UUID and big-number fields for which Spring Data MongoDB 5
* no longer supplies a default representation.
*/
public class FindMissingMongoValueRepresentation extends ScanningRecipe<FindMissingMongoValueRepresentation.Accumulator> {

static final String UUID_PROPERTY = "spring.mongodb.representation.uuid";
static final String BIG_NUMBER_PROPERTY = "spring.data.mongodb.representation.big-decimal";
// The value a suggested-but-not-yet-chosen property is created with. Must be a real, validly
// bindable enum constant (not placeholder text like "<representation>") so a project that never
// gets its suggestion followed up on still starts up correctly — it's simply as unconfigured as
// it was before the recipe ran, and gets the same treatment as a user writing UNSPECIFIED
// themselves (see ValueKind.isConfiguredValue, which deliberately excludes it).
static final String UNSPECIFIED_VALUE = "UNSPECIFIED";
static final String UUID_TYPE = "java.util.UUID";
static final String BIG_DECIMAL_TYPE = "java.math.BigDecimal";
static final String BIG_INTEGER_TYPE = "java.math.BigInteger";

private static final String UUID_INVALID_PROPERTY_MESSAGE =
"`" + UUID_PROPERTY + "` needs a concrete UUID representation matching the existing BSON data.";

private static final String BIG_NUMBER_INVALID_PROPERTY_MESSAGE =
"`" + BIG_NUMBER_PROPERTY + "` needs a concrete big-number representation matching the existing BSON data.";

final transient MongoValueRepresentationFields affectedFields = new MongoValueRepresentationFields(this);

@Getter
final String displayName = "Find missing MongoDB value representation configuration";

@Getter
final String description = "Find explicitly MongoDB-mapped UUID, BigInteger, and BigDecimal fields that require an " +
"explicit representation when migrating to Spring Data MongoDB 5. The recipe reports affected fields " +
"without choosing a storage representation.";

/**
* A baseline configuration file generated by {@link #generate} is necessarily empty the cycle
* it's created — the scan that fed that cycle's {@link Accumulator} predates the file's own
* existence, so it can't yet be recognised as the project's preferred configuration source. A
* further cycle is required to scan the now-persisted file and populate it. Without opting in
* here, a real (non-test) run stops after the generating cycle, since the default {@code false}
* only accounts for cycles requested by other recipes.
*/
@Override
public boolean causesAnotherCycle() {
return true;
}

enum ValueKind {
// Mirrors org.bson.UuidRepresentation, excluding UNSPECIFIED (not a valid choice).
UUID("UUID", UUID_PROPERTY, UUID_INVALID_PROPERTY_MESSAGE,
"standard", "java-legacy", "c-sharp-legacy", "python-legacy"),
// Mirrors Spring Data MongoDB's BigDecimalRepresentation, excluding UNSPECIFIED.
BIG_NUMBER("BigDecimal/BigInteger", BIG_NUMBER_PROPERTY,
BIG_NUMBER_INVALID_PROPERTY_MESSAGE, "string", "decimal128");

final String displayName;
final String configurationProperty;
final String invalidPropertyMessage;
private final Set<String> supportedValues;

ValueKind(String displayName, String configurationProperty, String invalidPropertyMessage,
String... supportedValues) {
this.displayName = displayName;
this.configurationProperty = configurationProperty;
this.invalidPropertyMessage = invalidPropertyMessage;
this.supportedValues = new HashSet<>();
for (String supportedValue : supportedValues) {
this.supportedValues.add(normalize(supportedValue));
}
}

boolean isConfiguredValue(@Nullable String value) {
if (value == null) {
return false;
}
String candidate = value.trim();
if (candidate.startsWith("${") && candidate.endsWith("}")) {
return true;
}
return supportedValues.contains(normalize(candidate));
}

private static String normalize(String value) {
return value.replace("-", "")
.replace("_", "")
.replace(" ", "")
.toUpperCase(Locale.ROOT);
}
}

@Value
static class Occurrence {
Path sourcePath;
UUID owningClassId;
String owningType;
String field;
ValueKind kind;
}

@Value
static class ConfigurationIssue {
Path sourcePath;
UUID treeId;
ValueKind kind;
}

public static class Accumulator {
final Map<ValueKind, Set<JavaProject>> validlyConfigured = newProjectSetsByKind();
final Map<ValueKind, Set<JavaProject>> propertyAttempted = newProjectSetsByKind();
final Map<ValueKind, Set<JavaProject>> javaAttempted = newProjectSetsByKind();

// Populated only by scanning for a ProjectDiagnostic marker already present in the tree
// (left there by a *prior* cycle's edit) — Accumulator itself is rebuilt from scratch every
// cycle, so this is the only way "already fully handled" survives across cycles.
final Set<JavaProject> finalizedProjects = ConcurrentHashMap.newKeySet();

// Guards against duplicate data-table rows both within a cycle and across cycles: seeded
// from a RowsRecorded marker (see FindMissingMongoValueRepresentation.generate()) whenever
// a baseline configuration file generated in an earlier cycle is rescanned.
final Set<JavaProject> rowsInsertedProjects = ConcurrentHashMap.newKeySet();

// The single winning path per project, chosen via SpringConfigFileSupport.preferredConfigurationSource
// — not a list of every config file found.
final Map<JavaProject, Path> preferredConfigurationSource = new ConcurrentHashMap<>();

final Map<JavaProject, ConcurrentLinkedQueue<Occurrence>> occurrences = new ConcurrentHashMap<>();

// Covers both properties/YAML entries with an unsupported value and Java configuration
// calls with an invalid argument (e.g., uuidRepresentation(null)) — the same record type
// serves both, since only the source path and kind differ.
final Map<JavaProject, ConcurrentLinkedQueue<ConfigurationIssue>> configurationIssues =
new ConcurrentHashMap<>();

void markConfigured(ValueKind kind, JavaProject project) {
projectsFor(validlyConfigured, kind).add(project);
}

boolean isUnconfigured(ValueKind kind, JavaProject project) {
return !projectsFor(validlyConfigured, kind).contains(project);
}

void markPropertyAttempted(ValueKind kind, JavaProject project) {
projectsFor(propertyAttempted, kind).add(project);
}

boolean isPropertyUnattempted(ValueKind kind, JavaProject project) {
return !projectsFor(propertyAttempted, kind).contains(project);
}

void markJavaAttempted(ValueKind kind, JavaProject project) {
projectsFor(javaAttempted, kind).add(project);
}

boolean isJavaUnattempted(ValueKind kind, JavaProject project) {
return !projectsFor(javaAttempted, kind).contains(project);
}

void addOccurrence(JavaProject project, Occurrence occurrence) {
occurrences.computeIfAbsent(project, ignored -> new ConcurrentLinkedQueue<>()).add(occurrence);
}

void addConfigurationIssue(JavaProject project, ConfigurationIssue issue) {
configurationIssues.computeIfAbsent(project, ignored -> new ConcurrentLinkedQueue<>()).add(issue);
}

private static Map<ValueKind, Set<JavaProject>> newProjectSetsByKind() {
Map<ValueKind, Set<JavaProject>> byKind = new EnumMap<>(ValueKind.class);
for (ValueKind kind : ValueKind.values()) {
byKind.put(kind, ConcurrentHashMap.newKeySet());
}
return byKind;
}

// newProjectSetsByKind() always populates every ValueKind, so the lookup can never miss.
private static Set<JavaProject> projectsFor(Map<ValueKind, Set<JavaProject>> byKind, ValueKind kind) {
return Objects.requireNonNull(byKind.get(kind));
}
}

@Override
public Accumulator getInitialValue(ExecutionContext ctx) {
return new Accumulator();
}

@Override
public TreeVisitor<?, ExecutionContext> getScanner(Accumulator acc) {
return new TreeVisitor<Tree, ExecutionContext>() {
@Override
public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) {
if (!(tree instanceof SourceFile)) {
return tree;
}
SourceFile source = (SourceFile) tree;
JavaProject project = SpringConfigFileSupport.javaProject(source);
if (project != null) {
MongoValueRepresentationScanner.scan(source, project, acc, ctx);
}
return source;
}
};
}

@Override
public TreeVisitor<?, ExecutionContext> getVisitor(Accumulator acc) {
return new TreeVisitor<Tree, ExecutionContext>() {
@Override
public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) {
if (!(tree instanceof SourceFile)) {
return tree;
}
SourceFile source = (SourceFile) tree;
JavaProject project = SpringConfigFileSupport.javaProject(source);
return project == null ? source : MongoValueRepresentationDiagnostics.apply(
FindMissingMongoValueRepresentation.this, source, project, acc, ctx);
}
};
}

/**
* A project with affected fields but no Spring configuration file gets a baseline
* {@code application.properties} generated (path derived from an existing occurrence's own
* {@code src/main/java} location), so every affected field gets the same suggested-property
* treatment instead of an arbitrary class being singled out. The file is picked up by the
* scanner on the following cycle, the same as any pre-existing configuration file.
*/
@Override
public Collection<? extends SourceFile> generate(Accumulator acc, ExecutionContext ctx) {
List<SourceFile> generated = new ArrayList<>();
for (JavaProject project : acc.occurrences.keySet()) {
SourceFile baseline = MongoValueRepresentationDiagnostics.generateBaselineConfiguration(project, acc, ctx);
if (baseline != null) {
generated.add(baseline);
}
}
return generated;
}

@Value
@With
static class ProjectDiagnostic implements Marker {
UUID id;
}

/**
* Attached to a freshly generated baseline configuration file, so a later scan (once the
* generated file has been persisted into the tree) knows its project's data table rows were
* already recorded, even though {@link Accumulator} itself doesn't survive across cycles.
*/
@Value
@With
static class RowsRecorded implements Marker {
UUID id;
}
}
Loading
Loading