Skip to content

Commit ed31d6e

Browse files
authored
Mem perf recipe csv validators (#6467)
1 parent 99dde4e commit ed31d6e

File tree

15 files changed

+230
-380
lines changed

15 files changed

+230
-380
lines changed

doc/adr/0006-recipe-marketplace-csv-format.md

Lines changed: 123 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,76 @@ We will use a CSV format for recipe marketplace data with the following structur
3232

3333
- **`displayName`**: Human-readable recipe display name
3434
- **`description`**: Recipe description
35+
- **`recipeCount`**: Integer count of recipes (direct + transitive), defaults to 1
3536
- **`estimatedEffortPerOccurrence`**: ISO-8601 duration format (e.g., PT5M, PT1H)
3637
- **`category1, category2, ..., categoryN`**: Zero or more category columns, read **left to right** with **left representing the deepest level category**
37-
- **`option1Name, option1DisplayName, option1Description`**: First recipe option
38-
- **`option2Name, option2DisplayName, option2Description`**: Second recipe option
39-
- **`optionNName, optionNDisplayName, optionNDescription`**: Additional options following the same pattern
38+
- **`category1Description, category2Description, ..., categoryNDescription`**: Optional descriptions for each category level
39+
- **`options`**: JSON array of `OptionDescriptor` objects (see JSON Format section below)
40+
- **`dataTables`**: JSON array of `DataTableDescriptor` objects (see JSON Format section below)
4041

4142
### Optional Bundle Columns
4243

43-
- **`version`**: Package version (optional, allows version-independent recipe catalogs)
44+
- **`version`**: Resolved package version (optional, allows version-independent recipe catalogs)
45+
- **`requestedVersion`**: Version constraint as requested (e.g., `LATEST`, `0.2.0-SNAPSHOT`)
4446
- **`team`**: Optional team identifier for marketplace partitioning
4547

48+
### Metadata Columns
49+
50+
Any unrecognized columns are preserved as metadata in `RecipeListing.getMetadata()` as a `Map<String, Object>`, allowing forward compatibility with future extensions.
51+
52+
### JSON Format for Options
53+
54+
The `options` column contains a JSON array of option descriptors:
55+
56+
```json
57+
[
58+
{
59+
"name": "groupId",
60+
"type": "String",
61+
"displayName": "Group ID",
62+
"description": "The group ID of the dependency.",
63+
"example": "org.openrewrite",
64+
"required": true
65+
},
66+
{
67+
"name": "artifactId",
68+
"type": "String",
69+
"displayName": "Artifact ID",
70+
"description": "The artifact ID of the dependency.",
71+
"required": false
72+
}
73+
]
74+
```
75+
76+
### JSON Format for DataTables
77+
78+
The `dataTables` column contains a JSON array of data table descriptors:
79+
80+
```json
81+
[
82+
{
83+
"name": "org.openrewrite.java.dependencies.DependencyListTable",
84+
"displayName": "Dependencies",
85+
"description": "Lists all dependencies found in the project.",
86+
"columns": [
87+
{
88+
"name": "groupId",
89+
"type": "String",
90+
"displayName": "Group ID",
91+
"description": "The dependency group."
92+
}
93+
]
94+
}
95+
]
96+
```
97+
98+
The following data tables are excluded during writing as they are internal infrastructure tables:
99+
- `org.openrewrite.table.SearchResults`
100+
- `org.openrewrite.table.SourcesFileResults`
101+
- `org.openrewrite.table.SourcesFileErrors`
102+
- `org.openrewrite.table.RecipeRunStats`
103+
- `org.openrewrite.table.ParseFailures`
104+
46105
### Category Structure
47106

48107
The `RecipeMarketplace` is a recursive data structure where each category is itself a marketplace. Categories are determined by column headers starting with "category":
@@ -67,31 +126,53 @@ Since `version` is optional, marketplaces can represent version-independent reci
67126
### Implementation
68127

69128
- **Data Model**:
70-
- `RecipeBundle`: Simple data class containing ecosystem, packageName, version (optional), and team (optional)
71-
- `RecipeListing`: Represents a recipe with metadata (name, displayName, description, options) and an associated `RecipeBundle`
129+
- `RecipeBundle`: Data class containing:
130+
- `packageEcosystem`: The package ecosystem (e.g., `maven`, `npm`, `yaml`)
131+
- `packageName`: The package identifier
132+
- `requestedVersion`: Version constraint as requested (optional)
133+
- `version`: Resolved version (optional)
134+
- `team`: Team identifier (optional)
135+
- `RecipeListing`: Represents a recipe with metadata:
136+
- `name`, `displayName`, `description`
137+
- `estimatedEffortPerOccurrence`: ISO-8601 duration
138+
- `options`: List of `OptionDescriptor` objects
139+
- `dataTables`: List of `DataTableDescriptor` objects
140+
- `recipeCount`: Total count of recipes (direct + transitive)
141+
- `metadata`: Map of custom key-value pairs from unknown columns
142+
- `bundle`: Associated `RecipeBundle`
72143
- `RecipeMarketplace`: Hierarchical structure with nested `Category` instances and a list of `RecipeBundleResolver` instances
73144

74145
- **Reader**: `RecipeMarketplaceReader` (using univocity-parsers)
75146
- Parses CSV into `RecipeMarketplace` hierarchies
76147
- Creates `RecipeListing` instances with `RecipeBundle` objects from CSV data
77-
- Requires `ecosystem` and `packageName` columns; `version` and `team` are optional
78-
- Dynamically detects category and option columns
148+
- Uses Jackson `ObjectMapper` for JSON deserialization of options and dataTables
149+
- Requires `ecosystem` and `packageName` columns; other columns are optional
150+
- Dynamically detects category columns by header prefix
151+
- Preserves unknown columns as metadata
79152

80153
- **Writer**: `RecipeMarketplaceWriter` (using univocity-parsers)
81-
- Dynamically determines required category and option columns based on marketplace content
82-
- Always includes `ecosystem` and `packageName` columns
83-
- Only includes `version` column if at least one recipe has version information
84-
- Only includes `team` column if at least one recipe has team information
154+
- Dynamically determines required columns based on marketplace content
155+
- Always includes `ecosystem`, `packageName`, and `name` columns
156+
- Only includes optional columns if at least one recipe has data for them
157+
- Uses Jackson for JSON serialization with `NON_DEFAULT` inclusion
158+
- Places `options` and `dataTables` columns last for human readability
85159

86160
- **Bundle Resolution**: Two-phase resolution system
87161
- `RecipeBundleResolver`: Interface with `getEcosystem()` and `resolve(RecipeBundle)` methods
88162
- Ecosystem-specific resolvers are registered with the `RecipeMarketplace`
89-
- Examples: `MavenRecipeBundleResolver` in `rewrite-maven`, `NpmRecipeBundleResolver` in `rewrite-javascript`
163+
- Implementations:
164+
- `MavenRecipeBundleResolver` in `rewrite-maven`
165+
- `NpmRecipeBundleResolver` in `rewrite-javascript`
166+
- `YamlRecipeBundleResolver` in `rewrite-core`
90167
- `RecipeBundleReader`: Interface returned by resolvers with methods:
91168
- `getBundle()`: Returns the associated `RecipeBundle`
92169
- `read()`: Reads the bundle and returns a `RecipeMarketplace`
93170
- `describe(RecipeListing)`: Returns a `RecipeDescriptor` for a listing
94171
- `prepare(RecipeListing, Map<String, Object>)`: Creates a configured `Recipe` instance
172+
- Implementations:
173+
- `MavenRecipeBundleReader`: Downloads Maven artifacts, reads `META-INF/rewrite/recipes.csv` from JAR, falls back to classpath scanning
174+
- `NpmRecipeBundleReader`: Uses `JavaScriptRewriteRpc` for remote communication with npm packages
175+
- `YamlRecipeBundleReader`: Reads recipes from YAML files by path or URI
95176
- `RecipeListing.resolve()`: Convenience method that finds the appropriate resolver and returns a `RecipeBundleReader`
96177

97178
- **Validators**: Tools for ensuring marketplace quality and completeness
@@ -124,16 +205,18 @@ Since `version` is optional, marketplaces can represent version-independent reci
124205

125206
### Negative
126207

127-
1. **CSV limitations**: No native support for nested structures (mitigated by column naming conventions)
128-
2. **Sparse data**: Recipes with few options result in many empty cells in CSVs with high option counts
208+
1. **CSV limitations**: No native support for nested structures (mitigated by JSON columns and column naming conventions)
209+
2. **JSON in CSV cells**: Options and dataTables are stored as JSON strings, which can be harder to edit manually in spreadsheet tools
129210
3. **Always requires bundle metadata**: Unlike earlier designs, ecosystem and packageName are always required, even for basic recipe catalogs
130211

131212
### Trade-offs
132213

133214
- **Left-to-right category ordering** (left = deepest): This matches the `moderne-organizations-format` convention but may be counterintuitive to some users who expect left-to-right to represent root-to-leaf
134215
- **Two-phase resolution**: Separating RecipeBundleResolver and RecipeBundleReader provides flexibility but adds complexity compared to a single interface
135-
- **Dynamic columns**: Provides flexibility but means schema varies between files, making generic CSV processing tools less effective
216+
- **JSON for complex data**: Using JSON for options and dataTables provides a fixed column structure but requires JSON parsing/writing
217+
- **Dynamic category columns**: The number of category columns varies between files based on hierarchy depth
136218
- **Resolver configuration**: RecipeBundleResolvers must be registered with the RecipeMarketplace instance before calling RecipeListing.resolve(), describe(), or prepare()
219+
- **Requested vs resolved version**: Supporting both `requestedVersion` (constraint) and `version` (resolved) adds flexibility but also complexity in version handling
137220

138221
## Examples
139222

@@ -151,11 +234,25 @@ ecosystem,packageName,version,name,displayName,description,category1
151234
maven,org.openrewrite:rewrite-java,8.0.0,org.openrewrite.java.cleanup.UnnecessaryParentheses,Remove Unnecessary Parentheses,Removes unnecessary parentheses.,Java Cleanup
152235
```
153236

154-
### With Recipe Options
237+
### With Requested Version
238+
239+
```csv
240+
ecosystem,packageName,requestedVersion,version,name,displayName,description,category1
241+
maven,org.openrewrite:rewrite-java,LATEST,8.45.0,org.openrewrite.java.cleanup.UnnecessaryParentheses,Remove Unnecessary Parentheses,Removes unnecessary parentheses.,Java Cleanup
242+
```
243+
244+
### With Recipe Options (JSON Format)
155245

156246
```csv
157-
ecosystem,packageName,name,displayName,description,category1,option1Name,option1DisplayName,option1Description,option2Name,option2DisplayName,option2Description
158-
maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.UpgradeDependencyVersion,Upgrade Dependency,Upgrades a Maven dependency.,Maven,groupId,Group ID,The group ID.,artifactId,Artifact ID,The artifact ID.
247+
ecosystem,packageName,name,displayName,description,category1,options
248+
maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.UpgradeDependencyVersion,Upgrade Dependency,Upgrades a Maven dependency.,Maven,"[{""name"":""groupId"",""displayName"":""Group ID"",""description"":""The group ID.""},{""name"":""artifactId"",""displayName"":""Artifact ID"",""description"":""The artifact ID.""}]"
249+
```
250+
251+
### With Recipe Count
252+
253+
```csv
254+
ecosystem,packageName,name,displayName,description,recipeCount,category1
255+
maven,org.openrewrite:rewrite-java,org.openrewrite.java.format.Autoformat,Autoformat,Formats Java source code.,15,Java Formatting
159256
```
160257

161258
### With Team Partitioning
@@ -174,3 +271,10 @@ maven,org.openrewrite:rewrite-java,org.openrewrite.java.format.AutoFormat,Format
174271
```
175272

176273
Creates: `Best Practices > Java > Cleanup > UnnecessaryParentheses` and `Best Practices > Java > Formatting > AutoFormat`
274+
275+
### YAML Ecosystem Example
276+
277+
```csv
278+
ecosystem,packageName,name,displayName,description
279+
yaml,/path/to/recipes.yml,org.example.MyRecipe,My Custom Recipe,A custom recipe from a YAML file.
280+
```

rewrite-core/src/main/java/org/openrewrite/marketplace/RecipeMarketplaceContentValidator.java

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ private Validated<RecipeMarketplace> validate(RecipeMarketplace.Category categor
4646
for (RecipeListing recipe : category.getRecipes()) {
4747
String value = recipe.getName() + (categoryPath.isEmpty() ? "" : categoryPath.stream()
4848
.collect(joining(" > ", "[", "]")));
49-
validation = validation.and(validateDisplayName(validation, recipe.getDisplayName(), value));
50-
validation = validation.and(validateDescription(validation, recipe.getDescription(), value));
49+
validation = validation.and(validateDisplayName(recipe.getDisplayName(), value));
50+
validation = validation.and(validateDescription(recipe.getDescription(), value));
5151
}
5252

5353
for (RecipeMarketplace.Category child : category.getCategories()) {
@@ -59,9 +59,8 @@ private Validated<RecipeMarketplace> validate(RecipeMarketplace.Category categor
5959
return validation;
6060
}
6161

62-
private Validated<RecipeMarketplace> validateDisplayName(Validated<RecipeMarketplace> validation,
63-
String displayName,
64-
String recipe) {
62+
private Validated<RecipeMarketplace> validateDisplayName(String displayName, String recipe) {
63+
Validated<RecipeMarketplace> validation = Validated.none();
6564
String property = recipe + ".displayName";
6665
if (displayName.isEmpty()) {
6766
validation = validation.and(Validated.invalid(property, displayName, "Display must not be empty"));
@@ -75,12 +74,11 @@ private Validated<RecipeMarketplace> validateDisplayName(Validated<RecipeMarketp
7574
return validation;
7675
}
7776

78-
private Validated<RecipeMarketplace> validateDescription(Validated<RecipeMarketplace> validation,
79-
String description,
80-
String recipe) {
77+
private Validated<RecipeMarketplace> validateDescription(String description, String recipe) {
8178
if (description.isEmpty()) {
82-
return validation;
79+
return Validated.none();
8380
}
81+
Validated<RecipeMarketplace> validation = Validated.none();
8482
String property = recipe + ".description";
8583
if (!Character.isUpperCase(description.charAt(0))) {
8684
validation = validation.and(Validated.invalid(property, description, "Description must be sentence cased"));

0 commit comments

Comments
 (0)