All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.neo4j.shell.cli.CliArgHelper Maven / Gradle / Ivy

/*
 * Copyright (c) "Neo4j"
 * Neo4j Sweden AB [https://neo4j.com]
 *
 * This file is part of Neo4j.
 *
 * Neo4j is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see .
 */
package org.neo4j.shell.cli;

import static java.lang.String.format;
import static java.util.Optional.ofNullable;
import static org.neo4j.shell.DatabaseManager.ABSENT_DB_NAME;
import static org.neo4j.shell.cli.CliArgs.DEFAULT_HOST;
import static org.neo4j.shell.cli.CliArgs.DEFAULT_PORT;
import static org.neo4j.shell.cli.CliArgs.DEFAULT_SCHEME;
import static org.neo4j.shell.cli.FailBehavior.FAIL_AT_END;
import static org.neo4j.shell.cli.FailBehavior.FAIL_FAST;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
import java.util.logging.ConsoleHandler;
import java.util.logging.FileHandler;
import java.util.logging.Handler;
import net.sourceforge.argparse4j.ArgumentParsers;
import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.impl.action.StoreConstArgumentAction;
import net.sourceforge.argparse4j.impl.action.StoreTrueArgumentAction;
import net.sourceforge.argparse4j.impl.choice.CollectionArgumentChoice;
import net.sourceforge.argparse4j.impl.type.BooleanArgumentType;
import net.sourceforge.argparse4j.inf.Argument;
import net.sourceforge.argparse4j.inf.ArgumentGroup;
import net.sourceforge.argparse4j.inf.ArgumentParser;
import net.sourceforge.argparse4j.inf.ArgumentParserException;
import net.sourceforge.argparse4j.inf.ArgumentType;
import net.sourceforge.argparse4j.inf.FeatureControl;
import net.sourceforge.argparse4j.inf.MutuallyExclusiveGroup;
import net.sourceforge.argparse4j.inf.Namespace;
import org.neo4j.shell.Environment;
import org.neo4j.shell.log.Logger;
import org.neo4j.shell.parameter.ParameterService;
import org.neo4j.shell.terminal.CypherShellTerminal;

/**
 * Command line argument parsing and related stuff
 */
public class CliArgHelper {
    private static final Logger log = Logger.create();
    public static final String USERNAME_ENV_VAR = "NEO4J_USERNAME";
    public static final String PASSWORD_ENV_VAR = "NEO4J_PASSWORD";
    public static final String DATABASE_ENV_VAR = "NEO4J_DATABASE";
    public static final String ADDRESS_ENV_VAR = "NEO4J_ADDRESS";
    public static final String URI_ENV_VAR = "NEO4J_URI";
    private static final String HISTORY_ENV_VAR = "NEO4J_CYPHER_SHELL_HISTORY";
    private static final String DEFAULT_ADDRESS = format("%s://%s:%d", DEFAULT_SCHEME, DEFAULT_HOST, DEFAULT_PORT);
    private static final AccessMode DEFAULT_ACCESS_MODE = AccessMode.WRITE;
    public static final Duration DEFAULT_IDLE_TIMEOUT = Duration.ofSeconds(-1);
    public static final Duration DEFAULT_IDLE_TIMEOUT_DELAY = Duration.ofMinutes(2);

    private final Environment environment;

    public CliArgHelper(Environment environment) {
        this.environment = environment;
    }

    /**
     * @param args to parse
     * @return null in case of error, commandline arguments otherwise
     */
    public CliArgs parse(String... args) {
        try {
            return parseAndThrow(args);
        } catch (ArgumentParserException e) {
            e.getParser().handleError(e);
            return null;
        }
    }

    private static void preValidateArguments(ArgumentParser parser, String... args) throws ArgumentParserException {
        if (Arrays.asList(args).contains("-file")) {
            throw new ArgumentParserException("Unrecognized argument '-file', did you mean --file?", parser);
        }
    }

