Skip to main content

arrow_tiberius/mssql/
identifier.rs

1//! SQL Server identifier types and quoting helpers.
2
3use crate::Result;
4
5const MAX_IDENTIFIER_CHARS: usize = 128;
6
7/// SQL Server identifier policy.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9#[non_exhaustive]
10pub enum IdentifierPolicy {
11    /// Reject empty and over-length identifiers, then render with bracket
12    /// quoting.
13    BracketQuoted,
14}
15
16/// A single SQL Server identifier part.
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub struct Identifier {
19    value: String,
20}
21
22impl Identifier {
23    /// Creates a SQL Server identifier using the default identifier policy.
24    pub fn new(value: impl Into<String>) -> Result<Self> {
25        Self::with_policy(value, IdentifierPolicy::BracketQuoted)
26    }
27
28    /// Creates a SQL Server identifier using an explicit identifier policy.
29    pub fn with_policy(value: impl Into<String>, policy: IdentifierPolicy) -> Result<Self> {
30        let value = value.into();
31        validate_identifier(&value, policy)?;
32        Ok(Self { value })
33    }
34
35    /// Returns the unquoted identifier text.
36    pub fn as_str(&self) -> &str {
37        &self.value
38    }
39
40    /// Renders this identifier with SQL Server bracket quoting.
41    pub fn quoted_sql(&self) -> String {
42        bracket_quote(&self.value)
43    }
44}
45
46/// SQL Server table name.
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub struct TableName {
49    schema: Option<Identifier>,
50    table: Identifier,
51}
52
53impl TableName {
54    /// Creates a schema-qualified table name.
55    pub fn new(schema: impl Into<String>, table: impl Into<String>) -> Result<Self> {
56        Ok(Self {
57            schema: Some(Identifier::new(schema)?),
58            table: Identifier::new(table)?,
59        })
60    }
61
62    /// Creates an unqualified table name.
63    pub fn unqualified(table: impl Into<String>) -> Result<Self> {
64        Ok(Self {
65            schema: None,
66            table: Identifier::new(table)?,
67        })
68    }
69
70    /// Returns the optional schema identifier.
71    pub fn schema(&self) -> Option<&Identifier> {
72        self.schema.as_ref()
73    }
74
75    /// Returns the table identifier.
76    pub fn table(&self) -> &Identifier {
77        &self.table
78    }
79
80    /// Renders the table name with bracket-quoted identifier parts.
81    pub fn quoted_sql(&self) -> String {
82        match &self.schema {
83            Some(schema) => format!("{}.{}", schema.quoted_sql(), self.table.quoted_sql()),
84            None => self.table.quoted_sql(),
85        }
86    }
87}
88
89fn validate_identifier(value: &str, policy: IdentifierPolicy) -> Result<()> {
90    match policy {
91        IdentifierPolicy::BracketQuoted => {
92            if value.is_empty() {
93                return Err(crate::Error::InvalidIdentifier {
94                    reason: "identifier cannot be empty".to_owned(),
95                });
96            }
97
98            if value.chars().any(char::is_control) {
99                return Err(crate::Error::InvalidIdentifier {
100                    reason: "identifier cannot contain control characters".to_owned(),
101                });
102            }
103
104            let len = value.chars().count();
105            if len > MAX_IDENTIFIER_CHARS {
106                return Err(crate::Error::InvalidIdentifier {
107                    reason: format!(
108                        "identifier is {len} characters; maximum is {MAX_IDENTIFIER_CHARS}"
109                    ),
110                });
111            }
112
113            Ok(())
114        }
115    }
116}
117
118fn bracket_quote(value: &str) -> String {
119    let escaped = value.replace(']', "]]");
120    format!("[{escaped}]")
121}
122
123#[cfg(test)]
124mod tests {
125    use super::{Identifier, TableName};
126
127    #[test]
128    fn quotes_ordinary_identifier() {
129        let ident = Identifier::new("target_table").unwrap();
130
131        assert_eq!(ident.as_str(), "target_table");
132        assert_eq!(ident.quoted_sql(), "[target_table]");
133    }
134
135    #[test]
136    fn quotes_identifier_with_spaces() {
137        let ident = Identifier::new("target table").unwrap();
138
139        assert_eq!(ident.quoted_sql(), "[target table]");
140    }
141
142    #[test]
143    fn quotes_reserved_like_identifier() {
144        let ident = Identifier::new("select").unwrap();
145
146        assert_eq!(ident.quoted_sql(), "[select]");
147    }
148
149    #[test]
150    fn treats_dot_as_literal_identifier_content() {
151        let ident = Identifier::new("dbo.target").unwrap();
152
153        assert_eq!(ident.quoted_sql(), "[dbo.target]");
154    }
155
156    #[test]
157    fn escapes_brackets() {
158        let ident = Identifier::new("a]b").unwrap();
159
160        assert_eq!(ident.quoted_sql(), "[a]]b]");
161    }
162
163    #[test]
164    fn quotes_injection_shaped_identifier_as_one_identifier() {
165        let ident = Identifier::new("dbo].[target]; DROP TABLE [prod];--").unwrap();
166
167        assert_eq!(
168            ident.quoted_sql(),
169            "[dbo]].[target]]; DROP TABLE [prod]];--]"
170        );
171    }
172
173    #[test]
174    fn accepts_exactly_128_unicode_scalar_values() {
175        let value = "表".repeat(128);
176        let ident = Identifier::new(value.clone()).unwrap();
177
178        assert_eq!(ident.as_str(), value);
179    }
180
181    #[test]
182    fn rejects_empty_identifier() {
183        let err = Identifier::new("").expect_err("empty identifiers should be rejected");
184
185        assert!(
186            err.to_string().contains("identifier cannot be empty"),
187            "unexpected error: {err}"
188        );
189    }
190
191    #[test]
192    fn rejects_control_characters() {
193        for value in ["line\nbreak", "tab\tname", "nul\0name"] {
194            let err = Identifier::new(value).expect_err("control characters should be rejected");
195
196            assert!(
197                err.to_string().contains("control characters"),
198                "unexpected error: {err}"
199            );
200        }
201    }
202
203    #[test]
204    fn rejects_over_length_identifier() {
205        let value = "x".repeat(129);
206        let err = Identifier::new(value).expect_err("over-length identifiers should be rejected");
207
208        assert!(
209            err.to_string().contains("maximum is 128"),
210            "unexpected error: {err}"
211        );
212    }
213
214    #[test]
215    fn rejects_over_length_unicode_identifier_by_character_count() {
216        let value = "表".repeat(129);
217        let err = Identifier::new(value).expect_err("over-length identifiers should be rejected");
218
219        assert!(
220            err.to_string().contains("identifier is 129 characters"),
221            "unexpected error: {err}"
222        );
223    }
224
225    #[test]
226    fn renders_schema_qualified_table_name() {
227        let table = TableName::new("dbo", "target").unwrap();
228
229        assert_eq!(table.quoted_sql(), "[dbo].[target]");
230        assert_eq!(table.schema().unwrap().as_str(), "dbo");
231        assert_eq!(table.table().as_str(), "target");
232    }
233
234    #[test]
235    fn renders_unqualified_table_name() {
236        let table = TableName::unqualified("target").unwrap();
237
238        assert_eq!(table.quoted_sql(), "[target]");
239        assert!(table.schema().is_none());
240        assert_eq!(table.table().as_str(), "target");
241    }
242
243    #[test]
244    fn table_name_does_not_split_dots_inside_parts() {
245        let table = TableName::new("dbo.part", "target.part").unwrap();
246
247        assert_eq!(table.quoted_sql(), "[dbo.part].[target.part]");
248    }
249
250    #[test]
251    fn rejects_invalid_schema_or_table_part() {
252        let err = TableName::new("", "target").expect_err("empty schema should be rejected");
253        assert!(
254            err.to_string().contains("identifier cannot be empty"),
255            "unexpected error: {err}"
256        );
257
258        let err = TableName::new("dbo", "").expect_err("empty table should be rejected");
259        assert!(
260            err.to_string().contains("identifier cannot be empty"),
261            "unexpected error: {err}"
262        );
263
264        let err = TableName::unqualified("").expect_err("empty table should be rejected");
265        assert!(
266            err.to_string().contains("identifier cannot be empty"),
267            "unexpected error: {err}"
268        );
269    }
270}