You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2025/05/13 17:37:09 UTC

(sis) 04/04: `SQLStore` should accept queries with a "projection" (in SQL sense) that contains links such as "sis:identifier".

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

commit 0db3b21989774006e39c393c526524879efc2b7c
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Tue May 13 07:26:26 2025 +0200

    `SQLStore` should accept queries with a "projection" (in SQL sense) that contains links such as "sis:identifier".
    
    https://issues.apache.org/jira/browse/SIS-609
---
 .../org.apache.sis.feature/main/module-info.java   |   1 +
 .../org/apache/sis/feature/AbstractOperation.java  |   4 +-
 .../apache/sis/feature/ExpressionOperation.java    |   2 +-
 .../org/apache/sis/feature/FeatureOperations.java  |  48 +-
 .../sis/feature/builder/OperationWrapper.java      |  30 +-
 .../main/org/apache/sis/feature/package-info.java  |   2 +-
 .../sis/feature/privy/FeatureExpression.java       |  89 +--
 .../sis/feature/privy/FeatureProjection.java       | 341 +++++++++++
 .../feature/privy/FeatureProjectionBuilder.java    | 682 +++++++++++++++++++++
 .../org/apache/sis/feature/privy/FeatureView.java  | 131 ++++
 .../org/apache/sis/filter/ArithmeticFunction.java  |   8 +-
 .../org/apache/sis/filter/AssociationValue.java    |  22 +-
 .../org/apache/sis/filter/ConvertFunction.java     |  19 +-
 .../apache/sis/filter/DefaultFilterFactory.java    |  25 +
 .../apache/sis/filter/InvalidXPathException.java   |  81 +++
 .../main/org/apache/sis/filter/LeafExpression.java |  15 +-
 .../main/org/apache/sis/filter/PropertyValue.java  |  75 ++-
 .../main/org/apache/sis/filter/privy/XPath.java    |  18 +-
 .../apache/sis/filter/sqlmm/FunctionWithSRID.java  |  24 +-
 .../apache/sis/filter/sqlmm/SpatialFunction.java   |  36 +-
 .../sis/storage/sql/feature/FeatureIterator.java   |  31 +-
 .../sis/storage/sql/feature/FeatureStream.java     |  38 +-
 .../org/apache/sis/storage/sql/feature/Table.java  |  46 +-
 .../main/org/apache/sis/storage/FeatureQuery.java  |  59 +-
 .../main/org/apache/sis/storage/FeatureSubset.java |   6 +-
 .../apache/sis/storage/base/FeatureProjection.java | 383 ------------
 26 files changed, 1598 insertions(+), 618 deletions(-)

