Skip to main content

postgrest_parser/parser/
schema.rs

1use crate::ast::ResolvedTable;
2use crate::error::{Error, ParseError};
3use std::collections::HashMap;
4
5/// Resolves the schema and table name from various sources with priority:
6/// 1. Explicit schema in table name (e.g., "auth.users")
7/// 2. Header-based (Accept-Profile for GET, Content-Profile for POST/PATCH/DELETE)
8/// 3. Default "public" schema
9///
10/// # Arguments
11///
12/// * `table` - Table name, optionally schema-qualified (e.g., "users" or "auth.users")
13/// * `method` - HTTP method (GET, POST, PATCH, DELETE)
14/// * `headers` - Optional headers map containing profile headers
15///
16/// # Examples
17///
18/// ```
19/// use postgrest_parser::parser::resolve_schema;
20/// use std::collections::HashMap;
21///
22/// // Explicit schema in table name
23/// let table = resolve_schema("auth.users", "GET", None).unwrap();
24/// assert_eq!(table.schema, "auth");
25/// assert_eq!(table.name, "users");
26///
27/// // Header-based schema
28/// let mut headers = HashMap::new();
29/// headers.insert("Accept-Profile".to_string(), "myschema".to_string());
30/// let table = resolve_schema("users", "GET", Some(&headers)).unwrap();
31/// assert_eq!(table.schema, "myschema");
32///
33/// // Default public schema
34/// let table = resolve_schema("users", "POST", None).unwrap();
35/// assert_eq!(table.schema, "public");
36/// ```
37pub fn resolve_schema(
38    table: &str,
39    method: &str,
40    headers: Option<&HashMap<String, String>>,
41) -> Result<ResolvedTable, Error> {
42    if table.is_empty() {
43        return Err(Error::Parse(ParseError::InvalidTableName(
44            "Table name cannot be empty".to_string(),
45        )));
46    }
47
48    // Try to parse schema from table name first (e.g., "schema.table")
49    if let Some((schema, name)) = parse_qualified_table(table)? {
50        validate_identifier(&schema)?;
51        validate_identifier(&name)?;
52        return Ok(ResolvedTable::new(schema, name));
53    }
54
55    // Table name is not schema-qualified, use headers or default
56    validate_identifier(table)?;
57
58    let schema = get_profile_header(method, headers).unwrap_or_else(|| "public".to_string());
59    validate_identifier(&schema)?;
60
61    Ok(ResolvedTable::new(schema, table))
62}
63
64/// Parses a potentially schema-qualified table name.
65///
66/// Returns (schema, table) if qualified, None if not qualified.
67///
68/// # Examples
69///
70/// ```
71/// use postgrest_parser::parser::parse_qualified_table;
72///
73/// let result = parse_qualified_table("auth.users").unwrap();
74/// assert_eq!(result, Some(("auth".to_string(), "users".to_string())));
75///
76/// let result = parse_qualified_table("users").unwrap();
77/// assert_eq!(result, None);
78/// ```
79pub fn parse_qualified_table(table: &str) -> Result<Option<(String, String)>, Error> {
80    let parts: Vec<&str> = table.split('.').collect();
81
82    match parts.len() {
83        1 => Ok(None),
84        2 => {
85            let schema = parts[0].trim();
86            let name = parts[1].trim();
87
88            if schema.is_empty() || name.is_empty() {
89                return Err(Error::Parse(ParseError::InvalidTableName(format!(
90                    "Invalid qualified table name: '{}'",
91                    table
92                ))));
93            }
94
95            Ok(Some((schema.to_string(), name.to_string())))
96        }
97        _ => Err(Error::Parse(ParseError::InvalidTableName(format!(
98            "Invalid table name format: '{}'. Expected 'table' or 'schema.table'",
99            table
100        )))),
101    }
102}
103
104/// Gets the appropriate profile header based on HTTP method.
105///
106/// - GET → Accept-Profile
107/// - POST/PATCH/DELETE → Content-Profile
108///
109/// # Examples
110///
111/// ```
112/// use postgrest_parser::parser::get_profile_header;
113/// use std::collections::HashMap;
114///
115/// let mut headers = HashMap::new();
116/// headers.insert("Accept-Profile".to_string(), "myschema".to_string());
117/// let schema = get_profile_header("GET", Some(&headers));
118/// assert_eq!(schema, Some("myschema".to_string()));
119///
120/// let mut headers = HashMap::new();
121/// headers.insert("Content-Profile".to_string(), "auth".to_string());
122/// let schema = get_profile_header("POST", Some(&headers));
123/// assert_eq!(schema, Some("auth".to_string()));
124/// ```
125pub fn get_profile_header(
126    method: &str,
127    headers: Option<&HashMap<String, String>>,
128) -> Option<String> {
129    let headers = headers?;
130
131    let header_name = match method.to_uppercase().as_str() {
132        "GET" => "Accept-Profile",
133        "POST" | "PATCH" | "DELETE" => "Content-Profile",
134        _ => return None,
135    };
136
137    // Check both exact case and case-insensitive
138    headers
139        .get(header_name)
140        .or_else(|| {
141            let lowercase = header_name.to_lowercase();
142            headers
143                .iter()
144                .find(|(k, _)| k.to_lowercase() == lowercase)
145                .map(|(_, v)| v)
146        })
147        .map(|s| s.trim().to_string())
148        .filter(|s| !s.is_empty())
149}
150
151/// Validates that an identifier (schema or table name) contains only valid characters.
152///
153/// Valid identifiers:
154/// - Start with a letter (a-z, A-Z) or underscore
155/// - Contain only letters, digits, underscores
156/// - Are not empty
157fn validate_identifier(identifier: &str) -> Result<(), Error> {
158    if identifier.is_empty() {
159        return Err(Error::Parse(ParseError::InvalidTableName(
160            "Identifier cannot be empty".to_string(),
161        )));
162    }
163
164    let first_char = identifier.chars().next().unwrap();
165    if !first_char.is_alphabetic() && first_char != '_' {
166        return Err(Error::Parse(ParseError::InvalidSchema(format!(
167            "Identifier '{}' must start with a letter or underscore",
168            identifier
169        ))));
170    }
171
172    for ch in identifier.chars() {
173        if !ch.is_alphanumeric() && ch != '_' {
174            return Err(Error::Parse(ParseError::InvalidSchema(format!(
175                "Identifier '{}' contains invalid character '{}'",
176                identifier, ch
177            ))));
178        }
179    }
180
181    Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn test_resolve_schema_explicit_in_table_name() {
190        let table = resolve_schema("auth.users", "GET", None).unwrap();
191        assert_eq!(table.schema, "auth");
192        assert_eq!(table.name, "users");
193    }
194
195    #[test]
196    fn test_resolve_schema_with_accept_profile_header() {
197        let mut headers = HashMap::new();
198        headers.insert("Accept-Profile".to_string(), "myschema".to_string());
199        let table = resolve_schema("users", "GET", Some(&headers)).unwrap();
200        assert_eq!(table.schema, "myschema");
201        assert_eq!(table.name, "users");
202    }
203
204    #[test]
205    fn test_resolve_schema_with_content_profile_header() {
206        let mut headers = HashMap::new();
207        headers.insert("Content-Profile".to_string(), "auth".to_string());
208        let table = resolve_schema("users", "POST", Some(&headers)).unwrap();
209        assert_eq!(table.schema, "auth");
210        assert_eq!(table.name, "users");
211    }
212
213    #[test]
214    fn test_resolve_schema_default_public() {
215        let table = resolve_schema("users", "POST", None).unwrap();
216        assert_eq!(table.schema, "public");
217        assert_eq!(table.name, "users");
218    }
219
220    #[test]
221    fn test_resolve_schema_explicit_overrides_header() {
222        let mut headers = HashMap::new();
223        headers.insert("Accept-Profile".to_string(), "other".to_string());
224        let table = resolve_schema("auth.users", "GET", Some(&headers)).unwrap();
225        assert_eq!(table.schema, "auth"); // Explicit wins
226        assert_eq!(table.name, "users");
227    }
228
229    #[test]
230    fn test_resolve_schema_empty_table_name() {
231        let result = resolve_schema("", "GET", None);
232        assert!(result.is_err());
233    }
234
235    #[test]
236    fn test_parse_qualified_table_with_schema() {
237        let result = parse_qualified_table("auth.users").unwrap();
238        assert_eq!(result, Some(("auth".to_string(), "users".to_string())));
239    }
240
241    #[test]
242    fn test_parse_qualified_table_without_schema() {
243        let result = parse_qualified_table("users").unwrap();
244        assert_eq!(result, None);
245    }
246
247    #[test]
248    fn test_parse_qualified_table_multiple_dots() {
249        let result = parse_qualified_table("schema.table.extra");
250        assert!(result.is_err());
251    }
252
253    #[test]
254    fn test_parse_qualified_table_empty_parts() {
255        let result = parse_qualified_table(".users");
256        assert!(result.is_err());
257
258        let result = parse_qualified_table("schema.");
259        assert!(result.is_err());
260    }
261
262    #[test]
263    fn test_get_profile_header_accept_profile() {
264        let mut headers = HashMap::new();
265        headers.insert("Accept-Profile".to_string(), "myschema".to_string());
266        let schema = get_profile_header("GET", Some(&headers));
267        assert_eq!(schema, Some("myschema".to_string()));
268    }
269
270    #[test]
271    fn test_get_profile_header_content_profile_post() {
272        let mut headers = HashMap::new();
273        headers.insert("Content-Profile".to_string(), "auth".to_string());
274        let schema = get_profile_header("POST", Some(&headers));
275        assert_eq!(schema, Some("auth".to_string()));
276    }
277
278    #[test]
279    fn test_get_profile_header_content_profile_patch() {
280        let mut headers = HashMap::new();
281        headers.insert("Content-Profile".to_string(), "auth".to_string());
282        let schema = get_profile_header("PATCH", Some(&headers));
283        assert_eq!(schema, Some("auth".to_string()));
284    }
285
286    #[test]
287    fn test_get_profile_header_content_profile_delete() {
288        let mut headers = HashMap::new();
289        headers.insert("Content-Profile".to_string(), "auth".to_string());
290        let schema = get_profile_header("DELETE", Some(&headers));
291        assert_eq!(schema, Some("auth".to_string()));
292    }
293
294    #[test]
295    fn test_get_profile_header_no_headers() {
296        let schema = get_profile_header("GET", None);
297        assert_eq!(schema, None);
298    }
299
300    #[test]
301    fn test_get_profile_header_empty_value() {
302        let mut headers = HashMap::new();
303        headers.insert("Accept-Profile".to_string(), "".to_string());
304        let schema = get_profile_header("GET", Some(&headers));
305        assert_eq!(schema, None);
306    }
307
308    #[test]
309    fn test_get_profile_header_whitespace_trimmed() {
310        let mut headers = HashMap::new();
311        headers.insert("Accept-Profile".to_string(), "  myschema  ".to_string());
312        let schema = get_profile_header("GET", Some(&headers));
313        assert_eq!(schema, Some("myschema".to_string()));
314    }
315
316    #[test]
317    fn test_get_profile_header_case_insensitive() {
318        let mut headers = HashMap::new();
319        headers.insert("accept-profile".to_string(), "myschema".to_string());
320        let schema = get_profile_header("GET", Some(&headers));
321        assert_eq!(schema, Some("myschema".to_string()));
322    }
323
324    #[test]
325    fn test_validate_identifier_valid() {
326        assert!(validate_identifier("users").is_ok());
327        assert!(validate_identifier("_users").is_ok());
328        assert!(validate_identifier("users_table").is_ok());
329        assert!(validate_identifier("users123").is_ok());
330        assert!(validate_identifier("MyTable").is_ok());
331    }
332
333    #[test]
334    fn test_validate_identifier_invalid_start() {
335        assert!(validate_identifier("123users").is_err());
336        assert!(validate_identifier("-users").is_err());
337    }
338
339    #[test]
340    fn test_validate_identifier_invalid_chars() {
341        assert!(validate_identifier("users-table").is_err());
342        assert!(validate_identifier("users.table").is_err());
343        assert!(validate_identifier("users@table").is_err());
344    }
345
346    #[test]
347    fn test_validate_identifier_empty() {
348        assert!(validate_identifier("").is_err());
349    }
350
351    #[test]
352    fn test_resolved_table_qualified_name() {
353        let table = ResolvedTable::new("auth", "users");
354        assert_eq!(table.qualified_name(), "\"auth\".\"users\"");
355    }
356}