1use crate::error::{ServiceError, ServiceResult};
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10#[derive(Debug, Clone, Default)]
12pub enum DatabaseType {
13 #[default]
15 PostGis,
16 MySql,
18 Sqlite,
20 Generic,
22}
23
24#[derive(Debug, Clone)]
26pub struct DatabaseSource {
27 pub connection_string: String,
29 pub database_type: DatabaseType,
31 pub table_name: String,
33 pub geometry_column: String,
35 pub id_column: Option<String>,
37 pub srid: Option<i32>,
39 pub schema: Option<String>,
41 pub count_cache: Option<CountCacheConfig>,
43}
44
45impl DatabaseSource {
46 pub fn new(connection_string: impl Into<String>, table_name: impl Into<String>) -> Self {
48 Self {
49 connection_string: connection_string.into(),
50 database_type: DatabaseType::default(),
51 table_name: table_name.into(),
52 geometry_column: "geom".to_string(),
53 id_column: Some("id".to_string()),
54 srid: Some(4326),
55 schema: None,
56 count_cache: Some(CountCacheConfig::default()),
57 }
58 }
59
60 pub fn with_database_type(mut self, db_type: DatabaseType) -> Self {
62 self.database_type = db_type;
63 self
64 }
65
66 pub fn with_geometry_column(mut self, column: impl Into<String>) -> Self {
68 self.geometry_column = column.into();
69 self
70 }
71
72 pub fn with_id_column(mut self, column: impl Into<String>) -> Self {
74 self.id_column = Some(column.into());
75 self
76 }
77
78 pub fn with_srid(mut self, srid: i32) -> Self {
80 self.srid = Some(srid);
81 self
82 }
83
84 pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
86 self.schema = Some(schema.into());
87 self
88 }
89
90 pub fn with_count_cache(mut self, cache: CountCacheConfig) -> Self {
92 self.count_cache = Some(cache);
93 self
94 }
95
96 pub fn without_count_cache(mut self) -> Self {
98 self.count_cache = None;
99 self
100 }
101
102 pub fn qualified_table_name(&self) -> String {
104 match &self.schema {
105 Some(schema) => format!("\"{}\".\"{}\"", schema, self.table_name),
106 None => format!("\"{}\"", self.table_name),
107 }
108 }
109}
110
111#[derive(Debug, Clone)]
113pub struct CountCacheConfig {
114 pub ttl: Duration,
116 pub max_entries: usize,
118 pub use_estimation_threshold: Option<usize>,
120}
121
122impl Default for CountCacheConfig {
123 fn default() -> Self {
124 Self {
125 ttl: Duration::from_secs(60),
126 max_entries: 100,
127 use_estimation_threshold: Some(1_000_000),
128 }
129 }
130}
131
132#[derive(Debug, Clone)]
134struct CachedCount {
135 count: usize,
136 timestamp: Instant,
137 is_estimated: bool,
138}
139
140pub struct DatabaseFeatureCounter {
142 cache: Arc<dashmap::DashMap<String, CachedCount>>,
143 config: CountCacheConfig,
144}
145
146impl DatabaseFeatureCounter {
147 pub fn new(config: CountCacheConfig) -> Self {
149 Self {
150 cache: Arc::new(dashmap::DashMap::new()),
151 config,
152 }
153 }
154
155 pub async fn get_count(
157 &self,
158 source: &DatabaseSource,
159 filter: Option<&CqlFilter>,
160 bbox: Option<&BboxFilter>,
161 ) -> ServiceResult<CountResult> {
162 let cache_key = self.build_cache_key(source, filter, bbox);
163
164 if let Some(cached) = self.get_cached(&cache_key) {
166 return Ok(cached);
167 }
168
169 let result = self.execute_count(source, filter, bbox).await?;
171
172 self.cache_result(&cache_key, &result);
174
175 Ok(result)
176 }
177
178 fn build_cache_key(
180 &self,
181 source: &DatabaseSource,
182 filter: Option<&CqlFilter>,
183 bbox: Option<&BboxFilter>,
184 ) -> String {
185 let mut key = format!("{}:{}", source.connection_string, source.table_name);
186
187 if let Some(f) = filter {
188 key.push(':');
189 key.push_str(&f.expression);
190 }
191
192 if let Some(b) = bbox {
193 key.push_str(&format!(
194 ":bbox({},{},{},{})",
195 b.min_x, b.min_y, b.max_x, b.max_y
196 ));
197 }
198
199 key
200 }
201
202 fn get_cached(&self, key: &str) -> Option<CountResult> {
204 if let Some(entry) = self.cache.get(key) {
205 if entry.timestamp.elapsed() < self.config.ttl {
206 return Some(CountResult {
207 count: entry.count,
208 is_estimated: entry.is_estimated,
209 from_cache: true,
210 });
211 }
212 drop(entry);
214 self.cache.remove(key);
215 }
216 None
217 }
218
219 fn cache_result(&self, key: &str, result: &CountResult) {
221 if self.cache.len() >= self.config.max_entries {
223 let mut oldest_key = None;
225 let mut oldest_time = Instant::now();
226
227 for entry in self.cache.iter() {
228 if entry.value().timestamp < oldest_time {
229 oldest_time = entry.value().timestamp;
230 oldest_key = Some(entry.key().clone());
231 }
232 }
233
234 if let Some(key) = oldest_key {
235 self.cache.remove(&key);
236 }
237 }
238
239 self.cache.insert(
240 key.to_string(),
241 CachedCount {
242 count: result.count,
243 timestamp: Instant::now(),
244 is_estimated: result.is_estimated,
245 },
246 );
247 }
248
249 async fn execute_count(
251 &self,
252 source: &DatabaseSource,
253 filter: Option<&CqlFilter>,
254 bbox: Option<&BboxFilter>,
255 ) -> ServiceResult<CountResult> {
256 let sql = self.build_count_sql(source, filter, bbox)?;
258
259 match source.database_type {
261 DatabaseType::PostGis => self.execute_postgis_count(source, &sql).await,
262 DatabaseType::MySql => self.execute_generic_count(source, &sql).await,
263 DatabaseType::Sqlite => self.execute_generic_count(source, &sql).await,
264 DatabaseType::Generic => self.execute_generic_count(source, &sql).await,
265 }
266 }
267
268 fn build_count_sql(
270 &self,
271 source: &DatabaseSource,
272 filter: Option<&CqlFilter>,
273 bbox: Option<&BboxFilter>,
274 ) -> ServiceResult<String> {
275 let table = source.qualified_table_name();
276 let mut sql = format!("SELECT COUNT(*) FROM {table}");
277
278 let mut conditions: Vec<String> = Vec::new();
279
280 if let Some(b) = bbox {
282 let geom_col = &source.geometry_column;
283 let srid = source.srid.unwrap_or(4326);
284
285 let bbox_condition = match source.database_type {
286 DatabaseType::PostGis => {
287 format!(
288 "ST_Intersects(\"{geom_col}\", ST_MakeEnvelope({}, {}, {}, {}, {srid}))",
289 b.min_x, b.min_y, b.max_x, b.max_y
290 )
291 }
292 DatabaseType::MySql => {
293 format!(
294 "MBRIntersects(`{geom_col}`, ST_GeomFromText('POLYGON(({} {}, {} {}, {} {}, {} {}, {} {}))', {srid}))",
295 b.min_x,
296 b.min_y,
297 b.max_x,
298 b.min_y,
299 b.max_x,
300 b.max_y,
301 b.min_x,
302 b.max_y,
303 b.min_x,
304 b.min_y
305 )
306 }
307 DatabaseType::Sqlite => {
308 format!(
309 "Intersects(\"{geom_col}\", BuildMbr({}, {}, {}, {}, {srid}))",
310 b.min_x, b.min_y, b.max_x, b.max_y
311 )
312 }
313 DatabaseType::Generic => {
314 format!(
316 "(\"{geom_col}_minx\" <= {} AND \"{geom_col}_maxx\" >= {} AND \"{geom_col}_miny\" <= {} AND \"{geom_col}_maxy\" >= {})",
317 b.max_x, b.min_x, b.max_y, b.min_y
318 )
319 }
320 };
321 conditions.push(bbox_condition);
322 }
323
324 if let Some(f) = filter {
326 let parsed = f.to_sql(&source.database_type)?;
327 conditions.push(parsed);
328 }
329
330 if !conditions.is_empty() {
332 sql.push_str(" WHERE ");
333 sql.push_str(&conditions.join(" AND "));
334 }
335
336 Ok(sql)
337 }
338
339 async fn execute_postgis_count(
341 &self,
342 source: &DatabaseSource,
343 sql: &str,
344 ) -> ServiceResult<CountResult> {
345 if let Some(threshold) = self
347 .config
348 .use_estimation_threshold
349 .filter(|_| source.count_cache.is_some())
350 {
351 if let Ok(estimate) = self.get_postgis_estimate(source).await {
353 if estimate > threshold {
354 return Ok(CountResult {
355 count: estimate,
356 is_estimated: true,
357 from_cache: false,
358 });
359 }
360 }
361 }
362
363 let count = self
365 .execute_sql_count(&source.connection_string, sql)
366 .await?;
367
368 Ok(CountResult {
369 count,
370 is_estimated: false,
371 from_cache: false,
372 })
373 }
374
375 async fn get_postgis_estimate(&self, source: &DatabaseSource) -> ServiceResult<usize> {
377 let estimate_sql = match &source.schema {
378 Some(schema) => {
379 format!(
380 "SELECT reltuples::bigint AS estimate FROM pg_class c \
381 JOIN pg_namespace n ON n.oid = c.relnamespace \
382 WHERE n.nspname = '{}' AND c.relname = '{}'",
383 schema, source.table_name
384 )
385 }
386 None => {
387 format!(
388 "SELECT reltuples::bigint AS estimate FROM pg_class \
389 WHERE relname = '{}'",
390 source.table_name
391 )
392 }
393 };
394
395 self.execute_sql_count(&source.connection_string, &estimate_sql)
396 .await
397 }
398
399 async fn execute_generic_count(
401 &self,
402 source: &DatabaseSource,
403 sql: &str,
404 ) -> ServiceResult<CountResult> {
405 let count = self
406 .execute_sql_count(&source.connection_string, sql)
407 .await?;
408
409 Ok(CountResult {
410 count,
411 is_estimated: false,
412 from_cache: false,
413 })
414 }
415
416 async fn execute_sql_count(
418 &self,
419 _connection_string: &str,
420 _sql: &str,
421 ) -> ServiceResult<usize> {
422 Err(ServiceError::Internal(
433 "Database connection not configured. Use oxigdal-postgis for PostGIS connections."
434 .to_string(),
435 ))
436 }
437
438 pub fn clear_cache(&self) {
440 self.cache.clear();
441 }
442
443 pub fn cache_stats(&self) -> CacheStats {
445 let mut expired = 0;
446 let mut valid = 0;
447
448 for entry in self.cache.iter() {
449 if entry.value().timestamp.elapsed() < self.config.ttl {
450 valid += 1;
451 } else {
452 expired += 1;
453 }
454 }
455
456 CacheStats {
457 total_entries: self.cache.len(),
458 valid_entries: valid,
459 expired_entries: expired,
460 max_entries: self.config.max_entries,
461 }
462 }
463}
464
465impl Default for DatabaseFeatureCounter {
466 fn default() -> Self {
467 Self::new(CountCacheConfig::default())
468 }
469}
470
471#[derive(Debug, Clone)]
473pub struct CountResult {
474 pub count: usize,
476 pub is_estimated: bool,
478 pub from_cache: bool,
480}
481
482#[derive(Debug, Clone)]
484pub struct CacheStats {
485 pub total_entries: usize,
487 pub valid_entries: usize,
489 pub expired_entries: usize,
491 pub max_entries: usize,
493}
494
495#[derive(Debug, Clone)]
497pub struct CqlFilter {
498 pub expression: String,
500}
501
502impl CqlFilter {
503 pub fn new(expression: impl Into<String>) -> Self {
505 Self {
506 expression: expression.into(),
507 }
508 }
509
510 pub fn to_sql(&self, db_type: &DatabaseType) -> ServiceResult<String> {
512 let sql = self.parse_cql_expression(db_type)?;
515 Ok(sql)
516 }
517
518 fn parse_cql_expression(&self, db_type: &DatabaseType) -> ServiceResult<String> {
520 let expr = self.expression.trim();
521
522 if expr.is_empty() {
524 return Ok("1=1".to_string());
525 }
526
527 let tokens = self.tokenize(expr)?;
529 self.tokens_to_sql(&tokens, db_type)
530 }
531
532 fn tokenize(&self, expr: &str) -> ServiceResult<Vec<CqlToken>> {
534 let mut tokens = Vec::new();
535 let mut chars = expr.chars().peekable();
536 let mut current = String::new();
537
538 while let Some(c) = chars.next() {
539 match c {
540 ' ' | '\t' | '\n' | '\r' => {
541 if !current.is_empty() {
542 tokens.push(self.classify_token(¤t)?);
543 current.clear();
544 }
545 }
546 '(' => {
547 if !current.is_empty() {
548 tokens.push(self.classify_token(¤t)?);
549 current.clear();
550 }
551 tokens.push(CqlToken::OpenParen);
552 }
553 ')' => {
554 if !current.is_empty() {
555 tokens.push(self.classify_token(¤t)?);
556 current.clear();
557 }
558 tokens.push(CqlToken::CloseParen);
559 }
560 '\'' => {
561 if !current.is_empty() {
563 tokens.push(self.classify_token(¤t)?);
564 current.clear();
565 }
566 let mut string_val = String::new();
567 while let Some(&next_c) = chars.peek() {
568 chars.next();
569 if next_c == '\'' {
570 if chars.peek() == Some(&'\'') {
572 string_val.push('\'');
573 chars.next();
574 } else {
575 break;
576 }
577 } else {
578 string_val.push(next_c);
579 }
580 }
581 tokens.push(CqlToken::StringLiteral(string_val));
582 }
583 '=' | '<' | '>' | '!' => {
584 if !current.is_empty() {
585 tokens.push(self.classify_token(¤t)?);
586 current.clear();
587 }
588 let mut op = c.to_string();
589 if let Some(&next_c) = chars.peek() {
590 if next_c == '=' || (c == '<' && next_c == '>') {
591 op.push(next_c);
592 chars.next();
593 }
594 }
595 tokens.push(CqlToken::Operator(op));
596 }
597 ',' => {
598 if !current.is_empty() {
599 tokens.push(self.classify_token(¤t)?);
600 current.clear();
601 }
602 tokens.push(CqlToken::Comma);
603 }
604 _ => {
605 current.push(c);
606 }
607 }
608 }
609
610 if !current.is_empty() {
611 tokens.push(self.classify_token(¤t)?);
612 }
613
614 Ok(tokens)
615 }
616
617 fn classify_token(&self, token: &str) -> ServiceResult<CqlToken> {
619 let upper = token.to_uppercase();
620
621 match upper.as_str() {
622 "AND" => Ok(CqlToken::And),
623 "OR" => Ok(CqlToken::Or),
624 "NOT" => Ok(CqlToken::Not),
625 "LIKE" => Ok(CqlToken::Operator("LIKE".to_string())),
626 "ILIKE" => Ok(CqlToken::Operator("ILIKE".to_string())),
627 "IN" => Ok(CqlToken::Operator("IN".to_string())),
628 "BETWEEN" => Ok(CqlToken::Operator("BETWEEN".to_string())),
629 "IS" => Ok(CqlToken::Operator("IS".to_string())),
630 "NULL" => Ok(CqlToken::Null),
631 "TRUE" => Ok(CqlToken::BoolLiteral(true)),
632 "FALSE" => Ok(CqlToken::BoolLiteral(false)),
633 _ => {
634 if let Ok(n) = token.parse::<f64>() {
636 Ok(CqlToken::NumberLiteral(n))
637 } else {
638 Ok(CqlToken::Identifier(token.to_string()))
640 }
641 }
642 }
643 }
644
645 fn tokens_to_sql(&self, tokens: &[CqlToken], db_type: &DatabaseType) -> ServiceResult<String> {
647 let mut sql = String::new();
648 let mut i = 0;
649
650 while i < tokens.len() {
651 let token = &tokens[i];
652
653 match token {
654 CqlToken::Identifier(name) => {
655 sql.push_str(&self.quote_identifier(name, db_type));
656 }
657 CqlToken::StringLiteral(val) => {
658 sql.push_str(&format!("'{}'", val.replace('\'', "''")));
659 }
660 CqlToken::NumberLiteral(n) => {
661 sql.push_str(&n.to_string());
662 }
663 CqlToken::BoolLiteral(b) => {
664 sql.push_str(if *b { "TRUE" } else { "FALSE" });
665 }
666 CqlToken::Null => {
667 sql.push_str("NULL");
668 }
669 CqlToken::And => {
670 sql.push_str(" AND ");
671 }
672 CqlToken::Or => {
673 sql.push_str(" OR ");
674 }
675 CqlToken::Not => {
676 sql.push_str("NOT ");
677 }
678 CqlToken::Operator(op) => {
679 sql.push(' ');
680 sql.push_str(op);
681 sql.push(' ');
682 }
683 CqlToken::OpenParen => {
684 sql.push('(');
685 }
686 CqlToken::CloseParen => {
687 sql.push(')');
688 }
689 CqlToken::Comma => {
690 sql.push_str(", ");
691 }
692 }
693
694 i += 1;
695 }
696
697 Ok(sql.trim().to_string())
698 }
699
700 fn quote_identifier(&self, name: &str, db_type: &DatabaseType) -> String {
702 match db_type {
703 DatabaseType::PostGis | DatabaseType::Sqlite | DatabaseType::Generic => {
704 format!("\"{}\"", name.replace('"', "\"\""))
705 }
706 DatabaseType::MySql => {
707 format!("`{}`", name.replace('`', "``"))
708 }
709 }
710 }
711}
712
713#[derive(Debug, Clone)]
715enum CqlToken {
716 Identifier(String),
717 StringLiteral(String),
718 NumberLiteral(f64),
719 BoolLiteral(bool),
720 Null,
721 And,
722 Or,
723 Not,
724 Operator(String),
725 OpenParen,
726 CloseParen,
727 Comma,
728}
729
730#[derive(Debug, Clone, Copy)]
732pub struct BboxFilter {
733 pub min_x: f64,
735 pub min_y: f64,
737 pub max_x: f64,
739 pub max_y: f64,
741 pub crs: Option<i32>,
743}
744
745impl BboxFilter {
746 pub fn new(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
748 Self {
749 min_x,
750 min_y,
751 max_x,
752 max_y,
753 crs: None,
754 }
755 }
756
757 pub fn from_bbox_string(bbox_str: &str) -> ServiceResult<Self> {
759 let parts: Vec<&str> = bbox_str.split(',').collect();
760
761 if parts.len() < 4 {
762 return Err(ServiceError::InvalidBbox(
763 "BBOX must have at least 4 coordinates".to_string(),
764 ));
765 }
766
767 let min_x = parts[0]
768 .trim()
769 .parse::<f64>()
770 .map_err(|_| ServiceError::InvalidBbox("Invalid minx".to_string()))?;
771 let min_y = parts[1]
772 .trim()
773 .parse::<f64>()
774 .map_err(|_| ServiceError::InvalidBbox("Invalid miny".to_string()))?;
775 let max_x = parts[2]
776 .trim()
777 .parse::<f64>()
778 .map_err(|_| ServiceError::InvalidBbox("Invalid maxx".to_string()))?;
779 let max_y = parts[3]
780 .trim()
781 .parse::<f64>()
782 .map_err(|_| ServiceError::InvalidBbox("Invalid maxy".to_string()))?;
783
784 let crs = if parts.len() > 4 {
785 let crs_str = parts[4].trim();
787 let srid = if crs_str.to_uppercase().starts_with("EPSG:") {
788 crs_str[5..]
789 .parse::<i32>()
790 .map_err(|_| ServiceError::InvalidBbox("Invalid CRS".to_string()))?
791 } else {
792 crs_str
793 .parse::<i32>()
794 .map_err(|_| ServiceError::InvalidBbox("Invalid CRS".to_string()))?
795 };
796 Some(srid)
797 } else {
798 None
799 };
800
801 Ok(Self {
802 min_x,
803 min_y,
804 max_x,
805 max_y,
806 crs,
807 })
808 }
809
810 pub fn with_crs(mut self, crs: i32) -> Self {
812 self.crs = Some(crs);
813 self
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 #[test]
822 fn test_database_source_creation() {
823 let source = DatabaseSource::new("postgresql://localhost/gis", "buildings");
824
825 assert_eq!(source.table_name, "buildings");
826 assert_eq!(source.geometry_column, "geom");
827 assert!(matches!(source.database_type, DatabaseType::PostGis));
828 }
829
830 #[test]
831 fn test_database_source_builder() {
832 let source = DatabaseSource::new("postgresql://localhost/gis", "roads")
833 .with_database_type(DatabaseType::PostGis)
834 .with_geometry_column("the_geom")
835 .with_id_column("gid")
836 .with_srid(3857)
837 .with_schema("public");
838
839 assert_eq!(source.geometry_column, "the_geom");
840 assert_eq!(source.id_column, Some("gid".to_string()));
841 assert_eq!(source.srid, Some(3857));
842 assert_eq!(source.schema, Some("public".to_string()));
843 }
844
845 #[test]
846 fn test_qualified_table_name() {
847 let source = DatabaseSource::new("postgresql://localhost/gis", "buildings");
848 assert_eq!(source.qualified_table_name(), "\"buildings\"");
849
850 let source_with_schema = source.with_schema("public");
851 assert_eq!(
852 source_with_schema.qualified_table_name(),
853 "\"public\".\"buildings\""
854 );
855 }
856
857 #[test]
858 fn test_bbox_filter_from_string() {
859 let bbox = BboxFilter::from_bbox_string("-180,-90,180,90");
860 assert!(bbox.is_ok());
861
862 let bbox = bbox.expect("bbox should parse");
863 assert!((bbox.min_x - (-180.0)).abs() < f64::EPSILON);
864 assert!((bbox.min_y - (-90.0)).abs() < f64::EPSILON);
865 assert!((bbox.max_x - 180.0).abs() < f64::EPSILON);
866 assert!((bbox.max_y - 90.0).abs() < f64::EPSILON);
867 }
868
869 #[test]
870 fn test_bbox_filter_with_crs() {
871 let bbox = BboxFilter::from_bbox_string("-180,-90,180,90,EPSG:4326");
872 assert!(bbox.is_ok());
873
874 let bbox = bbox.expect("bbox should parse");
875 assert_eq!(bbox.crs, Some(4326));
876 }
877
878 #[test]
879 fn test_bbox_filter_invalid() {
880 let bbox = BboxFilter::from_bbox_string("invalid");
881 assert!(bbox.is_err());
882
883 let bbox = BboxFilter::from_bbox_string("-180,-90,180");
884 assert!(bbox.is_err());
885 }
886
887 #[test]
888 fn test_cql_filter_simple() {
889 let filter = CqlFilter::new("name = 'test'");
890 let sql = filter.to_sql(&DatabaseType::PostGis);
891 assert!(sql.is_ok());
892
893 let sql = sql.expect("sql should parse");
894 assert!(sql.contains("\"name\""));
895 assert!(sql.contains("'test'"));
896 }
897
898 #[test]
899 fn test_cql_filter_with_and() {
900 let filter = CqlFilter::new("status = 'active' AND count > 10");
901 let sql = filter.to_sql(&DatabaseType::PostGis);
902 assert!(sql.is_ok());
903
904 let sql = sql.expect("sql should parse");
905 assert!(sql.contains("AND"));
906 }
907
908 #[test]
909 fn test_cql_filter_mysql_quoting() {
910 let filter = CqlFilter::new("name = 'test'");
911 let sql = filter.to_sql(&DatabaseType::MySql);
912 assert!(sql.is_ok());
913
914 let sql = sql.expect("sql should parse");
915 assert!(sql.contains("`name`"));
916 }
917
918 #[test]
919 fn test_count_cache_config_default() {
920 let config = CountCacheConfig::default();
921 assert_eq!(config.ttl, Duration::from_secs(60));
922 assert_eq!(config.max_entries, 100);
923 assert_eq!(config.use_estimation_threshold, Some(1_000_000));
924 }
925
926 #[test]
927 fn test_database_feature_counter_creation() {
928 let counter = DatabaseFeatureCounter::new(CountCacheConfig::default());
929 let stats = counter.cache_stats();
930 assert_eq!(stats.total_entries, 0);
931 }
932
933 #[test]
934 fn test_cache_stats() {
935 let counter = DatabaseFeatureCounter::default();
936 let stats = counter.cache_stats();
937
938 assert_eq!(stats.total_entries, 0);
939 assert_eq!(stats.valid_entries, 0);
940 assert_eq!(stats.expired_entries, 0);
941 }
942
943 #[tokio::test]
944 async fn test_get_count_returns_error_without_connection() {
945 let counter = DatabaseFeatureCounter::default();
946 let source = DatabaseSource::new("postgresql://localhost/gis", "buildings");
947
948 let result = counter.get_count(&source, None, None).await;
949 assert!(result.is_err());
950 }
951}