diff --git a/endorsed/src/org.apache.sis.feature/main/module-info.java b/endorsed/src/org.apache.sis.feature/main/module-info.java
index 86a5451ee6..9518b5edcc 100644
--- a/endorsed/src/org.apache.sis.feature/main/module-info.java
+++ b/endorsed/src/org.apache.sis.feature/main/module-info.java
@@ -51,6 +51,7 @@ module org.apache.sis.feature {
     exports org.apache.sis.feature.privy to
             org.apache.sis.storage,
             org.apache.sis.storage.xml,
+            org.apache.sis.storage.sql,
             org.apache.sis.storage.netcdf,
             org.apache.sis.storage.shapefile,       // In the "incubator" sub-project.
             org.apache.sis.storage.gdal,            // In the "optional" sub-project.
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 a498244380..cd7d311557 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
@@ -204,8 +204,8 @@ public abstract class AbstractOperation extends AbstractIdentifiedType implement
     /**
      * Returns the names of feature properties that this operation needs for performing its task.
      * This method does not resolve transitive dependencies, i.e. if a dependency is itself an operation having
-     * other dependencies, the returned set will contain the name of that operation but not the names of that
-     * operation dependencies (unless they are the same that the direct dependencies of {@code this}).
+     * other dependencies, the returned set will contain the name of that operation but not the names of the
+     * dependencies of that operation (unless they are the same that the direct dependencies of {@code this}).
      *
      * <div class="note"><b>Rational:</b>
      * this information is needed for writing the {@code SELECT} SQL statement to send to a database server.
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/ExpressionOperation.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/ExpressionOperation.java
index e01675cc6b..5d27304400 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/ExpressionOperation.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/ExpressionOperation.java
@@ -62,7 +62,7 @@ final class ExpressionOperation<V> extends AbstractOperation {
      * The expression to which to delegate the execution of this operation.
      */
     @SuppressWarnings("serial")                         // Not statically typed as serializable.
-    private final Function<? super Feature, ? extends V> expression;
+    final Function<? super Feature, ? extends V> expression;
 
     /**
      * The type of result of evaluating the expression.
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/FeatureOperations.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/FeatureOperations.java
index f1ca57ee9a..05210b25e4 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/FeatureOperations.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/FeatureOperations.java
@@ -27,6 +27,8 @@ import org.apache.sis.util.Static;
 import org.apache.sis.util.collection.WeakHashSet;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.privy.Strings;
+import org.apache.sis.filter.DefaultFilterFactory;
+import org.apache.sis.filter.privy.XPath;
 import org.apache.sis.setup.GeometryLibrary;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
@@ -103,7 +105,7 @@ import org.opengis.filter.Expression;
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.4
+ * @version 1.5
  * @since   0.7
  */
 public final class FeatureOperations extends Static {
@@ -306,7 +308,7 @@ public final class FeatureOperations extends Static {
      * @param  <V>             the type of values computed by the expression and assigned to the feature property.
      * @param  identification  the name of the operation, together with optional information.
      * @param  expression      the expression to evaluate on feature instances.
-     * @param  resultType      type of values computed by the expression and assigned to the feature property.
+     * @param  resultType      type of values computed by the expression.
      * @return a feature operation which computes its values using the given expression.
      *
      * @since 1.4
@@ -334,7 +336,7 @@ public final class FeatureOperations extends Static {
      * @param  <V>             the type of values computed by the expression and assigned to the feature property.
      * @param  identification  the name of the operation, together with optional information.
      * @param  expression      the expression to evaluate on feature instances.
-     * @param  resultType      type of values computed by the expression and assigned to the feature property.
+     * @param  resultType      type of values computed by the expression.
      * @return a feature operation which computes its values using the given expression.
      * @throws ClassCastException if the result type is not a target type supported by the expression.
      *
@@ -346,4 +348,44 @@ public final class FeatureOperations extends Static {
     {
         return function(identification, expression.toValueType(resultType.getValueClass()), resultType);
     }
+
+    /**
+     * Returns an expression for fetching the values of properties identified by the given type.
+     * The returned expression will be the first of the following choices which is applicable:
+     *
+     * <ul>
+     *   <li>If the property is an expression built by {@link #expression expression(…)}, then the
+     *       expression given to that method, or a derivative of that expression, is returned.</li>
+     *   <li>If the property {@linkplain Features#getLinkTarget is a link},
+     *       then a {@code ValueReference} fetching the link target is returned.</li>
+     *   <li>Otherwise, a {@linkplain DefaultFilterFactory.Features#property value reference}
+     *       is created for the name of the given property.</li>
+     * </ul>
+     *
+     * @param  property  the property for which to get an expression.
+     * @return an expression for fetching the values of the property identified by the given type.
+     * @since 1.5
+     */
+    public static Expression<? super Feature, ?> expressionOf(final PropertyType property) {
+        // Test final class first because it is fast.
+        if (property instanceof ExpressionOperation<?>) {
+            final Function<? super Feature, ?> expression = ((ExpressionOperation<?>) property).expression;
+            if (expression instanceof Expression<?,?>) {
+                return (Expression<? super Feature, ?>) expression;
+            }
+        }
+        String name;
+        final Class<?> type;
+        if (property instanceof AttributeType<?>) {
+            type = ((AttributeType<?>) property).getValueClass();
+            name = null;
+        } else {
+            type = Object.class;
+            name = Features.getLinkTarget(property).orElse(null);
+        }
+        if (name == null) {
+            name = property.getName().toString();
+        }
+        return DefaultFilterFactory.forFeatures().property(XPath.fromPropertyName(name), type);
+    }
 }
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/OperationWrapper.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/OperationWrapper.java
index aba5d401b8..c8ebf432b2 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/OperationWrapper.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/OperationWrapper.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.feature.builder;
 
+import java.util.Objects;
 import org.opengis.util.GenericName;
 import org.apache.sis.util.resources.Errors;
 
@@ -58,22 +59,33 @@ final class OperationWrapper extends PropertyTypeBuilder {
     /**
      * Do not allow a change of multiplicity.
      */
-    @Override public PropertyTypeBuilder setMinimumOccurs(int occurs) {if (occurs == 1) return this; throw readOnly();}
-    @Override public PropertyTypeBuilder setMaximumOccurs(int occurs) {if (occurs == 1) return this; throw readOnly();}
+    @Override public PropertyTypeBuilder setMinimumOccurs(int occurs) {return readOnly(1, occurs);}
+    @Override public PropertyTypeBuilder setMaximumOccurs(int occurs) {return readOnly(1, occurs);}
 
     /**
      * Do not allow modifications.
      */
-    @Override public PropertyTypeBuilder setName       (GenericName name)         {throw readOnly();}
-    @Override public PropertyTypeBuilder setDefinition (CharSequence definition)  {throw readOnly();}
-    @Override public PropertyTypeBuilder setDesignation(CharSequence designation) {throw readOnly();}
-    @Override public PropertyTypeBuilder setDescription(CharSequence description) {throw readOnly();}
-    @Override public PropertyTypeBuilder setDeprecated (boolean deprecated)       {throw readOnly();}
+    @Override public PropertyTypeBuilder setName       (GenericName name)         {return readOnly(str(getName()),   str(name));}
+    @Override public PropertyTypeBuilder setDefinition (CharSequence definition)  {return readOnly(getDefinition(),  definition);}
+    @Override public PropertyTypeBuilder setDesignation(CharSequence designation) {return readOnly(getDesignation(), designation);}
+    @Override public PropertyTypeBuilder setDescription(CharSequence description) {return readOnly(getDescription(), description);}
+    @Override public PropertyTypeBuilder setDeprecated (boolean deprecated)       {return readOnly(isDeprecated(),   deprecated);}
 
     /**
-     * Returns the exception to be thrown for read-only wrapper.
+     * Returns the string representation of the given name, or {@code null} if the name is null.
+     * This is used for relaxing the comparison of names before to throw an exception.
      */
-    private UnsupportedOperationException readOnly() {
+    private static String str(final GenericName name) {
+        return (name != null) ? name.toString() : null;
+    }
+
+    /**
+     * Throws {@link UnsupportedOperationException} if the given value is different than the expected value.
+     */
+    private OperationWrapper readOnly(final Object expected, final Object actual) {
+        if (Objects.equals(expected, actual)) {
+            return this;
+        }
         throw new UnsupportedOperationException(errors().getString(Errors.Keys.UnmodifiableObject_1, PropertyTypeBuilder.class));
     }
 }
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/package-info.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/package-info.java
index 75ca037d52..b7fe070c00 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/package-info.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/package-info.java
@@ -93,7 +93,7 @@
  * @author  Travis L. Pinney
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.4
+ * @version 1.5
  * @since   0.5
  */
 package org.apache.sis.feature;
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureExpression.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureExpression.java
index b60a74b5e9..ae76ede67e 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureExpression.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureExpression.java
@@ -18,24 +18,26 @@ package org.apache.sis.feature.privy;
 
 import java.util.Set;
 import org.apache.sis.math.FunctionProperty;
+import org.apache.sis.util.UnconvertibleObjectException;
 import org.apache.sis.filter.Optimization;
 import org.apache.sis.filter.DefaultFilterFactory;
-import org.apache.sis.feature.builder.FeatureTypeBuilder;
-import org.apache.sis.feature.builder.PropertyTypeBuilder;
-import org.apache.sis.feature.builder.AttributeTypeBuilder;
 import org.apache.sis.filter.internal.Node;
+import org.opengis.util.GenericName;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
-import org.opengis.feature.FeatureType;
+import org.opengis.feature.Feature;
 import org.opengis.feature.AttributeType;
+import org.opengis.feature.PropertyType;
+import org.opengis.feature.PropertyNotFoundException;
+import org.opengis.filter.InvalidFilterValueException;
 import org.opengis.filter.Literal;
 import org.opengis.filter.Expression;
 import org.opengis.filter.ValueReference;
 
 
 /**
- * OGC expressions or other functions operating on feature instances.
- * This interface adds an additional method, {@link #expectedType(FeatureType, FeatureTypeBuilder)},
+ * <abbr>OGC</abbr> expressions or other functions operating on feature instances.
+ * This interface adds an additional method, {@link #expectedType expectedType(…)},
  * for fetching in advance the expected type of expression results.
  *
  * <p>This is an experimental interface which may be removed in any future version.</p>
@@ -73,56 +75,73 @@ public interface FeatureExpression<R,V> extends Expression<R,V> {
     }
 
     /**
-     * Provides the expected type of values produced by this expression when a feature of the given
-     * type is evaluated. The resulting type shall describe a "static" property, i.e. it can be an
-     * {@link AttributeType} or a {@link org.opengis.feature.FeatureAssociationRole}
-     * but not an {@link org.opengis.feature.Operation}.
+     * Provides the expected type of values produced by this expression when a feature of a given type is evaluated.
+     * Except for the special case of links (described below), the resulting type shall describe a "static" property,
+     * <i>i.e.</i> the type should be an {@link AttributeType} or a {@link org.opengis.feature.FeatureAssociationRole}
+     * but not an {@link org.opengis.feature.Operation}. The value of the static property will be set to the result of
+     * evaluating the expression when instances of the {@code FeatureType} will be created.
+     * This evaluation will be performed by {@link FeatureProjection#apply(Feature)}.
      *
-     * <p>If this method returns an instance of {@link AttributeTypeBuilder}, then its parameterized
-     * type should be the same {@code <V>} than this {@code FeatureExpression}.</p>
+     * <h4>Implementation guideline</h4>
+     * Implementations should declare the property by invoking some of the following methods:
      *
-     * @param  valueType  the type of features to be evaluated by the given expression.
-     * @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.
-     * @throws IllegalArgumentException if this method can operate only on some feature types
-     *         and the given type is not one of them.
+     * <ul>
+     *   <li>{@link FeatureProjectionBuilder#source()} for the source of the {@link PropertyType} in next point.</li>
+     *   <li>{@link FeatureProjectionBuilder#addSourceProperty(PropertyType, boolean)}</li>
+     *   <li>{@link FeatureProjectionBuilder#addOptionalAttribute(String, Class)}</li>
+     *   <li>{@link FeatureProjectionBuilder#addComputedAttribute(GenericName, Class)}</li>
+     * </ul>
+     *
+     * Inherited methods such as {@link FeatureProjectionBuilder#addAttribute(Class)} can also be invoked,
+     * but callers will be responsible for providing the value of the properties added by those methods.
+     * These values will not be provided by {@link FeatureProjection#apply(Feature)}.
+     *
+     * <h4>Operations</h4>
+     * If the property is a link to another property, such as {@code "sis:identifier"} or {@code "sis:geometry"},
+     * then adding this property may require the addition of dependencies. These dependencies will be detected by
+     * {@link FeatureProjectionBuilder}, which may generate an intermediate {@code FeatureType}.
+     *
+     * @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 this expression is invalid for the requested operation.
+     * @throws PropertyNotFoundException if the property was not found in {@code addTo.source()}.
+     * @throws UnconvertibleObjectException if the property default value cannot be converted to the expected type.
      */
-    PropertyTypeBuilder expectedType(FeatureType valueType, FeatureTypeBuilder addTo);
+    FeatureProjectionBuilder.Item expectedType(FeatureProjectionBuilder addTo);
 
     /**
      * Tries to cast or convert the given expression to a {@link FeatureExpression}.
-     * If the given expression cannot be casted, then this method creates a copy
-     * provided that the expression is one of the following type:
+     * If the given expression cannot be cast, then this method creates a copy
+     * if the expression is one of the following types:
      *
      * <ol>
      *   <li>{@link Literal}.</li>
-     *   <li>{@link ValueReference}, assuming that the expression expects feature instances.</li>
+     *   <li>{@link ValueReference} if the given expression accepts feature instances.</li>
      * </ol>
      *
-     * Otherwise this method returns {@code null}.
+     * Otherwise, this method returns {@code null}.
      * It is caller's responsibility to verify if this method returns {@code null} and to throw an exception in such case.
-     * We leave that responsibility to the caller because (s)he may be able to provide better error messages.
-     *
-     * <h4>Note on type safety</h4>
-     * This method does not use parameterized types because of the assumption on {@link ValueReference}.
-     * As of Apache SIS 1.3, we have no way to check if {@code <R>} is for feature instances.
+     * We leave that responsibility to the callers because they may be able to provide better error messages.
      *
+     * @param  <R>        the type of resources used as inputs.
      * @param  candidate  the expression to cast or copy. Can be null.
-     * @return the given expression as a feature expression, or {@code null} if it cannot be casted or converted.
+     * @return the given expression as a feature expression, or {@code null} if it cannot be cast or converted.
      */
-    public static FeatureExpression<?,?> castOrCopy(final Expression<?,?> candidate) {
+    public static <R> FeatureExpression<? super R, ?> castOrCopy(final Expression<R,?> candidate) {
         if (candidate instanceof FeatureExpression<?,?>) {
-            return (FeatureExpression<?,?>) candidate;
+            return (FeatureExpression<R,?>) candidate;
         }
-        final Expression<?,?> copy;
+        final Expression<? super R, ?> copy;
         if (candidate instanceof Literal<?,?>) {
-            copy = Optimization.literal(((Literal<?,?>) candidate).getValue());
+            copy = Optimization.literal(((Literal<R,?>) candidate).getValue());
         } else if (candidate instanceof ValueReference<?,?>) {
-            final String xpath = ((ValueReference<?,?>) candidate).getXPath();
-            copy = DefaultFilterFactory.forFeatures().property(xpath);
+            final String xpath = ((ValueReference<R,?>) candidate).getXPath();
+            copy = DefaultFilterFactory.forResources(candidate.getResourceClass())
+                    .map((factory) -> factory.property(xpath)).orElse(null);
         } else {
             return null;
         }
-        return (FeatureExpression<?,?>) copy;
+        // We do not expect a `ClassCastException` here because `copy` should be a SIS implementation.
+        return (FeatureExpression<? super R, ?>) copy;
     }
 }
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureProjection.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureProjection.java
new file mode 100644
index 0000000000..b263c1e4dc
--- /dev/null
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureProjection.java
@@ -0,0 +1,341 @@
+/*
+ * 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.privy;
+
+import java.util.List;
+import java.util.Set;
+import java.util.Map;
+import java.util.LinkedHashMap;
+import java.util.Optional;
+import java.util.function.UnaryOperator;
+import org.apache.sis.util.Debug;
+import org.apache.sis.util.ArraysExt;
+import org.apache.sis.util.resources.Vocabulary;
+import org.apache.sis.util.privy.UnmodifiableArrayList;
+import org.apache.sis.filter.privy.ListingPropertyVisitor;
+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.filter.Expression;
+import org.opengis.filter.ValueReference;
+
+
+/**
+ * A function applying projections (with "projection" in the <abbr>SQL</abbr> database sense) of features.
+ * Given full feature instances in input, the function returns feature instances containing only a subset
+ * of the properties. The property values may also be different if they are computed by the expressions.
+ *
+ * @author Guilhem Legal (Geomatys)
+ * @author Martin Desruisseaux (Geomatys)
+ */
+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)}.
+     */
+    public final FeatureType typeRequested;
+
+    /**
+     * 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).
+     */
+    public final FeatureType typeWithDependencies;
+
+    /**
+     * Names of the properties to be stored in the feature instances created by this {@code FeatureProjection}.
+     * Properties that are computed on-the-fly from other properties are not included in this array. Properties
+     * that are present in {@link #typeRequested} but not in the source features are not in this array neither.
+     */
+    private final String[] propertiesToCopy;
+
+    /**
+     * Expressions to apply on the source feature for fetching the property values of the projected feature.
+     * This array has the same length as {@link #propertiesToCopy} and each expression is associated to the
+     * property at the same index.
+     */
+    private final Expression<? super Feature, ?>[] expressions;
+
+    /**
+     * Whether the {@link #apply(Feature)} method shall create instances of {@link #typeWithDependencies}.
+     * If {@code false}, then the instances given to the {@link #apply(Feature)} method will be assumed
+     * to be already instances of {@link #typeWithDependencies} and will be modified in-place.
+     */
+    private final boolean createInstance;
+
+    /**
+     * Creates a new projection with the given properties specified by a builder.
+     * The {@link #apply(Feature)} method will copy the properties of the given
+     * features into new instances of {@link #typeWithDependencies}.
+     *
+     * @param  typeRequested  the type of projected features.
+     * @param  projection     descriptions of the properties to keep in the projected features.
+     */
+    FeatureProjection(final FeatureType typeRequested, final FeatureType typeWithDependencies,
+                      final List<FeatureProjectionBuilder.Item> projection)
+    {
+        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()];
+
+        // Names of the properties to be stored in the attributes of the target features.
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
+        final String[] propertiesToCopy = new String[expressions.length];
+
+        for (final FeatureProjectionBuilder.Item item : projection) {
+            final var expression = item.attributeValueGetter();
+            if (expression != null) {
+                expressions[storedCount] = expression;
+                propertiesToCopy[storedCount++] = item.getName();
+            }
+        }
+        this.propertiesToCopy = ArraysExt.resize(propertiesToCopy, storedCount);
+        this.expressions      = ArraysExt.resize(expressions, storedCount);
+    }
+
+    /**
+     * Creates a new projection with a subset of the properties of another projection.
+     * This constructor is invoked when the caller handles itself some of the properties.
+     *
+     * <h4>behavioral change</h4>
+     * Projections created by this constructor assumes that the feature instances given to the
+     * {@link #apply(Feature)} method are already instances of {@link #typeWithDependencies}
+     * and can be modified (if needed) in place. This constructor is designed for cases where
+     * the caller does itself a part of the {@code FeatureProjection} work.
+     *
+     * @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[])
+     */
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    private FeatureProjection(final FeatureProjection parent, final int[] remaining) {
+        createInstance       = false;
+        typeRequested        = parent.typeRequested;
+        typeWithDependencies = parent.typeWithDependencies;
+        expressions          = new Expression[remaining.length];
+        propertiesToCopy     = new String[remaining.length];
+        for (int i=0; i<remaining.length; i++) {
+            final int index = remaining[i];
+            propertiesToCopy[i] = parent.propertiesToCopy[index];
+            expressions[i] = parent.expressions[index];
+        }
+    }
+
+    /**
+     * Returns a variant of this projection where the caller has created the target feature instance itself.
+     * The callers is may have set some property values itself, and the {@code remaining} argument gives the
+     * indexes of the properties that still need to be copied after caller's processing.
+     *
+     * @param  remaining  index of the properties that still need to be copied after the caller did its processing.
+     * @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) {
+        if (remaining.length == 0 && typeRequested == typeWithDependencies) {
+            return null;
+        }
+        return new FeatureProjection(this, remaining);
+    }
+
+    /**
+     * Returns the names of all stored properties. This list may be shorter than the list of properties of the
+     * {@linkplain #typeRequested requested feature type} if some feature properties are computed on-the-fly,
+     * or if the target feature contains some new properties that are not in the source.
+     *
+     * @return the name of all stored properties.
+     */
+    public final List<String> propertiesToCopy() {
+        return UnmodifiableArrayList.wrap(propertiesToCopy);
+    }
+
+    /**
+     * Returns the path to the value (in source features) of the property at the given index.
+     * The argument corresponds to an index in the list returned by {@link #propertiesToCopy()}.
+     * The return value is often the same name as {@code propertiesToCopy().get(index)} but may
+     * differ if the user has specified aliases or if two properties have the same default name.
+     *
+     * @param  index  index of the stored property for which to get the name in the source feature.
+     * @return path in the source features, or empty if the property is not a {@link ValueReference}.
+     */
+    public Optional<String> xpath(final int index) {
+        final Expression<? super Feature, ?> expression = expressions[index];
+        if (expression instanceof ValueReference<?,?>) {
+            return Optional.of(((ValueReference<?,?>) expression).getXPath());
+        }
+        return Optional.empty();
+    }
+
+    /**
+     * 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.
+     *
+     * @return all dependencies (including transitive dependencies) as XPaths.
+     */
+    public Set<String> dependencies() {
+        Set<String> references = null;
+        for (var expression : expressions) {
+            references = ListingPropertyVisitor.xpaths(expression, references);
+        }
+        return (references != null) ? references : Set.of();
+    }
+
+    /**
+     * Derives a new projected feature instance from the given source.
+     * The feature type of the returned feature instance will be 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>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
+     *     of above step is wrapped in a view which hides the undesired properties.</li>
+     * </ol>
+     *
+     * @param  source the source feature instance.
+     * @return the "projected" (<abbr>SQL</abbr> database sense) feature instance.
+     */
+    @Override
+    public Feature apply(final Feature source) {
+        var feature = createInstance ? typeWithDependencies.newInstance() : source;
+        for (int i=0; i < expressions.length; i++) {
+            feature.setPropertyValue(propertiesToCopy[i], expressions[i].apply(source));
+        }
+        if (typeRequested != typeWithDependencies) {
+            feature = new FeatureView(typeRequested, feature);
+        }
+        return feature;
+    }
+
+    /**
+     * Returns a string representation of this projection for debugging purposes.
+     * The current implementation formats a table with all properties, including
+     * dependencies, and a column saying whether the property is an operation,
+     * is stored or whether there is an error.
+     *
+     * @return a string representation.
+     */
+    @Override
+    public String toString() {
+        return Row.toString(this, propertiesToCopy);
+    }
+
+    /**
+     * Helper class for the implementation of {@link FeatureProjection#toString()}.
+     * Each instance represents a row in the table to be formatted..
+     * This is used for debugging purposes only.
+     */
+    @Debug
+    private static final class Row {
+        /**
+         * Returns a string representation of the {@link FeatureProjection} having the given values.
+         * Having this method in a separated class reduces the amount of classes loading, since this
+         * {@code Row} class is rarely needed in production environment.
+         *
+         * @param  projection        the projection for which to format a string representation.
+         * @param  propertiesToCopy  value of {@link FeatureProjection#propertiesToCopy}.
+         * @return the string representation of the given projection.
+         */
+        static String toString(final FeatureProjection projection, final String[] propertiesToCopy) {
+            final var rowByName = new LinkedHashMap<String,Row>();
+            if (projection.typeWithDependencies != projection.typeRequested) {
+                addAll(rowByName, projection.typeWithDependencies, "dependency");
+            }
+            addAll(rowByName, projection.typeRequested, "operation");   // Overwrite above dependencies.
+            for (int i=0; i < propertiesToCopy.length; i++) {
+                String name = propertiesToCopy[i];
+                String value;
+                try {
+                    name = projection.typeWithDependencies.getProperty(name).getName().toString();
+                    value = "stored";
+                } catch (RuntimeException e) {
+                    value = e.toString();
+                }
+                Row row   = rowByName.computeIfAbsent(name, Row::new);
+                row.type  = value;
+                row.xpath = projection.xpath(i).orElse("");
+            }
+            final var words = Vocabulary.forLocale(null);
+            final var table = new TableAppender(" │ ");
+            table.setMultiLinesCells(true);
+            table.appendHorizontalSeparator();
+            table.append(words.getString(Vocabulary.Keys.Property)).nextColumn();
+            table.append(words.getString(Vocabulary.Keys.Type)).nextColumn();
+            table.append("XPath").nextLine();
+            table.appendHorizontalSeparator();
+            for (final Row row : rowByName.values()) {
+                table.append(row.property).nextColumn();
+                table.append(row.type).nextColumn();
+                table.append(row.xpath).nextLine();
+            }
+            table.appendHorizontalSeparator();
+            return table.toString();
+        }
+
+        /**
+         * Adds all properties of the given feature type into the specified map.
+         * For each property, {@link #type} is overwritten with the {@code type} argument value.
+         *
+         * @param rowByName    where to add the properties.
+         * @param featureType  the type from which to get the list of properties.
+         * @param type         the "stored", "operation" or "dependency" value to assign to {@link #type}.
+         */
+        private static void addAll(final Map<String,Row> rowByName, final FeatureType featureType, final String type) {
+            for (final var property : featureType.getProperties(true)) {
+                Row row = rowByName.computeIfAbsent(property.getName().toString(), Row::new);
+                row.type = type;
+            }
+        }
+
+        /**
+         * Creates a row with no value.
+         *
+         * @param  name  number under which the property is stored.
+         */
+        private Row(final String name) {
+            property = name;
+            xpath = "";
+        }
+
+        /**
+         * Name of the property in the projected feature type.
+         */
+        private final String property;
+
+        /**
+         * Path to the property value in the source feature.
+         */
+        private String xpath;
+
+        /**
+         * Type: stored, operation or dependency.
+         */
+        private String type;
+    }
+}
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureProjectionBuilder.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureProjectionBuilder.java
new file mode 100644
index 0000000000..f8a5c7a7c3
--- /dev/null
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureProjectionBuilder.java
@@ -0,0 +1,682 @@
+/*
+ * 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.privy;
+
+import java.util.Map;
+import java.util.HashMap;
+import java.util.List;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.UnaryOperator;
+import org.opengis.util.GenericName;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.feature.Features;
+import org.apache.sis.feature.AbstractOperation;
+import org.apache.sis.feature.FeatureOperations;
+import org.apache.sis.feature.builder.AttributeTypeBuilder;
+import org.apache.sis.feature.builder.FeatureTypeBuilder;
+import org.apache.sis.feature.builder.PropertyTypeBuilder;
+import org.apache.sis.util.privy.Strings;
+import org.apache.sis.util.resources.Vocabulary;
+
+// 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.PropertyType;
+import org.opengis.feature.IdentifiedType;
+import org.opengis.feature.Operation;
+import org.opengis.filter.Expression;
+import org.opengis.filter.ValueReference;
+
+
+/**
+ * A builder for deriving a feature type containing a subset of the properties of another type.
+ * The other type is called the {@linkplain #source() source} feature type. The properties that
+ * are retained may have different names than the property names of the source feature type.
+ * If a property is a link such as {@code sis:identifier} or {@code sis:geometry},
+ * this class keeps trace of the dependencies required for recreating the link.
+ *
+ * <p>Properties that are copied from the source feature type are declared by calls to
+ * {@link #addSourceProperty(PropertyType, boolean)} and related methods defined in this class.
+ * The methods inherited from the parent class can also be invoked,
+ * but they will receive no special treatment.</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+public final class FeatureProjectionBuilder extends FeatureTypeBuilder {
+    /**
+     * The type of features that provide the values to store in the projected features.
+     * The value of this field does not change, except when following a XPath such as {@code "a/b/c"}.
+     *
+     * @see #source()
+     */
+    private FeatureType source;
+
+    /**
+     * Whether the source is a dependency of the feature type given to the constructor.
+     * This flag become {@code true} when following a XPath of the form {@code "a/b/c"}.
+     * In such case, {@link #source} may be temporarily set to the tip {@code "c"} type.
+     *
+     * @see #using(FeatureType, FeatureExpression)
+     */
+    private boolean sourceIsDependency;
+
+    /**
+     * The properties to inherit from the {@linkplain #source} feature type by explicit user's request.
+     * The property types and the property names are not necessarily the same as in the source feature.
+     * For example, an operation may be replaced by an attribute which will store the operation result.
+     *
+     * <h4>Implementation note</h4>
+     * This collection cannot be a {@link java.util.Map} with names of the source properties as keys,
+     * because the list may contain more than one item with the same {@link Item#sourceName} value.
+     * This collision happens if some items are the results of XPath evaluations such as {@code "a/b/c"}.
+     * This is the reason why properties can be renamed before to be stored in the projected features.
+     */
+    private final List<Item> requested;
+
+    /**
+     * Names that are actually used, or may be used, in the projected feature type.
+     * For each name, the associated value is the item that is explicitly using that name.
+     * A {@code null} value means that the name is not used, but is nevertheless reserved
+     * because potentially ambiguous. This information is used for avoiding name collisions
+     * 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>
+     *
+     * @see #reserve(GenericName, Item)
+     */
+    private final Map<GenericName, Item> reservedNames;
+
+    /**
+     * Whether at least one item has a name different than the property in the source feature type.
+     * If {@code true}, then the projection cannot be an {@linkplain #isIdentity() identity} operation.
+     * Result of operations 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()
+     */
+    private boolean hasRenamedItems;
+
+    /**
+     * Sequential number for generating default names of unnamed properties. This is used when the
+     * name inherited from the {@linkplain #source} feature is unknown or collides with another name.
+     */
+    private int unnamedNumber;
+
+    /**
+     * Names of {@linkplain #source} properties that are dependencies found in 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.
+     *
+     * <p>At first, this map is populated without checking if the properties requested by the user contains
+     * those dependencies. After all user's requested properties have been declared to this builder, values
+     * are filtered for identifying which dependencies need to be added as implicit properties.</p>
+     */
+    private final Map<String, List<Item>> dependencies;
+
+    /**
+     * Creates a new builder instance using the default factories.
+     *
+     * @todo provides a way to specify the factories used by the data store.
+     *
+     * @param source  the type from which to take the properties to keep in the projected feature.
+     * @param locale  the locale to use for formatting error messages, or {@code null} for the default locale.
+     */
+    public FeatureProjectionBuilder(final FeatureType source, final Locale locale) {
+        super(null, null, locale);
+        this.source   = Objects.requireNonNull(source);
+        requested     = new ArrayList<>();
+        dependencies  = new HashMap<>();
+        reservedNames = new HashMap<>();
+    }
+
+    /**
+     * Returns the type of features that provide the values to store in the projected features.
+     * This is the type given at construction time, except when following a XPath such as
+     * {@code "a/b/c"} in which case it may temporarily be the leaf {@code "c"} type.
+     *
+     * @return the current source of properties (never {@code null}).
+     */
+    public FeatureType source() {
+        return source;
+    }
+
+    /**
+     * Returns the expected type of the given expression using the given feature type as the source.
+     * This method temporarily sets the {@linkplain #source() source} to the given {@code childType},
+     * then returns the value of {@code expression.expectedType(this)}.
+     * This is used for the last expression in a XPath such as {@code "a/b/c"}.
+     *
+     * @param  childType   the feature type to use.
+     * @param  expression  the expression from which to get the expected type.
+     * @return the expected type, or {@code null}.
+     *
+     * @see FeatureExpression#expectedType(FeatureProjectionBuilder)
+     */
+    public Item using(final FeatureType childType, final FeatureExpression<?,?> expression) {
+        final FeatureType previous = source;
+        final boolean status = sourceIsDependency;
+        try {
+            sourceIsDependency = true;
+            source = Objects.requireNonNull(childType);
+            return expression.expectedType(this);
+        } finally {
+            source = previous;
+            sourceIsDependency = status;
+        }
+    }
+
+    /**
+     * 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} list.
+     *
+     * @param  property  the {@linkplain #source} property to add.
+     * @param  deferred  where to add operation's dependencies, or {@code null} for not collecting dependencies.
+     * @return builder for the projected property, or {@code null} if it cannot be resolved.
+     */
+    private PropertyTypeBuilder addPropertyResult(PropertyType property, final List<String> deferred) {
+        if (property instanceof Operation) {
+            final GenericName name = property.getName();
+            do {
+                if (deferred != null) {
+                    if (property instanceof AbstractOperation) {
+                        deferred.addAll(((AbstractOperation) property).getDependencies());
+                    } else {
+                        /*
+                         * Cannot resolve dependencies. Current implementation assumes that there is no dependency.
+                         * Note: we could conservatively add all properties as dependencies, but this is difficult
+                         * to implement efficiently.
+                         */
+                    }
+                }
+                final IdentifiedType result = ((Operation) property).getResult();
+                if (result != property && result instanceof PropertyType) {
+                    property = (PropertyType) result;
+                } else if (result instanceof FeatureType) {
+                    return addAssociation((FeatureType) result).setName(name);
+                } else {
+                    return null;
+                }
+            } while (property instanceof Operation);
+            return addProperty(property).setName(name);
+        }
+        return addProperty(property);
+    }
+
+    /**
+     * Adds a property from the source feature type. The given property should be the result of a call to
+     * {@code source().getProperty(sourceName)}. The call to {@code getProperty(…)} is left to the caller
+     * because some callers need to wrap that call in a {@code try} block.
+     *
+     * @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.
+     */
+    public Item addSourceProperty(final PropertyType property, final boolean named) {
+        final PropertyTypeBuilder builder;
+        List<String> deferred;
+        if (sourceIsDependency) {
+            /*
+             * Adding a property which is not defined in the feature type specified at construction time,
+             * but which is defined at the tip of some XPath such as "a/b/c". This is not the same thing
+             * as adding an association. This is rather adding a subset of an association. We do not add
+             * dependency information because the dependencies are not directly in the source feature.
+             */
+            reserve(property.getName(), null);
+            deferred = new ArrayList<>();
+            builder = addPropertyResult(property, deferred);
+        } else {
+            /*
+             * For link operations, remember the dependencies in order to determine (after we added all properties)
+             * if we can keep the property as an operation or if we will need to copy the value in an attribute.
+             * For other kind of operations, unconditionally replace the operation by its result.
+             */
+            deferred = Features.getLinkTargets(property);
+            if (deferred.isEmpty()) {
+                deferred = new ArrayList<>();
+                builder = addPropertyResult(property, deferred);
+            } else {
+                builder = addProperty(property);
+            }
+        }
+        final var item = new Item(named ? property.getName() : null, builder);
+        requested.add(item);
+        for (String dependency : deferred) {
+            dependencies.computeIfAbsent(dependency, (key) -> new ArrayList<>(2)).add(item);
+        }
+        return item;
+    }
+
+    /**
+     * Adds an optional attribute type for values of the specified class.
+     * This method can be invoked as an alternative to {@link #addSourceProperty(PropertyType, boolean)}
+     * when the source property cannot be found and a fallback is added instead.
+     *
+     * @param  name  the name in the source feature.
+     * @param  type  the class of attribute values.
+     * @return handler for the given item.
+     */
+    public Item addOptionalAttribute(final String name, final Class<?> type) {
+        final AttributeTypeBuilder<?> builder = addAttribute(type).setMinimumOccurs(0);
+        final var item = new Item(builder.setName(name).getName(), builder);
+        requested.add(item);
+        return item;
+    }
+
+    /**
+     * Adds a mandatory attribute type for values of the specified class.
+     * The attribute is assumed to be computed by a function of the given name.
+     * This is usually not the name of a property in the {@linkplain #source} feature.
+     *
+     * @param  name  name of the function computing the attribute value.
+     * @param  type  the class of attribute values.
+     * @return handler for the given item.
+     */
+    public Item addComputedAttribute(final GenericName name, final Class<?> type) {
+        final var item = new Item(null, addAttribute(type).setName(name));
+        requested.add(item);
+        return item;
+    }
+
+    /**
+     * 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)}.
+     * 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.
+     */
+    public final class Item {
+        /**
+         * The name that the property had in the {@linkplain #source() source} feature, or {@code null}.
+         * The property built by the {@linkplain #builder} will often have the same name, but not always.
+         */
+        final GenericName sourceName;
+
+        /**
+         * The builder for configuring the property.
+         */
+        private PropertyTypeBuilder builder;
+
+        /**
+         * Whether this item got an explicit name. The specified name may be
+         * identical to the name in the {@linkplain #source() source} feature.
+         */
+        private boolean isNamed;
+
+        /**
+         * Whether to keep the current name if it is available. This is set to {@code true} when user did not
+         * specified explicitly a name, but keeping the name of the source property would be a natural choice.
+         * However, before to use that name, we need to wait and see if that name will be explicitly used for
+         * another property.
+         */
+        private boolean preferCurrentName;
+
+        /**
+         * Whether this property needs at least one dependency which is not included in the list of properties
+         * requested by the user. In such case, we cannot keep the link operation and need to replace the link
+         * by a stored attribute.
+         *
+         * @see #replaceIfMissingDependency()
+         */
+        private boolean hasMissingDependency;
+
+        /**
+         * Expression for evaluating the attribute value from a source feature instance, or {@code null} if none.
+         * This field should be non-null only if the value will be stored in an attribute. If the property is an
+         * operation, then this field should be null (this is not the expression of the operation).
+         *
+         * @see #attributeValueGetter()
+         */
+        private Expression<? super Feature, ?> attributeValueGetter;
+
+        /**
+         * Creates a new handle for the property created by the given builder.
+         *
+         * @param  sourceName  the property name in the {@linkplain #source() source} feature, or {@code null}.
+         * @param  builder     the builder for configuring the property.
+         */
+        private Item(final GenericName sourceName, final PropertyTypeBuilder builder) {
+            this.sourceName = sourceName;
+            this.builder = builder;
+        }
+
+        /**
+         * Returns a string representation for debugging purposes.
+         */
+        @Override
+        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);
+        }
+
+        /**
+         * 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.
+         */
+        private void replaceIfMissingDependency() {
+            if (hasMissingDependency) {
+                hasMissingDependency = false;
+                final var old = builder;
+                builder = addPropertyResult(old.build(), null);   // `old.build()` returns the existing operation.
+                old.replaceBy(builder);
+            }
+        }
+
+        /**
+         * Sets the class of attribute values. If the builder is an instance of {@link AttributeTypeBuilder}
+         * and if {@code type.apply(valueClass)} returns a non-null value ({@code valueClass} is the current
+         * 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}.
+         *
+         * @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}.
+         * @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) {
+                    builder = ab.setValueClass(r);
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * 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.
+         *
+         * @param  expression  the expression to be evaluated by the operation.
+         */
+        public void setValueGetter(final Expression<? super Feature, ?> expression, final boolean stored) {
+            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);
+                }
+            } else {
+                // The property is an operation, usually a link. Leave it as-is.
+            }
+        }
+
+        /**
+         * Returns the expression for evaluating the value to store in the attribute built by this item.
+         * The expression may be {@code null} if the value is computed on-the-fly (i.e. the property is
+         * an operation), or if the expression has not been specified.
+         */
+        final Expression<? super Feature, ?> attributeValueGetter() {
+            return attributeValueGetter;
+        }
+
+        /**
+         * Sets the coordinate reference system that characterizes the values of this attribute.
+         *
+         * @param  crs  coordinate reference system associated to attribute values, or {@code null}.
+         * @return {@code this} for method calls chaining.
+         */
+        public Item setCRS(final CoordinateReferenceSystem crs) {
+            if (builder instanceof AttributeTypeBuilder<?>) {
+                builder = ((AttributeTypeBuilder<?>) builder).setCRS(crs);
+            }
+            return this;
+        }
+
+        /**
+         * Returns whether the property built by this item is equivalent to the given property.
+         * The current implementation bases its test only on the property name.
+         * This is reliable only if {@link #hasRenamedItems} is {@code false}.
+         *
+         * @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());
+        }
+
+        /**
+         * 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)}.
+         *
+         * @return the name of the projected property.
+         */
+        public String getName() {
+            return builder.getName().toString();
+        }
+
+        /**
+         * Sets the name of the projected property. A {@code null} argument means that the name is unspecified,
+         * in which case a different name may be generated later if the current name collides with other names.
+         *
+         * <p>This method should be invoked exactly once for each item, even if the argument is {@code null}.
+         * The reason is because this method uses this information for recording which names to reserve.</p>
+         *
+         * @param  targetName  the desired name in the projected feature, or {@code null} if unspecified.
+         */
+        public void setName(final GenericName targetName) {
+            if (targetName == null) {
+                reserve(sourceName, null);      // Will use that name only if not owned by another item.
+                preferCurrentName = true;
+            } else if (targetName.equals(sourceName)) {
+                reserve(sourceName, this);      // Take possession of that name.
+                isNamed = true;
+            } else {
+                builder.setName(targetName);
+                reserve(targetName, this);
+                hasRenamedItems = true;
+                isNamed = true;
+            }
+        }
+
+        /**
+         * 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.
+         */
+        private void validateName() {
+            if (!isNamed) {
+                final Item owner = reservedNames.get(sourceName);
+                if (owner != this) {
+                    GenericName name = sourceName;
+                    if (owner != null || name == null || (!preferCurrentName && reservedNames.containsKey(name))) {
+                        do {
+                            var text = Vocabulary.formatInternational(Vocabulary.Keys.Unnamed_1, ++unnamedNumber);
+                            name = builder.setName(text).getName();     // Local name with the appropriate name space.
+                        } while (reservedNames.containsKey(name));      // Reminder: the associated value may be null.
+                    }
+                    reserve(name, this);
+                }
+                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).
+     */
+    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)) {
+                    /*
+                     * If we want to support that feature in a future version, we would need a `replace` method
+                     * for replacing a builder at a specific index or for a specific property name. A difficulty
+                     * is that for compound identifiers, we have no API for reusing the same prefix and suffix.
+                     */
+                    throw new UnsupportedOperationException("Renaming of properties used in links is not yet supported.");
+                }
+            } else {
+                for (Item dependent : entry.getValue()) {
+                    dependent.hasMissingDependency = true;
+                }
+                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 (hasRenamedItems) {
+            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
+     * type augmented with dependencies of operations such as links.
+     *
+     * <p>This method should be invoked exactly once.</p>
+     *
+     * <h4>Identity operation</h4>
+     * If the result is a feature type with all the properties of the source feature,
+     * with the same property names in the same order, and if the expressions are only
+     * fetching the values (no computation), then this method returns an empty value
+     * for meaning that this projection does nothing.
+     *
+     * @return the feature types with and without dependencies, or empty if there is no projection.
+     */
+    public Optional<FeatureProjection> project() {
+        requested.forEach(Item::validateName);
+        /*
+         * Add properties for all dependencies that are required by link operations but are not already present.
+         * If there is no need to add anything, `typeWithDependencies` will be directly the feature type to return.
+         */
+        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()) {
+            typeRequested = typeWithDependencies = build();
+        } else {
+            do {
+                for (PropertyType property : deferred) {
+                    final Item item = addSourceProperty(property, true);
+                    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.
+            requested.forEach(Item::replaceIfMissingDependency);
+            typeRequested = build();
+        }
+        if (source.equals(typeRequested) && source.equals(typeWithDependencies)) {
+            return Optional.empty();
+        }
+        return Optional.of(new FeatureProjection(typeRequested, typeWithDependencies, requested));
+    }
+}
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureView.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureView.java
new file mode 100644
index 0000000000..a0dac0a57f
--- /dev/null
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureView.java
@@ -0,0 +1,131 @@
+/*
+ * 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.privy;
+
+import org.apache.sis.feature.AbstractFeature;
+
+// 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.Property;
+
+
+/**
+ * A feature containing a subset of the properties of another feature.
+ * The feature type of the view must be specified in argument together with the source feature instance.
+ * All properties that are present in this feature view shall have the same name as in the source feature.
+ * This class does not verify that requirement.
+ *
+ * <h2>Limitations</h2>
+ * For performance and simplicity reasons, the current implementation does not prevent users
+ * from requesting a property that exists in the full feature instance but not in this subset.
+ *
+ * <h2>Possible evolution</h2>
+ * It would be possible for the view to contain operations that are not present in the source features.
+ * This extension has not yet been implemented because not yet needed.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+final class FeatureView extends AbstractFeature {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = -4299168913599597991L;
+
+    /**
+     * The instance with all properties.
+     */
+    @SuppressWarnings("serial")     // Apache SIS implementations are serializable.
+    private final Feature source;
+
+    /**
+     * Creates a new feature instance which is a subset of the given {@code source} feature.
+     * This constructor does not verify the consistency of the two arguments.
+     *
+     * @param  subset  the feature type that describes the feature subset.
+     * @param  source  the complete feature instance to view as a subset.
+     */
+    FeatureView(final FeatureType subset, final Feature source) {
+        super(subset);
+        this.source = source;
+    }
+
+    /**
+     * Returns the property (attribute, feature association or operation result) of the given name.
+     * This method delegates to the wrapped source without checking whether the given name exists in this subset.
+     *
+     * @param  name  the property name.
+     * @return the property of the given name (never {@code null}).
+     */
+    @Override
+    public Property getProperty(final String name) {
+        return source.getProperty(name);
+    }
+
+    /**
+     * Sets the property (attribute or feature association).
+     * This method delegates to the wrapped source without checking whether the given name exists in this subset.
+     *
+     * @param  property  the property to set.
+     */
+    @Override
+    public void setProperty(final Property property) {
+        source.setProperty(property);
+    }
+
+    /**
+     * Returns the value for the property of the given name.
+     * This method delegates to the wrapped source without checking whether the given name exists in this subset.
+     *
+     * @param  name  the property name.
+     * @return value of the specified property, or the default value (which may be {@code null}} if none.
+     */
+    @Override
+    public Object getPropertyValue(final String name) {
+        return source.getPropertyValue(name);
+    }
+
+    /**
+     * Sets the value for the property of the given name.
+     * This method delegates to the wrapped source without checking whether the given name exists in this subset.
+     *
+     * @param  name   the property name.
+     * @param  value  the new value for the specified property (may be {@code null}).
+     */
+    @Override
+    public void setPropertyValue(final String name, final Object value) {
+        source.setPropertyValue(name, value);
+    }
+
+    /**
+     * Returns the value for the property of the given name if that property exists, or a fallback value otherwise.
+     * This method delegates to the wrapped source without checking whether the given name exists in this subset.
+     *
+     * <h4>Design note</h4>
+     * We could add a verification of whether the property exists in the feature type given by {@link #getType()}.
+     * We don't do that for now because the current usages of this method in the Apache SIS code base do not need
+     * this method to be strict, and for consistency with the behavior of other methods in this class.
+     *
+     * @param  name  the property name.
+     * @param  missingPropertyFallback  the value to return if no attribute or association of the given name exists.
+     * @return value or default value of the specified property, or {@code missingPropertyFallback}.
+     */
+    @Override
+    public Object getValueOrFallback(final String name, final Object missingPropertyFallback) {
+        return source.getValueOrFallback(name, missingPropertyFallback);
+    }
+}
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/ArithmeticFunction.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/ArithmeticFunction.java
index f5a9c9a2ed..eabd8cdf58 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/ArithmeticFunction.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/ArithmeticFunction.java
@@ -19,9 +19,8 @@ package org.apache.sis.filter;
 import java.math.BigDecimal;
 import java.math.BigInteger;
 import org.opengis.util.ScopedName;
-import org.apache.sis.feature.builder.FeatureTypeBuilder;
-import org.apache.sis.feature.builder.PropertyTypeBuilder;
 import org.apache.sis.feature.privy.FeatureExpression;
+import org.apache.sis.feature.privy.FeatureProjectionBuilder;
 import org.apache.sis.filter.privy.FunctionNames;
 import org.apache.sis.util.UnconvertibleObjectException;
 import org.apache.sis.util.resources.Errors;
@@ -29,7 +28,6 @@ import org.apache.sis.math.Fraction;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
 import org.opengis.feature.AttributeType;
-import org.opengis.feature.FeatureType;
 import org.opengis.filter.Expression;
 
 
@@ -88,8 +86,8 @@ abstract class ArithmeticFunction<R> extends BinaryFunction<R,Number,Number>
      * on the {@code ArithmeticFunction} subclass and is given by {@link #expectedType()}.
      */
     @Override
-    public final PropertyTypeBuilder expectedType(FeatureType ignored, FeatureTypeBuilder addTo) {
-        return addTo.addProperty(expectedType());
+    public final FeatureProjectionBuilder.Item expectedType(FeatureProjectionBuilder addTo) {
+        return addTo.addSourceProperty(expectedType(), false);
     }
 
     /**
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/AssociationValue.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/AssociationValue.java
index 5597c0dc3e..1ab716a0a3 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/AssociationValue.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/AssociationValue.java
@@ -22,8 +22,7 @@ import java.util.List;
 import java.util.Collection;
 import java.util.Optional;
 import org.apache.sis.feature.Features;
-import org.apache.sis.feature.builder.FeatureTypeBuilder;
-import org.apache.sis.feature.builder.PropertyTypeBuilder;
+import org.apache.sis.feature.privy.FeatureProjectionBuilder;
 import org.apache.sis.math.FunctionProperty;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
@@ -38,8 +37,8 @@ import org.opengis.filter.ValueReference;
 
 /**
  * Expression whose value is computed by retrieving the value indicated by the provided path.
- * This is used for value reference given by XPath such as "a/b/c". The last element of the path
- * (the tip) is evaluated by a {@link PropertyValue}.
+ * This is used for value reference given by a limited form of XPath such as {@code "a/b/c"}.
+ * The last element of the path (the tip) is evaluated by a {@link PropertyValue}.
  *
  * @author  Martin Desruisseaux (Geomatys)
  *
@@ -202,15 +201,16 @@ walk:   if (specifiedType != null) try {
     }
 
     /**
-     * Provides the expected type of values produced by this expression when a feature of the given type is evaluated.
+     * Provides the expected type of values produced by this expression. This method delegates to
+     * {@link #accessor} after setting the source feature type to the tip of {@code "a/b/c"} path.
      *
-     * @param  valueType  the type of features to be evaluated by the given expression.
-     * @param  addTo      where to add the type of properties evaluated by the given expression.
+     * @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.
-     * @throws IllegalArgumentException if this method cannot determine the property type for the given feature type.
+     * @throws PropertyNotFoundException if the property was not found in {@code addTo.source()}.
      */
     @Override
-    public PropertyTypeBuilder expectedType(FeatureType valueType, final FeatureTypeBuilder addTo) {
+    public FeatureProjectionBuilder.Item expectedType(final FeatureProjectionBuilder addTo) {
+        FeatureType valueType = addTo.source();
         for (final String p : path) {
             final PropertyType type;
             try {
@@ -218,7 +218,7 @@ walk:   if (specifiedType != null) try {
             } catch (PropertyNotFoundException e) {
                 if (accessor.isVirtual) {
                     // The association does not exist but may be defined on a yet unknown child type.
-                    return accessor.expectedType(addTo);
+                    return accessor.defaultType(addTo);
                 }
                 throw e;
             }
@@ -227,7 +227,7 @@ walk:   if (specifiedType != null) try {
             }
             valueType = ((FeatureAssociationRole) type).getValueType();
         }
-        return accessor.expectedType(valueType, addTo);
+        return addTo.using(valueType, accessor);
     }
 
     /**
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/ConvertFunction.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/ConvertFunction.java
index 3e9d2f3110..c614215ed9 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/ConvertFunction.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/ConvertFunction.java
@@ -23,16 +23,13 @@ import org.opengis.util.ScopedName;
 import org.apache.sis.util.ObjectConverter;
 import org.apache.sis.util.ObjectConverters;
 import org.apache.sis.util.UnconvertibleObjectException;
-import org.apache.sis.feature.builder.FeatureTypeBuilder;
-import org.apache.sis.feature.builder.PropertyTypeBuilder;
-import org.apache.sis.feature.builder.AttributeTypeBuilder;
 import org.apache.sis.feature.privy.FeatureExpression;
+import org.apache.sis.feature.privy.FeatureProjectionBuilder;
 import org.apache.sis.math.FunctionProperty;
 import org.apache.sis.util.resources.Errors;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
 import org.opengis.filter.Expression;
-import org.opengis.feature.FeatureType;
 
 
 /**
@@ -166,20 +163,20 @@ final class ConvertFunction<R,S,V> extends UnaryFunction<R,S>
     }
 
     /**
-     * Provides the type of values produced by this expression when a feature of the given type is evaluated.
+     * Provides the type of values produced by this expression.
      * May return {@code null} if the type cannot be determined.
+     *
+     * @throws UnconvertibleObjectException if the property default value cannot be converted to the expected type.
      */
     @Override
-    public PropertyTypeBuilder expectedType(final FeatureType valueType, final FeatureTypeBuilder addTo) {
+    public FeatureProjectionBuilder.Item expectedType(final FeatureProjectionBuilder addTo) {
         final FeatureExpression<?,?> fex = FeatureExpression.castOrCopy(expression);
         if (fex == null) {
             return null;
         }
-        final PropertyTypeBuilder p = fex.expectedType(valueType, addTo);
-        if (p instanceof AttributeTypeBuilder<?>) {
-            return ((AttributeTypeBuilder<?>) p).setValueClass(getValueClass());
-        }
-        return p;
+        final FeatureProjectionBuilder.Item item = fex.expectedType(addTo);
+        item.replaceValueClass((c) -> getValueClass());
+        return item;
     }
 
     /**
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/DefaultFilterFactory.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/DefaultFilterFactory.java
index c020754996..9133bec01e 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/DefaultFilterFactory.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/DefaultFilterFactory.java
@@ -20,6 +20,7 @@ import java.util.Map;
 import java.util.HashMap;
 import java.util.Collection;
 import java.util.ServiceLoader;
+import java.util.Optional;
 import javax.measure.Quantity;
 import javax.measure.quantity.Length;
 import org.opengis.geometry.Envelope;
@@ -138,6 +139,30 @@ public abstract class DefaultFilterFactory<R,G,T> extends AbstractFactory implem
         return Features.DEFAULT;
     }
 
+    /**
+     * Returns a factory operating on resource instances of the given class.
+     * The current implementation recognizes the following classes:
+     *
+     * <ul>
+     *   <li>{@link Feature}: delegate to {@link #forFeatures()}.</li>
+     * </ul>
+     *
+     * More classes may be added in future versions.
+     *
+     * @param  <R>   compile-time value of the {@code type} argument.
+     * @param  type  type of resources that the factory shall accept.
+     * @return factory operating on resource instances of the given class.
+     * @since 1.5
+     */
+    @SuppressWarnings("unchecked")
+    public static <R> Optional<FilterFactory<R, Object, Object>> forResources(final Class<R> type) {
+        if (type.equals(Feature.class)) {
+            return Optional.of((FilterFactory<R, Object, Object>) forFeatures());
+        } else {
+            return Optional.empty();
+        }
+    }
+
     /**
      * Describes the abilities of this factory. The description includes restrictions on
      * the available spatial operations, scalar operations, lists of supported functions,
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/InvalidXPathException.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/InvalidXPathException.java
new file mode 100644
index 0000000000..3a49222670
--- /dev/null
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/InvalidXPathException.java
@@ -0,0 +1,81 @@
+/*
+ * 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.filter;
+
+// Specific to the geoapi-3.1 and geoapi-4.0 branches:
+import org.opengis.filter.InvalidFilterValueException;
+
+
+/**
+ * Exceptions thrown when the XPath in an expression is invalid or unsupported.
+ * Apache SIS currently supports only a small subset of XPath syntax, mostly paths of
+ * the form {@code "a/b/c"} (and not everywhere) and the {@code "Q{namespace}"} syntax.
+ *
+ * <h4>Relationship with standard libraries</h4>
+ * The standard Java libraries provides a {@link javax.xml.xpath.XPathException}.
+ * This {@code InvalidXPathException} differs in that it is an unchecked exception
+ * and is thrown in the context of operations with OGC filters and expressions.
+ * In some implementations, {@code InvalidXPathException} may have a {@code XPathException} as its cause.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.5
+ *
+ * @see javax.xml.xpath.XPathException
+ *
+ * @since 1.5
+ */
+public class InvalidXPathException extends InvalidFilterValueException {
+    /**
+     * Serial number for inter-operability with different versions.
+     */
+    private static final long serialVersionUID = 1654277877397802378L;
+
+    /**
+     * Creates an exception with no message.
+     */
+    public InvalidXPathException() {
+        super();
+    }
+
+    /**
+     * Creates an exception with the specified message.
+     *
+     * @param message  the detail message, saved for later retrieval by the {@link #getMessage()} method.
+     */
+    public InvalidXPathException(final String message) {
+        super(message);
+    }
+
+    /**
+     * Creates an exception with the specified cause.
+     *
+     * @param cause  the cause, saved for later retrieval by the {@link #getCause()} method.
+     */
+    public InvalidXPathException(final Throwable cause) {
+        super(cause);
+    }
+
+    /**
+     * Creates an exception with the specified message and cause.
+     *
+     * @param message  the detail message, saved for later retrieval by the {@link #getMessage()} method.
+     * @param cause    the cause, saved for later retrieval by the {@link #getCause()} method.
+     */
+    public InvalidXPathException(final String message, final Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/LeafExpression.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/LeafExpression.java
index 308c9adc7c..a869d79ae4 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/LeafExpression.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/LeafExpression.java
@@ -27,13 +27,11 @@ import org.apache.sis.util.iso.Names;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.collection.WeakValueHashMap;
 import org.apache.sis.feature.privy.FeatureExpression;
+import org.apache.sis.feature.privy.FeatureProjectionBuilder;
 import org.apache.sis.filter.internal.Node;
-import org.apache.sis.feature.builder.FeatureTypeBuilder;
-import org.apache.sis.feature.builder.PropertyTypeBuilder;
 import org.apache.sis.math.FunctionProperty;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
-import org.opengis.feature.FeatureType;
 import org.opengis.feature.AttributeType;
 import org.opengis.filter.Expression;
 
@@ -151,14 +149,15 @@ abstract class LeafExpression<R,V> extends Node implements FeatureExpression<R,V
         }
 
         /**
-         * Provides the type of values returned by {@link #apply(Object)}
-         * wrapped in an {@link AttributeType} named "Literal".
+         * Provides the type of values returned by {@link #apply(Object)}.
+         * The returned item wraps an {@link AttributeType} named "Literal".
+         * The attribute type is determined by the class of the {@linkplain #value}.
          *
          * @param  addTo  where to add the type of properties evaluated by the given expression.
-         * @return builder of the added property.
+         * @return handler for the added property.
          */
         @Override
-        public PropertyTypeBuilder expectedType(FeatureType ignored, final FeatureTypeBuilder addTo) {
+        public FeatureProjectionBuilder.Item expectedType(final FeatureProjectionBuilder addTo) {
             final Class<?> valueType = getValueClass();
             AttributeType<?> propertyType;
             synchronized (TYPES) {
@@ -171,7 +170,7 @@ abstract class LeafExpression<R,V> extends Node implements FeatureExpression<R,V
                     }
                 }
             }
-            return addTo.addProperty(propertyType);
+            return addTo.addSourceProperty(propertyType, true);
         }
 
         /**
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 bb62d7559c..473b98d4db 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
@@ -23,9 +23,7 @@ import org.apache.sis.feature.Features;
 import org.apache.sis.util.ObjectConverter;
 import org.apache.sis.util.ObjectConverters;
 import org.apache.sis.util.UnconvertibleObjectException;
-import org.apache.sis.feature.builder.FeatureTypeBuilder;
-import org.apache.sis.feature.builder.PropertyTypeBuilder;
-import org.apache.sis.feature.builder.AttributeTypeBuilder;
+import org.apache.sis.feature.privy.FeatureProjectionBuilder;
 import org.apache.sis.filter.privy.XPath;
 import org.apache.sis.util.resources.Errors;
 
@@ -34,8 +32,6 @@ import org.opengis.feature.Feature;
 import org.opengis.feature.FeatureType;
 import org.opengis.feature.PropertyType;
 import org.opengis.feature.AttributeType;
-import org.opengis.feature.IdentifiedType;
-import org.opengis.feature.Operation;
 import org.opengis.feature.PropertyNotFoundException;
 import org.opengis.filter.ValueReference;
 
@@ -68,8 +64,8 @@ abstract class PropertyValue<V> extends LeafExpression<Feature,V>
 
     /**
      * Whether the property to fetch is considered virtual (a property that may be defined only in sub-types).
-     * If {@code true}, then {@link #expectedType(FeatureType, FeatureTypeBuilder)} will not throw an exception
-     * if the property is not found.
+     * If {@code true}, then {@link #expectedType(FeatureProjectionBuilder)} will not throw an exception when
+     * the property is not found.
      */
     protected final boolean isVirtual;
 
@@ -172,11 +168,15 @@ abstract class PropertyValue<V> extends LeafExpression<Feature,V>
     }
 
     /**
-     * Returns the default value of {@link #expectedType(FeatureType, FeatureTypeBuilder)}
-     * when it cannot be inferred by the analysis of the given {@code FeatureType}.
+     * Returns the value of {@code expectedType(…)} to return as a fallback when
+     * that value cannot be inferred by the analysis of the source feature type.
+     * It happens when {@link #isVirtual} is {@code true} and the property to add may be
+     * defined in a feature sub-type not known at {@code expectedType(…)} invocation time.
+     *
+     * @see #expectedType(FeatureProjectionBuilder)
      */
-    final PropertyTypeBuilder expectedType(final FeatureTypeBuilder addTo) {
-        return addTo.addAttribute(getValueClass()).setName(name).setMinimumOccurs(0);
+    final FeatureProjectionBuilder.Item defaultType(final FeatureProjectionBuilder addTo) {
+        return addTo.addOptionalAttribute(name, getValueClass());
     }
 
     /**
@@ -349,53 +349,48 @@ abstract class PropertyValue<V> extends LeafExpression<Feature,V>
         }
 
         /**
-         * Provides the expected type of values produced by this expression
-         * when a feature of the given type is evaluated.
+         * Provides the expected type of values produced by this expression.
+         * If the converted {@linkplain #type} is a generalization of the attribute type,
+         * the original attribute type is kept unchanged because {@link #apply(Feature)}
+         * does not convert those values.
+         *
+         * @throws UnconvertibleObjectException if the property default value cannot be converted to {@link #type}.
          */
         @Override
-        public final PropertyTypeBuilder expectedType(final FeatureType valueType, final FeatureTypeBuilder addTo) {
-            final PropertyTypeBuilder p = super.expectedType(valueType, addTo);
-            if (p instanceof AttributeTypeBuilder<?>) {
-                final AttributeTypeBuilder<?> a = (AttributeTypeBuilder<?>) p;
-                if (!type.isAssignableFrom(a.getValueClass())) {
-                    return a.setValueClass(type);
-                }
-            }
-            return p;
+        public final FeatureProjectionBuilder.Item expectedType(final FeatureProjectionBuilder addTo) {
+            FeatureProjectionBuilder.Item item = super.expectedType(addTo);
+            item.replaceValueClass((c) -> type.isAssignableFrom(c) ? c : type);
+            return item;
         }
     }
 
     /**
      * Provides the expected type of values produced by this expression when a feature of the given type is evaluated.
+     * The source feature type is specified indirectly by {@link FeatureProjectionBuilder#source()}.
      *
-     * @param  valueType  the type of features to be evaluated by the given expression.
-     * @param  addTo      where to add the type of properties evaluated by the given expression.
+     * <h4>Handling of operations</h4>
+     * Properties that are operations are replaced by attributes where the operation result will be stored.
+     * An exception to this rule is the links such as {@code "sis:identifier"} and {@code "sis:geometry"},
+     * 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.
-     * @throws IllegalArgumentException if this method cannot determine the property type for the given feature type.
+     * @throws PropertyNotFoundException if the property was not found in {@code addTo.source()}.
      */
     @Override
-    public PropertyTypeBuilder expectedType(final FeatureType valueType, final FeatureTypeBuilder addTo) {
+    public FeatureProjectionBuilder.Item expectedType(final FeatureProjectionBuilder addTo) {
         PropertyType type;
         try {
-            type = valueType.getProperty(name);
+            type = addTo.source().getProperty(name);
         } catch (PropertyNotFoundException e) {
             if (isVirtual) {
-                // The property does not exist but may be defined on a yet unknown child type.
-                return expectedType(addTo);
+                // The property does not exist but may be defined in a yet unknown child type.
+                return defaultType(addTo);
             }
             throw e;
         }
-        while (type instanceof Operation) {
-            final IdentifiedType result = ((Operation) type).getResult();
-            if (result != type && result instanceof PropertyType) {
-                type = (PropertyType) result;
-            } else if (result instanceof FeatureType) {
-                return addTo.addAssociation((FeatureType) result).setName(name);
-            } else {
-                return null;
-            }
-        }
-        return addTo.addProperty(type);
+        return addTo.addSourceProperty(type, true);
     }
 
 
diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/privy/XPath.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/privy/XPath.java
index 30930dc451..679b7631c8 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/privy/XPath.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/privy/XPath.java
@@ -23,6 +23,7 @@ import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.iso.DefaultNameSpace;
 import static org.apache.sis.util.CharSequences.*;
 import org.apache.sis.feature.internal.Resources;
+import org.apache.sis.filter.InvalidXPathException;
 
 
 /**
@@ -214,6 +215,7 @@ public final class XPath {
     /**
      * Rewrites a property name as an XPath.
      * The path components are assumed already parsed (no braced URI literals).
+     * If the tip contain path separator ({@code '/'}), the path will be escaped in a {@code Q{…}} form.
      *
      * @param  prefix  a prefix, or {@code null} if none.
      * @param  path    components of the XPath before the tip, or {@code null} if none.
@@ -233,19 +235,31 @@ public final class XPath {
         }
     }
 
+    /**
+     * Returns a XPath for the given property name. In the usual case where the given name
+     * does not use reserved characters, that name is returned as-is.
+     *
+     * @param  name  the property name.
+     * @return the given name as an XPath.
+     */
+    public static String fromPropertyName(final String name) {
+        // TODO: do we need to escape '{' and '}'?
+        return toString(null, null, name);
+    }
+
     /**
      * Rewrites the XPath in a form accepted by feature properties.
      * This is the tip, without {@code "Q{namespace}"} escaping.
      *
      * @param  xpath  the XPath to convert to a property name.
      * @return the XPath as a property name without escape syntax for qualified URIs.
-     * @throws IllegalArgumentException if the given XPath contains a path instead of only a tip.
+     * @throws InvalidXPathException if the given XPath contains a path instead of only a tip.
      */
     public static String toPropertyName(final String xpath) {
         final var x = new XPath(xpath);
         if (x.path == null) {
             return x.tip;
         }
-        throw new IllegalArgumentException(Resources.format(Resources.Keys.PropertyNameCannotBeXPath_1, xpath));
+        throw new InvalidXPathException(Resources.format(Resources.Keys.PropertyNameCannotBeXPath_1, xpath));
     }
 }
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 3e56cef2da..2d751dd31e 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
@@ -24,14 +24,11 @@ import org.opengis.util.FactoryException;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.referencing.CRS;
 import org.apache.sis.referencing.factory.InvalidGeodeticParameterException;
-import org.apache.sis.feature.builder.FeatureTypeBuilder;
-import org.apache.sis.feature.builder.PropertyTypeBuilder;
-import org.apache.sis.feature.builder.AttributeTypeBuilder;
+import org.apache.sis.feature.privy.FeatureProjectionBuilder;
 import org.apache.sis.util.privy.Constants;
 import org.apache.sis.util.resources.Errors;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
-import org.opengis.feature.FeatureType;
 import org.opengis.filter.Literal;
 import org.opengis.filter.Expression;
 import org.opengis.filter.InvalidFilterValueException;
@@ -213,20 +210,15 @@ search: if (crs instanceof CoordinateReferenceSystem) {
     }
 
     /**
-     * Provides the type of values produced by this expression when a feature of the given type is evaluated.
+     * 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  valueType  the type of features on which to apply this expression.
-     * @param  addTo      where to add the type of properties evaluated by this expression.
-     * @return builder of type resulting from expression evaluation (never null).
-     * @throws IllegalArgumentException if the given feature type does not contain the expected properties.
+     * @param  addTo  where to add the type of properties evaluated by this expression.
+     * @return handler of type resulting from expression evaluation (never null).
      */
     @Override
-    public PropertyTypeBuilder expectedType(final FeatureType valueType, final FeatureTypeBuilder addTo) {
-        final PropertyTypeBuilder pt = super.expectedType(valueType, addTo);
-        if (pt instanceof AttributeTypeBuilder<?>) {
-            // We must unconditionally override the CRS set by parent class.
-            ((AttributeTypeBuilder<?>) pt).setCRS(literalCRS ? targetCRS : null);
-        }
-        return pt;
+    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);
     }
 }
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 c127135a4b..92e5900a57 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
@@ -24,16 +24,13 @@ import org.apache.sis.filter.Optimization;
 import org.apache.sis.filter.internal.Node;
 import org.apache.sis.feature.internal.Resources;
 import org.apache.sis.feature.privy.FeatureExpression;
+import org.apache.sis.feature.privy.FeatureProjectionBuilder;
 import org.apache.sis.geometry.wrapper.Geometries;
-import org.apache.sis.feature.builder.FeatureTypeBuilder;
-import org.apache.sis.feature.builder.PropertyTypeBuilder;
-import org.apache.sis.feature.builder.AttributeTypeBuilder;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.iso.Names;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
-import org.opengis.feature.FeatureType;
 import org.opengis.filter.Expression;
 import org.opengis.filter.InvalidFilterValueException;
 
@@ -171,7 +168,7 @@ abstract class SpatialFunction<R> extends Node implements FeatureExpression<R,Ob
     }
 
     /**
-     * Provides the type of values produced by this expression when a feature of the given type is evaluated.
+     * Provides the type of values produced by this expression.
      * There are two cases:
      *
      * <ul class="verbose">
@@ -181,33 +178,28 @@ abstract class SpatialFunction<R> extends Node implements FeatureExpression<R,Ob
      *   <li>Otherwise an attribute is created with the return value specified by the operation.</li>
      * </ul>
      *
-     * @param  valueType  the type of features on which to apply this expression.
-     * @param  addTo      where to add the type of properties evaluated by this expression.
+     * @param  addTo  where to add the type of properties evaluated by this expression.
      * @return builder of type resulting from expression evaluation (never null).
-     * @throws InvalidFilterValueException if the given feature type does not contain the expected properties,
+     * @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.
      */
     @Override
-    public PropertyTypeBuilder expectedType(final FeatureType valueType, final FeatureTypeBuilder addTo) {
-        AttributeTypeBuilder<?> att;
-cases:  if (operation.isGeometryInOut()) {
+    public FeatureProjectionBuilder.Item expectedType(final FeatureProjectionBuilder addTo) {
+        if (operation.isGeometryInOut()) {
             final FeatureExpression<?,?> fex = FeatureExpression.castOrCopy(getParameters().get(0));
             if (fex != null) {
-                final PropertyTypeBuilder type = fex.expectedType(valueType, addTo);
-                if (type instanceof AttributeTypeBuilder<?>) {
-                    att = (AttributeTypeBuilder<?>) type;
-                    final Geometries<?> library = Geometries.factory(att.getValueClass());
-                    if (library != null) {
-                        att = att.setValueClass(operation.getReturnType(library));
-                        break cases;
-                    }
+                final FeatureProjectionBuilder.Item item = fex.expectedType(addTo);
+                final boolean success = item.replaceValueClass((c) -> {
+                    final Geometries<?> library = Geometries.factory(c);
+                    return (library == null) ? null : operation.getReturnType(library);
+                });
+                if (success) {
+                    return item;
                 }
             }
             throw new InvalidFilterValueException(Resources.format(Resources.Keys.NotAGeometryAtFirstExpression));
-        } else {
-            att = addTo.addAttribute(getValueClass());
         }
-        return att.setName(getFunctionName());
+        return addTo.addComputedAttribute(getFunctionName(), getValueClass());
     }
 }
diff --git a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureIterator.java b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureIterator.java
index 428b947eac..4636952051 100644
--- a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureIterator.java
+++ b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureIterator.java
@@ -25,7 +25,7 @@ import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import org.apache.sis.metadata.sql.privy.SQLBuilder;
-import org.apache.sis.storage.base.FeatureProjection;
+import org.apache.sis.feature.privy.FeatureProjection;
 import org.apache.sis.util.collection.WeakValueHashMap;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
@@ -107,21 +107,9 @@ final class FeatureIterator implements Spliterator<Feature>, AutoCloseable {
      * Additional properties to compute from the main properties, or {@code null} if none.
      * This is usually {@code null}. It may be non-null if the user specified a query with
      * operations other than {@code ValueReference} or {@code Literal}.
-     *
-     * <h4>Completion</h4>
-     * Usually, the expressions are executed on a source feature instance and the results
-     * are copied in a target feature instance. However, if {@link #completion} is true,
-     * then the source and target features are the same instance. The completion mode is
-     * used when the target feature have all information needed for computing the query
-     * expressions, so that the projection is only a completion.
      */
     private final FeatureProjection projection;
 
-    /**
-     * Whether the {@linkplain #projection} should be applied on the same
-     */
-    private final boolean completion;
-
     /**
      * Creates a new iterator over features.
      *
@@ -130,8 +118,8 @@ final class FeatureIterator implements Spliterator<Feature>, AutoCloseable {
      * @param distinct    whether the set should contain distinct feature instances.
      * @param selection   condition to append, not including the {@code WHERE} keyword.
      * @param sort        the {@code ORDER BY} clauses, or {@code null} if none.
-     * @param offset      number of rows to skip in underlying SQL query, or ≤ 0 for none.
-     * @param count       maximum number of rows to return, or ≤ 0 for no limit.
+     * @param offset      number of rows to skip in underlying SQL query, or 0 for none.
+     * @param count       maximum number of rows to return, or 0 (not -1) for no limit.
      * @param projection  additional properties to compute, or {@code null} if none.
      */
     FeatureIterator(final Table           table,
@@ -153,7 +141,7 @@ final class FeatureIterator implements Spliterator<Feature>, AutoCloseable {
             spatialInformation = null;
         }
         final String filter = (selection != null) ? selection.query(connection, spatialInformation) : null;
-        if (distinct || filter != null || sort != null || offset > 0 || count > 0) {
+        if (distinct || filter != null || sort != null || (offset | count) != 0) {
             final var builder = new SQLBuilder(table.database).append(sql);
             if (distinct) {
                 builder.insertDistinctAfterSelect();
@@ -180,13 +168,13 @@ final class FeatureIterator implements Spliterator<Feature>, AutoCloseable {
          * for metadata (probably a bug, but not all JDBC drivers are mature).
          */
         if (filter == null) {
-            estimatedSize = Math.min(table.countRows(connection.getMetaData(), distinct, true), offset + count) - offset;
+            final long n = table.countRows(connection.getMetaData(), distinct, true) - offset;
+            estimatedSize = (count != 0) ? Math.min(n, count) : n;
         } else {
             estimatedSize = 0;              // Cannot estimate the size if there is filtering conditions.
         }
         this.result       = connection.createStatement().executeQuery(sql);
         this.dependencies = new FeatureIterator[adapter.dependencies.length];
-        this.completion   = projection != null && projection.featureType == table.featureType;
         this.projection   = projection;
         this.statement    = null;
     }
@@ -209,7 +197,6 @@ final class FeatureIterator implements Spliterator<Feature>, AutoCloseable {
         this.adapter  = adapter;
         statement     = connection.prepareStatement(adapter.sql);
         dependencies  = new FeatureIterator[adapter.dependencies.length];
-        completion    = false;
         projection    = null;
         estimatedSize = 0;
     }
@@ -327,11 +314,7 @@ final class FeatureIterator implements Spliterator<Feature>, AutoCloseable {
              * complex expressions, we need to handle them in Java code.
              */
             if (projection != null) {
-                if (completion) {
-                    projection.applySelf(feature);
-                } else {
-                    feature = projection.apply(feature);
-                }
+                feature = projection.apply(feature);
             }
             action.accept(feature);
             if (!all) return true;
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 d2a5b14106..2386a8fa35 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
@@ -16,9 +16,8 @@
  */
 package org.apache.sis.storage.sql.feature;
 
-import java.util.Set;
+import java.util.BitSet;
 import java.util.HashSet;
-import java.util.LinkedHashMap;
 import java.util.Objects;
 import java.util.Comparator;
 import java.util.Spliterator;
@@ -32,7 +31,6 @@ import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
 import org.apache.sis.filter.Optimization;
-import org.apache.sis.filter.privy.ListingPropertyVisitor;
 import org.apache.sis.filter.privy.SortByComparator;
 import org.apache.sis.filter.privy.WarningEvent;
 import org.apache.sis.metadata.sql.privy.SQLBuilder;
@@ -41,11 +39,10 @@ import org.apache.sis.util.privy.Strings;
 import org.apache.sis.util.stream.DeferredStream;
 import org.apache.sis.util.stream.PaginedStream;
 import org.apache.sis.storage.DataStoreException;
-import org.apache.sis.storage.base.FeatureProjection;
+import org.apache.sis.feature.privy.FeatureProjection;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
 import org.opengis.feature.Feature;
-import org.opengis.filter.Expression;
 import org.opengis.filter.Filter;
 import org.opengis.filter.SortBy;
 
@@ -122,8 +119,10 @@ final class FeatureStream extends DeferredStream<Feature> {
     private long offset;
 
     /**
-     * Maximum number of rows to return, or 0 for no limit. Note that 0 is a valid value for the limit,
-     * but when this value is reached the {@link #empty()} stream should be immediately returned.
+     * Maximum number of rows to return, or 0 for no limit. Note: the use of 0 for "no limit" is unusual
+     * (a more usual value is -1) because 0 could be valid value for the limit. However, the methods in
+     * this class should return {@link #empty()} eagerly when they detect that the stream will be empty.
+     * Therefore, we should not have instances of {@code FeatureStream} with a limit which is really zero.
      *
      * @see #limit(long)
      */
@@ -425,28 +424,17 @@ final class FeatureStream extends DeferredStream<Feature> {
         Table projected = table;
         FeatureProjection completion = null;
         if (projection != null) {
-            final var unhandled = new LinkedHashMap<String, Expression<? super Feature, ?>>();
+            final var unhandled   = new BitSet();
             final var reusedNames = new HashSet<String>();
             projected = new Table(projected, projection, reusedNames, unhandled);
-            if (!unhandled.isEmpty()) {
+            completion = projection.afterPreprocessing(unhandled.stream().toArray());
+            if (completion != null && !reusedNames.containsAll(completion.dependencies())) {
                 /*
-                 * Some properties may not be handled by the projection. Check if the projected feature contains
-                 * enough information for computing missing properties without the need for the original feature.
+                 * Cannot use `projected` because some expressions need properties available only
+                 * in the source features. Request full feature instances from the original table.
                  */
-                Set<String> references = null;
-                for (var expression : unhandled.values()) {
-                    references = ListingPropertyVisitor.xpaths(expression, references);
-                }
-                if (references == null || reusedNames.containsAll(references)) {
-                    completion = new FeatureProjection(projected.featureType, unhandled);
-                } else {
-                    /*
-                     * Cannot use `projected` because some expressions need properties
-                     * available only in the original features.
-                     */
-                    projected = table;
-                    completion = projection;
-                }
+                projected  = table;
+                completion = projection;
             }
         }
         lock(projected.database.transactionLocks);
diff --git a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Table.java b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Table.java
index 65dc579abb..1a56e7149e 100644
--- a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Table.java
+++ b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Table.java
@@ -19,6 +19,7 @@ package org.apache.sis.storage.sql.feature;
 import java.util.Map;
 import java.util.Set;
 import java.util.List;
+import java.util.BitSet;
 import java.util.Optional;
 import java.util.stream.Stream;
 import java.sql.Connection;
@@ -28,10 +29,12 @@ import java.sql.SQLException;
 import java.sql.SQLFeatureNotSupportedException;
 import org.opengis.util.GenericName;
 import org.opengis.geometry.Envelope;
+import org.apache.sis.filter.privy.XPath;
+import org.apache.sis.filter.InvalidXPathException;
 import org.apache.sis.storage.AbstractFeatureSet;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.InternalDataStoreException;
-import org.apache.sis.storage.base.FeatureProjection;
+import org.apache.sis.feature.privy.FeatureProjection;
 import org.apache.sis.metadata.sql.privy.Reflection;
 import org.apache.sis.metadata.sql.privy.SQLBuilder;
 import org.apache.sis.pending.jdk.JDK19;
@@ -48,7 +51,6 @@ import org.opengis.feature.Feature;
 import org.opengis.feature.FeatureType;
 import org.opengis.feature.AttributeType;
 import org.opengis.feature.FeatureAssociationRole;
-import org.opengis.filter.Expression;
 import org.opengis.filter.InvalidFilterValueException;
 
 
@@ -78,6 +80,13 @@ final class Table extends AbstractFeatureSet {
      * except synthetic attributes like "sis:identifier". The feature may also contain associations
      * inferred from foreigner keys that are not immediately apparent in the table.
      *
+     * <h4>Relationship with {@code FeatureSet.getType()}</h4>
+     * When this {@code Table} has been created by inspection of the database metadata, this feature type
+     * is the value shown to users. But when this {@code Table} has been created from a query, this value
+     * is not directly shown to user because it may contain additional properties for the dependencies of
+     * operations. In the latter case, this table is hidden behind {@link FeatureIterator} and the type
+     * seen by user is provided by {@link org.apache.sis.storage.FeatureSubset#resultType}.
+     *
      * @see #getType()
      */
     final FeatureType featureType;
@@ -136,7 +145,7 @@ final class Table extends AbstractFeatureSet {
     final boolean hasRaster;
 
     /**
-     * Map from attribute name to columns. This is built from {@link #columns} array when first needed.
+     * Map from attribute name to columns. This is built from the {@link #attributes} array when first needed.
      *
      * @see #getColumn(String)
      */
@@ -195,27 +204,27 @@ final class Table extends AbstractFeatureSet {
      * Creates a new table as a projection (subset of columns) of the given table.
      * The columns to retain, potentially under different names, are specified in {@code projection}.
      * The projection may also contain complex expressions that cannot be handled by this constructor.
-     * Such expressions are stored in the {@code unhandled} map.
+     * Such expressions have their indexes stored in the {@code unhandled} set.
      *
-     * @param  table        the source table.
+     * @param  source       the source table.
      * @param  projection   description of the columns to keep.
      * @param  reusedNames  an initially empty set where to store the names of attributes that are not renamed.
-     * @param  unhandled    an initially empty map where to add expressions that are not handled by the new table.
+     * @param  unhandled    where to set the bits for indexes of expressions that are not handled by the new table.
      * @throws InvalidFilterValueException if there is an error in the declaration of property values.
      */
     @SuppressWarnings("LocalVariableHidesMemberVariable")
     Table(final Table source,
           final FeatureProjection projection,
           final Set<String> reusedNames,
-          final Map<String, Expression<? super Feature, ?>> unhandled)
+          final BitSet unhandled)
     {
         super(source.listeners, false);
         database    = source.database;
         query       = source.query;
         name        = source.name;
-        featureType = projection.featureType;
+        featureType = projection.typeWithDependencies;
         /*
-         * Temporary values the fields, before assignment to final fields.
+         * Temporary values of fields, before assignment to final fields.
          * The final number of attributes and foreigner keys may be smaller.
          */
         final var attributes   = new Column  [source.attributes  .length];
@@ -229,12 +238,12 @@ final class Table extends AbstractFeatureSet {
          * Take all columns that we can put in the `WHERE` clause of the SQL statement.
          * The columns that we cannot take will be declared in the `unhandled` bit set.
          */
-        final List<String> storedProperties = projection.getStoredPropertyNames();
+        final List<String> storedProperties = projection.propertiesToCopy();
         final int count = storedProperties.size();
         for (int i=0; i<count; i++) {
-            final String xpath = projection.getSourcePropertyName(i, false);
+            final String xpath = projection.xpath(i).orElse(null);
             if (xpath == null) {
-                unhandled.put(storedProperties.get(i), projection.getExpression(i));
+                unhandled.set(i);
                 continue;
             }
             final Column column = source.getColumn(xpath);
@@ -274,7 +283,7 @@ final class Table extends AbstractFeatureSet {
         if (source.primaryKey != null) {
             for (String column : source.primaryKey.getColumns()) {
                 final int i = storedProperties.indexOf(column);
-                if (i < 0 || !column.equals(projection.getSourcePropertyName(i, true))) {
+                if (i < 0 || !column.equals(projection.xpath(i).orElse(null))) {
                     primaryKey = null;
                     return;
                 }
@@ -386,6 +395,8 @@ final class Table extends AbstractFeatureSet {
 
     /**
      * Returns the feature type inferred from the database structure analysis.
+     * Note that this type may not be the type shown to user if this table is
+     * created behind a {@link org.apache.sis.storage.FeatureSubset}.
      */
     @Override
     public final FeatureType getType() {
@@ -435,6 +446,7 @@ final class Table extends AbstractFeatureSet {
      *
      * @param  xpath  the XPath (currently only attribute name).
      * @return column for the given XPath, or {@code null} if the specified attribute is not found.
+     * @throws InvalidXPathException if the given XPath is not supported.
      */
     final Column getColumn(final String xpath) {
         Map<String,Column> m;
@@ -453,18 +465,20 @@ final class Table extends AbstractFeatureSet {
                 attributeToColumns = m;
             }
         }
-        return m.get(xpath);
+        return m.get(XPath.toPropertyName(xpath));      // XPaths other than tips are not yet supported.
     }
 
     /**
      * Returns the relation from an attribute name specified as XPath.
-     * See {@link #getColumn(String)} for a note on the wat that {@code xpath} is interpreted.
+     * See {@link #getColumn(String)} for a note on the way that {@code xpath} is interpreted.
      *
      * @param  xpath    the XPath (currently only attribute name).
      * @param  exports  {@code true} for searching in exported keys, or {@code false} for searching in imported keys.
      * @return relation for the given XPath, or {@code null} if the specified attribute is not found.
+     * @throws InvalidXPathException if the given XPath is not supported.
      */
-    private Relation getRelation(final String xpath, final boolean exports) {
+    private Relation getRelation(String xpath, final boolean exports) {
+        xpath = XPath.toPropertyName(xpath);    // XPaths other than tips are not yet supported.
         boolean tip = false;
         do {    // Execute 1 or 2 times: check tips only if no exact match.
             for (final Relation c : (exports ? exportedKeys : importedKeys)) {
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 7a961f9af1..80dec5de00 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
@@ -19,7 +19,9 @@ package org.apache.sis.storage;
 import java.util.Arrays;
 import java.util.Set;
 import java.util.Map;
+import java.util.Locale;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.OptionalLong;
 import java.util.function.Function;
 import java.io.Serializable;
@@ -29,6 +31,9 @@ import org.opengis.util.GenericName;
 import org.opengis.geometry.Envelope;
 import org.apache.sis.feature.FeatureOperations;
 import org.apache.sis.feature.privy.AttributeConvention;
+import org.apache.sis.feature.privy.FeatureExpression;
+import org.apache.sis.feature.privy.FeatureProjection;
+import org.apache.sis.feature.privy.FeatureProjectionBuilder;
 import org.apache.sis.filter.DefaultFilterFactory;
 import org.apache.sis.filter.Optimization;
 import org.apache.sis.filter.privy.ListingPropertyVisitor;
@@ -42,12 +47,14 @@ import org.apache.sis.util.iso.Names;
 
 // 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.Attribute;
 import org.opengis.feature.AttributeType;
 import org.opengis.feature.Operation;
 import org.opengis.filter.FilterFactory;
 import org.opengis.filter.Filter;
 import org.opengis.filter.Expression;
+import org.opengis.filter.InvalidFilterValueException;
 import org.opengis.filter.Literal;
 import org.opengis.filter.ValueReference;
 import org.opengis.filter.SortBy;
@@ -389,6 +396,7 @@ public class FeatureQuery extends Query implements Cloneable, Emptiable, Seriali
     @SafeVarargs
     @SuppressWarnings("varargs")
     public final void setSortBy(final SortProperty<Feature>... properties) {
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
         SortBy<Feature> sortBy = null;
         if (properties != null) {
             sortBy = SortByComparator.create(properties);
@@ -527,7 +535,7 @@ public class FeatureQuery extends Query implements Cloneable, Emptiable, Seriali
      *
      * Columns can be given to the {@link FeatureQuery#setProjection(NamedExpression[])} method.
      *
-     * @version 1.4
+     * @version 1.5
      * @since   1.1
      */
     public static class NamedExpression implements Serializable {
@@ -606,6 +614,25 @@ public class FeatureQuery extends Query implements Cloneable, Emptiable, Seriali
             this.alias      = alias;
         }
 
+        /**
+         * Adds this named expression as a property into the given builder.
+         *
+         * @param  builder  the builder where to add the property.
+         * @return whether the property has been successfully added.
+         */
+        final boolean addTo(final FeatureProjectionBuilder builder) {
+            final FeatureExpression<? super Feature, ?> fex = FeatureExpression.castOrCopy(expression);
+            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.setValueGetter(expression, type == ProjectionType.STORED);
+                    return true;
+                }
+            }
+            return false;
+        }
+
         /**
          * Returns a hash code value for this column.
          *
@@ -706,6 +733,36 @@ public class FeatureQuery extends Query implements Cloneable, Emptiable, Seriali
         return xpaths;
     }
 
+    /**
+     * Creates the projection (in <abbr>SQL</abbr> sense) of the given feature type.
+     * If some expressions have no name, default names are computed as below:
+     *
+     * <ul>
+     *   <li>If the expression is an instance of {@link ValueReference}, the name of the
+     *       property referenced by the {@linkplain ValueReference#getXPath() XPath}.</li>
+     *   <li>Otherwise the localized string "Unnamed #1" with increasing numbers.</li>
+     * </ul>
+     *
+     * @param sourceType  the feature type to project.
+     * @param locale      locale for error messages, or {@code null} for the default locale.
+     */
+    final Optional<FeatureProjection> project(final FeatureType sourceType, final Locale locale) {
+        if (projection == null) {
+            return Optional.empty();
+        }
+        final var builder = new FeatureProjectionBuilder(sourceType, locale);
+        for (int column = 0; column < projection.length; column++) {
+            final NamedExpression item = projection[column];
+            if (!item.addTo(builder)) {
+                final var name = item.expression.getFunctionName().toInternationalString();
+                throw new InvalidFilterValueException(Resources.forLocale(locale)
+                            .getString(Resources.Keys.InvalidExpression_2, column, name));
+            }
+        }
+        builder.setName(sourceType.getName());
+        return builder.project();
+    }
+
     /**
      * Applies this query on the given feature set.
      * This method is invoked by the default implementation of {@link FeatureSet#subset(Query)}.
diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureSubset.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureSubset.java
index 32159973bd..c4d4e792a7 100644
--- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureSubset.java
+++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureSubset.java
@@ -19,7 +19,7 @@ package org.apache.sis.storage;
 import java.util.OptionalLong;
 import java.util.stream.Stream;
 import org.opengis.metadata.Metadata;
-import org.apache.sis.storage.base.FeatureProjection;
+import org.apache.sis.feature.privy.FeatureProjection;
 import org.apache.sis.storage.base.MetadataBuilder;
 import org.apache.sis.storage.base.StoreUtilities;
 import org.apache.sis.storage.internal.Resources;
@@ -95,8 +95,8 @@ final class FeatureSubset extends AbstractFeatureSet {
         if (resultType == null) {
             final FeatureType type = source.getType();
             try {
-                projection = FeatureProjection.create(type, query.getProjection());
-                resultType = (projection != null) ? projection.featureType : type;
+                projection = query.project(type, listeners.getLocale()).orElse(null);
+                resultType = (projection != null) ? projection.typeRequested : type;
             } catch (IllegalArgumentException e) {
                 throw new DataStoreContentException(Resources.forLocale(listeners.getLocale())
                         .getString(Resources.Keys.CanNotDeriveTypeFromFeature_1, type.getName()), e);
diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/FeatureProjection.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/FeatureProjection.java
deleted file mode 100644
index 5a25571e28..0000000000
--- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/FeatureProjection.java
+++ /dev/null
@@ -1,383 +0,0 @@
-/*
- * 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.storage.base;
-
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.ConcurrentModificationException;
-import java.util.function.UnaryOperator;
-import org.opengis.util.GenericName;
-import org.apache.sis.feature.AbstractOperation;
-import org.apache.sis.feature.FeatureOperations;
-import org.apache.sis.feature.builder.FeatureTypeBuilder;
-import org.apache.sis.feature.builder.PropertyTypeBuilder;
-import org.apache.sis.feature.builder.AttributeTypeBuilder;
-import org.apache.sis.feature.privy.FeatureExpression;
-import org.apache.sis.filter.privy.XPath;
-import org.apache.sis.storage.FeatureQuery;
-import org.apache.sis.storage.internal.Resources;
-import org.apache.sis.pending.jdk.JDK19;
-import org.apache.sis.util.ArraysExt;
-import org.apache.sis.util.iso.Names;
-import org.apache.sis.util.logging.Logging;
-import org.apache.sis.util.privy.UnmodifiableArrayList;
-import org.apache.sis.util.resources.Vocabulary;
-
-// 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.PropertyNotFoundException;
-import org.opengis.filter.InvalidFilterValueException;
-import org.opengis.filter.Expression;
-import org.opengis.filter.ValueReference;
-
-
-/**
- * A function applying projections (with "projected" in the <abbr>SQL</abbr> database sense) of features.
- * Given full feature instances in input, this method returns feature instances containing only a subset
- * of the properties. The property values may also be different if they are computed by the expressions.
- *
- * @author Guilhem Legal (Geomatys)
- * @author Martin Desruisseaux (Geomatys)
- */
-public class FeatureProjection implements UnaryOperator<Feature> {
-    /**
-     * The type of features created by this mapper.
-     */
-    public final FeatureType featureType;
-
-    /**
-     * Names of the properties to be stored in the target features. This is inferred from the {@code properties}
-     * given at construction time, retaining only the elements of type {@link FeatureQuery.ProjectionType#STORED}.
-     * Properties that are computed on-the-fly from other properties are not included in this list.
-     * This array is arbitrarily set to {@code null} if this projection is an identity operation.
-     *
-     * <p>When a new {@code Feature} instance needs to be created with a subset of the properties
-     * of the original feature instance, usually only the stored properties need to be copied.
-     * Other properties computed on-the-fly from stored properties may not need copy.</p>
-     *
-     * @see #isIdentity()
-     */
-    private final String[] storedProperties;
-
-    /**
-     * Expressions to apply on the source feature for fetching the property values of the projected feature.
-     * Shall have the same length as {@link #storedProperties} and the properties shall be in the same order.
-     * This array is arbitrarily set to {@code null} if this projection is an identity operation.
-     *
-     * @see #isIdentity()
-     */
-    private final Expression<? super Feature, ?>[] expressions;
-
-    /**
-     * Infers the type of values evaluated by a query when executed on features of the given type.
-     * If some expressions have no name, default names are computed as below:
-     *
-     * <ul>
-     *   <li>If the expression is an instance of {@link ValueReference}, the name of the
-     *       property referenced by the {@linkplain ValueReference#getXPath() XPath}.</li>
-     *   <li>Otherwise the localized string "Unnamed #1" with increasing numbers.</li>
-     * </ul>
-     *
-     * <h4>Identity operation</h4>
-     * If the result is a feature type with all the properties of the source feature,
-     * with the same property names in the same order, and if the expressions are only
-     * fetching the values (no computation), then this method returns {@code null} for
-     * meaning that this projection does nothing.
-     *
-     * @param  sourceType  the type of features to be converted to projected features.
-     * @param  projection  descriptions of the properties to keep in the projected features, or {@code null} if none.
-     * @return a function for projecting the feature instances, or {@code null} if none.
-     * @throws IllegalArgumentException if this method can operate only on some feature types
-     *         and the given type is not one of them.
-     * @throws InvalidFilterValueException if this method cannot determine the result type of an expression.
-     *         It may be because that expression is backed by an unsupported implementation.
-     */
-    public static FeatureProjection create(final FeatureType sourceType, final FeatureQuery.NamedExpression[] projection) {
-        if (projection != null) {
-            var fp = new FeatureProjection(sourceType, projection);
-            if (!fp.isIdentity()) {
-                return fp;
-            }
-        }
-        return null;
-    }
-
-    /**
-     * Creates a new feature projection. If some expressions have no name,
-     * default names are computed as described in {@link #create create(…)}.
-     *
-     * <p>Callers shall invoke {@link #isIdentity()} after construction.
-     * This instance shall not be used if it is the identity operation.</p>
-     *
-     * @param  sourceType  the type of features to be converted to projected features.
-     * @param  projection  descriptions of the properties to keep in the projected features, or {@code null} if none.
-     * @throws IllegalArgumentException if this method can operate only on some feature types
-     *         and the given type is not one of them.
-     * @throws InvalidFilterValueException if this method cannot determine the result type of an expression.
-     *         It may be because that expression is backed by an unsupported implementation.
-     */
-    protected FeatureProjection(final FeatureType sourceType, final FeatureQuery.NamedExpression[] projection) {
-        int storedCount   = 0;
-        int unnamedNumber = 0;          // Sequential number for unnamed expressions.
-        Set<String> names = null;       // Names already used, for avoiding collisions.
-
-        // For detecting if the projection would be an identity operation.
-        var propertiesOfIdentity = sourceType.getProperties(true).iterator();
-
-        @SuppressWarnings({"LocalVariableHidesMemberVariable", "unchecked", "rawtypes"})
-        final Expression<? super Feature,?>[] expressions = new Expression[projection.length];
-
-        @SuppressWarnings("LocalVariableHidesMemberVariable")
-        final String[] storedProperties = new String[projection.length];
-
-        final FeatureTypeBuilder ftb = new FeatureTypeBuilder().setName(sourceType.getName());
-        for (int column = 0; column < projection.length; column++) {
-            final FeatureQuery.NamedExpression item = projection[column];
-            /*
-             * For each property, get the expected type (mandatory) and its name (optional).
-             * A default name will be computed if no alias was explicitly given by the user.
-             */
-            final var expression = item.expression;
-            final var fex = FeatureExpression.castOrCopy(expression);
-            final PropertyTypeBuilder resultType;
-            if (fex == null || (resultType = fex.expectedType(sourceType, ftb)) == null) {
-                throw new InvalidFilterValueException(Resources.format(Resources.Keys.InvalidExpression_2,
-                            expression.getFunctionName().toInternationalString(), column));
-            }
-            GenericName name = item.alias;
-            if (name == null) {
-                /*
-                 * Build a list of aliases declared by the user, for making sure that we do not collide with them.
-                 * No check for `GenericName` collision here because it was already verified by `setProjection(…)`.
-                 * We may have collision of their `String` representations however, which is okay.
-                 */
-                if (names == null) {
-                    names = JDK19.newHashSet(projection.length);
-                    for (final FeatureQuery.NamedExpression p : projection) {
-                        if (p.alias != null) {
-                            names.add(p.alias.toString());
-                        }
-                    }
-                }
-                /*
-                 * If the expression is a `ValueReference`, the `PropertyType` instance can be taken directly
-                 * from the source feature (the Apache SIS implementation does just that). If the name is set,
-                 * then we assume that it is correct. Otherwise we take the tip of the XPath.
-                 */
-                String tip = reference(expression, true);
-                if (tip != null) {
-                    /*
-                     * Take the existing `GenericName` instance from the property. It should be equivalent to
-                     * creating a name from the tip, except that it may be a `ScopedName` instead of `LocalName`.
-                     * We do not take `resultType.getName()` because the latter is different if the property
-                     * is itself a link to another property (in which case `resultType` is the final target).
-                     */
-                    name = sourceType.getProperty(tip).getName();
-                    if (name == null || !names.add(name.toString())) {
-                        name = null;
-                        if (tip.isBlank() || names.contains(tip)) {
-                            tip = null;
-                        }
-                    }
-                }
-                /*
-                 * If we still have no name at this point, create a name like "Unnamed #1".
-                 * Note that despite the use of `Vocabulary` resources, the name will be unlocalized
-                 * (for easier programmatic use) because `GenericName` implementation is designed for
-                 * providing localized names only if explicitly requested.
-                 */
-                if (name == null) {
-                    CharSequence text = tip;
-                    if (text == null) do {
-                        text = Vocabulary.formatInternational(Vocabulary.Keys.Unnamed_1, ++unnamedNumber);
-                    } while (!names.add(text.toString()));
-                    name = Names.createLocalName(null, null, text);
-                }
-            }
-            /*
-             * If the attribute that we just added should be virtual, replace the attribute by an operation.
-             * When `FeatureProjection` contains at least one such virtual attributes, the result cannot be
-             * an identity operation.
-             */
-            if (item.type != FeatureQuery.ProjectionType.STORED) {
-                propertiesOfIdentity = null;        // No longer an identity operation.
-                if (resultType instanceof AttributeTypeBuilder<?>) {
-                    final var ab = (AttributeTypeBuilder<?>) resultType;
-                    final var storedType = ab.build();
-                    if (ftb.properties().remove(resultType)) {
-                        final var identification = Map.of(AbstractOperation.NAME_KEY, name);
-                        ftb.addProperty(FeatureOperations.expression(identification, expression, storedType));
-                    }
-                }
-                continue;
-            }
-            /*
-             * This is the usual case where the property value is copied in the "projected" feature.
-             * If the target property name is equal to the source property name, in the same order,
-             * and the expression is just fetching the value from a property of the same name, then
-             * we consider that the projection is an identity operation.
-             */
-            resultType.setName(name);
-            storedProperties[storedCount] = name.toString();
-            expressions[storedCount++] = expression;
-isIdentity: if (propertiesOfIdentity != null) {
-                if (propertiesOfIdentity.hasNext()) {
-                    final var property = propertiesOfIdentity.next();
-                    if (name.equals(property.getName())) {
-                        final String tip = name.tip().toString();
-                        if (tip.equals(reference(expression, true))) try {
-                            if (property.equals(sourceType.getProperty(tip))) {
-                                break isIdentity;   // Continue to consider that we may have an identity projection.
-                            }
-                        } catch (PropertyNotFoundException e) {
-                            // It may be because the property name is ambiguous.
-                            Logging.ignorableException(StoreUtilities.LOGGER, FeatureProjection.class, "create", e);
-                        }
-                    }
-                }
-                propertiesOfIdentity = null;
-            }
-        }
-        /*
-         * End of the construction of properties from the given expressions. If all properties
-         * are just copying values of properties of the same name from the source, returns `null`.
-         */
-        if (propertiesOfIdentity == null || propertiesOfIdentity.hasNext()) {
-            this.featureType      = ftb.build();
-            this.storedProperties = ArraysExt.resize(storedProperties, storedCount);
-            this.expressions      = ArraysExt.resize(expressions, storedCount);
-        } else {
-            this.featureType      = sourceType;
-            this.storedProperties = null;
-            this.expressions      = null;    // Means "identity operation".
-        }
-    }
-
-    /**
-     * Creates a new projection with the given properties.
-     *
-     * @param type        the type of feature instances created by this projection.
-     * @param projection  all properties by name, associated to expressions for getting values from source features.
-     */
-    @SuppressWarnings({"rawtypes", "unchecked"})
-    public FeatureProjection(final FeatureType type, final Map<String, Expression<? super Feature, ?>> projection) {
-        featureType = type;
-        expressions = new Expression[projection.size()];
-        storedProperties = new String[expressions.length];
-        int i = 0;
-        for (var entry : projection.entrySet()) {
-            storedProperties[i] = entry.getKey();
-            expressions[i++] = entry.getValue();
-        }
-        if (i != expressions.length) {
-            // Should never happen, unless the `Map` has been modified concurrently.
-            throw new ConcurrentModificationException();
-        }
-    }
-
-    /**
-     * If the given expression is a value reference, returns the tip of the referenced property.
-     * Otherwise, returns {@code null}.
-     *
-     * @param  expression the expression from which to get the referenced property name.
-     * @param  decode  whether to decode the XPath syntax (e.g. {@code {@code "Q{namespace}"}} syntax).
-     * @return the referenced property name, or {@code null} if none.
-     */
-    private static String reference(final Expression<? super Feature, ?> expression, final boolean decode) {
-        if (expression instanceof ValueReference<?,?>) {
-            String xpath = ((ValueReference<?,?>) expression).getXPath();
-            return decode ? XPath.toPropertyName(xpath) : xpath;
-        }
-        return null;
-    }
-
-    /**
-     * Returns whether this projection performs no operation.
-     * If this method returns {@code true}, then this instance shall <strong>not</strong> be used.
-     *
-     * @return whether this projection performs no operation.
-     */
-    public final boolean isIdentity() {
-        return expressions == null;
-    }
-
-    /**
-     * Returns the names of all stored properties. This list may be shorter than the list of properties
-     * of the {@linkplain #featureType feature type} if some feature properties are computed on-the-fly.
-     *
-     * @return the name of all stored properties.
-     */
-    public final List<String> getStoredPropertyNames() {
-        return UnmodifiableArrayList.wrap(storedProperties);
-    }
-
-    /**
-     * Returns the name of a property in the source features.
-     * The given index corresponds to an index in the list returned by {@link #getStoredPropertyNames()}.
-     * The return value is often the same name as {@code getStoredPropertyNames().get(index)}.
-     *
-     * @param  index   index of the stored property for which to get the name in the source feature.
-     * @param  decode  whether to decode the XPath syntax (e.g. {@code {@code "Q{namespace}"}} syntax).
-     * @return name in the source features, or {@code null} if the property is not a {@link ValueReference}.
-     * @throws NullPointerException if {@link #isIdentity()} is {@code true}.
-     */
-    public final String getSourcePropertyName(final int index, final boolean decode) {
-        return reference(expressions[index], decode);
-    }
-
-    /**
-     * Returns the expression at the given index.
-     *
-     * @param  index  index of the stored property for which to get the expression.
-     * @return expression at the given index.
-     * @throws NullPointerException if {@link #isIdentity()} is {@code true}.
-     */
-    public final Expression<? super Feature, ?> getExpression(final int index) {
-        return expressions[index];
-    }
-
-    /**
-     * Derives a new projected feature instance from the given source.
-     *
-     * @param  source the source feature instance.
-     * @return the "projected" (<abbr>SQL</abbr> database sense) feature instance.
-     * @throws NullPointerException if {@link #isIdentity()} is {@code true}.
-     */
-    @Override
-    public final Feature apply(final Feature source) {
-        final var feature = featureType.newInstance();
-        for (int i=0; i < expressions.length; i++) {
-            feature.setPropertyValue(storedProperties[i], expressions[i].apply(source));
-        }
-        return feature;
-    }
-
-    /**
-     * Applies the expressions with the given feature as both the source and the target.
-     * IT can be useful when all expressions are computing values derived from other attributes.
-     *
-     * @param  feature  the feature on which to apply the expressions.
-     * @throws NullPointerException if {@link #isIdentity()} is {@code true}.
-     */
-    public final void applySelf(final Feature feature) {
-        for (int i=0; i < expressions.length; i++) {
-            feature.setPropertyValue(storedProperties[i], expressions[i].apply(feature));
-        }
-    }
-}