• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geo-engine / geoengine / 13053993104

30 Jan 2025 01:35PM UTC coverage: 90.027% (-0.07%) from 90.093%
13053993104

push

github

web-flow
Merge pull request #1011 from geo-engine/update-2025-01-17

update to rust 1.84

46 of 54 new or added lines in 14 files covered. (85.19%)

102 existing lines in 41 files now uncovered.

125596 of 139510 relevant lines covered (90.03%)

57696.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.05
/services/src/layers/postgres_layer_db.rs
1
use super::external::{DataProvider, TypedDataProviderDefinition};
2
use super::layer::{
3
    CollectionItem, Layer, LayerCollection, LayerCollectionListOptions, LayerCollectionListing,
4
    LayerListing, Property, ProviderLayerCollectionId, ProviderLayerId, UpdateLayer,
5
    UpdateLayerCollection,
6
};
7
use super::listing::{
8
    LayerCollectionProvider, ProviderCapabilities, SearchCapabilities, SearchParameters,
9
    SearchType, SearchTypes,
10
};
11
use super::storage::{
12
    LayerDb, LayerProviderDb, LayerProviderListing, LayerProviderListingOptions,
13
    INTERNAL_PROVIDER_ID,
14
};
15
use crate::contexts::PostgresDb;
16
use crate::layers::external::DataProviderDefinition;
17
use crate::permissions::{Permission, RoleId, TxPermissionDb};
18
use crate::workflows::registry::TxWorkflowRegistry;
19
use crate::{
20
    error::{self, Result},
21
    layers::{
22
        layer::{AddLayer, AddLayerCollection},
23
        listing::LayerCollectionId,
24
        storage::INTERNAL_LAYER_DB_ROOT_COLLECTION_ID,
25
        LayerDbError,
26
    },
27
};
28
use bb8_postgres::bb8::PooledConnection;
29
use bb8_postgres::tokio_postgres::{
30
    tls::{MakeTlsConnect, TlsConnect},
31
    Socket,
32
};
33
use bb8_postgres::PostgresConnectionManager;
34
use geoengine_datatypes::dataset::{DataProviderId, LayerId};
35
use geoengine_datatypes::error::BoxedResultExt;
36
use geoengine_datatypes::util::HashMapTextTextDbType;
37
use snafu::{ensure, ResultExt};
38
use std::str::FromStr;
39
use tokio_postgres::Transaction;
40
use tonic::async_trait;
41
use uuid::Uuid;
42

43
#[async_trait]
44
impl<Tls> LayerDb for PostgresDb<Tls>
45
where
46
    Tls: MakeTlsConnect<Socket> + Clone + Send + Sync + 'static + std::fmt::Debug,
47
    <Tls as MakeTlsConnect<Socket>>::Stream: Send + Sync,
48
    <Tls as MakeTlsConnect<Socket>>::TlsConnect: Send,
49
    <<Tls as MakeTlsConnect<Socket>>::TlsConnect as TlsConnect<Socket>>::Future: Send,
50
{
51
    async fn add_layer(&self, layer: AddLayer, collection: &LayerCollectionId) -> Result<LayerId> {
27✔
52
        let layer_id = Uuid::new_v4();
27✔
53
        let layer_id = LayerId(layer_id.to_string());
27✔
54

27✔
55
        self.add_layer_with_id(&layer_id, layer, collection).await?;
27✔
56

57
        Ok(layer_id)
27✔
58
    }
54✔
59

60
    async fn update_layer(&self, id: &LayerId, layer: UpdateLayer) -> Result<()> {
1✔
61
        let layer_id =
1✔
62
            Uuid::from_str(&id.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
1✔
63
                found: id.0.clone(),
×
64
            })?;
1✔
65

66
        let mut conn = self.conn_pool.get().await?;
1✔
67
        let transaction = conn.build_transaction().start().await?;
1✔
68

69
        self.ensure_permission_in_tx(id.clone().into(), Permission::Owner, &transaction)
1✔
70
            .await
1✔
71
            .boxed_context(crate::error::PermissionDb)?;
1✔
72

73
        let workflow_id = self
1✔
74
            .register_workflow_in_tx(layer.workflow, &transaction)
1✔
75
            .await?;
1✔
76

77
        transaction.execute(
1✔
78
                "
1✔
79
                UPDATE layers
1✔
80
                SET name = $1, description = $2, symbology = $3, properties = $4, metadata = $5, workflow_id = $6
1✔
81
                WHERE id = $7;",
1✔
82
                &[
1✔
83
                    &layer.name,
1✔
84
                    &layer.description,
1✔
85
                    &layer.symbology,
1✔
86
                    &layer.properties,
1✔
87
                    &HashMapTextTextDbType::from(&layer.metadata),
1✔
88
                    &workflow_id,
1✔
89
                    &layer_id,
1✔
90
                ],
1✔
91
            )
1✔
92
            .await?;
1✔
93

94
        transaction.commit().await.map_err(Into::into)
1✔
95
    }
2✔
96

97
    async fn remove_layer(&self, id: &LayerId) -> Result<()> {
1✔
98
        let layer_id =
1✔
99
            Uuid::from_str(&id.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
1✔
100
                found: id.0.clone(),
×
101
            })?;
1✔
102

103
        let mut conn = self.conn_pool.get().await?;
1✔
104
        let transaction = conn.build_transaction().start().await?;
1✔
105

106
        self.ensure_permission_in_tx(id.clone().into(), Permission::Owner, &transaction)
1✔
107
            .await
1✔
108
            .boxed_context(crate::error::PermissionDb)?;
1✔
109

110
        transaction
1✔
111
            .execute(
1✔
112
                "
1✔
113
            DELETE FROM layers
1✔
114
            WHERE id = $1;",
1✔
115
                &[&layer_id],
1✔
116
            )
1✔
117
            .await?;
1✔
118

119
        transaction.commit().await.map_err(Into::into)
1✔
120
    }
2✔
121

