Skip to content
Merged
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
11 changes: 10 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,16 @@
"WebFetch(domain:docs.flamingock.io)",
"Bash(wc:*)",
"Bash(./gradlew test:*)",
"Bash(./gradlew spotlessCheck:*)"
"Bash(./gradlew spotlessCheck:*)",
"Bash(./gradlew spotlessApply:*)",
"Bash(git -C /Users/dieppa/workspace/flamingock/library/flamingock-java-template-mongodb status)",
"Bash(git -C /Users/dieppa/workspace/flamingock/library/flamingock-java-template-mongodb diff)",
"Bash(git -C /Users/dieppa/workspace/flamingock/library/flamingock-java-template-mongodb log --oneline -5)",
"Bash(git -C /Users/dieppa/workspace/flamingock/library/flamingock-java-template-mongodb diff --cached -- ANALYSIS.md src/main/java/io/flamingock/template/mongodb/model/MongoOperationType.java src/main/java/io/flamingock/template/mongodb/validation/ModifyCollectionParametersValidator.java src/test/java/io/flamingock/template/mongodb/model/MongoOperationValidateTest.java)",
"Bash(git -C /Users/dieppa/workspace/flamingock/library/flamingock-java-template-mongodb reset HEAD .claude/settings.local.json CLAUDE.md)",
"Bash(git -C:*)",
"Bash(git restore:*)",
"Bash(git commit:*)"
]
}
}
15 changes: 4 additions & 11 deletions ANALYSIS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,10 @@ YAML -> MongoOperation (deserialized) -> MongoOperationValidator -> MongoOperati
**Original issue:** `modifyCollection` used `OperationValidator.NO_OP` in `MongoOperationType`, so none of its three parameters (`validator`, `validationLevel`, `validationAction`) were validated. A user could pass `validationLevel: "banana"` and it would pass validation, only to fail at MongoDB runtime.
**Resolution:** `ModifyCollectionParametersValidator` now validates at load time: requires at least one recognized parameter, checks `validator` is a document (Map), checks `validationLevel` is one of `off`/`strict`/`moderate`, and checks `validationAction` is one of `error`/`warn`. Multiple errors accumulate. Wired into `MongoOperationType.MODIFY_COLLECTION` replacing the `NO_OP` validator.

### #4 - HIGH: Type-unsafe parameter extraction throughout MongoOperation
**File:** `MongoOperation.java:46-115`
**Impact:** Every parameter getter uses `@SuppressWarnings("unchecked")` with raw casts from `Map<String, Object>`. Examples:
- `getDocuments()` (line 47): `(List<Map<String, Object>>) parameters.get("documents")` - throws `ClassCastException` if YAML has `documents: "not a list"`
- `getKeys()` (line 54): `(Map<String, Object>) parameters.get("keys")` - same issue
- `getFilter()` (line 68): same pattern
- `getUpdate()` (line 108): same pattern

