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