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)); + } + } +}