Author: gk
Date: Wed Jul 22 14:03:02 2026
New Revision: 1936487

Log:
TORQUE-369 patches (TORQUE-369-1-fix), TORQUE-369-2-tests), thanks to Max 
Philipp Wriedt.
Resolves issue "LoadExternalSchemaTransformer recursively includes when 
cross-referencing" using an InclusionTracker object or a set to differentiate 
between references and inline schemata (to include or to skip). No support for 
nested references from subdirectories; contains also tests with example 
schemata.

Added:
   db/torque/trunk/torque-templates/src/test/cyclic-external-schema/
   db/torque/trunk/torque-templates/src/test/cyclic-external-schema/a-schema.xml
   db/torque/trunk/torque-templates/src/test/cyclic-external-schema/b-schema.xml
   db/torque/trunk/torque-templates/src/test/cyclic-include-schema/
   db/torque/trunk/torque-templates/src/test/cyclic-include-schema/a-schema.xml
   db/torque/trunk/torque-templates/src/test/cyclic-include-schema/b-schema.xml
   db/torque/trunk/torque-templates/src/test/diamond-include-schema/
   
db/torque/trunk/torque-templates/src/test/diamond-include-schema/left-schema.xml
   
db/torque/trunk/torque-templates/src/test/diamond-include-schema/right-schema.xml
   
db/torque/trunk/torque-templates/src/test/diamond-include-schema/root-schema.xml
   
db/torque/trunk/torque-templates/src/test/diamond-include-schema/shared-schema.xml
   
db/torque/trunk/torque-templates/src/test/java/org/apache/torque/templates/CyclicSchemaReferenceTest.java
Modified:
   
db/torque/trunk/torque-templates/src/main/java/org/apache/torque/templates/transformer/IncludeSchemaTransformer.java
   
db/torque/trunk/torque-templates/src/main/java/org/apache/torque/templates/transformer/LoadExternalSchemaTransformer.java

Modified: 
db/torque/trunk/torque-templates/src/main/java/org/apache/torque/templates/transformer/IncludeSchemaTransformer.java
==============================================================================
--- 
db/torque/trunk/torque-templates/src/main/java/org/apache/torque/templates/transformer/IncludeSchemaTransformer.java
        Wed Jul 22 13:34:40 2026        (r1936486)
+++ 
db/torque/trunk/torque-templates/src/main/java/org/apache/torque/templates/transformer/IncludeSchemaTransformer.java
        Wed Jul 22 14:03:02 2026        (r1936487)
@@ -20,8 +20,12 @@ package org.apache.torque.templates.tran
  */
 
 import java.io.File;
+import java.io.IOException;
 import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Set;
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
@@ -92,7 +96,29 @@ public class IncludeSchemaTransformer im
             final ControllerState controllerState)
                     throws SourceTransformerException
     {
-        final SourceElement root = (SourceElement) modelRoot;
+        return transform(
+                (SourceElement) modelRoot,
+                controllerState,
+                new InclusionTracker(controllerState.getSourceFile()));
+    }
+
+    /**
+     * Loads the included schema objects into the current source graph.
+     *
+     * @param root the database root element of the source tree, not null.
+     * @param controllerState the controller state, not null.
+     * @param tracker keeps track of the schema files already included in this
+     *        transformation, not null.
+     * @return SourceElement
+     * @throws SourceTransformerException if the transformation fails,
+     *         or if the schema files include each other cyclically.
+     */
+    private SourceElement transform(
+            final SourceElement root,
+            final ControllerState controllerState,
+            final InclusionTracker tracker)
+                    throws SourceTransformerException
+    {
         final List<SourceElement> includeSchemaList
         = root.getChildren(TorqueSchemaElementName.INCLUDE_SCHEMA);
         final List<SourceElement> childrenList = root.getChildren();
@@ -114,6 +140,12 @@ public class IncludeSchemaTransformer im
                     .toString();
             final File includeSchemaPath
                 = new File(includeSchemaBaseDir, relativePath);
+            if (!tracker.enter(includeSchemaPath))
+            {
+                log.debug("Skipping included file " + includeSchemaPath
+                        + ", it was already included in this transformation.");
+                continue;
+            }
             log.trace("Trying to read included file " + includeSchemaPath);
             try
             {
@@ -126,7 +158,10 @@ public class IncludeSchemaTransformer im
                 log.trace("successfully read included file "
                         + includeSchemaPath);
 
-                this.transform(includeSchemaRootElement, controllerState);
+                this.transform(
+                        includeSchemaRootElement,
+                        controllerState,
+                        tracker);
 
                 // disattach children from their current parent
                 // so that the new parent is the primary parent
@@ -150,6 +185,10 @@ public class IncludeSchemaTransformer im
                         e);
                 throw new SourceTransformerException(e);
             }
