VerifyTableDefinitionCatalog.java

/*
 *
 * The DbUnit Database Testing Framework
 * Copyright (C)2002-2026, DbUnit.org
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library 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
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 *
 */
package org.dbunit.annotation.runtime;

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.dbunit.VerifyTableDefinition;
import org.dbunit.VerifyTableDefinitionsProvider;
import org.dbunit.annotation.DbUnitConfig;
import org.dbunit.annotation.DbUnitExpected;

/**
 * Reads one or more catalog classes named by {@link DbUnitConfig#verifyDefinitions()} or
 * {@link DbUnitExpected#verifyDefinitions()} and indexes their {@link VerifyTableDefinition}s
 * by table name, for {@link DbUnitExpected#verifyTables()} to select from.
 *
 * <p>Each catalog class is read one of two ways: if it implements
 * {@link VerifyTableDefinitionsProvider}, it is reflectively instantiated with its no-arg
 * constructor and asked for its definitions; otherwise its {@code public static final
 * VerifyTableDefinition} fields are read directly. The same table name appearing in two
 * catalog classes is rejected rather than silently resolved by declaration order.
 *
 * <p>Not intended for direct use by test code; this is machinery consumed by
 * {@link AnnotatedTestConfiguration}.
 *
 * @author Jeff Jensen
 * @since 3.6.0
 */
public class VerifyTableDefinitionCatalog
{
    private static final Map<List<Class<?>>, VerifyTableDefinitionCatalog> CACHE =
            new ConcurrentHashMap<>();

    private final Map<String, VerifyTableDefinition> definitionsByTableName =
            new LinkedHashMap<>();
    private final Map<String, Class<?>> catalogClassByTableName = new LinkedHashMap<>();
    private final List<String> catalogClassNames = new ArrayList<>();

    /**
     * Returns the catalog for the given classes, reading and merging them only the first time
     * this exact combination is asked for - resolution runs once per test method, and a suite
     * naming the same catalog class(es) from many test methods would otherwise re-reflect them
     * every time, for a result that can never change within one JVM run.
     *
     * <p>The cache key is order-sensitive: {@code forClasses(A, B)} and {@code forClasses(B, A)}
     * are different combinations, each cached (and merged) separately, even though the same
     * table name colliding between {@code A} and {@code B} would reject both the same way
     * regardless of order. Deliberately simple rather than order-insensitive, since normalizing
     * the key would only save reflection work for the unusual case of a suite naming the same
     * catalog classes in more than one order.
     *
     * @param catalogClasses The catalog classes to read; see
     *            {@link #VerifyTableDefinitionCatalog(Class[])}.
     * @return The (possibly cached) catalog.
     */
    static VerifyTableDefinitionCatalog forClasses(final Class<?>... catalogClasses)
    {
        return CACHE.computeIfAbsent(Arrays.asList(catalogClasses),
                classes -> new VerifyTableDefinitionCatalog(
                        classes.toArray(new Class<?>[0])));
    }

    /**
     * Reads and merges the given catalog classes.
     *
     * @param catalogClasses The catalog classes to read; each either implements
     *            {@link VerifyTableDefinitionsProvider} or exposes {@code public static
     *            final VerifyTableDefinition} fields.
     */
    public VerifyTableDefinitionCatalog(final Class<?>... catalogClasses)
    {
        for (final Class<?> catalogClass : catalogClasses)
        {
            catalogClassNames.add(catalogClass.getName());
            for (final VerifyTableDefinition definition : readDefinitions(catalogClass))
            {
                addDefinition(catalogClass, definition);
            }
        }
    }

    private void addDefinition(final Class<?> catalogClass,
            final VerifyTableDefinition definition)
    {
        if (definition == null)
        {
            throw new IllegalStateException("Catalog class " + catalogClass.getName()
                    + " supplied a null VerifyTableDefinition.");
        }
        final String tableName = definition.getTableName();
        final Class<?> existingCatalogClass = catalogClassByTableName.get(tableName);
        if (existingCatalogClass != null)
        {
            final String message = existingCatalogClass == catalogClass
                    ? "Table '" + tableName + "' is defined more than once in "
                            + catalogClass.getName()
                            + ". Each table name must be defined in exactly one catalog class."
                    : "Table '" + tableName + "' is defined in both "
                            + existingCatalogClass.getName() + " and " + catalogClass.getName()
                            + ". Each table name must be defined in exactly one catalog class.";
            throw new IllegalStateException(message);
        }
        definitionsByTableName.put(tableName, definition);
        catalogClassByTableName.put(tableName, catalogClass);
    }

