Skip to main content

arrow_tiberius/mssql/
profile.rs

1//! SQL Server profile types.
2
3use arrow_schema::Schema;
4
5use crate::schema::{PlannedSchema, plan_arrow_schema_to_mssql_schema};
6use crate::write::PlanOptions;
7use crate::{PlanOutcome, Result};
8
9/// SQL Server engine version targeted by planning.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11#[non_exhaustive]
12pub enum MssqlVersion {
13    /// SQL Server 2016.
14    SqlServer2016,
15    /// SQL Server 2017.
16    SqlServer2017,
17    /// SQL Server 2019.
18    SqlServer2019,
19    /// SQL Server 2022.
20    SqlServer2022,
21    /// SQL Server 2025.
22    SqlServer2025,
23}
24
25impl MssqlVersion {
26    /// Returns the default database compatibility level for this SQL Server
27    /// engine version.
28    pub const fn default_compatibility_level(self) -> CompatibilityLevel {
29        match self {
30            Self::SqlServer2016 => CompatibilityLevel::SQL_SERVER_2016,
31            Self::SqlServer2017 => CompatibilityLevel::SQL_SERVER_2017,
32            Self::SqlServer2019 => CompatibilityLevel::SQL_SERVER_2019,
33            Self::SqlServer2022 => CompatibilityLevel::SQL_SERVER_2022,
34            Self::SqlServer2025 => CompatibilityLevel::SQL_SERVER_2025,
35        }
36    }
37
38    /// Returns whether this SQL Server engine version supports the database
39    /// compatibility level.
40    pub const fn supports_compatibility_level(
41        self,
42        compatibility_level: CompatibilityLevel,
43    ) -> bool {
44        match self {
45            Self::SqlServer2016 => compatibility_level.as_u16() <= 130,
46            Self::SqlServer2017 => compatibility_level.as_u16() <= 140,
47            Self::SqlServer2019 => compatibility_level.as_u16() <= 150,
48            Self::SqlServer2022 => compatibility_level.as_u16() <= 160,
49            Self::SqlServer2025 => compatibility_level.as_u16() <= 170,
50        }
51    }
52}
53
54/// SQL Server database compatibility level.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
56pub struct CompatibilityLevel(u16);
57
58impl CompatibilityLevel {
59    /// SQL Server 2008 / 2008 R2 compatibility level.
60    pub const SQL_SERVER_2008: Self = Self(100);
61    /// SQL Server 2012 compatibility level.
62    pub const SQL_SERVER_2012: Self = Self(110);
63    /// SQL Server 2014 compatibility level.
64    pub const SQL_SERVER_2014: Self = Self(120);
65    /// SQL Server 2016 compatibility level.
66    pub const SQL_SERVER_2016: Self = Self(130);
67    /// SQL Server 2017 compatibility level.
68    pub const SQL_SERVER_2017: Self = Self(140);
69    /// SQL Server 2019 compatibility level.
70    pub const SQL_SERVER_2019: Self = Self(150);
71    /// SQL Server 2022 compatibility level.
72    pub const SQL_SERVER_2022: Self = Self(160);
73    /// SQL Server 2025 compatibility level.
74    pub const SQL_SERVER_2025: Self = Self(170);
75
76    /// Creates a validated compatibility level.
77    pub fn new(level: u16) -> Result<Self> {
78        if !Self::is_supported(level) {
79            return Err(crate::Error::InvalidCompatibilityLevel { level });
80        }
81
82        Ok(Self(level))
83    }
84
85    /// Returns the numeric compatibility level.
86    pub const fn as_u16(self) -> u16 {
87        self.0
88    }
89
90    const fn is_supported(level: u16) -> bool {
91        matches!(level, 100 | 110 | 120 | 130 | 140 | 150 | 160 | 170)
92    }
93}
94
95impl TryFrom<u16> for CompatibilityLevel {
96    type Error = crate::Error;
97
98    fn try_from(value: u16) -> Result<Self> {
99        Self::new(value)
100    }
101}
102
103/// SQL Server planning profile.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
105pub struct MssqlProfile {
106    version: MssqlVersion,
107    compatibility_level: CompatibilityLevel,
108}
109
110/// Profile-selected strategy for converting Arrow timestamps to SQL Server
111/// `datetime` fragments.
112///
113/// SQL Server can round `datetime` casts differently by database
114/// compatibility level. Writers should ask the profile for this semantic
115/// behavior instead of checking raw compatibility-level numbers.
116///
117/// The important boundary is database compatibility level 130. Older
118/// compatibility levels keep the legacy precision-loss step used by SQL Server
119/// casts from high-precision temporal values to `datetime`; level 130 and newer
120/// use the improved direct rounding behavior.
121///
122/// Legacy mode does not preserve more source precision. It can store a larger
123/// displayed `datetime` value for some inputs because the source is rounded to
124/// milliseconds before SQL Server chooses the final 1/300-second fragment.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
126#[allow(dead_code)]
127pub(crate) enum DateTimeRounding {
128    /// Use legacy pre-130 `datetime` cast semantics.
129    ///
130    /// This first rounds the source instant to whole milliseconds, then rounds
131    /// that millisecond value to SQL Server's 1/300-second `datetime`
132    /// fragments. For example, `2026-06-03T23:36:33.684582` stores as `.687`
133    /// after losing the sub-millisecond portion.
134    LegacyPre130,
135    /// Use compatibility-level 130 and later nearest-fragment semantics.
136    ///
137    /// This rounds the original high-precision source instant directly to the
138    /// nearest 1/300-second `datetime` fragment. For example,
139    /// `2026-06-03T23:36:33.684582` stores as `.683`.
140    Compat130Plus,
141}
142
143impl MssqlProfile {
144    /// Creates a SQL Server profile after validating that the engine version
145    /// supports the requested database compatibility level.
146    pub fn new(version: MssqlVersion, compatibility_level: CompatibilityLevel) -> Result<Self> {
147        if !version.supports_compatibility_level(compatibility_level) {
148            return Err(crate::Error::UnsupportedCompatibilityLevel {
149                version,
150                compatibility_level: compatibility_level.as_u16(),
151            });
152        }
153
154        Ok(Self {
155            version,
156            compatibility_level,
157        })
158    }
159
160    /// Creates the v0.1 SQL Server 2016 profile with database compatibility
161    /// level 100.
162    pub const fn sql_server_2016_compat_100() -> Self {
163        Self {
164            version: MssqlVersion::SqlServer2016,
165            compatibility_level: CompatibilityLevel::SQL_SERVER_2008,
166        }
167    }
168
169    /// Creates the SQL Server 2017 profile with database compatibility
170    /// level 100.
171    pub const fn sql_server_2017_compat_100() -> Self {
172        Self {
173            version: MssqlVersion::SqlServer2017,
174            compatibility_level: CompatibilityLevel::SQL_SERVER_2008,
175        }
176    }
177
178    /// Creates the SQL Server 2017 profile with database compatibility
179    /// level 110.
180    pub const fn sql_server_2017_compat_110() -> Self {
181        Self {
182            version: MssqlVersion::SqlServer2017,
183            compatibility_level: CompatibilityLevel::SQL_SERVER_2012,
184        }
185    }
186
187    /// Creates the SQL Server 2017 profile with database compatibility
188    /// level 120.
189    pub const fn sql_server_2017_compat_120() -> Self {
190        Self {
191            version: MssqlVersion::SqlServer2017,
192            compatibility_level: CompatibilityLevel::SQL_SERVER_2014,
193        }
194    }
195
196    /// Creates the SQL Server 2017 profile with database compatibility
197    /// level 130.
198    pub const fn sql_server_2017_compat_130() -> Self {
199        Self {
200            version: MssqlVersion::SqlServer2017,
201            compatibility_level: CompatibilityLevel::SQL_SERVER_2016,
202        }
203    }
204
205    /// Creates the SQL Server 2017 profile with database compatibility
206    /// level 140.
207    pub const fn sql_server_2017_compat_140() -> Self {
208        Self {
209            version: MssqlVersion::SqlServer2017,
210            compatibility_level: CompatibilityLevel::SQL_SERVER_2017,
211        }
212    }
213
214    /// Plans an Arrow schema using this SQL Server profile.
215    pub fn plan_arrow_schema(
216        self,
217        schema: impl AsRef<Schema>,
218        options: PlanOptions,
219    ) -> Result<PlanOutcome<PlannedSchema>> {
220        plan_arrow_schema_to_mssql_schema(schema, self, options)
221    }
222
223    /// Returns the SQL Server engine version.
224    pub const fn version(self) -> MssqlVersion {
225        self.version
226    }
227
228    /// Returns the database compatibility level.
229    pub const fn compatibility_level(self) -> CompatibilityLevel {
230        self.compatibility_level
231    }
232
233    /// Returns the `datetime` rounding behavior selected by compatibility level.
234    ///
235    /// This is the single place that maps raw SQL Server compatibility levels
236    /// to runtime timestamp-conversion behavior.
237    #[allow(dead_code)]
238    pub(crate) const fn datetime_rounding(self) -> DateTimeRounding {
239        if self.compatibility_level.as_u16() < 130 {
240            DateTimeRounding::LegacyPre130
241        } else {
242            DateTimeRounding::Compat130Plus
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::{CompatibilityLevel, DateTimeRounding, MssqlProfile, MssqlVersion};
250
251    #[test]
252    fn constructs_sql_server_2016_compat_100_profile() {
253        let profile = MssqlProfile::sql_server_2016_compat_100();
254
255        assert_eq!(profile.version(), MssqlVersion::SqlServer2016);
256        assert_eq!(profile.compatibility_level().as_u16(), 100);
257    }
258
259    #[test]
260    fn constructs_sql_server_2017_profiles() {
261        let cases = [
262            (MssqlProfile::sql_server_2017_compat_100(), 100),
263            (MssqlProfile::sql_server_2017_compat_110(), 110),
264            (MssqlProfile::sql_server_2017_compat_120(), 120),
265            (MssqlProfile::sql_server_2017_compat_130(), 130),
266            (MssqlProfile::sql_server_2017_compat_140(), 140),
267        ];
268
269        for (profile, compatibility_level) in cases {
270            assert_eq!(profile.version(), MssqlVersion::SqlServer2017);
271            assert_eq!(profile.compatibility_level().as_u16(), compatibility_level);
272        }
273    }
274
275    #[test]
276    fn reports_default_compatibility_level_by_version() {
277        let cases = [
278            (MssqlVersion::SqlServer2016, 130),
279            (MssqlVersion::SqlServer2017, 140),
280            (MssqlVersion::SqlServer2019, 150),
281            (MssqlVersion::SqlServer2022, 160),
282            (MssqlVersion::SqlServer2025, 170),
283        ];
284
285        for (version, compatibility_level) in cases {
286            assert_eq!(
287                version.default_compatibility_level().as_u16(),
288                compatibility_level
289            );
290        }
291    }
292
293    #[test]
294    fn creates_profiles_for_supported_version_compatibility_pairs() {
295        let cases: &[(MssqlVersion, &[u16])] = &[
296            (MssqlVersion::SqlServer2016, &[100, 110, 120, 130]),
297            (MssqlVersion::SqlServer2017, &[100, 110, 120, 130, 140]),
298            (MssqlVersion::SqlServer2019, &[100, 110, 120, 130, 140, 150]),
299            (
300                MssqlVersion::SqlServer2022,
301                &[100, 110, 120, 130, 140, 150, 160],
302            ),
303            (
304                MssqlVersion::SqlServer2025,
305                &[100, 110, 120, 130, 140, 150, 160, 170],
306            ),
307        ];
308
309        for (version, compatibility_levels) in cases {
310            for compatibility_level in *compatibility_levels {
311                let compatibility_level = CompatibilityLevel::new(*compatibility_level).unwrap();
312                let profile = MssqlProfile::new(*version, compatibility_level).unwrap();
313
314                assert_eq!(profile.version(), *version);
315                assert_eq!(profile.compatibility_level(), compatibility_level);
316            }
317        }
318    }
319
320    #[test]
321    fn rejects_version_compatibility_pairs_not_supported_by_sql_server() {
322        let cases = [
323            (MssqlVersion::SqlServer2016, 140),
324            (MssqlVersion::SqlServer2017, 150),
325            (MssqlVersion::SqlServer2019, 160),
326            (MssqlVersion::SqlServer2022, 170),
327        ];
328
329        for (version, compatibility_level) in cases {
330            let level = CompatibilityLevel::new(compatibility_level).unwrap();
331            let err = MssqlProfile::new(version, level).expect_err("profile should be rejected");
332
333            assert!(
334                err.to_string().contains(&format!(
335                    "does not support database compatibility level {compatibility_level}"
336                )),
337                "unexpected error: {err}"
338            );
339        }
340    }
341
342    #[test]
343    fn accepts_supported_compatibility_levels() {
344        let cases = [
345            (100, CompatibilityLevel::SQL_SERVER_2008),
346            (110, CompatibilityLevel::SQL_SERVER_2012),
347            (120, CompatibilityLevel::SQL_SERVER_2014),
348            (130, CompatibilityLevel::SQL_SERVER_2016),
349            (140, CompatibilityLevel::SQL_SERVER_2017),
350            (150, CompatibilityLevel::SQL_SERVER_2019),
351            (160, CompatibilityLevel::SQL_SERVER_2022),
352            (170, CompatibilityLevel::SQL_SERVER_2025),
353        ];
354
355        for (value, expected) in cases {
356            let level = CompatibilityLevel::new(value).unwrap();
357
358            assert_eq!(level, expected);
359            assert_eq!(level.as_u16(), value);
360        }
361    }
362
363    #[test]
364    fn try_from_accepts_supported_compatibility_levels() {
365        for value in [100, 110, 120, 130, 140, 150, 160, 170] {
366            let level = CompatibilityLevel::try_from(value).unwrap();
367
368            assert_eq!(level.as_u16(), value);
369        }
370    }
371
372    #[test]
373    fn rejects_unsupported_compatibility_level() {
374        let err = CompatibilityLevel::new(90).expect_err("level should be rejected");
375
376        assert!(err.to_string().contains("invalid compatibility level 90"));
377    }
378
379    #[test]
380    fn rejects_nearby_and_extreme_compatibility_levels() {
381        for level in [
382            0,
383            99,
384            101,
385            109,
386            111,
387            119,
388            121,
389            129,
390            131,
391            139,
392            141,
393            149,
394            151,
395            159,
396            161,
397            169,
398            171,
399            u16::MAX,
400        ] {
401            let err = CompatibilityLevel::new(level).expect_err("level should be rejected");
402
403            assert!(
404                err.to_string()
405                    .contains(&format!("invalid compatibility level {level}")),
406                "unexpected error: {err}"
407            );
408        }
409    }
410
411    #[test]
412    fn selects_datetime_rounding_by_compatibility_level() {
413        let cases = [
414            (
415                MssqlProfile::sql_server_2016_compat_100(),
416                DateTimeRounding::LegacyPre130,
417            ),
418            (
419                MssqlProfile::sql_server_2017_compat_100(),
420                DateTimeRounding::LegacyPre130,
421            ),
422            (
423                MssqlProfile::sql_server_2017_compat_110(),
424                DateTimeRounding::LegacyPre130,
425            ),
426            (
427                MssqlProfile::sql_server_2017_compat_120(),
428                DateTimeRounding::LegacyPre130,
429            ),
430            (
431                MssqlProfile::sql_server_2017_compat_130(),
432                DateTimeRounding::Compat130Plus,
433            ),
434            (
435                MssqlProfile::sql_server_2017_compat_140(),
436                DateTimeRounding::Compat130Plus,
437            ),
438            (
439                MssqlProfile::new(
440                    MssqlVersion::SqlServer2019,
441                    CompatibilityLevel::SQL_SERVER_2019,
442                )
443                .unwrap(),
444                DateTimeRounding::Compat130Plus,
445            ),
446            (
447                MssqlProfile::new(
448                    MssqlVersion::SqlServer2022,
449                    CompatibilityLevel::SQL_SERVER_2022,
450                )
451                .unwrap(),
452                DateTimeRounding::Compat130Plus,
453            ),
454            (
455                MssqlProfile::new(
456                    MssqlVersion::SqlServer2025,
457                    CompatibilityLevel::SQL_SERVER_2025,
458                )
459                .unwrap(),
460                DateTimeRounding::Compat130Plus,
461            ),
462        ];
463
464        for (profile, expected) in cases {
465            assert_eq!(profile.datetime_rounding(), expected);
466        }
467    }
468}