This is an automated email from the ASF dual-hosted git repository.

mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new aa663b24ee [#14626] feat(core): Persist Semantic Model dataset source 
associations (#14627)
aa663b24ee is described below

commit aa663b24ee23ec23bec58f3dc0d3ef25fb7708de
Author: mchades <[email protected]>
AuthorDate: Thu Nov 5 23:13:37 2026 +0800

    [#14626] feat(core): Persist Semantic Model dataset source associations 
(#14627)
    
    ### What changes were proposed in this pull request?
    
    Add relational storage for Semantic Model dataset-to-source associations
    on H2, MySQL, and PostgreSQL. Maintain explicitly supplied associations
    in the model transaction, soft-delete them on replacement or
    model/parent deletion, and purge retired rows through bounded garbage
    collection.
    
    ### Why are the changes needed?
    
    Implement the storage foundation for source lookup approved in #14378.
    
    Fixes #14626.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No public API changes. Model API write-path integration and lookup will
    follow in separate PRs.
    
    ### How was this patch tested?
    
    Unit and H2/MySQL/PostgreSQL tests passed, including schema upgrades and
    transaction rollback. Spotless and Javadoc passed.
---
 .../apache/gravitino/meta/SemanticModelEntity.java |  24 +
 .../gravitino/semantic/SemanticModelSource.java    |  51 ++
 .../semantic/SemanticModelSourceUpdate.java        | 100 ++++
 .../mapper/SemanticModelSourceMapper.java          |  77 +++
 .../SemanticModelSourceSQLProviderFactory.java     |  73 +++
 .../provider/DefaultMapperPackageProvider.java     |   2 +
 .../base/SemanticModelSourceBaseSQLProvider.java   |  83 +++
 .../SemanticModelSourcePostgreSQLProvider.java     |  42 ++
 .../relational/service/CatalogMetaService.java     |   7 +-
 .../relational/service/MetalakeMetaService.java    |   7 +-
 .../relational/service/SchemaMetaService.java      |   7 +-
 .../service/SemanticModelMetaService.java          |  44 +-
 .../semantic/TestSemanticModelSourceUpdate.java    |  82 +++
 .../apache/gravitino/storage/TestSQLScripts.java   |  19 +-
 .../TestSemanticModelSourceAssociations.java       | 589 +++++++++++++++++++++
 scripts/h2/schema-2.0.0-h2.sql                     |  14 +
 scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql           |  14 +
 scripts/mysql/schema-2.0.0-mysql.sql               |  15 +
 scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql     |  15 +
 scripts/postgresql/schema-2.0.0-postgresql.sql     |  21 +
 .../upgrade-1.3.0-to-2.0.0-postgresql.sql          |  21 +
 21 files changed, 1293 insertions(+), 14 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/meta/SemanticModelEntity.java 
b/core/src/main/java/org/apache/gravitino/meta/SemanticModelEntity.java
index 1f4684d671..b4b89fa623 100644
--- a/core/src/main/java/org/apache/gravitino/meta/SemanticModelEntity.java
+++ b/core/src/main/java/org/apache/gravitino/meta/SemanticModelEntity.java
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.meta;
 
+import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import java.util.Collections;
@@ -32,6 +33,7 @@ import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.semantic.SemanticModel;
 import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.apache.gravitino.semantic.SemanticModelSourceUpdate;
 
 /** A metadata-store entity representing a schema-scoped Semantic Model. */
 @ToString
@@ -77,10 +79,21 @@ public class SemanticModelEntity implements Entity, 
Auditable, HasIdentifier, Se
   private Map<String, String> properties = Collections.emptyMap();
   private AuditInfo auditInfo;
 
+  @JsonIgnore @ToString.Exclude
+  private transient SemanticModelSourceUpdate sourceUpdate = 
SemanticModelSourceUpdate.preserve();
+
   @Nullable private String comment;
 
   private SemanticModelEntity() {}
 
+  /**
+   * @return The internal association update instruction for this write.
+   */
+  @JsonIgnore
+  public SemanticModelSourceUpdate sourceUpdate() {
+    return sourceUpdate == null ? SemanticModelSourceUpdate.preserve() : 
sourceUpdate;
+  }
+
   /**
    * Returns the fields and values of this Semantic Model entity.
    *
@@ -300,6 +313,17 @@ public class SemanticModelEntity implements Entity, 
Auditable, HasIdentifier, Se
       return this;
     }
 
+    /**
+     * Attaches the source associations resolved for this write; they are not 
snapshot fields.
+     *
+     * @param update The explicit replacement or preserve instruction.
+     * @return This builder.
+     */
+    public Builder withSourceUpdate(SemanticModelSourceUpdate update) {
+      semanticModel.sourceUpdate = Objects.requireNonNull(update, 
"sourceUpdate");
+      return this;
+    }
+
     /**
      * Sets the Gravitino-specific properties.
      *
diff --git 
a/core/src/main/java/org/apache/gravitino/semantic/SemanticModelSource.java 
b/core/src/main/java/org/apache/gravitino/semantic/SemanticModelSource.java
new file mode 100644
index 0000000000..694927ab3c
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/semantic/SemanticModelSource.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.semantic;
+
+import com.google.common.base.Preconditions;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import org.apache.gravitino.Entity;
+
+/** A dataset's source resolved during caller-authorized definition 
validation. */
+@Getter
+@EqualsAndHashCode
+public final class SemanticModelSource {
+  private final String datasetName;
+  private final Entity.EntityType sourceType;
+  private final long sourceId;
+
+  /**
+   * Creates a resolved source association.
+   *
+   * @param datasetName The exact dataset name in the definition.
+   * @param sourceType TABLE or VIEW.
+   * @param sourceId The registered Gravitino entity ID.
+   */
+  public SemanticModelSource(String datasetName, Entity.EntityType sourceType, 
long sourceId) {
+    Preconditions.checkArgument(
+        datasetName != null && !datasetName.isEmpty(), "Dataset name is 
required");
+    Preconditions.checkArgument(
+        sourceType == Entity.EntityType.TABLE || sourceType == 
Entity.EntityType.VIEW,
+        "Source must be a table or view");
+    this.datasetName = datasetName;
+    this.sourceType = sourceType;
+    this.sourceId = sourceId;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/semantic/SemanticModelSourceUpdate.java
 
b/core/src/main/java/org/apache/gravitino/semantic/SemanticModelSourceUpdate.java
new file mode 100644
index 0000000000..12d4928acb
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/semantic/SemanticModelSourceUpdate.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.semantic;
+
+import com.google.common.base.Preconditions;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.EqualsAndHashCode;
+
+/** Internal write intent for source associations; never part of a public 
model definition. */
+@EqualsAndHashCode
+public final class SemanticModelSourceUpdate {
+  private static final SemanticModelSourceUpdate PRESERVE =
+      new SemanticModelSourceUpdate(false, List.of());
+  private final boolean replace;
+  private final List<SemanticModelSource> sources;
+
+  private SemanticModelSourceUpdate(boolean replace, List<SemanticModelSource> 
sources) {
+    this.replace = replace;
+    this.sources = List.copyOf(sources);
+  }
+
+  /**
+   * @return An instruction to preserve existing associations during 
metadata-only updates.
+   */
+  public static SemanticModelSourceUpdate preserve() {
+    return PRESERVE;
+  }
+
+  /**
+   * Creates an instruction to replace the entire association set.
+   *
+   * @param sources All resolved datasets of the new definition.
+   * @return The immutable replacement instruction.
+   * @throws IllegalArgumentException If sources is null or contains duplicate 
dataset names.
+   */
+  public static SemanticModelSourceUpdate replace(List<SemanticModelSource> 
sources) {
+    Preconditions.checkArgument(sources != null, "sources must not be null");
+    Set<String> names = new HashSet<>();
+    sources.forEach(
+        source ->
+            Preconditions.checkArgument(
+                names.add(source.getDatasetName()),
+                "Duplicate dataset association: %s",
+                source.getDatasetName()));
+    return new SemanticModelSourceUpdate(true, sources);
+  }
+
+  /**
+   * @return Whether this write replaces the associations, even for an 
identical definition.
+   */
+  public boolean replacesSources() {
+    return replace;
+  }
+
+  /**
+   * @return The complete immutable replacement set, or an empty list for 
preserve.
+   */
+  public List<SemanticModelSource> sources() {
+    return sources;
+  }
+
+  /**
+   * Checks that a replacement describes exactly the datasets being committed.
+   *
+   * @param definition The definition written in the same transaction.
+   */
+  public void validate(SemanticModelDefinition definition) {
+    if (replace) {
+      Set<String> datasets =
+          
Arrays.stream(definition.datasets()).map(Dataset::name).collect(Collectors.toSet());
+      Preconditions.checkArgument(
+          datasets.size() == definition.datasets().length, "Duplicate dataset 
names in definition");
+      Set<String> associations =
+          
sources.stream().map(SemanticModelSource::getDatasetName).collect(Collectors.toSet());
+      Preconditions.checkArgument(
+          datasets.equals(associations),
+          "Source associations must match the definition's dataset names");
+    }
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelSourceMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelSourceMapper.java
new file mode 100644
index 0000000000..a325649cf1
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelSourceMapper.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.mapper;
+
+import java.util.List;
+import org.apache.gravitino.semantic.SemanticModelSource;
+import org.apache.ibatis.annotations.DeleteProvider;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.UpdateProvider;
+
+/** Persists dataset-to-source associations and their retirement state. */
+public interface SemanticModelSourceMapper {
+  /** The dataset source association table name. */
+  String TABLE_NAME = "semantic_model_source_rel";
+
+  /** Inserts live associations with database-generated row IDs. */
+  @Insert(
+      """
+      <script>INSERT INTO semantic_model_source_rel
+      (metalake_id, semantic_model_id, dataset_name, source_type, source_id, 
deleted_at) VALUES
+      <foreach collection='sources' item='source' separator=','>
+      (#{metalakeId}, #{modelId}, #{source.datasetName}, #{source.sourceType}, 
#{source.sourceId}, 0)
+      </foreach></script>
+      """)
+  void insertSources(
+      @Param("metalakeId") long metalakeId,
+      @Param("modelId") long modelId,
+      @Param("sources") List<SemanticModelSource> sources);
+
+  /** Retires all live associations of a model. */
+  @UpdateProvider(
+      type = SemanticModelSourceSQLProviderFactory.class,
+      method = "softDeleteByModelId")
+  int softDeleteByModelId(@Param("modelId") Long modelId);
+
+  /** Retires live associations owned by models in the given schemas. */
+  @UpdateProvider(
+      type = SemanticModelSourceSQLProviderFactory.class,
+      method = "softDeleteBySchemaIds")
+  int softDeleteBySchemaIds(@Param("schemaIds") List<Long> schemaIds);
+
+  /** Retires live associations owned by models in a catalog. */
+  @UpdateProvider(
+      type = SemanticModelSourceSQLProviderFactory.class,
+      method = "softDeleteByCatalogId")
+  int softDeleteByCatalogId(@Param("catalogId") Long catalogId);
+
+  /** Retires live associations owned by models in a metalake. */
+  @UpdateProvider(
+      type = SemanticModelSourceSQLProviderFactory.class,
+      method = "softDeleteByMetalakeId")
+  int softDeleteByMetalakeId(@Param("metalakeId") Long metalakeId);
+
+  /** Permanently deletes retired associations older than a timeline. */
+  @DeleteProvider(
+      type = SemanticModelSourceSQLProviderFactory.class,
+      method = "deleteByLegacyTimeline")
+  int deleteByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit);
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelSourceSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelSourceSQLProviderFactory.java
new file mode 100644
index 0000000000..34d6edafeb
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelSourceSQLProviderFactory.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.mapper;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.storage.relational.JDBCBackend.JDBCBackendType;
+import 
org.apache.gravitino.storage.relational.mapper.provider.base.SemanticModelSourceBaseSQLProvider;
+import 
org.apache.gravitino.storage.relational.mapper.provider.postgresql.SemanticModelSourcePostgreSQLProvider;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.annotations.Param;
+
+/** Selects database-specific SQL for retiring and purging dataset source 
associations. */
+public class SemanticModelSourceSQLProviderFactory {
+  private static final Map<JDBCBackendType, 
SemanticModelSourceBaseSQLProvider> PROVIDERS =
+      ImmutableMap.of(
+          JDBCBackendType.MYSQL, new SemanticModelSourceBaseSQLProvider(),
+          JDBCBackendType.H2, new SemanticModelSourceBaseSQLProvider(),
+          JDBCBackendType.POSTGRESQL, new 
SemanticModelSourcePostgreSQLProvider());
+
+  /** Returns the SQL provider for the configured relational backend. */
+  public static SemanticModelSourceBaseSQLProvider getProvider() {
+    String databaseId =
+        SqlSessionFactoryHelper.getInstance()
+            .getSqlSessionFactory()
+            .getConfiguration()
+            .getDatabaseId();
+    return PROVIDERS.get(JDBCBackendType.fromString(databaseId));
+  }
+
+  /** Retires all live associations of a model. */
+  public static String softDeleteByModelId(@Param("modelId") Long modelId) {
+    return getProvider().softDeleteByModelId(modelId);
+  }
+
+  /** Retires live associations owned by models in the given schemas. */
+  public static String softDeleteBySchemaIds(@Param("schemaIds") List<Long> 
schemaIds) {
+    return getProvider().softDeleteBySchemaIds(schemaIds);
+  }
+
+  /** Retires live associations owned by models in a catalog. */
+  public static String softDeleteByCatalogId(@Param("catalogId") Long 
catalogId) {
+    return getProvider().softDeleteByCatalogId(catalogId);
+  }
+
+  /** Retires live associations owned by models in a metalake. */
+  public static String softDeleteByMetalakeId(@Param("metalakeId") Long 
metalakeId) {
+    return getProvider().softDeleteByMetalakeId(metalakeId);
+  }
+
+  /** Permanently deletes retired associations older than a timeline. */
+  public static String deleteByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
+    return getProvider().deleteByLegacyTimeline(legacyTimeline, limit);
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/DefaultMapperPackageProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/DefaultMapperPackageProvider.java
index 32f289750c..ab433bb5cf 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/DefaultMapperPackageProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/DefaultMapperPackageProvider.java
@@ -45,6 +45,7 @@ import 
org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
 import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
 import 
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
+import 
org.apache.gravitino.storage.relational.mapper.SemanticModelSourceMapper;
 import 
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
 import org.apache.gravitino.storage.relational.mapper.StatisticMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.TableColumnMapper;
@@ -89,6 +90,7 @@ public class DefaultMapperPackageProvider implements 
MapperPackageProvider {
         SecurableObjectMapper.class,
         SemanticModelMetaMapper.class,
         SemanticModelMetricMapper.class,
+        SemanticModelSourceMapper.class,
         SemanticModelVersionInfoMapper.class,
         StatisticMetaMapper.class,
         TableColumnMapper.class,
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/SemanticModelSourceBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/SemanticModelSourceBaseSQLProvider.java
new file mode 100644
index 0000000000..99f9718924
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/SemanticModelSourceBaseSQLProvider.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.mapper.provider.base;
+
+import static 
org.apache.gravitino.storage.relational.mapper.SemanticModelSourceMapper.TABLE_NAME;
+
+import java.util.List;
+import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
+import org.apache.ibatis.annotations.Param;
+
+/** Provides MySQL-compatible SQL for retiring dataset source associations. */
+public class SemanticModelSourceBaseSQLProvider {
+
+  /** Returns SQL for retiring a model's live associations. */
+  public String softDeleteByModelId(@Param("modelId") Long modelId) {
+    return softDeleteBy("semantic_model_id = #{modelId}");
+  }
+
+  /** Returns SQL for retiring associations owned by models in the given 
schemas. */
+  public String softDeleteBySchemaIds(@Param("schemaIds") List<Long> 
schemaIds) {
+    return "<script>"
+        + softDeleteBy(
+            "semantic_model_id IN (SELECT semantic_model_id FROM "
+                + SemanticModelMetaMapper.TABLE_NAME
+                + " WHERE schema_id IN ("
+                + "<foreach collection='schemaIds' item='schemaId' 
separator=','>"
+                + "#{schemaId}</foreach>))")
+        + "</script>";
+  }
+
+  /** Returns SQL for retiring associations owned by models in a catalog. */
+  public String softDeleteByCatalogId(@Param("catalogId") Long catalogId) {
+    return softDeleteBy(
+        "semantic_model_id IN (SELECT semantic_model_id FROM "
+            + SemanticModelMetaMapper.TABLE_NAME
+            + " WHERE catalog_id = #{catalogId})");
+  }
+
+  /** Returns SQL for retiring associations owned by models in a metalake. */
+  public String softDeleteByMetalakeId(@Param("metalakeId") Long metalakeId) {
+    return softDeleteBy("metalake_id = #{metalakeId}");
+  }
+
+  /** Returns SQL for purging retired rows without depending on model or 
parent metadata. */
+  public String deleteByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
+    return "DELETE FROM "
+        + TABLE_NAME
+        + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit}";
+  }
+
+  /** Returns the database expression for the current time in epoch 
milliseconds. */
+  protected String currentTimestampMillis() {
+    return "(UNIX_TIMESTAMP() * 1000.0)"
+        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000";
+  }
+
+  private String softDeleteBy(String condition) {
+    return "UPDATE "
+        + TABLE_NAME
+        + " SET deleted_at = "
+        + currentTimestampMillis()
+        + " WHERE "
+        + condition
+        + " AND deleted_at = 0";
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/SemanticModelSourcePostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/SemanticModelSourcePostgreSQLProvider.java
new file mode 100644
index 0000000000..ceb500ba9c
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/SemanticModelSourcePostgreSQLProvider.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.mapper.provider.postgresql;
+
+import static 
org.apache.gravitino.storage.relational.mapper.SemanticModelSourceMapper.TABLE_NAME;
+
+import 
org.apache.gravitino.storage.relational.mapper.provider.base.SemanticModelSourceBaseSQLProvider;
+import org.apache.ibatis.annotations.Param;
+
+/** Provides PostgreSQL SQL for retiring and purging dataset source 
associations. */
+public class SemanticModelSourcePostgreSQLProvider extends 
SemanticModelSourceBaseSQLProvider {
+  @Override
+  public String deleteByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
+    return "DELETE FROM "
+        + TABLE_NAME
+        + " WHERE id IN (SELECT id FROM "
+        + TABLE_NAME
+        + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit})";
+  }
+
+  @Override
+  protected String currentTimestampMillis() {
+    return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
index 14aed3a43c..42ad99f9e8 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
@@ -50,6 +50,7 @@ import 
org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
 import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
 import 
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
+import 
org.apache.gravitino.storage.relational.mapper.SemanticModelSourceMapper;
 import 
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
 import org.apache.gravitino.storage.relational.mapper.StatisticMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.TableColumnMapper;
@@ -359,7 +360,11 @@ public class CatalogMetaService {
           () ->
               SessionUtils.doWithoutCommit(
                   SemanticModelMetricMapper.class,
-                  mapper -> 
mapper.softDeleteSemanticModelMetricsByCatalogId(catalogId)));
+                  mapper -> 
mapper.softDeleteSemanticModelMetricsByCatalogId(catalogId)),
+          () ->
+              SessionUtils.doWithoutCommit(
+                  SemanticModelSourceMapper.class,
+                  mapper -> mapper.softDeleteByCatalogId(catalogId)));
     } else {
       SessionUtils.doMultipleWithCommit(
           () -> {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
index f4280ceb8a..beeba0556f 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
@@ -56,6 +56,7 @@ import 
org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
 import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
 import 
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
+import 
org.apache.gravitino.storage.relational.mapper.SemanticModelSourceMapper;
 import 
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
 import org.apache.gravitino.storage.relational.mapper.StatisticMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.TableColumnMapper;
@@ -348,7 +349,11 @@ public class MetalakeMetaService {
             () ->
                 SessionUtils.doWithoutCommit(
                     SemanticModelMetricMapper.class,
-                    mapper -> 
mapper.softDeleteSemanticModelMetricsByMetalakeId(metalakeId)));
+                    mapper -> 
mapper.softDeleteSemanticModelMetricsByMetalakeId(metalakeId)),
+            () ->
+                SessionUtils.doWithoutCommit(
+                    SemanticModelSourceMapper.class,
+                    mapper -> mapper.softDeleteByMetalakeId(metalakeId)));
       } else {
         SessionUtils.doMultipleWithCommit(
             () -> {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
index 6ddfe16616..ee924f51ea 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
@@ -60,6 +60,7 @@ import 
org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
 import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
 import 
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
+import 
org.apache.gravitino.storage.relational.mapper.SemanticModelSourceMapper;
 import 
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
 import org.apache.gravitino.storage.relational.mapper.StatisticMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.TableColumnMapper;
@@ -410,7 +411,11 @@ public class SchemaMetaService {
           () ->
               SessionUtils.doWithoutCommit(
                   SemanticModelMetricMapper.class,
-                  mapper -> 
mapper.softDeleteSemanticModelMetricsBySchemaIds(schemaIds.get())));
+                  mapper -> 
mapper.softDeleteSemanticModelMetricsBySchemaIds(schemaIds.get())),
+          () ->
+              SessionUtils.doWithoutCommit(
+                  SemanticModelSourceMapper.class,
+                  mapper -> mapper.softDeleteBySchemaIds(schemaIds.get())));
     } else {
       SessionUtils.doMultipleWithCommit(
           () -> {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SemanticModelMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SemanticModelMetaService.java
index 582481f4c7..ffe5e6ccb2 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SemanticModelMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SemanticModelMetaService.java
@@ -48,10 +48,12 @@ import org.apache.gravitino.meta.SemanticModelEntity;
 import org.apache.gravitino.metrics.Monitored;
 import org.apache.gravitino.semantic.Metric;
 import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.apache.gravitino.semantic.SemanticModelSource;
 import org.apache.gravitino.storage.IdGenerator;
 import org.apache.gravitino.storage.relational.EntityChangeLogWriter;
 import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
 import 
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
+import 
org.apache.gravitino.storage.relational.mapper.SemanticModelSourceMapper;
 import 
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
 import org.apache.gravitino.storage.relational.po.SemanticModelMetricPO;
 import org.apache.gravitino.storage.relational.po.SemanticModelPO;
@@ -69,6 +71,7 @@ public class SemanticModelMetaService {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(SemanticModelMetaService.class);
   private static final int METRIC_INSERT_BATCH_SIZE = 1000;
+  private static final int SOURCE_INSERT_BATCH_SIZE = 1000;
   private static final SemanticModelMetaService INSTANCE = new 
SemanticModelMetaService();
 
   private final BasePOStorageOps<SemanticModelPO, SemanticModelMetaMapper> ops;
@@ -139,7 +142,8 @@ public class SemanticModelMetaService {
   }
 
   /**
-   * Inserts or overwrites a Semantic Model, its snapshot, and its metric 
identities atomically.
+   * Inserts or overwrites a Semantic Model, its snapshot, metric identities, 
and supplied source
+   * associations atomically.
    *
    * @param semanticModelEntity The Semantic Model entity.
    * @param overwrite Whether to overwrite rows for the same stable ID or 
natural key.
@@ -186,6 +190,7 @@ public class SemanticModelMetaService {
                         persistedPO.set(replacementPO);
                       }),
               () -> reconcileMetricIdentities(persistedPO.get(), 
semanticModelEntity.definition()),
+              () -> replaceSources(persistedPO.get(), semanticModelEntity),
               () ->
                   SessionUtils.doWithoutCommit(
                       SemanticModelVersionInfoMapper.class,
@@ -272,6 +277,7 @@ public class SemanticModelMetaService {
               reconcileMetricIdentities(newSemanticModelPO, 
newEntity.definition());
             }
           },
+          () -> replaceSources(newSemanticModelPO, newEntity),
           () ->
               SessionUtils.doWithoutCommit(
                   SemanticModelVersionInfoMapper.class,
@@ -295,7 +301,8 @@ public class SemanticModelMetaService {
   }
 
   /**
-   * Soft-deletes a Semantic Model, its metric identities, and all of its 
version snapshots.
+   * Soft-deletes a Semantic Model, its metric identities, source 
associations, and version
+   * snapshots.
    *
    * @param identifier The Semantic Model identifier.
    * @return {@code true} when an active identity was deleted.
@@ -312,8 +319,8 @@ public class SemanticModelMetaService {
   }
 
   /**
-   * Permanently deletes retired metric identities and deleted models and 
snapshots older than a
-   * timeline.
+   * Permanently deletes retired source associations, metric identities, and 
deleted models and
+   * snapshots older than a timeline.
    *
    * @param legacyTimeline The exclusive deletion timeline in epoch 
milliseconds.
    * @param limit The maximum rows to delete from each table.
@@ -323,6 +330,10 @@ public class SemanticModelMetaService {
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "deleteSemanticModelMetasByLegacyTimeline")
   public int deleteSemanticModelMetasByLegacyTimeline(Long legacyTimeline, int 
limit) {
+    int sourceDeletedCount =
+        SessionUtils.doWithCommitAndFetchResult(
+            SemanticModelSourceMapper.class,
+            mapper -> mapper.deleteByLegacyTimeline(legacyTimeline, limit));
     int metricDeletedCount =
         SessionUtils.doWithCommitAndFetchResult(
             SemanticModelMetricMapper.class,
@@ -335,7 +346,7 @@ public class SemanticModelMetaService {
         SessionUtils.doWithCommitAndFetchResult(
             SemanticModelMetaMapper.class,
             mapper -> 
mapper.deleteSemanticModelMetasByLegacyTimeline(legacyTimeline, limit));
-    return metricDeletedCount + versionDeletedCount + metaDeletedCount;
+    return sourceDeletedCount + metricDeletedCount + versionDeletedCount + 
metaDeletedCount;
   }
 
   /**
@@ -416,11 +427,34 @@ public class SemanticModelMetaService {
             SessionUtils.doWithoutCommit(
                 SemanticModelMetricMapper.class,
                 mapper -> 
mapper.softDeleteSemanticModelMetricsBySemanticModelId(semanticModelId)),
+        () ->
+            SessionUtils.doWithoutCommit(
+                SemanticModelSourceMapper.class,
+                mapper -> mapper.softDeleteByModelId(semanticModelId)),
         () ->
             EntityChangeLogWriter.append(
                 semanticModelIdent, Entity.EntityType.SEMANTIC_MODEL, 
OperateType.DROP));
   }
 
+  private void replaceSources(SemanticModelPO po, SemanticModelEntity entity) {
+    if (!entity.sourceUpdate().replacesSources()) {
+      return;
+    }
+    entity.sourceUpdate().validate(entity.definition());
+    SessionUtils.doWithoutCommit(
+        SemanticModelSourceMapper.class,
+        mapper -> {
+          mapper.softDeleteByModelId(po.getSemanticModelId());
+          List<SemanticModelSource> sources = entity.sourceUpdate().sources();
+          for (int start = 0; start < sources.size(); start += 
SOURCE_INSERT_BATCH_SIZE) {
+            mapper.insertSources(
+                po.getMetalakeId(),
+                po.getSemanticModelId(),
+                sources.subList(start, Math.min(start + 
SOURCE_INSERT_BATCH_SIZE, sources.size())));
+          }
+        });
+  }
+
   private static void reconcileMetricIdentities(
       SemanticModelPO model, SemanticModelDefinition definition) {
     Set<String> names = metricNames(definition);
diff --git 
a/core/src/test/java/org/apache/gravitino/semantic/TestSemanticModelSourceUpdate.java
 
b/core/src/test/java/org/apache/gravitino/semantic/TestSemanticModelSourceUpdate.java
new file mode 100644
index 0000000000..fde5a99bd8
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/semantic/TestSemanticModelSourceUpdate.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.semantic;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.junit.jupiter.api.Test;
+
+class TestSemanticModelSourceUpdate {
+  @Test
+  void testImmutableCompleteReplacement() {
+    SemanticModelSource source = new SemanticModelSource("Orders", 
Entity.EntityType.TABLE, 123);
+    List<SemanticModelSource> mutable = new ArrayList<>(List.of(source));
+    SemanticModelSourceUpdate update = 
SemanticModelSourceUpdate.replace(mutable);
+    mutable.clear();
+    assertEquals(List.of(source), update.sources());
+    assertTrue(update.replacesSources());
+    assertThrows(UnsupportedOperationException.class, () -> 
update.sources().clear());
+    update.validate(definition("Orders"));
+    assertThrows(IllegalArgumentException.class, () -> 
update.validate(definition("orders")));
+    Dataset dataset = definition("Orders").datasets()[0];
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            update.validate(
+                SemanticModelDefinition.builder()
+                    .withDatasets(new Dataset[] {dataset, dataset})
+                    .build()));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> SemanticModelSourceUpdate.replace(List.of(source, source)));
+    assertFalse(SemanticModelSourceUpdate.preserve().replacesSources());
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> new SemanticModelSource("Orders", Entity.EntityType.CATALOG, 
123));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> new SemanticModelSource("", Entity.EntityType.TABLE, 123));
+  }
+
+  @Test
+  void testNullSourcesRejected() {
+    IllegalArgumentException exception =
+        assertThrows(IllegalArgumentException.class, () -> 
SemanticModelSourceUpdate.replace(null));
+    assertEquals("sources must not be null", exception.getMessage());
+  }
+
+  private SemanticModelDefinition definition(String name) {
+    return SemanticModelDefinition.builder()
+        .withDatasets(
+            new Dataset[] {
+              Dataset.builder()
+                  .withName(name)
+                  .withSource(NameIdentifier.of("catalog", "schema", "orders"))
+                  .build()
+            })
+        .build();
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java 
b/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java
index 6b04a69b0f..55f4fff8b5 100644
--- a/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java
+++ b/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java
@@ -104,7 +104,7 @@ public class TestSQLScripts extends TestJDBCBackend {
         executeScript(scriptFile);
       }
       if ("2.0.0".equals(versionScripts.getKey())) {
-        assertRoleRelationsIndexedByRoleId("schema " + 
versionScripts.getKey());
+        assertCleanupIndexes("schema " + versionScripts.getKey());
       }
     }
   }
@@ -137,7 +137,7 @@ public class TestSQLScripts extends TestJDBCBackend {
       executeScript(sourceSchema);
       executeScript(upgradeScript);
       if ("2.0.0".equals(upgradeMatcher.group(2))) {
-        assertRoleRelationsIndexedByRoleId(upgradeScript.getName());
+        assertCleanupIndexes(upgradeScript.getName());
       }
     }
   }
@@ -605,12 +605,19 @@ public class TestSQLScripts extends TestJDBCBackend {
     }
   }
 
-  private void assertRoleRelationsIndexedByRoleId(String source) throws 
SQLException {
+  private void assertCleanupIndexes(String source) throws SQLException {
+    Map<String, String> leadingColumns =
+        Map.of(
+            "user_role_rel", "role_id",
+            "group_role_rel", "role_id",
+            "semantic_model_source_rel", "deleted_at");
     try (SqlSession sqlSession =
             
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
         Connection connection = sqlSession.getConnection()) {
       DatabaseMetaData metaData = connection.getMetaData();
-      for (String table : List.of("user_role_rel", "group_role_rel")) {
+      for (Map.Entry<String, String> entry : leadingColumns.entrySet()) {
+        String table = entry.getKey();
+        String column = entry.getValue();
         String tableName =
             metaData.storesUpperCaseIdentifiers() ? 
table.toUpperCase(Locale.ROOT) : table;
         boolean indexed = false;
@@ -618,14 +625,14 @@ public class TestSQLScripts extends TestJDBCBackend {
             metaData.getIndexInfo(connection.getCatalog(), null, tableName, 
false, false)) {
           while (indexes.next()) {
             if (indexes.getShort("ORDINAL_POSITION") == 1
-                && 
"role_id".equalsIgnoreCase(indexes.getString("COLUMN_NAME"))) {
+                && column.equalsIgnoreCase(indexes.getString("COLUMN_NAME"))) {
               indexed = true;
               break;
             }
           }
         }
         Assertions.assertTrue(
-            indexed, "No index leading with role_id on " + table + " after " + 
source);
+            indexed, "No index leading with " + column + " on " + table + " 
after " + source);
       }
     }
   }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSemanticModelSourceAssociations.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSemanticModelSourceAssociations.java
new file mode 100644
index 0000000000..07634e026a
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSemanticModelSourceAssociations.java
@@ -0,0 +1,589 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.meta.SemanticModelEntity;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.apache.gravitino.semantic.SemanticModelSource;
+import org.apache.gravitino.semantic.SemanticModelSourceUpdate;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+import org.apache.ibatis.session.SqlSession;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestTemplate;
+
+/** Verifies association writes, retirement, rollback and GC on every 
relational backend. */
+public class TestSemanticModelSourceAssociations extends TestJDBCBackend {
+  private Namespace sourceNamespace;
+  private Namespace modelNamespace;
+  private TableEntity source;
+  private long metalakeId;
+
+  @BeforeEach
+  void prepareSources() throws Exception {
+    metalakeId = createAndInsertMakeLake("lake").id();
+    createAndInsertCatalog("lake", "sources");
+    createAndInsertCatalog("lake", "models");
+    createAndInsertSchema("lake", "sources", "hr");
+    createAndInsertSchema("lake", "models", "semantic");
+    sourceNamespace = Namespace.of("lake", "sources", "hr");
+    modelNamespace = Namespace.of("lake", "models", "semantic");
+    source = createAndInsertTableEntity(sourceNamespace, "employees");
+  }
+
+  @TestTemplate
+  void testSharedSourcesPreserveExactDatasetNames() throws Exception {
+    String[] names = {"employee", "Employee", "manager.with.dot", 
"x".repeat(1200), "员工'"};
+    SemanticModelEntity model = model("staff", source.id(), names);
+    backend.insert(model, false);
+    List<Association> live = live(model.id());
+    assertEquals(
+        Set.of(names), 
live.stream().map(Association::datasetName).collect(Collectors.toSet()));
+    assertEquals(names.length, 
live.stream().map(Association::id).distinct().count());
+    assertTrue(live.stream().allMatch(row -> row.metalakeId() == metalakeId));
+    assertTrue(live.stream().allMatch(row -> row.sourceId() == source.id()));
+    assertTrue(live.stream().allMatch(row -> 
row.sourceType().equals("TABLE")));
+    SemanticModelEntity loaded = 
service().getSemanticModelByIdentifier(model.nameIdentifier());
+    assertEquals(model, loaded);
+    assertFalse(loaded.sourceUpdate().replacesSources());
+  }
+
+  @TestTemplate
+  void testTableAndViewAssociationsKeepTheirTypes() throws Exception {
+    SemanticModelEntity model = model("staff", source.id(), "table", "view");
+    List<SemanticModelSource> sources =
+        List.of(
+            new SemanticModelSource("table", Entity.EntityType.TABLE, 
source.id()),
+            new SemanticModelSource("view", Entity.EntityType.VIEW, 
source.id()));
+    backend.insert(
+        copy(model, model.name(), model.definition(), 
SemanticModelSourceUpdate.replace(sources)),
+        false);
+    assertEquals(Set.copyOf(sources), sourceSet(model.id()));
+  }
+
+  @TestTemplate
+  void testReplaceEntireSetAndRetirePreviousRows() throws Exception {
+    SemanticModelEntity original = model("staff", source.id(), "employee", 
"manager");
+    backend.insert(original, false);
+    Set<Long> initialIds = liveIds(original.id());
+    SemanticModelEntity changed = model("staff", source.id(), "manager", 
"renamed", "added");
+    backend.update(
+        original.nameIdentifier(),
+        original.type(),
+        old -> copy(original, "staff", changed.definition(), 
changed.sourceUpdate()));
+    assertEquals(Set.of("manager", "renamed", "added"), 
datasetNames(original.id()));
+    
assertTrue(liveIds(original.id()).stream().noneMatch(initialIds::contains));
+    List<Association> retired =
+        rows(original.id()).stream().filter(row -> row.deletedAt() > 
0).toList();
+    assertEquals(2, retired.size());
+
+    SemanticModelEntity reordered = model("staff", source.id(), "added", 
"renamed", "manager");
+    backend.update(
+        original.nameIdentifier(),
+        original.type(),
+        old -> copy(original, "staff", reordered.definition(), 
reordered.sourceUpdate()));
+    assertEquals(Set.of("manager", "renamed", "added"), 
datasetNames(original.id()));
+    assertTrue(rows(original.id()).containsAll(retired));
+    assertEquals(8, rows(original.id()).size());
+    assertEquals(
+        3, 
service().getSemanticModelPOByIdentifier(original.nameIdentifier()).getCurrentVersion());
+  }
+
+  @TestTemplate
+  void testMetadataUpdatesPreserveRowsAndExplicitReplacementRebinds() throws 
Exception {
+    SemanticModelEntity original = model("staff", source.id(), "employee");
+    backend.insert(original, false);
+    List<Association> initial = rows(original.id());
+    assertTrue(backend.delete(source.nameIdentifier(), source.type(), false));
+    TableEntity replacement = createAndInsertTableEntity(sourceNamespace, 
source.name());
+    assertNotEquals(source.id(), replacement.id());
+    SemanticModelEntity renamed =
+        copy(original, "renamed", original.definition(), 
SemanticModelSourceUpdate.preserve());
+    backend.update(original.nameIdentifier(), original.type(), old -> renamed);
+    assertEquals(initial, rows(original.id()));
+    assertEquals(renamed, 
service().getSemanticModelByIdentifier(renamed.nameIdentifier()));
+
+    SemanticModelSourceUpdate rebound =
+        SemanticModelSourceUpdate.replace(
+            List.of(new SemanticModelSource("employee", source.type(), 
replacement.id())));
+    backend.update(
+        renamed.nameIdentifier(),
+        renamed.type(),
+        old -> copy(renamed, renamed.name(), renamed.definition(), rebound));
+    assertEquals(Set.copyOf(rebound.sources()), sourceSet(original.id()));
+    assertEquals(2, rows(original.id()).size());
+    assertTrue(rows(original.id()).get(0).deletedAt() > 0);
+  }
+
+  @TestTemplate
+  void testPreserveKeepsRowsWhenDatasetNamesChange() throws Exception {
+    SemanticModelEntity original = model("staff", source.id(), "employee");
+    backend.insert(original, false);
+    List<Association> initial = rows(original.id());
+    assertEquals(1, initial.size());
+
+    SemanticModelDefinition changedDefinition = model("staff", source.id(), 
"manager").definition();
+    SemanticModelEntity changed =
+        copy(original, original.name(), changedDefinition, 
SemanticModelSourceUpdate.preserve());
+    backend.update(original.nameIdentifier(), original.type(), old -> changed);
+
+    assertEquals(changed, 
service().getSemanticModelByIdentifier(original.nameIdentifier()));
+    assertEquals(initial, rows(original.id()));
+    assertEquals(2, snapshotCount(original.id()));
+  }
+
+  @TestTemplate
+  void testSourceRenamePreservesAssociationsAndDefinition() throws Exception {
+    SemanticModelEntity original = model("staff", source.id(), "employee");
+    backend.insert(original, false);
+    List<Association> initial = rows(original.id());
+    TableEntity renamed =
+        createTableEntity(source.id(), sourceNamespace, "employees_v2", 
AUDIT_INFO);
+    backend.update(source.nameIdentifier(), source.type(), old -> renamed);
+    assertEquals(initial, rows(original.id()));
+    assertEquals(original, 
service().getSemanticModelByIdentifier(original.nameIdentifier()));
+  }
+
+  @TestTemplate
+  void testOverwriteUsesPersistedModelIdentity() throws Exception {
+    SemanticModelEntity original = model("staff", source.id(), "employee");
+    backend.insert(original, false);
+    SemanticModelEntity overwrite = model("staff", source.id(), "manager");
+    assertNotEquals(original.id(), overwrite.id());
+    backend.insert(overwrite, true);
+    assertEquals(
+        original.id(), 
service().getSemanticModelByIdentifier(original.nameIdentifier()).id());
+    assertEquals(Set.of("manager"), datasetNames(original.id()));
+    assertEquals(2, rows(original.id()).size());
+    assertTrue(rows(overwrite.id()).isEmpty());
+  }
+
+  @TestTemplate
+  void testInvalidAssociationSetRollsBackCreateAndUpdate() throws Exception {
+    SemanticModelEntity original = model("staff", source.id(), "employee");
+    backend.insert(original, false);
+    List<Association> initial = rows(original.id());
+    SemanticModelSourceUpdate invalid =
+        SemanticModelSourceUpdate.replace(
+            List.of(new SemanticModelSource("wrong", source.type(), 
source.id())));
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            backend.update(
+                original.nameIdentifier(),
+                original.type(),
+                old -> copy(original, "renamed", original.definition(), 
invalid)));
+    assertEquals(original, 
service().getSemanticModelByIdentifier(original.nameIdentifier()));
+    assertEquals(initial, rows(original.id()));
+    assertEquals(
+        1, 
service().getSemanticModelPOByIdentifier(original.nameIdentifier()).getCurrentVersion());
+    SemanticModelEntity invalidCreate =
+        copy(model("invalid", source.id(), "employee"), "invalid", 
original.definition(), invalid);
+    assertThrows(IllegalArgumentException.class, () -> 
backend.insert(invalidCreate, false));
+    assertThrows(
+        NoSuchEntityException.class,
+        () -> 
service().getSemanticModelByIdentifier(invalidCreate.nameIdentifier()));
+    assertTrue(rows(invalidCreate.id()).isEmpty());
+  }
+
+  @TestTemplate
+  void testBatchedInsertAndFailureRollBackRetirementAndSnapshot() throws 
Exception {
+    String[] names = IntStream.range(0, 1001).mapToObj(i -> "dataset_" + 
i).toArray(String[]::new);
+    SemanticModelEntity original = model("staff", source.id(), names);
+    backend.insert(original, false);
+    List<Association> initial = rows(original.id());
+    assertEquals(1001, initial.size());
+    TableEntity blocked = createAndInsertTableEntity(sourceNamespace, 
"blocked");
+    List<SemanticModelSource> sources = new 
ArrayList<>(original.sourceUpdate().sources());
+    sources.set(1000, new SemanticModelSource(names[1000], source.type(), 
blocked.id()));
+    addConstraint("reject_source", "source_id <> " + blocked.id());
+    try {
+      IOException failure =
+          assertThrows(
+              IOException.class,
+              () ->
+                  backend.update(
+                      original.nameIdentifier(),
+                      original.type(),
+                      old ->
+                          copy(
+                              original,
+                              "renamed",
+                              original.definition(),
+                              SemanticModelSourceUpdate.replace(sources))));
+      assertTrue(
+          
ExceptionUtils.getStackTrace(failure).toLowerCase(Locale.ROOT).contains("reject_source"));
+      assertEquals(initial, rows(original.id()));
+      assertEquals(original, 
service().getSemanticModelByIdentifier(original.nameIdentifier()));
+      assertEquals(
+          1,
+          
service().getSemanticModelPOByIdentifier(original.nameIdentifier()).getCurrentVersion());
+      assertEquals(1, snapshotCount(original.id()));
+      assertThrows(
+          IOException.class,
+          () ->
+              backend.insert(
+                  copy(
+                      original,
+                      original.name(),
+                      original.definition(),
+                      SemanticModelSourceUpdate.replace(sources)),
+                  true));
+      assertEquals(initial, rows(original.id()));
+      assertEquals(original, 
service().getSemanticModelByIdentifier(original.nameIdentifier()));
+      assertEquals(1, snapshotCount(original.id()));
+      SemanticModelEntity invalidCreate = model("invalid", blocked.id(), 
"employee");
+      assertThrows(IOException.class, () -> backend.insert(invalidCreate, 
false));
+      assertThrows(
+          NoSuchEntityException.class,
+          () -> 
service().getSemanticModelByIdentifier(invalidCreate.nameIdentifier()));
+      assertTrue(rows(invalidCreate.id()).isEmpty());
+      assertEquals(0, snapshotCount(invalidCreate.id()));
+    } finally {
+      dropConstraint("reject_source");
+    }
+  }
+
+  @TestTemplate
+  void testModelAndParentDeletionRetireOnlyOwnedAssociations() throws 
Exception {
+    for (Entity.EntityType type :
+        List.of(
+            Entity.EntityType.SEMANTIC_MODEL,
+            Entity.EntityType.SCHEMA,
+            Entity.EntityType.CATALOG,
+            Entity.EntityType.METALAKE)) {
+      resetDatabase();
+      prepareSources();
+      SemanticModelEntity original = model("staff", source.id(), "employee");
+      backend.insert(original, false);
+      NameIdentifier target = deletionTarget(original, type);
+      assertTrue(backend.delete(target, type, true));
+      assertTrue(live(original.id()).isEmpty());
+      assertEquals(1, rows(original.id()).size());
+      assertTrue(rows(original.id()).get(0).deletedAt() > 0);
+      assertThrows(
+          NoSuchEntityException.class,
+          () -> 
service().getSemanticModelByIdentifier(original.nameIdentifier()));
+    }
+  }
+
+  @TestTemplate
+  void testRetirementFailureRollsBackModelAndEveryParentCascade() throws 
Exception {
+    for (Entity.EntityType type :
+        List.of(
+            Entity.EntityType.SEMANTIC_MODEL,
+            Entity.EntityType.SCHEMA,
+            Entity.EntityType.CATALOG,
+            Entity.EntityType.METALAKE)) {
+      resetDatabase();
+      prepareSources();
+      SemanticModelEntity original = model("staff", source.id(), "employee");
+      backend.insert(original, false);
+      List<Association> initial = rows(original.id());
+      NameIdentifier target = deletionTarget(original, type);
+      addConstraint("reject_retirement", "deleted_at = 0");
+      try {
+        Exception failure = assertThrows(Exception.class, () -> 
backend.delete(target, type, true));
+        assertTrue(
+            ExceptionUtils.getStackTrace(failure)
+                .toLowerCase(Locale.ROOT)
+                .contains("reject_retirement"));
+        assertEquals(original, 
service().getSemanticModelByIdentifier(original.nameIdentifier()));
+        assertTrue(backend.exists(target, type));
+        assertTrue(backend.exists(source.nameIdentifier(), source.type()));
+        assertEquals(initial, rows(original.id()));
+        assertEquals(1, snapshotCount(original.id()));
+      } finally {
+        dropConstraint("reject_retirement");
+      }
+    }
+  }
+
+  @TestTemplate
+  void testSchemaCascadeIncludesDescendantsAndPreservesOtherModels() throws 
Exception {
+    SemanticModelEntity parent = model("parent", source.id(), "employee");
+    backend.insert(parent, false);
+    createAndInsertSchema("lake", "models", "semantic:child");
+    Namespace originalNamespace = modelNamespace;
+    modelNamespace = Namespace.of("lake", "models", "semantic:child");
+    SemanticModelEntity child = model("child", source.id(), "manager");
+    backend.insert(child, false);
+    createAndInsertSchema("lake", "models", "other");
+    modelNamespace = Namespace.of("lake", "models", "other");
+    SemanticModelEntity other = model("other", source.id(), "employee");
+    backend.insert(other, false);
+    List<Association> unrelated = rows(other.id());
+    assertTrue(
+        backend.delete(
+            NameIdentifier.of(originalNamespace.levels()), 
Entity.EntityType.SCHEMA, true));
+    assertTrue(live(parent.id()).isEmpty());
+    assertTrue(live(child.id()).isEmpty());
+    assertEquals(unrelated, rows(other.id()));
+  }
+
+  @TestTemplate
+  void testSourceDeletionAndParentDeletionPreserveLiveAssociations() throws 
Exception {
+    for (Entity.EntityType type :
+        List.of(Entity.EntityType.TABLE, Entity.EntityType.SCHEMA, 
Entity.EntityType.CATALOG)) {
+      resetDatabase();
+      prepareSources();
+      SemanticModelEntity original = model("staff", source.id(), "employee");
+      backend.insert(original, false);
+      List<Association> initial = rows(original.id());
+      NameIdentifier target =
+          switch (type) {
+            case TABLE -> source.nameIdentifier();
+            case SCHEMA -> NameIdentifier.of(sourceNamespace.levels());
+            default -> NameIdentifier.of("lake", "sources");
+          };
+      assertTrue(backend.delete(target, type, true));
+      backend.hardDeleteLegacyData(type, Long.MAX_VALUE);
+      service().deleteSemanticModelMetasByLegacyTimeline(Long.MAX_VALUE, 100);
+      assertEquals(initial, rows(original.id()));
+      assertEquals(original, 
service().getSemanticModelByIdentifier(original.nameIdentifier()));
+    }
+  }
+
+  @TestTemplate
+  void testGcTimelineLimitAndSnapshotRetention() throws Exception {
+    SemanticModelEntity original = model("staff", source.id(), "employee", 
"manager");
+    backend.insert(original, false);
+    SemanticModelEntity replacement = model("staff", source.id(), "employee");
+    backend.update(
+        original.nameIdentifier(),
+        original.type(),
+        old ->
+            copy(original, original.name(), replacement.definition(), 
replacement.sourceUpdate()));
+    List<Association> current = live(original.id());
+    execute("UPDATE semantic_model_source_rel SET deleted_at = 10 WHERE 
deleted_at > 0");
+    assertEquals(0, service().deleteSemanticModelMetasByLegacyTimeline(10L, 
1));
+    assertEquals(1, service().deleteSemanticModelMetasByLegacyTimeline(11L, 
1));
+    assertEquals(2, rows(original.id()).size());
+    assertEquals(1, service().deleteSemanticModelMetasByLegacyTimeline(11L, 
1));
+    assertEquals(1, service().deleteSemanticModelVersionsByRetentionCount(1L, 
100));
+    service().deleteSemanticModelMetasByLegacyTimeline(Long.MAX_VALUE, 100);
+    assertEquals(current, rows(original.id()));
+    assertEquals(1, snapshotCount(original.id()));
+  }
+
+  @TestTemplate
+  void testGcAfterModelAndParentsHaveBeenPhysicallyRemoved() throws Exception {
+    SemanticModelEntity original = model("staff", source.id(), "employee", 
"manager");
+    backend.insert(original, false);
+    backend.delete(NameIdentifier.of("lake"), Entity.EntityType.METALAKE, 
true);
+    SessionUtils.doWithCommitAndFetchResult(
+        SemanticModelMetaMapper.class,
+        mapper -> 
mapper.deleteSemanticModelMetasByLegacyTimeline(Long.MAX_VALUE, 100));
+    
SchemaMetaService.getInstance().deleteSchemaMetasByLegacyTimeline(Long.MAX_VALUE,
 100);
+    
CatalogMetaService.getInstance().deleteCatalogMetasByLegacyTimeline(Long.MAX_VALUE,
 100);
+    
MetalakeMetaService.getInstance().deleteMetalakeMetasByLegacyTimeline(Long.MAX_VALUE,
 100);
+    assertEquals(2, rows(original.id()).size());
+    execute("UPDATE semantic_model_source_rel SET deleted_at = 10 WHERE 
deleted_at > 0");
+    assertEquals(1, service().deleteSemanticModelMetasByLegacyTimeline(11L, 
1));
+    assertEquals(1, rows(original.id()).size());
+    assertEquals(1, service().deleteSemanticModelMetasByLegacyTimeline(11L, 
1));
+    assertTrue(rows(original.id()).isEmpty());
+  }
+
+  private static SemanticModelMetaService service() {
+    return SemanticModelMetaService.getInstance();
+  }
+
+  private NameIdentifier deletionTarget(SemanticModelEntity model, 
Entity.EntityType type) {
+    return switch (type) {
+      case SEMANTIC_MODEL -> model.nameIdentifier();
+      case SCHEMA -> NameIdentifier.of(modelNamespace.levels());
+      case CATALOG -> NameIdentifier.of("lake", "models");
+      default -> NameIdentifier.of("lake");
+    };
+  }
+
+  private void addConstraint(String name, String condition) throws Exception {
+    execute(
+        "ALTER TABLE semantic_model_source_rel ADD CONSTRAINT "
+            + name
+            + " CHECK ("
+            + condition
+            + ")");
+  }
+
+  private void dropConstraint(String name) throws Exception {
+    execute(
+        "ALTER TABLE semantic_model_source_rel DROP "
+            + ("mysql".equals(backendType) ? "CHECK " : "CONSTRAINT ")
+            + name);
+  }
+
+  private void execute(String sql) throws Exception {
+    try (SqlSession session =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession();
+        Statement statement = session.getConnection().createStatement()) {
+      statement.execute(sql);
+      session.commit();
+    }
+  }
+
+  private List<Association> rows(long modelId) throws Exception {
+    try (SqlSession session =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession();
+        PreparedStatement statement =
+            session
+                .getConnection()
+                .prepareStatement(
+                    "SELECT id, metalake_id, dataset_name, source_type, 
source_id, deleted_at"
+                        + " FROM semantic_model_source_rel WHERE 
semantic_model_id = ? ORDER BY id")) {
+      statement.setLong(1, modelId);
+      try (ResultSet result = statement.executeQuery()) {
+        List<Association> rows = new ArrayList<>();
+        while (result.next()) {
+          rows.add(
+              new Association(
+                  result.getLong(1),
+                  result.getLong(2),
+                  result.getString(3),
+                  result.getString(4),
+                  result.getLong(5),
+                  result.getLong(6)));
+        }
+        return rows;
+      }
+    }
+  }
+
+  private List<Association> live(long modelId) throws Exception {
+    return rows(modelId).stream().filter(row -> row.deletedAt() == 0).toList();
+  }
+
+  private Set<Long> liveIds(long modelId) throws Exception {
+    return 
live(modelId).stream().map(Association::id).collect(Collectors.toSet());
+  }
+
+  private Set<String> datasetNames(long modelId) throws Exception {
+    return 
live(modelId).stream().map(Association::datasetName).collect(Collectors.toSet());
+  }
+
+  private Set<SemanticModelSource> sourceSet(long modelId) throws Exception {
+    return live(modelId).stream()
+        .map(
+            row ->
+                new SemanticModelSource(
+                    row.datasetName(), 
Entity.EntityType.valueOf(row.sourceType()), row.sourceId()))
+        .collect(Collectors.toSet());
+  }
+
+  private int snapshotCount(long modelId) throws Exception {
+    try (SqlSession session =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession();
+        PreparedStatement statement =
+            session
+                .getConnection()
+                .prepareStatement(
+                    "SELECT COUNT(*) FROM semantic_model_version_info WHERE 
semantic_model_id = ? AND deleted_at = 0")) {
+      statement.setLong(1, modelId);
+      try (ResultSet result = statement.executeQuery()) {
+        result.next();
+        return result.getInt(1);
+      }
+    }
+  }
+
+  private SemanticModelEntity model(String name, long sourceId, String... 
datasets) {
+    SemanticModelDefinition definition =
+        SemanticModelDefinition.builder()
+            .withDatasets(
+                Arrays.stream(datasets)
+                    .map(
+                        dataset ->
+                            Dataset.builder()
+                                .withName(dataset)
+                                .withSource(NameIdentifier.of("sources", "hr", 
"employees"))
+                                .build())
+                    .toArray(Dataset[]::new))
+            .build();
+    return SemanticModelEntity.builder()
+        .withId(RandomIdGenerator.INSTANCE.nextId())
+        .withName(name)
+        .withNamespace(modelNamespace)
+        .withDefinition(definition)
+        .withProperties(Map.of())
+        .withAuditInfo(AUDIT_INFO)
+        .withSourceUpdate(
+            SemanticModelSourceUpdate.replace(
+                Arrays.stream(datasets)
+                    .map(
+                        dataset ->
+                            new SemanticModelSource(dataset, 
Entity.EntityType.TABLE, sourceId))
+                    .toList()))
+        .build();
+  }
+
+  private SemanticModelEntity copy(
+      SemanticModelEntity original,
+      String name,
+      SemanticModelDefinition definition,
+      SemanticModelSourceUpdate update) {
+    return SemanticModelEntity.builder()
+        .withId(original.id())
+        .withName(name)
+        .withNamespace(original.namespace())
+        .withDefinition(definition)
+        .withComment("Updated comment")
+        .withProperties(Map.of("updated", "true"))
+        .withAuditInfo(AUDIT_INFO)
+        .withSourceUpdate(update)
+        .build();
+  }
+
+  @SuppressWarnings("UnusedVariable") // Error Prone does not recognize record 
component accessors.
+  private record Association(
+      long id,
+      long metalakeId,
+      String datasetName,
+      String sourceType,
+      long sourceId,
+      long deletedAt) {}
+}
diff --git a/scripts/h2/schema-2.0.0-h2.sql b/scripts/h2/schema-2.0.0-h2.sql
index a1fc82b59d..035007284f 100644
--- a/scripts/h2/schema-2.0.0-h2.sql
+++ b/scripts/h2/schema-2.0.0-h2.sql
@@ -781,3 +781,17 @@ CREATE TABLE IF NOT EXISTS `table_snapshot_metrics` (
 ) COMMENT='table snapshot metrics metadata';
 CREATE INDEX IF NOT EXISTS `idx_tsm_tid_pid_sid` ON 
`table_snapshot_metrics`(`table_id`, `policy_id`, `snapshot_id`);
 CREATE INDEX IF NOT EXISTS `idx_tsm_created_at` ON 
`table_snapshot_metrics`(`created_at`);
+
+CREATE TABLE IF NOT EXISTS `semantic_model_source_rel` (
+    `id` BIGINT AUTO_INCREMENT COMMENT 'auto increment id',
+    `metalake_id` BIGINT NOT NULL COMMENT 'containing metalake id',
+    `semantic_model_id` BIGINT NOT NULL COMMENT 'containing semantic model id',
+    `dataset_name` CLOB NOT NULL COMMENT 'exact dataset name in the current 
definition',
+    `source_type` VARCHAR(64) NOT NULL COMMENT 'TABLE or VIEW',
+    `source_id` BIGINT NOT NULL COMMENT 'resolved source object id',
+    `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT 'association retired at',
+    PRIMARY KEY (`id`)
+) COMMENT='current semantic model dataset source associations';
+CREATE INDEX IF NOT EXISTS `idx_smsr_model` ON `semantic_model_source_rel` 
(`semantic_model_id`, `deleted_at`);
+CREATE INDEX IF NOT EXISTS `idx_smsr_source` ON `semantic_model_source_rel` 
(`metalake_id`, `source_type`, `source_id`, `deleted_at`);
+CREATE INDEX IF NOT EXISTS `idx_smsr_deleted_at` ON 
`semantic_model_source_rel` (`deleted_at`);
diff --git a/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql 
b/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
index 1151ba1755..d246e6cea0 100644
--- a/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
+++ b/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
@@ -232,3 +232,17 @@ ALTER TABLE `policy_meta`
 
 -- Role deletion soft-deletes relation rows by role_id.
 CREATE INDEX IF NOT EXISTS `group_role_rel_idx_role_id` ON `group_role_rel` 
(`role_id`);
+
+CREATE TABLE IF NOT EXISTS `semantic_model_source_rel` (
+    `id` BIGINT AUTO_INCREMENT COMMENT 'auto increment id',
+    `metalake_id` BIGINT NOT NULL COMMENT 'containing metalake id',
+    `semantic_model_id` BIGINT NOT NULL COMMENT 'containing semantic model id',
+    `dataset_name` CLOB NOT NULL COMMENT 'exact dataset name in the current 
definition',
+    `source_type` VARCHAR(64) NOT NULL COMMENT 'TABLE or VIEW',
+    `source_id` BIGINT NOT NULL COMMENT 'resolved source object id',
+    `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT 'association retired at',
+    PRIMARY KEY (`id`)
+) COMMENT='current semantic model dataset source associations';
+CREATE INDEX IF NOT EXISTS `idx_smsr_model` ON `semantic_model_source_rel` 
(`semantic_model_id`, `deleted_at`);
+CREATE INDEX IF NOT EXISTS `idx_smsr_source` ON `semantic_model_source_rel` 
(`metalake_id`, `source_type`, `source_id`, `deleted_at`);
+CREATE INDEX IF NOT EXISTS `idx_smsr_deleted_at` ON 
`semantic_model_source_rel` (`deleted_at`);
diff --git a/scripts/mysql/schema-2.0.0-mysql.sql 
b/scripts/mysql/schema-2.0.0-mysql.sql
index 93b12fd127..56ec88cd70 100644
--- a/scripts/mysql/schema-2.0.0-mysql.sql
+++ b/scripts/mysql/schema-2.0.0-mysql.sql
@@ -768,3 +768,18 @@ CREATE TABLE IF NOT EXISTS `table_snapshot_metrics` (
     KEY `idx_tsm_tid_pid_sid` (`table_id`, `policy_id`, `snapshot_id`),
     KEY `idx_tsm_created_at` (`created_at`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'table 
snapshot metrics metadata';
+
+CREATE TABLE IF NOT EXISTS `semantic_model_source_rel` (
+    `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment 
id',
+    `metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'containing metalake 
id',
+    `semantic_model_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'containing 
semantic model id',
+    `dataset_name` MEDIUMTEXT NOT NULL COMMENT 'exact dataset name in the 
current definition',
+    `source_type` VARCHAR(64) NOT NULL COMMENT 'TABLE or VIEW',
+    `source_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'resolved source object 
id',
+    `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'association 
retired at',
+    PRIMARY KEY (`id`),
+    KEY `idx_smsr_model` (`semantic_model_id`, `deleted_at`),
+    KEY `idx_smsr_source` (`metalake_id`, `source_type`, `source_id`, 
`deleted_at`),
+    KEY `idx_smsr_deleted_at` (`deleted_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
+  COMMENT 'current semantic model dataset source associations';
diff --git a/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql 
b/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
index 90cab77bea..bf43eae536 100644
--- a/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
+++ b/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
@@ -697,3 +697,18 @@ SET @ddl = IF(EXISTS(SELECT 1 FROM 
information_schema.statistics
 PREPARE stmt FROM @ddl;
 EXECUTE stmt;
 DEALLOCATE PREPARE stmt;
+
+CREATE TABLE IF NOT EXISTS `semantic_model_source_rel` (
+    `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment 
id',
+    `metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'containing metalake 
id',
+    `semantic_model_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'containing 
semantic model id',
+    `dataset_name` MEDIUMTEXT NOT NULL COMMENT 'exact dataset name in the 
current definition',
+    `source_type` VARCHAR(64) NOT NULL COMMENT 'TABLE or VIEW',
+    `source_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'resolved source object 
id',
+    `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'association 
retired at',
+    PRIMARY KEY (`id`),
+    KEY `idx_smsr_model` (`semantic_model_id`, `deleted_at`),
+    KEY `idx_smsr_source` (`metalake_id`, `source_type`, `source_id`, 
`deleted_at`),
+    KEY `idx_smsr_deleted_at` (`deleted_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
+  COMMENT 'current semantic model dataset source associations';
diff --git a/scripts/postgresql/schema-2.0.0-postgresql.sql 
b/scripts/postgresql/schema-2.0.0-postgresql.sql
index f9e6bfe7dc..1612123497 100644
--- a/scripts/postgresql/schema-2.0.0-postgresql.sql
+++ b/scripts/postgresql/schema-2.0.0-postgresql.sql
@@ -1339,3 +1339,24 @@ COMMENT ON COLUMN table_snapshot_metrics.policy_id IS 
'policy id from policy_met
 COMMENT ON COLUMN table_snapshot_metrics.snapshot_id IS 'Iceberg snapshot id';
 COMMENT ON COLUMN table_snapshot_metrics.metrics_value IS 'metrics value as 
JSON';
 COMMENT ON COLUMN table_snapshot_metrics.created_at IS 'creation timestamp in 
milliseconds';
+
+CREATE TABLE IF NOT EXISTS semantic_model_source_rel (
+    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+    metalake_id BIGINT NOT NULL,
+    semantic_model_id BIGINT NOT NULL,
+    dataset_name TEXT NOT NULL,
+    source_type VARCHAR(64) NOT NULL,
+    source_id BIGINT NOT NULL,
+    deleted_at BIGINT NOT NULL DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS idx_smsr_model ON semantic_model_source_rel 
(semantic_model_id, deleted_at);
+CREATE INDEX IF NOT EXISTS idx_smsr_source ON semantic_model_source_rel 
(metalake_id, source_type, source_id, deleted_at);
+CREATE INDEX IF NOT EXISTS idx_smsr_deleted_at ON semantic_model_source_rel 
(deleted_at);
+COMMENT ON TABLE semantic_model_source_rel IS 'current semantic model dataset 
source associations';
+COMMENT ON COLUMN semantic_model_source_rel.id IS 'auto increment id';
+COMMENT ON COLUMN semantic_model_source_rel.metalake_id IS 'containing 
metalake id';
+COMMENT ON COLUMN semantic_model_source_rel.semantic_model_id IS 'containing 
semantic model id';
+COMMENT ON COLUMN semantic_model_source_rel.dataset_name IS 'exact dataset 
name in the current definition';
+COMMENT ON COLUMN semantic_model_source_rel.source_type IS 'TABLE or VIEW';
+COMMENT ON COLUMN semantic_model_source_rel.source_id IS 'resolved source 
object id';
+COMMENT ON COLUMN semantic_model_source_rel.deleted_at IS 'association retired 
at';
diff --git a/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql 
b/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
index 28906b2de3..3424170694 100644
--- a/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
+++ b/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
@@ -344,3 +344,24 @@ COMMENT ON COLUMN policy_meta.occ_version IS 'policy 
optimistic concurrency vers
 -- Role deletion soft-deletes relation rows by role_id.
 CREATE INDEX IF NOT EXISTS user_role_rel_idx_role_id ON user_role_rel 
(role_id);
 CREATE INDEX IF NOT EXISTS group_role_rel_idx_role_id ON group_role_rel 
(role_id);
+
+CREATE TABLE IF NOT EXISTS semantic_model_source_rel (
+    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+    metalake_id BIGINT NOT NULL,
+    semantic_model_id BIGINT NOT NULL,
+    dataset_name TEXT NOT NULL,
+    source_type VARCHAR(64) NOT NULL,
+    source_id BIGINT NOT NULL,
+    deleted_at BIGINT NOT NULL DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS idx_smsr_model ON semantic_model_source_rel 
(semantic_model_id, deleted_at);
+CREATE INDEX IF NOT EXISTS idx_smsr_source ON semantic_model_source_rel 
(metalake_id, source_type, source_id, deleted_at);
+CREATE INDEX IF NOT EXISTS idx_smsr_deleted_at ON semantic_model_source_rel 
(deleted_at);
+COMMENT ON TABLE semantic_model_source_rel IS 'current semantic model dataset 
source associations';
+COMMENT ON COLUMN semantic_model_source_rel.id IS 'auto increment id';
+COMMENT ON COLUMN semantic_model_source_rel.metalake_id IS 'containing 
metalake id';
+COMMENT ON COLUMN semantic_model_source_rel.semantic_model_id IS 'containing 
semantic model id';
+COMMENT ON COLUMN semantic_model_source_rel.dataset_name IS 'exact dataset 
name in the current definition';
+COMMENT ON COLUMN semantic_model_source_rel.source_type IS 'TABLE or VIEW';
+COMMENT ON COLUMN semantic_model_source_rel.source_id IS 'resolved source 
object id';
+COMMENT ON COLUMN semantic_model_source_rel.deleted_at IS 'association retired 
at';

Reply via email to