    /**
     * Parse command line arguments, including environmental variable fallbacks.
     *
     * @param args to parse
     * @return commandline arguments
     * @throws ArgumentParserException if an argument can't be parsed.
     */
    public CliArgs parseAndThrow(String... args) throws ArgumentParserException {
        final CliArgs cliArgs = new CliArgs();
        final ArgumentParser parser = setupParser();
        preValidateArguments(parser, args);
        return getCliArgs(cliArgs, parser, parser.parseArgs(args));
    }

    private Optional addressFromEnvironment() {
        final var address = ofNullable(environment.getVariable(ADDRESS_ENV_VAR));
        final var uri = ofNullable(environment.getVariable(URI_ENV_VAR));

        if (address.isPresent() && uri.isPresent()) {
            throw new IllegalArgumentException(
                    "Specify one or none of environment variables " + ADDRESS_ENV_VAR + " and " + URI_ENV_VAR);
        }

        return address.or(() -> uri);
    }

    private CliArgs getCliArgs(CliArgs cliArgs, ArgumentParser parser, Namespace ns) throws ArgumentParserException {
        final var address = ofNullable(ns.getString("address"))
                .or(this::addressFromEnvironment)
                .orElse(DEFAULT_ADDRESS);
        final URI uri = parseURI(parser, address);

        // ---------------------
        // Connection arguments
        cliArgs.setUri(uri);

        // Also parse username and password from address if available
        parseUserInfo(uri, cliArgs);

        // Only overwrite user from address string if the argument were specified
        ofNullable(ns.getString("username"))
                .or(() -> ofNullable(environment.getVariable(USERNAME_ENV_VAR)))
                .ifPresent(user -> cliArgs.setUsername(user, cliArgs.getUsername()));

        // Only overwrite password from address string if the argument were specified
        ofNullable(ns.getString("password"))
                .or(() -> ofNullable(environment.getVariable(PASSWORD_ENV_VAR)))
                .ifPresent(pass -> cliArgs.setPassword(pass, cliArgs.getPassword()));

        String impersonatedUser = ns.getString("impersonate");
        if (impersonatedUser != null) {
            cliArgs.setImpersonatedUser(impersonatedUser);
        }

        cliArgs.setAccessMode(ns.get("access-mode"));

        cliArgs.setEnableAutocompletions(ns.get("enable-autocompletions"));

        cliArgs.setEncryption(Encryption.parse(ns.get("encryption")));

        final var database = ofNullable(ns.getString("database"))
                .or(() -> ofNullable(environment.getVariable(DATABASE_ENV_VAR)))
                .orElse(ABSENT_DB_NAME);
        cliArgs.setDatabase(database);

        cliArgs.setInputFilename(ns.getString("file"));

        // ----------------
        // Other arguments
        // cypher string might not be given, represented by null
        cliArgs.setCypher(ns.getString("cypher"));
        // Fail behavior as sensible default and returns a proper type
        cliArgs.setFailBehavior(ns.get("fail-behavior"));

        // Set Output format
        cliArgs.setFormat(Format.parse(ns.get("format")));

        cliArgs.setParameters(ns.getList("param"));

        cliArgs.setNonInteractive(ns.getBoolean("force-non-interactive"));

        cliArgs.setWrap(ns.getBoolean("wrap"));

        cliArgs.setNumSampleRows(ns.getInt("sample-rows"));

        cliArgs.setVersion(ns.getBoolean("version"));

        cliArgs.setDriverVersion(ns.getBoolean("driver-version"));

        cliArgs.setChangePassword(ns.getBoolean("change-password"));

        cliArgs.setLogHandler(ns.get("log-file"));

        final var historyBehaviour = ofNullable(ns.get("history-behaviour"))
                .or(() -> ofNullable(environment.getVariable(HISTORY_ENV_VAR))
                        .map(HistoryBehaviourHandler::historyFromFilePath))
                .orElseGet(CypherShellTerminal.DefaultHistory::new);
        cliArgs.setHistoryBehaviour(historyBehaviour);

        cliArgs.setNotificationsEnabled(ns.getBoolean("enable-notifications"));

        ofNullable(ns.get("idle-timeout")).ifPresent(cliArgs::setIdleTimeout);
        ofNullable(ns.get("hidden-idle-timeout-delay")).ifPresent(cliArgs::setIdleTimeoutDelay);

        return cliArgs;
    }

