Skip to main content

mbtiles/
patcher.rs

1use std::path::PathBuf;
2
3use log::{debug, info, warn};
4use sqlx::{Connection as _, query};
5
6use crate::MbtType::{Flat, FlatWithHash, Normalized};
7use crate::queries::detach_db;
8use crate::{
9    AGG_TILES_HASH, AGG_TILES_HASH_AFTER_APPLY, AGG_TILES_HASH_BEFORE_APPLY, MbtError, MbtResult,
10    MbtType, Mbtiles,
11};
12
13pub async fn apply_patch(base_file: PathBuf, patch_file: PathBuf, force: bool) -> MbtResult<()> {
14    let base_mbt = Mbtiles::new(base_file)?;
15    let patch_mbt = Mbtiles::new(patch_file)?;
16
17    let mut conn = patch_mbt.open_readonly().await?;
18    let patch_info = patch_mbt.examine_diff(&mut conn).await?;
19    if patch_info.patch_type.is_some() {
20        return Err(MbtError::UnsupportedPatchType);
21    }
22    patch_mbt.validate_diff_info(&patch_info, force)?;
23    let patch_type = patch_info.mbt_type;
24    conn.close().await?;
25
26    let mut conn = base_mbt.open().await?;
27    let base_info = base_mbt.examine_diff(&mut conn).await?;
28    let base_hash = base_mbt.get_agg_tiles_hash(&mut conn).await?;
29    base_mbt.assert_hashes(&base_info, force)?;
30
31    match (force, base_hash, patch_info.agg_tiles_hash_before_apply) {
32        (false, Some(base_hash), Some(expected_hash)) if base_hash != expected_hash => {
33            return Err(MbtError::AggHashMismatchWithDiff(
34                patch_mbt.filepath().to_string(),
35                expected_hash,
36                base_mbt.filepath().to_string(),
37                base_hash,
38            ));
39        }
40        (true, Some(base_hash), Some(expected_hash)) if base_hash != expected_hash => {
41            warn!(
42                "Aggregate tiles hash mismatch: Patch file expected {expected_hash} but found {base_hash} in {base_mbt} (force mode)"
43            );
44        }
45        _ => {}
46    }
47
48    info!(
49        "Applying patch file {patch_mbt} ({patch_type}) to {base_mbt} ({base_type})",
50        base_type = base_info.mbt_type
51    );
52
53    patch_mbt.attach_to(&mut conn, "patchDb").await?;
54    let select_from = get_select_from(base_info.mbt_type, patch_type);
55    let (main_table, insert1, insert2) = get_insert_sql(base_info.mbt_type, select_from);
56
57    let sql = format!("{insert1} WHERE tile_data NOTNULL");
58    query(&sql).execute(&mut conn).await?;
59
60    if let Some(insert2) = insert2 {
61        let sql = format!("{insert2} WHERE tile_data NOTNULL");
62        query(&sql).execute(&mut conn).await?;
63    }
64
65    let sql = format!(
66        "
67    DELETE FROM {main_table}
68    WHERE (zoom_level, tile_column, tile_row) IN (
69        SELECT zoom_level, tile_column, tile_row FROM ({select_from} WHERE tile_data ISNULL)
70    )"
71    );
72    query(&sql).execute(&mut conn).await?;
73
74    if base_info.mbt_type.is_normalized() {
75        debug!("Removing unused tiles from the images table (normalized schema)");
76        let sql = "DELETE FROM images WHERE tile_id NOT IN (SELECT tile_id FROM map)";
77        query(sql).execute(&mut conn).await?;
78    }
79
80    // Copy metadata from patchDb to the destination file, replacing existing values
81    // Convert 'agg_tiles_hash_in_patch' into 'agg_tiles_hash'
82    // Delete metadata entries if the value is NULL in patchDb
83    let sql = format!(
84        "
85    INSERT OR REPLACE INTO metadata (name, value)
86    SELECT IIF(name = '{AGG_TILES_HASH_AFTER_APPLY}', '{AGG_TILES_HASH}', name) as name,
87           value
88    FROM patchDb.metadata
89    WHERE name NOTNULL AND name NOT IN ('{AGG_TILES_HASH}', '{AGG_TILES_HASH_BEFORE_APPLY}');"
90    );
91    query(&sql).execute(&mut conn).await?;
92
93    let sql = "
94    DELETE FROM metadata
95    WHERE name IN (SELECT name FROM patchDb.metadata WHERE value ISNULL);";
96    query(sql).execute(&mut conn).await?;
97
98    detach_db(&mut conn, "patchDb").await
99}
100
101fn get_select_from(src_type: MbtType, patch_type: MbtType) -> &'static str {
102    if src_type == Flat {
103        "SELECT zoom_level, tile_column, tile_row, tile_data FROM patchDb.tiles"
104    } else {
105        match patch_type {
106            Flat => {
107                "
108        SELECT zoom_level, tile_column, tile_row, tile_data, md5_hex(tile_data) as hash
109        FROM patchDb.tiles"
110            }
111            FlatWithHash => {
112                "
113        SELECT zoom_level, tile_column, tile_row, tile_data, tile_hash AS hash
114        FROM patchDb.tiles_with_hash"
115            }
116            Normalized { .. } => {
117                "
118        SELECT zoom_level, tile_column, tile_row, tile_data, map.tile_id AS hash
119        FROM patchDb.map LEFT JOIN patchDb.images
120          ON patchDb.map.tile_id = patchDb.images.tile_id"
121            }
122        }
123    }
124}
125
126fn get_insert_sql(src_type: MbtType, select_from: &str) -> (&'static str, String, Option<String>) {
127    match src_type {
128        Flat => (
129            "tiles",
130            format!(
131                "
132    INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data)
133    {select_from}"
134            ),
135            None,
136        ),
137        FlatWithHash => (
138            "tiles_with_hash",
139            format!(
140                "
141    INSERT OR REPLACE INTO tiles_with_hash (zoom_level, tile_column, tile_row, tile_data, tile_hash)
142    {select_from}"
143            ),
144            None,
145        ),
146        Normalized { .. } => (
147            "map",
148            format!(
149                "
150    INSERT OR REPLACE INTO map (zoom_level, tile_column, tile_row, tile_id)
151    SELECT zoom_level, tile_column, tile_row, hash as tile_id
152    FROM ({select_from})"
153            ),
154            Some(format!(
155                "
156    INSERT OR REPLACE INTO images (tile_id, tile_data)
157    SELECT hash as tile_id, tile_data
158    FROM ({select_from})"
159            )),
160        ),
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use sqlx::Executor as _;
167
168    use super::*;
169    use crate::MbtilesCopier;
170    use crate::metadata::temp_named_mbtiles;
171
172    #[actix_rt::test]
173    async fn apply_flat_patch_file() {
174        // Copy the src file to an in-memory DB
175        let script = include_str!("../../tests/fixtures/mbtiles/world_cities.sql");
176        let (_mbt, _conn, src_file) = temp_named_mbtiles("flat_src_file_mem", script).await;
177
178        let dst_file = PathBuf::from("file:apply_flat_patch_file?mode=memory&cache=shared");
179
180        let mut src_conn = MbtilesCopier {
181            src_file: src_file.clone(),
182            dst_file: dst_file.clone(),
183            ..Default::default()
184        }
185        .run()
186        .await
187        .unwrap();
188
189        // Apply patch to the src data in in-memory DB
190        let script = include_str!("../../tests/fixtures/mbtiles/world_cities_diff.sql");
191        let (_mbt, _conn, patch_file) = temp_named_mbtiles("flat_patch_file_mem", script).await;
192        apply_patch(dst_file, patch_file, true).await.unwrap();
193
194        // Verify the data is the same as the file the patch was generated from
195        let script = include_str!("../../tests/fixtures/mbtiles/world_cities_modified.sql");
196        let (mbt, _conn, _) = temp_named_mbtiles("flat_attached_mem_db", script).await;
197        mbt.attach_to(&mut src_conn, "testOtherDb").await.unwrap();
198
199        assert!(
200            src_conn
201                .fetch_optional("SELECT * FROM tiles EXCEPT SELECT * FROM testOtherDb.tiles;")
202                .await
203                .unwrap()
204                .is_none()
205        );
206    }
207
208    #[actix_rt::test]
209    async fn apply_normalized_patch_file() {
210        // Copy the src file to an in-memory DB
211        let script = include_str!("../../tests/fixtures/mbtiles/geography-class-jpg.sql");
212        let (_mbt, _conn, src_file) = temp_named_mbtiles("normalized_src_file_mem", script).await;
213
214        let dst_file =
215            PathBuf::from("file:apply_normalized_diff_file_mem_db?mode=memory&cache=shared");
216
217        let mut src_conn = MbtilesCopier {
218            src_file: src_file.clone(),
219            dst_file: dst_file.clone(),
220            ..Default::default()
221        }
222        .run()
223        .await
224        .unwrap();
225
226        // Apply patch to the src data in in-memory DB
227        let script = include_str!("../../tests/fixtures/mbtiles/geography-class-jpg-diff.sql");
228        let (_mbt, _conn, patch_file) =
229            temp_named_mbtiles("normalized_patch_file_mem", script).await;
230        apply_patch(dst_file, patch_file, true).await.unwrap();
231
232        // Verify the data is the same as the file the patch was generated from
233        let script = include_str!("../../tests/fixtures/mbtiles/geography-class-jpg-modified.sql");
234        let (mbt, _conn, _) = temp_named_mbtiles("normalized_attached_mem_db", script).await;
235        mbt.attach_to(&mut src_conn, "testOtherDb").await.unwrap();
236
237        assert!(
238            src_conn
239                .fetch_optional("SELECT * FROM tiles EXCEPT SELECT * FROM testOtherDb.tiles;")
240                .await
241                .unwrap()
242                .is_none()
243        );
244    }
245}