/* The MIT License (MIT) * * Copyright (c) 2015 Reinventing Geospatial, Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ package com.rgi.geopackage.features; import com.rgi.common.BoundingBox; import com.rgi.common.Pair; import com.rgi.common.util.functional.ThrowingFunction; import com.rgi.common.util.jdbc.JdbcUtility; import com.rgi.geopackage.core.GeoPackageCore; import com.rgi.geopackage.core.SpatialReferenceSystem; import com.rgi.geopackage.features.geometry.Geometry; import com.rgi.geopackage.features.geometry.GeometryFactory; import com.rgi.geopackage.utility.DatabaseUtility; import com.rgi.geopackage.verification.VerificationIssue; import com.rgi.geopackage.verification.VerificationLevel; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.function.Consumer; import java.util.stream.Collectors; /** * @author Luke Lambert * */ public class GeoPackageFeatures { /** * Constructor * * @param databaseConnection * The open connection to the database that contains a GeoPackage * @param core * Access to GeoPackage's "core" methods */ public GeoPackageFeatures(final Connection databaseConnection, final GeoPackageCore core) { this.databaseConnection = databaseConnection; this.core = core; } /** * @param verificationLevel * Controls the level of verification testing performed * @return the Feature GeoPackage requirements this GeoPackage fails to conform to * @throws SQLException * If there is a database error */ public Collection<VerificationIssue> getVerificationIssues(final VerificationLevel verificationLevel) throws SQLException { return new FeaturesVerifier(this.databaseConnection, verificationLevel).getVerificationIssues(); } /** * Creates a user defined features table, and adds a corresponding entry to * the content table * * @param tableName * The name of the features table. The table name must begin * with a letter (A..Z, a..z) or an underscore (_) and may only * be followed by letters, underscores, or numbers, and may not * begin with the prefix "gpkg_" * @param identifier * A human-readable identifier (e.g. short name) for the * tableName content * @param description * A human-readable description for the tableName content * @param boundingBox * Bounding box for all content in tableName * @param spatialReferenceSystem * Spatial Reference System (SRS) * @param primaryKeyColumnName * Column name for the primary key. The column name must begin * with a letter (A..Z, a..z) or an underscore (_) and may * only be followed by letters, underscores, or numbers * @param geometryColumn * Geometry column definition * @param columnDefinitions * Definitions of non-geometry columns * @return Returns a newly created user defined features table * @throws SQLException * throws if the method {@link #getFeatureSet(String) * getFeatureSet} or the method {@link * DatabaseUtility#tableOrViewExists(Connection, String) * tableOrViewExists} or if the database cannot roll back the * changes after a different exception throws will throw an * SQLException */ public FeatureSet addFeatureSet(final String tableName, final String identifier, final String description, final BoundingBox boundingBox, final SpatialReferenceSystem spatialReferenceSystem, final String primaryKeyColumnName, final GeometryColumnDefinition geometryColumn, final ColumnDefinition... columnDefinitions) throws SQLException { return this.addFeatureSet(tableName, identifier, description, boundingBox, spatialReferenceSystem, primaryKeyColumnName, geometryColumn, Arrays.asList(columnDefinitions)); } /** * Creates a user defined features table, and adds a corresponding entry to * the content table * * @param tableName * The name of the features table. The table name must begin * with a letter (A..Z, a..z) or an underscore (_) and may only * be followed by letters, underscores, or numbers, and may not * begin with the prefix "gpkg_" * @param identifier * A human-readable identifier (e.g. short name) for the * tableName content * @param description * A human-readable description for the tableName content * @param boundingBox * Bounding box for all content in tableName * @param spatialReferenceSystem * Spatial Reference System (SRS) * @param primaryKeyColumnName * Column name for the primary key. The column name must begin * with a letter (A..Z, a..z) or an underscore (_) and may * only be followed by letters, underscores, or numbers * @param geometryColumn * Geometry column definition * @param columnDefinitions * Definitions of non-geometry columns * @return Returns a newly created user defined features table * @throws SQLException * throws if the method {@link #getFeatureSet(String) * getFeatureSet} or the method {@link * DatabaseUtility#tableOrViewExists(Connection, String) * tableOrViewExists} or if the database cannot roll back the * changes after a different exception throws will throw an * SQLException */ public FeatureSet addFeatureSet(final String tableName, final String identifier, final String description, final BoundingBox boundingBox, final SpatialReferenceSystem spatialReferenceSystem, final String primaryKeyColumnName, final GeometryColumnDefinition geometryColumn, final Collection<ColumnDefinition> columnDefinitions) throws SQLException { DatabaseUtility.validateTableName(tableName); if(geometryColumn == null) { throw new IllegalArgumentException("Geometry column definition name may not be null"); } if(columnDefinitions == null || columnDefinitions.contains(null)) { throw new IllegalArgumentException("Column definitions may not be null"); } if(DatabaseUtility.tableOrViewExists(this.databaseConnection, tableName)) { throw new IllegalArgumentException("A table already exists with this feature set's table name"); } try { // Create the feature set table this.addFeatureTableNoCommit(tableName, primaryKeyColumnName, geometryColumn, columnDefinitions); // Add feature set to the content table this.core.addContent(tableName, FeatureSet.FeatureContentType, identifier, description, boundingBox, spatialReferenceSystem); this.addGeometryColumnNoCommit(tableName, geometryColumn, spatialReferenceSystem.getIdentifier()); this.databaseConnection.commit(); return this.getFeatureSet(tableName); // TODO this is a lazy way of doing things (carried on from the tiles implementation). There should be a method in core to query for the only information that can't already be obtained in this function - the last_change } catch(final Throwable th) { this.databaseConnection.rollback(); throw th; } } /** * Gets the geometry column definition for a specified {@link FeatureSet} * * @param featureSet * Target feature set * @return a {@link GeometryColumn} * @throws SQLException * When there is a database error */ public GeometryColumn getGeometryColumn(final FeatureSet featureSet) throws SQLException { if(featureSet == null) { throw new IllegalArgumentException("Feature set may not be null"); } final String geometryColumnQuery = String.format("SELECT %s, %s, %s, %s, %s FROM %s WHERE %s = ?", "column_name", "geometry_type_name", "srs_id", "z", "m", GeoPackageFeatures.GeometryColumnsTableName, "table_name"); return JdbcUtility.selectOne(this.databaseConnection, geometryColumnQuery, preparedStatement -> preparedStatement.setString(1, featureSet.getTableName()), resultSet -> new GeometryColumn(featureSet.getTableName(), resultSet.getString(1), // geometry column name resultSet.getString(2), // geometry column type resultSet.getInt (3), // geometry column srs id ValueRequirement.fromInt(resultSet.getInt(4)), // z value requirement ValueRequirement.fromInt(resultSet.getInt(5)))); // m value requirement } /** * Gets the non-identifier (primary key) and non-geometry columns * * @param featureSet * Handle to a feature table * @return a collection of {@link Column}s that describe the attributes the feature set contains * @throws SQLException * If there is a database error */ public List<Column> getAttributeColumns(final FeatureSet featureSet) throws SQLException { if(featureSet == null) { throw new IllegalArgumentException("Feature set may not be null"); } try(final Statement statement = GeoPackageFeatures.this.databaseConnection.createStatement()) { //noinspection JDBCExecuteWithNonConstantString try(final ResultSet tableInfo = statement.executeQuery(String.format("PRAGMA table_info(%s)", featureSet.getTableName()))) { final List<Column> columns = new ArrayList<>(); while(tableInfo.next()) { final String name = tableInfo.getString("name"); if(!tableInfo.getBoolean("pk") && // We don't want the primary key column !name.equalsIgnoreCase(featureSet.getGeometryColumnName())) // We also don't want the geometry column { final String type = tableInfo.getString("type"); final String defaultValue = tableInfo.getString("dflt_value"); final EnumSet<ColumnFlag> flags = EnumSet.noneOf(ColumnFlag.class); if(tableInfo.getBoolean("notnull")) { flags.add(ColumnFlag.NotNull); } // TODO there are other ColumnFlags that need to be checked here: AutoIncrement, Unique // TODO neither those flags aren't directly available in table_info columns.add(new Column(name, type, flags, null, defaultValue)); } } return columns; } } } /** * Gets a feature set object based on its table name * * @param featureSetTableName * Name of a feature set table * @return Returns a {@link FeatureSet} or null if there isn't with the * supplied table name * @throws SQLException * If there is a database error */ @SuppressWarnings("JDBCExecuteWithNonConstantString") public FeatureSet getFeatureSet(final String featureSetTableName) throws SQLException { if(!DatabaseUtility.tableOrViewExists(this.databaseConnection, GeoPackageFeatures.GeometryColumnsTableName)) { return null; } final String geometryColumnQuery = String.format("SELECT %s FROM %s WHERE %s = ?", "column_name", GeoPackageFeatures.GeometryColumnsTableName, "table_name"); final String geometryColumnName = JdbcUtility.selectOne(this.databaseConnection, geometryColumnQuery, preparedStatement -> preparedStatement.setString(1, featureSetTableName), resultSet -> resultSet.getString(1)); // geometry column name if(geometryColumnName == null) // If the table exists, but isn't an entry in the geometry column table... { return null; } try(final Statement statement = GeoPackageFeatures.this.databaseConnection.createStatement()) { try(final ResultSet tableInfo = statement.executeQuery(String.format("PRAGMA table_info(%s)", featureSetTableName))) { String primaryKeyColumnName = null; final Collection<String> attributeColumnNames = new ArrayList<>(); while(tableInfo.next()) { final String name = tableInfo.getString("name"); if(!name.equalsIgnoreCase(geometryColumnName)) { if(tableInfo.getBoolean("pk")) { primaryKeyColumnName = name; } else // non-primary key columns (there can only be one primary key column according to the spec) { attributeColumnNames.add(name); } } } final String finalPrimaryKeyColumnName = primaryKeyColumnName; return this.core.getContent(featureSetTableName, (tableName, dataType, identifier, description, lastChange, minimumX, minimumY, maximumX, maximumY, spatialReferenceSystem) -> new FeatureSet(tableName, identifier, description, lastChange, minimumX, minimumY, maximumX, maximumY, spatialReferenceSystem, finalPrimaryKeyColumnName, geometryColumnName, attributeColumnNames)); } } } /** * Gets all entries in the GeoPackage's contents table with the "features" * data_type * * @return Returns a collection of {@link FeatureSet}s * @throws SQLException * throws if the method * {@link #getFeatureSets(SpatialReferenceSystem) getFeatureSets} * throws */ public Collection<FeatureSet> getFeatureSets() throws SQLException { return this.getFeatureSets(null); } /** * Gets all entries in the GeoPackage's contents table with the "features" * data_type that also match the supplied spatial reference system * * @param matchingSpatialReferenceSystem * Spatial reference system that returned {@link FeatureSet}s * much refer to * @return Returns a collection of {@link FeatureSet}s * @throws SQLException * if there's an SQL error */ public Collection<FeatureSet> getFeatureSets(final SpatialReferenceSystem matchingSpatialReferenceSystem) throws SQLException { return this.core .getContentTableNames(FeatureSet.FeatureContentType, matchingSpatialReferenceSystem) .stream() .map((ThrowingFunction<String, FeatureSet>)(this::getFeatureSet)) .collect(Collectors.toList()); } /** * Returns a list of all features that correspond to the given feature set. * If a large set of features is anticipated use {@link #visitFeatures} to * avoid memory issues * * @param featureSet * Handle to a feature table * @return List of features * @throws SQLException * if there is a database error * @throws WellKnownBinaryFormatException * Handle to a feature table */ public List<Feature> getFeatures(final FeatureSet featureSet) throws SQLException, WellKnownBinaryFormatException { if(featureSet == null) { throw new IllegalArgumentException("Feature set may not be null"); } final String featureQuery = String.format("SELECT %s, %s%s FROM %s", featureSet.getPrimaryKeyColumnName(), featureSet.getGeometryColumnName(), featureSet.getAttributeColumnNames().isEmpty() ? "" : ", " + String.join(", ", featureSet.getAttributeColumnNames()), featureSet.getTableName()); try(final Statement statement = this.databaseConnection.createStatement()) { //noinspection JDBCExecuteWithNonConstantString try(final ResultSet resultSet = statement.executeQuery(featureQuery)) { final List<Feature> results = new ArrayList<>(); while(resultSet.next()) { final Map<String, Object> attributes = new HashMap<>(); for(final String columnName : featureSet.getAttributeColumnNames()) { attributes.put(columnName, resultSet.getObject(columnName)); } results.add(new Feature(resultSet.getInt(featureSet.getPrimaryKeyColumnName()), this.createGeometry(resultSet.getBytes(featureSet.getGeometryColumnName())), attributes)); } return results; } } } /** * Gets a {@link Feature} given a geometry column and feature identifier * * @param featureSet * Feature set containing the requested feature * @param featureIdentifier * Identifier for a feature * @return a {@link Feature} * @throws SQLException * if there is a database error * @throws WellKnownBinaryFormatException * if any of the features contain malformed Well Known Binary data */ public Feature getFeature(final FeatureSet featureSet, final int featureIdentifier) throws SQLException, WellKnownBinaryFormatException { if(featureSet == null) { throw new IllegalArgumentException("Feature set may not be null"); } final String featureQuery = String.format("SELECT %s%s FROM %s WHERE %s = ?", featureSet.getGeometryColumnName(), featureSet.getAttributeColumnNames().isEmpty() ? "" : ", " + String.join(", ", featureSet.getAttributeColumnNames()), featureSet.getTableName(), featureSet.getPrimaryKeyColumnName()); final Pair<byte[], Map<String, Object>> feature = JdbcUtility.selectOne(this.databaseConnection, featureQuery, preparedStatement -> preparedStatement.setInt(1, featureIdentifier), resultSet -> { final Map<String, Object> attributes = new HashMap<>(); for(final String columnName : featureSet.getAttributeColumnNames()) { attributes.put(columnName, resultSet.getObject(columnName)); } return Pair.of(resultSet.getBytes(featureSet.getGeometryColumnName()), attributes); }); if(feature == null) { return null; } return new Feature(featureIdentifier, this.createGeometry(feature.getLeft()), feature.getRight()); } /** * Applies a consumer to every feature in a feature set * * @param featureSet * Handle to a feature table * @param featureConsumer * Callback that operates on a single feature * @throws SQLException * if there is a database error * @throws WellKnownBinaryFormatException * Handle to a feature table */ public void visitFeatures(final FeatureSet featureSet, final Consumer<Feature> featureConsumer) throws SQLException, WellKnownBinaryFormatException { if(featureSet == null) { throw new IllegalArgumentException("Geometry column may not be null"); } if(featureConsumer == null) { throw new IllegalArgumentException("Feature consumer may not be null"); } final String featureQuery = String.format("SELECT %s, %s%s FROM %s", featureSet.getPrimaryKeyColumnName(), featureSet.getGeometryColumnName(), featureSet.getAttributeColumnNames().isEmpty() ? "" : ", " + String.join(", ", featureSet.getAttributeColumnNames()), featureSet.getTableName()); try(final Statement statement = this.databaseConnection.createStatement()) { //noinspection JDBCExecuteWithNonConstantString try(final ResultSet resultSet = statement.executeQuery(featureQuery)) { while(resultSet.next()) { final Map<String, Object> attributes = new HashMap<>(); for(final String columnName : featureSet.getAttributeColumnNames()) { attributes.put(columnName, resultSet.getObject(columnName)); } featureConsumer.accept(new Feature(resultSet.getInt(featureSet.getPrimaryKeyColumnName()), this.createGeometry(resultSet.getBytes(featureSet.getGeometryColumnName())), attributes)); } } } } /** * Adds a feature to a feature set * * @param geometryColumn * Geometry column of a feature set * @param geometry * Geometry of a feature * @param attributeColumnNames * List of attribute column names, specified in the same order as the supplied values * @param attributeValues * List of attribute values, specified in the same order as the supplied column names * @return a handle to the newly created {@link Feature} object * @throws SQLException * if there is a database error */ public Feature addFeature(final GeometryColumn geometryColumn, final Geometry geometry, final List<String> attributeColumnNames, final List<Object> attributeValues) throws SQLException { if(geometryColumn == null) { throw new IllegalArgumentException("Geometry column may not be null"); } if(geometry == null) { throw new IllegalArgumentException("Geometry may not be null"); } if(attributeColumnNames == null) { throw new IllegalArgumentException("Attribute column names may not be null"); } if(attributeValues == null) { throw new IllegalArgumentException("Attribute values may not be null"); } if(attributeColumnNames.size() != attributeValues.size()) { throw new IllegalArgumentException("The number of attribute column names must match the number of attribute values"); } if(!geometryColumn.getGeometryType() .toUpperCase() .equals(geometry.getGeometryTypeName())) { throw new IllegalArgumentException("Geometry column may only contain geometries of type " + geometryColumn.getGeometryType().toUpperCase()); } verifyValueRequirements(geometryColumn, geometry); final List<String> columnNames = new LinkedList<>(attributeColumnNames); columnNames.add(0, geometryColumn.getColumnName()); final String insertFeatureSql = String.format("INSERT INTO %s (%s) VALUES (%s)", geometryColumn.getTableName(), String.join(", ", columnNames), String.join(", ", Collections.nCopies(columnNames.size(), "?"))); final int identifier = JdbcUtility.update(this.databaseConnection, insertFeatureSql, preparedStatement -> { int parameterIndex = 1; final byte[] bytes = createBlob(geometry, geometryColumn.getSpatialReferenceSystemIdentifier()); preparedStatement.setBytes(parameterIndex++, bytes); columnNames.remove(0); // Skip the geometry column for(final Object attributeValue : attributeValues) { preparedStatement.setObject(parameterIndex++, attributeValue); } }, resultSet -> resultSet.getInt(1)); // New feature identifier this.databaseConnection.commit(); final Map<String, Object> attributes = new HashMap<>(attributeColumnNames.size()); for(int x = 0; x < attributeColumnNames.size(); ++x) { attributes.put(attributeColumnNames.get(x), attributeValues .get(x)); } return new Feature(identifier, geometry, attributes); } /** * Add multiple features to a feature set * * @param geometryColumn * Geometry column of the target feature set * @param attributeColumnNames * A list of columns for which the attribute values are being provided * @param features * A collection of geometry/attribute collection pairs. The * attribute collection must have the same number and order for * attributes as specified by the attributeColumns parameter. * @throws SQLException * if there is a database error */ public void addFeatures(final GeometryColumn geometryColumn, final List<String> attributeColumnNames, final Iterable<Pair<Geometry, List<Object>>> features) throws SQLException { if(geometryColumn == null) { throw new IllegalArgumentException("Geometry column may not be null"); } if(attributeColumnNames == null) { throw new IllegalArgumentException("Columns may not be null"); } if(features == null) { throw new IllegalArgumentException("Values may not be null"); } features.forEach(feature -> { if(feature == null) { throw new IllegalArgumentException("Features collection may not contain null features"); } final Geometry geometry = feature.getLeft(); if(geometry == null) { throw new IllegalArgumentException("Features collection may not contain null geometries"); } final List<Object> attributes = feature.getRight(); if(attributes == null) { throw new IllegalArgumentException("Feature collection may not have a null set of attributes"); } if(attributes.size() != attributeColumnNames.size()) { throw new IllegalArgumentException("Feature attribute collections must match the size of the attribute column name collection"); } if(!geometryColumn.getGeometryType() .toUpperCase() .equals(geometry.getGeometryTypeName())) { throw new IllegalArgumentException("Geometry column may only contain geometries of type " + geometryColumn.getGeometryType().toUpperCase()); } verifyValueRequirements(geometryColumn, geometry); }); final List<String> columnNames = new LinkedList<>(attributeColumnNames); columnNames.add(0, geometryColumn.getColumnName()); final int columnCount = columnNames.size(); final String insertFeatureSql = String.format("INSERT INTO %s (%s) VALUES (%s)", geometryColumn.getTableName(), String.join(", ", columnNames), String.join(", ", Collections.nCopies(columnNames.size(), "?"))); JdbcUtility.update(this.databaseConnection, insertFeatureSql, features, (preparedStatement, feature) -> { final Geometry geometry = feature.getLeft(); final List<Object> attributes = feature.getRight(); preparedStatement.setBytes(1, createBlob(geometry, geometryColumn.getSpatialReferenceSystemIdentifier())); for(int parameterIndex = 2; parameterIndex <= columnCount; ++parameterIndex) { preparedStatement.setObject(parameterIndex, attributes.get(parameterIndex-2)); } }); this.databaseConnection.commit(); } /** * Associate a geometry factory with a specific geometry type code. * * @param geometryTypeCode * Code representation of the geometry type. Must be in the * range 0 and 2^32 - 1 (range of a 32 bit unsigned integer) * inclusive * @param geometryFactory * Callback that creates a geometry that corresponds to the * geometry type code */ public void registerGeometryFactory(final long geometryTypeCode, final GeometryFactory geometryFactory) { this.wellKnownBinaryFactory.registerGeometryFactory(geometryTypeCode, geometryFactory); } /** * Creates the Geometry Column table * <br> * <br> * <b>**WARNING**</b> this does not do a database commit. It is expected * that this transaction will always be paired with others that need to be * committed or roll back as a single transaction. * * @throws SQLException * if there is a database error */ protected void createGeometryColumnTableNoCommit() throws SQLException { // Create the geometry column table or view if(!DatabaseUtility.tableOrViewExists(this.databaseConnection, GeoPackageFeatures.GeometryColumnsTableName)) { JdbcUtility.update(this.databaseConnection, getGeometryColumnsCreationSql()); } } /** * Creates an entry in the gpkg_geometry_columns table * <br> * <br> * <b>**WARNING**</b> this does not do a database commit. It is expected * that this transaction will always be paired with others that need to be * committed or roll back as a single transaction. * * @param tableName * The name of the features table * @param geometryColumn * Definition of a geometry column to be added * @param spatialReferenceSystemIdentifier * Spatial Reference System (SRS) * @throws SQLException * if there is a database error */ protected void addGeometryColumnNoCommit(final String tableName, final GeometryColumnDefinition geometryColumn, final int spatialReferenceSystemIdentifier) throws SQLException { this.createGeometryColumnTableNoCommit(); // Create the feature metadata table final String insertTileMatrix = String.format("INSERT INTO %s (%s, %s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, ?, ?)", GeoPackageFeatures.GeometryColumnsTableName, "table_name", "column_name", "geometry_type_name", "srs_id", "z", "m"); JdbcUtility.update(this.databaseConnection, insertTileMatrix, preparedStatement -> { preparedStatement.setString(1, tableName); preparedStatement.setString(2, geometryColumn.getName()); preparedStatement.setString(3, geometryColumn.getType()); preparedStatement.setInt (4, spatialReferenceSystemIdentifier); preparedStatement.setInt (5, geometryColumn.getZRequirement().getValue()); preparedStatement.setInt (6, geometryColumn.getMRequirement().getValue()); }); } protected static String getGeometryColumnsCreationSql() { // http://www.geopackage.org/spec/#gpkg_geometry_columns_cols // http://www.geopackage.org/spec/#gpkg_geometry_columns_sql return "CREATE TABLE " + GeoPackageFeatures.GeometryColumnsTableName + '\n' + "(table_name TEXT NOT NULL, -- Name of the table containing the geometry column\n" + " column_name TEXT NOT NULL, -- Name of a column in the feature table that is a Geometry Column\n" + " geometry_type_name TEXT NOT NULL, -- Name from Geometry Type Codes (Core) or Geometry Type Codes (Extension) in Geometry Types (Normative)\n" + " srs_id INTEGER NOT NULL, -- Spatial Reference System ID: gpkg_spatial_ref_sys.srs_id\n" + " z TINYINT NOT NULL, -- 0: z values prohibited; 1: z values mandatory; 2: z values optional\n" + " m TINYINT NOT NULL, -- 0: m values prohibited; 1: m values mandatory; 2: m values optional\n" + " CONSTRAINT pk_geom_cols PRIMARY KEY (table_name, column_name)," + " CONSTRAINT uk_gc_table_name UNIQUE (table_name)," + " CONSTRAINT fk_gc_tn FOREIGN KEY (table_name) REFERENCES gpkg_contents (table_name)," + " CONSTRAINT fk_gc_srs FOREIGN KEY (srs_id) REFERENCES gpkg_spatial_ref_sys (srs_id));"; } private Geometry createGeometry(final byte[] geoPackageBinaryBlob) throws WellKnownBinaryFormatException { final BinaryHeader binaryHeader = new BinaryHeader(geoPackageBinaryBlob); // This will throw if the array length is too short to contain a header (or if it's not long enough to contain the envelope type specified) if(binaryHeader.getBinaryType() == BinaryType.Standard) { final int headerByteLength = binaryHeader.getByteSize(); return this.wellKnownBinaryFactory .createGeometry(ByteBuffer.wrap(geoPackageBinaryBlob, headerByteLength, geoPackageBinaryBlob.length - headerByteLength) .asReadOnlyBuffer()); // Minor insurance that geometry extension implementations can't change the buffer } // else, this is an extended binary type. The next 4 bytes are the "extension_code" // http://www.geopackage.org/spec/#_requirement-70 // "... This extension_code SHOULD identify the implementer of the // extension and/or the particular geometry type extension, and SHOULD // be unique." // TODO: read the 4 byte extension code, and look it up in a mapping of known extensions codes/parsers (to be registered by extension implementors) throw new WellKnownBinaryFormatException("Extensions of GeoPackageBinary geometry encoding are not currently supported"); } private static byte[] createBlob(final Geometry geometry, final int spatialReferenceSystemIdentifier) { try(final ByteOutputStream byteOutputStream = new ByteOutputStream()) { // TODO HEADER USES OPTIONS: // FORCE ENVELOPE // FORCE ENDIANNESS BinaryHeader.writeBytes(byteOutputStream, geometry, spatialReferenceSystemIdentifier); byteOutputStream.setByteOrder(ByteOrder.BIG_ENDIAN); // TODO make this an option (?) geometry.writeWellKnownBinary(byteOutputStream); return byteOutputStream.array(); } } private static void verifyValueRequirements(final GeometryColumn geometryColumn, final Geometry geometry) { final ValueRequirement zRequirement = geometryColumn.getZRequirement(); final ValueRequirement mRequirement = geometryColumn.getMRequirement(); final boolean hasZ = geometry.hasZ(); final boolean hasM = geometry.hasM(); if((zRequirement == ValueRequirement.Prohibited && hasZ) || (zRequirement == ValueRequirement.Mandatory && !hasZ) || (mRequirement == ValueRequirement.Prohibited && hasM) || (mRequirement == ValueRequirement.Mandatory && !hasM)) { throw new IllegalArgumentException(String.format("Geometry is incompatible with the requirements Z %s, M %s", zRequirement.toString().toLowerCase(), mRequirement.toString().toLowerCase())); } } private void addFeatureTableNoCommit(final String featureTableName, final String primaryKeyColumnName, final GeometryColumnDefinition geometryColumn, final Collection<ColumnDefinition> columnDefinitions) throws SQLException { // http://www.geopackage.org/spec/#feature_user_tables // http://www.geopackage.org/spec/#example_feature_table_sql final List<AbstractColumnDefinition> columns = new LinkedList<>(columnDefinitions); columns.add(0, new PrimaryKeyColumnDefinition(primaryKeyColumnName)); columns.add(1, geometryColumn); // TODO Move the table-building functionality to the table definition class? final StringBuilder createTableSql = new StringBuilder(); createTableSql.append("CREATE TABLE "); createTableSql.append(featureTableName); createTableSql.append("\n("); for(int columnIndex = 0; columnIndex < columns.size(); ++columnIndex) { final AbstractColumnDefinition column = columns.get(columnIndex); final String comment = column.getComment(); final String defaultValue = column.getDefaultValue().equals(ColumnDefault.None) ? "" : " DEFAULT " + column.getDefaultValue().sqlLiteral(); createTableSql.append(System.lineSeparator()); createTableSql.append(column.getName()); // Verified by AbstractColumnDefinition to not contain SQL injection createTableSql.append(' '); createTableSql.append(column.getType()); // Verified by AbstractColumnDefinition to not contain SQL injection createTableSql.append(column.hasFlag(ColumnFlag.PrimaryKey) ? " PRIMARY KEY" : ""); createTableSql.append(column.hasFlag(ColumnFlag.AutoIncrement) ? " AUTOINCREMENT" : ""); createTableSql.append(column.hasFlag(ColumnFlag.NotNull) ? " NOT NULL" : ""); createTableSql.append(column.hasFlag(ColumnFlag.Unique) ? " UNIQUE" : ""); createTableSql.append(defaultValue); createTableSql.append(columnIndex == columns.size()-1 ? "" : ","); createTableSql.append(comment == null ? "" : " -- " + comment); // Verified by AbstractColumnDefinition to not contain newlines, which I think is the only way injection could work here } createTableSql.append("\n);"); JdbcUtility.update(this.databaseConnection, createTableSql.toString()); } /** * Standard table name of the GeoPackage Geometry Columns table */ public static final String GeometryColumnsTableName = "gpkg_geometry_columns"; private final Connection databaseConnection; private final GeoPackageCore core; private final WellKnownBinaryFactory wellKnownBinaryFactory = new WellKnownBinaryFactory(); }