SchemaComparator.java
/*
** Module : SchemaComparator.java
** Abstract : Compares database schema to a known last state using JDBC.
**
** Copyright (c) 2022-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 BS 20221103 Created initial version.
** 002 BS 20230331 Added support for extent fields and sequences.
** 003 BS 20230419 Excluded tables functionality.
** 004 OM 20240412 Added sort direction for index components.
** 005 OM 20240516 Skip tables marked with 'Skip: true'.
** OM 20240517 Throw StopConditionException() in the mutable database failed validation.
*/
/*
** 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 may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
**
** Additional terms under GNU Affero GPL version 3 section 7:
**
** Under Section 7 of the GNU Affero GPL version 3, the following additional
** terms apply to the works covered under the License. These additional terms
** are non-permissive additional terms allowed under Section 7 of the GNU
** Affero GPL version 3 and may not be removed by you.
**
** 0. Attribution Requirement.
**
** You must preserve all legal notices or author attributions in the covered
** work or Appropriate Legal Notices displayed by works containing the covered
** work. You may not remove from the covered work any author or developer
** credit already included within the covered work.
**
** 1. No License To Use Trademarks.
**
** This license does not grant any license or rights to use the trademarks
** Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
** of Golden Code Development Corporation. You are not authorized to use the
** name Golden Code, FWD, or the names of any author or contributor, for
** publicity purposes without written authorization.
**
** 2. No Misrepresentation of Affiliation.
**
** You may not represent yourself as Golden Code Development Corporation or FWD.
**
** You may not represent yourself for publicity purposes as associated with
** Golden Code Development Corporation, FWD, or any author or contributor to
** the covered work, without written authorization.
**
** 3. No Misrepresentation of Source or Origin.
**
** You may not represent the covered work as solely your work. All modified
** versions of the covered work must be marked in a reasonable way to make it
** clear that the modified work is not originating from Golden Code Development
** Corporation or FWD. All modified versions must contain the notices of
** attribution required in this license.
*/
package com.goldencode.p2j.persist.orm.schema;
import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.persist.dialect.*;
import com.goldencode.p2j.persist.orm.*;
import com.goldencode.p2j.persist.orm.schema.element.*;
import com.goldencode.p2j.util.*;
import com.goldencode.util.*;
import org.w3c.dom.*;
import org.w3c.dom.Element;
import org.xml.sax.*;
import javax.xml.parsers.*;
import java.io.*;
import java.sql.*;
import java.time.*;
import java.util.*;
import java.util.regex.*;
import java.util.stream.*;
/**
* JDBC schema comparator. Compares current DB schema state with last known state. Last known state may be
* provided.
* <p>
* Usage:
* <pre>
* SchemaComparator comparator = SchemaComparator.getInstanceExistingState(database);
* ComparisonResult result = comparator.compareWithPrevious();
* if (!result.isSame())
* {
* // Use changes provided by the result
* }
* </pre>
* <p>
* In case it is required to reset the state (server restart, etc.) - this overwrites the last known state
* file:
* <pre>
* SchemaComparator comparator = SchemaComparator.saveInitialState(database);
* </pre>
*/
public class SchemaComparator
{
/** Path to file into which last known database state is serialized. */
private static final File LAST_KNOWN_STATE_FILE = new File("/tmp/last_known_state.xml");
private static final boolean WRITE_KNOWN_STATE_FILE = false;
private static final Pattern PATTERN_EXTENT_TABLE = Pattern.compile("(.+)__([0-9]+)");
private static final Pattern PATTERN_EXTENT_COLUMN = Pattern.compile("(.+)_1");
private static final Pattern COMMENT_EXTENT_COLUMN = Pattern.compile("\\s*EXTENT:?\\s*([0-9]+)",
Pattern.CASE_INSENSITIVE);
// column_1, column_2, column_3 is the minimum pattern to be handled as an extent automatically
private static final int MINIMUM_EXTENT_COLUMNS = 9999;
private static final String XML_TABLES = "tables";
private static final String XML_TABLE = "table";
private static final String XML_COLUMNS = "columns";
private static final String XML_COLUMN = "column";
private static final String XML_INDEX = "index";
private static final String XML_ASCENDING = "ascending";
private static final String XML_SEQUENCES = "sequences";
private static final String XML_SEQUENCE = "sequence";
private static final String XML_NAME = "name";
private static final String XML_TYPE = "type";
private static final String XML_PRECISION = "precision";
private static final String XML_NULLABLE = "nullable";
private static final String XML_PRIMARY = "primary";
private static final String XML_UNIQUE = "unique";
private static final String XML_DEFAULT = "default";
private static final String XML_COMMENT = "comment";
/** Database reference - used for all connections; can't be changed in one instance of the comparator. */
private final Database database;
/** In-memory storage of the last known state. This is to prevent extensive file operations. */
private static DatabaseState lastKnownState;
/**
* List of lower-case names of tables that should not be used in schema comparison. They will be simply
* ignored when loading tables from the database.
*
* NOTE: This configuration may be moved to a file if it becomes too large.
*/
private static final Collection<String> EXCLUDED_TABLES = new HashSet<>();
static
{
EXCLUDED_TABLES.add("meta_user");
}
/**
* Get an instance of the Comparator, load the current database state for use in comparison.
* This overwrites the last known state file. Also, it's a blocking operation.
*
* @param database
* Database to which to connect.
*
* @return Comparator instance.
*/
public static SchemaComparator saveInitialState(Database database)
throws PersistenceException
{
SchemaComparator comparator = new SchemaComparator(database);
comparator.compare(new DatabaseState(Instant.now()));
return comparator;
}
/**
* Get an instance of the Comparator, assuming the current database state file exists (it will be loaded
* on demand).
*
* @param database
* Database to which to connect.
*
* @return Comparator instance.
*/
public static SchemaComparator getInstanceExistingState(Database database)
{
return new SchemaComparator(database);
}
/**
* Constructor
*
* @param database
* Database reference.
*/
private SchemaComparator(Database database)
{
this.database = database;
}
/**
* Compare current state with an empty database state, effectively reporting everything found in the scan
* as new.
*
* @return {@link ComparisonResult} instance.
*
* @throws PersistenceException
* if there is an error performing the JDBC scan.
*/
public ComparisonResult getFreshResult()
throws PersistenceException
{
return compare(new DatabaseState(Instant.now()));
}
/**
* Compare current state with last known state.
*
* @return {@link ComparisonResult} instance.
*
* @throws PersistenceException
* if there is an error performing the JDBC scan.
*/
public ComparisonResult compareWithPrevious()
throws PersistenceException
{
return compare(getLastKnownState());
}
/**
* Compare current state with given state.
*
* @param previousState
* Previous known state for comparison.
*
* @return ComparisonResult instance
*
* @throws PersistenceException
* if there is an error performing the JDBC scan.
*/
public ComparisonResult compare(DatabaseState previousState)
throws PersistenceException
{
try (Session session = new Session(database))
{
Connection conn = session.getConnection();
ComparisonResult comparisonResult = new ComparisonResult();
getAndCompareTables(conn, previousState, comparisonResult);
getAndCompareColumns(conn, previousState, comparisonResult);
getAndCompareIndexes(conn, previousState, comparisonResult);
getAndCompareSequences(conn, previousState, comparisonResult);
if (!comparisonResult.isSame())
{
if (WRITE_KNOWN_STATE_FILE)
{
saveLastKnownState(comparisonResult.getState());
}
else
{
lastKnownState = comparisonResult.getState();
}
}
return comparisonResult;
}
catch (PersistenceException | SQLException e)
{
throw new PersistenceException("Connection failed during comparison", e);
}
}
/**
* Load last persisted state. This is to be used on application start.
*
* @return Database state.
*
* @throws PersistenceException
* if there was an error reading the last known state, other than a file not found error
*/
synchronized DatabaseState getLastKnownState()
throws PersistenceException
{
if (lastKnownState == null)
{
lastKnownState = new DatabaseState(Instant.now());
try (FileInputStream fis = new FileInputStream(LAST_KNOWN_STATE_FILE))
{
Document dom = XmlHelper.parse(fis);
NodeList tableNodes = dom.getElementsByTagName(XML_TABLE);
for (int i = 0; i < tableNodes.getLength(); i++)
{
Node tableNode = tableNodes.item(i);
Table table = new Table(
database,
XmlHelper.getAttribute(tableNode, XML_NAME),
XmlHelper.getAttribute(tableNode, XML_COMMENT)
);
lastKnownState.getTables().add(table);
NodeList childNodes = tableNode.getChildNodes();
for (int j = 0; j < childNodes.getLength(); j++)
{
Node node = childNodes.item(j);
if (node.getNodeName().equalsIgnoreCase(XML_COLUMN))
{
Attribute attribute = new Attribute(
XmlHelper.getAttribute(node, XML_NAME),
table,
XmlHelper.getAttribute(node, XML_TYPE),
XmlHelper.getAttribute(node, XML_PRECISION),
XmlHelper.getAttributeBoolean(node, XML_NULLABLE, false),
XmlHelper.getAttribute(node, XML_DEFAULT),
0,
XmlHelper.getAttribute(node, XML_COMMENT));
table.getAttributes().add(attribute);
}
else if (node.getNodeName().equalsIgnoreCase(XML_INDEX))
{
Collection<Tuple<String, Boolean>> attributeList = new ArrayList<>();
NodeList indexChildren = node.getChildNodes();
for (int k = 0; k < indexChildren.getLength(); k++)
{
Node indexChild = indexChildren.item(k);
if (indexChild.getNodeName().equalsIgnoreCase(XML_COLUMNS))
{
NodeList components = indexChild.getChildNodes();
for (int l = 0; l < components.getLength(); l++)
{
Node component = components.item(l);
if (component.getNodeName().equalsIgnoreCase(XML_COLUMN))
{
attributeList.add(new Tuple<>(
XmlHelper.getAttribute(component, XML_NAME),
XmlHelper.getAttributeBoolean(component, XML_ASCENDING, false)));
}
}
break; // should be exactly one
}
}
Index index = new Index(
XmlHelper.getAttribute(node, XML_NAME),
table,
attributeList,
XmlHelper.getAttributeBoolean(node, XML_UNIQUE, false),
XmlHelper.getAttributeBoolean(node, XML_PRIMARY, false),
XmlHelper.getAttribute(node, XML_COMMENT));
table.getIndexes().add(index);
}
}
}
NodeList sequenceNodes = dom.getElementsByTagName(XML_SEQUENCES);
for (int i = 0; i < sequenceNodes.getLength(); i++)
{
Node sequenceNode = sequenceNodes.item(i);
Sequence sequence = new Sequence(
database,
XmlHelper.getAttribute(sequenceNode, XML_NAME),
XmlHelper.getAttribute(sequenceNode, XML_COMMENT)
);
lastKnownState.getSequences().add(sequence);
}
}
catch (FileNotFoundException exc)
{
// assume this means no previous state exists and we must create a bootstrap database state;
// empty database state will be returned
}
catch (IOException | ParserConfigurationException | SAXException exc)
{
throw new PersistenceException("Error reading last known schema state", exc);
}
}
return lastKnownState;
}
/**
* Persist the database state. This is to be used after every successful state comparison.
*
* @param newState
* Database state to persist.
*/
synchronized void saveLastKnownState(DatabaseState newState)
throws PersistenceException
{
lastKnownState = newState;
try (FileOutputStream fos = new FileOutputStream(LAST_KNOWN_STATE_FILE))
{
Document dom = XmlHelper.newDocument();
Element tableRoot = dom.createElement(XML_TABLES);
for (Table table : lastKnownState.getTables())
{
Element tableNode = dom.createElement(XML_TABLE);
XmlHelper.setAttributeOptional(tableNode, XML_NAME, table.getName());
XmlHelper.setAttributeOptional(tableNode, XML_COMMENT, table.getComment());
tableRoot.appendChild(tableNode);
for (Attribute attribute : table.getAttributes())
{
Element attrNode = dom.createElement(XML_COLUMN);
XmlHelper.setAttributeOptional(attrNode, XML_NAME, attribute.getName());
XmlHelper.setAttributeOptional(attrNode, XML_TYPE, attribute.getType());
XmlHelper.setAttributeOptional(attrNode, XML_PRECISION, attribute.getPrecision());
XmlHelper.setAttribute(attrNode, XML_NULLABLE, attribute.getNullable());
XmlHelper.setAttributeOptional(attrNode, XML_DEFAULT, attribute.getDefaultValue());
XmlHelper.setAttributeOptional(attrNode, XML_COMMENT, attribute.getComment());
tableNode.appendChild(attrNode);
}
for (Index index : table.getIndexes())
{
Element indexNode = dom.createElement(XML_INDEX);
XmlHelper.setAttributeOptional(indexNode, XML_NAME, index.getName());
XmlHelper.setAttribute(indexNode, XML_UNIQUE, index.getUnique());
XmlHelper.setAttribute(indexNode, XML_PRIMARY, index.getPrimary());
XmlHelper.setAttributeOptional(indexNode, XML_COMMENT, index.getComment());
Element columnsNode = dom.createElement(XML_COLUMNS);
for (Tuple<String, Boolean> column : index.getColumns())
{
Element indexComp = dom.createElement(XML_COLUMN);
XmlHelper.setAttribute(indexComp, XML_NAME, column.getKey());
XmlHelper.setAttribute(indexComp, XML_ASCENDING, column.getValue());
columnsNode.appendChild(indexComp);
}
indexNode.appendChild(columnsNode);
tableNode.appendChild(indexNode);
}
}
dom.appendChild(tableRoot);
Element sequenceRoot = dom.createElement(XML_SEQUENCES);
for (Sequence sequence : lastKnownState.getSequences())
{
Element sequenceNode = dom.createElement(XML_SEQUENCE);
XmlHelper.setAttributeOptional(sequenceNode, XML_NAME, sequence.getName());
XmlHelper.setAttributeOptional(sequenceNode, XML_COMMENT, sequence.getComment());
sequenceRoot.appendChild(sequenceNode);
}
dom.appendChild(sequenceRoot);
XmlHelper.write(dom, fos);
}
catch (IOException | ParserConfigurationException exc)
{
throw new PersistenceException("Failed to write last known schema state", exc);
}
}
/**
* Load information on tables from the provided connection, put the state into result. If previous state is
* provided we can compare current state with the previous one and report any differences into result.
*
* @param conn
* Already initiated connection.
* @param previousState
* Last known state.
* @param comparisonResult
* Instance of result to be filled in.
*
* @throws SQLException
* on unsuccessful calls to DB.
*/
private void getAndCompareTables(Connection conn,
DatabaseState previousState,
ComparisonResult comparisonResult)
throws SQLException
{
Dialect dialect = Dialect.getDialect(database);
// Load tables from DB
ResultSet tables = conn.getMetaData()
.getTables(dialect.getCatalogPattern(database),
dialect.getSchemaPattern(database),
"%",
new String[] {"TABLE"});
// Put all data into the new State
DatabaseState currentState = comparisonResult.getState();
while (tables.next())
{
String tableName = tables.getString("TABLE_NAME");
if (!EXCLUDED_TABLES.contains(tableName.toLowerCase()))
{
String remarks = tables.getString("REMARKS");
Map<String, String> attrs = SchemaCheck.processComment(remarks);
if (!Boolean.parseBoolean(SchemaCheck.getTokenValue(attrs, SchemaCheck.SKIP, "false")))
{
currentState.getTables().add(new Table(database, tableName, remarks));
}
}
}
// Compare if old State is available
if (previousState != null)
{
// Detect added
for (Table table : currentState.getTables())
{
if (!previousState.getTables().contains(table))
{
comparisonResult.getDifferences()
.add(new Difference(table, null, table));
}
}
// Detect removed
for (Table table : previousState.getTables())
{
if (!currentState.getTables().contains(table))
{
comparisonResult.getDifferences()
.add(new Difference(table, table, null));
}
}
}
}
/**
* Load information on columns from the provided connection, put the state into result. If previous state
* is provided, we can compare current state with the previous one and report any differences into result.
*
* @param conn
* Already initiated connection.
* @param previousState
* Last known state.
* @param comparisonResult
* Instance of result to be filled in.
*
* @throws SQLException
* on unsuccessful calls to DB.
*/
private void getAndCompareColumns(Connection conn,
DatabaseState previousState,
ComparisonResult comparisonResult)
throws SQLException
{
Dialect dialect = Dialect.getDialect(database);
DatabaseState currentState = comparisonResult.getState();
for (Table table : comparisonResult.getState().getTables())
{
// Load columns from DB
ResultSet columns = conn.getMetaData()
.getColumns(dialect.getCatalogPattern(database),
dialect.getSchemaPattern(database),
table.getName(),
null);
// Put all data into the new State
while (columns.next())
{
String columnName = columns.getString("COLUMN_NAME");
String columnType = columns.getString("TYPE_NAME");
String precision = columns.getString("DECIMAL_DIGITS");
boolean nullable = columns.getBoolean("NULLABLE");
String defaultValue = columns.getString("COLUMN_DEF");
String comment = columns.getString("REMARKS");
if ("current_timestamp(3)".equalsIgnoreCase(defaultValue)) {
defaultValue = "now";
}
table.getAttributes().add(new Attribute(columnName,
table,
columnType,
precision,
nullable,
defaultValue,
0,
comment));
}
}
// Compare if old State is available
if (previousState != null)
{
for (Table currentTable : currentState.getTables())
{
if (previousState.getTables().contains(currentTable))
{
// Table already existed, let's check for column differences
previousState.getTables().stream()
.filter(t -> t.equals(currentTable))
.findFirst()
.ifPresent(previousTable ->
{
// Detect added or changed columns
for (Attribute currentAttribute : currentTable.getAttributes())
{
if (!previousTable.getAttributes().contains(currentAttribute))
{
// Added column
comparisonResult.getDifferences()
.add(new Difference(currentTable,
null,
currentAttribute));
}
else
{
// Column already existed, let's check for differences
previousTable.getAttributes().stream()
.filter(prevA ->
prevA.equals(currentAttribute))
.filter(prevA ->
!prevA.getType()
.equals(currentAttribute.getType()))
.filter(prevA ->
!prevA.getPrecision()
.equals(currentAttribute.getPrecision()))
.filter(prevA ->
prevA.getNullable() != currentAttribute.getNullable())
.filter(prevA ->
!prevA.getDefaultValue()
.equals(currentAttribute.getDefaultValue()))
.filter(prevA ->
!prevA.getComment().equals(currentAttribute.getComment()))
.findFirst()
.ifPresent(previousAttribute ->
comparisonResult.getDifferences()
.add(new Difference(currentTable,
previousAttribute,
currentAttribute)));
}
}
// Detect removed columns
for (Attribute previousAttribute : previousTable.getAttributes())
{
if (!currentTable.getAttributes().contains(previousAttribute))
{
comparisonResult.getDifferences()
.add(new Difference(currentTable,
previousAttribute,
null));
}
}
});
}
}
}
}
/**
* Load information on indexes from the provided connection, put the state into result. If previous state
* is provided, we can compare current state with the previous one and report any differences into result.
*
* @param conn
* Already initiated connection.
* @param previousState
* Last known state.
* @param comparisonResult
* Instance of result to be filled in.
*
* @throws SQLException
* On unsuccessful calls to DB.
*/
private void getAndCompareIndexes(Connection conn,
DatabaseState previousState,
ComparisonResult comparisonResult)
throws SQLException
{
Dialect dialect = Dialect.getDialect(database);
DatabaseState currentState = comparisonResult.getState();
for (Table table : comparisonResult.getState().getTables())
{
// Load indexes from DB
ResultSet indexes = conn.getMetaData()
.getIndexInfo(dialect.getCatalogPattern(database),
dialect.getSchemaPattern(database),
table.getName(),
false,
false);
// Put all data into the new State
while (indexes.next())
{
String indexName = indexes.getString("INDEX_NAME");
String columnName = indexes.getString("COLUMN_NAME");
boolean ascending = !"D".equalsIgnoreCase(indexes.getString("ASC_OR_DESC"));
boolean primary = false;
boolean unique = !indexes.getBoolean("NON_UNIQUE");
String comment = "";
List<Tuple<String, Boolean>> comp = Collections.singletonList(new Tuple<>(columnName, ascending));
Index index = new Index(indexName, table, comp, unique, primary, comment);
if (table.getIndexes().contains(index))
{
// TODO: this could use heavy optimization
// Index already exists. Just merge the columns.
table.getIndexes().stream()
.filter(existingIndex -> existingIndex.equals(index))
.findFirst()
.ifPresent(existingIndex ->
existingIndex.getColumns().addAll(index.getColumns()));
}
else
{
// New index
table.getIndexes().add(index);
}
}
for (Index index : table.getIndexes())
{
List<Tuple<String, Boolean>> columns = index.getColumns();
if (columns.size() == 1 && columns.get(0).getKey().equalsIgnoreCase(Session.PK))
{
index.setPrimary(true);
}
}
}
// Compare if old State is available
if (previousState != null)
{
for (Table currentTable : currentState.getTables())
{
if (previousState.getTables().contains(currentTable))
{
// Table already existed, let's check for index differences
previousState.getTables().stream()
.filter(t -> t.equals(currentTable))
.findFirst()
.ifPresent(previousTable ->
{
// Detect added or changed indexes
for (Index currentIndex : currentTable.getIndexes())
{
if (!previousTable.getIndexes().contains(currentIndex))
{
// Added index
comparisonResult.getDifferences()
.add(new Difference(currentTable, null, currentIndex));
}
else
{
// Index already existed, let's check for differences
previousTable.getIndexes().stream()
.filter(prevI -> prevI.equals(currentIndex))
.filter(prevI ->
!prevI.getColumns().equals(currentIndex.getColumns()))
.filter(prevI ->
prevI.getUnique() != currentIndex.getUnique())
.filter(prevI ->
prevI.getPrimary() != currentIndex.getPrimary())
.filter(prevI ->
!prevI.getComment().equals(currentIndex.getComment()))
.findFirst()
.ifPresent(previousIndex ->
comparisonResult.getDifferences()
.add(new Difference(currentTable,
previousIndex,
currentIndex)));
}
}
// Detect removed indexes
for (Index previousIndex : previousTable.getIndexes())
{
if (!currentTable.getIndexes().contains(previousIndex))
{
comparisonResult.getDifferences()
.add(new Difference(currentTable,
previousIndex,
null));
}
}
});
}
}
}
}
/**
* Load information on sequences from the provided connection, put the state into result. If previous state
* is provided we can compare current state with the previous one and report any differences into result.
*
* @param conn
* Already initiated connection.
* @param previousState
* Last known state.
* @param comparisonResult
* Instance of result to be filled in.
*
* @throws SQLException
* on unsuccessful calls to DB.
*/
private void getAndCompareSequences(Connection conn,
DatabaseState previousState,
ComparisonResult comparisonResult)
throws SQLException
{
Dialect dialect = Dialect.getDialect(database);
// Load sequences from DB
ResultSet sequences = conn.prepareStatement(dialect.getSequenceSQL(database)).executeQuery();
// Put all data into the new State
DatabaseState currentState = comparisonResult.getState();
while (sequences.next())
{
currentState.getSequences()
.add(new Sequence(database, sequences.getString("sequence_name"), ""));
}
// Compare if old State is available
if (previousState != null)
{
// Detect added
for (Sequence sequence : currentState.getSequences())
{
if (!previousState.getSequences().contains(sequence))
{
comparisonResult.getDifferences()
.add(new Difference(null, null, sequence));
}
}
// Detect removed
for (Sequence sequence : currentState.getSequences())
{
if (!currentState.getSequences().contains(sequence))
{
comparisonResult.getDifferences()
.add(new Difference(null, sequence, null));
}
}
}
}
/**
* If table validation or DMO generation failed, this method can remove it from result so that it can be
* loaded next time.
*
* @param table
* failing table
* @throws PersistenceException
* only thrown if file storage is enabled and writing it fails
*/
public void removeTableFromLastKnownState(Table table)
throws PersistenceException
{
lastKnownState.getTables().remove(table);
if (WRITE_KNOWN_STATE_FILE)
{
saveLastKnownState(lastKnownState);
}
}
/**
* EXTENT property of columns are stored in separate tables/columns (normalized/denormalized mode). This
* method looks for extent tables and assigns found attributes to the result. Also, extent tables are
* removed from the result and parent tables are inserted instead.
*
* This alters the result (it doesn't fully represent what is available in the database any more), but
* is required for EXTENT fields to work.
*
* @param result
* Original comparison result
*/
public void enrichExtentFields(ComparisonResult result)
{
// Map tables for easier lookup
Set<Table> tables = result.getState().tables;
Map<String, Table> tablesByName = tables.stream()
.collect(Collectors.toMap(Table::getName, table -> table));
// Iterate
for (Table table : new ArrayList<>(tables))
{
// Find normalized EXTENT tables by name pattern
Matcher tabMatcher = PATTERN_EXTENT_TABLE.matcher(table.getName());
if (tabMatcher.find())
{
String parentTableName = tabMatcher.group(1);
int extent = Integer.parseInt(tabMatcher.group(2));
// Find parent table
Table parentTable = tablesByName.get(parentTableName);
if (parentTable != null)
{
throw new StopConditionException(
"Table '" + table.getName() + "' appears to be a normalized extent table. " +
"They are not supported in mutable databases. Please, convert to denormalized extent."
);
}
}
// Find denormalized columns
for (Attribute attribute : new ArrayList<>(table.getAttributes()))
{
Matcher colMatcher = PATTERN_EXTENT_COLUMN.matcher(attribute.getName());
if (colMatcher.find())
{
String baseName = colMatcher.group(1);
// Find further columns matching the pattern
int highestExtent;
for (highestExtent = 1; true; highestExtent++)
{
String extentName = baseName + "_" + (highestExtent + 1);
if (table.getAttributes().stream()
.noneMatch(attr -> attr.getName().equalsIgnoreCase(extentName)))
{
break;
}
}
// Find extent meta info in comments
Matcher metaMatcher = COMMENT_EXTENT_COLUMN.matcher(attribute.getComment());
boolean metaAvailable = metaMatcher.find();
if (metaAvailable)
{
highestExtent = Integer.parseInt(metaMatcher.group(1));
}
// Found at least 3 columns matching the pattern, consider it an extent column
if (highestExtent >= MINIMUM_EXTENT_COLUMNS || metaAvailable)
{
updateResultByExtentColumn(result, table, highestExtent, baseName);
}
}
}
}
}
/**
* Update result to reflect detected EXTENT based on columns (denormalized mode).
*
* @param result
* Original comparison result
* @param table
* Table containing extent columns
* @param extent
* Calculated extent value
* @param baseName
* Calculated name of the base column
*/
private void updateResultByExtentColumn(ComparisonResult result, Table table, int extent, String baseName)
{
boolean hadDifference = false;
Attribute lastAttribute = null;
for (int i = 1; i <= extent; i++)
{
String extentName = baseName + "_" + i;
for (Attribute attr : table.attributes)
{
if (attr.getName().equalsIgnoreCase(extentName))
{
table.attributes.remove(attr);
if (result.differences.removeIf(difference -> difference.getNewElement() == attr))
{
hadDifference = true;
}
lastAttribute = attr;
break;
}
}
}
if (lastAttribute != null)
{
Attribute attr = new Attribute(
baseName,
lastAttribute.getTable(),
lastAttribute.getType(),
lastAttribute.getPrecision(),
lastAttribute.getNullable(),
lastAttribute.getDefaultValue(),
extent,
lastAttribute.getComment()
);
table.getAttributes().add(attr);
if (hadDifference) {
result.differences.add(new Difference(table, null, attr));
}
}
}
}