122
    async fn add_layer_with_id(
123
        &self,
124
        id: &LayerId,
125
        layer: AddLayer,
126
        collection: &LayerCollectionId,
127
    ) -> Result<()> {
27✔
128
        let mut conn = self.conn_pool.get().await?;
27✔
129
        let trans = conn.build_transaction().start().await?;
27✔
130

131
        self.ensure_permission_in_tx(collection.clone().into(), Permission::Owner, &trans)
27✔
132
            .await
27✔
133
            .boxed_context(crate::error::PermissionDb)?;
27✔
134

135
        let layer_id = insert_layer(self, &trans, id, layer, collection).await?;
27✔
136

137
        // TODO: `ON CONFLICT DO NOTHING` means, we do not get an error if the permission already exists.
138
        //       Do we want that, or should we report an error and let the caller decide whether to ignore it?
139
        //       We should decide that and adjust all places where `ON CONFLICT DO NOTHING` is used.
140
        let stmt = trans
27✔
141
            .prepare(
27✔
142
                "
27✔
143
            INSERT INTO permissions (role_id, permission, layer_id)
27✔
144
            VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;",
27✔
145
            )
27✔
146
            .await?;
27✔
147

148
        trans
27✔
149
            .execute(
27✔
150
                &stmt,
27✔
151
                &[
27✔
152
                    &RoleId::from(self.session.user.id),
27✔
153
                    &Permission::Owner,
27✔
154
                    &layer_id,
27✔
155
                ],
27✔
156
            )
27✔
157
            .await?;
27✔
158

159
        trans.commit().await?;
27✔
160

161
        Ok(())
27✔
162
    }
54✔
163

164
    async fn add_layer_to_collection(
165
        &self,
166
        layer: &LayerId,
167
        collection: &LayerCollectionId,
168
    ) -> Result<()> {
3✔
169
        let mut conn = self.conn_pool.get().await?;
3✔
170
        let tx = conn.build_transaction().start().await?;
3✔
171

172
        self.ensure_permission_in_tx(collection.clone().into(), Permission::Owner, &tx)
3✔
173
            .await
3✔
174
            .boxed_context(crate::error::PermissionDb)?;
3✔
175

176
        let layer_id =
2✔
177
            Uuid::from_str(&layer.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
3✔
178
                found: layer.0.clone(),
1✔
179
            })?;
3✔
180

181
        let collection_id =
2✔
182
            Uuid::from_str(&collection.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
2✔
183
                found: collection.0.clone(),
×
184
            })?;
2✔
185

186
        let stmt = tx
2✔
187
            .prepare(
2✔
188
                "
2✔
189
            INSERT INTO collection_layers (collection, layer)
2✔
190
            VALUES ($1, $2) ON CONFLICT DO NOTHING;",
2✔
191
            )
2✔
192
            .await?;
2✔
193

194
        tx.execute(&stmt, &[&collection_id, &layer_id]).await?;
2✔
195

196
        tx.commit().await?;
2✔
197

198
        Ok(())
2✔
199
    }
6✔
200

201
    async fn add_layer_collection(
202
        &self,
203
        collection: AddLayerCollection,
204
        parent: &LayerCollectionId,
205
    ) -> Result<LayerCollectionId> {
32✔
206
        let collection_id = Uuid::new_v4();
32✔
207
        let collection_id = LayerCollectionId(collection_id.to_string());
32✔
208

32✔
209
        self.add_layer_collection_with_id(&collection_id, collection, parent)
32✔
210
            .await?;
32✔
211

212
        Ok(collection_id)
32✔
213
    }
64✔
214

215
    async fn add_layer_collection_with_id(
216
        &self,
217
        id: &LayerCollectionId,
218
        collection: AddLayerCollection,
219
        parent: &LayerCollectionId,
220
    ) -> Result<()> {
32✔
221
        let mut conn = self.conn_pool.get().await?;
32✔
222
        let trans = conn.build_transaction().start().await?;
32✔
223

224
        self.ensure_permission_in_tx(parent.clone().into(), Permission::Owner, &trans)
32✔
225
            .await
32✔
226
            .boxed_context(crate::error::PermissionDb)?;
32✔
227

228
        let collection_id = insert_layer_collection_with_id(&trans, id, collection, parent).await?;
32✔
229

230
        let stmt = trans
32✔
231
            .prepare(
32✔
232
                "
32✔
233
            INSERT INTO permissions (role_id, permission, layer_collection_id)
32✔
234
            VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;",
32✔
235
            )
32✔
236
            .await?;
32✔
237

238
        trans
32✔
239
            .execute(
32✔
240
                &stmt,
32✔
241
                &[
32✔
242
                    &RoleId::from(self.session.user.id),
32✔
243
                    &Permission::Owner,
32✔
244
                    &collection_id,
32✔
245
                ],
32✔
246
            )
32✔
247
            .await?;
32✔
248

249
        trans.commit().await?;
32✔
250

251
        Ok(())
32✔
252
    }
64✔
253

254
    async fn add_collection_to_parent(
255
        &self,
256
        collection: &LayerCollectionId,
257
        parent: &LayerCollectionId,
258
    ) -> Result<()> {
2✔
259
        let conn = self.conn_pool.get().await?;
2✔
260
        insert_collection_parent(&conn, collection, parent).await
2✔
261
    }
4✔
262

263
    async fn remove_layer_collection(&self, collection: &LayerCollectionId) -> Result<()> {
5✔
264
        let mut conn = self.conn_pool.get().await?;
5✔
265
        let transaction = conn.build_transaction().start().await?;
5✔
266

267
        self.ensure_permission_in_tx(collection.clone().into(), Permission::Owner, &transaction)
5✔
268
            .await
5✔
269
            .boxed_context(crate::error::PermissionDb)?;
5✔
270

271
        delete_layer_collection(&transaction, collection).await?;
5✔
272

273
        transaction.commit().await.map_err(Into::into)
3✔
274
    }
10✔
275

276
    async fn remove_layer_from_collection(
277
        &self,
278
        layer: &LayerId,
279
        collection: &LayerCollectionId,
280
    ) -> Result<()> {
3✔
281
        let mut conn = self.conn_pool.get().await?;
3✔
282
        let transaction = conn.build_transaction().start().await?;
3✔
283

284
        self.ensure_permission_in_tx(collection.clone().into(), Permission::Owner, &transaction)
3✔
285
            .await
3✔
286
            .boxed_context(crate::error::PermissionDb)?;
3✔
287

288
        delete_layer_from_collection(&transaction, layer, collection).await?;
3✔
289

290
        transaction.commit().await.map_err(Into::into)
3✔
291
    }
