Skip to content
Remko Popma edited this page Jan 21, 2020 · 1 revision
package picocli.example.db;

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParameterException;
import picocli.CommandLine.Spec;

import java.math.BigDecimal;
import java.sql.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

@Command(name = "db", mixinStandardHelpOptions = true,
        versionProvider = DbVersionProvider.class,
        subcommands = {ListRows.class, AddRow.class, UpdateRow.class, DeleteRow.class},
        description = "CRUD (Create, Retrieve, Update, Delete) utility for managing the example db.")
public class Db implements Runnable {
    @Spec
    CommandSpec spec;

    @Option(names = {"--jdbc-driver"},
            description = "Fully qualified class name of the JDBC Driver. Default: ${DEFAULT-VALUE}.",
            defaultValue = "org.sqlite.JDBC")
    private String jdbcDriver;

    @Option(names = {"--jdbc-url"},
            description = {"JDBC URL to the SQLite file.", "  Default: ${DEFAULT-VALUE}: the file is expected to be in the current directory."},
            defaultValue = "jdbc:sqlite:db.sqlite")
    private String jdbcURL;

    @Option(names = {"--table"},
            description = {"Name of the table that the CRUD operations apply to. Default: ${DEFAULT-VALUE}."},
            defaultValue = "mytable")
    String tableName;

    @Option(names = {"-v", "--verbose"},
            description = {
                    "Specify multiple -v options to increase verbosity.",
                    "For example, `-v -v -v` or `-vvv`"})
    boolean[] verbosity = new boolean[0];


    @Override
    public void run() {
        throw new ParameterException(spec.commandLine(), "Missing required subcommand");
    }

    <R> R executeQuery(String sql, Function<ResultSet, R> resultSetConsumer)
            throws SQLException, ClassNotFoundException {
        if (verbosity.length > 1) {
            err("Loading jdbc driver '%s'...%n", jdbcDriver);
        }
        Class.forName(jdbcDriver);
        if (verbosity.length > 1) {
            err("Connecting to '%s'...%n", jdbcURL);
        }
        try (Connection connection = DriverManager.getConnection(jdbcURL)) {
            if (verbosity.length > 0) {
                err("Executing SQL: %s%n", sql);
            }
            try (PreparedStatement ps = connection.prepareStatement(sql);
                 ResultSet rs = ps.executeQuery()) {
                R result = resultSetConsumer.apply(rs);
                if (verbosity.length > 0) {
                    if (result instanceof int[]) {
                        err("Transformed result: %s%n", Arrays.toString((int[]) result));
                    } else {
                        err("Transformed result: %s%n", result);
                    }
                }
                return result;
            }
        }
    }

    List<String> selectPrimaryKeyColumns()
            throws SQLException, ClassNotFoundException {
        if (verbosity.length > 1) {
            err("Loading jdbc driver '%s'...%n", jdbcDriver);
        }
        Class.forName(jdbcDriver);
        if (verbosity.length > 1) {
            err("Connecting to '%s'...%n", jdbcURL);
        }
        List<String> result = new ArrayList<>();
        try (Connection connection = DriverManager.getConnection(jdbcURL)) {
            DatabaseMetaData metaData = connection.getMetaData();
            try (ResultSet rs = metaData.getPrimaryKeys(null, null, tableName)) {
                while (rs.next()) {
                    result.add(rs.getString(4));
                }
            }
        }
        return result;
    }

    interface TriConsumer<A, B, C> {
        void accept(A a, B b, C c);
    }

    <B, C> int executeUpdate(String sql, TriConsumer<PreparedStatement, B, C> preparedStatementConsumer, B b, C c)
            throws SQLException, ClassNotFoundException {
        if (verbosity.length > 1) {
            err("Loading jdbc driver '%s'...%n", jdbcDriver);
        }
        Class.forName(jdbcDriver);
        if (verbosity.length > 1) {
            err("Connecting to '%s'...%n", jdbcURL);
        }
        try (Connection connection = DriverManager.getConnection(jdbcURL)) {
            if (verbosity.length > 0) {
                err("Executing SQL: %s%n", sql);
            }
            try (PreparedStatement ps = connection.prepareStatement(sql)) {
                preparedStatementConsumer.accept(ps, b, c);
                int result = ps.executeUpdate();
                if (verbosity.length > 0) {
                    err("%d rows affected.%n", result);
                }
                return result;
            }
        }
    }