    private static void parseUserInfo(URI uri, CliArgs cliArgs) {
        String userInfo = uri.getUserInfo();
        String user = null;
        String password = null;
        if (userInfo != null) {
            String[] split = userInfo.split(":");
            if (split.length == 0) {
                user = userInfo;
            } else if (split.length == 2) {
                user = split[0];
                password = split[1];
            } else {
                throw new IllegalArgumentException("Cannot parse user and password from " + userInfo);
            }
        }
        cliArgs.setUsername(user, "");
        cliArgs.setPassword(password, "");
    }

    static URI parseURI(ArgumentParser parser, String address) throws ArgumentParserException {
        try {
            if (!address.contains("://")) {
                // URI can't parse addresses without scheme, prepend fake "bolt://" to reuse the parsing facility
                address = DEFAULT_SCHEME + "://" + address;
            }

            var uri = new URI(address);
            if (uri.getPort() == -1) {
                uri = new URI(
                        uri.getScheme(),
                        uri.getUserInfo(),
                        uri.getHost(),
                        DEFAULT_PORT,
                        uri.getPath(),
                        uri.getQuery(),
                        uri.getFragment());
            }
            return uri;
        } catch (URISyntaxException e) {
            log.error(e);
            var message =
                    """
                    cypher-shell: error: Failed to parse address: '%s'
                    Address should be of the form: [scheme://][username:password@][host][:port]"""
                            .formatted(address);
            throw new ArgumentParserException(message, e, parser);
        }
    }