6✔
292

293
    async fn remove_layer_collection_from_parent(
294
        &self,
295
        collection: &LayerCollectionId,
296
        parent: &LayerCollectionId,
297
    ) -> Result<()> {
2✔
298
        let mut conn = self.conn_pool.get().await?;
2✔
299
        let transaction = conn.build_transaction().start().await?;
2✔
300

301
        self.ensure_permission_in_tx(collection.clone().into(), Permission::Owner, &transaction)
2✔
302
            .await
2✔
303
            .boxed_context(crate::error::PermissionDb)?;
2✔
304

305
        delete_layer_collection_from_parent(&transaction, collection, parent).await?;
2✔
306

307
        transaction.commit().await.map_err(Into::into)
2✔
308
    }
4✔
309

310
    async fn update_layer_collection(
311
        &self,
312
        collection: &LayerCollectionId,
313
        update: UpdateLayerCollection,
314
    ) -> Result<()> {
1✔
315
        let collection_id =
1✔
316
            Uuid::from_str(&collection.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
1✔
317
                found: collection.0.clone(),
×
318
            })?;
1✔
319

320
        let mut conn = self.conn_pool.get().await?;
1✔
321
        let transaction = conn.build_transaction().start().await?;
1✔
322

323
        self.ensure_permission_in_tx(collection.clone().into(), Permission::Owner, &transaction)
1✔
324
            .await
1✔
325
            .boxed_context(crate::error::PermissionDb)?;
1✔
326

327
        transaction
1✔
328
            .execute(
1✔
329
                "UPDATE layer_collections 
1✔
330
                SET name = $1, description = $2, properties = $3
1✔
331
                WHERE id = $4;",
1✔
332
                &[
1✔
333
                    &update.name,
1✔
334
                    &update.description,
1✔
335
                    &update.properties,
1✔
336
                    &collection_id,
1✔
337
                ],
1✔
338
            )
1✔
339
            .await?;
1✔
340

341
        transaction.commit().await.map_err(Into::into)
1✔
342
    }
2✔
343
}
344

345
fn create_search_query(full_info: bool) -> String {
32✔
346
    format!("
32✔
347
        WITH RECURSIVE parents AS (
32✔
348
            SELECT $1::uuid as id
32✔
349
            UNION ALL SELECT DISTINCT child FROM collection_children JOIN parents ON (id = parent)
32✔
350
        )
32✔
351
        SELECT DISTINCT *
32✔
352
        FROM (
32✔
353
            SELECT 
32✔
354
                {}
32✔
355
            FROM user_permitted_layer_collections u
32✔
356
                JOIN layer_collections lc ON (u.layer_collection_id = lc.id)
32✔
357
                JOIN (SELECT DISTINCT child FROM collection_children JOIN parents ON (id = parent)) cc ON (id = cc.child)
32✔
358
            WHERE u.user_id = $4 AND name ILIKE $5
32✔
359
        ) u UNION (
32✔
360
            SELECT 
32✔
361
                {}
32✔
362
            FROM user_permitted_layers ul
32✔
363
                JOIN layers uc ON (ul.layer_id = uc.id)
32✔
364
                JOIN (SELECT DISTINCT layer FROM collection_layers JOIN parents ON (collection = id)) cl ON (id = cl.layer)
32✔
365
            WHERE ul.user_id = $4 AND name ILIKE $5
32✔
366
        )
32✔
367
        ORDER BY {}name ASC
32✔
368
        LIMIT $2 
32✔
369
        OFFSET $3;",
32✔
370
        if full_info {
32✔
371
            "concat(id, '') AS id,
16✔
372
        name,
16✔
373
        description,
16✔
374
        properties,
16✔
375
        FALSE AS is_layer"
16✔
376
        } else { "name" },
16✔
377
        if full_info {
32✔
378
            "concat(id, '') AS id,
16✔
379
        name,
16✔
380
        description,
16✔
381
        properties,
16✔
382
        TRUE AS is_layer"
16✔
383
        } else { "name" },
16✔
384
        if full_info { "is_layer ASC," } else { "" })
32✔
385
}
32✔
386

387
#[async_trait]
388
impl<Tls> LayerCollectionProvider for PostgresDb<Tls>
389
where
390
    Tls: MakeTlsConnect<Socket> + Clone + Send + Sync + 'static + std::fmt::Debug,
391
    <Tls as MakeTlsConnect<Socket>>::Stream: Send + Sync,
392
    <Tls as MakeTlsConnect<Socket>>::TlsConnect: Send,
393
    <<Tls as MakeTlsConnect<Socket>>::TlsConnect as TlsConnect<Socket>>::Future: Send,
394
{
395
    fn capabilities(&self) -> ProviderCapabilities {
2✔
396
        ProviderCapabilities {
2✔
397
            listing: true,
2✔
398
            search: SearchCapabilities {
2✔
399
                search_types: SearchTypes {
2✔
400
                    fulltext: true,
2✔
401
                    prefix: true,
2✔
402
                },
2✔
403
                autocomplete: true,
2✔
404
                filters: None,
2✔
405
            },
2✔
406
        }
2✔
407
    }
2✔
408

NEW
409
    fn name(&self) -> &'static str {
×
410
        "Postgres Layer Collection Provider (Pro)"
×
411
    }
×
412

NEW
413
    fn description(&self) -> &'static str {
×
414
        "Layer collection provider for Postgres (Pro)"
×
415
    }
×
416

417
    #[allow(clippy::too_many_lines)]
