From 95b82ccda4065ba764b82e9481de69af6e9a533c Mon Sep 17 00:00:00 2001 From: Attila Klenik Date: Fri, 10 Apr 2026 11:34:53 +0200 Subject: [PATCH 01/22] ci: add build & test workflow and CI/CD documentation Add GitHub Actions workflow for automated build and test on push/PR events targeting the main branch. Include CI/CD documentation covering the workflow setup, configuration, and contribution guidelines. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Attila Klenik --- .github/workflows/build.yml | 62 +++++++++++++++++++++++++++++++++++++ docs/cicd.md | 43 +++++++++++++++++++++++++ docs/index.md | 7 +++++ 3 files changed, 112 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 docs/cicd.md create mode 100644 docs/index.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..d793d724 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,62 @@ +name: Build & Test + +on: + push: + branches: ['**'] + paths: + - 'lib/**' + - '*.gradle.kts' + - 'gradle/**' + - 'gradlew*' + - '.github/workflows/build.yml' + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + paths: + - 'lib/**' + - '*.gradle.kts' + - 'gradle/**' + - 'gradlew*' + - '.github/workflows/build.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Check formatting + run: ./gradlew spotlessCheck + + - name: Assemble + run: ./gradlew assemble + + - name: Run tests + run: ./gradlew test + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: lib/build/reports/tests/test/ + retention-days: 14 diff --git a/docs/cicd.md b/docs/cicd.md new file mode 100644 index 00000000..8e9fb867 --- /dev/null +++ b/docs/cicd.md @@ -0,0 +1,43 @@ +# CI/CD + +This project uses GitHub Actions for continuous integration and delivery. + +## Workflows + +| Workflow | File | Purpose | +|----------|------|---------| +| Build & Test | `.github/workflows/build.yml` | Formatting checks, compilation, and test suite | + +## Build & Test + +Runs `spotlessCheck`, `assemble`, and `test` on every push and pull request that touches code-relevant files. Documentation-only changes are skipped via path filters. + +If a new commit is pushed while a run is already in progress for the same branch, the older run is automatically cancelled. + +### Reading CI results + +1. **Status check on PRs** — the workflow result appears as a check on the pull request page. +2. **Actions tab** — navigate to the repository's Actions tab to see all workflow runs. +3. **Test report artifact** — every run uploads an HTML test report. Download it from the Artifacts section of the workflow run summary (retained for 14 days). + +### Troubleshooting + +#### Formatting check failed + +The `spotlessCheck` step enforces Google Java Format. To fix locally: + +```bash +./gradlew spotlessApply +``` + +This reformats all source files in place. Commit the changes and push again. + +#### Test failure + +Check the uploaded test report artifact for details: + +1. Go to the failed workflow run in the Actions tab. +2. Scroll to the **Artifacts** section. +3. Download **test-report** and open `index.html` in a browser. + +The report shows which tests failed, with stack traces and assertion messages. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..fcb47eb7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,7 @@ +# Hypernate Documentation + +Hypernate is an entity framework for Hyperledger Fabric that brings ORM-like convenience to chaincode development. It provides object-oriented CRUD operations, declarative entity key configuration, and an extensible middleware chain. + +## Pages + +- [CI/CD](cicd.md) — Continuous integration and delivery workflows From 1a5cd6751a97a565b98aebf2409d560199390c72 Mon Sep 17 00:00:00 2001 From: Nesar976 Date: Fri, 10 Apr 2026 15:24:33 +0530 Subject: [PATCH 02/22] refactor: reorganize RegistryTest by method-based grouping Signed-off-by: Nesar976 --- .../bme/mit/ftsrg/hypernate/RegistryTest.java | 339 +++++++++--------- 1 file changed, 176 insertions(+), 163 deletions(-) diff --git a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java index d808908c..6ce9f8fd 100644 --- a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java +++ b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java @@ -90,10 +90,10 @@ record KeylessTestEntity(String foo, Integer bar) {} private record TestEntity(String foo, Integer bar) {} @Nested - class given_empty_ledger { + class when_must_create { @Test - void when_must_create_then_call_putState() + void given_empty_ledger_then_call_putState() throws SerializationException, EntityExistsException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); @@ -106,206 +106,144 @@ void when_must_create_then_call_putState() } @Test - void when_try_create_then_return_true_and_call_putState() throws SerializationException { + void given_existing_entity_then_throw_exists() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(new byte[] {}); - - boolean result = registry.tryCreate(entity); + given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - assertTrue(result); - then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); + assertThrows(EntityExistsException.class, () -> registry.mustCreate(entity)); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_try_create { @Test - void when_must_update_then_throw_not_found() { + void given_empty_ledger_then_return_true_and_call_putState() throws SerializationException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); given(stub.getState(anyString())).willReturn(new byte[] {}); - assertThrows(EntityNotFoundException.class, () -> registry.mustUpdate(entity)); + boolean result = registry.tryCreate(entity); + + assertTrue(result); + then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); verifyNoMoreInteractions(stub); } @Test - void when_try_update_then_return_false_and_do_nothing() { + void given_existing_entity_then_return_false_and_do_nothing() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(new byte[] {}); + given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); AtomicBoolean result = new AtomicBoolean(false); - assertDoesNotThrow(() -> result.set(registry.tryUpdate(entity))); + assertDoesNotThrow(() -> result.set(registry.tryCreate(entity))); assertFalse(result.get()); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_must_update { @Test - void when_must_delete_then_throw_not_found() { + void given_empty_ledger_then_throw_not_found() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); given(stub.getState(anyString())).willReturn(new byte[] {}); - assertThrows(EntityNotFoundException.class, () -> registry.mustDelete(entity)); + assertThrows(EntityNotFoundException.class, () -> registry.mustUpdate(entity)); verifyNoMoreInteractions(stub); } @Test - void when_try_delete_then_return_false_and_do_nothing() { + void given_existing_entity_then_call_putState() + throws SerializationException, EntityNotFoundException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(new byte[] {}); - - AtomicBoolean result = new AtomicBoolean(false); - assertDoesNotThrow(() -> result.set(registry.tryDelete(entity))); - assertFalse(result.get()); - verifyNoMoreInteractions(stub); - } - - @Test - void when_readAll_then_return_empty_list() throws SerializationException { - given(stub.createCompositeKey(anyString())).willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getStateByPartialCompositeKey(anyString())) - .willReturn( - new QueryResultsIterator<>() { - @Override - public void close() {} - - @Override - public @Nonnull Iterator iterator() { - return new Iterator<>() { - @Override - public boolean hasNext() { - return false; - } - - @Override - public KeyValue next() { - throw new UnsupportedOperationException(); - } - }; - } - }); + given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - List results = registry.readAll(TestEntity.class); + registry.mustUpdate(entity); - then(stub).should().getStateByPartialCompositeKey(anyString()); - assertTrue(results.isEmpty()); + then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); verifyNoMoreInteractions(stub); } - - @Nested - class when_must_read { - - @Test - void with_insufficient_key_parts_then_throw_illegal_argument() { - assertThrows( - IllegalArgumentException.class, () -> registry.mustRead(TestEntity.class, entity.foo)); - verifyNoMoreInteractions(stub); - } - - @Test - void with_complete_key_then_throw_not_found() { - given(stub.createCompositeKey(anyString(), any(String[].class))) - .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(new byte[] {}); - - assertThrows( - EntityNotFoundException.class, - () -> registry.mustRead(TestEntity.class, entity.foo, entity.bar)); - verifyNoMoreInteractions(stub); - } - } - - @Nested - class when_try_read { - - @Test - void with_insufficient_key_parts_then_throw_illegal_argument() { - assertThrows( - IllegalArgumentException.class, () -> registry.tryRead(TestEntity.class, entity.foo)); - verifyNoMoreInteractions(stub); - } - - @Test - void with_complete_key_then_return_null() throws SerializationException { - given(stub.createCompositeKey(anyString(), any(String[].class))) - .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(new byte[] {}); - - TestEntity readEntity = registry.tryRead(TestEntity.class, entity.foo, entity.bar); - - assertNull(readEntity); - verifyNoMoreInteractions(stub); - } - } } @Nested - class given_existing_entity { + class when_try_update { @Test - void when_must_create_then_throw_exists() { + void given_empty_ledger_then_return_false_and_do_nothing() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); + given(stub.getState(anyString())).willReturn(new byte[] {}); - assertThrows(EntityExistsException.class, () -> registry.mustCreate(entity)); + AtomicBoolean result = new AtomicBoolean(false); + assertDoesNotThrow(() -> result.set(registry.tryUpdate(entity))); + assertFalse(result.get()); verifyNoMoreInteractions(stub); } @Test - void when_try_create_then_return_false_and_do_nothing() { + void given_existing_entity_then_return_true_and_call_putState() throws SerializationException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - AtomicBoolean result = new AtomicBoolean(false); - assertDoesNotThrow(() -> result.set(registry.tryCreate(entity))); - assertFalse(result.get()); + boolean result = registry.tryUpdate(entity); + + assertTrue(result); + then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_must_delete { @Test - void when_must_update_then_call_putState() - throws SerializationException, EntityNotFoundException { + void given_empty_ledger_then_throw_not_found() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - - registry.mustUpdate(entity); + given(stub.getState(anyString())).willReturn(new byte[] {}); - then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); + assertThrows(EntityNotFoundException.class, () -> registry.mustDelete(entity)); verifyNoMoreInteractions(stub); } @Test - void when_try_update_then_return_true_and_call_putState() throws SerializationException { + void given_existing_entity_then_call_delState() throws EntityNotFoundException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - boolean result = registry.tryUpdate(entity); + registry.mustDelete(entity); - assertTrue(result); - then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); + then(stub).should().delState(ENTITY_COMPOSITE_KEY_STR); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_try_delete { @Test - void when_must_delete_then_call_delState() throws EntityNotFoundException { + void given_empty_ledger_then_return_false_and_do_nothing() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - - registry.mustDelete(entity); + given(stub.getState(anyString())).willReturn(new byte[] {}); - then(stub).should().delState(ENTITY_COMPOSITE_KEY_STR); + AtomicBoolean result = new AtomicBoolean(false); + assertDoesNotThrow(() -> result.set(registry.tryDelete(entity))); + assertFalse(result.get()); verifyNoMoreInteractions(stub); } @Test - void when_try_delete_then_return_true_and_call_delState() throws EntityNotFoundException { + void given_existing_entity_then_return_true_and_call_delState() throws EntityNotFoundException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); @@ -316,9 +254,45 @@ void when_try_delete_then_return_true_and_call_delState() throws EntityNotFoundE then(stub).should().delState(ENTITY_COMPOSITE_KEY_STR); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_readAll { + + @Test + void given_empty_ledger_then_return_empty_list() throws SerializationException { + given(stub.createCompositeKey(anyString())).willReturn(ENTITY_COMPOSITE_KEY); + given(stub.getStateByPartialCompositeKey(anyString())) + .willReturn( + new QueryResultsIterator<>() { + @Override + public void close() {} + + @Override + public @Nonnull Iterator iterator() { + return new Iterator<>() { + @Override + public boolean hasNext() { + return false; + } + + @Override + public KeyValue next() { + throw new UnsupportedOperationException(); + } + }; + } + }); + + List results = registry.readAll(TestEntity.class); + + then(stub).should().getStateByPartialCompositeKey(anyString()); + assertTrue(results.isEmpty()); + verifyNoMoreInteractions(stub); + } @Test - void when_readAll_then_return_one_long_list() throws SerializationException { + void given_existing_entity_then_return_one_long_list() throws SerializationException { given(stub.createCompositeKey(anyString())).willReturn(ENTITY_COMPOSITE_KEY); given(stub.getStateByPartialCompositeKey(anyString())) .willReturn( @@ -371,54 +345,93 @@ public String getStringValue() { assertEquals(1, results.size()); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_must_read { + + @Test + void given_empty_ledger_with_insufficient_key_parts_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, () -> registry.mustRead(TestEntity.class, entity.foo)); + verifyNoMoreInteractions(stub); + } + + @Test + void given_empty_ledger_with_complete_key_then_throw_not_found() { + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(ENTITY_COMPOSITE_KEY); + given(stub.getState(anyString())).willReturn(new byte[] {}); + + assertThrows( + EntityNotFoundException.class, + () -> registry.mustRead(TestEntity.class, entity.foo, entity.bar)); + verifyNoMoreInteractions(stub); + } + + @Test + void given_existing_entity_with_insufficient_key_parts_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, () -> registry.mustRead(TestEntity.class, entity.foo)); + verifyNoMoreInteractions(stub); + } + + @Test + void given_existing_entity_with_complete_key_then_call_getState_and_return_entity() + throws SerializationException, EntityNotFoundException { + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(ENTITY_COMPOSITE_KEY); + given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); + + TestEntity result = registry.mustRead(TestEntity.class, entity.foo, entity.bar); + + then(stub).should().getState(ENTITY_COMPOSITE_KEY_STR); + assertEquals(entity, result); + verifyNoMoreInteractions(stub); + } + } + + @Nested + class when_try_read { - @Nested - class when_must_read { - - @Test - void with_insufficient_key_parts_then_throw_illegal_argument() { - assertThrows( - IllegalArgumentException.class, () -> registry.mustRead(TestEntity.class, entity.foo)); - verifyNoMoreInteractions(stub); - } - - @Test - void with_complete_key_then_call_getState_and_return_entity() - throws SerializationException, EntityNotFoundException { - given(stub.createCompositeKey(anyString(), any(String[].class))) - .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - - TestEntity result = registry.mustRead(TestEntity.class, entity.foo, entity.bar); - - then(stub).should().getState(ENTITY_COMPOSITE_KEY_STR); - assertEquals(entity, result); - verifyNoMoreInteractions(stub); - } + @Test + void given_empty_ledger_with_insufficient_key_parts_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, () -> registry.tryRead(TestEntity.class, entity.foo)); + verifyNoMoreInteractions(stub); } - @Nested - class when_try_read { + @Test + void given_empty_ledger_with_complete_key_then_return_null() throws SerializationException { + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(ENTITY_COMPOSITE_KEY); + given(stub.getState(anyString())).willReturn(new byte[] {}); + + TestEntity readEntity = registry.tryRead(TestEntity.class, entity.foo, entity.bar); + + assertNull(readEntity); + verifyNoMoreInteractions(stub); + } - @Test - void with_insufficient_key_parts_then_throw_illegal_argument() { - assertThrows( - IllegalArgumentException.class, () -> registry.tryRead(TestEntity.class, entity.foo)); - verifyNoMoreInteractions(stub); - } + @Test + void given_existing_entity_with_insufficient_key_parts_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, () -> registry.tryRead(TestEntity.class, entity.foo)); + verifyNoMoreInteractions(stub); + } - @Test - void with_complete_key_then_call_getState_and_return_entity() throws SerializationException { - given(stub.createCompositeKey(anyString(), any(String[].class))) - .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); + @Test + void given_existing_entity_with_complete_key_then_call_getState_and_return_entity() + throws SerializationException { + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(ENTITY_COMPOSITE_KEY); + given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - TestEntity result = registry.tryRead(TestEntity.class, entity.foo, entity.bar); + TestEntity result = registry.tryRead(TestEntity.class, entity.foo, entity.bar); - then(stub).should().getState(ENTITY_COMPOSITE_KEY_STR); - assertEquals(entity, result); - verifyNoMoreInteractions(stub); - } + then(stub).should().getState(ENTITY_COMPOSITE_KEY_STR); + assertEquals(entity, result); + verifyNoMoreInteractions(stub); } } } From 51fd897281192c902079a5b1fb514bbc29812805 Mon Sep 17 00:00:00 2001 From: Anvay Date: Fri, 10 Apr 2026 23:15:28 +0530 Subject: [PATCH 03/22] docs(site): scaffold and migrate markdown content Signed-off-by: Anvay --- docs/api/annotations.md | 3 + docs/api/registry.md | 3 + docs/concepts/annotations.md | 3 + docs/concepts/index.md | 104 +++++++++++++++++++++++++++ docs/concepts/middleware.md | 3 + docs/concepts/registry.md | 3 + docs/contributing/index.md | 19 +++++ docs/contributing/maintainers.md | 20 ++++++ docs/getting-started/index.md | 59 +++++++++++++++ docs/getting-started/installation.md | 3 + docs/getting-started/quickstart.md | 3 + docs/guides/cicd.md | 47 ++++++++++++ docs/index.md | 33 ++++++++- docs/references/citing.md | 27 +++++++ 14 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 docs/api/annotations.md create mode 100644 docs/api/registry.md create mode 100644 docs/concepts/annotations.md create mode 100644 docs/concepts/index.md create mode 100644 docs/concepts/middleware.md create mode 100644 docs/concepts/registry.md create mode 100644 docs/contributing/index.md create mode 100644 docs/contributing/maintainers.md create mode 100644 docs/getting-started/index.md create mode 100644 docs/getting-started/installation.md create mode 100644 docs/getting-started/quickstart.md create mode 100644 docs/guides/cicd.md create mode 100644 docs/references/citing.md diff --git a/docs/api/annotations.md b/docs/api/annotations.md new file mode 100644 index 00000000..6acc1364 --- /dev/null +++ b/docs/api/annotations.md @@ -0,0 +1,3 @@ +# Annotations + +This section is coming soon. Check back after the next release. diff --git a/docs/api/registry.md b/docs/api/registry.md new file mode 100644 index 00000000..34dc5ed0 --- /dev/null +++ b/docs/api/registry.md @@ -0,0 +1,3 @@ +# Registry + +This section is coming soon. Check back after the next release. diff --git a/docs/concepts/annotations.md b/docs/concepts/annotations.md new file mode 100644 index 00000000..6acc1364 --- /dev/null +++ b/docs/concepts/annotations.md @@ -0,0 +1,3 @@ +# Annotations + +This section is coming soon. Check back after the next release. diff --git a/docs/concepts/index.md b/docs/concepts/index.md new file mode 100644 index 00000000..f8fdc4cf --- /dev/null +++ b/docs/concepts/index.md @@ -0,0 +1,104 @@ +# Concepts + +## Core Concepts + +Hypernate builds on Hyperledger Fabric by adding a higher-level programming model focused on +entities, typed operations, and reusable middleware. + +## Declarative Entity Keys + +Hypernate uses annotations to define key construction declaratively. + +### Declaring Primary Keys + +Use `@PrimaryKey` with one or more `@AttributeInfo` entries. + +```java +@FieldNameConstants +@PrimaryKey(@AttributeInfo(name = Asset.Fields.assetID)) +public record Asset( + String assetID, + String color, + int size, + int appraisedValue, + String owner) {} +``` + +### Key Mappers + +Fabric keys are strings, so key-part mapping matters for sorting and range scans. + +If numeric IDs are mapped with plain `toString`, lexical ordering can become semantically wrong. +For example, `9`, `10`, `11` become `"9"`, `"10"`, `"11"`, and lexical ordering puts `"10"` +before `"9"`. + +Hypernate supports mapper classes to preserve ordering semantics. Available mappers include: + +- `IntegerZeroPadder` +- `IntegerFlipperAndZeroPadder` +- `LongZeroPadder` +- `LongFlipperAndZeroPadder` +- `ObjectToString` + +### Composite Keys + +Hypernate supports ordered composite keys with multiple attributes. + +```java +@FieldNameConstants +@PrimaryKey({ + @AttributeInfo(name = Asset.Fields.owner), + @AttributeInfo(name = Asset.Fields.assetID, mapper = IntegerZeroPadder.class) +}) +public record Asset( + String owner, + int assetID, + String color, + int size, + int appraisedValue) {} +``` + +## CRUD Semantics with Registry + +Use `Registry` through `HypernateContext` for object-oriented ledger access. + +Methods use explicit semantics: + +- `must*` methods fail with exceptions when invariants are not satisfied. +- `try*` methods return graceful outcomes for non-fatal paths. + +```java +@Transaction(intent = EVALUATE) +public boolean AssetExists(final HypernateContext ctx, final String assetID) { + return ctx.getRegistry().tryRead(Asset.class, assetID) != null; +} +``` + +```java +Asset toDelete = reg.mustRead(Asset.class, assetID); +ctx.getRegistry().mustDelete(toDelete); +``` + +## Middleware Chain + +Hypernate middleware wraps `ChaincodeStub` behavior in reusable processors. + +Common middleware includes: + +- `LoggingStubMiddleware` +- `WriteBackCachedStubMiddleware` + +You can chain middleware with `@MiddlewareInfo`: + +```java +@MiddlewareInfo({ + LoggingStubMiddleware.class, + WriteBackCachedStubMiddleware.class +}) +public class MyBusinessContract implements HypernateContract +``` + +This allows call interception for logging, caching, and other cross-cutting behavior while +keeping business code focused. + +> Hypernate context and middleware instances are specific to individual transaction executions. diff --git a/docs/concepts/middleware.md b/docs/concepts/middleware.md new file mode 100644 index 00000000..9f736012 --- /dev/null +++ b/docs/concepts/middleware.md @@ -0,0 +1,3 @@ +# Middleware + +This section is coming soon. Check back after the next release. diff --git a/docs/concepts/registry.md b/docs/concepts/registry.md new file mode 100644 index 00000000..34dc5ed0 --- /dev/null +++ b/docs/concepts/registry.md @@ -0,0 +1,3 @@ +# Registry + +This section is coming soon. Check back after the next release. diff --git a/docs/contributing/index.md b/docs/contributing/index.md new file mode 100644 index 00000000..e278de0a --- /dev/null +++ b/docs/contributing/index.md @@ -0,0 +1,19 @@ +# Contributing + +## Developer Guide + +The preferred contribution flow is: + +1. Fork the repository. +2. Create a branch with a meaningful name. +3. Make changes using Conventional Commits. +4. Push the branch to your fork. +5. Create a pull request. + +Conventional Commits reference: +https://www.conventionalcommits.org/en/v1.0.0/#summary + +## Maintainers and Governance + +See the [Maintainers](maintainers.md) page for the current maintainer list and original +contributors. diff --git a/docs/contributing/maintainers.md b/docs/contributing/maintainers.md new file mode 100644 index 00000000..375659a1 --- /dev/null +++ b/docs/contributing/maintainers.md @@ -0,0 +1,20 @@ +# Contributors of Hypernate + +## Maintainers + +Below is a table of active Hypernate maintainers. + +| Name | GitHub | Discord | +| --------------------- | ------- | ------- | +| Bertalan Zoltán Péter | bzp99 | | +| Attila Klenik | aklenik | klenik | + +## Original Contributors + +- Bertalan Zoltán Péter +- Attila Klenik +- Imre Kocsis +- [Budapest University of Technology and Economics](https://www.bme.hu/en/) + - [Faculty of Electrical Engineering and Informatics](https://www.vik.bme.hu/en/) + - [Department of Artificial Intelligence and Systems Engineering](http://www.mit.bme.hu/eng/) + - [Critical Systems Research Group](https://ftsrg.mit.bme.hu/en/) diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 00000000..95f5a4c1 --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,59 @@ +# Getting Started + +## Overview + +Hypernate is an entity framework for Hyperledger Fabric. + +If Fabric allows you to keep your familiar programming language, then Hypernate will allow you to +keep your familiar programming style. + +No more low-level boilerplate code for key-value storage operations and other housekeeping tasks. +Take advantage of Hypernate's high abstraction level, aspect-oriented approaches, and extensibility +to keep critical business logic as clean as possible. + +Enhance your chaincode with features like: + +- Object-oriented CRUD (create, read, update, delete) operations with explicit semantics +- Declarative and flexible configuration of entity keys +- An extensible chain of middleware processors handling non-business concerns + +Get access to these features using a simple incantation: + +```java +public class MyBusinessContract implements HypernateContract +``` + +More features are on the way: + +- Partial and range queries based on non-key attributes +- Range query support for composite keys +- Overall friendlier query support +- OpenTelemetry integration +- Support for data schemas + +## Setup Flow + +For complete examples, refer to the +[hypernate-samples](https://github.com/LF-Decentralized-Trust-labs/hypernate-samples) +repository. + +The typical setup flow is: + +1. Include the Hypernate library as a dependency in your project. +2. Use Hypernate annotations on your entities (DTOs or POJOs). +3. Use `HypernateContract` as the base class for your business `Contract` implementation. +4. Use the `Registry` class to handle annotated entities in a friendly way. + +The easiest integration path is using `HypernateContext` as your transaction `Context` +implementation so it can wire the registry and middleware setup automatically. + +## Next Steps + +- Continue to the [Concepts overview](../concepts/index.md) for design details. +- Follow the [Quickstart](quickstart.md) once available. +- Read [Installation](installation.md) for environment setup details. + +## License + +Hypernate uses the Apache License Version 2.0. +For details, see `NOTICES.md`, `MAINTAINERS.md`, and `LICENSE` in the repository root. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 00000000..d3ec4b12 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,3 @@ +# Installation + +This section is coming soon. Check back after the next release. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 00000000..4c553d91 --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,3 @@ +# Quickstart + +This section is coming soon. Check back after the next release. diff --git a/docs/guides/cicd.md b/docs/guides/cicd.md new file mode 100644 index 00000000..71906533 --- /dev/null +++ b/docs/guides/cicd.md @@ -0,0 +1,47 @@ +# CI/CD + +This project uses GitHub Actions for continuous integration and delivery. + +## Workflows + +| Workflow | File | Purpose | +| ------------ | ----------------------------- | ----------------------------------------- | +| Build & Test | `.github/workflows/build.yml` | Formatting checks, compilation, and tests | + +## Build & Test + +The Build & Test workflow runs `spotlessCheck`, `assemble`, and `test` on pushes and pull +requests that touch code-relevant files. + +Documentation-only changes are skipped via path filters. + +If a new commit is pushed while a run is already in progress for the same branch, the older run is +automatically canceled. + +## Reading CI Results + +1. **Status check on PRs** - The workflow result appears as a check on the pull request page. +2. **Actions tab** - Open the repository Actions tab to view workflow runs. +3. **Test report artifact** - Every run uploads an HTML test report artifact retained for 14 days. + +## Troubleshooting + +### Formatting Check Failed + +The `spotlessCheck` step enforces Google Java Format. To fix locally: + +```bash +./gradlew spotlessApply +``` + +This reformats source files in place. Commit the changes and push again. + +### Test Failure + +Check the uploaded test report artifact: + +1. Open the failed workflow run in the Actions tab. +2. Scroll to the **Artifacts** section. +3. Download **test-report** and open `index.html` in a browser. + +The report shows failing tests with stack traces and assertion messages. diff --git a/docs/index.md b/docs/index.md index fcb47eb7..5bd68e8d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,34 @@ # Hypernate Documentation -Hypernate is an entity framework for Hyperledger Fabric that brings ORM-like convenience to chaincode development. It provides object-oriented CRUD operations, declarative entity key configuration, and an extensible middleware chain. +Hypernate is an entity framework for Hyperledger Fabric that brings ORM-like convenience to +chaincode development. -## Pages +## What Hypernate Is -- [CI/CD](cicd.md) — Continuous integration and delivery workflows +Hypernate provides object-oriented CRUD operations, declarative entity key configuration, and an +extensible middleware chain for Fabric chaincode development. + +## Why It Exists + +Fabric is powerful, but many projects repeat the same low-level key-value boilerplate code. +Hypernate exists to reduce that repetition and keep business logic clear and focused. + +## Key Features + +- Object-oriented CRUD operations with explicit behavior. +- Flexible key declarations with annotations. +- Middleware chain support for logging, caching, and similar cross-cutting concerns. + +## Start Here + +- [Getting Started Overview](getting-started/index.md) +- [Concepts Overview](concepts/index.md) +- [API Reference - Registry](api/registry.md) +- [API Reference - Annotations](api/annotations.md) + +## Guides + +- [CI/CD](guides/cicd.md) +- [Contributing](contributing/index.md) +- [Maintainers](contributing/maintainers.md) +- [Citing](references/citing.md) diff --git a/docs/references/citing.md b/docs/references/citing.md new file mode 100644 index 00000000..07f8faa2 --- /dev/null +++ b/docs/references/citing.md @@ -0,0 +1,27 @@ +# Citing + +Please use the following information when you use or reference this project (or the related +research paper) in your own work. + +Paper link: +https://doi.org/10.21203/rs.3.rs-4606405/v1 + +## Text Citation + +Damaris Jepkurui Kangogo, Bertalan Zoltán Péter, Attila Klenik, Imre Kocsis. +_Practical runtime verification of cross-organizational smart contracts_, 11 July 2024, +PREPRINT (Version 1) available at Research Square +[https://doi.org/10.21203/rs.3.rs-4606405/v1] + +## BibTeX + +```bibtex +@unpublished{kangogo2024practical, + title={Practical runtime verification of cross-organizational smart contracts}, + author={Kangogo, Damaris Jepkurui and Péter, Bertalan Zoltán and Klenik, Attila + and Kocsis, Imre}, + year={2024}, + note={Preprint at \url{https://www.researchsquare.com/article/rs-4606405/latest}}, + doi={10.21203/rs.3.rs-4606405/v1} +} +``` From a2a07dba75bd97caa05751339638661890d5eb96 Mon Sep 17 00:00:00 2001 From: Anvay Date: Fri, 10 Apr 2026 23:16:11 +0530 Subject: [PATCH 04/22] ci(docs): add mkdocs config and pages deployment workflow Signed-off-by: Anvay --- .github/workflows/docs.yml | 53 ++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + docs/requirements.txt | 2 ++ mkdocs.yml | 52 +++++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/requirements.txt create mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..e4007e60 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,53 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + tags: + - 'v*.*.*' + +jobs: + deploy-latest: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install dependencies + run: pip install -r docs/requirements.txt + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Deploy latest + run: | + mike deploy --push --update-aliases latest + mike set-default --push latest + + deploy-version: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install dependencies + run: pip install -r docs/requirements.txt + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Extract version + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + - name: Deploy version + run: | + mike deploy --push --update-aliases ${{ steps.version.outputs.VERSION }} latest diff --git a/.gitignore b/.gitignore index c7690edf..bb277d38 100644 --- a/.gitignore +++ b/.gitignore @@ -186,3 +186,4 @@ gradle-app.setting ### Custom ### .idea/ lib/bin/ +site/ diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..fa286e37 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +mkdocs-material==9.6.11 +mike==2.1.3 diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..76c39afd --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,52 @@ +site_name: Hypernate +site_url: https://lf-decentralized-trust-labs.github.io/hypernate +repo_url: https://github.com/LF-Decentralized-Trust-labs/hypernate +repo_name: LF-Decentralized-Trust-labs/hypernate +edit_uri: edit/main/docs/ + +theme: + name: material + palette: + primary: indigo + accent: indigo + features: + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - search.highlight + - search.share + - toc.follow + +plugins: + - search + - mike: + alias_type: symlink + canonical_version: latest + +extra: + version: + provider: mike + default: latest + +nav: + - Home: index.md + - Getting Started: + - Overview: getting-started/index.md + - Installation: getting-started/installation.md + - Quickstart: getting-started/quickstart.md + - Concepts: + - Overview: concepts/index.md + - Registry: concepts/registry.md + - Middleware: concepts/middleware.md + - Annotations: concepts/annotations.md + - Guides: + - CI/CD: guides/cicd.md + - API Reference: + - Registry: api/registry.md + - Annotations: api/annotations.md + - Contributing: + - Overview: contributing/index.md + - Maintainers: contributing/maintainers.md + - References: + - Citing: references/citing.md From bea5d17e3452bb0ae2f41368bc7af570c640cd7b Mon Sep 17 00:00:00 2001 From: Anvay Date: Sat, 11 Apr 2026 01:39:29 +0530 Subject: [PATCH 05/22] ci(docs): fix tag deployment conflict in mike workflow Signed-off-by: Anvay --- .github/workflows/docs.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e4007e60..6ebb9ad6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,6 +7,13 @@ on: tags: - 'v*.*.*' +permissions: + contents: write + +concurrency: + group: docs-deploy + cancel-in-progress: false + jobs: deploy-latest: if: github.ref == 'refs/heads/main' @@ -26,7 +33,7 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" - name: Deploy latest run: | - mike deploy --push --update-aliases latest + mike deploy --push --allow-empty --update-aliases latest mike set-default --push latest deploy-version: @@ -50,4 +57,4 @@ jobs: run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - name: Deploy version run: | - mike deploy --push --update-aliases ${{ steps.version.outputs.VERSION }} latest + mike deploy --push --allow-empty ${{ steps.version.outputs.VERSION }} From bcf4b51c2a50b543b4a10b876890290a4db4d39a Mon Sep 17 00:00:00 2001 From: Anvay Date: Wed, 15 Apr 2026 23:14:31 +0530 Subject: [PATCH 06/22] docs: enforce strict mkdocs and clean migrated CI/CD page Signed-off-by: Anvay --- .github/workflows/docs.yml | 14 ++++++------- docs/cicd.md | 43 -------------------------------------- mkdocs.yml | 1 + 3 files changed, 8 insertions(+), 50 deletions(-) delete mode 100644 docs/cicd.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6ebb9ad6..db7d693a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,15 +5,11 @@ on: branches: - main tags: - - 'v*.*.*' + - "v*.*.*" permissions: contents: write -concurrency: - group: docs-deploy - cancel-in-progress: false - jobs: deploy-latest: if: github.ref == 'refs/heads/main' @@ -24,9 +20,11 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" - name: Install dependencies run: pip install -r docs/requirements.txt + - name: Build docs (strict) + run: mkdocs build --strict - name: Configure git run: | git config user.name "github-actions[bot]" @@ -45,9 +43,11 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" - name: Install dependencies run: pip install -r docs/requirements.txt + - name: Build docs (strict) + run: mkdocs build --strict - name: Configure git run: | git config user.name "github-actions[bot]" diff --git a/docs/cicd.md b/docs/cicd.md deleted file mode 100644 index 8e9fb867..00000000 --- a/docs/cicd.md +++ /dev/null @@ -1,43 +0,0 @@ -# CI/CD - -This project uses GitHub Actions for continuous integration and delivery. - -## Workflows - -| Workflow | File | Purpose | -|----------|------|---------| -| Build & Test | `.github/workflows/build.yml` | Formatting checks, compilation, and test suite | - -## Build & Test - -Runs `spotlessCheck`, `assemble`, and `test` on every push and pull request that touches code-relevant files. Documentation-only changes are skipped via path filters. - -If a new commit is pushed while a run is already in progress for the same branch, the older run is automatically cancelled. - -### Reading CI results - -1. **Status check on PRs** — the workflow result appears as a check on the pull request page. -2. **Actions tab** — navigate to the repository's Actions tab to see all workflow runs. -3. **Test report artifact** — every run uploads an HTML test report. Download it from the Artifacts section of the workflow run summary (retained for 14 days). - -### Troubleshooting - -#### Formatting check failed - -The `spotlessCheck` step enforces Google Java Format. To fix locally: - -```bash -./gradlew spotlessApply -``` - -This reformats all source files in place. Commit the changes and push again. - -#### Test failure - -Check the uploaded test report artifact for details: - -1. Go to the failed workflow run in the Actions tab. -2. Scroll to the **Artifacts** section. -3. Download **test-report** and open `index.html` in a browser. - -The report shows which tests failed, with stack traces and assertion messages. diff --git a/mkdocs.yml b/mkdocs.yml index 76c39afd..4c69c45f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,6 +3,7 @@ site_url: https://lf-decentralized-trust-labs.github.io/hypernate repo_url: https://github.com/LF-Decentralized-Trust-labs/hypernate repo_name: LF-Decentralized-Trust-labs/hypernate edit_uri: edit/main/docs/ +strict: true theme: name: material From 88668aefbab5437ca0b446bbf0b3aa5593063630 Mon Sep 17 00:00:00 2001 From: Anvay Date: Thu, 16 Apr 2026 02:19:08 +0530 Subject: [PATCH 07/22] ci(docs): remove redundant mkdocs build steps Signed-off-by: Anvay --- .github/workflows/docs.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index db7d693a..a9fb2c9a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -23,8 +23,6 @@ jobs: python-version: "3.12" - name: Install dependencies run: pip install -r docs/requirements.txt - - name: Build docs (strict) - run: mkdocs build --strict - name: Configure git run: | git config user.name "github-actions[bot]" @@ -46,8 +44,6 @@ jobs: python-version: "3.12" - name: Install dependencies run: pip install -r docs/requirements.txt - - name: Build docs (strict) - run: mkdocs build --strict - name: Configure git run: | git config user.name "github-actions[bot]" From 816adc4569ac4bbb0a2faabd8dcb8a165aff6713 Mon Sep 17 00:00:00 2001 From: Sreejesh Date: Sat, 2 May 2026 12:00:00 +0530 Subject: [PATCH 08/22] feat: Context and cross-chaincode exception handling Signed-off-by: Sreejesh --- ...rossChaincodeDeserializationException.java | 19 ++++ .../context/CrossChaincodeException.java | 21 ++++ .../hypernate/context/HypernateContext.java | 11 ++ .../hypernate/context/InvocationBuilder.java | 102 ++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeDeserializationException.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeException.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/InvocationBuilder.java diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeDeserializationException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeDeserializationException.java new file mode 100644 index 00000000..ab0ad3c6 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeDeserializationException.java @@ -0,0 +1,19 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.context; + +import lombok.Getter; + +@Getter +public class CrossChaincodeDeserializationException extends RuntimeException { + private final String chaincodeId; + private final String functionName; + private final Class expectedType; + + public CrossChaincodeDeserializationException(String chaincodeId, String functionName, Class expectedType, Throwable cause) { + super(String.format("Failed to deserialize response from %s#%s into %s: %s", + chaincodeId, functionName, expectedType.getSimpleName(), cause.getMessage()), cause); + this.chaincodeId = chaincodeId; + this.functionName = functionName; + this.expectedType = expectedType; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeException.java new file mode 100644 index 00000000..f35e910c --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeException.java @@ -0,0 +1,21 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.context; + +import lombok.Getter; + +@Getter +public class CrossChaincodeException extends RuntimeException { + private final String chaincodeId; + private final String functionName; + private final int statusCode; + private final String originalMessage; + + public CrossChaincodeException(String chaincodeId, String functionName, int statusCode, String originalMessage) { + super(String.format("Cross-chaincode call to %s#%s failed with status %d: %s", + chaincodeId, functionName, statusCode, originalMessage)); + this.chaincodeId = chaincodeId; + this.functionName = functionName; + this.statusCode = statusCode; + this.originalMessage = originalMessage; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/HypernateContext.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/HypernateContext.java index 2893f3f7..9f7e1e32 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/HypernateContext.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/HypernateContext.java @@ -37,6 +37,17 @@ public HypernateContext(final StubMiddlewareChain middlewareChain) { this.registry = new Registry(middlewareChain.getFirst()); } + /** + * Start a cross-chaincode invocation builder. + * + * @param chaincodeName the target chaincode name + * @param functionName the target function name + * @return an {@link InvocationBuilder} instance + */ + public InvocationBuilder invoke(String chaincodeName, String functionName) { + return new InvocationBuilder<>(this, chaincodeName, functionName); + } + /** * Send a {@link HypernateNotification}. * diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/InvocationBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/InvocationBuilder.java new file mode 100644 index 00000000..602ee218 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/InvocationBuilder.java @@ -0,0 +1,102 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.context; + +import hu.bme.mit.ftsrg.hypernate.util.JSON; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.hyperledger.fabric.shim.Chaincode.Response; + +public class InvocationBuilder { + private final HypernateContext context; + private final String chaincodeName; + private final String functionName; + private final String channel; + private final Object[] typedArgs; + private final byte[][] rawArgs; + private final Class returnType; + private final boolean voidReturn; + + private InvocationBuilder(HypernateContext context, String chaincodeName, String functionName, + String channel, Object[] typedArgs, byte[][] rawArgs, + Class returnType, boolean voidReturn) { + this.context = context; + this.chaincodeName = chaincodeName; + this.functionName = functionName; + this.channel = channel; + this.typedArgs = typedArgs; + this.rawArgs = rawArgs; + this.returnType = returnType; + this.voidReturn = voidReturn; + } + + public InvocationBuilder(HypernateContext context, String chaincodeName, String functionName) { + this(context, chaincodeName, functionName, null, new Object[0], null, null, false); + } + + public InvocationBuilder onChannel(String channel) { + return new InvocationBuilder<>(context, chaincodeName, functionName, channel, typedArgs, rawArgs, returnType, voidReturn); + } + + public InvocationBuilder withArgs(Object... args) { + return new InvocationBuilder<>(context, chaincodeName, functionName, channel, args, rawArgs, returnType, voidReturn); + } + + public InvocationBuilder withRawArgs(byte[]... args) { + return new InvocationBuilder<>(context, chaincodeName, functionName, channel, typedArgs, args, returnType, voidReturn); + } + + public InvocationBuilder returning(Class returnType) { + return new InvocationBuilder<>(context, chaincodeName, functionName, channel, typedArgs, rawArgs, returnType, false); + } + + public InvocationBuilder returningVoid() { + return new InvocationBuilder<>(context, chaincodeName, functionName, channel, typedArgs, rawArgs, null, true); + } + + public T execute() { + if (rawArgs != null && typedArgs != null && typedArgs.length > 0) { + throw new IllegalStateException("Cannot use both withArgs() and withRawArgs() on the same invocation."); + } + if (!voidReturn && returnType == null) { + throw new IllegalStateException("Call .returning(Class) or .returningVoid() before execute()."); + } + + List assembledArgs = new ArrayList<>(); + assembledArgs.add(functionName.getBytes(StandardCharsets.UTF_8)); + + if (rawArgs != null) { + assembledArgs.addAll(Arrays.asList(rawArgs)); + } else if (typedArgs != null) { + for (int i = 0; i < typedArgs.length; i++) { + try { + assembledArgs.add(JSON.serialize(typedArgs[i]).getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize typed argument at index " + i, e); + } + } + } + + Response response; + if (channel != null && !channel.isEmpty()) { + response = context.getFabricStub().invokeChaincode(chaincodeName, assembledArgs, channel); + } else { + response = context.getFabricStub().invokeChaincode(chaincodeName, assembledArgs); + } + + if (response.getStatus() != Response.Status.SUCCESS) { + throw new CrossChaincodeException(chaincodeName, functionName, response.getStatus().getCode(), response.getMessage()); + } + + if (voidReturn) { + return null; + } + + try { + return JSON.deserialize(response.getStringPayload(), returnType); + } catch (Exception e) { + throw new CrossChaincodeDeserializationException(chaincodeName, functionName, returnType, e); + } + } +} From caf354b92172ca6bf7f1268c5a0f9138b17dd0a8 Mon Sep 17 00:00:00 2001 From: Sreejesh Date: Sun, 3 May 2026 12:00:00 +0530 Subject: [PATCH 09/22] feat: Add and update stub middleware logic Signed-off-by: Sreejesh --- .../hu/bme/mit/ftsrg/hypernate/middleware/StubMiddleware.java | 4 ++++ .../hypernate/middleware/WriteBackCachedStubMiddleware.java | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/middleware/StubMiddleware.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/middleware/StubMiddleware.java index 2fc0afe0..878e05a3 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/middleware/StubMiddleware.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/middleware/StubMiddleware.java @@ -28,6 +28,10 @@ public abstract class StubMiddleware implements ChaincodeStub, Subscriber Date: Mon, 4 May 2026 12:00:00 +0530 Subject: [PATCH 10/22] feat: Core registry operations and collectors Signed-off-by: Sreejesh --- .../ftsrg/hypernate/registry/PagedResult.java | 20 ++++++ .../registry/QueryResultCollector.java | 23 +++++++ .../ftsrg/hypernate/registry/Registry.java | 63 +++++++++++++++++-- 3 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/PagedResult.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/QueryResultCollector.java diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/PagedResult.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/PagedResult.java new file mode 100644 index 00000000..af3cfed2 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/PagedResult.java @@ -0,0 +1,20 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry; + +import java.util.List; + +public class PagedResult { + private final List results; + private final String nextBookmark; + private final boolean hasMore; + + public PagedResult(List results, String nextBookmark, boolean hasMore) { + this.results = results; + this.nextBookmark = nextBookmark; + this.hasMore = hasMore; + } + + public List results() { return results; } + public String nextBookmark() { return nextBookmark; } + public boolean hasMore() { return hasMore; } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/QueryResultCollector.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/QueryResultCollector.java new file mode 100644 index 00000000..2e8cf2cf --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/QueryResultCollector.java @@ -0,0 +1,23 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import org.hyperledger.fabric.shim.ledger.KeyValue; +import org.hyperledger.fabric.shim.ledger.QueryResultsIterator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class QueryResultCollector { + private static final Logger logger = LoggerFactory.getLogger(QueryResultCollector.class); + + static List collect(Iterable iterable, Class clazz) { + List results = new java.util.ArrayList<>(); + for (KeyValue kv : iterable) { + logger.debug("Deserializing entity at key: {}", kv.getKey()); + results.add(Registry.EntityUtil.fromBuffer(kv.getValue(), clazz)); + } + return results; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java index 46a6df4d..cd1624a7 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java @@ -31,6 +31,10 @@ public Registry(final ChaincodeStub stub) { this.stub = stub; } + ChaincodeStub getStub() { + return stub; + } + /** * Create a new entity. * @@ -44,6 +48,7 @@ public void mustCreate(final T entity) throws EntityExistsException { final String key = getCompositeKey(entity); final byte[] buffer = EntityUtil.toBuffer(entity); stub.putState(key, buffer); + applyEndorsementPolicyIfPresent(entity, key); } /** @@ -77,6 +82,7 @@ public void mustUpdate(final T entity) throws EntityNotFoundException { final String key = getCompositeKey(entity); final byte[] buffer = EntityUtil.toBuffer(entity); stub.putState(key, buffer); + applyEndorsementPolicyIfPresent(entity, key); } /** @@ -190,15 +196,41 @@ public T tryRead(Class clazz, Object... keys) { */ public List readAll(final Class clazz) { final String key = stub.createCompositeKey(EntityUtil.getType(clazz)).toString(); - Iterator iterator = stub.getStateByPartialCompositeKey(key).iterator(); - Iterable iterable = () -> iterator; + try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator iterator = stub.getStateByPartialCompositeKey(key)) { + return collectResults(iterator, clazz); + } catch (Exception e) { + throw new RuntimeException("Failed to read all entities", e); + } + } + + /** + * Start a fluent CouchDB rich query for the given entity type. + * + * @param clazz the entity class + * @return a {@link RichQueryBuilder} instance + */ + public RichQueryBuilder query(Class clazz) { + return new RichQueryBuilder<>(this, clazz); + } + + /** + * Start a fluent composite key range scan for the given entity type. + * + * @param clazz the entity class + * @return a {@link RangeQueryBuilder} instance + */ + public RangeQueryBuilder rangeQuery(Class clazz) { + return new RangeQueryBuilder<>(this, clazz); + } + + List collectResults(org.hyperledger.fabric.shim.ledger.QueryResultsIterator iterator, Class clazz) { + Iterable iterable = () -> iterator.iterator(); return StreamSupport.stream(iterable.spliterator(), false) .map( kv -> { final byte[] value = kv.getValue(); logger.debug( - "Found value at partial key {}: {} -> {}", - key, + "Found value at key {}: {}", kv.getKey(), Arrays.toString(value)); return EntityUtil.fromBuffer(value, clazz); @@ -206,6 +238,27 @@ public List readAll(final Class clazz) { .collect(Collectors.toList()); } + /** + * Dynamically set a state-based endorsement policy for an asset. + * + * @param entity the entity to apply the policy to + * @param policy the compiled endorsement policy + * @param the entity type + */ + public void setEndorsementPolicy(T entity, hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy policy) { + stub.setStateValidationParameter(getCompositeKey(entity), policy.getPolicyBytes()); + } + + private void applyEndorsementPolicyIfPresent(T entity, String key) { + hu.bme.mit.ftsrg.hypernate.annotations.EndorsementPolicy annot = + entity.getClass().getAnnotation(hu.bme.mit.ftsrg.hypernate.annotations.EndorsementPolicy.class); + if (annot != null) { + hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy policy = + hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy.of(annot.value()); + stub.setStateValidationParameter(key, policy.getPolicyBytes()); + } + } + @Loggable(Loggable.DEBUG) private boolean keyExists(final String key) { final byte[] valueOnLedger = stub.getState(key); @@ -237,7 +290,7 @@ private String getCompositeKey(final T ent) { } @UtilityClass - private class EntityUtil { + class EntityUtil { private final Logger logger = LoggerFactory.getLogger(EntityUtil.class); From 21cd9c3927a9bb00e9f9322e87e9bdb7f1c6b42e Mon Sep 17 00:00:00 2001 From: Sreejesh Date: Tue, 5 May 2026 12:00:00 +0530 Subject: [PATCH 11/22] feat: Implement rich query builder patterns Signed-off-by: Sreejesh --- .../hypernate/registry/RangeQueryBuilder.java | 118 ++++++++++ .../hypernate/registry/RichQueryBuilder.java | 204 ++++++++++++++++++ .../hypernate/registry/SelectorBuilder.java | 65 ++++++ .../ftsrg/hypernate/registry/SortOrder.java | 6 + .../query/InvalidRangeQueryException.java | 8 + .../query/UncommittedStateException.java | 8 + 6 files changed, 409 insertions(+) create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RangeQueryBuilder.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RichQueryBuilder.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SelectorBuilder.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SortOrder.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/InvalidRangeQueryException.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/UncommittedStateException.java diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RangeQueryBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RangeQueryBuilder.java new file mode 100644 index 00000000..3dc2debf --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RangeQueryBuilder.java @@ -0,0 +1,118 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry; + +import hu.bme.mit.ftsrg.hypernate.registry.query.InvalidRangeQueryException; +import java.util.List; +import org.hyperledger.fabric.shim.ledger.QueryResultsIteratorWithMetadata; + +public class RangeQueryBuilder { + private final Registry registry; + private final Class clazz; + private Object[] prefixParts; + private boolean isFullScan = false; + private boolean isPaginated = false; + private int pageSize; + private String bookmark; + + public RangeQueryBuilder(Registry registry, Class clazz) { + this.registry = registry; + this.clazz = clazz; + } + + public RangeQueryBuilder withKeyPrefix(Object... prefixParts) { + validatePrimaryKeyConfiguration(); + + int expectedCount = Registry.EntityUtil.getPrimaryKeyCount(clazz); + if (prefixParts.length > expectedCount) { + throw new InvalidRangeQueryException(String.format( + "Too many prefix parts for %s: provided %d, maximum is %d.", + clazz.getName(), prefixParts.length, expectedCount)); + } + + for (int i = 0; i < prefixParts.length; i++) { + if (prefixParts[i] == null) { + throw new InvalidRangeQueryException(String.format( + "Null prefix component at index %d for %s. Null composite key parts produce silently incorrect range scans.", + i, clazz.getName())); + } + } + + this.prefixParts = prefixParts; + this.isFullScan = false; + return this; + } + + public RangeQueryBuilder fullScan() { + validatePrimaryKeyConfiguration(); + this.isFullScan = true; + this.prefixParts = new Object[0]; + return this; + } + + public RangeQueryBuilder paginated(int pageSize, String bookmark) { + if (pageSize <= 0) { + throw new IllegalArgumentException("pageSize must be > 0"); + } + this.isPaginated = true; + this.pageSize = pageSize; + this.bookmark = (bookmark == null) ? "" : bookmark; + return this; + } + + public List execute() { + if (!isFullScan && prefixParts == null) { + throw new InvalidRangeQueryException("execute() called without withKeyPrefix() or fullScan(). Configure the query before executing."); + } + + org.hyperledger.fabric.shim.ledger.CompositeKey compositeKey = buildPartialKey(); + + if (isPaginated) { + try (QueryResultsIteratorWithMetadata iter = + registry.getStub().getStateByPartialCompositeKeyWithPagination(compositeKey, pageSize, bookmark)) { + return QueryResultCollector.collect(iter, clazz); + } catch (Exception e) { + throw new RuntimeException("Failed to close iterator or collect results", e); + } + } else { + try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator iter = registry.getStub().getStateByPartialCompositeKey(compositeKey)) { + return QueryResultCollector.collect(iter, clazz); + } catch (Exception e) { + throw new RuntimeException("Failed to close iterator or collect results", e); + } + } + } + + public PagedResult executePaged() { + if (!isPaginated) { + throw new IllegalStateException("executePaged() requires .paginated() to be configured first."); + } + if (!isFullScan && prefixParts == null) { + throw new InvalidRangeQueryException("executePaged() called without withKeyPrefix() or fullScan()."); + } + + org.hyperledger.fabric.shim.ledger.CompositeKey compositeKey = buildPartialKey(); + try (QueryResultsIteratorWithMetadata iter = + registry.getStub().getStateByPartialCompositeKeyWithPagination(compositeKey, pageSize, bookmark)) { + String nextBookmark = iter.getMetadata().getBookmark(); + boolean hasMore = !nextBookmark.isEmpty() || iter.getMetadata().getFetchedRecordsCount() > 0; + List results = QueryResultCollector.collect(iter, clazz); + return new PagedResult<>(results, nextBookmark, hasMore); + } catch (Exception e) { + throw new RuntimeException("Failed to close iterator or collect results", e); + } + } + + private void validatePrimaryKeyConfiguration() { + if (Registry.EntityUtil.getPrimaryKeyCount(clazz) == 0) { + throw new InvalidRangeQueryException(String.format( + "Cannot range-query %s: no @PrimaryKey annotation found. Range queries require at least one composite key field.", + clazz.getName())); + } + } + + private org.hyperledger.fabric.shim.ledger.CompositeKey buildPartialKey() { + String type = Registry.EntityUtil.getType(clazz); + String[] mappedParts = Registry.EntityUtil.mapKeyPartsToString(clazz, prefixParts); + return registry.getStub().createCompositeKey(type, mappedParts); + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RichQueryBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RichQueryBuilder.java new file mode 100644 index 00000000..b59f766c --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RichQueryBuilder.java @@ -0,0 +1,204 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry; + +import hu.bme.mit.ftsrg.hypernate.middleware.StubMiddleware; +import hu.bme.mit.ftsrg.hypernate.middleware.WriteBackCachedStubMiddleware; +import hu.bme.mit.ftsrg.hypernate.registry.query.UncommittedStateException; +import hu.bme.mit.ftsrg.hypernate.util.JSON; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.hyperledger.fabric.shim.ChaincodeStub; +import org.hyperledger.fabric.shim.ledger.QueryResultsIteratorWithMetadata; + +public class RichQueryBuilder { + + private final Registry registry; + private final Class clazz; + private final SelectorBuilder selectorBuilder = new SelectorBuilder(); + private final List> sort = new ArrayList<>(); + private String bookmark; + private List useIndex; + private Integer limit; + private Integer skip; + + public RichQueryBuilder(Registry registry, Class clazz) { + this.registry = registry; + this.clazz = clazz; + this.selectorBuilder.addCondition("docType", "$eq", Registry.EntityUtil.getType(clazz)); + } + + public ConditionBuilder where(String field) { + return new ConditionBuilder(field); + } + + public ConditionBuilder and(String field) { + return new ConditionBuilder(field); + } + + public RichQueryBuilder or(RichQueryBuilder... subQueries) { + List> subSelectors = new ArrayList<>(); + for (RichQueryBuilder sq : subQueries) { + subSelectors.add(sq.selectorBuilder.build()); + } + selectorBuilder.addOr(subSelectors); + return this; + } + + /** + * Add a sort condition. Note: CouchDB requires an index to exist on the sorted fields. + * If an index does not exist, Fabric CouchDB runtime will return an error during execution. + */ + public RichQueryBuilder sortBy(String field, SortOrder order) { + this.sort.add(Map.of(field, order == SortOrder.DESC ? "desc" : "asc")); + return this; + } + + public RichQueryBuilder useIndex(String designDoc, String indexName) { + this.useIndex = Arrays.asList(designDoc, indexName); + return this; + } + + public RichQueryBuilder bookmark(String token) { + this.bookmark = token; + return this; + } + + public RichQueryBuilder limit(int n) { + this.limit = n; + return this; + } + + public RichQueryBuilder skip(int n) { + this.skip = n; + return this; + } + + /** + * Execute the rich query. + * Note: Calling execute() with no conditions will result in a full-collection scan + * for the given entity type, matching all documents of this type. + */ + public List execute() throws UncommittedStateException { + checkUncommittedState(); + String queryString = buildQueryString(); + try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator iter = registry.getStub().getQueryResult(queryString)) { + return QueryResultCollector.collect(iter, clazz); + } catch (Exception e) { + throw new RuntimeException("Failed to close iterator or collect results", e); + } + } + + public PagedResult executePaged() throws UncommittedStateException { + checkUncommittedState(); + String queryString = buildQueryString(); + int pageSize = (limit != null) ? limit : 25; + String bm = (bookmark != null) ? bookmark : ""; + + try (QueryResultsIteratorWithMetadata iter = + registry.getStub().getQueryResultWithPagination(queryString, pageSize, bm)) { + String nextBookmark = iter.getMetadata().getBookmark(); + boolean hasMore = !nextBookmark.isEmpty() || iter.getMetadata().getFetchedRecordsCount() > 0; + List results = QueryResultCollector.collect(iter, clazz); + return new PagedResult<>(results, nextBookmark, hasMore); + } catch (Exception e) { + throw new RuntimeException("Failed to close iterator or collect results", e); + } + } + + private String buildQueryString() { + Map query = new HashMap<>(); + query.put("selector", selectorBuilder.build()); + if (!sort.isEmpty()) query.put("sort", sort); + if (useIndex != null) query.put("use_index", useIndex); + if (bookmark != null) query.put("bookmark", bookmark); + if (limit != null) query.put("limit", limit); + if (skip != null) query.put("skip", skip); + + try { + return JSON.serialize(query); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize CouchDB query", e); + } + } + + private void checkUncommittedState() throws UncommittedStateException { + ChaincodeStub current = registry.getStub(); + while (current instanceof StubMiddleware sm) { + if (current instanceof WriteBackCachedStubMiddleware wbc) { + if (wbc.hasUncommittedState()) { + throw new UncommittedStateException("Rich query cannot proceed: uncommitted writes exist in the middleware cache. " + + "CouchDB reads committed ledger state only — buffered writes in WriteBackCachedStubMiddleware are invisible to getQueryResult(). " + + "Flush pending writes via ctx.notify(new TransactionEnd()) before querying, or restructure your transaction to query before writing."); + } + } + current = sm.getNextStub(); + } + } + + public class ConditionBuilder { + private final String field; + + private ConditionBuilder(String field) { + this.field = field; + } + + public RichQueryBuilder is(Object value) { + selectorBuilder.addCondition(field, "$eq", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder isNot(Object value) { + selectorBuilder.addCondition(field, "$ne", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder greaterThan(Object value) { + selectorBuilder.addCondition(field, "$gt", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder greaterThanOrEq(Object value) { + selectorBuilder.addCondition(field, "$gte", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder lessThan(Object value) { + selectorBuilder.addCondition(field, "$lt", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder lessThanOrEq(Object value) { + selectorBuilder.addCondition(field, "$lte", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder in(Object... values) { + selectorBuilder.addCondition(field, "$in", Arrays.asList(values)); + return RichQueryBuilder.this; + } + + public RichQueryBuilder notIn(Object... values) { + selectorBuilder.addCondition(field, "$nin", Arrays.asList(values)); + return RichQueryBuilder.this; + } + + public RichQueryBuilder exists(boolean present) { + selectorBuilder.addCondition(field, "$exists", present); + return RichQueryBuilder.this; + } + + public RichQueryBuilder regex(String pattern) { + selectorBuilder.addCondition(field, "$regex", pattern); + return RichQueryBuilder.this; + } + + public RichQueryBuilder between(Object low, Object high) { + selectorBuilder.addCondition(field, "$gte", low); + selectorBuilder.addCondition(field, "$lte", high); + return RichQueryBuilder.this; + } + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SelectorBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SelectorBuilder.java new file mode 100644 index 00000000..f35638e6 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SelectorBuilder.java @@ -0,0 +1,65 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class SelectorBuilder { + private final Map selector = new HashMap<>(); + private final List> orConditions = new ArrayList<>(); + + @SuppressWarnings("unchecked") + void addCondition(String field, String operator, Object value) { + Object existing = selector.get(field); + + if (existing == null) { + if (operator.equals("$eq")) { + selector.put(field, value); + } else { + Map fieldConditions = new HashMap<>(); + fieldConditions.put(operator, value); + selector.put(field, fieldConditions); + } + } else if (existing instanceof Map && !((Map) existing).containsKey(operator)) { + ((Map) existing).put(operator, value); + } else { + List> andList; + if (selector.containsKey("$and")) { + andList = (List>) selector.get("$and"); + } else { + andList = new ArrayList<>(); + selector.put("$and", andList); + } + + Map newCond = new HashMap<>(); + if (operator.equals("$eq")) { + newCond.put(field, value); + } else { + newCond.put(field, Map.of(operator, value)); + } + andList.add(newCond); + } + } + + void addOr(List> subSelectors) { + if (subSelectors != null && !subSelectors.isEmpty()) { + orConditions.addAll(subSelectors); + } + } + + Map build() { + Map finalSelector = new HashMap<>(selector); + if (!orConditions.isEmpty()) { + if (finalSelector.containsKey("$or")) { + @SuppressWarnings("unchecked") + List> existingOrs = (List>) finalSelector.get("$or"); + existingOrs.addAll(orConditions); + } else { + finalSelector.put("$or", new ArrayList<>(orConditions)); + } + } + return finalSelector; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SortOrder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SortOrder.java new file mode 100644 index 00000000..c459c7f7 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SortOrder.java @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry; + +public enum SortOrder { + ASC, DESC +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/InvalidRangeQueryException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/InvalidRangeQueryException.java new file mode 100644 index 00000000..86cb85ca --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/InvalidRangeQueryException.java @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry.query; + +public class InvalidRangeQueryException extends RuntimeException { + public InvalidRangeQueryException(String message) { + super(message); + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/UncommittedStateException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/UncommittedStateException.java new file mode 100644 index 00000000..7e628ab7 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/UncommittedStateException.java @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry.query; + +public class UncommittedStateException extends Exception { + public UncommittedStateException(String message) { + super(message); + } +} From 9a2a9955262a3457268d2e1cd11b670bf40e5788 Mon Sep 17 00:00:00 2001 From: Maanvi212006 Date: Tue, 21 Apr 2026 20:37:14 +0100 Subject: [PATCH 12/22] docs: fix typos in README.md Closes #95 Signed-off-by: Maanvi Chetwani --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 84596f37..f6b68a8e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ If Fabric allows you to keep your familiar programming language, then Hypernate No more low-level boilerplate code for key-value storage operations and other housekeeping tasks! Take advantage of Hypernate’s _high abstraction level,_ _aspect-oriented_ approaches and _extensibility_ to keep your critical business logic as clean as possible! -Enhance your chaincode with feaures, like: +Enhance your chaincode with features, like: * Object-oriented CRUD (create, read, update, delete) operations with explicit semantics * Declarative and flexible configuration of your entity keys * An extensible chain of middleware processors handling non-business stuff (caching, logging, tracing, etc.) @@ -98,10 +98,10 @@ public record Asset( ``` Currently, the following mapper classes are available in the `hu.bme.mit.ftsrg.hypernate.mappers` package (but feel free to implement and use your own): -* `IntegerZeroPadder`: Pads numbers with `"0"`s to the lenght of `"2147483647"`, the maximum integer value. +* `IntegerZeroPadder`: Pads numbers with `"0"`s to the length of `"2147483647"`, the maximum integer value. * `IntegerFlipperAndZeroPadder`: flips the range of positive integers before padding them to their max length. Useful for constructing descending string key orders from originally ascending integer keys (in case you want to enumerate them in reverse order). -* `LongZeroPadder`: Pads numbers with `"0"`s to the lenght of `"9223372036854775807"`, the maximum long number value. +* `LongZeroPadder`: Pads numbers with `"0"`s to the length of `"9223372036854775807"`, the maximum long number value. * `LongFlipperAndZeroPadder`: flips the range of positive long numbers before padding them to their max length. Useful for constructing descending string key orders from originally ascending long number keys (in case you want to enumerate them in reverse order). * `ObjectToString`: simply call `toString` on the attribute value (the default behavior) @@ -158,7 +158,7 @@ public boolean AssetExists(final HypernateContext ctx, final String assetID) { ``` On the other hand, the following code snippet shows how to express that the operation _must_ be performed on this entity successfully. -By using the _must_ semantics, an exception will be thrown if the asset does not exists. +By using the _must_ semantics, an exception will be thrown if the asset does not exist. No more cluttering `if-else` or `try-catch` blocks for every ledger access operation! ```java @@ -197,7 +197,7 @@ The above declaration will result in two ChaincodeStub-like components intercept So it is possible that the original stub won’t even get the call, it is served from the local cache. > [!IMPORTANT] -> Hypernate context and middleware instances are specific to your individual TX executions/endoresements! +> Hypernate context and middleware instances are specific to your individual TX executions/endorsements! > Hypernate does not introduce dependencies between TXs, following the traditional (and important!) Fabric chaincode development practice. From d31f06dce079cc3fb5f9bf1db8b41095bee7e112 Mon Sep 17 00:00:00 2001 From: Sreejesh Date: Wed, 6 May 2026 12:00:00 +0530 Subject: [PATCH 13/22] feat: Add endorsement policy handling Signed-off-by: Sreejesh --- .../annotations/EndorsementPolicy.java | 13 ++ .../endorsement/EndorsementPolicy.java | 127 ++++++++++++++++++ .../endorsement/EndorsementPolicyBuilder.java | 85 ++++++++++++ .../InvalidEndorsementPolicyException.java | 8 ++ 4 files changed, 233 insertions(+) create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EndorsementPolicy.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicy.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicyBuilder.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/InvalidEndorsementPolicyException.java diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EndorsementPolicy.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EndorsementPolicy.java new file mode 100644 index 00000000..02d5e76a --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EndorsementPolicy.java @@ -0,0 +1,13 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface EndorsementPolicy { + String value(); +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicy.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicy.java new file mode 100644 index 00000000..c8c2be25 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicy.java @@ -0,0 +1,127 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.endorsement; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Stack; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.Getter; + +public class EndorsementPolicy { + @Getter private final String expression; + + private EndorsementPolicy(String expression) { + this.expression = expression; + } + + public static EndorsementPolicy of(String expression) { + validate(expression); + return new EndorsementPolicy(expression); + } + + public static EndorsementPolicyBuilder builder() { + return new EndorsementPolicyBuilder(); + } + + /** + * NOTE: A production implementation would compile this expression to a SignaturePolicyEnvelope + * protobuf byte array using Fabric's msp/policydsl package. This prototype uses UTF-8-encoded + * expression strings for simplicity, which is not compatible with stub.setStateValidationParameter() + * in a real network. + */ + public byte[] getPolicyBytes() { + return expression.getBytes(StandardCharsets.UTF_8); + } + + private static void validate(String expr) { + if (expr == null || expr.isBlank()) { + throw new InvalidEndorsementPolicyException("Expression cannot be blank"); + } + + Stack parenStack = new Stack<>(); + char[] chars = expr.toCharArray(); + for (int i = 0; i < chars.length; i++) { + if (chars[i] == '(') parenStack.push(i); + else if (chars[i] == ')') { + if (parenStack.isEmpty()) { + throw new InvalidEndorsementPolicyException("Unexpected closing parenthesis at position " + i); + } + parenStack.pop(); + } + } + if (!parenStack.isEmpty()) { + throw new InvalidEndorsementPolicyException( + "Unclosed parenthesis: " + parenStack.size() + " opening parenthesis(es) never closed"); + } + + Pattern funcPattern = Pattern.compile("([A-Za-z]+)\\("); + Matcher funcMatcher = funcPattern.matcher(expr); + while (funcMatcher.find()) { + String func = funcMatcher.group(1); + if (!func.equals("AND") && !func.equals("OR") && !func.equals("OutOf")) { + throw new InvalidEndorsementPolicyException( + "Unsupported policy function '" + func + "'. Only AND, OR, OutOf are allowed."); + } + if (func.equals("OutOf")) { + int contentStart = funcMatcher.end(); + int commaIndex = expr.indexOf(',', contentStart); + int closeParen = expr.indexOf(')', contentStart); + int firstArgEnd = (commaIndex != -1 && commaIndex < closeParen) ? commaIndex : closeParen; + String firstArg = expr.substring(contentStart, firstArgEnd).trim(); + if (!firstArg.matches("\\d+")) { + throw new InvalidEndorsementPolicyException( + "OutOf() requires an integer as its first argument, found '" + firstArg + "'"); + } + } + } + + Pattern mspPattern = Pattern.compile("'([^']+)'"); + Matcher mspMatcher = mspPattern.matcher(expr); + List foundMsps = new ArrayList<>(); + while (mspMatcher.find()) { + String msp = mspMatcher.group(1); + foundMsps.add(msp); + if (!msp.matches("^[A-Za-z0-9]+MSP\\.(peer|member|admin|client)$")) { + throw new InvalidEndorsementPolicyException( + "Invalid MSP principal '" + msp + "'. Expected format: OrgNameMSP.(peer|member|admin|client)"); + } + } + + if (foundMsps.isEmpty()) { + throw new InvalidEndorsementPolicyException("Expression contains no valid principals"); + } + + // Validate OutOf threshold semantics + if (expr.contains("OutOf(")) { + Matcher outOfMatcher = Pattern.compile("OutOf\\(\\s*(\\d+)\\s*,").matcher(expr); + if (outOfMatcher.find()) { + int threshold = Integer.parseInt(outOfMatcher.group(1)); + // Find closing paren matching this OutOf( + int openIdx = outOfMatcher.end() - 1; // position of comma + int stack = 1; + int closeIdx = -1; + for(int i = outOfMatcher.start() + 6; i < expr.length(); i++) { + if (expr.charAt(i) == '(') stack++; + if (expr.charAt(i) == ')') { + stack--; + if (stack == 0) { + closeIdx = i; + break; + } + } + } + if (closeIdx != -1) { + String outOfBody = expr.substring(outOfMatcher.end(), closeIdx); + Matcher innerMspMatcher = mspPattern.matcher(outOfBody); + int count = 0; + while (innerMspMatcher.find()) count++; + if (threshold > count) { + throw new InvalidEndorsementPolicyException("OutOf threshold " + threshold + " exceeds principal count " + count); + } + } + } + } + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicyBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicyBuilder.java new file mode 100644 index 00000000..9ba0650f --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicyBuilder.java @@ -0,0 +1,85 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.endorsement; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class EndorsementPolicyBuilder { + private String combinator; + private Integer threshold; + private final List clauses = new ArrayList<>(); + + public EndorsementPolicyBuilder or() { + this.combinator = "OR"; + return this; + } + + public EndorsementPolicyBuilder and() { + this.combinator = "AND"; + return this; + } + + public EndorsementPolicyBuilder outOf(int n) { + this.combinator = "OutOf"; + this.threshold = n; + return this; + } + + public OrgBuilder org(String orgName) { + return new OrgBuilder(orgName); + } + + public EndorsementPolicy build() { + if (clauses.isEmpty()) { + throw new IllegalStateException("No principals added"); + } + if (combinator == null) { + throw new IllegalStateException("Combinator (and/or/outOf) must be set"); + } + + String joinedClauses = clauses.stream() + .map(c -> "'" + c + "'") + .collect(Collectors.joining(", ")); + + String expression; + if ("OutOf".equals(combinator)) { + if (threshold > clauses.size()) { + throw new IllegalStateException("OutOf threshold " + threshold + " exceeds number of principals " + clauses.size()); + } + expression = String.format("OutOf(%d, %s)", threshold, joinedClauses); + } else { + expression = String.format("%s(%s)", combinator, joinedClauses); + } + + return EndorsementPolicy.of(expression); + } + + public class OrgBuilder { + private final String orgName; + + private OrgBuilder(String orgName) { + this.orgName = orgName; + } + + public EndorsementPolicyBuilder peer() { + clauses.add(orgName + "MSP.peer"); + return EndorsementPolicyBuilder.this; + } + + public EndorsementPolicyBuilder member() { + clauses.add(orgName + "MSP.member"); + return EndorsementPolicyBuilder.this; + } + + public EndorsementPolicyBuilder admin() { + clauses.add(orgName + "MSP.admin"); + return EndorsementPolicyBuilder.this; + } + + public EndorsementPolicyBuilder client() { + clauses.add(orgName + "MSP.client"); + return EndorsementPolicyBuilder.this; + } + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/InvalidEndorsementPolicyException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/InvalidEndorsementPolicyException.java new file mode 100644 index 00000000..a07a3b94 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/InvalidEndorsementPolicyException.java @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.endorsement; + +public class InvalidEndorsementPolicyException extends RuntimeException { + public InvalidEndorsementPolicyException(String message) { + super(message); + } +} From e9dcdbd10b4ab17750ebd778a44c4e01f3914ba6 Mon Sep 17 00:00:00 2001 From: Sreejesh Date: Thu, 7 May 2026 12:00:00 +0530 Subject: [PATCH 14/22] test: Add prototype tests and sample chaincode Signed-off-by: Sreejesh --- .../bme/mit/ftsrg/hypernate/sample/Asset.java | 57 ++ .../ftsrg/hypernate/sample/AssetStatus.java | 6 + .../hypernate/sample/PricingRequest.java | 28 + .../hypernate/sample/PricingResponse.java | 24 + .../hypernate/sample/SampleChaincode.java | 192 ++++++ .../hypernate/sample/SensitiveAsset.java | 67 +++ .../hypernate/sample/TransferRequest.java | 28 + .../hypernate/sample/TransferResult.java | 24 + .../mit/ftsrg/hypernate/PrototypeTests.java | 555 ++++++++++++++++++ 9 files changed, 981 insertions(+) create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/Asset.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/AssetStatus.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingRequest.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingResponse.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SampleChaincode.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SensitiveAsset.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferRequest.java create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferResult.java create mode 100644 lib/src/test/java/hu/bme/mit/ftsrg/hypernate/PrototypeTests.java diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/Asset.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/Asset.java new file mode 100644 index 00000000..a9b27dea --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/Asset.java @@ -0,0 +1,57 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +import hu.bme.mit.ftsrg.hypernate.annotations.AttributeInfo; +import hu.bme.mit.ftsrg.hypernate.annotations.PrimaryKey; + +@PrimaryKey({ + @AttributeInfo(name = "owner"), + @AttributeInfo(name = "id") +}) +public class Asset { + private String id; + private String color; + private int size; + private String owner; + private int value; + + private long timestamp; + private AssetStatus status; + + // Constructors + public Asset() {} + + public Asset(String id, String color, int size, String owner, int value) { + this.id = id; + this.color = color; + this.size = size; + this.owner = owner; + this.value = value; + } + + public Asset(String id, String color, int size, String owner, int value, long timestamp, AssetStatus status) { + this.id = id; + this.color = color; + this.size = size; + this.owner = owner; + this.value = value; + this.timestamp = timestamp; + this.status = status; + } + + // Getters and Setters + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getColor() { return color; } + public void setColor(String color) { this.color = color; } + public int getSize() { return size; } + public void setSize(int size) { this.size = size; } + public String getOwner() { return owner; } + public void setOwner(String owner) { this.owner = owner; } + public int getValue() { return value; } + public void setValue(int value) { this.value = value; } + public long getTimestamp() { return timestamp; } + public void setTimestamp(long timestamp) { this.timestamp = timestamp; } + public AssetStatus getStatus() { return status; } + public void setStatus(AssetStatus status) { this.status = status; } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/AssetStatus.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/AssetStatus.java new file mode 100644 index 00000000..78d9fe0f --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/AssetStatus.java @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +public enum AssetStatus { + ACTIVE, PENDING_TRANSFER, ARCHIVED +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingRequest.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingRequest.java new file mode 100644 index 00000000..6a3a6ece --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingRequest.java @@ -0,0 +1,28 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +public class PricingRequest { + private String assetId; + private int currentValue; + private String newOwner; + private int offeredPrice; + + public PricingRequest() {} + + public PricingRequest(String assetId, int currentValue, String newOwner, int offeredPrice) { + this.assetId = assetId; + this.currentValue = currentValue; + this.newOwner = newOwner; + this.offeredPrice = offeredPrice; + } + + public String assetId() { return assetId; } + public int currentValue() { return currentValue; } + public String newOwner() { return newOwner; } + public int offeredPrice() { return offeredPrice; } + + public String getAssetId() { return assetId; } + public int getCurrentValue() { return currentValue; } + public String getNewOwner() { return newOwner; } + public int getOfferedPrice() { return offeredPrice; } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingResponse.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingResponse.java new file mode 100644 index 00000000..0daae5bd --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingResponse.java @@ -0,0 +1,24 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +public class PricingResponse { + private boolean valid; + private int approvedPrice; + private String notes; + + public PricingResponse() {} + + public PricingResponse(boolean valid, int approvedPrice, String notes) { + this.valid = valid; + this.approvedPrice = approvedPrice; + this.notes = notes; + } + + public boolean valid() { return valid; } + public int approvedPrice() { return approvedPrice; } + public String notes() { return notes; } + + public boolean isValid() { return valid; } + public int getApprovedPrice() { return approvedPrice; } + public String getNotes() { return notes; } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SampleChaincode.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SampleChaincode.java new file mode 100644 index 00000000..6bcd0874 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SampleChaincode.java @@ -0,0 +1,192 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +import hu.bme.mit.ftsrg.hypernate.context.HypernateContext; +import hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy; +import hu.bme.mit.ftsrg.hypernate.registry.Registry; +import hu.bme.mit.ftsrg.hypernate.registry.SortOrder; +import hu.bme.mit.ftsrg.hypernate.registry.query.UncommittedStateException; +import hu.bme.mit.ftsrg.hypernate.util.JSON; +import java.time.Instant; +import java.util.List; +import org.hyperledger.fabric.contract.Context; +import org.hyperledger.fabric.contract.ContractInterface; +import org.hyperledger.fabric.contract.annotation.Contract; +import org.hyperledger.fabric.contract.annotation.Default; +import org.hyperledger.fabric.contract.annotation.Info; +import org.hyperledger.fabric.contract.annotation.Transaction; + +@Contract( + name = "SampleChaincode", + info = @Info(title = "SampleChaincode", description = "A sample Hypernate chaincode", version = "1.0")) +@Default +public class SampleChaincode implements ContractInterface { + + @Transaction + public void createAsset(HypernateContext ctx, String id, String color, int size, String owner, int value) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("Field 'id' must not be blank"); + if (color == null || color.isBlank()) throw new IllegalArgumentException("Field 'color' must not be blank"); + if (owner == null || owner.isBlank()) throw new IllegalArgumentException("Field 'owner' must not be blank"); + if (size <= 0) throw new IllegalArgumentException("Field 'size' must be positive"); + if (value <= 0) throw new IllegalArgumentException("Field 'value' must be positive"); + + long timestampMs = ctx.getStub().getTxTimestamp().toEpochMilli(); + Asset asset = new Asset(id, color, size, owner, value, timestampMs, AssetStatus.ACTIVE); + ctx.getRegistry().mustCreate(asset); + } + + @Transaction + public void createSensitiveAsset(HypernateContext ctx, String id, String color, int size, String owner, int value, String classLevel, String orgsJson) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("Field 'id' must not be blank"); + if (color == null || color.isBlank()) throw new IllegalArgumentException("Field 'color' must not be blank"); + if (owner == null || owner.isBlank()) throw new IllegalArgumentException("Field 'owner' must not be blank"); + if (size <= 0) throw new IllegalArgumentException("Field 'size' must be positive"); + if (value <= 0) throw new IllegalArgumentException("Field 'value' must be positive"); + + List orgs; + try { + @SuppressWarnings("unchecked") + List parsed = JSON.deserialize(orgsJson, List.class); + orgs = parsed; + } catch (Exception e) { + throw new IllegalArgumentException("Failed to deserialize orgsJson: " + e.getMessage(), e); + } + + long timestampMs = ctx.getStub().getTxTimestamp().toEpochMilli(); + + SensitiveAsset sensitiveAsset = new SensitiveAsset(id, color, size, owner, value, timestampMs, AssetStatus.ACTIVE, classLevel, orgs); + ctx.getRegistry().mustCreate(sensitiveAsset); + } + + @Transaction + public Asset getAsset(HypernateContext ctx, String owner, String id) { + return ctx.getRegistry().mustRead(Asset.class, owner, id); + } + + @Transaction + public List queryAssetsByColorAndOwner(HypernateContext ctx, String color, String owner1, String owner2) { + try { + return ctx.getRegistry().query(Asset.class) + .where("color").is(color) + .and("owner").in(owner1, owner2) + .and("status").is(AssetStatus.ACTIVE.name()) + .sortBy("value", SortOrder.DESC) + .limit(100) + .execute(); + } catch (UncommittedStateException e) { + throw new RuntimeException(e); + } + } + + @Transaction + public List queryAssetsByValueRange(HypernateContext ctx, int minValue, int maxValue) { + if (minValue > maxValue) throw new IllegalArgumentException("minValue must be <= maxValue"); + try { + return ctx.getRegistry().query(Asset.class) + .where("value").between(minValue, maxValue) + .and("status").isNot(AssetStatus.ARCHIVED.name()) + .sortBy("value", SortOrder.ASC) + .execute(); + } catch (UncommittedStateException e) { + throw new RuntimeException(e); + } + } + + @Transaction + public List listAssetsByOwner(HypernateContext ctx, String owner) { + return ctx.getRegistry().rangeQuery(Asset.class).withKeyPrefix(owner).execute(); + } + + @Transaction + public List listAllAssets(HypernateContext ctx) { + return ctx.getRegistry().rangeQuery(Asset.class).fullScan().execute(); + } + + @Transaction + public Asset initiateTransfer(HypernateContext ctx, String assetId, String currentOwner, String newOwner, int agreedPrice) { + Registry registry = ctx.getRegistry(); + Asset asset = registry.mustRead(Asset.class, currentOwner, assetId); + + if (asset.getStatus() != AssetStatus.ACTIVE) { + throw new RuntimeException(String.format("Asset %s is not in ACTIVE status; current status: %s. Only ACTIVE assets can be transferred.", assetId, asset.getStatus())); + } + if (!asset.getOwner().equals(currentOwner)) { + throw new RuntimeException(String.format("Owner mismatch: asset %s is owned by %s, not %s", assetId, asset.getOwner(), currentOwner)); + } + + PricingResponse pricing = ctx.invoke("PricingChaincode", "validatePrice") + .withArgs(new PricingRequest(assetId, asset.getValue(), newOwner, agreedPrice)) + .returning(PricingResponse.class) + .execute(); + + if (!pricing.valid()) { + throw new RuntimeException("Transfer rejected by PricingChaincode: " + pricing.notes()); + } + + asset.setStatus(AssetStatus.PENDING_TRANSFER); + registry.mustUpdate(asset); + return asset; + } + + @Transaction + public Asset completeTransfer(HypernateContext ctx, String assetId, String currentOwner, String newOwner) { + Registry registry = ctx.getRegistry(); + Asset asset = registry.mustRead(Asset.class, currentOwner, assetId); + + if (asset.getStatus() != AssetStatus.PENDING_TRANSFER) { + throw new RuntimeException(String.format("Asset %s is not pending transfer; status: %s", assetId, asset.getStatus())); + } + + asset.setOwner(newOwner); + asset.setStatus(AssetStatus.ACTIVE); + asset.setTimestamp(ctx.getStub().getTxTimestamp().toEpochMilli()); + registry.mustUpdate(asset); + return asset; + } + + @Transaction + public void archiveAsset(HypernateContext ctx, String owner, String id) { + Asset asset = ctx.getRegistry().mustRead(Asset.class, owner, id); + if (asset.getStatus() == AssetStatus.ARCHIVED) { + throw new RuntimeException("Asset " + id + " is already archived"); + } + asset.setStatus(AssetStatus.ARCHIVED); + ctx.getRegistry().mustUpdate(asset); + } + + @Transaction + public void setSensitiveAssetPolicy(HypernateContext ctx, String id, String owner, String expression) { + SensitiveAsset asset = ctx.getRegistry().mustRead(SensitiveAsset.class, owner, id); + EndorsementPolicy policy = EndorsementPolicy.of(expression); + ctx.getRegistry().setEndorsementPolicy(asset, policy); + } +} + +@Contract( + name = "PricingChaincode", + info = @Info(title = "PricingChaincode", description = "Mock pricing chaincode", version = "1.0")) +class PricingChaincode implements ContractInterface { + + @Transaction + public String validatePrice(Context ctx, String pricingRequestJson) { + PricingRequest req; + try { + req = JSON.deserialize(pricingRequestJson, PricingRequest.class); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid JSON for PricingRequest: " + e.getMessage(), e); + } + + if (req.currentValue() > 1000) { + boolean approved = req.offeredPrice() >= req.currentValue() * 0.9; + if (!approved) { + try { + return JSON.serialize(new PricingResponse(false, 0, + String.format("Offered price %d is below 90%% of market value %d", req.offeredPrice(), req.currentValue()))); + } catch (Exception e) { throw new RuntimeException(e); } + } + } + try { + return JSON.serialize(new PricingResponse(true, req.offeredPrice(), "Transfer approved at price " + req.offeredPrice())); + } catch (Exception e) { throw new RuntimeException(e); } + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SensitiveAsset.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SensitiveAsset.java new file mode 100644 index 00000000..1685527b --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SensitiveAsset.java @@ -0,0 +1,67 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +import hu.bme.mit.ftsrg.hypernate.annotations.AttributeInfo; +import hu.bme.mit.ftsrg.hypernate.annotations.EndorsementPolicy; +import hu.bme.mit.ftsrg.hypernate.annotations.PrimaryKey; + +@PrimaryKey({ + @AttributeInfo(name = "owner"), + @AttributeInfo(name = "id") +}) +@EndorsementPolicy("OR('Org1MSP.peer', 'Org2MSP.peer')") +public class SensitiveAsset { + private String id; + private String color; + private int size; + private String owner; + private int value; + + private long timestamp; + private AssetStatus status; + private String classLevel; + private java.util.List orgs; + + // Constructors + public SensitiveAsset() {} + + public SensitiveAsset(String id, String color, int size, String owner, int value) { + this.id = id; + this.color = color; + this.size = size; + this.owner = owner; + this.value = value; + } + + public SensitiveAsset(String id, String color, int size, String owner, int value, long timestamp, AssetStatus status, String classLevel, java.util.List orgs) { + this.id = id; + this.color = color; + this.size = size; + this.owner = owner; + this.value = value; + this.timestamp = timestamp; + this.status = status; + this.classLevel = classLevel; + this.orgs = orgs; + } + + // Getters and Setters + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getColor() { return color; } + public void setColor(String color) { this.color = color; } + public int getSize() { return size; } + public void setSize(int size) { this.size = size; } + public String getOwner() { return owner; } + public void setOwner(String owner) { this.owner = owner; } + public int getValue() { return value; } + public void setValue(int value) { this.value = value; } + public long getTimestamp() { return timestamp; } + public void setTimestamp(long timestamp) { this.timestamp = timestamp; } + public AssetStatus getStatus() { return status; } + public void setStatus(AssetStatus status) { this.status = status; } + public String getClassLevel() { return classLevel; } + public void setClassLevel(String classLevel) { this.classLevel = classLevel; } + public java.util.List getOrgs() { return orgs; } + public void setOrgs(java.util.List orgs) { this.orgs = orgs; } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferRequest.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferRequest.java new file mode 100644 index 00000000..c8a6a98e --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferRequest.java @@ -0,0 +1,28 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +public class TransferRequest { + private String assetId; + private String currentOwner; + private String newOwner; + private int agreedPrice; + + public TransferRequest() {} + + public TransferRequest(String assetId, String currentOwner, String newOwner, int agreedPrice) { + this.assetId = assetId; + this.currentOwner = currentOwner; + this.newOwner = newOwner; + this.agreedPrice = agreedPrice; + } + + public String assetId() { return assetId; } + public String currentOwner() { return currentOwner; } + public String newOwner() { return newOwner; } + public int agreedPrice() { return agreedPrice; } + + public String getAssetId() { return assetId; } + public String getCurrentOwner() { return currentOwner; } + public String getNewOwner() { return newOwner; } + public int getAgreedPrice() { return agreedPrice; } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferResult.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferResult.java new file mode 100644 index 00000000..6c2c1777 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferResult.java @@ -0,0 +1,24 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +public class TransferResult { + private boolean approved; + private String reason; + private int finalPrice; + + public TransferResult() {} + + public TransferResult(boolean approved, String reason, int finalPrice) { + this.approved = approved; + this.reason = reason; + this.finalPrice = finalPrice; + } + + public boolean approved() { return approved; } + public String reason() { return reason; } + public int finalPrice() { return finalPrice; } + + public boolean isApproved() { return approved; } + public String getReason() { return reason; } + public int getFinalPrice() { return finalPrice; } +} diff --git a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/PrototypeTests.java b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/PrototypeTests.java new file mode 100644 index 00000000..034c7950 --- /dev/null +++ b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/PrototypeTests.java @@ -0,0 +1,555 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import hu.bme.mit.ftsrg.hypernate.context.CrossChaincodeException; +import hu.bme.mit.ftsrg.hypernate.context.HypernateContext; +import hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy; +import hu.bme.mit.ftsrg.hypernate.endorsement.InvalidEndorsementPolicyException; +import hu.bme.mit.ftsrg.hypernate.middleware.StubMiddlewareChain; +import hu.bme.mit.ftsrg.hypernate.middleware.WriteBackCachedStubMiddleware; +import hu.bme.mit.ftsrg.hypernate.registry.Registry; +import hu.bme.mit.ftsrg.hypernate.registry.EntityNotFoundException; +import hu.bme.mit.ftsrg.hypernate.registry.SortOrder; +import hu.bme.mit.ftsrg.hypernate.registry.query.InvalidRangeQueryException; +import hu.bme.mit.ftsrg.hypernate.registry.query.UncommittedStateException; +import hu.bme.mit.ftsrg.hypernate.sample.Asset; +import hu.bme.mit.ftsrg.hypernate.sample.AssetStatus; +import hu.bme.mit.ftsrg.hypernate.sample.PricingResponse; +import hu.bme.mit.ftsrg.hypernate.sample.SampleChaincode; +import hu.bme.mit.ftsrg.hypernate.sample.SensitiveAsset; +import hu.bme.mit.ftsrg.hypernate.sample.TransferResult; +import hu.bme.mit.ftsrg.hypernate.util.JSON; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import org.hyperledger.fabric.shim.Chaincode.Response; +import org.hyperledger.fabric.shim.ChaincodeStub; +import org.hyperledger.fabric.shim.ledger.CompositeKey; +import org.hyperledger.fabric.shim.ledger.KeyValue; +import org.hyperledger.fabric.shim.ledger.QueryResultsIterator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@org.mockito.junit.jupiter.MockitoSettings(strictness = org.mockito.quality.Strictness.LENIENT) +public class PrototypeTests { + + @Test + public void testQueryBuilderSelector_SingleCondition() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).where("color").is("blue").execute(); + + String json = queryCaptor.getValue(); + assertThat(json).contains("\"color\":\"blue\""); + assertThat(json).contains("\"docType\":\"HU.BME.MIT.FTSRG.HYPERNATE.SAMPLE.ASSET\""); + } + + @Test + public void testQueryBuilderSelector_MultiCondition() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).where("color").is("blue").and("size").greaterThan(10).execute(); + + String json = queryCaptor.getValue(); + assertThat(json).contains("\"color\":\"blue\""); + assertThat(json).contains("\"size\":{\"$gt\":10}"); + } + + @Test + public void testQueryBuilderSelector_Between() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).where("value").between(100, 500).execute(); + + String json = queryCaptor.getValue(); + assertThat(json).contains("\"$gte\":100"); + assertThat(json).contains("\"$lte\":500"); + } + + @Test + public void testQueryBuilderSelector_In() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).and("owner").in("Alice", "Bob").execute(); + assertThat(queryCaptor.getValue()).contains("\"$in\":[\"Alice\",\"Bob\"]"); + } + + @Test + public void testQueryBuilderSelector_SortBy() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).sortBy("value", SortOrder.DESC).sortBy("color", SortOrder.ASC).execute(); + assertThat(queryCaptor.getValue()).contains("\"sort\":[{\"value\":\"desc\"},{\"color\":\"asc\"}]"); + } + + @Test + public void testQueryBuilderSelector_UseIndex() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).useIndex("designDocName", "indexName").execute(); + assertThat(queryCaptor.getValue()).contains("\"use_index\":[\"designDocName\",\"indexName\"]"); + } + + @Test + public void testQueryBuilderSelector_LimitSkip() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).limit(50).skip(10).execute(); + String json = queryCaptor.getValue(); + assertThat(json).contains("\"limit\":50"); + assertThat(json).contains("\"skip\":10"); + } + + @Test + public void testQueryBuilderSelector_DocTypeAutoFilter() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).execute(); + assertThat(queryCaptor.getValue()).contains("\"docType\":\"HU.BME.MIT.FTSRG.HYPERNATE.SAMPLE.ASSET\""); + } + + @Test + public void testUncommittedStateException_ThrowsWhenDirty() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + StubMiddlewareChain chain = StubMiddlewareChain.builder(fabricStub) + .push(WriteBackCachedStubMiddleware.class).build(); + WriteBackCachedStubMiddleware mw = (WriteBackCachedStubMiddleware) chain.getFirst(); + mw.putState("k", "v".getBytes()); + + Registry registry = new Registry(chain.getFirst()); + assertThatThrownBy(() -> registry.query(Asset.class).where("color").is("x").execute()) + .isInstanceOf(UncommittedStateException.class) + .hasMessageContaining("uncommitted writes exist in the middleware cache") + .hasMessageContaining("invisible to getQueryResult") + .hasMessageContaining("TransactionEnd"); + } + + @Test + public void testUncommittedStateException_CleanCache() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + StubMiddlewareChain chain = StubMiddlewareChain.builder(fabricStub) + .push(WriteBackCachedStubMiddleware.class).build(); + Registry registry = new Registry(chain.getFirst()); + + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(anyString())).thenReturn(iter); + + registry.query(Asset.class).where("color").is("x").execute(); + } + + @Test + public void testUncommittedStateException_NoMiddleware() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(anyString())).thenReturn(iter); + + registry.query(Asset.class).where("color").is("x").execute(); + } + + public static class NoPKEntity { public String id; } + + @Test + public void testRangeQueryGuard_NoPK() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + assertThatThrownBy(() -> registry.rangeQuery(NoPKEntity.class).withKeyPrefix("x")) + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("no @PrimaryKey annotation found"); + } + + @Test + public void testRangeQueryGuard_TooManyPrefixParts() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + assertThatThrownBy(() -> registry.rangeQuery(Asset.class).withKeyPrefix("a", "b", "c")) + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("Too many prefix parts"); + } + + @Test + public void testRangeQueryGuard_NullPrefix() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + assertThatThrownBy(() -> registry.rangeQuery(Asset.class).withKeyPrefix("Alice", null)) + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("Null prefix component at index 1"); + } + + @Test + public void testRangeQueryGuard_FullScan() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("test")); + Registry registry = new Registry(fabricStub); + + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getStateByPartialCompositeKey(any(CompositeKey.class))).thenReturn(iter); + + List result = registry.rangeQuery(Asset.class).fullScan().execute(); + assertThat(result).isEmpty(); + } + + @Test + public void testRangeQueryGuard_ExecuteWithoutConfig() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + assertThatThrownBy(() -> registry.rangeQuery(Asset.class).execute()) + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("Configure the query before executing"); + } + + @Test + public void testCrossChaincode_Success() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + Response resp = mock(Response.class); + when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); + when(resp.getStringPayload()).thenReturn(JSON.serialize(new TransferResult(true, "OK", 100))); + when(fabricStub.invokeChaincode(eq("cc"), org.mockito.ArgumentMatchers.>any())).thenReturn(resp); + + TransferResult res = ctx.invoke("cc", "fn").returning(TransferResult.class).execute(); + assertThat(res.approved()).isTrue(); + } + + @Test + public void testCrossChaincode_ErrorPropagation() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + Response resp = mock(Response.class); + when(resp.getStatus()).thenReturn(Response.Status.INTERNAL_SERVER_ERROR); + when(resp.getMessage()).thenReturn("chaincode panicked"); + when(fabricStub.invokeChaincode(eq("testCC"), org.mockito.ArgumentMatchers.>any())).thenReturn(resp); + + assertThatThrownBy(() -> ctx.invoke("testCC", "doWork").returningVoid().execute()) + .isInstanceOf(CrossChaincodeException.class) + .satisfies(e -> { + CrossChaincodeException ex = (CrossChaincodeException) e; + assertThat(ex.getChaincodeId()).isEqualTo("testCC"); + assertThat(ex.getFunctionName()).isEqualTo("doWork"); + assertThat(ex.getStatusCode()).isEqualTo(500); + assertThat(ex.getOriginalMessage()).isEqualTo("chaincode panicked"); + }); + } + + @Test + public void testCrossChaincode_ReturningVoid() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + Response resp = mock(Response.class); + when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); + when(fabricStub.invokeChaincode(eq("cc"), org.mockito.ArgumentMatchers.>any())).thenReturn(resp); + + Void res = ctx.invoke("cc", "fn").returningVoid().execute(); + assertThat(res).isNull(); + verify(resp, never()).getStringPayload(); + } + + @Test + public void testCrossChaincode_NoReturnConfig() { + HypernateContext ctx = mock(HypernateContext.class); + ChaincodeStub dummyStub = mock(ChaincodeStub.class); + when(ctx.getStub()).thenReturn(dummyStub); + when(ctx.getFabricStub()).thenReturn(dummyStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + assertThatThrownBy(() -> ctx.invoke("cc", "fn").execute()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("returning(Class) or .returningVoid()"); + } + + @Test + public void testCrossChaincode_MixedArgs() { + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + assertThatThrownBy(() -> ctx.invoke("cc", "fn").withArgs("x").withRawArgs("y".getBytes()).returningVoid().execute()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + public void testEndorsementPolicyValidation_Valid() { + EndorsementPolicy.of("OR('Org1MSP.peer', 'Org2MSP.peer')"); + EndorsementPolicy.of("AND('Org1MSP.member', 'Org2MSP.member')"); + EndorsementPolicy.of("OutOf(2, 'Org1MSP.peer', 'Org2MSP.peer', 'Org3MSP.peer')"); + } + + @Test + public void testEndorsementPolicyValidation_Unbalanced1() { + assertThatThrownBy(() -> EndorsementPolicy.of("OR('Org1MSP.peer'")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Unclosed parenthesis"); + } + + @Test + public void testEndorsementPolicyValidation_Unbalanced2() { + assertThatThrownBy(() -> EndorsementPolicy.of(")OR('Org1MSP.peer')")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Unexpected closing parenthesis"); + } + + @Test + public void testEndorsementPolicyValidation_UnsupportedFunction() { + assertThatThrownBy(() -> EndorsementPolicy.of("XOR('Org1MSP.peer')")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Unsupported policy function 'XOR'"); + } + + @Test + public void testEndorsementPolicyValidation_InvalidMSPFormat() { + assertThatThrownBy(() -> EndorsementPolicy.of("OR('Org1.peer')")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Invalid MSP principal"); + } + + @Test + public void testEndorsementPolicyValidation_InvalidRole() { + assertThatThrownBy(() -> EndorsementPolicy.of("OR('Org1MSP.validator')")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Invalid MSP principal"); + } + + @Test + public void testEndorsementPolicyValidation_Empty() { + assertThatThrownBy(() -> EndorsementPolicy.of("")) + .isInstanceOf(InvalidEndorsementPolicyException.class); + } + + @Test + public void testEndorsementPolicyValidation_OutOfArg() { + assertThatThrownBy(() -> EndorsementPolicy.of("OutOf(two, 'Org1MSP.peer')")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("integer as its first argument"); + } + + @Test + public void testEndorsementPolicyBuilder_AND() { + EndorsementPolicy built = EndorsementPolicy.builder().and().org("Org1").peer().org("Org2").peer().build(); + assertThat(built.getExpression()).isEqualTo("AND('Org1MSP.peer', 'Org2MSP.peer')"); + } + + @Test + public void testEndorsementPolicyBuilder_OutOf() { + EndorsementPolicy built = EndorsementPolicy.builder().outOf(2).org("Org1").peer().org("Org2").peer().org("Org3").peer().build(); + assertThat(built.getExpression()).isEqualTo("OutOf(2, 'Org1MSP.peer', 'Org2MSP.peer', 'Org3MSP.peer')"); + } + + @Test + public void testEndorsementPolicyBuilder_EqualsDirect() { + String expr = "OR('Org1MSP.peer', 'Org2MSP.peer')"; + EndorsementPolicy direct = EndorsementPolicy.of(expr); + EndorsementPolicy built = EndorsementPolicy.builder().or().org("Org1").peer().org("Org2").peer().build(); + assertThat(direct.getExpression()).isEqualTo(built.getExpression()); + } + + @Test + public void testEndorsementPolicyBuilder_Empty() { + assertThatThrownBy(() -> EndorsementPolicy.builder().build()).isInstanceOf(IllegalStateException.class); + } + + @Test + public void testEndorsementPolicyBuilder_ExceedsThreshold() { + assertThatThrownBy(() -> EndorsementPolicy.builder().outOf(3).org("Org1").peer().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("exceeds number of principals"); + } + + @Test + public void testRegistryEndorsement_mustCreateSensitive() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + when(fabricStub.getState(anyString())).thenReturn(null); + Registry registry = new Registry(fabricStub); + + SensitiveAsset sensitiveAsset = new SensitiveAsset("id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE, "A", Collections.emptyList()); + registry.mustCreate(sensitiveAsset); + + verify(fabricStub).setStateValidationParameter(anyString(), any(byte[].class)); + } + + @Test + public void testRegistryEndorsement_mustCreateNormal() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + when(fabricStub.getState(anyString())).thenReturn(null); + Registry registry = new Registry(fabricStub); + + Asset asset = new Asset("id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE); + registry.mustCreate(asset); + + verify(fabricStub, never()).setStateValidationParameter(anyString(), any(byte[].class)); + } + + @Test + public void testRegistryEndorsement_setEndorsementPolicyNotFound() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + when(fabricStub.getState(anyString())).thenReturn(new byte[0]); + Registry registry = new Registry(fabricStub); + + Asset asset = new Asset("id", "red", 1, "owner", 10); + EndorsementPolicy pol = EndorsementPolicy.of("OR('Org1MSP.peer')"); + + assertThatThrownBy(() -> registry.setEndorsementPolicy(asset, pol)) + .isInstanceOf(EntityNotFoundException.class) + .hasMessageContaining("Cannot set endorsement policy: entity does not exist"); + } + + @Test + public void testSampleChaincode_createAssetBlankId() { + SampleChaincode sc = new SampleChaincode(); + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + org.hyperledger.fabric.shim.ext.sbe.StateBasedEndorsement sbe = mock(org.hyperledger.fabric.shim.ext.sbe.StateBasedEndorsement.class); + when(fabricStub.getTxTimestamp()).thenReturn(java.time.Instant.now()); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); + assertThatThrownBy(() -> sc.createAsset(ctx, "", "red", 10, "Alice", 100)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("id"); + } + + @Test + public void testSampleChaincode_createAssetNegativeSize() { + SampleChaincode sc = new SampleChaincode(); + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.getTxTimestamp()).thenReturn(java.time.Instant.now()); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); + assertThatThrownBy(() -> sc.createAsset(ctx, "a1", "red", -1, "Alice", 100)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("size"); + } + + @Test + public void testSampleChaincode_initiateTransferArchived() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + Asset archived = new Asset("id", "red", 1, "owner", 10, 0, AssetStatus.ARCHIVED); + when(fabricStub.getState(anyString())).thenReturn(JSON.serialize(archived).getBytes()); + + SampleChaincode sc = new SampleChaincode(); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + assertThatThrownBy(() -> sc.initiateTransfer(ctx, "id", "owner", "new", 10)) + .isInstanceOf(RuntimeException.class).hasMessageContaining("not in ACTIVE status"); + } + + @Test + public void testSampleChaincode_initiateTransferRejected() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + Asset active = new Asset("id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE); + when(fabricStub.getState(anyString())).thenReturn(JSON.serialize(active).getBytes()); + + Response resp = mock(Response.class); + when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); + when(resp.getStringPayload()).thenReturn(JSON.serialize(new PricingResponse(false, 0, "Too low"))); + when(fabricStub.invokeChaincode(eq("PricingChaincode"), org.mockito.ArgumentMatchers.>any())).thenReturn(resp); + + SampleChaincode sc = new SampleChaincode(); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + assertThatThrownBy(() -> sc.initiateTransfer(ctx, "id", "owner", "new", 10)) + .isInstanceOf(RuntimeException.class).hasMessageContaining("Transfer rejected"); + } + + @Test + public void testSampleChaincode_initiateTransferSuccess() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + Asset active = new Asset("id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE); + when(fabricStub.getState(anyString())).thenReturn(JSON.serialize(active).getBytes()); + + Response resp = mock(Response.class); + when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); + when(resp.getStringPayload()).thenReturn(JSON.serialize(new PricingResponse(true, 50, "OK"))); + when(fabricStub.invokeChaincode(eq("PricingChaincode"), org.mockito.ArgumentMatchers.>any())).thenReturn(resp); + + SampleChaincode sc = new SampleChaincode(); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + Asset result = sc.initiateTransfer(ctx, "id", "owner", "new", 50); + assertThat(result.getStatus()).isEqualTo(AssetStatus.PENDING_TRANSFER); + verify(fabricStub).putState(anyString(), any(byte[].class)); // Must update + } +} From 29c719cc6b875fcd366f428b91e5bb1b7b469bc4 Mon Sep 17 00:00:00 2001 From: Sreejesh Date: Fri, 8 May 2026 12:00:00 +0530 Subject: [PATCH 15/22] chore: Update settings Signed-off-by: Sreejesh --- settings.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/settings.gradle.kts b/settings.gradle.kts index 4db73cc0..7379705f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} rootProject.name = "hypernate" include("lib") From 8b20383159e613905156293c9bf5b64bcb8bebac Mon Sep 17 00:00:00 2001 From: Sreejesh Date: Sat, 9 May 2026 12:00:00 +0530 Subject: [PATCH 16/22] chore: Apply spotless formatting and fix Registry endorsement validation Signed-off-by: Sreejesh --- ...rossChaincodeDeserializationException.java | 10 +- .../context/CrossChaincodeException.java | 9 +- .../hypernate/context/InvocationBuilder.java | 36 ++- .../endorsement/EndorsementPolicy.java | 62 ++--- .../endorsement/EndorsementPolicyBuilder.java | 12 +- .../ftsrg/hypernate/registry/PagedResult.java | 14 +- .../registry/QueryResultCollector.java | 3 - .../hypernate/registry/RangeQueryBuilder.java | 54 +++-- .../ftsrg/hypernate/registry/Registry.java | 25 +- .../hypernate/registry/RichQueryBuilder.java | 24 +- .../hypernate/registry/SelectorBuilder.java | 3 +- .../ftsrg/hypernate/registry/SortOrder.java | 3 +- .../bme/mit/ftsrg/hypernate/sample/Asset.java | 83 +++++-- .../ftsrg/hypernate/sample/AssetStatus.java | 4 +- .../hypernate/sample/PricingRequest.java | 40 +++- .../hypernate/sample/PricingResponse.java | 28 ++- .../hypernate/sample/SampleChaincode.java | 130 +++++++---- .../hypernate/sample/SensitiveAsset.java | 105 +++++++-- .../hypernate/sample/TransferRequest.java | 40 +++- .../hypernate/sample/TransferResult.java | 30 ++- .../mit/ftsrg/hypernate/PrototypeTests.java | 219 +++++++++++------- 21 files changed, 634 insertions(+), 300 deletions(-) diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeDeserializationException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeDeserializationException.java index ab0ad3c6..e1e7babd 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeDeserializationException.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeDeserializationException.java @@ -9,9 +9,13 @@ public class CrossChaincodeDeserializationException extends RuntimeException { private final String functionName; private final Class expectedType; - public CrossChaincodeDeserializationException(String chaincodeId, String functionName, Class expectedType, Throwable cause) { - super(String.format("Failed to deserialize response from %s#%s into %s: %s", - chaincodeId, functionName, expectedType.getSimpleName(), cause.getMessage()), cause); + public CrossChaincodeDeserializationException( + String chaincodeId, String functionName, Class expectedType, Throwable cause) { + super( + String.format( + "Failed to deserialize response from %s#%s into %s: %s", + chaincodeId, functionName, expectedType.getSimpleName(), cause.getMessage()), + cause); this.chaincodeId = chaincodeId; this.functionName = functionName; this.expectedType = expectedType; diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeException.java index f35e910c..8174735b 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeException.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeException.java @@ -10,9 +10,12 @@ public class CrossChaincodeException extends RuntimeException { private final int statusCode; private final String originalMessage; - public CrossChaincodeException(String chaincodeId, String functionName, int statusCode, String originalMessage) { - super(String.format("Cross-chaincode call to %s#%s failed with status %d: %s", - chaincodeId, functionName, statusCode, originalMessage)); + public CrossChaincodeException( + String chaincodeId, String functionName, int statusCode, String originalMessage) { + super( + String.format( + "Cross-chaincode call to %s#%s failed with status %d: %s", + chaincodeId, functionName, statusCode, originalMessage)); this.chaincodeId = chaincodeId; this.functionName = functionName; this.statusCode = statusCode; diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/InvocationBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/InvocationBuilder.java index 602ee218..dfa56d18 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/InvocationBuilder.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/InvocationBuilder.java @@ -18,9 +18,15 @@ public class InvocationBuilder { private final Class returnType; private final boolean voidReturn; - private InvocationBuilder(HypernateContext context, String chaincodeName, String functionName, - String channel, Object[] typedArgs, byte[][] rawArgs, - Class returnType, boolean voidReturn) { + private InvocationBuilder( + HypernateContext context, + String chaincodeName, + String functionName, + String channel, + Object[] typedArgs, + byte[][] rawArgs, + Class returnType, + boolean voidReturn) { this.context = context; this.chaincodeName = chaincodeName; this.functionName = functionName; @@ -36,31 +42,38 @@ public InvocationBuilder(HypernateContext context, String chaincodeName, String } public InvocationBuilder onChannel(String channel) { - return new InvocationBuilder<>(context, chaincodeName, functionName, channel, typedArgs, rawArgs, returnType, voidReturn); + return new InvocationBuilder<>( + context, chaincodeName, functionName, channel, typedArgs, rawArgs, returnType, voidReturn); } public InvocationBuilder withArgs(Object... args) { - return new InvocationBuilder<>(context, chaincodeName, functionName, channel, args, rawArgs, returnType, voidReturn); + return new InvocationBuilder<>( + context, chaincodeName, functionName, channel, args, rawArgs, returnType, voidReturn); } public InvocationBuilder withRawArgs(byte[]... args) { - return new InvocationBuilder<>(context, chaincodeName, functionName, channel, typedArgs, args, returnType, voidReturn); + return new InvocationBuilder<>( + context, chaincodeName, functionName, channel, typedArgs, args, returnType, voidReturn); } public InvocationBuilder returning(Class returnType) { - return new InvocationBuilder<>(context, chaincodeName, functionName, channel, typedArgs, rawArgs, returnType, false); + return new InvocationBuilder<>( + context, chaincodeName, functionName, channel, typedArgs, rawArgs, returnType, false); } public InvocationBuilder returningVoid() { - return new InvocationBuilder<>(context, chaincodeName, functionName, channel, typedArgs, rawArgs, null, true); + return new InvocationBuilder<>( + context, chaincodeName, functionName, channel, typedArgs, rawArgs, null, true); } public T execute() { if (rawArgs != null && typedArgs != null && typedArgs.length > 0) { - throw new IllegalStateException("Cannot use both withArgs() and withRawArgs() on the same invocation."); + throw new IllegalStateException( + "Cannot use both withArgs() and withRawArgs() on the same invocation."); } if (!voidReturn && returnType == null) { - throw new IllegalStateException("Call .returning(Class) or .returningVoid() before execute()."); + throw new IllegalStateException( + "Call .returning(Class) or .returningVoid() before execute()."); } List assembledArgs = new ArrayList<>(); @@ -86,7 +99,8 @@ public T execute() { } if (response.getStatus() != Response.Status.SUCCESS) { - throw new CrossChaincodeException(chaincodeName, functionName, response.getStatus().getCode(), response.getMessage()); + throw new CrossChaincodeException( + chaincodeName, functionName, response.getStatus().getCode(), response.getMessage()); } if (voidReturn) { diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicy.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicy.java index c8c2be25..627a7140 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicy.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicy.java @@ -28,8 +28,8 @@ public static EndorsementPolicyBuilder builder() { /** * NOTE: A production implementation would compile this expression to a SignaturePolicyEnvelope * protobuf byte array using Fabric's msp/policydsl package. This prototype uses UTF-8-encoded - * expression strings for simplicity, which is not compatible with stub.setStateValidationParameter() - * in a real network. + * expression strings for simplicity, which is not compatible with + * stub.setStateValidationParameter() in a real network. */ public byte[] getPolicyBytes() { return expression.getBytes(StandardCharsets.UTF_8); @@ -46,7 +46,8 @@ private static void validate(String expr) { if (chars[i] == '(') parenStack.push(i); else if (chars[i] == ')') { if (parenStack.isEmpty()) { - throw new InvalidEndorsementPolicyException("Unexpected closing parenthesis at position " + i); + throw new InvalidEndorsementPolicyException( + "Unexpected closing parenthesis at position " + i); } parenStack.pop(); } @@ -85,7 +86,9 @@ else if (chars[i] == ')') { foundMsps.add(msp); if (!msp.matches("^[A-Za-z0-9]+MSP\\.(peer|member|admin|client)$")) { throw new InvalidEndorsementPolicyException( - "Invalid MSP principal '" + msp + "'. Expected format: OrgNameMSP.(peer|member|admin|client)"); + "Invalid MSP principal '" + + msp + + "'. Expected format: OrgNameMSP.(peer|member|admin|client)"); } } @@ -95,33 +98,34 @@ else if (chars[i] == ')') { // Validate OutOf threshold semantics if (expr.contains("OutOf(")) { - Matcher outOfMatcher = Pattern.compile("OutOf\\(\\s*(\\d+)\\s*,").matcher(expr); - if (outOfMatcher.find()) { - int threshold = Integer.parseInt(outOfMatcher.group(1)); - // Find closing paren matching this OutOf( - int openIdx = outOfMatcher.end() - 1; // position of comma - int stack = 1; - int closeIdx = -1; - for(int i = outOfMatcher.start() + 6; i < expr.length(); i++) { - if (expr.charAt(i) == '(') stack++; - if (expr.charAt(i) == ')') { - stack--; - if (stack == 0) { - closeIdx = i; - break; - } - } - } - if (closeIdx != -1) { - String outOfBody = expr.substring(outOfMatcher.end(), closeIdx); - Matcher innerMspMatcher = mspPattern.matcher(outOfBody); - int count = 0; - while (innerMspMatcher.find()) count++; - if (threshold > count) { - throw new InvalidEndorsementPolicyException("OutOf threshold " + threshold + " exceeds principal count " + count); - } + Matcher outOfMatcher = Pattern.compile("OutOf\\(\\s*(\\d+)\\s*,").matcher(expr); + if (outOfMatcher.find()) { + int threshold = Integer.parseInt(outOfMatcher.group(1)); + // Find closing paren matching this OutOf( + int openIdx = outOfMatcher.end() - 1; // position of comma + int stack = 1; + int closeIdx = -1; + for (int i = outOfMatcher.start() + 6; i < expr.length(); i++) { + if (expr.charAt(i) == '(') stack++; + if (expr.charAt(i) == ')') { + stack--; + if (stack == 0) { + closeIdx = i; + break; } + } } + if (closeIdx != -1) { + String outOfBody = expr.substring(outOfMatcher.end(), closeIdx); + Matcher innerMspMatcher = mspPattern.matcher(outOfBody); + int count = 0; + while (innerMspMatcher.find()) count++; + if (threshold > count) { + throw new InvalidEndorsementPolicyException( + "OutOf threshold " + threshold + " exceeds principal count " + count); + } + } + } } } } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicyBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicyBuilder.java index 9ba0650f..07cad833 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicyBuilder.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicyBuilder.java @@ -37,21 +37,21 @@ public EndorsementPolicy build() { if (combinator == null) { throw new IllegalStateException("Combinator (and/or/outOf) must be set"); } - - String joinedClauses = clauses.stream() - .map(c -> "'" + c + "'") - .collect(Collectors.joining(", ")); + + String joinedClauses = + clauses.stream().map(c -> "'" + c + "'").collect(Collectors.joining(", ")); String expression; if ("OutOf".equals(combinator)) { if (threshold > clauses.size()) { - throw new IllegalStateException("OutOf threshold " + threshold + " exceeds number of principals " + clauses.size()); + throw new IllegalStateException( + "OutOf threshold " + threshold + " exceeds number of principals " + clauses.size()); } expression = String.format("OutOf(%d, %s)", threshold, joinedClauses); } else { expression = String.format("%s(%s)", combinator, joinedClauses); } - + return EndorsementPolicy.of(expression); } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/PagedResult.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/PagedResult.java index af3cfed2..8462126c 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/PagedResult.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/PagedResult.java @@ -14,7 +14,15 @@ public PagedResult(List results, String nextBookmark, boolean hasMore) { this.hasMore = hasMore; } - public List results() { return results; } - public String nextBookmark() { return nextBookmark; } - public boolean hasMore() { return hasMore; } + public List results() { + return results; + } + + public String nextBookmark() { + return nextBookmark; + } + + public boolean hasMore() { + return hasMore; + } } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/QueryResultCollector.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/QueryResultCollector.java index 2e8cf2cf..64a55d0d 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/QueryResultCollector.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/QueryResultCollector.java @@ -2,10 +2,7 @@ package hu.bme.mit.ftsrg.hypernate.registry; import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; import org.hyperledger.fabric.shim.ledger.KeyValue; -import org.hyperledger.fabric.shim.ledger.QueryResultsIterator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RangeQueryBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RangeQueryBuilder.java index 3dc2debf..e28e829e 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RangeQueryBuilder.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RangeQueryBuilder.java @@ -21,22 +21,24 @@ public RangeQueryBuilder(Registry registry, Class clazz) { public RangeQueryBuilder withKeyPrefix(Object... prefixParts) { validatePrimaryKeyConfiguration(); - + int expectedCount = Registry.EntityUtil.getPrimaryKeyCount(clazz); if (prefixParts.length > expectedCount) { - throw new InvalidRangeQueryException(String.format( - "Too many prefix parts for %s: provided %d, maximum is %d.", - clazz.getName(), prefixParts.length, expectedCount)); + throw new InvalidRangeQueryException( + String.format( + "Too many prefix parts for %s: provided %d, maximum is %d.", + clazz.getName(), prefixParts.length, expectedCount)); } - + for (int i = 0; i < prefixParts.length; i++) { if (prefixParts[i] == null) { - throw new InvalidRangeQueryException(String.format( - "Null prefix component at index %d for %s. Null composite key parts produce silently incorrect range scans.", - i, clazz.getName())); + throw new InvalidRangeQueryException( + String.format( + "Null prefix component at index %d for %s. Null composite key parts produce silently incorrect range scans.", + i, clazz.getName())); } } - + this.prefixParts = prefixParts; this.isFullScan = false; return this; @@ -61,20 +63,25 @@ public RangeQueryBuilder paginated(int pageSize, String bookmark) { public List execute() { if (!isFullScan && prefixParts == null) { - throw new InvalidRangeQueryException("execute() called without withKeyPrefix() or fullScan(). Configure the query before executing."); + throw new InvalidRangeQueryException( + "execute() called without withKeyPrefix() or fullScan(). Configure the query before executing."); } - + org.hyperledger.fabric.shim.ledger.CompositeKey compositeKey = buildPartialKey(); if (isPaginated) { - try (QueryResultsIteratorWithMetadata iter = - registry.getStub().getStateByPartialCompositeKeyWithPagination(compositeKey, pageSize, bookmark)) { + try (QueryResultsIteratorWithMetadata iter = + registry + .getStub() + .getStateByPartialCompositeKeyWithPagination(compositeKey, pageSize, bookmark)) { return QueryResultCollector.collect(iter, clazz); } catch (Exception e) { throw new RuntimeException("Failed to close iterator or collect results", e); } } else { - try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator iter = registry.getStub().getStateByPartialCompositeKey(compositeKey)) { + try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator< + org.hyperledger.fabric.shim.ledger.KeyValue> + iter = registry.getStub().getStateByPartialCompositeKey(compositeKey)) { return QueryResultCollector.collect(iter, clazz); } catch (Exception e) { throw new RuntimeException("Failed to close iterator or collect results", e); @@ -84,15 +91,19 @@ public List execute() { public PagedResult executePaged() { if (!isPaginated) { - throw new IllegalStateException("executePaged() requires .paginated() to be configured first."); + throw new IllegalStateException( + "executePaged() requires .paginated() to be configured first."); } if (!isFullScan && prefixParts == null) { - throw new InvalidRangeQueryException("executePaged() called without withKeyPrefix() or fullScan()."); + throw new InvalidRangeQueryException( + "executePaged() called without withKeyPrefix() or fullScan()."); } org.hyperledger.fabric.shim.ledger.CompositeKey compositeKey = buildPartialKey(); - try (QueryResultsIteratorWithMetadata iter = - registry.getStub().getStateByPartialCompositeKeyWithPagination(compositeKey, pageSize, bookmark)) { + try (QueryResultsIteratorWithMetadata iter = + registry + .getStub() + .getStateByPartialCompositeKeyWithPagination(compositeKey, pageSize, bookmark)) { String nextBookmark = iter.getMetadata().getBookmark(); boolean hasMore = !nextBookmark.isEmpty() || iter.getMetadata().getFetchedRecordsCount() > 0; List results = QueryResultCollector.collect(iter, clazz); @@ -104,9 +115,10 @@ public PagedResult executePaged() { private void validatePrimaryKeyConfiguration() { if (Registry.EntityUtil.getPrimaryKeyCount(clazz) == 0) { - throw new InvalidRangeQueryException(String.format( - "Cannot range-query %s: no @PrimaryKey annotation found. Range queries require at least one composite key field.", - clazz.getName())); + throw new InvalidRangeQueryException( + String.format( + "Cannot range-query %s: no @PrimaryKey annotation found. Range queries require at least one composite key field.", + clazz.getName())); } } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java index cd1624a7..9b19c14b 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java @@ -196,7 +196,8 @@ public T tryRead(Class clazz, Object... keys) { */ public List readAll(final Class clazz) { final String key = stub.createCompositeKey(EntityUtil.getType(clazz)).toString(); - try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator iterator = stub.getStateByPartialCompositeKey(key)) { + try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator iterator = + stub.getStateByPartialCompositeKey(key)) { return collectResults(iterator, clazz); } catch (Exception e) { throw new RuntimeException("Failed to read all entities", e); @@ -223,16 +224,14 @@ public RangeQueryBuilder rangeQuery(Class clazz) { return new RangeQueryBuilder<>(this, clazz); } - List collectResults(org.hyperledger.fabric.shim.ledger.QueryResultsIterator iterator, Class clazz) { + List collectResults( + org.hyperledger.fabric.shim.ledger.QueryResultsIterator iterator, Class clazz) { Iterable iterable = () -> iterator.iterator(); return StreamSupport.stream(iterable.spliterator(), false) .map( kv -> { final byte[] value = kv.getValue(); - logger.debug( - "Found value at key {}: {}", - kv.getKey(), - Arrays.toString(value)); + logger.debug("Found value at key {}: {}", kv.getKey(), Arrays.toString(value)); return EntityUtil.fromBuffer(value, clazz); }) .collect(Collectors.toList()); @@ -245,15 +244,21 @@ List collectResults(org.hyperledger.fabric.shim.ledger.QueryResultsIterat * @param policy the compiled endorsement policy * @param the entity type */ - public void setEndorsementPolicy(T entity, hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy policy) { + public void setEndorsementPolicy( + T entity, hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy policy) { + if (!exists(entity)) { + throw new EntityNotFoundException("Cannot set endorsement policy: entity does not exist"); + } stub.setStateValidationParameter(getCompositeKey(entity), policy.getPolicyBytes()); } private void applyEndorsementPolicyIfPresent(T entity, String key) { - hu.bme.mit.ftsrg.hypernate.annotations.EndorsementPolicy annot = - entity.getClass().getAnnotation(hu.bme.mit.ftsrg.hypernate.annotations.EndorsementPolicy.class); + hu.bme.mit.ftsrg.hypernate.annotations.EndorsementPolicy annot = + entity + .getClass() + .getAnnotation(hu.bme.mit.ftsrg.hypernate.annotations.EndorsementPolicy.class); if (annot != null) { - hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy policy = + hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy policy = hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy.of(annot.value()); stub.setStateValidationParameter(key, policy.getPolicyBytes()); } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RichQueryBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RichQueryBuilder.java index b59f766c..01304397 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RichQueryBuilder.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RichQueryBuilder.java @@ -48,8 +48,8 @@ public RichQueryBuilder or(RichQueryBuilder... subQueries) { } /** - * Add a sort condition. Note: CouchDB requires an index to exist on the sorted fields. - * If an index does not exist, Fabric CouchDB runtime will return an error during execution. + * Add a sort condition. Note: CouchDB requires an index to exist on the sorted fields. If an + * index does not exist, Fabric CouchDB runtime will return an error during execution. */ public RichQueryBuilder sortBy(String field, SortOrder order) { this.sort.add(Map.of(field, order == SortOrder.DESC ? "desc" : "asc")); @@ -77,14 +77,15 @@ public RichQueryBuilder skip(int n) { } /** - * Execute the rich query. - * Note: Calling execute() with no conditions will result in a full-collection scan - * for the given entity type, matching all documents of this type. + * Execute the rich query. Note: Calling execute() with no conditions will result in a + * full-collection scan for the given entity type, matching all documents of this type. */ public List execute() throws UncommittedStateException { checkUncommittedState(); String queryString = buildQueryString(); - try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator iter = registry.getStub().getQueryResult(queryString)) { + try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator< + org.hyperledger.fabric.shim.ledger.KeyValue> + iter = registry.getStub().getQueryResult(queryString)) { return QueryResultCollector.collect(iter, clazz); } catch (Exception e) { throw new RuntimeException("Failed to close iterator or collect results", e); @@ -96,8 +97,8 @@ public PagedResult executePaged() throws UncommittedStateException { String queryString = buildQueryString(); int pageSize = (limit != null) ? limit : 25; String bm = (bookmark != null) ? bookmark : ""; - - try (QueryResultsIteratorWithMetadata iter = + + try (QueryResultsIteratorWithMetadata iter = registry.getStub().getQueryResultWithPagination(queryString, pageSize, bm)) { String nextBookmark = iter.getMetadata().getBookmark(); boolean hasMore = !nextBookmark.isEmpty() || iter.getMetadata().getFetchedRecordsCount() > 0; @@ -129,9 +130,10 @@ private void checkUncommittedState() throws UncommittedStateException { while (current instanceof StubMiddleware sm) { if (current instanceof WriteBackCachedStubMiddleware wbc) { if (wbc.hasUncommittedState()) { - throw new UncommittedStateException("Rich query cannot proceed: uncommitted writes exist in the middleware cache. " - + "CouchDB reads committed ledger state only — buffered writes in WriteBackCachedStubMiddleware are invisible to getQueryResult(). " - + "Flush pending writes via ctx.notify(new TransactionEnd()) before querying, or restructure your transaction to query before writing."); + throw new UncommittedStateException( + "Rich query cannot proceed: uncommitted writes exist in the middleware cache. " + + "CouchDB reads committed ledger state only — buffered writes in WriteBackCachedStubMiddleware are invisible to getQueryResult(). " + + "Flush pending writes via ctx.notify(new TransactionEnd()) before querying, or restructure your transaction to query before writing."); } } current = sm.getNextStub(); diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SelectorBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SelectorBuilder.java index f35638e6..03433df5 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SelectorBuilder.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SelectorBuilder.java @@ -54,7 +54,8 @@ Map build() { if (!orConditions.isEmpty()) { if (finalSelector.containsKey("$or")) { @SuppressWarnings("unchecked") - List> existingOrs = (List>) finalSelector.get("$or"); + List> existingOrs = + (List>) finalSelector.get("$or"); existingOrs.addAll(orConditions); } else { finalSelector.put("$or", new ArrayList<>(orConditions)); diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SortOrder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SortOrder.java index c459c7f7..fda150b0 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SortOrder.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SortOrder.java @@ -2,5 +2,6 @@ package hu.bme.mit.ftsrg.hypernate.registry; public enum SortOrder { - ASC, DESC + ASC, + DESC } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/Asset.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/Asset.java index a9b27dea..29fbb514 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/Asset.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/Asset.java @@ -4,10 +4,7 @@ import hu.bme.mit.ftsrg.hypernate.annotations.AttributeInfo; import hu.bme.mit.ftsrg.hypernate.annotations.PrimaryKey; -@PrimaryKey({ - @AttributeInfo(name = "owner"), - @AttributeInfo(name = "id") -}) +@PrimaryKey({@AttributeInfo(name = "owner"), @AttributeInfo(name = "id")}) public class Asset { private String id; private String color; @@ -29,7 +26,14 @@ public Asset(String id, String color, int size, String owner, int value) { this.value = value; } - public Asset(String id, String color, int size, String owner, int value, long timestamp, AssetStatus status) { + public Asset( + String id, + String color, + int size, + String owner, + int value, + long timestamp, + AssetStatus status) { this.id = id; this.color = color; this.size = size; @@ -40,18 +44,59 @@ public Asset(String id, String color, int size, String owner, int value, long ti } // Getters and Setters - public String getId() { return id; } - public void setId(String id) { this.id = id; } - public String getColor() { return color; } - public void setColor(String color) { this.color = color; } - public int getSize() { return size; } - public void setSize(int size) { this.size = size; } - public String getOwner() { return owner; } - public void setOwner(String owner) { this.owner = owner; } - public int getValue() { return value; } - public void setValue(int value) { this.value = value; } - public long getTimestamp() { return timestamp; } - public void setTimestamp(long timestamp) { this.timestamp = timestamp; } - public AssetStatus getStatus() { return status; } - public void setStatus(AssetStatus status) { this.status = status; } + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public int getValue() { + return value; + } + + public void setValue(int value) { + this.value = value; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } + + public AssetStatus getStatus() { + return status; + } + + public void setStatus(AssetStatus status) { + this.status = status; + } } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/AssetStatus.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/AssetStatus.java index 78d9fe0f..2faa20bf 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/AssetStatus.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/AssetStatus.java @@ -2,5 +2,7 @@ package hu.bme.mit.ftsrg.hypernate.sample; public enum AssetStatus { - ACTIVE, PENDING_TRANSFER, ARCHIVED + ACTIVE, + PENDING_TRANSFER, + ARCHIVED } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingRequest.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingRequest.java index 6a3a6ece..5bdc041f 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingRequest.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingRequest.java @@ -16,13 +16,35 @@ public PricingRequest(String assetId, int currentValue, String newOwner, int off this.offeredPrice = offeredPrice; } - public String assetId() { return assetId; } - public int currentValue() { return currentValue; } - public String newOwner() { return newOwner; } - public int offeredPrice() { return offeredPrice; } - - public String getAssetId() { return assetId; } - public int getCurrentValue() { return currentValue; } - public String getNewOwner() { return newOwner; } - public int getOfferedPrice() { return offeredPrice; } + public String assetId() { + return assetId; + } + + public int currentValue() { + return currentValue; + } + + public String newOwner() { + return newOwner; + } + + public int offeredPrice() { + return offeredPrice; + } + + public String getAssetId() { + return assetId; + } + + public int getCurrentValue() { + return currentValue; + } + + public String getNewOwner() { + return newOwner; + } + + public int getOfferedPrice() { + return offeredPrice; + } } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingResponse.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingResponse.java index 0daae5bd..1c64d845 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingResponse.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingResponse.java @@ -14,11 +14,27 @@ public PricingResponse(boolean valid, int approvedPrice, String notes) { this.notes = notes; } - public boolean valid() { return valid; } - public int approvedPrice() { return approvedPrice; } - public String notes() { return notes; } + public boolean valid() { + return valid; + } + + public int approvedPrice() { + return approvedPrice; + } + + public String notes() { + return notes; + } - public boolean isValid() { return valid; } - public int getApprovedPrice() { return approvedPrice; } - public String getNotes() { return notes; } + public boolean isValid() { + return valid; + } + + public int getApprovedPrice() { + return approvedPrice; + } + + public String getNotes() { + return notes; + } } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SampleChaincode.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SampleChaincode.java index 6bcd0874..957cbbb5 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SampleChaincode.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SampleChaincode.java @@ -7,7 +7,6 @@ import hu.bme.mit.ftsrg.hypernate.registry.SortOrder; import hu.bme.mit.ftsrg.hypernate.registry.query.UncommittedStateException; import hu.bme.mit.ftsrg.hypernate.util.JSON; -import java.time.Instant; import java.util.List; import org.hyperledger.fabric.contract.Context; import org.hyperledger.fabric.contract.ContractInterface; @@ -18,15 +17,23 @@ @Contract( name = "SampleChaincode", - info = @Info(title = "SampleChaincode", description = "A sample Hypernate chaincode", version = "1.0")) + info = + @Info( + title = "SampleChaincode", + description = "A sample Hypernate chaincode", + version = "1.0")) @Default public class SampleChaincode implements ContractInterface { @Transaction - public void createAsset(HypernateContext ctx, String id, String color, int size, String owner, int value) { - if (id == null || id.isBlank()) throw new IllegalArgumentException("Field 'id' must not be blank"); - if (color == null || color.isBlank()) throw new IllegalArgumentException("Field 'color' must not be blank"); - if (owner == null || owner.isBlank()) throw new IllegalArgumentException("Field 'owner' must not be blank"); + public void createAsset( + HypernateContext ctx, String id, String color, int size, String owner, int value) { + if (id == null || id.isBlank()) + throw new IllegalArgumentException("Field 'id' must not be blank"); + if (color == null || color.isBlank()) + throw new IllegalArgumentException("Field 'color' must not be blank"); + if (owner == null || owner.isBlank()) + throw new IllegalArgumentException("Field 'owner' must not be blank"); if (size <= 0) throw new IllegalArgumentException("Field 'size' must be positive"); if (value <= 0) throw new IllegalArgumentException("Field 'value' must be positive"); @@ -36,10 +43,21 @@ public void createAsset(HypernateContext ctx, String id, String color, int size, } @Transaction - public void createSensitiveAsset(HypernateContext ctx, String id, String color, int size, String owner, int value, String classLevel, String orgsJson) { - if (id == null || id.isBlank()) throw new IllegalArgumentException("Field 'id' must not be blank"); - if (color == null || color.isBlank()) throw new IllegalArgumentException("Field 'color' must not be blank"); - if (owner == null || owner.isBlank()) throw new IllegalArgumentException("Field 'owner' must not be blank"); + public void createSensitiveAsset( + HypernateContext ctx, + String id, + String color, + int size, + String owner, + int value, + String classLevel, + String orgsJson) { + if (id == null || id.isBlank()) + throw new IllegalArgumentException("Field 'id' must not be blank"); + if (color == null || color.isBlank()) + throw new IllegalArgumentException("Field 'color' must not be blank"); + if (owner == null || owner.isBlank()) + throw new IllegalArgumentException("Field 'owner' must not be blank"); if (size <= 0) throw new IllegalArgumentException("Field 'size' must be positive"); if (value <= 0) throw new IllegalArgumentException("Field 'value' must be positive"); @@ -53,8 +71,10 @@ public void createSensitiveAsset(HypernateContext ctx, String id, String color, } long timestampMs = ctx.getStub().getTxTimestamp().toEpochMilli(); - - SensitiveAsset sensitiveAsset = new SensitiveAsset(id, color, size, owner, value, timestampMs, AssetStatus.ACTIVE, classLevel, orgs); + + SensitiveAsset sensitiveAsset = + new SensitiveAsset( + id, color, size, owner, value, timestampMs, AssetStatus.ACTIVE, classLevel, orgs); ctx.getRegistry().mustCreate(sensitiveAsset); } @@ -64,12 +84,17 @@ public Asset getAsset(HypernateContext ctx, String owner, String id) { } @Transaction - public List queryAssetsByColorAndOwner(HypernateContext ctx, String color, String owner1, String owner2) { + public List queryAssetsByColorAndOwner( + HypernateContext ctx, String color, String owner1, String owner2) { try { - return ctx.getRegistry().query(Asset.class) - .where("color").is(color) - .and("owner").in(owner1, owner2) - .and("status").is(AssetStatus.ACTIVE.name()) + return ctx.getRegistry() + .query(Asset.class) + .where("color") + .is(color) + .and("owner") + .in(owner1, owner2) + .and("status") + .is(AssetStatus.ACTIVE.name()) .sortBy("value", SortOrder.DESC) .limit(100) .execute(); @@ -82,9 +107,12 @@ public List queryAssetsByColorAndOwner(HypernateContext ctx, String color public List queryAssetsByValueRange(HypernateContext ctx, int minValue, int maxValue) { if (minValue > maxValue) throw new IllegalArgumentException("minValue must be <= maxValue"); try { - return ctx.getRegistry().query(Asset.class) - .where("value").between(minValue, maxValue) - .and("status").isNot(AssetStatus.ARCHIVED.name()) + return ctx.getRegistry() + .query(Asset.class) + .where("value") + .between(minValue, maxValue) + .and("status") + .isNot(AssetStatus.ARCHIVED.name()) .sortBy("value", SortOrder.ASC) .execute(); } catch (UncommittedStateException e) { @@ -103,22 +131,30 @@ public List listAllAssets(HypernateContext ctx) { } @Transaction - public Asset initiateTransfer(HypernateContext ctx, String assetId, String currentOwner, String newOwner, int agreedPrice) { + public Asset initiateTransfer( + HypernateContext ctx, String assetId, String currentOwner, String newOwner, int agreedPrice) { Registry registry = ctx.getRegistry(); Asset asset = registry.mustRead(Asset.class, currentOwner, assetId); - + if (asset.getStatus() != AssetStatus.ACTIVE) { - throw new RuntimeException(String.format("Asset %s is not in ACTIVE status; current status: %s. Only ACTIVE assets can be transferred.", assetId, asset.getStatus())); + throw new RuntimeException( + String.format( + "Asset %s is not in ACTIVE status; current status: %s. Only ACTIVE assets can be transferred.", + assetId, asset.getStatus())); } if (!asset.getOwner().equals(currentOwner)) { - throw new RuntimeException(String.format("Owner mismatch: asset %s is owned by %s, not %s", assetId, asset.getOwner(), currentOwner)); + throw new RuntimeException( + String.format( + "Owner mismatch: asset %s is owned by %s, not %s", + assetId, asset.getOwner(), currentOwner)); } - PricingResponse pricing = ctx.invoke("PricingChaincode", "validatePrice") - .withArgs(new PricingRequest(assetId, asset.getValue(), newOwner, agreedPrice)) - .returning(PricingResponse.class) - .execute(); - + PricingResponse pricing = + ctx.invoke("PricingChaincode", "validatePrice") + .withArgs(new PricingRequest(assetId, asset.getValue(), newOwner, agreedPrice)) + .returning(PricingResponse.class) + .execute(); + if (!pricing.valid()) { throw new RuntimeException("Transfer rejected by PricingChaincode: " + pricing.notes()); } @@ -129,12 +165,15 @@ public Asset initiateTransfer(HypernateContext ctx, String assetId, String curre } @Transaction - public Asset completeTransfer(HypernateContext ctx, String assetId, String currentOwner, String newOwner) { + public Asset completeTransfer( + HypernateContext ctx, String assetId, String currentOwner, String newOwner) { Registry registry = ctx.getRegistry(); Asset asset = registry.mustRead(Asset.class, currentOwner, assetId); if (asset.getStatus() != AssetStatus.PENDING_TRANSFER) { - throw new RuntimeException(String.format("Asset %s is not pending transfer; status: %s", assetId, asset.getStatus())); + throw new RuntimeException( + String.format( + "Asset %s is not pending transfer; status: %s", assetId, asset.getStatus())); } asset.setOwner(newOwner); @@ -155,7 +194,8 @@ public void archiveAsset(HypernateContext ctx, String owner, String id) { } @Transaction - public void setSensitiveAssetPolicy(HypernateContext ctx, String id, String owner, String expression) { + public void setSensitiveAssetPolicy( + HypernateContext ctx, String id, String owner, String expression) { SensitiveAsset asset = ctx.getRegistry().mustRead(SensitiveAsset.class, owner, id); EndorsementPolicy policy = EndorsementPolicy.of(expression); ctx.getRegistry().setEndorsementPolicy(asset, policy); @@ -164,7 +204,8 @@ public void setSensitiveAssetPolicy(HypernateContext ctx, String id, String owne @Contract( name = "PricingChaincode", - info = @Info(title = "PricingChaincode", description = "Mock pricing chaincode", version = "1.0")) + info = + @Info(title = "PricingChaincode", description = "Mock pricing chaincode", version = "1.0")) class PricingChaincode implements ContractInterface { @Transaction @@ -175,18 +216,29 @@ public String validatePrice(Context ctx, String pricingRequestJson) { } catch (Exception e) { throw new IllegalArgumentException("Invalid JSON for PricingRequest: " + e.getMessage(), e); } - + if (req.currentValue() > 1000) { boolean approved = req.offeredPrice() >= req.currentValue() * 0.9; if (!approved) { try { - return JSON.serialize(new PricingResponse(false, 0, - String.format("Offered price %d is below 90%% of market value %d", req.offeredPrice(), req.currentValue()))); - } catch (Exception e) { throw new RuntimeException(e); } + return JSON.serialize( + new PricingResponse( + false, + 0, + String.format( + "Offered price %d is below 90%% of market value %d", + req.offeredPrice(), req.currentValue()))); + } catch (Exception e) { + throw new RuntimeException(e); + } } } try { - return JSON.serialize(new PricingResponse(true, req.offeredPrice(), "Transfer approved at price " + req.offeredPrice())); - } catch (Exception e) { throw new RuntimeException(e); } + return JSON.serialize( + new PricingResponse( + true, req.offeredPrice(), "Transfer approved at price " + req.offeredPrice())); + } catch (Exception e) { + throw new RuntimeException(e); + } } } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SensitiveAsset.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SensitiveAsset.java index 1685527b..11e07ae2 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SensitiveAsset.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SensitiveAsset.java @@ -5,10 +5,7 @@ import hu.bme.mit.ftsrg.hypernate.annotations.EndorsementPolicy; import hu.bme.mit.ftsrg.hypernate.annotations.PrimaryKey; -@PrimaryKey({ - @AttributeInfo(name = "owner"), - @AttributeInfo(name = "id") -}) +@PrimaryKey({@AttributeInfo(name = "owner"), @AttributeInfo(name = "id")}) @EndorsementPolicy("OR('Org1MSP.peer', 'Org2MSP.peer')") public class SensitiveAsset { private String id; @@ -33,7 +30,16 @@ public SensitiveAsset(String id, String color, int size, String owner, int value this.value = value; } - public SensitiveAsset(String id, String color, int size, String owner, int value, long timestamp, AssetStatus status, String classLevel, java.util.List orgs) { + public SensitiveAsset( + String id, + String color, + int size, + String owner, + int value, + long timestamp, + AssetStatus status, + String classLevel, + java.util.List orgs) { this.id = id; this.color = color; this.size = size; @@ -46,22 +52,75 @@ public SensitiveAsset(String id, String color, int size, String owner, int value } // Getters and Setters - public String getId() { return id; } - public void setId(String id) { this.id = id; } - public String getColor() { return color; } - public void setColor(String color) { this.color = color; } - public int getSize() { return size; } - public void setSize(int size) { this.size = size; } - public String getOwner() { return owner; } - public void setOwner(String owner) { this.owner = owner; } - public int getValue() { return value; } - public void setValue(int value) { this.value = value; } - public long getTimestamp() { return timestamp; } - public void setTimestamp(long timestamp) { this.timestamp = timestamp; } - public AssetStatus getStatus() { return status; } - public void setStatus(AssetStatus status) { this.status = status; } - public String getClassLevel() { return classLevel; } - public void setClassLevel(String classLevel) { this.classLevel = classLevel; } - public java.util.List getOrgs() { return orgs; } - public void setOrgs(java.util.List orgs) { this.orgs = orgs; } + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public int getValue() { + return value; + } + + public void setValue(int value) { + this.value = value; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } + + public AssetStatus getStatus() { + return status; + } + + public void setStatus(AssetStatus status) { + this.status = status; + } + + public String getClassLevel() { + return classLevel; + } + + public void setClassLevel(String classLevel) { + this.classLevel = classLevel; + } + + public java.util.List getOrgs() { + return orgs; + } + + public void setOrgs(java.util.List orgs) { + this.orgs = orgs; + } } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferRequest.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferRequest.java index c8a6a98e..34d4cabc 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferRequest.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferRequest.java @@ -16,13 +16,35 @@ public TransferRequest(String assetId, String currentOwner, String newOwner, int this.agreedPrice = agreedPrice; } - public String assetId() { return assetId; } - public String currentOwner() { return currentOwner; } - public String newOwner() { return newOwner; } - public int agreedPrice() { return agreedPrice; } - - public String getAssetId() { return assetId; } - public String getCurrentOwner() { return currentOwner; } - public String getNewOwner() { return newOwner; } - public int getAgreedPrice() { return agreedPrice; } + public String assetId() { + return assetId; + } + + public String currentOwner() { + return currentOwner; + } + + public String newOwner() { + return newOwner; + } + + public int agreedPrice() { + return agreedPrice; + } + + public String getAssetId() { + return assetId; + } + + public String getCurrentOwner() { + return currentOwner; + } + + public String getNewOwner() { + return newOwner; + } + + public int getAgreedPrice() { + return agreedPrice; + } } diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferResult.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferResult.java index 6c2c1777..5d590992 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferResult.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferResult.java @@ -14,11 +14,27 @@ public TransferResult(boolean approved, String reason, int finalPrice) { this.finalPrice = finalPrice; } - public boolean approved() { return approved; } - public String reason() { return reason; } - public int finalPrice() { return finalPrice; } - - public boolean isApproved() { return approved; } - public String getReason() { return reason; } - public int getFinalPrice() { return finalPrice; } + public boolean approved() { + return approved; + } + + public String reason() { + return reason; + } + + public int finalPrice() { + return finalPrice; + } + + public boolean isApproved() { + return approved; + } + + public String getReason() { + return reason; + } + + public int getFinalPrice() { + return finalPrice; + } } diff --git a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/PrototypeTests.java b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/PrototypeTests.java index 034c7950..3bfcc015 100644 --- a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/PrototypeTests.java +++ b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/PrototypeTests.java @@ -17,8 +17,8 @@ import hu.bme.mit.ftsrg.hypernate.endorsement.InvalidEndorsementPolicyException; import hu.bme.mit.ftsrg.hypernate.middleware.StubMiddlewareChain; import hu.bme.mit.ftsrg.hypernate.middleware.WriteBackCachedStubMiddleware; -import hu.bme.mit.ftsrg.hypernate.registry.Registry; import hu.bme.mit.ftsrg.hypernate.registry.EntityNotFoundException; +import hu.bme.mit.ftsrg.hypernate.registry.Registry; import hu.bme.mit.ftsrg.hypernate.registry.SortOrder; import hu.bme.mit.ftsrg.hypernate.registry.query.InvalidRangeQueryException; import hu.bme.mit.ftsrg.hypernate.registry.query.UncommittedStateException; @@ -29,7 +29,6 @@ import hu.bme.mit.ftsrg.hypernate.sample.SensitiveAsset; import hu.bme.mit.ftsrg.hypernate.sample.TransferResult; import hu.bme.mit.ftsrg.hypernate.util.JSON; -import java.time.Instant; import java.util.Collections; import java.util.List; import org.hyperledger.fabric.shim.Chaincode.Response; @@ -56,7 +55,7 @@ public void testQueryBuilderSelector_SingleCondition() throws Exception { when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); registry.query(Asset.class).where("color").is("blue").execute(); - + String json = queryCaptor.getValue(); assertThat(json).contains("\"color\":\"blue\""); assertThat(json).contains("\"docType\":\"HU.BME.MIT.FTSRG.HYPERNATE.SAMPLE.ASSET\""); @@ -72,7 +71,7 @@ public void testQueryBuilderSelector_MultiCondition() throws Exception { when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); registry.query(Asset.class).where("color").is("blue").and("size").greaterThan(10).execute(); - + String json = queryCaptor.getValue(); assertThat(json).contains("\"color\":\"blue\""); assertThat(json).contains("\"size\":{\"$gt\":10}"); @@ -88,7 +87,7 @@ public void testQueryBuilderSelector_Between() throws Exception { when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); registry.query(Asset.class).where("value").between(100, 500).execute(); - + String json = queryCaptor.getValue(); assertThat(json).contains("\"$gte\":100"); assertThat(json).contains("\"$lte\":500"); @@ -116,8 +115,13 @@ public void testQueryBuilderSelector_SortBy() throws Exception { when(iter.iterator()).thenReturn(Collections.emptyIterator()); when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); - registry.query(Asset.class).sortBy("value", SortOrder.DESC).sortBy("color", SortOrder.ASC).execute(); - assertThat(queryCaptor.getValue()).contains("\"sort\":[{\"value\":\"desc\"},{\"color\":\"asc\"}]"); + registry + .query(Asset.class) + .sortBy("value", SortOrder.DESC) + .sortBy("color", SortOrder.ASC) + .execute(); + assertThat(queryCaptor.getValue()) + .contains("\"sort\":[{\"value\":\"desc\"},{\"color\":\"asc\"}]"); } @Test @@ -158,30 +162,31 @@ public void testQueryBuilderSelector_DocTypeAutoFilter() throws Exception { when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); registry.query(Asset.class).execute(); - assertThat(queryCaptor.getValue()).contains("\"docType\":\"HU.BME.MIT.FTSRG.HYPERNATE.SAMPLE.ASSET\""); + assertThat(queryCaptor.getValue()) + .contains("\"docType\":\"HU.BME.MIT.FTSRG.HYPERNATE.SAMPLE.ASSET\""); } @Test public void testUncommittedStateException_ThrowsWhenDirty() { ChaincodeStub fabricStub = mock(ChaincodeStub.class); - StubMiddlewareChain chain = StubMiddlewareChain.builder(fabricStub) - .push(WriteBackCachedStubMiddleware.class).build(); + StubMiddlewareChain chain = + StubMiddlewareChain.builder(fabricStub).push(WriteBackCachedStubMiddleware.class).build(); WriteBackCachedStubMiddleware mw = (WriteBackCachedStubMiddleware) chain.getFirst(); mw.putState("k", "v".getBytes()); - + Registry registry = new Registry(chain.getFirst()); assertThatThrownBy(() -> registry.query(Asset.class).where("color").is("x").execute()) - .isInstanceOf(UncommittedStateException.class) - .hasMessageContaining("uncommitted writes exist in the middleware cache") - .hasMessageContaining("invisible to getQueryResult") - .hasMessageContaining("TransactionEnd"); + .isInstanceOf(UncommittedStateException.class) + .hasMessageContaining("uncommitted writes exist in the middleware cache") + .hasMessageContaining("invisible to getQueryResult") + .hasMessageContaining("TransactionEnd"); } @Test public void testUncommittedStateException_CleanCache() throws Exception { ChaincodeStub fabricStub = mock(ChaincodeStub.class); - StubMiddlewareChain chain = StubMiddlewareChain.builder(fabricStub) - .push(WriteBackCachedStubMiddleware.class).build(); + StubMiddlewareChain chain = + StubMiddlewareChain.builder(fabricStub).push(WriteBackCachedStubMiddleware.class).build(); Registry registry = new Registry(chain.getFirst()); QueryResultsIterator iter = mock(QueryResultsIterator.class); @@ -203,15 +208,17 @@ public void testUncommittedStateException_NoMiddleware() throws Exception { registry.query(Asset.class).where("color").is("x").execute(); } - public static class NoPKEntity { public String id; } + public static class NoPKEntity { + public String id; + } @Test public void testRangeQueryGuard_NoPK() { ChaincodeStub fabricStub = mock(ChaincodeStub.class); Registry registry = new Registry(fabricStub); assertThatThrownBy(() -> registry.rangeQuery(NoPKEntity.class).withKeyPrefix("x")) - .isInstanceOf(InvalidRangeQueryException.class) - .hasMessageContaining("no @PrimaryKey annotation found"); + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("no @PrimaryKey annotation found"); } @Test @@ -219,8 +226,8 @@ public void testRangeQueryGuard_TooManyPrefixParts() { ChaincodeStub fabricStub = mock(ChaincodeStub.class); Registry registry = new Registry(fabricStub); assertThatThrownBy(() -> registry.rangeQuery(Asset.class).withKeyPrefix("a", "b", "c")) - .isInstanceOf(InvalidRangeQueryException.class) - .hasMessageContaining("Too many prefix parts"); + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("Too many prefix parts"); } @Test @@ -228,14 +235,15 @@ public void testRangeQueryGuard_NullPrefix() { ChaincodeStub fabricStub = mock(ChaincodeStub.class); Registry registry = new Registry(fabricStub); assertThatThrownBy(() -> registry.rangeQuery(Asset.class).withKeyPrefix("Alice", null)) - .isInstanceOf(InvalidRangeQueryException.class) - .hasMessageContaining("Null prefix component at index 1"); + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("Null prefix component at index 1"); } @Test public void testRangeQueryGuard_FullScan() throws Exception { ChaincodeStub fabricStub = mock(ChaincodeStub.class); - when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("test")); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("test")); Registry registry = new Registry(fabricStub); QueryResultsIterator iter = mock(QueryResultsIterator.class); @@ -251,8 +259,8 @@ public void testRangeQueryGuard_ExecuteWithoutConfig() { ChaincodeStub fabricStub = mock(ChaincodeStub.class); Registry registry = new Registry(fabricStub); assertThatThrownBy(() -> registry.rangeQuery(Asset.class).execute()) - .isInstanceOf(InvalidRangeQueryException.class) - .hasMessageContaining("Configure the query before executing"); + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("Configure the query before executing"); } @Test @@ -262,11 +270,12 @@ public void testCrossChaincode_Success() throws Exception { when(ctx.getStub()).thenReturn(fabricStub); when(ctx.getFabricStub()).thenReturn(fabricStub); when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); - + Response resp = mock(Response.class); when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); when(resp.getStringPayload()).thenReturn(JSON.serialize(new TransferResult(true, "OK", 100))); - when(fabricStub.invokeChaincode(eq("cc"), org.mockito.ArgumentMatchers.>any())).thenReturn(resp); + when(fabricStub.invokeChaincode(eq("cc"), org.mockito.ArgumentMatchers.>any())) + .thenReturn(resp); TransferResult res = ctx.invoke("cc", "fn").returning(TransferResult.class).execute(); assertThat(res.approved()).isTrue(); @@ -283,17 +292,19 @@ public void testCrossChaincode_ErrorPropagation() { Response resp = mock(Response.class); when(resp.getStatus()).thenReturn(Response.Status.INTERNAL_SERVER_ERROR); when(resp.getMessage()).thenReturn("chaincode panicked"); - when(fabricStub.invokeChaincode(eq("testCC"), org.mockito.ArgumentMatchers.>any())).thenReturn(resp); + when(fabricStub.invokeChaincode(eq("testCC"), org.mockito.ArgumentMatchers.>any())) + .thenReturn(resp); assertThatThrownBy(() -> ctx.invoke("testCC", "doWork").returningVoid().execute()) - .isInstanceOf(CrossChaincodeException.class) - .satisfies(e -> { - CrossChaincodeException ex = (CrossChaincodeException) e; - assertThat(ex.getChaincodeId()).isEqualTo("testCC"); - assertThat(ex.getFunctionName()).isEqualTo("doWork"); - assertThat(ex.getStatusCode()).isEqualTo(500); - assertThat(ex.getOriginalMessage()).isEqualTo("chaincode panicked"); - }); + .isInstanceOf(CrossChaincodeException.class) + .satisfies( + e -> { + CrossChaincodeException ex = (CrossChaincodeException) e; + assertThat(ex.getChaincodeId()).isEqualTo("testCC"); + assertThat(ex.getFunctionName()).isEqualTo("doWork"); + assertThat(ex.getStatusCode()).isEqualTo(500); + assertThat(ex.getOriginalMessage()).isEqualTo("chaincode panicked"); + }); } @Test @@ -306,7 +317,8 @@ public void testCrossChaincode_ReturningVoid() { Response resp = mock(Response.class); when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); - when(fabricStub.invokeChaincode(eq("cc"), org.mockito.ArgumentMatchers.>any())).thenReturn(resp); + when(fabricStub.invokeChaincode(eq("cc"), org.mockito.ArgumentMatchers.>any())) + .thenReturn(resp); Void res = ctx.invoke("cc", "fn").returningVoid().execute(); assertThat(res).isNull(); @@ -321,16 +333,22 @@ public void testCrossChaincode_NoReturnConfig() { when(ctx.getFabricStub()).thenReturn(dummyStub); when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); assertThatThrownBy(() -> ctx.invoke("cc", "fn").execute()) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("returning(Class) or .returningVoid()"); + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("returning(Class) or .returningVoid()"); } @Test public void testCrossChaincode_MixedArgs() { HypernateContext ctx = mock(HypernateContext.class); when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); - assertThatThrownBy(() -> ctx.invoke("cc", "fn").withArgs("x").withRawArgs("y".getBytes()).returningVoid().execute()) - .isInstanceOf(IllegalStateException.class); + assertThatThrownBy( + () -> + ctx.invoke("cc", "fn") + .withArgs("x") + .withRawArgs("y".getBytes()) + .returningVoid() + .execute()) + .isInstanceOf(IllegalStateException.class); } @Test @@ -343,91 +361,107 @@ public void testEndorsementPolicyValidation_Valid() { @Test public void testEndorsementPolicyValidation_Unbalanced1() { assertThatThrownBy(() -> EndorsementPolicy.of("OR('Org1MSP.peer'")) - .isInstanceOf(InvalidEndorsementPolicyException.class) - .hasMessageContaining("Unclosed parenthesis"); + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Unclosed parenthesis"); } @Test public void testEndorsementPolicyValidation_Unbalanced2() { assertThatThrownBy(() -> EndorsementPolicy.of(")OR('Org1MSP.peer')")) - .isInstanceOf(InvalidEndorsementPolicyException.class) - .hasMessageContaining("Unexpected closing parenthesis"); + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Unexpected closing parenthesis"); } @Test public void testEndorsementPolicyValidation_UnsupportedFunction() { assertThatThrownBy(() -> EndorsementPolicy.of("XOR('Org1MSP.peer')")) - .isInstanceOf(InvalidEndorsementPolicyException.class) - .hasMessageContaining("Unsupported policy function 'XOR'"); + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Unsupported policy function 'XOR'"); } @Test public void testEndorsementPolicyValidation_InvalidMSPFormat() { assertThatThrownBy(() -> EndorsementPolicy.of("OR('Org1.peer')")) - .isInstanceOf(InvalidEndorsementPolicyException.class) - .hasMessageContaining("Invalid MSP principal"); + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Invalid MSP principal"); } @Test public void testEndorsementPolicyValidation_InvalidRole() { assertThatThrownBy(() -> EndorsementPolicy.of("OR('Org1MSP.validator')")) - .isInstanceOf(InvalidEndorsementPolicyException.class) - .hasMessageContaining("Invalid MSP principal"); + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Invalid MSP principal"); } @Test public void testEndorsementPolicyValidation_Empty() { assertThatThrownBy(() -> EndorsementPolicy.of("")) - .isInstanceOf(InvalidEndorsementPolicyException.class); + .isInstanceOf(InvalidEndorsementPolicyException.class); } @Test public void testEndorsementPolicyValidation_OutOfArg() { assertThatThrownBy(() -> EndorsementPolicy.of("OutOf(two, 'Org1MSP.peer')")) - .isInstanceOf(InvalidEndorsementPolicyException.class) - .hasMessageContaining("integer as its first argument"); + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("integer as its first argument"); } @Test public void testEndorsementPolicyBuilder_AND() { - EndorsementPolicy built = EndorsementPolicy.builder().and().org("Org1").peer().org("Org2").peer().build(); + EndorsementPolicy built = + EndorsementPolicy.builder().and().org("Org1").peer().org("Org2").peer().build(); assertThat(built.getExpression()).isEqualTo("AND('Org1MSP.peer', 'Org2MSP.peer')"); } @Test public void testEndorsementPolicyBuilder_OutOf() { - EndorsementPolicy built = EndorsementPolicy.builder().outOf(2).org("Org1").peer().org("Org2").peer().org("Org3").peer().build(); - assertThat(built.getExpression()).isEqualTo("OutOf(2, 'Org1MSP.peer', 'Org2MSP.peer', 'Org3MSP.peer')"); + EndorsementPolicy built = + EndorsementPolicy.builder() + .outOf(2) + .org("Org1") + .peer() + .org("Org2") + .peer() + .org("Org3") + .peer() + .build(); + assertThat(built.getExpression()) + .isEqualTo("OutOf(2, 'Org1MSP.peer', 'Org2MSP.peer', 'Org3MSP.peer')"); } @Test public void testEndorsementPolicyBuilder_EqualsDirect() { String expr = "OR('Org1MSP.peer', 'Org2MSP.peer')"; EndorsementPolicy direct = EndorsementPolicy.of(expr); - EndorsementPolicy built = EndorsementPolicy.builder().or().org("Org1").peer().org("Org2").peer().build(); + EndorsementPolicy built = + EndorsementPolicy.builder().or().org("Org1").peer().org("Org2").peer().build(); assertThat(direct.getExpression()).isEqualTo(built.getExpression()); } @Test public void testEndorsementPolicyBuilder_Empty() { - assertThatThrownBy(() -> EndorsementPolicy.builder().build()).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> EndorsementPolicy.builder().build()) + .isInstanceOf(IllegalStateException.class); } @Test public void testEndorsementPolicyBuilder_ExceedsThreshold() { assertThatThrownBy(() -> EndorsementPolicy.builder().outOf(3).org("Org1").peer().build()) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("exceeds number of principals"); + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("exceeds number of principals"); } @Test public void testRegistryEndorsement_mustCreateSensitive() throws Exception { ChaincodeStub fabricStub = mock(ChaincodeStub.class); - when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); when(fabricStub.getState(anyString())).thenReturn(null); Registry registry = new Registry(fabricStub); - SensitiveAsset sensitiveAsset = new SensitiveAsset("id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE, "A", Collections.emptyList()); + SensitiveAsset sensitiveAsset = + new SensitiveAsset( + "id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE, "A", Collections.emptyList()); registry.mustCreate(sensitiveAsset); verify(fabricStub).setStateValidationParameter(anyString(), any(byte[].class)); @@ -436,7 +470,8 @@ public void testRegistryEndorsement_mustCreateSensitive() throws Exception { @Test public void testRegistryEndorsement_mustCreateNormal() throws Exception { ChaincodeStub fabricStub = mock(ChaincodeStub.class); - when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); when(fabricStub.getState(anyString())).thenReturn(null); Registry registry = new Registry(fabricStub); @@ -449,30 +484,33 @@ public void testRegistryEndorsement_mustCreateNormal() throws Exception { @Test public void testRegistryEndorsement_setEndorsementPolicyNotFound() { ChaincodeStub fabricStub = mock(ChaincodeStub.class); - when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); when(fabricStub.getState(anyString())).thenReturn(new byte[0]); Registry registry = new Registry(fabricStub); - + Asset asset = new Asset("id", "red", 1, "owner", 10); EndorsementPolicy pol = EndorsementPolicy.of("OR('Org1MSP.peer')"); - + assertThatThrownBy(() -> registry.setEndorsementPolicy(asset, pol)) - .isInstanceOf(EntityNotFoundException.class) - .hasMessageContaining("Cannot set endorsement policy: entity does not exist"); + .isInstanceOf(EntityNotFoundException.class) + .hasMessageContaining("Cannot set endorsement policy: entity does not exist"); } @Test public void testSampleChaincode_createAssetBlankId() { SampleChaincode sc = new SampleChaincode(); ChaincodeStub fabricStub = mock(ChaincodeStub.class); - org.hyperledger.fabric.shim.ext.sbe.StateBasedEndorsement sbe = mock(org.hyperledger.fabric.shim.ext.sbe.StateBasedEndorsement.class); + org.hyperledger.fabric.shim.ext.sbe.StateBasedEndorsement sbe = + mock(org.hyperledger.fabric.shim.ext.sbe.StateBasedEndorsement.class); when(fabricStub.getTxTimestamp()).thenReturn(java.time.Instant.now()); HypernateContext ctx = mock(HypernateContext.class); when(ctx.getStub()).thenReturn(fabricStub); when(ctx.getFabricStub()).thenReturn(fabricStub); when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); assertThatThrownBy(() -> sc.createAsset(ctx, "", "red", 10, "Alice", 100)) - .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("id"); + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("id"); } @Test @@ -485,16 +523,18 @@ public void testSampleChaincode_createAssetNegativeSize() { when(ctx.getFabricStub()).thenReturn(fabricStub); when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); assertThatThrownBy(() -> sc.createAsset(ctx, "a1", "red", -1, "Alice", 100)) - .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("size"); + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("size"); } @Test public void testSampleChaincode_initiateTransferArchived() throws Exception { ChaincodeStub fabricStub = mock(ChaincodeStub.class); - when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); Asset archived = new Asset("id", "red", 1, "owner", 10, 0, AssetStatus.ARCHIVED); when(fabricStub.getState(anyString())).thenReturn(JSON.serialize(archived).getBytes()); - + SampleChaincode sc = new SampleChaincode(); HypernateContext ctx = mock(HypernateContext.class); when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); @@ -503,20 +543,25 @@ public void testSampleChaincode_initiateTransferArchived() throws Exception { when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); assertThatThrownBy(() -> sc.initiateTransfer(ctx, "id", "owner", "new", 10)) - .isInstanceOf(RuntimeException.class).hasMessageContaining("not in ACTIVE status"); + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("not in ACTIVE status"); } @Test public void testSampleChaincode_initiateTransferRejected() throws Exception { ChaincodeStub fabricStub = mock(ChaincodeStub.class); - when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); Asset active = new Asset("id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE); when(fabricStub.getState(anyString())).thenReturn(JSON.serialize(active).getBytes()); - + Response resp = mock(Response.class); when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); - when(resp.getStringPayload()).thenReturn(JSON.serialize(new PricingResponse(false, 0, "Too low"))); - when(fabricStub.invokeChaincode(eq("PricingChaincode"), org.mockito.ArgumentMatchers.>any())).thenReturn(resp); + when(resp.getStringPayload()) + .thenReturn(JSON.serialize(new PricingResponse(false, 0, "Too low"))); + when(fabricStub.invokeChaincode( + eq("PricingChaincode"), org.mockito.ArgumentMatchers.>any())) + .thenReturn(resp); SampleChaincode sc = new SampleChaincode(); HypernateContext ctx = mock(HypernateContext.class); @@ -526,20 +571,24 @@ public void testSampleChaincode_initiateTransferRejected() throws Exception { when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); assertThatThrownBy(() -> sc.initiateTransfer(ctx, "id", "owner", "new", 10)) - .isInstanceOf(RuntimeException.class).hasMessageContaining("Transfer rejected"); + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Transfer rejected"); } @Test public void testSampleChaincode_initiateTransferSuccess() throws Exception { ChaincodeStub fabricStub = mock(ChaincodeStub.class); - when(fabricStub.createCompositeKey(anyString(), any(String[].class))).thenReturn(new CompositeKey("mock")); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); Asset active = new Asset("id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE); when(fabricStub.getState(anyString())).thenReturn(JSON.serialize(active).getBytes()); - + Response resp = mock(Response.class); when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); when(resp.getStringPayload()).thenReturn(JSON.serialize(new PricingResponse(true, 50, "OK"))); - when(fabricStub.invokeChaincode(eq("PricingChaincode"), org.mockito.ArgumentMatchers.>any())).thenReturn(resp); + when(fabricStub.invokeChaincode( + eq("PricingChaincode"), org.mockito.ArgumentMatchers.>any())) + .thenReturn(resp); SampleChaincode sc = new SampleChaincode(); HypernateContext ctx = mock(HypernateContext.class); From 955f6bc938b13171f5070c0aca9daa2e936a9efe Mon Sep 17 00:00:00 2001 From: bzp99 Date: Wed, 10 Jun 2026 14:31:24 +0200 Subject: [PATCH 17/22] ci: add automatic labelling of new issues with needs-triage Signed-off-by: bzp99 --- .github/workflows/triage_label.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/triage_label.yml diff --git a/.github/workflows/triage_label.yml b/.github/workflows/triage_label.yml new file mode 100644 index 00000000..66264a39 --- /dev/null +++ b/.github/workflows/triage_label.yml @@ -0,0 +1,24 @@ +--- +name: Auto-add needs-triage labels to new issues + + +on: + issues: + types: [opened] + + +permissions: + issues: write + + +jobs: + label: + runs-on: ubuntu-latest + steps: + - run: >- + gh issue + edit ${{ github.event.issue.number }} + --add-label needs-triage + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} From dfbcb4b1a723204e3307a65e0e3a25ec2a95e377 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Wed, 10 Jun 2026 14:54:57 +0200 Subject: [PATCH 18/22] ci: add automatic flagging of non-design-approved PRs Signed-off-by: bzp99 --- .github/scripts/check_design_approval.js | 177 ++++++++++++++++++++ .github/workflows/check_design_approval.yml | 27 +++ 2 files changed, 204 insertions(+) create mode 100644 .github/scripts/check_design_approval.js create mode 100644 .github/workflows/check_design_approval.yml diff --git a/.github/scripts/check_design_approval.js b/.github/scripts/check_design_approval.js new file mode 100644 index 00000000..40aa9926 --- /dev/null +++ b/.github/scripts/check_design_approval.js @@ -0,0 +1,177 @@ +/* + * Checks that a PR has at least one linked issue carrying the `design/approved` label. + * If not, it applies a `needs-approved-issue` label and posts an explanatory comment. + * + * Usage (called by the workflow via actions/github-script): + * const script = require('./.github/scripts/check_design_approval.js') + * await script({ github, context, core }) + */ + +const REQUIRED_LABEL = 'design/approved' +const FLAG_LABEL = 'needs-approved-issue' + +// Fetch everything we need in a single GraphQL query: +// - Linked issues + their labels +// - Current labels on the PR +// - Existing bot comments on the PR (to avoid duplicate comments) +const QUERY = ` + query CheckDesignApproval($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + author { login } + labels(first: 10) { + nodes { name } + } + comments(last: 50) { + nodes { + body + author { + login + __typename + } + } + } + closingIssuesReferences(first: 10) { + nodes { + number + labels(first: 10) { + nodes { name } + } + } + } + } + } + } +` + +/** + * Ensure a label exists in the repo, creating it if necessary. + * @param {object} github - Octokit instance from github-script + * @param {object} repo - { owner, repo } + * @param {string} label + */ +async function ensureLabelExists (github, repo, label) { + try { + await github.rest.issues.getLabel({ ...repo, name: label }) + } catch (err) { + if (err.status === 404) { + await github.rest.issues.createLabel({ + ...repo, + name: label, + color: 'fbca04', + description: 'PR is not linked to a design/approved issue' + }) + } else { + throw err + } + } +} + +/** + * Entry point called by the actions/github-script workflow step. + */ +module.exports = async ({ github, context, core }) => { + const { owner, repo } = context.repo + const prNumber = context.payload.pull_request.number + + core.info(`Checking PR #${prNumber}`) + + // ── 1. Single GraphQL query that reads all data ───────────────────── + const { repository } = await github.graphql(QUERY, { + owner, + repo, + prNumber + }) + + const pr = repository.pullRequest + const prAuthor = pr.author.login + const currentLabelNames = pr.labels.nodes.map((l) => l.name) + const linkedIssues = pr.closingIssuesReferences.nodes + + core.info( + linkedIssues.length + ? `Found ${linkedIssues.length} linked issue(s): ${linkedIssues.map((i) => `#${i.number}`).join(', ')}` + : 'No linked issues found.' + ) + + // ── 2. Check linked issues for the required label ─────────────────── + let approvedIssue = null + for (const issue of linkedIssues) { + const issueLabels = issue.labels.nodes.map((l) => l.name) + core.info(`Issue #${issue.number} labels: [${issueLabels.join(', ')}]`) + if (issueLabels.includes(REQUIRED_LABEL)) { + core.info(`✅ Issue #${issue.number} has '${REQUIRED_LABEL}' – PR passes.`) + approvedIssue = issue + break + } + } + + const alreadyFlagged = currentLabelNames.includes(FLAG_LABEL) + + // ── 3a. PR passes; clean up flag label if previously applied ──────── + if (approvedIssue) { + if (alreadyFlagged) { + core.info(`Removing '${FLAG_LABEL}' label as the PR now passes.`) + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: FLAG_LABEL + }) + } + return + } + + // ── 3b. PR fails; apply label + comment ───────────────────────────── + core.info(`PR #${prNumber} does not meet requirements.`) + + await ensureLabelExists(github, { owner, repo }, FLAG_LABEL) + + if (!alreadyFlagged) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: [FLAG_LABEL] + }) + core.info(`Applied '${FLAG_LABEL}' label.`) + } + + // Bot comments have __typename "Bot" on the author node in GraphQL. + const alreadyCommented = pr.comments.nodes.some( + (c) => c.author.__typename === 'Bot' && c.body.includes(REQUIRED_LABEL) + ) + if (!alreadyCommented) { + const message = + linkedIssues.length === 0 + ? `Hey @${prAuthor}, thanks for the contribution! 👋 + +This PR was flagged because it has no linked issues. Please link one using a closing keyword in the PR description; for example: + +\`\`\` +Closes # +\`\`\` + +The linked issue must also carry the \`${REQUIRED_LABEL}\` label. If no issue exists yet, please open one and get design approval from the maintainers first.` + : `Hey @${prAuthor}, thanks for the contribution! 👋 + +This PR was flagged because the linked issue(s) (${linkedIssues.map((i) => `#${i.number}`).join(', ')}) do not have the \`${REQUIRED_LABEL}\` label. + +Please ensure the linked issue has been through the design approval process and carries the \`${REQUIRED_LABEL}\` label before this PR can be merged.` + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: message + }) + core.info('Posted explanatory comment.') + } else { + core.info('Comment already exists; skipping to avoid spam.') + } + + // Signal failure so the check shows as ❌ in the PR's status checks. + core.setFailed( + `PR #${prNumber} is not linked to an issue with the '${REQUIRED_LABEL}' label.` + ) +} diff --git a/.github/workflows/check_design_approval.yml b/.github/workflows/check_design_approval.yml new file mode 100644 index 00000000..12e50d67 --- /dev/null +++ b/.github/workflows/check_design_approval.yml @@ -0,0 +1,27 @@ +--- +name: Flag PRs that do not have associated design/approved issues + + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + + +permissions: + contents: read + issues: write + pull-requests: write + + +jobs: + check: + name: Linked issue has design/approved + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Run design-approval check + uses: actions/github-script@v9 + with: + script: |- + const script = require('./.github/scripts/check_design_approval.js') + await script({ github, context, core }) From caaf2a777589dc91011da90e1d25750a72f26acd Mon Sep 17 00:00:00 2001 From: bzp99 Date: Wed, 10 Jun 2026 15:31:47 +0200 Subject: [PATCH 19/22] ci: mark old non-approved PRs stale Signed-off-by: bzp99 --- .github/workflows/stale_not_approved.yml | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/stale_not_approved.yml diff --git a/.github/workflows/stale_not_approved.yml b/.github/workflows/stale_not_approved.yml new file mode 100644 index 00000000..e808c4c1 --- /dev/null +++ b/.github/workflows/stale_not_approved.yml @@ -0,0 +1,31 @@ +--- +name: >- + Mark PRs without design/approved linked issues as stale + and eventually close them + + +on: + schedule: + - cron: 30 1 * * * + + +permissions: + pull-requests: write + + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v10 + with: + stale-pr-message: |- + This PR has been automatically marked as stale due to inactivity. + It will be closed in 60 days if no further activity occurs. + days-before-stale: 14 + days-before-close: 60 + days-before-issue-stale: -1 + days-before-issue-close: -1 + stale-pr-label: stale + exempt-pr-labels: pinned,security + any-of-labels: needs-approved-issue From 998e523daa263dcf3c471bbcf8416d3ca6a2bba9 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Wed, 10 Jun 2026 15:49:13 +0200 Subject: [PATCH 20/22] docs(ci): document new CI workflows Signed-off-by: bzp99 --- docs/guides/cicd.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/guides/cicd.md b/docs/guides/cicd.md index 71906533..5d06cfb8 100644 --- a/docs/guides/cicd.md +++ b/docs/guides/cicd.md @@ -4,9 +4,12 @@ This project uses GitHub Actions for continuous integration and delivery. ## Workflows -| Workflow | File | Purpose | -| ------------ | ----------------------------- | ----------------------------------------- | -| Build & Test | `.github/workflows/build.yml` | Formatting checks, compilation, and tests | +| Workflow | File | Purpose | +| ---------------- | -------------------------------------------- | --------------------------------------------------------- | +| Build & Test | `.github/workflows/build.yml` | Formatting checks, compilation, and tests | +| Triage Label | `.github/workflows/triage_label.yml` | Labels newly opened issues with `needs-triage` | +| Design Approval | `.github/workflows/check_design_approval.yml`| Flags PRs without a linked design/approved issue | +| Stale PRs | `.github/workflows/stale_not_approved.yml` | Marks and eventually closes stale, non-approved PRs | ## Build & Test @@ -18,6 +21,25 @@ Documentation-only changes are skipped via path filters. If a new commit is pushed while a run is already in progress for the same branch, the older run is automatically canceled. +## Triage Label + +When an issue is opened, the Triage Label workflow automatically adds the `needs-triage` label so +new issues are easy to find and route. + +## Design Approval + +On every pull request (opened, edited, synchronized, or reopened), the Design Approval workflow checks that the PR links to an issue carrying the `design/approved` label. +PRs that lack such a linked issue are flagged via the `needs-approved-issue` label. + +Because the workflow is triggered by changes to the PR, it will not rerun if a non-`design/approved` issue gets the `design/approved` label. +In these cases, either a maintainer can force rerun the check or you can make a dummy change to the PR body (like adding a newline) and it will trigger the workflow. + +## Stale PRs + +A scheduled daily job marks pull requests carrying the `needs-approved-issue` label as `stale` +after 14 days of inactivity and closes them after 60 days. PRs labeled `pinned` or `security` are +exempt. Issues are never affected. + ## Reading CI Results 1. **Status check on PRs** - The workflow result appears as a check on the pull request page. From 0a2d1f41e5ca6b0970828d0dc868f413ba4b47ee Mon Sep 17 00:00:00 2001 From: Sreejesh06 Date: Sun, 12 Apr 2026 00:00:30 +0530 Subject: [PATCH 21/22] feat: add EntityType annotation Signed-off-by: Sreejesh06 --- README.md | 16 ++++++++++ .../hypernate/annotations/EntityType.java | 31 +++++++++++++++++++ .../ftsrg/hypernate/registry/Registry.java | 4 ++- .../bme/mit/ftsrg/hypernate/RegistryTest.java | 25 +++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EntityType.java diff --git a/README.md b/README.md index f6b68a8e..2cddc128 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,22 @@ public record Asset( String owner) {} ``` +#### Declaring a stable entity type + +By default, Hypernate uses the entity class name as the composite key object type. +To decouple the ledger key space from Java package/class renaming, define an explicit entity type with `@EntityType`: + +```java +@EntityType("Asset") +@PrimaryKey(@AttributeInfo(name = Asset.Fields.assetID)) +public record Asset( + String assetID, + String color, + int size, + int appraisedValue, + String owner) {} +``` + #### Polishing your primary key with mappers In the end, Fabric expects a string value as an entity key and the previous example used a string valued attribute as key (part), so all is good (hopefully). diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EntityType.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EntityType.java new file mode 100644 index 00000000..4b06ccf3 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EntityType.java @@ -0,0 +1,31 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines the explicit entity type name of the annotated class to be used for Registry composite + * keys. + * + *

