Skip to main content

rust_ef/
model_builder.rs

1//! Model builder  - ?Fluent API for configuring entity types.
2//!
3//! The `ModelBuilder` is the central configuration hub. It collects
4//! `EntityTypeMeta` from all registered entity types and entity
5//! configurations, and produces the final metadata model used by
6//! the migration engine and query builder.
7
8use crate::entity::{IEntitySnapshot, IEntityType};
9use crate::entity_snapshot::EntitySnapshot;
10use crate::metadata::{
11    CheckMeta, EntityTypeMeta, ForeignKeyMeta, IndexColumn, IndexMeta, TableOptions,
12};
13use crate::query::{BoolExpr, CompiledFilter, Predicate};
14use crate::relations::DeleteBehavior;
15use std::any::TypeId;
16use std::borrow::Cow;
17use std::collections::HashMap;
18use std::marker::PhantomData;
19use std::sync::Arc;
20use std::sync::OnceLock;
21
22// ---------------------------------------------------------------------------
23// EntityConfig  - ?stored configuration overrides
24// ---------------------------------------------------------------------------
25
26#[derive(Debug, Clone, Default)]
27pub(crate) struct EntityConfig {
28    pub(crate) table_name: Option<String>,
29    /// Schema/database prefix (`ToTable(name, schema)`).
30    pub(crate) table_schema: Option<String>,
31    /// Maps to an existing view (`ToView`) — excluded from table DDL.
32    pub(crate) is_view: bool,
33    /// Keyless entity (`HasNoKey`).
34    pub(crate) is_keyless: bool,
35    /// Table comment (`HasComment`).
36    pub(crate) table_comment: Option<String>,
37    /// Fields ignored via `Ignore()`.
38    pub(crate) ignored_fields: Vec<String>,
39    pub(crate) primary_key_fields: Option<Vec<String>>,
40    pub(crate) property_overrides: HashMap<String, PropertyConfigOverride>,
41    pub(crate) query_filter: Option<BoolExpr>,
42    pub(crate) seed_rows: Vec<EntitySnapshot>,
43    /// Declarative indexes (composite / partial / expression).
44    pub(crate) indexes: Vec<IndexMeta>,
45    /// Alternate keys (unique constraints), resolved to table-qualified index
46    /// names at model-build time.
47    pub(crate) alternate_keys: Vec<Vec<String>>,
48    /// Declared foreign keys (composite; single-column scalar FKs come from the
49    /// derive macro).
50    pub(crate) foreign_keys: Vec<ForeignKeyMeta>,
51    /// Table-level CHECK constraints declared via `has_check`.
52    pub(crate) checks: Vec<CheckMeta>,
53    /// MySQL table options.
54    pub(crate) table_options: TableOptions,
55}
56
57#[derive(Debug, Clone, Default)]
58pub(crate) struct PropertyConfigOverride {
59    pub(crate) column_name: Option<String>,
60    pub(crate) is_required: Option<bool>,
61    pub(crate) max_length: Option<usize>,
62    pub(crate) is_unique: Option<bool>,
63    pub(crate) has_index: Option<bool>,
64    /// Raw SQL DEFAULT expression.
65    pub(crate) default_value: Option<String>,
66    /// Raw SQL CHECK expression (constraint name derived from table+column).
67    pub(crate) check: Option<String>,
68    /// Explicit database column type (`HasColumnType`).
69    pub(crate) column_type: Option<String>,
70    /// Numeric/decimal/temporal precision and scale.
71    pub(crate) precision: Option<u32>,
72    pub(crate) scale: Option<u32>,
73    /// Fixed-length string (`CHAR(n)`).
74    pub(crate) is_fixed_length: Option<bool>,
75    /// Column-level collation.
76    pub(crate) collation: Option<String>,
77    /// Column comment.
78    pub(crate) comment: Option<String>,
79    /// Generated/computed column expression + storage mode.
80    pub(crate) computed_sql: Option<String>,
81    pub(crate) computed_stored: bool,
82    /// Optimistic concurrency token.
83    pub(crate) is_concurrency_token: Option<bool>,
84}
85
86// ---------------------------------------------------------------------------
87// ModelBuilder
88// ---------------------------------------------------------------------------
89
90/// Central configuration point for the EF model.
91pub struct ModelBuilder {
92    entity_metas: Vec<EntityTypeMeta>,
93    configs: HashMap<TypeId, EntityConfig>,
94    /// Cache of `build()` output. Invalidated by any mutation.
95    build_cache: OnceLock<Vec<EntityTypeMeta>>,
96    /// Cache of `filters_by_table()` output, shared via `Arc` so DbSets can
97    /// hold a cheap clone without re-traversing configs on every query.
98    filter_cache: OnceLock<Arc<HashMap<String, CompiledFilter>>>,
99}
100
101impl ModelBuilder {
102    pub fn new() -> Self {
103        Self {
104            entity_metas: Vec::new(),
105            configs: HashMap::new(),
106            build_cache: OnceLock::new(),
107            filter_cache: OnceLock::new(),
108        }
109    }
110
111    /// Constructs a `ModelBuilder` pre-populated from a cached `BuiltMetadata`.
112    ///
113    /// Used by `DbContext::from_options()` to skip re-iterating `inventory::iter`
114    /// and re-running `IEntityTypeConfiguration::configure()` on every request.
115    /// The `build_cache` and `filter_cache` are left empty (lazy) — they will
116    /// be populated on first access, same as the non-cached path.
117    ///
118    /// Per-instance mutations (`has_query_filter`, `entity::<T>()`, etc.) after
119    /// construction only affect this `ModelBuilder` instance, not the cache.
120    pub(crate) fn from_built(built: &crate::metadata_cache::BuiltMetadata) -> Self {
121        Self {
122            entity_metas: built.model_metas.clone(),
123            configs: built.configs.clone(),
124            build_cache: OnceLock::new(),
125            filter_cache: OnceLock::new(),
126        }
127    }
128
129    /// Read-only access to the `configs` map. Used by `MetadataCache::build()`
130    /// to snapshot the configs produced by `IEntityTypeConfiguration::configure()`
131    /// callbacks into the process-level cache.
132    pub(crate) fn configs(&self) -> &HashMap<TypeId, EntityConfig> {
133        &self.configs
134    }
135
136    /// Read-only access to the `entity_metas` vec. Used by `MetadataCache::build()`
137    /// to snapshot the registered entity metas into the process-level cache.
138    pub(crate) fn entity_metas_vec(&self) -> &[EntityTypeMeta] {
139        &self.entity_metas
140    }
141
142    /// Drops the cached `build()` and `filters_by_table()` results.
143    ///
144    /// Called by every mutating method so that subsequent reads observe the
145    /// new configuration. Must be `&mut self` to enforce exclusive access.
146    fn invalidate_cache(&mut self) {
147        self.build_cache.take();
148        self.filter_cache.take();
149    }
150
151    /// Test-only hook: returns `true` if `build()` has populated its cache.
152    #[doc(hidden)]
153    pub fn build_cache_populated(&self) -> bool {
154        self.build_cache.get().is_some()
155    }
156
157    /// Test-only hook: returns `true` if `filters_by_table()` has populated
158    /// its cache.
159    #[doc(hidden)]
160    pub fn filter_cache_populated(&self) -> bool {
161        self.filter_cache.get().is_some()
162    }
163
164    pub fn entity<T: IEntityType>(&mut self) -> EntityTypeBuilder<'_, T> {
165        let meta = T::entity_meta();
166        let type_id = meta.type_id;
167        if !self.entity_metas.iter().any(|m| m.type_id == type_id) {
168            self.entity_metas.push(meta);
169            self.invalidate_cache();
170        }
171        self.configs.entry(type_id).or_default();
172        EntityTypeBuilder::new(self, type_id)
173    }
174
175    pub fn apply_configuration<C, T>(&mut self) -> &mut Self
176    where
177        C: IEntityTypeConfiguration<T> + Default + Send + Sync + 'static,
178        T: IEntityType,
179    {
180        let meta = T::entity_meta();
181        let type_id = meta.type_id;
182        if !self.entity_metas.iter().any(|m| m.type_id == type_id) {
183            self.entity_metas.push(meta);
184            self.invalidate_cache();
185        }
186
187        let config = C::default();
188        let mut builder = EntityTypeBuilder::new(self, type_id);
189        config.configure(&mut builder);
190
191        self
192    }
193
194    pub fn build(&self) -> Vec<EntityTypeMeta> {
195        self.build_cache
196            .get_or_init(|| {
197                self.entity_metas
198                    .iter()
199                    .map(|meta| self.apply_config_to_meta(meta))
200                    .collect()
201            })
202            .clone()
203    }
204
205    fn apply_config_to_meta(&self, meta: &EntityTypeMeta) -> EntityTypeMeta {
206        let config = match self.configs.get(&meta.type_id) {
207            Some(c) => c,
208            None => return meta.clone(),
209        };
210
211        let mut result = meta.clone();
212        if let Some(ref table) = config.table_name {
213            result.table_name = std::borrow::Cow::Owned(table.clone());
214        }
215        // Schema qualification: `schema.table`. Dotted identifiers are quoted
216        // per part by the dialects/providers.
217        if let Some(ref schema) = config.table_schema {
218            let base = result.table_name.to_string();
219            // A derive-level `#[table(..., schema = "...")]` already
220            // qualified the table.
221            if !base.contains('.') {
222                result.table_name = std::borrow::Cow::Owned(format!("{schema}.{base}"));
223            }
224        }
225        if config.is_view {
226            result.is_view = true;
227        }
228        if config.is_keyless {
229            result.is_keyless = true;
230        }
231        if let Some(ref comment) = config.table_comment {
232            result.comment = Some(std::borrow::Cow::Owned(comment.clone()));
233        }
234
235        if let Some(ref pk_fields) = config.primary_key_fields {
236            for prop in &mut result.properties {
237                prop.is_primary_key = pk_fields.iter().any(|f| f == prop.field_name.as_ref());
238            }
239            result.primary_keys = pk_fields
240                .iter()
241                .map(|f| std::borrow::Cow::Owned(f.clone()))
242                .collect();
243        }
244
245        for prop in &mut result.properties {
246            if config
247                .ignored_fields
248                .iter()
249                .any(|f| f == prop.field_name.as_ref())
250            {
251                prop.is_not_mapped = true;
252            }
253            if let Some(override_cfg) = config.property_overrides.get(prop.field_name.as_ref()) {
254                if let Some(ref col) = override_cfg.column_name {
255                    prop.column_name = std::borrow::Cow::Owned(col.clone());
256                }
257                if let Some(required) = override_cfg.is_required {
258                    prop.is_required = required;
259                }
260                if let Some(max_len) = override_cfg.max_length {
261                    prop.max_length = Some(max_len);
262                }
263                if let Some(unique) = override_cfg.is_unique {
264                    prop.is_unique = unique;
265                }
266                if let Some(index) = override_cfg.has_index {
267                    prop.has_index = index;
268                }
269                if let Some(ref column_type) = override_cfg.column_type {
270                    prop.column_type = Some(std::borrow::Cow::Owned(column_type.clone()));
271                }
272                if let Some(precision) = override_cfg.precision {
273                    prop.precision = Some(precision);
274                }
275                if let Some(scale) = override_cfg.scale {
276                    prop.scale = Some(scale);
277                }
278                if let Some(fixed) = override_cfg.is_fixed_length {
279                    prop.is_fixed_length = fixed;
280                }
281                if let Some(ref collation) = override_cfg.collation {
282                    prop.collation = Some(std::borrow::Cow::Owned(collation.clone()));
283                }
284                if let Some(ref comment) = override_cfg.comment {
285                    prop.comment = Some(std::borrow::Cow::Owned(comment.clone()));
286                }
287                if let Some(ref computed_sql) = override_cfg.computed_sql {
288                    prop.computed_sql = Some(std::borrow::Cow::Owned(computed_sql.clone()));
289                    prop.computed_stored = override_cfg.computed_stored;
290                }
291                if let Some(ref default_sql) = override_cfg.default_value {
292                    prop.default_value = Some(std::borrow::Cow::Owned(default_sql.clone()));
293                }
294                if let Some(token) = override_cfg.is_concurrency_token {
295                    prop.is_concurrency_token = token;
296                }
297            }
298            // Generated columns are computed by the database: a DEFAULT and
299            // NOT NULL constraint are not applicable.
300            if prop.computed_sql.is_some() {
301                prop.default_value = None;
302                prop.is_required = false;
303            }
304        }
305
306        // Property-level CHECK constraints, named after table + column.
307        for prop in &result.properties {
308            if let Some(override_cfg) = config.property_overrides.get(prop.field_name.as_ref()) {
309                if let Some(ref check_sql) = override_cfg.check {
310                    result.checks.push(CheckMeta {
311                        name: Cow::Owned(format!(
312                            "CK_{}_{}",
313                            result.table_name.replace('.', "_"),
314                            prop.column_name
315                        )),
316                        expression: Cow::Owned(check_sql.clone()),
317                    });
318                }
319            }
320        }
321
322        // Alternate keys become table-qualified unique indexes.
323        for cols in &config.alternate_keys {
324            let name = crate::metadata::sanitize_index_name(&format!(
325                "ak_{}_{}",
326                result.table_name,
327                cols.join("_")
328            ));
329            result.indexes.push(IndexMeta {
330                name: Some(Cow::Owned(name)),
331                columns: cols
332                    .iter()
333                    .map(|c| IndexColumn::Column(Cow::Owned(c.clone())))
334                    .collect(),
335                is_unique: true,
336                filter: None,
337                include: Vec::new(),
338            });
339        }
340
341        // Declarative indexes / foreign keys / table-level checks / options.
342        result.indexes.extend(config.indexes.iter().cloned());
343        result
344            .foreign_keys
345            .extend(config.foreign_keys.iter().cloned());
346        result.checks.extend(config.checks.iter().cloned());
347        if !config.table_options.is_empty() {
348            result.table_options = config.table_options.clone();
349        }
350
351        result
352    }
353
354    /// Escape hatch for direct mutation of `entity_metas`.
355    ///
356    /// **Cache caveat**: this returns a `&mut Vec`, so mutations performed
357    /// through it cannot auto-invalidate `build()` / `filters_by_table()`.
358    /// Callers that mutate via this handle must drop the borrow and perform
359    /// any subsequent Fluent API mutation (or never read the caches again
360    /// in this context). Prefer `register_entity_meta()` / `entity::<T>()`
361    /// for cache-safe registration.
362    pub fn entity_metas_mut(&mut self) -> &mut Vec<EntityTypeMeta> {
363        // Note: cannot call invalidate_cache() here because the returned
364        // &mut Vec would conflict. Documented as a manual-invalidation path.
365        &mut self.entity_metas
366    }
367
368    pub fn find_entity<T: IEntityType>(&self) -> Option<&EntityTypeMeta> {
369        let type_id = TypeId::of::<T>();
370        self.entity_metas.iter().find(|m| m.type_id == type_id)
371    }
372
373    /// Returns `true` if an entity with the given `type_id` is already
374    /// registered in STORE B.
375    pub fn has_entity(&self, type_id: TypeId) -> bool {
376        self.entity_metas.iter().any(|m| m.type_id == type_id)
377    }
378
379    /// Registers an entity meta directly, without going through
380    /// `entity::<T>()`. Used by `DbContext::discover_entities()` to populate
381    /// STORE B from `inventory::iter::<EntityRegistration>()`.
382    ///
383    /// Ensures a corresponding `EntityConfig` entry exists so that subsequent
384    /// Fluent API calls (`to_table`, `property_named`, etc.) have somewhere
385    /// to write their overrides.
386    pub fn register_entity_meta(&mut self, meta: EntityTypeMeta) {
387        let type_id = meta.type_id;
388        if !self.entity_metas.iter().any(|m| m.type_id == type_id) {
389            self.entity_metas.push(meta);
390            self.invalidate_cache();
391        }
392        self.configs.entry(type_id).or_default();
393    }
394
395    /// Registers a global query filter for entity type `T`.
396    ///
397    /// Accepts what `linq!(|b: T| ...)` returns (`Predicate<T>`, which also
398    /// infers `T`) as well as a bare `BoolExpr`.
399    pub fn has_query_filter<T: IEntityType>(
400        &mut self,
401        filter: impl Into<Predicate<T>>,
402    ) -> &mut Self {
403        let filter = filter.into().into_expr();
404        let type_id = TypeId::of::<T>();
405        let config = self.configs.entry(type_id).or_default();
406        config.query_filter = Some(filter);
407
408        let meta = T::entity_meta();
409        if !self.entity_metas.iter().any(|m| m.type_id == type_id) {
410            self.entity_metas.push(meta);
411        }
412
413        self.invalidate_cache();
414        self
415    }
416
417    pub fn get_query_filter(&self, type_id: &TypeId) -> Option<&BoolExpr> {
418        self.configs
419            .get(type_id)
420            .and_then(|c| c.query_filter.as_ref())
421    }
422
423    /// Collects all registered query filters keyed by compile-time table name
424    /// (i.e. `EntityTypeMeta.table_name` from `#[table("...")]`).
425    ///
426    /// This must match the `related_table` stored in navigation metadata, which
427    /// is also the compile-time name — NOT the Fluent API override. If a Fluent
428    /// `to_table()` override renames the table, the navigation SQL and the
429    /// filter lookup both use the compile-time name, staying consistent.
430    ///
431    /// Each filter is wrapped in a `CompiledFilter` that pre-collects parameter
432    /// values at registration time, avoiding redundant `collect_bool_expr_values`
433    /// traversals on every query. The SQL fragment is still compiled per query
434    /// (dialect-specific placeholders depend on the provider and query context).
435    pub fn filters_by_table(&self) -> Arc<HashMap<String, CompiledFilter>> {
436        self.filter_cache
437            .get_or_init(|| {
438                let mut map = HashMap::new();
439                // Keyed by the compile-time table name (same identity used by
440                // navigation metadata / lazy loading). Fluent `to_table` /
441                // `to_schema` overrides do not change this key — see the
442                // doc comment above.
443                for meta in &self.entity_metas {
444                    if let Some(config) = self.configs.get(&meta.type_id) {
445                        if let Some(filter) = &config.query_filter {
446                            map.insert(
447                                meta.table_name.to_string(),
448                                CompiledFilter::new(filter.clone()),
449                            );
450                        }
451                    }
452                }
453                Arc::new(map)
454            })
455            .clone()
456    }
457
458    /// Returns seed rows configured via `EntityTypeBuilder::has_data`.
459    pub fn seed_rows_for(&self, type_id: &TypeId) -> &[EntitySnapshot] {
460        self.configs
461            .get(type_id)
462            .map(|c| c.seed_rows.as_slice())
463            .unwrap_or(&[])
464    }
465}
466
467impl Default for ModelBuilder {
468    fn default() -> Self {
469        Self::new()
470    }
471}
472
473// ---------------------------------------------------------------------------
474// EntityTypeBuilder<T>
475// ---------------------------------------------------------------------------
476
477pub struct EntityTypeBuilder<'a, T> {
478    model: &'a mut ModelBuilder,
479    type_id: TypeId,
480    _phantom: PhantomData<T>,
481}
482
483impl<'a, T> EntityTypeBuilder<'a, T> {
484    pub fn new(model: &'a mut ModelBuilder, type_id: TypeId) -> Self {
485        Self {
486            model,
487            type_id,
488            _phantom: PhantomData,
489        }
490    }
491
492    pub fn to_table(&mut self, name: &str) -> &mut Self {
493        let config = self.model.configs.entry(self.type_id).or_default();
494        config.table_name = Some(name.to_string());
495        config.is_view = false;
496        self.model.invalidate_cache();
497        self
498    }
499
500    pub fn property_named<'b>(&'b mut self, field_name: &'static str) -> PropertyBuilder<'b, T> {
501        PropertyBuilder {
502            model: self.model,
503            type_id: self.type_id,
504            field_name,
505            _entity: PhantomData,
506            _value: PhantomData,
507        }
508    }
509
510    pub fn has_key_named(&mut self, field_name: &str) -> &mut Self {
511        let config = self.model.configs.entry(self.type_id).or_default();
512        config.primary_key_fields = Some(vec![field_name.to_string()]);
513        config.is_keyless = false;
514        self.model.invalidate_cache();
515        self
516    }
517
518    pub fn has_keys(&mut self, field_names: &[&str]) -> &mut Self {
519        let config = self.model.configs.entry(self.type_id).or_default();
520        config.primary_key_fields = Some(field_names.iter().map(|s| s.to_string()).collect());
521        config.is_keyless = false;
522        self.model.invalidate_cache();
523        self
524    }
525
526    /// Qualifies the table with a schema/database prefix (`ToTable(name, schema)`).
527    ///
528    /// Generates `schema.table` (quoted per part), e.g. PostgreSQL `"audit"."logs"`.
529    pub fn to_schema(&mut self, schema: &str) -> &mut Self {
530        let config = self.model.configs.entry(self.type_id).or_default();
531        config.table_schema = Some(schema.to_string());
532        self.model.invalidate_cache();
533        self
534    }
535
536    /// Maps this entity to an existing database view (`ToView`).
537    ///
538    /// View entities are **excluded from table DDL** (no CREATE/DROP/ALTER);
539    /// the view itself is managed outside rust-ef. The entity is read-only in
540    /// practice (`save_changes` should not be used for it).
541    pub fn to_view(&mut self, name: &str) -> &mut Self {
542        let config = self.model.configs.entry(self.type_id).or_default();
543        config.table_name = Some(name.to_string());
544        config.is_view = true;
545        self.model.invalidate_cache();
546        self
547    }
548
549    /// Marks the entity as keyless (`HasNoKey`): no primary key is emitted.
550    ///
551    /// Useful for projections/views. `find`/`remove` require a key and must not
552    /// be used on keyless entities.
553    pub fn has_no_key(&mut self) -> &mut Self {
554        let config = self.model.configs.entry(self.type_id).or_default();
555        config.primary_key_fields = Some(Vec::new());
556        config.is_keyless = true;
557        self.model.invalidate_cache();
558        self
559    }
560
561    /// Sets the table/entity comment (`HasComment`).
562    pub fn has_comment(&mut self, comment: &str) -> &mut Self {
563        let config = self.model.configs.entry(self.type_id).or_default();
564        config.table_comment = Some(comment.to_string());
565        self.model.invalidate_cache();
566        self
567    }
568
569    /// Ignores a scalar field (`Ignore()`), excluding it from mapping/DDL.
570    pub fn ignore(&mut self, field_name: &str) -> &mut Self {
571        let config = self.model.configs.entry(self.type_id).or_default();
572        if !config.ignored_fields.iter().any(|f| f == field_name) {
573            config.ignored_fields.push(field_name.to_string());
574        }
575        self.model.invalidate_cache();
576        self
577    }
578
579    /// Declares an alternate key (`HasAlternateKey`): a unique constraint that
580    /// can be referenced by foreign keys.
581    ///
582    /// Implemented as a unique index named `ak_<table>_<cols>` (equivalent for
583    /// PostgreSQL/SQLite/MySQL foreign-key targets).
584    pub fn has_alternate_key(&mut self, columns: &'static [&'static str]) -> &mut Self {
585        let config = self.model.configs.entry(self.type_id).or_default();
586        config
587            .alternate_keys
588            .push(columns.iter().map(|c| c.to_string()).collect());
589        self.model.invalidate_cache();
590        self
591    }
592
593    /// Configures the primary key from column constants.
594    ///
595    /// `#[doc(hidden)]` — called by `linq!(key |b: T| b.id)` expansion.
596    #[doc(hidden)]
597    pub fn has_key(&mut self, columns: &'static [&'static str]) -> &mut Self {
598        let config = self.model.configs.entry(self.type_id).or_default();
599        config.primary_key_fields = Some(columns.iter().map(|s| s.to_string()).collect());
600        config.is_keyless = false;
601        self.model.invalidate_cache();
602        self
603    }
604
605    /// Declares a (possibly composite) index from column constants.
606    ///
607    /// Use `linq!(index |b: T| (b.author_id, b.created_at))` to build the
608    /// column array type-safely. The returned [`IndexBuilder`] configures
609    /// uniqueness, an explicit name, or a partial-index predicate:
610    ///
611    /// ```ignore
612    /// entity.has_index(&[Order::COLUMN_TENANT_ID, Order::COLUMN_REQUEST_KEY])
613    ///       .is_unique();
614    /// entity.has_index(&[Invite::COLUMN_TENANT_ID, Invite::COLUMN_CODE])
615    ///       .is_unique()
616    ///       .has_filter(linq!(|i: Invite| i.status == "PENDING"));
617    /// ```
618    pub fn has_index(&mut self, columns: &'static [&'static str]) -> IndexBuilder<'_, T> {
619        let parts = columns
620            .iter()
621            .map(|c| IndexColumn::Column(Cow::Borrowed(*c)))
622            .collect();
623        self.push_index(parts)
624    }
625
626    /// Declares an index over raw SQL expressions, e.g.
627    /// `has_index_expression(&["coalesce(channel_id, '')"])`.
628    ///
629    /// Expressions are emitted verbatim, so write portable SQL (lower-case
630    /// identifiers work unquoted on PostgreSQL, SQLite and MySQL).
631    pub fn has_index_expression(
632        &mut self,
633        expressions: &'static [&'static str],
634    ) -> IndexBuilder<'_, T> {
635        let parts = expressions
636            .iter()
637            .map(|e| IndexColumn::Expression(Cow::Borrowed(*e)))
638            .collect();
639        self.push_index(parts)
640    }
641
642    fn push_index(&mut self, columns: Vec<IndexColumn>) -> IndexBuilder<'_, T> {
643        let config = self.model.configs.entry(self.type_id).or_default();
644        let position = config.indexes.len();
645        config.indexes.push(IndexMeta {
646            name: None,
647            columns,
648            is_unique: false,
649            filter: None,
650            include: Vec::new(),
651        });
652        self.model.invalidate_cache();
653        IndexBuilder {
654            model: self.model,
655            type_id: self.type_id,
656            position,
657            _entity: PhantomData,
658        }
659    }
660
661    /// Declares a (possibly composite) foreign key.
662    ///
663    /// Single-column keys are usually inferred from `BelongsTo` navigations or
664    /// `#[foreign_key(Target)]`; use this for composite keys such as
665    /// `(tenant_id, order_id) → order_record(tenant_id, id)`:
666    ///
667    /// ```ignore
668    /// entity.has_foreign_key(
669    ///     &[Voucher::COLUMN_TENANT_ID, Voucher::COLUMN_ORDER_ID],
670    ///     OrderRecord::TABLE,
671    ///     &[OrderRecord::COLUMN_TENANT_ID, OrderRecord::COLUMN_ID],
672    /// );
673    /// ```
674    pub fn has_foreign_key(
675        &mut self,
676        columns: &'static [&'static str],
677        referenced_table: &'static str,
678        referenced_columns: &'static [&'static str],
679    ) -> ForeignKeyBuilder<'_, T> {
680        let config = self.model.configs.entry(self.type_id).or_default();
681        let position = config.foreign_keys.len();
682        config.foreign_keys.push(ForeignKeyMeta {
683            name: None,
684            columns: columns.iter().map(|c| Cow::Borrowed(*c)).collect(),
685            referenced_table: Cow::Borrowed(referenced_table),
686            referenced_columns: referenced_columns
687                .iter()
688                .map(|c| Cow::Borrowed(*c))
689                .collect(),
690            on_delete: None,
691        });
692        self.model.invalidate_cache();
693        ForeignKeyBuilder {
694            model: self.model,
695            type_id: self.type_id,
696            position,
697            _entity: PhantomData,
698        }
699    }
700
701    /// Declares a table-level `CHECK` constraint.
702    pub fn has_check(&mut self, name: &str, expression: &str) -> &mut Self {
703        let config = self.model.configs.entry(self.type_id).or_default();
704        config.checks.push(CheckMeta {
705            name: Cow::Owned(name.to_string()),
706            expression: Cow::Owned(expression.to_string()),
707        });
708        self.model.invalidate_cache();
709        self
710    }
711
712    /// Sets the MySQL storage engine (e.g. `InnoDB`). Ignored on other dialects.
713    pub fn engine(&mut self, name: &str) -> &mut Self {
714        let config = self.model.configs.entry(self.type_id).or_default();
715        config.table_options.engine = Some(Cow::Owned(name.to_string()));
716        self.model.invalidate_cache();
717        self
718    }
719
720    /// Sets the MySQL default character set (e.g. `utf8mb4`).
721    pub fn charset(&mut self, name: &str) -> &mut Self {
722        let config = self.model.configs.entry(self.type_id).or_default();
723        config.table_options.charset = Some(Cow::Owned(name.to_string()));
724        self.model.invalidate_cache();
725        self
726    }
727
728    /// Sets the MySQL default collation (e.g. `utf8mb4_bin`).
729    pub fn collate(&mut self, name: &str) -> &mut Self {
730        let config = self.model.configs.entry(self.type_id).or_default();
731        config.table_options.collate = Some(Cow::Owned(name.to_string()));
732        self.model.invalidate_cache();
733        self
734    }
735
736    /// Seeds initial data (applied on `DbContext::ensure_created`).
737    pub fn has_data(&mut self, data: &[T]) -> &mut Self
738    where
739        T: IEntitySnapshot,
740    {
741        if let Some(config) = self.model.configs.get_mut(&self.type_id) {
742            config.seed_rows = data.iter().map(|e| e.snapshot()).collect();
743        }
744        self.model.invalidate_cache();
745        self
746    }
747}
748
749// ---------------------------------------------------------------------------
750// IndexBuilder / ForeignKeyBuilder
751// ---------------------------------------------------------------------------
752
753/// Configures an index declared by [`EntityTypeBuilder::has_index`] or
754/// [`EntityTypeBuilder::has_index_expression`].
755pub struct IndexBuilder<'a, T> {
756    model: &'a mut ModelBuilder,
757    type_id: TypeId,
758    position: usize,
759    _entity: PhantomData<T>,
760}
761
762impl<T> IndexBuilder<'_, T> {
763    fn entry(&mut self) -> &mut IndexMeta {
764        self.model.invalidate_cache();
765        &mut self
766            .model
767            .configs
768            .get_mut(&self.type_id)
769            .expect("index config registered")
770            .indexes[self.position]
771    }
772
773    /// Marks the index UNIQUE.
774    pub fn is_unique(&mut self) -> &mut Self {
775        self.entry().is_unique = true;
776        self
777    }
778
779    /// Sets an explicit index name (otherwise derived from table + columns).
780    pub fn has_name(&mut self, name: &'static str) -> &mut Self {
781        self.entry().name = Some(Cow::Borrowed(name));
782        self
783    }
784
785    /// Turns the index into a partial index with the given predicate.
786    ///
787    /// Applied as `... WHERE <predicate>` on PostgreSQL/SQLite. MySQL has no
788    /// partial indexes: a filtered **unique** index is emulated with a
789    /// functional `CASE WHEN` unique index, and a filtered non-unique index is
790    /// skipped with a warning comment.
791    pub fn has_filter(&mut self, filter: impl Into<BoolExpr>) -> &mut Self {
792        self.entry().filter = Some(filter.into());
793        self
794    }
795
796    /// Adds non-key included columns (`INCLUDE (...)`).
797    ///
798    /// Supported by PostgreSQL; MySQL/SQLite emit a warning comment and create
799    /// the index without the include list.
800    pub fn include(&mut self, columns: &'static [&'static str]) -> &mut Self {
801        let entry = self.entry();
802        entry
803            .include
804            .extend(columns.iter().map(|c| Cow::Borrowed(*c)));
805        self
806    }
807}
808
809/// Configures a foreign key declared by
810/// [`EntityTypeBuilder::has_foreign_key`].
811pub struct ForeignKeyBuilder<'a, T> {
812    model: &'a mut ModelBuilder,
813    type_id: TypeId,
814    position: usize,
815    _entity: PhantomData<T>,
816}
817
818impl<T> ForeignKeyBuilder<'_, T> {
819    fn entry(&mut self) -> &mut ForeignKeyMeta {
820        self.model.invalidate_cache();
821        &mut self
822            .model
823            .configs
824            .get_mut(&self.type_id)
825            .expect("foreign key config registered")
826            .foreign_keys[self.position]
827    }
828
829    /// Sets an explicit constraint name (otherwise derived from table+columns).
830    pub fn has_name(&mut self, name: &'static str) -> &mut Self {
831        self.entry().name = Some(Cow::Borrowed(name));
832        self
833    }
834
835    /// Sets the `ON DELETE` behavior.
836    pub fn on_delete(&mut self, behavior: DeleteBehavior) -> &mut Self {
837        self.entry().on_delete = Some(Cow::Borrowed(behavior.to_sql_clause()));
838        self
839    }
840}
841
842// ---------------------------------------------------------------------------
843// PropertyBuilder
844// ---------------------------------------------------------------------------
845
846pub struct PropertyBuilder<'a, T, V = ()> {
847    model: &'a mut ModelBuilder,
848    type_id: TypeId,
849    field_name: &'static str,
850    _entity: PhantomData<T>,
851    _value: PhantomData<V>,
852}
853
854impl<'a, T, V> PropertyBuilder<'a, T, V> {
855    fn override_entry(&mut self) -> &mut PropertyConfigOverride {
856        // Invalidate caches before mutating so a subsequent `build()` /
857        // `filters_by_table()` reflects the new override.
858        self.model.invalidate_cache();
859        self.model
860            .configs
861            .entry(self.type_id)
862            .or_default()
863            .property_overrides
864            .entry(self.field_name.to_string())
865            .or_default()
866    }
867
868    pub fn is_required(mut self) -> Self {
869        self.override_entry().is_required = Some(true);
870        self
871    }
872
873    /// Marks the column nullable (opposite of [`Self::is_required`]).
874    pub fn is_optional(mut self) -> Self {
875        self.override_entry().is_required = Some(false);
876        self
877    }
878
879    /// Overrides the database column type verbatim for every dialect
880    /// (`HasColumnType`), e.g. `"JSON"`, `"MEDIUMTEXT"`, `"DECIMAL(12,2)"`.
881    pub fn has_column_type(mut self, sql_type: &str) -> Self {
882        self.override_entry().column_type = Some(sql_type.to_string());
883        self
884    }
885
886    /// Sets decimal/numeric precision and scale (`HasPrecision(p, s)`).
887    ///
888    /// For temporal types the precision is emitted as the fractional-seconds
889    /// precision (`DATETIME(p)` / `TIMESTAMP(p)`).
890    pub fn has_precision(mut self, precision: u32, scale: u32) -> Self {
891        let entry = self.override_entry();
892        entry.precision = Some(precision);
893        entry.scale = Some(scale);
894        self
895    }
896
897    /// Uses a fixed-length string type (`CHAR(n)`), `n = max_length`.
898    pub fn is_fixed_length(mut self) -> Self {
899        self.override_entry().is_fixed_length = Some(true);
900        self
901    }
902
903    /// Sets a column-level collation (`HasCollation`), emitted as
904    /// `COLLATE <name>` (include quotes yourself for quoted names).
905    pub fn has_collation(mut self, name: &str) -> Self {
906        self.override_entry().collation = Some(name.to_string());
907        self
908    }
909
910    /// Sets a column comment (`HasComment`).
911    pub fn has_comment(mut self, text: &str) -> Self {
912        self.override_entry().comment = Some(text.to_string());
913        self
914    }
915
916    /// Declares a generated/computed column (`HasComputedColumnSql`).
917    ///
918    /// `sql` is a raw SQL expression (e.g. `unit_price * quantity`). Computed
919    /// columns are excluded from INSERT/UPDATE. Use [`Self::is_stored`] to
920    /// persist the value (required by PostgreSQL).
921    pub fn has_computed_column_sql(mut self, sql: &str) -> Self {
922        let entry = self.override_entry();
923        entry.computed_sql = Some(sql.to_string());
924        self
925    }
926
927    /// Persists a computed column (`STORED`); no-op for non-computed columns.
928    pub fn is_stored(mut self) -> Self {
929        self.override_entry().computed_stored = true;
930        self
931    }
932
933    /// Marks the column as an optimistic concurrency token.
934    pub fn is_concurrency_token(mut self) -> Self {
935        self.override_entry().is_concurrency_token = Some(true);
936        self
937    }
938
939    pub fn has_max_length(mut self, n: usize) -> Self {
940        self.override_entry().max_length = Some(n);
941        self
942    }
943
944    pub fn has_column_name(mut self, name: &'static str) -> Self {
945        self.override_entry().column_name = Some(name.to_string());
946        self
947    }
948
949    pub fn is_unique(mut self) -> Self {
950        self.override_entry().is_unique = Some(true);
951        self
952    }
953
954    pub fn has_index(mut self) -> Self {
955        self.override_entry().has_index = Some(true);
956        self
957    }
958
959    /// Sets a raw SQL `DEFAULT` expression, e.g. `.has_default("1")` or
960    /// `.has_default("CURRENT_TIMESTAMP")`. String literals must include their
961    /// quotes (`"'pending'"`).
962    pub fn has_default(mut self, sql: &str) -> Self {
963        self.override_entry().default_value = Some(sql.to_string());
964        self
965    }
966
967    /// Adds a column-level CHECK constraint. The constraint name is derived as
968    /// `CK_<table>_<column>` at model-build time (so table renames apply).
969    pub fn has_check(mut self, expression: &str) -> Self {
970        self.override_entry().check = Some(expression.to_string());
971        self
972    }
973}
974
975// ---------------------------------------------------------------------------
976// IEntityTypeConfiguration<T>
977// ---------------------------------------------------------------------------
978
979pub trait IEntityTypeConfiguration<T: IEntityType> {
980    fn configure(&self, entity: &mut EntityTypeBuilder<'_, T>);
981}