418
    async fn load_layer_collection(
419
        &self,
420
        collection_id: &LayerCollectionId,
421
        options: LayerCollectionListOptions,
422
    ) -> Result<LayerCollection> {
21✔
423
        let mut conn = self.conn_pool.get().await?;
21✔
424
        let tx = conn.build_transaction().start().await?;
21✔
425

426
        self.ensure_permission_in_tx(collection_id.clone().into(), Permission::Read, &tx)
21✔
427
            .await
21✔
428
            .boxed_context(crate::error::PermissionDb)?;
21✔
429

430
        let collection = Uuid::from_str(&collection_id.0).map_err(|_| {
17✔
431
            crate::error::Error::IdStringMustBeUuid {
×
432
                found: collection_id.0.clone(),
×
433
            }
×
434
        })?;
17✔
435

436
        let stmt = tx
17✔
437
            .prepare(
17✔
438
                "
17✔
439
        SELECT name, description, properties
17✔
440
        FROM user_permitted_layer_collections p 
17✔
441
            JOIN layer_collections c ON (p.layer_collection_id = c.id) 
17✔
442
        WHERE p.user_id = $1 AND layer_collection_id = $2;",
17✔
443
            )
17✔
444
            .await?;
17✔
445

446
        let row = tx
17✔
447
            .query_one(&stmt, &[&self.session.user.id, &collection])
17✔
448
            .await?;
17✔
449

450
        let name: String = row.get(0);
17✔
451
        let description: String = row.get(1);
17✔
452
        let properties: Vec<Property> = row.get(2);
17✔
453

454
        let stmt = tx
17✔
455
            .prepare(
17✔
456
                "
17✔
457
        SELECT DISTINCT id, name, description, properties, is_layer
17✔
458
        FROM (
17✔
459
            SELECT 
17✔
460
                concat(id, '') AS id, 
17✔
461
                name, 
17✔
462
                description, 
17✔
463
                properties, 
17✔
464
                FALSE AS is_layer
17✔
465
            FROM user_permitted_layer_collections u 
17✔
466
                JOIN layer_collections lc ON (u.layer_collection_id = lc.id)
17✔
467
                JOIN collection_children cc ON (layer_collection_id = cc.child)
17✔
468
            WHERE u.user_id = $4 AND cc.parent = $1
17✔
469
        ) u UNION (
17✔
470
            SELECT 
17✔
471
                concat(id, '') AS id, 
17✔
472
                name, 
17✔
473
                description, 
17✔
474
                properties, 
17✔
475
                TRUE AS is_layer
17✔
476
            FROM user_permitted_layers ul
17✔
477
                JOIN layers uc ON (ul.layer_id = uc.id) 
17✔
478
                JOIN collection_layers cl ON (layer_id = cl.layer)
17✔
479
            WHERE ul.user_id = $4 AND cl.collection = $1
17✔
480
        )
17✔
481
        ORDER BY is_layer ASC, name ASC
17✔
482
        LIMIT $2 
17✔
483
        OFFSET $3;            
17✔
484
        ",
17✔
485
            )
17✔
486
            .await?;
17✔
487

488
        let rows = tx
17✔
489
            .query(
17✔
490
                &stmt,
17✔
491
                &[
17✔
492
                    &collection,
17✔
493
                    &i64::from(options.limit),
17✔
494
                    &i64::from(options.offset),
17✔
495
                    &self.session.user.id,
17✔
496
                ],
17✔
497
            )
17✔
498
            .await?;
17✔
499

500
        let items = rows
17✔
501
            .into_iter()
17✔
502
            .map(|row| {
19✔
503
                let is_layer: bool = row.get(4);
19✔
504

19✔
505
                if is_layer {
19✔
506
                    Ok(CollectionItem::Layer(LayerListing {
7✔
507
                        id: ProviderLayerId {
7✔
508
                            provider_id: INTERNAL_PROVIDER_ID,
7✔
509
                            layer_id: LayerId(row.get(0)),
7✔
510
                        },
7✔
511
                        name: row.get(1),
7✔
512
                        description: row.get(2),
7✔
513
                        properties: row.get(3),
7✔
514
                    }))
7✔
515
                } else {
516
                    Ok(CollectionItem::Collection(LayerCollectionListing {
12✔
517
                        id: ProviderLayerCollectionId {
12✔
518
                            provider_id: INTERNAL_PROVIDER_ID,
12✔
519
                            collection_id: LayerCollectionId(row.get(0)),
12✔
520
                        },
12✔
521
                        name: row.get(1),
12✔
522
                        description: row.get(2),
12✔
523
                        properties: row.get(3),
12✔
524
                    }))
12✔
525
                }
526
            })
19✔
527
            .collect::<Result<Vec<CollectionItem>>>()?;
17✔
528

529
        tx.commit().await?;
17✔
530

531
        Ok(LayerCollection {
17✔
532
            id: ProviderLayerCollectionId {
17✔
533
                provider_id: INTERNAL_PROVIDER_ID,
17✔
534
                collection_id: collection_id.clone(),
17✔
535
            },
17✔
536
            name,
17✔
537
            description,
17✔
538
            items,
17✔
539
            entry_label: None,
17✔
540
            properties,
17✔
541
        })
17✔
542
    }
42✔
543

544
    #[allow(clippy::too_many_lines)]
