diff --git a/pom.xml b/pom.xml index 106d82b..c10f083 100644 --- a/pom.xml +++ b/pom.xml @@ -105,6 +105,11 @@ hakunapi-gpkg ${project.version} + + fi.nls.hakunapi + hakunapi-source-gpkg + ${project.version} + fi.nls.hakunapi hakunapi-html diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/config/HakunaConfigParser.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/config/HakunaConfigParser.java index fe63a30..2fff306 100644 --- a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/config/HakunaConfigParser.java +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/config/HakunaConfigParser.java @@ -279,8 +279,6 @@ public FeatureType readCollection(Path path, Map sourcesBy // Current prefix for properties String p = "collections." + collectionId + "."; - String title = get(p + "title", collectionId); - String description = get(p + "description", title); int[] srids = getSRIDs(get(p + "srid", get("default.collections.srid"))); HakunaGeometryDimension dim = getGeometryDims(collectionId, get(p + "geometryDimension")); @@ -292,6 +290,9 @@ public FeatureType readCollection(Path path, Map sourcesBy } SimpleFeatureType ft = source.parse(this, path, collectionId, srids); + String title = get(p + "title", ft.getTitle() != null ? ft.getTitle() : collectionId); + String description = get(p + "description", ft.getDescription() != null ? ft.getDescription() : title); + ft.setTitle(title); ft.setDescription(description); ft.setGeomDimension(dim); diff --git a/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGFeatureCollectionWriter.java b/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGFeatureCollectionWriter.java index c80d543..638f7fe 100644 --- a/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGFeatureCollectionWriter.java +++ b/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGFeatureCollectionWriter.java @@ -111,7 +111,7 @@ public void endFeatureCollection() throws Exception { insert.close(); GPKGGeometryColumn geometryColumn = createGeometryColumn(ft.getGeom()); GPKG.insertSpatialRefSys(c, GPKGSpatialRefSystems.get(srid)); - GPKGFeaturesTable tableEntry = new GPKGFeaturesTable(tableName, ft.getName(), ft.getTitle(), ft.getDescription(), envelope, srid); + GPKGFeaturesTable tableEntry = new GPKGFeaturesTable(tableName, ft.getTitle(), ft.getDescription(), envelope, srid); GPKG.insertFeaturesTableEntry(c, tableEntry); GPKG.insertGeometryColumn(c, geometryColumn); String idColumnName = isPropertyIntegerType(ft.getId()) ? ft.getId().getName() : FALLBACK_ID_FIELD; diff --git a/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGFeaturesTable.java b/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGFeaturesTable.java index 67c858f..21d01a6 100644 --- a/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGFeaturesTable.java +++ b/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGFeaturesTable.java @@ -8,7 +8,6 @@ public class GPKGFeaturesTable { private final String tableName; private final String identifier; - private final String title; private final String description; private final double minX; private final double minY; @@ -16,10 +15,9 @@ public class GPKGFeaturesTable { private final double maxY; private final int srid; - public GPKGFeaturesTable(String tableName, String identifier, String title, String description, Envelope envelope, int srid) { + public GPKGFeaturesTable(String tableName, String identifier, String description, Envelope envelope, int srid) { this.tableName = tableName; this.identifier = identifier; - this.title = title; this.description = description; this.minX = envelope.getMinX(); this.minY = envelope.getMinY(); @@ -36,10 +34,6 @@ public String getIdentifier() { return identifier; } - public String getTitle() { - return title; - } - public String getDescription() { return description; } diff --git a/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGGeometry.java b/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGGeometry.java index ead171e..d93d96e 100644 --- a/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGGeometry.java +++ b/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGGeometry.java @@ -72,15 +72,15 @@ public static ByteOrder getByteOrder(byte flags) { public static int getWKBOffset(int envelopeIndicator) { switch (envelopeIndicator) { case NO_ENVELOPE: - return 8 + LENGHT_NO_ENVELOPE; + return LENGHT_NO_ENVELOPE; case XY_ENVELOPE: - return 8 + LENGHT_XY_ENVELOPE; + return LENGHT_XY_ENVELOPE; case XYZ_ENVELOPE: - return 8 + LENGHT_XYZ_ENVELOPE; + return LENGHT_XYZ_ENVELOPE; case XYM_ENVELOPE: - return 8 + LENGHT_XYM_ENVELOPE; + return LENGHT_XYM_ENVELOPE; case XYZM_ENVELOPE: - return 8 + LENGHT_XYZM_ENVELOPE; + return LENGHT_XYZM_ENVELOPE; default: throw new IllegalArgumentException("Invalid value"); } diff --git a/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGGeometryColumn.java b/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGGeometryColumn.java index d97bd30..7403ba9 100644 --- a/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGGeometryColumn.java +++ b/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/GPKGGeometryColumn.java @@ -1,6 +1,8 @@ package fi.nls.hakunapi.gpkg; public class GPKGGeometryColumn { + + public static final String GPKG_TABLE_GEOMETRY_COLUMNS = "gpkg_geometry_columns"; public enum GeometryTypeName { GEOMETRY, diff --git a/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/extension/RtreeIndexExtension.java b/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/extension/RtreeIndexExtension.java index b5ea3d0..ce4aec8 100644 --- a/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/extension/RtreeIndexExtension.java +++ b/src/hakunapi-gpkg/src/main/java/fi/nls/hakunapi/gpkg/extension/RtreeIndexExtension.java @@ -28,6 +28,10 @@ public void createExtension(Connection c) throws SQLException { createTriggers(c); } + public static String getRtreeName(String tableName, String columnName) { + return String.format("rtree_%s_%s", tableName, columnName); + } + private void createVirtualTable(Connection c) throws SQLException { String sql = String.format("CREATE VIRTUAL TABLE rtree_%s_%s USING rtree(id, minx, maxx, miny, maxy)", tableName, columnName); diff --git a/src/hakunapi-simple-webapp-javax/pom.xml b/src/hakunapi-simple-webapp-javax/pom.xml index ccd1173..6b92967 100644 --- a/src/hakunapi-simple-webapp-javax/pom.xml +++ b/src/hakunapi-simple-webapp-javax/pom.xml @@ -34,6 +34,10 @@ fi.nls.hakunapi hakunapi-gpkg + + fi.nls.hakunapi + hakunapi-source-gpkg + fi.nls.hakunapi hakunapi-smile diff --git a/src/hakunapi-source-gpkg/pom.xml b/src/hakunapi-source-gpkg/pom.xml new file mode 100644 index 0000000..15814c2 --- /dev/null +++ b/src/hakunapi-source-gpkg/pom.xml @@ -0,0 +1,25 @@ + + 4.0.0 + + fi.nls.hakunapi + src + 1.3.0-SNAPSHOT + + hakunapi-source-gpkg + + + + fi.nls.hakunapi + hakunapi-gpkg + + + com.zaxxer + HikariCP + + + junit + junit + test + + + diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/BufferedResultSet.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/BufferedResultSet.java new file mode 100644 index 0000000..8d08fb0 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/BufferedResultSet.java @@ -0,0 +1,88 @@ +package fi.nls.hakunapi.source.gpkg; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import fi.nls.hakunapi.core.FeatureStream; +import fi.nls.hakunapi.core.ObjectArrayValueContainer; +import fi.nls.hakunapi.core.ValueContainer; +import fi.nls.hakunapi.core.ValueMapper; +import fi.nls.hakunapi.core.ValueProvider; +import fi.nls.hakunapi.core.util.U; + +public class BufferedResultSet implements FeatureStream { + + private static final Logger LOG = LoggerFactory.getLogger(BufferedResultSet.class); + + private final Connection c; + private final PreparedStatement ps; + private final ResultSet rs; + private final ResultSetValueProvider valueProvider; + private final List mappers; + private final ValueContainer[] buffer; + + private int i; + private int j; + private boolean closed; + + public BufferedResultSet(Connection c, PreparedStatement ps, ResultSet rs, int numColsRs, List mappers, int bufSize) { + this.c = c; + this.ps = ps; + this.rs = rs; + this.valueProvider = new ResultSetValueProvider(rs, numColsRs); + this.mappers = mappers; + this.buffer = new ValueContainer[bufSize]; + } + + @Override + public boolean hasNext() { + if (i < j) { + return true; + } + if (closed) { + return false; + } + + i = 0; + j = 0; + try { + for (; j < buffer.length; j++) { + if (!rs.next()) { + close(); + break; + } + ValueContainer container = buffer[j]; + if (container == null) { + buffer[j] = container = new ObjectArrayValueContainer(mappers.size()); + } + for (ValueMapper mapper : mappers) { + mapper.accept(valueProvider, container); + } + } + return j > 0; + } catch (Exception e) { + LOG.error("Failed to retrieve more features", e); + close(); + return false; + } + } + + @Override + public ValueProvider next() { + return buffer[i++]; + } + + @Override + public void close() { + closed = true; + U.closeSilent(rs); + U.closeSilent(ps); + U.closeSilent(c); + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgFeatureType.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgFeatureType.java new file mode 100644 index 0000000..66782cf --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgFeatureType.java @@ -0,0 +1,48 @@ +package fi.nls.hakunapi.source.gpkg; + +import javax.sql.DataSource; + +import fi.nls.hakunapi.core.FeatureProducer; +import fi.nls.hakunapi.core.SimpleFeatureType; +import fi.nls.hakunapi.gpkg.extension.RtreeIndexExtension; + +public class GpkgFeatureType extends SimpleFeatureType { + + private String table; + private boolean spatialIndex; + private DataSource ds; + + public String getTable() { + return table; + } + + public void setTable(String table) { + this.table = table; + } + + public boolean isSpatialIndex() { + return spatialIndex; + } + + public void setSpatialIndex(boolean spatialIndex) { + this.spatialIndex = spatialIndex; + } + + public DataSource getDatabase() { + return ds; + } + + public void setDatabase(DataSource ds) { + this.ds = ds; + } + + @Override + public FeatureProducer getFeatureProducer() { + return new GpkgSimpleFeatureProducer(); + } + + public String getSpatialIndexName() { + return RtreeIndexExtension.getRtreeName(table, getGeom().getColumn()); + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgQueryUtil.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgQueryUtil.java new file mode 100644 index 0000000..d1d3d0d --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgQueryUtil.java @@ -0,0 +1,171 @@ +package fi.nls.hakunapi.source.gpkg; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import fi.nls.hakunapi.core.QueryContext; +import fi.nls.hakunapi.core.ValueMapper; +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.filter.FilterOp; +import fi.nls.hakunapi.core.property.HakunaProperty; +import fi.nls.hakunapi.core.property.HakunaPropertyComposite; +import fi.nls.hakunapi.core.util.StringPair; +import fi.nls.hakunapi.source.gpkg.filter.GpkgIntersects; +import fi.nls.hakunapi.source.gpkg.filter.SQLAnd; +import fi.nls.hakunapi.source.gpkg.filter.SQLEqualTo; +import fi.nls.hakunapi.source.gpkg.filter.SQLFilter; +import fi.nls.hakunapi.source.gpkg.filter.SQLGreaterThan; +import fi.nls.hakunapi.source.gpkg.filter.SQLGreaterThanOrEqualTo; +import fi.nls.hakunapi.source.gpkg.filter.SQLIsNotNull; +import fi.nls.hakunapi.source.gpkg.filter.SQLIsNull; +import fi.nls.hakunapi.source.gpkg.filter.SQLLessThan; +import fi.nls.hakunapi.source.gpkg.filter.SQLLessThanOrEqualTo; +import fi.nls.hakunapi.source.gpkg.filter.SQLLike; +import fi.nls.hakunapi.source.gpkg.filter.SQLNot; +import fi.nls.hakunapi.source.gpkg.filter.SQLNotEqualTo; +import fi.nls.hakunapi.source.gpkg.filter.SQLOr; +import fi.nls.hakunapi.source.gpkg.filter.SQLUtil; + +public class GpkgQueryUtil { + + private static final EnumMap FILTERS; + static { + FILTERS = new EnumMap<>(FilterOp.class); + FILTERS.put(FilterOp.EQUAL_TO, new SQLEqualTo()); + FILTERS.put(FilterOp.NOT_EQUAL_TO, new SQLNotEqualTo()); + FILTERS.put(FilterOp.GREATER_THAN, new SQLGreaterThan()); + FILTERS.put(FilterOp.GREATER_THAN_OR_EQUAL_TO, new SQLGreaterThanOrEqualTo()); + FILTERS.put(FilterOp.LESS_THAN, new SQLLessThan()); + FILTERS.put(FilterOp.LESS_THAN_OR_EQUAL_TO, new SQLLessThanOrEqualTo()); + + FILTERS.put(FilterOp.LIKE, new SQLLike()); + + FILTERS.put(FilterOp.NULL, new SQLIsNull()); + FILTERS.put(FilterOp.NOT_NULL, new SQLIsNotNull()); + + FILTERS.put(FilterOp.OR, new SQLOr(FILTERS)); + FILTERS.put(FilterOp.AND, new SQLAnd(FILTERS)); + FILTERS.put(FilterOp.NOT, new SQLNot(FILTERS)); + + FILTERS.put(FilterOp.INTERSECTS_INDEX, new GpkgIntersects()); + FILTERS.put(FilterOp.INTERSECTS, new GpkgIntersects()); + } + + public static List select(StringBuilder query, List properties, QueryContext ctx) throws Exception { + query.append("SELECT "); + + Map columnToIndex = new HashMap<>(); + for (HakunaProperty property : properties) { + addToSelect(query, columnToIndex, property); + } + // Remove trailing comma + query.setLength(query.length() - 1); + + List mappers = new ArrayList<>(); + int iValueContainer = 0; + for (HakunaProperty property : properties) { + mappers.add(property.getMapper(columnToIndex, iValueContainer++, ctx)); + } + return mappers; + } + + private static void addToSelect(StringBuilder query, Map columnToIndex, HakunaProperty property) { + if (property instanceof HakunaPropertyComposite) { + HakunaPropertyComposite composite = (HakunaPropertyComposite) property; + for (HakunaProperty part : composite.getParts()) { + addToSelect(query, columnToIndex, part); + } + } else { + String table = property.getTable(); + for (String column : property.getColumns()) { + StringPair key = new StringPair(table, column); + if (!columnToIndex.containsKey(key)) { + int i = columnToIndex.size(); + columnToIndex.put(key, i); + String s = SQLUtil.toSQL(table, column); + query.append(s); + query.append(','); + } + } + } + } + + public static void from(StringBuilder query, String table) { + query.append(" FROM "); + query.append('"').append(table).append('"'); + } + + public static void where(StringBuilder q, List filters) { + boolean first = true; + for (Filter filter : filters) { + String sql = FILTERS.get(filter.getOp()).toSQL(filter); + if (sql == null) { + continue; + } + if (first) { + q.append(" WHERE "); + first = false; + } else { + q.append(" AND " ); + } + q.append(sql); + } + } + + public static void orderBy(StringBuilder queryBuilder, HakunaProperty prop, boolean asc) { + queryBuilder.append(" ORDER BY "); + queryBuilder.append('"'); + queryBuilder.append(prop.getTable()); + queryBuilder.append('"'); + queryBuilder.append('.'); + queryBuilder.append('"'); + queryBuilder.append(prop.getColumn()); + queryBuilder.append('"'); + queryBuilder.append(asc ? " ASC" : " DESC"); + } + + public static void orderBy(StringBuilder queryBuilder, List props, List ascending) { + if (props == null || props.isEmpty()) { + return; + } + queryBuilder.append(" ORDER BY "); + for (int i = 0; i < props.size(); i++) { + HakunaProperty prop = props.get(i); + boolean asc = ascending.get(i); + if (i > 0) { + queryBuilder.append(','); + } + queryBuilder.append('"'); + queryBuilder.append(prop.getTable()); + queryBuilder.append('"'); + queryBuilder.append('.'); + queryBuilder.append('"'); + queryBuilder.append(prop.getColumn()); + queryBuilder.append('"'); + queryBuilder.append(asc ? " ASC" : " DESC"); + } + } + + public static void offset(StringBuilder q, int offset) { + q.append(" OFFSET ").append(offset); + + } + + public static void limit(StringBuilder q, int limit) { + q.append(" LIMIT ").append(limit); + } + + public static void bind(Connection c, PreparedStatement ps, List filters) throws SQLException { + int i = 1; + for (Filter filter : filters) { + i = FILTERS.get(filter.getOp()).bind(filter, c, ps, i); + } + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgSimpleFeatureProducer.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgSimpleFeatureProducer.java new file mode 100644 index 0000000..7cc1a14 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgSimpleFeatureProducer.java @@ -0,0 +1,116 @@ +package fi.nls.hakunapi.source.gpkg; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import fi.nls.hakunapi.core.FeatureProducer; +import fi.nls.hakunapi.core.FeatureStream; +import fi.nls.hakunapi.core.PaginationStrategy; +import fi.nls.hakunapi.core.QueryContext; +import fi.nls.hakunapi.core.ValueMapper; +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.param.LimitParam; +import fi.nls.hakunapi.core.property.HakunaProperty; +import fi.nls.hakunapi.core.request.GetFeatureCollection; +import fi.nls.hakunapi.core.request.GetFeatureRequest; +import fi.nls.hakunapi.core.util.EmptyFeatureStream; +import fi.nls.hakunapi.core.util.U; + +/** + * Streaming implementation + * Reads a batch of BATCH_SIZE rows at a time + */ +public class GpkgSimpleFeatureProducer implements FeatureProducer { + + private static final Logger LOG = LoggerFactory.getLogger(GpkgSimpleFeatureProducer.class); + private static final int BATCH_SIZE = 250; + + @Override + public FeatureStream getFeatures(GetFeatureRequest request, GetFeatureCollection col) throws Exception { + GpkgFeatureType ft = (GpkgFeatureType) col.getFt(); + List filters = col.getFilters(); + int limit = request.getLimit(); + PaginationStrategy pagination = ft.getPaginationStrategy(); + + if (filters.stream().anyMatch(it -> it == Filter.DENY)) { + return new EmptyFeatureStream(); + } + + QueryContext ctx = new QueryContext(); + ctx.setSRID(request.getSRID()); + + StringBuilder q = new StringBuilder(); + List mappers = GpkgQueryUtil.select(q, col.getProperties(), ctx); + GpkgQueryUtil.from(q, ft.getTable()); + GpkgQueryUtil.where(q, filters); + if (limit != LimitParam.UNLIMITED) { + if (pagination.shouldSortBy()) { + GpkgQueryUtil.orderBy(q, pagination.getProperties(), pagination.getAscending()); + } + if (pagination.shouldOffset()) { + GpkgQueryUtil.offset(q, request.getOffset()); + } + // Limit by n + maxGroupSize for pagination purposes + GpkgQueryUtil.limit(q, limit + pagination.getMaxGroupSize()); + } + String query = q.toString(); + + Connection c = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + c = ft.getDatabase().getConnection(); + c.setAutoCommit(false); + ps = c.prepareStatement(query); + GpkgQueryUtil.bind(c, ps, filters); + LOG.info("{}", ps.toString()); + int bufSize = BATCH_SIZE; + ps.setFetchSize(bufSize); + rs = ps.executeQuery(); + int numColsRs = rs.getMetaData().getColumnCount(); + return new BufferedResultSet(c, ps, rs, numColsRs, mappers, bufSize); + } catch (Exception e) { + U.closeSilent(rs); + U.closeSilent(ps); + U.closeSilent(c); + throw e; + } + } + + @Override + public int getNumberMatched(GetFeatureRequest request, GetFeatureCollection col) throws Exception { + GpkgFeatureType ft = (GpkgFeatureType) col.getFt(); + List filters = col.getFilters(); + + List allProperties = new ArrayList<>(); + allProperties.add(ft.getId()); + if (ft.getGeom() != null) { + allProperties.add(ft.getGeom()); + } + allProperties.addAll(col.getProperties()); + + StringBuilder q = new StringBuilder("SELECT COUNT(*)"); + GpkgQueryUtil.from(q, ft.getTable()); + GpkgQueryUtil.where(q, filters); + String query = q.toString(); + + try (Connection c = ft.getDatabase().getConnection(); + PreparedStatement ps = c.prepareStatement(query)) { + GpkgQueryUtil.bind(c, ps, filters); + LOG.debug(ps.toString()); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + return -1; + } + return rs.getInt(1); + } + } + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgSimpleSource.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgSimpleSource.java new file mode 100644 index 0000000..5fc52ea --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/GpkgSimpleSource.java @@ -0,0 +1,581 @@ +package fi.nls.hakunapi.source.gpkg; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import javax.sql.DataSource; + +import org.locationtech.jts.geom.Envelope; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sqlite.SQLiteConnection; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.HakunapiPlaceholder; +import fi.nls.hakunapi.core.PaginationStrategy; +import fi.nls.hakunapi.core.PaginationStrategyCursor; +import fi.nls.hakunapi.core.PaginationStrategyOffset; +import fi.nls.hakunapi.core.SimpleFeatureType; +import fi.nls.hakunapi.core.SimpleSource; +import fi.nls.hakunapi.core.config.HakunaConfigParser; +import fi.nls.hakunapi.core.geom.HakunaGeometryType; +import fi.nls.hakunapi.core.property.HakunaProperty; +import fi.nls.hakunapi.core.property.HakunaPropertyHidden; +import fi.nls.hakunapi.core.property.HakunaPropertyNumberEnum; +import fi.nls.hakunapi.core.property.HakunaPropertyStatic; +import fi.nls.hakunapi.core.property.HakunaPropertyStringEnum; +import fi.nls.hakunapi.core.property.HakunaPropertyTransformed; +import fi.nls.hakunapi.core.property.HakunaPropertyType; +import fi.nls.hakunapi.core.property.HakunaPropertyWriter; +import fi.nls.hakunapi.core.property.HakunaPropertyWriters; +import fi.nls.hakunapi.core.property.simple.HakunaPropertyGeometry; +import fi.nls.hakunapi.core.property.simple.HakunaPropertyInt; +import fi.nls.hakunapi.core.property.simple.HakunaPropertyLong; +import fi.nls.hakunapi.core.property.simple.HakunaPropertyString; +import fi.nls.hakunapi.core.transformer.ValueTransformer; +import fi.nls.hakunapi.gpkg.GPKGFeaturesTable; +import fi.nls.hakunapi.gpkg.GPKGGeometryColumn; +import fi.nls.hakunapi.gpkg.GPKGGeometryColumn.GeometryTypeName; +import fi.nls.hakunapi.gpkg.extension.RtreeIndexExtension; +import fi.nls.hakunapi.gpkg.function.ST_IsEmpty; +import fi.nls.hakunapi.gpkg.function.ST_MaxX; +import fi.nls.hakunapi.gpkg.function.ST_MaxY; +import fi.nls.hakunapi.gpkg.function.ST_MinX; +import fi.nls.hakunapi.gpkg.function.ST_MinY; + +public class GpkgSimpleSource implements SimpleSource { + + private static final Logger LOG = LoggerFactory.getLogger(GpkgSimpleSource.class); + + private List dataSourcesToClose = new ArrayList<>(); + private Map dataSources = new HashMap<>(); + + @Override + public String getType() { + return "gpkg"; + } + + private DataSource getDataSource(HakunaConfigParser cfg, Path path, String name) throws SQLException { + DataSource ds = dataSources.get(name); + if (ds == null) { + HikariDataSource hds = readDataSource(cfg, path, name); + dataSourcesToClose.add(hds); + ds = hds; + try (Connection c = ds.getConnection()) { + SQLiteConnection sqlitec = c.unwrap(SQLiteConnection.class); + new ST_IsEmpty().create(sqlitec); + new ST_MinX().create(sqlitec); + new ST_MinY().create(sqlitec); + new ST_MaxX().create(sqlitec); + new ST_MaxY().create(sqlitec); + } + dataSources.put(name, ds); + } + return ds; + } + + private HikariDataSource readDataSource(HakunaConfigParser cfg, Path path, String name) { + final Properties props; + + if (Arrays.stream(cfg.getMultiple("db", new String[0])).anyMatch(name::equals)) { + // if name appears in db=a,b,c,d listing then consider it's configuration to be inlined + props = getInlinedDBProperties(cfg, name); + } else { + // Not inlined, check separate file $name.properties + String dataSourcePath = getDataSourcePath(path, name); + props = loadProperties(dataSourcePath); + } + + HikariConfig config = new HikariConfig(props); + HikariDataSource ds = new HikariDataSource(config); + return ds; + } + + protected static Properties getInlinedDBProperties(HakunaConfigParser cfg, String name) { + Properties props = new Properties(); + cfg.getAllStartingWith("db." + name + ".").forEach(props::setProperty); + return props; + } + + protected static String getDataSourcePath(Path path, String name) { + String prefix = ""; + Path parent = path.getParent(); + if (parent != null) { + prefix = path.getParent().toString() + "/"; + } + return prefix + name + ".properties"; + } + + protected Properties loadProperties(String path) { + try (InputStream in = getInputStream(path)) { + Properties props = new Properties(); + props.load(in); + return HakunapiPlaceholder.replacePlaceholders(props); + } catch (IOException e) { + throw new RuntimeException("Failed to read property file", e); + } + } + + protected InputStream getInputStream(String path) throws IOException { + File file = new File(path); + if (file.isFile()) { + return new FileInputStream(file); + } + InputStream resource = getClass().getResourceAsStream(path); + if (resource != null) { + return resource; + } + resource = getClass().getClassLoader().getResourceAsStream(path); + return resource; + } + + @Override + public void close() throws IOException { + dataSourcesToClose.forEach(HikariDataSource::close); + } + + @Override + public SimpleFeatureType parse(HakunaConfigParser cfg, Path path, String collectionId, int[] srids) throws Exception { + // Current prefix for properties + String p = "collections." + collectionId + "."; + GpkgFeatureType ft = new GpkgFeatureType(); + ft.setName(collectionId); + + String db = cfg.get(p + "db"); + DataSource ds = getDataSource(cfg, path, db); + if (ds == null) { + throw new IllegalArgumentException("Unknown db: " + db + " collection: " + collectionId); + } + ft.setDatabase(ds); + + String cfgTable = cfg.get(p + "table"); + if (cfgTable == null || cfgTable.isEmpty()) { + throw new IllegalArgumentException("Missing required property " + (p + "table")); + } + String table = cfgTable.replace("\"", ""); + ft.setTable(table); + + GPKGFeaturesTable contents = getFeatureContents(ds, table) + .orElseThrow(() -> new IllegalArgumentException("Could not find entry from gpkg_contents with table_name: " + table + " and data_type: features")); + ft.setTitle(contents.getIdentifier()); + ft.setDescription(contents.getDescription()); + + // TODO: Transform these to CRS84 + // ft.setSpatialExtent(new double[] { contents.getMinX(), contents.getMinY(), contents.getMaxX(), contents.getMaxY() ); + + int cfgSrid = Integer.parseInt(cfg.get(p + "srid.storage", "0")); + + List primaryKeys = getPrimaryKeys(ds, table); + if (primaryKeys.size() != 1) { + throw new IllegalArgumentException("Failed to find single primary key for table: " + table); + } + + String idMapping = cfg.get(p + "id.mapping"); + if (idMapping == null) { + idMapping = primaryKeys.get(0); + } + + // Each features table has exactly one geometry column + GPKGGeometryColumn geometryColumn = discoverGeometryColumn(ds, table) + .orElseThrow(() -> new IllegalArgumentException("Could not find entry from gpkg_geometry_columns with table_name: " + table)); + + String[] properties = cfg.getMultiple(p + "properties"); + if (properties == null || properties.length == 0 || "*".equals(properties[0])) { + Set reserved = new HashSet<>(); + reserved.add(idMapping); + reserved.add(geometryColumn.getColumn()); + for (int i = 1; i < properties.length; i++) { + if (properties[i].charAt(0) == '~' && properties[i].length() > 1) { + reserved.add(properties[i].substring(1)); + } + } + properties = discoverProperties(ds, table, column -> !reserved.contains(column)).toArray(new String[0]); + } + + List columns = new ArrayList<>(); + columns.add(idMapping); + for (String property : properties) { + String mapping = cfg.get(p + "properties." + property + ".mapping", property); + mapping = mapping.replace("\"", ""); + if (!HakunaConfigParser.isStaticMapping(mapping)) { + columns.add(mapping); + } + } + + Map propertyTypes = getPropertyTypes(ds, getSelectSchema(table, columns)); + + columns.add(geometryColumn.getColumn()); + Map propertyNullability = getNullability(ds, getSelectSchema(table, columns)); + + ft.setId(getIdProperty(ft, table, idMapping, propertyTypes, propertyNullability)); + ft.setGeom(getGeometryProperty(geometryColumn, srids, cfgSrid, propertyNullability)); + ft.setSpatialIndex(hasGeometryIndex(ft.getGeom())); + + List hakunaProperties = new ArrayList<>(); + for (String property : properties) { + hakunaProperties.add(getProperty(cfg, ft, p, table, property, propertyTypes, propertyNullability)); + } + ft.setProperties(hakunaProperties); + + ft.setPaginationStrategy(getPaginationStrategy(cfg, p, ft)); + + return ft; + } + + private List getPrimaryKeys(DataSource ds, String table) throws SQLException { + List primaryKeys = new ArrayList<>(); + + try (Connection c = ds.getConnection()) { + DatabaseMetaData meta = c.getMetaData(); + try (ResultSet rs = meta.getPrimaryKeys(null, null, table)) { + while (rs.next()) { + primaryKeys.add(rs.getString(4)); // COLUMN_NAME + } + } + } + + return primaryKeys; + } + + private Optional getFeatureContents(DataSource ds, String table) throws SQLException { + String select = "SELECT identifier, description, min_x, min_y, max_x, max_y, srs_id FROM gpkg_contents WHERE data_type=? AND table_name=?"; + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement(select)) { + ps.setString(1, GPKGFeaturesTable.DATA_TYPE); + ps.setString(2, table); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + return Optional.empty(); + } + String identifier = rs.getString("identifier"); + String description = rs.getString("description"); + double minX = rs.getDouble("min_x"); + double minY = rs.getDouble("min_y"); + double maxX = rs.getDouble("max_x"); + double maxY = rs.getDouble("max_y"); + Envelope bbox = new Envelope(minX, maxX, minY, maxY); + int srsId = rs.getInt("srs_id"); + return Optional.of(new GPKGFeaturesTable(table, identifier, description, bbox, srsId)); + } + } + } + + /** + * Each features table has exactly one geometry column, a BLOB + */ + private Optional discoverGeometryColumn(DataSource ds, String table) throws SQLException { + String select = "SELECT column_name, geometry_type_name, srs_id, z, m FROM gpkg_geometry_columns WHERE table_name=?"; + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement(select)) { + ps.setString(1, table); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + return Optional.empty(); + } + String column = rs.getString("column_name"); + String geometryTypeName = rs.getString("geometry_type_name"); + GeometryTypeName type = GeometryTypeName.valueOf(geometryTypeName.toUpperCase()); + int srid = rs.getInt("srs_id"); + boolean hasZ = rs.getInt("z") != 0; + boolean hasM = rs.getInt("m") != 0; + return Optional.of(new GPKGGeometryColumn(table, column, type, srid, hasZ, hasM)); + } + } + } + + private List discoverProperties(DataSource ds, String table, Predicate check) throws SQLException { + try (Connection c = ds.getConnection()) { + DatabaseMetaData md = c.getMetaData(); + try (ResultSet rs = md.getColumns(null, null, table, null)) { + List columns = new ArrayList<>(); + while (rs.next()) { + String column = rs.getString(4); + if (check.test(column)) { + columns.add(column); + } + } + return columns; + } + } + } + + private HakunaProperty getIdProperty(FeatureType ft, String table, String idMapping, Map propertyTypes, + Map propertyNullability) { + HakunaPropertyType idColumnType = propertyTypes.get(idMapping); + boolean nullable = false; + HakunaPropertyWriter propWriter = HakunaPropertyWriters.getIdPropertyWriter(ft, ft.getName(), "id", idColumnType); + switch (idColumnType) { + case INT: + return new HakunaPropertyInt("id", table, idMapping, nullable, true, propWriter); + case LONG: + return new HakunaPropertyLong("id", table, idMapping, nullable, true, propWriter); + case STRING: + return new HakunaPropertyString("id", table, idMapping, nullable, true, propWriter); + default: + throw new IllegalArgumentException("Invalid id type"); + } + } + + private HakunaPropertyGeometry getGeometryProperty( + GPKGGeometryColumn geometryColumn, + int[] srids, + int cfgSrid, + Map propertyNullability) throws SQLException { + String table = geometryColumn.getTable(); + String column = geometryColumn.getColumn(); + String name = "geometry"; + boolean defaultGeometry = true; + boolean nullable = propertyNullability.get(geometryColumn.getColumn()); + int dim = 2 + (geometryColumn.isHasZ() ? 1 : 0) + (geometryColumn.isHasM() ? 1 : 0); + int srid = getSrid(table, column, cfgSrid, geometryColumn.getSrid()); + if (Arrays.stream(srids).noneMatch(it -> it == srid)) { + throw new IllegalArgumentException(String.format( + "For table %s column %s storage srid is %d which is missing from configured srid list!", table, column, srid)); + } + HakunaGeometryType geometryType = HakunaGeometryType.valueOf(geometryColumn.getGeometryTypeName().name()); + HakunaPropertyWriter propWriter = HakunaPropertyWriters.getGeometryPropertyWriter(name, defaultGeometry); + return new HakunaPropertyGeometry(name, table, column, nullable, geometryType, srids, srid, dim, propWriter); + } + + private int getSrid(String table, String column, int sridStorage, int dbSrid) { + if (dbSrid <= 0 && sridStorage <= 0) { + throw new IllegalArgumentException(String.format("For table %s column %s srid is unknown!", table, column)); + } else if (dbSrid <= 0) { + return sridStorage; + } else { + if (sridStorage > 0) { + LOG.warn("For table {} column {} using srid {} from database instead of configured {}", table, column, dbSrid, sridStorage); + } + return dbSrid; + } + } + + private boolean hasGeometryIndex(HakunaPropertyGeometry geom) throws SQLException { + GpkgFeatureType ft = (GpkgFeatureType) geom.getFeatureType(); + try (Connection c = ft.getDatabase().getConnection(); + PreparedStatement ps = c.prepareStatement( + "SELECT COUNT(*) " + + "FROM gpkg_extensions " + + "WHERE extension_name = ? " + + "AND table_name = ? " + + "AND column_name = ?")) { + ps.setString(1, RtreeIndexExtension.EXTENSION_NAME); + ps.setString(2, geom.getTable()); + ps.setString(3, geom.getColumn()); + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getInt(1) == 1; + } + } + } + + private HakunaProperty getProperty( + HakunaConfigParser cfg, + GpkgFeatureType ft, + String p, + String table, + String property, + Map propertyTypes, + Map propertyNullability) throws Exception { + String propertyPrefix = p + "properties." + property + "."; + String mapping = cfg.get(propertyPrefix + "mapping", property); + mapping = mapping.replace("\"", ""); + String[] enumeration = cfg.getMultiple(propertyPrefix + "enum"); + String transformerClass = cfg.get(propertyPrefix + "transformer"); + String transformerArg = cfg.get(propertyPrefix + "transformer.arg"); + boolean unique = Boolean.parseBoolean(cfg.get(propertyPrefix + "unique", "false")); + boolean hidden = Boolean.parseBoolean(cfg.get(propertyPrefix + "hidden", "false")); + String alias = mapping; + + if (HakunaConfigParser.isStaticMapping(mapping)) { + String value = mapping.substring(1, mapping.length() - 1); + return HakunaPropertyStatic.create(property, table, value); + } + + HakunaPropertyType type = propertyTypes.get(alias); + if (type == null) { + throw new IllegalArgumentException( + "Property " + property + " with mapping " + mapping + " can't find type from table " + table); + } + + boolean nullable = propertyNullability.get(mapping); + HakunaProperty hakunaProperty = cfg.getDynamicProperty(property, table, mapping, Arrays.asList(type), nullable, unique, hidden); + + if (transformerClass != null) { + ValueTransformer transformer = cfg.instantiateTransformer(transformerClass); + hakunaProperty = new HakunaPropertyTransformed(hakunaProperty, transformer); + transformer.init(hakunaProperty, transformerArg); + } + + if (enumeration.length > 0) { + if (hakunaProperty.getType() == HakunaPropertyType.INT + || hakunaProperty.getType() == HakunaPropertyType.LONG) { + hakunaProperty = new HakunaPropertyNumberEnum(hakunaProperty, + Arrays.stream(enumeration).map(Long::parseLong).collect(Collectors.toSet())); + } else if (hakunaProperty.getType() == HakunaPropertyType.STRING) { + hakunaProperty = new HakunaPropertyStringEnum(hakunaProperty, + Arrays.stream(enumeration).collect(Collectors.toSet())); + } else { + // Log the fact that we are ignoring this + } + } + + return hakunaProperty; + } + + private String getSelectSchema(String table, Iterable columns) { + StringBuilder sb = new StringBuilder(); + sb.append("SELECT "); + for (String col : columns) { + sb.append('"').append(col).append('"'); + sb.append(','); + } + sb.setLength(sb.length() - 1); + sb.append(" FROM "); + sb.append('"').append(table).append('"'); + sb.append(" LIMIT 1"); + return sb.toString(); + } + + private Map getPropertyTypes(DataSource ds, String select) throws SQLException { + Map propertyTypes = new LinkedHashMap<>(); + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement(select); + ResultSet rs = ps.executeQuery()) { + boolean hasRow = rs.next(); + ResultSetMetaData rsmd = rs.getMetaData(); + int numCols = rsmd.getColumnCount(); + for (int i = 1; i <= numCols; i++) { + String label = rsmd.getColumnLabel(i); + int type = rsmd.getColumnType(i); + rsmd.isNullable(i); + String typeName = rsmd.getColumnTypeName(i); + HakunaPropertyType hakunaType = fromJDBCType(type, typeName); + if (hakunaType == null) { + throw new IllegalArgumentException("Unknown type: " + type + ", typeName: " + typeName + ", column: " + label); + } + propertyTypes.put(label, hakunaType); + } + } + return propertyTypes; + } + + private HakunaPropertyType fromJDBCType(int columnType, String columnTypeName) { + switch (columnType) { + case java.sql.Types.BIT: + case java.sql.Types.BOOLEAN: + return HakunaPropertyType.BOOLEAN; + case java.sql.Types.TINYINT: + case java.sql.Types.SMALLINT: + case java.sql.Types.INTEGER: + return HakunaPropertyType.INT; + case java.sql.Types.BIGINT: + return HakunaPropertyType.LONG; + case java.sql.Types.REAL: + return HakunaPropertyType.FLOAT; + case java.sql.Types.FLOAT: + case java.sql.Types.DECIMAL: + case java.sql.Types.DOUBLE: + case java.sql.Types.NUMERIC: + return HakunaPropertyType.DOUBLE; + case java.sql.Types.CHAR: + case java.sql.Types.NCHAR: + case java.sql.Types.VARCHAR: + case java.sql.Types.NVARCHAR: + return HakunaPropertyType.STRING; + case java.sql.Types.DATE: + return HakunaPropertyType.DATE; + case java.sql.Types.TIMESTAMP: + switch (columnTypeName) { + case "timestamptz": + return HakunaPropertyType.TIMESTAMPTZ; + default: + return HakunaPropertyType.TIMESTAMP; + } + default: + return null; + } + } + + private Map getNullability(DataSource ds, String select) throws SQLException { + try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(select)) { + LOG.info(ps.toString()); + Map labelToNullable = new LinkedHashMap<>(); + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + ResultSetMetaData rsmd = rs.getMetaData(); + int numCols = rsmd.getColumnCount(); + for (int i = 1; i <= numCols; i++) { + String label = rsmd.getColumnLabel(i); + boolean nullable = rsmd.isNullable(i) != ResultSetMetaData.columnNoNulls; + labelToNullable.put(label, nullable); + } + } + return labelToNullable; + } + } + + private PaginationStrategy getPaginationStrategy(HakunaConfigParser cfg, String p, SimpleFeatureType sft) { + String paginationStrategy = cfg.get(p + "pagination.strategy", "cursor").toLowerCase(); + switch (paginationStrategy) { + case "cursor": + return getPaginationCursor(cfg, p, sft); + case "offset": + return PaginationStrategyOffset.INSTANCE; + default: + throw new IllegalArgumentException("Unknown pagination strategy " + paginationStrategy); + } + } + + private PaginationStrategyCursor getPaginationCursor(HakunaConfigParser cfg, String p, SimpleFeatureType sft) { + String[] pagination = cfg.getMultiple(p + "pagination"); + String[] paginationOrder = cfg.getMultiple(p + "pagination.order"); + // int maxGroupSize = Integer.parseInt(get(p + "pagination.maxGroupSize", "1")); + + if (pagination.length == 0) { + HakunaProperty id = new HakunaPropertyHidden(sft.getId()); + boolean asc = true; + return new PaginationStrategyCursor( + Collections.singletonList(id), + Collections.singletonList(asc) + ); + } else { + List props = new ArrayList<>(pagination.length); + List ascending = new ArrayList<>(pagination.length); + for (int i = 0; i < pagination.length; i++) { + HakunaProperty prop = cfg.getProperty(sft, pagination[i]); + HakunaProperty hidden = new HakunaPropertyHidden(prop); + Boolean asc = paginationOrder.length > i ? !"DESC".equalsIgnoreCase(paginationOrder[i]) : true; + props.add(hidden); + ascending.add(asc); + } + return new PaginationStrategyCursor(props, ascending); + } + } + +} \ No newline at end of file diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/HakunaGeometryGPKG.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/HakunaGeometryGPKG.java new file mode 100644 index 0000000..e03d0f3 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/HakunaGeometryGPKG.java @@ -0,0 +1,284 @@ +package fi.nls.hakunapi.source.gpkg; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; + +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.io.WKBReader; + +import fi.nls.hakunapi.core.GeometryWriter; +import fi.nls.hakunapi.core.geom.HakunaGeometry; +import fi.nls.hakunapi.core.geom.HakunaGeometryJTS; +import fi.nls.hakunapi.core.geom.HakunaGeometryType; +import fi.nls.hakunapi.gpkg.GPKGGeometry; + +public class HakunaGeometryGPKG implements HakunaGeometry { + + public final ByteBuffer bb; + public final int wkbOffset; + public final int dataStart; + public final int type; + public final int dimension; + public final int srid; + + public HakunaGeometryGPKG(byte[] b) { + this(ByteBuffer.wrap(b)); + } + + public HakunaGeometryGPKG(ByteBuffer bb) { + this.bb = bb; + byte magic1 = bb.get(); + byte magic2 = bb.get(); + byte version = bb.get(); + byte flags = bb.get(); + + bb.order(GPKGGeometry.getByteOrder(flags)); + srid = bb.getInt(); + + int envelopeIndicator = GPKGGeometry.getEnvelopeIndicatorCode(flags); + // TODO: Read and cache envelope for boundedBy() + wkbOffset = GPKGGeometry.getWKBOffset(envelopeIndicator); + bb.position(wkbOffset); + + bb.order(bb.get() == 0 ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN); + int typeInt = bb.getInt(); + int thousands = (typeInt & 0xFFFF) / 1000; + boolean hasZ = thousands == 1 || thousands == 3; + boolean hasM = thousands == 2 || thousands == 3; + dimension = 2 + (hasZ ? 1 : 0) + (hasM ? 1 : 0); + type = typeInt & 0x7; + dataStart = bb.position(); + } + + @Override + public byte[] toEWKB() { + // TODO: Optimize me + return new HakunaGeometryJTS(toJTSGeometry()).toEWKB(); + } + + @Override + public void write(GeometryWriter writer) throws Exception { + bb.position(dataStart); + final int n; + switch (type) { + case 1: + writer.init(HakunaGeometryType.POINT, srid, dimension); + writePoint(writer); + writer.end(); + return; + case 2: + writer.init(HakunaGeometryType.LINESTRING, srid, dimension); + writeRing(writer); + writer.end(); + return; + case 3: + writer.init(HakunaGeometryType.POLYGON, srid, dimension); + writeRings(writer); + writer.end(); + return; + case 4: + writer.init(HakunaGeometryType.MULTIPOINT, srid, dimension); + writer.startRing(); + n = bb.getInt(); + for (int i = 0; i < n; i++) { + if (bb.get() != 0) { + bb.order(ByteOrder.LITTLE_ENDIAN); + } + bb.getInt(); + writePoint(writer); + } + writer.endRing(); + writer.end(); + return; + case 5: + writer.init(HakunaGeometryType.MULTILINESTRING, srid, dimension); + writer.startRing(); + n = bb.getInt(); + for (int i = 0; i < n; i++) { + if (bb.get() != 0) { + bb.order(ByteOrder.LITTLE_ENDIAN); + } + bb.getInt(); + writeRing(writer); + } + writer.endRing(); + writer.end(); + return; + case 6: + writer.init(HakunaGeometryType.MULTIPOLYGON, srid, dimension); + writer.startRing(); + n = bb.getInt(); + for (int i = 0; i < n; i++) { + if (bb.get() != 0) { + bb.order(ByteOrder.LITTLE_ENDIAN); + } + bb.getInt(); + writeRings(writer); + } + writer.endRing(); + writer.end(); + return; + default: + // TODO handle GeometryCollection + throw new IllegalArgumentException(); + } + } + + public void writePoint(GeometryWriter writer) throws Exception { + if (dimension == 2) { + writer.writeCoordinate(bb.getDouble(), bb.getDouble()); + } else { + writer.writeCoordinate(bb.getDouble(), bb.getDouble(), bb.getDouble()); + } + } + + public void writeRing(GeometryWriter writer) throws Exception { + writer.startRing(); + final int n = bb.getInt(); + for (int i = 0; i < n; i++) { + if (dimension == 2) { + writer.writeCoordinate(bb.getDouble(), bb.getDouble()); + } + if (dimension == 3) { + writer.writeCoordinate(bb.getDouble(), bb.getDouble(), bb.getDouble()); + } + if (dimension == 4) { + writer.writeCoordinate(bb.getDouble(), bb.getDouble(), bb.getDouble(), bb.getDouble()); + } + } + writer.endRing(); + } + + public void writeRings(GeometryWriter writer) throws Exception { + writer.startRing(); + final int n = bb.getInt(); + for (int i = 0; i < n; i++) { + writeRing(writer); + } + writer.endRing(); + } + + @Override + public Geometry toJTSGeometry() { + try { + byte[] blob = bb.array(); + byte[] wkb = Arrays.copyOfRange(blob, wkbOffset, blob.length); + return new WKBReader().read(wkb); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public Envelope boundedBy() { + Envelope e = new Envelope(); + int n; + switch (type) { + case 1: + expandToIncludeCurrentPoint(e); + break; + case 2: + expandToIncludeCurrentRing(e); + break; + case 3: + expandToIncludeCurrentPolygon(e); + break; + case 4: + n = bb.getInt(); + for (int i = 0; i < n; i++) { + if (bb.get() != 0) { + bb.order(ByteOrder.LITTLE_ENDIAN); + } + bb.getInt(); // Skip type + expandToIncludeCurrentPoint(e); + } + break; + case 5: + n = bb.getInt(); + for (int i = 0; i < n; i++) { + if (bb.get() != 0) { + bb.order(ByteOrder.LITTLE_ENDIAN); + } + bb.getInt(); // Skip type + expandToIncludeCurrentRing(e); + } + break; + case 6: + n = bb.getInt(); + for (int i = 0; i < n; i++) { + if (bb.get() != 0) { + bb.order(ByteOrder.LITTLE_ENDIAN); + } + bb.getInt(); // Skip type + expandToIncludeCurrentPolygon(e); + } + break; + default: + // TODO handle GeometryCollection + throw new IllegalArgumentException(); + } + + return e; + } + + private void expandToIncludeCurrentPoint(Envelope e) { + int extraDimensionSkipPerPoint = (dimension - 2) * 8; + e.expandToInclude(bb.getDouble(), bb.getDouble()); + bb.position(bb.position() + extraDimensionSkipPerPoint); + } + + private void expandToIncludeCurrentRing(Envelope e) { + int extraDimensionSkipPerPoint = (dimension - 2) * 8; + int numPoints = bb.getInt(); + for (int i = 0; i < numPoints; i++) { + e.expandToInclude(bb.getDouble(), bb.getDouble()); + bb.position(bb.position() + extraDimensionSkipPerPoint); + } + } + + private void expandToIncludeCurrentPolygon(Envelope e) { + int numRings = bb.getInt(); + + // Only expand by exterior ring + expandToIncludeCurrentRing(e); + // Ignore interior rings + for (int i = 1; i < numRings; i++) { + skipCurrentRing(); + } + } + + private void skipCurrentRing() { + int numPoints = bb.getInt(); + int skipPerPoint = dimension * 8; + int skip = skipPerPoint * numPoints; + bb.position(bb.position() + skip); + } + + @Override + public int getSrid() { + return srid; + } + + @Override + public int getWKBType() { + return type; + } + + @Override + public int getDimension() { + return dimension; + } + + @Override + public int getWKBLength() { + return bb.array().length - wkbOffset; + } + + @Override + public void toWKB(byte[] wkb, int off) { + System.arraycopy(bb.array(), dataStart, wkb, off, bb.array().length - dataStart); + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/ResultSetValueProvider.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/ResultSetValueProvider.java new file mode 100644 index 0000000..a0748ff --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/ResultSetValueProvider.java @@ -0,0 +1,187 @@ +package fi.nls.hakunapi.source.gpkg; + +import java.nio.charset.StandardCharsets; +import java.sql.Array; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.UUID; + +import fi.nls.hakunapi.core.ValueProvider; +import fi.nls.hakunapi.core.geom.HakunaGeometry; + +public class ResultSetValueProvider implements ValueProvider { + + private final ResultSet rs; + private final int numCols; + + public ResultSetValueProvider(ResultSet rs, int numCols) { + this.rs = rs; + this.numCols = numCols; + } + + @Override + public boolean isNull(int i) { + try { + return rs.getObject(i + 1) == null; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public Boolean getBoolean(int i) { + try { + boolean b = rs.getBoolean(i + 1); + return !b && rs.wasNull() ? null : b; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public Integer getInt(int i) { + try { + int v = rs.getInt(i + 1); + return v == 0 && rs.wasNull() ? null : v; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public Long getLong(int i) { + try { + long v = rs.getLong(i + 1); + return v == 0 && rs.wasNull() ? null : v; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public Float getFloat(int i) { + try { + float v = rs.getFloat(i + 1); + return v == 0 && rs.wasNull() ? null : v; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public Double getDouble(int i) { + try { + double v = rs.getDouble(i + 1); + return v == 0 && rs.wasNull() ? null : v; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public Object getObject(int i) { + try { + return rs.getObject(i + 1); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public String getString(int i) { + try { + return rs.getString(i + 1); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public Instant getInstant(int i) { + try { + OffsetDateTime odt = rs.getObject(i + 1, OffsetDateTime.class); + return odt == null ? null : odt.toInstant(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public LocalDateTime getLocalDateTime(int i) { + try { + return rs.getObject(i + 1, LocalDateTime.class); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public LocalDate getLocalDate(int i) { + try { + return rs.getObject(i + 1, LocalDate.class); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public HakunaGeometry getHakunaGeometry(int i) { + try { + byte[] blob = rs.getBytes(i + 1); + if (blob == null) { + return null; + } + return new HakunaGeometryGPKG(blob); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public UUID getUUID(int i) { + try { + return rs.getObject(i + 1, UUID.class); + } catch (Exception e) { + throw new RuntimeException(); + } + } + + @Override + public int size() { + return numCols; + } + + @Override + public Object[] getArray(int i) { + try { + Array array = rs.getArray(i + 1); + if (array == null) { + return null; + } + Object actual = array.getArray(); + array.free(); + return (Object[]) actual; + } catch (Exception e) { + throw new RuntimeException(); + } + } + + @Override + public byte[] getJSON(int i) { + try { + String json = rs.getString(i + 1); + if (json == null) { + return null; + } + byte[] actual = json.getBytes(StandardCharsets.UTF_8); + return actual; + } catch (Exception e) { + throw new RuntimeException(); + } + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/GpkgIntersects.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/GpkgIntersects.java new file mode 100644 index 0000000..9f761d5 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/GpkgIntersects.java @@ -0,0 +1,65 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; + +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.projection.ProjectionHelper; +import fi.nls.hakunapi.core.property.simple.HakunaPropertyGeometry; +import fi.nls.hakunapi.source.gpkg.GpkgFeatureType; + +public class GpkgIntersects implements SQLFilter { + + @Override + public String toSQL(Filter filter) { + HakunaPropertyGeometry prop = (HakunaPropertyGeometry) filter.getProp(); + GpkgFeatureType ft = (GpkgFeatureType) prop.getFeatureType(); + + StringBuilder where = new StringBuilder(); + + if (ft.isSpatialIndex()) { + String col = SQLUtil.toSQL(ft.getId()); + where.append(col); + where.append(" IN ("); + where.append("SELECT id"); + where.append(" FROM ").append(ft.getSpatialIndexName()).append(" rtree"); + where.append(" WHERE rtree.maxx >= ?"); + where.append(" AND rtree.minx <= ?"); + where.append(" AND rtree.maxy >= ?"); + where.append(" AND rtree.miny <= ?"); + where.append(")"); + } else { + String col = SQLUtil.toSQL(prop); + where.append("("); + where.append("ST_MaxX(").append(col).append(") >= ?"); + where.append(" AND "); + where.append("ST_MinX(").append(col).append(") <= ?"); + where.append(" AND "); + where.append("ST_MaxY(").append(col).append(") >= ?"); + where.append(" AND "); + where.append("ST_MinY(").append(col).append(") <= ?"); + where.append(")"); + } + + return where.toString(); + } + + @Override + public int bind(Filter filter, Connection c, PreparedStatement ps, int i) throws SQLException { + HakunaPropertyGeometry prop = (HakunaPropertyGeometry) filter.getProp(); + Geometry geom = ProjectionHelper.reprojectToStorageCRS(prop, (Geometry) filter.getValue()); + Envelope envelope = geom.getEnvelopeInternal(); + + ps.setDouble(i++, envelope.getMinX()); + ps.setDouble(i++, envelope.getMaxX()); + ps.setDouble(i++, envelope.getMinY()); + ps.setDouble(i++, envelope.getMaxY()); + + return i; + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLAnd.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLAnd.java new file mode 100644 index 0000000..08fddf8 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLAnd.java @@ -0,0 +1,55 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.EnumMap; +import java.util.List; + +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.filter.FilterOp; + +public class SQLAnd implements SQLFilter { + + private final EnumMap filterOpToImpl; + + public SQLAnd(EnumMap filterOpToImpl) { + this.filterOpToImpl = filterOpToImpl; + } + + @Override + public String toSQL(Filter filter) { + @SuppressWarnings("unchecked") + List subFilters = (List) filter.getValue(); + StringBuilder sb = new StringBuilder(); + sb.append('('); + boolean first = true; + for (Filter subFilter : subFilters) { + if (!first) { + sb.append(" AND "); + } + SQLFilter sqlFilter = filterOpToImpl.get(subFilter.getOp()); + if (sqlFilter != null) { + sb.append('(').append(sqlFilter.toSQL(subFilter)).append(')'); + first = false; + } + } + sb.append(')'); + return sb.toString(); + } + + @Override + public int bind(Filter filter, Connection c, PreparedStatement ps, int i) throws SQLException { + @SuppressWarnings("unchecked") + List subFilters = (List) filter.getValue(); + for (Filter subFilter : subFilters) { + SQLFilter sqlFilter = filterOpToImpl.get(subFilter.getOp()); + if (sqlFilter != null) { + i = sqlFilter.bind(subFilter, c, ps, i); + } + } + return i; + } + +} + diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLComparison.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLComparison.java new file mode 100644 index 0000000..ff7b0ac --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLComparison.java @@ -0,0 +1,58 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +import java.sql.Connection; +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.UUID; + +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.property.HakunaProperty; + +public abstract class SQLComparison implements SQLFilter { + + @Override + public String toSQL(Filter filter) { + HakunaProperty prop = filter.getProp(); + return SQLUtil.toSQL(prop) + getOp() + "?"; + } + + @Override + public int bind(Filter filter, Connection c, PreparedStatement ps, int i) throws SQLException { + return doBind(filter.getValue(), c, ps, i); + } + + protected static int doBind(Object value, Connection c, PreparedStatement ps, int i) throws SQLException { + if (value instanceof Boolean) { + ps.setBoolean(i, (Boolean) value); + } else if (value instanceof Integer) { + ps.setInt(i, (Integer) value); + } else if (value instanceof Long) { + ps.setLong(i, (Long) value); + } else if (value instanceof Float) { + ps.setFloat(i, (Float) value); + } else if (value instanceof Double) { + ps.setDouble(i, (Double) value); + } else if (value instanceof String) { + ps.setString(i, (String) value); + } else if (value instanceof LocalDate) { + ps.setDate(i, Date.valueOf((LocalDate) value)); + } else if (value instanceof Instant) { + ps.setTimestamp(i, Timestamp.from((Instant) value)); + } else if (value instanceof OffsetDateTime) { + ps.setTimestamp(i, Timestamp.from(((OffsetDateTime)value).toInstant())); + } else if (value instanceof UUID) { + ps.setObject(i, (UUID) value, java.sql.Types.OTHER); + } else { + throw new UnsupportedOperationException("Unsupported value type " + value.getClass().getName()); + } + return i + 1; + } + + public abstract String getOp(); + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLEqualTo.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLEqualTo.java new file mode 100644 index 0000000..6af6666 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLEqualTo.java @@ -0,0 +1,10 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +public class SQLEqualTo extends SQLComparison { + + @Override + public String getOp() { + return "="; + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLFilter.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLFilter.java new file mode 100644 index 0000000..2c34ce4 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLFilter.java @@ -0,0 +1,14 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +import fi.nls.hakunapi.core.filter.Filter; + +public interface SQLFilter { + + public String toSQL(Filter filter); + public int bind(Filter filter, Connection c, PreparedStatement ps, int i) throws SQLException; + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLGreaterThan.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLGreaterThan.java new file mode 100644 index 0000000..a7200ce --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLGreaterThan.java @@ -0,0 +1,10 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +public class SQLGreaterThan extends SQLComparison { + + @Override + public String getOp() { + return ">"; + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLGreaterThanOrEqualTo.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLGreaterThanOrEqualTo.java new file mode 100644 index 0000000..976d51d --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLGreaterThanOrEqualTo.java @@ -0,0 +1,10 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +public class SQLGreaterThanOrEqualTo extends SQLComparison { + + @Override + public String getOp() { + return ">="; + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLIsNotNull.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLIsNotNull.java new file mode 100644 index 0000000..6ab8e57 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLIsNotNull.java @@ -0,0 +1,23 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.property.HakunaProperty; + +public class SQLIsNotNull implements SQLFilter { + + @Override + public String toSQL(Filter filter) { + HakunaProperty prop = filter.getProp(); + return SQLUtil.toSQL(prop) + " IS NOT NULL"; + } + + @Override + public int bind(Filter filter, Connection c, PreparedStatement ps, int i) throws SQLException { + return i; + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLIsNull.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLIsNull.java new file mode 100644 index 0000000..d19c630 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLIsNull.java @@ -0,0 +1,23 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.property.HakunaProperty; + +public class SQLIsNull implements SQLFilter { + + @Override + public String toSQL(Filter filter) { + HakunaProperty prop = filter.getProp(); + return SQLUtil.toSQL(prop) + " IS NULL"; + } + + @Override + public int bind(Filter filter, Connection c, PreparedStatement ps, int i) throws SQLException { + return i; + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLLessThan.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLLessThan.java new file mode 100644 index 0000000..1ce42ba --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLLessThan.java @@ -0,0 +1,10 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +public class SQLLessThan extends SQLComparison { + + @Override + public String getOp() { + return "<"; + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLLessThanOrEqualTo.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLLessThanOrEqualTo.java new file mode 100644 index 0000000..89db58a --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLLessThanOrEqualTo.java @@ -0,0 +1,10 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +public class SQLLessThanOrEqualTo extends SQLComparison { + + @Override + public String getOp() { + return "<="; + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLLike.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLLike.java new file mode 100644 index 0000000..a09e24d --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLLike.java @@ -0,0 +1,101 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.filter.LikeFilter; +import fi.nls.hakunapi.core.property.HakunaProperty; + +public class SQLLike implements SQLFilter { + + @Override + public String toSQL(Filter filter) { + if (filter.getProp().isStatic()) { + throw new UnsupportedOperationException(); + } + + LikeFilter likeFilter = (LikeFilter) filter; + HakunaProperty prop = filter.getProp(); + + String propertyName; + if (likeFilter.isCaseInsensitive()) { + propertyName = String.format("lower(%s)", SQLUtil.toSQL(prop)); + } else { + propertyName = SQLUtil.toSQL(prop); + } + + return propertyName + " LIKE ?"; + } + + @Override + public int bind(Filter filter, Connection c, PreparedStatement ps, int i) throws SQLException { + LikeFilter likeFilter = (LikeFilter) filter; + String value = (String) filter.getValue(); + char wild = likeFilter.getWildCard(); + char single = likeFilter.getSingleChar(); + char escape = likeFilter.getEscape(); + value = replaceWildcards(value, wild, single, escape); + if (likeFilter.isCaseInsensitive()) { + value = value.toLowerCase(); + } + ps.setString(i, value); + return i + 1; + } + + protected static String replaceWildcards(String value, char wild, char single, char escape) { + if (wild == '%' && single == '_' && escape == '\\') { + return value; + } + + // wild(*), single(?), escape(\) + // foo\?bar => LIKE foo?bar + // foo*bar => LIKE foo%bar + // foo\\?bar => LIKE foo\\%bar + + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if (ch == escape) { + if (i + 1 == value.length()) { // foo@ (escape '@') => foo + break; + } + char next = value.charAt(++i); + // foo@bar (escape '@', wild '*', single '?') => foobar + + // foo@@bar (escape '@', wild '*', single '?') => foo@bar + + // foo@%bar (escape '@', wild '*', single '?') => foo\%bar + // foo@%bar (escape '@', wild '%', single '?') => foo\%bar + // foo@*bar (escape '@', wild '*', single '?') => foo*bar + + // foo@_bar (escape '@', wild '*', single '?') => foo\_bar + // foo@?bar (escape '@', wild '*', single '?') => foo\_bar + // foo@_bar (escape '@', wild '*', single '_') => foo\_bar + + if (next == '\\') { + sb.append('\\').append('\\'); + } else if (next == '%') { + sb.append('\\').append('%'); + } else if (next == '_') { + sb.append('\\').append('_'); + } else { + sb.append(next); + } + } else if (ch == single) { + sb.append('_'); + } else if (ch == '_') { + sb.append('\\').append('_'); + } else if (ch == wild) { + sb.append('%'); + } else if (ch == '%') { + sb.append('\\').append('%'); + } else { + sb.append(ch); + } + } + return sb.toString(); + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLNot.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLNot.java new file mode 100644 index 0000000..7f03bbd --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLNot.java @@ -0,0 +1,33 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.EnumMap; + +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.filter.FilterOp; + +public class SQLNot implements SQLFilter { + + private final EnumMap filterOpToImpl; + + public SQLNot(EnumMap filterOpToImpl) { + this.filterOpToImpl = filterOpToImpl; + } + + @Override + public String toSQL(Filter filter) { + Filter toNegate = (Filter) filter.getValue(); + SQLFilter sqlFilter = filterOpToImpl.get(toNegate.getOp()); + return "NOT " + sqlFilter.toSQL(toNegate); + } + + @Override + public int bind(Filter filter, Connection c, PreparedStatement ps, int i) throws SQLException { + Filter toNegate = (Filter) filter.getValue(); + SQLFilter sqlFilter = filterOpToImpl.get(toNegate.getOp()); + return sqlFilter.bind(toNegate, c, ps, i); + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLNotEqualTo.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLNotEqualTo.java new file mode 100644 index 0000000..7f6f3e9 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLNotEqualTo.java @@ -0,0 +1,10 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +public class SQLNotEqualTo extends SQLComparison { + + @Override + public String getOp() { + return "<>"; + } + +} diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLOr.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLOr.java new file mode 100644 index 0000000..ec84ad4 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLOr.java @@ -0,0 +1,55 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.EnumMap; +import java.util.List; + +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.filter.FilterOp; + +public class SQLOr implements SQLFilter { + + private final EnumMap filterOpToImpl; + + public SQLOr(EnumMap filterOpToImpl) { + this.filterOpToImpl = filterOpToImpl; + } + + @Override + public String toSQL(Filter filter) { + @SuppressWarnings("unchecked") + List subFilters = (List) filter.getValue(); + StringBuilder sb = new StringBuilder(); + sb.append('('); + boolean first = true; + for (Filter subFilter : subFilters) { + if (!first) { + sb.append(" OR "); + } + SQLFilter sqlFilter = filterOpToImpl.get(subFilter.getOp()); + if (sqlFilter != null) { + sb.append('(').append(sqlFilter.toSQL(subFilter)).append(')'); + first = false; + } + } + sb.append(')'); + return sb.toString(); + } + + @Override + public int bind(Filter filter, Connection c, PreparedStatement ps, int i) throws SQLException { + @SuppressWarnings("unchecked") + List subFilters = (List) filter.getValue(); + for (Filter subFilter : subFilters) { + SQLFilter sqlFilter = filterOpToImpl.get(subFilter.getOp()); + if (sqlFilter != null) { + i = sqlFilter.bind(subFilter, c, ps, i); + } + } + return i; + } + +} + diff --git a/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLUtil.java b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLUtil.java new file mode 100644 index 0000000..2f7a969 --- /dev/null +++ b/src/hakunapi-source-gpkg/src/main/java/fi/nls/hakunapi/source/gpkg/filter/SQLUtil.java @@ -0,0 +1,24 @@ +package fi.nls.hakunapi.source.gpkg.filter; + +import fi.nls.hakunapi.core.property.HakunaProperty; + +public class SQLUtil { + + public static String toSQL(HakunaProperty prop) { + return toSQL(prop.getTable(), prop.getColumn()); + } + + public static String toSQL(String table, String column) { + StringBuilder sb = new StringBuilder(); + if (table != null && !table.isEmpty()) { + sb.append('"').append(table).append('"').append('.'); + } + if (column.contains("(")) { + sb.append(column); + } else { + sb.append('"').append(column).append('"'); + } + return sb.toString(); + } + +} diff --git a/src/pom.xml b/src/pom.xml index 1208614..dd29b37 100644 --- a/src/pom.xml +++ b/src/pom.xml @@ -27,5 +27,6 @@ hakunapi-cql2 hakunapi-cql2-functions hakunapi-simple-webapp-test-javax + hakunapi-source-gpkg