+            finally
+            {
+                tracker.leave(includeSchemaPath);
+            }
         }
         return root;
     }
@@ -167,6 +206,29 @@ public class IncludeSchemaTransformer im
             final ControllerState controllerState)
                     throws SourceTransformerException
     {
+        return transform(
+                database,
+                controllerState,
+                new InclusionTracker(controllerState.getSourceFile()));
+    }
+
+    /**
+     * Loads the included schema content into the current model.
+     *
+     * @param database the database root element of the source tree, not null.
+     * @param controllerState the controller state, not null.
+     * @param tracker keeps track of the schema files already included in this
+     *        transformation, not null.
+     * @return Database object
+     * @throws SourceTransformerException if the transformation fails,
+     *         or if the schema files include each other cyclically.
+     */
+    private Database transform(
+            final Database database,
+            final ControllerState controllerState,
+            final InclusionTracker tracker)
+                    throws SourceTransformerException
+    {
         // copy original list to iterate so we can modify the model list
         // during iteration
         final List<IncludeSchema> originalIncludeSchemaList
@@ -185,6 +247,12 @@ public class IncludeSchemaTransformer im
             }
             final File includeSchemaPath
                 = new File(includeSchemaBaseDir, includeSchema.filename);
+            if (!tracker.enter(includeSchemaPath))
+            {
+                log.debug("Skipping included file " + includeSchemaPath
+                        + ", it was already included in this transformation.");
+                continue;
+            }
             log.trace("Trying to read included file " + includeSchemaPath);
             try
             {
@@ -201,7 +269,7 @@ public class IncludeSchemaTransformer im
                         includeSchemaRootElement,
                         controllerState);
 
-                this.transform(includedDatabase, controllerState);
+                this.transform(includedDatabase, controllerState, tracker);
 
                 // add child elements from loaded schema
                 database.optionList.addAll(includedDatabase.optionList);
@@ -223,7 +291,121 @@ public class IncludeSchemaTransformer im
                         e);
                 throw new SourceTransformerException(e);
             }
+            finally
+            {
+                tracker.leave(includeSchemaPath);
+            }
         }
         return database;
     }
+
+    /**
+     * Keeps track of the schema files included during a single transformation.
+     *
+     * <p>Two cases must be told apart. A schema file which is reached again
+     * while it is still being processed further up the recursion forms a
+     * cycle; as include-schema inlines the content of the included file, the
+     * result would depend on the schema file the generation was started from,
+     * so this is treated as an error. A schema file which is reached again
+     * after it has been completely processed is simply included by more than
+     * one schema file; it is skipped, because inlining it twice would
+     * duplicate its tables.</p>
+     *
+     * <p>The tracker is created per transformation and handed down the
+     * recursion, because the transformer is held in static fields by its
+     * callers and must not keep transformation state of its own.</p>
+     */
+    private static final class InclusionTracker
+    {
+        /** The canonical paths of all schema files included so far. */
+        private final Set<String> included = new HashSet<>();
+
+        /**
+         * The canonical paths of the schema files currently being processed,
+         * in the order in which they include each other.
+         */
+        private final LinkedHashSet<String> inProgress = new LinkedHashSet<>();
+
+        /**
+         * Constructor.
+         *
+         * @param startFile the schema file the transformation starts from,
+         *        may be null.
+         */
+        private InclusionTracker(final File startFile)
+        {
+            if (startFile != null)
+            {
+                final String startPath = canonicalPath(startFile);
+                included.add(startPath);
+                inProgress.add(startPath);
+            }
+        }
+
+        /**
+         * Registers that a schema file is about to be included.
+         * Every call which returns true must be matched by a call to
+         * {@link #leave(File)}.
+         *
+         * @param schemaFile the schema file to include, not null.
+         * @return true if the file should be included, false if it was
+         *         already included and should be skipped.
+         * @throws SourceTransformerException if the file is already being
+         *         processed further up the recursion, i.e. the schema files
+         *         include each other cyclically.
+         */
+        private boolean enter(final File schemaFile)
+                throws SourceTransformerException
+        {
+            final String path = canonicalPath(schemaFile);
+            if (inProgress.contains(path))
+            {
+                throw new SourceTransformerException(
+                        "Cyclic include-schema reference: "
+                        + String.join(" -> ", inProgress) + " -> " + path
+                        + ". A schema file must not include itself,"
+                        + " directly or indirectly.");
+            }
+            if (!included.add(path))
+            {
+                return false;
+            }
+            inProgress.add(path);
+            return true;
+        }
+
+        /**
+         * Registers that a schema file has been processed completely.
+         *
+         * @param schemaFile the schema file, not null.
+         */
+        private void leave(final File schemaFile)
+        {
+            inProgress.remove(canonicalPath(schemaFile));
+        }
+
+        /**
+         * Returns the canonical path of a schema file, which is used to
+         * recognize a schema file which was already included. The database
+         * name cannot serve as identity here because it is optional in the
+         * schema and need not be unique across schema files.
+         *
+         * @param file the schema file, not null.
+         * @return the canonical path, or the absolute path if the canonical
+         *         path cannot be determined.
+         */
+        private static String canonicalPath(final File file)
+        {
+            try
+            {
+                return file.getCanonicalPath();
+            }
+            catch (final IOException e)
+            {
+                log.debug("Could not determine canonical path of " + file
+                        + ", falling back to the absolute path.", e);
+                return file.getAbsolutePath();
+            }
+        }
+    }
 }

