Skip to content

Commit 5ea4edf

Browse files
committed
HTM-1977: implement geopackage extract
1 parent e4bd1a2 commit 5ea4edf

7 files changed

Lines changed: 157 additions & 21 deletions

File tree

.mvn/jvm.config

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@
1111
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
1212
--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
1313
--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED
14-
--add-modules=jdk.incubator.vector
14+
--add-modules=jdk.incubator.vector
15+
--enable-native-access=ALL-UNNAMED

pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,10 @@ SPDX-License-Identifier: MIT
328328
<groupId>org.geotools</groupId>
329329
<artifactId>gt-geojson-store</artifactId>
330330
</dependency>
331+
<dependency>
332+
<groupId>org.geotools</groupId>
333+
<artifactId>gt-geopkg</artifactId>
334+
</dependency>
331335
<dependency>
332336
<groupId>org.geotools</groupId>
333337
<artifactId>gt-http</artifactId>

src/main/java/org/tailormap/api/controller/LayerExtractController.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ private void validateExcelLimits(TMFeatureType featureType, Set<String> attribut
279279
}
280280

281281
public enum ExtractOutputFormat {
282+
GEOPACKAGE("geopackage", ".gpkg"),
282283
CSV("csv", ".csv"),
283284
GEOJSON("geojson", ".geojson"),
284285
XLSX("xlsx", ".xlsx"),

src/main/java/org/tailormap/api/service/CreateLayerExtractService.java

Lines changed: 100 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,8 @@
77

88
import ch.rasc.sse.eventbus.SseEvent;
99
import ch.rasc.sse.eventbus.SseEventBus;
10-
import java.io.File;
11-
import java.io.IOException;
12-
import java.lang.invoke.MethodHandles;
13-
import java.nio.charset.StandardCharsets;
14-
import java.nio.file.Files;
15-
import java.nio.file.Path;
16-
import java.time.Instant;
17-
import java.util.ArrayList;
18-
import java.util.Comparator;
19-
import java.util.List;
20-
import java.util.Map;
21-
import java.util.Objects;
22-
import java.util.Set;
23-
import java.util.concurrent.TimeUnit;
24-
import java.util.concurrent.atomic.AtomicInteger;
25-
import java.util.stream.Stream;
26-
import java.util.zip.ZipEntry;
27-
import java.util.zip.ZipOutputStream;
2810
import org.apache.commons.lang3.StringUtils;
11+
import org.geotools.api.data.DataStore;
2912
import org.geotools.api.data.FeatureEvent;
3013
import org.geotools.api.data.FileDataStore;
3114
import org.geotools.api.data.Query;
@@ -43,6 +26,7 @@
4326
import org.geotools.data.shapefile.ShapefileDumper;
4427
import org.geotools.factory.CommonFactoryFinder;
4528
import org.geotools.feature.SchemaException;
29+
import org.geotools.geopkg.GeoPkgDataStoreFactory;
4630
import org.geotools.util.factory.GeoTools;
4731
import org.jspecify.annotations.NonNull;
4832
import org.jspecify.annotations.Nullable;
@@ -66,6 +50,25 @@
6650
import tools.jackson.databind.SerializationFeature;
6751
import tools.jackson.databind.json.JsonMapper;
6852

53+
import java.io.File;
54+
import java.io.IOException;
55+
import java.lang.invoke.MethodHandles;
56+
import java.nio.charset.StandardCharsets;
57+
import java.nio.file.Files;
58+
import java.nio.file.Path;
59+
import java.time.Instant;
60+
import java.util.ArrayList;
61+
import java.util.Comparator;
62+
import java.util.List;
63+
import java.util.Map;
64+
import java.util.Objects;
65+
import java.util.Set;
66+
import java.util.concurrent.TimeUnit;
67+
import java.util.concurrent.atomic.AtomicInteger;
68+
import java.util.stream.Stream;
69+
import java.util.zip.ZipEntry;
70+
import java.util.zip.ZipOutputStream;
71+
6972
@Service
7073
public class CreateLayerExtractService {
7174
private static final Logger logger =
@@ -218,6 +221,9 @@ public void createLayerExtract(
218221
this.emitProgress(clientId, outputFileName, 0, false, "Starting extract");
219222

220223
switch (extractOutputFormat) {
224+
case GEOPACKAGE ->
225+
this.handleGeoPackage(
226+
clientId, inputTmFeatureType, attributes, filter, sortBy, sortOrder, outputFileName);
221227
case SHAPE ->
222228
this.handleWithShapeDumper(
223229
clientId, inputTmFeatureType, attributes, filter, sortBy, sortOrder, outputFileName);
@@ -234,6 +240,82 @@ public void createLayerExtract(
234240
}
235241
}
236242

243+
private void handleGeoPackage(
244+
@NonNull String clientId,
245+
@NonNull TMFeatureType inputTmFeatureType,
246+
@NonNull Set<String> attributes,
247+
Filter filter,
248+
String sortBy,
249+
SortOrder sortOrder,
250+
@NonNull String outputFileName) {
251+
252+
SimpleFeatureSource inputFeatureSource = null;
253+
DataStore outputDataStore = null;
254+
255+
try (Transaction outputTransaction = new DefaultTransaction("tailormap-extract-output")) {
256+
inputFeatureSource = featureSourceFactoryHelper.openGeoToolsFeatureSource(inputTmFeatureType);
257+
258+
Query q = createQuery(inputFeatureSource, attributes, filter, sortBy, sortOrder);
259+
260+
int featCount = getFeatureCount(inputFeatureSource, q);
261+
if (featCount < 0) {
262+
logger.warn("Could not determine feature count for extract, progress reporting will be omitted");
263+
}
264+
265+
outputDataStore = new GeoPkgDataStoreFactory()
266+
.createDataStore(Map.of(
267+
GeoPkgDataStoreFactory.DBTYPE.key,
268+
"geopkg",
269+
GeoPkgDataStoreFactory.DATABASE.key,
270+
getValidatedOutputFile(outputFileName),
271+
GeoPkgDataStoreFactory.CONTENTS_ONLY.key,
272+
false));
273+
274+
SimpleFeatureType fType =
275+
DataUtilities.createSubType(inputFeatureSource.getSchema(), attributes.toArray(new String[0]));
276+
outputDataStore.createSchema(fType);
277+
278+
final AtomicInteger featsAdded = new AtomicInteger();
279+
if (outputDataStore.getFeatureSource(fType.getName()) instanceof SimpleFeatureStore featureStore) {
280+
featureStore.setTransaction(outputTransaction);
281+
featureStore.addFeatureListener(event -> {
282+
if (event.getType().equals(FeatureEvent.Type.ADDED)) {
283+
featsAdded.getAndIncrement();
284+
logger.debug("Added feature {}", featsAdded.get());
285+
}
286+
if (featCount > 0) {
287+
if (featsAdded.get() % progressReportInterval == 0) {
288+
this.emitProgress(
289+
clientId,
290+
outputFileName,
291+
(int) ((featsAdded.doubleValue() / featCount) * 100),
292+
false,
293+
null);
294+
}
295+
}
296+
});
297+
featureStore.addFeatures(inputFeatureSource.getFeatures(q));
298+
outputTransaction.commit();
299+
outputDataStore.dispose();
300+
this.emitProgress(clientId, outputFileName, 100, true, "Extract completed successfully");
301+
}
302+
} catch (SchemaException | IOException | IllegalArgumentException e) {
303+
emitError(clientId, e.getMessage());
304+
logger.error("Creating extract failed", e);
305+
} finally {
306+
if (inputFeatureSource != null) {
307+
try {
308+
inputFeatureSource.getDataStore().dispose();
309+
} catch (Exception e) {
310+
logger.warn("Error disposing datastore for feature source {}", inputFeatureSource.getName(), e);
311+
}
312+
}
313+
if (outputDataStore != null) {
314+
outputDataStore.dispose();
315+
}
316+
}
317+
}
318+
237319
private void handleSingleFileFormats(
238320
@NonNull String clientId,
239321
@NonNull TMFeatureType inputTmFeatureType,

src/main/resources/application.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ tailormap-api.features.wfs_count_exact=false
2929
tailormap-api.feature.info.maxitems=30
3030

3131
# see org.tailormap.api.controller.LayerExtractController.ExtractOutputFormat for valid values
32-
tailormap-api.extract.allowed-outputformats=csv,geojson,xlsx,shape
32+
tailormap-api.extract.allowed-outputformats=csv,geojson,xlsx,shape,geopackage
3333
# any files older than this (in minutes) in the extract output directory will be deleted by a scheduled job, to prevent filling up the disk
3434
# tailormap-api.extract.cleanup-minutes=120
3535
# the directory where the extract output files are stored, should be writable by the application

src/test/java/org/tailormap/api/controller/LayerExtractControllerIntegrationTest.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import static org.tailormap.api.TestRequestProcessor.setServletPath;
2525
import static org.tailormap.api.controller.LayerExtractController.ExtractOutputFormat.CSV;
2626
import static org.tailormap.api.controller.LayerExtractController.ExtractOutputFormat.GEOJSON;
27+
import static org.tailormap.api.controller.LayerExtractController.ExtractOutputFormat.GEOPACKAGE;
2728
import static org.tailormap.api.controller.LayerExtractController.ExtractOutputFormat.SHAPE;
2829
import static org.tailormap.api.controller.TestUrls.layerBegroeidTerreindeelPostgis;
2930
import static org.tailormap.api.controller.TestUrls.layerProxiedWithAuthInPublicApp;
@@ -530,4 +531,51 @@ void should_export_large_filter_to_shape() throws Exception {
530531
assertEquals(6, extensions.size(), "Expected 6 unique file extensions in the shapefile zip");
531532
}
532533
}
534+
535+
@Test
536+
void should_export_to_geopackage() throws Exception {
537+
final String extractUrl = apiBasePath + layerBegroeidTerreindeelPostgis + extractPath + sseClientId;
538+
mockMvc.perform(post(extractUrl)
539+
.accept(MediaType.APPLICATION_JSON)
540+
.with(setServletPath(extractUrl))
541+
.with(csrf())
542+
.param("attributes", "")
543+
.param("outputFormat", GEOPACKAGE.getValue())
544+
.param("filter", StaticTestData.get("large_cql_filter"))
545+
.acceptCharset(StandardCharsets.UTF_8)
546+
.characterEncoding(StandardCharsets.UTF_8)
547+
.contentType(MediaType.APPLICATION_FORM_URLENCODED))
548+
.andExpect(status().isAccepted());
549+
550+
// The SseEventBus may dispatch events slightly after the POST returns.
551+
// Awaitility polls the buffered SSE response until the expected content appears.
552+
Awaitility.await()
553+
.atMost(10, SECONDS)
554+
.untilAsserted(() -> assertThat(
555+
sseResult.getResponse().getContentAsString(), containsString("Extract task received")));
556+
557+
Awaitility.await().pollInterval(5, SECONDS).atMost(30, SECONDS).untilAsserted(() -> {
558+
final String stream = sseResult.getResponse().getContentAsString();
559+
assertThat(count_completed_messages(stream), greaterThanOrEqualTo(1));
560+
});
561+
562+
final String lastCompletedEventJson =
563+
getLastCompletedEventJson(sseResult.getResponse().getContentAsString());
564+
assertThat(lastCompletedEventJson.length(), greaterThanOrEqualTo(100));
565+
566+
final String extractedDownloadId = getDownloadId(lastCompletedEventJson);
567+
assertThat(extractedDownloadId, containsString(GEOPACKAGE.getExtension()));
568+
569+
final String downloadUrl = apiBasePath + layerBegroeidTerreindeelPostgis + downloadPath + extractedDownloadId;
570+
mockMvc.perform(get(downloadUrl).with(setServletPath(downloadUrl)))
571+
.andExpect(status().isOk())
572+
.andExpect(result -> {
573+
String contentType = result.getResponse().getContentType();
574+
assertThat(contentType, containsString("application/geopackage+sqlite3"));
575+
576+
String contentDisposition = result.getResponse().getHeader("Content-Disposition");
577+
assertThat(contentDisposition, containsString("attachment; filename="));
578+
assertThat(contentDisposition, containsString(extractedDownloadId));
579+
});
580+
}
533581
}

src/test/resources/application.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ tailormap-api.admin.base-path=/api/admin
33
management.endpoints.web.base-path=/api/actuator
44
tailormap-api.new-admin-username=tm-admin
55
# see org.tailormap.api.controller.LayerExtractController.ExtractOutputFormat for valid values
6-
tailormap-api.extract.allowed-outputformats=csv,geojson,xlsx,shape
6+
tailormap-api.extract.allowed-outputformats=csv,geojson,xlsx,shape,geopackage
77
# the number of features after which a progress report is sent back to the viewer, to update the progress bar
88
tailormap-api.extract.progress-report-interval=10
99
# any files older than this (in minutes) in the extract output directory will be deleted by a scheduled job, to prevent filling up the disk

0 commit comments

Comments
 (0)