Skip to main content

rust_ef/
metadata.rs

1//! Model metadata types for describing entities, properties, and relationships.
2//!
3//! These types form the metadata layer of rust-ef, analogous to EFCore's
4//! `IEntityType`, `IProperty`, `INavigation`, etc.
5
6use crate::query::BoolExpr;
7use std::any::TypeId;
8use std::borrow::Cow;
9use std::collections::HashMap;
10
11// ---------------------------------------------------------------------------
12// PropertyMeta ?describes a single column / property
13// ---------------------------------------------------------------------------
14
15/// Metadata describing a property (column) of an entity type.
16/// Corresponds to EFCore's `IProperty`.
17#[derive(Debug, Clone)]
18pub struct PropertyMeta {
19    /// The Rust field name.
20    pub field_name: Cow<'static, str>,
21    /// The database column name (may differ from field_name via #[column]).
22    pub column_name: Cow<'static, str>,
23    /// The Rust type identifier.
24    pub type_id: TypeId,
25    /// Human-readable type name for debugging.
26    pub type_name: Cow<'static, str>,
27    /// Whether this property is part of the primary key.
28    pub is_primary_key: bool,
29    /// Whether this property is auto-increment / identity.
30    pub is_auto_increment: bool,
31    /// Whether this property is backed by a database sequence (PostgreSQL).
32    /// On non-PG providers, falls back to auto_increment behavior.
33    pub is_sequence: bool,
34    /// The sequence name when `is_sequence` is true (PostgreSQL only).
35    pub sequence_name: Option<Cow<'static, str>>,
36    /// Whether this property is required (NOT NULL).
37    pub is_required: bool,
38    /// Whether this property is a foreign key.
39    pub is_foreign_key: bool,
40    /// Whether this property is used for optimistic concurrency control.
41    pub is_concurrency_token: bool,
42    /// Maximum length for string columns (None = unbounded).
43    pub max_length: Option<usize>,
44    /// Whether this property has a unique index.
45    pub is_unique: bool,
46    /// Whether this property has a regular index.
47    pub has_index: bool,
48    /// Whether this property is excluded from mapping (NotMapped).
49    pub is_not_mapped: bool,
50    /// Explicit database column type (`HasColumnType`), emitted verbatim for every
51    /// dialect. Overrides the Rust-type mapping (e.g. `"JSON"`,
52    /// `"MEDIUMTEXT"`, `"DECIMAL(12,2)"`).
53    pub column_type: Option<Cow<'static, str>>,
54    /// Numeric/decimal precision (`HasPrecision(p, s)`), also used as the
55    /// fractional-seconds precision for temporal types.
56    pub precision: Option<u32>,
57    /// Numeric/decimal scale (`HasPrecision(p, s)`).
58    pub scale: Option<u32>,
59    /// Whether string columns use a fixed-length type (`CHAR(n)`).
60    pub is_fixed_length: bool,
61    /// Column-level collation (`HasCollation`).
62    pub collation: Option<Cow<'static, str>>,
63    /// Column/table comment (`HasComment`).
64    pub comment: Option<Cow<'static, str>>,
65    /// Generated/computed column expression (`HasComputedColumnSql`).
66    pub computed_sql: Option<Cow<'static, str>>,
67    /// Whether a computed column is persisted (`STORED`) or virtual.
68    pub computed_stored: bool,
69    /// Raw SQL DEFAULT expression (`HasDefaultValue` / `HasDefaultValueSql`).
70    pub default_value: Option<Cow<'static, str>>,
71}
72
73impl PropertyMeta {
74    /// Returns `true` for generated/computed columns (excluded from
75    /// INSERT/UPDATE and column defaults).
76    pub fn is_computed(&self) -> bool {
77        self.computed_sql.is_some()
78    }
79}
80
81/// Builder for configuring [`PropertyMeta`].
82pub struct PropertyMetaBuilder {
83    meta: PropertyMeta,
84}
85
86impl PropertyMetaBuilder {
87    pub fn new(field_name: &'static str, type_id: TypeId, type_name: &'static str) -> Self {
88        Self {
89            meta: PropertyMeta {
90                field_name: Cow::Borrowed(field_name),
91                column_name: Cow::Borrowed(field_name),
92                type_id,
93                type_name: Cow::Borrowed(type_name),
94                is_primary_key: false,
95                is_auto_increment: false,
96                is_sequence: false,
97                sequence_name: None,
98                is_required: false,
99                is_foreign_key: false,
100                is_concurrency_token: false,
101                max_length: None,
102                is_unique: false,
103                has_index: false,
104                is_not_mapped: false,
105                column_type: None,
106                precision: None,
107                scale: None,
108                is_fixed_length: false,
109                collation: None,
110                comment: None,
111                computed_sql: None,
112                computed_stored: false,
113                default_value: None,
114            },
115        }
116    }
117
118    pub fn column_name(mut self, name: &'static str) -> Self {
119        self.meta.column_name = Cow::Borrowed(name);
120        self
121    }
122
123    pub fn is_primary_key(mut self, v: bool) -> Self {
124        self.meta.is_primary_key = v;
125        self
126    }
127
128    pub fn is_auto_increment(mut self, v: bool) -> Self {
129        self.meta.is_auto_increment = v;
130        self
131    }
132
133    pub fn is_sequence(mut self, v: bool) -> Self {
134        self.meta.is_sequence = v;
135        self
136    }
137
138    pub fn sequence_name(mut self, name: &'static str) -> Self {
139        self.meta.sequence_name = Some(Cow::Borrowed(name));
140        self
141    }
142
143    pub fn is_required(mut self, v: bool) -> Self {
144        self.meta.is_required = v;
145        self
146    }
147
148    pub fn is_foreign_key(mut self, v: bool) -> Self {
149        self.meta.is_foreign_key = v;
150        self
151    }
152
153    pub fn is_concurrency_token(mut self, v: bool) -> Self {
154        self.meta.is_concurrency_token = v;
155        self
156    }
157
158    pub fn max_length(mut self, n: usize) -> Self {
159        self.meta.max_length = Some(n);
160        self
161    }
162
163    pub fn is_unique(mut self, v: bool) -> Self {
164        self.meta.is_unique = v;
165        self
166    }
167
168    pub fn has_index(mut self, v: bool) -> Self {
169        self.meta.has_index = v;
170        self
171    }
172
173    pub fn is_not_mapped(mut self, v: bool) -> Self {
174        self.meta.is_not_mapped = v;
175        self
176    }
177
178    pub fn column_type(mut self, name: &'static str) -> Self {
179        self.meta.column_type = Some(Cow::Borrowed(name));
180        self
181    }
182
183    pub fn precision(mut self, precision: u32, scale: u32) -> Self {
184        self.meta.precision = Some(precision);
185        self.meta.scale = Some(scale);
186        self
187    }
188
189    pub fn is_fixed_length(mut self, v: bool) -> Self {
190        self.meta.is_fixed_length = v;
191        self
192    }
193
194    pub fn collation(mut self, name: &'static str) -> Self {
195        self.meta.collation = Some(Cow::Borrowed(name));
196        self
197    }
198
199    pub fn comment(mut self, text: &'static str) -> Self {
200        self.meta.comment = Some(Cow::Borrowed(text));
201        self
202    }
203
204    pub fn computed_sql(mut self, sql: &'static str, stored: bool) -> Self {
205        self.meta.computed_sql = Some(Cow::Borrowed(sql));
206        self.meta.computed_stored = stored;
207        self
208    }
209
210    pub fn default_value(mut self, sql: &'static str) -> Self {
211        self.meta.default_value = Some(Cow::Borrowed(sql));
212        self
213    }
214
215    pub fn build(self) -> PropertyMeta {
216        self.meta
217    }
218}
219
220// ---------------------------------------------------------------------------
221// NavigationMeta ?describes a navigation property (relationship)
222// ---------------------------------------------------------------------------
223
224/// Describes the type of navigation.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum NavigationKind {
227    /// `BelongsTo<T>` — reference to a single related entity (FK on this side).
228    BelongsTo,
229    /// `HasOne<T>` — reference to a single related entity (FK on other side).
230    HasOne,
231    /// `HasMany<T>` — collection of related entities.
232    HasMany,
233    /// `HasMany<T, Join>` — many-to-many via join entity.
234    ManyToMany,
235}
236
237/// Metadata describing a navigation property (relationship).
238/// Corresponds to EFCore's `INavigation`.
239#[derive(Debug, Clone)]
240pub struct NavigationMeta {
241    /// The Rust field name of this navigation property.
242    pub field_name: Cow<'static, str>,
243    /// The kind of navigation (BelongsTo, HasOne, HasMany).
244    pub kind: NavigationKind,
245    /// The related entity's TypeId.
246    pub related_type_id: TypeId,
247    /// The related entity's type name.
248    pub related_type_name: Cow<'static, str>,
249    /// The foreign key property name on the dependent entity.
250    pub foreign_key_field: Option<Cow<'static, str>>,
251    /// The inverse navigation field name on the related entity.
252    pub inverse_navigation: Option<Cow<'static, str>>,
253    /// For many-to-many: the join entity TypeId.
254    pub through_type_id: Option<TypeId>,
255    /// Join table name (many-to-many).
256    pub through_table: Option<Cow<'static, str>>,
257    /// FK column on the join table pointing to the principal entity.
258    pub through_parent_fk: Option<Cow<'static, str>>,
259    /// FK column on the join table pointing to the related entity.
260    pub through_related_fk: Option<Cow<'static, str>>,
261    /// Column index in join-table rows for the principal FK.
262    pub through_parent_fk_index: usize,
263    /// Column index in join-table rows for the related FK.
264    pub through_related_fk_index: usize,
265    /// Related entity table name (for eager loading).
266    pub related_table: Option<Cow<'static, str>>,
267    /// Foreign key column on the dependent/related table.
268    pub fk_column: Option<Cow<'static, str>>,
269    /// Referenced key column on the principal table.
270    pub referenced_key_column: Option<Cow<'static, str>>,
271    /// Column index in `SELECT *` rows for the FK (HasMany grouping).
272    pub fk_row_index: usize,
273    /// Column index in `SELECT *` rows for the related PK (BelongsTo lookup).
274    pub pk_row_index: usize,
275    /// Resolves metadata for the related entity type (nested Include / ThenInclude).
276    pub related_entity_meta: Option<fn() -> EntityTypeMeta>,
277    /// Delete behavior for this navigation. `None` means use the default
278    /// (Cascade for required FKs and M2M, Restrict for optional FKs).
279    /// Set via `#[on_delete(Cascade|Restrict|SetNull|NoAction)]`.
280    pub delete_behavior: Option<crate::relations::DeleteBehavior>,
281}
282
283// ---------------------------------------------------------------------------
284// IndexMeta / ForeignKeyMeta / CheckMeta / TableOptions
285// ---------------------------------------------------------------------------
286
287/// One key part of an [`IndexMeta`].
288///
289/// Plain columns are emitted quoted; expressions are emitted verbatim (and are
290/// therefore dialect-sensitive — use portable SQL such as
291/// `coalesce(channel_id, '')`).
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum IndexColumn {
294    /// A (already resolved) database column name.
295    Column(Cow<'static, str>),
296    /// A raw SQL expression, e.g. `coalesce(channel_id, '')`.
297    Expression(Cow<'static, str>),
298}
299
300impl IndexColumn {
301    /// Returns the underlying column/expression text.
302    pub fn as_str(&self) -> &str {
303        match self {
304            IndexColumn::Column(s) | IndexColumn::Expression(s) => s.as_ref(),
305        }
306    }
307
308    /// Whether this key part is a raw expression.
309    pub fn is_expression(&self) -> bool {
310        matches!(self, IndexColumn::Expression(_))
311    }
312}
313
314/// A declarative (possibly composite, partial, or expression) index.
315///
316/// Produced by [`crate::model_builder::EntityTypeBuilder::has_index`] and
317/// consumed by the migration engine. A `filter` turns the index into a partial
318/// index on PostgreSQL/SQLite; on MySQL a filtered *unique* index is emulated
319/// with a functional (`CASE WHEN`) unique index because MySQL has no partial
320/// indexes. Filtered non-unique indexes are skipped on MySQL.
321#[derive(Debug, Clone, Default)]
322pub struct IndexMeta {
323    /// Explicit index name. `None` → derived from table + key parts.
324    pub name: Option<Cow<'static, str>>,
325    /// Index key parts, in order.
326    pub columns: Vec<IndexColumn>,
327    /// Whether this is a UNIQUE index.
328    pub is_unique: bool,
329    /// Optional partial-index predicate (`WHERE <expr>`).
330    pub filter: Option<BoolExpr>,
331    /// Included (non-key) columns (`INCLUDE (...)`); PostgreSQL only.
332    pub include: Vec<Cow<'static, str>>,
333}
334
335impl IndexMeta {
336    /// Derives the default index name for `table` from its key parts.
337    ///
338    /// Format: `ix_<table>_<part1>_<part2>...`, sanitized to identifier-safe
339    /// characters and truncated to 64 characters (MySQL's identifier limit).
340    pub fn derive_name(&self, table: &str) -> String {
341        let mut name = format!("ix_{table}");
342        for col in &self.columns {
343            if col.is_expression() {
344                // Expressions are not identifier-safe; append a short stable
345                // hash so two expression indexes on the same table do not
346                // collide.
347                let mut hash: u64 = 1469598103934665603;
348                for b in col.as_str().as_bytes() {
349                    hash ^= *b as u64;
350                    hash = hash.wrapping_mul(1099511628211);
351                }
352                name.push_str(&format!("_expr{hash:08x}"));
353            } else {
354                name.push('_');
355                name.push_str(col.as_str());
356            }
357        }
358        sanitize_index_name(&name)
359    }
360}
361
362/// Sanitizes a derived index name to `[A-Za-z0-9_]`, max 64 chars.
363pub(crate) fn sanitize_index_name(raw: &str) -> String {
364    let mut out: String = raw
365        .chars()
366        .map(|c| {
367            if c.is_ascii_alphanumeric() || c == '_' {
368                c
369            } else {
370                '_'
371            }
372        })
373        .collect();
374    if out.len() > 64 {
375        let mut truncated: String = out.chars().take(55).collect();
376        // Short, deterministic suffix to avoid collisions after truncation.
377        let mut hash: u64 = 1469598103934665603;
378        for b in raw.as_bytes() {
379            hash ^= *b as u64;
380            hash = hash.wrapping_mul(1099511628211);
381        }
382        truncated.push('_');
383        truncated.push_str(&format!("{hash:08x}"));
384        out = truncated;
385    }
386    out
387}
388
389/// A foreign key declared on an entity.
390///
391/// Single-column keys are usually inferred from `BelongsTo` navigations or the
392/// scalar `#[foreign_key(Target)]` attribute; composite keys are declared via
393/// [`crate::model_builder::EntityTypeBuilder::has_foreign_key`].
394#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct ForeignKeyMeta {
396    /// Explicit constraint name. `None` → derived from table + columns.
397    pub name: Option<Cow<'static, str>>,
398    /// Dependent columns (on this entity's table), in order.
399    pub columns: Vec<Cow<'static, str>>,
400    /// Principal table name.
401    pub referenced_table: Cow<'static, str>,
402    /// Principal columns, in order (same arity as `columns`).
403    pub referenced_columns: Vec<Cow<'static, str>>,
404    /// Optional `ON DELETE` SQL clause (e.g. `"CASCADE"`).
405    pub on_delete: Option<Cow<'static, str>>,
406}
407
408impl ForeignKeyMeta {
409    /// Derives the default constraint name for `table`.
410    pub fn derive_name(&self, table: &str) -> String {
411        let cols: Vec<&str> = self.columns.iter().map(|c| c.as_ref()).collect();
412        sanitize_index_name(&format!(
413            "fk_{table}_{}_{}",
414            cols.join("_"),
415            self.referenced_table
416        ))
417    }
418}
419
420/// A table-level `CHECK` constraint.
421#[derive(Debug, Clone, PartialEq, Eq)]
422pub struct CheckMeta {
423    /// Constraint name (required by PostgreSQL/MySQL for later dropping).
424    pub name: Cow<'static, str>,
425    /// SQL boolean expression, e.g. `amount > 0`.
426    pub expression: Cow<'static, str>,
427}
428
429/// MySQL table-level storage options (`ENGINE`, `DEFAULT CHARSET`, `COLLATE`).
430///
431/// Ignored by PostgreSQL and SQLite.
432#[derive(Debug, Clone, Default, PartialEq, Eq)]
433pub struct TableOptions {
434    /// Storage engine, e.g. `InnoDB`.
435    pub engine: Option<Cow<'static, str>>,
436    /// Default character set, e.g. `utf8mb4`.
437    pub charset: Option<Cow<'static, str>>,
438    /// Default collation, e.g. `utf8mb4_bin`.
439    pub collate: Option<Cow<'static, str>>,
440}
441
442impl TableOptions {
443    /// Returns `true` when no option is configured.
444    pub fn is_empty(&self) -> bool {
445        self.engine.is_none() && self.charset.is_none() && self.collate.is_none()
446    }
447}
448
449// ---------------------------------------------------------------------------
450// EntityTypeMeta ?describes an entity type as a whole
451// ---------------------------------------------------------------------------
452
453/// Metadata describing an entity type.
454/// Corresponds to EFCore's `IEntityType`.
455#[derive(Debug, Clone)]
456pub struct EntityTypeMeta {
457    /// The Rust type identifier.
458    pub type_id: TypeId,
459    /// The Rust struct name.
460    pub type_name: Cow<'static, str>,
461    /// The database table name.
462    pub table_name: Cow<'static, str>,
463    /// Properties (columns) of this entity.
464    pub properties: Vec<PropertyMeta>,
465    /// Navigation properties (relationships) of this entity.
466    pub navigations: Vec<NavigationMeta>,
467    /// The name of the primary key property (simplified; multi-key via `Vec<String>` in practice).
468    pub primary_keys: Vec<Cow<'static, str>>,
469    /// Declarative indexes: composite, unique, partial (`filter`), or expression.
470    pub indexes: Vec<IndexMeta>,
471    /// Declared foreign keys (composite keys and scalar `#[foreign_key]` targets).
472    pub foreign_keys: Vec<ForeignKeyMeta>,
473    /// Table-level CHECK constraints.
474    pub checks: Vec<CheckMeta>,
475    /// MySQL table options.
476    pub table_options: TableOptions,
477    /// Maps to a database view (`ToView`): excluded from table DDL.
478    pub is_view: bool,
479    /// Keyless entity (`HasNoKey`): no primary key; read-only projections/views.
480    pub is_keyless: bool,
481    /// Table/entity comment (`HasComment`).
482    pub comment: Option<Cow<'static, str>>,
483    /// Lazily-built `field_name -> properties index` map for O(1) lookup.
484    /// Built on first `find_property` call; empty after clone (rebuilt on demand).
485    /// `#[doc(hidden)]` — internal cache, do not set manually.
486    #[doc(hidden)]
487    pub property_index: std::sync::OnceLock<HashMap<String, usize>>,
488    /// Lazily-built `field_name -> navigations index` map for O(1) lookup.
489    /// Built on first `find_navigation` call; empty after clone (rebuilt on demand).
490    /// `#[doc(hidden)]` — internal cache, do not set manually.
491    #[doc(hidden)]
492    pub navigation_index: std::sync::OnceLock<HashMap<String, usize>>,
493}
494
495impl EntityTypeMeta {
496    /// Find a property by field name.
497    ///
498    /// Uses a lazily-built HashMap index for O(1) lookup instead of linear
499    /// scan. The index is cached for the lifetime of this `EntityTypeMeta`;
500    /// a cloned meta rebuilds its own index on first access.
501    pub fn find_property(&self, field_name: &str) -> Option<&PropertyMeta> {
502        let idx = self
503            .property_index
504            .get_or_init(|| {
505                self.properties
506                    .iter()
507                    .enumerate()
508                    .map(|(i, p)| (p.field_name.to_string(), i))
509                    .collect()
510            })
511            .get(field_name)
512            .copied();
513        idx.and_then(|i| self.properties.get(i))
514    }
515
516    /// Find a navigation by field name.
517    ///
518    /// Uses a lazily-built HashMap index for O(1) lookup instead of linear
519    /// scan. The index is cached for the lifetime of this `EntityTypeMeta`;
520    /// a cloned meta rebuilds its own index on first access.
521    pub fn find_navigation(&self, field_name: &str) -> Option<&NavigationMeta> {
522        let idx = self
523            .navigation_index
524            .get_or_init(|| {
525                self.navigations
526                    .iter()
527                    .enumerate()
528                    .map(|(i, n)| (n.field_name.to_string(), i))
529                    .collect()
530            })
531            .get(field_name)
532            .copied();
533        idx.and_then(|i| self.navigations.get(i))
534    }
535
536    /// Get the primary key property.
537    pub fn primary_key_property(&self) -> Option<&PropertyMeta> {
538        self.properties.iter().find(|p| p.is_primary_key)
539    }
540
541    /// Returns properties that are not navigation and not mapped.
542    pub fn mapped_scalar_properties(&self) -> impl Iterator<Item = &PropertyMeta> {
543        self.properties.iter().filter(|p| !p.is_not_mapped)
544    }
545
546    /// Returns mapped properties that can be written by INSERT/UPDATE.
547    ///
548    /// Computed/generated columns are readable but must never appear in
549    /// INSERT/UPDATE (the database computes them).
550    pub fn writable_scalar_properties(&self) -> impl Iterator<Item = &PropertyMeta> {
551        self.properties
552            .iter()
553            .filter(|p| !p.is_not_mapped && !p.is_computed())
554    }
555}
556
557impl Default for EntityTypeMeta {
558    /// Produces an empty `EntityTypeMeta` with lazily-built index caches.
559    /// `type_id` defaults to `TypeId::of::<()>()` since `TypeId` itself has no
560    /// `Default` impl; callers always override it in struct literals via
561    /// `..EntityTypeMeta::default()`.
562    fn default() -> Self {
563        Self {
564            type_id: TypeId::of::<()>(),
565            type_name: Cow::Borrowed(""),
566            table_name: Cow::Borrowed(""),
567            properties: Vec::new(),
568            navigations: Vec::new(),
569            primary_keys: Vec::new(),
570            indexes: Vec::new(),
571            foreign_keys: Vec::new(),
572            checks: Vec::new(),
573            table_options: TableOptions::default(),
574            is_view: false,
575            is_keyless: false,
576            comment: None,
577            property_index: std::sync::OnceLock::new(),
578            navigation_index: std::sync::OnceLock::new(),
579        }
580    }
581}