545
    async fn search(
546
        &self,
547
        collection_id: &LayerCollectionId,
548
        search: SearchParameters,
549
    ) -> Result<LayerCollection> {
16✔
550
        let mut conn = self.conn_pool.get().await?;
16✔
551
        let tx = conn.build_transaction().start().await?;
16✔
552

553
        self.ensure_permission_in_tx(collection_id.clone().into(), Permission::Read, &tx)
16✔
554
            .await
16✔
555
            .boxed_context(crate::error::PermissionDb)?;
16✔
556

557
        let collection = Uuid::from_str(&collection_id.0).map_err(|_| {
16✔
558
            crate::error::Error::IdStringMustBeUuid {
×
559
                found: collection_id.0.clone(),
×
560
            }
×
561
        })?;
16✔
562

563
        let stmt = tx
16✔
564
            .prepare(
16✔
565
                "
16✔
566
        SELECT name, description, properties
16✔
567
        FROM user_permitted_layer_collections p 
16✔
568
            JOIN layer_collections c ON (p.layer_collection_id = c.id) 
16✔
569
        WHERE p.user_id = $1 AND layer_collection_id = $2;",
16✔
570
            )
16✔
571
            .await?;
16✔
572

573
        let row = tx
16✔
574
            .query_one(&stmt, &[&self.session.user.id, &collection])
16✔
575
            .await?;
16✔
576

577
        let name: String = row.get(0);
16✔
578
        let description: String = row.get(1);
16✔
579
        let properties: Vec<Property> = row.get(2);
16✔
580

581
        let pattern = match search.search_type {
16✔
582
            SearchType::Fulltext => {
583
                format!("%{}%", search.search_string)
11✔
584
            }
585
            SearchType::Prefix => {
586
                format!("{}%", search.search_string)
5✔
587
            }
588
        };
589

590
        let stmt = tx.prepare(&create_search_query(true)).await?;
16✔
591

592
        let rows = tx
16✔
593
            .query(
16✔
594
                &stmt,
16✔
595
                &[
16✔
596
                    &collection,
16✔
597
                    &i64::from(search.limit),
16✔
598
                    &i64::from(search.offset),
16✔
599
                    &self.session.user.id,
16✔
600
                    &pattern,
16✔
601
                ],
16✔
602
            )
16✔
603
            .await?;
16✔
604

605
        let items = rows
16✔
606
            .into_iter()
16✔
607
            .map(|row| {
31✔
608
                let is_layer: bool = row.get(4);
31✔
609

31✔
610
                if is_layer {
31✔
611
                    Ok(CollectionItem::Layer(LayerListing {
13✔
612
                        id: ProviderLayerId {
13✔
613
                            provider_id: INTERNAL_PROVIDER_ID,
13✔
614
                            layer_id: LayerId(row.get(0)),
13✔
615
                        },
13✔
616
                        name: row.get(1),
13✔
617
                        description: row.get(2),
13✔
618
                        properties: row.get(3),
13✔
619
                    }))
13✔
620
                } else {
621
                    Ok(CollectionItem::Collection(LayerCollectionListing {
18✔
622
                        id: ProviderLayerCollectionId {
18✔
623
                            provider_id: INTERNAL_PROVIDER_ID,
18✔
624
                            collection_id: LayerCollectionId(row.get(0)),
18✔
625
                        },
18✔
626
                        name: row.get(1),
18✔
627
                        description: row.get(2),
18✔
628
                        properties: row.get(3),
18✔
629
                    }))
18✔
630
                }
631
            })
31✔
632
            .collect::<Result<Vec<CollectionItem>>>()?;
16✔
633

634
        tx.commit().await?;
16✔
635

636
        Ok(LayerCollection {
16✔
637
            id: ProviderLayerCollectionId {
16✔
638
                provider_id: INTERNAL_PROVIDER_ID,
16✔
639
                collection_id: collection_id.clone(),
16✔
640
            },
16✔
641
            name,
16✔
642
            description,
16✔
643
            items,
16✔
644
            entry_label: None,
16✔
645
            properties,
16✔
646
        })
16✔
647
    }
32✔
648

649
    #[allow(clippy::too_many_lines)]
650
    async fn autocomplete_search(
651
        &self,
652
        collection_id: &LayerCollectionId,
653
        search: SearchParameters,
654
    ) -> Result<Vec<String>> {
16✔
655
        let mut conn = self.conn_pool.get().await?;
16✔
656
        let tx = conn.build_transaction().start().await?;
16✔
657

658
        self.ensure_permission_in_tx(collection_id.clone().into(), Permission::Read, &tx)
16✔
659
            .await
16✔
660
            .boxed_context(crate::error::PermissionDb)?;
16✔
661

662
        let collection = Uuid::from_str(&collection_id.0).map_err(|_| {
16✔
663
            crate::error::Error::IdStringMustBeUuid {
×
664
                found: collection_id.0.clone(),
×
665
            }
×
666
        })?;
16✔
667

668
        let pattern = match search.search_type {
16✔
669
            SearchType::Fulltext => {
670
                format!("%{}%", search.search_string)
11✔
671
            }
672
            SearchType::Prefix => {
673
                format!("{}%", search.search_string)
5✔
674
            }
675
        };
676

677
        let stmt = tx.prepare(&create_search_query(false)).await?;
16✔
678

679
        let rows = tx
16✔
680
            .query(
16✔
681
                &stmt,
16✔
682
                &[
16✔
683
                    &collection,
16✔
684
                    &i64::from(search.limit),
16✔
685
                    &i64::from(search.offset),
16✔
686
                    &self.session.user.id,
16✔
687
                    &pattern,
16✔
688
                ],
16✔
689
            )
16✔
690
            .await?;
16✔
691

692
        let items = rows
16✔
693
            .into_iter()
16✔
694
            .map(|row| Ok(row.get::<usize, &str>(0).to_string()))
31✔
695
            .collect::<Result<Vec<String>>>()?;
16✔
696

697
        tx.commit().await?;
16✔
698

699
        Ok(items)
16✔
700
    }
32✔
701

702
    async fn get_root_layer_collection_id(&self) -> Result<LayerCollectionId> {
30✔
703
        Ok(LayerCollectionId(
30✔
704
            INTERNAL_LAYER_DB_ROOT_COLLECTION_ID.to_string(),
30✔
705
        ))
30✔
706
    }
60✔
707

708
    async fn load_layer(&self, id: &LayerId) -> Result<Layer> {
20✔
709
        let mut conn = self.conn_pool.get().await?;
20✔
710
        let tx = conn.build_transaction().start().await?;
20✔
711

712
        self.ensure_permission_in_tx(id.clone().into(), Permission::Read, &tx)
20✔
713
            .await
20✔
714
            .boxed_context(crate::error::PermissionDb)?;
20✔
715

716
        let layer_id =
15✔
717
            Uuid::from_str(&id.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
15✔
718
                found: id.0.clone(),
×
719
            })?;
15✔
720

721
        let stmt = tx
15✔
722
            .prepare(
15✔
723
                "
15✔
724
            SELECT 
15✔
725
                l.name,
15✔
726
                l.description,
15✔
727
                w.workflow,
15✔
728
                l.symbology,
15✔
729
                l.properties,
15✔
730
                l.metadata
15✔
731
            FROM 
15✔
732
                layers l JOIN workflows w ON (l.workflow_id = w.id)
15✔
733
            WHERE 
15✔
734
                l.id = $1;",
15✔
735
            )
15✔
736
            .await?;
15✔
737

738
        let row = tx
15✔
739
            .query_one(&stmt, &[&layer_id])
15✔
740
            .await