    int[] extractTypes(ResultSet rs) {
        try {
            ResultSetMetaData metaData = rs.getMetaData();
            int[] result = new int[metaData.getColumnCount()];
            for (int i = 1; i <= metaData.getColumnCount(); i++) {
                result[i - 1] = metaData.getColumnType(i);
            }
            return result;
        } catch (SQLException ex) {
            throw new RuntimeException(ex);
        }
    }

    void prepareStatement(PreparedStatement ps, Map<String, String> columnValues, int[] types) {
        try {
            if (verbosity.length > 0) {
                err("Setting values: %s%n", columnValues);
            }
            List<String> columnNames = new ArrayList<>(columnValues.keySet());
            for (int i = 0; i < types.length; i++) {
                String value = columnValues.get(columnNames.get(i));
                switch (types[i]) {
                    case Types.ARRAY:
                        //ps.setArray(i + 1, createArray());
                        break;
                    case Types.BIGINT:
                        ps.setBigDecimal(i + 1, new BigDecimal(value));
                        break;
                    case Types.DECIMAL:
                    case Types.DOUBLE:
                    case Types.NUMERIC:
                        ps.setDouble(i + 1, new Double(value));
                        break;
                    case Types.FLOAT:
                        ps.setFloat(i + 1, new Float(value));
                        break;
                    case Types.INTEGER:
                    case Types.REAL:
                    case Types.SMALLINT:
                    case Types.TINYINT:
                        ps.setInt(i + 1, new Integer(value));
                        break;
                    case Types.BOOLEAN:
                    case Types.BIT:
                        ps.setBoolean(i + 1, new Boolean(value));
                        break;
                    case Types.CHAR:
                    case Types.LONGNVARCHAR:
                    case Types.LONGVARCHAR:
                    case Types.NCHAR:
                    case Types.NVARCHAR:
                    case Types.VARCHAR:
                        ps.setString(i + 1, value);
                        break;
                    case Types.DATE:
                        ps.setDate(i + 1, java.sql.Date.valueOf(value));
                        break;
                    case Types.TIME:
                        ps.setTime(i + 1, java.sql.Time.valueOf(value));
                        break;
                    case Types.TIMESTAMP:
                        ps.setTimestamp(i + 1, java.sql.Timestamp.valueOf(value));
                        break;
                }
            }
        } catch (SQLException ex) {
            throw new RuntimeException(ex);
        }
    }

    void out(Object value) {
        spec.commandLine().getOut().println(value);
    }

    void err(String pattern, Object... params) {
        spec.commandLine().getErr().printf(pattern, params);
    }

    public static void main(String[] args) {
        System.exit(new CommandLine(new Db()).execute(args));
    }
}
package picocli.example.db;

import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParentCommand;

import java.math.BigDecimal;
import java.sql.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;

@Command(name = "add", mixinStandardHelpOptions = true, //usageHelpAutoWidth = true,
        versionProvider = DbVersionProvider.AddRow.class,
        description = "Adds a row to the example db.",
        footerHeading = "%nExample:%n",
        footer = {
            "# This adds a new row to the `mytable` table.",
            "# Note that the `id` (primary key and auto-increment) column is omitted.",
            "db -v --table=mytable add -Chostname=abc -Cserver_type=PHYS -Cclass=PROD -Cserial_no=12345 -Cmodel=DL380G9 -Cstatus=SOFF -Cdescription=\"This is a box\""
        })
public class AddRow implements Callable<Integer> {

    @ParentCommand
    private Db db;

    @Option(names = {"-C", "--column"}, paramLabel = "<column>=<value>", required = true,
            description = {"Key-value pair specifying the value to insert in a column. " +
                    "This option may be specified multiple times " +
                    "(once for each column that you want to specify a value for). ",
                    "Columns of type DATE must be specified in \"yyyy-[m]m-[d]d\" format (the leading zero for mm and dd may be omitted);",
                    "Columns of type TIME must be specified in \"hh:mm:ss\" format;",
                    "Columns of type TIMESTAMP must be specified in format \"yyyy-[m]m-[d]d hh:mm:ss[.f...]\" " +
                    "(the fractional seconds may be omitted. The leading zero for mm and dd may also be omitted.)"})
    Map<String, String> columnValues;

