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

liquibase.change.core.LoadDataChange Maven / Gradle / Ivy

The newest version!
package liquibase.change.core;

import com.opencsv.exceptions.CsvMalformedLineException;
import liquibase.CatalogAndSchema;
import liquibase.GlobalConfiguration;
import liquibase.Scope;
import liquibase.change.*;
import liquibase.changelog.ChangeSet;
import liquibase.database.AbstractJdbcDatabase;
import liquibase.database.Database;
import liquibase.database.core.MSSQLDatabase;
import liquibase.database.core.MySQLDatabase;
import liquibase.database.core.PostgresDatabase;
import liquibase.datatype.DataTypeFactory;
import liquibase.datatype.LiquibaseDataType;
import liquibase.exception.*;
import liquibase.executor.ExecutorService;
import liquibase.executor.LoggingExecutor;
import liquibase.io.EmptyLineAndCommentSkippingInputStream;
import liquibase.logging.Logger;
import liquibase.resource.Resource;
import liquibase.resource.ResourceAccessor;
import liquibase.snapshot.InvalidExampleException;
import liquibase.snapshot.SnapshotControl;
import liquibase.snapshot.SnapshotGeneratorFactory;
import liquibase.statement.BatchDmlExecutablePreparedStatement;
import liquibase.statement.ExecutablePreparedStatementBase;
import liquibase.statement.InsertExecutablePreparedStatement;
import liquibase.statement.SqlStatement;
import liquibase.statement.core.InsertOrUpdateStatement;
import liquibase.statement.core.InsertSetStatement;
import liquibase.statement.core.InsertStatement;
import liquibase.structure.core.Column;
import liquibase.structure.core.DataType;
import liquibase.structure.core.Table;
import liquibase.util.BooleanUtil;
import liquibase.util.ObjectUtil;
import liquibase.util.StreamUtil;
import liquibase.util.StringUtil;
import liquibase.util.csv.CSVReader;
import lombok.Setter;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;

import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.util.*;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;

import static java.util.ResourceBundle.getBundle;
import static liquibase.change.ChangeParameterMetaData.ALL;

@DatabaseChange(name = "loadData",
        description = "Loads data from a CSV file into an existing table",
        priority = ChangeMetaData.PRIORITY_DEFAULT, appliesTo = "table",
        since = "1.7")
@SuppressWarnings("java:S2583")
public class LoadDataChange extends AbstractTableChange implements ChangeWithColumns {
    /**
     * CSV Lines starting with that sign(s) will be treated as comments by default
     */
    public static final String DEFAULT_COMMENT_PATTERN = "#";
    public static final Pattern BASE64_PATTERN = Pattern.compile("^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$");
    private static final Logger LOG = Scope.getCurrentScope().getLog(LoadDataChange.class);
    private static final ResourceBundle coreBundle = getBundle("liquibase/i18n/liquibase-core");
    @Setter
    private String file;
    private String commentLineStartsWith = DEFAULT_COMMENT_PATTERN;
    @Setter
    private Boolean relativeToChangelogFile;
    @Setter
    private String encoding;
    private String separator = CSVReader.DEFAULT_SEPARATOR + "";
    @Setter
    private String quotchar = CSVReader.DEFAULT_QUOTE_CHARACTER + "";
    private List columns = new ArrayList<>();

    @Setter
    private Boolean usePreparedStatements;

    /**
     * Transform a value read from a CSV file into a string to be written into the database if the column type
     * is not known.
     *
     * @param value the value to transform
     * @return if the value is empty or the string "NULL" (case-insensitive), return the empty string.
     * If not, the value "toString()" representation (trimmed of spaces left and right) is returned.
     */
    protected static String getValueToWrite(Object value) {
        if ((value == null) || StringUtil.equalsWordNull(value.toString())) {
            return "";
        } else {
            return value.toString().trim();
        }
    }

    // TODO: We can currently do this for INSERT operations, but not yet for UPDATE operations, so loadUpdateDataChange
    // will overwrite this flag for now.
    protected boolean hasPreparedStatementsImplemented() {
        return true;
    }

    @Override
    public boolean supports(Database database) {
        return true;
    }

    @Override
    public boolean generateRollbackStatementsVolatile(Database database) {
        return true;
    }

    @Override
    @DatabaseChangeProperty(description = "Name of the table to insert data into",
        requiredForDatabase = ALL, mustEqualExisting = "table")
    public String getTableName() {
        return super.getTableName();
    }

    @DatabaseChangeProperty(
        description = "CSV file to load", exampleValue = "com/example/users.csv",
        requiredForDatabase = ALL)
    public String getFile() {
        return file;
    }

    @DatabaseChangeProperty(
        description = "Whether to use prepared statements instead of INSERT statement strings (if the database supports it)")
    public Boolean getUsePreparedStatements() {
        return usePreparedStatements;
    }

    @DatabaseChangeProperty(supportsDatabase = ALL,
        description = "Lines starting with this are treated as comments and ignored. "+
            "To disable comments, set 'commentLineStartsWith' to an empty value. Default: " + DEFAULT_COMMENT_PATTERN)
    public String getCommentLineStartsWith() {
        return commentLineStartsWith;
    }

    public void setCommentLineStartsWith(String commentLineStartsWith) {
        //if the value is null (not provided) we want to use default value
        if (commentLineStartsWith == null) {
            this.commentLineStartsWith = DEFAULT_COMMENT_PATTERN;
        } else if (commentLineStartsWith.isEmpty()) {
            this.commentLineStartsWith = null;
        } else {
            this.commentLineStartsWith = commentLineStartsWith;
        }
    }

    @DatabaseChangeProperty(supportsDatabase = ALL, description = "Specifies whether the file path is relative to the changelog file " +
        "rather than looked up in the search path. Default: false.")
    public Boolean isRelativeToChangelogFile() {
        return relativeToChangelogFile;
    }

    @DatabaseChangeProperty(exampleValue = "UTF-8", supportsDatabase = ALL,
        description = "Encoding of the CSV file (defaults to UTF-8)")
    public String getEncoding() {
        return encoding;
    }

    @DatabaseChangeProperty(exampleValue = ",", supportsDatabase = ALL,
        description = "Character separating the fields. Default: " + CSVReader.DEFAULT_SEPARATOR)
    public String getSeparator() {
        return separator;
    }

    public void setSeparator(String separator) {
        if ("\\t".equals(separator)) {
            separator = "\t";
        }
        this.separator = separator;
    }

    @DatabaseChangeProperty(exampleValue = "'", supportsDatabase = ALL,
        description = "The quote character for string fields containing the separator character. " +
            "Default: " + CSVReader.DEFAULT_QUOTE_CHARACTER)
    public String getQuotchar() {
        return quotchar;
    }

    @Override
    public void addColumn(LoadDataColumnConfig column) {
        columns.add(column);
    }

    @Override
    @DatabaseChangeProperty(supportsDatabase = ALL, serializationType = SerializationType.NESTED_OBJECT,
        description = "Column mapping and defaults can be defined.\n\n" +
            "'header' or 'index' attributes need to be defined. If the header name in the CSV " +
            "is different than the column, 'name' needs to be inserted.\n" +
            "If column 'type' is not defined, the type is taken from the database. " +
            "Otherwise, for non-string columns, the type definition might be required.\n" +
            "The 'defaultValue*' attributes can define values for empty fields.")
    public List getColumns() {
        return columns;
    }

    @Override
    public void setColumns(List columns) {
        this.columns = columns;
    }

    /**
     * Returns a unique {@code string} for the column for better identification.
     * The returned {@code string} includes the index of the column and, if available, its name.
     *
     * @param index        the index of the column
     * @param columnConfig the column configuration
     * @return a string in the format " / column[index] (name:'columnName')" or " / column[index]" if the column name is not available.
     */
    protected String columnIdString(int index, LoadDataColumnConfig columnConfig) {
        return " / column[" + index + "]" +
                (StringUtils.trimToNull(columnConfig.getName()) != null ?
                        " (name:'" + columnConfig.getName() + "')" : "");
    }

    /**
     * Validate all columns and collect errors in 'validationErrors'
     *
     * @param validationErrors ValidationErrors to collect errors
     * @return validationErrors
     */
    protected ValidationErrors validateColumns(ValidationErrors validationErrors) {
        if (getColumns() != null) {
            int i = 1;
            for (LoadDataColumnConfig columnConfig : getColumns()) {
                validateColumn(columnConfig, validationErrors, columnIdString(i, columnConfig));
                i++;
            }
        }
        return validationErrors;
    }

    @Override
    public SqlStatement[] generateStatements(Database database) {
        supportsBatchUpdates(database);

        try (CSVReader reader = getCSVReader()) {

            if (reader == null) {
                throw new UnexpectedLiquibaseException("Unable to read file " + this.getFile());
            }

            String[] headers = reader.readNext();
            if (headers == null) {
                throw new UnexpectedLiquibaseException("Data file " + getFile() + " was empty");
            }

            // Make sure all take the column list we interpolated from the CSV headers
            addColumnsFromHeaders(headers);

            // If we have an real JDBC connection to the database, ask the database for any missing column types.
            try {
                retrieveMissingColumnLoadTypes(columns, database);
            } catch (DatabaseException e) {
                throw new UnexpectedLiquibaseException(e);
            }

            String[] line;
            // Start at '1' to take into account the header (already processed):
            int lineNumber = 1;

            boolean isCommentingEnabled = StringUtils.isNotEmpty(commentLineStartsWith);

            List rows = new ArrayList<>();
            while ((line = reader.readNext()) != null) {
                lineNumber++;
                if
                ((line.length == 0) || ((line.length == 1) && (StringUtils.trimToNull(line[0]) == null)) ||
                        (isCommentingEnabled && isLineCommented(line))
                ) {
                    //nothing interesting on this line
                    continue;
                }

                // Ensure each line has the same number of columns defined as does the header.
                // (Failure could indicate unquoted strings with commas, for example).
                if (line.length != headers.length) {
                    throw new UnexpectedLiquibaseException(
                            "CSV file " + getFile() + " Line " + lineNumber + " has " + line.length +
                                    " values defined, Header has " + headers.length +
                                    ". Numbers MUST be equal (check for unquoted string with embedded commas)"
                    );
                }

                boolean needsPreparedStatement = false;

                List columnsFromCsv = new ArrayList<>();
                for (int i = 0; i < headers.length; i++) {
                    String value = line[i];
                    String columnName = headers[i].trim();

                    LoadDataColumnConfig valueConfig = new LoadDataColumnConfig();

                    LoadDataColumnConfig columnConfig = getColumnConfig(i, columnName);
                    if (columnConfig != null) {
                        if ("skip".equalsIgnoreCase(columnConfig.getType())) {
                            continue;
                        }

                        // don't overwrite header name unless there is actually a value to override it with
                        if (columnConfig.getName() != null) {
                            columnName = columnConfig.getName();
                        }
                        final boolean isNull = isNullValue(value, columnConfig);
                        //
                        // Always set the type for the valueConfig if the value is NULL
                        //
                        if (isNull) {
                            valueConfig.setType(columnConfig.getType());
                        }
                        valueConfig.setName(columnName);
                        valueConfig.setAllowUpdate(columnConfig.getAllowUpdate());

                        if (StringUtils.isEmpty(value)) {
                            value = columnConfig.getDefaultValue();
                        }
                        if (isNull) {
                            valueConfig.setValue(null);
                        } else if (columnConfig.getType() == null) {
                            // columnConfig did not specify a type
                            valueConfig.setValue(value);
                        } else if (columnConfig.getTypeEnum() == LOAD_DATA_TYPE.BOOLEAN) {
                            if (value == null) { // TODO getDefaultValueBoolean should use BooleanUtil.parseBoolean also for consistent behaviour
                                valueConfig.setValueBoolean(columnConfig.getDefaultValueBoolean());
                            } else {
                                valueConfig.setValueBoolean(BooleanUtil.parseBoolean(value));
                            }
                        } else if (columnConfig.getTypeEnum() == LOAD_DATA_TYPE.NUMERIC) {
                            if (value != null) {
                                valueConfig.setValueNumeric(value);
                            } else {
                                valueConfig.setValueNumeric(columnConfig.getDefaultValueNumeric());
                            }
                        } else if (columnConfig.getType().equalsIgnoreCase("date")
                                || columnConfig.getType().equalsIgnoreCase("datetime")
                                || columnConfig.getType().equalsIgnoreCase("time")) {
                            try {
                                // Need the column type for handling 'NOW' or 'TODAY' type column value
                                valueConfig.setType(columnConfig.getType());
                                if (StringUtil.equalsWordNull(value) || StringUtils.isEmpty(value)) {
                                    valueConfig.setValue(null);
                                    valueConfig.setValueDate(columnConfig.getDefaultValueDate());
                                } else {
                                    valueConfig.setValueDate(value);
                                }
                            }
                            catch (DateParseException e) {
                                throw new UnexpectedLiquibaseException(e);
                            }
                        } else if (columnConfig.getTypeEnum() == LOAD_DATA_TYPE.STRING) {
                            valueConfig.setType(columnConfig.getType());
                            valueConfig.setValue(value == null ? "" : value);
                        } else if (columnConfig.getTypeEnum() == LOAD_DATA_TYPE.COMPUTED) {
                            if (null != value) {
                                liquibase.statement.DatabaseFunction function =
                                        new liquibase.statement.DatabaseFunction(value);
                                valueConfig.setValueComputed(function);
                            } else {
                                valueConfig.setValueComputed(columnConfig.getDefaultValueComputed());
                            }
                        } else if (columnConfig.getTypeEnum() == LOAD_DATA_TYPE.SEQUENCE) {
                            if (value == null) {
                                throw new UnexpectedLiquibaseException(
                                        "Must set a sequence name in the loadData column defaultValue attribute"
                                );
                            }
                            liquibase.statement.SequenceNextValueFunction function =
                                    new liquibase.statement.SequenceNextValueFunction(getSchemaName(), value);
                            valueConfig.setValueComputed(function);

                        } else if (columnConfig.getType().equalsIgnoreCase(LOAD_DATA_TYPE.BLOB.toString())) {
                            if (StringUtil.equalsWordNull(value)) {
                                valueConfig.setValue(null);
                            } else if (BASE64_PATTERN.matcher(value).matches()) {
                                valueConfig.setType(columnConfig.getType());
                                valueConfig.setValue(value);
                                needsPreparedStatement = true;
                            } else {
                                // If the value is not base64 encoded we are expecting the value to be a
                                // valid path to another file which holds the entire value we are expecting
                                // to load into the db.
                                valueConfig.setValueBlobFile(value);
                                needsPreparedStatement = true;
                            }
                        } else if (columnConfig.getTypeEnum() == LOAD_DATA_TYPE.CLOB) {
                            // Previously, we expected all clobs found using loadData to be a valid path to a file.
                            // To maintain backwards compatibility, we will first try to find the file.
                            // If found, we then load the entire file into the value when executing the statement.
                            // If not found, we load the value as a string.
                            Resource r;
                            if (getRelativeTo() != null) {
                                r = Scope.getCurrentScope().getResourceAccessor().get(getRelativeTo()).resolveSibling(value);
                            } else {
                                r = Scope.getCurrentScope().getResourceAccessor().get(value);
                            }

                            if (r.exists()) {
                                valueConfig.setValueClobFile(value);
                            } else {
                                LOG.fine(String.format("File %s not found. Inserting the value as a string. See https://docs.liquibase.com for more information.", value));
                                valueConfig.setValue(value);
                            }
                            needsPreparedStatement = true;
                        }  else if (columnConfig.getTypeEnum() == LOAD_DATA_TYPE.UUID) {
                            valueConfig.setType(columnConfig.getType());
                            if (StringUtil.equalsWordNull(value)) {
                                valueConfig.setValue(null);
                            } else {
                                valueConfig.setValue(value);
                            }
                        } else if (columnConfig.getType().equalsIgnoreCase(LOAD_DATA_TYPE.OTHER.toString())) {
                            valueConfig.setType(columnConfig.getType());
                            if (StringUtil.equalsWordNull(value)) {
                                valueConfig.setValue(null);
                            } else {
                                valueConfig.setValue(value);
                            }
                        } else if (columnConfig.getTypeEnum() == LOAD_DATA_TYPE.UNKNOWN) {
                            // columnConfig did not match a specific type
                            valueConfig.setValue(value);
                        } else {
                            throw new UnexpectedLiquibaseException(
                                    String.format(coreBundle.getString("loaddata.type.is.not.supported"),
                                            columnConfig.getType()
                                    )
                            );
                        }
                    } else {
                        // No columnConfig found. Assume header column name to be the table column name.
                        if (columnName.contains("(") || (columnName.contains(")") && (database instanceof
                                AbstractJdbcDatabase))) {
                            columnName = ((AbstractJdbcDatabase) database).quoteObject(columnName, Column.class);
                        }

                        valueConfig.setName(columnName);

                        valueConfig.setValue(getValueToWrite(value));
                    }
                    columnsFromCsv.add(valueConfig);
                }
                // end of: iterate through all the columns of a CSV line

                // Try to use prepared statements if any of the following conditions apply:
                // 1. There is no other option than using a prepared statement (e.g. in cases of LOBs) regardless
                //     of whether the 'usePreparedStatement' is set to false
                // 2. The database supports batched statements (for improved performance) AND we are not in an
                //    "SQL" mode (i.e. we generate an SQL file instead of actually modifying the database).
                // BUT: if the user specifically requests usePreparedStatement=false, then respect that
                boolean actuallyUsePreparedStatements = false;
                if (hasPreparedStatementsImplemented()) {
                    if (usePreparedStatements != null) {
                        if (!usePreparedStatements && needsPreparedStatement) {
                            throw new UnexpectedLiquibaseException("loadData is requesting usePreparedStatements=false but prepared statements are required");
                        }
                        actuallyUsePreparedStatements = usePreparedStatements;
                    } else {
                        actuallyUsePreparedStatements = needsPreparedStatement || (!isLoggingExecutor(database) && preferPreparedStatements(database));
                    }
                }
                rows.add(new LoadDataRowConfig(actuallyUsePreparedStatements, columnsFromCsv));
            }
            return generateStatementsFromRows(database, rows);
        } catch (CsvMalformedLineException e) {
            throw new RuntimeException("Error parsing " + getRelativeTo() + " on line " + e.getLineNumber() + ": " + e.getMessage());
        } catch (IOException | LiquibaseException e) {
            throw new RuntimeException(e);
        } catch (UnexpectedLiquibaseException ule) {
            if ((getChangeSet() != null) && (getChangeSet().getFailOnError() != null) && !getChangeSet()
                    .getFailOnError()) {
                LOG.info("Changeset " + getChangeSet().toString(false) +
                        " failed, but failOnError was false.  Error: " + ule.getMessage());
                return SqlStatement.EMPTY_SQL_STATEMENT;
            } else {
                throw ule;
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        // Do nothing
    }

    /**
     * If the loaded data does not require a prepared statement, and the user did not specify whether to use them or not,
     * should we use prepared statements as a default?
     */
    private boolean preferPreparedStatements(Database database) {
        if (database instanceof PostgresDatabase) {
            return false; //postgresql seems surprisingly slow with prepared statements
        }
        return supportsBatchUpdates(database);
    }

    protected boolean supportsBatchUpdates(Database database) {
        boolean databaseSupportsBatchUpdates = false;
        try {
            databaseSupportsBatchUpdates = database.supportsBatchUpdates();
        } catch (DatabaseException e) {
            throw new UnexpectedLiquibaseException(e);
        }
        return databaseSupportsBatchUpdates;
    }

    private boolean isLoggingExecutor(Database database) {
        final ExecutorService executorService = Scope.getCurrentScope().getSingleton(ExecutorService.class);

        return executorService.executorExists("logging", database) &&
                (executorService.getExecutor("logging", database) instanceof LoggingExecutor);
    }

    /**
     * Iterate through the List of LoadDataColumnConfig and ask the database for any column types that we have
     * no data type of.
     *
     * @param columns a list of LoadDataColumnConfigs to process
     */
    @SuppressWarnings("CommentedOutCodeLine")
    private void retrieveMissingColumnLoadTypes(List columns, Database database) throws
            DatabaseException {
        // If no column is missing type information, we are already done.
        if (columns.stream().noneMatch(c -> c.getType() == null)) {
            return;
        }

        // Snapshot the database table
        CatalogAndSchema catalogAndSchema = new CatalogAndSchema(getCatalogName(), getSchemaName());
        catalogAndSchema = catalogAndSchema.standardize(database);
        Table targetTable = new Table(catalogAndSchema.getCatalogName(), catalogAndSchema.getSchemaName(),
                database.correctObjectName(getTableName(), Table.class));
        Table snapshotOfTable;
        try {
            snapshotOfTable = SnapshotGeneratorFactory.getInstance().createSnapshot(
                    targetTable,
                    database, new SnapshotControl(database, Table.class, Column.class));
        } catch (InvalidExampleException e) {
            throw new DatabaseException(e);
        }
        if (snapshotOfTable == null) {
            LOG.warning(String.format(
                    coreBundle.getString("could.not.snapshot.table.to.get.the.missing.column.type.information"),
                    database.escapeTableName(
                            targetTable.getSchema().getCatalogName(),
                            targetTable.getSchema().getName(),
                            targetTable.getName())
            ));
            return;
        }

        // Save the columns of the database table in a lookup table
        Map tableColumns = new HashMap<>();
        for (Column c : snapshotOfTable.getColumns()) {
            // Normalise the LoadDataColumnConfig column names to the database
            tableColumns.put(database.correctObjectName(c.getName(), Column.class), c);
        }
        /* The above is the JDK7 version of:
            snapshotOfTable.getColumns().forEach(c -> tableColumns.put(c.getName(), c));
        */

        // Normalise the LoadDataColumnConfig column names to the database
        Map columnConfigs = new HashMap<>();
        for (LoadDataColumnConfig c : columns) {
            columnConfigs.put(
                    database.correctObjectName(c.getName(), Column.class),
                    c
            );
        }
        /* The above is the JDK7 version of:
        columns.forEach(c -> columnConfigs.put(
                database.correctObjectName(c.getName(), Column.class),
                c
        ));
        */

        for (Map.Entry entry : columnConfigs.entrySet()) {
            if (entry.getValue().getType() != null) {
                continue;
            }
            LoadDataColumnConfig columnConfig = entry.getValue();
            Column c = tableColumns.get(entry.getKey());
            if (null == c) {
                LOG.severe(String.format(coreBundle.getString("unable.to.find.column.in.table"),
                        columnConfig.getName(), snapshotOfTable));
            } else {
                DataType dataType = c.getType();
                if (dataType == null) {
                    LOG.warning(String.format(coreBundle.getString("unable.to.find.load.data.type"),
                            columnConfig.toString(), snapshotOfTable));
                    columnConfig.setType(LOAD_DATA_TYPE.STRING);
                } else {
                    LiquibaseDataType liquibaseDataType = DataTypeFactory.getInstance()
                            .fromDescription(dataType.toString(), database);
                    if (liquibaseDataType != null) {
                        columnConfig.setType(liquibaseDataType.getLoadTypeName());
                    } else {
                        LOG.warning(String.format(coreBundle.getString("unable.to.convert.load.data.type"),
                                columnConfig.toString(), snapshotOfTable, dataType));
                    }
                }
            }
        }

        /* The above is the JDK7 version of:
        columnConfigs.entrySet().stream()
                .filter(entry -> entry.getValue().getType() == null)
                .forEach(entry -> {
                    LoadDataColumnConfig columnConfig = entry.getValue();
                    DataType dataType = tableColumns.get(entry.getKey()).getType();
                    if (dataType == null) {
                        LOG.warning(String.format(coreBundle.getString("unable.to.find.load.data.type"),
                                columnConfig.toString(), snapshotOfTable.toString() ));
                        columnConfig.setType(LOAD_DATA_TYPE.STRING.toString());
                    } else {
                        LiquibaseDataType liquibaseDataType = DataTypeFactory.getInstance()
                                .fromDescription(dataType.toString(), database);
                        if (liquibaseDataType != null) {
                            columnConfig.setType(liquibaseDataType.getLoadTypeName().toString());
                        } else {
                            LOG.warning(String.format(coreBundle.getString("unable.to.convert.load.data.type"),
                                    columnConfig.toString(), snapshotOfTable.toString(), liquibaseDataType.toString()));
                        }
                    }
                }
        );
        */
    }

    @Override
    public ValidationErrors validate(Database database) {
        ValidationErrors validationErrors = new ValidationErrors(this);
        validationErrors.addAll(super.validate(database));
        return validateColumns(validationErrors);
    }

    public void validateColumn(LoadDataColumnConfig columnConfig, ValidationErrors validationErrors, String columnIDString) {
        if (columnConfig.getHeader() != null && columnConfig.getIndex() != null) {
            validationErrors.addWarning("Since attribute 'header' is also defined, 'index' ignored for "
                    + validationErrors.getChangeName() + columnIDString);
        }
    }

    /**
     * Get the column using the name, index or the header
     *
     * @param name name or header of the column searched
     * @param idx  the index of the column searched
     * @return The column having the name or header equal to "name" or the index equal to idx
     * or a new LoadDataColumnConfig if not found
     */
    protected LoadDataColumnConfig columnConfigFromName(String name, Integer idx) {
        for (LoadDataColumnConfig c : this.columns) {
            if (name.equals(c.getName()) || name.equals(c.getHeader()) || idx.equals(c.getIndex())) {
                return c;
            }
        }
        if (null == StringUtils.trimToNull(name)) {
            throw new UnexpectedLiquibaseException("Unreferenced unnamed column is not supported");
        }
        LoadDataColumnConfig cfg = new LoadDataColumnConfig();
        columns.add(cfg);
        cfg.setName(name);
        return cfg;
    }

    /**
     * Add columns if they were not specified in the loadData change, we interpolate their names from
     * the header columns of the CSV file.
     *
     * @param headers the headers of the CSV file
     */
    private void addColumnsFromHeaders(String[] headers) {
        int i = 0;
        boolean shouldTrimHeader = GlobalConfiguration.TRIM_LOAD_DATA_FILE_HEADER.getCurrentValue();
        LoadDataColumnConfig loadDataColumnConfig;
        for (String columnNameFromHeader : headers) {
            if(shouldTrimHeader) {
                loadDataColumnConfig = columnConfigFromName(columnNameFromHeader.trim(), i);
            }
            else {
                loadDataColumnConfig = columnConfigFromName(columnNameFromHeader, i);
            }

            loadDataColumnConfig.setIndex(i);
            loadDataColumnConfig.setHeader(columnNameFromHeader);
            i++;
        }
    }

    private boolean isLineCommented(String[] line) {
        return StringUtils.startsWith(line[0], commentLineStartsWith);
    }

    @Override
    public boolean generateStatementsVolatile(Database database) {
        return true;
    }

    public CSVReader getCSVReader() throws IOException, LiquibaseException {
        ResourceAccessor resourceAccessor = Scope.getCurrentScope().getResourceAccessor();
        if (resourceAccessor == null) {
            throw new UnexpectedLiquibaseException("No file resourceAccessor specified for " + getFile());
        }

        Resource resource;
        if (getRelativeTo() == null) {
            resource = resourceAccessor.get(file);
        } else {
            resource = resourceAccessor.get(getRelativeTo()).resolveSibling(file);
        }
        if (!resource.exists()) {
            return null;
        }

        @SuppressWarnings("java:S2095") // SONAR want us to close the stream here, but it is only read by CSVReader outside this method.
        InputStream stream = resource.openInputStream();
        if (resource.getPath().toLowerCase().endsWith(".gz") && !(stream instanceof GZIPInputStream)) {
            stream = new GZIPInputStream(stream);
        }
        Reader streamReader = StreamUtil.readStreamWithReader(stream, getEncoding());

        char quotchar;
        if (StringUtils.trimToEmpty(this.quotchar).isEmpty()) {
            // hope this is impossible to have a field surrounded with non ascii char 0x01
            quotchar = '\1';
        } else {
            quotchar = this.quotchar.charAt(0);
        }

        if (separator == null) {
            separator = CSVReader.DEFAULT_SEPARATOR + "";
        }

        return new CSVReader(streamReader, separator.charAt(0), quotchar);
    }

    protected String getRelativeTo() {
        String relativeTo = null;
        if (ObjectUtil.defaultIfNull(isRelativeToChangelogFile(), false)) {
            relativeTo = getChangeSet().getChangeLog().getPhysicalFilePath();
        }
        return relativeTo;
    }

    protected ExecutablePreparedStatementBase createPreparedStatement(
            Database database, String catalogName, String schemaName, String tableName,
            List columns, ChangeSet changeSet, ResourceAccessor resourceAccessor) {
        return new InsertExecutablePreparedStatement(database, catalogName, schemaName, tableName, columns,
                changeSet, resourceAccessor);
    }

    protected InsertStatement createStatement(String catalogName, String schemaName, String tableName) {
        return new InsertStatement(catalogName, schemaName, tableName);
    }

    protected InsertSetStatement createStatementSet(String catalogName, String schemaName, String tableName) {
        return new InsertSetStatement(catalogName, schemaName, tableName);
    }

    protected LoadDataColumnConfig getColumnConfig(int index, String header) {
        for (LoadDataColumnConfig config : columns) {
            if ((config.getIndex() != null) && config.getIndex().equals(index)) {
                return config;
            }
            if ((config.getHeader() != null) && config.getHeader().equalsIgnoreCase(header)) {
                return config;
            }

            if ((config.getName() != null) && config.getName().equalsIgnoreCase(header)) {
                return config;
            }
        }
        return null;
    }

    @Override
    public ChangeStatus checkStatus(Database database) {
        return new ChangeStatus().unknown("Cannot check loadData status");
    }

    @Override
    public String getConfirmationMessage() {
        return String.format(coreBundle.getString("loaddata.successful"), getFile(), getTableName());
    }

    @Override
    public CheckSum generateCheckSum() {
        InputStream stream = null;
        try {
            ResourceAccessor resourceAccessor = Scope.getCurrentScope().getResourceAccessor();
            Resource resource;
            if (getRelativeTo() == null) {
                resource = resourceAccessor.getExisting(file);
            } else {
                resource = resourceAccessor.get(getRelativeTo()).resolveSibling(file);
            }

            stream = new EmptyLineAndCommentSkippingInputStream(resource.openInputStream(), commentLineStartsWith);
            return CheckSum.compute(getTableName() + ":" + CheckSum.compute(stream, /*standardizeLineEndings*/ true));
        } catch (IOException e) {
            throw new UnexpectedLiquibaseException(e);
        } finally {
            if (stream != null) {
                try {
                    stream.close();
                } catch (IOException ignore) {
                    // Do nothing
                }
            }
        }
    }

    @Override
    public Warnings warn(Database database) {
        return null;
    }

    @Override
    public String getSerializedObjectNamespace() {
        return STANDARD_CHANGELOG_NAMESPACE;
    }


    protected SqlStatement[] generateStatementsFromRows(Database database, List rows) {
        List statements = new ArrayList<>();
        List preparedStatements = new ArrayList<>();

        for (LoadDataRowConfig row : rows) {
            List columnsFromCsv = row.getColumns();
            if (row.needsPreparedStatement()) {
                ExecutablePreparedStatementBase stmt =
                        this.createPreparedStatement(
                                database, getCatalogName(), getSchemaName(), getTableName(), columnsFromCsv,
                                getChangeSet(), Scope.getCurrentScope().getResourceAccessor()
                        );
                preparedStatements.add(stmt);
            } else {
                InsertStatement insertStatement =
                        this.createStatement(getCatalogName(), getSchemaName(), getTableName());

                for (LoadDataColumnConfig column : columnsFromCsv) {
                    String columnName = column.getName();
                    Object value = column.getValueObject();

                    if (value == null) {
                        value = "NULL";
                    }

                    insertStatement.addColumnValue(columnName, value);

                    if (insertStatement instanceof InsertOrUpdateStatement) {
                        ((InsertOrUpdateStatement) insertStatement).setAllowColumnUpdate(columnName, column.getAllowUpdate() == null || column.getAllowUpdate());
                    }
                }

                statements.add(insertStatement);
            }
        }
        if (rows.stream().anyMatch(LoadDataRowConfig::needsPreparedStatement)) {
            // If we have only prepared statements and the database supports batching, let's roll
            if (supportsBatchUpdates(database) && !preparedStatements.isEmpty()) {
                if (database instanceof PostgresDatabase || database instanceof MySQLDatabase) {
                    // we don't do batch updates for Postgres but we still send as a prepared statement, see LB-744
                    // mysql supports batch updates, but the performance vs. the big insert is worse
                    return preparedStatements.toArray(SqlStatement.EMPTY_SQL_STATEMENT);
                } else {
                    return new SqlStatement[]{
                            new BatchDmlExecutablePreparedStatement(
                                    database, getCatalogName(), getSchemaName(),
                                    getTableName(), columns,
                                    getChangeSet(), Scope.getCurrentScope().getResourceAccessor(),
                                    preparedStatements)
                    };
                }
            } else {
                return statements.toArray(SqlStatement.EMPTY_SQL_STATEMENT);
            }
        } else {
            if (statements.isEmpty()) {
                // avoid returning unnecessary dummy statement
                return SqlStatement.EMPTY_SQL_STATEMENT;
            }

            InsertSetStatement statementSet = this.createStatementSet(
                    getCatalogName(), getSchemaName(), getTableName()
            );
            for (SqlStatement stmt : statements) {
                statementSet.addInsertStatement((InsertStatement) stmt);
            }

            if ((database instanceof MSSQLDatabase) || (database instanceof MySQLDatabase) || (database
                    instanceof PostgresDatabase)) {
                List innerStatements = statementSet.getStatements();
                if ((innerStatements != null) && (!innerStatements.isEmpty()) && (innerStatements.get(0)
                        instanceof InsertOrUpdateStatement)) {
                    //cannot do insert or update in a single statement
                    return statementSet.getStatementsArray();
                }
                // we only return a single "statement" - it's capable of emitting multiple sub-statements,
                // should the need arise, on generation.
                return new SqlStatement[]{statementSet};
            } else {
                return statementSet.getStatementsArray();
            }
        }
    }

    protected boolean isNullValue(final String value, final LoadDataColumnConfig column) {
        final String nullPlaceholder = column.getNullPlaceholder();
        final boolean retValue;
        if (nullPlaceholder == null) {
            retValue = StringUtil.equalsWordNull(value);
        } else {
            retValue = nullPlaceholder.equals(value);
        }
        return retValue;
    }

    @SuppressWarnings("HardCodedStringLiteral")
    public enum LOAD_DATA_TYPE {
        BOOLEAN, NUMERIC, DATE, STRING, COMPUTED, SEQUENCE, BLOB, CLOB, SKIP, UUID, OTHER, UNKNOWN
    }

    protected static class LoadDataRowConfig {

        private final boolean needsPreparedStatement;
        private final List columns;

        public LoadDataRowConfig(boolean needsPreparedStatement, List columns) {
            this.needsPreparedStatement = needsPreparedStatement;
            this.columns = columns;
        }

        public boolean needsPreparedStatement() {
            return needsPreparedStatement;
        }

        public List getColumns() {
            return columns;
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy