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

com.heliorm.sql.SqlModeller Maven / Gradle / Ivy

package com.heliorm.sql;

import com.heliorm.sql.mysql.MysqlModeller;
import com.heliorm.sql.postgres.PostgresModeller;

import java.sql.Connection;
import java.sql.JDBCType;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;

import static java.lang.String.format;

/**
 * A modeller allows us to model SQL database structures into Java structures and modify SQL database structures.
 * This class must be extended to provide support for specific database types.
 */
public abstract class SqlModeller {

    private final Supplier supplier;


    /**
     * Create a modeller for MySQL/MariaDB databases.
     *
     * @param supplier A supplier of SQL connections.
     * @return The modeller
     */
    public static SqlModeller mysql(Supplier supplier, boolean anonymousDb) {
        return new MysqlModeller(supplier, anonymousDb);
    }

    public static SqlModeller mysql(Supplier supplier) {
        return mysql(supplier, false);
    }

    /**
     * Create a modeller for PostgreSQL databases.
     *
     * @param supplier A supplier of SQL connections.
     * @return The modeller
     */
    public static SqlModeller postgres(Supplier supplier) {
        return new PostgresModeller(supplier);
    }

    /**
     * Read a database from SQL and return a model for it.
     *
     * @param name The name of the database to read
     * @return The model
     * @throws SqlModellerException Thrown if there is a problem reading the model
     */
    public final Database readDatabase(String name) throws SqlModellerException {
        var database = new SqlDatabase(name);
        try (var con = con()) {
            var dbm = con.getMetaData();
            try (var tables = dbm.getTables(name, null, null, new String[]{"TABLE"})) {
                while (tables.next()) {
                    database.addTable(readTable(database, tables.getString("TABLE_NAME")));
                }
            }
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error scanning database '%s' (%s)", name, ex.getMessage()), ex);
        }
        return database;
    }

    /**
     * Read a table from SQL and return a model for it.
     *
     * @param database The database for the table
     * @param name     The name of the table
     * @return The table model
     * @throws SqlModellerException Thrown if there is a problem reading the model
     */
    public final Table readTable(Database database, String name) throws SqlModellerException {
        try (var con = con()) {
            var dbm = con.getMetaData();
            var table = new SqlTable(database, name);
            var sqlColumns = new HashMap();
            try (var columns = dbm.getColumns(database.getName(), null, table.getName(), "%")) {
                while (columns.next()) {
                    var column = getColumnFromResultSet(table, columns);
                    sqlColumns.put(column.getName(), column);
                }
            }
            var keyNames = new HashSet();
            try (var keys = dbm.getPrimaryKeys(database.getName(), null, table.getName())) {
                while (keys.next()) {
                    var column = sqlColumns.get(keys.getString("COLUMN_NAME"));
                    var pkName = keys.getString("PK_NAME");
                    if (column == null) {
                        throw new SqlModellerException(format("Cannot find column '%s' in table '%s' yet it is a primary key", keys.getString("COLUMN_NAME"), table.getName()));
                    }
                    keyNames.add(pkName);
                    column.setKey(true);
                }
            }
            for (var column : sqlColumns.values()) {
                table.addColumn(column);
            }
            var idxMap = new HashMap();
            try (var indexes = dbm.getIndexInfo(database.getName(), null, table.getName(), false, false)) {
                while (indexes.next()) {
                    var index_name = indexes.getString("INDEX_NAME");
                    var column_name = indexes.getString("COLUMN_NAME");
                    var non_unique = indexes.getBoolean("NON_UNIQUE");
                    SqlIndex sqlIndex;
                    if (idxMap.containsKey(index_name)) {
                        sqlIndex = idxMap.get(index_name);
                    } else {
                        sqlIndex = new SqlIndex(table, index_name, !non_unique);
                        idxMap.put(index_name, sqlIndex);
                    }
                    sqlIndex.addColunm(table.getColumn(column_name));
                }
            }
            for (Index index : idxMap.values()) {
                if (!keyNames.contains(index.getName())) {
                    table.addIndex(index);
                }
            }
            return table;
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error scanning table '%s' (%s)", name, ex.getMessage()), ex);
        }
    }

