This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new e821e9247b Add `FeatureProjectionTest` and fix bugs identified by the 
tests:
e821e9247b is described below

commit e821e9247b8c4f76edd6e33c04ea894b15ef93bf
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Wed Nov 19 19:37:53 2025 +0100

    Add `FeatureProjectionTest` and fix bugs identified by the tests:
    
    * ClassCastException when the result of an expression does not match the 
type expected by the feature property.
    * Inconsistency between the property type declared by `requestedType` and 
the property type actually returned.
    * Documentation and renaming for clarifying expectations and relationship 
between fields and some methods.
    * Consolidation of management of dependencies of expressions used in 
projections (still work in progress).
---
 .../apache/sis/feature/AbstractAssociation.java    |   2 +-
 .../org/apache/sis/feature/AbstractFeature.java    |   2 +-
 .../org/apache/sis/feature/AbstractOperation.java  |   7 +-
 .../org/apache/sis/feature/DefaultFeatureType.java |   2 +-
 .../sis/feature/builder/FeatureTypeBuilder.java    |   2 +
 .../org/apache/sis/feature/internal/Resources.java |   6 +-
 .../sis/feature/internal/Resources.properties      |   2 +-
 .../sis/feature/internal/Resources_fr.properties   |   2 +-
 .../feature/internal/shared/FeatureProjection.java |  46 ++-
 .../internal/shared/FeatureProjectionBuilder.java  | 416 +++++++++++----------
 .../sis/feature/internal/shared/OperationView.java | 192 ++++++++++
 .../main/org/apache/sis/filter/PropertyValue.java  |  81 +++-
 .../apache/sis/filter/base/ConvertFunction.java    |   4 +-
 .../main/org/apache/sis/filter/math/Function.java  |   2 +
 .../apache/sis/filter/sqlmm/FunctionWithSRID.java  |   8 +-
 .../apache/sis/filter/sqlmm/SpatialFunction.java   |   8 +-
 .../internal/shared/FeatureProjectionTest.java     | 294 +++++++++++++++
 .../sis/storage/sql/feature/FeatureAdapter.java    |   2 +-
 .../sis/storage/sql/feature/FeatureStream.java     |   6 +-
 .../main/org/apache/sis/storage/FeatureQuery.java  |   2 +-
 .../org/apache/sis/util/resources/Vocabulary.java  |   5 +
 .../sis/util/resources/Vocabulary.properties       |   1 +
 .../sis/util/resources/Vocabulary_fr.properties    |   1 +
 23 files changed, 861 insertions(+), 232 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractAssociation.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractAssociation.java