    @Override
    public Integer call() throws SQLException, ClassNotFoundException {
        String columns = String.join(",", columnValues.keySet());
        String[] tmp = new String[columnValues.size()];
        Arrays.fill(tmp, "?");
        String values = String.join(",", Arrays.asList(tmp));
        String sql = "insert into " + db.tableName + " (" + columns + ") values (" + values + ")";

        int[] types = db.executeQuery("select " + columns + " from " + db.tableName, db::extractTypes);
        db.executeUpdate(sql, db::prepareStatement, columnValues, types);
        return 0;
    }
}
package picocli.example.db;

import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParentCommand;

import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;

@Command(name = "delete", mixinStandardHelpOptions = true, //usageHelpAutoWidth = true,
        versionProvider = DbVersionProvider.DeleteRow.class,
        description = "Deletes a row in the db.",
        footerHeading = "%nExample:%n",
        footer = {
                "# This deletes a row from the `mytable` table where id=1.",
                "db -v --table=mytable delete -Cid=1",
                "",
                "# This deletes all rows from the `mytable` table where server_type=PHYS and class=DEV.",
                "db -v --table=mytable delete -Cserver_type=PHYS -Cclass=DEV",
        })
public class DeleteRow implements Callable<Integer> {

    @ParentCommand
    private Db db;

    @Option(names = {"-C", "--column"}, paramLabel = "<column>=<value>", required = true,
            description = {"Key-value pair specifying a column and its value. " +
                    "This option may be specified multiple times " +
                    "(once for each column that you want to specify a value for). ",
                    "Columns of type DATE must be specified in \"yyyy-[m]m-[d]d\" format (the leading zero for mm and dd may be omitted);",
                    "Columns of type TIME must be specified in \"hh:mm:ss\" format;",
                    "Columns of type TIMESTAMP must be specified in format \"yyyy-[m]m-[d]d hh:mm:ss[.f...]\" " +
                    "(the fractional seconds may be omitted. The leading zero for mm and dd may also be omitted.)"})
    private Map<String, String> columnValues;

    @Override
    public Integer call() throws SQLException, ClassNotFoundException {
        String cols = String.join(",", columnValues.keySet());
        int[] types = db.executeQuery("select " + cols + " from " + db.tableName, db::extractTypes);

        String columns = String.join("=?,", columnValues.keySet());
        String sql = "delete from " + db.tableName + " where " + columns + "=?";
        db.executeUpdate(sql, db::prepareStatement, columnValues, types);

        return 0;
    }
}
package picocli.example.db;

import picocli.CommandLine.IVersionProvider;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.Properties;

public class DbVersionProvider implements IVersionProvider {
    private static final String INVENTORY_VERSION = "1.0";

    @Override
    public String[] getVersion() throws IOException {
        return new String[] {
                commandNameAndVersion(),
                sqlLiteNameAndVersion(),
                "Picocli " + picocli.CommandLine.VERSION,
                "JVM: ${java.version} (${java.vendor} ${java.vm.name} ${java.vm.version})",
                "OS: ${os.name} ${os.version} ${os.arch}"
        };
    }

    private String sqlLiteNameAndVersion() throws IOException {
        URL resource = getClass().getResource("/sqlite-jdbc.properties");
        //System.out.println(resource);
        try (InputStream in = resource.openStream()) {
            Properties p = new Properties();
            p.load(in);
            return "Driver: "
                    + p.getProperty("name", "UNKNOWN NAME") + " "
                    + p.getProperty("version", "UNKNOWN VERSION");
        }
    }

    protected String commandNameAndVersion() {
        return "db " + INVENTORY_VERSION;
    }

    static class AddRow extends DbVersionProvider {
        @Override
        protected String commandNameAndVersion() {
            return "db add " + INVENTORY_VERSION;
        }
    }

    static class UpdateRow extends DbVersionProvider {
        @Override
        protected String commandNameAndVersion() {
            return "db update " + INVENTORY_VERSION;
        }
    }

    static class DeleteRow extends DbVersionProvider {
        @Override
        protected String commandNameAndVersion() {
            return "db delete " + INVENTORY_VERSION;
        }
    }

    static class ListRows extends DbVersionProvider {
        @Override
        protected String commandNameAndVersion() {
            return "db list " + INVENTORY_VERSION;
        }
    }
}
package picocli.example.db;

import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParentCommand;

import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.List;
import java.util.concurrent.Callable;

@Command(name = "list", mixinStandardHelpOptions = true,
        versionProvider = DbVersionProvider.ListRows.class,
        description = "Lists all rows in the example db.")
public class ListRows implements Callable<Integer> {

    @ParentCommand
    private Db db;

