VerifyTableDefinitionResolver.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.InvocationTargetException;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import org.dbunit.VerifyTableDefinition;
import org.dbunit.annotation.DbUnitColumnComparer;
import org.dbunit.annotation.DbUnitConfig;
import org.dbunit.annotation.DbUnitExpected;
import org.dbunit.annotation.DbUnitVerifyTable;
import org.dbunit.assertion.comparer.value.ValueComparer;
import org.dbunit.dataset.DataSetException;
import org.dbunit.dataset.IDataSet;
import org.dbunit.util.fileloader.DataFileLoader;

/**
 * Resolves {@code @DbUnitExpected}'s verification spec - its inline {@code verify()}
 * {@link DbUnitVerifyTable}s, its {@code verifyTables()} names, its {@code verifyDefinitions()}
 * catalog classes, or {@link DbUnitConfig#verifyDefinitions()} class-level catalogs - into the
 * {@link VerifyTableDefinition}[] {@link AnnotatedTestConfiguration} carries.
 *
 * <p>Precedence, first match wins: {@code @DbUnitExpected.verifyDefinitions()}, then
 * {@code verify()}, then {@code @DbUnitConfig.verifyDefinitions()}, then bare
 * {@code verifyTables()}, then one definition per table found in the expected datasets.
 * {@code verifyTables()} narrows a catalog rather than conflicting with it; declaring both
 * {@code verify()} and either catalog form, or the same table twice, is rejected. So is a
 * {@code @DbUnitExpected} that names no expected dataset, whatever else it declares, since the
 * tables it verifies are compared to that dataset.
 *
 * @author Jeff Jensen
 * @since 3.6.0
 */
final class VerifyTableDefinitionResolver
{
    private static final ClassValue<ValueComparer> COMPARERS = new ClassValue<ValueComparer>()
    {
        @Override
        @SuppressWarnings("unchecked")
        protected ValueComparer computeValue(final Class<?> comparerClass)
        {
            return newComparerInstance((Class<? extends ValueComparer>) comparerClass);
        }
    };

    /**
     * Resolves the verification spec the annotations spell out, leaving the one form that needs
     * a data file loader - the default of one definition per table in the expected datasets -
     * to {@link #deriveFromExpectedDatasets(DataFileLoader, String[])}, so the loader can be the
     * one the test case that will compare those datasets actually uses.
     *
     * @param config The resolved {@code @DbUnitConfig}, or {@code null} if absent.
     * @param expected The resolved {@code @DbUnitExpected} (never {@code null} - this runs only
     *            when it is present).
     * @param expectedDataFiles The resolved expected dataset paths.
     * @return The table definitions to verify, or empty when the annotations declare none and
     *         they are to be derived from the expected datasets.
     * @throws IllegalStateException If no expected dataset is named, or the spec is
     *             contradictory.
     */
    Optional<VerifyTableDefinition[]> resolveDeclared(final DbUnitConfig config,
            final DbUnitExpected expected, final String[] expectedDataFiles)
    {
        if (expectedDataFiles.length == 0)
        {
            throw new IllegalStateException("@DbUnitExpected names no expected dataset, so"
                    + " there is nothing to verify the database against: verify(),"
                    + " verifyTables() and verifyDefinitions() only choose which tables of that"
                    + " dataset are compared. Name the dataset with value() or provider(), or"
                    + " drop @DbUnitExpected for a test that verifies nothing.");
        }
        final Class<?>[] classLevelCatalogs =
                config == null ? new Class<?>[0] : config.verifyDefinitions();
        final DbUnitVerifyTable[] verify = expected.verify();
        final String[] verifyTables = expected.verifyTables();

        ProvidedAttribute.rejectBothSet(
                verify.length > 0 && expected.verifyDefinitions().length > 0, "@DbUnitExpected",
                "verify()", "verifyDefinitions()", "See VerifyTableDefinitionsProvider.");
        ProvidedAttribute.rejectBothSet(verify.length > 0 && verifyTables.length > 0,
                "@DbUnitExpected", "verify()", "verifyTables()",
                "verifyTables() only narrows a catalog.");
        requireNoDuplicateTableNames(verifyTables);
        final Class<?>[] catalogClasses;
        if (expected.verifyDefinitions().length > 0)
        {
            catalogClasses = expected.verifyDefinitions();
        } else if (verify.length > 0)
        {
            catalogClasses = new Class<?>[0];
        } else
        {
            catalogClasses = classLevelCatalogs;
        }
        if (catalogClasses.length > 0)
        {
            return Optional.of(
                    VerifyTableDefinitionCatalog.forClasses(catalogClasses).select(verifyTables));
        }
        if (verify.length > 0)
        {
            final VerifyTableDefinition[] definitions =
                    new VerifyTableDefinition[verify.length];
            final Set<String> tableNames = new LinkedHashSet<>(verify.length);
            for (int i = 0; i < verify.length; i++)
            {
                if (!tableNames.add(verify[i].value()))
                {
                    throw new IllegalStateException("@DbUnitExpected declares more than one"
                            + " @DbUnitVerifyTable entry for table '" + verify[i].value()
                            + "'; declare at most one per table.");
                }
                definitions[i] = toVerifyTableDefinition(verify[i]);
            }
            return Optional.of(definitions);
        }
        if (verifyTables.length > 0)
        {
            final VerifyTableDefinition[] definitions =
                    new VerifyTableDefinition[verifyTables.length];
            for (int i = 0; i < verifyTables.length; i++)
            {
                definitions[i] = new VerifyTableDefinition(verifyTables[i], (String[]) null);
            }
            return Optional.of(definitions);
        }
        return Optional.empty();
    }

