Skip to main content

mbtiles/
metadata.rs

1use std::fmt::Display;
2use std::path::PathBuf;
3use std::str::FromStr as _;
4
5use futures::TryStreamExt as _;
6use log::{info, warn};
7use serde::Serialize;
8use serde_json::{Value as JSONValue, Value, json};
9use sqlx::{SqliteConnection, SqliteExecutor, query};
10use tilejson::{Bounds, Center, TileJSON, tilejson};
11
12use crate::MbtError::InvalidZoomValue;
13use crate::Mbtiles;
14use crate::errors::MbtResult;
15
16/// Tileset metadata combining [MBTiles](https://github.com/mapbox/mbtiles-spec)
17/// and [TileJSON](https://github.com/mapbox/tilejson-spec) specifications.
18///
19/// Returned by [`Mbtiles::get_metadata`] and [`crate::MbtilesPool::get_metadata`].
20///
21/// # Example
22///
23/// ```no_run
24/// use mbtiles::MbtilesPool;
25///
26/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
27/// let pool = MbtilesPool::open_readonly("world.mbtiles").await?;
28/// let metadata = pool.get_metadata().await?;
29/// let tile_info = pool.detect_format(&metadata.tilejson).await?;
30///
31/// println!("Name: {}", metadata.tilejson.name.unwrap_or_default());
32/// println!("Zoom: {:?}-{:?}", metadata.tilejson.minzoom, metadata.tilejson.maxzoom);
33///
34/// // To get tile format, use detect_format separately:
35/// if let Some(tile_info) = tile_info {
36///     println!("Format: {}", tile_info.format);
37/// }
38/// # Ok(())
39/// # }
40/// ```
41#[serde_with::skip_serializing_none]
42#[derive(Clone, Debug, PartialEq, Serialize)]
43pub struct Metadata {
44    /// Tileset identifier, typically the filename without extension.
45    pub id: String,
46
47    /// Layer type: `"overlay"` or `"baselayer"` (MBTiles-specific field).
48    // todo: change this to an enum
49    pub layer_type: Option<String>,
50
51    /// Core [TileJSON](https://github.com/mapbox/tilejson-spec) metadata.
52    ///
53    /// Includes name, bounds, zoom levels, attribution, vector layers, etc.
54    pub tilejson: TileJSON,
55
56    /// Custom JSON metadata for application-specific data.
57    pub json: Option<JSONValue>,
58
59    /// Aggregate hash of all tiles for validation and change detection.
60    ///
61    /// See [`Mbtiles::validate`] and [`Mbtiles::update_agg_tiles_hash`].
62    pub agg_tiles_hash: Option<String>,
63}
64
65impl Mbtiles {
66    fn to_val<V, E: Display>(&self, val: Result<V, E>, title: &str) -> Option<V> {
67        match val {
68            Ok(v) => Some(v),
69            Err(err) => {
70                let name = &self.filename();
71                warn!("Unable to parse metadata {title} value in {name}: {err}");
72                None
73            }
74        }
75    }
76
77    /// Get a single metadata value from the metadata table
78    pub async fn get_metadata_value<T>(&self, conn: &mut T, key: &str) -> MbtResult<Option<String>>
79    where
80        for<'e> &'e mut T: SqliteExecutor<'e>,
81    {
82        let query = query!("SELECT value from metadata where name = ?", key);
83        let row = query.fetch_optional(conn).await?;
84        if let Some(row) = row
85            && let Some(value) = row.value
86        {
87            return Ok(Some(value));
88        }
89        Ok(None)
90    }
91
92    pub async fn get_metadata_zoom_value<T>(
93        &self,
94        conn: &mut T,
95        zoom_name: &'static str,
96    ) -> MbtResult<Option<u8>>
97    where
98        for<'e> &'e mut T: SqliteExecutor<'e>,
99    {
100        self.get_metadata_value(conn, zoom_name)
101            .await?
102            .map(|v| v.parse().map_err(|_| InvalidZoomValue(zoom_name, v)))
103            .transpose()
104    }
105
106    pub async fn set_metadata_value<T, S>(&self, conn: &mut T, key: &str, value: S) -> MbtResult<()>
107    where
108        S: ToString,
109        for<'e> &'e mut T: SqliteExecutor<'e>,
110    {
111        let value = value.to_string();
112        query!(
113            "INSERT OR REPLACE INTO metadata(name, value) VALUES(?, ?)",
114            key,
115            value
116        )
117        .execute(conn)
118        .await?;
119        Ok(())
120    }
121
122    pub async fn delete_metadata_value<T>(&self, conn: &mut T, key: &str) -> MbtResult<()>
123    where
124        for<'e> &'e mut T: SqliteExecutor<'e>,
125    {
126        query!("DELETE FROM metadata WHERE name=?", key)
127            .execute(conn)
128            .await?;
129        Ok(())
130    }
131
132    /// Retrieves all metadata from the `MBTiles` file.
133    ///
134    /// Reads the entire metadata table and constructs a [`Metadata`] struct
135    /// containing all tileset information. This includes:
136    /// - All `TileJSON` fields (bounds, zoom levels, attribution, etc.)
137    /// - Vector layer definitions (for MVT tiles)
138    /// - Custom JSON metadata
139    /// - Aggregate tiles hash (if present)
140    ///
141    /// # Format Detection
142    ///
143    /// To detect tile format and encoding, use [`detect_format`](Mbtiles::detect_format)
144    /// separately after getting metadata.
145    ///
146    /// # Examples
147    ///
148    /// ```no_run
149    /// use mbtiles::Mbtiles;
150    ///
151    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
152    /// let mbt = Mbtiles::new("world.mbtiles")?;
153    /// let mut conn = mbt.open_readonly().await?;
154    ///
155    /// let meta = mbt.get_metadata(&mut conn).await?;
156    /// let tile_info = mbt.detect_format(&meta.tilejson, &mut conn).await?;
157    ///
158    /// // Access various metadata fields
159    /// println!("Name: {}", meta.tilejson.name.unwrap_or_default());
160    /// println!("Bounds: {:?}", meta.tilejson.bounds);
161    /// println!("Zoom: {:?}-{:?}", meta.tilejson.minzoom, meta.tilejson.maxzoom);
162    ///
163    /// // Detect tile format separately
164    /// if let Some(tile_info) = tile_info {
165    ///     println!("Format: {}", tile_info.format);
166    /// } else {
167    ///     println!("Format: Unknown (empty mbtiles)")
168    /// }
169    ///
170    /// if let Some(layers) = &meta.tilejson.vector_layers {
171    ///     println!("Vector layers: {}", layers.len());
172    /// }
173    /// # Ok(())
174    /// # }
175    /// ```
176    pub async fn get_metadata<T>(&self, conn: &mut T) -> MbtResult<Metadata>
177    where
178        for<'e> &'e mut T: SqliteExecutor<'e>,
179    {
180        let query = query!("SELECT name, value FROM metadata WHERE value IS NOT ''");
181        let mut rows = query.fetch(&mut *conn);
182
183        let mut tj = tilejson! { tiles: vec![] };
184        let mut layer_type: Option<String> = None;
185        let mut json: Option<JSONValue> = None;
186        let mut agg_tiles_hash: Option<String> = None;
187
188        while let Some(row) = rows.try_next().await? {
189            if let (Some(name), Some(value)) = (row.name, row.value) {
190                match name.as_ref() {
191                    // This list should loosely match the `insert_metadata` function below
192                    "name" => tj.name = Some(value),
193                    "version" => tj.version = Some(value),
194                    "bounds" => tj.bounds = self.to_val(Bounds::from_str(value.as_str()), &name),
195                    "center" => tj.center = self.to_val(Center::from_str(value.as_str()), &name),
196                    "minzoom" => tj.minzoom = self.to_val(value.parse(), &name),
197                    "maxzoom" => tj.maxzoom = self.to_val(value.parse(), &name),
198                    "description" => tj.description = Some(value),
199                    "attribution" => tj.attribution = Some(value),
200                    "type" => layer_type = Some(value),
201                    "legend" => tj.legend = Some(value),
202                    "template" => tj.template = Some(value),
203                    "json" => json = self.to_val(serde_json::from_str(&value), &name),
204                    "format" | "generator" => {
205                        tj.other.insert(name, Value::String(value));
206                    }
207                    "agg_tiles_hash" => agg_tiles_hash = Some(value),
208                    "scheme" => {
209                        if value != "tms" {
210                            let file = &self.filename();
211                            warn!(
212                                "File {file} has an unexpected metadata value {name}='{value}'. Only 'tms' is supported. Ignoring."
213                            );
214                        }
215                    }
216                    _ => {
217                        let file = &self.filename();
218                        info!("{file} has an unrecognized metadata value {name}={value}");
219                        tj.other.insert(name, Value::String(value));
220                    }
221                }
222            }
223        }
224
225        if let Some(JSONValue::Object(obj)) = &mut json {
226            if let Some(value) = obj.remove("vector_layers") {
227                if let Ok(v) = serde_json::from_value(value) {
228                    tj.vector_layers = Some(v);
229                } else {
230                    warn!(
231                        "Unable to parse metadata vector_layers value in {}",
232                        self.filename()
233                    );
234                }
235            }
236            if obj.is_empty() {
237                json = None;
238            }
239        }
240
241        // Need to drop rows in order to re-borrow connection reference as mutable
242        drop(rows);
243
244        Ok(Metadata {
245            id: self.filename().to_string(),
246            tilejson: tj,
247            layer_type,
248            json,
249            agg_tiles_hash,
250        })
251    }
252
253    /// Inserts `TileJSON` metadata into the `MBTiles` metadata table.
254    ///
255    /// Writes all fields from a [`TileJSON`] struct to the metadata table,
256    /// converting them to the `MBTiles` key-value format. This includes:
257    /// - Standard fields: name, description, version, attribution, legend, template
258    /// - Geographic fields: bounds, center
259    /// - Zoom fields: minzoom, maxzoom
260    /// - Vector tile fields: `vector_layers` (stored in `json` metadata key)
261    /// - Custom fields: any values in `TileJSON.other`
262    ///
263    /// > [!NOTE]
264    /// > - Existing metadata values are replaced (INSERT OR REPLACE)
265    /// > - `None` values are skipped (not inserted)
266    /// > - Vector layers are serialized to JSON and stored in the `json` metadata key
267    ///
268    /// # Examples
269    ///
270    /// ```no_run
271    /// use mbtiles::Mbtiles;
272    /// use tilejson::tilejson;
273    ///
274    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
275    /// let mbt = Mbtiles::new("output.mbtiles")?;
276    /// let mut conn = mbt.open().await?;
277    ///
278    /// // Create TileJSON metadata
279    /// let tj = tilejson! { tiles: vec![] };
280    ///
281    /// // Insert all metadata
282    /// mbt.insert_metadata(&mut conn, &tj).await?;
283    /// # Ok(())
284    /// # }
285    /// ```
286    pub async fn insert_metadata<T>(&self, conn: &mut T, tile_json: &TileJSON) -> MbtResult<()>
287    where
288        for<'e> &'e mut T: SqliteExecutor<'e>,
289    {
290        for (key, value) in &tile_json.other {
291            if let Some(value) = value.as_str() {
292                self.set_metadata_value(conn, key, value).await?;
293            } else {
294                self.set_metadata_value(conn, key, &serde_json::to_string(value)?)
295                    .await?;
296            }
297        }
298        for (key, value) in &[
299            ("name", tile_json.name.as_deref()),
300            ("version", tile_json.version.as_deref()),
301            ("description", tile_json.description.as_deref()),
302            ("attribution", tile_json.attribution.as_deref()),
303            ("legend", tile_json.legend.as_deref()),
304            ("template", tile_json.template.as_deref()),
305        ] {
306            if let Some(value) = value {
307                self.set_metadata_value(conn, key, value).await?;
308            }
309        }
310        if let Some(bounds) = &tile_json.bounds {
311            self.set_metadata_value(conn, "bounds", bounds).await?;
312        }
313        if let Some(center) = &tile_json.center {
314            self.set_metadata_value(conn, "center", center).await?;
315        }
316        if let Some(minzoom) = &tile_json.minzoom {
317            self.set_metadata_value(conn, "minzoom", minzoom).await?;
318        }
319        if let Some(maxzoom) = &tile_json.maxzoom {
320            self.set_metadata_value(conn, "maxzoom", maxzoom).await?;
321        }
322        if let Some(vector_layers) = &tile_json.vector_layers {
323            self.set_metadata_value(
324                conn,
325                "json",
326                &serde_json::to_string(&json!({ "vector_layers": vector_layers }))?,
327            )
328            .await?;
329        }
330
331        Ok(())
332    }
333}
334
335/// Create an in memory, temporary mbtile connection with the given `script`
336pub async fn anonymous_mbtiles(script: &str) -> (Mbtiles, SqliteConnection) {
337    let mbt = Mbtiles::new(":memory:").expect("in-memory mbtiles can be created");
338    let mut conn = mbt.open().await.expect("in-memory mbtiles can be opened");
339    sqlx::raw_sql(script)
340        .execute(&mut conn)
341        .await
342        .expect("script execution succeeded");
343    (mbt, conn)
344}
345
346/// Create a named, in memory, temporary mbtile connection with the given `script`
347#[expect(
348    clippy::panic,
349    reason = "only useful for testing and the debug messages are better with panic"
350)]
351pub async fn temp_named_mbtiles(
352    file_name: &str,
353    script: &str,
354) -> (Mbtiles, SqliteConnection, PathBuf) {
355    let file = PathBuf::from(format!("file:{file_name}?mode=memory&cache=shared"));
356    let mbt =
357        Mbtiles::new(&file).unwrap_or_else(|_| panic!("can create pool for {}", file.display()));
358    let mut conn = mbt
359        .open()
360        .await
361        .unwrap_or_else(|_| panic!("can open connection to {}", file.display()));
362    sqlx::raw_sql(script)
363        .execute(&mut conn)
364        .await
365        .unwrap_or_else(|_| panic!("can execute script on {}", file.display()));
366    (mbt, conn, file)
367}
368
369#[cfg(test)]
370mod tests {
371    use std::collections::BTreeMap;
372
373    use martin_tile_utils::{Encoding, Format, TileInfo};
374    use sqlx::Executor as _;
375    use tilejson::VectorLayer;
376
377    use super::*;
378    use crate::mbtiles::tests::open;
379    use crate::{MbtType, init_mbtiles_schema};
380
381    #[actix_rt::test]
382    async fn mbtiles_meta() {
383        let script = include_str!("../../tests/fixtures/mbtiles/geography-class-jpg.sql");
384        let (mbt, _) = anonymous_mbtiles(script).await;
385        assert_eq!(mbt.filepath(), ":memory:");
386        assert_eq!(mbt.filename(), ":memory:");
387    }
388
389    #[actix_rt::test]
390    async fn metadata_jpeg() {
391        let script = include_str!("../../tests/fixtures/mbtiles/geography-class-jpg.sql");
392        let (mbt, mut conn) = anonymous_mbtiles(script).await;
393        let meta = mbt.get_metadata(&mut conn).await.unwrap();
394
395        let tile_info = mbt.detect_format(&meta.tilejson, &mut conn).await.unwrap();
396        assert_eq!(tile_info, Some(Format::Jpeg.into()));
397
398        let tj = meta.tilejson;
399        assert_eq!(
400            tj.description.unwrap(),
401            "One of the example maps that comes with TileMill - a bright & colorful world map that blends retro and high-tech with its folded paper texture and interactive flag tooltips. "
402        );
403        assert!(tj.legend.unwrap().starts_with("<div style="));
404        assert_eq!(tj.maxzoom.unwrap(), 1);
405        assert_eq!(tj.minzoom.unwrap(), 0);
406        assert_eq!(tj.name.unwrap(), "Geography Class");
407        assert_eq!(tj.template.unwrap(), "foobar");
408        assert_eq!(tj.version.unwrap(), "1.0.0");
409        assert_eq!(meta.id, ":memory:");
410    }
411
412    #[actix_rt::test]
413    async fn metadata_mvt() {
414        let script = include_str!("../../tests/fixtures/mbtiles/world_cities.sql");
415        let (mbt, mut conn) = anonymous_mbtiles(script).await;
416        let meta = mbt.get_metadata(&mut conn).await.unwrap();
417
418        let tile_info = mbt.detect_format(&meta.tilejson, &mut conn).await.unwrap();
419        assert_eq!(tile_info, Some(TileInfo::new(Format::Mvt, Encoding::Gzip)));
420
421        let tj = meta.tilejson;
422
423        assert_eq!(tj.maxzoom.unwrap(), 6);
424        assert_eq!(tj.minzoom.unwrap(), 0);
425        assert_eq!(tj.name.unwrap(), "Major cities from Natural Earth data");
426        assert_eq!(tj.version.unwrap(), "2");
427        assert_eq!(
428            tj.vector_layers,
429            Some(vec![VectorLayer {
430                id: "cities".to_string(),
431                fields: vec![("name".to_string(), "String".to_string())]
432                    .into_iter()
433                    .collect(),
434                description: Some(String::new()),
435                minzoom: Some(0),
436                maxzoom: Some(6),
437                other: BTreeMap::default()
438            }])
439        );
440        assert_eq!(meta.id, ":memory:");
441        assert_eq!(meta.layer_type, Some("overlay".to_string()));
442    }
443
444    #[actix_rt::test]
445    async fn metadata_get_key() {
446        let script = include_str!("../../tests/fixtures/mbtiles/world_cities.sql");
447        let (mbt, mut conn) = anonymous_mbtiles(script).await;
448        let res = mbt
449            .get_metadata_value(&mut conn, "bounds")
450            .await
451            .unwrap()
452            .unwrap();
453        assert_eq!(res, "-123.123590,-37.818085,174.763027,59.352706");
454        let res = mbt
455            .get_metadata_value(&mut conn, "name")
456            .await
457            .unwrap()
458            .unwrap();
459        assert_eq!(res, "Major cities from Natural Earth data");
460        let res = mbt
461            .get_metadata_value(&mut conn, "maxzoom")
462            .await
463            .unwrap()
464            .unwrap();
465        assert_eq!(res, "6");
466        let res = mbt
467            .get_metadata_value(&mut conn, "nonexistent_key")
468            .await
469            .unwrap();
470        assert_eq!(res, None);
471        let res = mbt.get_metadata_value(&mut conn, "").await.unwrap();
472        assert_eq!(res, None);
473    }
474
475    #[actix_rt::test]
476    async fn metadata_set_key() {
477        let (mut conn, mbt) = open(":memory:").await.unwrap();
478
479        conn.execute("CREATE TABLE metadata (name text NOT NULL PRIMARY KEY, value text);")
480            .await
481            .unwrap();
482
483        mbt.set_metadata_value(&mut conn, "bounds", "0.0, 0.0, 0.0, 0.0")
484            .await
485            .unwrap();
486        assert_eq!(
487            mbt.get_metadata_value(&mut conn, "bounds")
488                .await
489                .unwrap()
490                .unwrap(),
491            "0.0, 0.0, 0.0, 0.0"
492        );
493
494        mbt.set_metadata_value(
495            &mut conn,
496            "bounds",
497            "-123.123590,-37.818085,174.763027,59.352706",
498        )
499        .await
500        .unwrap();
501        assert_eq!(
502            mbt.get_metadata_value(&mut conn, "bounds")
503                .await
504                .unwrap()
505                .unwrap(),
506            "-123.123590,-37.818085,174.763027,59.352706"
507        );
508
509        mbt.delete_metadata_value(&mut conn, "bounds")
510            .await
511            .unwrap();
512        assert_eq!(
513            mbt.get_metadata_value(&mut conn, "bounds").await.unwrap(),
514            None
515        );
516    }
517
518    #[actix_rt::test]
519    async fn metadata_empty_tileset() {
520        let mbt = Mbtiles::new(":memory:").unwrap();
521        let mut conn = mbt.open().await.unwrap();
522        init_mbtiles_schema(&mut conn, MbtType::Flat).await.unwrap();
523
524        // get_metadata should work on empty tileset
525        let meta = mbt.get_metadata(&mut conn).await;
526        let meta = meta.expect("get_metadata works on empty tileset");
527
528        // detect_format should return None for empty tileset
529        let tile_info = mbt.detect_format(&meta.tilejson, &mut conn).await.unwrap();
530        assert_eq!(tile_info, None);
531    }
532}