    private VerifyTableDefinition[] readDefinitions(final Class<?> catalogClass)
    {
        if (VerifyTableDefinitionsProvider.class.isAssignableFrom(catalogClass))
        {
            final VerifyTableDefinitionsProvider provider =
                    (VerifyTableDefinitionsProvider) instantiate(catalogClass);
            final VerifyTableDefinition[] definitions = provider.getVerifyTableDefinitions();
            if (definitions == null)
            {
                throw new IllegalStateException("VerifyTableDefinitionsProvider "
                        + catalogClass.getName() + ", named by DbUnitConfig.verifyDefinitions()"
                        + " or DbUnitExpected.verifyDefinitions(), returned null from"
                        + " getVerifyTableDefinitions().");
            }
            return definitions;
        }
        return readConstants(catalogClass);
    }

    private VerifyTableDefinition[] readConstants(final Class<?> catalogClass)
    {
        final List<Field> fields = new ArrayList<>();
        // getDeclaredFields(), not getFields(): a catalog class extending some unrelated
        // superclass must not silently pull in that superclass's own public static final
        // VerifyTableDefinition constants, if it happens to declare any, as part of this
        // catalog - only fields this class declares itself are the catalog's contents.
        for (final Field field : catalogClass.getDeclaredFields())
        {
            final int modifiers = field.getModifiers();
            if (Modifier.isPublic(modifiers) && Modifier.isStatic(modifiers)
                    && Modifier.isFinal(modifiers)
                    && VerifyTableDefinition.class.isAssignableFrom(field.getType()))
            {
                fields.add(field);
            }
        }
        // Class#getDeclaredFields() does not guarantee declaration order either; sort by name
        // so catalog order is deterministic across JVMs rather than incidentally stable on most
        // of them.
        fields.sort(Comparator.comparing(Field::getName));
        final List<VerifyTableDefinition> definitions = new ArrayList<>(fields.size());
        for (final Field field : fields)
        {
            definitions.add(readConstant(catalogClass, field));
        }
        return definitions.toArray(new VerifyTableDefinition[0]);
    }

    private VerifyTableDefinition readConstant(final Class<?> catalogClass, final Field field)
    {
        try
        {
            field.setAccessible(true);
            return (VerifyTableDefinition) field.get(null);
        } catch (final IllegalAccessException e)
        {
            throw new IllegalStateException("Field '" + field.getName() + "' on catalog class "
                    + catalogClass.getName() + " is not accessible.", e);
        }
    }

    private Object instantiate(final Class<?> catalogClass)
    {
        return ReflectiveInstantiation.instantiate(catalogClass,
                "VerifyTableDefinitionsProvider catalog");
    }

    /**
     * Selects the named tables' definitions, or every definition in the catalog when no names
     * are given.
     *
     * @param tableNames The table names to select; empty selects every definition in the
     *            catalog.
     * @return The selected definitions, in the order named (or catalog order, when every
     *         definition is selected).
     * @throws IllegalStateException When a named table is not present in the catalog.
     */
    public VerifyTableDefinition[] select(final String... tableNames)
    {
        if (tableNames.length == 0)
        {
            return definitionsByTableName.values()
                    .toArray(new VerifyTableDefinition[0]);
        }
        final VerifyTableDefinition[] selected =
                new VerifyTableDefinition[tableNames.length];
        for (int i = 0; i < tableNames.length; i++)
        {
            selected[i] = select(tableNames[i]);
        }
        return selected;
    }

    private VerifyTableDefinition select(final String tableName)
    {
        final VerifyTableDefinition definition = definitionsByTableName.get(tableName);
        if (definition == null)
        {
            throw new IllegalStateException("Table '" + tableName
                    + "' not found in catalog class(es) " + catalogClassNames
                    + ". Available table names: " + definitionsByTableName.keySet() + ".");
        }
        return definition;
    }
}