15✔
741
            .map_err(|_error| LayerDbError::NoLayerForGivenId { id: id.clone() })?;
15✔
742

743
        tx.commit().await?;
15✔
744

745
        Ok(Layer {
746
            id: ProviderLayerId {
15✔
747
                provider_id: INTERNAL_PROVIDER_ID,
15✔
748
                layer_id: id.clone(),
15✔
749
            },
15✔
750
            name: row.get(0),
15✔
751
            description: row.get(1),
15✔
752
            workflow: serde_json::from_value(row.get(2)).context(crate::error::SerdeJson)?,
15✔
753
            symbology: row.get(3),
15✔
754
            properties: row.get(4),
15✔
755
            metadata: row.get::<_, HashMapTextTextDbType>(5).into(),
15✔
756
        })
757
    }
40✔
758
}
759

760
#[async_trait]
761
impl<Tls> LayerProviderDb for PostgresDb<Tls>
762
where
763
    Tls: MakeTlsConnect<Socket> + Clone + Send + Sync + 'static + std::fmt::Debug,
764
    <Tls as MakeTlsConnect<Socket>>::Stream: Send + Sync,
765
    <Tls as MakeTlsConnect<Socket>>::TlsConnect: Send,
766
    <<Tls as MakeTlsConnect<Socket>>::TlsConnect as TlsConnect<Socket>>::Future: Send,
767
{
768
    async fn add_layer_provider(
769
        &self,
770
        provider: TypedDataProviderDefinition,
771
    ) -> Result<DataProviderId> {
9✔
772
        ensure!(self.session.is_admin(), error::PermissionDenied);
9✔
773

774
        let conn = self.conn_pool.get().await?;
9✔
775

776
        let prio = DataProviderDefinition::<Self>::priority(&provider);
9✔
777
        let clamp_prio = prio.clamp(-1000, 1000);
9✔
778

9✔
779
        if prio != clamp_prio {
9✔
780
            log::warn!(
×
781
                "The priority of the provider {} is out of range! --> clamped {} to {}",
×
782
                DataProviderDefinition::<Self>::name(&provider),
×
783
                prio,
784
                clamp_prio
785
            );
786
        }
9✔
787

788
        let stmt = conn
9✔
789
            .prepare(
9✔
790
                "
9✔
791
              INSERT INTO layer_providers (
9✔
792
                  id, 
9✔
793
                  type_name, 
9✔
794
                  name,
9✔
795
                  definition,
9✔
796
                  priority
9✔
797
              )
9✔
798
              VALUES ($1, $2, $3, $4, $5)",
9✔
799
            )
9✔
800
            .await?;
9✔
801

802
        let id = DataProviderDefinition::<Self>::id(&provider);
9✔
803
        conn.execute(
9✔
804
            &stmt,
9✔
805
            &[
9✔
806
                &id,
9✔
807
                &DataProviderDefinition::<Self>::type_name(&provider),
9✔
808
                &DataProviderDefinition::<Self>::name(&provider),
9✔
809
                &provider,
9✔
810
                &clamp_prio,
9✔
811
            ],
9✔
812
        )
9✔
813
        .await?;
9✔
814
        Ok(id)
9✔
815
    }
18✔
816

817
    async fn list_layer_providers(
818
        &self,
819
        options: LayerProviderListingOptions,
820
    ) -> Result<Vec<LayerProviderListing>> {
2✔
821
        // TODO: permission
822
        let conn = self.conn_pool.get().await?;
2✔
823

824
        let stmt = conn
2✔
825
            .prepare(
2✔
826
                "
2✔
827
                SELECT 
2✔
828
                    id, 
2✔
829
                    name,
2✔
830
                    type_name,
2✔
831
                    priority
2✔
832
                FROM 
2✔
833
                    layer_providers
2✔
834
                WHERE
2✔
835
                    priority > -1000
2✔
836
                ORDER BY priority desc, name ASC
2✔
837
                LIMIT $1 
2✔
838
                OFFSET $2;
2✔
839
                ",
2✔
840
            )
2✔
841
            .await?;
2✔
842

843
        let rows = conn
2✔
844
            .query(
2✔
845
                &stmt,
2✔
846
                &[&i64::from(options.limit), &i64::from(options.offset)],
2✔
847
            )
2✔
848
            .await?;
2✔
849

850
        Ok(rows
2✔
851
            .iter()
2✔
852
            .map(|row| LayerProviderListing {
2✔
853
                id: row.get(0),
2✔
854
                name: row.get(1),
2✔
855
                priority: row.get(3),
2✔
856
            })
2✔
857
            .collect())
2✔
858
    }
4✔
859

860
    async fn load_layer_provider(&self, id: DataProviderId) -> Result<Box<dyn DataProvider>> {
19✔
861
        // TODO: permissions
862
        let conn = self.conn_pool.get().await?;
19✔
863

864
        let stmt = conn
19✔
865
            .prepare(
19✔
866
                "
19✔
867
                SELECT
19✔
868
                    definition
19✔
869
                FROM
19✔
870
                    layer_providers
19✔
871
                WHERE
19✔
872
                    id = $1
19✔
873
                ",
19✔
874
            )
19✔
875
            .await?;
19✔
876

877
        let row = conn.query_one(&stmt, &[&id]).await?;
19✔
878
        let definition: TypedDataProviderDefinition = row.get(0);
12✔
879

12✔
880
        return Box::new(definition)
12✔
881
            .initialize(PostgresDb {
12✔
882
                conn_pool: self.conn_pool.clone(),
12✔
883
                session: self.session.clone(),
12✔
884
            })
12✔
885
            .await;
12✔
886
    }
38✔
887
}
888

