Skip to main content

sea_orm/schema/
builder.rs

1use super::{Schema, TopologicalSort, entity::index_table_ref};
2use crate::{ConnectionTrait, DbBackend, DbErr, EntityTrait, Statement};
3use sea_query::{
4    ForeignKeyCreateStatement, Index, IndexCreateStatement, IntoIden, TableAlterStatement,
5    TableCreateStatement, TableName, TableRef, extension::postgres::TypeCreateStatement,
6};
7
8/// A schema builder that can take a registry of Entities and synchronize it with database.
9pub struct SchemaBuilder {
10    helper: Schema,
11    entities: Vec<EntitySchemaInfo>,
12}
13
14/// Schema info for Entity. Can be used to re-create schema in database.
15pub struct EntitySchemaInfo {
16    table: TableCreateStatement,
17    enums: Vec<TypeCreateStatement>,
18    indexes: Vec<IndexCreateStatement>,
19    /// The schema name from the entity definition (e.g., `#[sea_orm(schema_name = "sys")]`).
20    /// `None` means the entity uses the database's current/default schema.
21    schema_name: Option<String>,
22}
23
24impl std::fmt::Debug for SchemaBuilder {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(f, "SchemaBuilder {{")?;
27        write!(f, " entities: [")?;
28        for (i, entity) in self.entities.iter().enumerate() {
29            if i > 0 {
30                write!(f, ", ")?;
31            }
32            entity.debug_print(f, &self.helper.backend)?;
33        }
34        write!(f, " ]")?;
35        write!(f, " }}")
36    }
37}
38
39impl std::fmt::Debug for EntitySchemaInfo {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        self.debug_print(f, &DbBackend::Sqlite)
42    }
43}
44
45impl SchemaBuilder {
46    /// Creates a new schema builder
47    pub fn new(schema: Schema) -> Self {
48        Self {
49            helper: schema,
50            entities: Default::default(),
51        }
52    }
53
54    /// Register an entity to this schema
55    pub fn register<E: EntityTrait>(mut self, entity: E) -> Self {
56        let entity = EntitySchemaInfo::new(entity, &self.helper);
57        if !self
58            .entities
59            .iter()
60            .any(|e| e.table.get_table_name() == entity.table.get_table_name())
61        {
62            self.entities.push(entity);
63        }
64        self
65    }
66
67    #[cfg(feature = "entity-registry")]
68    pub(crate) fn helper(&self) -> &Schema {
69        &self.helper
70    }
71
72    #[cfg(feature = "entity-registry")]
73    pub(crate) fn register_entity(&mut self, entity: EntitySchemaInfo) {
74        self.entities.push(entity);
75    }
76
77    /// Synchronize the schema with the database: creates any missing tables, columns,
78    /// unique keys, and foreign keys.
79    ///
80    /// Non-destructive by design. Sync only adds — it never drops or alters existing tables
81    /// or columns. If a column already exists but its type or constraints differ from the
82    /// entity, sync leaves it untouched and logs a warning; apply such changes with a
83    /// migration. Destructive operations (ALTER / DROP) are intentionally out of scope and
84    /// would be a separate, explicitly-named API.
85    ///
86    /// Unstable: schema sync is experimental and exempt from semver — its behaviour and
87    /// signature may change in a minor (2.x) release.
88    #[cfg(feature = "schema-sync")]
89    #[cfg_attr(docsrs, doc(cfg(feature = "schema-sync")))]
90    pub fn sync<C>(self, db: &C) -> Result<(), DbErr>
91    where
92        C: ConnectionTrait + sea_schema::Connection,
93    {
94        let _existing = match db.get_database_backend() {
95            #[cfg(feature = "sqlx-mysql")]
96            DbBackend::MySql => {
97                use sea_schema::{mysql::discovery::SchemaDiscovery, probe::SchemaProbe};
98
99                let current_schema: String = db
100                    .query_one(
101                        sea_query::SelectStatement::new()
102                            .expr(sea_schema::mysql::MySql::get_current_schema()),
103                    )?
104                    .ok_or_else(|| DbErr::RecordNotFound("Can't get current schema".into()))?
105                    .try_get_by_index(0)?;
106
107                // Collect all unique schemas that registered entities belong to
108                let mut target_schemas = std::collections::BTreeSet::new();
109                for entity in &self.entities {
110                    let schema = entity.schema_name.as_deref().unwrap_or(&current_schema);
111                    target_schemas.insert(schema.to_string());
112                }
113
114                let mut tables_by_schema = std::collections::HashMap::new();
115                for schema_name in &target_schemas {
116                    let schema_discovery = SchemaDiscovery::new_no_exec(schema_name);
117                    let schema = schema_discovery
118                        .discover_with(db)
119                        .map_err(|err| DbErr::Query(crate::RuntimeErr::SqlxError(err.into())))?;
120
121                    tables_by_schema.insert(
122                        schema_name.clone(),
123                        schema.tables.iter().map(|table| table.write()).collect(),
124                    );
125                }
126
127                DiscoveredSchema {
128                    current_schema,
129                    tables_by_schema,
130                    enums_by_schema: Default::default(),
131                }
132            }
133            #[cfg(feature = "sqlx-postgres")]
134            DbBackend::Postgres => {
135                use sea_schema::{postgres::discovery::SchemaDiscovery, probe::SchemaProbe};
136
137                let current_schema: String = db
138                    .query_one(
139                        sea_query::SelectStatement::new()
140                            .expr(sea_schema::postgres::Postgres::get_current_schema()),
141                    )?
142                    .ok_or_else(|| DbErr::RecordNotFound("Can't get current schema".into()))?
143                    .try_get_by_index(0)?;
144
145                // Collect all unique schemas that registered entities belong to
146                let mut target_schemas = std::collections::BTreeSet::new();
147                for entity in &self.entities {
148                    let schema = entity.schema_name.as_deref().unwrap_or(&current_schema);
149                    target_schemas.insert(schema.to_string());
150                }
151
152                let mut tables_by_schema = std::collections::HashMap::new();
153                let mut enums_by_schema = std::collections::HashMap::new();
154                for schema_name in &target_schemas {
155                    let schema_discovery = SchemaDiscovery::new_no_exec(schema_name);
156                    let schema = schema_discovery
157                        .discover_with(db)
158                        .map_err(|err| DbErr::Query(crate::RuntimeErr::SqlxError(err.into())))?;
159
160                    tables_by_schema.insert(
161                        schema_name.clone(),
162                        schema.tables.iter().map(|table| table.write()).collect(),
163                    );
164                    enums_by_schema.insert(
165                        schema_name.clone(),
166                        schema.enums.iter().map(|def| def.write()).collect(),
167                    );
168                }
169
170                DiscoveredSchema {
171                    current_schema,
172                    tables_by_schema,
173                    enums_by_schema,
174                }
175            }
176            #[cfg(feature = "sqlx-sqlite")]
177            DbBackend::Sqlite => {
178                use sea_schema::sqlite::{SqliteDiscoveryError, discovery::SchemaDiscovery};
179                let schema = SchemaDiscovery::discover_with(db)
180                    .map_err(|err| {
181                        DbErr::Query(match err {
182                            SqliteDiscoveryError::SqlxError(err) => {
183                                crate::RuntimeErr::SqlxError(err.into())
184                            }
185                            _ => crate::RuntimeErr::Internal(format!("{err:?}")),
186                        })
187                    })?
188                    .merge_indexes_into_table();
189                let mut tables_by_schema = std::collections::HashMap::new();
190                tables_by_schema.insert(
191                    String::new(),
192                    schema.tables.iter().map(|table| table.write()).collect(),
193                );
194                DiscoveredSchema {
195                    current_schema: String::new(),
196                    tables_by_schema,
197                    enums_by_schema: Default::default(),
198                }
199            }
200            #[cfg(feature = "rusqlite")]
201            DbBackend::Sqlite => {
202                use sea_schema::sqlite::{SqliteDiscoveryError, discovery::SchemaDiscovery};
203                let schema = SchemaDiscovery::discover_with(db)
204                    .map_err(|err| {
205                        DbErr::Query(match err {
206                            SqliteDiscoveryError::RusqliteError(err) => {
207                                crate::RuntimeErr::Rusqlite(err.into())
208                            }
209                            _ => crate::RuntimeErr::Internal(format!("{err:?}")),
210                        })
211                    })?
212                    .merge_indexes_into_table();
213                let mut tables_by_schema = std::collections::HashMap::new();
214                tables_by_schema.insert(
215                    String::new(),
216                    schema.tables.iter().map(|table| table.write()).collect(),
217                );
218                DiscoveredSchema {
219                    current_schema: String::new(),
220                    tables_by_schema,
221                    enums_by_schema: Default::default(),
222                }
223            }
224            #[allow(unreachable_patterns)]
225            other => {
226                return Err(DbErr::BackendNotSupported {
227                    db: other.as_str(),
228                    ctx: "SchemaBuilder::sync",
229                });
230            }
231        };
232
233        #[allow(unreachable_code)]
234        let mut created_enums: Vec<Statement> = Default::default();
235
236        #[allow(unreachable_code)]
237        for table_name in self.sorted_tables() {
238            if let Some(entity) = self
239                .entities
240                .iter()
241                .find(|entity| table_name == get_table_name(entity.table.get_table_name()))
242            {
243                entity.sync(db, &_existing, &mut created_enums)?;
244            }
245        }
246
247        Ok(())
248    }
249
250    /// Create all registered tables, columns, unique keys, and foreign keys.
251    /// Fails if any table already exists. Use `sync` (feature `schema-sync`)
252    /// instead for an incremental version that diffs against the live schema.
253    pub fn apply<C: ConnectionTrait>(self, db: &C) -> Result<(), DbErr> {
254        let mut created_enums: Vec<Statement> = Default::default();
255
256        for table_name in self.sorted_tables() {
257            if let Some(entity) = self
258                .entities
259                .iter()
260                .find(|entity| table_name == get_table_name(entity.table.get_table_name()))
261            {
262                entity.apply(db, &mut created_enums)?;
263            }
264        }
265
266        Ok(())
267    }
268
269    // Regression guard for #3100: `sync()` must return a `Send` future, otherwise it
270    // cannot be used from `tokio::spawn` / most runtimes. Compiled (never called)
271    // whenever `schema-sync` + a backend is on, so a future dep bump that reintroduces a
272    // `!Send` value across an await fails the build here.
273    #[allow(dead_code)]
274    #[cfg(all(feature = "schema-sync", feature = "sqlx-sqlite"))]
275    fn _assert_sync_future_is_send(self, db: &crate::DatabaseConnection) {
276        fn assert_send<T: Send>(_: &T) {}
277        assert_send(&self.sync(db));
278    }
279
280    fn sorted_tables(&self) -> Vec<TableName> {
281        let mut sorter = TopologicalSort::<TableName>::new();
282
283        for entity in self.entities.iter() {
284            let table_name = get_table_name(entity.table.get_table_name());
285            sorter.insert(table_name);
286        }
287        for entity in self.entities.iter() {
288            let self_table = get_table_name(entity.table.get_table_name());
289            for fk in entity.table.get_foreign_key_create_stmts().iter() {
290                let fk = fk.get_foreign_key();
291                let ref_table = get_table_name(fk.get_ref_table());
292                if self_table != ref_table {
293                    // self cycle is okay
294                    sorter.add_dependency(ref_table, self_table.clone());
295                }
296            }
297        }
298        let mut sorted = Vec::new();
299        while let Some(i) = sorter.pop() {
300            sorted.push(i);
301        }
302        if sorted.len() != self.entities.len() {
303            // push leftover tables
304            for entity in self.entities.iter() {
305                let table_name = get_table_name(entity.table.get_table_name());
306                if !sorted.contains(&table_name) {
307                    sorted.push(table_name);
308                }
309            }
310        }
311
312        sorted
313    }
314}
315
316struct DiscoveredSchema {
317    /// The current/default schema of the database connection (e.g., "public" for Postgres).
318    current_schema: String,
319    /// Tables discovered from the database, grouped by schema name.
320    tables_by_schema: std::collections::HashMap<String, Vec<TableCreateStatement>>,
321    /// Enums discovered from the database, grouped by schema name.
322    enums_by_schema: std::collections::HashMap<String, Vec<TypeCreateStatement>>,
323}
324
325impl DiscoveredSchema {
326    /// Find an existing table in the discovered schema that matches the given entity.
327    ///
328    /// `entity_schema` is the entity's explicit schema_name (from `#[sea_orm(schema_name = "...")]`).
329    /// If `None`, the entity uses the database's current/default schema.
330    ///
331    /// The comparison uses bare table names (without schema qualifiers) because
332    /// `sea-schema` discovery results do not include schema information in the
333    /// `TableCreateStatement`.
334    fn find_table(
335        &self,
336        entity_schema: Option<&str>,
337        entity_table_name: &TableName,
338    ) -> Option<&TableCreateStatement> {
339        let schema = entity_schema.unwrap_or(&self.current_schema);
340        let schema_tables = self.tables_by_schema.get(schema)?;
341        // Strip schema from entity table name for comparison, because discovered
342        // tables from sea-schema do not carry schema qualifiers.
343        let bare_entity_name = TableName(None, entity_table_name.1.clone());
344        schema_tables
345            .iter()
346            .find(|tbl| get_table_name(tbl.get_table_name()) == bare_entity_name)
347    }
348
349    fn find_enums(&self, entity_schema: Option<&str>) -> &[TypeCreateStatement] {
350        let schema = entity_schema.unwrap_or(&self.current_schema);
351        self.enums_by_schema
352            .get(schema)
353            .map(|v| v.as_slice())
354            .unwrap_or(&[])
355    }
356}
357
358impl EntitySchemaInfo {
359    /// Creates a EntitySchemaInfo object given a generic Entity.
360    pub fn new<E: EntityTrait>(entity: E, helper: &Schema) -> Self {
361        Self {
362            table: helper.create_table_from_entity(entity),
363            enums: helper.create_enum_from_entity(entity),
364            indexes: helper.create_index_from_entity(entity),
365            schema_name: entity.schema_name().map(|s| s.to_string()),
366        }
367    }
368
369    fn apply<C: ConnectionTrait>(
370        &self,
371        db: &C,
372        created_enums: &mut Vec<Statement>,
373    ) -> Result<(), DbErr> {
374        for stmt in self.enums.iter() {
375            let new_stmt = db.get_database_backend().build(stmt);
376            if !created_enums.iter().any(|s| s == &new_stmt) {
377                db.execute(stmt)?;
378                created_enums.push(new_stmt);
379            }
380        }
381        db.execute(&self.table)?;
382        for stmt in self.indexes.iter() {
383            db.execute(stmt)?;
384        }
385        Ok(())
386    }
387
388    // better to always compile this function
389    #[allow(dead_code)]
390    fn sync<C: ConnectionTrait>(
391        &self,
392        db: &C,
393        existing: &DiscoveredSchema,
394        created_enums: &mut Vec<Statement>,
395    ) -> Result<(), DbErr> {
396        let db_backend = db.get_database_backend();
397
398        // create enum before creating table
399        let existing_enums = existing.find_enums(self.schema_name.as_deref());
400        for stmt in self.enums.iter() {
401            let mut has_enum = false;
402            let new_stmt = db_backend.build(stmt);
403            for existing_enum in existing_enums {
404                if db_backend.build(existing_enum) == new_stmt {
405                    has_enum = true;
406                    // TODO add enum variants
407                    break;
408                }
409            }
410            if !has_enum && !created_enums.iter().any(|s| s == &new_stmt) {
411                db.execute(stmt)?;
412                created_enums.push(new_stmt);
413            }
414        }
415        let table_name = get_table_name(self.table.get_table_name());
416        // Use schema-aware lookup: find existing table in the correct schema
417        let existing_table = existing.find_table(self.schema_name.as_deref(), &table_name);
418        if let Some(existing_table) = existing_table {
419            for column_def in self.table.get_columns() {
420                let existing_column = existing_table
421                    .get_columns()
422                    .iter()
423                    .find(|c| c.get_column_name() == column_def.get_column_name());
424                let Some(existing_column) = existing_column else {
425                    let mut renamed_from = "";
426                    if let Some(comment) = &column_def.get_column_spec().comment
427                        && let Some((_, suffix)) = comment.rsplit_once("renamed_from \"")
428                        && let Some((prefix, _)) = suffix.split_once('"')
429                    {
430                        renamed_from = prefix;
431                    }
432                    if renamed_from.is_empty() {
433                        db.execute(
434                            TableAlterStatement::new()
435                                .table(self.table.get_table_name().expect("Checked above").clone())
436                                .add_column(column_def.to_owned()),
437                        )?;
438                    } else {
439                        db.execute(
440                            TableAlterStatement::new()
441                                .table(self.table.get_table_name().expect("Checked above").clone())
442                                .rename_column(
443                                    renamed_from.to_owned(),
444                                    column_def.get_column_name(),
445                                ),
446                        )?;
447                    }
448                    continue;
449                };
450                // The column already exists. Sync is non-destructive and will not alter it,
451                // but warn on a type divergence so the change isn't silently ignored (#3106).
452                if let (Some(desired), Some(current)) = (
453                    column_def.get_column_type(),
454                    existing_column.get_column_type(),
455                ) {
456                    let backend = db.get_database_backend();
457                    let desired_sql = render_column_type(backend, desired);
458                    let current_sql = render_column_type(backend, current);
459                    if desired_sql != current_sql {
460                        tracing::warn!(
461                            "schema sync: column `{}`.`{}` is `{}` in the database but the entity \
462                             defines `{}`; sync is non-destructive and will not alter it (apply the \
463                             change with a migration)",
464                            table_name.1.to_string(),
465                            column_def.get_column_name(),
466                            current_sql,
467                            desired_sql,
468                        );
469                    }
470                }
471            }
472            if db.get_database_backend() != DbBackend::Sqlite {
473                for foreign_key in self.table.get_foreign_key_create_stmts().iter() {
474                    let mut key_exists = false;
475                    for existing_key in existing_table.get_foreign_key_create_stmts().iter() {
476                        if compare_foreign_key(foreign_key, existing_key) {
477                            key_exists = true;
478                            break;
479                        }
480                    }
481                    if !key_exists {
482                        db.execute(foreign_key)?;
483                    }
484                }
485            }
486        } else {
487            db.execute(&self.table)?;
488        }
489        for stmt in self.indexes.iter() {
490            let mut has_index = false;
491            if let Some(existing_table) = existing_table {
492                for existing_index in existing_table.get_indexes() {
493                    if existing_index.get_index_spec().get_column_names()
494                        == stmt.get_index_spec().get_column_names()
495                    {
496                        has_index = true;
497                        break;
498                    }
499                }
500            }
501            if !has_index {
502                // shall we do alter table add constraint for unique index?
503                let mut stmt = stmt.clone();
504                stmt.if_not_exists();
505                db.execute(&stmt)?;
506            }
507        }
508        if let Some(existing_table) = existing_table {
509            // For columns with a column-level UNIQUE constraint (#[sea_orm(unique)]) that
510            // already exist in the table but do not yet have a unique index, create one.
511            for column_def in self.table.get_columns() {
512                if column_def.get_column_spec().unique {
513                    let col_name = column_def.get_column_name();
514                    let col_exists = existing_table
515                        .get_columns()
516                        .iter()
517                        .any(|c| c.get_column_name() == col_name);
518                    if !col_exists {
519                        // Column is being added in this sync pass; the ALTER TABLE ADD COLUMN
520                        // will include the UNIQUE inline, so no separate index needed.
521                        continue;
522                    }
523                    let already_unique = existing_table.get_indexes().iter().any(|idx| {
524                        if !idx.is_unique_key() {
525                            return false;
526                        }
527                        let cols = idx.get_index_spec().get_column_names();
528                        cols.len() == 1 && cols[0] == col_name
529                    });
530                    if !already_unique {
531                        let table_name =
532                            self.table.get_table_name().expect("table must have a name");
533                        let tbl_str = table_name.sea_orm_table().to_string();
534                        let table_ref = index_table_ref(table_name.clone(), db_backend);
535                        db.execute(
536                            Index::create()
537                                .name(format!("idx-{tbl_str}-{col_name}"))
538                                .table(table_ref)
539                                .col(col_name.into_iden())
540                                .unique()
541                                .if_not_exists(),
542                        )?;
543                    }
544                }
545            }
546        }
547        if let Some(existing_table) = existing_table {
548            // find all unique keys from existing table
549            // if it no longer exist in new schema, drop it
550            for existing_index in existing_table.get_indexes() {
551                if existing_index.is_unique_key() {
552                    let mut has_index = false;
553                    for stmt in self.indexes.iter() {
554                        if existing_index.get_index_spec().get_column_names()
555                            == stmt.get_index_spec().get_column_names()
556                        {
557                            has_index = true;
558                            break;
559                        }
560                    }
561                    // Also check if the unique index corresponds to a column-level UNIQUE
562                    // constraint (from #[sea_orm(unique)]). These are embedded in the CREATE
563                    // TABLE column definition and not tracked in self.indexes, so we must not
564                    // try to drop them during sync.
565                    if !has_index {
566                        let index_cols = existing_index.get_index_spec().get_column_names();
567                        if index_cols.len() == 1 {
568                            for column_def in self.table.get_columns() {
569                                if column_def.get_column_name() == index_cols[0]
570                                    && column_def.get_column_spec().unique
571                                {
572                                    has_index = true;
573                                    break;
574                                }
575                            }
576                        }
577                    }
578                    if !has_index
579                        && let Some(drop_existing) = existing_index
580                            .get_index_spec()
581                            .get_name()
582                            .map(|s| s.to_owned())
583                    {
584                        if db_backend == DbBackend::Postgres {
585                            // On PostgreSQL, unique indexes created via column-level UNIQUE
586                            // (e.g. ADD COLUMN ... UNIQUE) are backed by a named constraint.
587                            // DROP INDEX fails on constraint-owned indexes; use
588                            // ALTER TABLE ... DROP CONSTRAINT instead.
589                            db.execute(
590                                TableAlterStatement::new()
591                                    .table(
592                                        self.table.get_table_name().expect("Checked above").clone(),
593                                    )
594                                    .drop_constraint(drop_existing),
595                            )?;
596                        } else {
597                            db.execute(sea_query::Index::drop().name(drop_existing))?;
598                        }
599                    }
600                }
601            }
602        }
603        Ok(())
604    }
605
606    fn debug_print(
607        &self,
608        f: &mut std::fmt::Formatter<'_>,
609        backend: &DbBackend,
610    ) -> std::fmt::Result {
611        write!(f, "EntitySchemaInfo {{")?;
612        write!(f, " table: {:?}", backend.build(&self.table).to_string())?;
613        write!(f, " enums: [")?;
614        for (i, stmt) in self.enums.iter().enumerate() {
615            if i > 0 {
616                write!(f, ", ")?;
617            }
618            write!(f, "{:?}", backend.build(stmt).to_string())?;
619        }
620        write!(f, " ]")?;
621        write!(f, " indexes: [")?;
622        for (i, stmt) in self.indexes.iter().enumerate() {
623            if i > 0 {
624                write!(f, ", ")?;
625            }
626            write!(f, "{:?}", backend.build(stmt).to_string())?;
627        }
628        write!(f, " ]")?;
629        write!(f, " }}")
630    }
631}
632
633fn get_table_name(table_ref: Option<&TableRef>) -> TableName {
634    match table_ref {
635        Some(TableRef::Table(table_name, _)) => table_name.clone(),
636        None => panic!("Expect TableCreateStatement is properly built"),
637        _ => unreachable!("Unexpected {table_ref:?}"),
638    }
639}
640
641/// Render a `ColumnType` to its backend-specific SQL type string. Used to compare an
642/// entity's column type against the live database (equal renderings mean no divergence,
643/// so e.g. SQLite `Integer`/`BigInteger` — both `integer` — don't false-alarm).
644// Always compiled, like `EntitySchemaInfo::sync` which calls it.
645#[allow(dead_code)]
646fn render_column_type(backend: DbBackend, col_type: &sea_query::ColumnType) -> String {
647    use sea_query::backend::TableBuilder;
648    let mut sql = String::new();
649    match backend {
650        DbBackend::MySql => sea_query::MysqlQueryBuilder.prepare_column_type(col_type, &mut sql),
651        DbBackend::Postgres => {
652            sea_query::PostgresQueryBuilder.prepare_column_type(col_type, &mut sql)
653        }
654        DbBackend::Sqlite => sea_query::SqliteQueryBuilder.prepare_column_type(col_type, &mut sql),
655    }
656    sql
657}
658
659fn compare_foreign_key(a: &ForeignKeyCreateStatement, b: &ForeignKeyCreateStatement) -> bool {
660    let a = a.get_foreign_key();
661    let b = b.get_foreign_key();
662
663    a.get_name() == b.get_name()
664        || (a.get_ref_table() == b.get_ref_table()
665            && a.get_columns() == b.get_columns()
666            && a.get_ref_columns() == b.get_ref_columns())
667}