Modified: 
db/torque/trunk/torque-templates/src/main/java/org/apache/torque/templates/transformer/LoadExternalSchemaTransformer.java
==============================================================================
--- 
db/torque/trunk/torque-templates/src/main/java/org/apache/torque/templates/transformer/LoadExternalSchemaTransformer.java
   Wed Jul 22 13:34:40 2026        (r1936486)
+++ 
db/torque/trunk/torque-templates/src/main/java/org/apache/torque/templates/transformer/LoadExternalSchemaTransformer.java
   Wed Jul 22 14:03:02 2026        (r1936487)
@@ -20,7 +20,10 @@ package org.apache.torque.templates.tran
  */
 
 import java.io.File;
+import java.io.IOException;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
@@ -100,7 +103,28 @@ public class LoadExternalSchemaTransform
             final ControllerState controllerState)
                     throws SourceTransformerException
     {
-        final SourceElement root = (SourceElement) modelRoot;
+        return transform(
+                (SourceElement) modelRoot,
+                controllerState,
+                newVisitedSchemaSet(controllerState));
+    }
+
+    /**
+     * Loads the external schemata tables into the current graph.
+     *
+     * @param root the database root element of the source tree, not null.
+     * @param controllerState the controller state, not null.
+     * @param visitedSchemaPaths the canonical paths of all schema files
+     *        already loaded in this transformation, not null.
+     * @return SourceElement
+     * @throws SourceTransformerException if the transformation fails.
+     */
+    private SourceElement transform(
+            final SourceElement root,
+            final ControllerState controllerState,
+            final Set<String> visitedSchemaPaths)
+                    throws SourceTransformerException
+    {
         final List<SourceElement> externalSchemaElementList
         = root.getChildren(TorqueSchemaElementName.EXTERNAL_SCHEMA);
         SourceElement allTables = root.getChild(
@@ -137,6 +161,14 @@ public class LoadExternalSchemaTransform
                     .toString();
             final File externalSchemaPath
                 = new File(externalSchemaBaseDir, relativePath);
+            if (!visitedSchemaPaths.add(canonicalPath(externalSchemaPath)))
+            {
+                log.info("Skipping external schema " + externalSchemaPath
+                        + " because it was already loaded in this"
+                        + " transformation (cyclic external-schema"
+                        + " reference).");
+                continue;
+            }
             try
             {
                 final FileSource fileSource = new FileSource(
@@ -153,8 +185,10 @@ public class LoadExternalSchemaTransform
                         rootDatabaseName,
                         controllerState);
 
-                // todo infinite loop
-                this.transform(externalSchemaRootElement, controllerState);
+                this.transform(
+                        externalSchemaRootElement,
+                        controllerState,
+                        visitedSchemaPaths);
 
                 externalSchemaElement.getChildren().add(
                         externalSchemaRootElement);
@@ -219,6 +253,28 @@ public class LoadExternalSchemaTransform
             final ControllerState controllerState)
                     throws SourceTransformerException
     {
+        return transform(
+                database,
+                controllerState,
+                newVisitedSchemaSet(controllerState));
+    }
+
+    /**
+     * Loads the external schemata tables into the current model.
+     *
+     * @param database the database root element of the source tree, not null.
+     * @param controllerState the controller state, not null.
+     * @param visitedSchemaPaths the canonical paths of all schema files
+     *        already loaded in this transformation, not null.
+     * @return Database object
+     * @throws SourceTransformerException if the transformation fails.
+     */
+    private Database transform(
+            final Database database,
+            final ControllerState controllerState,
+            final Set<String> visitedSchemaPaths)
+                    throws SourceTransformerException
+    {
         for (final ExternalSchema externalSchema : database.externalSchemaList)
         {
             File externalSchemaBaseDir;
@@ -233,6 +289,14 @@ public class LoadExternalSchemaTransform
             }
             final File externalSchemaPath
                 = new File(externalSchemaBaseDir, externalSchema.filename);
+            if (!visitedSchemaPaths.add(canonicalPath(externalSchemaPath)))
+            {
+                log.info("Skipping external schema " + externalSchemaPath
+                        + " because it was already loaded in this"
+                        + " transformation (cyclic external-schema"
+                        + " reference).");
+                continue;
+            }
             try
             {
                 final FileSource fileSource = new FileSource(
@@ -245,31 +309,18 @@ public class LoadExternalSchemaTransform
                 = (Database) toModelTransformer.transform(
                         externalSchemaRootElement,
                         controllerState);
-                
-//                log.warn( "get value of database.rootDatabaseName and set " 
+ database.rootDatabaseName );
                 externalDatabase.rootDatabaseName = database.rootDatabaseName;
 
-                // TODO continue if 
-//                if (database.externalSchemaList.contains( externalSchema)) {
-//                    log.warn( "checked root has externalShema included: " + 
externalSchema );
-//                }
-//                log.warn( "database.name " + database.name );
-//                log.warn( "externalDatabase.externalSchemaList " + 
externalDatabase.externalSchemaList.toString() );
-//                log.warn( "externalDatabase.name " + externalDatabase.name );
-//                boolean identicalInclude = 
externalDatabase.externalSchemaList.stream().anyMatch( 
-//                        extSchema -> extSchema != null && 
extSchema.database.name.equals( externalDatabase.name ));
-//                        //.parent.externalSchemaList.contains( 
externalDatabase )  );
-//                log.warn( "identicalInclude " + identicalInclude );
-//                if (identicalInclude) {
-//                    return database;
-//                }
                 // TODO fix static access
                 