    private static ArgumentParser setupParser() {
        ArgumentParser parser = ArgumentParsers.newFor("cypher-shell")
                .defaultFormatWidth(100)
                .build()
                .defaultHelp(true)
                .description(format(
                        "Cypher Shell is a command-line tool used to run queries and perform administrative tasks "
                                + "against a Neo4j instance. By default, the shell is interactive, but you can also use it for scripting "
                                + "by passing Cypher directly on the command line or by piping a file with Cypher statements "
                                + "(requires Powershell on Windows). "
                                + "It communicates via the Bolt protocol."
                                + "%n%n"
                                + "Example of piping a file:%n"
                                + "  cat some-cypher.txt | cypher-shell"));

        ArgumentGroup connGroup = parser.addArgumentGroup("connection arguments");
        connGroup
                .addArgument("-a", "--address", "--uri")
                .action(new OnceArgumentAction())
                .help("Address and port to connect to. Defaults to " + DEFAULT_ADDRESS
                        + ". Can also be specified using the environment variable " + ADDRESS_ENV_VAR + " or "
                        + URI_ENV_VAR
                        + ".");
        connGroup
                .addArgument("-u", "--username")
                .help("Username to connect as. Can also be specified using the environment variable " + USERNAME_ENV_VAR
                        + ".");
        connGroup.addArgument("--impersonate").help("User to impersonate.");
        connGroup
                .addArgument("-p", "--password")
                .help("Password to connect with. Can also be specified using the environment variable "
                        + PASSWORD_ENV_VAR + ".");
        connGroup
                .addArgument("--encryption")
                .help("Whether the connection to Neo4j should be encrypted. This must be consistent with the Neo4j's "
                        + "configuration. If choosing '"
                        + Encryption.DEFAULT.name().toLowerCase(Locale.ROOT)
                        + "', the encryption setting is deduced from the specified address. "
                        + "For example, the 'neo4j+ssc' protocol uses encryption.")
                .choices(new CollectionArgumentChoice<>(
                        Encryption.TRUE.name().toLowerCase(Locale.ROOT),
                        Encryption.FALSE.name().toLowerCase(Locale.ROOT),
                        Encryption.DEFAULT.name().toLowerCase(Locale.ROOT)))
                .setDefault(Encryption.DEFAULT.name().toLowerCase(Locale.ROOT));
        connGroup
                .addArgument("-d", "--database")
                .help("Database to connect to. Can also be specified using the environment variable " + DATABASE_ENV_VAR
                        + ".");
        connGroup
                .addArgument("--access-mode")
                .dest("access-mode")
                .type(Arguments.caseInsensitiveEnumStringType(AccessMode.class))
                .setDefault(DEFAULT_ACCESS_MODE)
                .help("Access mode. Defaults to " + DEFAULT_ACCESS_MODE.name() + ".");
        MutuallyExclusiveGroup failGroup = parser.addMutuallyExclusiveGroup();
        failGroup
                .addArgument("--fail-fast")
                .help(
                        "Exit and report failure on the first error when reading from a file (this is the default behavior).")
                .dest("fail-behavior")
                .setConst(FAIL_FAST)
                .action(new StoreConstArgumentAction());
        failGroup
                .addArgument("--fail-at-end")
                .help("Exit and report failures at the end of the input when reading from a file.")
                .dest("fail-behavior")
                .setConst(FAIL_AT_END)
                .action(new StoreConstArgumentAction());
        parser.setDefault("fail-behavior", FAIL_FAST);

        parser.addArgument("--enable-autocompletions")
                .dest("enable-autocompletions")
                .help(
                        "Whether to enable Cypher autocompletions inside the CLI. Completions can only be enabled for neo4j 5 and later.")
                .action(Arguments.storeTrue());

        parser.addArgument("--format")
                .help(
                        """
                                Desired output format. Displays the results in tabular format if you use the shell interactively \
                                and with minimal formatting if you use it for scripting.
                                `verbose` displays results in tabular format and prints statistics.
                                `plain` displays data with minimal formatting.""")
                .choices(new CollectionArgumentChoice<>(
                        Format.AUTO.name().toLowerCase(Locale.ROOT),
                        Format.VERBOSE.name().toLowerCase(Locale.ROOT),
                        Format.PLAIN.name().toLowerCase(Locale.ROOT)))
                .setDefault(Format.AUTO.name().toLowerCase(Locale.ROOT));

        parser.addArgument("-P", "--param")
                .help("Add a parameter to this session."
                        + " Example: `-P {a: 1}` or `-P {a: 1, b: duration({seconds: 1})}`."
                        + " This argument can be specified multiple times.")
                .action(new AddParamArgumentAction(ParameterService.createParser()))
                .setDefault(new ArrayList());

        parser.addArgument("--non-interactive")
                .help("Force non-interactive mode. Only useful when auto-detection fails (like on Windows).")
                .dest("force-non-interactive")
                .action(new StoreTrueArgumentAction());

        parser.addArgument("--sample-rows")
                .help("Number of rows sampled to compute table widths (only for format=VERBOSE).")
                .type(new PositiveIntegerType())
                .dest("sample-rows")
                .setDefault(CliArgs.DEFAULT_NUM_SAMPLE_ROWS);

        parser.addArgument("--wrap")
                .help("Wrap table column values if column is too narrow (only for format=VERBOSE).")
                .type(new BooleanArgumentType())
                .setDefault(true);

        parser.addArgument("-v", "--version")
                .help("Print Cypher Shell version and exit.")
                .action(new StoreTrueArgumentAction());

        parser.addArgument("--driver-version")
                .help("Print Neo4j Driver version and exit.")
                .dest("driver-version")
                .action(new StoreTrueArgumentAction());

        parser.addArgument("cypher").nargs("?").help("An optional string of Cypher to execute and then exit.");
        parser.addArgument("-f", "--file")
                .help(
                        "Pass a file with Cypher statements to be executed. After executing all statements, Cypher Shell shuts down.");

        parser.addArgument("--change-password")
                .action(Arguments.storeTrue())
                .dest("change-password")
                .help("Change the neo4j user password and exit.");

        parser.addArgument("--log")
                .nargs("?")
                .type(new LogHandlerType())
                .dest("log-file")
                .help("Enable logging to the specified file, or standard error if the file is omitted.")
                .setDefault((LogHandlerType) null)
                .setConst(new ConsoleHandler());

        parser.addArgument("--history")
                .help(
                        "File path of a query and a command history file or `in-memory` for in-memory history. Defaults to /.neo4j/.cypher_shell_history. Can also be set using the environment variable "
                                + HISTORY_ENV_VAR + ".")
                .dest("history-behaviour")
                .type(new HistoryBehaviourHandler())
                .setDefault((CypherShellTerminal.HistoryBehaviour) null);

        parser.addArgument("--notifications")
                .help("Enable notifications in interactive mode.")
                .dest("enable-notifications")
                .action(Arguments.storeTrue());

        parser.addArgument("--idle-timeout")
                .dest("idle-timeout")
                .type(new TimeoutHandler())
                .help(
                        "Closes the application after the specified amount of idle time in interactive mode. You can specify the duration using the format `hms`, for example `1h` (1 hour), `1h30m` (1 hour 30 minutes), or `30m` (30 minutes).");

        parser.addArgument("--hidden-idle-timeout-delay")
                .dest("hidden-idle-timeout-delay")
                .type(new TimeoutHandler())
                .help(FeatureControl.SUPPRESS);

        return parser;
    }

