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 c5ac1dc86a3caf358bc4821cee77420ccc7b1085
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Fri Mar 28 17:30:52 2025 +0100

    When only a subset of the feature properties is requested,
    declare only that subset in the SQL `SELECT` statement.
---
 .../apache/sis/feature/privy/FeatureUtilities.java |  39 +--
 .../apache/sis/storage/sql/feature/Analyzer.java   |   5 +-
 .../org/apache/sis/storage/sql/feature/Column.java |  67 +++-
 .../apache/sis/storage/sql/feature/Database.java   |  24 +-
 .../sis/storage/sql/feature/FeatureAdapter.java    |   8 +-
 .../sis/storage/sql/feature/FeatureAnalyzer.java   |  10 +-
 .../sis/storage/sql/feature/FeatureIterator.java   |  49 ++-
 .../sis/storage/sql/feature/FeatureStream.java     |  52 ++-
 .../apache/sis/storage/sql/feature/Relation.java   |  48 ++-
 .../apache/sis/storage/sql/feature/Resources.java  |   5 +
 .../sis/storage/sql/feature/Resources.properties   |   1 +
 .../storage/sql/feature/Resources_fr.properties    |   3 +-
 .../org/apache/sis/storage/sql/feature/Table.java  | 140 ++++++--
 .../apache/sis/storage/sql/postgis/Postgres.java   |   2 +-
 .../org/apache/sis/storage/sql/SQLStoreTest.java   |  22 +-
 .../main/org/apache/sis/storage/FeatureQuery.java  | 213 ++++--------
 .../main/org/apache/sis/storage/FeatureSubset.java |  37 +-
 .../apache/sis/storage/base/FeatureProjection.java | 383 +++++++++++++++++++++
 18 files changed, 837 insertions(+), 271 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureUtilities.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureUtilities.java
index c34d9b00e6..e8a2ab139b 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureUtilities.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureUtilities.java
@@ -16,12 +16,7 @@
  */
 package org.apache.sis.feature.privy;
 
-import java.util.Map;
 import java.util.HashMap;
-import java.util.Iterator;
-import java.util.Collection;
-import java.util.ConcurrentModificationException;
-import org.opengis.util.GenericName;
 import org.opengis.metadata.Identifier;
 import org.opengis.parameter.ParameterDescriptor;
 import org.opengis.parameter.ParameterDescriptorGroup;
@@ -29,9 +24,6 @@ import 
org.apache.sis.parameter.DefaultParameterDescriptorGroup;
 import org.apache.sis.metadata.iso.citation.Citations;
 import org.apache.sis.util.Static;
 
-// Specific to the geoapi-3.1 and geoapi-4.0 branches:
-import org.opengis.feature.PropertyType;
-
 
 /**
  * Non-public utility methods for Apache SIS internal usage.
@@ -61,38 +53,9 @@ public final class FeatureUtilities extends Static {
      * @return description of the parameters group.
      */
     public static ParameterDescriptorGroup parameters(final String name, final 
ParameterDescriptor<?>... parameters) {
-        final Map<String,Object> properties = new HashMap<>(4);
+        final var properties = new HashMap<String,Object>(4);
         properties.put(ParameterDescriptorGroup.NAME_KEY, name);
         properties.put(Identifier.AUTHORITY_KEY, Citations.SIS);
         return new DefaultParameterDescriptorGroup(properties, 1, 1);
     }
-
-    /**
-     * Gets the name of all given properties. If any property is null or has a 
null name,
-     * then the corresponding entry in the returned array will be null.
-     *
-     * @param  properties  the properties for which to get the names, or 
{@code null}.
-     * @return the name of all given properties, or {@code null} if the given 
list was null.
-     */
-    public static String[] getNames(final Collection<? extends PropertyType> 
properties) {
-        if (properties == null) {
-            return null;
-        }
-        final String[] names = new String[properties.size()];
-        final Iterator<? extends PropertyType> it = properties.iterator();
-        for (int i=0; i < names.length; i++) {
-            final PropertyType property = it.next();
-            if (property != null) {
-                final GenericName name = property.getName();
-                if (name != null) {
-                    names[i] = name.toString();
-                }
-            }
-        }
-        // Should not have any element left, unless collection size changed 
during iteration.
-        if (it.hasNext()) {
-            throw new ConcurrentModificationException();
-        }
-        return names;
-    }
 }
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Analyzer.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Analyzer.java
index a2333db673..406b8c9239 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Analyzer.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Analyzer.java
@@ -27,7 +27,6 @@ import java.util.HashMap;
 import java.util.Objects;
 import java.util.Locale;
 import java.util.logging.Level;
-import java.util.logging.LogRecord;
 import java.util.concurrent.locks.ReadWriteLock;
 import java.sql.SQLException;
 import java.sql.DatabaseMetaData;
@@ -427,9 +426,7 @@ public final class Analyzer {
             table.setDeferredSearchTables(this, featureTables);
         }
         if (featureNotSupported != null) {
-            LogRecord record = resources().getLogRecord(Level.WARNING, 
Resources.Keys.CanNotAnalyzeFully);
-            record.setThrown(featureNotSupported);
-            database.log(record);
+            database.warning(Resources.Keys.CanNotAnalyzeFully, 
featureNotSupported);
         }
         for (final ResourceInternationalString warning : warnings) {
             database.log(warning.toLogRecord(Level.WARNING));
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Column.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Column.java
index 0ed50f5cbc..e4a2393e17 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Column.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Column.java
@@ -51,25 +51,31 @@ import org.apache.sis.util.resources.Errors;
  * @see ResultSet#getMetaData()
  * @see DatabaseMetaData#getColumns(String, String, String, String)
  */
-public final class Column {
+public final class Column implements Cloneable {
     /**
-     * Name of the column.
+     * Name of the column as declared in the table.
      *
      * @see Reflection#COLUMN_NAME
+     * @see ResultSetMetaData#getColumnName(int)
      */
     public final String name;
 
     /**
-     * Title to use for displays. This is the name specified by the {@code AS} 
keyword in a {@code SELECT} clause.
-     * This is never null but may be identical to {@link #name} if no label 
was specified.
+     * Name of the column as declared in with a {@code AS} clause in the 
<abbr>SQL</abbr> statement.
+     * This is never null but may be identical to {@link #name} if no {@code 
AS} clause was specified.
+     *
+     * @see ResultSetMetaData#getColumnLabel(int)
      */
     public final String label;
 
     /**
      * Name to use for feature property. This is the same as {@link #label} 
unless there is a name collision.
      * In that case the property name is modified for avoiding the collision.
+     *
+     * @see #getPropertyName()
+     * @see #setPropertyName(AttributeTypeBuilder)
      */
-    String propertyName;
+    private String propertyName;
 
     /**
      * Type of values as one of the constants enumerated in {@link Types} 
class.
@@ -233,6 +239,42 @@ public final class Column {
                         .getString(Errors.Keys.ValueAlreadyDefined_1, 
property));
     }
 
+    /**
+     * Returns a column identical to this column except for the property name.
+     * This method does not modify this column, but may return {@code this} if
+     * there is no name change to apply.
+     *
+     * @param  property  the new property name.
+     * @return a column with the given property name (may be {@code this}).
+     */
+    final Column rename(final String property) {
+        if (property.equals(propertyName)) {
+            return this;
+        }
+        final Column c = clone();
+        c.propertyName = property;
+        return c;
+    }
+
+    /**
+     * Returns the name to use for feature property.
+     * This is often, but not necessarily, the column {@linkplain #name}.
+     *
+     * @return the name of the feature property where to store the value.
+     */
+    public final String getPropertyName() {
+        return propertyName;
+    }
+
+    /**
+     * Sets the property name.
+     *
+     * @param attribute the builder which is creating the attribute for this 
column.
+     */
+    final void setPropertyName(final AttributeTypeBuilder<?> attribute) {
+        propertyName = attribute.getName().toString();
+    }
+
     /**
      * If this column is a geometry column, returns the type of the geometry 
objects.
      * Otherwise returns empty (including the case where this is a raster 
column).
@@ -300,4 +342,19 @@ public final class Column {
                 "name", name, "propertyName", propertyName, "type", type, 
"typeName", typeName,
                 "geometryType", geometryType, "precision", precision, 
"isNullable", isNullable);
     }
+
+    /**
+     * Returns a shallow clone of this column.
+     * Used by this {@code Column} implementation only and should not be 
invoked directly.
+     *
+     * @see #rename(String)
+     */
+    @Override
+    protected final Column clone() {
+        try {
+            return (Column) super.clone();
+        } catch (CloneNotSupportedException e) {
+            throw new AssertionError(e);
+        }
+    }
 }
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Database.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Database.java
index fbb62c850b..940afb0440 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Database.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Database.java
@@ -24,6 +24,7 @@ import java.util.WeakHashMap;
 import java.util.LinkedHashMap;
 import java.util.Locale;
 import java.util.Optional;
+import java.util.logging.Level;
 import java.util.logging.LogRecord;
 import java.util.concurrent.locks.ReadWriteLock;
 import java.sql.Array;
@@ -273,6 +274,10 @@ public class Database<G> extends Syntax  {
             throws SQLException
     {
         super(metadata, true);
+        this.source        = source;
+        this.geomLibrary   = geomLibrary;
+        this.contentLocale = contentLocale;
+        this.listeners     = listeners;      // Need to be set before code 
below.
         /*
          * Get information about whether byte are unsigned.
          * According JDBC specification, the rows shall be ordered by 
DATA_TYPE.
@@ -298,14 +303,9 @@ public class Database<G> extends Syntax  {
             cause = e;
         }
         if (cause != null || wasNull) {
-            listeners.warning(Resources.forLocale(listeners.getLocale())
-                                       
.getString(Resources.Keys.AssumeUnsigned), cause);
+            warning(Resources.Keys.AssumeUnsigned, cause);
         }
-        this.source        = source;
         this.isByteSigned  = !unsigned;
-        this.geomLibrary   = geomLibrary;
-        this.contentLocale = contentLocale;
-        this.listeners     = listeners;
         this.cacheOfCRS    = new Cache<>(7, 2, false);
         this.cacheOfSRID   = new WeakHashMap<>();
         this.tablesByNames = new FeatureNaming<>();
@@ -845,6 +845,18 @@ public class Database<G> extends Syntax  {
         return new InfoStatements(this, connection);
     }
 
+    /**
+     * Logs a warning with a localized message and an optional cause.
+     *
+     * @param resourceKey  one of {@code Resources.Keys} constants.
+     * @param cause        the cause, or {@code null} if none.
+     */
+    final void warning(final short resourceKey, final Exception cause) {
+        LogRecord record = 
Resources.forLocale(listeners.getLocale()).getLogRecord(Level.WARNING, 
resourceKey);
+        record.setThrown(cause);
+        log(record);
+    }
+
     /**
      * Sets the logger, class and method names of the given record, then logs 
it.
      * This method declares {@link SQLStore#components()} as the public source 
of the log.
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAdapter.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAdapter.java
index 5ce00aa6f8..7cb4b6e8f7 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAdapter.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAdapter.java
@@ -198,13 +198,13 @@ final class FeatureAdapter {
                 if (dependency.excluded) continue;
                 if (dependency != noFollow) {
                     dependency.startFollowing(following);                   // 
Safety against never-ending recursion.
-                    associationNames   [count] = dependency.propertyName;
+                    associationNames   [count] = dependency.getPropertyName();
                     foreignerKeyIndices[count] = getColumnIndices(sql, 
dependency, columnIndices);
                     dependencies       [count] = new 
FeatureAdapter(dependency.getSearchTable(), metadata, following, noFollow);
                     dependency.endFollowing(following);
                     count++;
                 } else {
-                    deferredAssociation = dependency.propertyName;
+                    deferredAssociation = dependency.getPropertyName();
                 }
             }
             importCount = count;
@@ -219,7 +219,7 @@ final class FeatureAdapter {
                 dependency.startFollowing(following);                   // 
Safety against never-ending recursion.
                 final Table foreigner  = dependency.getSearchTable();
                 final Relation inverse = foreigner.getInverseOf(dependency, 
table.name);
-                associationNames   [count] = dependency.propertyName;
+                associationNames   [count] = dependency.getPropertyName();
                 foreignerKeyIndices[count] = getColumnIndices(sql, dependency, 
columnIndices);
                 dependencies       [count] = new FeatureAdapter(foreigner, 
metadata, following, inverse);
                 dependency.endFollowing(following);
@@ -327,7 +327,7 @@ final class FeatureAdapter {
             final Column column = attributes[i];
             final Object value = column.valueGetter.getValue(stmts, result, 
i+1);
             if (value != null) {
-                feature.setPropertyValue(column.propertyName, value);
+                feature.setPropertyValue(column.getPropertyName(), value);
             }
         }
         return feature;
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAnalyzer.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAnalyzer.java
index 169df707e0..7918ac1fd5 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAnalyzer.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAnalyzer.java
@@ -261,7 +261,7 @@ abstract class FeatureAnalyzer {
                      * do not know which column describes better the 
association (often there is none).
                      * In such case we use the foreigner key name as a 
fallback.
                      */
-                    dependency.setPropertyName(column.propertyName, count++);
+                    dependency.setPropertyName(column.getPropertyName(), 
count++);
                     final AssociationRoleBuilder association;
                     if (table != null) {
                         dependency.setSearchTable(analyzer, table, 
table.primaryKey, Relation.Direction.IMPORT);
@@ -269,7 +269,7 @@ abstract class FeatureAnalyzer {
                     } else {
                         association = feature.addAssociation(typeName);     // 
May happen in case of cyclic dependency.
                     }
-                    association.setName(dependency.propertyName);
+                    association.setName(dependency.getPropertyName());
                     if (column.isNullable) {
                         association.setMinimumOccurs(0);
                     }
@@ -280,8 +280,8 @@ abstract class FeatureAnalyzer {
                      * column should rarely be used directly.
                      */
                     if (attribute != null) {
-                        
attribute.setName(analyzer.nameFactory.createGenericName(null, "pk", 
column.propertyName));
-                        column.propertyName = attribute.getName().toString();
+                        
attribute.setName(analyzer.nameFactory.createGenericName(null, "pk", 
column.getPropertyName()));
+                        column.setPropertyName(attribute);
                         attribute = null;
                     }
                 }
@@ -322,7 +322,7 @@ abstract class FeatureAnalyzer {
                 while (feature.isNameUsed(propertyName)) {
                     propertyName = base + '-' + ++count;
                 }
-                dependency.propertyName = propertyName;
+                dependency.setPropertyName(propertyName);
                 final Table table = analyzer.table(dependency, typeName, id);
                 final AssociationRoleBuilder association;
                 if (table != null) {
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 450f625a5b..428b947eac 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,6 +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.util.collection.WeakValueHashMap;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
@@ -102,6 +103,25 @@ final class FeatureIterator implements 
Spliterator<Feature>, AutoCloseable {
      */
     private final FeatureIterator[] dependencies;
 
+    /**
+     * 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.
      *
@@ -112,6 +132,7 @@ final class FeatureIterator implements 
Spliterator<Feature>, AutoCloseable {
      * @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 projection  additional properties to compute, or {@code null} if 
none.
      */
     FeatureIterator(final Table           table,
                     final Connection      connection,
@@ -119,7 +140,8 @@ final class FeatureIterator implements 
Spliterator<Feature>, AutoCloseable {
                     final SelectionClause selection,
                     final SortBy<? super Feature> sort,
                     final long offset,
-                    final long count)
+                    final long count,
+                    final FeatureProjection projection)
             throws Exception
     {
         adapter = table.adapter(connection);
@@ -162,9 +184,11 @@ final class FeatureIterator implements 
Spliterator<Feature>, AutoCloseable {
         } else {
             estimatedSize = 0;              // Cannot estimate the size if 
there is filtering conditions.
         }
-        result = connection.createStatement().executeQuery(sql);
-        dependencies = new FeatureIterator[adapter.dependencies.length];
-        statement = null;
+        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;
     }
 
     /**
@@ -185,6 +209,8 @@ 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;
     }
 
@@ -260,7 +286,7 @@ final class FeatureIterator implements 
Spliterator<Feature>, AutoCloseable {
      */
     private boolean fetch(final Consumer<? super Feature> action, final 
boolean all) throws Exception {
         while (result.next()) {
-            final Feature feature = adapter.createFeature(spatialInformation, 
result);
+            Feature feature = adapter.createFeature(spatialInformation, 
result);
             for (int i=0; i < dependencies.length; i++) {
                 WeakValueHashMap<?,Object> instances = null;
                 Object key = null, value = null;
@@ -294,6 +320,19 @@ final class FeatureIterator implements 
Spliterator<Feature>, AutoCloseable {
                 }
                 feature.setPropertyValue(adapter.associationNames[i], value);
             }
+            /*
+             * At this point, we have done everything we could do using SQL 
statements.
+             * Those statements were derived (among others) from expressions 
that have
+             * been recognized as `ValueReference` instances. If the user 
specified more
+             * complex expressions, we need to handle them in Java code.
+             */
+            if (projection != null) {
+                if (completion) {
+                    projection.applySelf(feature);
+                } else {
+                    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 1cbd99ae93..c2a226c459 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,6 +16,9 @@
  */
 package org.apache.sis.storage.sql.feature;
 
+import java.util.Set;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.Objects;
 import java.util.Comparator;
 import java.util.Spliterator;
@@ -29,6 +32,7 @@ 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.metadata.sql.privy.SQLBuilder;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.privy.Strings;
@@ -37,9 +41,11 @@ import org.apache.sis.util.stream.PaginedStream;
 import org.apache.sis.filter.privy.SortByComparator;
 import org.apache.sis.filter.privy.WarningEvent;
 import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.base.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;
 
@@ -63,6 +69,14 @@ final class FeatureStream extends DeferredStream<Feature> {
      */
     private final Table table;
 
+    /**
+     * The columns that the user wants to keep, or {@code null} for all 
columns.
+     * The word "projection" in this context is in the <abbr>SQL</abbr> 
database sense.
+     *
+     * @see #map(Function)
+     */
+    private FeatureProjection projection;
+
     /**
      * The visitor to use for converting filters/expressions to SQL statements.
      * This is used for writing the content of the {@link SelectionClause}.
@@ -183,7 +197,7 @@ final class FeatureStream extends DeferredStream<Feature> {
             WarningEvent.LISTENER.set(selection);
             final var optimization = new Optimization();
             optimization.setFeatureType(table.featureType);
-            for (final Filter<? super Feature> filter : 
optimization.applyAndDecompose((Filter<? super Feature>) predicate)) {
+            for (final var filter : optimization.applyAndDecompose((Filter<? 
super Feature>) predicate)) {
                 if (filter == Filter.include()) continue;
                 if (filter == Filter.exclude()) return empty();
                 if (!selection.tryAppend(filterToSQL, filter)) {
@@ -295,7 +309,12 @@ final class FeatureStream extends DeferredStream<Feature> {
      * be optimized.
      */
     @Override
+    @SuppressWarnings("unchecked")
     public <R> Stream<R> map(final Function<? super Feature, ? extends R> 
mapper) {
+        if (projection == null && mapper instanceof FeatureProjection) {
+            projection = (FeatureProjection) mapper;
+            return (Stream) this;
+        }
         return new PaginedStream<>(super.map(mapper), this);
     }
 
@@ -403,11 +422,38 @@ final class FeatureStream extends DeferredStream<Feature> 
{
      */
     @Override
     protected Spliterator<Feature> createSourceIterator() throws Exception {
-        lock(table.database.transactionLocks);
+        Table projected = table;
+        FeatureProjection completion = null;
+        if (projection != null) {
+            final var unhandled = new LinkedHashMap<String, Expression<? super 
Feature, ?>>();
+            final var reusedNames = new HashSet<String>();
+            projected = new Table(projected, projection, reusedNames, 
unhandled);
+            if (!unhandled.isEmpty()) {
+                /*
+                 * 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.
+                 */
+                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;
+                }
+            }
+        }
+        lock(projected.database.transactionLocks);
         final Connection connection = getConnection();
         setCloseHandler(connection);  // Executed only if `FeatureIterator` 
creation fails, discarded later otherwise.
         makeReadOnly(connection);
-        final var features = new FeatureIterator(table, connection, distinct, 
selection, sort, offset, count);
+        final var features = new FeatureIterator(projected, connection, 
distinct, selection, sort, offset, count, completion);
         setCloseHandler(features);
         selection = null;             // Let the garbage collector do its work.
         return features;
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Relation.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Relation.java
index 8471b3ccbc..6cd050cd52 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Relation.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Relation.java
@@ -54,7 +54,7 @@ import org.apache.sis.util.resources.Errors;
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
  */
-final class Relation extends TableReference {
+final class Relation extends TableReference implements Cloneable {
     /**
      * An empty array used when there are no relations.
      */
@@ -154,8 +154,11 @@ final class Relation extends TableReference {
     /**
      * The name of the feature property where the association to {@link 
#searchTable} table will be stored.
      * If the foreigner key uses exactly one column, then this is the name of 
that column.
+     *
+     * @see #getPropertyName()
+     * @see #setPropertyName(String)
      */
-    String propertyName;
+    private String propertyName;
 
     /**
      * Whether the {@link #columns} map include all primary key columns. This 
field is set to {@code false}
@@ -247,6 +250,23 @@ final class Relation extends TableReference {
         }
     }
 
+    /**
+     * Returns a relation identical to this relation except for the property 
name.
+     * This method does not modify this relation, but may return {@code this} 
if
+     * there is no name change to apply.
+     *
+     * @param  property  the new property name.
+     * @return a relation with the given property name (may be {@code this}).
+     */
+    final Relation rename(final String property) {
+        if (property.equals(propertyName)) {
+            return this;
+        }
+        final Relation c = clone();
+        c.propertyName = property;
+        return c;
+    }
+
     /**
      * Invoked after construction for setting the name of the feature property 
of the enclosing table where to
      * store association to the feature instances read from the {@linkplain 
#getSearchTable() search table}.
@@ -265,6 +285,15 @@ final class Relation extends TableReference {
         }
     }
 
+    /**
+     * Sets the property name. This method should be invoked during 
{@linkplain FeatureAnalyzer analysis time} only.
+     *
+     * @param  name  the new property name.
+     */
+    final void setPropertyName(final String name) {
+        propertyName = name;
+    }
+
     /**
      * Returns the name of the feature property where the association to the 
search table will be stored.
      * If the foreigner key uses exactly one column, then this is the name of 
that column.
@@ -471,4 +500,19 @@ final class Relation extends TableReference {
     public String toString() {
         return toString(this, (n) -> appendTo(n, " — "));
     }
+
+    /**
+     * Returns a shallow clone of this relation.
+     * Used by this {@code Relation} implementation only and should not be 
invoked directly.
+     *
+     * @see #rename(String)
+     */
+    @Override
+    protected final Relation clone() {
+        try {
+            return (Relation) super.clone();
+        } catch (CloneNotSupportedException e) {
+            throw new AssertionError(e);
+        }
+    }
 }
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources.java
index 7ea58675af..000c657f73 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources.java
@@ -63,6 +63,11 @@ public class Resources extends IndexedResourceBundle {
          */
         public static final short CanNotAnalyzeFully = 17;
 
+        /**
+         * Error while building the SQL query. Fallback partially on pure Java 
implementation.
+         */
+        public static final short CanNotBuildSQL = 19;
+
         /**
          * Cannot fetch a Coordinate Reference System (CRS) for SRID code {0}.
          */
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources.properties
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources.properties
index 1060cb1e1c..15eaa17134 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources.properties
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources.properties
@@ -21,6 +21,7 @@
 #
 AssumeUnsigned                    = Assume database byte/tinyint unsigned, due 
to a lack of metadata.
 CanNotAnalyzeFully                = Cannot analyze fully the database schema 
because of incomplete metadata.
+CanNotBuildSQL                    = Error while building the SQL query. 
Fallback partially on pure Java implementation.
 CanNotFetchCRS_1                  = Cannot fetch a Coordinate Reference System 
(CRS) for SRID code {0}.
 CanNotFindSRID_1                  = Cannot find an identifier in the database 
for the reference system \u201c{0}\u201d.
 DataSource                        = Provider of connections to the database.
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources_fr.properties
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources_fr.properties
index 3d6a4a87f0..6f7724df8d 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources_fr.properties
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Resources_fr.properties
@@ -26,13 +26,14 @@
 #
 AssumeUnsigned                    = Les valeurs de type 
\u2018tinyint\u2019/\u2018byte\u2019 seront consid\u00e9r\u00e9es comme non 
sign\u00e9es, car la base de donn\u00e9es ne fournit pas d\u2019information 
\u00e0 ce sujet.
 CanNotAnalyzeFully                = Ne peut pas analyser compl\u00e8tement le 
sch\u00e9ma de la base de donn\u00e9es parce que les m\u00e9ta-donn\u00e9es 
sont incompl\u00e8tes.
+CanNotBuildSQL                    = Erreur lors de la construction de la 
requ\u00eate SQL. Ex\u00e9cute partiellement en Java comme solution de rechange.
 CanNotFetchCRS_1                  = Ne peut pas obtenir un syst\u00e8me de 
r\u00e9f\u00e9rence des coordonn\u00e9es pour le code SRID {0}.
 CanNotFindSRID_1                  = Ne peut pas trouver un identifiant dans la 
base de donn\u00e9es pour le syst\u00e8me de r\u00e9f\u00e9rence 
\u00ab\u202f{0}\u202f\u00bb.
 DataSource                        = Fournisseur de connexions \u00e0 la base 
de donn\u00e9es.
 DuplicatedColumn_1                = Doublon inattendu d\u2019une colonne 
nomm\u00e9e \u00ab\u202f{0}\u202f\u00bb.
 DuplicatedSRID_2                  = L\u2019identifiant de r\u00e9f\u00e9rence 
spatiale (SRID) {1} a plusieurs entr\u00e9s dans la table 
\u00ab\u202f{0}\u202f\u00bb.
 IllegalQualifiedName_1            = \u00ab\u202f{0}\u202f\u00bb n\u2019est pas 
un nom qualifi\u00e9 de table valide.
-IncompatibleLiteralCRS_2          = Le litt\u00e9ral de la fonction 
\u00ab\u202f{0}\u202f\u00bb n'est pas compatible avec le syst\u00e8me de 
r\u00e9f\u00e9rence de la propri\u00e9t\u00e9 \u00ab\u202f{1}\u202f\u00bb.
+IncompatibleLiteralCRS_2          = Le litt\u00e9ral de la fonction 
\u00ab\u202f{0}\u202f\u00bb n\u2019est pas compatible avec le syst\u00e8me de 
r\u00e9f\u00e9rence de la propri\u00e9t\u00e9 \u00ab\u202f{1}\u202f\u00bb.
 InternalError                     = Erreur inattendue pendant l\u2019analyse 
du sch\u00e9ma de la base de donn\u00e9es.
 MalformedForeignerKey_2           = Colonne \u00ab\u202f{1}\u202f\u00bb 
inattendue dans la cl\u00e9 \u00e9trang\u00e8re \u00ab\u202f{0}\u202f\u00bb.
 MappedSQLQueries                  = Noms de ressources associ\u00e9s \u00e0 
des requ\u00eates SQL.
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 13856b4c89..808c4b80b7 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
@@ -17,6 +17,8 @@
 package org.apache.sis.storage.sql.feature;
 
 import java.util.Map;
+import java.util.Set;
+import java.util.List;
 import java.util.Optional;
 import java.util.stream.Stream;
 import java.sql.Connection;
@@ -29,20 +31,25 @@ import org.opengis.geometry.Envelope;
 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.metadata.sql.privy.Reflection;
 import org.apache.sis.metadata.sql.privy.SQLBuilder;
 import org.apache.sis.pending.jdk.JDK19;
+import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.Debug;
 import org.apache.sis.util.Exceptions;
 import org.apache.sis.util.collection.WeakValueHashMap;
 import org.apache.sis.util.collection.TreeTable;
 import org.apache.sis.util.iso.DefaultNameSpace;
+import org.apache.sis.util.resources.Errors;
 
 // 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.FeatureAssociationRole;
+import org.opengis.filter.Expression;
+import org.opengis.filter.InvalidFilterValueException;
 
 
 /**
@@ -186,25 +193,94 @@ 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.
      *
-     * @todo This constructor is not yet used because it is an unfinished work.
-     *       We need to invent some mechanism for using a subset of the 
columns.
-     *       A starting point is {@link 
org.apache.sis.storage.FeatureQuery#expectedType(FeatureType)}.
+     * @param  table        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.
+     * @throws InvalidFilterValueException if there is an error in the 
declaration of property values.
      */
-    Table(final Table parent) {
-        super(parent.listeners, false);
-        database = parent.database;
-        query    = parent.query;
-        name     = parent.name;
-
-        // TODO: filter the columns.
-        primaryKey   = parent.primaryKey;
-        attributes   = parent.attributes;
-        importedKeys = parent.importedKeys;
-        exportedKeys = parent.exportedKeys;
-        featureType  = parent.featureType;
-        hasGeometry  = parent.hasGeometry;
-        hasRaster    = parent.hasRaster;
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
+    Table(final Table source,
+          final FeatureProjection projection,
+          final Set<String> reusedNames,
+          final Map<String, Expression<? super Feature, ?>> unhandled)
+    {
+        super(source.listeners, false);
+        database    = source.database;
+        query       = source.query;
+        name        = source.name;
+        featureType = projection.featureType;
+        /*
+         * Temporary values the 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];
+        final var importedKeys = new Relation[source.importedKeys.length];
+        final var exportedKeys = new Relation[source.exportedKeys.length];
+        int attributesCount    = 0;
+        int importedKeysCount  = 0;
+        int exportedKeysCount  = 0;
+        boolean hasGeometry    = false;
+        /*
+         * 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 int count = storedProperties.size();
+        for (int i=0; i<count; i++) {
+            final String xpath = projection.getSourcePropertyName(i, false);
+            if (xpath == null) {
+                unhandled.put(storedProperties.get(i), 
projection.getExpression(i));
+                continue;
+            }
+            final Column column = source.getColumn(xpath);
+            if (column != null) {
+                hasGeometry |= column.getGeometryType().isPresent();
+                final String name = storedProperties.get(i);
+                final Column renamed = column.rename(name);
+                attributes[attributesCount++] = renamed;
+                if (renamed == column) {
+                    reusedNames.add(name);
+                }
+                continue;
+            }
+            Relation relation = source.getRelation(xpath, false);
+            if (relation != null) {
+                importedKeys[importedKeysCount++] = relation;
+                continue;
+            }
+            relation = source.getRelation(xpath, false);
+            if (relation != null) {
+                exportedKeys[exportedKeysCount++] = relation;
+                continue;
+            }
+            throw new 
InvalidFilterValueException(Errors.forLocale(listeners.getLocale())
+                    .getString(Errors.Keys.PropertyNotFound_2, 
source.featureType.getName(), xpath));
+        }
+        /*
+         * Save the columns and foreigner keys that this table handles.
+         * Take the primary key only if we have all the needed columns
+         * and those columns have the same names as in the source table.
+         */
+        this.hasGeometry  = hasGeometry;
+        this.hasRaster    = source.hasRaster;   // Not yet accurately 
determined.
+        this.attributes   = ArraysExt.resize(attributes,   attributesCount);
+        this.importedKeys = ArraysExt.resize(importedKeys, importedKeysCount);
+        this.exportedKeys = ArraysExt.resize(exportedKeys, exportedKeysCount);
+        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))) {
+                    primaryKey = null;
+                    return;
+                }
+            }
+        }
+        this.primaryKey = source.primaryKey;
     }
 
     /**
@@ -229,7 +305,7 @@ final class Table extends AbstractFeatureSet {
                      * have been set to association names. If 
`ClassCastException` occurs here, it is a bug
                      * in our object constructions.
                      */
-                    final var association = (FeatureAssociationRole) 
featureType.getProperty(relation.propertyName);
+                    final var association = (FeatureAssociationRole) 
featureType.getProperty(relation.getPropertyName());
                     final Table table = 
tables.get(association.getValueType().getName());
                     if (table == null) {
                         throw new 
InternalDataStoreException(association.toString());
@@ -278,7 +354,7 @@ final class Table extends AbstractFeatureSet {
     final void appendTo(TreeTable.Node parent) {
         parent = Relation.newChild(parent, featureType.getName().toString());
         for (final Column attribute : attributes) {
-            TableReference.newChild(parent, attribute.propertyName);
+            TableReference.newChild(parent, attribute.getPropertyName());
         }
         appendAll(parent, importedKeys, " → ");
         appendAll(parent, exportedKeys, " ← ");
@@ -367,7 +443,7 @@ final class Table extends AbstractFeatureSet {
             if (m == null) {
                 m = JDK19.newHashMap(attributes.length);
                 for (final Column c : attributes) {
-                    String label = c.propertyName;
+                    String label = c.getPropertyName();
                     m.put(label, c);
                     final int s = 
label.lastIndexOf(DefaultNameSpace.DEFAULT_SEPARATOR);
                     if (s >= 0) {
@@ -380,6 +456,30 @@ final class Table extends AbstractFeatureSet {
         return m.get(xpath);
     }
 
+    /**
+     * 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.
+     *
+     * @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.
+     */
+    private Relation getRelation(final String xpath, final boolean exports) {
+        boolean tip = false;
+        do {    // Execute 1 or 2 times: check tips only if no exact match.
+            for (final Relation c : (exports ? exportedKeys : importedKeys)) {
+                String label = c.getPropertyName();
+                if (tip) {
+                    label = 
label.substring(label.lastIndexOf(DefaultNameSpace.DEFAULT_SEPARATOR) + 1);
+                }
+                if (label.equals(xpath)) {
+                    return c;
+                }
+            }
+        } while ((tip = !tip) == true);
+        return null;
+    }
+
     /**
      * If this table imports the inverse of the given relation, returns the 
imported relation.
      * Otherwise returns {@code null}. This method is used for preventing 
infinite recursion.
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/Postgres.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/Postgres.java
index 7507ebf31d..a68494cd8f 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/Postgres.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/Postgres.java
@@ -84,7 +84,7 @@ public final class Postgres<G> extends Database<G> {
                 }
             }
         } catch (SQLException e) {
-            log(Resources.forLocale(null).getLogRecord(Level.CONFIG,
+            
log(Resources.forLocale(listeners.getLocale()).getLogRecord(Level.CONFIG,
                 Resources.Keys.SpatialExtensionNotFound_1, "PostGIS"));
         }
     }
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/test/org/apache/sis/storage/sql/SQLStoreTest.java
 
b/endorsed/src/org.apache.sis.storage.sql/test/org/apache/sis/storage/sql/SQLStoreTest.java
index 80a484c799..989576bb5b 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/test/org/apache/sis/storage/sql/SQLStoreTest.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/test/org/apache/sis/storage/sql/SQLStoreTest.java
@@ -174,9 +174,9 @@ public final class SQLStoreTest extends TestOnAllDatabases {
      * Creates a {@link SQLStore} instance with the specified table as a 
resource, then tests some queries.
      */
     private void testTableQuery(final StorageConnector connector, final 
ResourceDefinition table) throws Exception {
-        try (SimpleFeatureStore store = new SimpleFeatureStore(new 
SQLStoreProvider(), connector, table)) {
+        try (var store = new SimpleFeatureStore(new SQLStoreProvider(), 
connector, table)) {
             verifyFeatureTypes(store);
-            final Map<String,Integer> countryCount = new HashMap<>();
+            final var countryCount = new HashMap<String,Integer>();
             try (Stream<Feature> features = 
store.findResource("Cities").features(false)) {
                 features.forEach((f) -> verifyContent(f, countryCount));
             }
@@ -287,7 +287,7 @@ public final class SQLStoreTest extends TestOnAllDatabases {
          */
         assertEquals(c.countryName, getIndirectPropertyValue(feature, 
"country", "native_name"));
         if (isCanada) {
-            final Feature f = (Feature) feature.getPropertyValue("country");
+            final var f = (Feature) feature.getPropertyValue("country");
             if (canada == null) {
                 canada = f;
             } else {
@@ -424,8 +424,8 @@ public final class SQLStoreTest extends TestOnAllDatabases {
     private void verifyWhereOnLink(SimpleFeatureStore dataset) throws 
Exception {
         final String   desiredProperty = "native_name";
         final String[] expectedValues  = {"Canada"};
-        final FeatureSet   countries   = dataset.findResource("Countries");
-        final FeatureQuery query       = new FeatureQuery();
+        final FeatureSet countries     = dataset.findResource("Countries");
+        final var query = new FeatureQuery();
         query.setSelection(FF.equal(FF.property("sis:identifier"), 
FF.literal("CAN")));
         final String executionMode;
         final Object[] names;
@@ -452,8 +452,8 @@ public final class SQLStoreTest extends TestOnAllDatabases {
      */
     private void verifyStreamOperations(final FeatureSet cities) throws 
DataStoreException {
         try (Stream<Feature> features = cities.features(false)) {
-            final AtomicInteger peekCount = new AtomicInteger();
-            final AtomicInteger mapCount  = new AtomicInteger();
+            final var peekCount = new AtomicInteger();
+            final var mapCount  = new AtomicInteger();
             final long actualPopulations = features.peek(f -> 
peekCount.incrementAndGet())
                     .peek(f -> peekCount.incrementAndGet())
                     .map (f -> {mapCount.incrementAndGet(); return f;})
@@ -487,7 +487,7 @@ public final class SQLStoreTest extends TestOnAllDatabases {
                 "SELECT * FROM " + SCHEMA + ".\"Cities\" WHERE \"population\" 
>= 1000000")))
         {
             final FeatureSet cities = store.findResource("LargeCities");
-            final Map<String,Integer> countryCount = new HashMap<>();
+            final var countryCount = new HashMap<String,Integer>();
             try (Stream<Feature> features = cities.features(false)) {
                 features.forEach((f) -> verifyContent(f, countryCount));
             }
@@ -510,7 +510,7 @@ public final class SQLStoreTest extends TestOnAllDatabases {
             /*
              * Add a filter for parks in France.
              */
-            final FeatureQuery query = new FeatureQuery();
+            final var query = new FeatureQuery();
             query.setSortBy(FF.sort(FF.property("native_name"), 
SortOrder.DESCENDING));
             query.setSelection(FF.equal(FF.property("country"), 
FF.literal("FRA")));
             query.setProjection(FF.property("native_name"));
@@ -546,7 +546,7 @@ public final class SQLStoreTest extends TestOnAllDatabases {
         {
             final FeatureSet parks = store.findResource("MyQuery");
             final FeatureType type = parks.getType();
-            final AttributeType<?> property = (AttributeType<?>) 
TestUtilities.getSingleton(type.getProperties(true));
+            final var property = (AttributeType<?>) 
TestUtilities.getSingleton(type.getProperties(true));
             assertEquals("title", property.getName().toString(), "Property 
name should be label defined in query");
             assertEquals(String.class, property.getValueClass(), "Attribute 
should be a string");
             assertEquals(0, property.getMinimumOccurs(), "Column should be 
nullable.");
@@ -581,7 +581,7 @@ public final class SQLStoreTest extends TestOnAllDatabases {
      */
     private void verifyDistinctQuery(final StorageConnector connector) throws 
Exception {
         final Object[] expected;
-        try (SimpleFeatureStore store = new SimpleFeatureStore(null, 
connector, ResourceDefinition.query("Countries",
+        try (var store = new SimpleFeatureStore(null, connector, 
ResourceDefinition.query("Countries",
                 "SELECT \"country\" FROM " + SCHEMA + ".\"Parks\" ORDER BY 
\"country\"")))
         {
             final FeatureSet countries = store.findResource("Countries");
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 675104c8c7..7a961f9af1 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
@@ -27,29 +27,21 @@ import javax.measure.Quantity;
 import javax.measure.quantity.Length;
 import org.opengis.util.GenericName;
 import org.opengis.geometry.Envelope;
-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.AttributeConvention;
-import org.apache.sis.feature.privy.FeatureExpression;
 import org.apache.sis.filter.DefaultFilterFactory;
 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.XPath;
 import org.apache.sis.storage.internal.Resources;
 import org.apache.sis.pending.jdk.JDK19;
 import org.apache.sis.util.ArgumentChecks;
-import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.CharSequences;
+import org.apache.sis.util.Emptiable;
 import org.apache.sis.util.iso.Names;
-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.Attribute;
 import org.opengis.feature.AttributeType;
 import org.opengis.feature.Operation;
@@ -60,7 +52,6 @@ import org.opengis.filter.Literal;
 import org.opengis.filter.ValueReference;
 import org.opengis.filter.SortBy;
 import org.opengis.filter.SortProperty;
-import org.opengis.filter.InvalidFilterValueException;
 
 
 /**
@@ -86,7 +77,7 @@ import org.opengis.filter.InvalidFilterValueException;
  * @version 1.5
  * @since   1.1
  */
-public class FeatureQuery extends Query implements Cloneable, Serializable {
+public class FeatureQuery extends Query implements Cloneable, Emptiable, 
Serializable {
     /**
      * For cross-version compatibility.
      */
@@ -109,7 +100,7 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
     private NamedExpression[] projection;
 
     /**
-     * The filter for trimming feature instances.
+     * The filter for trimming feature instances, or {@code null} if none.
      * In a database, "feature instances" are table rows.
      * Subset of rows is called <dfn>selection</dfn> in relational database 
terminology.
      *
@@ -140,7 +131,7 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
     private long limit;
 
     /**
-     * The expressions to use for sorting the feature instances.
+     * The expressions to use for sorting the feature instances, or {@code 
null} if none.
      *
      * @see #getSortBy()
      * @see #setSortBy(SortBy)
@@ -149,7 +140,7 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
     private SortBy<Feature> sortBy;
 
     /**
-     * Hint used by resources to optimize returned features.
+     * Hint used by resources to optimize returned features, or {@code null} 
for full resolution.
      * Different stores make use of vector tiles of different scales.
      * A {@code null} value means to query data at their full resolution.
      *
@@ -166,19 +157,53 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
         limit = UNLIMITED;
     }
 
+    /**
+     * Creates a new query initialized to the same values than the given query.
+     * This is an alternative to the {@link #clone()} method when the caller
+     * wants to change the implementation class.
+     *
+     * @param  other  the other query from which to copy the configuration.
+     *
+     * @see #clone()
+     *
+     * @since 1.5
+     */
+    public FeatureQuery(final FeatureQuery other) {
+        projection       = other.projection;
+        selection        = other.selection;
+        skip             = other.skip;
+        limit            = other.limit;
+        sortBy           = other.sortBy;
+        linearResolution = other.linearResolution;
+    }
+
+    /**
+     * Returns {@code true} if this query do not specify any filtering.
+     *
+     * @return if this query performs no filtering.
+     *
+     * @since 1.5
+     */
+    @Override
+    public boolean isEmpty() {
+        return (projection == null) && (selection == null) && (skip == 0) && 
(limit < 0) && (sortBy == null)
+                && (linearResolution == null);
+    }
+
     /**
      * Sets the properties to retrieve by their names. This convenience method 
wraps the
      * given names in {@link ValueReference} expressions without alias and 
delegates to
      * {@link #setProjection(NamedExpression...)}.
      *
      * @param  properties  properties to retrieve, or {@code null} to retrieve 
all properties.
-     * @throws IllegalArgumentException if a property is duplicated.
+     * @throws IllegalArgumentException if the given array is empty of if a 
property is duplicated.
      */
     @Override
     public void setProjection(final String... properties) {
         NamedExpression[] wrappers = null;
         if (properties != null) {
-            final FilterFactory<Feature,?,?> ff = 
DefaultFilterFactory.forFeatures();
+            ArgumentChecks.ensureNonEmpty("properties", properties);
+            final var ff = DefaultFilterFactory.forFeatures();
             wrappers = new NamedExpression[properties.length];
             for (int i=0; i<wrappers.length; i++) {
                 final String p = properties[i];
@@ -195,12 +220,14 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
      * delegates to {@link #setProjection(NamedExpression...)}.
      *
      * @param  properties  properties to retrieve, or {@code null} to retrieve 
all properties.
-     * @throws IllegalArgumentException if a property is duplicated.
+     * @throws IllegalArgumentException if the given array is empty of if a 
property is duplicated.
      */
     @SafeVarargs
+    @SuppressWarnings("varargs")
     public final void setProjection(final Expression<? super Feature, ?>... 
properties) {
         NamedExpression[] wrappers = null;
         if (properties != null) {
+            ArgumentChecks.ensureNonEmpty("properties", properties);
             wrappers = new NamedExpression[properties.length];
             for (int i=0; i<wrappers.length; i++) {
                 final Expression<? super Feature, ?> e = properties[i];
@@ -227,7 +254,7 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
         if (properties != null) {
             ArgumentChecks.ensureNonEmpty("properties", properties);
             properties = properties.clone();
-            final Map<Object,Integer> uniques = 
JDK19.newLinkedHashMap(properties.length);
+            final var uniques = 
JDK19.<Object,Integer>newLinkedHashMap(properties.length);
             for (int i=0; i<properties.length; i++) {
                 final NamedExpression c = properties[i];
                 ArgumentChecks.ensureNonNullElement("properties", i, c);
@@ -255,25 +282,6 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
         return (projection != null) ? projection.clone() : null;
     }
 
-    /**
-     * Returns the properties to be stored in the target feature.
-     */
-    final NamedExpression[] getStoredProjection() {
-        final NamedExpression[] stored = getProjection();
-        if (stored != null) {
-            int count = 0;
-            for (final NamedExpression p : stored) {
-                if (p.type == ProjectionType.STORED) {
-                    stored[count++] = p;
-                }
-            }
-            if (count != 0) {
-                return ArraysExt.resize(stored, count);
-            }
-        }
-        return null;
-    }
-
     /**
      * Sets the approximate area of feature instances to include in the subset.
      * This convenience method creates a filter that checks if the bounding box
@@ -620,7 +628,7 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
                 return true;
             }
             if (obj != null && getClass() == obj.getClass()) {
-                final NamedExpression other = (NamedExpression) obj;
+                final var other = (NamedExpression) obj;
                 return expression.equals(other.expression) && 
Objects.equals(alias, other.alias) && type == other.type;
             }
             return false;
@@ -633,7 +641,7 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
          */
         @Override
         public String toString() {
-            final StringBuilder buffer = new StringBuilder("SELECT ");
+            final var buffer = new StringBuilder("SELECT ");
             appendTo(buffer);
             return buffer.toString();
         }
@@ -719,125 +727,40 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
      * @since 1.2
      */
     protected FeatureSet execute(final FeatureSet source) throws 
DataStoreException {
-        Objects.requireNonNull(source);
-        final FeatureQuery query = clone();
-        if (query.selection != null) {
-            final Optimization optimization = new Optimization();
-            optimization.setFeatureType(source.getType());
-            query.selection = optimization.apply(query.selection);
+        if (isEmpty()) {
+            return source;
         }
+        final FeatureQuery query = clone();
+        query.optimize(source);
         return new FeatureSubset(source, query);
     }
 
     /**
-     * Returns the type of values evaluated by this query when executed on 
features of the given type.
-     * If some expressions have no name, default names are computed as below:
+     * Optimizes this query before execution. This method is invoked by {@link 
#execute(FeatureSet)}
+     * on a {@linkplain #clone() clone} of the user-provided query. The 
default implementations tries
+     * to optimize the {@linkplain #getSelection() selection} filter using 
{@link Optimization}.
+     * Subclasses can override for modifying the optimization algorithm.
      *
-     * <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  source  the set of features given to the {@code 
execute(FeatureSet)} method.
+     * @throws DataStoreException if an error occurred during the optimization 
of this query.
      *
-     * @param  valueType  the type of features to be evaluated by the 
expressions in this query.
-     * @return type resulting from expressions evaluation (never null).
-     * @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
-     *         in this query. It may be because that expression is backed by 
an unsupported implementation.
+     * @since 1.5
      */
-    final FeatureType expectedType(final FeatureType valueType) {
-        if (projection == null) {
-            return valueType;           // All columns included: result is of 
the same type.
-        }
-        int unnamedNumber = 0;          // Sequential number for unnamed 
expressions.
-        Set<String> names = null;       // Names already used, for avoiding 
collisions.
-        final FeatureTypeBuilder ftb = new 
FeatureTypeBuilder().setName(valueType.getName());
-        for (int column = 0; column < projection.length; column++) {
-            final 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 Expression<? super Feature,?> expression = item.expression;
-            final FeatureExpression<?,?> fex = 
FeatureExpression.castOrCopy(expression);
-            final PropertyTypeBuilder resultType;
-            if (fex == null || (resultType = fex.expectedType(valueType, 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 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 = null;
-                if (expression instanceof ValueReference<?,?>) {
-                    tip = XPath.toPropertyName(((ValueReference<?,?>) 
expression).getXPath());
-                    /*
-                     * 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 = valueType.getProperty(tip).getName();
-                    if (name == null || !names.add(name.toString())) {
-                        name = null;
-                        if (tip.isEmpty() || 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.
-             */
-            if (item.type == ProjectionType.COMPUTING && resultType instanceof 
AttributeTypeBuilder<?>) {
-                final var ab = (AttributeTypeBuilder<?>) resultType;
-                final AttributeType<?> storedType = ab.build();
-                if (ftb.properties().remove(resultType)) {
-                    final var properties = Map.of(AbstractOperation.NAME_KEY, 
name);
-                    ftb.addProperty(FeatureOperations.expression(properties, 
expression, storedType));
-                }
-            } else {
-                resultType.setName(name);
-            }
+    protected void optimize(final FeatureSet source) throws DataStoreException 
{
+        if (selection != null) {
+            final var optimization = new Optimization();
+            optimization.setFeatureType(source.getType());
+            selection = optimization.apply(selection);
         }
-        return ftb.build();
     }
 
     /**
      * Returns a clone of this query.
      *
      * @return a clone of this query.
+     *
+     * @see #FeatureQuery(FeatureQuery)
+     * @see #optimize(FeatureSet)
      */
     @Override
     public FeatureQuery clone() {
@@ -876,7 +799,7 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
             return true;
         }
         if (obj != null && getClass() == obj.getClass()) {
-            final FeatureQuery other = (FeatureQuery) obj;
+            final var other = (FeatureQuery) obj;
             return skip  == other.skip &&
                    limit == other.limit &&
                    Objects.equals(selection,        other.selection) &&
@@ -895,7 +818,7 @@ public class FeatureQuery extends Query implements 
Cloneable, Serializable {
      */
     @Override
     public String toString() {
-        final StringBuilder sb = new StringBuilder(80);
+        final var sb = new StringBuilder(80);
         sb.append("SELECT ");
         if (projection != null) {
             for (int i=0; i<projection.length; i++) {
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 ce6e9563bc..32159973bd 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.feature.privy.FeatureUtilities;
+import org.apache.sis.storage.base.FeatureProjection;
 import org.apache.sis.storage.base.MetadataBuilder;
 import org.apache.sis.storage.base.StoreUtilities;
 import org.apache.sis.storage.internal.Resources;
@@ -28,7 +28,6 @@ import org.apache.sis.storage.internal.Resources;
 import org.opengis.feature.Feature;
 import org.opengis.feature.FeatureType;
 import org.opengis.filter.Filter;
-import org.opengis.filter.Expression;
 import org.opengis.filter.SortBy;
 
 
@@ -52,6 +51,12 @@ final class FeatureSubset extends AbstractFeatureSet {
      */
     private final FeatureQuery query;
 
+    /**
+     * A function applying projections (with "projected" in the 
<abbr>SQL</abbr> database sense) of features.
+     * This is computed together with {@link #resultType}. May stay {@code 
null} if there is no projection.
+     */
+    private FeatureProjection projection;
+
     /**
      * The type of features in this set. May or may not be the same as {@link 
#source}.
      * This is computed when first needed.
@@ -74,7 +79,7 @@ final class FeatureSubset extends AbstractFeatureSet {
      */
     @Override
     protected Metadata createMetadata() throws DataStoreException {
-        final MetadataBuilder builder = new MetadataBuilder();
+        final var builder = new MetadataBuilder();
         builder.addDefaultMetadata(this, listeners);
         
builder.addLineage(Resources.formatInternational(Resources.Keys.UnfilteredData));
         
builder.addProcessDescription(Resources.formatInternational(Resources.Keys.SubsetQuery_1,
 StoreUtilities.getLabel(source)));
@@ -90,7 +95,8 @@ final class FeatureSubset extends AbstractFeatureSet {
         if (resultType == null) {
             final FeatureType type = source.getType();
             try {
-                resultType = query.expectedType(type);
+                projection = FeatureProjection.create(type, 
query.getProjection());
+                resultType = (projection != null) ? projection.featureType : 
type;
             } catch (IllegalArgumentException e) {
                 throw new 
DataStoreContentException(Resources.forLocale(listeners.getLocale())
                         
.getString(Resources.Keys.CanNotDeriveTypeFromFeature_1, type.getName()), e);
@@ -134,25 +140,14 @@ final class FeatureSubset extends AbstractFeatureSet {
             stream = stream.limit(limit.getAsLong());
         }
         /*
-         * Transform feature instances.
-         * Note: "projection" here is in relational database sense, not map 
projection.
+         * Transform feature instances, usually for keeping only a subset of 
the properties.
+         * Note: "projection" here is in relational database sense (SQL), not 
map projection.
+         * This operation should be last, because the filter applied above may 
need properties
+         * that are excluded by this projection.
          */
-        final FeatureQuery.NamedExpression[] projection = 
query.getStoredProjection();
+        getType();      // Force the computation of `projection` if not 
already done.
         if (projection != null) {
-            @SuppressWarnings({"unchecked", "rawtypes"})
-            final Expression<? super Feature,?>[] expressions = new 
Expression[projection.length];
-            for (int i=0; i<expressions.length; i++) {
-                expressions[i] = projection[i].expression;
-            }
-            final FeatureType type = getType();
-            final String[] names = 
FeatureUtilities.getNames(type.getProperties(false));
-            stream = stream.map(t -> {
-                final Feature f = type.newInstance();
-                for (int i=0; i < expressions.length; i++) {
-                    f.setPropertyValue(names[i], expressions[i].apply(t));
-                }
-                return f;
-            });
+            stream = stream.map(projection);
         }
         return stream;
     }
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
new file mode 100644
index 0000000000..5a25571e28
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/FeatureProjection.java
@@ -0,0 +1,383 @@
+/*
+ * 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));
+        }
+    }
+}


Reply via email to