use std::{fs::remove_file, path::Path, sync::Mutex};
use anyhow::{Result, bail};
use r2d2::Pool;
use r2d2_sqlite::{SqliteConnectionManager, rusqlite::params};
use versatiles_core::{Blob, TileCompression, TileCoord, TileFormat, TileJSON, json::JsonObject};
use versatiles_derive::context;
use crate::TileSink;
const BUFFER_SIZE: usize = 4096;
pub struct MBTilesTileSink {
pool: Pool<SqliteConnectionManager>,
format_str: String,
buffer: Mutex<Vec<(TileCoord, Vec<u8>)>>,
}
impl MBTilesTileSink {
pub fn open(
destination: &str,
tile_format: TileFormat,
tile_compression: TileCompression,
_runtime: &crate::TilesRuntime,
) -> Result<Box<dyn TileSink>> {
if destination.starts_with("sftp://") {
bail!("MBTiles does not support SFTP output (SQLite requires local filesystem)");
}
let path = std::env::current_dir()?.join(destination);
Ok(Box::new(Self::new(&path, tile_format, tile_compression)?))
}
#[context("creating MBTilesTileSink for '{}'", path.display())]
fn new(path: &Path, tile_format: TileFormat, tile_compression: TileCompression) -> Result<Self> {
let (format_str, required) = super::required_encoding(tile_format)?;
if tile_compression != required {
bail!("MBTiles stores {tile_format} tiles {required}, but this sink was given {tile_compression}");
}
if path.exists() {
remove_file(path)?;
}
let manager = SqliteConnectionManager::file(path);
let pool = Pool::builder().max_size(10).build(manager)?;
pool.get()?.execute_batch(
"CREATE TABLE metadata (name TEXT, value TEXT, UNIQUE (name));
CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB, UNIQUE (zoom_level, tile_column, tile_row));
CREATE UNIQUE INDEX tile_index on tiles (zoom_level, tile_column, tile_row);",
)?;
Ok(Self {
pool,
format_str: format_str.to_string(),
buffer: Mutex::new(Vec::with_capacity(BUFFER_SIZE)),
})
}
fn flush_buffer(&self, tiles: &[(TileCoord, Vec<u8>)]) -> Result<()> {
if tiles.is_empty() {
return Ok(());
}
let mut conn = self.pool.get()?;
let transaction = conn.transaction()?;
for (c, blob) in tiles {
let max_index = 2u32.pow(u32::from(c.level)) - 1;
transaction.execute(
"INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?1, ?2, ?3, ?4)",
params![c.level, c.x, max_index - c.y, blob.as_slice()],
)?;
}
transaction.commit()?;
Ok(())
}
fn set_metadata(&self, name: &str, value: &str) -> Result<()> {
self.pool.get()?.execute(
"INSERT OR REPLACE INTO metadata (name, value) VALUES (?1, ?2)",
params![name, value],
)?;
Ok(())
}
fn write_tilejson_metadata(&self, tilejson: &TileJSON) -> Result<()> {
self.set_metadata("format", &self.format_str)?;
self.set_metadata("type", "baselayer")?;
self.set_metadata("version", "3.0")?;
if let Some(bbox) = tilejson.bounds {
self.set_metadata(
"bounds",
&format!("{},{},{},{}", bbox.x_min, bbox.y_min, bbox.x_max, bbox.y_max),
)?;
}
if let Some(center) = tilejson.center {
self.set_metadata("center", &format!("{},{},{}", center.0, center.1, center.2))?;
}
if let Some(z) = tilejson.zoom_min() {
self.set_metadata("minzoom", &z.to_string())?;
}
if let Some(z) = tilejson.zoom_max() {
self.set_metadata("maxzoom", &z.to_string())?;
}
if let Some(vector_layers) = tilejson.as_object().get("vector_layers") {
self.set_metadata(
"json",
&JsonObject::from(vec![("vector_layers", vector_layers)]).stringify(),
)?;
}
for key in ["name", "author", "type", "description", "version", "license"] {
if let Some(value) = tilejson.str(key) {
self.set_metadata(key, value)?;
}
}
Ok(())
}
}
impl TileSink for MBTilesTileSink {
fn write_tile(&self, coord: &TileCoord, blob: &Blob) -> Result<()> {
let mut buf = self.buffer.lock().expect("poisoned mutex");
buf.push((*coord, blob.as_slice().to_vec()));
if buf.len() >= BUFFER_SIZE {
let tiles: Vec<_> = buf.drain(..).collect();
drop(buf);
self.flush_buffer(&tiles)?;
}
Ok(())
}
fn finish(self: Box<Self>, tilejson: &TileJSON, _runtime: &crate::TilesRuntime) -> Result<()> {
let remaining: Vec<_> = self.buffer.lock().expect("poisoned mutex").drain(..).collect();
self.flush_buffer(&remaining)?;
self.write_tilejson_metadata(tilejson)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{MBTilesReader, TileSource, TilesRuntime};
#[tokio::test]
async fn write_and_read_back() -> Result<()> {
let temp = assert_fs::NamedTempFile::new("test_sink.mbtiles")?;
let runtime = TilesRuntime::default();
let sink = MBTilesTileSink::open(
temp.to_str().unwrap(),
TileFormat::PNG,
TileCompression::Uncompressed,
&runtime,
)?;
let coord = TileCoord::new(3, 1, 2)?;
let blob = Blob::from(vec![0u8; 16]);
sink.write_tile(&coord, &blob)?;
let mut tilejson = TileJSON::default();
tilejson.set_string("tilejson", "3.0.0")?;
tilejson.set_zoom_min(3);
tilejson.set_zoom_max(3);
Box::new(sink).finish(&tilejson, &crate::TilesRuntime::default())?;
let reader = MBTilesReader::open(&temp, TilesRuntime::default())?;
assert_eq!(reader.metadata().tile_format(), &TileFormat::PNG);
assert_eq!(reader.tile_pyramid().await?.count_tiles(), 1);
Ok(())
}
#[test]
fn rejects_unsupported_format() {
let temp = assert_fs::NamedTempFile::new("test_sink_bad.mbtiles").unwrap();
let runtime = TilesRuntime::default();
let result = MBTilesTileSink::open(
temp.to_str().unwrap(),
TileFormat::PNG,
TileCompression::Brotli,
&runtime,
);
assert!(result.is_err());
}
#[tokio::test]
async fn write_multiple_and_read_back() -> Result<()> {
let temp = assert_fs::NamedTempFile::new("test_sink_multi.mbtiles")?;
let runtime = TilesRuntime::default();
let sink = MBTilesTileSink::open(
temp.to_str().unwrap(),
TileFormat::WEBP,
TileCompression::Uncompressed,
&runtime,
)?;
for y in 0..4 {
for x in 0..4 {
let coord = TileCoord::new(2, x, y)?;
#[allow(clippy::cast_possible_truncation)]
let blob = Blob::from(vec![x as u8; 8]);
sink.write_tile(&coord, &blob)?;
}
}
let mut tilejson = TileJSON::default();
tilejson.set_string("tilejson", "3.0.0")?;
tilejson.set_zoom_min(2);
tilejson.set_zoom_max(2);
Box::new(sink).finish(&tilejson, &crate::TilesRuntime::default())?;
let reader = MBTilesReader::open(&temp, TilesRuntime::default())?;
assert_eq!(reader.metadata().tile_format(), &TileFormat::WEBP);
assert_eq!(reader.tile_pyramid().await?.count_tiles(), 16);
let tile = reader.tile(&TileCoord::new(2, 1, 1)?).await?;
assert!(tile.is_some());
let blob = tile.unwrap().into_blob(&TileCompression::Uncompressed)?;
assert_eq!(blob.as_slice(), &[1u8; 8]);
Ok(())
}
}