This is an automated email from the ASF dual-hosted git repository.

janhoy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/main by this push:
     new 162db9ec392 SOLR-18513: Port ExportTool to picocli (#5040)
162db9ec392 is described below

commit 162db9ec39205071a932f02a3335235360b9b60c
Author: Serhiy Bzhezytskyy <[email protected]>
AuthorDate: Thu Oct 8 18:51:12 2026 +0300

    SOLR-18513: Port ExportTool to picocli (#5040)
    
    Co-authored-by: Jan Høydahl <[email protected]>
---
 .../SOLR-17697-picocli-experimental-cli.yml        |   2 +
 .../src/java/org/apache/solr/cli/ExportTool.java   | 136 ++++++++++++++++++-
 .../core/src/java/org/apache/solr/cli/SolrCLI.java |   3 +-
 .../org/apache/solr/cli/ExportToolPicocliTest.java |  46 +++++++
 .../test/org/apache/solr/cli/ExportToolTest.java   | 147 +++++++++++++++++++++
 .../modules/deployment-guide/deployment-nav.adoc   |   1 +
 .../modules/deployment-guide/pages/cli/index.adoc  |   1 +
 .../deployment-guide/pages/cli/solr-export.adoc    | 101 ++++++++++++++
 8 files changed, 435 insertions(+), 2 deletions(-)

diff --git a/changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml 
b/changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml
index c008021477d..3510707f7e0 100644
--- a/changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml
+++ b/changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml
@@ -19,3 +19,5 @@ links:
     url: https://issues.apache.org/jira/browse/SOLR-18511
   - name: SOLR-18512
     url: https://issues.apache.org/jira/browse/SOLR-18512
+  - name: SOLR-18513
+    url: https://issues.apache.org/jira/browse/SOLR-18513
diff --git a/solr/core/src/java/org/apache/solr/cli/ExportTool.java 
b/solr/core/src/java/org/apache/solr/cli/ExportTool.java
index b63c2ba002d..03dacb31f48 100644
--- a/solr/core/src/java/org/apache/solr/cli/ExportTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ExportTool.java
@@ -84,8 +84,22 @@ import org.noggit.CharArr;
 import org.noggit.JSONWriter;
 
 /** Supports export command in the bin/solr script. */
+@SuppressWarnings("UnnecessarilyFullyQualified")
[email protected](
+    name = "export",
+    description = "Exports documents from a collection to a local file.",
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Export a collection's documents to gettingstarted.json",
+      "  bin/solr export -c gettingstarted --solr-url http://localhost:8983";
+    })
 public class ExportTool extends ToolBase {
 
+  /**
+   * @deprecated Only used by the commons-cli parser; the picocli path 
declares this as an annotated
+   *     field.
+   */
+  @Deprecated
   private static final Option COLLECTION_NAME_OPTION =
       Option.builder("c")
           .longOpt("name")
@@ -94,6 +108,11 @@ public class ExportTool extends ToolBase {
           .desc("Name of the collection.")
           .get();
 
+  /**
+   * @deprecated Only used by the commons-cli parser; the picocli path 
declares this as an annotated
+   *     field.
+   */
+  @Deprecated
   private static final Option OUTPUT_OPTION =
       Option.builder()
           .longOpt("output")
@@ -103,6 +122,11 @@ public class ExportTool extends ToolBase {
               "Path to output the exported data, and optionally the file name, 
defaults to 'collection-name'.")
           .get();
 
+  /**
+   * @deprecated Only used by the commons-cli parser; the picocli path 
declares this as an annotated
+   *     field.
+   */
+  @Deprecated
   private static final Option FORMAT_OPTION =
       Option.builder()
           .longOpt("format")
@@ -111,9 +135,19 @@ public class ExportTool extends ToolBase {
           .desc("Output format for exported docs (json, jsonl or javabin), 
defaulting to json.")
           .get();
 
+  /**
+   * @deprecated Only used by the commons-cli parser; the picocli path 
declares this as an annotated
+   *     field.
+   */
+  @Deprecated
   private static final Option COMPRESS_OPTION =
       Option.builder().longOpt("compress").desc("Compress the output. Defaults 
to false.").get();
 
+  /**
+   * @deprecated Only used by the commons-cli parser; the picocli path 
declares this as an annotated
+   *     field.
+   */
+  @Deprecated
   private static final Option LIMIT_OPTION =
       Option.builder()
           .longOpt("limit")
@@ -122,6 +156,11 @@ public class ExportTool extends ToolBase {
           .desc("Maximum number of docs to download. Default is 100, use -1 
for all docs.")
           .get();
 
+  /**
+   * @deprecated Only used by the commons-cli parser; the picocli path 
declares this as an annotated
+   *     field.
+   */
+  @Deprecated
   private static final Option QUERY_OPTION =
       Option.builder()
           .longOpt("query")
@@ -130,6 +169,11 @@ public class ExportTool extends ToolBase {
           .desc("A custom query, default is '*:*'.")
           .get();
 
+  /**
+   * @deprecated Only used by the commons-cli parser; the picocli path 
declares this as an annotated
+   *     field.
+   */
+  @Deprecated
   private static final Option FIELDS_OPTION =
       Option.builder()
           .longOpt("fields")
@@ -149,6 +193,74 @@ public class ExportTool extends ToolBase {
       String fields,
       String limit) {}
 
+  // --- picocli fields ---
+  // The connection group is mandatory (multiplicity "1"): the commons-cli 
path throws
+  // IllegalArgumentException when no connection target is given, so ArgGroup 
enforces the same
+  // requirement declaratively.
+
+  @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+  private ConnectionOptions connectionOptions;
+
+  @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions;
+
+  @picocli.CommandLine.Option(
+      names = {"-c", "--name"},
+      required = true,
+      paramLabel = "NAME",
+      description = "Name of the collection.")
+  private String nameOpt;
+
+  @picocli.CommandLine.Option(
+      names = "--output",
+      paramLabel = "PATH",
+      description =
+          "Path to output the exported data, and optionally the file name, 
defaults to"
+              + " 'collection-name'.")
+  private String outputOpt;
+
+  /** The values of {@code --format}; each constant is spelled as it is on the 
command line. */
+  enum Format {
+    json,
+    jsonl,
+    javabin
+  }
+
+  @picocli.CommandLine.Option(
+      names = "--format",
+      paramLabel = "FORMAT",
+      description =
+          "Output format for exported docs (${COMPLETION-CANDIDATES}), 
defaulting to json.")
+  private Format formatOpt;
+
+  @picocli.CommandLine.Option(
+      names = "--compress",
+      description = "Compress the output. Defaults to false.")
+  private boolean compressOpt;
+
+  @picocli.CommandLine.Option(
+      names = "--limit",
+      defaultValue = "100",
+      paramLabel = "#",
+      description = "Maximum number of docs to download. Default is 100, use 
-1 for all docs.")
+  private long limitOpt;
+
+  @picocli.CommandLine.Option(
+      names = "--query",
+      defaultValue = "*:*",
+      paramLabel = "QUERY",
+      description = "A custom query, default is '*:*'.")
+  private String queryOpt;
+
+  @picocli.CommandLine.Option(
+      names = "--fields",
+      paramLabel = "FIELDA,FIELDB",
+      description = "Comma separated list of fields to export. By default all 
fields are fetched.")
+  private String fieldsOpt;
+
+  public ExportTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public ExportTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -729,6 +841,28 @@ public class ExportTool extends ToolBase {
 
   @Override
   public int callTool() throws Exception {
-    throw new UnsupportedOperationException("This tool does not yet support 
PicoCli");
+    String url = resolveSolrUrl(credentialsOptions.credentials) + "/solr/" + 
nameOpt;
+    ExportParams params =
+        new ExportParams(
+            url,
+            credentialsOptions.credentials,
+            queryOpt,
+            outputOpt,
+            formatOpt == null ? null : formatOpt.name(),
+            compressOpt,
+            fieldsOpt,
+            String.valueOf(limitOpt));
+    export(params);
+    return 0;
+  }
+
+  private String resolveSolrUrl(String credentials) throws Exception {
+    String solrUrlArg = connectionOptions.effectiveSolrUrl();
+    if (solrUrlArg != null) {
+      return CLIUtils.normalizeSolrUrl(solrUrlArg);
+    }
+    return CLIUtils.solrUrlFromConnection(
+        
CloudSolrClient.CloudSolrClientConnection.parse(connectionOptions.effectiveZkHost()),
+        credentials);
   }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/SolrCLI.java 
b/solr/core/src/java/org/apache/solr/cli/SolrCLI.java
index 93ffd2965e4..2dd48b27cad 100755
--- a/solr/core/src/java/org/apache/solr/cli/SolrCLI.java
+++ b/solr/core/src/java/org/apache/solr/cli/SolrCLI.java
@@ -84,7 +84,8 @@ import org.slf4j.LoggerFactory;
       AssertTool.class,
       ApiTool.class,
       ClusterTool.class,
-      ConfigTool.class
+      ConfigTool.class,
+      ExportTool.class
     })
 public class SolrCLI implements CLIO, java.util.concurrent.Callable<Integer> {
 
diff --git a/solr/core/src/test/org/apache/solr/cli/ExportToolPicocliTest.java 
b/solr/core/src/test/org/apache/solr/cli/ExportToolPicocliTest.java
new file mode 100644
index 00000000000..62781d4c4f4
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/cli/ExportToolPicocliTest.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.cli;
+
+import java.util.Arrays;
+import org.junit.Test;
+import picocli.CommandLine;
+
+/**
+ * Runs all {@link ExportToolTest} tests through the picocli invocation path.
+ *
+ * <p>All {@code @Test} methods are inherited; only the invocation strategy is 
overridden.
+ */
+public class ExportToolPicocliTest extends ExportToolTest {
+
+  @Override
+  protected int runTool(String[] args, Class<? extends ToolBase> clazz) throws 
Exception {
+    // args[0] is the tool name used by commons-cli dispatch; strip it for 
picocli.
+    String[] toolArgs = Arrays.copyOfRange(args, 1, args.length);
+    ToolRuntime runtime = new CLITestHelper.TestingRuntime(false);
+    ToolBase tool = 
clazz.getDeclaredConstructor(ToolRuntime.class).newInstance(runtime);
+    return new CommandLine(tool)
+        .setDefaultValueProvider(new CliDefaultValueProvider())
+        .execute(toolArgs);
+  }
+
+  @Test
+  public void testNonNumericLimitIsAUsageError() throws Exception {
+    // picocli reads --limit as a number, so it rejects "abc" before the tool 
runs: usage error 2
+    assertEquals(2, exportTo(createTempDir(), "--limit", "abc"));
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/cli/ExportToolTest.java 
b/solr/core/src/test/org/apache/solr/cli/ExportToolTest.java
new file mode 100644
index 00000000000..c5ceffa4cc0
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/cli/ExportToolTest.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.cli;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.apache.solr.client.solrj.request.AbstractUpdateRequest;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class ExportToolTest extends SolrCloudTestCase {
+  private static final String COLLECTION = "exportToolColl";
+  private static final int DOCS = 10;
+
+  /** Runs the tool. Overridden by the picocli variant of this test. */
+  protected int runTool(String[] args, Class<? extends ToolBase> clazz) throws 
Exception {
+    return CLITestHelper.runTool(args, clazz);
+  }
+
+  @BeforeClass
+  public static void setupCluster() throws Exception {
+    configureCluster(1).addConfig("conf", 
configset("cloud-minimal")).configure();
+    CollectionAdminRequest.createCollection(COLLECTION, "conf", 1, 1)
+        .process(cluster.getSolrClient());
+    cluster.waitForActiveCollection(COLLECTION, 1, 1);
+
+    UpdateRequest ur = new UpdateRequest();
+    ur.setAction(AbstractUpdateRequest.ACTION.COMMIT, true, true);
+    for (int i = 0; i < DOCS; i++) {
+      ur.add("id", String.valueOf(i), "desc_s", "doc" + i);
+    }
+    ur.process(cluster.getSolrClient(), COLLECTION);
+  }
+
+  private String solrUrl() {
+    return cluster.getJettySolrRunner(0).getBaseUrl().toString();
+  }
+
+  private List<String> exportLines(String... extraArgs) throws Exception {
+    Path outDir = createTempDir();
+    String[] fixed = {
+      "export",
+      "-c",
+      COLLECTION,
+      "--solr-url",
+      solrUrl(),
+      "--format",
+      "jsonl",
+      "--output",
+      outDir.toString()
+    };
+    String[] args = new String[fixed.length + extraArgs.length];
+    System.arraycopy(fixed, 0, args, 0, fixed.length);
+    System.arraycopy(extraArgs, 0, args, fixed.length, extraArgs.length);
+
+    assertEquals(0, runTool(args, ExportTool.class));
+    return Files.readAllLines(outDir.resolve(COLLECTION + ".jsonl"));
+  }
+
+  @Test
+  public void testExportAllDocs() throws Exception {
+    assertEquals(DOCS, exportLines("--limit", "-1").size());
+  }
+
+  @Test
+  public void testLimitDefaultsToOneHundredAndIsHonoured() throws Exception {
+    assertEquals(DOCS, exportLines().size());
+    assertEquals(3, exportLines("--limit", "3").size());
+  }
+
+  @Test
+  public void testQueryAndFields() throws Exception {
+    List<String> lines = exportLines("--query", "id:7", "--fields", "id");
+    assertEquals(1, lines.size());
+    assertTrue(lines.get(0), lines.get(0).contains("\"id\":\"7\""));
+    assertFalse(lines.get(0), lines.get(0).contains("desc_s"));
+  }
+
+  @Test
+  public void testFailsWithoutAConnectionTarget() throws Exception {
+    // commons-cli reports 1; picocli reports its usage-error code 2
+    assertNotEquals(0, runTool(new String[] {"export", "-c", COLLECTION}, 
ExportTool.class));
+  }
+
+  int exportTo(Path outDir, String... extraArgs) throws Exception {
+    String[] fixed = {
+      "export", "-c", COLLECTION, "--solr-url", solrUrl(), "--output", 
outDir.toString()
+    };
+    String[] args = new String[fixed.length + extraArgs.length];
+    System.arraycopy(fixed, 0, args, 0, fixed.length);
+    System.arraycopy(extraArgs, 0, args, fixed.length, extraArgs.length);
+    return runTool(args, ExportTool.class);
+  }
+
+  @Test
+  public void testFormatDefaultsToJson() throws Exception {
+    Path outDir = createTempDir();
+    assertEquals(0, exportTo(outDir));
+    String json = Files.readString(outDir.resolve(COLLECTION + ".json"));
+    assertTrue(json, json.contains("\"id\":\"1\""));
+  }
+
+  @Test
+  public void testJavabinFormat() throws Exception {
+    Path outDir = createTempDir();
+    assertEquals(0, exportTo(outDir, "--format", "javabin"));
+    assertTrue(Files.size(outDir.resolve(COLLECTION + ".javabin")) > 0);
+  }
+
+  @Test
+  public void testUnknownFormatFails() throws Exception {
+    Path outDir = createTempDir();
+    // commons-cli reports 1; picocli reports its usage-error code 2
+    assertNotEquals(0, exportTo(outDir, "--format", "xml"));
+    try (var written = Files.list(outDir)) {
+      assertEquals(List.of(), written.toList());
+    }
+  }
+
+  @Test
+  public void testNonNumericLimitFails() throws Exception {
+    Path outDir = createTempDir();
+    // commons-cli fails on parsing the number (1); picocli rejects it up 
front (2)
+    assertNotEquals(0, exportTo(outDir, "--limit", "abc"));
+    try (var written = Files.list(outDir)) {
+      assertEquals(List.of(), written.toList());
+    }
+  }
+}
diff --git a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc 
b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc
index 47f6a502c08..712f9fee593 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc
@@ -27,6 +27,7 @@
 *** xref:cli/solr-config.adoc[config]
 *** xref:cli/solr-create.adoc[create]
 *** xref:cli/solr-delete.adoc[delete]
+*** xref:cli/solr-export.adoc[export]
 *** xref:cli/solr-start.adoc[start]
 *** xref:cli/solr-status.adoc[status]
 *** xref:cli/solr-stop.adoc[stop]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc 
b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc
index 600c2d842c5..051f9f6f1b7 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc
@@ -66,6 +66,7 @@ Any command not listed here is unavailable when the picocli 
implementation is en
 |xref:cli/solr-config.adoc[bin/solr config]
 |xref:cli/solr-create.adoc[bin/solr create]
 |xref:cli/solr-delete.adoc[bin/solr delete]
+|xref:cli/solr-export.adoc[bin/solr export]
 |xref:cli/solr-start.adoc[bin/solr start]
 |xref:cli/solr-status.adoc[bin/solr status]
 |xref:cli/solr-stop.adoc[bin/solr stop]
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-export.adoc 
b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-export.adoc
new file mode 100644
index 00000000000..74d126d3943
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-export.adoc
@@ -0,0 +1,101 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then 
run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr export
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, 
which is not enabled by default.
+For what `bin/solr` does by default, see 
xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known 
limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr export [-hv] [--compress] -c=NAME [--fields=FIELDA,FIELDB]
+                [--format=FORMAT] [--limit=#] [--output=PATH] [--query=QUERY]
+                [-u=<credentials>] (-s=<solrConnection> | --solr-url=<solrUrl> 
|
+                -z=<zkHost>)
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Exports documents from a collection to a local file.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-c*, *--name*=_NAME_::
+  *(required)* Name of the collection.
+
+*--compress*::
+  Compress the output. Defaults to false.
+
+*--fields*=_FIELDA,FIELDB_::
+  Comma separated list of fields to export. By default all fields are fetched.
+
+*--format*=_FORMAT_::
+  Output format for exported docs (json, jsonl, javabin), defaulting to json.
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*--limit*=_#_::
+  Maximum number of docs to download. Default is 100, use -1 for all docs.
+
+*--output*=_PATH_::
+  Path to output the exported data, and optionally the file name, defaults to 
'collection-name'.
+
+*--query*=_QUERY_::
+  A custom query, default is '*:*'.
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is 
defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if that's not 
known.
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials 
solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in 
solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Export a collection's documents to gettingstarted.json
+  bin/solr export -c gettingstarted --solr-url http://localhost:8983
+
+// end::picocli-generated-man-section-footer[]

Reply via email to