DatabaseMapInitTransformer.setDatabaseMapInitClassNameAttributes(
                         externalSchemaRootElement,
                         database.rootDatabaseName,
                         controllerState);
 
-                this.transform(externalDatabase, controllerState);
+                this.transform(
+                        externalDatabase,
+                        controllerState,
+                        visitedSchemaPaths);
 
                 externalSchema.database = externalDatabase;
 
@@ -291,4 +342,51 @@ public class LoadExternalSchemaTransform
         database.allViews.addAll(database.viewList);
         return database;
     }
+
+    /**
+     * Creates the set of schema files already loaded in a transformation.
+     * The set is seeded with the schema file the transformation starts from,
+     * so that an external schema referencing its own origin is detected.
+     * The set is created per invocation and handed down the recursion because
+     * this transformer is held in static fields by its callers and must not
+     * keep transformation state of its own.
+     *
+     * @param controllerState the controller state, not null.
+     * @return a new mutable set of canonical schema file paths, not null.
+     */
+    private Set<String> newVisitedSchemaSet(
+            final ControllerState controllerState)
+    {
+        final Set<String> visitedSchemaPaths = new HashSet<>();
+        final File sourceFile = controllerState.getSourceFile();
+        if (sourceFile != null)
+        {
+            visitedSchemaPaths.add(canonicalPath(sourceFile));
+        }
+        return visitedSchemaPaths;
+    }
+
+    /**
+     * Returns the canonical path of a schema file, which is used to recognize
+     * a schema file which was already loaded. The database name cannot serve
+     * as identity here because it is optional in the schema and need not be
+     * unique across schema files.
+     *
+     * @param file the schema file, not null.
+     * @return the canonical path, or the absolute path if the canonical path
+     *         cannot be determined.
+     */
+    private static String canonicalPath(final File file)
+    {
+        try
+        {
+            return file.getCanonicalPath();
+        }
+        catch (final IOException e)
+        {
+            log.debug("Could not determine canonical path of " + file
+                    + ", falling back to the absolute path.", e);
+            return file.getAbsolutePath();
+        }
+    }
 }