This annotation is optional. In its absence, the entity type name defaults to the fully + * qualified class name (FQCN) returned by {@link Class#getName()}. + * + *

Specifying a stable, explicit name is recommended to decouple the keys in the ledger from + * potential Java class/package renames. + * + *

Example: + * + *

{@code
+ * @EntityType("Asset")
+ * @PrimaryKey(@AttributeInfo(name = Asset.Fields.id))
+ * public record Asset(String id, String color, int size) {}
+ * }
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface EntityType { + String value(); +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java index 46a6df4d..d5d125ed 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java @@ -3,6 +3,7 @@ import com.jcabi.aspects.Loggable; import hu.bme.mit.ftsrg.hypernate.annotations.AttributeInfo; +import hu.bme.mit.ftsrg.hypernate.annotations.EntityType; import hu.bme.mit.ftsrg.hypernate.annotations.PrimaryKey; import hu.bme.mit.ftsrg.hypernate.util.JSON; import java.lang.reflect.Constructor; @@ -246,7 +247,8 @@ String getType(final T entity) { } String getType(final Class clazz) { - return clazz.getName().toUpperCase(); + final EntityType annot = clazz.getAnnotation(EntityType.class); + return annot != null ? annot.value() : clazz.getName(); } int getPrimaryKeyCount(final Class clazz) { diff --git a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java index 6ce9f8fd..1a72e572 100644 --- a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java +++ b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import hu.bme.mit.ftsrg.hypernate.annotations.AttributeInfo; +import hu.bme.mit.ftsrg.hypernate.annotations.EntityType; import hu.bme.mit.ftsrg.hypernate.annotations.PrimaryKey; import hu.bme.mit.ftsrg.hypernate.registry.EntityExistsException; import hu.bme.mit.ftsrg.hypernate.registry.EntityNotFoundException; @@ -89,6 +90,11 @@ record KeylessTestEntity(String foo, Integer bar) {} }) private record TestEntity(String foo, Integer bar) {} + @FieldNameConstants + @EntityType("Asset") + @PrimaryKey(@AttributeInfo(name = AnnotatedEntity.Fields.id)) + private record AnnotatedEntity(String id) {} + @Nested class when_must_create { @@ -369,6 +375,25 @@ void given_empty_ledger_with_complete_key_then_throw_not_found() { verifyNoMoreInteractions(stub); } + @Test + void given_annotated_entity_type_then_use_annotation_value_for_composite_key() { + // Mock the stub to return a CompositeKey when asked, and represent an empty ledger + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(new CompositeKey("Asset", "id-1")); + given(stub.getState(anyString())).willReturn(new byte[] {}); + + // This mustRead call is expected to fail with EntityNotFoundException because the ledger + // is empty. We are only executing mustRead here to force the call to key generation + // (which calls createCompositeKey internally) so that we can verify the key type name. + assertThrows( + EntityNotFoundException.class, () -> registry.mustRead(AnnotatedEntity.class, "id-1")); + + // Verify that the annotated entity type ("Asset") was indeed used for creating the composite + // key + then(stub).should().createCompositeKey(eq("Asset"), any(String[].class)); + verifyNoMoreInteractions(stub); + } + @Test void given_existing_entity_with_insufficient_key_parts_then_throw_illegal_argument() { assertThrows( From c6222cf1f9194613672e057000b6622030d41564 Mon Sep 17 00:00:00 2001 From: Sreejesh06 Date: Mon, 20 Jul 2026 19:37:55 +0530 Subject: [PATCH 22/22] feat: validate EntityType annotation parameters [bzp99: refactored Registry logic slightly] Signed-off-by: Sreejesh06 Signed-off-by: bzp99 --- .../ftsrg/hypernate/registry/Registry.java | 13 ++++- .../bme/mit/ftsrg/hypernate/RegistryTest.java | 49 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java index d5d125ed..1f7cf39e 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java @@ -248,7 +248,18 @@ String getType(final T entity) { String getType(final Class clazz) { final EntityType annot = clazz.getAnnotation(EntityType.class); - return annot != null ? annot.value() : clazz.getName(); + if (annot == null) { + return clazz.getName(); + } + + final String value = annot.value(); + if (value.isBlank()) { + throw new IllegalArgumentException( + String.format( + "The @EntityType annotation on class %s has an empty or blank value", + clazz.getName())); + } + return value; } int getPrimaryKeyCount(final Class clazz) { diff --git a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java index 1a72e572..3ff1453e 100644 --- a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java +++ b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java @@ -95,6 +95,16 @@ private record TestEntity(String foo, Integer bar) {} @PrimaryKey(@AttributeInfo(name = AnnotatedEntity.Fields.id)) private record AnnotatedEntity(String id) {} + @FieldNameConstants + @EntityType("") + @PrimaryKey(@AttributeInfo(name = EmptyEntityTypeEntity.Fields.id)) + private record EmptyEntityTypeEntity(String id) {} + + @FieldNameConstants + @EntityType(" ") + @PrimaryKey(@AttributeInfo(name = BlankEntityTypeEntity.Fields.id)) + private record BlankEntityTypeEntity(String id) {} + @Nested class when_must_create { @@ -394,6 +404,25 @@ void given_annotated_entity_type_then_use_annotation_value_for_composite_key() { verifyNoMoreInteractions(stub); } + @Test + void given_no_entity_type_annotation_then_use_fqcn_for_composite_key() { + // Mock the stub to return a CompositeKey when asked, and represent an empty ledger + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(new CompositeKey(TestEntity.class.getName(), "fooValue", "110")); + given(stub.getState(anyString())).willReturn(new byte[] {}); + + // This mustRead call is expected to fail with EntityNotFoundException because the ledger + // is empty. We are only executing mustRead here to force the call to key generation + // (which calls createCompositeKey internally) so that we can verify the key type name. + assertThrows( + EntityNotFoundException.class, + () -> registry.mustRead(TestEntity.class, entity.foo, entity.bar)); + + // Verify that the FQCN (not uppercased) was indeed used for creating the composite key + then(stub).should().createCompositeKey(eq(TestEntity.class.getName()), any(String[].class)); + verifyNoMoreInteractions(stub); + } + @Test void given_existing_entity_with_insufficient_key_parts_then_throw_illegal_argument() { assertThrows( @@ -459,4 +488,24 @@ void given_existing_entity_with_complete_key_then_call_getState_and_return_entit verifyNoMoreInteractions(stub); } } + + @Nested + class when_entity_type_is_invalid { + + @Test + void given_empty_entity_type_annotation_value_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, + () -> registry.tryRead(EmptyEntityTypeEntity.class, "id-1")); + verifyNoMoreInteractions(stub); + } + + @Test + void given_blank_entity_type_annotation_value_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, + () -> registry.tryRead(BlankEntityTypeEntity.class, "id-1")); + verifyNoMoreInteractions(stub); + } + } }