    /**
     * Check if a table exists in SQL
     *
     * @param table The table
     * @return Does it exist?
     * @throws SqlModellerException Thrown if there is a problem
     */
    public final boolean tableExists(Table table) throws SqlModellerException {
        try (var con = con()) {
            var dbm = con.getMetaData();
            try (var tables = dbm.getTables(getDatabaseName(table.getDatabase()), null, table.getName(), null)) {
                return tables.next();
            }
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error checking table '%s' (%s)", table.getName(), ex.getMessage()), ex);
        }
    }

    /**
     * Create a table based on a table model.
     *
     * @param table The table model
     * @throws SqlModellerException Thrown if there is a problem creating the table
     */
    public final void createTable(Table table) throws SqlModellerException {
        try (var con = con(); var stmt = con.createStatement()) {
            stmt.executeUpdate(makeCreateTableQuery(table));
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error creating table '%s' (%s)", table.getName(), ex.getMessage()), ex);
        }
    }

    /**
     * Delete a table from SQL
     *
     * @param table The table model
     * @throws SqlModellerException Thrown if there is a problem deleting the table
     */
    public final void deleteTable(Table table) throws SqlModellerException {
        try (var con = con(); var stmt = con.createStatement()) {
            stmt.executeUpdate(makeDeleteTableQuery(table));
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error deleting table '%s' (%s)", table.getName(), ex.getMessage()), ex);
        }
    }

    /**
     * Add a column to a table.
     *
     * @param column The column to add
     * @throws SqlModellerException Thrown if there is a problem adding the column
     */
    public final void addColumn(Column column) throws SqlModellerException {
        try (var con = con(); var stmt = con.createStatement()) {
            stmt.executeUpdate(makeAddColumnQuery(column));
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error adding column '%s' to table '%s' (%s)", column.getName(), column.getTable().getName(), ex.getMessage()), ex);
        }
    }

    /**
     * Rename a column.
     *
     * @param current The current column
     * @param changed The changed column
     * @throws SqlModellerException Thrown if there is a problem reaming the column
     */
    public final void renameColumn(Column current, Column changed) throws SqlModellerException {
        try (var con = con(); var stmt = con.createStatement()) {
            stmt.executeUpdate(makeRenameColumnQuery(current, changed));
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error renaming column '%s' in table '%s' (%s)", current.getName(), current.getTable().getName(), ex.getMessage()), ex);
        }
    }

    /**
     * Delete a column from SQL
     *
     * @param column The column to delete
     * @throws SqlModellerException Thrown if there is a problem deleting the column
     */
    public final void deleteColumn(Column column) throws SqlModellerException {
        try (var con = con(); var stmt = con.createStatement()) {
            stmt.executeUpdate(makeDeleteColumnQuery(column));
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error deleting column '%s' from table '%s' (%s)", column.getName(), column.getTable().getName(), ex.getMessage()), ex);
        }
    }

    /**
     * Modify a column in SQL.
     *
     * @param changed The changed column
     * @throws SqlModellerException Thrown if there is a problem modifying the model
     */
    public void modifyColumn(Column changed) throws SqlModellerException {
        try (var con = con(); var stmt = con.createStatement()) {
            for (var sql : makeModifyColumnQuery(changed)) {
                stmt.executeUpdate(sql);
            }
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error modifying column '%s' in table '%s' (%s)", changed.getName(), changed.getTable().getName(), ex.getMessage()), ex);
        }
    }

    public void modifyColumn(Column current, Column changed) throws SqlModellerException {
        try (var con = con(); var stmt = con.createStatement()) {
            for (var sql : makeModifyColumnQuery(current, changed)) {
                stmt.executeUpdate(sql);
            }
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error modifying column '%s' in table '%s' (%s)", current.getName(), current.getTable().getName(), ex.getMessage()), ex);
        }
    }


    /**
     * Add an index to a SQL table.
     *
     * @param index The index to add
     */
    public final void addIndex(Index index) throws SqlModellerException {
        try (var con = con(); var stmt = con.createStatement()) {
            stmt.executeUpdate(makeAddIndexQuery(index));
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error adding index '%s' in table '%s' (%s)", index.getName(), index.getTable().getName(), ex.getMessage()), ex);
        }
    }

    /**
     * Rename an index on a SQL table.
     *
     * @param current The index to modify
     * @param changed The changed index
     */
    public final void renameIndex(Index current, Index changed) throws SqlModellerException {
        try (var con = con(); var stmt = con.createStatement()) {
            stmt.executeUpdate(makeRenameIndexQuery(current, changed));
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error renaming index '%s' in table '%s' (%s)", current.getName(), current.getTable().getName(), ex.getMessage()), ex);
        }
    }

    /**
     * Modify an index on a SQL table
     *
     * @param index The index to modify
     */
    public abstract void modifyIndex(Index index) throws SqlModellerException;

    /**
     * Check if a modeller supports SET types
     *
     * @return True if it does
     */
    public abstract boolean supportsSet();

    /**
     * Remove an index from a SQL table.
     *
     * @param index The index to remove
     */
    public final void removeIndex(Index index) throws SqlModellerException {
        try (var con = con(); var stmt = con.createStatement()) {
            stmt.executeUpdate(makeRemoveIndexQuery(index));
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error removing index '%s' in table '%s' (%s)", index.getName(), index.getTable().getName(), ex.getMessage()));
        }
    }

    /**
     * Create a new modeller with the given connection supplier and driver.
     *
     * @param supplier The connection supplier
     */
    protected SqlModeller(Supplier supplier) {
        this.supplier = supplier;
    }

    /**
     * Compare two columns by their typing. Returns true if they are essentially the same. Must be provided
     * by a database specific implementation.
     *
     * @param one   One column
     * @param other The other column
     * @return True if the same
     */
    protected abstract boolean typesAreCompatible(Column one, Column other);

    /**
     * Extract the allowed values of a Set type.
     *
     * @param string The string from the database.
     * @return The set of strings.
     */
    protected abstract Set extractSetValues(String string);


    /**
     * Extract the default value from a string
     *
     * @param string The string from the database.
     * @return The set of strings.
     */
    protected abstract String extractDefault(String string);

    /**
     * Determine if a column is a SET column
     *
     * @param colunmName The column name
     * @param jdbcType   The column type
     * @param typeName   The column type name
     * @return True if it is a set.
     */
    protected abstract boolean isSetColumn(String colunmName, JDBCType jdbcType, String typeName);

    /**
     * Read the possible enum values for a ENUM column
     *
     * @param column The column
     * @return The set values.
     */
    protected abstract Set readEnumValues(EnumColumn column) throws SqlModellerException;

    /**
     * Generate SQL statement to add an index to a table.
     *
     * @param index The index
     * @return The SQL
     */
    protected final String makeAddIndexQuery(Index index) {
        return format("CREATE %sINDEX %s on %s (%s)",
                index.isUnique() ? "UNIQUE " : "",
                getIndexName(index),
                getTableName(index.getTable()),
                index.getColumns().stream()
                        .map(this::getColumnName)
                        .collect(Collectors.joining(",")));
    }

    /**
     * Generate SQL statement to create a table.
     *
     * @param table The table
     * @return The SQL
     */
    protected abstract String makeCreateTableQuery(Table table) throws SqlModellerException;

    /**
     * Generate the database specific column name from a column.
     *
     * @param column The column
     * @return The name
     */
    protected abstract String getColumnName(Column column);

    /**
     * Generate the database specific column type as used when creating a column.
     *
     * @param column The column
     * @return The type text
     */
    protected abstract String getCreateType(Column column) throws SqlModellerException;

    /**
     * Generate the database specific table name from a table.
     *
     * @param table The table
     * @return The name
     */
    protected abstract String getTableName(Table table);


    /**
     * Generate the database specific database name from a database.
     *
     * @param database The database
     * @return The name
     */
    protected abstract String getDatabaseName(Database database);

