Skip to main content

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