index eec56f1b88..b80093c16b 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractAssociation.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractAssociation.java
@@ -190,7 +190,7 @@ public abstract class AbstractAssociation extends 
Field<Feature> implements Feat
     final void ensureValid(final FeatureType base, final FeatureType type) {
         if (base != type && !DefaultFeatureType.maybeAssignableFrom(base, 
type)) {
             throw new InvalidPropertyValueException(
-                    Resources.format(Resources.Keys.IllegalFeatureType_3, 
getName(), base.getName(), type.getName()));
+                    Resources.format(Resources.Keys.IllegalFeatureType_4, 0, 
getName(), base.getName(), type.getName()));
         }
     }
 
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractFeature.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractFeature.java
index 3effa90bea..a8a65ec244 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractFeature.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractFeature.java
@@ -780,7 +780,7 @@ public abstract class AbstractFeature implements Feature, 
Serializable {
     private static String illegalFeatureType(
             final FeatureAssociationRole association, final FeatureType 
expected, final FeatureType actual)
     {
-        return Resources.format(Resources.Keys.IllegalFeatureType_3,
+        return Resources.format(Resources.Keys.IllegalFeatureType_4, 0,
                                 association.getName(), expected.getName(), 
actual.getName());
     }
 
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractOperation.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractOperation.java
index 6c76e4c245..2fe724d6fe 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractOperation.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractOperation.java
@@ -155,7 +155,8 @@ public abstract class AbstractOperation extends 
AbstractIdentifiedType implement
     public abstract ParameterDescriptorGroup getParameters();
 
     /**
-     * Returns the expected result type, or {@code null} if none.
+     * Returns the expected result type.
+     * This type is normally not allowed to be null, but it may happen on 
occasion.
      *
      * @return the type of the result, or {@code null} if none.
      */
@@ -183,7 +184,7 @@ public abstract class AbstractOperation extends 
AbstractIdentifiedType implement
      * in the Java language, and may be {@code null} if the operation does not 
need a feature instance
      * (like static methods in the Java language).</div>
      *
-     * @param  feature     the feature on which to execute the operation.
+     * @param  instance    the feature instance on which to execute the 
operation.
      *                     Can be {@code null} if the operation does not need 
feature instance.
      * @param  parameters  the parameters to use for executing the operation.
      *                     Can be {@code null} if the operation does not take 
any parameters.
@@ -191,7 +192,7 @@ public abstract class AbstractOperation extends 
AbstractIdentifiedType implement
      * @throws FeatureOperationException if the operation execution cannot 
complete.
      */
     @Override
-    public abstract Property apply(Feature feature, ParameterValueGroup 
parameters) throws FeatureOperationException;
+    public abstract Property apply(Feature instance, ParameterValueGroup 
parameters) throws FeatureOperationException;
 
     /**
      * Returns the names of feature properties that this operation needs for 
performing its task.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/DefaultFeatureType.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/DefaultFeatureType.java
index 35fe577163..485144073c 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/DefaultFeatureType.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/DefaultFeatureType.java
@@ -934,7 +934,7 @@ public class DefaultFeatureType extends 
AbstractIdentifiedType implements Featur
             return true;
         }
         if (super.equals(obj)) {
-            final DefaultFeatureType that = (DefaultFeatureType) obj;
+            final var that = (DefaultFeatureType) obj;
             return isAbstract == that.isAbstract &&
                    superTypes.equals(that.superTypes) &&
                    properties.equals(that.properties);
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/FeatureTypeBuilder.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/FeatureTypeBuilder.java
index 97b0d7c760..bfd7549cc7 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/FeatureTypeBuilder.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/FeatureTypeBuilder.java
@@ -39,6 +39,7 @@ import org.apache.sis.geometry.wrapper.Geometries;
 import org.apache.sis.util.CorruptedObjectException;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.Numbers;
+import org.apache.sis.util.OptionalCandidate;
 import org.apache.sis.util.iso.DefaultNameFactory;
 import org.apache.sis.util.resources.Errors;
 
@@ -653,6 +654,7 @@ public class FeatureTypeBuilder extends TypeBuilder {
      *
      * @see #addProperty(PropertyType)
      */
+    @OptionalCandidate
     public PropertyTypeBuilder getProperty(final String name) {
         return forName(properties, name, true);
     }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
index 93914b7037..2b38525bf8 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
@@ -207,10 +207,10 @@ public class Resources extends IndexedResourceBundle {
         public static final short IllegalCharacteristicsType_3 = 25;
 
         /**
-         * Association “{0}” does not accept features of type ‘{2}’. Expected 
an instance of ‘{1}’ or
-         * derived type.
+         * The “{1}” {0,choice,0#association|1#operation} expects features of 
type ‘{2}’, but an
+         * instance of ‘{3}’ has been given.
          */
-        public static final short IllegalFeatureType_3 = 26;
+        public static final short IllegalFeatureType_4 = 26;
 
         /**
          * Illegal grid envelope [{1,number} … {2,number}] for dimension {0}.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
index f03d695a80..3fc96f2d07 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
@@ -49,7 +49,7 @@ GridEnvelopeMustBeNDimensional_1  = The grid envelope must 
have at least {0} dim
 GridExtentsAreDisjoint_5          = The specified grid extent is outside the 
domain. Indices [{3,number} \u2026 {4,number}] specified in dimension {0} do 
not intersect the [{1,number} \u2026 {2,number}] grid extent.
 IllegalCategoryRange_2            = Sample value range {1} for \u201c{0}\u201d 
category is illegal.
 IllegalCharacteristicsType_3      = Expected an instance of \u2018{1}\u2019 
for the \u201c{0}\u201d characteristics, but got an instance of \u2018{2}\u2019.
-IllegalFeatureType_3              = Association \u201c{0}\u201d does not 
accept features of type \u2018{2}\u2019. Expected an instance of 
\u2018{1}\u2019 or derived type.
+IllegalFeatureType_4              = The \u201c{1}\u201d 
{0,choice,0#association|1#operation} expects features of type \u2018{2}\u2019, 
but an instance of \u2018{3}\u2019 has been given.
 IllegalGridEnvelope_3             = Illegal grid envelope [{1,number} \u2026 
{2,number}] for dimension {0}.
 IllegalGridGeometryComponent_1    = Cannot create a grid geometry with the 
given \u201c{0}\u201d component.
 IllegalPropertyType_2             = Type or result of \u201c{0}\u201d property 
cannot be \u2018{1}\u2019 for this operation.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
index fa880920db..5610f695c7 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
@@ -54,7 +54,7 @@ GridEnvelopeMustBeNDimensional_1  = L\u2019enveloppe de la 
grille doit avoir au
 GridExtentsAreDisjoint_5          = L\u2019\u00e9tendue de grille 
sp\u00e9cifi\u00e9e est en dehors du domaine. Les indices [{3,number} \u2026 
{4,number}] qui ont \u00e9t\u00e9 sp\u00e9cifi\u00e9es dans la dimension {0} 
n\u2019interceptent pas l\u2019\u00e9tendue [{1,number} \u2026 {2,number}] de 
la grille.
 IllegalCategoryRange_2            = La plage de valeurs {1} pour la 
cat\u00e9gorie \u00ab\u202f{0}\u202f\u00bb est ill\u00e9gale.
 IllegalCharacteristicsType_3      = Une instance \u2018{1}\u2019 \u00e9tait 
attendue pour la caract\u00e9ristique \u00ab\u202f{0}\u202f\u00bb, mais la 
valeur donn\u00e9e est une instance de \u2018{2}\u2019.
-IllegalFeatureType_3              = L\u2019association 
\u00ab\u202f{0}\u202f\u00bb n\u2019accepte pas les entit\u00e9s de type 
\u2018{2}\u2019. Une instance de \u2018{1}\u2019 ou d\u2019un type 
d\u00e9riv\u00e9 \u00e9tait attendue.
+IllegalFeatureType_4              = 
L\u2019{0,choice,0#association|1#op\u00e9ration} \u00ab\u202f{1}\u202f\u00bb 
attend des entit\u00e9s de type \u2018{2}\u2019, mais une instance de 
\u2018{3}\u2019 a \u00e9t\u00e9 donn\u00e9e.
 IllegalGridEnvelope_3             = La plage d\u2019index [{1,number} \u2026 
{2,number}] de la dimension {0} n\u2019est pas valide.
 IllegalGridGeometryComponent_1    = Ne peut pas construire une 
g\u00e9om\u00e9trie de grille avec la composante \u00ab\u202f{0}\u202f\u00bb 
donn\u00e9e.
 IllegalPropertyType_2             = Le type ou le r\u00e9sultat de la 
propri\u00e9t\u00e9 \u00ab\u202f{0}\u202f\u00bb ne peut pas \u00eatre 
\u2018{1}\u2019 pour cette op\u00e9ration.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/shared/FeatureProjection.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/shared/FeatureProjection.java
index 5efe2dc376..deac884387 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/shared/FeatureProjection.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/shared/FeatureProjection.java
@@ -25,6 +25,7 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.function.BiFunction;
 import java.util.function.UnaryOperator;
+import org.opengis.util.GenericName;
 import org.apache.sis.util.Debug;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.resources.Vocabulary;
@@ -35,6 +36,7 @@ import org.apache.sis.io.TableAppender;
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
 import org.opengis.feature.Feature;
 import org.opengis.feature.FeatureType;
+import org.opengis.feature.Operation;
 import org.opengis.filter.Expression;
 import org.opengis.filter.Literal;
 import org.opengis.filter.ValueReference;
@@ -52,7 +54,15 @@ public final class FeatureProjection implements 
UnaryOperator<Feature> {
     /**
      * The type of features with the properties explicitly requested by the 
user.
      * The property names may differ from the properties of the {@link 
FeatureProjectionBuilder#source() source}
-     * features if aliases were specified by calls to {@link 
FeatureProjectionBuilder.Item#setName(GenericName)}.
+     * features if aliases were specified by {@link 
FeatureProjectionBuilder.Item#setPreferredName(GenericName)}.
+     *
+     * <h4>Relationship with {@code typeWithDependencies}</h4>
+     * The property <em>names</em> of {@code typeRequested} shall be a subset 
of the property names of
+     * {@link #typeWithDependencies}. However, a property of the same name may 
be an {@link Operation}
+     * in {@code typeWithDependencies} and replaced by {@link OperationView} 
in {@code typeRequested}.
+     * This replacement is done by {@link 
FeatureProjectionBuilder.Item#replaceIfMissingDependency()}.
+     * It is necessary because the {@link 
org.apache.sis.feature.DefaultFeatureType} constructor
+     * verifies that all dependencies of all operations exist.
      */
     public final FeatureType typeRequested;
 
@@ -60,6 +70,9 @@ public final class FeatureProjection implements 
UnaryOperator<Feature> {
      * The requested type augmented with dependencies required for the 
execution of operations such as links.
      * If there is no need for additional properties, then this value is the 
same as {@link #typeRequested}.
      * The property names are the same as {@link #typeRequested} (i.e., may be 
aliases).
+     * However, some operations may be wrapped in {@link OperationView}.
+     *
+     * @see #dependencies()
      */
     public final FeatureType typeWithDependencies;
 
@@ -98,21 +111,21 @@ public final class FeatureProjection implements 
UnaryOperator<Feature> {
         this.createInstance       = true;
         this.typeRequested        = typeRequested;
         this.typeWithDependencies = typeWithDependencies;
-        int storedCount = 0;
 
         // Expressions to apply on the source feature for fetching the 
property values of the projected feature.
         @SuppressWarnings({"LocalVariableHidesMemberVariable", "unchecked", 
"rawtypes"})
-        final Expression<? super Feature,?>[] expressions = new 
Expression[projection.size()];
+        final Expression<? super Feature, ?>[] expressions = new 
Expression[projection.size()];
 
         // Names of the properties to be stored in the attributes of the 
target features.
         @SuppressWarnings("LocalVariableHidesMemberVariable")
         final String[] propertiesToCopy = new String[expressions.length];
 
+        int storedCount = 0;
         for (final FeatureProjectionBuilder.Item item : projection) {
             final var expression = item.attributeValueGetter();
             if (expression != null) {
                 expressions[storedCount] = expression;
-                propertiesToCopy[storedCount++] = item.getName();
+                propertiesToCopy[storedCount++] = item.getPreferredName();
             }
         }
         this.propertiesToCopy = ArraysExt.resize(propertiesToCopy, 
storedCount);
@@ -132,7 +145,7 @@ public final class FeatureProjection implements 
UnaryOperator<Feature> {
      * @param  parent     the projection from which to inherit the types and 
expressions.
      * @param  remaining  index of the properties that still need to be copied 
after the caller did its processing.
      *
-     * @see #afterPreprocessing(int[])
+     * @see #forPreexistingFeatureInstances(int[])
      */
     @SuppressWarnings({"rawtypes", "unchecked"})
     private FeatureProjection(final FeatureProjection parent, final int[] 
remaining) {
@@ -157,7 +170,7 @@ public final class FeatureProjection implements 
UnaryOperator<Feature> {
      * @return a variant of this projection which only completes the 
projection done by the caller,
      *         or {@code null} if there is nothing left to complete.
      */
-    public FeatureProjection afterPreprocessing(final int[] remaining) {
+    public FeatureProjection forPreexistingFeatureInstances(final int[] 
remaining) {
         if (remaining.length == 0 && typeRequested == typeWithDependencies) {
             return null;
         }
@@ -166,6 +179,12 @@ public final class FeatureProjection implements 
UnaryOperator<Feature> {
 
     /**
      * Creates a new projection with the same properties as the source 
projection, but modified expressions.
+     * The modifications are specified by the given {@code mapper}. No 
expression can be added or removed.
+     * New expressions should return values of the same type as the previous 
expressions.
+     * The new expressions shall not introduce new dependencies.
+     *
+     * <h4>Purpose</h4>
+     * This constructor is used when the caller can replace some expressions 
by <abbr>SQL</abbr> statements.
      *
      * @param source  the projection to copy.
      * @param mapper  a function receiving in arguments the property name and 
the expression fetching the property value,
@@ -241,7 +260,11 @@ public final class FeatureProjection implements 
UnaryOperator<Feature> {
     /**
      * Returns all dependencies used, directly or indirectly, by all 
expressions used in this projection.
      * The set includes transitive dependencies (expressions with operands 
that are other expressions).
-     * The elements are XPaths.
+     * The elements are XPaths to properties in the <em>source</em> features.
+     *
+     * <p>This method does not search for operations in {@link 
#typeWithDependencies} because the operation
+     * dependencies are references to {@link #typeWithDependencies} properties 
instead of properties of the
+     * source features. The property names may differ.</p>
      *
      * @return all dependencies (including transitive dependencies) as XPaths.
      */
@@ -255,13 +278,14 @@ public final class FeatureProjection implements 
UnaryOperator<Feature> {
 
     /**
      * Derives a new projected feature instance from the given source.
-     * The feature type of the returned feature instance will be be {@link 
#typeRequested}.
+     * The given source feature should be of type {@link 
#typeWithDependencies}.
+     * The type of the returned feature instance will be {@link 
#typeRequested}.
      * This method performs the following steps:
      *
      * <ol class="verbose">
-     *   <li>If this projection was created by {@link 
#afterPreprocessing(int[])}, then the given feature
-     *     shall be an instances of {@link #typeWithDependencies} and may be 
modified in-place. Otherwise,
-     *     this method creates a new instance of {@link 
#typeWithDependencies}.</li>
+     *   <li>If this projection was created by {@link 
#forPreexistingFeatureInstances(int[])},
+     *     then the given feature shall be an instance of {@link 
#typeWithDependencies} and may be modified.
+     *     Otherwise, this method creates a new instance of {@link 
#typeWithDependencies}.</li>
      *   <li>This method executes all expressions for fetching values from 
{@code source}
      *     and stores the results in the feature instance of above step.</li>
      *   <li>If {@link #typeWithDependencies} is different than {@link 
#typeRequested}, then the feature
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/shared/FeatureProjectionBuilder.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/shared/FeatureProjectionBuilder.java
index c1e57566b3..17ca838849 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/shared/FeatureProjectionBuilder.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/shared/FeatureProjectionBuilder.java
@@ -67,6 +67,8 @@ import org.opengis.filter.ValueReference;
  * but they will receive no special treatment.</p>
  *
  * @author  Martin Desruisseaux (Geomatys)
+ *
+ * @see #project()
  */
 public final class FeatureProjectionBuilder extends FeatureTypeBuilder {
     /**
@@ -107,8 +109,9 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
      * in automatically generated names.
      *
      * <p>Note that the keys are not necessarily the values of {@link 
Item#sourceName}.
-     * Keys are rather the values of {@code Item.builder.getName()}, except 
that the
-     * latter may not be valid before {@link Item#validateName()} is 
invoked.</p>
+     * Keys are rather the values of {@code Item.builder.getPreferredName()},
+     * except that the latter may not be valid before {@link 
Item#finish(Optimization)}
+     * has been invoked.</p>
      *
      * @see #reserve(GenericName, Item)
      */
@@ -117,12 +120,10 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
     /**
      * Whether at least one item is modified compared to the original property 
in the source feature type.
      * A modified item may be an item with a name different than the property 
in {@linkplain #source}.
-     * If {@code true}, then the projection cannot be an {@linkplain 
#isIdentity() identity} operation.
      * Result of operations such as links may also need to be fetched in 
advance,
      * because operations cannot be executed anymore after the name of a 
dependency changed.
      *
-     * @see Item#setName(GenericName)
-     * @see #isIdentity()
+     * @see Item#setPreferredName(GenericName)
      */
     private boolean hasModifiedProperties;
 
@@ -133,7 +134,7 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
     private int unnamedNumber;
 
     /**
-     * Names of {@linkplain #source} properties that are dependencies found in 
operations.
+     * Names of {@linkplain #source} properties that are dependencies 
(arguments) of feature operations.
      * The most common cases are the targets of {@code "sis:identifier"} and 
{@code "sis:geometry"} links.
      * Values are the items having this dependency. Many items may share the 
same dependency.
      *
@@ -144,15 +145,17 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
     private final Map<String, List<Item>> dependencies;
 
     /**
-     * Whether to store operations as attributes. By default, when a 
{@linkplain #addSourceProperty source
-     * property is added in the projection}, operation are forwarded as given 
(with their dependencies).
-     * But if this flag is set to {@code true}, then operations are replaced 
by an attribute.
-     * Then, it will be caller's responsibility to store the value.
+     * Whether to store operations as attributes. By default, when {@linkplain 
#addSourceProperty source
+     * properties are added in the projection}, operations are forwarded as 
given (with their dependencies).
+     * But if this flag is set to {@code true}, then operations are replaced 
by attributes.
+     * In such case, it will be caller's responsibility to store the value.
      */
     private boolean operationResultAsAttribute;
 
     /**
      * Creates a new builder instance using the default factories.
+     * This constructor does <em>not</em> inherits the name of the given 
feature type.
+     * Callers need to invoke a {@code setName(…)} method after construction.
      *
      * @todo provides a way to specify the factories used by the data store.
      *
@@ -228,8 +231,8 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
      * Adds the given property, replacing operation by an attribute storing 
the operation result.
      * This method may return {@code null} if it cannot resolve the property 
type, in which case
      * the caller should throw an exception (throwing an exception is left to 
the caller because
-     * it can produces a better error message). Operation's dependencies, if 
any, are added into
-     * the given {@code deferred} set.
+     * it can produce a better error message). Operation's dependencies, if 
any, are added into
+     * the given {@code deferred} collection.
      *
      * @param  property  the {@linkplain #source} property to add.
      * @param  deferred  where to add operation's dependencies, or {@code 
null} for not collecting dependencies.
@@ -271,12 +274,9 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
      *
      * @param  property  the property type, usually as one of the properties 
of {@link #source()}.
      * @param  named     whether the {@code property} name can be used as a 
default name.
-     * @return handler for the given item, or {@code null} if the given 
property cannot be resolved.
+     * @return handler for the given item (never {@code null}).
      */
     public Item addSourceProperty(final PropertyType property, final boolean 
named) {
-        if (property == null) {
-            return null;
-        }
         final PropertyTypeBuilder builder;
         final Collection<String> deferred;
         if (sourceIsDependency) {
@@ -302,11 +302,22 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
             builder = addPropertyResult(property, deferred);
         }
         final var item = new Item(named ? property.getName() : null, builder);
+        declareDependencies(item, deferred);
         requested.add(item);
+        return item;
+    }
+
+    /**
+     * Declares that the {@code deferred} elements are dependencies of the 
given item.
+     * This information will be used by {@link #resolveDependencies(List)}.
+     *
+     * @param item      the item having dependencies.
+     * @param deferred  the item dependencies.
+     */
+    private void declareDependencies(final Item item, final Collection<String> 
deferred) {
         for (String dependency : deferred) {
             dependencies.computeIfAbsent(dependency, (key) -> new 
ArrayList<>(2)).add(item);
         }
-        return item;
     }
 
     /**
@@ -319,15 +330,12 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
      * It also verifies that no {@link Item} have been created for that 
builder yet.
      * For performance reasons, those verifications are performed only if 
assertions are enabled.
      *
-     * @param  builder  builder for the computed property, or {@code null}.
+     * @param  builder  builder for the computed property.
      * @param  named    whether the {@code builder} name can be used as a 
default name.
-     * @return handler for the given item, or {@code null} if the given 
builder was null.
+     * @return handler for the given item (should never be {@code null}).
      */
     @ArgumentCheckByAssertion
     public Item addComputedProperty(final PropertyTypeBuilder builder, final 
boolean named) {
-        if (builder == null) {
-            return null;
-        }
         assert properties().contains(builder) : builder;
         assert requested.stream().noneMatch((item) -> item.builder == builder) 
: builder;
         final var item = new Item(named ? builder.getName() : null, builder);
@@ -335,9 +343,58 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
         return item;
     }
 
+    /**
+     * Adds dependencies. This method adds into the {@code deferred} list any 
transitive
+     * dependencies which may need to be added in a second pass after this 
method call.
+     * The elements added into {@code deferred} are {@linkplain #source} 
properties.
+     *
+     * @param  deferred  where to add missing transitive dependencies (source 
properties).
+     * @return whether there is no dependency to resolve (i.e., all 
dependencies are already resolved).
+     * @throws UnsupportedOperationException if there is an attempt to rename 
a property which is used by an operation.
+     */
+    private boolean resolveDependencies(final List<PropertyType> deferred) {
+        final Iterator<Map.Entry<String, List<Item>>> it = 
dependencies.entrySet().iterator();
+        while (it.hasNext()) {
+            final Map.Entry<String, List<Item>> entry = it.next();
+            final PropertyType property = source.getProperty(entry.getKey());
+            final GenericName sourceName = property.getName();
+            final Item item = reservedNames.get(sourceName);
+            if (item != null) {
+                if (!sourceName.equals(item.sourceName)) {
+                    throw new 
UnsupportedOperationException(Resources.forLocale(getLocale())
+                            
.getString(Resources.Keys.CannotRenameDependency_2, item.sourceName, 
sourceName));
+                }
+            } else {
+                for (Item dependent : entry.getValue()) {
+                    dependent.hasMissingDependency = true;
+                }
+                deferred.add(property);
+            }
+            it.remove();
+        }
+        return deferred.isEmpty();
+    }
+
+    /**
+     * Declares that the given name is reserved. If this class needs to 
generate a default name,
+     * it will ensure that any automatically generated name do not conflict 
with reserved names.
+     *
+     * @param  name   name to reserve for a projected property type, or {@code 
null} if none.
+     * @param  owner  the builder using that name, or {@code null} if none.
+     */
+    private void reserve(GenericName name, final Item owner) {
+        if (name != null) {
+            // By `putIfAbsent` method contract, non-null values have 
precedence over null values.
+            reservedNames.putIfAbsent(name, owner);
+            if (name != (name = name.tip())) {              // Shortcut for a 
majority of cases.
+                reservedNames.putIfAbsent(name, owner);
+            }
+        }
+    }
+
     /**
      * Handler for a property inherited from the source feature type. The 
property is initially unnamed.
-     * A name can be specified explicitly after construction by a call to 
{@link #setName(GenericName)}.
+     * A name can be specified explicitly after construction by {@link 
#setPreferredName(GenericName)}.
      * If no name is specified, the default name will be the same as in the 
source feature type if that
      * name is available, or a default name otherwise.
      */
@@ -385,6 +442,15 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
          */
         private Expression<? super Feature, ?> attributeValueGetter;
 
+        /**
+         * Whether the {@link #attributeValueGetter} should be executed only 
when the property value is requested.
+         * If {@code false} (the default), the value is computed from the 
source feature exactly once, when the
+         * {@link FeatureProjection#apply(Feature)} method is invoked, and the 
result is stored as an attribute.
+         * If {@code true}, then the expression is wrapped in an {@link 
AbstractOperation} and evaluated when
+         * the property value is requested.
+         */
+        private boolean isDeferredValueGetter;
+
         /**
          * Creates a new handle for the property created by the given builder.
          *
@@ -403,9 +469,10 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
         public String toString() {
             return Strings.toString(getClass(),
                     "sourceName", (sourceName != null) ? sourceName.toString() 
: null,
-                    "targetName", isNamed ? getName() : null,
-                    "valueClass", (builder instanceof AttributeTypeBuilder<?>) 
? ((AttributeTypeBuilder<?>) builder).getValueClass() : null,
-                    null, hasMissingDependency ? "hasMissingDependency" : 
null);
+                    "targetName", isNamed ? getPreferredName() : null,
+                    "valueClass", getValueClass(),
+                    null, hasMissingDependency  ? "hasMissingDependency"  : 
null,
+                    null, isDeferredValueGetter ? "isDeferredValueGetter" : 
null);
         }
 
         /**
@@ -421,7 +488,7 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
          * Use the dedicated methods in this class instead:
          *
          * <ul>
-         *   <li>Set the name: use {@link #setName(GenericName)}.</li>
+         *   <li>Set the name: use {@link #setPreferredName(GenericName)}.</li>
          *   <li>Set the value class: use {@link 
#replaceValueClass(UnaryOperator)}.</li>
          * </ul>
          *
@@ -433,18 +500,20 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
         }
 
         /**
-         * Replaces this property by a stored attribute if at least one 
dependency is not in the list of properties
-         * requested by the user. This method should be invoked only for 
preparing the user requested feature type.
-         * This method should not be invoked for preparing the feature type 
with dependencies, because the latter
-         * should contain the missing dependencies.
+         * Replaces this property by a view that will not block the creation 
of {@code DefaultFeatureType}
+         * when a dependency is missing. This method should be invoked only 
for creating the feature type
+         * to be shown to users through {@link FeatureView}.
          */
         private void replaceIfMissingDependency() {
             if (hasMissingDependency) {
                 hasMissingDependency = false;
                 hasModifiedProperties = true;
-                final var old = builder;
-                builder = addPropertyResult(old.build(), null);   // 
`old.build()` returns the existing operation.
-                old.replaceBy(builder);
+                final PropertyTypeBuilder old = builder;
+                final PropertyType property = old.build();  // `old.build()` 
returns the existing operation.
+                if (property instanceof AbstractOperation) {
+                    builder = addProperty(new 
OperationView((AbstractOperation) property, getName()));
+                    old.replaceBy(builder);
+                }
             }
         }
 
@@ -454,16 +523,21 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
          * class of attribute values), then this method sets the new attribute 
value class to the specified
          * type and returns {@code true}. Otherwise, this method returns 
{@code false}.
          *
+         * <p>Note that when {@code type.apply(valueClass)} is invoked, 
returning {@code null} is not the same
+         * as returning the {@code valueClass} unchanged, even if this {@code 
Item} is unchanged in both cases.
+         * This method returns {@code false} in the former case while it 
returns {@code true} if the latter case.</p>
+         *
          * @param  type  a converter from current class to the new class of 
attribute values.
-         * @return whether the value class has been set to the value returned 
by {@code type}.
+         * @return whether the value returned by {@code type} has been 
accepted (not necessarily causing a change of state).
          * @throws UnconvertibleObjectException if the default value cannot be 
converted to the given type.
          */
         public boolean replaceValueClass(final UnaryOperator<Class<?>> type) {
             if (builder instanceof AttributeTypeBuilder<?>) {
                 final var ab = (AttributeTypeBuilder<?>) builder;
-                final Class<?> r = type.apply(ab.getValueClass());
-                if (r != null) {
-                    if (builder != (builder = ab.setValueClass(r))) {
+                final Class<?> oldType = ab.getValueClass();
+                final Class<?> newType = type.apply(oldType);
+                if (newType != null) {
+                    if (builder != (builder = ab.setValueClass(newType))) {
                         hasModifiedProperties = true;
                     }
                     return true;
@@ -480,18 +554,18 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
                      */
                     final var result = ((Operation) property).getResult();
                     if (result instanceof AttributeType<?>) {
-                        final Class<?> c = ((AttributeType<?>) 
result).getValueClass();
-                        final Class<?> r = type.apply(c);
-                        if (r != null) {
+                        final Class<?> oldType = ((AttributeType<?>) 
result).getValueClass();
+                        final Class<?> newType = type.apply(oldType);
+                        if (newType != null) {
                             /*
                              * We can be lenient for link operation, but must 
be strict for other operations.
                              * Example: a link to a geometry, but relaxing the 
`Polygon` type to `Geometry`.
                              */
-                            if (Features.getLinkTarget(property).isPresent() ? 
r.isAssignableFrom(c) : r.equals(c)) {
+                            if (Features.getLinkTarget(property).isPresent() ? 
newType.isAssignableFrom(oldType) : newType.equals(oldType)) {
                                 return true;
                             }
                             throw new 
UnconvertibleObjectException(Errors.forLocale(getLocale())
-                                        
.getString(Errors.Keys.CanNotConvertFromType_2, c, r));
+                                        
.getString(Errors.Keys.CanNotConvertFromType_2, oldType, newType));
                         }
                     }
                 }
@@ -499,47 +573,30 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
             return false;
         }
 
+        /**
+         * Returns the class of attribute values currently configured in the 
builder, or {@code null} if none.
+         */
+        private Class<?> getValueClass() {
+            return (builder instanceof AttributeTypeBuilder<?>) ? 
((AttributeTypeBuilder<?>) builder).getValueClass() : null;
+        }
+
         /**
          * Sets the expression to use for evaluating the property value.
          * If {@code stored} is {@code true} (the usual case), then the 
expression will be evaluated early
          * and its result will be stored as an attribute value, unless this 
property is not an attribute.
-         * If {@code stored} is {@code false}, this method replaces the 
attribute by an operation wrapping
-         * the given expression. In other words, the evaluation of the 
expression will be deferred.
-         * The latter case is possible only if the {@code FeatureType} 
contains all dependencies
-         * that the operation needs.
+         * If {@code stored} is {@code false}, this expression will be wrapped 
in an operation which will
+         * be executed when the property value is requested. Note that in the 
latter case, this builder
+         * may have to retain more dependencies than what the user specified.
          *
          * @param  expression  the expression to be evaluated by the operation.
+         * @param  stored      {@code true} for evaluating the expression 
immediately after feature instances
+         *                     are known, or {@code false} for wrapping the 
expression in a feature operation.
          */
         public void setValueGetter(final Expression<? super Feature, ?> 
expression, final boolean stored) {
+            // Modify only direct value references, not link (or other 
operations).
             if (builder instanceof AttributeTypeBuilder<?>) {
-                if (stored) {
-                    attributeValueGetter = expression;
-                } else {
-                    final var atb = (AttributeTypeBuilder<?>) builder;
-                    /*
-                     * Optimization: we could compute `storedType = 
atb.build()` unconditionally,
-                     * which creates an attribute with the final name in the 
target feature type.
-                     * However, in the particular case of links, we are better 
to use the name of
-                     * the property in the source feature type, because it 
allows an optimization
-                     * in `ExpressionOperation.create(…)` (a replacement by a 
`LinkOperation`).
-                     */
-                    AttributeType<?> storedType = null;
-                    if (expression instanceof ValueReference<?,?>) {
-                        var candidate = 
source.getProperty(((ValueReference<?,?>) expression).getXPath());
-                        if (candidate instanceof AttributeType<?>) {
-                            storedType = (AttributeType<?>) candidate;
-                        }
-                    }
-                    if (storedType == null) {
-                        storedType = atb.build();   // Same name as in the 
`identification` map below.
-                    }
-                    final var identification = 
Map.of(AbstractOperation.NAME_KEY, builder.getName());
-                    builder = 
addProperty(FeatureOperations.expression(identification, expression, 
storedType));
-                    atb.replaceBy(builder);
-                    hasModifiedProperties = true;
-                }
-            } else {
-                // The property is an operation, usually a link. Leave it 
as-is.
+                attributeValueGetter = expression;
+                isDeferredValueGetter = !stored;
             }
         }
 
@@ -552,13 +609,6 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
             return attributeValueGetter;
         }
 
-        /**
-         * Optimizes the expression. This is invoked as the last step before 
to build the final feature projection.
-         */
-        final void optimize(final Optimization optimizer) {
-            attributeValueGetter = optimizer.apply(attributeValueGetter);
-        }
-
         /**
          * Sets the coordinate reference system that characterizes the values 
of this attribute.
          *
@@ -581,8 +631,6 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
          *
          * @param  property  the property to compare.
          * @return whether this item builds a property equivalent to the given 
one.
-         *
-         * @see #isIdentity()
          */
         private boolean equivalent(final PropertyType property) {
             return builder.getName().equals(property.getName());
@@ -591,11 +639,11 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
         /**
          * Returns the name of the projected property.
          * This is initially the name of the property given at construction 
time,
-         * but can be changed later by a call to {@link #setName(GenericName)}.
+         * but can be changed later by a call to {@link 
#setPreferredName(GenericName)}.
          *
          * @return the name of the projected property.
          */
-        public String getName() {
+        public String getPreferredName() {
             return builder.getName().toString();
         }
 
@@ -608,7 +656,7 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
          *
          * @param  targetName  the desired name in the projected feature, or 
{@code null} if unspecified.
          */
-        public void setName(final GenericName targetName) {
+        public void setPreferredName(final GenericName targetName) {
             if (targetName == null) {
                 reserve(sourceName, null);      // Will use that name only if 
not owned by another item.
                 preferCurrentName = true;
@@ -624,12 +672,19 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
         }
 
         /**
+         * Completes and optimizes this item before construction of the final 
{@link FeatureProjection}.
+         *
+         * <h4>Naming</h4>
          * If this item has not received an explicit name, infers a default 
name.
-         * This method should be invoked only after {@link 
#setName(GenericName)}
-         * has been invoked for all items, for allowing this class to know 
which
-         * names are reserved.
+         * This method should be invoked only after {@link 
#setPreferredName(GenericName)}
+         * has been invoked for all items, for allowing this class to know 
which names are reserved.
+         *
+         * <h4>Optimization</h4>
+         * This method ensures that the {@link #attributeValueGetter} 
expression produces a value of the type
+         * expected by {@link FeatureProjection#typeWithDependencies}, adding 
a conversion step if needed.
+         * Then this method simplifies the expression, which may remove the 
conversion added just before.
          */
-        private void validateName() {
+        final void finish(final Optimization optimizer) {
             if (!isNamed) {
                 final Item owner = reservedNames.get(sourceName);
                 if (owner != this) {
@@ -644,83 +699,46 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
                 }
                 isNamed = true;
             }
-        }
-    }
-
-    /**
-     * Declares the given name as reserved. If this class needs to generate a 
default name,
-     * it will ensure that automatically generated names do not conflict with 
reserved names.
-     *
-     * @param  name   name to reserve for a projected property type, or {@code 
null} if none.
-     * @param  owner  the builder using that name, or {@code null} if none.
-     */
-    private void reserve(GenericName name, final Item owner) {
-        if (name != null) {
-            // By `putIfAbsent` method contract, non-null values have 
precedence over null values.
-            reservedNames.putIfAbsent(name, owner);
-            if (name != (name = name.tip())) {              // Shortcut for a 
majority of cases.
-                reservedNames.putIfAbsent(name, owner);
-            }
-        }
-    }
-
-    /**
-     * Adds dependencies. This method adds in the {@code deferred} list any 
transitive
-     * dependencies which may need to be added in a second pass after this 
method call.
-     * The elements added into {@code deferred} are {@linkplain #source} 
properties.
-     *
-     * @param  deferred  where to add missing transitive dependencies (source 
properties).
-     * @throws UnsupportedOperationException if there is an attempt to rename 
a property which is used by an operation.
-     */
-    private void resolveDependencies(final List<PropertyType> deferred) {
-        final var it = dependencies.entrySet().iterator();
-        while (it.hasNext()) {
-            final Map.Entry<String, List<Item>> entry = it.next();
-            final PropertyType property = source.getProperty(entry.getKey());
-            final GenericName sourceName = property.getName();
-            Item item = reservedNames.get(sourceName);
-            if (item != null) {
-                if (!sourceName.equals(item.sourceName)) {
-                    throw new 
UnsupportedOperationException(Resources.forLocale(getLocale())
-                            
.getString(Resources.Keys.CannotRenameDependency_2, item.sourceName, 
sourceName));
+            if (attributeValueGetter != null) {
+                final Class<?> valueClass = getValueClass();
+                if (valueClass != null) {
+                    attributeValueGetter = 
attributeValueGetter.toValueType(valueClass);
                 }
-            } else {
-                for (Item dependent : entry.getValue()) {
-                    dependent.hasMissingDependency = true;
+                attributeValueGetter = optimizer.apply(attributeValueGetter);
+                if (isDeferredValueGetter) {
+                    // Following cast should never fail because it was 
verified in `setValueGetter(…)`.
+                    final var atb = (AttributeTypeBuilder<?>) builder;
+                    /*
+                     * Optimization: we could compute `storedType = 
atb.build()` unconditionally,
+                     * which creates an attribute with the final name in the 
target feature type.
+                     * However, in the particular case of links, we are better 
to use the name of
+                     * the property in the source feature type, because it 
allows an optimization
+                     * (a replacement by a `LinkOperation`) in 
`ExpressionOperation.create(…)`.
+                     */
+                    AttributeType<?> storedType = null;
+                    if (attributeValueGetter instanceof ValueReference<?,?>) {
+                        var candidate = 
source.getProperty(((ValueReference<?,?>) attributeValueGetter).getXPath());
+                        if (candidate instanceof AttributeType<?>) {
+                            storedType = (AttributeType<?>) candidate;
+                        }
+                    }
+                    if (storedType == null) {
+                        storedType = atb.build();   // Same name as in the 
`identification` map below.
+                    }
+                    final Map<String, ?> identification = 
Map.of(AbstractOperation.NAME_KEY, atb.getName());
+                    final var operation = 
FeatureOperations.expression(identification, attributeValueGetter, storedType);
+                    if (operation instanceof AbstractOperation) {   // Should 
always be the case, but be safe.
+                        declareDependencies(this, ((AbstractOperation) 
operation).getDependencies());
+                        builder = addProperty(operation);
+                        atb.replaceBy(builder);
+                        hasModifiedProperties = true;
+                        attributeValueGetter = null;
+                    }
                 }
-                deferred.add(property);
             }
-            it.remove();
         }
     }
 
-    /**
-     * Returns {@code true} if the feature to be built should be equivalent to 
the source feature.
-     *
-     * @return whether the {@linkplain #source} feature type can be used 
directly.
-     */
-    private boolean isIdentity() {
-        if (hasModifiedProperties) {
-            return false;
-        }
-        final Iterator<Item> it = requested.iterator();
-        for (PropertyType property : source.getProperties(true)) {
-            if (!(it.hasNext() && it.next().equivalent(property))) {
-                return false;
-            }
-        }
-        return !it.hasNext();
-    }
-
-    /**
-     * Returns the feature type described by this builder. This method may 
return the
-     * {@linkplain #source() source} directly if this projection performs no 
operation.
-     */
-    @Override
-    public FeatureType build() {
-        return isIdentity() ? source : super.build();
-    }
-
     /**
      * Sets the default name of all anonymous properties, then builds the 
feature types.
      * Two feature types are built: one with only the requested properties, 
and another
@@ -738,45 +756,65 @@ public final class FeatureProjectionBuilder extends 
FeatureTypeBuilder {
      * @throws UnsupportedOperationException if there is an attempt to rename 
a property which is used by an operation.
      */
     public Optional<FeatureProjection> project() {
-        requested.forEach(Item::validateName);
+        final var optimizer = new Optimization();
+        optimizer.setFeatureType(source);
+        requested.forEach((item) -> item.finish(optimizer));
         /*
          * Add properties for all dependencies that are required by operations 
but are not already present.
-         * If there is no need to add anything, `typeWithDependencies` will be 
directly the feature type to return.
+         * If there is no need to add anything, `typeWithDependencies` will be 
directly `typeRequested`.
          */
+        final FeatureType typeRequested, typeWithDependencies;
         final List<PropertyTypeBuilder> properties = properties();
         final int count = properties.size();
         final var deferred = new ArrayList<PropertyType>();
-        resolveDependencies(deferred);
-        /*
-         * If there is no dependencies, the requested type and the type with 
dependencies are the same.
-         * Otherwise, we need to resolve transitive dependencies before to 
build each type.
-         */
-        final FeatureType typeRequested, typeWithDependencies;
-        if (deferred.isEmpty()) {
+        while (!resolveDependencies(deferred)) {
+            for (PropertyType property : deferred) {
+                final Item item = addSourceProperty(property, true);
+                if (item != null) {
+                    
item.setValueGetter(FeatureOperations.expressionOf(property), true);
+                    item.finish(optimizer);
+                }
+            }
+            deferred.clear();
+        }
+        final List<PropertyTypeBuilder> added = properties.subList(count, 
properties.size());
+        if (added.isEmpty()) {
             typeRequested = typeWithDependencies = build();
         } else {
-            do {
-                for (PropertyType property : deferred) {
-                    final Item item = addSourceProperty(property, true);
-                    if (item != null) {
-                        item.validateName();
-                        
item.setValueGetter(FeatureOperations.expressionOf(property), true);
-                    }
-                }
-                deferred.clear();
-                resolveDependencies(deferred);
-            } while (!deferred.isEmpty());
-            typeWithDependencies = build();
-            properties.subList(count, properties.size()).clear();     // Keep 
only the properties requested by user.
+            final GenericName name = getName();
+            typeWithDependencies = setName(
+                    Vocabulary.formatInternational(
+                            Vocabulary.Keys.PlusDependencies_1,
+                            name.tip().toInternationalString()))
+                    .build();
+
+            added.clear();     // Keep only the properties requested by user.
             requested.forEach(Item::replaceIfMissingDependency);
-            typeRequested = build();
+            typeRequested = setName(name).build();
         }
-        if (source.equals(typeRequested) && 
source.equals(typeWithDependencies)) {
+        if (typeRequested.equals(source)) {
             return Optional.empty();
         }
-        final var optimizer = new Optimization();
-        optimizer.setFeatureType(source);
-        requested.forEach((item) -> item.optimize(optimizer));
         return Optional.of(new FeatureProjection(typeRequested, 
typeWithDependencies, requested));
     }
+
+    /**
+     * Returns the feature type described by this builder. This method may 
return the
+     * {@linkplain #source() source} directly if this projection performs no 
operation.
+     *
+     * <p>Users should not invoke this method directly. Use {@link #project()} 
instead.</p>
+     */
+    @Override
+    public FeatureType build() {
+        if (hasModifiedProperties) {
+            return super.build();
+        }
+        final Iterator<Item> it = requested.iterator();
+        for (PropertyType property : source.getProperties(true)) {
+            if (!(it.hasNext() && it.next().equivalent(property))) {
+                return super.build();
+            }
+        }
+        return it.hasNext() ? super.build() : source;
+    }
 }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/shared/OperationView.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/shared/OperationView.java
new file mode 100644
index 0000000000..ddbeebe36b
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/shared/OperationView.java
@@ -0,0 +1,192 @@
+/*
+ * 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.
+ */
+package org.apache.sis.feature.internal.shared;
+
+import java.util.Optional;
+import java.io.Serializable;
+import org.opengis.util.GenericName;
+import org.opengis.util.InternationalString;
+import org.opengis.parameter.ParameterValueGroup;
+import org.opengis.parameter.ParameterDescriptorGroup;
+import org.apache.sis.feature.AbstractOperation;
+import org.apache.sis.feature.DefaultFeatureType;
+import org.apache.sis.feature.internal.Resources;
+import org.apache.sis.util.Deprecable;
+
+// Specific to the geoapi-3.1 and geoapi-4.0 branches:
+import org.opengis.feature.Feature;
+import org.opengis.feature.FeatureOperationException;
+import org.opengis.feature.IdentifiedType;
+import org.opengis.feature.Operation;
+import org.opengis.feature.Property;
+import org.opengis.feature.PropertyNotFoundException;
+
+
+/**
+ * An operation wrapper for hiding its dependencies. Because Apache 
<abbr>SIS</abbr>
+ * fetches operation dependencies only from instances of {@link 
AbstractOperation},
+ * we only need a wrapper that do <em>not</em> extend {@code 
AbstractOperation}.
+ * This class should be used only together with {@link FeatureView}.
+ *
+ * <h2>Purpose</h2>
+ * The {@link DefaultFeatureType} constructor verifies that all dependencies 
of all operations exist.
+ * This verification can block us from constructing the {@link 
FeatureView#source} type if the view
+ * does not include all dependencies needed by an operation. By wrapping the 
operation, we prevent
+ * {@link DefaultFeatureType} from doing this verification.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+final class OperationView implements Operation, Deprecable, Serializable {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = -4679426189917900959L;
+
+    /**
+     * The instance doing the actual operation.
+     */
+    private final AbstractOperation source;
+
+    /**
+     * The name of feature type expected by the operation.
+     */
+    @SuppressWarnings("serial")     // Most Apache SIS implementations are 
serializable.
+    private final GenericName valueType;
+
+    /**
+     * Creates a new operation wrapper.
+     *
+     * @param  valueType  the name of feature type expected by the operation.
+     * @param  source     the operation instance to make opaque.
+     */
+    OperationView(final AbstractOperation source, final GenericName valueType) 
{
+        this.source = source;
+        this.valueType = valueType;
+    }
+
+    /**
+     * Returns the name of the wrapped operation.
+     */
+    @Override
+    public GenericName getName() {
+        return source.getName();
+    }
+
+    /**
+     * Returns a concise definition of the operation.
+     */
+    @Override
+    public InternationalString getDefinition() {
+        return source.getDefinition();
+    }
+
+    /**
+     * Returns a natural language designator for the operation.
+     * This can be used as an alternative to the {@linkplain #getName() name} 
in user interfaces.
+     */
+    @Override
+    public Optional<InternationalString> getDesignation() {
+        return source.getDesignation();
+    }
+
+    /**
+     * Returns optional information beyond that required for concise 
definition of the element.
+     * The description may assist in understanding the element scope and 
application.
+     */
+    @Override
+    public Optional<InternationalString> getDescription() {
+        return source.getDescription();
+    }
+
+    /**
+     * If this instance is deprecated, the reason or the alternative to use.
+     */
+    @Override
+    public Optional<InternationalString> getRemarks() {
+        return source.getRemarks();
+    }
+
+    /**
+     * Returns {@code true} if this instance is deprecated.
+     */
+    @Override
+    public boolean isDeprecated() {
+        return source.isDeprecated();
+    }
+
+    /**
+     * Returns a description of the input parameters.
+     */
+    @Override
+    public ParameterDescriptorGroup getParameters() {
+        return source.getParameters();
+    }
+
+    /**
+     * Returns the expected result type.
+     */
+    @Override
+    public IdentifiedType getResult() {
+        return source.getResult();
+    }
+
+    /**
+     * Executes the operation on the specified feature with the specified 
parameters.
+     * This method is not strictly compliant with the contract of the public 
interface,
+     * because it requires that the given feature is an instance of {@link 
#valueType}.
+     * If not the case, this method tries to produce a more helpful exception 
message.
+     *
+     * @param  instance    the feature instance on which to execute the 
operation.
+     *                     Can be {@code null} if the operation does not need 
feature instance.
+     * @param  parameters  the parameters to use for executing the operation.
+     *                     Can be {@code null} if the operation does not take 
any parameters.
+     * @return the operation result.
+     */
+    @Override
+    public Property apply(final Feature instance, final ParameterValueGroup 
parameters) {
+        try {
+            return source.apply(instance, parameters);
+        } catch (PropertyNotFoundException e) {
+            throw new FeatureOperationException(Resources.format(
+                    Resources.Keys.IllegalFeatureType_4, 1, getName(), 
valueType, instance.getType().getName()), e);
+        }
+    }
+
+    /**
+     * Returns a string representation of this operation.
+     */
+    @Override
+    public String toString() {
+        return source.toString();
+    }
+
+    /**
+     * Returns a hash code value for this operation.
+     */
+    @Override
+    public int hashCode() {
+        return source.hashCode() * 37;
+    }
+
+    /**
+     * Compares this operation with the given object for equality.
+     */
+    @Override
+    public boolean equals(final Object obj) {
+        return (obj instanceof OperationView) && 
source.equals(((OperationView) obj).source);
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/PropertyValue.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/PropertyValue.java
index c4d56289c6..f3a521ddd1 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/PropertyValue.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/PropertyValue.java
@@ -191,12 +191,14 @@ abstract class PropertyValue<V> extends 
LeafExpression<Feature,V>
         // `getResultClass()` should never return null with our subtypes of 
`PropertyValue`.
         if (target == getResultClass()) {
             return (PropertyValue<N>) this;
+        } else if (target == Object.class) {
+            return (PropertyValue<N>) new AsObject(name, isVirtual);
         }
         final Class<?> source = getSourceClass();
-        if (target == Object.class) {
-            return (PropertyValue<N>) new AsObject(name, isVirtual);
-        } else if (source == Object.class) {
+        if (source == Object.class) {
             return new Converted<>(target, name, isVirtual);
+        } else if (target.isAssignableFrom(source)) {
+            return new Unsafe<>(source, target, name, isVirtual);
         } else {
             return new CastedAndConverted<>(source, target, name, isVirtual);
         }
@@ -229,7 +231,7 @@ abstract class PropertyValue<V> extends 
LeafExpression<Feature,V>
         }
 
         /**
-         * Returns the type of objects retuned by this expression.
+         * Returns the type of objects returned by this expression.
          */
         @Override
         public Class<Object> getResultClass() {
@@ -275,7 +277,7 @@ abstract class PropertyValue<V> extends 
LeafExpression<Feature,V>
 
     /**
      * An expression fetching property values as an object of specified type.
-     * The value is converted from {@link Object} to the specified type.
+     * The value is converted from an arbitrary {@link Object} to an instance 
of the specified type.
      *
      * @param  <V>  the type of value computed by the expression.
      */
@@ -288,6 +290,8 @@ abstract class PropertyValue<V> extends 
LeafExpression<Feature,V>
 
         /**
          * Creates a new expression retrieving values from a property of the 
given name.
+         * The {@code type} argument should never be {@code Object.class}, 
otherwise an
+         * {@link AsObject} should have been constructed instead.
          *
          * @param  type  the desired type for the expression result.
          * @param  name  the name of the property to fetch.
@@ -299,6 +303,7 @@ abstract class PropertyValue<V> extends 
LeafExpression<Feature,V>
 
         /**
          * Returns the type of values computed by this expression.
+         * Should be a subtype of {@link Object}, never {@code Object} itself.
          */
         @Override
         public final Class<V> getResultClass() {
@@ -356,6 +361,8 @@ abstract class PropertyValue<V> extends 
LeafExpression<Feature,V>
                 if (!(rename.equals(name) && source.equals(original))) {
                     if (source == Object.class) {
                         return new Converted<>(type, rename, isVirtual);
+                    } else if (type.isAssignableFrom(source)) {
+                        return new Unsafe<>(source, type, rename, isVirtual);
                     } else {
                         return new CastedAndConverted<>(source, type, rename, 
isVirtual);
                     }
@@ -373,6 +380,8 @@ abstract class PropertyValue<V> extends 
LeafExpression<Feature,V>
          * the original attribute type is kept unchanged because {@link 
#apply(Feature)}
          * does not convert those values.
          *
+         * @param  addTo  where to add the type of the property evaluated by 
this expression.
+         * @return handler of the added property (never {@code null}).
          * @throws UnconvertibleObjectException if the property default value 
cannot be converted to {@link #type}.
          */
         @Override
@@ -393,8 +402,8 @@ abstract class PropertyValue<V> extends 
LeafExpression<Feature,V>
      * in which case the link operation is kept. It may force {@code 
FeatureProjectionBuilder} to add also
      * the dependencies (targets) of the link.
      *
-     * @param  addTo  where to add the type of properties evaluated by this 
expression.
-     * @return builder of the added property, or {@code null} if this method 
cannot add a property.
+     * @param  addTo  where to add the type of the property evaluated by this 
expression.
+     * @return handler of the added property (never {@code null}).
      * @throws PropertyNotFoundException if the property was not found in 
{@code addTo.source()}.
      */
     @Override
@@ -434,7 +443,11 @@ abstract class PropertyValue<V> extends 
LeafExpression<Feature,V>
         @SuppressWarnings("serial")         // Most SIS implementations are 
serializable.
         private final ObjectConverter<? super S, ? extends V> converter;
 
-        /** Creates a new expression retrieving values from a property of the 
given name. */
+        /**
+         * Creates a new expression retrieving values from a property of the 
given name.
+         * The {@code type} argument should never be {@code Object.class}, 
otherwise an
+         * {@link AsObject} should have been constructed instead.
+         */
         CastedAndConverted(final Class<S> source, final Class<V> type, final 
String xpath, final boolean isVirtual) {
             super(type, xpath, isVirtual);
             this.source = source;
@@ -463,4 +476,56 @@ abstract class PropertyValue<V> extends 
LeafExpression<Feature,V>
             return null;
         }
     }
+
+
+
+    /**
+     * An expression skipping the conversion step because the features already 
provide instances of the desired type.
+     * This variant is said "unsafe" because it trusts that the feature 
instances guarantee that the property values
+     * are instances of the class declared in the {@code FeatureType}. If this 
assumption is wrong (which would be a
+     * bug in the caller's code rather than this class), a {@link 
ClassCastException} will probably be thrown anyway
+     * but later, possibly in a bridge method generated by the compiler for 
generic types.
+     *
+     * @param  <S>  the type of source value before conversion.
+     * @param  <V>  the type of value computed by the expression.
+     */
+    private static final class Unsafe<S,V> extends Converted<V> {
+        /** For cross-version compatibility. */
+        private static final long serialVersionUID = -223028669950189532L;
+
+        /** The type of values fetched from the feature instance. */
+        private final Class<S> source;
+
+        /**
+         * Creates a new expression retrieving values from a property of the 
given name.
+         * The {@code type} argument should never be {@code Object.class}, 
otherwise an
+         * {@link AsObject} should have been constructed instead.
+         */
+        Unsafe(final Class<S> source, final Class<V> type, final String xpath, 
final boolean isVirtual) {
+            super(type, xpath, isVirtual);
+            this.source = source;
+        }
+
+        /** Returns the type of values fetched from {@link Feature} instance. 
*/
+        @Override
+        protected Class<S> getSourceClass() {
+            return source;
+        }
+
+        /**
+         * Returns the value of the property of the given name, or {@code 
null} if none.
+         * For performance reason, this method does not verify the value type 
on the
+         * assumption that the type will be verified again by the caller 
anyway.
+         */
+        @Override
+        @SuppressWarnings("unchecked")
+        public V apply(final Feature instance) {
+            if (instance != null) try {
+                return (V) instance.getPropertyValue(name);
+            } catch (PropertyNotFoundException e) {
+                warning(e, false);
+            }
+            return null;
+        }
+    }
 }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/base/ConvertFunction.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/base/ConvertFunction.java
index cdce085ed6..802f293207 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/base/ConvertFunction.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/base/ConvertFunction.java
@@ -170,6 +170,8 @@ public final class ConvertFunction<R,S,V> extends 
UnaryFunction<R,S>
      * Provides the type of values produced by this expression.
      * May return {@code null} if the type cannot be determined.
      *
+     * @param  addTo  where to add the type of the property evaluated by this 
expression.
+     * @return handler of the added property, or {@code null} if the property 
cannot be added.
      * @throws UnconvertibleObjectException if the property default value 
cannot be converted to the expected type.
      */
     @Override
@@ -179,7 +181,7 @@ public final class ConvertFunction<R,S,V> extends 
UnaryFunction<R,S>
             return null;
         }
         final FeatureProjectionBuilder.Item item = 
addTo.addTemplateProperty(fex);
-        item.replaceValueClass((c) -> getResultClass());
+        if (item != null) item.replaceValueClass((c) -> getResultClass());
         return item;
     }
 
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/math/Function.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/math/Function.java
index 9beeb396b6..40ef4d856d 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/math/Function.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/math/Function.java
@@ -267,6 +267,8 @@ public enum Function implements FunctionIdentifier, 
AvailableFunction {
 
     /**
      * Returns the attribute type to declare in feature types that store 
result of this function.
+     *
+     * @return the attribute type (never {@code null}).
      */
     final synchronized AttributeType<?> getResultType() {
         if (resultType == null) {
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/sqlmm/FunctionWithSRID.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/sqlmm/FunctionWithSRID.java
index d031ff73d7..3542baf1b3 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/sqlmm/FunctionWithSRID.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/sqlmm/FunctionWithSRID.java
@@ -213,12 +213,14 @@ search: if (crs instanceof CoordinateReferenceSystem) {
      * Provides the type of values produced by this expression.
      * This is the value computed by the parent class except for the 
<abbr>SRID</abbr>.
      *
-     * @param  addTo  where to add the type of properties evaluated by this 
expression.
-     * @return handler of type resulting from expression evaluation (never 
null).
+     * @param  addTo  where to add the type of the property evaluated by this 
expression.
+     * @return handler of the added property, or {@code null} if the property 
cannot be added.
      */
     @Override
     public FeatureProjectionBuilder.Item expectedType(final 
FeatureProjectionBuilder addTo) {
         // We must unconditionally overwrite the CRS set by the parent class.
-        return super.expectedType(addTo).setCRS(literalCRS ? targetCRS : null);
+        FeatureProjectionBuilder.Item item = super.expectedType(addTo);
+        if (item != null) item.setCRS(literalCRS ? targetCRS : null);
+        return item;
     }
 }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/sqlmm/SpatialFunction.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/sqlmm/SpatialFunction.java
index c6ffa0a876..2e0756c7e2 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/sqlmm/SpatialFunction.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/sqlmm/SpatialFunction.java
@@ -180,8 +180,8 @@ abstract class SpatialFunction<R> extends Node
      *   <li>Otherwise, an attribute is created with the return value 
specified by the operation.</li>
      * </ul>
      *
-     * @param  addTo  where to add the type of properties evaluated by this 
expression.
-     * @return builder of type resulting from expression evaluation (never 
null).
+     * @param  addTo  where to add the type of the property evaluated by this 
expression.
+     * @return handler of the added property, or {@code null} if the property 
cannot be added.
      * @throws InvalidFilterValueException if the source feature type does not 
contain the expected properties,
      *         or if this method cannot determine the result type of the 
expression.
      *         It may be because that expression is backed by an unsupported 
implementation.
@@ -192,11 +192,11 @@ abstract class SpatialFunction<R> extends Node
             final FeatureExpression<?,?> fex = 
FeatureExpression.castOrCopy(getParameters().get(0));
             if (fex != null) {
                 final FeatureProjectionBuilder.Item item = 
addTo.addTemplateProperty(fex);
-                final boolean success = item.replaceValueClass((c) -> {
+                final boolean accept = (item == null) || 
item.replaceValueClass((c) -> {
                     final Geometries<?> library = Geometries.factory(c);
                     return (library == null) ? null : 
operation.getReturnType(library);
                 });
-                if (success) {
+                if (accept) {
                     return item;
                 }
             }
diff --git 
a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/feature/internal/shared/FeatureProjectionTest.java
 
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/feature/internal/shared/FeatureProjectionTest.java
new file mode 100644
index 0000000000..282f42986c
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/feature/internal/shared/FeatureProjectionTest.java
@@ -0,0 +1,294 @@
+/*
+ * 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.
+ */
+package org.apache.sis.feature.internal.shared;
+
+import java.util.Set;
+import java.util.HashSet;
+import org.apache.sis.util.iso.Names;
+import org.apache.sis.feature.builder.FeatureTypeBuilder;
+import org.apache.sis.filter.DefaultFilterFactory;
+
+// Specific to the geoapi-3.1 and geoapi-4.0 branches:
+import org.opengis.feature.Feature;
+import org.opengis.feature.FeatureType;
+import org.opengis.feature.AttributeType;
+import org.opengis.feature.Operation;
+import org.opengis.filter.FilterFactory;
+
+// Test dependencies
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.apache.sis.test.Assertions.assertSetEquals;
+import org.apache.sis.test.TestCase;
+
+
+/**
+ * Tests {@link FeatureProjection} and {@link FeatureProjectionBuilder}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public final class FeatureProjectionTest extends TestCase {
+    /**
+     * The feature type to use as a source, before projection.
+     */
+    private final FeatureType source;
+
+    /**
+     * The factory to use for building expressions.
+     */
+    private final FilterFactory<Feature, ?, ?> ff;
+
+    /**
+     * Creates a new test case.
+     */
+    public FeatureProjectionTest() {
+        ff = DefaultFilterFactory.forFeatures();
+        var builder = new FeatureTypeBuilder().setName("Country");
+        builder.addAttribute(String .class).setName("name");
+        builder.addAttribute(String .class).setName("capital");
+        builder.addAttribute(Integer.class).setName("population");
+        builder.addAttribute(Double .class).setName("area");
+        source = builder.build();
+    }
+
+    /**
+     * Tests the creation of a subset on a simple feature type.
+     * The type of values are not changed.
+     */
+    @Test
+    public void testSubsetWithSameValueClasses() {
+        final var builder = new FeatureProjectionBuilder(source, null);
+        addCountry(builder);
+        addPopulation(builder, false);
+        final FeatureProjection projection = 
assertContryAndPopulation(builder);
+        assertValueClassEquals(Integer.class, projection, "population");
+        final Feature result = applyOnFeatureInstance(projection);
+        assertEquals("Canada", result.getPropertyValue("country"));
+        assertEquals(40769890, result.getPropertyValue("population"));
+    }
+
+    /**
+     * Tests the creation of a subset on a simple feature type with a change 
of property type.
+     * The type of the population value is changed from {@code Integer} to 
{@code Long}.
+     */
+    @Test
+    public void testSubsetWithModifiedValueClasses() {
+        final var builder = new FeatureProjectionBuilder(source, null);
+        addCountry(builder);
+        addPopulation(builder, true);
+        final FeatureProjection projection = 
assertContryAndPopulation(builder);
+        assertValueClassEquals(Long.class, projection, "population");
+        final Feature result = applyOnFeatureInstance(projection);
+        assertEquals("Canada",  result.getPropertyValue("country"));
+        assertEquals(40769890L, result.getPropertyValue("population"));
+    }
+
+    /**
+     * Creates a feature instance, applies the projection and returns the 
result.
+     *
+     * @param  projection  the projection to apply.
+     * @return the projected feature instance.
+     */
+    private Feature applyOnFeatureInstance(final FeatureProjection projection) 
{
+        final Feature instance = source.newInstance();
+        instance.setPropertyValue("name",       "Canada");
+        instance.setPropertyValue("area",       9984670d);
+        instance.setPropertyValue("capital",    "Ottawa");
+        instance.setPropertyValue("population", 40769890);  // In 2024.
+        final Feature result = projection.apply(instance);
+        assertNotEquals(instance, result);
+        return result;
+    }
+
+    /**
+     * Builds the projection and asserts that it contains a country name and a 
population.
+     *
+     * @param  builder  the builder.
+     * @return the projection built by the given builder.
+     */
+    private FeatureProjection assertContryAndPopulation(final 
FeatureProjectionBuilder builder) {
+        assertSame(source, builder.source());
+        builder.setName("Country population");
+        final FeatureProjection projection = builder.project().orElseThrow();
+        assertSame(projection.typeRequested, projection.typeWithDependencies);
+        assertPropertyNamesEqual(projection.typeRequested, "country", 
"population");
+        assertSetEquals(Set.of("name", "population"), 
projection.dependencies());
+        assertValueClassEquals(String.class, projection, "country");
+        return projection;
+    }
+
+    /**
+     * Asserts that the given feature has properties of the given names in the 
same order.
+     */
+    private static void assertPropertyNamesEqual(final FeatureType type, final 
String... expected) {
+        assertArrayEquals(expected, type.getProperties(true).stream().map((p) 
-> p.getName().toString()).toArray());
+    }
+
+    /**
+     * Asserts that the given property is an attribute with the specified 
value type.
+     *
+     * @param expected    expected value class.
+     * @param projection  the projection to verify.
+     * @param property    the property to verify in the given projection.
+     */
+    private static void assertValueClassEquals(final Class<?> expected, final 
FeatureProjection projection, final String property) {
+        assertEquals(expected, assertInstanceOf(AttributeType.class, 
projection.typeRequested.getProperty(property)).getValueClass());
+    }
+
+    /**
+     * Adds a projection item for the country name.
+     * The property is renamed from "name" to "country".
+     *
+     * @param builder  the builder in which to add the item.
+     */
+    private void addCountry(final FeatureProjectionBuilder builder) {
+        final FeatureProjectionBuilder.Item item = 
builder.addSourceProperty(source.getProperty("name"), true);
+        assertEquals("name", item.sourceName.toString());
+        assertEquals("name", item.getPreferredName());
+        item.setPreferredName(Names.createLocalName(null, null, "country"));
+        assertEquals("country", item.getPreferredName());
+        assertNull(item.attributeValueGetter());
+        item.setValueGetter(ff.property("name"), true);
+    }
+
+    /**
+     * Adds a projection item for the population, optionally with a change of 
the value type.
+     *
+     * @param builder     the builder in which to add the item.
+     * @param changeType  whether to change the population type from {@code 
Integer} to {@code Long}.
+     */
+    private void addPopulation(final FeatureProjectionBuilder builder, final 
boolean changeType) {
+        final FeatureProjectionBuilder.Item item = 
builder.addSourceProperty(source.getProperty("population"), true);
+        assertEquals("population", item.sourceName.toString());
+        assertEquals("population", item.getPreferredName());
+        assertNull(item.attributeValueGetter());
+        item.setValueGetter(ff.property("population"), true);
+        assertTrue(item.replaceValueClass((type) -> {
+            assertEquals(Integer.class, type);
+            return changeType ? Long.class : type;
+        }));
+    }
+
+    /**
+     * Adds an expression which computes the population density from two other 
properties.
+     *
+     * @param builder  the builder in which to add the item.
+     * @param stored   {@code true} for evaluating the expression immediately 
after feature instances
+     *                 are known, or {@code false} for wrapping the expression 
in a feature operation.
+     */
+    private void addPopulationDensity(final FeatureProjectionBuilder builder, 
final boolean stored) {
+        final FeatureProjectionBuilder.Item item = builder.addComputedProperty(
+                builder.addAttribute(Number.class).setName("density"), true);
+        assertEquals("density", item.sourceName.toString());
+        assertEquals("density", item.getPreferredName());
+        assertNull(item.attributeValueGetter());
+        
item.setValueGetter(ff.divide(ff.property("population").toValueType(Number.class),
+                                      
ff.property("area").toValueType(Number.class)), stored);
+    }
+
+    /**
+     * Tests the creation of a feature with an additional property computed 
early.
+     * The operation is computed immediately from the source feature type.
+     */
+    @Test
+    public void testSubsetWithStoredOperation() {
+        final var builder = new FeatureProjectionBuilder(source, null);
+        addCountry(builder);
+        addPopulation(builder, false);
+        addPopulationDensity(builder, true);
+        builder.setName("Population density");
+        final FeatureProjection projection = builder.project().orElseThrow();
+        assertSame(projection.typeRequested, projection.typeWithDependencies);
+        assertSetEquals(Set.of("name", "population", "area"), 
projection.dependencies());
+        assertPropertyNamesEqual(projection.typeRequested, "country", 
"population", "density");
+
+        // Property is an attribute because we requested the "stored" mode.
+        assertInstanceOf(AttributeType.class, 
projection.typeWithDependencies.getProperty("density"));
+        assertValueClassEquals(Number.class,  projection, "density");
+        verifyDensityOnFeatureInstance(projection);
+    }
+
+    /**
+     * Tests the creation of a feature with an additional property computed 
when first requested.
+     * The operation forces the projection to add dependencies that were not 
part of the request.
+     */
+    @Test
+    public void testSubsetWithDeferredOperation() {
+        final var builder = new FeatureProjectionBuilder(source, null);
+        addCountry(builder);
+        addPopulation(builder, false);
+        addPopulationDensity(builder, false);
+        builder.setName("Population density");
+        final FeatureProjection projection = builder.project().orElseThrow();
+        assertNotEquals(projection.typeRequested, 
projection.typeWithDependencies);
+        assertSetEquals(Set.of("name", "population", "area"), 
projection.dependencies());
+        assertPropertyNamesEqual(projection.typeRequested, "country", 
"population", "density");
+        assertPropertyNamesEqual(projection.typeWithDependencies, "country", 
"population", "density", "area");
+
+        // Property is an operation because we requested the "deferred" mode.
+        assertInstanceOf(Operation.class, 
projection.typeWithDependencies.getProperty("density"));
+
+        // Operation should have been replaced by a view because of missing 
dependencies.
+        assertInstanceOf(OperationView.class, 
projection.typeRequested.getProperty("density"));
+        verifyDensityOnFeatureInstance(projection);
+    }
+
+    /**
+     * Tests the creation of a feature where an operation has been replaced by 
a simpler one.
+     * This case happen when a pure-Java operation has been replaced by a 
<abbr>SQL</abbr> expression,
+     * in which case the expression is simpler from <abbr>SIS</abbr> 
perspective. A consequence of this
+     * simplification is that it may remove the need for some dependencies.
+     */
+    @Test
+    public void testSubsetWithReplacedOperation() {
+        final var builder = new FeatureProjectionBuilder(source, null);
+        addCountry(builder);
+        addPopulation(builder, false);
+        addPopulationDensity(builder, true);
+        builder.setName("Population density");
+        FeatureProjection projection = builder.project().orElseThrow();
+        final Set<String> expected = new HashSet<>(Set.of("country", 
"population", "density"));
+        projection = new FeatureProjection(projection, (name, expression) -> {
+            assertTrue(expected.remove(name), name);
+            if (name.equals("density")) {
+                return ff.literal(4.08);
+            }
+            return expression;
+        });
+        assertTrue(expected.isEmpty(), expected.toString());
+        assertSame(projection.typeRequested, projection.typeWithDependencies); 
 // Because no more extra dependency.
+        assertSetEquals(Set.of("name", "population"), 
projection.dependencies());
+        assertPropertyNamesEqual(projection.typeRequested, "country", 
"population", "density");
+        verifyDensityOnFeatureInstance(projection);
+    }
+
+    /**
+     * Creates a feature instance, applies the projection and verifies the 
result.
+     *
+     * @param  projection  the projection to apply.
+     */
+    private void verifyDensityOnFeatureInstance(final FeatureProjection 
projection) {
+        assertValueClassEquals(String.class,  projection, "country");
+        assertValueClassEquals(Integer.class, projection, "population");
+        final Feature result = applyOnFeatureInstance(projection);
+        assertEquals("Canada", result.getPropertyValue("country"));
+        assertEquals(40769890, result.getPropertyValue("population"));
+        assertEquals(4.08, assertInstanceOf(Double.class, 
result.getPropertyValue("density")), 1.01);
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAdapter.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAdapter.java
index 413665c3cf..ea303cb847 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAdapter.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAdapter.java
@@ -343,7 +343,7 @@ final class FeatureAdapter {
      */
     final Feature createFeature(final InfoStatements stmts, final ResultSet 
result) throws Exception {
         final Feature feature = featureType.newInstance();
-        for (int i=0; i<attributes.length; i++) {
+        for (int i=0; i < attributes.length; i++) {
             final Column column = attributes[i];
             final Object value = column.valueGetter.getValue(stmts, result, 
i+1);
             if (value != null) {
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureStream.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureStream.java
index cef33f702c..e2c49b3962 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureStream.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureStream.java
@@ -465,8 +465,8 @@ final class FeatureStream extends DeferredStream<Feature> {
                     final JDBCType type = filterToSQL.writeFunction(columnSQL, 
expression);
                     if (type != null) try {
                         columnSQL.append(" AS ").appendIdentifier(name);
-                        expression = new ComputedColumn(database, type, name,
-                                            columnSQL.query(connection, 
spatialInformation));
+                        String sql = columnSQL.query(connection, 
spatialInformation);
+                        expression = new ComputedColumn(database, type, name, 
sql);
                     } catch (Exception e) {
                         throw cannotExecute(e);
                     }
@@ -483,7 +483,7 @@ final class FeatureStream extends DeferredStream<Feature> {
             final var unhandled   = new BitSet();
             final var reusedNames = new HashSet<String>();
             projected = new Table(projected, queriedProjection, reusedNames, 
unhandled);
-            completion = 
queriedProjection.afterPreprocessing(unhandled.stream().toArray());
+            completion = 
queriedProjection.forPreexistingFeatureInstances(unhandled.stream().toArray());
             if (completion != null && 
!reusedNames.containsAll(completion.dependencies())) {
                 /*
                  * Cannot use `projected` because some expressions need 
properties available only
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureQuery.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureQuery.java
index 0d52a4c275..9ee1019a4e 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureQuery.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureQuery.java
@@ -629,7 +629,7 @@ public class FeatureQuery extends Query implements 
Cloneable, Emptiable, Seriali
             if (fex != null) {
                 final FeatureProjectionBuilder.Item item = 
fex.expectedType(builder);
                 if (item != null) {
-                    item.setName(alias);    // Need to be invoked aven if the 
alias is null.
+                    item.setPreferredName(alias);   // Need to be invoked even 
if the alias is null.
                     item.setValueGetter(expression, type == 
ProjectionType.STORED);
                     return true;
                 }
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary.java
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary.java
index e0f372470a..b4cddd1f8d 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary.java
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary.java
@@ -1014,6 +1014,11 @@ public class Vocabulary extends IndexedResourceBundle {
          */
         public static final short Plugins = 160;
 
+        /**
+         * {0} + dependencies
+         */
+        public static final short PlusDependencies_1 = 281;
+
         /**
          * Preprocessing
          */
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary.properties
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary.properties
index 3ae9fe1411..6b870cc509 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary.properties
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary.properties
@@ -207,6 +207,7 @@ Panchromatic            = Panchromatic
 Parenthesis_2           = {0} ({1})
 Paths                   = Paths
 Plugins                 = Plug-ins
+PlusDependencies_1      = {0} + dependencies
 Preprocessing           = Preprocessing
 Projected               = Projected
 Property                = Property
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary_fr.properties
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary_fr.properties
index c1a58d477a..89762168e2 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary_fr.properties
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Vocabulary_fr.properties
@@ -214,6 +214,7 @@ Panchromatic            = Panchromatique
 Parenthesis_2           = {0} ({1})
 Paths                   = Chemins
 Plugins                 = Modules d\u2019extension
+PlusDependencies_1      = {0} + d\u00e9pendances
 Preprocessing           = Pr\u00e9traitement
 Projected               = Projet\u00e9
 Property                = Propri\u00e9t\u00e9

Reply via email to