diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/BrowseChildrenOptions.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/BrowseChildrenOptions.java
new file mode 100644
index 0000000..2015fe5
--- /dev/null
+++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/BrowseChildrenOptions.java
@@ -0,0 +1,105 @@
+package com.zb.mom.ww.mxgateway.client;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Filters and shape options for {@link GalaxyRepositoryClient#browse(BrowseChildrenOptions)}.
+ * Mirror of the existing DiscoverHierarchy options for the lazy-browse path.
+ *
+ *
All filter fields are AND-combined server-side. Empty / unset fields disable
+ * that filter. The {@code includeAttributes} tri-state uses {@code null} to mean
+ * "let the server use its default"; non-{@code null} forwards the explicit flag.
+ */
+public final class BrowseChildrenOptions {
+ private final List categoryIds;
+ private final List templateChainContains;
+ private final String tagNameGlob;
+ private final Boolean includeAttributes;
+ private final boolean alarmBearingOnly;
+ private final boolean historizedOnly;
+
+ private BrowseChildrenOptions(Builder b) {
+ this.categoryIds = List.copyOf(b.categoryIds);
+ this.templateChainContains = List.copyOf(b.templateChainContains);
+ this.tagNameGlob = b.tagNameGlob;
+ this.includeAttributes = b.includeAttributes;
+ this.alarmBearingOnly = b.alarmBearingOnly;
+ this.historizedOnly = b.historizedOnly;
+ }
+
+ /** @return immutable list of category IDs to include; empty disables this filter. */
+ public List getCategoryIds() { return categoryIds; }
+
+ /** @return immutable list of template names that must appear in each child's template chain. */
+ public List getTemplateChainContains() { return templateChainContains; }
+
+ /** @return SQL-LIKE-style glob applied to {@code tag_name}; empty disables. */
+ public String getTagNameGlob() { return tagNameGlob; }
+
+ /** @return tri-state override for {@code include_attributes}; {@code null} keeps the server default. */
+ public Boolean getIncludeAttributes() { return includeAttributes; }
+
+ /** @return restrict to alarm-bearing objects. */
+ public boolean isAlarmBearingOnly() { return alarmBearingOnly; }
+
+ /** @return restrict to objects with at least one historized attribute. */
+ public boolean isHistorizedOnly() { return historizedOnly; }
+
+ /** @return a fresh builder. */
+ public static Builder builder() { return new Builder(); }
+
+ /** @return options with every filter disabled and {@code includeAttributes} unset. */
+ public static BrowseChildrenOptions empty() { return builder().build(); }
+
+ /** Fluent builder for {@link BrowseChildrenOptions}. */
+ public static final class Builder {
+ private List categoryIds = Collections.emptyList();
+ private List templateChainContains = Collections.emptyList();
+ private String tagNameGlob = "";
+ private Boolean includeAttributes = null;
+ private boolean alarmBearingOnly = false;
+ private boolean historizedOnly = false;
+
+ /** Sets the category-id filter. */
+ public Builder categoryIds(List v) {
+ this.categoryIds = v == null ? Collections.emptyList() : v;
+ return this;
+ }
+
+ /** Sets the template-chain-contains filter. */
+ public Builder templateChainContains(List v) {
+ this.templateChainContains = v == null ? Collections.emptyList() : v;
+ return this;
+ }
+
+ /** Sets the tag-name glob. */
+ public Builder tagNameGlob(String v) {
+ this.tagNameGlob = v == null ? "" : v;
+ return this;
+ }
+
+ /** Sets the tri-state {@code includeAttributes} override; {@code null} keeps the server default. */
+ public Builder includeAttributes(Boolean v) {
+ this.includeAttributes = v;
+ return this;
+ }
+
+ /** Toggles the alarm-bearing-only filter. */
+ public Builder alarmBearingOnly(boolean v) {
+ this.alarmBearingOnly = v;
+ return this;
+ }
+
+ /** Toggles the historized-only filter. */
+ public Builder historizedOnly(boolean v) {
+ this.historizedOnly = v;
+ return this;
+ }
+
+ /** Builds the immutable options. */
+ public BrowseChildrenOptions build() {
+ return new BrowseChildrenOptions(this);
+ }
+ }
+}
diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClient.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClient.java
index 8ef38c8..e46a829 100644
--- a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClient.java
+++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClient.java
@@ -4,6 +4,8 @@ import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;
import galaxy_repository.v1.GalaxyRepositoryGrpc;
+import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply;
+import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
@@ -37,6 +39,7 @@ import javax.net.ssl.SSLException;
*/
public final class GalaxyRepositoryClient implements AutoCloseable {
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
+ private static final int BROWSE_CHILDREN_PAGE_SIZE = 500;
private final ManagedChannel ownedChannel;
private final MxGatewayClientOptions options;
@@ -213,6 +216,98 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>());
}
+ /**
+ * Lazy-browse entry point: fetches the root layer of the Galaxy hierarchy.
+ * Each returned {@link LazyBrowseNode} can be expanded on demand via
+ * {@link LazyBrowseNode#expand()} to load its direct children.
+ *
+ * @return the root nodes (no parent selector) with default options
+ * @throws MxGatewayException on transport or protocol failure
+ */
+ public List browse() {
+ return browse(null);
+ }
+
+ /**
+ * Lazy-browse entry point with caller-supplied filters / shape.
+ *
+ * @param options filter and shape options; {@code null} means {@link BrowseChildrenOptions#empty()}
+ * @return the root nodes matching the options
+ * @throws MxGatewayException on transport or protocol failure
+ */
+ public List browse(BrowseChildrenOptions options) {
+ BrowseChildrenOptions effective = options == null ? BrowseChildrenOptions.empty() : options;
+ return browseChildrenInner(null, effective);
+ }
+
+ /**
+ * Issues a single {@code BrowseChildren} RPC and returns the raw reply.
+ * Callers wanting full control over pagination can drive the loop themselves.
+ *
+ * @param request the request to send
+ * @return the reply
+ * @throws MxGatewayException on transport or protocol failure
+ */
+ public BrowseChildrenReply browseChildrenRaw(BrowseChildrenRequest request) {
+ try {
+ return rawBlockingStub().browseChildren(request);
+ } catch (RuntimeException error) {
+ if (error instanceof MxGatewayException) {
+ throw error;
+ }
+ throw MxGatewayErrors.fromGrpc("galaxy browse children", error);
+ }
+ }
+
+ /**
+ * Drives the BrowseChildren paging loop for a single parent (or roots when
+ * {@code parentGobjectId} is {@code null}). Detects repeated page tokens to
+ * avoid infinite loops on a buggy server.
+ */
+ List browseChildrenInner(Integer parentGobjectId, BrowseChildrenOptions options) {
+ java.util.ArrayList nodes = new java.util.ArrayList<>();
+ java.util.HashSet seenPageTokens = new java.util.HashSet<>();
+ String pageToken = "";
+ while (true) {
+ BrowseChildrenRequest.Builder builder = BrowseChildrenRequest.newBuilder()
+ .setPageSize(BROWSE_CHILDREN_PAGE_SIZE)
+ .setPageToken(pageToken)
+ .setAlarmBearingOnly(options.isAlarmBearingOnly())
+ .setHistorizedOnly(options.isHistorizedOnly());
+ if (parentGobjectId != null) {
+ builder.setParentGobjectId(parentGobjectId.intValue());
+ }
+ if (!options.getCategoryIds().isEmpty()) {
+ builder.addAllCategoryIds(options.getCategoryIds());
+ }
+ if (!options.getTemplateChainContains().isEmpty()) {
+ builder.addAllTemplateChainContains(options.getTemplateChainContains());
+ }
+ if (!options.getTagNameGlob().isEmpty()) {
+ builder.setTagNameGlob(options.getTagNameGlob());
+ }
+ if (options.getIncludeAttributes() != null) {
+ builder.setIncludeAttributes(options.getIncludeAttributes());
+ }
+
+ BrowseChildrenReply reply = browseChildrenRaw(builder.build());
+
+ for (int i = 0; i < reply.getChildrenCount(); i++) {
+ boolean hint = i < reply.getChildHasChildrenCount() && reply.getChildHasChildren(i);
+ nodes.add(new LazyBrowseNode(this, reply.getChildren(i), hint, options));
+ }
+
+ pageToken = reply.getNextPageToken();
+ if (pageToken == null || pageToken.isEmpty()) {
+ return nodes;
+ }
+ if (!seenPageTokens.add(pageToken)) {
+ throw new MxGatewayException(
+ "galaxy browse children returned repeated page token: " + pageToken);
+ }
+ }
+ }
+
/**
* Subscribes to {@code WatchDeployEvents} via the async stub and consumes
* results through a blocking iterator. Closing the returned stream cancels
diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/LazyBrowseNode.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/LazyBrowseNode.java
new file mode 100644
index 0000000..34ebd21
--- /dev/null
+++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/LazyBrowseNode.java
@@ -0,0 +1,75 @@
+package com.zb.mom.ww.mxgateway.client;
+
+import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * One node in a lazy-loaded Galaxy browse tree. Holds the underlying
+ * {@link GalaxyObject} and exposes {@link #expand()} to fetch its direct
+ * children on demand. Expansion is one-shot: a second call is a no-op.
+ * Pagination of large sibling sets is handled internally by the client.
+ */
+public final class LazyBrowseNode {
+ private final GalaxyRepositoryClient client;
+ private final GalaxyObject object;
+ private final boolean hasChildrenHint;
+ private final BrowseChildrenOptions options;
+ private final Object lock = new Object();
+ private List children = Collections.emptyList();
+ private boolean isExpanded;
+
+ LazyBrowseNode(
+ GalaxyRepositoryClient client,
+ GalaxyObject object,
+ boolean hasChildrenHint,
+ BrowseChildrenOptions options) {
+ this.client = client;
+ this.object = object;
+ this.hasChildrenHint = hasChildrenHint;
+ this.options = options;
+ }
+
+ /** @return the underlying Galaxy object proto for this node. */
+ public GalaxyObject getObject() {
+ return object;
+ }
+
+ /** @return {@code true} when the server reports this node has at least one matching descendant. */
+ public boolean hasChildrenHint() {
+ return hasChildrenHint;
+ }
+
+ /** @return a snapshot of direct children loaded by {@link #expand()}; empty until then. */
+ public List getChildren() {
+ synchronized (lock) {
+ return List.copyOf(children);
+ }
+ }
+
+ /** @return {@code true} after the first {@link #expand()} call completes. */
+ public boolean isExpanded() {
+ synchronized (lock) {
+ return isExpanded;
+ }
+ }
+
+ /**
+ * Fetches direct children from the gateway and populates {@link #getChildren()}.
+ * Idempotent: subsequent calls are no-ops and do not issue a second RPC.
+ *
+ * @throws MxGatewayException on transport or protocol failure
+ */
+ public void expand() {
+ synchronized (lock) {
+ if (isExpanded) {
+ return;
+ }
+ List loaded =
+ client.browseChildrenInner(Integer.valueOf(object.getGobjectId()), options);
+ this.children = loaded;
+ this.isExpanded = true;
+ }
+ }
+}
diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClientTests.java b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClientTests.java
index 3b4cfbf..ba84e34 100644
--- a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClientTests.java
+++ b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClientTests.java
@@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.protobuf.Timestamp;
import galaxy_repository.v1.GalaxyRepositoryGrpc;
+import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply;
+import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
@@ -24,6 +26,7 @@ import io.grpc.Server;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
+import io.grpc.Status;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.stub.ClientCallStreamObserver;
@@ -31,9 +34,13 @@ import io.grpc.stub.ClientResponseObserver;
import io.grpc.stub.StreamObserver;
import java.time.Duration;
import java.time.Instant;
+import java.util.ArrayDeque;
+import java.util.Collections;
import java.util.List;
import java.util.Optional;
+import java.util.Queue;
import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
@@ -306,6 +313,209 @@ final class GalaxyRepositoryClientTests {
}
}
+ @Test
+ void browseNoParentReturnsRoots() throws Exception {
+ BrowseChildrenService service = new BrowseChildrenService();
+ service.replies.add(browseReply(
+ List.of(obj(1, "Plant", true), obj(2, "Other", false)),
+ List.of(true, false),
+ 1L,
+ ""));
+
+ try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
+ GalaxyRepositoryClient client = g.client("")) {
+ List roots = client.browse();
+
+ assertEquals(2, roots.size());
+ assertEquals("Plant", roots.get(0).getObject().getTagName());
+ assertTrue(roots.get(0).hasChildrenHint());
+ assertFalse(roots.get(0).isExpanded());
+ assertEquals("Other", roots.get(1).getObject().getTagName());
+ assertFalse(roots.get(1).hasChildrenHint());
+ assertFalse(roots.get(1).isExpanded());
+ assertEquals(1, service.calls.size());
+ assertFalse(service.calls.get(0).hasParentGobjectId());
+ }
+ }
+
+ @Test
+ void browseExpandPopulatesChildrenAndMarksExpanded() throws Exception {
+ BrowseChildrenService service = new BrowseChildrenService();
+ service.replies.add(browseReply(
+ List.of(obj(1, "Plant", true)),
+ List.of(true),
+ 1L,
+ ""));
+ service.replies.add(browseReply(
+ List.of(obj(10, "Line1", false)),
+ List.of(false),
+ 1L,
+ ""));
+
+ try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
+ GalaxyRepositoryClient client = g.client("")) {
+ List roots = client.browse();
+ roots.get(0).expand();
+
+ assertTrue(roots.get(0).isExpanded());
+ assertEquals(1, roots.get(0).getChildren().size());
+ assertEquals("Line1", roots.get(0).getChildren().get(0).getObject().getTagName());
+ assertEquals(2, service.calls.size());
+ assertTrue(service.calls.get(1).hasParentGobjectId());
+ assertEquals(1, service.calls.get(1).getParentGobjectId());
+ }
+ }
+
+ @Test
+ void browseExpandIdempotentNoSecondRpc() throws Exception {
+ BrowseChildrenService service = new BrowseChildrenService();
+ service.replies.add(browseReply(
+ List.of(obj(1, "Plant", true)),
+ List.of(true),
+ 1L,
+ ""));
+ service.replies.add(browseReply(
+ List.of(obj(10, "Line1", false)),
+ List.of(false),
+ 1L,
+ ""));
+
+ try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
+ GalaxyRepositoryClient client = g.client("")) {
+ List roots = client.browse();
+ roots.get(0).expand();
+ roots.get(0).expand();
+
+ assertEquals(2, service.calls.size());
+ assertEquals(1, roots.get(0).getChildren().size());
+ }
+ }
+
+ @Test
+ void browseExpandUnknownParentThrowsGalaxyNotFound() throws Exception {
+ BrowseChildrenService service = new BrowseChildrenService();
+ service.replies.add(browseReply(
+ List.of(obj(1, "Plant", true)),
+ List.of(true),
+ 1L,
+ ""));
+ service.errors.add(Status.NOT_FOUND.withDescription("Parent not found").asRuntimeException());
+
+ try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
+ GalaxyRepositoryClient client = g.client("")) {
+ List roots = client.browse();
+
+ MxGatewayException error = assertThrows(MxGatewayException.class, () -> roots.get(0).expand());
+ assertTrue(
+ error.getMessage().toLowerCase().contains("not found"),
+ "expected message to mention 'not found', got: " + error.getMessage());
+ }
+ }
+
+ @Test
+ void browseExpandMultiPageGathersAllPages() throws Exception {
+ BrowseChildrenService service = new BrowseChildrenService();
+ // Roots
+ service.replies.add(browseReply(
+ List.of(obj(7, "Plant", true)),
+ List.of(true),
+ 1L,
+ ""));
+ // First child page with a next token
+ service.replies.add(browseReply(
+ List.of(obj(70, "ChildA", false), obj(71, "ChildB", false)),
+ List.of(false, false),
+ 1L,
+ "7:abc:2"));
+ // Second child page closes the loop
+ service.replies.add(browseReply(
+ List.of(obj(72, "ChildC", false)),
+ List.of(false),
+ 1L,
+ ""));
+
+ try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
+ GalaxyRepositoryClient client = g.client("")) {
+ List roots = client.browse();
+ roots.get(0).expand();
+
+ assertEquals(3, roots.get(0).getChildren().size());
+ assertEquals(3, service.calls.size());
+ assertEquals("7:abc:2", service.calls.get(2).getPageToken());
+ }
+ }
+
+ @Test
+ void browseWithFilterForwardsToRequest() throws Exception {
+ BrowseChildrenService service = new BrowseChildrenService();
+ // Default reply is empty; only the request shape matters here.
+ try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
+ GalaxyRepositoryClient client = g.client("")) {
+ client.browse(BrowseChildrenOptions.builder()
+ .tagNameGlob("Mixer*")
+ .alarmBearingOnly(true)
+ .build());
+ }
+
+ assertEquals(1, service.calls.size());
+ BrowseChildrenRequest request = service.calls.get(0);
+ assertEquals("Mixer*", request.getTagNameGlob());
+ assertTrue(request.getAlarmBearingOnly());
+ }
+
+ private static GalaxyObject obj(int id, String tag, boolean isArea) {
+ return GalaxyObject.newBuilder()
+ .setGobjectId(id)
+ .setTagName(tag)
+ .setBrowseName(tag)
+ .setIsArea(isArea)
+ .build();
+ }
+
+ private static BrowseChildrenReply browseReply(
+ List children,
+ List childHasChildren,
+ long cacheSequence,
+ String nextPageToken) {
+ BrowseChildrenReply.Builder b = BrowseChildrenReply.newBuilder()
+ .setTotalChildCount(children.size())
+ .setCacheSequence(cacheSequence)
+ .setNextPageToken(nextPageToken);
+ b.addAllChildren(children);
+ b.addAllChildHasChildren(childHasChildren);
+ return b.build();
+ }
+
+ private static final class BrowseChildrenService extends TestService {
+ final List calls =
+ Collections.synchronizedList(new CopyOnWriteArrayList<>());
+ final Queue replies = new ArrayDeque<>();
+ final Queue errors = new ArrayDeque<>();
+
+ @Override
+ public void browseChildren(
+ BrowseChildrenRequest request, StreamObserver responseObserver) {
+ calls.add(request);
+ BrowseChildrenReply reply;
+ Throwable err;
+ synchronized (this) {
+ // Prefer queued replies first; once they're exhausted, fall through to any
+ // queued error. This matches the .NET fake's ordering used by parity tests.
+ reply = replies.poll();
+ err = reply == null ? errors.poll() : null;
+ }
+ if (err != null) {
+ responseObserver.onError(err);
+ return;
+ }
+ if (reply == null) {
+ reply = BrowseChildrenReply.getDefaultInstance();
+ }
+ responseObserver.onNext(reply);
+ responseObserver.onCompleted();
+ }
+ }
+
private abstract static class TestService extends GalaxyRepositoryGrpc.GalaxyRepositoryImplBase {
@Override
public void testConnection(