This is an automated email from the ASF dual-hosted git repository. jamesfredley pushed a commit to branch feat/artefact-index-seed in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit ccc1a3c86abf8b66d3b94b7bc344a07a2d43187c Author: James Fredley <[email protected]> AuthorDate: Fri Jul 17 14:38:55 2026 -0400 Add optional artefact index reader for startup scanning Assisted-by: opencode:gpt-5.6-sol --- .../boot/config/ApplicationArtefactScanner.groovy | 10 +- .../grails/boot/config/ArtefactIndexReader.java | 83 ++++++ .../config/ApplicationArtefactScannerSpec.groovy | 316 +++++++++++++++++++++ .../boot/config/excluded/ExcludedArtefact.groovy | 25 ++ .../boot/config/indexed/IncludedArtefact.groovy | 25 ++ 5 files changed, 457 insertions(+), 2 deletions(-) diff --git a/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy b/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy index a69cfef692..c43483e2f4 100644 --- a/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy +++ b/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy @@ -60,8 +60,14 @@ final class ApplicationArtefactScanner { * @return The classes that constitute the Grails application */ static Collection<Class> scanApplicationClasses(Class<?> applicationClass, Collection<String> packageNames) { - Collection<Class> classes = new HashSet<>() - classes.addAll(new ClassPathScanner().scan(applicationClass, packageNames)) + Collection<Class> classes = new LinkedHashSet<>() + Collection<Class> indexedClasses = ArtefactIndexReader.read(applicationClass, packageNames) + if (indexedClasses == null) { + classes.addAll(new ClassPathScanner().scan(applicationClass, packageNames)) + } + else { + classes.addAll(indexedClasses) + } classes.addAll(loadTransformedClasses(applicationClass.classLoader)) return classes } diff --git a/grails-core/src/main/groovy/grails/boot/config/ArtefactIndexReader.java b/grails-core/src/main/groovy/grails/boot/config/ArtefactIndexReader.java new file mode 100644 index 0000000000..ba15333eac --- /dev/null +++ b/grails-core/src/main/groovy/grails/boot/config/ArtefactIndexReader.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 grails.boot.config; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; + +import grails.io.IOUtils; + +/** + * Reads the optional application artefact index. + * + * <p>The index is UTF-8 text at {@value #RESOURCE_NAME}, with one fully qualified + * class name per nonempty line. Any unreadable or unresolvable entry rejects the + * complete index so callers can use their normal classpath scan.</p> + */ +final class ArtefactIndexReader { + + static final String RESOURCE_NAME = "META-INF/grails/artefacts.idx"; + + private ArtefactIndexReader() { + } + + static Collection<Class> read(Class<?> applicationClass, Collection<String> packageNames) { + try { + URL resource = new URL(IOUtils.findRootResource(applicationClass), RESOURCE_NAME); + Set<Class> classes = new LinkedHashSet<>(); + return readResource(resource, applicationClass.getClassLoader(), packageNames, classes) ? classes : null; + } catch (IOException ignored) { + return null; + } + } + + private static boolean readResource(URL resource, ClassLoader classLoader, Collection<String> packageNames, Set<Class> classes) { + try (InputStream inputStream = resource.openStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + String className; + while ((className = reader.readLine()) != null) { + if (className.isEmpty()) { + return false; + } + if (isInPackage(className, packageNames)) { + classes.add(classLoader.loadClass(className)); + } + } + return true; + } catch (IOException | ClassNotFoundException | LinkageError ignored) { + return false; + } + } + + private static boolean isInPackage(String className, Collection<String> packageNames) { + for (String packageName : packageNames) { + if (packageName != null && (packageName.isEmpty() ? !className.contains(".") : className.startsWith(packageName + "."))) { + return true; + } + } + return false; + } +} diff --git a/grails-core/src/test/groovy/grails/boot/config/ApplicationArtefactScannerSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/ApplicationArtefactScannerSpec.groovy new file mode 100644 index 0000000000..1c5ebe6d84 --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/config/ApplicationArtefactScannerSpec.groovy @@ -0,0 +1,316 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 grails.boot.config + +import java.io.InputStream +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.lang.reflect.Field +import java.net.URL +import java.net.URLClassLoader + +import grails.boot.config.excluded.ExcludedArtefact +import grails.boot.config.indexed.IncludedArtefact +import org.grails.compiler.injection.AbstractGrailsArtefactTransformer +import spock.lang.Specification + +class ApplicationArtefactScannerSpec extends Specification { + + private final List<File> temporaryDirectories = [] + private final List<URLClassLoader> classLoaders = [] + private Collection<String> transformedClassNames + + void setup() { + transformedClassNames = new ArrayList<>(knownTransformedClassNames()) + } + + void 'uses indexed artefacts in declaration order'() { + given: + Class<?> applicationClass = applicationClass(''' +grails.boot.config.IndexedSecondArtefact +grails.boot.config.IndexedFirstArtefact +''') + + when: + Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + indexedClassNames(classes) == [ + IndexedSecondArtefact.name, + IndexedFirstArtefact.name + ] + } + + void 'deduplicates indexed artefacts while preserving the first declaration'() { + given: + Class<?> applicationClass = applicationClass(''' +grails.boot.config.IndexedSecondArtefact +grails.boot.config.IndexedFirstArtefact +grails.boot.config.IndexedSecondArtefact +''') + + when: + Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + indexedClassNames(classes) == [ + IndexedSecondArtefact.name, + IndexedFirstArtefact.name + ] + } + + void 'falls back to classpath scanning when no index is present'() { + given: + Class<?> applicationClass = applicationClass(null) + + when: + Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(FallbackArtefact.name) + } + + void 'falls back to classpath scanning when an index is malformed'() { + given: + Class<?> applicationClass = applicationClass(''' +grails.boot.config.IndexedFirstArtefact + +grails.boot.config.IndexedSecondArtefact +''') + + when: + Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(FallbackArtefact.name) + } + + void 'falls back to application scanning when only a dependency index is present'() { + given: + Class<?> applicationClass = applicationClass(null, ForeignArtefact.name) + + when: + Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(FallbackArtefact.name) + !classes*.name.contains(ForeignArtefact.name) + } + + void 'does not include entries from a dependency index'() { + given: + Class<?> applicationClass = applicationClass(IndexedFirstArtefact.name, ForeignArtefact.name) + + when: + Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(IndexedFirstArtefact.name) + !classes*.name.contains(ForeignArtefact.name) + } + + void 'filters indexed artefacts by package names'() { + given: + Class<?> applicationClass = applicationClass(""" +${IncludedArtefact.name} +${ExcludedArtefact.name} +""") + + when: + Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass, [IncludedArtefact.package.name]) + + then: + classes*.name.contains(IncludedArtefact.name) + !classes*.name.contains(ExcludedArtefact.name) + } + + void 'falls back to classpath scanning when an indexed class is missing'() { + given: + Class<?> applicationClass = applicationClass('grails.boot.config.MissingArtefact') + + when: + Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(FallbackArtefact.name) + } + + void 'falls back to classpath scanning when an indexed class has a linkage error'() { + given: + Class<?> applicationClass = applicationClass('grails.boot.config.LinkageErrorArtefact', null, 'grails.boot.config.LinkageErrorArtefact') + + when: + Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(FallbackArtefact.name) + } + + void 'appends transformed artefacts to indexed artefacts without duplicates'() { + given: + AbstractGrailsArtefactTransformer.addToTransformedClasses(IndexedFirstArtefact.name) + AbstractGrailsArtefactTransformer.addToTransformedClasses(IndexedSecondArtefact.name) + Class<?> applicationClass = applicationClass(IndexedFirstArtefact.name) + + when: + Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + indexedClassNames(classes) == [ + IndexedFirstArtefact.name, + IndexedSecondArtefact.name + ] + } + + void cleanup() { + classLoaders.each { URLClassLoader classLoader -> classLoader.close() } + temporaryDirectories.each { File directory -> directory.deleteDir() } + Collection<String> knownTransformedClassNames = knownTransformedClassNames() + knownTransformedClassNames.clear() + knownTransformedClassNames.addAll(transformedClassNames) + } + + private Class<?> applicationClass(String index, String dependencyIndex = null, String linkageErrorClassName = null) { + File directory = File.createTempDir() + temporaryDirectories << directory + [IndexedApplication, IndexedFirstArtefact, IndexedSecondArtefact, FallbackArtefact, ArtefactMarker, IncludedArtefact, ExcludedArtefact].each { + Class<?> type -> copyClass(type, directory) + } + if (index != null) { + writeIndex(directory, index) + } + ClassLoader parent = ApplicationArtefactScannerSpec.classLoader + if (dependencyIndex != null) { + File dependencyDirectory = File.createTempDir() + temporaryDirectories << dependencyDirectory + copyClass(ForeignArtefact, dependencyDirectory) + writeIndex(dependencyDirectory, dependencyIndex) + URLClassLoader dependencyClassLoader = new URLClassLoader([dependencyDirectory.toURI().toURL()] as URL[], parent) + classLoaders << dependencyClassLoader + parent = dependencyClassLoader + } + URLClassLoader classLoader = new TestApplicationClassLoader([directory.toURI().toURL()] as URL[], parent, linkageErrorClassName) + classLoaders << classLoader + return classLoader.loadClass(IndexedApplication.name) + } + + private static void writeIndex(File directory, String index) { + File indexFile = new File(directory, ArtefactIndexReader.RESOURCE_NAME) + indexFile.parentFile.mkdirs() + indexFile.text = index.stripIndent().trim() + } + + private static Collection<String> knownTransformedClassNames() { + Field field = AbstractGrailsArtefactTransformer.getDeclaredField('KNOWN_TRANSFORMED_CLASSES') + field.accessible = true + (Collection<String>) field.get(null) + } + + private static void copyClass(Class<?> type, File directory) { + String resourceName = type.name.replace('.', '/') + '.class' + InputStream inputStream = type.classLoader.getResourceAsStream(resourceName) + try { + File classFile = new File(directory, resourceName) + classFile.parentFile.mkdirs() + classFile.bytes = inputStream.bytes + } finally { + inputStream.close() + } + } + + private static List<String> indexedClassNames(Collection<Class> classes) { + classes*.name.findAll { String className -> + className == IndexedFirstArtefact.name || className == IndexedSecondArtefact.name + } + } +} + +class IndexedApplication { +} + +class IndexedFirstArtefact { +} + +class IndexedSecondArtefact { +} + +class ForeignArtefact { +} + +@ArtefactMarker +class FallbackArtefact { +} + +@Retention(RetentionPolicy.RUNTIME) +@interface ArtefactMarker { +} + +class TestApplicationClassLoader extends URLClassLoader { + + private final String linkageErrorClassName + + TestApplicationClassLoader(URL[] urls, ClassLoader parent, String linkageErrorClassName) { + super(urls, parent) + this.linkageErrorClassName = linkageErrorClassName + } + + @Override + protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name == linkageErrorClassName) { + throw new NoClassDefFoundError(name) + } + if (!isFixtureClass(name)) { + return super.loadClass(name, resolve) + } + + synchronized (getClassLoadingLock(name)) { + Class<?> loadedClass = findLoadedClass(name) + if (loadedClass == null) { + try { + loadedClass = findClass(name) + } catch (ClassNotFoundException ignored) { + return super.loadClass(name, resolve) + } + } + if (resolve) { + resolveClass(loadedClass) + } + return loadedClass + } + } + + @Override + URL getResource(String name) { + if (isFixtureClassResource(name)) { + URL resource = findResource(name) + if (resource != null) { + return resource + } + } + return super.getResource(name) + } + + private static boolean isFixtureClass(String name) { + name.startsWith('grails.boot.config.Indexed') || name.startsWith('grails.boot.config.indexed.') || name.startsWith('grails.boot.config.excluded.') || name == FallbackArtefact.name || name == ArtefactMarker.name + } + + private static boolean isFixtureClassResource(String name) { + name.endsWith('.class') && isFixtureClass(name.substring(0, name.length() - '.class'.length()).replace('/', '.')) + } +} diff --git a/grails-core/src/test/groovy/grails/boot/config/excluded/ExcludedArtefact.groovy b/grails-core/src/test/groovy/grails/boot/config/excluded/ExcludedArtefact.groovy new file mode 100644 index 0000000000..a475a442c5 --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/config/excluded/ExcludedArtefact.groovy @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 grails.boot.config.excluded + +import grails.boot.config.ArtefactMarker + +@ArtefactMarker +class ExcludedArtefact { +} diff --git a/grails-core/src/test/groovy/grails/boot/config/indexed/IncludedArtefact.groovy b/grails-core/src/test/groovy/grails/boot/config/indexed/IncludedArtefact.groovy new file mode 100644 index 0000000000..6cfac753fe --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/config/indexed/IncludedArtefact.groovy @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 grails.boot.config.indexed + +import grails.boot.config.ArtefactMarker + +@ArtefactMarker +class IncludedArtefact { +}
