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}