ClasspathSqlMigrationScanner.java

/*-
 * ========================LICENSE_START=================================
 * flyway-nc-scanners
 * ========================================================================
 * Copyright (C) 2010 - 2025 Red Gate Software Ltd
 * ========================================================================
 * 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.
 * =========================LICENSE_END==================================
 */
package org.flywaydb.scanners;

import static org.flywaydb.scanners.ScannerUtils.validateMigrationNaming;

import java.io.File;
import java.io.IOException;
import java.net.JarURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import lombok.CustomLog;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.Location;
import org.flywaydb.core.api.configuration.Configuration;
import org.flywaydb.core.api.resource.LoadableResource;
import org.flywaydb.core.internal.parser.ParsingContext;
import org.flywaydb.core.internal.resource.ResourceName;
import org.flywaydb.core.internal.resource.ResourceNameParser;
import org.flywaydb.core.internal.resource.classpath.ClassPathResource;
import org.flywaydb.core.internal.sqlscript.SqlScriptMetadata;
import org.flywaydb.core.internal.util.Pair;

@CustomLog
public class ClasspathSqlMigrationScanner extends BaseSqlMigrationScanner {
    @Override
    public Collection<Pair<LoadableResource, SqlScriptMetadata>> scan(final Location location,
        final Configuration configuration,
        final ParsingContext parsingContext) {
        if (!location.isClassPath()) {
            return List.of();
        }

        LOG.debug("Scanning for classpath resources at '" + location.getRootPath() + "'");

        final ClassLoader classLoader = configuration.getClassLoader();
        List<URL> locationUrls = new ArrayList<>();
        Enumeration<URL> urls;
        try {
            urls = classLoader.getResources(location.getRootPath());
            while (urls.hasMoreElements()) {
                locationUrls.add(urls.nextElement());
            }
        } catch (IOException e) {
            LOG.error("Unable to resolve location "
                + location
                + " (ClassLoader: "
                + classLoader
                + "): "
                + e.getMessage()
                + ".");
        }

        if (locationUrls.isEmpty()) {
            if (configuration.isFailOnMissingLocations()) {
                throw new FlywayException("Failed to find classpath location: " + location.getRootPath());
            }

            LOG.debug("Skipping classpath location: " + location.getRootPath());
            return Collections.emptyList();
        }

        Collection<Pair<LoadableResource, SqlScriptMetadata>> classPathMigrations = new ArrayList<>();
        for (URL locationUrl : locationUrls) {
            LOG.debug("Scanning URL: " + locationUrl.toExternalForm());

            String protocol = locationUrl.getProtocol();
            if ("file".equals(protocol)) {
                classPathMigrations.addAll(scanFromFileSystem(new File(locationUrl.getPath()),
                    location,
                    configuration,
                    parsingContext));
            } else if ("jar".equals(protocol)) {
                classPathMigrations.addAll(scanFromJarFile(location, locationUrl, configuration, parsingContext));
            }
        }

        return classPathMigrations;
    }

    @Override
    boolean matchesPath(final String path, final Location location) {
        final String rootPath = new File(Thread.currentThread()
            .getContextClassLoader()
            .getResource(".")
            .getPath()).getAbsolutePath();
        String remainingPath = path.replace("\\", "/").substring(rootPath.length() + 1);

        return location.matchesPath(remainingPath);
    }

    private Collection<Pair<LoadableResource, SqlScriptMetadata>> scanFromJarFile(final Location location,
        final URL locationUrl,
        final Configuration configuration,
        final ParsingContext parsingContext) {
        final Set<String> resourceNames = findResourceNames(location.getRootPath(), locationUrl);

        final List<Pair<LoadableResource, SqlScriptMetadata>> fileList = resourceNames.stream()
            .map(resourceName -> processJarResource(location, locationUrl, configuration, resourceName, parsingContext))
            .toList();

        final List<Pair<String, ResourceName>> resources = fileList.stream()
            .map(x -> Pair.of(x.getLeft().getFilename(),
                new ResourceNameParser(configuration).parse(x.getLeft().getFilename())))
            .toList();
        validateMigrationNaming(resources,
            configuration.isValidateMigrationNaming(),
            configuration.getSqlMigrationSuffixes());

        return fileList.stream().filter(x -> {
            final ResourceName name = new ResourceNameParser(configuration).parse(x.getLeft().getFilename());
            return name.isValid() && !"".equals(name.getSuffix());
        }).collect(Collectors.toSet());
    }

    private Pair<LoadableResource, SqlScriptMetadata> processJarResource(final Location location,
        final URL locationUrl,
        final Configuration configuration,
        final String resourceName,
        final ParsingContext parsingContext) {

        final ClassPathResource classPathResource = new ClassPathResource(location,
            resourceName,
            configuration.getClassLoader(),
            configuration.getEncoding(),
            locationUrl.getPath(),
            configuration.isStream());
        return Pair.of(classPathResource, null);
    }

    private Set<String> findResourceNames(final String location, final URL locationUrl) {
        JarFile jarFile;
        try {
            URLConnection con = locationUrl.openConnection();
            if (con instanceof final JarURLConnection jarCon) {
                jarCon.setUseCaches(false);
                jarFile = jarCon.getJarFile();
            } else {
                return Collections.emptySet();
            }
        } catch (IOException e) {
            LOG.warn("Unable to determine jar from url (" + locationUrl + "): " + e.getMessage());
            return Collections.emptySet();
        }

        try {
            return findResourceNamesFromJarFile(jarFile, location);
        } finally {
            try {
                jarFile.close();
            } catch (IOException ignored) {
            }
        }
    }

    private Set<String> findResourceNamesFromJarFile(final JarFile jarFile, final String location) {
        String toScan = location + (location.endsWith("/") ? "" : "/");
        Set<String> resourceNames = new TreeSet<>();

        Enumeration<JarEntry> entries = jarFile.entries();
        while (entries.hasMoreElements()) {
            JarEntry entry = entries.nextElement();
            if (entry.isDirectory()) {
                continue;
            }
            String entryName = entry.getName();
            if (entryName.startsWith(toScan)) {
                resourceNames.add(entryName);
            }
        }

        return resourceNames;
    }
}