org.geowebcache.sqlite.MbtilesBlobStore.java Source code

Java tutorial

Introduction

Here is the source code for org.geowebcache.sqlite.MbtilesBlobStore.java

Source

/**
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * <p>
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * <p>
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * @author Nuno Oliveira, GeoSolutions S.A.S., Copyright 2016
 */
package org.geowebcache.sqlite;

import org.apache.commons.io.IOUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.geotools.mbtiles.GeoToolsMbtilesUtils;
import org.geotools.mbtiles.MBTilesFile;
import org.geotools.mbtiles.MBTilesMetadata;
import org.geotools.mbtiles.MBTilesMetadata.t_format;
import org.geotools.mbtiles.MBTilesTile;
import org.geotools.sql.SqlUtil;
import org.geowebcache.filter.parameters.ParametersUtils;
import org.geowebcache.mime.ApplicationMime;
import org.geowebcache.mime.MimeException;
import org.geowebcache.mime.MimeType;
import org.geowebcache.storage.BlobStoreListener;
import org.geowebcache.storage.BlobStoreListenerList;
import org.geowebcache.storage.StorageException;
import org.geowebcache.storage.TileObject;
import org.geowebcache.storage.TileRange;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.sql.Connection;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.CompletionService;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorCompletionService;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

/**
 * Blobstore that store the tiles in a sqlite database using the mbtiles specification.
 */
public final class MbtilesBlobStore extends SqliteBlobStore {

    private static Log LOGGER = LogFactory.getLog(MbtilesBlobStore.class);

    // pattern for matching the name of a file that contains mbtiles metadata (layerName.properties)
    private final static Pattern MBTILES_METADATA_FILE_NAME_PATTERN = Pattern.compile("(.*?)\\.properties");

    // sqlite database that will contain layers metadata
    private final File metadataFile;

    // if true we will prefer delete a file rather than delete only a few tiles (avoiding vacuum)
    private final boolean eagerDelete;

    // if false we will not care about tile create time (expiration rules will not work)
    private final boolean useCreateTime;

    private final BlobStoreListenerList listeners;

    // parsed mbtiles metadata indexed per layer
    private final Map<String, MBTilesMetadata> layersMetadata = new ConcurrentHashMap<>();

    // Executor that can be used to perform parallel operations
    private final ExecutorService executorService;

    // Apply GZIP compression to uncompressed vector tile formats.
    private final boolean gzipVector;

    MbtilesBlobStore(MbtilesConfiguration configuration) {
        // caution this constructor will create a new connection pool
        this(configuration,
                new SqliteConnectionManager(configuration.getPoolSize(), configuration.getPoolReaperIntervalMs()));
    }

    public MbtilesBlobStore(MbtilesConfiguration configuration, SqliteConnectionManager connectionManager) {
        super(configuration, connectionManager);
        metadataFile = new File(configuration.getRootDirectoryFile(), "metadata.sqlite");
        eagerDelete = configuration.eagerDelete();
        useCreateTime = configuration.useCreateTime();
        executorService = Executors.newFixedThreadPool(configuration.getExecutorConcurrency());
        listeners = new BlobStoreListenerList();
        gzipVector = configuration.isGzipVector();
        initMbtilesLayersMetadata(configuration.getMbtilesMetadataDirectory());
        if (LOGGER.isInfoEnabled()) {
            LOGGER.info(String.format("MBTiles blob store initiated: [eagerDelete='%b', useCreateTime='%b'.",
                    eagerDelete, useCreateTime));
        }
    }

    private boolean tileIsGzipped(TileObject tile) throws MimeException {
        return gzipVector && MimeType.createFromFormat(tile.getBlobFormat()).isVector();
    }

