Skip to main content

cratefield_tables/
schema.rs

1//! The schema type: what a venture declares, and the rules the
2//! declaration itself must satisfy.
3//!
4//! This is a small purpose-built enum, not JSON Schema. JSON Schema is
5//! sprawling and says nothing about the SQLite and Postgres DDL mapping,
6//! so it is emitted as a derived view instead.
7//!
8//! The bound on what a field can express: a declaration covers required,
9//! length, range, format, enum, uniqueness, foreign keys, defaults and
10//! indexes. Anything that has to read another row or another request is a
11//! function, not a field. Overlap checks, state machines and side effects
12//! belong in a module or a sidecar.
13
14use cratefield_core::ConfigError;
15use serde_json::Value;
16
17/// The longest identifier both dialects accept without truncation.
18/// Postgres cuts identifiers at 63 bytes; the rule keeps a declared name
19/// meaning the same thing on both engines.
20pub const MAX_IDENTIFIER_CHARS: usize = 63;
21
22/// Name prefixes the harness keeps for itself. A declared table or field
23/// may not start with one, so a venture can never shadow the migration
24/// bookkeeping or an engine catalogue.
25pub const RESERVED_PREFIXES: &[&str] = &["harness_", "sqlite_", "pg_", "cf_"];
26
27/// Every word either engine treats as a keyword in a table or column
28/// position, plus the Postgres system column names. A declared name is
29/// rejected rather than quoted, which is why the generated DDL needs no
30/// quoting at all.
31///
32/// Sorted, deduplicated, and merged from four lists:
33///
34/// - PostgreSQL's `reserved` category, the `reserved_keyword` production
35///   in its grammar, listed in Appendix C of the manual. These can never
36///   be a table or column name unquoted.
37/// - PostgreSQL's `type_func_name_keyword` category, Appendix C's
38///   "reserved (can be function or type name)". A column named `binary`
39///   or `similar` is a syntax error the same way.
40/// - SQLite's keyword list, <https://sqlite.org/lang_keywords.html>.
41///   SQLite accepts most of these in some positions and rejects them in
42///   others, so the whole list is rejected rather than the subset that
43///   happens to break today.
44/// - The Postgres system column names, which every table has whether or
45///   not they are declared: `tableoid`, `xmin`, `cmin`, `xmax`, `cmax`
46///   and `ctid`. A declared column of one of those names is refused at
47///   `CREATE TABLE`.
48///
49/// Two entries are a version ahead of the engines the review ran
50/// against: `system_user` became reserved in PostgreSQL 16, and `xmax`
51/// is a system column the review's list did not spell out.
52pub const RESERVED_WORDS: &[&str] = &[
53    "abort",
54    "action",
55    "add",
56    "after",
57    "all",
58    "alter",
59    "always",
60    "analyse",
61    "analyze",
62    "and",
63    "any",
64    "array",
65    "as",
66    "asc",
67    "asymmetric",
68    "attach",
69    "authorization",
70    "autoincrement",
71    "before",
72    "begin",
73    "between",
74    "binary",
75    "both",
76    "by",
77    "cascade",
78    "case",
79    "cast",
80    "check",
81    "cmax",
82    "cmin",
83    "collate",
84    "collation",
85    "column",
86    "commit",
87    "concurrently",
88    "conflict",
89    "constraint",
90    "create",
91    "cross",
92    "ctid",
93    "current",
94    "current_catalog",
95    "current_date",
96    "current_role",
97    "current_schema",
98    "current_time",
99    "current_timestamp",
100    "current_user",
101    "database",
102    "default",
103    "deferrable",
104    "deferred",
105    "delete",
106    "desc",
107    "detach",
108    "distinct",
109    "do",
110    "drop",
111    "each",
112    "else",
113    "end",
114    "escape",
115    "except",
116    "exclude",
117    "exclusive",
118    "exists",
119    "explain",
120    "fail",
121    "false",
122    "fetch",
123    "filter",
124    "first",
125    "following",
126    "for",
127    "foreign",
128    "freeze",
129    "from",
130    "full",
131    "generated",
132    "glob",
133    "grant",
134    "group",
135    "groups",
136    "having",
137    "if",
138    "ignore",
139    "ilike",
140    "immediate",
141    "in",
142    "index",
143    "indexed",
144    "initially",
145    "inner",
146    "insert",
147    "instead",
148    "intersect",
149    "into",
150    "is",
151    "isnull",
152    "join",
153    "key",
154    "last",
155    "lateral",
156    "leading",
157    "left",
158    "like",
159    "limit",
160    "localtime",
161    "localtimestamp",
162    "match",
163    "materialized",
164    "natural",
165    "no",
166    "not",
167    "nothing",
168    "notnull",
169    "null",
170    "nulls",
171    "of",
172    "offset",
173    "on",
174    "only",
175    "or",
176    "order",
177    "others",
178    "outer",
179    "over",
180    "overlaps",
181    "partition",
182    "placing",
183    "plan",
184    "pragma",
185    "preceding",
186    "primary",
187    "query",
188    "raise",
189    "range",
190    "recursive",
191    "references",
192    "regexp",
193    "reindex",
194    "release",
195    "rename",
196    "replace",
197    "restrict",
198    "returning",
199    "right",
200    "rollback",
201    "row",
202    "rows",
203    "savepoint",
204    "select",
205    "session_user",
206    "set",
207    "similar",
208    "some",
209    "symmetric",
210    "system_user",
211    "table",
212    "tableoid",
213    "tablesample",
214    "temp",
215    "temporary",
216    "then",
217    "ties",
218    "to",
219    "trailing",
220    "transaction",
221    "trigger",
222    "true",
223    "unbounded",
224    "union",
225    "unique",
226    "update",
227    "user",
228    "using",
229    "vacuum",
230    "values",
231    "variadic",
232    "verbose",
233    "view",
234    "virtual",
235    "when",
236    "where",
237    "window",
238    "with",
239    "without",
240    "xmax",
241    "xmin",
242];
243
244/// A text field's declared format. The row validator asserts these; JSON
245/// Schema only annotates them, which is one of the reasons the JSON
246/// Schema rendering is a view and not the source.
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub enum TextFormat {
249    /// The address rule `cratefield-core` already applies everywhere it
250    /// stores one, reused here so a declared table and a module cannot
251    /// disagree about what an address is.
252    Email,
253    /// An absolute `http` or `https` URL with a non-empty host and no
254    /// whitespace.
255    Url,
256}
257
258impl TextFormat {
259    /// The wire name, as written in the manifest.
260    #[must_use]
261    pub fn as_str(self) -> &'static str {
262        match self {
263            TextFormat::Email => "email",
264            TextFormat::Url => "url",
265        }
266    }
267}
268
269/// What one column holds. Deliberately small: every variant maps to a
270/// column type in both dialects and to one rule in the row validator.
271#[derive(Debug, Clone, PartialEq)]
272pub enum FieldKind {
273    /// A Unicode string. `min_len` and `max_len` count Unicode scalar
274    /// values, never bytes and never UTF-16 code units.
275    Text {
276        min_len: Option<u32>,
277        max_len: Option<u32>,
278        format: Option<TextFormat>,
279    },
280    /// A whole number. Bounds are inclusive.
281    Integer { min: Option<i64>, max: Option<i64> },
282    /// A floating-point number. Bounds are inclusive.
283    Real { min: Option<f64>, max: Option<f64> },
284    /// `true` or `false`, never `0`, `1` or `"true"`.
285    Boolean,
286    /// An RFC 3339 timestamp, stored as ISO-8601 text. That is the
287    /// portable subset every module migration already uses.
288    Timestamp,
289    /// A hyphenated 8-4-4-4-12 UUID string, stored as text.
290    Uuid,
291    /// Any JSON value, stored as text.
292    Json,
293    /// One of a fixed list of strings, compared case-sensitively.
294    Enum { values: Vec<String> },
295}
296
297impl FieldKind {
298    /// Whether a column of this kind can hold whose a row is.
299    ///
300    /// A subject is a caller's id — `Caller::id()`, a string that came
301    /// out of a verified credential — so the column has to be one a
302    /// string binds to. An `integer` subject column cannot hold one, and
303    /// every scoped read of such a table fails while building its
304    /// statement rather than while being declared.
305    ///
306    /// `timestamp` and `enum` are strings on the wire and still not
307    /// subjects: a caller id is not a moment, and it is not one of a
308    /// fixed set the manifest wrote down. Admitting them because the
309    /// binding would happen to work is how a rule stops meaning what it
310    /// is named after.
311    ///
312    /// One definition, two callers: `cratefield-manifest` refuses a
313    /// declaration that breaks it, and `cratefield-tables-api` refuses to
314    /// serve one that reached a deployment anyway.
315    #[must_use]
316    pub fn can_hold_a_subject(&self) -> bool {
317        matches!(self, FieldKind::Text { .. } | FieldKind::Uuid)
318    }
319
320    /// The wire name, as written in the manifest's `kind` key.
321    #[must_use]
322    pub fn as_str(&self) -> &'static str {
323        match self {
324            FieldKind::Text { .. } => "text",
325            FieldKind::Integer { .. } => "integer",
326            FieldKind::Real { .. } => "real",
327            FieldKind::Boolean => "boolean",
328            FieldKind::Timestamp => "timestamp",
329            FieldKind::Uuid => "uuid",
330            FieldKind::Json => "json",
331            FieldKind::Enum { .. } => "enum",
332        }
333    }
334
335    /// A plain text field with no length bounds and no format.
336    #[must_use]
337    pub fn text() -> Self {
338        FieldKind::Text {
339            min_len: None,
340            max_len: None,
341            format: None,
342        }
343    }
344
345    /// An integer field with no bounds.
346    #[must_use]
347    pub fn integer() -> Self {
348        FieldKind::Integer {
349            min: None,
350            max: None,
351        }
352    }
353
354    /// A real field with no bounds.
355    #[must_use]
356    pub fn real() -> Self {
357        FieldKind::Real {
358            min: None,
359            max: None,
360        }
361    }
362}
363
364/// One declared column.
365#[derive(Debug, Clone, PartialEq)]
366pub struct FieldDef {
367    /// Lowercase snake-case identifier, unique within the table.
368    pub name: String,
369    pub kind: FieldKind,
370    /// `NOT NULL` in the generated DDL. A required field with a default
371    /// may still be left out of a write: the database supplies the value.
372    pub required: bool,
373    /// `UNIQUE` in the generated DDL. Uniqueness is enforced by the
374    /// database, never by the row validator, because it reads other rows.
375    pub unique: bool,
376    /// Gets its own `CREATE INDEX`.
377    pub indexed: bool,
378    /// Literal default, checked against `kind` by [`Schema::validate`].
379    pub default: Option<Value>,
380}
381
382impl FieldDef {
383    /// An optional field of `kind`, with no default and no index.
384    #[must_use]
385    pub fn new(name: impl Into<String>, kind: FieldKind) -> Self {
386        Self {
387            name: name.into(),
388            kind,
389            required: false,
390            unique: false,
391            indexed: false,
392            default: None,
393        }
394    }
395
396    #[must_use]
397    pub fn required(mut self) -> Self {
398        self.required = true;
399        self
400    }
401
402    #[must_use]
403    pub fn unique(mut self) -> Self {
404        self.unique = true;
405        self
406    }
407
408    #[must_use]
409    pub fn indexed(mut self) -> Self {
410        self.indexed = true;
411        self
412    }
413
414    #[must_use]
415    pub fn default_value(mut self, value: Value) -> Self {
416        self.default = Some(value);
417        self
418    }
419
420    /// Whether a write must carry this field. A default satisfies
421    /// `required`, so only a required field without one has to be sent.
422    #[must_use]
423    pub fn must_be_present(&self) -> bool {
424        self.required && self.default.is_none()
425    }
426}
427
428/// A reference from one declared field to another declared table's
429/// primary key.
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct ForeignKey {
432    /// The field in this table that holds the reference.
433    pub field: String,
434    /// The declared table it points at. The referenced column is that
435    /// table's primary key, which must be a single column.
436    pub references: String,
437}
438
439impl ForeignKey {
440    #[must_use]
441    pub fn new(field: impl Into<String>, references: impl Into<String>) -> Self {
442        Self {
443            field: field.into(),
444            references: references.into(),
445        }
446    }
447}
448
449/// One declared table.
450#[derive(Debug, Clone, PartialEq)]
451pub struct TableDef {
452    /// Lowercase snake-case identifier, unique within the schema.
453    pub name: String,
454    /// Columns, in declaration order. That order is the column order of
455    /// the generated DDL and the order errors are reported in.
456    pub fields: Vec<FieldDef>,
457    /// One or more declared field names. Primary-key fields are `NOT
458    /// NULL` whether or not they are marked required.
459    pub primary_key: Vec<String>,
460    pub foreign_keys: Vec<ForeignKey>,
461}
462
463impl TableDef {
464    /// A table with a single-column primary key.
465    #[must_use]
466    pub fn new(
467        name: impl Into<String>,
468        primary_key: impl Into<String>,
469        fields: Vec<FieldDef>,
470    ) -> Self {
471        Self {
472            name: name.into(),
473            fields,
474            primary_key: vec![primary_key.into()],
475            foreign_keys: Vec::new(),
476        }
477    }
478
479    #[must_use]
480    pub fn foreign_key(mut self, key: ForeignKey) -> Self {
481        self.foreign_keys.push(key);
482        self
483    }
484
485    /// The declared field called `name`, if there is one.
486    #[must_use]
487    pub fn field(&self, name: &str) -> Option<&FieldDef> {
488        self.fields.iter().find(|field| field.name == name)
489    }
490
491    /// Whether `name` is part of the primary key.
492    #[must_use]
493    pub fn is_primary_key(&self, name: &str) -> bool {
494        self.primary_key.iter().any(|key| key == name)
495    }
496}
497
498/// Every table a venture declares, in name order.
499#[derive(Debug, Clone, PartialEq, Default)]
500pub struct Schema {
501    /// Declared tables. [`Schema::new`] and the manifest deserializer both
502    /// sort by name, so the generated DDL does not depend on the order the
503    /// manifest happened to list them in.
504    pub tables: Vec<TableDef>,
505}
506
507impl Schema {
508    /// Builds a schema, sorted by table name.
509    #[must_use]
510    pub fn new(tables: Vec<TableDef>) -> Self {
511        let mut tables = tables;
512        tables.sort_by(|a, b| a.name.cmp(&b.name));
513        Self { tables }
514    }
515
516    /// `true` when nothing is declared. A venture with no `[tables]`
517    /// section has one of these, and it is the common case.
518    #[must_use]
519    pub fn is_empty(&self) -> bool {
520        self.tables.is_empty()
521    }
522
523    /// The declared table called `name`, if there is one.
524    #[must_use]
525    pub fn table(&self, name: &str) -> Option<&TableDef> {
526        self.tables.iter().find(|table| table.name == name)
527    }
528
529    /// Checks the declaration itself and reports every violation
530    /// together, the way [`cratefield_core::Venture`] does.
531    ///
532    /// The rules: identifiers are lowercase snake-case, not reserved and
533    /// not card-shaped; field and table names do not repeat; the primary
534    /// key names declared fields and every one of them is required or
535    /// defaulted; a foreign key points at a declared table with a
536    /// single-column primary key of a matching kind, and the references
537    /// hold no cycle; an enum declares at least one member; bounds are
538    /// the right way round; a default matches its field's kind; and no
539    /// two generated index names collide with each other or with a
540    /// declared table name.
541    ///
542    /// # Errors
543    ///
544    /// A [`ConfigError`] carrying one line per violation.
545    pub fn validate(&self) -> Result<(), ConfigError> {
546        let mut errors = ConfigError::new();
547        let mut seen_tables: Vec<String> = Vec::new();
548
549        for table in &self.tables {
550            check_identifier(&mut errors, "table", &table.name, &table.name);
551            if seen_tables.iter().any(|seen| seen == &table.name) {
552                errors.push(format!("table `{}`: declared twice", table.name));
553            }
554            seen_tables.push(table.name.clone());
555            self.validate_table(&mut errors, table);
556        }
557
558        self.validate_index_names(&mut errors);
559        self.validate_no_reference_cycle(&mut errors);
560
561        errors.into_result()
562    }
563
564    /// Index names live in the same namespace as table names, in both
565    /// engines, and that namespace is the whole schema rather than one
566    /// table. `{table}_{field}_idx` is ambiguous because an underscore is
567    /// legal in both halves, so `post.author_id` and `post_author.id`
568    /// generate the same name. `CREATE INDEX IF NOT EXISTS` then leaves
569    /// the second one silently absent, and a name that lands on a table
570    /// loses the table instead.
571    fn validate_index_names(&self, errors: &mut ConfigError) {
572        let mut generated: Vec<(String, String, String)> = Vec::new();
573        for table in &self.tables {
574            for field in &table.fields {
575                if !crate::ddl::needs_own_index(table, field) {
576                    continue;
577                }
578                let index = crate::ddl::index_name(&table.name, &field.name);
579                if let Some((_, first_table, first_field)) =
580                    generated.iter().find(|(name, _, _)| name == &index)
581                {
582                    errors.push(format!(
583                        "table `{}`, field `{}`: the generated index name `{index}` is already \
584                         generated by table `{first_table}`, field `{first_field}`",
585                        table.name, field.name
586                    ));
587                }
588                if self.table(&index).is_some() {
589                    errors.push(format!(
590                        "table `{}`, field `{}`: the generated index name `{index}` is also a \
591                         declared table name, and both engines keep indexes and tables in one \
592                         namespace",
593                        table.name, field.name
594                    ));
595                }
596                generated.push((index, table.name.clone(), field.name.clone()));
597            }
598        }
599    }
600
601    /// A foreign key makes the referenced table a prerequisite, so a
602    /// cycle has no creation order at all. Reported here rather than left
603    /// for the renderer, because the script it would produce runs on
604    /// neither engine.
605    fn validate_no_reference_cycle(&self, errors: &mut ConfigError) {
606        let (_, cyclic) = crate::ddl::creation_order(self);
607        let Some(first) = cyclic.first() else {
608            return;
609        };
610        let names: Vec<&str> = cyclic.iter().map(|table| table.name.as_str()).collect();
611        let path = self.walk_cycle(&first.name, &names);
612        errors.push(format!(
613            "table `{}`: the foreign keys form a reference cycle, {}, so there is no order that \
614             creates a table after the tables it references",
615            first.name,
616            path.join(" -> ")
617        ));
618    }
619
620    /// Follows unresolved references from `start`, taking the
621    /// alphabetically first each time, until a name repeats. Every table
622    /// in `unresolved` has at least one unresolved reference, which is
623    /// what left it there, so the walk always closes.
624    fn walk_cycle(&self, start: &str, unresolved: &[&str]) -> Vec<String> {
625        let mut path: Vec<&str> = Vec::new();
626        let mut current = start;
627        loop {
628            if let Some(at) = path.iter().position(|name| *name == current) {
629                let mut cycle: Vec<String> =
630                    path[at..].iter().map(|&name| name.to_owned()).collect();
631                cycle.push(current.to_owned());
632                return cycle;
633            }
634            path.push(current);
635            let next = self.table(current).and_then(|table| {
636                table
637                    .foreign_keys
638                    .iter()
639                    .map(|key| key.references.as_str())
640                    .filter(|name| unresolved.contains(name))
641                    .min()
642            });
643            match next {
644                Some(next) => current = next,
645                // Unreachable while `unresolved` comes from the sort, and
646                // a partial path still names the table that is stuck.
647                None => return path.iter().map(|&name| name.to_owned()).collect(),
648            }
649        }
650    }
651
652    fn validate_table(&self, errors: &mut ConfigError, table: &TableDef) {
653        if table.fields.is_empty() {
654            errors.push(format!("table `{}`: declares no fields", table.name));
655        }
656
657        let mut seen_fields: Vec<String> = Vec::new();
658        for field in &table.fields {
659            check_identifier(errors, "field", &field.name, &table.name);
660            if seen_fields.iter().any(|seen| seen == &field.name) {
661                errors.push(format!(
662                    "table `{}`: field `{}` is declared twice",
663                    table.name, field.name
664                ));
665            }
666            seen_fields.push(field.name.clone());
667            validate_field(errors, table, field);
668        }
669
670        validate_primary_key(errors, table);
671
672        for key in &table.foreign_keys {
673            self.validate_foreign_key(errors, table, key);
674        }
675    }
676
677    fn validate_foreign_key(&self, errors: &mut ConfigError, table: &TableDef, key: &ForeignKey) {
678        let Some(local) = table.field(&key.field) else {
679            errors.push(format!(
680                "table `{}`: foreign key on `{}`, which is not a declared field",
681                table.name, key.field
682            ));
683            return;
684        };
685        let Some(target) = self.table(&key.references) else {
686            errors.push(format!(
687                "table `{}`: foreign key `{}` references table `{}`, which is not declared",
688                table.name, key.field, key.references
689            ));
690            return;
691        };
692        let [target_key] = target.primary_key.as_slice() else {
693            errors.push(format!(
694                "table `{}`: foreign key `{}` references table `{}`, whose primary key is not a \
695                 single column",
696                table.name, key.field, target.name
697            ));
698            return;
699        };
700        let Some(target_field) = target.field(target_key) else {
701            // The target table's own primary-key rule already reported it.
702            return;
703        };
704        if local.kind.as_str() != target_field.kind.as_str() {
705            errors.push(format!(
706                "table `{}`: foreign key `{}` is {} but `{}.{}` is {}",
707                table.name,
708                key.field,
709                local.kind.as_str(),
710                target.name,
711                target_field.name,
712                target_field.kind.as_str()
713            ));
714        }
715    }
716}
717
718fn validate_primary_key(errors: &mut ConfigError, table: &TableDef) {
719    if table.primary_key.is_empty() {
720        errors.push(format!("table `{}`: no primary key", table.name));
721        return;
722    }
723    let mut seen: Vec<&String> = Vec::new();
724    for key in &table.primary_key {
725        match table.field(key) {
726            None => errors.push(format!(
727                "table `{}`: primary key names `{key}`, which is not a declared field",
728                table.name
729            )),
730            // The rendered column is `NOT NULL` whether or not the field
731            // says `required`, so a primary key that is neither required
732            // nor defaulted is a column the row validator and the JSON
733            // Schema would both let through and the insert would then
734            // reject. Rejecting the declaration is what keeps the three
735            // from ever disagreeing.
736            Some(field) if !field.required && field.default.is_none() => errors.push(format!(
737                "table `{}`: primary key `{key}` must be required or have a default, because a \
738                 primary-key column is NOT NULL",
739                table.name
740            )),
741            Some(_) => {}
742        }
743        if seen.contains(&key) {
744            errors.push(format!(
745                "table `{}`: primary key names `{key}` twice",
746                table.name
747            ));
748        }
749        seen.push(key);
750    }
751}
752
753fn validate_field(errors: &mut ConfigError, table: &TableDef, field: &FieldDef) {
754    let at = format!("table `{}`, field `{}`", table.name, field.name);
755    match &field.kind {
756        FieldKind::Text {
757            min_len, max_len, ..
758        } => {
759            if let (Some(min), Some(max)) = (min_len, max_len)
760                && min > max
761            {
762                errors.push(format!("{at}: min_len {min} is greater than max_len {max}"));
763            }
764            if *max_len == Some(0) {
765                errors.push(format!("{at}: max_len 0 accepts nothing"));
766            }
767        }
768        FieldKind::Integer { min, max } => {
769            if let (Some(min), Some(max)) = (min, max)
770                && min > max
771            {
772                errors.push(format!("{at}: min {min} is greater than max {max}"));
773            }
774        }
775        FieldKind::Real { min, max } => {
776            if let (Some(min), Some(max)) = (min, max)
777                && min > max
778            {
779                errors.push(format!("{at}: min {min} is greater than max {max}"));
780            }
781            for (label, bound) in [("min", min), ("max", max)] {
782                if bound.is_some_and(|value| !value.is_finite()) {
783                    errors.push(format!("{at}: {label} is not a finite number"));
784                }
785            }
786        }
787        FieldKind::Enum { values } => {
788            if values.is_empty() {
789                errors.push(format!("{at}: an enum must declare at least one value"));
790            }
791            let mut seen: Vec<&String> = Vec::new();
792            for value in values {
793                if seen.contains(&value) {
794                    errors.push(format!("{at}: enum value `{value}` is declared twice"));
795                }
796                seen.push(value);
797                // A member reaches the DDL inside a `CHECK (... IN (...))`
798                // and the renderer only doubles quotes, so a control
799                // character goes through raw: a NUL truncates the
800                // statement in SQLite and is rejected outright by
801                // PostgreSQL, and a newline produces a schema file nobody
802                // can read. Escaping it would keep a value no caller
803                // wants, so the declaration is refused instead. The
804                // position is named because the character itself does not
805                // print.
806                if let Some(position) = value.chars().position(char::is_control) {
807                    errors.push(format!(
808                        "{at}: enum value `{}` holds a control character at position {position}",
809                        value.escape_debug()
810                    ));
811                }
812            }
813        }
814        FieldKind::Boolean | FieldKind::Timestamp | FieldKind::Uuid | FieldKind::Json => {}
815    }
816
817    // A string default reaches the DDL as a literal, exactly like an enum
818    // member, so it carries that rule rather than the looser one a
819    // caller's value gets: a control character there is a schema file
820    // nobody can read, and a NUL is a statement PostgreSQL refuses. The
821    // position is named because the character does not print.
822    if let Some(text) = field.default.as_ref().and_then(serde_json::Value::as_str)
823        && let Some(position) = text.chars().position(char::is_control)
824    {
825        errors.push(format!(
826            "{at}: the default `{}` holds a control character at position {position}",
827            text.escape_debug()
828        ));
829    }
830    if let Some(default) = &field.default
831        && let Err(error) = crate::value::check_value(&field.kind, default)
832    {
833        errors.push(format!("{at}: the default {default} {}", error.message));
834    }
835
836    // The generated index name has to be a legal identifier too, and it
837    // is longer than either name it is built from.
838    if crate::ddl::needs_own_index(table, field) {
839        let index = crate::ddl::index_name(&table.name, &field.name);
840        if index.chars().count() > MAX_IDENTIFIER_CHARS {
841            errors.push(format!(
842                "{at}: the generated index name `{index}` is longer than \
843                 {MAX_IDENTIFIER_CHARS} characters"
844            ));
845        }
846    }
847}
848
849/// Identifier rules, shared by table and field names. `what` is the noun
850/// used in the message, `at` the table the name belongs to.
851fn check_identifier(errors: &mut ConfigError, what: &str, name: &str, at: &str) {
852    let where_ = if what == "table" {
853        format!("table `{name}`")
854    } else {
855        format!("table `{at}`, field `{name}`")
856    };
857
858    if !is_identifier(name) {
859        errors.push(format!(
860            "{where_}: a {what} name must match [a-z][a-z0-9_]* and hold no double or trailing \
861             underscore"
862        ));
863    } else if name.chars().count() > MAX_IDENTIFIER_CHARS {
864        errors.push(format!(
865            "{where_}: a {what} name is at most {MAX_IDENTIFIER_CHARS} characters"
866        ));
867    }
868
869    if RESERVED_WORDS.contains(&name) {
870        errors.push(format!("{where_}: `{name}` is a reserved SQL word"));
871    }
872    if let Some(prefix) = RESERVED_PREFIXES
873        .iter()
874        .find(|prefix| name.starts_with(*prefix))
875    {
876        errors.push(format!(
877            "{where_}: the `{prefix}` prefix is reserved for the harness"
878        ));
879    }
880    // The harness never stores card data (issue #44); a declared table is
881    // exactly where it would otherwise appear.
882    if let Some(fragment) = cratefield_core::card_data_hit(name) {
883        errors.push(format!(
884            "{where_}: `{fragment}` looks like card data. With a normal Stripe integration the \
885             card never reaches a backend, so store only Stripe's identifiers"
886        ));
887    }
888}
889
890/// Lowercase snake-case: starts with a letter, then letters, digits and
891/// single underscores, never ending in one. Nothing else is accepted, and
892/// that is what lets the generated DDL leave identifiers unquoted.
893#[must_use]
894pub fn is_identifier(name: &str) -> bool {
895    let mut chars = name.chars();
896    let Some(first) = chars.next() else {
897        return false;
898    };
899    if !first.is_ascii_lowercase() {
900        return false;
901    }
902    if name.ends_with('_') || name.contains("__") {
903        return false;
904    }
905    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
906}