Skip to main content

mdmodels_core/
option.rs

1/*
2 * Copyright (c) 2025 Jan Range
3 *
4 * Permission is hereby granted, free of charge, to any person obtaining a copy
5 * of this software and associated documentation files (the "Software"), to deal
6 * in the Software without restriction, including without limitation the rights
7 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 * copies of the Software, and to permit persons to whom the Software is
9 * furnished to do so, subject to the following conditions:
10 *
11 * The above copyright notice and this permission notice shall be included in
12 * all copies or substantial portions of the Software.
13 *
14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 * THE SOFTWARE.
21 *
22 */
23
24use std::str::FromStr;
25
26use serde::{Deserialize, Serialize};
27use strum_macros::{Display, EnumString};
28
29#[cfg(feature = "python")]
30use pyo3::pyclass;
31
32#[cfg(feature = "wasm")]
33use tsify_next::Tsify;
34
35/// Represents an option for an attribute in a data model.
36///
37/// This enum provides a strongly-typed representation of various attribute options
38/// that can be used to configure and constrain attributes in a data model.
39///
40/// The options are grouped into several categories:
41/// - JSON Schema validation options (e.g., minimum/maximum values, length constraints)
42/// - SQL database options (e.g., primary key)
43/// - LinkML specific options (e.g., readonly, recommended)
44/// - Custom options via the `Other` variant
45///
46#[derive(Debug, Clone, PartialEq, EnumString, Display)]
47#[cfg_attr(feature = "python", pyclass(get_all, from_py_object))]
48#[cfg_attr(feature = "wasm", derive(Tsify))]
49#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
50#[derive(Serialize, Deserialize)]
51#[serde(try_from = "RawOption")]
52#[serde(into = "RawOption")]
53pub enum AttrOption {
54    // General options
55    Example(String),
56
57    // JSON Schema validation options
58    /// Specifies the minimum value for a numeric attribute
59    #[strum(serialize = "minimum")]
60    MinimumValue(f64),
61    /// Specifies the maximum value for a numeric attribute
62    #[strum(serialize = "maximum")]
63    MaximumValue(f64),
64    /// Specifies the minimum number of items for an array attribute
65    #[strum(serialize = "minitems")]
66    MinItems(usize),
67    /// Specifies the maximum number of items for an array attribute
68    #[strum(serialize = "maxitems")]
69    MaxItems(usize),
70    /// Specifies the minimum length for a string attribute
71    #[strum(serialize = "minlength")]
72    MinLength(usize),
73    /// Specifies the maximum length for a string attribute
74    #[strum(serialize = "maxlength")]
75    MaxLength(usize),
76    /// Specifies a regular expression pattern that a string attribute must match
77    #[strum(serialize = "pattern", serialize = "regex")]
78    Pattern(String),
79    /// Specifies whether array items must be unique
80    #[strum(serialize = "unique")]
81    Unique(bool),
82    /// Specifies that a numeric value must be a multiple of this number
83    #[strum(serialize = "multipleof")]
84    MultipleOf(i32),
85    /// Specifies an exclusive minimum value for a numeric attribute
86    #[strum(serialize = "exclusiveminimum")]
87    ExclusiveMinimum(f64),
88    /// Specifies an exclusive maximum value for a numeric attribute
89    #[strum(serialize = "exclusivemaximum")]
90    ExclusiveMaximum(f64),
91
92    // SQL database options
93    /// Indicates whether the attribute is a primary key in a database
94    #[strum(serialize = "pk")]
95    PrimaryKey(bool),
96
97    // LinkML specific options
98    /// Indicates whether the attribute is read-only
99    #[strum(serialize = "readonly")]
100    ReadOnly(bool),
101    /// Indicates whether the attribute is recommended to be included
102    #[strum(serialize = "recommended")]
103    Recommended(bool),
104
105    // Custom options
106    /// Represents a custom option not covered by the predefined variants
107    Other {
108        /// The key/name of the custom option
109        key: String,
110        /// The value of the custom option
111        value: String,
112    },
113}
114
115impl AttrOption {
116    /// Creates a new `AttrOption` from a key-value pair.
117    ///
118    /// This method attempts to parse the key and value into an appropriate `AttrOption` variant.
119    /// If the key matches a known option type, it will attempt to parse the value into the
120    /// appropriate type. If the key is not recognized, it will create an `Other` variant.
121    ///
122    /// # Arguments
123    ///
124    /// * `key` - The string key identifying the option type
125    /// * `value` - The string value to be parsed into the appropriate type
126    ///
127    /// # Returns
128    ///
129    /// A `Result` containing either the parsed `AttrOption` or an error if parsing fails
130    pub fn from_pair(key: &str, value: &str) -> Result<Self, Box<dyn std::error::Error>> {
131        match AttrOption::from_str(key) {
132            Ok(option) => match option {
133                AttrOption::MinimumValue(_) => Ok(AttrOption::MinimumValue(value.parse()?)),
134                AttrOption::MaximumValue(_) => Ok(AttrOption::MaximumValue(value.parse()?)),
135                AttrOption::MinItems(_) => Ok(AttrOption::MinItems(value.parse()?)),
136                AttrOption::MaxItems(_) => Ok(AttrOption::MaxItems(value.parse()?)),
137                AttrOption::MinLength(_) => Ok(AttrOption::MinLength(value.parse()?)),
138                AttrOption::MaxLength(_) => Ok(AttrOption::MaxLength(value.parse()?)),
139                AttrOption::Pattern(_) => Ok(AttrOption::Pattern(value.to_string())),
140                AttrOption::Unique(_) => Ok(AttrOption::Unique(value.parse()?)),
141                AttrOption::MultipleOf(_) => Ok(AttrOption::MultipleOf(value.parse()?)),
142                AttrOption::ExclusiveMinimum(_) => Ok(AttrOption::ExclusiveMinimum(value.parse()?)),
143                AttrOption::ExclusiveMaximum(_) => Ok(AttrOption::ExclusiveMaximum(value.parse()?)),
144                AttrOption::PrimaryKey(_) => Ok(AttrOption::PrimaryKey(value.parse()?)),
145                AttrOption::ReadOnly(_) => Ok(AttrOption::ReadOnly(value.parse()?)),
146                AttrOption::Recommended(_) => Ok(AttrOption::Recommended(value.parse()?)),
147                AttrOption::Example(_) => Ok(AttrOption::Example(value.to_string())),
148                AttrOption::Other { .. } => unreachable!(),
149            },
150            Err(_) => Ok(AttrOption::Other {
151                key: key.to_string(),
152                value: value.to_string(),
153            }),
154        }
155    }
156
157    /// Converts the option into a key-value pair.
158    ///
159    /// # Returns
160    ///
161    /// A tuple containing the option's key and value as strings
162    pub fn to_pair(&self) -> (String, String) {
163        (self.key(), self.value())
164    }
165
166    /// Gets the key/name of the option.
167    ///
168    /// For predefined options, this returns the serialized name.
169    /// For custom options, this returns the custom key.
170    ///
171    /// # Returns
172    ///
173    /// The option's key as a String
174    pub fn key(&self) -> String {
175        match self {
176            AttrOption::Other { key, .. } => key.to_string(),
177            _ => self.to_string(),
178        }
179    }
180
181    /// Gets the value of the option as a string.
182    ///
183    /// # Returns
184    ///
185    /// The option's value converted to a String
186    pub fn value(&self) -> String {
187        match self {
188            AttrOption::Other { value, .. } => value.to_string(),
189            AttrOption::MinimumValue(value) => value.to_string(),
190            AttrOption::MaximumValue(value) => value.to_string(),
191            AttrOption::MinItems(value) => value.to_string(),
192            AttrOption::MaxItems(value) => value.to_string(),
193            AttrOption::MinLength(value) => value.to_string(),
194            AttrOption::MaxLength(value) => value.to_string(),
195            AttrOption::Pattern(value) => value.to_string(),
196            AttrOption::Unique(value) => value.to_string(),
197            AttrOption::MultipleOf(value) => value.to_string(),
198            AttrOption::ExclusiveMinimum(value) => value.to_string(),
199            AttrOption::ExclusiveMaximum(value) => value.to_string(),
200            AttrOption::PrimaryKey(value) => value.to_string(),
201            AttrOption::ReadOnly(value) => value.to_string(),
202            AttrOption::Recommended(value) => value.to_string(),
203            AttrOption::Example(value) => value.to_string(),
204        }
205    }
206}
207
208/// A raw key-value representation of an attribute option.
209///
210/// This struct provides a simple string-based representation of options,
211/// which is useful for serialization/deserialization and when working
212/// with untyped data.
213#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
214#[cfg_attr(feature = "python", pyclass(get_all, from_py_object))]
215#[cfg_attr(feature = "wasm", derive(Tsify))]
216#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
217pub struct RawOption {
218    /// The key/name of the option
219    pub key: String,
220    /// The string value of the option
221    pub value: String,
222}
223
224impl RawOption {
225    /// Creates a new `RawOption` with the given key and value.
226    ///
227    /// The key is automatically converted to lowercase for consistency.
228    ///
229    /// # Arguments
230    ///
231    /// * `key` - The key/name of the option
232    /// * `value` - The value of the option
233    pub fn new(key: String, value: String) -> Self {
234        Self {
235            key: key.to_lowercase(),
236            value,
237        }
238    }
239
240    /// Gets a reference to the option's key.
241    ///
242    /// # Returns
243    ///
244    /// A string slice containing the option's key
245    pub fn key(&self) -> &str {
246        &self.key
247    }
248
249    /// Gets a reference to the option's value.
250    ///
251    /// # Returns
252    ///
253    /// A string slice containing the option's value
254    pub fn value(&self) -> &str {
255        &self.value
256    }
257}
258
259impl TryFrom<RawOption> for AttrOption {
260    type Error = Box<dyn std::error::Error>;
261
262    fn try_from(option: RawOption) -> Result<Self, Self::Error> {
263        AttrOption::from_pair(&option.key, &option.value)
264    }
265}
266
267impl From<AttrOption> for RawOption {
268    fn from(option: AttrOption) -> Self {
269        RawOption::new(option.key(), option.value())
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use std::path::PathBuf;
276
277    use crate::prelude::DataModel;
278    use pretty_assertions::assert_eq;
279
280    use super::*;
281
282    #[test]
283    fn test_from_pair_basic() {
284        let cases = vec![
285            ("minimum", "10.5", AttrOption::MinimumValue(10.5)),
286            ("maximum", "100.0", AttrOption::MaximumValue(100.0)),
287            ("minitems", "5", AttrOption::MinItems(5)),
288            ("maxitems", "10", AttrOption::MaxItems(10)),
289            ("minlength", "3", AttrOption::MinLength(3)),
290            ("maxlength", "20", AttrOption::MaxLength(20)),
291            (
292                "pattern",
293                "^[a-z]+$",
294                AttrOption::Pattern("^[a-z]+$".to_string()),
295            ),
296            (
297                "regex",
298                "^[a-z]+$",
299                AttrOption::Pattern("^[a-z]+$".to_string()),
300            ),
301            ("unique", "true", AttrOption::Unique(true)),
302            ("multipleof", "3", AttrOption::MultipleOf(3)),
303            ("exclusiveminimum", "0.5", AttrOption::ExclusiveMinimum(0.5)),
304            (
305                "exclusivemaximum",
306                "99.9",
307                AttrOption::ExclusiveMaximum(99.9),
308            ),
309            ("pk", "true", AttrOption::PrimaryKey(true)),
310            ("readonly", "false", AttrOption::ReadOnly(false)),
311            ("recommended", "true", AttrOption::Recommended(true)),
312        ];
313
314        for (key, value, expected) in cases {
315            let result = AttrOption::from_pair(key, value).unwrap();
316            assert_eq!(result, expected);
317        }
318    }
319
320    #[test]
321    fn test_from_pair_other() {
322        let result = AttrOption::from_pair("custom_option", "value").unwrap();
323        assert_eq!(
324            result,
325            AttrOption::Other {
326                key: "custom_option".to_string(),
327                value: "value".to_string()
328            }
329        );
330    }
331
332    #[test]
333    fn test_from_pair_invalid_values() {
334        // Test invalid number formats
335        assert!(AttrOption::from_pair("minimum", "not_a_number").is_err());
336        assert!(AttrOption::from_pair("minitems", "-1").is_err());
337        assert!(AttrOption::from_pair("multipleof", "3.5").is_err());
338
339        // Test invalid boolean formats
340        assert!(AttrOption::from_pair("unique", "not_a_bool").is_err());
341        assert!(AttrOption::from_pair("pk", "invalid").is_err());
342    }
343
344    #[test]
345    fn test_to_pair() {
346        let cases = vec![
347            (
348                AttrOption::MinimumValue(10.5),
349                ("minimum".to_string(), "10.5".to_string()),
350            ),
351            (
352                AttrOption::Pattern("^test$".to_string()),
353                ("pattern".to_string(), "^test$".to_string()),
354            ),
355            (
356                AttrOption::Other {
357                    key: "custom".to_string(),
358                    value: "test".to_string(),
359                },
360                ("custom".to_string(), "test".to_string()),
361            ),
362        ];
363
364        for (option, expected) in cases {
365            assert_eq!(option.to_pair(), expected);
366        }
367    }
368
369    #[test]
370    fn test_raw_option_conversion() {
371        let raw = RawOption::new("minimum".to_string(), "10.5".to_string());
372        let attr: AttrOption = raw.try_into().unwrap();
373        assert_eq!(attr, AttrOption::MinimumValue(10.5));
374
375        let raw_back: RawOption = attr.into();
376        assert_eq!(raw_back.key(), "minimum");
377        assert_eq!(raw_back.value(), "10.5");
378    }
379
380    #[test]
381    fn test_raw_option_case_sensitivity() {
382        let raw = RawOption::new("MINIMUM".to_string(), "10.5".to_string());
383        let attr: AttrOption = raw.try_into().unwrap();
384        assert_eq!(attr, AttrOption::MinimumValue(10.5));
385    }
386
387    #[test]
388    fn test_raw_option_serialize() {
389        let raw = RawOption::new("minimum".to_string(), "10.5".to_string());
390        let serialized = serde_json::to_string(&raw).unwrap();
391        assert_eq!(serialized, r#"{"key":"minimum","value":"10.5"}"#);
392    }
393
394    #[test]
395    fn test_raw_option_deserialize() {
396        let serialized = r#"{"key":"minimum","value":"10.5"}"#;
397        let deserialized: RawOption = serde_json::from_str(serialized).unwrap();
398        assert_eq!(deserialized.key(), "minimum");
399        assert_eq!(deserialized.value(), "10.5");
400    }
401
402    #[test]
403    fn test_attr_option_from_str() {
404        let path = PathBuf::from("tests/data/model_options.md");
405        let model = DataModel::from_markdown(&path).expect("Failed to parse markdown file");
406        let attr = model.objects.first().unwrap();
407        let attribute = attr.attributes.first().unwrap();
408        let options = attribute
409            .options
410            .iter()
411            .map(|o| o.key())
412            .collect::<Vec<_>>();
413
414        let expected = vec![
415            "minimum",
416            "maximum",
417            "minitems",
418            "maxitems",
419            "minlength",
420            "maxlength",
421            "pattern",
422            "unique",
423            "multipleof",
424            "exclusiveminimum",
425            "exclusivemaximum",
426            "primarykey",
427            "readonly",
428            "recommended",
429        ];
430
431        let mut missing = Vec::new();
432        for expected_option in expected {
433            if !options.contains(&expected_option.to_string()) {
434                missing.push(expected_option);
435            }
436        }
437        assert!(
438            missing.is_empty(),
439            "Expected options \n[{}]\nnot found in \n[{}]",
440            missing.join(", "),
441            options.join(", ")
442        );
443
444        // Assert that the content of the options is correct
445        let expected_options = vec![
446            AttrOption::Example("test".to_string()),
447            AttrOption::MinimumValue(0.0),
448            AttrOption::MaximumValue(100.0),
449            AttrOption::MinItems(1),
450            AttrOption::MaxItems(10),
451            AttrOption::MinLength(1),
452            AttrOption::MaxLength(100),
453            AttrOption::Pattern("^[a-zA-Z0-9]+$".to_string()),
454            AttrOption::Pattern("^[a-zA-Z0-9]+$".to_string()),
455            AttrOption::Unique(true),
456            AttrOption::MultipleOf(2),
457            AttrOption::ExclusiveMinimum(0.0),
458            AttrOption::ExclusiveMaximum(100.0),
459            AttrOption::PrimaryKey(true),
460            AttrOption::ReadOnly(true),
461            AttrOption::Recommended(true),
462        ];
463
464        for expected_option in expected_options.iter() {
465            for option in attribute.options.iter() {
466                if option.key() == expected_option.key() {
467                    assert_eq!(option, expected_option);
468                }
469            }
470        }
471    }
472}