prax_schema/ast/
server_group.rs

1//! Server group definitions for multi-server database configurations.
2//!
3//! Server groups allow organizing multiple database servers for:
4//! - Read replicas (load balancing reads)
5//! - Sharding (horizontal scaling)
6//! - Multi-region deployment
7//! - Failover and high availability
8
9use indexmap::IndexMap;
10use serde::{Deserialize, Serialize};
11use smol_str::SmolStr;
12
13use super::{Attribute, Documentation, Ident, Span};
14
15/// A server group containing multiple database servers.
16///
17/// # Example
18/// ```prax
19/// serverGroup MainCluster {
20///     @@strategy(ReadReplica)
21///     @@loadBalance(RoundRobin)
22///
23///     server primary {
24///         url = env("PRIMARY_DATABASE_URL")
25///         role = "primary"
26///         weight = 1
27///     }
28///
29///     server replica1 {
30///         url = env("REPLICA1_DATABASE_URL")
31///         role = "replica"
32///         weight = 2
33///         region = "us-east-1"
34///     }
35///
36///     server replica2 {
37///         url = env("REPLICA2_DATABASE_URL")
38///         role = "replica"
39///         weight = 2
40///         region = "us-west-2"
41///     }
42/// }
43/// ```
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct ServerGroup {
46    /// Group name.
47    pub name: Ident,
48    /// Servers in this group.
49    pub servers: IndexMap<SmolStr, Server>,
50    /// Group-level attributes (strategy, load balancing, etc.).
51    pub attributes: Vec<Attribute>,
52    /// Documentation comment.
53    pub documentation: Option<Documentation>,
54    /// Source location.
55    pub span: Span,
56}
57
58impl ServerGroup {
59    /// Create a new server group.
60    pub fn new(name: Ident, span: Span) -> Self {
61        Self {
62            name,
63            servers: IndexMap::new(),
64            attributes: vec![],
65            documentation: None,
66            span,
67        }
68    }
69
70    /// Add a server to the group.
71    pub fn add_server(&mut self, server: Server) {
72        self.servers.insert(server.name.name.clone(), server);
73    }
74
75    /// Add an attribute to the group.
76    pub fn add_attribute(&mut self, attr: Attribute) {
77        self.attributes.push(attr);
78    }
79
80    /// Set documentation.
81    pub fn set_documentation(&mut self, doc: Documentation) {
82        self.documentation = Some(doc);
83    }
84
85    /// Get the group strategy (e.g., ReadReplica, Sharding, MultiRegion).
86    pub fn strategy(&self) -> Option<ServerGroupStrategy> {
87        for attr in &self.attributes {
88            if attr.name.name == "strategy" {
89                if let Some(arg) = attr.args.first() {
90                    let value_str = arg.value.as_string()
91                        .map(|s| s.to_string())
92                        .or_else(|| arg.value.as_ident().map(|s| s.to_string()))?;
93                    return ServerGroupStrategy::from_str(&value_str);
94                }
95            }
96        }
97        None
98    }
99
100    /// Get the load balancing strategy.
101    pub fn load_balance(&self) -> Option<LoadBalanceStrategy> {
102        for attr in &self.attributes {
103            if attr.name.name == "loadBalance" {
104                if let Some(arg) = attr.args.first() {
105                    let value_str = arg.value.as_string()
106                        .map(|s| s.to_string())
107                        .or_else(|| arg.value.as_ident().map(|s| s.to_string()))?;
108                    return LoadBalanceStrategy::from_str(&value_str);
109                }
110            }
111        }
112        None
113    }
114
115    /// Get the primary server.
116    pub fn primary(&self) -> Option<&Server> {
117        self.servers
118            .values()
119            .find(|s| s.role() == Some(ServerRole::Primary))
120    }
121
122    /// Get all replica servers.
123    pub fn replicas(&self) -> Vec<&Server> {
124        self.servers
125            .values()
126            .filter(|s| s.role() == Some(ServerRole::Replica))
127            .collect()
128    }
129
130    /// Get servers by region.
131    pub fn servers_in_region(&self, region: &str) -> Vec<&Server> {
132        self.servers
133            .values()
134            .filter(|s| s.region() == Some(region))
135            .collect()
136    }
137
138    /// Get the failover order (sorted by priority).
139    pub fn failover_order(&self) -> Vec<&Server> {
140        let mut servers: Vec<_> = self.servers.values().collect();
141        servers.sort_by_key(|s| s.priority().unwrap_or(u32::MAX));
142        servers
143    }
144}
145
146/// An individual server within a server group.
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct Server {
149    /// Server name.
150    pub name: Ident,
151    /// Server properties (url, role, weight, etc.).
152    pub properties: IndexMap<SmolStr, ServerProperty>,
153    /// Source location.
154    pub span: Span,
155}
156
157impl Server {
158    /// Create a new server.
159    pub fn new(name: Ident, span: Span) -> Self {
160        Self {
161            name,
162            properties: IndexMap::new(),
163            span,
164        }
165    }
166
167    /// Add a property to the server.
168    pub fn add_property(&mut self, prop: ServerProperty) {
169        self.properties.insert(prop.name.clone(), prop);
170    }
171
172    /// Get a property value by name.
173    pub fn get_property(&self, name: &str) -> Option<&ServerPropertyValue> {
174        self.properties.get(name).map(|p| &p.value)
175    }
176
177    /// Get the server URL.
178    pub fn url(&self) -> Option<&str> {
179        match self.get_property("url")? {
180            ServerPropertyValue::String(s) => Some(s),
181            ServerPropertyValue::EnvVar(var) => Some(var),
182            _ => None,
183        }
184    }
185
186    /// Get the server role.
187    pub fn role(&self) -> Option<ServerRole> {
188        match self.get_property("role")? {
189            ServerPropertyValue::String(s) | ServerPropertyValue::Identifier(s) => {
190                ServerRole::from_str(s)
191            }
192            _ => None,
193        }
194    }
195
196    /// Get the server weight (for load balancing).
197    pub fn weight(&self) -> Option<u32> {
198        match self.get_property("weight")? {
199            ServerPropertyValue::Number(n) => Some(*n as u32),
200            _ => None,
201        }
202    }
203
204    /// Get the server region.
205    pub fn region(&self) -> Option<&str> {
206        match self.get_property("region")? {
207            ServerPropertyValue::String(s) => Some(s),
208            _ => None,
209        }
210    }
211
212    /// Get the server priority (for failover).
213    pub fn priority(&self) -> Option<u32> {
214        match self.get_property("priority")? {
215            ServerPropertyValue::Number(n) => Some(*n as u32),
216            _ => None,
217        }
218    }
219
220    /// Check if this is a read-only server.
221    pub fn is_read_only(&self) -> bool {
222        match self.get_property("readOnly") {
223            Some(ServerPropertyValue::Boolean(b)) => *b,
224            _ => self.role() == Some(ServerRole::Replica),
225        }
226    }
227
228    /// Get the maximum connections for this server.
229    pub fn max_connections(&self) -> Option<u32> {
230        match self.get_property("maxConnections")? {
231            ServerPropertyValue::Number(n) => Some(*n as u32),
232            _ => None,
233        }
234    }
235
236    /// Get the health check endpoint.
237    pub fn health_check(&self) -> Option<&str> {
238        match self.get_property("healthCheck")? {
239            ServerPropertyValue::String(s) => Some(s),
240            _ => None,
241        }
242    }
243}
244
245/// A server property key-value pair.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247pub struct ServerProperty {
248    /// Property name.
249    pub name: SmolStr,
250    /// Property value.
251    pub value: ServerPropertyValue,
252    /// Source location.
253    pub span: Span,
254}
255
256impl ServerProperty {
257    /// Create a new server property.
258    pub fn new(name: impl Into<SmolStr>, value: ServerPropertyValue, span: Span) -> Self {
259        Self {
260            name: name.into(),
261            value,
262            span,
263        }
264    }
265}
266
267/// Server property value types.
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269pub enum ServerPropertyValue {
270    /// String value.
271    String(String),
272    /// Number value.
273    Number(f64),
274    /// Boolean value.
275    Boolean(bool),
276    /// Identifier (enum-like value).
277    Identifier(String),
278    /// Environment variable reference.
279    EnvVar(String),
280    /// Array of values.
281    Array(Vec<ServerPropertyValue>),
282}
283
284impl std::fmt::Display for ServerPropertyValue {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        match self {
287            Self::String(s) => write!(f, "\"{}\"", s),
288            Self::Number(n) => write!(f, "{}", n),
289            Self::Boolean(b) => write!(f, "{}", b),
290            Self::Identifier(s) => write!(f, "{}", s),
291            Self::EnvVar(var) => write!(f, "env(\"{}\")", var),
292            Self::Array(arr) => {
293                write!(f, "[")?;
294                for (i, v) in arr.iter().enumerate() {
295                    if i > 0 {
296                        write!(f, ", ")?;
297                    }
298                    write!(f, "{}", v)?;
299                }
300                write!(f, "]")
301            }
302        }
303    }
304}
305
306/// Server role within a group.
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
308pub enum ServerRole {
309    /// Primary/master server (handles writes).
310    Primary,
311    /// Replica/slave server (handles reads).
312    Replica,
313    /// Analytics server (for reporting queries).
314    Analytics,
315    /// Archive server (for historical data).
316    Archive,
317    /// Shard server (for horizontal partitioning).
318    Shard,
319}
320
321impl ServerRole {
322    /// Parse role from string.
323    pub fn from_str(s: &str) -> Option<Self> {
324        match s.to_lowercase().as_str() {
325            "primary" | "master" | "writer" => Some(Self::Primary),
326            "replica" | "slave" | "reader" | "read" => Some(Self::Replica),
327            "analytics" | "reporting" | "olap" => Some(Self::Analytics),
328            "archive" | "historical" => Some(Self::Archive),
329            "shard" => Some(Self::Shard),
330            _ => None,
331        }
332    }
333
334    /// Get the role as a string.
335    pub fn as_str(&self) -> &'static str {
336        match self {
337            Self::Primary => "primary",
338            Self::Replica => "replica",
339            Self::Analytics => "analytics",
340            Self::Archive => "archive",
341            Self::Shard => "shard",
342        }
343    }
344}
345
346/// Server group strategy.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
348pub enum ServerGroupStrategy {
349    /// Read replica configuration (primary + replicas).
350    ReadReplica,
351    /// Sharding configuration (horizontal partitioning).
352    Sharding,
353    /// Multi-region deployment.
354    MultiRegion,
355    /// High availability with automatic failover.
356    HighAvailability,
357    /// Custom strategy.
358    Custom,
359}
360
361impl ServerGroupStrategy {
362    /// Parse strategy from string.
363    pub fn from_str(s: &str) -> Option<Self> {
364        match s.to_lowercase().replace(['-', '_'], "").as_str() {
365            "readreplica" | "replication" => Some(Self::ReadReplica),
366            "sharding" | "shard" | "partition" => Some(Self::Sharding),
367            "multiregion" | "georeplica" | "geographic" => Some(Self::MultiRegion),
368            "highavailability" | "ha" | "failover" => Some(Self::HighAvailability),
369            "custom" => Some(Self::Custom),
370            _ => None,
371        }
372    }
373
374    /// Get the strategy as a string.
375    pub fn as_str(&self) -> &'static str {
376        match self {
377            Self::ReadReplica => "ReadReplica",
378            Self::Sharding => "Sharding",
379            Self::MultiRegion => "MultiRegion",
380            Self::HighAvailability => "HighAvailability",
381            Self::Custom => "Custom",
382        }
383    }
384}
385
386/// Load balancing strategy for distributing queries.
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
388pub enum LoadBalanceStrategy {
389    /// Round-robin distribution.
390    RoundRobin,
391    /// Random selection.
392    Random,
393    /// Least connections.
394    LeastConnections,
395    /// Weighted distribution based on server weights.
396    Weighted,
397    /// Route to nearest (by latency or region).
398    Nearest,
399    /// Sticky sessions (same client to same server).
400    Sticky,
401}
402
403impl LoadBalanceStrategy {
404    /// Parse strategy from string.
405    pub fn from_str(s: &str) -> Option<Self> {
406        match s.to_lowercase().replace(['-', '_'], "").as_str() {
407            "roundrobin" | "rr" => Some(Self::RoundRobin),
408            "random" | "rand" => Some(Self::Random),
409            "leastconnections" | "leastconn" | "least" => Some(Self::LeastConnections),
410            "weighted" | "weight" => Some(Self::Weighted),
411            "nearest" | "latency" | "geo" => Some(Self::Nearest),
412            "sticky" | "affinity" | "session" => Some(Self::Sticky),
413            _ => None,
414        }
415    }
416
417    /// Get the strategy as a string.
418    pub fn as_str(&self) -> &'static str {
419        match self {
420            Self::RoundRobin => "RoundRobin",
421            Self::Random => "Random",
422            Self::LeastConnections => "LeastConnections",
423            Self::Weighted => "Weighted",
424            Self::Nearest => "Nearest",
425            Self::Sticky => "Sticky",
426        }
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_server_role_from_str() {
436        assert_eq!(ServerRole::from_str("primary"), Some(ServerRole::Primary));
437        assert_eq!(ServerRole::from_str("master"), Some(ServerRole::Primary));
438        assert_eq!(ServerRole::from_str("replica"), Some(ServerRole::Replica));
439        assert_eq!(ServerRole::from_str("slave"), Some(ServerRole::Replica));
440        assert_eq!(
441            ServerRole::from_str("analytics"),
442            Some(ServerRole::Analytics)
443        );
444        assert_eq!(ServerRole::from_str("shard"), Some(ServerRole::Shard));
445        assert_eq!(ServerRole::from_str("invalid"), None);
446    }
447
448    #[test]
449    fn test_server_group_strategy_from_str() {
450        assert_eq!(
451            ServerGroupStrategy::from_str("ReadReplica"),
452            Some(ServerGroupStrategy::ReadReplica)
453        );
454        assert_eq!(
455            ServerGroupStrategy::from_str("sharding"),
456            Some(ServerGroupStrategy::Sharding)
457        );
458        assert_eq!(
459            ServerGroupStrategy::from_str("multi-region"),
460            Some(ServerGroupStrategy::MultiRegion)
461        );
462        assert_eq!(
463            ServerGroupStrategy::from_str("HA"),
464            Some(ServerGroupStrategy::HighAvailability)
465        );
466    }
467
468    #[test]
469    fn test_load_balance_strategy_from_str() {
470        assert_eq!(
471            LoadBalanceStrategy::from_str("RoundRobin"),
472            Some(LoadBalanceStrategy::RoundRobin)
473        );
474        assert_eq!(
475            LoadBalanceStrategy::from_str("rr"),
476            Some(LoadBalanceStrategy::RoundRobin)
477        );
478        assert_eq!(
479            LoadBalanceStrategy::from_str("weighted"),
480            Some(LoadBalanceStrategy::Weighted)
481        );
482        assert_eq!(
483            LoadBalanceStrategy::from_str("nearest"),
484            Some(LoadBalanceStrategy::Nearest)
485        );
486    }
487
488    #[test]
489    fn test_server_property_value_display() {
490        assert_eq!(
491            ServerPropertyValue::String("test".to_string()).to_string(),
492            "\"test\""
493        );
494        assert_eq!(ServerPropertyValue::Number(42.0).to_string(), "42");
495        assert_eq!(ServerPropertyValue::Boolean(true).to_string(), "true");
496        assert_eq!(
497            ServerPropertyValue::Identifier("primary".to_string()).to_string(),
498            "primary"
499        );
500        assert_eq!(
501            ServerPropertyValue::EnvVar("DATABASE_URL".to_string()).to_string(),
502            "env(\"DATABASE_URL\")"
503        );
504    }
505
506    fn test_span() -> Span {
507        Span::new(0, 0)
508    }
509
510    #[test]
511    fn test_server_group_primary_and_replicas() {
512        let mut group = ServerGroup::new(
513            Ident::new("TestCluster", test_span()),
514            test_span(),
515        );
516
517        let mut primary = Server::new(
518            Ident::new("primary", test_span()),
519            test_span(),
520        );
521        primary.add_property(ServerProperty::new(
522            "role",
523            ServerPropertyValue::Identifier("primary".to_string()),
524            test_span(),
525        ));
526        group.add_server(primary);
527
528        let mut replica1 = Server::new(
529            Ident::new("replica1", test_span()),
530            test_span(),
531        );
532        replica1.add_property(ServerProperty::new(
533            "role",
534            ServerPropertyValue::Identifier("replica".to_string()),
535            test_span(),
536        ));
537        group.add_server(replica1);
538
539        let mut replica2 = Server::new(
540            Ident::new("replica2", test_span()),
541            test_span(),
542        );
543        replica2.add_property(ServerProperty::new(
544            "role",
545            ServerPropertyValue::Identifier("replica".to_string()),
546            test_span(),
547        ));
548        group.add_server(replica2);
549
550        assert!(group.primary().is_some());
551        assert_eq!(group.primary().unwrap().name.name.as_str(), "primary");
552        assert_eq!(group.replicas().len(), 2);
553    }
554}
555