Skip to main content

nodedb_types/columnar/
column_parse.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! [`std::fmt::Display`] and [`std::str::FromStr`] for [`ColumnType`], plus
4//! [`ColumnTypeParseError`].
5
6use std::fmt;
7use std::str::FromStr;
8
9use super::column_type::ColumnType;
10use super::decimal_typmod::{DecimalTypmod, DecimalTypmodError};
11use crate::error::sqlstate;
12
13/// Every declared spelling that resolves to [`ColumnType::Int64`].
14///
15/// nodedb stores every integer as a full `i64`, so all of these collapse to
16/// one storage variant. The declared width travels separately as an
17/// [`IntWidth`](super::IntWidth), which narrows the advertised wire OID and
18/// bounds writes. [`IntWidth::from_declared_type`](super::IntWidth::from_declared_type)
19/// must recognize every spelling listed here — a spelling it does not know
20/// advertises OID 20 (`bigint`) whatever the author declared.
21pub const DECLARED_INT_KEYWORDS: [&str; 8] = [
22    "BIGINT", "INT64", "INTEGER", "INT", "INT4", "INT8", "SMALLINT", "INT2",
23];
24
25/// Every declared spelling that resolves to [`ColumnType::Float64`].
26///
27/// The float counterpart of [`DECLARED_INT_KEYWORDS`]: nodedb stores every
28/// float as a full `f64`, and
29/// [`FloatWidth::from_declared_type`](super::FloatWidth::from_declared_type)
30/// carries the declared width that narrows the advertised wire OID. It must
31/// recognize every spelling listed here.
32pub const DECLARED_FLOAT_KEYWORDS: [&str; 8] = [
33    "FLOAT64",
34    "DOUBLE",
35    "DOUBLE PRECISION",
36    "FLOAT8",
37    "REAL",
38    "FLOAT4",
39    "FLOAT32",
40    "FLOAT",
41];
42
43/// Error from parsing a column type string.
44#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45#[non_exhaustive]
46pub enum ColumnTypeParseError {
47    #[error("unknown column type: '{0}'")]
48    Unknown(String),
49    #[error("'DATETIME' is not a valid type — use 'TIMESTAMP' instead")]
50    UseTimestamp,
51    #[error("invalid VECTOR dimension: '{0}' (must be a positive integer)")]
52    InvalidVectorDim(String),
53    #[error("invalid VARCHAR length: '{0}' (must be a positive integer)")]
54    InvalidCharLength(String),
55    #[error(
56        "invalid DECIMAL/NUMERIC params: '{0}' (expected DECIMAL(precision) or DECIMAL(precision, scale))"
57    )]
58    InvalidDecimalParams(String),
59    /// A well-formed `DECIMAL(p,s)` whose precision or scale is out of range.
60    #[error("{0}")]
61    InvalidDecimalTypmod(#[from] DecimalTypmodError),
62}
63
64impl ColumnTypeParseError {
65    /// The SQLSTATE a DDL statement refuses this declared type with.
66    ///
67    /// An out-of-range typmod is `22023` (invalid_parameter_value), as
68    /// PostgreSQL reports it. Every other refusal is `42601` (syntax_error).
69    pub fn sqlstate(&self) -> &'static str {
70        match self {
71            Self::InvalidDecimalTypmod(_) => sqlstate::INVALID_PARAMETER_VALUE,
72            Self::Unknown(_)
73            | Self::UseTimestamp
74            | Self::InvalidVectorDim(_)
75            | Self::InvalidCharLength(_)
76            | Self::InvalidDecimalParams(_) => sqlstate::SYNTAX_ERROR,
77        }
78    }
79}
80
81impl fmt::Display for ColumnType {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            Self::Int64 => f.write_str("BIGINT"),
85            Self::Float64 => f.write_str("FLOAT64"),
86            Self::String => f.write_str("TEXT"),
87            Self::Bool => f.write_str("BOOL"),
88            Self::Bytes => f.write_str("BYTES"),
89            Self::Timestamp => f.write_str("TIMESTAMP"),
90            Self::Timestamptz => f.write_str("TIMESTAMPTZ"),
91            Self::SystemTimestamp => f.write_str("SYSTEM_TIMESTAMP"),
92            Self::Decimal(Some(typmod)) => {
93                write!(f, "DECIMAL({},{})", typmod.precision(), typmod.scale())
94            }
95            Self::Decimal(None) => f.write_str("DECIMAL"),
96            Self::Geometry => f.write_str("GEOMETRY"),
97            Self::Vector(dim) => write!(f, "VECTOR({dim})"),
98            Self::SparseVector => f.write_str("SPARSEVECTOR"),
99            Self::Uuid => f.write_str("UUID"),
100            Self::Json => f.write_str("JSON"),
101            Self::Ulid => f.write_str("ULID"),
102            Self::Duration => f.write_str("DURATION"),
103            Self::Array => f.write_str("ARRAY"),
104            Self::Set => f.write_str("SET"),
105            Self::Regex => f.write_str("REGEX"),
106            Self::Range => f.write_str("RANGE"),
107            Self::Record => f.write_str("RECORD"),
108        }
109    }
110}
111
112impl FromStr for ColumnType {
113    type Err = ColumnTypeParseError;
114
115    fn from_str(s: &str) -> Result<Self, Self::Err> {
116        let upper = s.trim().to_uppercase();
117
118        // NUMERIC / DECIMAL, bare or with a `(p)` / `(p,s)` typmod. A longer
119        // word such as `NUMERIC_MONEY` is not this keyword and falls through
120        // to the unknown-type arm.
121        if let Some(rest) = upper
122            .strip_prefix("NUMERIC")
123            .or_else(|| upper.strip_prefix("DECIMAL"))
124        {
125            let rest = rest.trim();
126            if rest.is_empty() {
127                return Ok(Self::Decimal(None));
128            }
129            if rest.starts_with('(') {
130                return parse_decimal_typmod(rest).map(|typmod| Self::Decimal(Some(typmod)));
131            }
132        }
133
134        // VECTOR(N) special case.
135        if upper.starts_with("VECTOR") {
136            let inner = upper
137                .trim_start_matches("VECTOR")
138                .trim()
139                .trim_start_matches('(')
140                .trim_end_matches(')')
141                .trim();
142            if inner.is_empty() {
143                return Err(ColumnTypeParseError::InvalidVectorDim("empty".into()));
144            }
145            let dim: u32 = inner
146                .parse()
147                .map_err(|_| ColumnTypeParseError::InvalidVectorDim(inner.into()))?;
148            if dim == 0 {
149                return Err(ColumnTypeParseError::InvalidVectorDim("0".into()));
150            }
151            return Ok(Self::Vector(dim));
152        }
153
154        // VARCHAR(n) special case. A declared character length is a wire
155        // contract, not a storage property — nodedb stores every string
156        // unbounded — so `VARCHAR(255)` resolves to the answer bare `VARCHAR`
157        // gives. The length still parses, so a malformed one errors here
158        // instead of resolving to a type the author did not write.
159        if let Some(rest) = upper.strip_prefix("VARCHAR")
160            && let Some(inner) = rest.strip_prefix('(').and_then(|r| r.strip_suffix(')'))
161        {
162            let length: u32 = inner
163                .trim()
164                .parse()
165                .map_err(|_| ColumnTypeParseError::InvalidCharLength(inner.trim().to_string()))?;
166            if length == 0 {
167                return Err(ColumnTypeParseError::InvalidCharLength("0".into()));
168            }
169            return Ok(Self::String);
170        }
171
172        match upper.as_str() {
173            // Every PostgreSQL wire-width integer keyword collapses to the
174            // one `Int64` storage variant; `DECLARED_INT_KEYWORDS` lists them
175            // and carries the width contract.
176            keyword if DECLARED_INT_KEYWORDS.contains(&keyword) => Ok(Self::Int64),
177            // The float counterpart, listed by `DECLARED_FLOAT_KEYWORDS`.
178            keyword if DECLARED_FLOAT_KEYWORDS.contains(&keyword) => Ok(Self::Float64),
179            "TEXT" | "STRING" | "VARCHAR" => Ok(Self::String),
180            "BOOL" | "BOOLEAN" => Ok(Self::Bool),
181            "BYTES" | "BYTEA" | "BLOB" => Ok(Self::Bytes),
182            "TIMESTAMP" | "TIMESTAMP WITHOUT TIME ZONE" => Ok(Self::Timestamp),
183            "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" => Ok(Self::Timestamptz),
184            "SYSTEM_TIMESTAMP" | "SYSTEMTIMESTAMP" => Ok(Self::SystemTimestamp),
185            "GEOMETRY" => Ok(Self::Geometry),
186            // Dimensionless: an exact keyword with no `(N)`. Placed as an exact
187            // match rather than a `starts_with` guard because "SPARSEVECTOR"
188            // does not prefix-collide with the `starts_with("VECTOR")` branch
189            // above ("SPARSEVECTOR" starts with "SPARSE", not "VECTOR").
190            "SPARSEVECTOR" => Ok(Self::SparseVector),
191            "UUID" => Ok(Self::Uuid),
192            "JSON" | "JSONB" => Ok(Self::Json),
193            "ULID" => Ok(Self::Ulid),
194            "DURATION" => Ok(Self::Duration),
195            "ARRAY" => Ok(Self::Array),
196            "SET" => Ok(Self::Set),
197            "REGEX" => Ok(Self::Regex),
198            "RANGE" => Ok(Self::Range),
199            "RECORD" => Ok(Self::Record),
200            "DATETIME" => Err(ColumnTypeParseError::UseTimestamp),
201            other => Err(ColumnTypeParseError::Unknown(other.to_string())),
202        }
203    }
204}
205
206impl ColumnType {
207    /// Resolve a declared DDL type string to a column type.
208    ///
209    /// This is the single answer to "which [`ColumnType`] does this declared
210    /// string denote", shared by both planes. The catalog records the raw DDL
211    /// text that followed the column name, so an entry reads `INT DEFAULT 5`
212    /// or `DECIMAL(10, 2) NOT NULL`, not `INT`. This resolves the leading type
213    /// token, so a trailing modifier never changes the answer.
214    ///
215    /// `None` means the token names no known type. A caller that needs a
216    /// fallback picks its own — this reports the absence rather than guessing.
217    ///
218    /// A multi-word spelling resolves from its first word: `DOUBLE PRECISION`
219    /// is [`ColumnType::Float64`], and `TIMESTAMP WITH TIME ZONE` reaching
220    /// here as catalog text resolves to [`ColumnType::Timestamp`]. Pass such a
221    /// spelling to [`str::parse`] instead to resolve it whole.
222    pub fn from_declared_type(declared: &str) -> Option<Self> {
223        Self::parse_declared_type(declared).ok()
224    }
225
226    /// [`ColumnType::from_declared_type`] with the parse error kept.
227    ///
228    /// A DDL gate uses this to refuse a declared type it cannot hold, such as
229    /// `DECIMAL(1001,0)`, instead of reading it as an unknown type.
230    pub fn parse_declared_type(declared: &str) -> Result<Self, ColumnTypeParseError> {
231        bare_declared_token(declared).parse()
232    }
233}
234
235/// The typmod of `DECIMAL(p)` or `DECIMAL(p,s)`, from the `(p)` or `(p,s)`
236/// text after the keyword. A bare `(p)` has scale 0.
237///
238/// Text that is not one or two integers in parentheses is malformed. Integers
239/// out of range are an [`ColumnTypeParseError::InvalidDecimalTypmod`].
240fn parse_decimal_typmod(params: &str) -> Result<DecimalTypmod, ColumnTypeParseError> {
241    let malformed = || ColumnTypeParseError::InvalidDecimalParams(params.to_string());
242    let inner = params
243        .strip_prefix('(')
244        .and_then(|p| p.strip_suffix(')'))
245        .ok_or_else(malformed)?;
246    let (precision_text, scale_text) = match inner.split_once(',') {
247        Some((precision, scale)) => (precision, scale),
248        None => (inner, "0"),
249    };
250    let precision: i64 = precision_text.trim().parse().map_err(|_| malformed())?;
251    let scale: i64 = scale_text.trim().parse().map_err(|_| malformed())?;
252    Ok(DecimalTypmod::new(precision, scale)?)
253}
254
255/// The leading type token of a declared DDL type string.
256///
257/// The cut is the first whitespace outside parentheses, so a parameter list
258/// keeps its internal spaces and `DECIMAL(10, 2) NOT NULL` yields
259/// `DECIMAL(10, 2)`. A trailing comma left by a column-list split is dropped.
260fn bare_declared_token(declared: &str) -> &str {
261    let trimmed = declared.trim_start();
262    let mut depth = 0usize;
263    let mut end = trimmed.len();
264    for (index, ch) in trimmed.char_indices() {
265        match ch {
266            '(' => depth += 1,
267            ')' => depth = depth.saturating_sub(1),
268            _ if depth == 0 && ch.is_whitespace() => {
269                end = index;
270                break;
271            }
272            _ => {}
273        }
274    }
275    trimmed.get(..end).unwrap_or(trimmed).trim_end_matches(',')
276}
277
278#[cfg(test)]
279mod tests {
280    use super::super::{FloatWidth, IntWidth};
281    use super::*;
282
283    /// Every spelling the parser resolves to `Int64` must also resolve to a
284    /// declared [`IntWidth`]. A spelling one side knows and the other does not
285    /// advertises the wrong `RowDescription` OID for the column.
286    ///
287    /// The list is the one the parser itself matches on, so adding a spelling
288    /// there extends this test rather than leaving it behind.
289    #[test]
290    fn every_declared_int_keyword_resolves_to_int64_and_a_width() {
291        for keyword in DECLARED_INT_KEYWORDS {
292            assert_eq!(
293                keyword.parse::<ColumnType>(),
294                Ok(ColumnType::Int64),
295                "{keyword} must resolve to Int64"
296            );
297            assert!(
298                IntWidth::from_declared_type(keyword).is_some(),
299                "{keyword} must resolve to a declared IntWidth"
300            );
301        }
302    }
303
304    /// The float counterpart of
305    /// [`every_declared_int_keyword_resolves_to_int64_and_a_width`].
306    #[test]
307    fn every_declared_float_keyword_resolves_to_float64_and_a_width() {
308        for keyword in DECLARED_FLOAT_KEYWORDS {
309            assert_eq!(
310                keyword.parse::<ColumnType>(),
311                Ok(ColumnType::Float64),
312                "{keyword} must resolve to Float64"
313            );
314            assert!(
315                FloatWidth::from_declared_type(keyword).is_some(),
316                "{keyword} must resolve to a declared FloatWidth"
317            );
318        }
319    }
320
321    #[test]
322    fn declared_type_ignores_trailing_modifiers() {
323        assert_eq!(
324            ColumnType::from_declared_type("INT DEFAULT 5"),
325            Some(ColumnType::Int64)
326        );
327        assert_eq!(
328            ColumnType::from_declared_type("TEXT NOT NULL PRIMARY KEY"),
329            Some(ColumnType::String)
330        );
331        assert_eq!(
332            ColumnType::from_declared_type("timestamp time_key"),
333            Some(ColumnType::Timestamp)
334        );
335        assert_eq!(
336            ColumnType::from_declared_type("BIGINT,"),
337            Some(ColumnType::Int64)
338        );
339    }
340
341    /// A parameter list keeps its internal spaces: cutting at the first
342    /// whitespace would leave `DECIMAL(10,` and lose the type.
343    #[test]
344    fn declared_type_keeps_a_spaced_parameter_list() {
345        assert_eq!(
346            ColumnType::from_declared_type("DECIMAL(10, 2) NOT NULL"),
347            Some(ColumnType::Decimal(Some(
348                DecimalTypmod::new(10, 2).expect("valid typmod")
349            )))
350        );
351        assert_eq!(
352            ColumnType::from_declared_type("VECTOR(768)"),
353            Some(ColumnType::Vector(768))
354        );
355    }
356
357    /// An out-of-range typmod keeps its typed error through the declared-type
358    /// parse, and refuses as `22023`. A malformed one refuses as `42601`.
359    #[test]
360    fn declared_decimal_typmod_errors_are_typed() {
361        let err = ColumnType::parse_declared_type("DECIMAL(1001,0) NOT NULL")
362            .expect_err("precision 1001 is refused");
363        assert_eq!(
364            err,
365            ColumnTypeParseError::InvalidDecimalTypmod(DecimalTypmodError::PrecisionOutOfRange {
366                precision: 1001
367            })
368        );
369        assert_eq!(err.sqlstate(), "22023");
370        assert_eq!(ColumnType::from_declared_type("DECIMAL(1001,0)"), None);
371
372        let err = ColumnType::parse_declared_type("NUMERIC(x)").expect_err("malformed");
373        assert_eq!(err.sqlstate(), "42601");
374        assert_eq!(
375            ColumnType::parse_declared_type("NUMERIC DEFAULT 1"),
376            Ok(ColumnType::Decimal(None))
377        );
378    }
379
380    /// The two zone spellings resolve whole, so a parser that reads the full
381    /// string reads the zone the author wrote.
382    #[test]
383    fn both_timestamp_zone_spellings_resolve_whole() {
384        assert_eq!(
385            "TIMESTAMP WITH TIME ZONE".parse::<ColumnType>(),
386            Ok(ColumnType::Timestamptz)
387        );
388        assert_eq!(
389            "timestamp without time zone".parse::<ColumnType>(),
390            Ok(ColumnType::Timestamp)
391        );
392    }
393
394    /// A declared character length resolves to `String` and a malformed one
395    /// errors rather than resolving to an unwritten type.
396    #[test]
397    fn varchar_length_resolves_to_string() {
398        assert_eq!("VARCHAR(255)".parse::<ColumnType>(), Ok(ColumnType::String));
399        assert_eq!("varchar(1)".parse::<ColumnType>(), Ok(ColumnType::String));
400        assert_eq!(
401            ColumnType::from_declared_type("VARCHAR(8) NOT NULL"),
402            Some(ColumnType::String)
403        );
404        assert_eq!(
405            "VARCHAR(0)".parse::<ColumnType>(),
406            Err(ColumnTypeParseError::InvalidCharLength("0".into()))
407        );
408        assert_eq!(
409            "VARCHAR(abc)".parse::<ColumnType>(),
410            Err(ColumnTypeParseError::InvalidCharLength("ABC".into()))
411        );
412    }
413
414    #[test]
415    fn declared_type_reports_an_unknown_token_as_none() {
416        assert_eq!(ColumnType::from_declared_type("SOMETHING_ELSE"), None);
417        assert_eq!(ColumnType::from_declared_type(""), None);
418        assert_eq!(ColumnType::from_declared_type("   "), None);
419    }
420
421    /// `Timestamp` and `Timestamptz` are instants; `SystemTimestamp` is
422    /// engine-assigned and is not.
423    #[test]
424    fn only_timestamp_types_are_instants() {
425        assert!(ColumnType::Timestamp.is_instant());
426        assert!(ColumnType::Timestamptz.is_instant());
427        assert!(!ColumnType::SystemTimestamp.is_instant());
428        assert!(!ColumnType::Int64.is_instant());
429        assert!(!ColumnType::String.is_instant());
430    }
431}