Added: 
db/torque/trunk/torque-templates/src/test/cyclic-external-schema/a-schema.xml
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
db/torque/trunk/torque-templates/src/test/cyclic-external-schema/a-schema.xml   
    Wed Jul 22 14:03:02 2026        (r1936487)
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="ISO-8859-1" standalone="no" ?>
+<!--
+ 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
+
+   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.
+-->
+<!--
+ TORQUE-369: a-schema references b-schema and b-schema references back to
+ a-schema. Both schemata must be loadable without running into an endless
+ recursion.
+-->
+<database
+    xmlns="http://db.apache.org/torque/5.0/templates/database";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="https://db.apache.org/torque/5.0/templates/database
+        
https://db.apache.org/torque/torque-5.0/documentation/orm-reference/database-5-0.xsd";
+    name="a"
+    defaultIdMethod="native">
+
+  <external-schema filename="b-schema.xml" />
+
+  <table name="a">
+    <column
+      name="a_id"
+      required="true"
+      primaryKey="true"
+      type="INTEGER"
+    />
+    <column
+      name="b_id"
+      required="true"
+      type="INTEGER"
+    />
+    <foreign-key foreignTable="b">
+      <reference local="b_id" foreign="b_id"/>
+    </foreign-key>
+  </table>
+</database>

Added: 
db/torque/trunk/torque-templates/src/test/cyclic-external-schema/b-schema.xml
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
db/torque/trunk/torque-templates/src/test/cyclic-external-schema/b-schema.xml   
    Wed Jul 22 14:03:02 2026        (r1936487)
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="ISO-8859-1" standalone="no" ?>
+<!--
+ 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
+
+   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.
+-->
+<!--
+ TORQUE-369: back reference to a-schema, which references this schema.
+-->
+<database
+    xmlns="http://db.apache.org/torque/5.0/templates/database";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="https://db.apache.org/torque/5.0/templates/database
+        
https://db.apache.org/torque/torque-5.0/documentation/orm-reference/database-5-0.xsd";
+    name="b"
+    defaultIdMethod="native">
+
+  <external-schema filename="a-schema.xml" />
+
+  <table name="b">
+    <column
+      name="b_id"
+      required="true"
+      primaryKey="true"
+      type="INTEGER"
+    />
+    <column
+      name="a_id"
+      required="true"
+      type="INTEGER"
+    />
+    <foreign-key foreignTable="a">
+      <reference local="a_id" foreign="a_id"/>
+    </foreign-key>
+  </table>
+</database>

Added: 
db/torque/trunk/torque-templates/src/test/cyclic-include-schema/a-schema.xml
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
db/torque/trunk/torque-templates/src/test/cyclic-include-schema/a-schema.xml    
    Wed Jul 22 14:03:02 2026        (r1936487)
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="ISO-8859-1" standalone="no" ?>
+<!--
+ 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
+
+   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.
+-->
+<!--
+ TORQUE-369: a-schema includes b-schema, which includes a-schema again.
+ As include-schema inlines the content of the included file, this cycle is
+ an error and must be reported as such.
+-->
+<database
+    xmlns="http://db.apache.org/torque/5.0/templates/database";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="https://db.apache.org/torque/5.0/templates/database
+        
https://db.apache.org/torque/torque-5.0/documentation/orm-reference/database-5-0.xsd";
+    name="cyclicinclude"
+    defaultIdMethod="native">
+
+  <include-schema filename="b-schema.xml" />
+
+  <table name="a">
+    <column
+      name="a_id"
+      required="true"
+      primaryKey="true"
+      type="INTEGER"
+    />
+  </table>
+</database>

Added: 
db/torque/trunk/torque-templates/src/test/cyclic-include-schema/b-schema.xml
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
db/torque/trunk/torque-templates/src/test/cyclic-include-schema/b-schema.xml    
    Wed Jul 22 14:03:02 2026        (r1936487)
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="ISO-8859-1" standalone="no" ?>
+<!--
+ 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
+
+   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.
+-->
+<!--
+ TORQUE-369: back reference to a-schema, which includes this schema.
+-->
+<database
+    xmlns="http://db.apache.org/torque/5.0/templates/database";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="https://db.apache.org/torque/5.0/templates/database
+        
https://db.apache.org/torque/torque-5.0/documentation/orm-reference/database-5-0.xsd";
+    name="cyclicinclude"
+    defaultIdMethod="native">
+
+  <include-schema filename="a-schema.xml" />
+
+  <table name="b">
+    <column
+      name="b_id"
+      required="true"
+      primaryKey="true"
+      type="INTEGER"
+    />
+  </table>
+</database>

