/* This file is part of VoltDB.
* Copyright (C) 2008-2017 VoltDB Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with VoltDB. If not, see <http://www.gnu.org/licenses/>.
*/
package org.voltdb.plannodes;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import org.json_voltpatches.JSONException;
import org.json_voltpatches.JSONObject;
import org.json_voltpatches.JSONStringer;
import org.voltdb.catalog.CatalogMap;
import org.voltdb.catalog.Column;
import org.voltdb.catalog.ColumnRef;
import org.voltdb.catalog.Constraint;
import org.voltdb.catalog.Database;
import org.voltdb.catalog.Index;
import org.voltdb.catalog.MaterializedViewHandlerInfo;
import org.voltdb.catalog.MaterializedViewInfo;
import org.voltdb.catalog.Table;
import org.voltdb.planner.PlanningErrorException;
import org.voltdb.types.ConstraintType;
import org.voltdb.types.PlanNodeType;
public class SwapTablesPlanNode extends AbstractOperationPlanNode {
private static class Members {
static final String OTHER_TARGET_TABLE_NAME = "OTHER_TARGET_TABLE_NAME";
static final String INDEXES = "INDEXES";
static final String OTHER_INDEXES = "OTHER_INDEXES";
}
private String m_otherTargetTableName;
private List<String> m_theIndexes = new ArrayList<>();
private List<String> m_otherIndexes = new ArrayList<>();
private static class FailureMessage {
private final List<String> m_failureReasons = new ArrayList<>();
private final String m_theTable;
private final String m_otherTable;
public FailureMessage(String theTable, String otherTable) {
m_theTable = theTable;
m_otherTable = otherTable;
}
public int numFailures() {
return m_failureReasons.size();
}
public void addReason(String reason) {
m_failureReasons.add(reason);
}
public String getMessage() {
if (numFailures() == 0) {
return "";
}
StringBuffer sb = new StringBuffer();
sb.append("Swapping tables " + m_theTable + " and " + m_otherTable + " failed for the following reason(s):");
for (String reason : m_failureReasons) {
sb.append("\n - " + reason);
}
return sb.toString();
}
}
public SwapTablesPlanNode() {
super();
}
@Override
public SwapTablesPlanNode clone() throws CloneNotSupportedException {
SwapTablesPlanNode other = (SwapTablesPlanNode) super.clone();
other.m_theIndexes = new ArrayList<>(m_theIndexes);
other.m_otherIndexes = new ArrayList<>(m_otherIndexes);
return other;
}
@Override
public PlanNodeType getPlanNodeType() { return PlanNodeType.SWAPTABLES; };
@Override
public void toJSONString(JSONStringer stringer) throws JSONException {
super.toJSONString(stringer);
stringer.keySymbolValuePair(Members.OTHER_TARGET_TABLE_NAME,
m_otherTargetTableName);
toJSONStringArrayString(stringer, Members.INDEXES, m_theIndexes);
toJSONStringArrayString(stringer, Members.OTHER_INDEXES, m_otherIndexes);
}
@Override
public void loadFromJSONObject(JSONObject jobj, Database db)
throws JSONException {
super.loadFromJSONObject(jobj, db);
m_otherTargetTableName = jobj.getString(Members.OTHER_TARGET_TABLE_NAME);
m_theIndexes = loadStringListMemberFromJSON(jobj, "INDEXES");
m_otherIndexes = loadStringListMemberFromJSON(jobj, "OTHER_INDEXES");
}
@Override
protected String explainPlanForNode(String indent) {
StringBuilder sb = new StringBuilder("SWAP TABLE ");
sb.append(getTargetTableName())
.append(" WITH ").append(m_otherTargetTableName);
if ( ! m_theIndexes.isEmpty()) {
sb.append(" swapping indexes: \n");
for (int ii = 0; ii < m_theIndexes.size(); ++ii) {
sb.append(indent).append(m_theIndexes.get(ii))
.append(" with ").append(m_otherIndexes.get(ii));
}
}
return sb.toString();
}
/** SWAP TABLES has no effect on data ordering. */
@Override
public boolean isOrderDeterministic() { return true; }
/**
* Fill out all of the serializable attributes of the node, validating
* its arguments' compatibility along the way to ensure successful
* execution.
* @param theTable the catalog definition of the 1st table swap argument
* @param otherTable the catalog definition of the 2nd table swap argument
* @throws PlannerErrorException if one or more compatibility validations fail
*/
public void initializeSwapTablesPlanNode(Table theTable, Table otherTable) {
String theName = theTable.getTypeName();
setTargetTableName(theName);
String otherName = otherTable.getTypeName();
m_otherTargetTableName = otherName;
FailureMessage failureMessage = new FailureMessage(theName, otherName);
validateTableCompatibility(theName, otherName, theTable, otherTable, failureMessage);
validateColumnCompatibility(theName, otherName, theTable, otherTable, failureMessage);
// Maintain sets of indexes and index-supported (UNIQUE) constraints
// and the primary key index found on otherTable.
// Removing them as they are matched by indexes/constraints on theTable
// and added to the list of swappable indexes should leave the sets empty.
HashSet<Index> otherIndexSet = new HashSet<>();
// The constraint set is actually a HashMap to retain the
// defining constraint name for help with error messages.
// Track the primary key separately since it should match one-to-one.
HashMap<Index, String> otherConstraintIndexMap = new HashMap<>();
Index otherPrimaryKeyIndex = null;
// Collect the system-defined (internal) indexes supporting constraints
// and the primary key index if any.
CatalogMap<Constraint> candidateConstraints = otherTable.getConstraints();
for (Constraint otherConstraint : candidateConstraints) {
Index otherIndex = otherConstraint.getIndex();
if (otherIndex == null) {
// Some kinds of constraints that are not index-based have no
// effect on the swap table plan.
continue;
}
// Set aside the one primary key index for special handling.
if (otherConstraint.getType() == ConstraintType.PRIMARY_KEY.getValue()) {
otherPrimaryKeyIndex = otherIndex;
continue;
}
otherConstraintIndexMap.put(otherIndex, otherConstraint.getTypeName());
}
// Collect the user-defined (external) indexes on otherTable. The indexes
// in this set are removed as corresponding matches are found.
// System-generated indexes that support constraints are checked separately,
// so don't add them to this set.
CatalogMap<Index> candidateIndexes = otherTable.getIndexes();
for (Index otherIndex : candidateIndexes) {
if (otherIndex != otherPrimaryKeyIndex &&
!otherConstraintIndexMap.containsKey(otherIndex)) {
otherIndexSet.add(otherIndex);
}
}
// Collect the indexes that support constraints on theTable
HashSet<Index> theConstraintIndexSet = new HashSet<>();
Index thePrimaryKeyIndex = null;
for (Constraint constraint : theTable.getConstraints()) {
Index theIndex = constraint.getIndex();
if (theIndex == null) {
continue;
}
if (constraint.getType() == ConstraintType.PRIMARY_KEY.getValue()) {
thePrimaryKeyIndex = theIndex;
continue;
}
theConstraintIndexSet.add(constraint.getIndex());
}
// Make sure that both either both or neither tables have primary keys, and if both,
// make sure the indexes are swappable.
if (thePrimaryKeyIndex != null && otherPrimaryKeyIndex != null) {
if (indexesCanBeSwapped(thePrimaryKeyIndex, otherPrimaryKeyIndex)) {
m_theIndexes.add(thePrimaryKeyIndex.getTypeName());
m_otherIndexes.add(otherPrimaryKeyIndex.getTypeName());
}
else {
failureMessage.addReason("PRIMARY KEY constraints do not match on both tables");
}
}
else if ((thePrimaryKeyIndex != null && otherPrimaryKeyIndex == null)
|| (thePrimaryKeyIndex == null && otherPrimaryKeyIndex != null)) {
failureMessage.addReason("one table has a PRIMARY KEY constraint and the other does not");
}
// Try to cross-reference each user-defined index on the two tables.
for (Index theIndex : theTable.getIndexes()) {
if (theConstraintIndexSet.contains(theIndex) || theIndex == thePrimaryKeyIndex) {
// Constraints are checked below.
continue;
}
boolean matched = false;
for (Index otherIndex : otherIndexSet) {
if (indexesCanBeSwapped(theIndex, otherIndex)) {
m_theIndexes.add(theIndex.getTypeName());
m_otherIndexes.add(otherIndex.getTypeName());
otherIndexSet.remove(otherIndex);
matched = true;
break;
}
}
if (matched) {
continue;
}
// No match: look for a likely near-match based on naming
// convention for the most helpful error message.
// Otherwise, give a more generic error message.
String theIndexName = theIndex.getTypeName();
String message = "the index " + theIndexName + " on table " + theName
+ " has no corresponding index in the other table";
String otherIndexName = theIndexName.replace(theName, otherName);
Index otherIndex = candidateIndexes.getIgnoreCase(otherIndexName);
if (otherIndex != null) {
message += "; the closest candidate ("
+ otherIndexName + ") has mismatches in the following attributes: "
+ String.join(", ", diagnoseIndexMismatch(theIndex, otherIndex));
}
failureMessage.addReason(message);
}
// At this point, all of theTable's indexes are matched.
// All of otherTable's indexes should also have been
// matched along the way.
if ( ! otherIndexSet.isEmpty()) {
List<String> indexNames = otherIndexSet.stream().map(idx -> idx.getTypeName())
.collect(Collectors.toList());
failureMessage.addReason("the table " + otherName + " contains these index(es) "
+ "which have no corresponding indexes on " + theName + ": "
+ "(" + String.join(", ", indexNames) + ")");
}
// Try to cross-reference each system-defined index supporting
// constraints on the two tables.
for (Constraint theConstraint : theTable.getConstraints()) {
Index theIndex = theConstraint.getIndex();
if (theIndex == null) {
// Some kinds of constraints that are not index-based have no
// effect on the swap table plan.
continue;
}
if (theConstraint.getType() == ConstraintType.PRIMARY_KEY.getValue()) {
// Primary key compatibility checked above.
continue;
}
boolean matched = false;
for (Entry<Index, String> otherEntry : otherConstraintIndexMap.entrySet()) {
Index otherIndex = otherEntry.getKey();
if (indexesCanBeSwapped(theIndex, otherIndex)) {
m_theIndexes.add(theIndex.getTypeName());
m_otherIndexes.add(otherIndex.getTypeName());
otherConstraintIndexMap.remove(otherIndex);
matched = true;
break;
}
}
if (matched) {
continue;
}
String theConstraintName = theConstraint.getTypeName();
failureMessage.addReason("the constraint " + theConstraintName + " on table " + theName + " "
+ "has no corresponding constraint on the other table");
}
// At this point, all of theTable's index-based constraints are matched.
// All of otherTable's index-based constraints should also have been
// matched along the way.
if ( ! otherConstraintIndexMap.isEmpty()) {
StringBuilder sb = new StringBuilder();
sb.append("these constraints (or system internal index names) on table " + otherName + " "
+ "have no corresponding constraints on the other table: (");
String separator = "";
for (Entry<Index, String> remainder : otherConstraintIndexMap.entrySet()) {
String constraintName = remainder.getValue();
String description =
(constraintName != null && ! constraintName.equals("")) ?
constraintName :
("<anonymous with system internal index name: " +
remainder.getKey().getTypeName() + ">");
sb.append(separator).append(description);
separator = ", ";
}
sb.append(")");
failureMessage.addReason(sb.toString());
}
if (failureMessage.numFailures() > 0) {
throw new PlanningErrorException(failureMessage.getMessage());
}
}
/**
* Give two strings, return a list of attributes that do not match
* @param theIndex
* @param otherIndex
* @return list of attributes that do not match
*/
private List<String> diagnoseIndexMismatch(Index theIndex, Index otherIndex) {
List<String> mismatchedAttrs = new ArrayList<>();
// Pairs of matching indexes must agree on type (int hash, etc.).
if (theIndex.getType() != otherIndex.getType()) {
mismatchedAttrs.add("index type (hash vs tree)");
}
// Pairs of matching indexes must agree whether they are (assume)unique.
if (theIndex.getUnique() != otherIndex.getUnique() ||
theIndex.getAssumeunique() != otherIndex.getAssumeunique()) {
mismatchedAttrs.add("UNIQUE attribute");
}
// Pairs of matching indexes must agree whether they are partial
// and if so, agree on the predicate.
String thePredicateJSON = theIndex.getPredicatejson();
String otherPredicateJSON = otherIndex.getPredicatejson();
if (thePredicateJSON == null) {
if (otherPredicateJSON != null) {
mismatchedAttrs.add("WHERE predicate");
}
}
else if ( ! thePredicateJSON.equals(otherPredicateJSON)) {
mismatchedAttrs.add("WHERE predicate");
}
// Pairs of matching indexes must agree that they do or do not index
// expressions and, if so, agree on the expressions.
String theExprsJSON = theIndex.getExpressionsjson();
String otherExprsJSON = otherIndex.getExpressionsjson();
if (theExprsJSON == null) {
if (otherExprsJSON != null) {
mismatchedAttrs.add("indexed expression");
}
}
else if ( ! theExprsJSON.equals(otherExprsJSON)) {
mismatchedAttrs.add("indexed expression");
}
// Indexes must agree on the columns they are based on,
// identifiable by the columns' order in the table.
CatalogMap<ColumnRef> theColumns = theIndex.getColumns();
int theColumnCount = theColumns.size();
CatalogMap<ColumnRef> otherColumns = otherIndex.getColumns();
if (theColumnCount != otherColumns.size() ) {
mismatchedAttrs.add("indexed expression");
}
Iterator<ColumnRef> theColumnIterator = theColumns.iterator();
Iterator<ColumnRef> otherColumnIterator = otherColumns.iterator();
for (int ii = 0 ;ii < theColumnCount; ++ii) {
int theColIndex = theColumnIterator.next().getColumn().getIndex();
int otherColIndex = otherColumnIterator.next().getColumn().getIndex();
if (theColIndex != otherColIndex) {
mismatchedAttrs.add("indexed expression");
}
}
return mismatchedAttrs;
}
/**
* @param theIndex candidate match for otherIndex on the target table
* @param otherIndex candidate match for theIndex on the other target table
* @param candidateIndexSet set of otherTable indexes as yet unmatched
*/
private static boolean indexesCanBeSwapped(Index theIndex, Index otherIndex) {
// Pairs of matching indexes must agree on type (int hash, etc.).
if (theIndex.getType() != otherIndex.getType()) {
return false;
}
// Pairs of matching indexes must agree whether they are (assume)unique.
if (theIndex.getUnique() != otherIndex.getUnique() ||
theIndex.getAssumeunique() != otherIndex.getAssumeunique()) {
return false;
}
// Pairs of matching indexes must agree whether they are partial
// and if so, agree on the predicate.
String thePredicateJSON = theIndex.getPredicatejson();
String otherPredicateJSON = otherIndex.getPredicatejson();
if (thePredicateJSON == null) {
if (otherPredicateJSON != null) {
return false;
}
}
else if ( ! thePredicateJSON.equals(otherPredicateJSON)) {
return false;
}
// Pairs of matching indexes must agree that they do or do not index
// expressions and, if so, agree on the expressions.
String theExprsJSON = theIndex.getExpressionsjson();
String otherExprsJSON = otherIndex.getExpressionsjson();
if (theExprsJSON == null) {
if (otherExprsJSON != null) {
return false;
}
}
else if ( ! theExprsJSON.equals(otherExprsJSON)) {
return false;
}
// Indexes must agree on the columns they are based on,
// identifiable by the columns' order in the table.
CatalogMap<ColumnRef> theColumns = theIndex.getColumns();
int theColumnCount = theColumns.size();
CatalogMap<ColumnRef> otherColumns = otherIndex.getColumns();
if (theColumnCount != otherColumns.size() ) {
return false;
}
Iterator<ColumnRef> theColumnIterator = theColumns.iterator();
Iterator<ColumnRef> otherColumnIterator = otherColumns.iterator();
for (int ii = 0 ;ii < theColumnCount; ++ii) {
int theColIndex = theColumnIterator.next().getColumn().getIndex();
int otherColIndex = otherColumnIterator.next().getColumn().getIndex();
if (theColIndex != otherColIndex) {
return false;
}
}
return true;
}
/**
* Flag any issues of incompatibility between the two table operands
* of a swap by appending error details to a feedback buffer. These
* details and possibly others should get attached to a
* PlannerErrorException's message by the caller.
* @param theName the first argument to the table swap
* @param otherName the second argument to the tble swap
* @param theTable the catalog Table definition named by theName
* @param otherTable the catalog Table definition named by otherName
* @return the current feedback output separator,
* it will be == TRUE_FB_SEPARATOR
* if the feedback buffer is not empty.
*/
private void validateTableCompatibility(String theName, String otherName,
Table theTable, Table otherTable, FailureMessage failureMessage) {
if (theTable.getIsdred() != otherTable.getIsdred()) {
failureMessage.addReason("To swap table " + theName + " with table " + otherName +
" both tables must be DR enabled or both tables must not be DR enabled.");
}
if (theTable.getIsreplicated() != otherTable.getIsreplicated()) {
failureMessage.addReason("one table is partitioned and the other is not");
}
if (theTable.getTuplelimit() != otherTable.getTuplelimit()) {
failureMessage.addReason("the tables differ in the LIMIT PARTITION ROWS constraint");
}
if ((theTable.getMaterializer() != null ||
! theTable.getMvhandlerinfo().isEmpty()) ||
(otherTable.getMaterializer() != null ||
! otherTable.getMvhandlerinfo().isEmpty())) {
failureMessage.addReason("one or both of the tables is actually a view");
}
StringBuilder viewNames = new StringBuilder();
if (viewsDependOn(theTable, viewNames)) {
failureMessage.addReason(theName + " is referenced in views " + viewNames.toString());
}
viewNames.setLength(0);
if (viewsDependOn(otherTable, viewNames)) {
failureMessage.addReason(otherName + " is referenced in views " + viewNames.toString());
}
}
/**
* @param theTable
* @return
*/
private static boolean viewsDependOn(Table aTable, StringBuilder viewNames) {
String separator = "(";
for (MaterializedViewInfo anyView : aTable.getViews()) {
viewNames.append(separator).append(anyView.getTypeName());
separator = ", ";
}
for (Table anyTable : ((Database) aTable.getParent()).getTables()) {
for (MaterializedViewHandlerInfo anyView : anyTable.getMvhandlerinfo()) {
if (anyView.getSourcetables().getIgnoreCase(aTable.getTypeName()) != null) {
viewNames.append(separator).append(anyView.getDesttable().getTypeName());
separator = ", ";
}
}
}
if (", ".equals(separator)) {
viewNames.append(")");
return true;
}
return false;
}
/**
* Flag any issues of incompatibility between the columns of the two table
* operands of a swap by appending error details to a feedback buffer.
* These details and possibly others should get attached to a
* PlannerErrorException's message by the caller.
* @param theName the first argument to the table swap
* @param otherName the second argument to the tble swap
* @param theTable the catalog Table definition named by theName
* @param otherTable the catalog Table definition named by otherName
* @return the current feedback output separator,
* it will be == TRUE_FB_SEPARATOR
* if the feedback buffer is not empty.
*/
private void validateColumnCompatibility(String theName, String otherName,
Table theTable, Table otherTable,
FailureMessage failureMessage) {
CatalogMap<Column> theColumns = theTable.getColumns();
int theColCount = theColumns.size();
CatalogMap<Column> otherColumns = otherTable.getColumns();
if (theColCount != otherColumns.size()) {
failureMessage.addReason("the tables have different numbers of columns");
return;
}
Column[] theColArray = new Column[theColumns.size()];
for (Column theColumn : theColumns) {
theColArray[theColumn.getIndex()] = theColumn;
}
for (Column otherColumn : otherColumns) {
int colIndex = otherColumn.getIndex();
String colName = otherColumn.getTypeName();
if (colIndex < theColCount) {
Column theColumn = theColArray[colIndex];
if (theColumn.getTypeName().equals(colName)) {
if (theColumn.getType() != otherColumn.getType() ||
theColumn.getSize() != otherColumn.getSize() ||
theColumn.getInbytes() != otherColumn.getInbytes()) {
failureMessage.addReason("columns named " + colName + " have different types or sizes");
}
continue;
}
}
Column matchedByName = theColumns.get(colName);
if (matchedByName != null) {
failureMessage.addReason(colName + " is in a different ordinal position in the two tables");
}
else {
failureMessage.addReason(colName + " appears in " + otherName + " but not in " + theName);
}
}
if ( ! theTable.getIsreplicated() && ! otherTable.getIsreplicated() ) {
if (! theTable.getPartitioncolumn().getTypeName().equals(
otherTable.getPartitioncolumn().getTypeName())) {
failureMessage.addReason("the tables are not partitioned on the same column");
}
}
}
}