    private static class PositiveIntegerType implements ArgumentType {
        @Override
        public Integer convert(ArgumentParser parser, Argument arg, String value) throws ArgumentParserException {
            try {
                int result = Integer.parseInt(value);
                if (result < 1) {
                    throw new NumberFormatException(value);
                }
                return result;
            } catch (NumberFormatException nfe) {
                throw new ArgumentParserException("Invalid value: " + value, parser);
            }
        }
    }

    private static class LogHandlerType implements ArgumentType {
        private static final int MAX_BYTES = 100_000_000;
        private static final int LOG_FILE_COUNT = 1;

        @Override
        public Handler convert(ArgumentParser parser, Argument arg, String value) throws ArgumentParserException {
            try {
                return new FileHandler(value, MAX_BYTES, LOG_FILE_COUNT, false);
            } catch (IOException e) {
                throw new ArgumentParserException("Failed to open log file: " + e.getMessage(), parser);
            }
        }
    }

    private static class HistoryBehaviourHandler implements ArgumentType {

        @Override
        public CypherShellTerminal.HistoryBehaviour convert(
                ArgumentParser argumentParser, Argument argument, String value) {
            if ("in-memory".equals(value.toLowerCase(Locale.ROOT))) {
                return new CypherShellTerminal.InMemoryHistory();
            } else {
                return historyFromFilePath(value);
            }
        }

        private static CypherShellTerminal.FileHistory historyFromFilePath(String path) {
            try {
                return new CypherShellTerminal.FileHistory(Path.of(path));
            } catch (InvalidPathException e) {
                throw new IllegalArgumentException("Invalid history file path " + path);
            }
        }
    }

    private static class TimeoutHandler implements ArgumentType {

        @Override
        public Duration convert(ArgumentParser parser, Argument arg, String value) throws ArgumentParserException {
            try {
                if ("disable".equalsIgnoreCase(value)) return null;
                else return Duration.parse("PT" + value);
            } catch (Exception e) {
                throw new ArgumentParserException(
                        "Invalid timeout value, valid values are for example 1h30m (1 hour and 30 minutes), 1h (1 hour), 10m (10 minutes): "
                                + value,
                        parser);
            }
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy