From d9ffeee4b7cfa60e6abe3cc944dd8524d6f5592d Mon Sep 17 00:00:00 2001 From: Dongliang Xie Date: Thu, 14 May 2026 16:18:21 +0800 Subject: [PATCH 1/2] Preserve nested Flyway migration paths in native images This commit preserves the classpath-relative path for Flyway migration discovered by the native image resource provider so nested migrations remain readable. See gh-50433 Signed-off-by: Dongliang Xie --- .../NativeImageResourceProvider.java | 60 +++++++++++++++++-- ...eImageResourceProviderCustomizerTests.java | 34 +++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/module/spring-boot-flyway/src/main/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProvider.java b/module/spring-boot-flyway/src/main/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProvider.java index d5065b9f689f..f5a7bdbc4e60 100644 --- a/module/spring-boot-flyway/src/main/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProvider.java +++ b/module/spring-boot-flyway/src/main/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProvider.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.io.UncheckedIOException; +import java.net.URI; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Collection; @@ -46,6 +47,7 @@ * {@link PathMatchingResourcePatternResolver} to find migration files in a native image. * * @author Moritz Halbritter + * @author Dongliang Xie */ class NativeImageResourceProvider implements ResourceProvider { @@ -106,9 +108,8 @@ public Collection getResources(String prefix, String[] suffixe } private ClassPathResource asClassPathResource(LocatedResource locatedResource) { - Location location = locatedResource.location(); - String fileNameWithAbsolutePath = location.getRootPath() + "/" + locatedResource.resource().getFilename(); - return new ClassPathResource(location, fileNameWithAbsolutePath, this.classLoader, this.encoding); + return new ClassPathResource(locatedResource.location(), locatedResource.path(), this.classLoader, + this.encoding); } private void ensureInitialized() { @@ -140,11 +141,60 @@ private void initialize() { } Resource[] resources = getResources(resolver, location, root); for (Resource resource : resources) { - this.locatedResources.add(new LocatedResource(resource, location)); + this.locatedResources + .add(new LocatedResource(resource, location, getClassPathResourcePath(location, root, resource))); } } } + private String getClassPathResourcePath(Location location, Resource root, Resource resource) { + if (resource instanceof org.springframework.core.io.ClassPathResource classPathResource) { + return classPathResource.getPath(); + } + String rootPath = location.getRootPath(); + String resourcePath = getResourcePathRelativeToRoot(root, resource, rootPath); + return (rootPath.isEmpty()) ? resourcePath : rootPath + "/" + resourcePath; + } + + private String getResourcePathRelativeToRoot(Resource root, Resource resource, String rootPath) { + try { + URI rootUri = root.getURI(); + URI resourceUri = resource.getURI(); + String relativePath = getRelativePath(rootUri, resourceUri); + if (relativePath != null) { + return relativePath; + } + String path = getUriPath(resourceUri); + if (!rootPath.isEmpty()) { + int rootPathIndex = path.indexOf(rootPath + "/"); + if (rootPathIndex != -1) { + return path.substring(rootPathIndex + rootPath.length() + 1); + } + } + String filename = resource.getFilename(); + return (filename != null) ? filename : path; + } + catch (IOException ex) { + throw new UncheckedIOException("Failed to determine path for " + resource, ex); + } + } + + private @Nullable String getRelativePath(URI rootUri, URI resourceUri) { + String rootPath = asDirectoryPath(rootUri); + String resourcePath = getUriPath(resourceUri); + return (resourcePath.startsWith(rootPath)) ? resourcePath.substring(rootPath.length()) : null; + } + + private String asDirectoryPath(URI uri) { + String path = getUriPath(uri); + return (path.endsWith("/")) ? path : path + "/"; + } + + private String getUriPath(URI uri) { + String path = uri.getPath(); + return (path != null) ? path : uri.toString(); + } + private Resource[] getResources(PathMatchingResourcePatternResolver resolver, Location location, Resource root) { try { return resolver.getResources(root.getURI() + "/**/*"); @@ -154,7 +204,7 @@ private Resource[] getResources(PathMatchingResourcePatternResolver resolver, Lo } } - private record LocatedResource(Resource resource, Location location) { + private record LocatedResource(Resource resource, Location location, String path) { } diff --git a/module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderCustomizerTests.java b/module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderCustomizerTests.java index 541f2d0215bb..9c2ac033b3fb 100644 --- a/module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderCustomizerTests.java +++ b/module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderCustomizerTests.java @@ -16,23 +16,34 @@ package org.springframework.boot.flyway.autoconfigure; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Collection; +import java.util.Collections; +import java.util.List; +import org.flywaydb.core.api.Location; import org.flywaydb.core.api.ResourceProvider; import org.flywaydb.core.api.configuration.FluentConfiguration; import org.flywaydb.core.api.resource.LoadableResource; import org.flywaydb.core.internal.resource.NoopResourceProvider; +import org.flywaydb.core.internal.scanner.Scanner; import org.junit.jupiter.api.Test; +import org.springframework.boot.testsupport.classpath.ForkedClassPath; import org.springframework.boot.testsupport.classpath.resources.WithResource; import org.springframework.boot.testsupport.classpath.resources.WithResources; +import org.springframework.util.FileCopyUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; /** * Tests for {@link NativeImageResourceProviderCustomizer}. * * @author Moritz Halbritter + * @author Dongliang Xie */ class NativeImageResourceProviderCustomizerTests { @@ -70,6 +81,29 @@ void nativeImageResourceProviderShouldFindNestedMigrations() { assertThat(migrations).containsExactlyInAnyOrder(v1, v2); } + @Test + @ForkedClassPath + @WithResource(name = "db/migration/nested/V2__users.sql", content = "select 1;") + void nativeImageResourceProviderShouldReadNestedMigrations() throws IOException { + System.setProperty("org.graalvm.nativeimage.imagecode", "true"); + try { + @SuppressWarnings("unchecked") + Scanner scanner = mock(Scanner.class); + given(scanner.getResources("V", ".sql")).willReturn(Collections.emptyList()); + Location location = Location.fromPath("classpath:", "db/migration"); + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + ResourceProvider resourceProvider = new NativeImageResourceProvider(scanner, classLoader, List.of(location), + StandardCharsets.UTF_8, true); + Collection migrations = resourceProvider.getResources("V", new String[] { ".sql" }); + assertThat(migrations).hasSize(1); + LoadableResource migration = migrations.iterator().next(); + assertThat(FileCopyUtils.copyToString(migration.read())).isEqualTo("select 1;"); + } + finally { + System.clearProperty("org.graalvm.nativeimage.imagecode"); + } + } + @Test void shouldBackOffOnCustomResourceProvider() { FluentConfiguration configuration = new FluentConfiguration(); From 9f8f4da5631a259b4167445ac951e090238f0a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Thu, 16 Jul 2026 15:08:18 +0200 Subject: [PATCH 2/2] Polish "Preserve nested Flyway migration paths in native images" See gh-50433 --- .../NativeImageResourceProvider.java | 51 +++----------- ...eImageResourceProviderCustomizerTests.java | 34 --------- .../NativeImageResourceProviderTests.java | 69 +++++++++++++++++++ 3 files changed, 79 insertions(+), 75 deletions(-) create mode 100644 module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderTests.java diff --git a/module/spring-boot-flyway/src/main/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProvider.java b/module/spring-boot-flyway/src/main/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProvider.java index f5a7bdbc4e60..51dbf9e84412 100644 --- a/module/spring-boot-flyway/src/main/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProvider.java +++ b/module/spring-boot-flyway/src/main/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProvider.java @@ -18,7 +18,6 @@ import java.io.IOException; import java.io.UncheckedIOException; -import java.net.URI; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Collection; @@ -39,6 +38,7 @@ import org.springframework.core.NativeDetector; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.util.PathMatcher; /** * A Flyway {@link ResourceProvider} which supports GraalVM native-image. @@ -48,6 +48,7 @@ * * @author Moritz Halbritter * @author Dongliang Xie + * @author Stephane Nicoll */ class NativeImageResourceProvider implements ResourceProvider { @@ -141,60 +142,28 @@ private void initialize() { } Resource[] resources = getResources(resolver, location, root); for (Resource resource : resources) { - this.locatedResources - .add(new LocatedResource(resource, location, getClassPathResourcePath(location, root, resource))); + this.locatedResources.add(new LocatedResource(resource, location, + getClassPathResourcePath(resolver.getPathMatcher(), location, root, resource))); } } } - private String getClassPathResourcePath(Location location, Resource root, Resource resource) { + private String getClassPathResourcePath(PathMatcher pathMatcher, Location location, Resource root, + Resource resource) { if (resource instanceof org.springframework.core.io.ClassPathResource classPathResource) { return classPathResource.getPath(); } - String rootPath = location.getRootPath(); - String resourcePath = getResourcePathRelativeToRoot(root, resource, rootPath); - return (rootPath.isEmpty()) ? resourcePath : rootPath + "/" + resourcePath; - } - - private String getResourcePathRelativeToRoot(Resource root, Resource resource, String rootPath) { try { - URI rootUri = root.getURI(); - URI resourceUri = resource.getURI(); - String relativePath = getRelativePath(rootUri, resourceUri); - if (relativePath != null) { - return relativePath; - } - String path = getUriPath(resourceUri); - if (!rootPath.isEmpty()) { - int rootPathIndex = path.indexOf(rootPath + "/"); - if (rootPathIndex != -1) { - return path.substring(rootPathIndex + rootPath.length() + 1); - } - } - String filename = resource.getFilename(); - return (filename != null) ? filename : path; + String rootPath = location.getRootPath(); + String relativePath = pathMatcher.extractPathWithinPattern(root.getURI() + "/**/*", + resource.getURI().toString()); + return (rootPath.isEmpty()) ? relativePath : rootPath + "/" + relativePath; } catch (IOException ex) { throw new UncheckedIOException("Failed to determine path for " + resource, ex); } } - private @Nullable String getRelativePath(URI rootUri, URI resourceUri) { - String rootPath = asDirectoryPath(rootUri); - String resourcePath = getUriPath(resourceUri); - return (resourcePath.startsWith(rootPath)) ? resourcePath.substring(rootPath.length()) : null; - } - - private String asDirectoryPath(URI uri) { - String path = getUriPath(uri); - return (path.endsWith("/")) ? path : path + "/"; - } - - private String getUriPath(URI uri) { - String path = uri.getPath(); - return (path != null) ? path : uri.toString(); - } - private Resource[] getResources(PathMatchingResourcePatternResolver resolver, Location location, Resource root) { try { return resolver.getResources(root.getURI() + "/**/*"); diff --git a/module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderCustomizerTests.java b/module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderCustomizerTests.java index 9c2ac033b3fb..541f2d0215bb 100644 --- a/module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderCustomizerTests.java +++ b/module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderCustomizerTests.java @@ -16,34 +16,23 @@ package org.springframework.boot.flyway.autoconfigure; -import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.util.Collection; -import java.util.Collections; -import java.util.List; -import org.flywaydb.core.api.Location; import org.flywaydb.core.api.ResourceProvider; import org.flywaydb.core.api.configuration.FluentConfiguration; import org.flywaydb.core.api.resource.LoadableResource; import org.flywaydb.core.internal.resource.NoopResourceProvider; -import org.flywaydb.core.internal.scanner.Scanner; import org.junit.jupiter.api.Test; -import org.springframework.boot.testsupport.classpath.ForkedClassPath; import org.springframework.boot.testsupport.classpath.resources.WithResource; import org.springframework.boot.testsupport.classpath.resources.WithResources; -import org.springframework.util.FileCopyUtils; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; /** * Tests for {@link NativeImageResourceProviderCustomizer}. * * @author Moritz Halbritter - * @author Dongliang Xie */ class NativeImageResourceProviderCustomizerTests { @@ -81,29 +70,6 @@ void nativeImageResourceProviderShouldFindNestedMigrations() { assertThat(migrations).containsExactlyInAnyOrder(v1, v2); } - @Test - @ForkedClassPath - @WithResource(name = "db/migration/nested/V2__users.sql", content = "select 1;") - void nativeImageResourceProviderShouldReadNestedMigrations() throws IOException { - System.setProperty("org.graalvm.nativeimage.imagecode", "true"); - try { - @SuppressWarnings("unchecked") - Scanner scanner = mock(Scanner.class); - given(scanner.getResources("V", ".sql")).willReturn(Collections.emptyList()); - Location location = Location.fromPath("classpath:", "db/migration"); - ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); - ResourceProvider resourceProvider = new NativeImageResourceProvider(scanner, classLoader, List.of(location), - StandardCharsets.UTF_8, true); - Collection migrations = resourceProvider.getResources("V", new String[] { ".sql" }); - assertThat(migrations).hasSize(1); - LoadableResource migration = migrations.iterator().next(); - assertThat(FileCopyUtils.copyToString(migration.read())).isEqualTo("select 1;"); - } - finally { - System.clearProperty("org.graalvm.nativeimage.imagecode"); - } - } - @Test void shouldBackOffOnCustomResourceProvider() { FluentConfiguration configuration = new FluentConfiguration(); diff --git a/module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderTests.java b/module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderTests.java new file mode 100644 index 000000000000..4fd5853e93be --- /dev/null +++ b/module/spring-boot-flyway/src/test/java/org/springframework/boot/flyway/autoconfigure/NativeImageResourceProviderTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-present the original author or authors. + * + * 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 + * + * https://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.springframework.boot.flyway.autoconfigure; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import org.flywaydb.core.api.Location; +import org.flywaydb.core.api.ResourceProvider; +import org.flywaydb.core.api.resource.LoadableResource; +import org.flywaydb.core.internal.scanner.Scanner; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.testsupport.classpath.ForkedClassPath; +import org.springframework.boot.testsupport.classpath.resources.WithResource; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link NativeImageResourceProvider}. + * + * @author Dongliang Xie + * @author Stephane Nicoll + */ +class NativeImageResourceProviderTests { + + @Test + @ForkedClassPath + @WithResource(name = "db/migration/nested/V2__users.sql", content = "select 1;") + void nativeImageResourceProviderShouldReadNestedMigrations() throws IOException { + System.setProperty("org.graalvm.nativeimage.imagecode", "true"); + try { + Scanner scanner = mock(); + given(scanner.getResources("V", ".sql")).willReturn(Collections.emptyList()); + Location location = Location.fromPath("classpath:", "db/migration"); + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + ResourceProvider resourceProvider = new NativeImageResourceProvider(scanner, classLoader, List.of(location), + StandardCharsets.UTF_8, true); + Collection migrations = resourceProvider.getResources("V", new String[] { ".sql" }); + assertThat(migrations).hasSize(1); + LoadableResource migration = migrations.iterator().next(); + assertThat(FileCopyUtils.copyToString(migration.read())).isEqualTo("select 1;"); + } + finally { + System.clearProperty("org.graalvm.nativeimage.imagecode"); + } + } + +}