Skip to main content

geopackage/
tiles.rs

1//! Tile pyramids: [`TilePyramidBuilder`], [`GeoPackage::create_tile_pyramid`],
2//! and the [`TilePyramid`] handle over an existing one.
3//!
4//! A tile pyramid is the container's second data type, alongside features and
5//! attributes, and its payloads are opaque here: this crate stores, indexes and
6//! validates tiles, and decodes none of them. What it does read is each
7//! payload's header, which is how a tile written at the wrong pixel size, or in
8//! a format the table may not contain, is caught rather than stored (see
9//! [`geopackage_core::tiles::probe`]).
10//!
11//! The geometry of a pyramid, and the spec's rules about it, live in
12//! [`geopackage_core::tiles`]. This module is the part that needs a database:
13//! the catalogue rows, the user table, and the extension registrations.
14//!
15//! Creation validates; reading does not. A pyramid another implementation wrote
16//! opens on whatever its `gpkg_tile_matrix` rows say, because a reader that
17//! rejects an imperfect file cannot be used to inspect one.
18
19use geopackage_core::ddl;
20use geopackage_core::ident::quote;
21use geopackage_core::tiles::{
22    self, TileCoord, TileFormat, TileMatrix, TileMatrixSet, TilePayload, WEBP_EXTENSION_DEFINITION,
23    WEBP_EXTENSION_NAME, ZOOM_OTHER_EXTENSION_DEFINITION, ZOOM_OTHER_EXTENSION_NAME,
24};
25use rusqlite::types::ValueRef;
26use rusqlite::{CachedStatement, Connection, OptionalExtension};
27
28use crate::transaction::WriteTransaction;
29use crate::{
30    BoundingBox, Error, ExtensionRow, GeoPackage, Result, resolve_table_name, table_exists,
31};
32
33/// A declarative builder for a tile pyramid.
34///
35/// Declares the pyramid's extent and spatial reference system
36/// ([`TileMatrixSet`]), its zoom levels ([`TileMatrix`]), and the catalogue
37/// metadata, then goes to [`GeoPackage::create_tile_pyramid`].
38///
39/// The zoom levels are usually built from the extent rather than written out:
40/// [`TileMatrixSet::ladder`] derives a power-of-two ladder whose pixel sizes
41/// span the extent exactly.
42///
43/// ```
44/// use geopackage::core::tiles::{TileMatrixSet, ZoomLadder};
45/// use geopackage::{GeoPackage, TilePyramidBuilder};
46///
47/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
48/// # let dir = tempfile::tempdir()?;
49/// # let path = dir.path().join("basemap.gpkg");
50/// let gpkg = GeoPackage::create(path)?;
51/// gpkg.add_epsg_srs(3857)?;
52///
53/// let matrix_set = TileMatrixSet::web_mercator_quad();
54/// let matrices = matrix_set.ladder(ZoomLadder::new(0, 4))?;
55/// let tiles = gpkg.create_tile_pyramid(
56///     &TilePyramidBuilder::new("basemap", matrix_set).matrices(matrices),
57/// )?;
58///
59/// assert_eq!(tiles.zoom_levels(), vec![0, 1, 2, 3, 4]);
60/// assert_eq!(tiles.matrix(4).map(|m| m.matrix_width), Some(16));
61/// # Ok(()) }
62/// ```
63#[derive(Debug, Clone)]
64pub struct TilePyramidBuilder {
65    table_name: String,
66    identifier: Option<String>,
67    description: Option<String>,
68    matrix_set: TileMatrixSet,
69    matrices: Vec<TileMatrix>,
70    allow_zoom_other: bool,
71}
72
73impl TilePyramidBuilder {
74    /// Starts a builder for a pyramid of the given name over the given
75    /// extent.
76    ///
77    /// The name is validated when the builder reaches
78    /// [`GeoPackage::create_tile_pyramid`], not here.
79    pub fn new(table_name: impl Into<String>, matrix_set: TileMatrixSet) -> Self {
80        Self {
81            table_name: table_name.into(),
82            identifier: None,
83            description: None,
84            matrix_set,
85            matrices: Vec::new(),
86            allow_zoom_other: false,
87        }
88    }
89
90    /// Declares one zoom level.
91    #[must_use]
92    pub fn matrix(mut self, matrix: TileMatrix) -> Self {
93        self.matrices.push(matrix);
94        self
95    }
96
97    /// Declares a set of zoom levels, in any order.
98    #[must_use]
99    pub fn matrices(mut self, matrices: impl IntoIterator<Item = TileMatrix>) -> Self {
100        self.matrices.extend(matrices);
101        self
102    }
103
104    /// Sets `gpkg_contents.identifier` (a human-readable name). Defaults to
105    /// the table name when left unset.
106    #[must_use]
107    pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
108        self.identifier = Some(identifier.into());
109        self
110    }
111
112    /// Sets `gpkg_contents.description`.
113    #[must_use]
114    pub fn description(mut self, description: impl Into<String>) -> Self {
115        self.description = Some(description.into());
116        self
117    }
118
119    /// Allows zoom levels that do not step by factors of two, registering the
120    /// `gpkg_zoom_other` extension for the table.
121    ///
122    /// Off by default, and the omission is an error rather than a silent
123    /// registration: a ladder that does not double is usually a mistake in the
124    /// pixel sizes, and a file that quietly registers an extension is one
125    /// whose readers may not have it. Such a pyramid is always *read* whether or not
126    /// this was set, as it is for the file's original writer.
127    #[must_use]
128    pub fn allow_zoom_other(mut self, allow: bool) -> Self {
129        self.allow_zoom_other = allow;
130        self
131    }
132
133    /// Returns the table name.
134    pub fn table_name(&self) -> &str {
135        &self.table_name
136    }
137}
138
139/// A handle to one tile pyramid of a [`GeoPackage`].
140///
141/// Obtained from [`GeoPackage::create_tile_pyramid`], [`GeoPackage::tiles`] or
142/// [`GeoPackage::tile_pyramids`]. The matrix set and the zoom levels are read
143/// once at construction and kept sorted by zoom level, so addressing a tile
144/// costs a binary search rather than a query, and the handle borrows the
145/// [`GeoPackage`] for its lifetime.
146pub struct TilePyramid<'a> {
147    gpkg: &'a GeoPackage,
148    table_name: String,
149    matrix_set: TileMatrixSet,
150    /// Ascending by zoom level, which [`Self::matrix`] binary-searches.
151    matrices: Vec<TileMatrix>,
152    /// Built once per handle: a statement's text is a `prepare_cached` key, and
153    /// formatting one per tile is the per-row rebuild this crate has spent
154    /// releases removing.
155    sql: TileSql,
156    /// The unidentified extension that blocks writes to this pyramid, read
157    /// once for the same reason the statements are built once: `put_tile` is a
158    /// per-tile call, and a catalogue query inside it would be paid per tile.
159    write_block: Option<ExtensionRow>,
160}
161
162/// The statement text a [`TilePyramid`] uses, built once at construction.
163#[derive(Debug, Clone)]
164struct TileSql {
165    get: String,
166    exists: String,
167    count: String,
168    count_at: String,
169    scan: String,
170    scan_at: String,
171    scan_in: String,
172    put: String,
173    delete: String,
174}
175
176impl TileSql {
177    /// Builds the statements for a table, whose name is quoted once here
178    /// rather than at every call.
179    fn new(table: &str) -> Result<Self> {
180        let table = quote(table)?;
181        // Matrix order, which is what a consumer walking a pyramid expects:
182        // zoom level, then north to south, then west to east.
183        let order = "ORDER BY zoom_level, tile_row, tile_column";
184        let columns = "zoom_level, tile_column, tile_row, tile_data";
185        let address = "zoom_level = ?1 AND tile_column = ?2 AND tile_row = ?3";
186        Ok(Self {
187            get: format!("SELECT tile_data FROM {table} WHERE {address}"),
188            exists: format!("SELECT 1 FROM {table} WHERE {address}"),
189            count: format!("SELECT count(*) FROM {table}"),
190            count_at: format!("SELECT count(*) FROM {table} WHERE zoom_level = ?1"),
191            scan: format!("SELECT {columns} FROM {table} {order}"),
192            scan_at: format!("SELECT {columns} FROM {table} WHERE zoom_level = ?1 {order}"),
193            scan_in: format!(
194                "SELECT {columns} FROM {table} \
195                 WHERE zoom_level = ?1 AND tile_column BETWEEN ?2 AND ?3 \
196                 AND tile_row BETWEEN ?4 AND ?5 {order}"
197            ),
198            put: format!(
199                "INSERT INTO {table} (zoom_level, tile_column, tile_row, tile_data) \
200                 VALUES (?1, ?2, ?3, ?4) \
201                 ON CONFLICT (zoom_level, tile_column, tile_row) \
202                 DO UPDATE SET tile_data = excluded.tile_data"
203            ),
204            delete: format!("DELETE FROM {table} WHERE {address}"),
205        })
206    }
207}
208
209/// One tile of a pyramid, borrowing its payload from the row it was read from.
210///
211/// The payload is the bytes as stored, in whatever format the table contains:
212/// this crate does not decode them. [`Tile::to_vec`] copies, and everything
213/// else here borrows.
214#[derive(Debug)]
215pub struct Tile<'a> {
216    coord: TileCoord,
217    data: &'a [u8],
218}
219
220impl Tile<'_> {
221    /// Returns where this tile sits in the pyramid.
222    pub fn coord(&self) -> TileCoord {
223        self.coord
224    }
225
226    /// Returns the stored payload, borrowed from SQLite's row buffer.
227    pub fn data(&self) -> &[u8] {
228        self.data
229    }
230
231    /// Returns the stored payload, copied into an owned buffer.
232    pub fn to_vec(&self) -> Vec<u8> {
233        self.data.to_vec()
234    }
235
236    /// Returns what the payload's header declares: its encoding and pixel
237    /// size.
238    ///
239    /// # Errors
240    ///
241    /// [`Error::Tile`] when the bytes are not a readable image header.
242    pub fn probe(&self) -> Result<TilePayload> {
243        Ok(tiles::probe(self.data)?)
244    }
245}
246
247impl std::fmt::Debug for TilePyramid<'_> {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        f.debug_struct("TilePyramid")
250            .field("table_name", &self.table_name)
251            .field("matrix_set", &self.matrix_set)
252            .field("zoom_levels", &self.zoom_levels())
253            .finish()
254    }
255}
256
257impl GeoPackage {
258    /// Creates a tile pyramid from a [`TilePyramidBuilder`].
259    ///
260    /// Emits the tile pyramid user table, a `gpkg_contents` row
261    /// (`data_type = 'tiles'`, bounded by the matrix set extent), the
262    /// `gpkg_tile_matrix_set` row and one `gpkg_tile_matrix` row per zoom
263    /// level, in one transaction, then returns a handle to the new pyramid.
264    /// `gpkg_tile_matrix_set` and `gpkg_tile_matrix` are created on first use,
265    /// as `gpkg_geometry_columns` is for feature layers.
266    ///
267    /// # Errors
268    ///
269    /// - [`Error::ReservedTablePrefix`] if the name begins `gpkg_`.
270    /// - [`Error::TableAlreadyExists`] if a table or view of that name exists.
271    /// - [`Error::UnknownSrs`] if the matrix set's `srs_id` is not registered
272    ///   in `gpkg_spatial_ref_sys`.
273    /// - [`Error::Tile`] if the pyramid breaks one of the spec's consistency
274    ///   rules (Requirements 45 to 53).
275    /// - [`Error::ZoomOtherNotEnabled`] if its zoom levels do not step by
276    ///   factors of two and [`TilePyramidBuilder::allow_zoom_other`] was not
277    ///   set.
278    pub fn create_tile_pyramid(&self, builder: &TilePyramidBuilder) -> Result<TilePyramid<'_>> {
279        let name = &builder.table_name;
280        // As with `create_layer`: a whole-GeoPackage extension we cannot
281        // identify covers a table that does not exist yet.
282        self.check_writable(name)?;
283        if name
284            .get(..5)
285            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("gpkg_"))
286        {
287            return Err(Error::ReservedTablePrefix {
288                table_name: name.clone(),
289            });
290        }
291        let conn = self.connection();
292        if table_exists(conn, name)? {
293            return Err(Error::TableAlreadyExists {
294                table_name: name.clone(),
295            });
296        }
297        if self.srs(builder.matrix_set.srs_id)?.is_none() {
298            return Err(Error::UnknownSrs {
299                srs_id: builder.matrix_set.srs_id,
300            });
301        }
302        builder.matrix_set.validate(&builder.matrices)?;
303        let zoom_other = !tiles::is_power_of_two_ladder(&builder.matrices);
304        if zoom_other && !builder.allow_zoom_other {
305            return Err(Error::ZoomOtherNotEnabled {
306                table_name: name.clone(),
307            });
308        }
309
310        let identifier = builder.identifier.clone().unwrap_or_else(|| name.clone());
311        let description = builder.description.clone().unwrap_or_default();
312        let set = &builder.matrix_set;
313
314        let tx = WriteTransaction::begin(conn)?;
315        for (exists, sql) in [
316            (
317                table_exists(conn, "gpkg_tile_matrix_set")?,
318                ddl::CREATE_GPKG_TILE_MATRIX_SET,
319            ),
320            (
321                table_exists(conn, "gpkg_tile_matrix")?,
322                ddl::CREATE_GPKG_TILE_MATRIX,
323            ),
324        ] {
325            if !exists {
326                conn.execute_batch(sql)?;
327            }
328        }
329        conn.execute_batch(&tiles::create_tile_table_sql(name)?)?;
330        // The extent is the matrix set's, not a measurement: for tiles it is
331        // exact by Requirement 144, and gpkg_contents records the same box.
332        conn.execute(
333            "INSERT INTO gpkg_contents \
334             (table_name, data_type, identifier, description, min_x, min_y, max_x, max_y, srs_id) \
335             VALUES (?1, 'tiles', ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
336            rusqlite::params![
337                name,
338                identifier,
339                description,
340                set.min_x,
341                set.min_y,
342                set.max_x,
343                set.max_y,
344                set.srs_id,
345            ],
346        )?;
347        conn.execute(
348            "INSERT INTO gpkg_tile_matrix_set (table_name, srs_id, min_x, min_y, max_x, max_y) \
349             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
350            rusqlite::params![name, set.srs_id, set.min_x, set.min_y, set.max_x, set.max_y],
351        )?;
352        {
353            let mut stmt = conn.prepare(
354                "INSERT INTO gpkg_tile_matrix \
355                 (table_name, zoom_level, matrix_width, matrix_height, tile_width, tile_height, \
356                  pixel_x_size, pixel_y_size) \
357                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
358            )?;
359            for matrix in &builder.matrices {
360                stmt.execute(rusqlite::params![
361                    name,
362                    matrix.zoom_level,
363                    matrix.matrix_width,
364                    matrix.matrix_height,
365                    matrix.tile_width,
366                    matrix.tile_height,
367                    matrix.pixel_x_size,
368                    matrix.pixel_y_size,
369                ])?;
370            }
371        }
372        if zoom_other {
373            crate::extensions::register(
374                conn,
375                Some(name),
376                Some(tiles::TILE_DATA_COLUMN),
377                ZOOM_OTHER_EXTENSION_NAME,
378                ZOOM_OTHER_EXTENSION_DEFINITION,
379                tiles::TILE_EXTENSION_SCOPE,
380            )?;
381        }
382        tx.commit()?;
383        self.tiles(name)
384    }
385
386    /// Opens a tile pyramid by name.
387    ///
388    /// Nothing is validated: the matrix set and zoom levels are reported as the
389    /// file records them.
390    ///
391    /// # Errors
392    ///
393    /// - [`Error::NoSuchLayer`] if `name` is not in `gpkg_contents`.
394    /// - [`Error::WrongDataType`] if it is registered but not as `tiles`.
395    /// - [`Error::NoTileMatrixSet`] if its `gpkg_tile_matrix_set` row is
396    ///   missing, which leaves its tiles unlocatable.
397    pub fn tiles(&self, name: &str) -> Result<TilePyramid<'_>> {
398        let conn = self.connection();
399        let row = conn
400            .query_row(
401                "SELECT table_name, data_type FROM gpkg_contents \
402                 WHERE table_name = ?1 COLLATE NOCASE",
403                [name],
404                |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
405            )
406            .optional()?;
407        let (declared_name, data_type) = row.ok_or_else(|| Error::NoSuchLayer {
408            table_name: name.to_owned(),
409        })?;
410        if data_type != "tiles" {
411            return Err(Error::WrongDataType {
412                table_name: declared_name,
413                expected: "tiles",
414                found: data_type,
415            });
416        }
417        let table_name = resolve_table_name(conn, &declared_name)?.unwrap_or(declared_name);
418        let matrix_set =
419            read_matrix_set(conn, &table_name)?.ok_or_else(|| Error::NoTileMatrixSet {
420                table_name: table_name.clone(),
421            })?;
422        let matrices = read_matrices(conn, &table_name)?;
423        let sql = TileSql::new(&table_name)?;
424        let write_block = self.blocking_extension(&table_name)?;
425        Ok(TilePyramid {
426            gpkg: self,
427            table_name,
428            matrix_set,
429            matrices,
430            sql,
431            write_block,
432        })
433    }
434
435    /// Returns every tile pyramid in the file, by `gpkg_contents` name.
436    pub fn tile_pyramids(&self) -> Result<Vec<TilePyramid<'_>>> {
437        if !table_exists(self.connection(), "gpkg_tile_matrix_set")? {
438            return Ok(Vec::new());
439        }
440        let names: Vec<String> = {
441            let mut stmt = self.connection().prepare(
442                "SELECT c.table_name FROM gpkg_contents c \
443                 JOIN gpkg_tile_matrix_set s ON s.table_name = c.table_name COLLATE NOCASE \
444                 WHERE c.data_type = 'tiles' ORDER BY c.table_name",
445            )?;
446            stmt.query_map([], |r| r.get(0))?
447                .collect::<rusqlite::Result<_>>()?
448        };
449        names.iter().map(|name| self.tiles(name)).collect()
450    }
451}
452
453impl<'a> TilePyramid<'a> {
454    /// Returns the physical SQLite table name backing this pyramid.
455    pub fn table_name(&self) -> &str {
456        &self.table_name
457    }
458
459    /// Returns the [`GeoPackage`] this pyramid belongs to.
460    pub fn gpkg(&self) -> &'a GeoPackage {
461        self.gpkg
462    }
463
464    /// Returns the pyramid's extent and spatial reference system.
465    pub fn matrix_set(&self) -> &TileMatrixSet {
466        &self.matrix_set
467    }
468
469    /// Returns every declared zoom level, ascending.
470    pub fn matrices(&self) -> &[TileMatrix] {
471        &self.matrices
472    }
473
474    /// Returns the zoom levels this pyramid declares, ascending.
475    pub fn zoom_levels(&self) -> Vec<i64> {
476        self.matrices.iter().map(|m| m.zoom_level).collect()
477    }
478
479    /// Returns the tile matrix for one zoom level, or `None` if the pyramid
480    /// does not declare that level.
481    pub fn matrix(&self, zoom_level: i64) -> Option<&TileMatrix> {
482        self.matrices
483            .binary_search_by_key(&zoom_level, |matrix| matrix.zoom_level)
484            .ok()
485            .and_then(|index| self.matrices.get(index))
486    }
487
488    /// Returns one tile's payload, or `None` where the pyramid has no tile at
489    /// that address.
490    ///
491    /// Copies the payload out of SQLite once, which is what an owned return
492    /// costs. [`Self::get_tile_into`] reuses a buffer instead, and
493    /// [`Self::cursor`] borrows the bytes without copying at all.
494    ///
495    /// The address is not checked against the zoom level's grid: a tile outside
496    /// it is simply absent, as any other empty address is. The write path is
497    /// where an impossible address is an error.
498    pub fn get_tile(&self, coord: TileCoord) -> Result<Option<Vec<u8>>> {
499        let conn = self.gpkg.connection();
500        let mut stmt = conn.prepare_cached(&self.sql.get)?;
501        Ok(stmt
502            .query_row(
503                rusqlite::params![coord.zoom_level, coord.column, coord.row],
504                |row| tile_blob(row, 0).map(<[u8]>::to_vec),
505            )
506            .optional()?)
507    }
508
509    /// Reads one tile's payload into a caller-owned buffer, returning whether
510    /// a tile was there.
511    ///
512    /// The buffer is cleared first and reused, so a loop over many tiles
513    /// allocates once rather than once per tile. Its contents are untouched
514    /// when the tile is absent.
515    pub fn get_tile_into(&self, coord: TileCoord, buffer: &mut Vec<u8>) -> Result<bool> {
516        let conn = self.gpkg.connection();
517        let mut stmt = conn.prepare_cached(&self.sql.get)?;
518        let found = stmt
519            .query_row(
520                rusqlite::params![coord.zoom_level, coord.column, coord.row],
521                |row| {
522                    let data = tile_blob(row, 0)?;
523                    buffer.clear();
524                    buffer.extend_from_slice(data);
525                    Ok(())
526                },
527            )
528            .optional()?;
529        Ok(found.is_some())
530    }
531
532    /// Returns `true` if the pyramid has a tile at an address, without
533    /// reading its payload.
534    pub fn has_tile(&self, coord: TileCoord) -> Result<bool> {
535        let conn = self.gpkg.connection();
536        let mut stmt = conn.prepare_cached(&self.sql.exists)?;
537        Ok(stmt
538            .query_row(
539                rusqlite::params![coord.zoom_level, coord.column, coord.row],
540                |_| Ok(()),
541            )
542            .optional()?
543            .is_some())
544    }
545
546    /// Returns the number of tiles in the pyramid.
547    pub fn tile_count(&self) -> Result<i64> {
548        let conn = self.gpkg.connection();
549        Ok(conn
550            .prepare_cached(&self.sql.count)?
551            .query_row([], |r| r.get(0))?)
552    }
553
554    /// Returns the number of tiles at one zoom level.
555    pub fn tile_count_at(&self, zoom_level: i64) -> Result<i64> {
556        let conn = self.gpkg.connection();
557        Ok(conn
558            .prepare_cached(&self.sql.count_at)?
559            .query_row([zoom_level], |r| r.get(0))?)
560    }
561
562    /// Streams every tile, in matrix order: by zoom level, then north to
563    /// south, then west to east.
564    ///
565    /// Two calls, as the feature read path is: the cursor owns the statement,
566    /// and [`TileCursor::tiles`] borrows it to walk the rows. There is no
567    /// materialising counterpart, because one zoom level of a real pyramid is
568    /// more payload than a `Vec` of them should contain.
569    pub fn cursor(&self) -> Result<TileCursor<'_>> {
570        self.cursor_with(&self.sql.scan, Vec::new())
571    }
572
573    /// Streams the tiles of one zoom level, in matrix order.
574    pub fn cursor_at(&self, zoom_level: i64) -> Result<TileCursor<'_>> {
575        self.cursor_with(&self.sql.scan_at, vec![zoom_level.into()])
576    }
577
578    /// Streams the tiles of one zoom level that a bounding box touches, in
579    /// matrix order.
580    ///
581    /// The box is in the pyramid's own spatial reference system; this crate
582    /// transforms nothing. It is turned into a range of tile indices and asked
583    /// of the table once, so the payloads read are the ones the box selects.
584    /// A box that misses the pyramid's extent yields no tiles.
585    ///
586    /// # Errors
587    ///
588    /// [`Error::UnknownZoomLevel`] when the pyramid declares no such zoom
589    /// level, since without its grid a box cannot be turned into tile indices.
590    pub fn cursor_in(&self, zoom_level: i64, bbox: BoundingBox) -> Result<TileCursor<'_>> {
591        let matrix = self
592            .matrix(zoom_level)
593            .ok_or_else(|| Error::UnknownZoomLevel {
594                table_name: self.table_name.clone(),
595                zoom_level,
596            })?;
597        // A box outside the extent binds an empty range rather than taking a
598        // path of its own: the query then returns nothing, which is the answer.
599        let range = self
600            .matrix_set
601            .tile_range(matrix, bbox.min_x, bbox.min_y, bbox.max_x, bbox.max_y);
602        let (min_column, max_column, min_row, max_row) = range.map_or((0, -1, 0, -1), |range| {
603            (
604                range.min_column,
605                range.max_column,
606                range.min_row,
607                range.max_row,
608            )
609        });
610        self.cursor_with(
611            &self.sql.scan_in,
612            vec![
613                zoom_level.into(),
614                min_column.into(),
615                max_column.into(),
616                min_row.into(),
617                max_row.into(),
618            ],
619        )
620    }
621
622    fn cursor_with(
623        &self,
624        sql: &str,
625        params: Vec<rusqlite::types::Value>,
626    ) -> Result<TileCursor<'_>> {
627        let stmt = self.gpkg.connection().prepare(sql)?;
628        Ok(TileCursor { stmt, params })
629    }
630
631    /// Writes one tile, replacing whatever was at that address.
632    ///
633    /// Validated: the zoom level has to be one the pyramid declares, the column
634    /// and row have to fall inside that level's grid, and the payload has to be
635    /// a PNG or JPEG (or a WebP, which registers `gpkg_webp` on the way past)
636    /// of exactly the pixel size the zoom level declares. A tile that fails any
637    /// of those is a tile no conforming reader could use, so it is rejected
638    /// rather than stored.
639    ///
640    /// One tile per transaction. Use [`Self::writer`] or [`Self::write_all`] to
641    /// write many.
642    ///
643    /// # Errors
644    ///
645    /// - [`Error::UnknownZoomLevel`] for a zoom level with no
646    ///   `gpkg_tile_matrix` row.
647    /// - [`Error::Tile`] for an address outside the grid, an unreadable
648    ///   payload, or one of the wrong pixel size.
649    /// - [`Error::TileFormatNotAllowed`] for a payload that is not PNG, JPEG or
650    ///   WebP.
651    pub fn put_tile(&self, coord: TileCoord, data: &[u8]) -> Result<()> {
652        let mut writer = self.writer()?;
653        writer.put(coord, data)?;
654        writer.commit()
655    }
656
657    /// Deletes one tile, returning whether there was one to delete.
658    pub fn delete_tile(&self, coord: TileCoord) -> Result<bool> {
659        let mut writer = self.writer()?;
660        let deleted = writer.delete(coord)?;
661        writer.commit()?;
662        Ok(deleted)
663    }
664
665    /// Opens a [`TileWriter`]: one transaction, prepared statements, and
666    /// per-tile `put`/`delete`.
667    ///
668    /// Nothing is written until [`TileWriter::commit`]; dropping the writer
669    /// rolls its transaction back.
670    pub fn writer(&self) -> Result<TileWriter<'a>> {
671        TileWriter::new(self)
672    }
673
674    /// Returns the extension this crate cannot identify that stops it writing
675    /// to this pyramid, if there is one.
676    ///
677    /// Read when the handle was opened, so a row registered since then is not
678    /// reflected here. See [`GeoPackage::blocking_extension`].
679    pub fn blocking_extension(&self) -> Option<&ExtensionRow> {
680        self.write_block.as_ref()
681    }
682
683    /// Rejects a write when an unidentified extension covers the pyramid.
684    fn check_writable(&self) -> Result<()> {
685        match &self.write_block {
686            None => Ok(()),
687            Some(row) => Err(Error::UnsupportedExtension {
688                table_name: self.table_name.clone(),
689                extension_name: row.name.clone(),
690                scope: row.scope.as_str().to_owned(),
691            }),
692        }
693    }
694
695    /// Writes many tiles, committing every `batch_size` of them (`0` writes
696    /// them all in one transaction), and returns how many were written.
697    ///
698    /// Payloads are borrowed, not consumed: anything that is `AsRef<[u8]>` will
699    /// do, so tiles copied from another pyramid go straight from one row's
700    /// buffer into the other's statement without a copy in between.
701    ///
702    /// # Errors
703    ///
704    /// As [`Self::put_tile`], at the first tile that fails. Tiles committed in
705    /// earlier batches stay written; the batch in flight is rolled back.
706    pub fn write_all<D: AsRef<[u8]>>(
707        &self,
708        tiles: impl IntoIterator<Item = (TileCoord, D)>,
709        batch_size: usize,
710    ) -> Result<usize> {
711        let mut tiles = tiles.into_iter();
712        let mut total = 0;
713        loop {
714            let mut writer = self.writer()?;
715            let mut in_batch = 0;
716            for (coord, data) in tiles.by_ref() {
717                writer.put(coord, data.as_ref())?;
718                in_batch += 1;
719                if batch_size != 0 && in_batch == batch_size {
720                    break;
721                }
722            }
723            writer.commit()?;
724            total += in_batch;
725            if batch_size == 0 || in_batch < batch_size {
726                return Ok(total);
727            }
728        }
729    }
730
731    /// Returns `Ok` if this pyramid satisfies the spec's consistency rules.
732    ///
733    /// Creation checks this, so a pyramid this crate wrote always passes. Worth
734    /// asking of one that arrived in a file from elsewhere, since every tile
735    /// bound is calculated from values it does not otherwise question.
736    ///
737    /// # Errors
738    ///
739    /// [`Error::Tile`] naming the first rule the pyramid breaks.
740    pub fn validate(&self) -> Result<()> {
741        self.matrix_set.validate(&self.matrices)?;
742        Ok(())
743    }
744}
745
746/// A transaction over one pyramid, with per-tile `put` and `delete`.
747///
748/// Obtained from [`TilePyramid::writer`]. Writes stage into the transaction the
749/// writer owns; [`Self::commit`] refreshes `gpkg_contents.last_change` and
750/// commits. Dropping a writer without committing rolls it back.
751///
752/// Unless a transaction was already open on the connection, in which case the
753/// writer joins it and committing or rolling back passes to whoever began it.
754/// See
755/// [`Self::commit`], and [`crate::FeatureWriter::commit`] for the reasoning in
756/// full.
757///
758/// The statements come from the connection rather than from the transaction, so
759/// they borrow what it borrows instead of borrowing it, exactly as
760/// [`crate::FeatureWriter`]'s do. They still run inside it: a SQLite
761/// transaction belongs to the connection, not to the statements prepared
762/// against it.
763pub struct TileWriter<'conn> {
764    tx: WriteTransaction<'conn>,
765    conn: &'conn Connection,
766    table_name: String,
767    /// The pyramid's zoom levels, ascending. Copied in rather than borrowed so
768    /// the writer has one lifetime; a pyramid has tens of levels, and this
769    /// is one allocation per writer against a binary search per tile.
770    matrices: Vec<TileMatrix>,
771    /// `INSERT ... ON CONFLICT DO UPDATE`: writing a tile where one already
772    /// sits replaces the payload and keeps the row's id, which the metadata
773    /// extension may reference.
774    put_stmt: CachedStatement<'conn>,
775    delete_stmt: CachedStatement<'conn>,
776    /// Whether anything has been written, so an untouched writer does not stamp
777    /// `last_change`.
778    dirty: bool,
779    /// Whether `gpkg_webp` is registered for this table. Read once, on the
780    /// first WebP payload, so a pyramid of PNGs never asks.
781    webp_registered: Option<bool>,
782}
783
784impl<'conn> TileWriter<'conn> {
785    fn new(pyramid: &TilePyramid<'conn>) -> Result<Self> {
786        // Every tile write reaches a writer, `put_tile` and `delete_tile`
787        // included, so this is the one place the check belongs.
788        pyramid.check_writable()?;
789        let conn = pyramid.gpkg.connection();
790        let tx = WriteTransaction::begin(conn)?;
791        let put_stmt = conn.prepare_cached(&pyramid.sql.put)?;
792        let delete_stmt = conn.prepare_cached(&pyramid.sql.delete)?;
793        Ok(Self {
794            tx,
795            conn,
796            table_name: pyramid.table_name.clone(),
797            matrices: pyramid.matrices.clone(),
798            put_stmt,
799            delete_stmt,
800            dirty: false,
801            webp_registered: None,
802        })
803    }
804
805    /// Writes one tile, replacing whatever was at that address.
806    ///
807    /// # Errors
808    ///
809    /// As [`TilePyramid::put_tile`], which is this call in its own
810    /// transaction.
811    pub fn put(&mut self, coord: TileCoord, data: &[u8]) -> Result<()> {
812        let matrix = self
813            .matrices
814            .binary_search_by_key(&coord.zoom_level, |matrix| matrix.zoom_level)
815            .ok()
816            .and_then(|index| self.matrices.get(index))
817            .ok_or_else(|| Error::UnknownZoomLevel {
818                table_name: self.table_name.clone(),
819                zoom_level: coord.zoom_level,
820            })?;
821        matrix.check_contains(coord.column, coord.row)?;
822        let payload = tiles::probe(data)?;
823        matrix.check_payload(&payload)?;
824        match payload.format {
825            TileFormat::Webp => self.register_webp()?,
826            format if format.is_core() => {}
827            format => {
828                return Err(Error::TileFormatNotAllowed {
829                    table_name: self.table_name.clone(),
830                    format,
831                });
832            }
833        }
834        // The payload binds as a borrowed slice: a tile read from one pyramid
835        // reaches another's statement without being copied on the way.
836        self.put_stmt.execute(rusqlite::params![
837            coord.zoom_level,
838            coord.column,
839            coord.row,
840            data
841        ])?;
842        self.dirty = true;
843        Ok(())
844    }
845
846    /// Deletes one tile, returning whether there was one to delete.
847    pub fn delete(&mut self, coord: TileCoord) -> Result<bool> {
848        let deleted = self.delete_stmt.execute(rusqlite::params![
849            coord.zoom_level,
850            coord.column,
851            coord.row
852        ])?;
853        self.dirty |= deleted > 0;
854        Ok(deleted > 0)
855    }
856
857    /// Refreshes `gpkg_contents.last_change` and commits.
858    ///
859    /// A writer that wrote nothing commits an empty transaction and leaves
860    /// `last_change` alone.
861    ///
862    /// # When the transaction was the caller's
863    ///
864    /// As [`crate::FeatureWriter::commit`]: a writer opened while a transaction
865    /// was already open joined it, so this stages the `last_change` refresh and
866    /// returns success without committing, and dropping such a writer rolls
867    /// nothing back.
868    pub fn commit(self) -> Result<()> {
869        let Self {
870            tx,
871            conn,
872            table_name,
873            dirty,
874            put_stmt,
875            delete_stmt,
876            ..
877        } = self;
878        // Statements borrow the connection, not the transaction, but dropping
879        // them here keeps the cache tidy before the commit.
880        drop(put_stmt);
881        drop(delete_stmt);
882        if dirty {
883            conn.execute(
884                "UPDATE gpkg_contents \
885                 SET last_change = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
886                 WHERE table_name = ?1",
887                [&table_name],
888            )?;
889        }
890        tx.commit()?;
891        Ok(())
892    }
893
894    /// Registers `gpkg_webp` for this table, once, on the first WebP
895    /// payload.
896    fn register_webp(&mut self) -> Result<()> {
897        if self.webp_registered.is_none() {
898            self.webp_registered = Some(crate::extensions::is_registered(
899                self.conn,
900                Some(&self.table_name),
901                WEBP_EXTENSION_NAME,
902            )?);
903        }
904        if self.webp_registered == Some(false) {
905            crate::extensions::register(
906                self.conn,
907                Some(&self.table_name),
908                Some(tiles::TILE_DATA_COLUMN),
909                WEBP_EXTENSION_NAME,
910                WEBP_EXTENSION_DEFINITION,
911                tiles::TILE_EXTENSION_SCOPE,
912            )?;
913            self.webp_registered = Some(true);
914        }
915        Ok(())
916    }
917}
918
919impl std::fmt::Debug for TileWriter<'_> {
920    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
921        f.debug_struct("TileWriter")
922            .field("table_name", &self.table_name)
923            .field("dirty", &self.dirty)
924            .finish_non_exhaustive()
925    }
926}
927
928/// A prepared tile scan, owning its statement so that [`TileCursor::tiles`]
929/// can borrow each payload straight out of the row.
930///
931/// The split is the one [`crate::FeatureCursor`] uses, and for the same reason:
932/// rusqlite's row cursor borrows its statement, so an iterator owning both
933/// would be self-referential, which `#![forbid(unsafe_code)]` rules out.
934pub struct TileCursor<'a> {
935    stmt: rusqlite::Statement<'a>,
936    params: Vec<rusqlite::types::Value>,
937}
938
939impl std::fmt::Debug for TileCursor<'_> {
940    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
941        f.debug_struct("TileCursor")
942            .field("parameters", &self.params.len())
943            .finish_non_exhaustive()
944    }
945}
946
947impl TileCursor<'_> {
948    /// Runs the scan and walks its tiles.
949    ///
950    /// Each call re-runs the query from the start, so a cursor can be walked
951    /// more than once.
952    pub fn tiles(&mut self) -> Result<TileStream<'_>> {
953        let rows = self
954            .stmt
955            .query(rusqlite::params_from_iter(self.params.iter()))?;
956        Ok(TileStream { rows })
957    }
958}
959
960/// A scan in progress, borrowing one tile at a time.
961///
962/// Not an [`Iterator`]: an iterator's item cannot borrow from the iterator,
963/// and the whole point here is to return the payload without copying it. Walk
964/// it with `while let Some(tile) = stream.next()?`, or pass a closure to
965/// [`Self::for_each`].
966///
967/// ```
968/// # use geopackage::core::tiles::{TileMatrixSet, ZoomLadder};
969/// # use geopackage::{GeoPackage, TilePyramidBuilder};
970/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
971/// # let dir = tempfile::tempdir()?;
972/// # let gpkg = GeoPackage::create(dir.path().join("t.gpkg"))?;
973/// # gpkg.add_epsg_srs(3857)?;
974/// # let set = TileMatrixSet::web_mercator_quad();
975/// # let matrices = set.ladder(ZoomLadder::new(0, 2))?;
976/// # let pyramid = gpkg.create_tile_pyramid(&TilePyramidBuilder::new("basemap", set).matrices(matrices))?;
977/// let mut cursor = pyramid.cursor()?;
978/// let mut stream = cursor.tiles()?;
979/// let mut bytes = 0;
980/// while let Some(tile) = stream.next()? {
981///     // `tile.data()` borrows the row: nothing is copied to count it.
982///     bytes += tile.data().len();
983/// }
984/// # assert_eq!(bytes, 0);
985/// # Ok(()) }
986/// ```
987pub struct TileStream<'c> {
988    rows: rusqlite::Rows<'c>,
989}
990
991impl std::fmt::Debug for TileStream<'_> {
992    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
993        // `rusqlite::Rows` is not `Debug`, and a scan mid-flight has no state
994        // worth printing beyond the query that produced it.
995        f.debug_struct("TileStream").finish_non_exhaustive()
996    }
997}
998
999impl TileStream<'_> {
1000    /// Returns the next tile of the scan, or `None` at its end.
1001    ///
1002    /// The returned [`Tile`] borrows this stream, so it is dropped before the
1003    /// next call. Copy what you need out of it with [`Tile::to_vec`].
1004    #[expect(
1005        clippy::should_implement_trait,
1006        reason = "a lending cursor cannot implement Iterator: its item borrows the iterator. `next` is the name a caller expects in a `while let` loop, and the fallible, borrowing signature is visibly not Iterator::next"
1007    )]
1008    pub fn next(&mut self) -> Result<Option<Tile<'_>>> {
1009        let Some(row) = self.rows.next()? else {
1010            return Ok(None);
1011        };
1012        Ok(Some(Tile {
1013            coord: TileCoord::new(row.get(0)?, row.get(1)?, row.get(2)?),
1014            data: tile_blob(row, 3)?,
1015        }))
1016    }
1017
1018    /// Runs a closure over every remaining tile.
1019    ///
1020    /// The same walk as [`Self::next`] with the borrow handled for you. The
1021    /// closure's error ends the scan.
1022    ///
1023    /// # Errors
1024    ///
1025    /// Whatever the closure returns, or a read error from the scan itself.
1026    pub fn for_each(&mut self, mut f: impl FnMut(&Tile<'_>) -> Result<()>) -> Result<()> {
1027        while let Some(tile) = self.next()? {
1028            f(&tile)?;
1029        }
1030        Ok(())
1031    }
1032}
1033
1034/// A tile payload, borrowed from the row rather than copied out of it.
1035///
1036/// `tile_data` is `NOT NULL` in every table this crate creates, and a
1037/// non-blob there is a malformed file rather than a value to coerce.
1038fn tile_blob<'a>(row: &'a rusqlite::Row<'a>, index: usize) -> rusqlite::Result<&'a [u8]> {
1039    match row.get_ref(index)? {
1040        ValueRef::Blob(data) => Ok(data),
1041        _ => Err(rusqlite::Error::InvalidColumnType(
1042            index,
1043            tiles::TILE_DATA_COLUMN.to_owned(),
1044            rusqlite::types::Type::Blob,
1045        )),
1046    }
1047}
1048
1049/// Reads a pyramid's `gpkg_tile_matrix_set` row.
1050fn read_matrix_set(conn: &Connection, table: &str) -> Result<Option<TileMatrixSet>> {
1051    Ok(conn
1052        .query_row(
1053            "SELECT srs_id, min_x, min_y, max_x, max_y FROM gpkg_tile_matrix_set \
1054             WHERE table_name = ?1 COLLATE NOCASE",
1055            [table],
1056            |r| {
1057                Ok(TileMatrixSet {
1058                    srs_id: r.get(0)?,
1059                    min_x: r.get(1)?,
1060                    min_y: r.get(2)?,
1061                    max_x: r.get(3)?,
1062                    max_y: r.get(4)?,
1063                })
1064            },
1065        )
1066        .optional()?)
1067}
1068
1069/// Reads a pyramid's `gpkg_tile_matrix` rows, ascending by zoom level.
1070///
1071/// A file with no `gpkg_tile_matrix` table at all has no zoom levels, which is
1072/// an empty pyramid rather than an error: the tiles table may be empty, and the
1073/// spec requires a row only for a level that contains tiles.
1074fn read_matrices(conn: &Connection, table: &str) -> Result<Vec<TileMatrix>> {
1075    if !table_exists(conn, "gpkg_tile_matrix")? {
1076        return Ok(Vec::new());
1077    }
1078    let mut stmt = conn.prepare(
1079        "SELECT zoom_level, matrix_width, matrix_height, tile_width, tile_height, \
1080         pixel_x_size, pixel_y_size FROM gpkg_tile_matrix \
1081         WHERE table_name = ?1 COLLATE NOCASE ORDER BY zoom_level",
1082    )?;
1083    let rows = stmt.query_map([table], |r| {
1084        Ok(TileMatrix {
1085            zoom_level: r.get(0)?,
1086            matrix_width: r.get(1)?,
1087            matrix_height: r.get(2)?,
1088            tile_width: r.get(3)?,
1089            tile_height: r.get(4)?,
1090            pixel_x_size: r.get(5)?,
1091            pixel_y_size: r.get(6)?,
1092        })
1093    })?;
1094    Ok(rows.collect::<rusqlite::Result<_>>()?)
1095}