/** * Copyright (C) 2009-2013 FoundationDB, LLC * * 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 this program. If not, see <http://www.gnu.org/licenses/>. */ package com.foundationdb.ais.util; import com.foundationdb.ais.model.AbstractVisitor; import com.foundationdb.ais.model.Column; import com.foundationdb.ais.model.ColumnName; import com.foundationdb.ais.model.ForeignKey; import com.foundationdb.ais.model.GroupIndex; import com.foundationdb.ais.model.Index; import com.foundationdb.ais.model.IndexColumn; import com.foundationdb.ais.model.Join; import com.foundationdb.ais.model.JoinColumn; import com.foundationdb.ais.model.Sequence; import com.foundationdb.ais.model.Table; import com.foundationdb.ais.model.TableIndex; import com.foundationdb.ais.model.TableName; import com.foundationdb.ais.util.TableChange.ChangeType; import com.foundationdb.server.store.format.columnkeys.ColumnKeysStorageDescription; import com.foundationdb.util.ArgumentValidation; import com.google.common.base.Objects; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import static com.foundationdb.ais.util.ChangedTableDescription.ParentChange; import static com.foundationdb.ais.util.TableChangeValidatorException.*; public class TableChangeValidator { private static final Map<String,String> EMPTY_STRING_MAP = Collections.emptyMap(); private static final List<TableName> EMPTY_TABLE_NAME_LIST = Collections.emptyList(); public static enum ChangeLevel { NONE, METADATA, METADATA_CONSTRAINT, INDEX, // TODO: Ugly. Final change level should be a Set and/or constraint changes passed separately. INDEX_CONSTRAINT, TABLE, GROUP } private final Table oldTable; private final Table newTable; private final TableChangeValidatorState state; private final List<RuntimeException> unmodifiedChanges; private ChangeLevel finalChangeLevel; private ChangedTableDescription.ParentChange parentChange; private boolean primaryKeyChanged; private boolean didCompare; public TableChangeValidator(Table oldTable, Table newTable, List<TableChange> columnChanges, List<TableChange> tableIndexChanges) { ArgumentValidation.notNull("oldTable", oldTable); ArgumentValidation.notNull("newTable", newTable); ArgumentValidation.notNull("columnChanges", columnChanges); ArgumentValidation.notNull("tableIndexChanges", tableIndexChanges); this.oldTable = oldTable; this.newTable = newTable; this.state = new TableChangeValidatorState(columnChanges, tableIndexChanges); this.unmodifiedChanges = new ArrayList<>(); this.finalChangeLevel = ChangeLevel.NONE; this.parentChange = ParentChange.NONE; } public ChangeLevel getFinalChangeLevel() { return finalChangeLevel; } public TableChangeValidatorState getState() { return state; } public boolean isParentChanged() { return (parentChange == ParentChange.ADD) || (parentChange == ParentChange.DROP); } public boolean isPrimaryKeyChanged() { return primaryKeyChanged; } public List<RuntimeException> getUnmodifiedChanges() { return unmodifiedChanges; } public void compare() { if(!didCompare) { compareTable(); compareColumns(); compareIndexes(); compareGrouping(); compareGroupIndexes(); compareForeignKeys(); updateFinalChangeLevel(ChangeLevel.NONE); checkFinalChangeLevel(); didCompare = true; } } private void updateFinalChangeLevel(ChangeLevel level) { if(level.ordinal() > finalChangeLevel.ordinal()) { finalChangeLevel = level; } } private void compareTable() { TableName oldName = oldTable.getName(); TableName newName = newTable.getName(); if(!oldName.equals(newName) || !Objects.equal(oldTable.getCharsetName(), newTable.getCharsetName()) || !Objects.equal(oldTable.getCollationName(), newTable.getCollationName())) { updateFinalChangeLevel(ChangeLevel.METADATA); } } private void compareColumns() { Map<String,Column> oldColumns = new HashMap<>(); Map<String,Column> newColumns = new HashMap<>(); for(Column column : oldTable.getColumnsIncludingInternal()) { oldColumns.put(column.getName(), column); } for(Column column : newTable.getColumnsIncludingInternal()) { newColumns.put(column.getName(), column); } checkChanges(ChangeLevel.TABLE, state.columnChanges, oldColumns, newColumns, false); // Look for position changes, not required to be declared for(Map.Entry<String, Column> oldEntry : oldColumns.entrySet()) { Column newColumn = newColumns.get(findNewName(state.columnChanges, oldEntry.getKey())); if((newColumn != null) && !oldEntry.getValue().getPosition().equals(newColumn.getPosition())) { updateFinalChangeLevel(ChangeLevel.TABLE); break; } } } private void compareIndexes() { Map<String,TableIndex> oldIndexes = new HashMap<>(); Map<String,TableIndex> newIndexes = new HashMap<>(); for(TableIndex index : oldTable.getIndexesIncludingInternal()) { oldIndexes.put(index.getIndexName().getName(), index); } // Look for incompatible spatial changes for(TableIndex oldIndex : oldTable.getIndexesIncludingInternal()) { String oldName = oldIndex.getIndexName().getName(); String newName = findNewName(state.tableIndexChanges, oldName); TableIndex newIndex = (newName != null) ? newTable.getIndexIncludingInternal(newName) : null; if((newIndex != null) && oldIndex.isSpatial() && !Index.isSpatialCompatible(newIndex)) { newTable.removeIndexes(Collections.singleton(newIndex)); // Remove any entry that already exists (e.g. MODIFY from compareColumns()) Iterator<TableChange> it = state.tableIndexChanges.iterator(); while(it.hasNext()) { TableChange c = it.next(); if(oldName.equals(c.getOldName())) { it.remove(); break; } } } } for(TableIndex index : newTable.getIndexesIncludingInternal()) { newIndexes.put(index.getIndexName().getName(), index); } checkChanges(ChangeLevel.INDEX, state.tableIndexChanges, oldIndexes, newIndexes, true); } private void compareGroupIndexes() { final Set<Table> keepTables = new HashSet<>(); final Table traverseStart; if(parentChange == ParentChange.DROP) { traverseStart = oldTable; } else { traverseStart = oldTable.getGroup().getRoot(); } traverseStart.visit(new AbstractVisitor() { @Override public void visit(Table table) { keepTables.add(table); } }); for(GroupIndex index : oldTable.getGroupIndexes()) { boolean dataChange = (finalChangeLevel == ChangeLevel.GROUP); List<ColumnName> remainingCols = new ArrayList<>(); for(IndexColumn iCol : index.getKeyColumns()) { Column column = iCol.getColumn(); if(!keepTables.contains(column.getTable())) { remainingCols.clear(); break; } String oldName = column.getName(); boolean isTargetTable = column.getTable() == oldTable; String newName = isTargetTable ? findNewName(state.columnChanges, oldName) : oldName; if(newName != null) { TableName tableName = isTargetTable ? newTable.getName() : column.getTable().getName(); remainingCols.add(new ColumnName(tableName, newName)); if(column.getTable() == oldTable) { Column oldColumn = oldTable.getColumn(oldName); Column newColumn = newTable.getColumn(newName); dataChange |= (compare(oldColumn, newColumn) == ChangeLevel.TABLE); } } else { dataChange = true; } } if(remainingCols.size() <= 1) { remainingCols.clear(); state.droppedGI.add(index.getIndexName().getName()); } else { state.affectedGI.put(index.getIndexName().getName(), remainingCols); if(dataChange) { state.dataAffectedGI.put(index.getIndexName().getName(), remainingCols); } } } } private <T> void checkChanges(ChangeLevel level, List<TableChange> changeList, Map<String,T> oldMap, Map<String,T> newMap, boolean doAutoChanges) { final boolean isIndex = (level == ChangeLevel.INDEX); Set<String> oldExcludes = new HashSet<>(); Set<String> newExcludes = new HashSet<>(); List<TableChange> autoChanges = doAutoChanges ? new ArrayList<TableChange>() : null; // Check declared changes for(TableChange change : changeList) { String oldName = change.getOldName(); String newName = change.getNewName(); switch(change.getChangeType()) { case ADD: { if(newMap.get(newName) == null) { if(doAutoChanges) { autoChanges.add(TableChange.createAdd(newName)); } else { addNotPresent(isIndex, change); } } else { updateFinalChangeLevel(level); newExcludes.add(newName); } } break; case DROP: { if(oldMap.get(oldName) == null) { dropNotPresent(isIndex, change); } else { updateFinalChangeLevel(level); oldExcludes.add(oldName); } } break; case MODIFY: { T oldVal = oldMap.get(oldName); T newVal = newMap.get(newName); if((oldVal == null) || (newVal == null)) { modifyNotPresent(isIndex, change); } else { ChangeLevel curChange = compare(oldVal, newVal); if(curChange == ChangeLevel.NONE) { unmodifiedChanges.add(modifyNotChanged(isIndex, change)); } else { updateFinalChangeLevel(curChange); oldExcludes.add(oldName); newExcludes.add(newName); } } } break; } } // Check remaining elements in old table for(Map.Entry<String,T> entry : oldMap.entrySet()) { String name = entry.getKey(); if(!oldExcludes.contains(name)) { T newVal = newMap.get(name); if(newVal == null) { if(doAutoChanges) { autoChanges.add(TableChange.createDrop(name)); } else { unchangedNotPresent(isIndex, name); } } else { ChangeLevel change = compare(entry.getValue(), newVal); if(change != ChangeLevel.NONE) { if(doAutoChanges) { autoChanges.add(TableChange.createModify(name, name)); } else { undeclaredChange(isIndex, name); } } newExcludes.add(name); } } } // Check remaining elements in new table (should be none) for(String name : newMap.keySet()) { if(!newExcludes.contains(name)) { if(doAutoChanges) { autoChanges.add(TableChange.createAdd(name)); } else { undeclaredChange(isIndex, name); } } } if(doAutoChanges && !autoChanges.isEmpty()) { changeList.addAll(autoChanges); updateFinalChangeLevel(level); } } private void compareGrouping() { parentChange = compareParentJoin(state.columnChanges, oldTable.getParentJoin(), newTable.getParentJoin()); primaryKeyChanged = containsOldOrNew(state.tableIndexChanges, Index.PRIMARY); List<TableName> droppedSequences = new ArrayList<>(); List<String> addedIdentity = new ArrayList<>(); List<String> addedIndexes = new ArrayList<>(); Map<String,String> renamedColumns = new HashMap<>(); for(TableChange change : state.columnChanges) { switch(change.getChangeType()) { case MODIFY: { if(!change.getOldName().equals(change.getNewName())) { renamedColumns.put(change.getOldName(), change.getNewName()); } Column oldColumn = oldTable.getColumn(change.getOldName()); Column newColumn = newTable.getColumn(change.getNewName()); if((oldColumn != null)) { Sequence oldSeq = oldColumn.getIdentityGenerator(); Sequence newSeq = newColumn.getIdentityGenerator(); if((oldSeq == null) && (newSeq != null)) { addedIdentity.add(newColumn.getName()); } else if((oldSeq != null) && (newSeq == null)) { droppedSequences.add(oldSeq.getSequenceName()); } // else both not null and not equal, not yet supported } } break; case DROP: { Column oldColumn = oldTable.getColumn(change.getOldName()); if((oldColumn != null) && (oldColumn.getIdentityGenerator() != null)) { droppedSequences.add(oldColumn.getIdentityGenerator().getSequenceName()); } } break; case ADD: { Column newColumn = newTable.getColumn(change.getNewName()); Sequence newSeq = newColumn.getIdentityGenerator(); if(newSeq != null) { addedIdentity.add(newColumn.getName()); } } break; } } for(TableChange change : state.tableIndexChanges) { if(change.getChangeType() == ChangeType.ADD) { addedIndexes.add(change.getNewName()); } } boolean renamed = !oldTable.getName().equals(newTable.getName()) || !renamedColumns.isEmpty(); Map<String,String> preserveIndexes = new TreeMap<>(); TableName parentName = (newTable.getParentJoin() != null) ? newTable.getParentJoin().getParent().getName() : null; state.descriptions.add( new ChangedTableDescription( oldTable.getTableId(), oldTable.getName(), newTable, renamedColumns, parentChange, parentName, EMPTY_STRING_MAP, preserveIndexes, droppedSequences, addedIdentity, addedIndexes, finalChangeLevel == ChangeLevel.TABLE, isParentChanged() || primaryKeyChanged ) ); if(!isParentChanged() && !primaryKeyChanged) { for(Index index : newTable.getIndexesIncludingInternal()) { String oldName = index.getIndexName().getName(); String newName = findNewName(state.tableIndexChanges, oldName); if(!containsOldOrNew(state.tableIndexChanges, oldName)) { preserveIndexes.put(oldName, newName); } } } Collection<Join> oldChildJoins = new ArrayList<>(oldTable.getCandidateChildJoins()); for(Join join : oldChildJoins) { Table oldChildTable = join.getChild(); // If referenced column has anymore has a TABLE change (or is missing), join needs dropped boolean dropParent = false; for(JoinColumn joinCol : join.getJoinColumns()) { Column oldColumn = joinCol.getParent().getColumn(); String newName = findNewName(state.columnChanges, oldColumn.getName()); if(newName == null) { dropParent = true; } else { Column newColumn = newTable.getColumn(newName); if(compare(oldColumn, newColumn) == ChangeLevel.TABLE) { dropParent = true; } } } boolean preserve = false; ParentChange change = null; // If PK changed and table had children, PK was dropped if(primaryKeyChanged || dropParent) { updateFinalChangeLevel(ChangeLevel.GROUP); change = ParentChange.DROP; } else if(isParentChanged() || (parentChange == ParentChange.ADD)) { updateFinalChangeLevel(ChangeLevel.GROUP); change = ParentChange.UPDATE; } else if(renamed) { updateFinalChangeLevel(ChangeLevel.METADATA); change = ParentChange.META; preserve = true; } if(change != null) { TableName newParent = (change == ParentChange.DROP) ? null : newTable.getName(); trackChangedTable(oldChildTable, change, newParent, renamedColumns, preserve); propagateChildChange(oldChildTable, ParentChange.UPDATE, preserve); } } if(isParentChanged() || primaryKeyChanged) { updateFinalChangeLevel(ChangeLevel.GROUP); } } private void compareForeignKeys() { // Flag referenced table as having metadata changed // No way to rename or alter a FK definition so only need to check presence change. Set<TableName> referencedChanges = new HashSet<>(); for(ForeignKey fk : oldTable.getReferencingForeignKeys()) { if(newTable.getReferencingForeignKey(fk.getConstraintName().getTableName()) == null) { referencedChanges.add(fk.getReferencedTable().getName()); } } for(ForeignKey fk : newTable.getReferencingForeignKeys()) { if(oldTable.getReferencingForeignKey(fk.getConstraintName().getTableName()) == null) { referencedChanges.add(fk.getReferencedTable().getName()); } } // TODO: Would be nice to track complete details (e.g. constraint name) instead of just table for(TableName refName : referencedChanges) { if(!state.hasOldTable(refName)) { Table table = oldTable.getAIS().getTable(refName); TableName parentName = (table.getParentJoin() != null) ? table.getParentJoin().getParent().getName() : null; trackChangedTable(table, ParentChange.NONE, parentName, null, true); } } if(!referencedChanges.isEmpty()) { switch(finalChangeLevel) { case NONE: case METADATA: case METADATA_CONSTRAINT: updateFinalChangeLevel(ChangeLevel.METADATA_CONSTRAINT); break; case INDEX: if(!state.dataAffectedGI.isEmpty()) { throw new IllegalStateException("New FOREIGN KEY and group index?"); } updateFinalChangeLevel(ChangeLevel.INDEX_CONSTRAINT); case TABLE: case GROUP: // None. These already have constraints checked. break; default: assert false : finalChangeLevel; } } } private void propagateChildChange(final Table table, final ParentChange change, final boolean allIndexes) { table.visitBreadthFirst(new AbstractVisitor() { @Override public void visit(Table curTable) { if(table != curTable) { TableName parentName = curTable.getParentJoin().getParent().getName(); trackChangedTable(curTable, change, parentName, null, allIndexes); } } }); } private void trackChangedTable(Table table, ParentChange parentChange, TableName parentName, Map<String, String> parentRenames, boolean doPreserve) { Map<String,String> preserved = new HashMap<>(); if(doPreserve) { for(Index index : table.getIndexesIncludingInternal()) { preserved.put(index.getIndexName().getName(), index.getIndexName().getName()); } } parentRenames = (parentRenames != null) ? parentRenames : EMPTY_STRING_MAP; state.descriptions.add( new ChangedTableDescription( table.getTableId(), table.getName(), null, EMPTY_STRING_MAP, parentChange, parentName, parentRenames, preserved, EMPTY_TABLE_NAME_LIST, Collections.<String>emptyList(), Collections.<String>emptyList(), false, !doPreserve ) ); } private static boolean containsOldOrNew(List<TableChange> changes, String name) { for(TableChange change : changes) { if(name.equals(change.getOldName()) || name.equals(change.getNewName())) { return true; } } return false; } private static String findNewName(List<TableChange> changes, String oldName) { for(TableChange change : changes) { if(oldName.equals(change.getOldName())) { switch(change.getChangeType()) { case ADD: throw new IllegalStateException("Old name was added? " + oldName); case DROP: return null; case MODIFY: return change.getNewName(); } } } return oldName; } private <T> ChangeLevel compare(T oldVal, T newVal) { if(oldVal instanceof Column) { return compare((Column)oldVal, (Column)newVal); } if(oldVal instanceof Index) { return compare((Index)oldVal, (Index)newVal); } throw new IllegalStateException("Cannot compare: " + oldVal + " and " + newVal); } private static ChangeLevel compare(Column oldCol, Column newCol) { if(!oldCol.getType().equalsExcludingNullable(newCol.getType())) { return ChangeLevel.TABLE; } if(oldCol.getTable().getGroup().getStorageDescription() instanceof ColumnKeysStorageDescription && !oldCol.getName().equals(newCol.getName())) { return ChangeLevel.TABLE; } boolean oldNull = oldCol.getNullable(); boolean newNull = newCol.getNullable(); if((oldNull == true) && (newNull == false)) { return ChangeLevel.METADATA_CONSTRAINT; } if((oldNull != newNull) || !oldCol.getName().equals(newCol.getName()) || !Objects.equal(oldCol.getDefaultValue(), newCol.getDefaultValue()) || !Objects.equal(oldCol.getDefaultIdentity(), newCol.getDefaultIdentity()) || sequenceChanged(oldCol.getIdentityGenerator(), newCol.getIdentityGenerator())) { return ChangeLevel.METADATA; } return ChangeLevel.NONE; } private ChangeLevel compare(Index oldIndex, Index newIndex) { if(oldIndex.getKeyColumns().size() != newIndex.getKeyColumns().size()) { return ChangeLevel.INDEX; } Iterator<IndexColumn> oldIt = oldIndex.getKeyColumns().iterator(); Iterator<IndexColumn> newIt = newIndex.getKeyColumns().iterator(); while(oldIt.hasNext()) { IndexColumn oldICol = oldIt.next(); IndexColumn newICol = newIt.next(); String newColName = findNewName(state.columnChanges, oldICol.getColumn().getName()); // Column the same? if((newColName == null) || !newICol.getColumn().getName().equals(newColName)) { return ChangeLevel.INDEX; } // IndexColumn properties if(!Objects.equal(oldICol.getIndexedLength(), newICol.getIndexedLength()) || !Objects.equal(oldICol.isAscending(), newICol.isAscending())) { return ChangeLevel.INDEX; } // Column being indexed if(compare(oldICol.getColumn(), newICol.getColumn()) == ChangeLevel.TABLE) { return ChangeLevel.INDEX; } } if(!oldIndex.getIndexName().getName().equals(newIndex.getIndexName().getName())) { return ChangeLevel.METADATA; } return ChangeLevel.NONE; } private static boolean sequenceChanged(Sequence oldSeq, Sequence newSeq) { if(oldSeq == null && newSeq == null) { return false; } if(oldSeq != null && newSeq == null) { return true; } if(oldSeq == null /*&& newSeq != null**/) { return true; } return (oldSeq.getStartsWith() != newSeq.getStartsWith()) || (oldSeq.getIncrement() != newSeq.getIncrement()) || (oldSeq.getMinValue() != newSeq.getMinValue()) || (oldSeq.getMaxValue() != newSeq.getMaxValue()) || (oldSeq.isCycle() != newSeq.isCycle()); } private static ParentChange compareParentJoin(List<TableChange> columnChanges, Join oldJoin, Join newJoin) { if(oldJoin == null && newJoin == null) { return ParentChange.NONE; } if(oldJoin != null && newJoin == null) { return ParentChange.DROP; } if(oldJoin == null /*&& newJoin != null*/) { return ParentChange.ADD; } Table oldParent = oldJoin.getParent(); Table newParent = newJoin.getParent(); if(!oldParent.getName().equals(newParent.getName()) || (oldJoin.getJoinColumns().size() != newJoin.getJoinColumns().size())) { return ParentChange.ADD; } boolean sawRename = false; Iterator<JoinColumn> oldIt = oldJoin.getJoinColumns().iterator(); Iterator<JoinColumn> newIt = newJoin.getJoinColumns().iterator(); while(oldIt.hasNext()) { Column oldCol = oldIt.next().getChild(); String newName = findNewName(columnChanges, newIt.next().getChild().getName()); if(newName == null) { return ParentChange.DROP; } else { Column newCol = newJoin.getChild().getColumn(newName); if(compare(oldCol, newCol) == ChangeLevel.TABLE) { return ParentChange.DROP; } else if(!oldCol.getName().equals(newName)) { sawRename = true; } } } return sawRename ? ParentChange.META : ParentChange.NONE; } private void addNotPresent(boolean isIndex, TableChange change) { String detail = change.toString(); throw isIndex ? new AddIndexNotPresentException(detail) : new AddColumnNotPresentException(detail); } private void dropNotPresent(boolean isIndex, TableChange change) { String detail = change.toString(); throw isIndex ? new DropIndexNotPresentException(detail) : new DropColumnNotPresentException(detail); } private static RuntimeException modifyNotChanged(boolean isIndex, TableChange change) { String detail = change.toString(); return isIndex ? new ModifyIndexNotChangedException(detail) : new ModifyColumnNotChangedException(detail); } private void modifyNotPresent(boolean isIndex, TableChange change) { String detail = change.toString(); throw isIndex ? new ModifyIndexNotPresentException(detail) : new ModifyColumnNotPresentException(detail); } private void unchangedNotPresent(boolean isIndex, String detail) { assert !isIndex; throw new UnchangedColumnNotPresentException(detail); } private void undeclaredChange(boolean isIndex, String detail) { assert !isIndex; throw new UndeclaredColumnChangeException(detail); } private void checkFinalChangeLevel() { if(finalChangeLevel == null) { return; } // Internal consistency checks switch(finalChangeLevel) { case NONE: if(!state.affectedGI.isEmpty()) { throw new IllegalStateException("NONE but had affected GI: " + state.affectedGI); } break; case METADATA: case METADATA_CONSTRAINT: if(!state.droppedGI.isEmpty()) { throw new IllegalStateException("META but had dropped GI: " + state.droppedGI); } if(!state.dataAffectedGI.isEmpty()) { throw new IllegalStateException("META but had data affected GI: " + state.dataAffectedGI); } break; case INDEX: case INDEX_CONSTRAINT: if(!state.dataAffectedGI.isEmpty()) { throw new IllegalStateException("INDEX but had data affected GI: " + state.dataAffectedGI); } break; case TABLE: case GROUP: break; default: throw new IllegalStateException(finalChangeLevel.toString()); } } }