    @Override
    public void put(TileObject tile) throws StorageException {
        File file = fileManager.getFile(tile);
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug(String.format("Tile '%s' mapped to file '%s'.", tile, file));
        }
        initDatabaseFileIfNeeded(file, tile.getLayerName(), tile.getBlobFormat());
        // do work in write mode
        connectionManager.doWork(file, false, connection -> {
            // instantiating geotools needed objects
            MBTilesFile mbtiles = GeoToolsMbtilesUtils.getMBTilesFile(connection, file);
            MBTilesTile gtTile = new MBTilesTile(tile.getXYZ()[2], tile.getXYZ()[0], tile.getXYZ()[1]);
            try {
                final boolean gzipped = tileIsGzipped(tile);

                byte[] bytes;
                if (gzipped) {
                    try (ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
                            GZIPOutputStream gzOut = new GZIPOutputStream(byteStream);) {
                        bytes = byteStream.toByteArray();
                    }
                } else {
                    bytes = Utils.resourceToByteArray(tile.getBlob());
                }
                gtTile.setData(bytes);

                // if necessary getting old data size for listeners
                byte[] olData = null;
                if (!listeners.isEmpty()) {
                    olData = mbtiles.loadTile(tile.getXYZ()[2], tile.getXYZ()[0], tile.getXYZ()[1]).getData();
                }
                // saving the tile
                mbtiles.saveTile(gtTile);
                if (useCreateTime) {
                    // we need to store this tile create time
                    putTileCreateTime(connection, tile.getXYZ()[2], tile.getXYZ()[0], tile.getXYZ()[1],
                            System.currentTimeMillis());
                }
                if (LOGGER.isDebugEnabled()) {
                    LOGGER.debug(String.format("Tile '%s' saved in file '%s'.", tile, file));
                }
                if (listeners.isEmpty()) {
                    // no listeners to update we are done
                    return;
                }
                if (olData == null) {
                    // this was new tile
                    listeners.sendTileStored(tile);
                } else {
                    // this an update
                    listeners.sendTileUpdated(tile, olData.length);
                }
            } catch (Exception exception) {
                throw Utils.exception(exception, "Error saving tile '%s' in file '%s'.", tile, file);
            }
        });

        persistParameterMap(tile);
    }

    @Override
    public boolean get(final TileObject tile) throws StorageException {
        File file = fileManager.getFile(tile);
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug(String.format("Tile '%s' mapped to file '%s'.", tile, file));
        }
        initDatabaseFileIfNeeded(file, tile.getLayerName(), tile.getBlobFormat());
        // do work in readonly mode
        boolean exists = connectionManager.doWork(file, true, connection -> {
            // instantiating geotools mbtiles reader
            MBTilesFile mbtiles = GeoToolsMbtilesUtils.getMBTilesFile(connection, file);
            try {

                final boolean gzipped = tileIsGzipped(tile);

                // loading the tile using geotools reader
                MBTilesTile gtTile = mbtiles.loadTile(tile.getXYZ()[2], tile.getXYZ()[0], tile.getXYZ()[1]);

                byte[] bytes = gtTile.getData();
                if (gtTile.getData() != null) {
                    if (gzipped) {
                        try (ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
                                ByteArrayInputStream byteIn = new ByteArrayInputStream(gtTile.getData());
                                GZIPInputStream gzIn = new GZIPInputStream(byteIn);) {
                            IOUtils.copy(gzIn, byteOut);
                            bytes = byteOut.toByteArray();
                        }
                    } else {
                        bytes = gtTile.getData();
                    }
                    tile.setBlob(Utils.byteArrayToResource(bytes));

                    if (LOGGER.isDebugEnabled()) {
                        LOGGER.debug(String.format("Tile '%s' found on file '%s'.", tile, file));
                    }
                    return true;
                }
            } catch (Exception exception) {
                throw Utils.exception(exception, "Error loading tile '%s' from MBTiles file '%s'.", tile, file);
            }
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug(String.format("Tile '%s' not found on file '%s'.", tile, file));
            }
            return false;
        });
        if (exists && useCreateTime) {
            // the tile exists and we need to set the create time in the tile object
            Long createdTime = getTileCreateTime(file, tile.getXYZ()[2], tile.getXYZ()[0], tile.getXYZ()[1]);
            if (createdTime == null) {
                // no create time associated with this tile let's assume the last modified time
                createdTime = file.lastModified();
                // update the create time
                putTileCreateTime(file, tile.getXYZ()[2], tile.getXYZ()[0], tile.getXYZ()[1], createdTime);
            }
            tile.setCreated(createdTime);
        } else if (exists) {
            // we don't care about the create time, tile will never expire
            tile.setCreated(System.currentTimeMillis());
        }
        return exists;
    }

    @Override
    public boolean delete(TileObject tile) throws StorageException {
        File file = fileManager.getFile(tile);
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug(String.format("Tile '%s' mapped to file '%s'.", tile, file));
        }
        if (!file.exists()) {
            // database file doesn't exists so nothing to do
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug(String.format("Containing file '%s' for tile '%s' doesn't exists.", file, tile));
            }
            return false;
        }
        // do work on write mode
        return connectionManager.doWork(file, false, connection -> {
            // instantiating geotools objects without setting the tile data (this way geotools will try to remove the tile)
            MBTilesFile mbtiles = GeoToolsMbtilesUtils.getMBTilesFile(connection, file);
            MBTilesTile gtTile = new MBTilesTile(tile.getXYZ()[2], tile.getXYZ()[0], tile.getXYZ()[1]);
            try {
                // getting tile old data and checking if the tile exists
                byte[] olData = mbtiles.loadTile(tile.getXYZ()[2], tile.getXYZ()[0], tile.getXYZ()[1]).getData();
                if (olData != null) {
                    // tile exists so let's remove the tile
                    tile.setBlobSize(olData.length);
                    mbtiles.saveTile(gtTile);
                    // updating the listener if any
                    listeners.sendTileDeleted(tile);
                    if (useCreateTime) {
                        // we care about the create time so let's remove it
                        deleteTileCreateTime(connection, tile.getXYZ()[2], tile.getXYZ()[0], tile.getXYZ()[1]);
                    }
                    if (LOGGER.isDebugEnabled()) {
                        LOGGER.debug(String.format("Tile '%s' deleted from file '%s'.", tile, file));
                    }
                    return true;
                }
            } catch (Exception exception) {
                throw Utils.exception(exception, "Error deleting tile '%s' from MBTiles file '%s'.", tile, file);
            }
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug(String.format("Tile '%s' not found on file '%s'.", tile, file));
            }
            return false;
        });
    }

    @Override
    public synchronized void putLayerMetadata(String layerName, String key, String value) {
        // storing metadata associated with a layer in the metadata file
        connectionManager.executeSql(metadataFile,
                "CREATE TABLE IF NOT EXISTS metadata (layerName text, key text, value text, PRIMARY KEY(layerName, key));");
        connectionManager.executeSql(metadataFile, "INSERT OR REPLACE INTO metadata VALUES (?, ?, ?);", layerName,
                key, value);
        if (LOGGER.isInfoEnabled()) {
            LOGGER.info(String.format("Metadata for layer '%s' for key '%s' inserted or updated on file '%s'.",
                    layerName, key, metadataFile));
        }
    }

    @Override
    public String getLayerMetadata(String layerName, String key) {
        try {
            return connectionManager.executeQuery(metadataFile, resultSet -> {
                try {
                    if (resultSet.next()) {
                        // metadata value is available
                        String value = resultSet.getString(1);
                        if (LOGGER.isDebugEnabled()) {
                            LOGGER.debug(String.format("Metadata for layer '%s' with key '%s' found '%s'.",
                                    layerName, key, value));
                        }
                        return value;
                    }
                    // metadata value not found
                    if (LOGGER.isDebugEnabled()) {
                        LOGGER.debug(
                                String.format("Metadata for layer '%s' with key '%s' not found.", layerName, key));
                    }
                    return null;
                } catch (Exception exception) {
                    throw Utils.exception(exception, "Error reading result set.");
                }
            }, "SELECT value FROM metadata WHERE layerName = ? AND key = ?;", layerName, key);
        } catch (Exception exception) {
            // probably because the metadata table doesn't exists
            if (LOGGER.isErrorEnabled()) {
                LOGGER.error(String.format("Error getting metadata from file '%s'.", metadataFile), exception);
            }
            return null;
        }
    }

    @Override
    public boolean layerExists(String layerName) {
        // a layer exists if there is at least one file associated to it
        return !fileManager.getFiles(layerName).isEmpty();
    }

    @Override
    public boolean delete(String layerName) throws StorageException {
        boolean deleted = deleteFiles(fileManager.getFiles(layerName));
        listeners.sendLayerDeleted(layerName);
        return deleted;
    }

    @Override
    public boolean deleteByGridsetId(String layerName, String gridSetId) throws StorageException {
        boolean deleted = deleteFiles(fileManager.getFiles(layerName, gridSetId));
        listeners.sendGridSubsetDeleted(layerName, gridSetId);
        return deleted;
    }

    @Override
    public boolean deleteByParametersId(String layerName, String parametersId) throws StorageException {
        boolean deleted = deleteFiles(fileManager.getParametersFiles(layerName, parametersId));
        listeners.sendParametersDeleted(layerName, parametersId);
        return deleted;
    }

    @Override
    public boolean delete(TileRange tileRange) throws StorageException {
        // getting the files associated with this tile range
        Map<File, List<long[]>> files = fileManager.getFiles(tileRange);
        if (files.isEmpty()) {
            // no files so nothing to do
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("Nothing to do.");
            }
            return false;
        }
        // let's delete the tiles
        CompletionService completionService = new ExecutorCompletionService(executorService);
        int tasks = 0;
        for (Map.Entry<File, List<long[]>> entry : files.entrySet()) {
            // FIXME: should we tell something to the listeners ?
            File file = entry.getKey();
            if (!file.exists()) {
                // this database file doesn't exists, so nothing to do
                continue;
            }
            if (eagerDelete) {
                // we delete the whole file avoiding fragmentation on the database
                completionService.submit(() -> connectionManager.delete(file), true);
            } else {
                // we need to delete all tiles that belong to the tiles range and are stored in the current file
                for (long[] range : entry.getValue()) {
                    if (LOGGER.isDebugEnabled()) {
                        LOGGER.debug(String.format(
                                "Deleting tiles range [minx=%d, miny=%d, maxx=%d, maxxy=%d, zoom=%d] in file '%s'.",
                                range[0], range[1], range[2], range[3], range[4], file));
                    }
                    completionService.submit(() -> connectionManager.executeSql(file,
                            "DELETE FROM tiles WHERE zoom_level = ? AND tile_column BETWEEN ? AND ? AND tile_row BETWEEN ? AND ?;",
                            range[4], range[0], range[2], range[1], range[3]), true);
                }
            }
            tasks++;
        }
        // let's wait for the tasks to finish
        for (int i = 0; i < tasks; i++) {
            try {
                completionService.take().get();
            } catch (Exception exception) {
                throw Utils.exception(exception, "Something bad happen when deleting tile range.");
            }
        }
        return true;
    }

    @Override
    public boolean rename(String oldLayerName, String newLayerName) throws StorageException {
        List<File> files = fileManager.getFiles(oldLayerName);
        if (files.isEmpty()) {
            return false;
        }
        for (File currentFile : files) {
            String normalizedLayerName = FileManager.normalizePathValue(newLayerName);
            File newFile = new File(currentFile.getPath().replace(oldLayerName, normalizedLayerName));
            connectionManager.rename(currentFile, newFile);
        }
        listeners.sendLayerRenamed(oldLayerName, newLayerName);
        return true;
    }

    @Override
    public void addListener(BlobStoreListener listener) {
        listeners.addListener(listener);
    }

    @Override
    public boolean removeListener(BlobStoreListener listener) {
        return listeners.removeListener(listener);
    }

    @Override
    public void clear() throws StorageException {
        connectionManager.reapAllConnections();
    }

    @Override
    public void destroy() {
        connectionManager.reapAllConnections();
        connectionManager.stopPoolReaper();
        executorService.shutdown();
        try {
            executorService.awaitTermination(5, TimeUnit.SECONDS);
        } catch (Exception exception) {
            if (LOGGER.isErrorEnabled()) {
                LOGGER.error("Error when waiting for executor task to finish.", exception);
            }
        }
    }

    /**
     * Helper method that delete the provided files.
     */
    private boolean deleteFiles(List<File> files) throws StorageException {
        if (files.isEmpty()) {
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info("No files to delete.");
            }
            return false;
        }
        // asking the connection manager to remove the database files
        CompletionService completionService = new ExecutorCompletionService(executorService);
        int tasks = 0;
        for (File file : files) {
            completionService.submit(() -> connectionManager.delete(file), true);
            tasks++;
        }
        // let's wait for the tasks to finish
        for (int i = 0; i < tasks; i++) {
            try {
                completionService.take().get();
            } catch (Exception exception) {
                throw Utils.exception(exception, "Something bad happen when deleting files.");
            }
        }
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("Files deleted.");
        }
        return true;
    }

    /**
     * Helper method that deletes the create time of a tile.
     */
    private void deleteTileCreateTime(Connection connection, long z, long x, long y) throws StorageException {
        try {
            connectionManager.executeSql(connection,
                    "DELETE FROM tiles_metadata " + "WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?", z,
                    x, y);
        } catch (Exception exception) {
            // probably the table doesn't exists
            if (LOGGER.isErrorEnabled()) {
                LOGGER.error(String.format("Something bad happen when deleting create time for tile '%d-%d-%d'.", x,
                        y, z), exception);
            }
        }
    }

    /**
     * Helper method that retrieves the create time of a tile.
     */
    private Long getTileCreateTime(File file, long z, long x, long y) throws StorageException {
        String query = "SELECT create_time FROM tiles_metadata WHERE zoom_level = ? "
                + "AND tile_column = ? AND tile_row = ?";
        try {
            return connectionManager.executeQuery(file, resultSet -> {
                if (resultSet.next()) {
                    return resultSet.getLong(1);
                }
                return null;
            }, query, z, x, y);
        } catch (Exception exception) {
            // probably the table doesn't exists
            if (LOGGER.isErrorEnabled()) {
                LOGGER.error(String.format("Something bad happen when querying create time for tile '%d-%d-%d'.", x,
                        y, z), exception);
            }
        }
        return null;
    }

    /**
     * Helper method that puts the create time of a tile opening a connection for it.
     */
    private void putTileCreateTime(File file, long z, long x, long y, long createTime) {
        connectionManager.doWork(file, false, connection -> {
            putTileCreateTime(connection, z, x, y, createTime);
        });
    }

    /**
     * Helper method that puts the create time of a tile using the provided connection.
     */
    private void putTileCreateTime(Connection connection, long z, long x, long y, long createTime) {
        createTilesMetadataTable(connection);
        connectionManager.executeSql(connection, "INSERT OR REPLACE INTO tiles_metadata VALUES (?, ?, ?, ?);", z, x,
                y, createTime);
    }

    private void createTilesMetadataTable(Connection connection) {
        connectionManager.executeSql(connection,
                "CREATE TABLE IF NOT EXISTS tiles_metadata (zoom_level integer, tile_column integer, "
                        + "tile_row integer, create_time integer, "
                        + "CONSTRAINT pk_tiles PRIMARY KEY(zoom_level, tile_column,tile_row));");
    }

    /**
     * Init database file if it doesn't exists.
     */
    void initDatabaseFileIfNeeded(File file, String layerName, String format) {
        if (file.exists()) {
            // database file exists
            return;
        }
        // initiating the database file
        connectionManager.doWork(file, false, (connection) -> {
            try {
                // creating mbtiles tables
                SqlUtil.runScript(getClass().getResourceAsStream("/org/geotools/mbtiles/mbtiles.sql"), connection);
                // create tiles metadata table for storing the create time if needed
                createTilesMetadataTable(connection);
                // insert mbtiles metadata for this layer
                insertMbtilesLayerMetadata(file, connection, layerName, format);
            } catch (Exception exception) {
                throw Utils.exception(exception, "Error running geotools mbtiles sql script.");
            }
        });
    }

    /**
     * Store the mbtiles metadata associated with a file.
     */
    private void insertMbtilesLayerMetadata(File file, Connection connection, String layerName, String format) {
        MBTilesMetadata gtMetadata = new MBTilesMetadata();
        gtMetadata.setName(layerName);
        // checking if we have a mbtiles supported format, otherwise we don't insert anything
        if (format.contains("png")) {
            gtMetadata.setFormat(MBTilesMetadata.t_format.PNG);
        } else if (format.contains("jpeg")) {
            gtMetadata.setFormat(MBTilesMetadata.t_format.JPEG);
        } else if (format.contains("protobuf")) {
            gtMetadata.setFormat(MBTilesMetadata.t_format.PBF);
        }
        MBTilesMetadata existingMetadata = layersMetadata.get(FileManager.normalizePathValue(layerName));
        if (existingMetadata != null) {
            // we have some user provided metadata let's use it
            gtMetadata.setName(layerName);
            gtMetadata.setAttribution(existingMetadata.getAttribution());
            gtMetadata.setBounds(existingMetadata.getBounds());
            gtMetadata.setDescription(existingMetadata.getDescription());
            gtMetadata.setMaxZoom(existingMetadata.getMaxZoom());
            gtMetadata.setMinZoom(existingMetadata.getMinZoom());
            gtMetadata.setType(existingMetadata.getType());
            gtMetadata.setVersion(existingMetadata.getVersion());
        }
        MBTilesFile mbtiles = GeoToolsMbtilesUtils.getMBTilesFile(connection, file);
        try {
            mbtiles.saveMetaData(gtMetadata);
        } catch (Exception exception) {
            throw Utils.exception(exception, "Error storing metadata on file '%s'.", file);
        }
    }

    /**
     * Reads user provided mbtiles metadata for a layer.
     */
    private void initMbtilesLayersMetadata(String mbtilesMetadataDirectoryPath) {
        if (mbtilesMetadataDirectoryPath == null) {
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info("Mbtiles metadata directory path is NULL, no mbtiles metadata will be parsed.");
            }
            return;
        }
        File mbtilesMetadataDirectory = new File(mbtilesMetadataDirectoryPath);
        if (!mbtilesMetadataDirectory.exists()) {
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info(String.format(
                        "Mbtiles metadata directory '%s' doesn't exists, no mbtiles metadata will be parsed.",
                        mbtilesMetadataDirectoryPath));
            }
            return;
        }
        File[] files = mbtilesMetadataDirectory.listFiles();
        if (files == null) {
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info(String.format(
                        "No files present in mbtiles metadata directory '%s', no mbtiles metadata will be parsed.",
                        mbtilesMetadataDirectoryPath));
            }
            return;
        }
        for (File file : files) {
            Matcher matcher = MBTILES_METADATA_FILE_NAME_PATTERN.matcher(file.getName());
            if (matcher.matches()) {
                // extracting the layer name
                String layerName = matcher.group(1);
                // parsing mbtiles metadata properties
                Properties metadata = new Properties();
                try (InputStream input = new FileInputStream(file)) {
                    metadata.load(input);
                } catch (Exception exception) {
                    throw Utils.exception(exception, "Error reading mbtiles metadata file '%s'.", file);
                }
                // creating geotools mbtiles metadata file
                MBTilesMetadata gtMetadata = new MBTilesMetadata();
                gtMetadata.setAttribution(metadata.getProperty("attribution"));
                gtMetadata.setBoundsStr(metadata.getProperty("bounds"));
                gtMetadata.setDescription(metadata.getProperty("description"));
                gtMetadata.setMaxZoomStr(metadata.getProperty("maxZoom"));
                gtMetadata.setMinZoomStr(metadata.getProperty("minZoom"));
                gtMetadata.setTypeStr(metadata.getProperty("type"));
                gtMetadata.setVersion(metadata.getProperty("version"));
                // index the parsed mbtiles metadata
                layersMetadata.put(layerName, gtMetadata);
                if (LOGGER.isInfoEnabled()) {
                    LOGGER.info(String.format("Parsed mbtiles metadata for layer '%s'.", layerName));
                }
            }
        }
    }

    public Map<String, Optional<Map<String, String>>> getParametersMapping(String layerName) {
        try {
            return (Map<String, Optional<Map<String, String>>>) connectionManager.executeQuery(metadataFile,
                    resultSet -> {
                        try {
                            Map<String, Optional<Map<String, String>>> result = new HashMap<>();
                            while (resultSet.next()) {
                                Map<String, String> params = ParametersUtils.getMap(resultSet.getString(1));
                                result.put(ParametersUtils.getId(params), Optional.of(params));
                            }
                            return result;
                        } catch (Exception exception) {
                            throw Utils.exception(exception, "Error reading result set.");
                        }
                    }, "SELECT value FROM metadata WHERE layerName = ? AND key like 'parameters.%';", layerName);
        } catch (Exception exception) {
            // probably because the metadata table doesn't exists
            if (LOGGER.isErrorEnabled()) {
                LOGGER.error(String.format("Error getting metadata from file '%s'.", metadataFile), exception);
            }
            return Collections.emptyMap();
        }
    }

    protected void persistParameterMap(TileObject stObj) {
        if (Objects.nonNull(stObj.getParametersId())) {
            putLayerMetadata(stObj.getLayerName(), "parameters." + stObj.getParametersId(),
                    ParametersUtils.getKvp(stObj.getParameters()));
        }
    }

}