Skip to main content

nautilus_schema/analysis/
hover.rs

1//! Hover documentation for `.nautilus` schema files.
2
3use super::{analyze, span_contains, AnalysisResult};
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    hover_with_analysis(source, &result, offset)
30}
31
32/// Returns hover documentation for the symbol at `offset` using a previously
33/// computed [`AnalysisResult`].
34pub fn hover_with_analysis(
35    _source: &str,
36    result: &AnalysisResult,
37    offset: usize,
38) -> Option<HoverInfo> {
39    let ast = result.ast.as_ref()?;
40
41    if let Some(h) = attribute_hover_at(&result.tokens, offset, Some(ast)) {
42        return Some(h);
43    }
44
45    for decl in &ast.declarations {
46        if !span_contains(decl.span(), offset) {
47            continue;
48        }
49
50        match decl {
51            Declaration::Model(model) => {
52                for field in &model.fields {
53                    if span_contains(field.span, offset) {
54                        let modifier = match field.modifier {
55                            FieldModifier::Array => "[]",
56                            FieldModifier::Optional => "?",
57                            FieldModifier::NotNull => "!",
58                            FieldModifier::None => "",
59                        };
60                        let type_str =
61                            format!("{}{}", field_type_name(&field.field_type), modifier);
62
63                        if field.has_relation_attribute() {
64                            let base = format!("**{}**: `{}`", field.name.value, type_str);
65                            let extra = relation_hover_details(ast, offset).unwrap_or_default();
66                            let content = if extra.is_empty() {
67                                base
68                            } else {
69                                format!("{base}  \n\n{extra}")
70                            };
71                            return Some(HoverInfo {
72                                content,
73                                span: Some(field.span),
74                            });
75                        }
76
77                        let attrs_str = format_field_attrs_short(&field.attributes);
78                        let detail = field_type_description(&field.field_type);
79                        let nullability = match field.modifier {
80                            FieldModifier::Optional => Some("nullable"),
81                            FieldModifier::NotNull => Some("not null"),
82                            _ => None,
83                        };
84                        let mut content = format!("**{}**: `{}`", field.name.value, type_str);
85                        if !attrs_str.is_empty() {
86                            content.push_str(&format!("  \n{}", attrs_str));
87                        }
88                        if let Some(hint) = nullability {
89                            content.push_str(&format!("  \n_{}_", hint));
90                        }
91                        if !detail.is_empty() {
92                            content.push_str(&format!("  \n{}", detail));
93                        }
94                        return Some(HoverInfo {
95                            content,
96                            span: Some(field.span),
97                        });
98                    }
99                }
100                let composite_names: std::collections::HashSet<String> =
101                    ast.types().map(|t| t.name.value.clone()).collect();
102                return Some(HoverInfo {
103                    content: model_hover_content(model, &composite_names),
104                    span: Some(model.span),
105                });
106            }
107
108            Declaration::Enum(enum_decl) => {
109                let variants: Vec<&str> = enum_decl
110                    .variants
111                    .iter()
112                    .map(|v| v.name.value.as_str())
113                    .collect();
114                return Some(HoverInfo {
115                    content: format!(
116                        "**enum** `{}`  \n**Variants ({}):** {}  \n",
117                        enum_decl.name.value,
118                        variants.len(),
119                        variants
120                            .iter()
121                            .map(|v| format!("`{v}`"))
122                            .collect::<Vec<_>>()
123                            .join(" · ")
124                    ),
125                    span: Some(enum_decl.span),
126                });
127            }
128
129            Declaration::Datasource(ds) => {
130                for field in &ds.fields {
131                    if span_contains(field.span, offset) {
132                        return Some(HoverInfo {
133                            content: config_field_hover(&field.name.value),
134                            span: Some(field.span),
135                        });
136                    }
137                }
138                return Some(HoverInfo {
139                    content: format!("**datasource** `{}`", ds.name.value),
140                    span: Some(ds.span),
141                });
142            }
143
144            Declaration::Generator(gen) => {
145                for field in &gen.fields {
146                    if span_contains(field.span, offset) {
147                        return Some(HoverInfo {
148                            content: config_field_hover(&field.name.value),
149                            span: Some(field.span),
150                        });
151                    }
152                }
153                return Some(HoverInfo {
154                    content: format!("**generator** `{}`", gen.name.value),
155                    span: Some(gen.span),
156                });
157            }
158
159            Declaration::Type(type_decl) => {
160                for field in &type_decl.fields {
161                    if span_contains(field.span, offset) {
162                        let modifier = match field.modifier {
163                            FieldModifier::Array => "[]",
164                            FieldModifier::Optional => "?",
165                            FieldModifier::NotNull => "!",
166                            FieldModifier::None => "",
167                        };
168                        let type_str =
169                            format!("{}{}", field_type_name(&field.field_type), modifier);
170                        let attrs_str = format_field_attrs_short(&field.attributes);
171                        let mut content = format!("**{}**: `{}`", field.name.value, type_str);
172                        if !attrs_str.is_empty() {
173                            content.push_str(&format!("  \n{}", attrs_str));
174                        }
175                        return Some(HoverInfo {
176                            content,
177                            span: Some(field.span),
178                        });
179                    }
180                }
181                return Some(HoverInfo {
182                    content: composite_type_hover_content(type_decl),
183                    span: Some(type_decl.span),
184                });
185            }
186        }
187    }
188
189    None
190}
191
192/// Hover documentation for datasource/generator config fields.
193pub fn config_field_hover(key: &str) -> String {
194    match key {
195        "provider" => concat!(
196            "**provider**  \n",
197            "Specifies the database provider or code-generator target.  \n\n",
198            "Datasource values: `\"postgresql\"`, `\"mysql\"`, `\"sqlite\"`  \n",
199            "Generator values: `\"nautilus-client-rs\"`, `\"nautilus-client-py\"`, `\"nautilus-client-js\"`",
200        ).to_string(),
201        "url" => concat!(
202            "**url**  \n",
203            "Database connection URL.  \n\n",
204            "Supports the `env(\"VAR\")` helper to read from environment variables.",
205        ).to_string(),
206        "output" => concat!(
207            "**output**  \n",
208            "Output directory path for generated client files.  \n\n",
209            "Relative paths are resolved from the schema file location.",
210        ).to_string(),
211        "interface" => concat!(
212            "**interface**  \n",
213            "Controls whether the generated client uses a synchronous or asynchronous API.  \n\n",
214            "- `\"sync\"` *(default)* — blocking API; safe to call from any context.  \n",
215            "- `\"async\"` — `async/await` API; requires an async runtime.",
216        ).to_string(),
217        "recursive_type_depth" => concat!(
218            "**recursive_type_depth**  \n",
219            "*(Python client only)* Depth of recursive include TypedDicts generated for the Python client.  \n\n",
220            "Default: `5`.  \n\n",
221            "Each depth level adds a `{Model}IncludeRecursive{N}` type and the corresponding  \n",
222            "`FindMany{Target}ArgsFrom{Source}Recursive{N}` typed-dict classes.  \n",
223            "At the maximum depth the `include` field is omitted to prevent infinite type recursion.  \n\n",
224            "Example: `recursive_type_depth = 3`",
225        ).to_string(),
226        other => format!("**{other}**"),
227    }
228}
229
230/// Returns hover info if `offset` falls on a `@attr` or `@@attr` token
231/// (including its parenthesised argument list, if any).
232fn attribute_hover_at(tokens: &[Token], offset: usize, ast: Option<&Schema>) -> Option<HoverInfo> {
233    let n = tokens.len();
234    let mut i = 0;
235    while i < n {
236        let tok = &tokens[i];
237        let is_double = tok.kind == TokenKind::AtAt;
238        let is_single = tok.kind == TokenKind::At;
239        if !is_double && !is_single {
240            i += 1;
241            continue;
242        }
243
244        let ident_i = match (i + 1..n).find(|&j| !matches!(tokens[j].kind, TokenKind::Newline)) {
245            Some(j) => j,
246            None => {
247                i += 1;
248                continue;
249            }
250        };
251        let ident_tok = &tokens[ident_i];
252        let attr_name = match &ident_tok.kind {
253            TokenKind::Ident(name) => name.clone(),
254            _ => {
255                i += 1;
256                continue;
257            }
258        };
259
260        let attr_start = tok.span.start;
261        let attr_name_end = ident_tok.span.end;
262
263        let lparen_i = (ident_i + 1..n).find(|&j| !matches!(tokens[j].kind, TokenKind::Newline));
264        let full_end = if lparen_i.map(|j| &tokens[j].kind) == Some(&TokenKind::LParen) {
265            find_paren_end(tokens, lparen_i.unwrap()).unwrap_or(attr_name_end)
266        } else {
267            attr_name_end
268        };
269
270        if offset >= attr_start && offset <= full_end {
271            let content = if is_double {
272                model_attr_hover_text(&attr_name)
273            } else {
274                field_attr_hover_text(&attr_name, ast, offset)
275            };
276            return Some(HoverInfo {
277                content,
278                span: Some(Span {
279                    start: attr_start,
280                    end: attr_name_end,
281                }),
282            });
283        }
284        i += 1;
285    }
286    None
287}
288
289/// Walk tokens from the `(` at `lparen_idx` and return the byte-end of the
290/// matching `)`.
291fn find_paren_end(tokens: &[Token], lparen_idx: usize) -> Option<usize> {
292    let mut depth: i32 = 0;
293    for tok in &tokens[lparen_idx..] {
294        match tok.kind {
295            TokenKind::LParen => depth += 1,
296            TokenKind::RParen => {
297                depth -= 1;
298                if depth == 0 {
299                    return Some(tok.span.end);
300                }
301            }
302            _ => {}
303        }
304    }
305    None
306}
307
308fn field_attr_hover_text(name: &str, ast: Option<&Schema>, offset: usize) -> String {
309    match name {
310        "id" => "**@id**  \nMarks this field as the primary key of the model.".to_string(),
311        "unique" => "**@unique**  \nAdds a `UNIQUE` constraint on this column.".to_string(),
312        "default" => [
313            "**@default(expr)**  ",
314            "Sets the default value for this field when not explicitly provided.  \n",
315            "Common expressions: `autoincrement()`, `now()`, `uuid()`,",
316            " enum variants, or literal values.",
317        ].concat(),
318        "map" => "**@map(\"name\")** \nMaps this field to a different physical column name in the database.".to_string(),
319        "store" => [
320            "**@store(json)**  \n",
321            "Stores this array field as a JSON value in the database.  \n",
322            "Useful for databases without native array support (MySQL, SQLite).",
323        ].concat(),
324        "updatedAt" => [
325            "**@updatedAt**  \n",
326            "Marks this `DateTime` field to be automatically set to the current timestamp ",
327            "on every CREATE and UPDATE operation.  \n",
328            "The framework manages this value — it is excluded from all user-input types.",
329        ].concat(),
330        "computed" => [
331            "**@computed(expr, Stored | Virtual)**  \n",
332            "Declares a database-generated (computed) column.  \n\n",
333            "- `expr` — raw SQL expression evaluated by the database (e.g. `price * quantity`, ",
334            "`first_name || ' ' || last_name`)  \n",
335            "- `Stored` — value is computed on write and persisted physically  \n",
336            "- `Virtual` — value is computed on read (not supported on PostgreSQL)  \n\n",
337            "Maps to SQL `GENERATED ALWAYS AS (expr) STORED` (PostgreSQL / MySQL) or ",
338            "`AS (expr) STORED` (SQLite).  \n",
339            "Computed fields are **read-only** — they are excluded from all create/update input types.",
340        ].concat(),
341        "check" => [
342            "**@check(expr)**  \n",
343            "Adds a SQL `CHECK` constraint on this column.  \n\n",
344            "The boolean expression can use SQL-style operators: ",
345            "`=`, `!=`, `<`, `>`, `<=`, `>=`, `AND`, `OR`, `NOT`, `IN`.  \n\n",
346            "Field-level `@check` can only reference the decorated field itself.  \n",
347            "Use `@@check` at the model level to reference multiple fields.  \n\n",
348            "**Examples:**  \n",
349            "```  \n",
350            "age    Int  @check(age >= 0 AND age <= 150)  \n",
351            "status Status @check(status IN [ACTIVE, PENDING])  \n",
352            "```",
353        ].concat(),
354        "relation" => {
355            let base = concat!(
356                "**@relation**  \n",
357                "Defines an explicit foreign-key relation between two models."
358            );
359            if let Some(schema) = ast {
360                if let Some(extra) = relation_hover_details(schema, offset) {
361                    return format!("{base}  \n\n{extra}");
362                }
363            }
364            base.to_string()
365        }
366        other => format!("**@{other}**"),
367    }
368}
369
370fn model_attr_hover_text(name: &str) -> String {
371    match name {
372        "map"    => "**@@map(\"name\")** \nMaps this model to a different physical table name in the database.".to_string(),
373        "id"     => "**@@id([fields])**  \nDefines a composite primary key spanning multiple fields.".to_string(),
374        "unique" => "**@@unique([fields])**  \nDefines a composite unique constraint spanning multiple fields.".to_string(),
375        "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(),
376        "check"  => [
377            "**@@check(expr)**  \n",
378            "Adds a table-level SQL `CHECK` constraint.  \n\n",
379            "Unlike field-level `@check`, the expression can reference any scalar field in the model.  \n\n",
380            "**Example:**  \n",
381            "```  \n",
382            "@@check(start_date < end_date)  \n",
383            "@@check(age > 18 OR status IN [MINOR])  \n",
384            "```",
385        ].concat(),
386        other    => format!("**@@{other}**"),
387    }
388}
389
390/// Extracts a rich Markdown summary of the `@relation(...)` attribute on the
391/// field whose span contains `offset`.
392///
393/// Shows:
394/// - Inferred relation type (one-to-many / one-to-one)
395/// - `ParentModel -> TargetType` arrow
396/// - All explicit arguments: name, fields, references, onDelete, onUpdate
397fn relation_hover_details(ast: &Schema, offset: usize) -> Option<String> {
398    for decl in &ast.declarations {
399        if let Declaration::Model(model) = decl {
400            for field in &model.fields {
401                if !span_contains(field.span, offset) {
402                    continue;
403                }
404                for attr in &field.attributes {
405                    if let FieldAttribute::Relation {
406                        name,
407                        fields,
408                        references,
409                        on_delete,
410                        on_update,
411                        ..
412                    } = attr
413                    {
414                        let target = field_type_name(&field.field_type);
415                        let modifier_str = match field.modifier {
416                            FieldModifier::Array => "[]",
417                            FieldModifier::Optional => "?",
418                            FieldModifier::NotNull => "!",
419                            FieldModifier::None => "",
420                        };
421                        let relation_kind = match field.modifier {
422                            FieldModifier::Array => "one-to-many",
423                            _ if fields.is_some() => "one-to-many",
424                            _ => "one-to-one",
425                        };
426
427                        let mut lines: Vec<String> = vec![format!(
428                            "**Type:** `{relation_kind}`  ·  `{}` -> `{target}{modifier_str}`",
429                            model.name.value
430                        )];
431
432                        let has_args = name.is_some()
433                            || fields.is_some()
434                            || references.is_some()
435                            || on_delete.is_some()
436                            || on_update.is_some();
437
438                        if has_args {
439                            lines.push(String::new());
440                            if let Some(n) = name {
441                                lines.push(format!("- **name**: `\"{n}\"` "));
442                            }
443                            if let Some(fs) = fields {
444                                let names: Vec<&str> =
445                                    fs.iter().map(|f| f.value.as_str()).collect();
446                                lines.push(format!("- **fields**: `[{}]`", names.join(", ")));
447                            }
448                            if let Some(rs) = references {
449                                let names: Vec<&str> =
450                                    rs.iter().map(|r| r.value.as_str()).collect();
451                                lines.push(format!("- **references**: `[{}]`", names.join(", ")));
452                            }
453                            if let Some(od) = on_delete {
454                                lines.push(format!("- **onDelete**: `{od}`"));
455                            }
456                            if let Some(ou) = on_update {
457                                lines.push(format!("- **onUpdate**: `{ou}`"));
458                            }
459                        }
460
461                        return Some(lines.join("  \n"));
462                    }
463                }
464            }
465        }
466    }
467    None
468}
469
470/// Formats field-level attributes as an inline string, e.g.
471/// `@id · @default(uuid()) · @map("user_id")`.
472/// `@relation` is omitted — it has its own dedicated hover.
473fn format_field_attrs_short(attrs: &[FieldAttribute]) -> String {
474    attrs
475        .iter()
476        .filter_map(|attr| match attr {
477            FieldAttribute::Id => Some("@id".to_string()),
478            FieldAttribute::Unique => Some("@unique".to_string()),
479            FieldAttribute::Default(expr, _) => {
480                Some(format!("@default({})", crate::formatter::format_expr(expr)))
481            }
482            FieldAttribute::Map(name) => Some(format!("@map(\"{}\")", name)),
483            FieldAttribute::Store { .. } => Some("@store(json)".to_string()),
484            FieldAttribute::UpdatedAt { .. } => Some("@updatedAt".to_string()),
485            FieldAttribute::Computed { expr, kind, .. } => {
486                let kind_str = match kind {
487                    ComputedKind::Stored => "Stored",
488                    ComputedKind::Virtual => "Virtual",
489                };
490                Some(format!("@computed({}, {})", expr, kind_str))
491            }
492            FieldAttribute::Check { expr, .. } => Some(format!("@check({})", expr)),
493            FieldAttribute::Relation { .. } => None,
494        })
495        .collect::<Vec<_>>()
496        .join(" · ")
497}
498
499/// Builds the full Markdown hover content for a `model` declaration.
500fn model_hover_content(
501    model: &crate::ast::ModelDecl,
502    composite_names: &std::collections::HashSet<String>,
503) -> String {
504    let table_name = model.table_name();
505    let mut lines: Vec<String> = vec![format!("**model** `{}`", model.name.value)];
506
507    if table_name != model.name.value {
508        lines.push(format!("**Table:** `{}`", table_name));
509    }
510
511    let composite_count = model
512        .fields
513        .iter()
514        .filter(|f| matches!(&f.field_type, FieldType::UserType(n) if composite_names.contains(n)))
515        .count();
516    let relation_count = model
517        .fields
518        .iter()
519        .filter(|f| matches!(&f.field_type, FieldType::UserType(n) if !composite_names.contains(n)))
520        .count();
521    let scalar_count = model.fields.len() - composite_count - relation_count;
522    let mut count_parts = vec![format!("{} scalar", scalar_count)];
523    if relation_count > 0 {
524        count_parts.push(format!("{} relation", relation_count));
525    }
526    if composite_count > 0 {
527        count_parts.push(format!("{} composite", composite_count));
528    }
529    lines.push(format!("**Fields:** {}", count_parts.join(" · ")));
530    lines.push(String::new());
531
532    for field in &model.fields {
533        let modifier = match field.modifier {
534            FieldModifier::Array => "[]",
535            FieldModifier::Optional => "?",
536            FieldModifier::NotNull => "!",
537            FieldModifier::None => "",
538        };
539        let type_str = format!("{}{}", field_type_name(&field.field_type), modifier);
540        let attrs_str = format_field_attrs_short(&field.attributes);
541        if attrs_str.is_empty() {
542            lines.push(format!("- `{}`: `{}`", field.name.value, type_str));
543        } else {
544            lines.push(format!(
545                "- `{}`: `{}`  — {}",
546                field.name.value, type_str, attrs_str
547            ));
548        }
549    }
550
551    let extra_attrs: Vec<String> = model
552        .attributes
553        .iter()
554        .filter_map(|attr| match attr {
555            ModelAttribute::Map(_) => None,
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}