    @Option(names = {"-c", "--columns"}, paramLabel = "NAME", defaultValue = "*", split = ",",
            description = "Comma-separated list of the columns to print. Default value: ${DEFAULT-VALUE}.")
    private List<String> columnNames;

    @Option(names = {"-s", "--separator"}, paramLabel = "STRING", defaultValue = "\t",
            description = "Separator between column names and column values. Default value: '${DEFAULT-VALUE}'.")
    private String separator;

    @Option(names = {"--no-column-names"}, negatable = true,
            description = "Whether to first output a row with column names. By default, column names are printed.")
    private boolean firstPrintColumnNames = true;

    @Override
    public Integer call() throws SQLException, ClassNotFoundException {
        String columns = String.join(",", columnNames);
        String sql = "select " + columns + " from " + db.tableName;
        db.executeQuery(sql, this::printAllColumns);
        return 0;
    }


    private Void printAllColumns(ResultSet rs) {
        try {
            ResultSetMetaData metaData = rs.getMetaData();
            StringBuilder sb = new StringBuilder();
            if (firstPrintColumnNames) {
                for (int i = 1; i <= metaData.getColumnCount(); i++) {
                    if (i > 1) {
                        sb.append(separator);
                    }
                    sb.append(metaData.getColumnName(i));
                }
                db.out(sb);
            }
            while (rs.next()) {
                sb.setLength(0);
                for (int i = 1; i <= metaData.getColumnCount(); i++) {
                    if (i > 1) {
                        sb.append(separator);
                    }
                    sb.append(rs.getObject(i));
                }
                db.out(sb);
            }
        } catch (SQLException ex) {
            throw new RuntimeException(ex);
        }
        return null;
    }
}
package picocli.example.db;

import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParentCommand;

import java.math.BigDecimal;
import java.sql.*;
import java.util.*;
import java.util.concurrent.Callable;

@Command(name = "update", mixinStandardHelpOptions = true, //usageHelpAutoWidth = true,
        versionProvider = DbVersionProvider.UpdateRow.class,
        description = "Updates a row in the example db.",
        footerHeading = "%nExample:%n",
        footer = {
            "# This updates a row in the `mytable` table.",
            "# Note that the primary key columns are mandatory.",
            "db -v --table=mytable update -Cid=1 -Chostname=abc -Cserver_type=PHYS -Cclass=PROD -Cserial_no=12345 -Cmodel=DL380G9 -Cstatus=SOFF -Cdescription=\"This is a box\""
        })
public class UpdateRow implements Callable<Integer> {

    @ParentCommand
    private Db db;

    @Option(names = {"-C", "--column"}, paramLabel = "<column>=<value>", required = true,
            description = {"Key-value pair specifying a column and its value. " +
                    "This option may be specified multiple times " +
                    "(once for each column that you want to specify a value for). ",
                    "Primary key columns are mandatory.",
                    "Columns of type DATE must be specified in \"yyyy-[m]m-[d]d\" format (the leading zero for mm and dd may be omitted);",
                    "Columns of type TIME must be specified in \"hh:mm:ss\" format;",
                    "Columns of type TIMESTAMP must be specified in format \"yyyy-[m]m-[d]d hh:mm:ss[.f...]\" " +
                    "(the fractional seconds may be omitted. The leading zero for mm and dd may also be omitted.)"})
    private Map<String, String> columnValues;

    @Override
    public Integer call() throws SQLException, ClassNotFoundException {
        List<String> keyColumnNames = db.selectPrimaryKeyColumns();

        List<String> valueColumnNames = new ArrayList<>(columnValues.keySet());
        valueColumnNames.removeAll(keyColumnNames);

        String valueColumns = String.join("=?,", valueColumnNames);
        String keyColumns = String.join("=?,", keyColumnNames);

        String columns1 = String.join(",", valueColumnNames);
        String columns2 = String.join(",", keyColumnNames);
        int[] types = db.executeQuery("select " + columns1 + "," + columns2 + " from " + db.tableName, db::extractTypes);

        String sql = "update " + db.tableName + " set " + valueColumns + "=? where " + keyColumns + "=?";
        Map<String, String> reordered = new LinkedHashMap<>();
        for (String key : valueColumnNames) { reordered.put(key, columnValues.get(key)); }
        for (String key : keyColumnNames)   { reordered.put(key, columnValues.get(key)); }
        db.executeUpdate(sql, db::prepareStatement, reordered, types);

        return 0;
    }
}