001/* 002 * (C) Copyright 2006-2016 Nuxeo SA (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * Florent Guillaume 018 */ 019package org.nuxeo.ecm.core.storage.sql.jdbc.dialect; 020 021import java.io.Serializable; 022import java.sql.Connection; 023import java.sql.DatabaseMetaData; 024import java.sql.PreparedStatement; 025import java.sql.ResultSet; 026import java.sql.SQLException; 027import java.sql.Statement; 028import java.sql.Types; 029import java.util.Arrays; 030import java.util.Collection; 031import java.util.Collections; 032import java.util.HashMap; 033import java.util.List; 034import java.util.Map; 035import java.util.stream.Collectors; 036 037import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer; 038import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.FulltextQuery; 039import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.Op; 040import org.nuxeo.ecm.core.storage.sql.ColumnType; 041import org.nuxeo.ecm.core.storage.sql.Model; 042import org.nuxeo.ecm.core.storage.sql.RepositoryDescriptor; 043import org.nuxeo.ecm.core.storage.sql.jdbc.JDBCLogger; 044import org.nuxeo.ecm.core.storage.sql.jdbc.db.Column; 045import org.nuxeo.ecm.core.storage.sql.jdbc.db.Database; 046import org.nuxeo.ecm.core.storage.sql.jdbc.db.Join; 047import org.nuxeo.ecm.core.storage.sql.jdbc.db.Table; 048 049/** 050 * MySQL-specific dialect. 051 * 052 * @author Florent Guillaume 053 */ 054public class DialectMySQL extends Dialect { 055 056 public DialectMySQL(DatabaseMetaData metadata, RepositoryDescriptor repositoryDescriptor) { 057 super(metadata, repositoryDescriptor); 058 } 059 060 @Override 061 public char openQuote() { 062 return '`'; 063 } 064 065 @Override 066 public char closeQuote() { 067 return '`'; 068 } 069 070 @Override 071 public String getAddForeignKeyConstraintString(String constraintName, String[] foreignKeys, String referencedTable, 072 String[] primaryKeys, boolean referencesPrimaryKey) { 073 String cols = String.join(", ", foreignKeys); 074 return String.format(" ADD INDEX %s (%s), ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)", 075 constraintName, cols, constraintName, cols, referencedTable, String.join(", ", primaryKeys)); 076 } 077 078 @Override 079 public boolean qualifyIndexName() { 080 return false; 081 } 082 083 @Override 084 public boolean supportsIfExistsBeforeTableName() { 085 return true; 086 } 087 088 @Override 089 public JDBCInfo getJDBCTypeAndString(ColumnType type) { 090 switch (type.spec) { 091 case STRING: 092 if (type.isUnconstrained()) { 093 // don't use the max 65535 because this max is actually for the 094 // total size of all columns of a given table, so allow several 095 // varchar columns in the same table 096 // 255 is max for a column to be primary key in UTF8 097 return jdbcInfo("VARCHAR(255)", Types.VARCHAR); 098 } else if (type.isClob() || type.length > 65535) { 099 return jdbcInfo("LONGTEXT", Types.LONGVARCHAR); 100 } else { 101 return jdbcInfo("VARCHAR(%d)", type.length, Types.VARCHAR); 102 } 103 case BOOLEAN: 104 return jdbcInfo("BIT", Types.BIT); 105 case LONG: 106 return jdbcInfo("BIGINT", Types.BIGINT); 107 case DOUBLE: 108 return jdbcInfo("DOUBLE", Types.DOUBLE); 109 case TIMESTAMP: 110 return jdbcInfo("DATETIME(3)", Types.TIMESTAMP); 111 case BLOBID: 112 return jdbcInfo("VARCHAR(250) BINARY", Types.VARCHAR); 113 // ----- 114 case NODEID: 115 case NODEIDFK: 116 case NODEIDFKNP: 117 case NODEIDFKMUL: 118 case NODEIDFKNULL: 119 case NODEIDPK: 120 case NODEVAL: 121 return jdbcInfo("VARCHAR(36) BINARY", Types.VARCHAR); 122 case SYSNAME: 123 case SYSNAMEARRAY: 124 // 255 is max for a column to have an index in UTF8 125 return jdbcInfo("VARCHAR(255) BINARY", Types.VARCHAR); 126 case TINYINT: 127 return jdbcInfo("TINYINT", Types.TINYINT); 128 case INTEGER: 129 return jdbcInfo("INTEGER", Types.INTEGER); 130 case AUTOINC: 131 return jdbcInfo("INTEGER AUTO_INCREMENT PRIMARY KEY", Types.INTEGER); 132 case FTINDEXED: 133 throw new AssertionError(type); 134 case FTSTORED: 135 return jdbcInfo("LONGTEXT", Types.LONGVARCHAR); 136 case CLUSTERNODE: 137 return jdbcInfo("BIGINT", Types.BIGINT); 138 case CLUSTERFRAGS: 139 return jdbcInfo("TEXT", Types.VARCHAR); 140 } 141 throw new AssertionError(type); 142 } 143 144 @Override 145 public boolean isAllowedConversion(int expected, int actual, String actualName, int actualSize) { 146 // LONGVARCHAR vs VARCHAR compatibility 147 if (expected == Types.VARCHAR && actual == Types.LONGVARCHAR) { 148 return true; 149 } 150 if (expected == Types.LONGVARCHAR && actual == Types.VARCHAR) { 151 return true; 152 } 153 // INTEGER vs BIGINT compatibility 154 if (expected == Types.BIGINT && actual == Types.INTEGER) { 155 return true; 156 } 157 if (expected == Types.INTEGER && actual == Types.BIGINT) { 158 return true; 159 } 160 return false; 161 } 162 163 @Override 164 public void setToPreparedStatement(PreparedStatement ps, int index, Serializable value, Column column) 165 throws SQLException { 166 switch (column.getJdbcType()) { 167 case Types.VARCHAR: 168 case Types.LONGVARCHAR: 169 setToPreparedStatementString(ps, index, value, column); 170 return; 171 case Types.BIT: 172 ps.setBoolean(index, ((Boolean) value).booleanValue()); 173 return; 174 case Types.TINYINT: 175 case Types.INTEGER: 176 case Types.BIGINT: 177 ps.setLong(index, ((Number) value).longValue()); 178 return; 179 case Types.DOUBLE: 180 ps.setDouble(index, ((Double) value).doubleValue()); 181 return; 182 case Types.TIMESTAMP: 183 setToPreparedStatementTimestamp(ps, index, value, column); 184 return; 185 default: 186 throw new SQLException("Unhandled JDBC type: " + column.getJdbcType()); 187 } 188 } 189 190 @Override 191 @SuppressWarnings("boxing") 192 public Serializable getFromResultSet(ResultSet rs, int index, Column column) throws SQLException { 193 switch (column.getJdbcType()) { 194 case Types.VARCHAR: 195 case Types.LONGVARCHAR: 196 return getFromResultSetString(rs, index, column); 197 case Types.BIT: 198 return rs.getBoolean(index); 199 case Types.TINYINT: 200 case Types.INTEGER: 201 case Types.BIGINT: 202 return rs.getLong(index); 203 case Types.DOUBLE: 204 return rs.getDouble(index); 205 case Types.TIMESTAMP: 206 return getFromResultSetTimestamp(rs, index, column); 207 } 208 throw new SQLException("Unhandled JDBC type: " + column.getJdbcType()); 209 } 210 211 @Override 212 protected int getMaxNameSize() { 213 return 64; 214 } 215 216 @Override 217 public String getCreateFulltextIndexSql(String indexName, String quotedIndexName, Table table, List<Column> columns, 218 Model model) { 219 String indexedColumns = columns.stream().map(Column::getQuotedName).collect(Collectors.joining(", ")); 220 return String.format("CREATE FULLTEXT INDEX %s ON %s (%s)", quotedIndexName, table.getQuotedName(), 221 indexedColumns); 222 } 223 224 @Override 225 public String getDialectFulltextQuery(String query) { 226 query = query.replace("%", "*"); 227 FulltextQuery ft = FulltextQueryAnalyzer.analyzeFulltextQuery(query); 228 if (ft == null || ft.op == Op.NOTWORD) { 229 return "DONTMATCHANYTHINGFOREMPTYQUERY"; 230 } 231 StringBuilder buf = new StringBuilder(); 232 translateForMySQL(ft, null, buf); 233 return buf.toString(); 234 } 235 236 protected static void translateForMySQL(FulltextQuery ft, Op superOp, StringBuilder buf) { 237 if (ft.op == Op.AND || ft.op == Op.OR) { 238 if (superOp == Op.AND) { 239 buf.append('+'); 240 } 241 buf.append('('); 242 for (int i = 0; i < ft.terms.size(); i++) { 243 FulltextQuery term = ft.terms.get(i); 244 if (i != 0) { 245 buf.append(' '); 246 } 247 translateForMySQL(term, ft.op, buf); 248 } 249 buf.append(')'); 250 } else { 251 if (ft.op == Op.NOTWORD) { 252 buf.append('-'); 253 } else { // Op.WORD 254 if (superOp == Op.AND) { 255 buf.append('+'); 256 } 257 } 258 boolean isPhrase = ft.word.contains(" "); 259 if (isPhrase) { 260 buf.append('"'); 261 } 262 buf.append(ft.word); 263 if (isPhrase) { 264 buf.append('"'); 265 } 266 } 267 } 268 269 // SELECT ..., (MATCH(`fulltext`.`simpletext`, `fulltext`.`binarytext`) 270 // .................. AGAINST (?) / 10) AS nxscore 271 // FROM ... LEFT JOIN `fulltext` ON ``fulltext`.`id` = `hierarchy`.`id` 272 // WHERE ... AND MATCH(`fulltext`.`simpletext`, `fulltext`.`binarytext`) 273 // ................... AGAINST (? IN BOOLEAN MODE) 274 // ORDER BY nxscore DESC 275 @Override 276 public FulltextMatchInfo getFulltextScoredMatchInfo(String fulltextQuery, String indexName, int nthMatch, 277 Column mainColumn, Model model, Database database) { 278 String nthSuffix = nthMatch == 1 ? "" : String.valueOf(nthMatch); 279 String indexSuffix = model.getFulltextIndexSuffix(indexName); 280 Table ft = database.getTable(Model.FULLTEXT_TABLE_NAME); 281 Column ftMain = ft.getColumn(Model.MAIN_KEY); 282 Column stColumn = ft.getColumn(Model.FULLTEXT_SIMPLETEXT_KEY + indexSuffix); 283 Column btColumn = ft.getColumn(Model.FULLTEXT_BINARYTEXT_KEY + indexSuffix); 284 String match = String.format("MATCH (%s, %s)", stColumn.getFullQuotedName(), btColumn.getFullQuotedName()); 285 FulltextMatchInfo info = new FulltextMatchInfo(); 286 if (nthMatch == 1) { 287 // Need only one JOIN involving the fulltext table 288 info.joins = Collections.singletonList(new Join(Join.INNER, ft.getQuotedName(), null, null, 289 ftMain.getFullQuotedName(), mainColumn.getFullQuotedName())); 290 } 291 info.whereExpr = String.format("%s AGAINST (? IN BOOLEAN MODE)", match); 292 info.whereExprParam = fulltextQuery; 293 // Note: using the boolean query in non-boolean mode gives approximate 294 // results but it's the best we have as MySQL does not provide a score 295 // in boolean mode. 296 // Note: dividing by 10 is arbitrary, but MySQL cannot really 297 // normalize scores. 298 info.scoreExpr = String.format("(%s AGAINST (?) / 10)", match); 299 info.scoreExprParam = fulltextQuery; 300 info.scoreAlias = "_nxscore" + nthSuffix; 301 info.scoreCol = new Column(mainColumn.getTable(), null, ColumnType.DOUBLE, null); 302 return info; 303 } 304 305 @Override 306 public boolean getMaterializeFulltextSyntheticColumn() { 307 return false; 308 } 309 310 @Override 311 public int getFulltextIndexedColumns() { 312 return 2; 313 } 314 315 @Override 316 public String getTableTypeString(Table table) { 317 if (table.hasFulltextIndex()) { 318 return " ENGINE=MyISAM"; 319 } else { 320 return " ENGINE=InnoDB"; 321 } 322 } 323 324 @Override 325 public boolean supportsUpdateFrom() { 326 return true; 327 } 328 329 @Override 330 public boolean doesUpdateFromRepeatSelf() { 331 return true; 332 } 333 334 @Override 335 public boolean needsOrderByKeysAfterDistinct() { 336 return false; 337 } 338 339 @Override 340 public boolean needsAliasForDerivedTable() { 341 return true; 342 } 343 344 @Override 345 public String getSecurityCheckSql(String idColumnName) { 346 return String.format("NX_ACCESS_ALLOWED(%s, ?, ?)", idColumnName); 347 } 348 349 @Override 350 public String getInTreeSql(String idColumnName, String id) { 351 return String.format("NX_IN_TREE(%s, ?)", idColumnName); 352 } 353 354 @Override 355 public String getSQLStatementsFilename() { 356 return "nuxeovcs/mysql.sql.txt"; 357 } 358 359 @Override 360 public String getTestSQLStatementsFilename() { 361 return "nuxeovcs/mysql.test.sql.txt"; 362 } 363 364 @Override 365 public Map<String, Serializable> getSQLStatementsProperties(Model model, Database database) { 366 Map<String, Serializable> properties = new HashMap<>(); 367 properties.put("idType", "varchar(36)"); 368 properties.put("fulltextEnabled", Boolean.valueOf(!fulltextDisabled)); 369 properties.put("fulltextSearchEnabled", Boolean.valueOf(!fulltextSearchDisabled)); 370 properties.put("clusteringEnabled", Boolean.valueOf(clusteringEnabled)); 371 return properties; 372 } 373 374 @Override 375 public boolean isConcurrentUpdateException(Throwable t) { 376 do { 377 if (t instanceof SQLException) { 378 String sqlState = ((SQLException) t).getSQLState(); 379 if ("23000".equals(sqlState)) { 380 // Integrity constraint violation: 1452 Cannot add or update a child row: 381 // a foreign key constraint fails 382 return true; 383 } 384 if ("40001".equals(sqlState)) { 385 // com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: 386 // java.sql.SQLTransactionRollbackException for MariaDB: 387 // Deadlock found when trying to get lock; try restarting transaction 388 return true; 389 } 390 } 391 t = t.getCause(); 392 } while (t != null); 393 return false; 394 } 395 396 @Override 397 public boolean isClusteringSupported() { 398 return true; 399 } 400 401 @Override 402 public boolean isClusteringDeleteNeeded() { 403 return true; 404 } 405 406 @Override 407 public String getClusterInsertInvalidations() { 408 return "CALL NX_CLUSTER_INVAL(?, ?, ?, ?)"; 409 } 410 411 @Override 412 public String getClusterGetInvalidations() { 413 return "SELECT id, fragments, kind FROM cluster_invals WHERE nodeid = ?"; 414 } 415 416 @Override 417 public boolean supportsPaging() { 418 return true; 419 } 420 421 @Override 422 public String addPagingClause(String sql, long limit, long offset) { 423 return sql + String.format(" LIMIT %d OFFSET %d", limit, offset); 424 } 425 426 @Override 427 public boolean isIdentityAlreadyPrimary() { 428 return true; 429 } 430 431 @Override 432 public String getBinaryFulltextSql(List<String> columns) { 433 return "SELECT " + String.join(", ", columns) + " FROM `fulltext` WHERE id=?"; 434 } 435 436 @Override 437 public List<String> checkStoredProcedure(String procName, String procCreate, String ddlMode, Connection connection, 438 JDBCLogger logger, Map<String, Serializable> properties) throws SQLException { 439 boolean compatCheck = ddlMode.contains(RepositoryDescriptor.DDL_MODE_COMPAT); 440 String ifExists = compatCheck ? "IF EXISTS " : ""; 441 String procDrop; 442 if (procCreate.toLowerCase().startsWith("create function ")) { 443 procDrop = "DROP FUNCTION " + ifExists + procName; 444 } else { 445 procDrop = "DROP PROCEDURE " + ifExists + procName; 446 } 447 if (compatCheck) { 448 return Arrays.asList(procDrop, procCreate); 449 } 450 try (Statement st = connection.createStatement()) { 451 String getBody = "SELECT body FROM mysql.proc WHERE db = DATABASE() AND name = '" + procName + "'"; 452 logger.log(getBody); 453 try (ResultSet rs = st.executeQuery(getBody)) { 454 if (rs.next()) { 455 String body = rs.getString(1); 456 if (normalizeString(procCreate).contains(normalizeString(body))) { 457 logger.log(" -> exists, unchanged"); 458 return Collections.emptyList(); 459 } else { 460 logger.log(" -> exists, old"); 461 return Arrays.asList(procDrop, procCreate); 462 } 463 } else { 464 logger.log(" -> missing"); 465 return Collections.singletonList(procCreate); 466 } 467 } 468 } 469 } 470 471 protected static String normalizeString(String string) { 472 // MySQL strips comments when recording a procedure's body 473 return string.replaceAll("-- .*", " ").replaceAll("[ \n\r\t]+", " ").trim(); 474 } 475 476 @Override 477 public Collection<? extends String> getDumpStart() { 478 return Collections.singleton("DELIMITER $$"); 479 } 480 481 @Override 482 public Collection<? extends String> getDumpStop() { 483 return Collections.singleton("DELIMITER ;"); 484 } 485 486 @Override 487 public String getSQLForDump(String sql) { 488 return sql + " $$"; 489 } 490 491}