While the validator catches some of these cases, the validator runs only on the apply path (see #1). Additionally, `getOptions()` (line 62) is called by operators but NEVER validated by `MongoOperationValidator` - an unknown key in `options` produces no error.
**Risk:** `ClassCastException` at runtime with unhelpful stack trace instead of a clear validation error.
**Fix:** Either add type checking in the getters (return Optional/throw descriptive error), or ensure validation is exhaustive for all parameter access paths.
### #4 - ~~HIGH: Type-unsafe parameter extraction throughout MongoOperation~~ RESOLVED
**Status:** Fixed by adding type checks to all parameter validators.
**Original issue:** Every parameter getter in `MongoOperation` uses `@SuppressWarnings("unchecked")` raw casts from `Map<String, Object>`, risking `ClassCastException` at runtime if YAML contained wrong-typed values. Additionally, `getOptions()` was called by operators but never validated.
**Resolution:** All parameter validators now include `instanceof` type checks that run at load time via `MongoOperation.validate()` (the `TemplatePayload` contract). Specifically: `UpdateParametersValidator` and `DeleteParametersValidator` check that `filter` is a `Map`; `DropIndexParametersValidator` checks that `keys` is a `Map` and `indexName` is a `String`; and the 5 validators for operations that support options (`InsertParametersValidator`, `UpdateParametersValidator`, `CreateIndexParametersValidator`, `CreateViewParametersValidator`, `RenameCollectionParametersValidator`) check that `options` is a `Map` when present. Since the framework guarantees `validate()` runs before any operator executes, the `@SuppressWarnings("unchecked")` casts in getters are now effectively safe — they can never be reached with wrong-typed data.

### #5 - HIGH: CreateIndexOperator claims `transactional=true` but ignores the session
**File:** `CreateIndexOperator.java:28,32-35`
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ See `ANALYSIS.md` for a detailed quality assessment. Key issues:
5. **CreateIndexOperator claims transactional but ignores session** — misleading behavior
6. **Collation mapping broken for YAML input** — `MapperUtil.getCollation()` expects `Collation` object, gets `Map`

## Commit Message Convention

All commits must follow [Conventional Commits](https://www.conventionalcommits.org/) with a well-structured body suitable for changelog extraction from `git log`.
DON'T add Claude as co-author

## Terminology

- Use "**changes**", not "migrations"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ public List<TemplatePayloadValidationError> validate(MongoOperation operation) {
"'keys' cannot be empty"));
}

Object options = params.get("options");
if (options != null && !(options instanceof Map)) {
errors.add(new TemplatePayloadValidationError("parameters.options",
"'options' must be a document"));
}

return errors;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ public List<TemplatePayloadValidationError> validate(MongoOperation operation) {
"'pipeline' must be a list"));
}

Object options = params.get("options");
if (options != null && !(options instanceof Map)) {
errors.add(new TemplatePayloadValidationError("parameters.options",
"'options' must be a document"));
}

return errors;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ public List<TemplatePayloadValidationError> validate(MongoOperation operation) {
List<TemplatePayloadValidationError> errors = new ArrayList<>();
Map<String, Object> params = operation.getParameters();

if (params == null || !params.containsKey("filter")) {
Object filter = params == null ? null : params.get("filter");
if (filter == null) {
errors.add(new TemplatePayloadValidationError("parameters.filter",
"Delete operation requires 'filter' parameter"));
} else if (!(filter instanceof Map)) {
errors.add(new TemplatePayloadValidationError("parameters.filter",
"'filter' must be a document"));
}

return errors;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ public List<TemplatePayloadValidationError> validate(MongoOperation operation) {
"DropIndex operation requires either 'indexName' or 'keys' parameter"));
}

if (indexName != null && !(indexName instanceof String)) {
errors.add(new TemplatePayloadValidationError("parameters.indexName",
"'indexName' must be a string"));
}

if (keys != null && !(keys instanceof Map)) {
errors.add(new TemplatePayloadValidationError("parameters.keys",
"'keys' must be a map"));
}

return errors;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ public List<TemplatePayloadValidationError> validate(MongoOperation operation) {
}
}

Object options = params.get("options");
if (options != null && !(options instanceof Map)) {
errors.add(new TemplatePayloadValidationError("parameters.options",
"'options' must be a document"));
}

return errors;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ public List<TemplatePayloadValidationError> validate(MongoOperation operation) {
"'target' cannot be null or empty"));
}

Object options = params.get("options");
if (options != null && !(options instanceof Map)) {
errors.add(new TemplatePayloadValidationError("parameters.options",
"'options' must be a document"));
}

return errors;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,13 @@ public List<TemplatePayloadValidationError> validate(MongoOperation operation) {
return errors;
}

if (!params.containsKey("filter")) {
Object filter = params.get("filter");
if (filter == null) {
errors.add(new TemplatePayloadValidationError("parameters.filter",
"Update operation requires 'filter' parameter"));
} else if (!(filter instanceof Map)) {
errors.add(new TemplatePayloadValidationError("parameters.filter",
"'filter' must be a document"));
}

Object update = params.get("update");
Expand All @@ -49,6 +53,12 @@ public List<TemplatePayloadValidationError> validate(MongoOperation operation) {
"'update' must be a document"));
}