889
/// delete all collections without parent collection
890
async fn _remove_collections_without_parent_collection(
5✔
891
    transaction: &tokio_postgres::Transaction<'_>,
5✔
892
) -> Result<()> {
5✔
893
    // HINT: a recursive delete statement seems reasonable, but hard to implement in postgres
894
    //       because you have a graph with potential loops
895

896
    let remove_layer_collections_without_parents_stmt = transaction
5✔
897
        .prepare(
5✔
898
            "DELETE FROM layer_collections
5✔
899
                 WHERE  id <> $1 -- do not delete root collection
5✔
900
                 AND    id NOT IN (
5✔
901
                    SELECT child FROM collection_children
5✔
902
                 );",
5✔
903
        )
5✔
904
        .await?;
5✔
905
    while 0 < transaction
8✔
906
        .execute(
8✔
907
            &remove_layer_collections_without_parents_stmt,
8✔
908
            &[&INTERNAL_LAYER_DB_ROOT_COLLECTION_ID],
8✔
909
        )
8✔
910
        .await?
8✔
911
    {
3✔
912
        // whenever one collection is deleted, we have to check again if there are more
3✔
913
        // collections without parents
3✔
914
    }
3✔
915

916
    Ok(())
5✔
917
}
5✔
918

919
/// delete all layers without parent collection
920
#[allow(clippy::used_underscore_items)] // TODO: maybe rename?
921
async fn _remove_layers_without_parent_collection(
8✔
922
    transaction: &tokio_postgres::Transaction<'_>,
8✔
923
) -> Result<()> {
8✔
924
    let remove_layers_without_parents_stmt = transaction
8✔
925
        .prepare(
8✔
926
            "DELETE FROM layers
8✔
927
                 WHERE id NOT IN (
8✔
928
                    SELECT layer FROM collection_layers
8✔
929
                 );",
8✔
930
        )
8✔
931
        .await?;
8✔
932
    transaction
8✔
933
        .execute(&remove_layers_without_parents_stmt, &[])
8✔
934
        .await?;
8✔
935

936
    Ok(())
8✔
937
}
8✔
938

939
pub async fn insert_layer<W: TxWorkflowRegistry>(
27✔
940
    workflow_registry: &W,
27✔
941
    trans: &Transaction<'_>,
27✔
942
    id: &LayerId,
27✔
943
    layer: AddLayer,
27✔
944
    collection: &LayerCollectionId,
27✔
945
) -> Result<Uuid> {
27✔
946
    let layer_id = Uuid::from_str(&id.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
27✔
947
        found: collection.0.clone(),
×
948
    })?;
27✔
949

950
    let collection_id =
27✔
951
        Uuid::from_str(&collection.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
27✔
952
            found: collection.0.clone(),
×
953
        })?;
27✔
954

955
    let workflow_id = workflow_registry
27✔
956
        .register_workflow_in_tx(layer.workflow, trans)
27✔
957
        .await?;
27✔
958

959
    let stmt = trans
27✔
960
        .prepare(
27✔
961
            "
27✔
962
            INSERT INTO layers (id, name, description, workflow_id, symbology, properties, metadata)
27✔
963
            VALUES ($1, $2, $3, $4, $5, $6, $7);",
27✔
964
        )
27✔
965
        .await?;
27✔
966

967
    trans
27✔
968
        .execute(
27✔
969
            &stmt,
27✔
970
            &[
27✔
971
                &layer_id,
27✔
972
                &layer.name,
27✔
973
                &layer.description,
27✔
974
                &workflow_id,
27✔
975
                &layer.symbology,
27✔
976
                &layer.properties,
27✔
977
                &HashMapTextTextDbType::from(&layer.metadata),
27✔
978
            ],
27✔
979
        )
27✔
980
        .await?;
27✔
981

982
    let stmt = trans
27✔
983
        .prepare(
27✔
984
            "
27✔
985
            INSERT INTO collection_layers (collection, layer)
27✔
986
            VALUES ($1, $2) ON CONFLICT DO NOTHING;",
27✔
987
        )
27✔
988
        .await?;
27✔
989

990
    trans.execute(&stmt, &[&collection_id, &layer_id]).await?;
27✔
991

992
    Ok(layer_id)
27✔
993
}
27✔
994

995
pub async fn insert_layer_collection_with_id(
32✔
996
    trans: &Transaction<'_>,
32✔
997
    id: &LayerCollectionId,
32✔
998
    collection: AddLayerCollection,
32✔
999
    parent: &LayerCollectionId,
32✔
1000
) -> Result<Uuid> {
32✔
1001
    let collection_id =
32✔
1002
        Uuid::from_str(&id.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
32✔
1003
            found: id.0.clone(),
×
1004
        })?;
32✔
1005

1006
    let parent =
32✔
1007
        Uuid::from_str(&parent.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
32✔
1008
            found: parent.0.clone(),
×
1009
        })?;
32✔
1010

1011
    let stmt = trans
32✔
1012
        .prepare(
32✔
1013
            "
32✔
1014
        INSERT INTO layer_collections (id, name, description, properties)
32✔
1015
        VALUES ($1, $2, $3, $4);",
32✔
1016
        )
32✔
1017
        .await?;
32✔
1018

1019
    trans
32✔
1020
        .execute(
32✔
1021
            &stmt,
32✔
1022
            &[
32✔
1023
                &collection_id,
32✔
1024
                &collection.name,
32✔
1025
                &collection.description,
32✔
1026
                &collection.properties,
32✔
1027
            ],
32✔
1028
        )
32✔
1029
        .await?;
32✔
1030

1031
    let stmt = trans
32✔
1032
        .prepare(
32✔
1033
            "
32✔
1034
        INSERT INTO collection_children (parent, child)
32✔
1035
        VALUES ($1, $2) ON CONFLICT DO NOTHING;",
32✔
1036
        )
32✔
1037
        .await?;
32✔
1038

1039
    trans.execute(&stmt, &[&parent, &collection_id]).await?;
32✔
1040

1041
    Ok(collection_id)
32✔
1042
}
32✔
1043

1044
pub async fn insert_collection_parent<Tls>(
2✔
1045
    conn: &PooledConnection<'_, PostgresConnectionManager<Tls>>,
2✔
1046
    collection: &LayerCollectionId,
2✔
1047
    parent: &LayerCollectionId,
2✔
1048
) -> Result<()>
2✔
1049
where
2✔
1050
    Tls: MakeTlsConnect<Socket> + Clone + Send + Sync + 'static + std::fmt::Debug,
2✔
1051
    <Tls as MakeTlsConnect<Socket>>::Stream: Send + Sync,
2✔
1052
    <Tls as MakeTlsConnect<Socket>>::TlsConnect: Send,
2✔
1053
    <<Tls as MakeTlsConnect<Socket>>::TlsConnect as TlsConnect<Socket>>::Future: Send,