Added: 
db/torque/trunk/torque-templates/src/test/diamond-include-schema/left-schema.xml
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
db/torque/trunk/torque-templates/src/test/diamond-include-schema/left-schema.xml
    Wed Jul 22 14:03:02 2026        (r1936487)
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="ISO-8859-1" standalone="no" ?>
+<!--
+ 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
+
+   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.
+-->
+<!--
+ TORQUE-369: includes shared-schema, as does the sibling schema.
+-->
+<database
+    xmlns="http://db.apache.org/torque/5.0/templates/database";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="https://db.apache.org/torque/5.0/templates/database
+        
https://db.apache.org/torque/torque-5.0/documentation/orm-reference/database-5-0.xsd";
+    name="diamond"
+    defaultIdMethod="native">
+
+  <include-schema filename="shared-schema.xml" />
+
+  <table name="left">
+    <column
+      name="left_id"
+      required="true"
+      primaryKey="true"
+      type="INTEGER"
+    />
+  </table>
+</database>

Added: 
db/torque/trunk/torque-templates/src/test/diamond-include-schema/right-schema.xml
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
db/torque/trunk/torque-templates/src/test/diamond-include-schema/right-schema.xml
   Wed Jul 22 14:03:02 2026        (r1936487)
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="ISO-8859-1" standalone="no" ?>
+<!--
+ 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
+
+   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.
+-->
+<!--
+ TORQUE-369: includes shared-schema, as does the sibling schema.
+-->
+<database
+    xmlns="http://db.apache.org/torque/5.0/templates/database";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="https://db.apache.org/torque/5.0/templates/database
+        
https://db.apache.org/torque/torque-5.0/documentation/orm-reference/database-5-0.xsd";
+    name="diamond"
+    defaultIdMethod="native">
+
+  <include-schema filename="shared-schema.xml" />
+
+  <table name="right">
+    <column
+      name="right_id"
+      required="true"
+      primaryKey="true"
+      type="INTEGER"
+    />
+  </table>
+</database>

Added: 
db/torque/trunk/torque-templates/src/test/diamond-include-schema/root-schema.xml
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
db/torque/trunk/torque-templates/src/test/diamond-include-schema/root-schema.xml
    Wed Jul 22 14:03:02 2026        (r1936487)
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="ISO-8859-1" standalone="no" ?>
+<!--
+ 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
+
+   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.
+-->
+<!--
+ TORQUE-369: root includes left and right, both of which include shared.
+ This is not a cycle, but shared must be inlined only once, otherwise its
+ tables are generated twice.
+-->
+<database
+    xmlns="http://db.apache.org/torque/5.0/templates/database";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="https://db.apache.org/torque/5.0/templates/database
+        
https://db.apache.org/torque/torque-5.0/documentation/orm-reference/database-5-0.xsd";
+    name="diamond"
+    defaultIdMethod="native">
+
+  <include-schema filename="left-schema.xml" />
+  <include-schema filename="right-schema.xml" />
+
+  <table name="root">
+    <column
+      name="root_id"
+      required="true"
+      primaryKey="true"
+      type="INTEGER"
+    />
+  </table>
+</database>

Added: 
db/torque/trunk/torque-templates/src/test/diamond-include-schema/shared-schema.xml
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
db/torque/trunk/torque-templates/src/test/diamond-include-schema/shared-schema.xml
  Wed Jul 22 14:03:02 2026        (r1936487)
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="ISO-8859-1" standalone="no" ?>
+<!--
+ 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
+
+   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.
+-->
+<!--
+ TORQUE-369: included by both left-schema and right-schema.
+-->
+<database
+    xmlns="http://db.apache.org/torque/5.0/templates/database";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="https://db.apache.org/torque/5.0/templates/database
+        
https://db.apache.org/torque/torque-5.0/documentation/orm-reference/database-5-0.xsd";
+    name="diamond"
+    defaultIdMethod="native">
+
+  <table name="shared">
+    <column
+      name="shared_id"
+      required="true"
+      primaryKey="true"
+      type="INTEGER"
+    />
+  </table>
+</database>

Added: 
db/torque/trunk/torque-templates/src/test/java/org/apache/torque/templates/CyclicSchemaReferenceTest.java
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
db/torque/trunk/torque-templates/src/test/java/org/apache/torque/templates/CyclicSchemaReferenceTest.java
   Wed Jul 22 14:03:02 2026        (r1936487)