Object options = params.get("options");
if (options != null && !(options instanceof Map)) {
errors.add(new TemplatePayloadValidationError("parameters.options",
"'options' must be a document"));
}

return errors;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,28 @@ void insertDocumentsWrongTypeTest() {
assertTrue(errors.get(0).getMessage().contains("must be a list"));
}

@Test
@DisplayName("WHEN insert options is wrong type THEN validation fails")
void insertOptionsWrongTypeTest() {
MongoOperation op = new MongoOperation();
op.setType("insert");
op.setCollection("test");
Map<String, Object> params = new HashMap<>();
List<Map<String, Object>> docs = new ArrayList<>();
Map<String, Object> doc = new HashMap<>();
doc.put("name", "Test");
docs.add(doc);
params.put("documents", docs);
params.put("options", "not a document");
op.setParameters(params);

List<TemplatePayloadValidationError> errors = op.validate();

assertEquals(1, errors.size());
assertEquals("parameters.options", errors.get(0).getField());
assertTrue(errors.get(0).getMessage().contains("must be a document"));
}

@Test
@DisplayName("WHEN insert is valid THEN validation passes")
void insertValidTest() {
Expand Down Expand Up @@ -310,6 +332,47 @@ void updateParamWrongTypeTest() {
assertTrue(errors.get(0).getMessage().contains("must be a document"));
}

@Test
@DisplayName("WHEN update filter is wrong type THEN validation fails")
void updateFilterWrongTypeTest() {
MongoOperation op = new MongoOperation();
op.setType("update");
op.setCollection("test");
Map<String, Object> params = new HashMap<>();
params.put("filter", "not a document");
Map<String, Object> update = new HashMap<>();
update.put("$set", new HashMap<>());
params.put("update", update);
op.setParameters(params);

List<TemplatePayloadValidationError> errors = op.validate();

assertEquals(1, errors.size());
assertEquals("parameters.filter", errors.get(0).getField());
assertTrue(errors.get(0).getMessage().contains("must be a document"));
}

@Test
@DisplayName("WHEN update options is wrong type THEN validation fails")
void updateOptionsWrongTypeTest() {
MongoOperation op = new MongoOperation();
op.setType("update");
op.setCollection("test");
Map<String, Object> params = new HashMap<>();
params.put("filter", new HashMap<>());
Map<String, Object> update = new HashMap<>();
update.put("$set", new HashMap<>());
params.put("update", update);
params.put("options", "not a document");
op.setParameters(params);

List<TemplatePayloadValidationError> errors = op.validate();

assertEquals(1, errors.size());
assertEquals("parameters.options", errors.get(0).getField());
assertTrue(errors.get(0).getMessage().contains("must be a document"));
}

@Test
@DisplayName("WHEN update missing both filter and update THEN both errors reported")
void updateMissingBothTest() {
Expand Down Expand Up @@ -363,6 +426,23 @@ void deleteMissingFilterTest() {
assertTrue(errors.get(0).getMessage().contains("requires 'filter'"));
}

@Test
@DisplayName("WHEN delete filter is wrong type THEN validation fails")
void deleteFilterWrongTypeTest() {
MongoOperation op = new MongoOperation();
op.setType("delete");
op.setCollection("test");
Map<String, Object> params = new HashMap<>();
params.put("filter", "not a document");
op.setParameters(params);

List<TemplatePayloadValidationError> errors = op.validate();

assertEquals(1, errors.size());
assertEquals("parameters.filter", errors.get(0).getField());
assertTrue(errors.get(0).getMessage().contains("must be a document"));
}

@Test
@DisplayName("WHEN delete has empty filter THEN validation passes")
void deleteEmptyFilterTest() {
Expand Down Expand Up @@ -464,6 +544,26 @@ void createIndexKeysWrongTypeTest() {
assertTrue(errors.get(0).getMessage().contains("must be a map"));
}

@Test
@DisplayName("WHEN createIndex options is wrong type THEN validation fails")
void createIndexOptionsWrongTypeTest() {
MongoOperation op = new MongoOperation();
op.setType("createIndex");
op.setCollection("test");
Map<String, Object> params = new HashMap<>();
Map<String, Object> keys = new HashMap<>();
keys.put("email", 1);
params.put("keys", keys);
params.put("options", "not a document");
op.setParameters(params);

List<TemplatePayloadValidationError> errors = op.validate();

assertEquals(1, errors.size());
assertEquals("parameters.options", errors.get(0).getField());
assertTrue(errors.get(0).getMessage().contains("must be a document"));
}

@Test
@DisplayName("WHEN createIndex is valid THEN validation passes")
void createIndexValidTest() {
Expand Down Expand Up @@ -500,6 +600,40 @@ void dropIndexMissingBothTest() {
assertTrue(errors.get(0).getMessage().contains("'indexName' or 'keys'"));
}

@Test
@DisplayName("WHEN dropIndex keys is wrong type THEN validation fails")
void dropIndexKeysWrongTypeTest() {
MongoOperation op = new MongoOperation();
op.setType("dropIndex");
op.setCollection("test");
Map<String, Object> params = new HashMap<>();
params.put("keys", "not a map");
op.setParameters(params);

List<TemplatePayloadValidationError> errors = op.validate();

assertEquals(1, errors.size());
assertEquals("parameters.keys", errors.get(0).getField());
assertTrue(errors.get(0).getMessage().contains("must be a map"));
}

@Test
@DisplayName("WHEN dropIndex indexName is wrong type THEN validation fails")
void dropIndexIndexNameWrongTypeTest() {
MongoOperation op = new MongoOperation();
op.setType("dropIndex");
op.setCollection("test");
Map<String, Object> params = new HashMap<>();
params.put("indexName", 123);
op.setParameters(params);

List<TemplatePayloadValidationError> errors = op.validate();

assertEquals(1, errors.size());
assertEquals("parameters.indexName", errors.get(0).getField());
assertTrue(errors.get(0).getMessage().contains("must be a string"));
}

@Test
@DisplayName("WHEN dropIndex has indexName THEN validation passes")
void dropIndexWithIndexNameTest() {
Expand Down Expand Up @@ -569,6 +703,24 @@ void renameCollectionEmptyTargetTest() {
assertTrue(errors.get(0).getMessage().contains("cannot be null or empty"));
}

@Test
@DisplayName("WHEN renameCollection options is wrong type THEN validation fails")
void renameCollectionOptionsWrongTypeTest() {
MongoOperation op = new MongoOperation();
op.setType("renameCollection");
op.setCollection("oldName");
Map<String, Object> params = new HashMap<>();
params.put("target", "newName");
params.put("options", "not a document");
op.setParameters(params);

List<TemplatePayloadValidationError> errors = op.validate();

assertEquals(1, errors.size());
assertEquals("parameters.options", errors.get(0).getField());
assertTrue(errors.get(0).getMessage().contains("must be a document"));
}

@Test
@DisplayName("WHEN renameCollection is valid THEN validation passes")
void renameCollectionValidTest() {
Expand Down Expand Up @@ -656,6 +808,25 @@ void createViewPipelineWrongTypeTest() {
assertTrue(errors.get(0).getMessage().contains("must be a list"));
}

@Test
@DisplayName("WHEN createView options is wrong type THEN validation fails")
void createViewOptionsWrongTypeTest() {
MongoOperation op = new MongoOperation();
op.setType("createView");
op.setCollection("testView");
Map<String, Object> params = new HashMap<>();
params.put("viewOn", "sourceCollection");
params.put("pipeline", Collections.singletonList(new HashMap<>()));
params.put("options", "not a document");
op.setParameters(params);

List<TemplatePayloadValidationError> errors = op.validate();

assertEquals(1, errors.size());
assertEquals("parameters.options", errors.get(0).getField());
assertTrue(errors.get(0).getMessage().contains("must be a document"));
}

@Test
@DisplayName("WHEN createView is valid THEN validation passes")
void createViewValidTest() {
Expand Down
Loading