    /**
     * Get a database connection.
     *
     * @return The connection
     */
    protected final Connection con() {
        return supplier.get();
    }


    /**
     * Generate SQL statement to modify an index.
     *
     * @param index The index
     * @return The SQL
     */
    protected abstract String makeModifyIndexQuery(Index index);

    /**
     * Determine if a column is an ENUM column
     *
     * @param columnName The column name
     * @param jdbcType   The column type
     * @param typeName   The column type name
     * @return True if it is a set.
     */
    protected abstract boolean isEnumColumn(String columnName, JDBCType jdbcType, String typeName) throws SqlModellerException;

    /**
     * Determine the effective character length of a string column.
     *
     * @param column The column
     * @return The effective length
     */
    protected final int actualTextLength(StringColumn column) {
        var length = column.getLength();
        if (length > 16777215) {
            return 2147483647;
        } else if (length > 65535) {
            return 16777215;
        } else if (length > 255) {
            return 65535;
        }
        return length;
    }

    protected final int actualLength(BinaryColumn column) {
        var length = column.getLength();
        if (length > 16777215) {
            return 2147483647;
        } else if (length > 65535) {
            return 16777215;
        } else if (length > 255) {
            return 65535;
        }
        return length;
    }

    /**
     * Generate a query to modify a column in a table.
     *
     * @param column The column
     * @return The SQL statement
     */
    protected abstract List makeModifyColumnQuery(Column column) throws SqlModellerException;

    /**
     * Generate a query to modify a column in a table.
     *
     * @param current The current column
     * @param changed The changed column
     * @return The SQL statement
     */
    protected abstract List makeModifyColumnQuery(Column current, Column changed) throws SqlModellerException;

    /**
     * Generate a query to add a column to a table
     *
     * @param column The column to add
     * @return The query
     */
    protected abstract String makeAddColumnQuery(Column column) throws SqlModellerException;

    /**
     * Generate a query to remove an index.
     *
     * @param index The index to remove
     * @return The query
     */
    protected abstract String makeRemoveIndexQuery(Index index);

    /**
     * Get the query syntax index name for the index.
     *
     * @param index The index
     * @return The name
     */
    protected abstract String getIndexName(Index index);

    /**
     * Make a query to read set values
     *
     * @param column The column to read the values for
     * @return The values as a string.
     */
    protected abstract String makeReadSetQuery(SetColumn column) throws SqlModellerException;

    /**
     * Generate a query to rename an index.
     *
     * @param current The current index
     * @param changed The changed index
     * @return The query
     */
    protected abstract String makeRenameIndexQuery(Index current, Index changed);

    /**
     * Read the possible set values for a SET column
     *
     * @param column The column
     * @return The set values.
     */
    private Set readSetValues(SetColumn column) throws SqlModellerException {
        var query = makeReadSetQuery(column);
        try (var con = con(); var stmt = con.createStatement(); var ers = stmt.executeQuery(query)) {
            if (ers.next()) {
                return extractSetValues(ers.getString(1));
            }
            return Collections.emptySet();
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error reading set values from %s.%s.%s (%s)",
                    column.getTable().getDatabase().getName(), column.getTable().getName(), column.getName(), ex.getMessage()), ex);
        }
    }

    /**
     * Generate a query to delete a table.
     *
     * @param table The table to delete
     * @return The query
     */
    private String makeDeleteTableQuery(Table table) {
        return format("DROP TABLE %s", getTableName(table));
    }

    /**
     * Generate a SQL statement to rename a column in a table.
     *
     * @param column The current column
     * @return The SQL
     */
    private String makeRenameColumnQuery(Column column, Column changed) {
        return format("ALTER TABLE %s RENAME COLUMN %s TO %s",
                getTableName(column.getTable()),
                getColumnName(column),
                getColumnName(changed));
    }

    /**
     * Generate a SQL statement to delete a column from a table.
     *
     * @param column The column to delete
     * @return The SQL
     */
    private String makeDeleteColumnQuery(Column column) {
        return format("ALTER TABLE %s DROP COLUMN %s",
                getTableName(column.getTable()),
                getColumnName(column));
    }

    /**
     * Determine if the given JDBC type represents a string column
     *
     * @param jdbcType The JDBC type
     * @return True if it is
     */
    private boolean isStringColumn(JDBCType jdbcType) {
        return switch (jdbcType) {
            case CHAR, VARCHAR, LONGVARCHAR -> true;
            default -> false;
        };
    }

    /**
     * Determine if the given JDBC type represents a date/time column
     *
     * @param jdbcType The JDBC type
     * @return True if it is
     */
    private boolean isDateTimeColumn(JDBCType jdbcType) {
        return switch (jdbcType) {
            case DATE, TIME, TIMESTAMP -> true;
            default -> false;
        };
    }

    /**
     * Determine if the given JDBC type represents a binary column
     *
     * @param jdbcType The JDBC type
     * @return True if it is
     */
    private boolean isBinaryColumn(JDBCType jdbcType) {
        return switch (jdbcType) {
            case LONGVARBINARY, VARBINARY, BINARY, BLOB -> true;
            default -> false;
        };
    }

    /**
     * Read a column model from a SQL result set.
     *
     * @param table The table for the column
     * @param rs    The result set
     * @return The column mode
     */
    private SqlColumn getColumnFromResultSet(Table table, ResultSet rs) throws SqlModellerException {
        try {
            var jdbcType = JDBCType.valueOf(rs.getInt("DATA_TYPE"));
            var size = rs.getInt("COLUMN_SIZE");
            var nullable = rs.getString("IS_NULLABLE").equals("YES");
            var autoIncrement = rs.getString("IS_AUTOINCREMENT").equals("YES");
            var columnName = rs.getString("COLUMN_NAME");
            var typeName = rs.getString("TYPE_NAME");
            var defVal = rs.getString("COLUMN_DEF");
            if (defVal != null) {
                defVal = extractDefault(defVal);
            }
            if (isEnumColumn(columnName, jdbcType, typeName)) {
                return new SqlEnumColumn(table, columnName, nullable, defVal, readEnumValues(new SqlEnumColumn(table, columnName, nullable, defVal, Collections.emptySet())));
            } else if (isSetColumn(columnName, jdbcType, typeName)) {
                return new SqlSetColumn(table, columnName, nullable, defVal, readSetValues(new SqlSetColumn(table, columnName, nullable, defVal, Collections.emptySet())));
            } else if (isStringColumn(jdbcType)) {
                return new SqlStringColumn(table, columnName, jdbcType, nullable, defVal, size);
            } else if (isBinaryColumn(jdbcType)) {
                return new SqlBinaryColumn(table, columnName, jdbcType, nullable, defVal, size);
            }
            if (isDateTimeColumn(jdbcType)) {
                if (typeName.equals("DATETIME")) {
                    return new SqlDateTimeColumn(table, columnName, jdbcType, nullable, defVal);
                } else {
                    return new SqlTimeStampColumn(table, columnName, jdbcType, nullable, defVal);
                }
            }
            return switch (jdbcType) {
                case BIT -> new SqlBitColumn(table, columnName, nullable, defVal, size);
                case BOOLEAN -> new SqlBooleanColumn(table, columnName, nullable, defVal);
                case DECIMAL, NUMERIC ->
                        new SqlDecimalColumn(table, columnName, jdbcType, nullable, defVal, size, rs.getInt("DECIMAL_DIGITS"));
                case DOUBLE -> new SqlDoubleColumn(table, columnName, jdbcType, nullable, defVal);
                case INTEGER, TINYINT, SMALLINT, BIGINT -> {
                    if (autoIncrement) {
                        yield new SqlIntegerColumn(table, columnName, jdbcType, nullable, defVal, true);
                    }
                    yield new SqlIntegerColumn(table, columnName, jdbcType, nullable, defVal, false);
                }
                default ->
                        throw new SqlModellerException(format("Unsupported JDBC type %s in result set. BUG!", jdbcType.getName()));
            };
        } catch (SQLException ex) {
            throw new SqlModellerException(format("Error reading SQL column information (%s)", ex.getMessage()), ex);
        }
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy