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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to this project are documented in this file.
## [Unreleased]

### Added
- Added `$unwind.includeArrayIndex` support, including MongoDB-compatible `null` index handling for scalar and preserved null/missing/empty inputs.
- Added `listCollections`, `drop`, and `dropDatabase` command handlers for metadata lookup and fixture cleanup flows.
- Added UTF `runCommand` importer support for `listCollections`, `distinct`, `drop`, and `dropDatabase`.
- Added tier-0 TTL runtime pruning for single-field `expireAfterSeconds` indexes using deterministic lazy cleanup.
Expand Down Expand Up @@ -51,7 +52,7 @@ All notable changes to this project are documented in this file.
- Fixed upsert seed extraction to honor equality clauses nested inside `$and`, restoring duplicate-key behavior for `findOneAndUpdate` lock-style filters.

### Changed
- Default local project version moved to `0.1.6-SNAPSHOT`.
- Default local project version moved to `0.1.9-SNAPSHOT`.

## [0.1.3] - 2026-02-24

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ This project targets integration-test compatibility for common Spring data paths
| --- | --- | --- |
| Command surface | 25 handlers | Mix of `Supported` and `Partial` |
| Query language | Core comparison/logical/array/regex + partial `$expr` (including `$add` subset) | Advanced parity incomplete |
| Aggregation | Core stages + selected Tier-2 stages + minimal `$graphLookup` subset | Full operator coverage not implemented |
| Aggregation | Core stages, `$unwind.includeArrayIndex`, selected Tier-2 stages + minimal `$graphLookup` subset | Full operator coverage not implemented |
| Transactions | Single-process session/transaction flow | Namespace-aware commit merge + snapshot reads (`find`/`aggregate`/`countDocuments`) + deterministic retry labels/contracts |
| Deployment profile | Standalone + single-node replica-set semantic profile | Replica-set profile exposes primary-only handshake/URI semantics for driver compatibility |
| Wire protocol | `OP_MSG` + `OP_QUERY` | In-process ingress and standalone TCP launcher mode, with OP_QUERY namespace-based `$db` fallback |
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = providers.gradleProperty("publishGroup").orElse("io.github.midagedev").get()
version = providers.gradleProperty("publishVersion").orElse("0.1.6-SNAPSHOT").get()
version = providers.gradleProperty("publishVersion").orElse("0.1.9-SNAPSHOT").get()

repositories {
mavenCentral()
Expand Down
3 changes: 1 addition & 2 deletions docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Implemented stages:
- `$group` (subset: `$sum`, `$first`, `$addToSet`)
- `$sort`
- `$limit`, `$skip`
- `$unwind` (without `includeArrayIndex`)
- `$unwind` (including `includeArrayIndex`)
- `$count`
- `$addFields`
- `$set` (alias subset of `$addFields`)
Expand All @@ -89,7 +89,6 @@ Not implemented or partial:
- unsupported stages return deterministic fail-fast
- many advanced expression operators are still missing
- `$group` accumulators other than `$sum` are not available
- `$unwind.includeArrayIndex` is not available
- `$merge` options beyond the terminal string / `{into: <collection>}` subset are deterministic unsupported paths
- `$graphLookup` options outside current subset (for example `depthField`, `restrictSearchWithMatch`) are deterministic unsupported paths
- `bypassDocumentValidation` for aggregate is excluded from current differential corpus
Expand Down
7 changes: 5 additions & 2 deletions docs/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ Status date: 2026-05-09

## Current Release-Line Notes

- Latest Java release tag: `v0.1.5` (run `22379241855`, commit `868bbc7`).
- Latest Java release tag: `v0.1.8` (run `23785415137`, commit `7b8ef1f`).
- Latest Node adapter tag: `node-v0.1.4` (run `22379340586`, commit `868bbc7`).
- Latest compatibility certification snapshot: commit `d2ba61b` (runs `22516323868`, `22516324202`, `22516137734`).
- Next Java tag candidate: `v0.1.6`; regenerate certification artifacts against the release-candidate commit, not reuse historical tag evidence.
- Next Java tag candidate: `v0.1.9`; regenerate certification artifacts against the release-candidate commit, not reuse historical tag evidence.

## Tagging Gate

Expand Down Expand Up @@ -76,6 +76,9 @@ Use this when validating `@jongodb/memory-server` canary automation:

| Version | Date (UTC) | Commit | Maven | GitHub Actions |
| --- | --- | --- | --- | --- |
| `0.1.8` | `2026-03-31` | `7b8ef1f` | `io.github.midagedev:jongodb:0.1.8` | run `23785415137` |
| `0.1.7` | `2026-03-05` | `b7c4a13` | `io.github.midagedev:jongodb:0.1.7` | run `22723048147` |
| `0.1.6` | `2026-03-01` | `12a259c` | `io.github.midagedev:jongodb:0.1.6` | run `22541258962` |
| `0.1.5` | `2026-02-25` | `868bbc7` | `io.github.midagedev:jongodb:0.1.5` | run `22379241855` |
| `0.1.4` | `2026-02-24` | `e6f6bbf` | `io.github.midagedev:jongodb:0.1.4` | run `22355417610` |
| `0.1.3` | `2026-02-24` | `61cfa03` | `io.github.midagedev:jongodb:0.1.3` | run `22341381717` |
Expand Down
2 changes: 1 addition & 1 deletion docs/SUPPORT_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Source artifact: `build/reports/r2-compatibility/r2-support-manifest.json`.
| `query.eq-ne-compare` | query | Supported | Core comparison operators |
| `query.elemMatch-all-regex` | query | Supported | Array and regex operators |
| `query.expr-subset` | query | Partial | Subset: eq/ne/gt/gte/lt/lte/and/or/not/literal/add |
| `aggregation.match-project-group` | aggregation | Supported | Tier-1 pipeline stages |
| `aggregation.match-project-group` | aggregation | Supported | Tier-1 pipeline stages, including `$unwind.includeArrayIndex` |
| `aggregation.lookup-union-facet` | aggregation | Partial | Tier-2 subset without full expression parity (includes minimal `$graphLookup` option subset) |
| `aggregation.expression-operators` | aggregation | Partial | Limited expression coverage |
| `index.unique-sparse-partial` | index | Supported | Unique/sparse/partial |
Expand Down
2 changes: 1 addition & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ Manual fallback command:

```bash
gradle \
-PpublishVersion=0.1.6 \
-PpublishVersion=0.1.9 \
-PpublishGroup=io.github.midagedev \
-PpublishArtifactId=jongodb \
centralRelease
Expand Down
41 changes: 32 additions & 9 deletions src/main/java/org/jongodb/engine/AggregationPipeline.java
Original file line number Diff line number Diff line change
Expand Up @@ -575,37 +575,54 @@ private static List<Document> applyUnwind(final List<Document> input, final Obje
final PathValue pathValue = resolvePath(source, unwindSpec.path());
if (!pathValue.present() || pathValue.value() == null) {
if (unwindSpec.preserveNullAndEmptyArrays()) {
output.add(DocumentCopies.copy(source));
final Document preserved = DocumentCopies.copy(source);
setUnwindArrayIndex(preserved, unwindSpec, null);
output.add(preserved);
}
continue;
}

if (pathValue.value() instanceof List<?> listValue) {
if (listValue.isEmpty()) {
if (unwindSpec.preserveNullAndEmptyArrays()) {
output.add(DocumentCopies.copy(source));
final Document preserved = DocumentCopies.copy(source);
removePath(preserved, unwindSpec.path());
setUnwindArrayIndex(preserved, unwindSpec, null);
output.add(preserved);
}
continue;
}

for (final Object item : listValue) {
for (int index = 0; index < listValue.size(); index++) {
final Object item = listValue.get(index);
final Document expanded = DocumentCopies.copy(source);
setPath(expanded, unwindSpec.path(), item);
setUnwindArrayIndex(expanded, unwindSpec, Long.valueOf(index));
output.add(expanded);
}
continue;
}

final Document expanded = DocumentCopies.copy(source);
setPath(expanded, unwindSpec.path(), pathValue.value());
setUnwindArrayIndex(expanded, unwindSpec, null);
output.add(expanded);
}
return output;
}

private static void setUnwindArrayIndex(
final Document target,
final UnwindSpec unwindSpec,
final Long arrayIndex) {
if (unwindSpec.includeArrayIndex() != null) {
setPath(target, unwindSpec.includeArrayIndex(), arrayIndex);
}
}

private static UnwindSpec parseUnwindSpec(final Object stageDefinition) {
if (stageDefinition instanceof String pathExpression) {
return new UnwindSpec(unwindPath(pathExpression), false);
return new UnwindSpec(unwindPath(pathExpression), false, null);
}

final Document unwindDocument = requireDocument(stageDefinition, "$unwind stage requires a string or document");
Expand All @@ -622,12 +639,18 @@ private static UnwindSpec parseUnwindSpec(final Object stageDefinition) {
}
preserveNullAndEmptyArrays = preserve;
}
String includeArrayIndex = null;
if (unwindDocument.containsKey("includeArrayIndex")) {
throw new UnsupportedFeatureException(
"aggregation.unwind.includeArrayIndex",
"$unwind includeArrayIndex is not supported yet");
final Object includeArrayIndexValue = unwindDocument.get("includeArrayIndex");
if (!(includeArrayIndexValue instanceof String includeArrayIndexName)
|| includeArrayIndexName.isBlank()
|| includeArrayIndexName.startsWith("$")) {
throw new IllegalArgumentException(
"$unwind.includeArrayIndex must be a non-empty field name that does not start with '$'");
}
includeArrayIndex = includeArrayIndexName;
}
return new UnwindSpec(unwindPath(pathExpression), preserveNullAndEmptyArrays);
return new UnwindSpec(unwindPath(pathExpression), preserveNullAndEmptyArrays, includeArrayIndex);
}

private static String unwindPath(final String pathExpression) {
Expand Down Expand Up @@ -1242,7 +1265,7 @@ private enum ProjectionFlag {

private record SortKey(String field, int direction) {}

private record UnwindSpec(String path, boolean preserveNullAndEmptyArrays) {}
private record UnwindSpec(String path, boolean preserveNullAndEmptyArrays, String includeArrayIndex) {}

private record PathValue(boolean present, Object value) {
private static PathValue missing() {
Expand Down
52 changes: 52 additions & 0 deletions src/test/java/org/jongodb/engine/AggregationPipelineTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.ArrayList;
Expand Down Expand Up @@ -49,4 +51,54 @@ void executeDoesNotMutateSourceDocumentsForUnwind() {
}
assertEquals(List.of("a", "b"), unwindValues);
}

@Test
void unwindSupportsIncludeArrayIndex() {
final List<Document> source = List.of(
new Document("_id", 1).append("tags", List.of("a", "b")),
new Document("_id", 2).append("tags", "scalar"),
new Document("_id", 3).append("tags", List.of()),
new Document("_id", 4),
new Document("_id", 5).append("tags", null));

final List<Document> result = AggregationPipeline.execute(
source,
List.of(new Document(
"$unwind",
new Document("path", "$tags")
.append("includeArrayIndex", "tagIndex")
.append("preserveNullAndEmptyArrays", true))));

assertEquals(6, result.size());
assertEquals("a", result.get(0).getString("tags"));
assertEquals(0L, result.get(0).get("tagIndex"));
assertEquals("b", result.get(1).getString("tags"));
assertEquals(1L, result.get(1).get("tagIndex"));
assertEquals("scalar", result.get(2).getString("tags"));
assertTrue(result.get(2).containsKey("tagIndex"));
assertNull(result.get(2).get("tagIndex"));
assertFalse(result.get(3).containsKey("tags"));
assertTrue(result.get(3).containsKey("tagIndex"));
assertNull(result.get(3).get("tagIndex"));
assertFalse(result.get(4).containsKey("tags"));
assertTrue(result.get(4).containsKey("tagIndex"));
assertNull(result.get(4).get("tagIndex"));
assertTrue(result.get(5).containsKey("tags"));
assertNull(result.get(5).get("tags"));
assertTrue(result.get(5).containsKey("tagIndex"));
assertNull(result.get(5).get("tagIndex"));
}

@Test
void unwindRejectsInvalidIncludeArrayIndexName() {
final List<Document> source = List.of(new Document("_id", 1).append("tags", List.of("a")));

assertThrows(
IllegalArgumentException.class,
() -> AggregationPipeline.execute(
source,
List.of(new Document(
"$unwind",
new Document("path", "$tags").append("includeArrayIndex", "$index")))));
}
}
Loading