Skip to main content

oxigdal_services/wfs/
database.rs

1//! Database source handling for WFS
2//!
3//! Provides database connection handling, feature counting, and caching
4//! for WFS database-backed feature types.
5
6use crate::error::{ServiceError, ServiceResult};
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10/// Database connection type
11#[derive(Debug, Clone, Default)]
12pub enum DatabaseType {
13    /// PostgreSQL/PostGIS
14    #[default]
15    PostGis,
16    /// MySQL with spatial extensions
17    MySql,
18    /// SQLite/SpatiaLite
19    Sqlite,
20    /// Generic SQL database
21    Generic,
22}
23
24/// Database source configuration
25#[derive(Debug, Clone)]
26pub struct DatabaseSource {
27    /// Connection string
28    pub connection_string: String,
29    /// Database type
30    pub database_type: DatabaseType,
31    /// Table name
32    pub table_name: String,
33    /// Geometry column name
34    pub geometry_column: String,
35    /// Feature ID column name (optional)
36    pub id_column: Option<String>,
37    /// SRID for spatial operations
38    pub srid: Option<i32>,
39    /// Schema name (optional)
40    pub schema: Option<String>,
41    /// Count cache settings
42    pub count_cache: Option<CountCacheConfig>,
43}
44
45impl DatabaseSource {
46    /// Create a new database source
47    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    /// Set the database type
61    pub fn with_database_type(mut self, db_type: DatabaseType) -> Self {
62        self.database_type = db_type;
63        self
64    }
65
66    /// Set the geometry column name
67    pub fn with_geometry_column(mut self, column: impl Into<String>) -> Self {
68        self.geometry_column = column.into();
69        self
70    }
71
72    /// Set the ID column name
73    pub fn with_id_column(mut self, column: impl Into<String>) -> Self {
74        self.id_column = Some(column.into());
75        self
76    }
77
78    /// Set the SRID
79    pub fn with_srid(mut self, srid: i32) -> Self {
80        self.srid = Some(srid);
81        self
82    }
83
84    /// Set the schema name
85    pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
86        self.schema = Some(schema.into());
87        self
88    }
89
90    /// Set count cache configuration
91    pub fn with_count_cache(mut self, cache: CountCacheConfig) -> Self {
92        self.count_cache = Some(cache);
93        self
94    }
95
96    /// Disable count caching
97    pub fn without_count_cache(mut self) -> Self {
98        self.count_cache = None;
99        self
100    }
101
102    /// Get the fully qualified table name
103    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/// Count cache configuration
112#[derive(Debug, Clone)]
113pub struct CountCacheConfig {
114    /// Cache duration
115    pub ttl: Duration,
116    /// Maximum cached entries
117    pub max_entries: usize,
118    /// Use estimation for large tables
119    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/// Cached count entry
133#[derive(Debug, Clone)]
134struct CachedCount {
135    count: usize,
136    timestamp: Instant,
137    is_estimated: bool,
138}
139
140/// Database feature counter with caching
141pub struct DatabaseFeatureCounter {
142    cache: Arc<dashmap::DashMap<String, CachedCount>>,
143    config: CountCacheConfig,
144}
145
146impl DatabaseFeatureCounter {
147    /// Create a new feature counter
148    pub fn new(config: CountCacheConfig) -> Self {
149        Self {
150            cache: Arc::new(dashmap::DashMap::new()),
151            config,
152        }
153    }
154
155    /// Get count for a database source
156    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        // Check cache first
165        if let Some(cached) = self.get_cached(&cache_key) {
166            return Ok(cached);
167        }
168
169        // Execute count query
170        let result = self.execute_count(source, filter, bbox).await?;
171
172        // Cache the result
173        self.cache_result(&cache_key, &result);
174
175        Ok(result)
176    }
177
178    /// Build cache key from source and filters
179    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    /// Check cache for existing result
203    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            // Remove expired entry
213            drop(entry);
214            self.cache.remove(key);
215        }
216        None
217    }
218
219    /// Cache a count result
220    fn cache_result(&self, key: &str, result: &CountResult) {
221        // Enforce max entries by removing oldest if needed
222        if self.cache.len() >= self.config.max_entries {
223            // Find and remove oldest entry
224            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    /// Execute count query against database
250    async fn execute_count(
251        &self,
252        source: &DatabaseSource,
253        filter: Option<&CqlFilter>,
254        bbox: Option<&BboxFilter>,
255    ) -> ServiceResult<CountResult> {
256        // Build the count SQL based on database type
257        let sql = self.build_count_sql(source, filter, bbox)?;
258
259        // Execute the query based on database type
260        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    /// Build count SQL query
269    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        // Add BBOX condition
281        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                    // Generic SQL using envelope intersection
315                    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        // Add CQL filter condition
325        if let Some(f) = filter {
326            let parsed = f.to_sql(&source.database_type)?;
327            conditions.push(parsed);
328        }
329
330        // Combine conditions
331        if !conditions.is_empty() {
332            sql.push_str(" WHERE ");
333            sql.push_str(&conditions.join(" AND "));
334        }
335
336        Ok(sql)
337    }
338
339    /// Execute count for PostGIS database
340    async fn execute_postgis_count(
341        &self,
342        source: &DatabaseSource,
343        sql: &str,
344    ) -> ServiceResult<CountResult> {
345        // Check if we should use estimation for large tables
346        if let Some(threshold) = self
347            .config
348            .use_estimation_threshold
349            .filter(|_| source.count_cache.is_some())
350        {
351            // Try to get estimated count first
352            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        // Execute actual count
364        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    /// Get estimated count from PostgreSQL statistics
376    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    /// Execute generic SQL count
400    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    /// Execute SQL and return count
417    async fn execute_sql_count(
418        &self,
419        _connection_string: &str,
420        _sql: &str,
421    ) -> ServiceResult<usize> {
422        // This is a placeholder for actual database execution
423        // In a real implementation, this would:
424        // 1. Get a connection from the pool
425        // 2. Execute the SQL query
426        // 3. Parse the count result
427        //
428        // For now, we return an error indicating the database is not connected
429        // This will be replaced with actual database calls when integrated
430        // with oxigdal-postgis or oxigdal-db-connectors
431
432        Err(ServiceError::Internal(
433            "Database connection not configured. Use oxigdal-postgis for PostGIS connections."
434                .to_string(),
435        ))
436    }
437
438    /// Clear the count cache
439    pub fn clear_cache(&self) {
440        self.cache.clear();
441    }
442
443    /// Get cache statistics
444    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/// Count result
472#[derive(Debug, Clone)]
473pub struct CountResult {
474    /// The count value
475    pub count: usize,
476    /// Whether this is an estimated count
477    pub is_estimated: bool,
478    /// Whether this was retrieved from cache
479    pub from_cache: bool,
480}
481
482/// Cache statistics
483#[derive(Debug, Clone)]
484pub struct CacheStats {
485    /// Total entries in cache
486    pub total_entries: usize,
487    /// Valid (non-expired) entries
488    pub valid_entries: usize,
489    /// Expired entries
490    pub expired_entries: usize,
491    /// Maximum allowed entries
492    pub max_entries: usize,
493}
494
495/// CQL (Common Query Language) filter
496#[derive(Debug, Clone)]
497pub struct CqlFilter {
498    /// The CQL expression
499    pub expression: String,
500}
501
502impl CqlFilter {
503    /// Create a new CQL filter
504    pub fn new(expression: impl Into<String>) -> Self {
505        Self {
506            expression: expression.into(),
507        }
508    }
509
510    /// Parse and convert CQL to SQL
511    pub fn to_sql(&self, db_type: &DatabaseType) -> ServiceResult<String> {
512        // Parse CQL expression and convert to SQL
513        // This is a simplified implementation - a full CQL parser would be more complex
514        let sql = self.parse_cql_expression(db_type)?;
515        Ok(sql)
516    }
517
518    /// Parse a CQL expression into SQL
519    fn parse_cql_expression(&self, db_type: &DatabaseType) -> ServiceResult<String> {
520        let expr = self.expression.trim();
521
522        // Handle empty expression
523        if expr.is_empty() {
524            return Ok("1=1".to_string());
525        }
526
527        // Tokenize and parse the expression
528        let tokens = self.tokenize(expr)?;
529        self.tokens_to_sql(&tokens, db_type)
530    }
531
532    /// Tokenize CQL expression
533    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(&current)?);
543                        current.clear();
544                    }
545                }
546                '(' => {
547                    if !current.is_empty() {
548                        tokens.push(self.classify_token(&current)?);
549                        current.clear();
550                    }
551                    tokens.push(CqlToken::OpenParen);
552                }
553                ')' => {
554                    if !current.is_empty() {
555                        tokens.push(self.classify_token(&current)?);
556                        current.clear();
557                    }
558                    tokens.push(CqlToken::CloseParen);
559                }
560                '\'' => {
561                    // String literal
562                    if !current.is_empty() {
563                        tokens.push(self.classify_token(&current)?);
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                            // Check for escaped quote
571                            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(&current)?);
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(&current)?);
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(&current)?);
612        }
613
614        Ok(tokens)
615    }
616
617    /// Classify a token string
618    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                // Check if it's a number
635                if let Ok(n) = token.parse::<f64>() {
636                    Ok(CqlToken::NumberLiteral(n))
637                } else {
638                    // It's an identifier (column name)
639                    Ok(CqlToken::Identifier(token.to_string()))
640                }
641            }
642        }
643    }
644
645    /// Convert tokens to SQL
646    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    /// Quote an identifier based on database type
701    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/// CQL token types
714#[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/// Bounding box filter
731#[derive(Debug, Clone, Copy)]
732pub struct BboxFilter {
733    /// Minimum X coordinate
734    pub min_x: f64,
735    /// Minimum Y coordinate
736    pub min_y: f64,
737    /// Maximum X coordinate
738    pub max_x: f64,
739    /// Maximum Y coordinate
740    pub max_y: f64,
741    /// Optional CRS (defaults to EPSG:4326)
742    pub crs: Option<i32>,
743}
744
745impl BboxFilter {
746    /// Create a new bounding box filter
747    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    /// Create from a BBOX string (minx,miny,maxx,maxy\[,crs\])
758    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            // Parse CRS - could be "EPSG:4326" or just "4326"
786            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    /// Set the CRS
811    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}