/*
* (C) Copyright 2006-2016 Nuxeo SA (http://nuxeo.com/) and others.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Contributors:
* Florent Guillaume
*/
package org.nuxeo.ecm.core.storage.sql.jdbc.dialect;
import java.io.Serializable;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Types;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer;
import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.FulltextQuery;
import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.Op;
import org.nuxeo.ecm.core.storage.sql.ColumnType;
import org.nuxeo.ecm.core.storage.sql.Model;
import org.nuxeo.ecm.core.storage.sql.RepositoryDescriptor;
import org.nuxeo.ecm.core.storage.sql.jdbc.JDBCLogger;
import org.nuxeo.ecm.core.storage.sql.jdbc.db.Column;
import org.nuxeo.ecm.core.storage.sql.jdbc.db.Database;
import org.nuxeo.ecm.core.storage.sql.jdbc.db.Join;
import org.nuxeo.ecm.core.storage.sql.jdbc.db.Table;
/**
* MySQL-specific dialect.
*
* @author Florent Guillaume
*/
public class DialectMySQL extends Dialect {
public DialectMySQL(DatabaseMetaData metadata, RepositoryDescriptor repositoryDescriptor) {
super(metadata, repositoryDescriptor);
}
@Override
public char openQuote() {
return '`';
}
@Override
public char closeQuote() {
return '`';
}
@Override
public String getAddForeignKeyConstraintString(String constraintName, String[] foreignKeys, String referencedTable,
String[] primaryKeys, boolean referencesPrimaryKey) {
String cols = String.join(", ", foreignKeys);
return String.format(" ADD INDEX %s (%s), ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)",
constraintName, cols, constraintName, cols, referencedTable, String.join(", ", primaryKeys));
}
@Override
public boolean qualifyIndexName() {
return false;
}
@Override
public boolean supportsIfExistsBeforeTableName() {
return true;
}
@Override
public JDBCInfo getJDBCTypeAndString(ColumnType type) {
switch (type.spec) {
case STRING:
if (type.isUnconstrained()) {
// don't use the max 65535 because this max is actually for the
// total size of all columns of a given table, so allow several
// varchar columns in the same table
// 255 is max for a column to be primary key in UTF8
return jdbcInfo("VARCHAR(255)", Types.VARCHAR);
} else if (type.isClob() || type.length > 65535) {
return jdbcInfo("LONGTEXT", Types.LONGVARCHAR);
} else {
return jdbcInfo("VARCHAR(%d)", type.length, Types.VARCHAR);
}
case BOOLEAN:
return jdbcInfo("BIT", Types.BIT);
case LONG:
return jdbcInfo("BIGINT", Types.BIGINT);
case DOUBLE:
return jdbcInfo("DOUBLE", Types.DOUBLE);
case TIMESTAMP:
return jdbcInfo("DATETIME(3)", Types.TIMESTAMP);
case BLOBID:
return jdbcInfo("VARCHAR(250) BINARY", Types.VARCHAR);
// -----
case NODEID:
case NODEIDFK:
case NODEIDFKNP:
case NODEIDFKMUL:
case NODEIDFKNULL:
case NODEIDPK:
case NODEVAL:
return jdbcInfo("VARCHAR(36) BINARY", Types.VARCHAR);
case SYSNAME:
case SYSNAMEARRAY:
// 255 is max for a column to have an index in UTF8
return jdbcInfo("VARCHAR(255) BINARY", Types.VARCHAR);
case TINYINT:
return jdbcInfo("TINYINT", Types.TINYINT);
case INTEGER:
return jdbcInfo("INTEGER", Types.INTEGER);
case AUTOINC:
return jdbcInfo("INTEGER AUTO_INCREMENT PRIMARY KEY", Types.INTEGER);
case FTINDEXED:
throw new AssertionError(type);
case FTSTORED:
return jdbcInfo("LONGTEXT", Types.LONGVARCHAR);
case CLUSTERNODE:
return jdbcInfo("BIGINT", Types.BIGINT);
case CLUSTERFRAGS:
return jdbcInfo("TEXT", Types.VARCHAR);
}
throw new AssertionError(type);
}
@Override
public boolean isAllowedConversion(int expected, int actual, String actualName, int actualSize) {
// LONGVARCHAR vs VARCHAR compatibility
if (expected == Types.VARCHAR && actual == Types.LONGVARCHAR) {
return true;
}
if (expected == Types.LONGVARCHAR && actual == Types.VARCHAR) {
return true;
}
// INTEGER vs BIGINT compatibility
if (expected == Types.BIGINT && actual == Types.INTEGER) {
return true;
}
if (expected == Types.INTEGER && actual == Types.BIGINT) {
return true;
}
return false;
}
@Override
public void setToPreparedStatement(PreparedStatement ps, int index, Serializable value, Column column)
throws SQLException {
switch (column.getJdbcType()) {
case Types.VARCHAR:
case Types.LONGVARCHAR:
setToPreparedStatementString(ps, index, value, column);
return;
case Types.BIT:
ps.setBoolean(index, ((Boolean) value).booleanValue());
return;
case Types.TINYINT:
case Types.INTEGER:
case Types.BIGINT:
ps.setLong(index, ((Number) value).longValue());
return;
case Types.DOUBLE:
ps.setDouble(index, ((Double) value).doubleValue());
return;
case Types.TIMESTAMP:
setToPreparedStatementTimestamp(ps, index, value, column);
return;
default:
throw new SQLException("Unhandled JDBC type: " + column.getJdbcType());
}
}
@Override
@SuppressWarnings("boxing")
public Serializable getFromResultSet(ResultSet rs, int index, Column column) throws SQLException {
switch (column.getJdbcType()) {
case Types.VARCHAR:
case Types.LONGVARCHAR:
return getFromResultSetString(rs, index, column);
case Types.BIT:
return rs.getBoolean(index);
case Types.TINYINT:
case Types.INTEGER:
case Types.BIGINT:
return rs.getLong(index);
case Types.DOUBLE:
return rs.getDouble(index);
case Types.TIMESTAMP:
return getFromResultSetTimestamp(rs, index, column);
}
throw new SQLException("Unhandled JDBC type: " + column.getJdbcType());
}
@Override
protected int getMaxNameSize() {
return 64;
}
@Override
public String getCreateFulltextIndexSql(String indexName, String quotedIndexName, Table table, List<Column> columns,
Model model) {
String indexedColumns = columns.stream().map(Column::getQuotedName).collect(Collectors.joining(", "));
return String.format("CREATE FULLTEXT INDEX %s ON %s (%s)", quotedIndexName, table.getQuotedName(),
indexedColumns);
}
@Override
public String getDialectFulltextQuery(String query) {
query = query.replace("%", "*");
FulltextQuery ft = FulltextQueryAnalyzer.analyzeFulltextQuery(query);
if (ft == null || ft.op == Op.NOTWORD) {
return "DONTMATCHANYTHINGFOREMPTYQUERY";
}
StringBuilder buf = new StringBuilder();
translateForMySQL(ft, null, buf);
return buf.toString();
}
protected static void translateForMySQL(FulltextQuery ft, Op superOp, StringBuilder buf) {
if (ft.op == Op.AND || ft.op == Op.OR) {
if (superOp == Op.AND) {
buf.append('+');
}
buf.append('(');
for (int i = 0; i < ft.terms.size(); i++) {
FulltextQuery term = ft.terms.get(i);
if (i != 0) {
buf.append(' ');
}
translateForMySQL(term, ft.op, buf);
}
buf.append(')');
} else {
if (ft.op == Op.NOTWORD) {
buf.append('-');
} else { // Op.WORD
if (superOp == Op.AND) {
buf.append('+');
}
}
boolean isPhrase = ft.word.contains(" ");
if (isPhrase) {
buf.append('"');
}
buf.append(ft.word);
if (isPhrase) {
buf.append('"');
}
}
}
// SELECT ..., (MATCH(`fulltext`.`simpletext`, `fulltext`.`binarytext`)
// .................. AGAINST (?) / 10) AS nxscore
// FROM ... LEFT JOIN `fulltext` ON ``fulltext`.`id` = `hierarchy`.`id`
// WHERE ... AND MATCH(`fulltext`.`simpletext`, `fulltext`.`binarytext`)
// ................... AGAINST (? IN BOOLEAN MODE)
// ORDER BY nxscore DESC
@Override
public FulltextMatchInfo getFulltextScoredMatchInfo(String fulltextQuery, String indexName, int nthMatch,
Column mainColumn, Model model, Database database) {
String nthSuffix = nthMatch == 1 ? "" : String.valueOf(nthMatch);
String indexSuffix = model.getFulltextIndexSuffix(indexName);
Table ft = database.getTable(Model.FULLTEXT_TABLE_NAME);
Column ftMain = ft.getColumn(Model.MAIN_KEY);
Column stColumn = ft.getColumn(Model.FULLTEXT_SIMPLETEXT_KEY + indexSuffix);
Column btColumn = ft.getColumn(Model.FULLTEXT_BINARYTEXT_KEY + indexSuffix);
String match = String.format("MATCH (%s, %s)", stColumn.getFullQuotedName(), btColumn.getFullQuotedName());
FulltextMatchInfo info = new FulltextMatchInfo();
if (nthMatch == 1) {
// Need only one JOIN involving the fulltext table
info.joins = Collections.singletonList(new Join(Join.INNER, ft.getQuotedName(), null, null,
ftMain.getFullQuotedName(), mainColumn.getFullQuotedName()));
}
info.whereExpr = String.format("%s AGAINST (? IN BOOLEAN MODE)", match);
info.whereExprParam = fulltextQuery;
// Note: using the boolean query in non-boolean mode gives approximate
// results but it's the best we have as MySQL does not provide a score
// in boolean mode.
// Note: dividing by 10 is arbitrary, but MySQL cannot really
// normalize scores.
info.scoreExpr = String.format("(%s AGAINST (?) / 10)", match);
info.scoreExprParam = fulltextQuery;
info.scoreAlias = "_nxscore" + nthSuffix;
info.scoreCol = new Column(mainColumn.getTable(), null, ColumnType.DOUBLE, null);
return info;
}
@Override
public boolean getMaterializeFulltextSyntheticColumn() {
return false;
}
@Override
public int getFulltextIndexedColumns() {
return 2;
}
@Override
public String getTableTypeString(Table table) {
if (table.hasFulltextIndex()) {
return " ENGINE=MyISAM";
} else {
return " ENGINE=InnoDB";
}
}
@Override
public boolean supportsUpdateFrom() {
return true;
}
@Override
public boolean doesUpdateFromRepeatSelf() {
return true;
}
@Override
public boolean needsOrderByKeysAfterDistinct() {
return false;
}
@Override
public boolean needsAliasForDerivedTable() {
return true;
}
@Override
public String getSecurityCheckSql(String idColumnName) {
return String.format("NX_ACCESS_ALLOWED(%s, ?, ?)", idColumnName);
}
@Override
public String getInTreeSql(String idColumnName, String id) {
return String.format("NX_IN_TREE(%s, ?)", idColumnName);
}
@Override
public String getSQLStatementsFilename() {
return "nuxeovcs/mysql.sql.txt";
}
@Override
public String getTestSQLStatementsFilename() {
return "nuxeovcs/mysql.test.sql.txt";
}
@Override
public Map<String, Serializable> getSQLStatementsProperties(Model model, Database database) {
Map<String, Serializable> properties = new HashMap<>();
properties.put("idType", "varchar(36)");
properties.put("fulltextEnabled", Boolean.valueOf(!fulltextDisabled));
properties.put("fulltextSearchEnabled", Boolean.valueOf(!fulltextSearchDisabled));
properties.put("clusteringEnabled", Boolean.valueOf(clusteringEnabled));
return properties;
}
@Override
public boolean isConcurrentUpdateException(Throwable t) {
do {
if (t instanceof SQLException) {
String sqlState = ((SQLException) t).getSQLState();
if ("23000".equals(sqlState)) {
// Integrity constraint violation: 1452 Cannot add or update a child row:
// a foreign key constraint fails
return true;
}
if ("40001".equals(sqlState)) {
// com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException:
// java.sql.SQLTransactionRollbackException for MariaDB:
// Deadlock found when trying to get lock; try restarting transaction
return true;
}
}
t = t.getCause();
} while (t != null);
return false;
}
@Override
public boolean isClusteringSupported() {
return true;
}
@Override
public boolean isClusteringDeleteNeeded() {
return true;
}
@Override
public String getClusterInsertInvalidations() {
return "CALL NX_CLUSTER_INVAL(?, ?, ?, ?)";
}
@Override
public String getClusterGetInvalidations() {
return "SELECT id, fragments, kind FROM cluster_invals WHERE nodeid = ?";
}
@Override
public boolean supportsPaging() {
return true;
}
@Override
public String addPagingClause(String sql, long limit, long offset) {
return sql + String.format(" LIMIT %d OFFSET %d", limit, offset);
}
@Override
public boolean isIdentityAlreadyPrimary() {
return true;
}
@Override
public String getBinaryFulltextSql(List<String> columns) {
return "SELECT " + String.join(", ", columns) + " FROM `fulltext` WHERE id=?";
}
@Override
public List<String> checkStoredProcedure(String procName, String procCreate, String ddlMode, Connection connection,
JDBCLogger logger, Map<String, Serializable> properties) throws SQLException {
boolean compatCheck = ddlMode.contains(RepositoryDescriptor.DDL_MODE_COMPAT);
String ifExists = compatCheck ? "IF EXISTS " : "";
String procDrop;
if (procCreate.toLowerCase().startsWith("create function ")) {
procDrop = "DROP FUNCTION " + ifExists + procName;
} else {
procDrop = "DROP PROCEDURE " + ifExists + procName;
}
if (compatCheck) {
return Arrays.asList(procDrop, procCreate);
}
try (Statement st = connection.createStatement()) {
String getBody = "SELECT body FROM mysql.proc WHERE db = DATABASE() AND name = '" + procName + "'";
logger.log(getBody);
try (ResultSet rs = st.executeQuery(getBody)) {
if (rs.next()) {
String body = rs.getString(1);
if (normalizeString(procCreate).contains(normalizeString(body))) {
logger.log(" -> exists, unchanged");
return Collections.emptyList();
} else {
logger.log(" -> exists, old");
return Arrays.asList(procDrop, procCreate);
}
} else {
logger.log(" -> missing");
return Collections.singletonList(procCreate);
}
}
}
}
protected static String normalizeString(String string) {
// MySQL strips comments when recording a procedure's body
return string.replaceAll("-- .*", " ").replaceAll("[ \n\r\t]+", " ").trim();
}
@Override
public Collection<? extends String> getDumpStart() {
return Collections.singleton("DELIMITER $$");
}
@Override
public Collection<? extends String> getDumpStop() {
return Collections.singleton("DELIMITER ;");
}
@Override
public String getSQLForDump(String sql) {
return sql + " $$";
}
}