Skip to main content

nautilus_schema/analysis/
hover.rs

1//! Hover documentation for `.nautilus` schema files.
2
3use super::{analyze, span_contains};
4use crate::ast::{
5    ComputedKind, Declaration, FieldAttribute, FieldModifier, FieldType, ModelAttribute, Schema,
6};
7use crate::span::Span;
8use crate::token::{Token, TokenKind};
9
10/// Information to display when hovering over a token.
11#[derive(Debug, Clone, PartialEq)]
12pub struct HoverInfo {
13    /// Markdown-formatted documentation string.
14    pub content: String,
15    /// Span of the token the hover applies to (for range highlighting).
16    pub span: Option<Span>,
17}
18
19/// Returns hover documentation for the symbol at byte `offset` in `source`.
20///
21/// Looks up the innermost AST node whose span contains `offset` and returns
22/// relevant documentation:
23/// - Scalar types → SQL mapping and description.
24/// - Identifiers matching a model name → model summary.
25/// - Identifiers matching an enum name → enum variant list.
26/// - Field declarations → field type and modifiers.
27pub fn hover(source: &str, offset: usize) -> Option<HoverInfo> {
28    let result = analyze(source);
29    let ast = result.ast.as_ref()?;
30
31    // Check attribute hover first (@id, @unique, @relation, @@map, …)
32    if let Some(h) = attribute_hover_at(&result.tokens, offset, Some(ast)) {
33        return Some(h);
34    }
35
36    // Walk declarations looking for the innermost span containing the offset.
37    for decl in &ast.declarations {
38        if !span_contains(decl.span(), offset) {
39            continue;
40        }
41
42        match decl {
43            Declaration::Model(model) => {
44                // Check field declarations inside this model.
45                for field in &model.fields {
46                    if span_contains(field.span, offset) {
47                        let modifier = match field.modifier {
48                            FieldModifier::Array => "[]",
49                            FieldModifier::Optional => "?",
50                            FieldModifier::NotNull => "!",
51                            FieldModifier::None => "",
52                        };
53                        let type_str =
54                            format!("{}{}", field_type_name(&field.field_type), modifier);
55
56                        if field.has_relation_attribute() {
57                            let base = format!("**{}**: `{}`", field.name.value, type_str);
58                            let extra = relation_hover_details(ast, offset).unwrap_or_default();
59                            let content = if extra.is_empty() {
60                                base
61                            } else {
62                                format!("{base}  \n\n{extra}")
63                            };
64                            return Some(HoverInfo {
65                                content,
66                                span: Some(field.span),
67                            });
68                        }
69
70                        let attrs_str = format_field_attrs_short(&field.attributes);
71                        let detail = field_type_description(&field.field_type);
72                        let nullability = match field.modifier {
73                            FieldModifier::Optional => Some("nullable"),
74                            FieldModifier::NotNull => Some("not null"),
75                            _ => None,
76                        };
77                        let mut content = format!("**{}**: `{}`", field.name.value, type_str);
78                        if !attrs_str.is_empty() {
79                            content.push_str(&format!("  \n{}", attrs_str));
80                        }
81                        if let Some(hint) = nullability {
82                            content.push_str(&format!("  \n_{}_", hint));
83                        }
84                        if !detail.is_empty() {
85                            content.push_str(&format!("  \n{}", detail));
86                        }
87                        return Some(HoverInfo {
88                            content,
89                            span: Some(field.span),
90                        });
91                    }
92                }
93                // Hovering over the model keyword / name itself.
94                let composite_names: std::collections::HashSet<String> =
95                    ast.types().map(|t| t.name.value.clone()).collect();
96                return Some(HoverInfo {
97                    content: model_hover_content(model, &composite_names),
98                    span: Some(model.span),
99                });
100            }
101
102            Declaration::Enum(enum_decl) => {
103                let variants: Vec<&str> = enum_decl
104                    .variants
105                    .iter()
106                    .map(|v| v.name.value.as_str())
107                    .collect();
108                return Some(HoverInfo {
109                    content: format!(
110                        "**enum** `{}`  \n**Variants ({}):** {}  \n",
111                        enum_decl.name.value,
112                        variants.len(),
113                        variants
114                            .iter()
115                            .map(|v| format!("`{v}`"))
116                            .collect::<Vec<_>>()
117                            .join(" · ")
118                    ),
119                    span: Some(enum_decl.span),
120                });
121            }
122
123            Declaration::Datasource(ds) => {
124                for field in &ds.fields {
125                    if span_contains(field.span, offset) {
126                        return Some(HoverInfo {
127                            content: config_field_hover(&field.name.value),
128                            span: Some(field.span),
129                        });
130                    }
131                }
132                return Some(HoverInfo {
133                    content: format!("**datasource** `{}`", ds.name.value),
134                    span: Some(ds.span),
135                });
136            }
137
138            Declaration::Generator(gen) => {
139                for field in &gen.fields {
140                    if span_contains(field.span, offset) {
141                        return Some(HoverInfo {
142                            content: config_field_hover(&field.name.value),
143                            span: Some(field.span),
144                        });
145                    }
146                }
147                return Some(HoverInfo {
148                    content: format!("**generator** `{}`", gen.name.value),
149                    span: Some(gen.span),
150                });
151            }
152
153            Declaration::Type(type_decl) => {
154                for field in &type_decl.fields {
155                    if span_contains(field.span, offset) {
156                        let modifier = match field.modifier {
157                            FieldModifier::Array => "[]",
158                            FieldModifier::Optional => "?",
159                            FieldModifier::NotNull => "!",
160                            FieldModifier::None => "",
161                        };
162                        let type_str =
163                            format!("{}{}", field_type_name(&field.field_type), modifier);
164                        let attrs_str = format_field_attrs_short(&field.attributes);
165                        let mut content = format!("**{}**: `{}`", field.name.value, type_str);
166                        if !attrs_str.is_empty() {
167                            content.push_str(&format!("  \n{}", attrs_str));
168                        }
169                        return Some(HoverInfo {
170                            content,
171                            span: Some(field.span),
172                        });
173                    }
174                }
175                return Some(HoverInfo {
176                    content: composite_type_hover_content(type_decl),
177                    span: Some(type_decl.span),
178                });
179            }
180        }
181    }
182
183    None
184}
185
186/// Hover documentation for datasource/generator config fields.
187pub fn config_field_hover(key: &str) -> String {
188    match key {
189        "provider" => concat!(
190            "**provider**  \n",
191            "Specifies the database provider or code-generator target.  \n\n",
192            "Datasource values: `\"postgresql\"`, `\"mysql\"`, `\"sqlite\"`  \n",
193            "Generator values: `\"python\"`",
194        ).to_string(),
195        "url" => concat!(
196            "**url**  \n",
197            "Database connection URL.  \n\n",
198            "Supports the `env(\"VAR\")` helper to read from environment variables.",
199        ).to_string(),
200        "output" => concat!(
201            "**output**  \n",
202            "Output directory path for generated client files.  \n\n",
203            "Relative paths are resolved from the schema file location.",
204        ).to_string(),
205        "interface" => concat!(
206            "**interface**  \n",
207            "Controls whether the generated client uses a synchronous or asynchronous API.  \n\n",
208            "- `\"sync\"` *(default)* — blocking API; safe to call from any context.  \n",
209            "- `\"async\"` — `async/await` API; requires an async runtime.",
210        ).to_string(),
211        "recursive_type_depth" => concat!(
212            "**recursive_type_depth**  \n",
213            "*(Python client only)* Depth of recursive include TypedDicts generated for the Python client.  \n\n",
214            "Default: `5`.  \n\n",
215            "Each depth level adds a `{Model}IncludeRecursive{N}` type and the corresponding  \n",
216            "`FindMany{Target}ArgsFrom{Source}Recursive{N}` typed-dict classes.  \n",
217            "At the maximum depth the `include` field is omitted to prevent infinite type recursion.  \n\n",
218            "Example: `recursive_type_depth = 3`",
219        ).to_string(),
220        other => format!("**{other}**"),
221    }
222}
223
224/// Returns hover info if `offset` falls on a `@attr` or `@@attr` token
225/// (including its parenthesised argument list, if any).
226fn attribute_hover_at(tokens: &[Token], offset: usize, ast: Option<&Schema>) -> Option<HoverInfo> {
227    let n = tokens.len();
228    let mut i = 0;
229    while i < n {
230        let tok = &tokens[i];
231        let is_double = tok.kind == TokenKind::AtAt;
232        let is_single = tok.kind == TokenKind::At;
233        if !is_double && !is_single {
234            i += 1;
235            continue;
236        }
237
238        // Find next non-newline token — the attribute name identifier.
239        let ident_i = match (i + 1..n).find(|&j| !matches!(tokens[j].kind, TokenKind::Newline)) {
240            Some(j) => j,
241            None => {
242                i += 1;
243                continue;
244            }
245        };
246        let ident_tok = &tokens[ident_i];
247        let attr_name = match &ident_tok.kind {
248            TokenKind::Ident(name) => name.clone(),
249            _ => {
250                i += 1;
251                continue;
252            }
253        };
254
255        let attr_start = tok.span.start;
256        let attr_name_end = ident_tok.span.end;
257
258        // Extend span to the closing `)` when an argument list follows.
259        let lparen_i = (ident_i + 1..n).find(|&j| !matches!(tokens[j].kind, TokenKind::Newline));
260        let full_end = if lparen_i.map(|j| &tokens[j].kind) == Some(&TokenKind::LParen) {
261            find_paren_end(tokens, lparen_i.unwrap()).unwrap_or(attr_name_end)
262        } else {
263            attr_name_end
264        };
265
266        if offset >= attr_start && offset <= full_end {
267            let content = if is_double {
268                model_attr_hover_text(&attr_name)
269            } else {
270                field_attr_hover_text(&attr_name, ast, offset)
271            };
272            return Some(HoverInfo {
273                content,
274                span: Some(Span {
275                    start: attr_start,
276                    end: attr_name_end,
277                }),
278            });
279        }
280        i += 1;
281    }
282    None
283}
284
285/// Walk tokens from the `(` at `lparen_idx` and return the byte-end of the
286/// matching `)`.
287fn find_paren_end(tokens: &[Token], lparen_idx: usize) -> Option<usize> {
288    let mut depth: i32 = 0;
289    for tok in &tokens[lparen_idx..] {
290        match tok.kind {
291            TokenKind::LParen => depth += 1,
292            TokenKind::RParen => {
293                depth -= 1;
294                if depth == 0 {
295                    return Some(tok.span.end);
296                }
297            }
298            _ => {}
299        }
300    }
301    None
302}
303
304fn field_attr_hover_text(name: &str, ast: Option<&Schema>, offset: usize) -> String {
305    match name {
306        "id" => "**@id**  \nMarks this field as the primary key of the model.".to_string(),
307        "unique" => "**@unique**  \nAdds a `UNIQUE` constraint on this column.".to_string(),
308        "default" => [
309            "**@default(expr)**  ",
310            "Sets the default value for this field when not explicitly provided.  \n",
311            "Common expressions: `autoincrement()`, `now()`, `uuid()`, `cuid()`,",
312            " `dbgenerated(...)`, or any literal value.",
313        ].concat(),
314        "map" => "**@map(\"name\")** \nMaps this field to a different physical column name in the database.".to_string(),
315        "store" => [
316            "**@store(json)**  \n",
317            "Stores this array field as a JSON value in the database.  \n",
318            "Useful for databases without native array support (MySQL, SQLite).",
319        ].concat(),
320        "updatedAt" => [
321            "**@updatedAt**  \n",
322            "Marks this `DateTime` field to be automatically set to the current timestamp ",
323            "on every CREATE and UPDATE operation.  \n",
324            "The framework manages this value — it is excluded from all user-input types.",
325        ].concat(),
326        "computed" => [
327            "**@computed(expr, Stored | Virtual)**  \n",
328            "Declares a database-generated (computed) column.  \n\n",
329            "- `expr` — raw SQL expression evaluated by the database (e.g. `price * quantity`, ",
330            "`first_name || ' ' || last_name`)  \n",
331            "- `Stored` — value is computed on write and persisted physically  \n",
332            "- `Virtual` — value is computed on read (not supported on PostgreSQL)  \n\n",
333            "Maps to SQL `GENERATED ALWAYS AS (expr) STORED` (PostgreSQL / MySQL) or ",
334            "`AS (expr) STORED` (SQLite).  \n",
335            "Computed fields are **read-only** — they are excluded from all create/update input types.",
336        ].concat(),
337        "check" => [
338            "**@check(expr)**  \n",
339            "Adds a SQL `CHECK` constraint on this column.  \n\n",
340            "The boolean expression can use SQL-style operators: ",
341            "`=`, `!=`, `<`, `>`, `<=`, `>=`, `AND`, `OR`, `NOT`, `IN`.  \n\n",
342            "Field-level `@check` can only reference the decorated field itself.  \n",
343            "Use `@@check` at the model level to reference multiple fields.  \n\n",
344            "**Examples:**  \n",
345            "```  \n",
346            "age    Int  @check(age >= 0 AND age <= 150)  \n",
347            "status Status @check(status IN [ACTIVE, PENDING])  \n",
348            "```",
349        ].concat(),
350        "relation" => {
351            let base = concat!(
352                "**@relation**  \n",
353                "Defines an explicit foreign-key relation between two models."
354            );
355            if let Some(schema) = ast {
356                if let Some(extra) = relation_hover_details(schema, offset) {
357                    return format!("{base}  \n\n{extra}");
358                }
359            }
360            base.to_string()
361        }
362        other => format!("**@{other}**"),
363    }
364}
365
366fn model_attr_hover_text(name: &str) -> String {
367    match name {
368        "map"    => "**@@map(\"name\")** \nMaps this model to a different physical table name in the database.".to_string(),
369        "id"     => "**@@id([fields])**  \nDefines a composite primary key spanning multiple fields.".to_string(),
370        "unique" => "**@@unique([fields])**  \nDefines a composite unique constraint spanning multiple fields.".to_string(),
371        "index"  => "**@@index([fields], type?, name?, map?)**  \nCreates a database index on the listed fields.  \n\nOptional arguments:  \n- `type:` — index access method: `BTree` (default, all DBs), `Hash` (PG/MySQL), `Gin` / `Gist` / `Brin` (PostgreSQL only), `FullText` (MySQL only)  \n- `name:` — logical developer name (ignored in DDL)  \n- `map:` — physical DDL index name override  \n\n**Examples:**  \n```  \n@@index([email])  \n@@index([email], type: Hash)  \n@@index([content], type: Gin)  \n@@index([createdAt], type: Brin, map: \"idx_created\")  \n```".to_string(),
372        "check"  => [
373            "**@@check(expr)**  \n",
374            "Adds a table-level SQL `CHECK` constraint.  \n\n",
375            "Unlike field-level `@check`, the expression can reference any scalar field in the model.  \n\n",
376            "**Example:**  \n",
377            "```  \n",
378            "@@check(start_date < end_date)  \n",
379            "@@check(age > 18 OR status IN [MINOR])  \n",
380            "```",
381        ].concat(),
382        other    => format!("**@@{other}**"),
383    }
384}
385
386/// Extracts a rich Markdown summary of the `@relation(...)` attribute on the
387/// field whose span contains `offset`.
388///
389/// Shows:
390/// - Inferred relation type (one-to-many / one-to-one)
391/// - `ParentModel → TargetType` arrow
392/// - All explicit arguments: name, fields, references, onDelete, onUpdate
393fn relation_hover_details(ast: &Schema, offset: usize) -> Option<String> {
394    for decl in &ast.declarations {
395        if let Declaration::Model(model) = decl {
396            for field in &model.fields {
397                if !span_contains(field.span, offset) {
398                    continue;
399                }
400                for attr in &field.attributes {
401                    if let FieldAttribute::Relation {
402                        name,
403                        fields,
404                        references,
405                        on_delete,
406                        on_update,
407                        ..
408                    } = attr
409                    {
410                        let target = field_type_name(&field.field_type);
411                        let modifier_str = match field.modifier {
412                            FieldModifier::Array => "[]",
413                            FieldModifier::Optional => "?",
414                            FieldModifier::NotNull => "!",
415                            FieldModifier::None => "",
416                        };
417                        let relation_kind = match field.modifier {
418                            FieldModifier::Array => "one-to-many",
419                            _ if fields.is_some() => "one-to-many",
420                            _ => "one-to-one",
421                        };
422
423                        let mut lines: Vec<String> = vec![format!(
424                            "**Type:** `{relation_kind}`  ·  `{}` → `{target}{modifier_str}`",
425                            model.name.value
426                        )];
427
428                        // Separator before argument details (only if there are any)
429                        let has_args = name.is_some()
430                            || fields.is_some()
431                            || references.is_some()
432                            || on_delete.is_some()
433                            || on_update.is_some();
434
435                        if has_args {
436                            lines.push(String::new());
437                            if let Some(n) = name {
438                                lines.push(format!("- **name**: `\"{n}\"` "));
439                            }
440                            if let Some(fs) = fields {
441                                let names: Vec<&str> =
442                                    fs.iter().map(|f| f.value.as_str()).collect();
443                                lines.push(format!("- **fields**: `[{}]`", names.join(", ")));
444                            }
445                            if let Some(rs) = references {
446                                let names: Vec<&str> =
447                                    rs.iter().map(|r| r.value.as_str()).collect();
448                                lines.push(format!("- **references**: `[{}]`", names.join(", ")));
449                            }
450                            if let Some(od) = on_delete {
451                                lines.push(format!("- **onDelete**: `{od}`"));
452                            }
453                            if let Some(ou) = on_update {
454                                lines.push(format!("- **onUpdate**: `{ou}`"));
455                            }
456                        }
457
458                        return Some(lines.join("  \n"));
459                    }
460                }
461            }
462        }
463    }
464    None
465}
466
467/// Formats field-level attributes as an inline string, e.g.
468/// `@id · @default(uuid()) · @map("user_id")`.
469/// `@relation` is omitted — it has its own dedicated hover.
470fn format_field_attrs_short(attrs: &[FieldAttribute]) -> String {
471    attrs
472        .iter()
473        .filter_map(|attr| match attr {
474            FieldAttribute::Id => Some("@id".to_string()),
475            FieldAttribute::Unique => Some("@unique".to_string()),
476            FieldAttribute::Default(expr, _) => {
477                Some(format!("@default({})", crate::formatter::format_expr(expr)))
478            }
479            FieldAttribute::Map(name) => Some(format!("@map(\"{}\")", name)),
480            FieldAttribute::Store { .. } => Some("@store(json)".to_string()),
481            FieldAttribute::UpdatedAt { .. } => Some("@updatedAt".to_string()),
482            FieldAttribute::Computed { expr, kind, .. } => {
483                let kind_str = match kind {
484                    ComputedKind::Stored => "Stored",
485                    ComputedKind::Virtual => "Virtual",
486                };
487                Some(format!("@computed({}, {})", expr, kind_str))
488            }
489            FieldAttribute::Check { expr, .. } => Some(format!("@check({})", expr)),
490            FieldAttribute::Relation { .. } => None,
491        })
492        .collect::<Vec<_>>()
493        .join(" · ")
494}
495
496/// Builds the full Markdown hover content for a `model` declaration.
497fn model_hover_content(
498    model: &crate::ast::ModelDecl,
499    composite_names: &std::collections::HashSet<String>,
500) -> String {
501    let table_name = model.table_name();
502    let mut lines: Vec<String> = vec![format!("**model** `{}`", model.name.value)];
503
504    // Physical table name — only shown when it differs from the model name.
505    if table_name != model.name.value {
506        lines.push(format!("**Table:** `{}`", table_name));
507    }
508
509    let composite_count = model
510        .fields
511        .iter()
512        .filter(|f| matches!(&f.field_type, FieldType::UserType(n) if composite_names.contains(n)))
513        .count();
514    let relation_count = model
515        .fields
516        .iter()
517        .filter(|f| matches!(&f.field_type, FieldType::UserType(n) if !composite_names.contains(n)))
518        .count();
519    let scalar_count = model.fields.len() - composite_count - relation_count;
520    let mut count_parts = vec![format!("{} scalar", scalar_count)];
521    if relation_count > 0 {
522        count_parts.push(format!("{} relation", relation_count));
523    }
524    if composite_count > 0 {
525        count_parts.push(format!("{} composite", composite_count));
526    }
527    lines.push(format!("**Fields:** {}", count_parts.join(" · ")));
528    lines.push(String::new());
529
530    // One bullet per field.
531    for field in &model.fields {
532        let modifier = match field.modifier {
533            FieldModifier::Array => "[]",
534            FieldModifier::Optional => "?",
535            FieldModifier::NotNull => "!",
536            FieldModifier::None => "",
537        };
538        let type_str = format!("{}{}", field_type_name(&field.field_type), modifier);
539        let attrs_str = format_field_attrs_short(&field.attributes);
540        if attrs_str.is_empty() {
541            lines.push(format!("- `{}`: `{}`", field.name.value, type_str));
542        } else {
543            lines.push(format!(
544                "- `{}`: `{}`  — {}",
545                field.name.value, type_str, attrs_str
546            ));
547        }
548    }
549
550    // Model-level attributes (@@id, @@unique, @@index, @@map already shown above).
551    let extra_attrs: Vec<String> = model
552        .attributes
553        .iter()
554        .filter_map(|attr| match attr {
555            ModelAttribute::Map(_) => None, // already shown in table name line
556            ModelAttribute::Id(fields) => {
557                let fs: Vec<&str> = fields.iter().map(|f| f.value.as_str()).collect();
558                Some(format!("_@@id([{}])_", fs.join(", ")))
559            }
560            ModelAttribute::Unique(fields) => {
561                let fs: Vec<&str> = fields.iter().map(|f| f.value.as_str()).collect();
562                Some(format!("_@@unique([{}])_", fs.join(", ")))
563            }
564            ModelAttribute::Index {
565                fields,
566                index_type,
567                name,
568                map,
569            } => {
570                let fs: Vec<&str> = fields.iter().map(|f| f.value.as_str()).collect();
571                let mut parts = vec![format!("[{}]", fs.join(", "))];
572                if let Some(t) = index_type {
573                    parts.push(format!("type: {}", t.value));
574                }
575                if let Some(n) = name {
576                    parts.push(format!("name: \"{}\"", n));
577                }
578                if let Some(m) = map {
579                    parts.push(format!("map: \"{}\"", m));
580                }
581                Some(format!("_@@index({})_", parts.join(", ")))
582            }
583            ModelAttribute::Check { expr, .. } => Some(format!("_@@check({})_", expr)),
584        })
585        .collect();
586
587    if !extra_attrs.is_empty() {
588        lines.push(String::new());
589        lines.extend(extra_attrs);
590    }
591
592    lines.join("  \n")
593}
594
595/// Builds the full Markdown hover content for a `type` declaration.
596fn composite_type_hover_content(type_decl: &crate::ast::TypeDecl) -> String {
597    let mut lines: Vec<String> = vec![format!("**type** `{}`", type_decl.name.value)];
598    lines.push(format!("**Fields:** {}", type_decl.fields.len()));
599    lines.push(String::new());
600
601    for field in &type_decl.fields {
602        let modifier = match field.modifier {
603            FieldModifier::Array => "[]",
604            FieldModifier::Optional => "?",
605            FieldModifier::NotNull => "!",
606            FieldModifier::None => "",
607        };
608        let type_str = format!("{}{}", field_type_name(&field.field_type), modifier);
609        let attrs_str = format_field_attrs_short(&field.attributes);
610        if attrs_str.is_empty() {
611            lines.push(format!("- `{}`: `{}`", field.name.value, type_str));
612        } else {
613            lines.push(format!(
614                "- `{}`: `{}`  — {}",
615                field.name.value, type_str, attrs_str
616            ));
617        }
618    }
619
620    lines.join("  \n")
621}
622
623fn field_type_name(ft: &FieldType) -> String {
624    match ft {
625        FieldType::String => "String".to_string(),
626        FieldType::Boolean => "Boolean".to_string(),
627        FieldType::Int => "Int".to_string(),
628        FieldType::BigInt => "BigInt".to_string(),
629        FieldType::Float => "Float".to_string(),
630        FieldType::Decimal { precision, scale } => format!("Decimal({}, {})", precision, scale),
631        FieldType::DateTime => "DateTime".to_string(),
632        FieldType::Bytes => "Bytes".to_string(),
633        FieldType::Json => "Json".to_string(),
634        FieldType::Uuid => "Uuid".to_string(),
635        FieldType::Jsonb => "Jsonb".to_string(),
636        FieldType::Xml => "Xml".to_string(),
637        FieldType::Char { length } => format!("Char({})", length),
638        FieldType::VarChar { length } => format!("VarChar({})", length),
639        FieldType::UserType(name) => name.clone(),
640    }
641}
642
643fn field_type_description(ft: &FieldType) -> &'static str {
644    match ft {
645        FieldType::String => "UTF-8 text string.  Maps to `VARCHAR` / `TEXT` in SQL.",
646        FieldType::Boolean => "Boolean value (`true` / `false`).  Maps to `BOOLEAN`.",
647        FieldType::Int => "32-bit signed integer.  Maps to `INTEGER`.",
648        FieldType::BigInt => "64-bit signed integer.  Maps to `BIGINT`.",
649        FieldType::Float => "64-bit IEEE 754 float.  Maps to `DOUBLE PRECISION`.",
650        FieldType::Decimal { .. } => "Exact-precision decimal number.  Maps to `NUMERIC(p, s)`.",
651        FieldType::DateTime => "Date and time with timezone.  Maps to `TIMESTAMPTZ`.",
652        FieldType::Bytes => "Raw binary data.  Maps to `BYTEA` / `BLOB`.",
653        FieldType::Json => "JSON document.  Maps to `JSONB` (Postgres) or `JSON` (MySQL/SQLite).",
654        FieldType::Uuid => "Universally unique identifier.  Maps to `UUID`.",
655        FieldType::Jsonb => "JSONB document (PostgreSQL only).  Maps to `JSONB`.",
656        FieldType::Xml => "XML document (PostgreSQL only).  Maps to `XML`.",
657        FieldType::Char { .. } => {
658            "Fixed-length character column.  Maps to `CHAR(n)` (PostgreSQL and MySQL)."
659        }
660        FieldType::VarChar { .. } => {
661            "Variable-length character column.  Maps to `VARCHAR(n)` (PostgreSQL and MySQL)."
662        }
663        FieldType::UserType(_) => "Reference to another model or enum.",
664    }
665}