View Javadoc
1   /*
2    * Copyright (C) 2025 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.geotools.featuresources;
7   
8   import jakarta.validation.constraints.NotNull;
9   import java.io.IOException;
10  import java.lang.invoke.MethodHandles;
11  import java.nio.ByteBuffer;
12  import java.sql.Connection;
13  import java.sql.DatabaseMetaData;
14  import java.sql.PreparedStatement;
15  import java.sql.ResultSet;
16  import java.sql.SQLException;
17  import java.sql.Statement;
18  import java.text.MessageFormat;
19  import java.time.OffsetDateTime;
20  import java.time.ZoneId;
21  import java.util.ArrayList;
22  import java.util.HashMap;
23  import java.util.List;
24  import java.util.Locale;
25  import java.util.Map;
26  import java.util.UUID;
27  import java.util.regex.Pattern;
28  import java.util.stream.Collectors;
29  import org.apache.commons.dbcp.DelegatingConnection;
30  import org.geotools.api.feature.type.AttributeDescriptor;
31  import org.geotools.jdbc.JDBCDataStore;
32  import org.slf4j.Logger;
33  import org.slf4j.LoggerFactory;
34  import org.springframework.lang.NonNull;
35  import org.springframework.security.core.context.SecurityContextHolder;
36  import org.tailormap.api.persistence.TMFeatureType;
37  import org.tailormap.api.persistence.json.JDBCConnectionProperties;
38  import org.tailormap.api.viewer.model.AttachmentMetadata;
39  
40  /** Helper class for managing the {@code <FT>_attachments} sidecar tables in JDBC DataStores. */
41  public final class AttachmentsHelper {
42    private static final Logger logger =
43        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
44  
45    private static final Pattern NUMERIC_WITH_IDENTITY = Pattern.compile(
46        "(?i)\\b(?:int|integer|bigint|smallint|numeric|decimal|number)(?:\\s*\\(\\s*\\d+(?:\\s*,\\s*\\d+)?\\s*\\))?\\s+identity\\b");
47  
48    private static final List<String> allowedPKTypesSupportingSize = List.of(
49        // list of database types that support size modifiers
50        // for their foreign key columns
51        // PostgreSQL types: https://www.postgresql.org/docs/current/datatype.html
52        "CHARACTER",
53        "CHARACTER VARYING",
54        "CHAR",
55        "VARCHAR",
56        // numeric/decimal takes size and precision but we don't want to use floating point for FK columns...
57        "NUMERIC",
58        "DECIMAL",
59        // SQL Server types:
60        // https://learn.microsoft.com/en-us/sql/t-sql/data-types/data-types-transact-sql?view=sql-server-ver17
61        "NVARCHAR",
62        "NCHAR",
63        // Oracle types
64        "VARCHAR2",
65        "NVARCHAR2",
66        "NUMBER",
67        "RAW");
68  
69    private AttachmentsHelper() {
70      // private constructor for utility class
71    }
72  
73    private static String getPostGISCreateAttachmentsTableStatement(
74        String tableName, String pkColumnName, String fkColumnType, String typeModifier, String schemaPrefix) {
75      if (!schemaPrefix.isEmpty()) {
76        schemaPrefix += ".";
77      }
78      return MessageFormat.format("""
79  CREATE TABLE IF NOT EXISTS {4}{0}_attachments (
80  {0}_pk          {2}{3}        NOT NULL REFERENCES {4}{0}({1}) ON DELETE CASCADE,
81  attachment_id   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
82  file_name       VARCHAR(255),
83  attribute_name  VARCHAR(255) NOT NULL,
84  description     TEXT,
85  attachment      BYTEA        NOT NULL,
86  attachment_size INTEGER      NOT NULL,
87  mime_type       VARCHAR(100),
88  created_at      TIMESTAMPTZ  NOT NULL DEFAULT CURRENT_TIMESTAMP,
89  created_by      VARCHAR(255) NOT NULL)
90  """, tableName, pkColumnName, fkColumnType, typeModifier, schemaPrefix);
91    }
92  
93    private static String getSQLServerCreateAttachmentsTableStatement(
94        String tableName, String pkColumnName, String fkColumnType, String typeModifier, String schemaPrefix) {
95      if (!schemaPrefix.isEmpty()) {
96        schemaPrefix += ".";
97      }
98      return MessageFormat.format("""
99  IF OBJECT_ID(N''{4}{0}_attachments'', ''U'') IS NULL
100 BEGIN
101 CREATE TABLE {4}{0}_attachments (
102 {0}_pk          {2}{3}         NOT NULL REFERENCES {4}{0}({1}) ON DELETE CASCADE,
103 attachment_id   UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
104 file_name       NVARCHAR(255),
105 attribute_name  VARCHAR(255)    NOT NULL,
106 description     NVARCHAR(MAX),
107 attachment      VARBINARY(MAX)   NOT NULL,
108 mime_type       NVARCHAR(100),
109 attachment_size INT              NOT NULL,
110 created_at      DATETIMEOFFSET   NOT NULL DEFAULT SYSDATETIMEOFFSET(),
111 created_by      NVARCHAR(255)    NOT NULL)
112 END
113 """, tableName, pkColumnName, fkColumnType, typeModifier, schemaPrefix);
114   }
115 
116   private static String getOracleCreateAttachmentsTableStatement(
117       String tableName, String pkColumnName, String fkColumnType, String typeModifier, String schemaPrefix) {
118     if (!schemaPrefix.isEmpty()) {
119       schemaPrefix += ".";
120     }
121     // Oracle supports  IF NOT EXISTS since 19.28
122     return MessageFormat.format("""
123 CREATE TABLE IF NOT EXISTS {4}{0}_ATTACHMENTS (
124 {0}_PK          {2}{3}      NOT NULL REFERENCES {4}{0}({1}) ON DELETE CASCADE,
125 ATTACHMENT_ID   RAW(16)       DEFAULT SYS_GUID() PRIMARY KEY,
126 FILE_NAME       VARCHAR2(255),
127 ATTACHMENT      BLOB          NOT NULL,
128 ATTRIBUTE_NAME  VARCHAR2(255) NOT NULL,
129 DESCRIPTION     CLOB,
130 MIME_TYPE       VARCHAR2(100),
131 ATTACHMENT_SIZE INT           NOT NULL,
132 CREATED_AT      TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
133 CREATED_BY      VARCHAR2(255) NOT NULL)
134 """, tableName, pkColumnName, fkColumnType, typeModifier, schemaPrefix);
135   }
136 
137   /**
138    * Create attachment table and index for the given FeatureType. This will validate that any AttachmentAttribute has
139    * a valid name.
140    *
141    * @param featureType the FeatureType to create the attachment table for
142    * @throws IOException when creating the GeoTools datastore fails
143    * @throws SQLException when executing the SQL statements fails
144    * @throws IllegalArgumentException when the FeatureType is invalid
145    */
146   public static void createAttachmentTableForFeatureType(TMFeatureType featureType)
147       throws IOException, SQLException, IllegalArgumentException {
148     if (featureType == null
149         || featureType.getSettings() == null
150         || featureType.getSettings().getAttachmentAttributes() == null
151         || featureType.getSettings().getAttachmentAttributes().isEmpty()) {
152       throw new IllegalArgumentException("FeatureType "
153           + (featureType != null ? featureType.getName() : "null")
154           + " is invalid or has no attachment attributes defined in its settings");
155     }
156     // check if any attachment attribute names are empty or null
157     featureType.getSettings().getAttachmentAttributes().stream()
158         .filter(attachmentAttributeType -> (attachmentAttributeType.getAttributeName() == null
159             || attachmentAttributeType.getAttributeName().isEmpty()))
160         .findAny()
161         .ifPresent(attachmentAttributeType -> {
162           throw new IllegalArgumentException("FeatureType "
163               + featureType.getName()
164               + " has an attachment attribute with invalid (null or empty) attribute name");
165         });
166 
167     logger.debug(
168         "Creating attachment table for FeatureType: {} and attachment names {}",
169         featureType.getName(),
170         featureType.getSettings().getAttachmentAttributes());
171 
172     JDBCDataStore ds = null;
173     try {
174       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
175 
176       try (Connection conn = ds.getDataSource().getConnection();
177           Statement stmt = conn.createStatement()) {
178         String sql = getCreateAttachmentsForFeatureTypeStatements(featureType, ds);
179         logger.debug("About to create attachments table using statement:\n{}", sql);
180         stmt.execute(sql);
181         logger.info("Attachment table created for FeatureType: {}", featureType.getName());
182 
183         sql = getCreateAttachmentsIndexForFeatureTypeStatements(featureType, ds);
184         logger.debug("About to create attachments table FK index using statement:\n{}", sql);
185         stmt.execute(sql);
186         logger.info("Attachment table FK index created for FeatureType: {}", featureType.getName());
187       }
188     } finally {
189       if (ds != null) {
190         ds.dispose();
191       }
192     }
193   }
194 
195   public static void dropAttachmentTableForFeatureType(TMFeatureType featureType) throws IOException, SQLException {
196     JDBCDataStore ds = null;
197     try {
198       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
199       String schemaPrefix = ds.getDatabaseSchema();
200       if (!schemaPrefix.isEmpty()) {
201         schemaPrefix += ".";
202       }
203       String dropSql = MessageFormat.format("DROP TABLE {1}{0}_attachments", featureType.getName(), schemaPrefix);
204       logger.debug("About to drop attachments table using statement:\n{}", dropSql);
205       try (Connection conn = ds.getDataSource().getConnection();
206           Statement stmt = conn.createStatement()) {
207         stmt.execute(dropSql);
208         logger.info("Attachment table dropped for FeatureType: {}", featureType.getName());
209       }
210     } finally {
211       if (ds != null) {
212         ds.dispose();
213       }
214     }
215   }
216 
217   /**
218    * Get the SQL statement to create the attachments table for the given feature type.
219    *
220    * @param featureType The feature type
221    * @return The SQL statement
222    * @throws IOException If an error connecting to the database occurs
223    * @throws IllegalArgumentException If the database type is not supported
224    */
225   private static String getCreateAttachmentsForFeatureTypeStatements(TMFeatureType featureType, JDBCDataStore ds)
226       throws IOException, IllegalArgumentException, SQLException {
227 
228     String fkColumnType = null;
229     int fkColumnSize = 0;
230     AttributeDescriptor pkDescriptor =
231         ds.getSchema(featureType.getName()).getDescriptor(featureType.getPrimaryKeyAttribute());
232 
233     try (Connection conn = ((DelegatingConnection) ds.getDataSource().getConnection()).getInnermostDelegate()) {
234       DatabaseMetaData metaData = conn.getMetaData();
235       try (ResultSet rs = metaData.getColumns(
236           conn.getCatalog(),
237           ds.getDatabaseSchema(),
238           featureType.getName(),
239           featureType.getPrimaryKeyAttribute())) {
240         if (rs.next()) {
241           fkColumnType = rs.getString("TYPE_NAME");
242           fkColumnSize = rs.getInt("COLUMN_SIZE");
243         }
244       }
245 
246       // Fallback to upper-case table/column names (common for some DBs, but something must be wrong in our
247       // configuration because we store uppercase when we get that from the database...)
248       if (fkColumnType == null) {
249         try (ResultSet rs = metaData.getColumns(
250             conn.getCatalog(),
251             ds.getDatabaseSchema(),
252             featureType.getName().toUpperCase(Locale.ROOT),
253             featureType.getPrimaryKeyAttribute().toUpperCase(Locale.ROOT))) {
254           if (rs.next()) {
255             fkColumnType = rs.getString("TYPE_NAME");
256             fkColumnSize = rs.getInt("COLUMN_SIZE");
257           }
258         }
259       }
260 
261       // Final fallback to GeoTools nativeType from the attribute descriptor
262       if (fkColumnType == null) {
263         fkColumnType = (String) pkDescriptor.getUserData().get("org.geotools.jdbc.nativeTypeName");
264       }
265     }
266 
267     String typeModifier = "";
268     if (fkColumnSize > 0) {
269       typeModifier = getValidModifier(fkColumnType, fkColumnSize);
270     }
271     logger.debug(
272         "Creating attachment table for feature type with primary key {} (native type: {}, meta type: {}, size:"
273             + " {} (modifier: {}))",
274         pkDescriptor.getLocalName(),
275         fkColumnType,
276         pkDescriptor.getUserData().get("org.geotools.jdbc.nativeTypeName"),
277         fkColumnSize,
278         typeModifier);
279 
280     JDBCConnectionProperties connProperties = featureType.getFeatureSource().getJdbcConnection();
281     fkColumnType = getValidColumnType(fkColumnType, connProperties.getDbtype());
282     switch (connProperties.getDbtype()) {
283       case POSTGIS -> {
284         return getPostGISCreateAttachmentsTableStatement(
285             featureType.getName(),
286             featureType.getPrimaryKeyAttribute(),
287             fkColumnType,
288             typeModifier,
289             ds.getDatabaseSchema());
290       }
291 
292       case ORACLE -> {
293         return getOracleCreateAttachmentsTableStatement(
294             featureType.getName(),
295             featureType.getPrimaryKeyAttribute(),
296             fkColumnType,
297             typeModifier,
298             ds.getDatabaseSchema());
299       }
300       case SQLSERVER -> {
301         return getSQLServerCreateAttachmentsTableStatement(
302             featureType.getName(),
303             featureType.getPrimaryKeyAttribute(),
304             fkColumnType,
305             typeModifier,
306             ds.getDatabaseSchema());
307       }
308       default ->
309         throw new IllegalArgumentException(
310             "Unsupported database type for attachments: " + connProperties.getDbtype());
311     }
312   }
313 
314   private static String getValidColumnType(String columnType, JDBCConnectionProperties.DbtypeEnum dbtype) {
315     if (dbtype.equals(JDBCConnectionProperties.DbtypeEnum.SQLSERVER)
316         && NUMERIC_WITH_IDENTITY.matcher(columnType).find()) {
317       // Remove IDENTITY keyword from numeric types as it is not supported in FK columns
318       columnType = columnType.replaceAll("(?i)\\s+identity\\b", "");
319     }
320 
321     return columnType;
322   }
323 
324   private static String getValidModifier(String columnType, int fkColumnSize) {
325     if (fkColumnSize > 0 && allowedPKTypesSupportingSize.contains(columnType.toUpperCase(Locale.ROOT))) {
326       if (columnType.equalsIgnoreCase("NUMERIC")
327           || columnType.equalsIgnoreCase("DECIMAL")
328           || columnType.equalsIgnoreCase("NUMBER")) {
329         // For NUMERIC/DECIMAL we should ideally also get the precision, but for FK columns
330         // we just use size with default precision 0
331         return "(" + fkColumnSize + ",0)";
332       }
333       return "(" + fkColumnSize + ")";
334     } else {
335       return "";
336     }
337   }
338 
339   /**
340    * Get the SQL statement to create the attachments foreign key index for the given feature type.
341    *
342    * @param featureType The feature type
343    * @return The SQL statement
344    * @throws IllegalArgumentException If the database type is not supported
345    */
346   private static String getCreateAttachmentsIndexForFeatureTypeStatements(TMFeatureType featureType, JDBCDataStore ds)
347       throws IllegalArgumentException {
348 
349     String schemaPrefix = ds.getDatabaseSchema();
350     if (!schemaPrefix.isEmpty()) {
351       schemaPrefix += ".";
352     }
353 
354     JDBCConnectionProperties connProperties = featureType.getFeatureSource().getJdbcConnection();
355     switch (connProperties.getDbtype()) {
356       case POSTGIS -> {
357         return MessageFormat.format(
358             "CREATE INDEX IF NOT EXISTS {0}_attachments_fk ON {1}{0}_attachments({0}_pk)",
359             featureType.getName(), schemaPrefix);
360       }
361       case SQLSERVER -> {
362         return MessageFormat.format("""
363 IF NOT EXISTS(SELECT * FROM sys.indexes WHERE name = ''{0}_attachments_fk'' AND object_id = OBJECT_ID(N''{1}{0}_attachments''))
364 BEGIN
365 CREATE INDEX {0}_attachments_fk ON {1}{0}_attachments({0}_pk)
366 END
367 """, featureType.getName(), schemaPrefix);
368       }
369       case ORACLE -> {
370         return MessageFormat.format(
371                 "CREATE INDEX IF NOT EXISTS {1}{0}_attachments_fk ON {1}{0}_attachments({0}_pk)",
372                 featureType.getName(), schemaPrefix)
373             .toUpperCase(Locale.ROOT);
374       }
375       default ->
376         throw new IllegalArgumentException(
377             "Unsupported database type for attachments: " + connProperties.getDbtype());
378     }
379   }
380 
381   /** Convert UUID to byte array for storage in Oracle RAW(16). */
382   private static byte[] asBytes(UUID uuid) {
383     ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
384     bb.putLong(uuid.getMostSignificantBits());
385     bb.putLong(uuid.getLeastSignificantBits());
386     return bb.array();
387   }
388 
389   public static AttachmentMetadata insertAttachment(
390       TMFeatureType featureType, AttachmentMetadata attachment, Object primaryKey, byte[] fileData)
391       throws IOException, SQLException {
392 
393     // create uuid here so we don't have to deal with DB-specific returning/generated key syntax
394     attachment.setAttachmentId(UUID.randomUUID());
395     attachment.setAttachmentSize((long) fileData.length);
396     attachment.createdAt(OffsetDateTime.now(ZoneId.of("UTC")));
397     attachment.setCreatedBy(
398         SecurityContextHolder.getContext().getAuthentication().getName());
399 
400     logger.debug(
401         "Adding attachment {} for feature {}:{}, type {}: {} (bytes: {})",
402         attachment.getAttachmentId(),
403         featureType.getName(),
404         primaryKey,
405         attachment.getMimeType(),
406         attachment,
407         fileData.length);
408 
409     JDBCDataStore ds = null;
410     try {
411       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
412 
413       String insertSql = MessageFormat.format(
414           """
415 INSERT INTO {1}{0}_attachments (
416 {0}_pk, attachment_id, file_name, attribute_name, description, attachment, attachment_size,
417 mime_type, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
418 """, featureType.getName(), ds.getDatabaseSchema().isEmpty() ? "" : ds.getDatabaseSchema() + ".");
419 
420       logger.debug("Insert attachment SQL: {}", insertSql);
421       try (Connection conn = ds.getDataSource().getConnection();
422           PreparedStatement stmt = conn.prepareStatement(insertSql)) {
423 
424         stmt.setObject(1, primaryKey);
425         if (featureType
426             .getFeatureSource()
427             .getJdbcConnection()
428             .getDbtype()
429             .equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) {
430 
431           stmt.setBytes(2, asBytes(attachment.getAttachmentId()));
432         } else {
433           stmt.setObject(2, attachment.getAttachmentId());
434         }
435         stmt.setString(3, attachment.getFileName());
436         stmt.setString(4, attachment.getAttributeName());
437         stmt.setString(5, attachment.getDescription());
438         stmt.setBytes(6, fileData);
439         stmt.setLong(7, fileData.length);
440         stmt.setString(8, attachment.getMimeType());
441         stmt.setTimestamp(
442             9, java.sql.Timestamp.from(attachment.getCreatedAt().toInstant()));
443         stmt.setString(10, attachment.getCreatedBy());
444 
445         stmt.executeUpdate();
446 
447         return attachment;
448       }
449     } finally {
450       if (ds != null) {
451         ds.dispose();
452       }
453     }
454   }
455 
456   public static void deleteAttachment(UUID attachmentId, TMFeatureType featureType) throws IOException, SQLException {
457     String deleteSql = MessageFormat.format("""
458 DELETE FROM {0}_attachments WHERE attachment_id = ?
459 """, featureType.getName());
460     JDBCDataStore ds = null;
461     try {
462       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
463       try (Connection conn = ds.getDataSource().getConnection();
464           PreparedStatement stmt = conn.prepareStatement(deleteSql)) {
465         if (featureType
466             .getFeatureSource()
467             .getJdbcConnection()
468             .getDbtype()
469             .equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) {
470           stmt.setBytes(1, asBytes(attachmentId));
471         } else {
472           stmt.setObject(1, attachmentId);
473         }
474 
475         stmt.executeUpdate();
476       }
477     } finally {
478       if (ds != null) {
479         ds.dispose();
480       }
481     }
482   }
483 
484   public static List<AttachmentMetadata> listAttachmentsForFeature(TMFeatureType featureType, Object primaryKey)
485       throws IOException, SQLException {
486 
487     List<AttachmentMetadata> attachments = new ArrayList<>();
488     JDBCDataStore ds = null;
489     try {
490       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
491       String querySql = MessageFormat.format(
492           """
493 SELECT
494 {0}_pk,
495 attachment_id,
496 file_name,
497 attribute_name,
498 description,
499 attachment_size,
500 mime_type,
501 created_at,
502 created_by
503 FROM {1}{0}_attachments WHERE {0}_pk = ?
504 """, featureType.getName(), ds.getDatabaseSchema().isEmpty() ? "" : ds.getDatabaseSchema() + ".");
505       try (Connection conn = ds.getDataSource().getConnection();
506           PreparedStatement stmt = conn.prepareStatement(querySql)) {
507 
508         stmt.setObject(1, primaryKey);
509 
510         try (ResultSet rs = stmt.executeQuery()) {
511           while (rs.next()) {
512             AttachmentMetadata a = getAttachmentMetadata(rs);
513             attachments.add(a);
514           }
515         }
516       }
517     } finally {
518       if (ds != null) {
519         ds.dispose();
520       }
521     }
522     return attachments;
523   }
524 
525   public static AttachmentWithBinary getAttachment(TMFeatureType featureType, UUID attachmentId)
526       throws IOException, SQLException {
527 
528     JDBCDataStore ds = null;
529     try {
530       byte[] attachment;
531       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
532       String querySql = MessageFormat.format(
533           "SELECT attachment, attachment_size, mime_type, file_name FROM {1}{0}_attachments WHERE attachment_id = ?",
534           featureType.getName(), ds.getDatabaseSchema().isEmpty() ? "" : ds.getDatabaseSchema() + ".");
535       try (Connection conn = ds.getDataSource().getConnection();
536           PreparedStatement stmt = conn.prepareStatement(querySql)) {
537 
538         if (featureType
539             .getFeatureSource()
540             .getJdbcConnection()
541             .getDbtype()
542             .equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) {
543           stmt.setBytes(1, asBytes(attachmentId));
544         } else {
545           stmt.setObject(1, attachmentId);
546         }
547 
548         try (ResultSet rs = stmt.executeQuery()) {
549           if (rs.next()) {
550             attachment = rs.getBytes("attachment");
551             AttachmentMetadata a = new AttachmentMetadata();
552             long size = rs.getLong("attachment_size");
553             if (!rs.wasNull()) {
554               a.setAttachmentSize(size);
555             }
556             a.setMimeType(rs.getString("mime_type"));
557             a.setFileName(rs.getString("file_name"));
558             return new AttachmentWithBinary(
559                 a, ByteBuffer.wrap(attachment).asReadOnlyBuffer());
560           } else {
561             return null;
562           }
563         }
564       }
565     } finally {
566       if (ds != null) {
567         ds.dispose();
568       }
569     }
570   }
571 
572   /**
573    * List attachments for multiple features grouped by their FIDs. <br>
574    * <strong>NOTE</strong>: the featurePKs list should contain objects that can be used as primary keys.
575    *
576    * @param featureType the feature type
577    * @param featurePKs the feature primary keys
578    * @return map of feature ID to list of attachments
579    * @throws IOException when an IO error occurs connecting to the database
580    */
581   public static @NonNull Map<String, List<AttachmentMetadata>> listAttachmentsForFeaturesByFeatureId(
582       TMFeatureType featureType, List<Object> featurePKs) throws IOException {
583     List<AttachmentMetadataListItem> attachments = new ArrayList<>();
584     if (featurePKs == null || featurePKs.isEmpty()) {
585       return new HashMap<>();
586     }
587 
588     JDBCDataStore ds = null;
589     try {
590       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
591       String querySql = MessageFormat.format(
592           """
593 SELECT
594 {0}_pk,
595 attachment_id,
596 file_name,
597 attribute_name,
598 description,
599 attachment_size,
600 mime_type,
601 created_at,
602 created_by
603 FROM {2}{0}_attachments WHERE {0}_pk IN ( {1} )
604 """,
605           featureType.getName(),
606           String.join(", ", featurePKs.stream().map(id -> "?").toArray(String[]::new)),
607           ds.getDatabaseSchema().isEmpty() ? "" : ds.getDatabaseSchema() + ".");
608 
609       try (Connection conn = ds.getDataSource().getConnection();
610           PreparedStatement stmt = conn.prepareStatement(querySql)) {
611 
612         Object firstPK = featurePKs.getFirst();
613         boolean isUUID = firstPK instanceof UUID;
614         boolean isByteBuffer = firstPK instanceof ByteBuffer;
615 
616         switch (featureType.getFeatureSource().getJdbcConnection().getDbtype()) {
617           case ORACLE -> {
618             for (int i = 0; i < featurePKs.size(); i++) {
619               if (isUUID) {
620                 // Oracle (RAW(16)): Comparisons are possible, but the values in the IN list must be
621                 // correctly formatted binary literals (hextoraw('...')).
622                 stmt.setBytes(i + 1, asBytes((UUID) featurePKs.get(i)));
623               } else if (isByteBuffer) {
624                 // unwrap ByteBuffer to byte[] for the query
625                 stmt.setBytes(i + 1, ((ByteBuffer) featurePKs.get(i)).array());
626               } else {
627                 stmt.setObject(i + 1, featurePKs.get(i));
628               }
629             }
630           }
631           case SQLSERVER -> {
632             for (int i = 0; i < featurePKs.size(); i++) {
633               if (isUUID) {
634                 // use uppercase string representation for SQL Server UNIQUEIDENTIFIER
635                 stmt.setString(
636                     i + 1, featurePKs.get(i).toString().toUpperCase(Locale.ROOT));
637               } else {
638                 stmt.setObject(i + 1, featurePKs.get(i));
639               }
640             }
641           }
642           case POSTGIS -> {
643             for (int i = 0; i < featurePKs.size(); i++) {
644               stmt.setObject(i + 1, featurePKs.get(i));
645             }
646           }
647           default ->
648             throw new UnsupportedOperationException("Unsupported database type: "
649                 + featureType
650                     .getFeatureSource()
651                     .getJdbcConnection()
652                     .getDbtype());
653         }
654 
655         try (ResultSet rs = stmt.executeQuery()) {
656           while (rs.next()) {
657             Object keyObject = rs.getObject(1);
658             if (isUUID
659                 && featureType
660                     .getFeatureSource()
661                     .getJdbcConnection()
662                     .getDbtype()
663                     .equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) {
664               // convert RAW(16) back to UUID
665               byte[] rawBytes = rs.getBytes(1);
666               ByteBuffer bb = ByteBuffer.wrap(rawBytes);
667               keyObject = new UUID(bb.getLong(), bb.getLong());
668             } else if (isUUID
669                 && featureType
670                     .getFeatureSource()
671                     .getJdbcConnection()
672                     .getDbtype()
673                     .equals(JDBCConnectionProperties.DbtypeEnum.SQLSERVER)) {
674               // convert uppercase string back to UUID
675               keyObject = UUID.fromString(rs.getString(1));
676             } else if (isByteBuffer) {
677               assert keyObject instanceof byte[];
678               keyObject = ByteBuffer.wrap((byte[]) keyObject);
679             }
680             attachments.add(new AttachmentMetadataListItem(
681                 AttachmentsHelper.fidFromPK(featureType, keyObject), getAttachmentMetadata(rs)));
682           }
683         }
684       } catch (SQLException ex) {
685         logger.error("Failed to get attachments for {}", featureType.getName(), ex);
686       }
687     } finally {
688       if (ds != null) {
689         ds.dispose();
690       }
691     }
692     logger.debug(
693         "Found {} attachments for {} features (features: {}, attachments: {})",
694         attachments.size(),
695         featurePKs.size(),
696         featurePKs,
697         attachments.toArray());
698 
699     return attachments.stream()
700         .collect(Collectors.groupingBy(
701             AttachmentMetadataListItem::fid,
702             Collectors.mapping(AttachmentMetadataListItem::value, Collectors.toList())));
703   }
704 
705   /**
706    * Constructs a Feature ID (FID) string from a feature type and primary key value. The FID format is
707    * "{featureTypeName}.{primaryKey}". For byte[] primary keys, the bytes are converted to UUID format.
708    *
709    * @param featureType the feature type
710    * @param featurePK the feature primary key (supports String, Number, UUID, byte[], etc.)
711    * @return the constructed FID as a String
712    */
713   public static String fidFromPK(@NotNull TMFeatureType featureType, @NotNull Object featurePK) {
714     if (featurePK == null) {
715       throw new IllegalArgumentException("featurePK cannot be null");
716     }
717     if (featureType == null) {
718       throw new IllegalArgumentException("featureType cannot be null");
719     }
720     if (featurePK instanceof byte[] pkBytes) {
721       ByteBuffer bb = ByteBuffer.wrap(pkBytes);
722       UUID pkUUID = new UUID(bb.getLong(), bb.getLong());
723       return "%s.%s".formatted(featureType.getName(), pkUUID);
724     } else {
725       return "%s.%s".formatted(featureType.getName(), featurePK);
726     }
727   }
728 
729   private static AttachmentMetadata getAttachmentMetadata(ResultSet rs) throws SQLException {
730     AttachmentMetadata a = new AttachmentMetadata();
731     // attachment_id (handle UUID, RAW(16) as byte[] or string)
732     Object idObj = rs.getObject("attachment_id");
733     if (idObj instanceof UUID u) {
734       a.setAttachmentId(u);
735     } else if (idObj instanceof byte[] b) {
736       ByteBuffer bb = ByteBuffer.wrap(b);
737       a.setAttachmentId(new UUID(bb.getLong(), bb.getLong()));
738     } else {
739       String s = rs.getString("attachment_id");
740       if (s != null && !s.isEmpty()) {
741         a.setAttachmentId(UUID.fromString(s));
742       }
743     }
744     a.setFileName(rs.getString("file_name"));
745     a.setAttributeName(rs.getString("attribute_name"));
746     a.setDescription(rs.getString("description"));
747     long size = rs.getLong("attachment_size");
748     if (!rs.wasNull()) {
749       a.setAttachmentSize(size);
750     }
751     a.setMimeType(rs.getString("mime_type"));
752     java.sql.Timestamp ts = rs.getTimestamp("created_at");
753     if (ts != null) {
754       a.setCreatedAt(OffsetDateTime.ofInstant(ts.toInstant(), ZoneId.of("UTC")));
755     }
756     a.setCreatedBy(rs.getString("created_by"));
757     return a;
758   }
759 
760   public record AttachmentWithBinary(
761       @NotNull AttachmentMetadata attachmentMetadata,
762       @NotNull ByteBuffer attachment) {}
763 
764   private record AttachmentMetadataListItem(
765       @NotNull String fid, @NotNull AttachmentMetadata value) {}
766 }