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.jspecify.annotations.NonNull;
33  import org.slf4j.Logger;
34  import org.slf4j.LoggerFactory;
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     return switch (connProperties.getDbtype()) {
283       case POSTGIS ->
284         getPostGISCreateAttachmentsTableStatement(
285             featureType.getName(),
286             featureType.getPrimaryKeyAttribute(),
287             fkColumnType,
288             typeModifier,
289             ds.getDatabaseSchema());
290       case ORACLE ->
291         getOracleCreateAttachmentsTableStatement(
292             featureType.getName(),
293             featureType.getPrimaryKeyAttribute(),
294             fkColumnType,
295             typeModifier,
296             ds.getDatabaseSchema());
297       case SQLSERVER ->
298         getSQLServerCreateAttachmentsTableStatement(
299             featureType.getName(),
300             featureType.getPrimaryKeyAttribute(),
301             fkColumnType,
302             typeModifier,
303             ds.getDatabaseSchema());
304       default ->
305         throw new IllegalArgumentException(
306             "Unsupported database type for attachments: " + connProperties.getDbtype());
307     };
308   }
309 
310   private static String getValidColumnType(String columnType, JDBCConnectionProperties.DbtypeEnum dbtype) {
311     if (dbtype.equals(JDBCConnectionProperties.DbtypeEnum.SQLSERVER)
312         && NUMERIC_WITH_IDENTITY.matcher(columnType).find()) {
313       // Remove IDENTITY keyword from numeric types as it is not supported in FK columns
314       columnType = columnType.replaceAll("(?i)\\s+identity\\b", "");
315     }
316 
317     return columnType;
318   }
319 
320   private static String getValidModifier(String columnType, int fkColumnSize) {
321     if (fkColumnSize > 0 && allowedPKTypesSupportingSize.contains(columnType.toUpperCase(Locale.ROOT))) {
322       if (columnType.equalsIgnoreCase("NUMERIC")
323           || columnType.equalsIgnoreCase("DECIMAL")
324           || columnType.equalsIgnoreCase("NUMBER")) {
325         // For NUMERIC/DECIMAL we should ideally also get the precision, but for FK columns
326         // we just use size with default precision 0
327         return "(" + fkColumnSize + ",0)";
328       }
329       return "(" + fkColumnSize + ")";
330     } else {
331       return "";
332     }
333   }
334 
335   /**
336    * Get the SQL statement to create the attachments foreign key index for the given feature type.
337    *
338    * @param featureType The feature type
339    * @return The SQL statement
340    * @throws IllegalArgumentException If the database type is not supported
341    */
342   private static String getCreateAttachmentsIndexForFeatureTypeStatements(TMFeatureType featureType, JDBCDataStore ds)
343       throws IllegalArgumentException {
344 
345     String schemaPrefix = ds.getDatabaseSchema();
346     if (!schemaPrefix.isEmpty()) {
347       schemaPrefix += ".";
348     }
349 
350     JDBCConnectionProperties connProperties = featureType.getFeatureSource().getJdbcConnection();
351     return switch (connProperties.getDbtype()) {
352       case POSTGIS ->
353         MessageFormat.format(
354             "CREATE INDEX IF NOT EXISTS {0}_attachments_fk ON {1}{0}_attachments({0}_pk)",
355             featureType.getName(), schemaPrefix);
356       case SQLSERVER -> MessageFormat.format("""
357 IF NOT EXISTS(SELECT * FROM sys.indexes WHERE name = ''{0}_attachments_fk'' AND object_id = OBJECT_ID(N''{1}{0}_attachments''))
358 BEGIN
359 CREATE INDEX {0}_attachments_fk ON {1}{0}_attachments({0}_pk)
360 END
361 """, featureType.getName(), schemaPrefix);
362       case ORACLE ->
363         MessageFormat.format(
364                 "CREATE INDEX IF NOT EXISTS {1}{0}_attachments_fk ON {1}{0}_attachments({0}_pk)",
365                 featureType.getName(), schemaPrefix)
366             .toUpperCase(Locale.ROOT);
367       default ->
368         throw new IllegalArgumentException(
369             "Unsupported database type for attachments: " + connProperties.getDbtype());
370     };
371   }
372 
373   /** Convert UUID to byte array for storage in Oracle RAW(16). */
374   private static byte[] asBytes(UUID uuid) {
375     ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
376     bb.putLong(uuid.getMostSignificantBits());
377     bb.putLong(uuid.getLeastSignificantBits());
378     return bb.array();
379   }
380 
381   public static AttachmentMetadata insertAttachment(
382       TMFeatureType featureType, AttachmentMetadata attachment, Object primaryKey, byte[] fileData)
383       throws IOException, SQLException {
384 
385     // create uuid here so we don't have to deal with DB-specific returning/generated key syntax
386     attachment.setAttachmentId(UUID.randomUUID());
387     attachment.setAttachmentSize((long) fileData.length);
388     attachment.createdAt(OffsetDateTime.now(ZoneId.of("UTC")));
389     attachment.setCreatedBy(
390         SecurityContextHolder.getContext().getAuthentication().getName());
391 
392     logger.debug(
393         "Adding attachment {} for feature {}:{}, type {}: {} (bytes: {})",
394         attachment.getAttachmentId(),
395         featureType.getName(),
396         primaryKey,
397         attachment.getMimeType(),
398         attachment,
399         fileData.length);
400 
401     JDBCDataStore ds = null;
402     try {
403       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
404 
405       String insertSql = MessageFormat.format(
406           """
407 INSERT INTO {1}{0}_attachments (
408 {0}_pk, attachment_id, file_name, attribute_name, description, attachment, attachment_size,
409 mime_type, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
410 """, featureType.getName(), ds.getDatabaseSchema().isEmpty() ? "" : ds.getDatabaseSchema() + ".");
411 
412       logger.debug("Insert attachment SQL: {}", insertSql);
413       try (Connection conn = ds.getDataSource().getConnection();
414           PreparedStatement stmt = conn.prepareStatement(insertSql)) {
415 
416         stmt.setObject(1, primaryKey);
417         if (featureType
418             .getFeatureSource()
419             .getJdbcConnection()
420             .getDbtype()
421             .equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) {
422 
423           stmt.setBytes(2, asBytes(attachment.getAttachmentId()));
424         } else {
425           stmt.setObject(2, attachment.getAttachmentId());
426         }
427         stmt.setString(3, attachment.getFileName());
428         stmt.setString(4, attachment.getAttributeName());
429         stmt.setString(5, attachment.getDescription());
430         stmt.setBytes(6, fileData);
431         stmt.setLong(7, fileData.length);
432         stmt.setString(8, attachment.getMimeType());
433         stmt.setTimestamp(
434             9, java.sql.Timestamp.from(attachment.getCreatedAt().toInstant()));
435         stmt.setString(10, attachment.getCreatedBy());
436 
437         stmt.executeUpdate();
438 
439         return attachment;
440       }
441     } finally {
442       if (ds != null) {
443         ds.dispose();
444       }
445     }
446   }
447 
448   public static void deleteAttachment(UUID attachmentId, TMFeatureType featureType) throws IOException, SQLException {
449     JDBCDataStore ds = null;
450     try {
451       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
452 
453       String deleteSql = MessageFormat.format(
454           """
455 DELETE FROM {1}{0}_attachments WHERE attachment_id = ?
456 """, featureType.getName(), ds.getDatabaseSchema().isEmpty() ? "" : ds.getDatabaseSchema() + ".");
457 
458       try (Connection conn = ds.getDataSource().getConnection();
459           PreparedStatement stmt = conn.prepareStatement(deleteSql)) {
460         if (featureType
461             .getFeatureSource()
462             .getJdbcConnection()
463             .getDbtype()
464             .equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) {
465           stmt.setBytes(1, asBytes(attachmentId));
466         } else {
467           stmt.setObject(1, attachmentId);
468         }
469 
470         stmt.executeUpdate();
471       }
472     } finally {
473       if (ds != null) {
474         ds.dispose();
475       }
476     }
477   }
478 
479   public static List<AttachmentMetadata> listAttachmentsForFeature(TMFeatureType featureType, Object primaryKey)
480       throws IOException, SQLException {
481 
482     List<AttachmentMetadata> attachments = new ArrayList<>();
483     JDBCDataStore ds = null;
484     try {
485       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
486       String querySql = MessageFormat.format(
487           """
488 SELECT
489 {0}_pk,
490 attachment_id,
491 file_name,
492 attribute_name,
493 description,
494 attachment_size,
495 mime_type,
496 created_at,
497 created_by
498 FROM {1}{0}_attachments WHERE {0}_pk = ?
499 """, featureType.getName(), ds.getDatabaseSchema().isEmpty() ? "" : ds.getDatabaseSchema() + ".");
500       try (Connection conn = ds.getDataSource().getConnection();
501           PreparedStatement stmt = conn.prepareStatement(querySql)) {
502 
503         stmt.setObject(1, primaryKey);
504 
505         try (ResultSet rs = stmt.executeQuery()) {
506           while (rs.next()) {
507             AttachmentMetadata a = getAttachmentMetadata(rs);
508             attachments.add(a);
509           }
510         }
511       }
512     } finally {
513       if (ds != null) {
514         ds.dispose();
515       }
516     }
517     return attachments;
518   }
519 
520   public static AttachmentWithBinary getAttachment(TMFeatureType featureType, UUID attachmentId)
521       throws IOException, SQLException {
522 
523     JDBCDataStore ds = null;
524     try {
525       byte[] attachment;
526       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
527       String querySql = MessageFormat.format(
528           "SELECT attachment, attachment_size, mime_type, file_name FROM {1}{0}_attachments WHERE attachment_id = ?",
529           featureType.getName(), ds.getDatabaseSchema().isEmpty() ? "" : ds.getDatabaseSchema() + ".");
530       try (Connection conn = ds.getDataSource().getConnection();
531           PreparedStatement stmt = conn.prepareStatement(querySql)) {
532 
533         if (featureType
534             .getFeatureSource()
535             .getJdbcConnection()
536             .getDbtype()
537             .equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) {
538           stmt.setBytes(1, asBytes(attachmentId));
539         } else {
540           stmt.setObject(1, attachmentId);
541         }
542 
543         try (ResultSet rs = stmt.executeQuery()) {
544           if (rs.next()) {
545             attachment = rs.getBytes("attachment");
546             AttachmentMetadata a = new AttachmentMetadata();
547             long size = rs.getLong("attachment_size");
548             if (!rs.wasNull()) {
549               a.setAttachmentSize(size);
550             }
551             a.setMimeType(rs.getString("mime_type"));
552             a.setFileName(rs.getString("file_name"));
553             return new AttachmentWithBinary(
554                 a, ByteBuffer.wrap(attachment).asReadOnlyBuffer());
555           } else {
556             return null;
557           }
558         }
559       }
560     } finally {
561       if (ds != null) {
562         ds.dispose();
563       }
564     }
565   }
566 
567   /**
568    * List attachments for multiple features grouped by their FIDs. <br>
569    * <strong>NOTE</strong>: the featurePKs list should contain objects that can be used as primary keys.
570    *
571    * @param featureType the feature type
572    * @param featurePKs the feature primary keys
573    * @return map of feature ID to list of attachments
574    * @throws IOException when an IO error occurs connecting to the database
575    */
576   public static @NonNull Map<String, List<AttachmentMetadata>> listAttachmentsForFeaturesByFeatureId(
577       TMFeatureType featureType, List<Object> featurePKs) throws IOException {
578     List<AttachmentMetadataListItem> attachments = new ArrayList<>();
579     if (featurePKs == null || featurePKs.isEmpty()) {
580       return new HashMap<>();
581     }
582 
583     JDBCDataStore ds = null;
584     try {
585       ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
586       String querySql = MessageFormat.format(
587           """
588 SELECT
589 {0}_pk,
590 attachment_id,
591 file_name,
592 attribute_name,
593 description,
594 attachment_size,
595 mime_type,
596 created_at,
597 created_by
598 FROM {2}{0}_attachments WHERE {0}_pk IN ( {1} )
599 """,
600           featureType.getName(),
601           String.join(", ", featurePKs.stream().map(id -> "?").toArray(String[]::new)),
602           ds.getDatabaseSchema().isEmpty() ? "" : ds.getDatabaseSchema() + ".");
603 
604       try (Connection conn = ds.getDataSource().getConnection();
605           PreparedStatement stmt = conn.prepareStatement(querySql)) {
606 
607         Object firstPK = featurePKs.getFirst();
608         boolean isUUID = firstPK instanceof UUID;
609         boolean isByteBuffer = firstPK instanceof ByteBuffer;
610 
611         switch (featureType.getFeatureSource().getJdbcConnection().getDbtype()) {
612           case ORACLE -> {
613             for (int i = 0; i < featurePKs.size(); i++) {
614               if (isUUID) {
615                 // Oracle (RAW(16)): Comparisons are possible, but the values in the IN list must be
616                 // correctly formatted binary literals (hextoraw('...')).
617                 stmt.setBytes(i + 1, asBytes((UUID) featurePKs.get(i)));
618               } else if (isByteBuffer) {
619                 // unwrap ByteBuffer to byte[] for the query
620                 stmt.setBytes(i + 1, ((ByteBuffer) featurePKs.get(i)).array());
621               } else {
622                 stmt.setObject(i + 1, featurePKs.get(i));
623               }
624             }
625           }
626           case SQLSERVER -> {
627             for (int i = 0; i < featurePKs.size(); i++) {
628               if (isUUID) {
629                 // use uppercase string representation for SQL Server UNIQUEIDENTIFIER
630                 stmt.setString(
631                     i + 1, featurePKs.get(i).toString().toUpperCase(Locale.ROOT));
632               } else {
633                 stmt.setObject(i + 1, featurePKs.get(i));
634               }
635             }
636           }
637           case POSTGIS -> {
638             for (int i = 0; i < featurePKs.size(); i++) {
639               stmt.setObject(i + 1, featurePKs.get(i));
640             }
641           }
642           default ->
643             throw new UnsupportedOperationException("Unsupported database type: "
644                 + featureType
645                     .getFeatureSource()
646                     .getJdbcConnection()
647                     .getDbtype());
648         }
649 
650         try (ResultSet rs = stmt.executeQuery()) {
651           while (rs.next()) {
652             Object keyObject = rs.getObject(1);
653             if (isUUID
654                 && featureType
655                     .getFeatureSource()
656                     .getJdbcConnection()
657                     .getDbtype()
658                     .equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) {
659               // convert RAW(16) back to UUID
660               byte[] rawBytes = rs.getBytes(1);
661               ByteBuffer bb = ByteBuffer.wrap(rawBytes);
662               keyObject = new UUID(bb.getLong(), bb.getLong());
663             } else if (isUUID
664                 && featureType
665                     .getFeatureSource()
666                     .getJdbcConnection()
667                     .getDbtype()
668                     .equals(JDBCConnectionProperties.DbtypeEnum.SQLSERVER)) {
669               // convert uppercase string back to UUID
670               keyObject = UUID.fromString(rs.getString(1));
671             } else if (isByteBuffer) {
672               assert keyObject instanceof byte[];
673               keyObject = ByteBuffer.wrap((byte[]) keyObject);
674             }
675             attachments.add(new AttachmentMetadataListItem(
676                 AttachmentsHelper.fidFromPK(featureType, keyObject), getAttachmentMetadata(rs)));
677           }
678         }
679       } catch (SQLException ex) {
680         logger.error("Failed to get attachments for {}", featureType.getName(), ex);
681       }
682     } finally {
683       if (ds != null) {
684         ds.dispose();
685       }
686     }
687     logger.debug(
688         "Found {} attachments for {} features (features: {}, attachments: {})",
689         attachments.size(),
690         featurePKs.size(),
691         featurePKs,
692         attachments.toArray());
693 
694     return attachments.stream()
695         .collect(Collectors.groupingBy(
696             AttachmentMetadataListItem::fid,
697             Collectors.mapping(AttachmentMetadataListItem::value, Collectors.toList())));
698   }
699 
700   /**
701    * Constructs a Feature ID (FID) string from a feature type and primary key value. The FID format is
702    * "{featureTypeName}.{primaryKey}". For byte[] primary keys, the bytes are converted to UUID format.
703    *
704    * @param featureType the feature type
705    * @param featurePK the feature primary key (supports String, Number, UUID, byte[], etc.)
706    * @return the constructed FID as a String
707    */
708   public static String fidFromPK(@NotNull TMFeatureType featureType, @NotNull Object featurePK) {
709     if (featurePK == null) {
710       throw new IllegalArgumentException("featurePK cannot be null");
711     }
712     if (featureType == null) {
713       throw new IllegalArgumentException("featureType cannot be null");
714     }
715     if (featurePK instanceof byte[] pkBytes) {
716       ByteBuffer bb = ByteBuffer.wrap(pkBytes);
717       UUID pkUUID = new UUID(bb.getLong(), bb.getLong());
718       return "%s.%s".formatted(featureType.getName(), pkUUID);
719     } else {
720       return "%s.%s".formatted(featureType.getName(), featurePK);
721     }
722   }
723 
724   private static AttachmentMetadata getAttachmentMetadata(ResultSet rs) throws SQLException {
725     AttachmentMetadata a = new AttachmentMetadata();
726     // attachment_id (handle UUID, RAW(16) as byte[] or string)
727     Object idObj = rs.getObject("attachment_id");
728     if (idObj instanceof UUID u) {
729       a.setAttachmentId(u);
730     } else if (idObj instanceof byte[] b) {
731       ByteBuffer bb = ByteBuffer.wrap(b);
732       a.setAttachmentId(new UUID(bb.getLong(), bb.getLong()));
733     } else {
734       String s = rs.getString("attachment_id");
735       if (s != null && !s.isEmpty()) {
736         a.setAttachmentId(UUID.fromString(s));
737       }
738     }
739     a.setFileName(rs.getString("file_name"));
740     a.setAttributeName(rs.getString("attribute_name"));
741     a.setDescription(rs.getString("description"));
742     long size = rs.getLong("attachment_size");
743     if (!rs.wasNull()) {
744       a.setAttachmentSize(size);
745     }
746     a.setMimeType(rs.getString("mime_type"));
747     java.sql.Timestamp ts = rs.getTimestamp("created_at");
748     if (ts != null) {
749       a.setCreatedAt(OffsetDateTime.ofInstant(ts.toInstant(), ZoneId.of("UTC")));
750     }
751     a.setCreatedBy(rs.getString("created_by"));
752     return a;
753   }
754 
755   public record AttachmentWithBinary(
756       @NotNull AttachmentMetadata attachmentMetadata,
757       @NotNull ByteBuffer attachment) {}
758 
759   private record AttachmentMetadataListItem(
760       @NotNull String fid, @NotNull AttachmentMetadata value) {}
761 }