2✔
1054
{
2✔
1055
    let collection =
2✔
1056
        Uuid::from_str(&collection.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
2✔
1057
            found: collection.0.clone(),
×
1058
        })?;
2✔
1059

1060
    let parent =
2✔
1061
        Uuid::from_str(&parent.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
2✔
1062
            found: parent.0.clone(),
×
1063
        })?;
2✔
1064

1065
    let stmt = conn
2✔
1066
        .prepare(
2✔
1067
            "
2✔
1068
        INSERT INTO collection_children (parent, child)
2✔
1069
        VALUES ($1, $2) ON CONFLICT DO NOTHING;",
2✔
1070
        )
2✔
1071
        .await?;
2✔
1072

1073
    conn.execute(&stmt, &[&parent, &collection]).await?;
2✔
1074

1075
    Ok(())
2✔
1076
}
2✔
1077

1078
pub async fn delete_layer_collection(
5✔
1079
    transaction: &Transaction<'_>,
5✔
1080
    collection: &LayerCollectionId,
5✔
1081
) -> Result<()> {
5✔
1082
    let collection =
5✔
1083
        Uuid::from_str(&collection.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
5✔
1084
            found: collection.0.clone(),
×
1085
        })?;
5✔
1086

1087
    if collection == INTERNAL_LAYER_DB_ROOT_COLLECTION_ID {
5✔
1088
        return Err(LayerDbError::CannotRemoveRootCollection.into());
2✔
1089
    }
3✔
1090

1091
    // delete the collection!
1092
    // on delete cascade removes all entries from `collection_children` and `collection_layers`
1093

1094
    let remove_layer_collection_stmt = transaction
3✔
1095
        .prepare(
3✔
1096
            "DELETE FROM layer_collections
3✔
1097
             WHERE id = $1;",
3✔
1098
        )
3✔
1099
        .await?;
3✔
1100
    transaction
3✔
1101
        .execute(&remove_layer_collection_stmt, &[&collection])
3✔
1102
        .await?;
3✔
1103

1104
    #[allow(clippy::used_underscore_items)] // TODO: maybe rename?
1105
    _remove_collections_without_parent_collection(transaction).await?;
3✔
1106

1107
    #[allow(clippy::used_underscore_items)] // TODO: maybe rename?
1108
    _remove_layers_without_parent_collection(transaction).await?;
3✔
1109

1110
    Ok(())
3✔
1111
}
5✔
1112

1113
pub async fn delete_layer_from_collection(
3✔
1114
    transaction: &Transaction<'_>,
3✔
1115
    layer: &LayerId,
3✔
1116
    collection: &LayerCollectionId,
3✔
1117
) -> Result<()> {
3✔
1118
    let collection_uuid =
3✔
1119
        Uuid::from_str(&collection.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
3✔
1120
            found: collection.0.clone(),
×
1121
        })?;
3✔
1122

1123
    let layer_uuid =
3✔
1124
        Uuid::from_str(&layer.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
3✔
1125
            found: layer.0.clone(),
×
1126
        })?;
3✔
1127

1128
    let remove_layer_collection_stmt = transaction
3✔
1129
        .prepare(
3✔
1130
            "DELETE FROM collection_layers
3✔
1131
             WHERE collection = $1
3✔
1132
             AND layer = $2;",
3✔
1133
        )
3✔
1134
        .await?;
3✔
1135
    let num_results = transaction
3✔
1136
        .execute(
3✔
1137
            &remove_layer_collection_stmt,
3✔
1138
            &[&collection_uuid, &layer_uuid],
3✔
1139
        )
3✔
1140
        .await?;
3✔
1141

1142
    if num_results == 0 {
3✔
1143
        return Err(LayerDbError::NoLayerForGivenIdInCollection {
×
1144
            layer: layer.clone(),
×
1145
            collection: collection.clone(),
×
1146
        }
×
1147
        .into());
×
1148
    }
3✔
1149

3✔
1150
    #[allow(clippy::used_underscore_items)] // TODO: maybe rename?
3✔
1151
    _remove_layers_without_parent_collection(transaction).await?;
3✔
1152

1153
    Ok(())
3✔
1154
}
3✔
1155

1156
pub async fn delete_layer_collection_from_parent(
2✔
1157
    transaction: &Transaction<'_>,
2✔
1158
    collection: &LayerCollectionId,
2✔
1159
    parent: &LayerCollectionId,
2✔
1160
) -> Result<()> {
2✔
1161
    let collection_uuid =
2✔
1162
        Uuid::from_str(&collection.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
2✔
1163
            found: collection.0.clone(),
×
1164
        })?;
2✔
1165

1166
    let parent_collection_uuid =
2✔
1167
        Uuid::from_str(&parent.0).map_err(|_| crate::error::Error::IdStringMustBeUuid {
2✔
1168
            found: parent.0.clone(),
×
1169
        })?;
2✔
1170

1171
    let remove_layer_collection_stmt = transaction
2✔
1172
        .prepare(
2✔
1173
            "DELETE FROM collection_children
2✔
1174
             WHERE child = $1
2✔
1175
             AND parent = $2;",
2✔
1176
        )
2✔
1177
        .await?;
2✔
1178
    let num_results = transaction
2✔
1179
        .execute(
2✔
1180
            &remove_layer_collection_stmt,
2✔
1181
            &[&collection_uuid, &parent_collection_uuid],
2✔
1182
        )
2✔
1183
        .await?;
2✔
1184

1185
    if num_results == 0 {
2✔
1186
        return Err(LayerDbError::NoCollectionForGivenIdInCollection {
×
1187
            collection: collection.clone(),
×
1188
            parent: parent.clone(),
×
1189
        }
×
1190
        .into());
×
1191
    }
2✔
1192

2✔
1193
    #[allow(clippy::used_underscore_items)] // TODO: maybe rename?
2✔
1194
    _remove_collections_without_parent_collection(transaction).await?;
2✔
1195

1196
    #[allow(clippy::used_underscore_items)] // TODO: maybe rename?
1197
    _remove_layers_without_parent_collection(transaction).await?;
2✔
1198

1199
    Ok(())
2✔
1200
}
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc