mbtiles 0.17.5

A simple low-level MbTiles access and processing library, with some tile format detection and other relevant heuristics.
Documentation
#![expect(
    clippy::cast_possible_truncation,
    clippy::cast_precision_loss,
    clippy::cast_sign_loss
)]

use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use std::str::FromStr as _;

use martin_tile_utils::{get_zoom_precision, xyz_to_bbox};
use serde::Serialize;
use size_format::SizeFormatterSI;
use sqlx::{SqliteExecutor, query};
use tilejson::Bounds;

use crate::{MbtResult, MbtType, Mbtiles, invert_y_value};

#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ZoomInfo {
    pub zoom: u8,
    pub tile_count: u64,
    pub min_tile_size: u64,
    pub max_tile_size: u64,
    pub avg_tile_size: f64,
    pub bbox: Bounds,
}

#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Summary {
    pub file_path: String,
    pub file_size: Option<u64>,
    pub mbt_type: MbtType,
    pub page_size: u64,
    pub page_count: u64,
    pub tile_count: u64,
    pub min_tile_size: Option<u64>,
    pub max_tile_size: Option<u64>,
    pub avg_tile_size: f64,
    pub bbox: Option<Bounds>,
    pub min_zoom: Option<u8>,
    pub max_zoom: Option<u8>,
    pub zoom_info: Vec<ZoomInfo>,
}

impl Display for Summary {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        writeln!(f, "{:15} {}", "File path:", self.file_path)?;
        writeln!(f, "{:15} {}", "Schema:", self.mbt_type)?;

        if let Some(file_size) = self.file_size {
            let file_size = SizeFormatterSI::new(file_size);
            writeln!(f, "{:15} {file_size:.2}B", "File size:")?;
        } else {
            writeln!(f, "{:15} unknown", "File size:")?;
        }
        let page_size = SizeFormatterSI::new(self.page_size);
        writeln!(f, "{:15} {page_size:.2}B", "SQL page size:")?;
        writeln!(f, "{:15} {:.2}", "SQL page count:", self.page_count)?;
        writeln!(f)?;
        if self.zoom_info.is_empty() {
            writeln!(f, "   There are no tiles in this mbtiles file")?;
        } else {
            writeln!(
                f,
                " {:^4} | {:^9} | {:^9} | {:^9} | {:^9} | Bounding Box",
                "Zoom", "Count", "Smallest", "Largest", "Average"
            )?;
        }

        for l in &self.zoom_info {
            let min = SizeFormatterSI::new(l.min_tile_size);
            let max = SizeFormatterSI::new(l.max_tile_size);
            let avg = SizeFormatterSI::new(l.avg_tile_size as u64);
            let prec = get_zoom_precision(l.zoom);

            writeln!(
                f,
                " {:>4} | {:>9} | {:>9} | {:>9} | {:>9} | {:.prec$}",
                l.zoom,
                l.tile_count,
                format!("{min:.1}B"),
                format!("{max:.1}B"),
                format!("{avg:.1}B"),
                l.bbox,
            )?;
        }

        if self.zoom_info.len() > 1
            && let (Some(min), Some(max), Some(bbox), Some(max_zoom)) = (
                self.min_tile_size,
                self.max_tile_size,
                self.bbox,
                self.max_zoom,
            )
        {
            let min = SizeFormatterSI::new(min);
            let max = SizeFormatterSI::new(max);
            let avg = SizeFormatterSI::new(self.avg_tile_size as u64);
            let prec = get_zoom_precision(max_zoom);
            writeln!(
                f,
                " {:>4} | {:>9} | {:>9} | {:>9} | {:>9} | {bbox:.prec$}",
                "all",
                self.tile_count,
                format!("{min}B"),
                format!("{max}B"),
                format!("{avg}B"),
            )?;
        }

        Ok(())
    }
}

impl Mbtiles {
    /// Compute `MBTiles` file summary
    #[hotpath::measure]
    pub async fn summary<T>(&self, conn: &mut T) -> MbtResult<Summary>
    where
        for<'e> &'e mut T: SqliteExecutor<'e>,
    {
        let mbt_type = self.detect_type(&mut *conn).await?;
        let file_size = PathBuf::from_str(self.filepath())
            .ok()
            .and_then(|p| p.metadata().ok())
            .map(|m| m.len());

        let sql = query!("PRAGMA page_size;");
        let page_size = sql
            .fetch_one(&mut *conn)
            .await?
            .page_size
            .expect("page_size is not null") as u64;

        let sql = query!("PRAGMA page_count;");
        let page_count = sql
            .fetch_one(&mut *conn)
            .await?
            .page_count
            .expect("page_count is not null") as u64;

        let zoom_info = query!(
            "
    SELECT zoom_level             AS zoom,
           count()                AS count,
           min(length(tile_data)) AS smallest,
           max(length(tile_data)) AS largest,
           avg(length(tile_data)) AS average,
           min(tile_column)       AS min_tile_x,
           min(tile_row)          AS min_tile_y,
           max(tile_column)       AS max_tile_x,
           max(tile_row)          AS max_tile_y
    FROM tiles
    GROUP BY zoom_level"
        )
        .fetch_all(&mut *conn)
        .await?;

        let zoom_info: Vec<ZoomInfo> = zoom_info
            .into_iter()
            .map(|r| {
                let zoom = u8::try_from(r.zoom.expect("zoom_level should not be NULL"))
                    .expect("zoom_level should fit in a u8");
                ZoomInfo {
                    zoom,
                    tile_count: r.count as u64,
                    min_tile_size: r.smallest.unwrap_or(0) as u64,
                    max_tile_size: r.largest.unwrap_or(0) as u64,
                    avg_tile_size: r.average.unwrap_or(0.0),
                    bbox: xyz_to_bbox(
                        zoom,
                        r.min_tile_x.expect("min_tile_x should not be None") as u32,
                        invert_y_value(
                            zoom,
                            r.max_tile_y.expect("max_tile_y should not be None") as u32,
                        ),
                        r.max_tile_x.expect("max_tile_x should not be None") as u32,
                        invert_y_value(
                            zoom,
                            r.min_tile_y.expect("min_tile_y should not be None") as u32,
                        ),
                    )
                    .into(),
                }
            })
            .collect();

        let tile_count = zoom_info.iter().map(|l| l.tile_count).sum();
        let avg_sum = zoom_info
            .iter()
            .map(|l| l.avg_tile_size * l.tile_count as f64)
            .sum::<f64>();

        Ok(Summary {
            file_path: self.filepath().to_string(),
            file_size,
            mbt_type,
            page_size,
            page_count,
            tile_count,
            min_tile_size: zoom_info.iter().map(|l| l.min_tile_size).reduce(u64::min),
            max_tile_size: zoom_info.iter().map(|l| l.max_tile_size).reduce(u64::max),
            avg_tile_size: avg_sum / tile_count as f64,
            bbox: zoom_info.iter().map(|l| l.bbox).reduce(|a, b| a + b),
            min_zoom: zoom_info.iter().map(|l| l.zoom).reduce(u8::min),
            max_zoom: zoom_info.iter().map(|l| l.zoom).reduce(u8::max),
            zoom_info,
        })
    }
}

#[cfg(test)]
mod tests {
    use insta::assert_yaml_snapshot;

    use crate::metadata::anonymous_mbtiles;
    use crate::{MbtType, Mbtiles, init_mbtiles_schema};

    #[actix_rt::test]
    async fn summary_empty_file() {
        let mbt = Mbtiles::new(":memory:").unwrap();
        let mut conn = mbt.open().await.unwrap();

        init_mbtiles_schema(&mut conn, MbtType::Flat, false)
            .await
            .unwrap();
        let res = mbt.summary(&mut conn).await.unwrap();
        assert_yaml_snapshot!(res, @r#"
        file_path: ":memory:"
        file_size: ~
        mbt_type: Flat
        page_size: 512
        page_count: 6
        tile_count: 0
        min_tile_size: ~
        max_tile_size: ~
        avg_tile_size: NaN
        bbox: ~
        min_zoom: ~
        max_zoom: ~
        zoom_info: []
        "#);
    }

    #[actix_rt::test]
    async fn summary() {
        let script = include_str!("../../tests/fixtures/mbtiles/world_cities.sql");
        let (mbt, mut conn) = anonymous_mbtiles(script).await;
        let res = mbt.summary(&mut conn).await.unwrap();

        assert_yaml_snapshot!(res, @r#"
        file_path: ":memory:"
        file_size: ~
        mbt_type: Flat
        page_size: 4096
        page_count: 5
        tile_count: 8
        min_tile_size: 20
        max_tile_size: 1107
        avg_tile_size: 202.625
        bbox:
          - -180
          - -85.0511287798066
          - 180.00000000000003
          - 85.0511287798066
        min_zoom: 0
        max_zoom: 6
        zoom_info:
          - zoom: 0
            tile_count: 1
            min_tile_size: 1107
            max_tile_size: 1107
            avg_tile_size: 1107
            bbox:
              - -180
              - -85.0511287798066
              - 180
              - 85.0511287798066
          - zoom: 1
            tile_count: 1
            min_tile_size: 20
            max_tile_size: 20
            avg_tile_size: 20
            bbox:
              - -180
              - -85.0511287798066
              - 0
              - 0
          - zoom: 2
            tile_count: 2
            min_tile_size: 151
            max_tile_size: 263
            avg_tile_size: 207
            bbox:
              - 90.00000000000001
              - -66.51326044311186
              - 180.00000000000003
              - 66.51326044311186
          - zoom: 3
            tile_count: 1
            min_tile_size: 20
            max_tile_size: 20
            avg_tile_size: 20
            bbox:
              - 134.99999999999997
              - -40.97989806962013
              - 179.99999999999997
              - 0
          - zoom: 4
            tile_count: 1
            min_tile_size: 20
            max_tile_size: 20
            avg_tile_size: 20
            bbox:
              - -22.500000000000014
              - 0.000000000000012549319548339412
              - -0.000000000000012549319548339412
              - 21.943045533438188
          - zoom: 5
            tile_count: 1
            min_tile_size: 20
            max_tile_size: 20
            avg_tile_size: 20
            bbox:
              - 0
              - 40.97989806962013
              - 11.25
              - 48.92249926375824
          - zoom: 6
            tile_count: 1
            min_tile_size: 20
            max_tile_size: 20
            avg_tile_size: 20
            bbox:
              - 73.12500000000001
              - 27.059125784374054
              - 78.75000000000001
              - 31.952162238024968
        "#);
    }
}