    private void requireNoDuplicateTableNames(final String[] verifyTables)
    {
        final Set<String> tableNames = new LinkedHashSet<>(verifyTables.length);
        for (final String tableName : verifyTables)
        {
            if (!tableNames.add(tableName))
            {
                throw new IllegalStateException("@DbUnitExpected declares more than one"
                        + " verifyTables() entry for table '" + tableName
                        + "'; declare at most one per table.");
            }
        }
    }

    private VerifyTableDefinition toVerifyTableDefinition(final DbUnitVerifyTable v)
    {
        final String[] include = v.include().length == 0 ? null : v.include();
        final ValueComparer defaultComparer = v.defaultComparer() == ValueComparer.class ? null
                : instantiateComparer(v.defaultComparer());
        final Map<String, ValueComparer> columnComparers =
                new LinkedHashMap<>(v.columnComparers().length);
        for (final DbUnitColumnComparer c : v.columnComparers())
        {
            if (columnComparers.containsKey(c.column()))
            {
                throw new IllegalStateException("@DbUnitVerifyTable(\"" + v.value()
                        + "\") declares more than one @DbUnitColumnComparer for column '"
                        + c.column() + "'; declare at most one per column.");
            }
            columnComparers.put(c.column(), instantiateComparer(c.comparer()));
        }
        return new VerifyTableDefinition(v.value(), v.exclude(), include, defaultComparer,
                columnComparers, v.sortOnFilteredColumnsOnly());
    }

    /**
     * Derives one default definition per table found in {@code expectedDataFiles}, read with
     * {@code dataFileLoader}. Parses the files a second time here, just to enumerate table
     * names - {@code DefaultPrepAndExpectedTestCase#configureTest()} parses the same files
     * again later for the real verification. Left as-is: avoiding the second parse needs a real
     * change to the boundary between resolving a configuration and the
     * {@code PrepAndExpectedTestCase} that consumes it, not a small tweak - not worth it for
     * what is normally a small test fixture file.
     *
     * <p>Takes the loader as an argument because it must be the one that test case compares the
     * files with, which can differ from the {@code @DbUnitConfig} one when the test case is
     * injected with a loader of its own.
     *
     * @param dataFileLoader The loader to read the expected datasets with.
     * @param expectedDataFiles The resolved expected dataset paths.
     * @return One default definition per table, in first-seen order.
     */
    VerifyTableDefinition[] deriveFromExpectedDatasets(final DataFileLoader dataFileLoader,
            final String[] expectedDataFiles)
    {
        final Set<String> tableNames = new LinkedHashSet<>();
        for (final String path : expectedDataFiles)
        {
            final IDataSet dataSet = dataFileLoader.load(path);
            try
            {
                for (final String tableName : dataSet.getTableNames())
                {
                    tableNames.add(tableName);
                }
            } catch (final DataSetException e)
            {
                throw new IllegalStateException(
                        "Failed to read table names from expected dataset '" + path + "'.", e);
            }
        }
        final VerifyTableDefinition[] definitions =
                new VerifyTableDefinition[tableNames.size()];
        int i = 0;
        for (final String tableName : tableNames)
        {
            definitions[i++] = new VerifyTableDefinition(tableName, (String[]) null);
        }
        return definitions;
    }

    /**
     * Caches a comparer instance for as long as its class lives - the same once-and-reused
     * caching {@link VerifyTableDefinitionCatalog#forClasses} already applies to a catalog
     * combination, for the same reason: this resolves fresh on every test method, and a suite
     * naming the same comparer class from many methods would otherwise reflectively construct a
     * new, functionally-identical instance every time. The instance is held by the class
     * itself, through a {@link ClassValue}, so it goes away with the class and its class loader
     * rather than keeping them alive. See {@link DbUnitColumnComparer#comparer()} for the
     * resulting purity requirement.
     */
    private static ValueComparer instantiateComparer(
            final Class<? extends ValueComparer> comparerClass)
    {
        return COMPARERS.get(comparerClass);
    }

    private static ValueComparer newComparerInstance(
            final Class<? extends ValueComparer> comparerClass)
    {
        try
        {
            return ReflectiveInstantiation.newInstance(comparerClass);
        } catch (final InvocationTargetException e)
        {
            throw new IllegalStateException("ValueComparer " + comparerClass.getName()
                    + " threw from its no-arg constructor.", e.getCause());
        } catch (final ReflectiveOperationException e)
        {
            throw new IllegalStateException("ValueComparer " + comparerClass.getName()
                    + " has no accessible no-arg constructor - it is likely a configured"
                    + " instance built with constructor arguments, which an annotation cannot"
                    + " express. Declare it as a VerifyTableDefinition constant in a catalog"
                    + " class instead; see VerifyTableDefinitionsProvider.", e);
        }
    }
}