/* Copyright 2017 Alfa Financial Software * * 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. */ package org.alfasoftware.morf.metadata; import static com.google.common.collect.Iterables.filter; import static org.alfasoftware.morf.metadata.SchemaUtils.index; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.regex.Pattern; import org.alfasoftware.morf.sql.element.AliasedField; import com.google.common.base.Function; import com.google.common.base.Predicate; import com.google.common.base.Splitter; import com.google.common.base.Supplier; import com.google.common.base.Suppliers; import com.google.common.collect.FluentIterable; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Sets; import com.google.common.io.CharStreams; /** * Class that validates {@link Schema} objects meet the rules. * * <p>This class is intended to impose Alfa development rules on database * attributes like name uniqueness, length and other conventions.</p> * * <p>Usage: create a new instance of this class and call {@link #validate(Schema)}.</p> * * <p>The current database rules are:</p> * <ul> * <li>An object (table, column, index) name must not be > 30 characters long (an Oracle restriction)</li> * <li>An object name must not be an SQL reserved word</li> * <li>Table, column and index names must match [a-zA-Z][a-zA-Z0-9_]* (any letters or numbers or underscores, but must start with a letter)</li> * <li>Indexes may not be simply by 'id'. This would duplicate the primary key and is superfluous.</li> * <li>No duplicated indexes are allowed.</li> * </ul> * * * @author Copyright (c) Alfa Financial Software 2010 */ public class SchemaValidator { /** * Maximum length allowed for entity names. */ private static final int MAX_LENGTH = 30; /** * All the words we can't use because they're special in some SQL dialect or other. * * <p>Note the following words are technically reserved, but we currently allow them because our database currently uses them:</p> * <ul> * <li>type</li> * <li>operation</li> * <li>method</li> * <li>language</li> * <li>location</li> * <li>year</li> * <li>day</li> * <li>security</li> * </ul> * * This site is quite useful: http://www.petefreitag.com/tools/sql_reserved_words_checker/ */ private static final Supplier<Set<String>> sqlReservedWords = Suppliers.memoize(new Supplier<Set<String>>() { @Override public Set<String> get() { try { try (InputStream inputStream = getClass().getResourceAsStream("SQL_RESERVED_WORDS.txt")) { if (inputStream == null) { throw new RuntimeException("Could not find resource: [SQL_RESERVED_WORDS.txt] near [" + getClass() + "]"); } try (InputStreamReader streamReader = new InputStreamReader(inputStream, "UTF-8")) { HashSet<String> sqlReservedWords = Sets.newHashSet(Splitter.on("\r\n").split(CharStreams.toString(streamReader))); // temporary removal of words we currently have to allow sqlReservedWords.remove("TYPE"); // DB2 sqlReservedWords.remove("OPERATION"); // DB2, SQL Server "future", PostGres sqlReservedWords.remove("METHOD"); // PostGres sqlReservedWords.remove("LANGUAGE"); // DB2, ODBC (?), SQL Server "future", PostGres sqlReservedWords.remove("LOCATION"); // PostGres sqlReservedWords.remove("YEAR"); // DB2, ODBC (?), SQL Server "future", PostGres sqlReservedWords.remove("DAY"); // DB2, ODBC (?), SQL Server "future", PostGres sqlReservedWords.remove("SECURITY"); // DB2, PostGres return ImmutableSet.copyOf(sqlReservedWords); } } } catch (IOException e) { throw new RuntimeException("Failed to load [SQL_RESERVED_WORDS.txt]", e); } } }); private static final Function<Column, String> COLUMN_TO_NAME = new Function<Column, String> () { @Override public String apply(Column input) { return input.getName(); } }; private static final Function<AliasedField, String> FIELD_TO_NAME = new Function<AliasedField, String> () { @Override public String apply(AliasedField input) { return input.getImpliedName(); } }; /** * The regular expression that defines a valid index name (underscores allowed) */ private static final Pattern validNamePattern = Pattern.compile("[a-zA-Z][a-zA-Z0-9_]*"); // <-- I know this is the same as \w, but this is clearer /** * The list of all failures */ private final List<String> validationFailures = new LinkedList<>(); /** * Validates a {@link Schema} meets the rules. * * @param schema the {@link Schema} to validate. */ public void validate(Schema schema) { for (Table table : schema.tables()) { validateTable(table); } for (View view : schema.views()) { validateView(view); } checkForValidationErrors(); } /** * Validate a {@link Table} meets the rules * * @param table The {@link Table} to validate */ public void validate(Table table) { validateTable(table); checkForValidationErrors(); } /** * Validate a {@link View} meets the rules * * @param view The {@link View} to validate */ public void validate(View view) { validateView(view); checkForValidationErrors(); } /** * <p> * Method to establish if a name matches an allowed pattern of characters to * follow the correct naming convention. * </p> * <p> * The name must: * </p> * <ul> * <li>begin with an alphabetic character [a-zA-Z]</li> * <li>only contain alphanumeric characters or underscore [a-zA-Z0-9_]</li> * </ul> * * @param name The string to check if it follows the correct naming convention * @return true if the name is valid otherwise false */ boolean isNameConventional(String name){ return validNamePattern.matcher(name).matches(); } /** * Method to establish if a given string is an SQL Reserved Word * * @param word the string to establish if its a SQL Reserved Word * @return true if its a SQL Reserved Word otherwise false. */ boolean isSQLReservedWord(String word){ return sqlReservedWords.get().contains(word.toUpperCase()); } /** * Method to establish if a given string representing an Entity name is within the allowed length of charchaters. * * @see #MAX_LENGTH * * @param word the string to establish if its within the allowed length * @return true if its within the allowed length otherwise false. */ boolean isEntityNameLengthValid(String name){ return name.length() <= MAX_LENGTH; } /** * Check whether any errors have been added */ private void checkForValidationErrors() { // check for errors if (!validationFailures.isEmpty()) { StringBuilder message = new StringBuilder("Schema validation failures:"); for (String failure : validationFailures) { message.append("\n"); message.append(failure); } throw new RuntimeException(message.toString()); } } /** * Validates a {@link Table} meets the rules. * * @param table The {@link Table} to validate. */ private void validateTable(Table table) { validateName(table.getName()); validateIndices(table); validateColumns(table); } /** * Validates a {@link Table} meets the rules. * * @param table The {@link Table} to validate. */ private void validateView(View view) { validateName(view.getName()); if (view.knowsSelectStatement()) { validateColumnNames(FluentIterable.from(view.getSelectStatement().getFields()).transform(FIELD_TO_NAME).toList(), view.getName()); } } /** * Validates the basic naming rules for a database object (currently a table or view). */ private void validateName(String tableOrViewName) { if (!isEntityNameLengthValid(tableOrViewName)) { validationFailures.add("Name of table or view [" + tableOrViewName + "] is not allowed - it is over " + MAX_LENGTH + " characters long"); } if (isSQLReservedWord(tableOrViewName)) { validationFailures.add("Name of table or view [" + tableOrViewName + "] is not allowed - it is an SQL reserved word"); } if (!isNameConventional(tableOrViewName)) { validationFailures.add("Name of table or view [" + tableOrViewName + "] is not allowed - it must match " + validNamePattern.toString()); } } /** * Validates a {@link Table}'s {@link Column}s meet the rules. * * @param table The {@link Table} on which to validate columns. */ private void validateColumns(Table table) { validateColumnNames(FluentIterable.from(table.columns()).transform(COLUMN_TO_NAME).toList(), table.getName()); for (Column column : table.columns()) { if (column.getType().hasWidth() && column.getWidth() == 0) { validationFailures.add("Column [" + column.getName() + "] on table [" + table.getName() + "] is not allowed - column data type [" + column.getType() + "] requires width to be specified"); } if (column.isPrimaryKey() && column.isNullable()) { validationFailures.add("Column [" + column.getName() + "] on table [" + table.getName() + "] is both nullable and in the primary key. This is not permitted."); } } } /** * Validates a {@link Table}'s {@link Column}s meet the rules. * * @param table The {@link Table} on which to validate columns. */ private void validateColumnNames(Collection<String> columnNames, String tableOrViewName) { for (String columnName : columnNames) { if (!isEntityNameLengthValid(columnName)) { validationFailures.add("Name of column [" + columnName + "] on [" + tableOrViewName + "] is not allowed - it is over " + MAX_LENGTH + " characters"); } if (isSQLReservedWord(columnName)) { validationFailures.add("Name of column [" + columnName + "] on [" + tableOrViewName + "] is not allowed - it is an SQL reserved word"); } if (!isNameConventional(columnName)) { validationFailures.add("Name of column [" + columnName + "] on [" + tableOrViewName + "] is not allowed - it must match " + validNamePattern.toString()); } } } /** * Validates a {@link Table}'s {@link Index}s meet the rules. * * @param table The {@link Table} on which to validate indexes. */ private void validateIndices(Table table) { Map<IndexSignature, Index> indexesBySignature = new HashMap<>(); // -- First add the 'PRIMARY' index which is always present in the database // for the column annotated @Id or @GeneratedId... // Iterable<Column> primaryKeyColumns = filter(table.columns(), new Predicate<Column>() { @Override public boolean apply(Column input) { return input.isPrimaryKey(); } }); Iterable<String> primaryKeyColumnNames = Iterables.transform( primaryKeyColumns, new Function<Column, String>() { @Override public String apply(Column input) { return input.getName(); } }); Index primaryKeyIndex = index("PRIMARY") .unique() .columns(primaryKeyColumnNames); indexesBySignature.put(new IndexSignature(primaryKeyIndex), primaryKeyIndex); for (Index index : table.indexes()) { if (!isEntityNameLengthValid(index.getName())) { validationFailures.add("Name of index [" + index.getName() + "] on table [" + table.getName() + "] is not allowed - it is over " + MAX_LENGTH + " characters long"); } if (isSQLReservedWord(index.getName())) { validationFailures.add("Name of index [" + index.getName() + "] on table [" + table.getName() + "] is not allowed - it is an SQL reserved word"); } if (!isNameConventional(index.getName())) { validationFailures.add("Name of index [" + index.getName() + "] on table [" + table.getName() + "] is not allowed - it must match " + validNamePattern.toString()); } // validate that there isn't an index by "id", which people have added in the past. This isn't necessary since id is the primary key, and in // fact it breaks Oracle. if (index.columnNames().equals(Arrays.asList("id"))) { validationFailures.add("Index [" + index.getName() + "] on table [" + table.getName() + "] is not allowed - indexes by 'id' only are superfluous since 'id' is the primary key."); } // validate that there aren't any duplicate indexes Index other = indexesBySignature.put(new IndexSignature(index), index); if (other != null) { validationFailures.add("Indexes [" + index.getName() + "] and [" + other.getName() + "] on table [" + table.getName() + "] are not allowed - one is a duplicate of the other"); } } } /** * Implementation of hashCode and equals for index comparison. * * @author Copyright (c) Alfa Financial Software 2010 */ private class IndexSignature { /***/ private final Index index; /** * @param index The {@link Index} to wrap. */ public IndexSignature(Index index) { super(); this.index = index; } /** * @see java.lang.Object#hashCode() */ @Override public int hashCode() { return index.columnNames().hashCode() + Boolean.valueOf(index.isUnique()).hashCode(); } /** * {@inheritDoc} * * @see java.lang.Object#equals(java.lang.Object) */ @Override public boolean equals(Object obj) { if (!(obj instanceof IndexSignature)) return false; IndexSignature other = (IndexSignature) obj; return Objects.equals(other.index.columnNames(), index.columnNames()) && other.index.isUnique() == index.isUnique(); } } }