@@ -0,0 +1,286 @@
+package org.apache.torque.templates;
+
+/*
+ * 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
+ *
+ *   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.
+ */
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.torque.generator.configuration.UnitDescriptor;
+import 
org.apache.torque.generator.configuration.option.MapOptionsConfiguration;
+import org.apache.torque.generator.configuration.paths.CustomProjectPaths;
+import 
org.apache.torque.generator.configuration.paths.DefaultTorqueGeneratorPaths;
+import 
org.apache.torque.generator.configuration.paths.Maven2DirectoryProjectPaths;
+import org.apache.torque.generator.configuration.paths.Maven2ProjectPaths;
+import org.apache.torque.generator.control.Controller;
+import org.apache.torque.generator.file.Fileset;
+import org.apache.torque.generator.source.SourceProvider;
+import org.apache.torque.generator.source.stream.FileSourceProvider;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests that schema files referencing each other cyclically are handled
+ * without running into an endless recursion.
+ *
+ * <p>See TORQUE-369. An external-schema reference is a reference to another
+ * database, so a back reference is a valid, if unusual, way to model foreign
+ * keys across schemata and the schema is only loaded once. An include-schema
+ * reference inlines the content of the included file, so a cycle would make
+ * the result depend on the schema file the generation was started from and is
+ * reported as an error. Including the same file from two different schema
+ * files is not a cycle and the file is inlined only once.</p>
+ *
+ * @version $Id$
+ */
+public class CyclicSchemaReferenceTest
+{
+    /** The configuration directory of the sql ddl templates. */
+    private static final File SQL_CONFIGURATION_DIR = new File(
+            "src/main/resources/org/apache/torque/templates/sql");
+
+    /**
+     * Two schemata referencing each other as external schema must be
+     * generated without an endless recursion, and each of them must yield
+     * its own create script.
+     *
+     * @throws Exception if the test fails.
+     */
+    @Test
+    public void testCyclicExternalSchemaIsLoadedOnce() throws Exception
+    {
+        final File generationDir
+            = new File("target/generated-sql-cyclic-external");
+        FileUtils.deleteDirectory(generationDir);
+
+        runGeneration(
+                new File("src/test/cyclic-external-schema"),
+                null,
+                generationDir);
+
+        final File aDdlScript = new File(generationDir, "a-schema.sql");
+        final File bDdlScript = new File(generationDir, "b-schema.sql");
+        assertTrue(aDdlScript.exists(),
+                "The ddl script for a-schema should have been generated");
+        assertTrue(bDdlScript.exists(),
+                "The ddl script for b-schema should have been generated");
+
+        // A ddl script contains the tables of its own schema only, the
+        // tables of the external schema are generated into their own script.
+        assertEquals(1, countCreateTable(aDdlScript, "a"));
+        assertEquals(0, countCreateTable(aDdlScript, "b"));
+        assertEquals(0, countCreateTable(bDdlScript, "a"));
+        assertEquals(1, countCreateTable(bDdlScript, "b"));
+
+        // The foreign keys into the other schema must still be resolved,
+        // which is only possible if the external schema was loaded despite
+        // the back reference to the schema it is loaded from.
+        assertTrue(contentOf(aDdlScript).contains("REFERENCES b"),
+                "The foreign key of a into the external schema b should have"
+                        + " been resolved");
+        assertTrue(contentOf(bDdlScript).contains("REFERENCES a"),
+                "The foreign key of b into the external schema a should have"
+                        + " been resolved");
+    }
+
+    /**
+     * Two schemata including each other must be rejected, because inlining
+     * them would make the result depend on the schema file the generation
+     * was started from.
+     */
+    @Test
+    public void testCyclicIncludeSchemaIsRejected()
+    {
+        final File generationDir
+            = new File("target/generated-sql-cyclic-include");
+
+        final Exception exception = assertThrows(
+                Exception.class,
+                () -> runGeneration(
+                        new File("src/test/cyclic-include-schema"),
+                        "a-schema.xml",
+                        generationDir));
+
+        assertTrue(
+                messageChainOf(exception).contains(
+                        "Cyclic include-schema reference"),
+                "The generation should have failed because of the cyclic"
+                        + " include-schema reference, but failed with: "
+                        + messageChainOf(exception));
+    }
+
+    /**
+     * A schema file which is included by two different schema files must be
+     * inlined only once, otherwise its tables are generated twice.
+     *
+     * @throws Exception if the test fails.
+     */
+    @Test
+    public void testSchemaIncludedTwiceIsInlinedOnce() throws Exception
+    {
+        final File generationDir
+            = new File("target/generated-sql-diamond-include");
+        FileUtils.deleteDirectory(generationDir);
+
+        runGeneration(
+                new File("src/test/diamond-include-schema"),
+                "root-schema.xml",
+                generationDir);
+
+        final File createScript
+            = new File(generationDir, "root-schema.sql");
+        assertTrue(createScript.exists(),
+                "The ddl script should have been generated");
+
+        assertEquals(1, countCreateTable(createScript, "shared"),
+                "The schema included by both left-schema and right-schema"
+                        + " should have been inlined only once");
+        assertEquals(1, countCreateTable(createScript, "root"));
+        assertEquals(1, countCreateTable(createScript, "left"));
+        assertEquals(1, countCreateTable(createScript, "right"));
+    }
+
+    /**
+     * Runs the createdb sql generation on a schema directory.
+     *
+     * @param schemaDir the directory containing the schema files, not null.
+     * @param sourceInclude the only schema file to use as a source,
+     *        or null to use all schema files in the directory.
+     * @param generationDir the directory to generate into, not null.
+     *
+     * @throws Exception if the generation fails.
+     */
+    private void runGeneration(
+            final File schemaDir,
+            final String sourceInclude,
+            final File generationDir)
+                    throws Exception
+    {
+        final Map<String, String> overrideOptions = new HashMap<>();
+        overrideOptions.put("torque.database", "mysql");
+
+        final CustomProjectPaths projectPaths = new CustomProjectPaths(
+                new Maven2DirectoryProjectPaths(new File(".")));
+        projectPaths.setConfigurationDir(SQL_CONFIGURATION_DIR);
+        projectPaths.setSourceDir(schemaDir);
+        projectPaths.setOutputDirectory(null, generationDir);
+        projectPaths.setOutputDirectory(
+                Maven2ProjectPaths.MODIFIABLE_OUTPUT_DIR_KEY,
+                new File(generationDir.getPath() + "-modifiable"));
+
+        final UnitDescriptor unitDescriptor = new UnitDescriptor(
+                UnitDescriptor.Packaging.DIRECTORY,
+                projectPaths,
+                new DefaultTorqueGeneratorPaths());
+        unitDescriptor.setOverrideOptions(
+                new MapOptionsConfiguration(overrideOptions));
+        if (sourceInclude != null)
+        {
+            final Fileset sourceFileset = new Fileset();
+            final Set<String> sourceIncludes = new HashSet<>();
+            sourceIncludes.add(sourceInclude);
+            sourceFileset.setIncludes(sourceIncludes);
+            sourceFileset.setBasedir(projectPaths.getDefaultSourcePath());
+            final SourceProvider sourceProvider = new FileSourceProvider(
+                    null,
+                    sourceFileset,
+                    null);
+            unitDescriptor.setOverrideSourceProvider(sourceProvider);
+        }
+
+        final List<UnitDescriptor> unitDescriptors = new ArrayList<>();
+        unitDescriptors.add(unitDescriptor);
+        new Controller().run(unitDescriptors);
+    }
+
+    /**
+     * Counts the create statements for a table in a generated sql script.
+     *
+     * @param sqlScript the generated sql script, not null.
+     * @param tableName the name of the table, not null.
+     *
+     * @return the number of create statements for the table.
+     *
+     * @throws Exception if the script cannot be read.
+     */
+    private int countCreateTable(final File sqlScript, final String tableName)
+            throws Exception
+    {
+        final String content = contentOf(sqlScript);
+        final Matcher matcher = Pattern.compile(
+                "CREATE TABLE\\s+`?" + Pattern.quote(tableName) + "`?\\s",
+                Pattern.CASE_INSENSITIVE).matcher(content);
+        int count = 0;
+        while (matcher.find())
+        {
+            count++;
+        }
+        return count;
+    }
+
+    /**
+     * Reads a generated sql script.
+     *
+     * @param sqlScript the generated sql script, not null.
+     *
+     * @return the content of the script, not null.
+     *
+     * @throws Exception if the script cannot be read.
+     */
+    private String contentOf(final File sqlScript) throws Exception
+    {
+        return new String(
+                Files.readAllBytes(sqlScript.toPath()),
+                StandardCharsets.UTF_8);
+    }
+
+    /**
+     * Concatenates the messages of an exception and all its causes, so that
+     * an assertion does not depend on how deeply the generator wraps an
+     * exception thrown by a transformer.
+     *
+     * @param exception the exception, not null.
+     *
+     * @return the concatenated messages, not null.
+     */
+    private String messageChainOf(final Throwable exception)
+    {
+        final StringBuilder result = new StringBuilder();
+        Throwable current = exception;
+        while (current != null)
+        {
+            result.append(current.getMessage()).append('\n');
+            current = current.getCause();
+        }
+        return result.toString();
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to