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

roryqi 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 0d97d6433d [#11067] feat(authn): Add built-in IdP user meta storage 
model (#11066)
0d97d6433d is described below

commit 0d97d6433d4da8929811f8d847a523c3ccaf5100
Author: MaSai <[email protected]>
AuthorDate: Fri May 15 20:34:08 2026 +0800

    [#11067] feat(authn): Add built-in IdP user meta storage model (#11066)
    
    ### What changes were proposed in this pull request?
    
    This PR adds the built-in IdP user metadata relational storage model
    under `plugins:idp-basic`.
    
    It introduces:
    - the `IdpUserPO` persistent object for built-in IdP users
    - the `IdpUserMetaMapper` MyBatis mapper for querying, inserting,
    updating passwords, soft deleting, and cleaning legacy user metadata
    - the `IdpUserMetaSQLProviderFactory` with MySQL, H2, and PostgreSQL SQL
    providers
    - mapper package registration for the built-in IdP user mapper in the
    `idp-basic` plugin
    - plugin dependency updates required by the new mapper and mapper tests
    - unit tests covering the user PO, mapper package provider, SQL
    providers, and mapper behavior
    
    This PR also follows the existing project patterns used in the
    relational storage layer:
    - the `IdpUserPO` builder follows the same copy-on-build style already
    used by `PolicyPO`
    - the SQL provider factory and PostgreSQL cleanup SQL keep the same
    style as existing relational provider implementations in `core`
    - multi-database mapper execution is controlled by `dockerTest=true`,
    consistent with the current backend test extension pattern
    
    ### Why are the changes needed?
    
    The built-in IdP needs a relational user metadata model so user records
    can be persisted and queried consistently across the supported JDBC
    backends.
    
    Fix: #11067
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    ```bash
    ./gradlew --no-daemon :plugins:idp-basic:test -PskipITs 
-PskipDockerTests=false \
      --tests 
org.apache.gravitino.idp.basic.storage.relational.po.TestIdpUserPO \
      --tests 
org.apache.gravitino.idp.basic.storage.relational.mapper.provider.base.TestIdpUserMetaBaseSQLProvider
 \
      --tests 
org.apache.gravitino.idp.basic.storage.relational.mapper.provider.mysql.TestIdpUserMetaMySQLProvider
 \
      --tests 
org.apache.gravitino.idp.basic.storage.relational.mapper.provider.postgresql.TestIdpUserMetaPostgreSQLProvider
 \
      --tests 
org.apache.gravitino.idp.basic.storage.relational.mapper.TestIdpUserMetaStorage
    ```
    
    `TestIdpUserMetaStorage` covers H2, MySQL, and PostgreSQL when
    `-PskipDockerTests=false` is set.
---
 plugins/idp-basic/build.gradle.kts                 |  21 ++
 .../idp/storage/mapper/IdpUserMetaMapper.java      |  62 +++++
 .../mapper/IdpUserMetaSQLProviderFactory.java      | 119 ++++++++
 .../provider/IdpBasicMapperPackageProvider.java}   |  26 +-
 .../provider/base/IdpUserMetaBaseSQLProvider.java  |  93 +++++++
 .../mapper/provider/h2/IdpUserMetaH2Provider.java} |  24 +-
 .../postgresql/IdpUserMetaPostgreSQLProvider.java  |  42 +++
 .../gravitino/idp/storage/po/IdpUserPO.java}       |  39 +--
 ...elational.mapper.provider.MapperPackageProvider |  18 ++
 .../storage/mapper/AbstractIdpMetaStorageTest.java | 301 +++++++++++++++++++++
 .../idp/storage/mapper/TestIdpUserMetaStorage.java | 169 ++++++++++++
 .../base/TestIdpUserMetaBaseSQLProvider.java       | 169 ++++++++++++
 .../provider/h2/TestIdpUserMetaH2Provider.java}    |  28 +-
 .../TestIdpUserMetaPostgreSQLProvider.java         |  45 +++
 .../gravitino/idp/storage/po/TestIdpUserPO.java    | 105 +++++++
 15 files changed, 1195 insertions(+), 66 deletions(-)

diff --git a/plugins/idp-basic/build.gradle.kts 
b/plugins/idp-basic/build.gradle.kts
index e10209214c..3229f452c8 100644
--- a/plugins/idp-basic/build.gradle.kts
+++ b/plugins/idp-basic/build.gradle.kts
@@ -24,10 +24,31 @@ plugins {
 }
 
 dependencies {
+  annotationProcessor(libs.lombok)
+
+  implementation(project(":core"))
+
   implementation(libs.bcprov.jdk18on)
   implementation(libs.commons.lang3)
   implementation(libs.guava)
+  implementation(libs.mybatis)
+
+  compileOnly(libs.lombok)
+
+  testImplementation(project(":common"))
+  testImplementation(project(":core"))
+  testImplementation(project(":integration-test-common", "testArtifacts"))
+
+  testImplementation(libs.awaitility)
   testImplementation(libs.junit.jupiter.api)
+  testImplementation(libs.junit.jupiter.params)
+  testImplementation(libs.commons.io)
+  testImplementation(libs.mysql.driver)
+  testImplementation(libs.postgresql.driver)
+  testImplementation(libs.testcontainers)
+  testImplementation(libs.testcontainers.mysql)
+  testImplementation(libs.testcontainers.postgresql)
+
   testRuntimeOnly(libs.junit.jupiter.engine)
 }
 
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaMapper.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaMapper.java
new file mode 100644
index 0000000000..9aa677ebe2
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaMapper.java
@@ -0,0 +1,62 @@
+/*
+ * 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.idp.storage.mapper;
+
+import java.util.List;
+import org.apache.gravitino.idp.storage.po.IdpUserPO;
+import org.apache.ibatis.annotations.DeleteProvider;
+import org.apache.ibatis.annotations.InsertProvider;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.SelectProvider;
+import org.apache.ibatis.annotations.UpdateProvider;
+
+/**
+ * A MyBatis mapper for built-in IdP user metadata operations.
+ *
+ * <p>This interface defines the SQL statements MyBatis executes for the 
built-in IdP user metadata
+ * store. The SQLs are provided through {@code *Provider} annotations on this 
mapper interface. See
+ * the <a href="https://mybatis.org/mybatis-3/getting-started.html";>MyBatis 
getting started
+ * guide</a>.
+ */
+public interface IdpUserMetaMapper {
+  String IDP_USER_TABLE_NAME = "idp_user_meta";
+
+  @SelectProvider(type = IdpUserMetaSQLProviderFactory.class, method = 
"selectIdpUser")
+  IdpUserPO selectIdpUser(@Param("username") String username);
+
+  @SelectProvider(type = IdpUserMetaSQLProviderFactory.class, method = 
"selectIdpUsers")
+  List<IdpUserPO> selectIdpUsers(@Param("usernames") List<String> usernames);
+
+  @InsertProvider(type = IdpUserMetaSQLProviderFactory.class, method = 
"insertIdpUser")
+  void insertIdpUser(@Param("userMeta") IdpUserPO userPO);
+
+  @UpdateProvider(type = IdpUserMetaSQLProviderFactory.class, method = 
"updateIdpUserPassword")
+  Integer updateIdpUserPassword(
+      @Param("userId") Long userId, @Param("passwordHash") String 
passwordHash);
+
+  @UpdateProvider(type = IdpUserMetaSQLProviderFactory.class, method = 
"softDeleteIdpUser")
+  Integer softDeleteIdpUser(@Param("userId") Long userId);
+
+  @DeleteProvider(
+      type = IdpUserMetaSQLProviderFactory.class,
+      method = "deleteIdpUserMetasByLegacyTimeline")
+  Integer deleteIdpUserMetasByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit);
+}
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaSQLProviderFactory.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaSQLProviderFactory.java
new file mode 100644
index 0000000000..27e77781ed
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/IdpUserMetaSQLProviderFactory.java
@@ -0,0 +1,119 @@
+/*
+ * 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.idp.storage.mapper;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.List;
+import java.util.Map;
+import 
org.apache.gravitino.idp.storage.mapper.provider.base.IdpUserMetaBaseSQLProvider;
+import 
org.apache.gravitino.idp.storage.mapper.provider.h2.IdpUserMetaH2Provider;
+import 
org.apache.gravitino.idp.storage.mapper.provider.postgresql.IdpUserMetaPostgreSQLProvider;
+import org.apache.gravitino.idp.storage.po.IdpUserPO;
+import org.apache.gravitino.storage.relational.JDBCBackend.JDBCBackendType;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.annotations.Param;
+
+public class IdpUserMetaSQLProviderFactory {
+  private static final IdpUserMetaBaseSQLProvider IDP_USER_META_BASE_PROVIDER =
+      new IdpUserMetaBaseSQLProvider();
+  private static final IdpUserMetaBaseSQLProvider IDP_USER_META_H2_PROVIDER =
+      new IdpUserMetaH2Provider();
+  private static final IdpUserMetaBaseSQLProvider 
IDP_USER_META_POSTGRESQL_PROVIDER =
+      new IdpUserMetaPostgreSQLProvider();
+
+  private static final Map<JDBCBackendType, IdpUserMetaBaseSQLProvider>
+      IDP_USER_META_SQL_PROVIDER_MAP =
+          ImmutableMap.of(
+              JDBCBackendType.MYSQL, IDP_USER_META_BASE_PROVIDER,
+              JDBCBackendType.H2, IDP_USER_META_H2_PROVIDER,
+              JDBCBackendType.POSTGRESQL, IDP_USER_META_POSTGRESQL_PROVIDER);
+
+  static IdpUserMetaBaseSQLProvider getProvider(String databaseId) {
+    if (databaseId == null) {
+      throw new IllegalStateException(
+          "MyBatis databaseId is not configured for IdP user SQL providers.");
+    }
+
+    try {
+      JDBCBackendType jdbcBackendType = JDBCBackendType.fromString(databaseId);
+      IdpUserMetaBaseSQLProvider provider = 
IDP_USER_META_SQL_PROVIDER_MAP.get(jdbcBackendType);
+      if (provider != null) {
+        return provider;
+      }
+
+      throw new IllegalStateException(
+          String.format(
+              "No IdP user SQL provider registered for backend %s (databaseId: 
%s)",
+              jdbcBackendType, databaseId));
+    } catch (IllegalArgumentException e) {
+      throw new IllegalStateException(
+          String.format(
+              "Unsupported IdP user SQL provider databaseId: %s, supported 
backends: %s",
+              databaseId, IDP_USER_META_SQL_PROVIDER_MAP.keySet()),
+          e);
+    }
+  }
+
+  public static IdpUserMetaBaseSQLProvider h2Provider() {
+    return IDP_USER_META_H2_PROVIDER;
+  }
+
+  public static IdpUserMetaBaseSQLProvider mysqlProvider() {
+    return IDP_USER_META_BASE_PROVIDER;
+  }
+
+  public static IdpUserMetaBaseSQLProvider postgresqlProvider() {
+    return IDP_USER_META_POSTGRESQL_PROVIDER;
+  }
+
+  public static String selectIdpUser(@Param("username") String username) {
+    return getProvider(currentDatabaseId()).selectIdpUser(username);
+  }
+
+  public static String selectIdpUsers(@Param("usernames") List<String> 
usernames) {
+    return getProvider(currentDatabaseId()).selectIdpUsers(usernames);
+  }
+
+  public static String insertIdpUser(@Param("userMeta") IdpUserPO userPO) {
+    return getProvider(currentDatabaseId()).insertIdpUser(userPO);
+  }
+
+  public static String updateIdpUserPassword(
+      @Param("userId") Long userId, @Param("passwordHash") String 
passwordHash) {
+    return getProvider(currentDatabaseId()).updateIdpUserPassword(userId, 
passwordHash);
+  }
+
+  public static String softDeleteIdpUser(@Param("userId") Long userId) {
+    return getProvider(currentDatabaseId()).softDeleteIdpUser(userId);
+  }
+
+  public static String deleteIdpUserMetasByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
+    return getProvider(currentDatabaseId())
+        .deleteIdpUserMetasByLegacyTimeline(legacyTimeline, limit);
+  }
+
+  private static String currentDatabaseId() {
+    return SqlSessionFactoryHelper.getInstance()
+        .getSqlSessionFactory()
+        .getConfiguration()
+        .getDatabaseId();
+  }
+}
diff --git a/plugins/idp-basic/build.gradle.kts 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/IdpBasicMapperPackageProvider.java
similarity index 60%
copy from plugins/idp-basic/build.gradle.kts
copy to 
plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/IdpBasicMapperPackageProvider.java
index e10209214c..3cf51de9fa 100644
--- a/plugins/idp-basic/build.gradle.kts
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/IdpBasicMapperPackageProvider.java
@@ -16,24 +16,18 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+package org.apache.gravitino.idp.storage.mapper.provider;
 
-plugins {
-  `maven-publish`
-  id("java")
-  id("idea")
-}
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.apache.gravitino.idp.storage.mapper.IdpUserMetaMapper;
+import 
org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider;
 
-dependencies {
-  implementation(libs.bcprov.jdk18on)
-  implementation(libs.commons.lang3)
-  implementation(libs.guava)
-  testImplementation(libs.junit.jupiter.api)
-  testRuntimeOnly(libs.junit.jupiter.engine)
-}
+/** Supplies built-in IdP mapper classes from the idp-basic plugin. */
+public class IdpBasicMapperPackageProvider implements MapperPackageProvider {
 
-tasks {
-  test {
-    environment("GRAVITINO_HOME", rootDir.path)
-    environment("GRAVITINO_TEST", "true")
+  @Override
+  public List<Class<?>> getMapperClasses() {
+    return ImmutableList.of(IdpUserMetaMapper.class);
   }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserMetaBaseSQLProvider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserMetaBaseSQLProvider.java
new file mode 100644
index 0000000000..5c026d1db5
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/base/IdpUserMetaBaseSQLProvider.java
@@ -0,0 +1,93 @@
+/*
+ * 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.idp.storage.mapper.provider.base;
+
+import java.util.List;
+import org.apache.gravitino.idp.storage.mapper.IdpUserMetaMapper;
+import org.apache.gravitino.idp.storage.po.IdpUserPO;
+import org.apache.ibatis.annotations.Param;
+
+public class IdpUserMetaBaseSQLProvider {
+  public String selectIdpUser(@Param("username") String username) {
+    return "SELECT user_id as userId, user_name as userName, password_hash as 
passwordHash,"
+        + " current_version as currentVersion,"
+        + " last_version as lastVersion, deleted_at as deletedAt"
+        + " FROM "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " WHERE user_name = #{username} AND deleted_at = 0";
+  }
+
+  public String selectIdpUsers(@Param("usernames") List<String> usernames) {
+    return "<script>"
+        + "SELECT user_id as userId, user_name as userName, password_hash as 
passwordHash,"
+        + " current_version as currentVersion,"
+        + " last_version as lastVersion, deleted_at as deletedAt"
+        + " FROM "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " WHERE deleted_at = 0 "
+        + "<foreach collection='usernames' item='username'"
+        + " open='AND user_name IN (' separator=',' close=')'>"
+        + "#{username}"
+        + "</foreach>"
+        + "</script>";
+  }
+
+  public String insertIdpUser(@Param("userMeta") IdpUserPO userPO) {
+    return "INSERT INTO "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " (user_id, user_name, password_hash, current_version, last_version, 
deleted_at)"
+        + " VALUES ("
+        + " #{userMeta.userId},"
+        + " #{userMeta.userName},"
+        + " #{userMeta.passwordHash},"
+        + " #{userMeta.currentVersion},"
+        + " #{userMeta.lastVersion},"
+        + " #{userMeta.deletedAt}"
+        + " )";
+  }
+
+  public String updateIdpUserPassword(
+      @Param("userId") Long userId, @Param("passwordHash") String 
passwordHash) {
+    return "UPDATE "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " SET password_hash = #{passwordHash}"
+        + " WHERE user_id = #{userId}"
+        + " AND deleted_at = 0";
+  }
+
+  public String softDeleteIdpUser(@Param("userId") Long userId) {
+    return "UPDATE "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " SET deleted_at = "
+        + currentTimeMillisExpression()
+        + " WHERE user_id = #{userId} AND deleted_at = 0";
+  }
+
+  public String deleteIdpUserMetasByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
+    return "DELETE FROM "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit}";
+  }
+
+  protected String currentTimeMillisExpression() {
+    return "(UNIX_TIMESTAMP() * 1000.0)";
+  }
+}
diff --git a/plugins/idp-basic/build.gradle.kts 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/h2/IdpUserMetaH2Provider.java
similarity index 64%
copy from plugins/idp-basic/build.gradle.kts
copy to 
plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/h2/IdpUserMetaH2Provider.java
index e10209214c..8731a3354d 100644
--- a/plugins/idp-basic/build.gradle.kts
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/h2/IdpUserMetaH2Provider.java
@@ -17,23 +17,15 @@
  * under the License.
  */
 
-plugins {
-  `maven-publish`
-  id("java")
-  id("idea")
-}
+package org.apache.gravitino.idp.storage.mapper.provider.h2;
 
-dependencies {
-  implementation(libs.bcprov.jdk18on)
-  implementation(libs.commons.lang3)
-  implementation(libs.guava)
-  testImplementation(libs.junit.jupiter.api)
-  testRuntimeOnly(libs.junit.jupiter.engine)
-}
+import 
org.apache.gravitino.idp.storage.mapper.provider.base.IdpUserMetaBaseSQLProvider;
+
+/** SQL provider for IdP user metadata statements on H2 backends. */
+public class IdpUserMetaH2Provider extends IdpUserMetaBaseSQLProvider {
 
-tasks {
-  test {
-    environment("GRAVITINO_HOME", rootDir.path)
-    environment("GRAVITINO_TEST", "true")
+  @Override
+  protected String currentTimeMillisExpression() {
+    return "DATEDIFF('MILLISECOND', TIMESTAMP '1970-01-01 00:00:00', 
CURRENT_TIMESTAMP())";
   }
 }
diff --git 
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserMetaPostgreSQLProvider.java
 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserMetaPostgreSQLProvider.java
new file mode 100644
index 0000000000..c41dd86589
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/IdpUserMetaPostgreSQLProvider.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.idp.storage.mapper.provider.postgresql;
+
+import org.apache.gravitino.idp.storage.mapper.IdpUserMetaMapper;
+import 
org.apache.gravitino.idp.storage.mapper.provider.base.IdpUserMetaBaseSQLProvider;
+import org.apache.ibatis.annotations.Param;
+
+public class IdpUserMetaPostgreSQLProvider extends IdpUserMetaBaseSQLProvider {
+
+  @Override
+  protected String currentTimeMillisExpression() {
+    return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
+  }
+
+  @Override
+  public String deleteIdpUserMetasByLegacyTimeline(
+      @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
+    return "DELETE FROM "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " WHERE user_id IN (SELECT user_id FROM "
+        + IdpUserMetaMapper.IDP_USER_TABLE_NAME
+        + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit})";
+  }
+}
diff --git a/plugins/idp-basic/build.gradle.kts 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/po/IdpUserPO.java
similarity index 57%
copy from plugins/idp-basic/build.gradle.kts
copy to 
plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/po/IdpUserPO.java
index e10209214c..cbe03a610b 100644
--- a/plugins/idp-basic/build.gradle.kts
+++ 
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/storage/po/IdpUserPO.java
@@ -16,24 +16,27 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+package org.apache.gravitino.idp.storage.po;
 
-plugins {
-  `maven-publish`
-  id("java")
-  id("idea")
-}
-
-dependencies {
-  implementation(libs.bcprov.jdk18on)
-  implementation(libs.commons.lang3)
-  implementation(libs.guava)
-  testImplementation(libs.junit.jupiter.api)
-  testRuntimeOnly(libs.junit.jupiter.engine)
-}
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
 
-tasks {
-  test {
-    environment("GRAVITINO_HOME", rootDir.path)
-    environment("GRAVITINO_TEST", "true")
-  }
+@Getter
+@EqualsAndHashCode
+@ToString
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder(setterPrefix = "with")
+public class IdpUserPO {
+  private Long userId;
+  private String userName;
+  private String passwordHash;
+  private Long currentVersion;
+  private Long lastVersion;
+  private Long deletedAt;
 }
diff --git 
a/plugins/idp-basic/src/main/resources/META-INF/services/org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider
 
b/plugins/idp-basic/src/main/resources/META-INF/services/org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider
new file mode 100644
index 0000000000..fac41a8f06
--- /dev/null
+++ 
b/plugins/idp-basic/src/main/resources/META-INF/services/org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider
@@ -0,0 +1,18 @@
+# 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.
+
+org.apache.gravitino.idp.storage.mapper.provider.IdpBasicMapperPackageProvider
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/AbstractIdpMetaStorageTest.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/AbstractIdpMetaStorageTest.java
new file mode 100644
index 0000000000..06f97b8288
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/AbstractIdpMetaStorageTest.java
@@ -0,0 +1,301 @@
+/*
+ * 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.idp.storage.mapper;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.UUID;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.config.ConfigConstants;
+import org.apache.gravitino.idp.storage.po.IdpUserPO;
+import org.apache.gravitino.integration.test.container.ContainerSuite;
+import org.apache.gravitino.integration.test.container.MySQLContainer;
+import org.apache.gravitino.integration.test.container.PostgreSQLContainer;
+import org.apache.gravitino.integration.test.util.TestDatabaseName;
+import org.apache.gravitino.storage.relational.JDBCBackend;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.session.SqlSession;
+import org.junit.jupiter.api.AfterEach;
+
+abstract class AbstractIdpMetaStorageTest {
+  private static final String H2_BACKEND = "h2";
+  private static final String MYSQL_BACKEND = "mysql";
+  private static final String POSTGRESQL_BACKEND = "postgresql";
+  private static final TestDatabaseName MYSQL_TEST_DATABASE = 
TestDatabaseName.MYSQL_JDBC_BACKEND;
+  private static final TestDatabaseName POSTGRESQL_TEST_DATABASE = 
TestDatabaseName.PG_JDBC_BACKEND;
+
+  protected JDBCBackend backend;
+  protected SqlSession sharedSession;
+  protected IdpUserMetaMapper idpUserMetaMapper;
+
+  private Config config;
+  private Path h2Path;
+
+  static Stream<String> storageProvider() {
+    return Stream.of(H2_BACKEND, MYSQL_BACKEND, POSTGRESQL_BACKEND);
+  }
+
+  @AfterEach
+  void closeSuite() throws IOException {
+    closeSession();
+    if (backend != null) {
+      backend.close();
+      backend = null;
+    }
+
+    SqlSessionFactoryHelper.getInstance().close();
+    ContainerSuite.getInstance().close();
+
+    if (h2Path != null && Files.exists(h2Path)) {
+      deleteDirectory(h2Path);
+      h2Path = null;
+    }
+  }
+
+  protected void init(String type) throws IOException {
+    config = createBackendConfig(type);
+    backend = new JDBCBackend();
+    backend.initialize(config);
+    sharedSession = 
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+    idpUserMetaMapper = sharedSession.getMapper(IdpUserMetaMapper.class);
+  }
+
+  protected void restartBackend() throws IOException {
+    closeSession();
+    backend.close();
+    backend = new JDBCBackend();
+    backend.initialize(config);
+    sharedSession = 
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+    idpUserMetaMapper = sharedSession.getMapper(IdpUserMetaMapper.class);
+  }
+
+  protected IdpUserPO insertUser(
+      long userId,
+      String userName,
+      String passwordHash,
+      long currentVersion,
+      long lastVersion,
+      long deletedAt) {
+    IdpUserPO userPO =
+        IdpUserPO.builder()
+            .withUserId(userId)
+            .withUserName(userName)
+            .withPasswordHash(passwordHash)
+            .withCurrentVersion(currentVersion)
+            .withLastVersion(lastVersion)
+            .withDeletedAt(deletedAt)
+            .build();
+    idpUserMetaMapper.insertIdpUser(userPO);
+    return userPO;
+  }
+
+  protected long queryLongValue(String table, String column, String idColumn, 
long idValue) {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = sqlSession.getConnection();
+        PreparedStatement statement =
+            connection.prepareStatement(
+                "SELECT " + column + " FROM " + table + " WHERE " + idColumn + 
" = ?")) {
+      statement.setLong(1, idValue);
+      try (ResultSet resultSet = statement.executeQuery()) {
+        assertTrue(resultSet.next());
+        return resultSet.getLong(1);
+      }
+    } catch (SQLException e) {
+      throw new RuntimeException("Query " + column + " from " + table + " 
failed", e);
+    }
+  }
+
+  protected int countRows(String table, String idColumn, long idValue) {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = sqlSession.getConnection();
+        PreparedStatement statement =
+            connection.prepareStatement(
+                "SELECT COUNT(1) FROM " + table + " WHERE " + idColumn + " = 
?")) {
+      statement.setLong(1, idValue);
+      try (ResultSet resultSet = statement.executeQuery()) {
+        assertTrue(resultSet.next());
+        return resultSet.getInt(1);
+      }
+    } catch (SQLException e) {
+      throw new RuntimeException("Count rows from " + table + " failed", e);
+    }
+  }
+
+  protected void closeSession() {
+    if (sharedSession != null) {
+      sharedSession.close();
+      sharedSession = null;
+    }
+  }
+
+  private Config createBackendConfig(String type) throws IOException {
+    Config backendConfig = new Config(false) {};
+    backendConfig.set(Configs.ENTITY_STORE, Configs.RELATIONAL_ENTITY_STORE);
+    backendConfig.set(Configs.ENTITY_RELATIONAL_STORE, type);
+    backendConfig.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS, 
20);
+    
backendConfig.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_MILLISECONDS, 
1000L);
+
+    switch (type) {
+      case MYSQL_BACKEND:
+        initializeMySQLBackend(backendConfig);
+        break;
+      case POSTGRESQL_BACKEND:
+        initializePostgreSQLBackend(backendConfig);
+        break;
+      case H2_BACKEND:
+        initializeH2Backend(backendConfig);
+        break;
+      default:
+        throw new IllegalArgumentException("Unsupported backend type: " + 
type);
+    }
+
+    return backendConfig;
+  }
+
+  private void initializeMySQLBackend(Config backendConfig) throws IOException 
{
+    ContainerSuite containerSuite = ContainerSuite.getInstance();
+    containerSuite.startMySQLContainer(MYSQL_TEST_DATABASE);
+    MySQLContainer mySQLContainer = containerSuite.getMySQLContainer();
+    String jdbcUrl = mySQLContainer.getJdbcUrl(MYSQL_TEST_DATABASE);
+
+    backendConfig.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL, jdbcUrl);
+    backendConfig.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_USER, 
mySQLContainer.getUsername());
+    backendConfig.set(
+        Configs.ENTITY_RELATIONAL_JDBC_BACKEND_PASSWORD, 
mySQLContainer.getPassword());
+    backendConfig.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER, 
"com.mysql.cj.jdbc.Driver");
+
+    try (Connection connection =
+            DriverManager.getConnection(
+                StringUtils.substringBeforeLast(jdbcUrl, "/"),
+                mySQLContainer.getUsername(),
+                mySQLContainer.getPassword());
+        Statement statement = connection.createStatement()) {
+      statement.execute("DROP DATABASE IF EXISTS " + MYSQL_TEST_DATABASE);
+      statement.execute("CREATE DATABASE " + MYSQL_TEST_DATABASE);
+      statement.execute("USE " + MYSQL_TEST_DATABASE);
+      executeSqlStatements(statement, loadSchemaStatements(MYSQL_BACKEND));
+    } catch (SQLException e) {
+      throw new RuntimeException("Failed to initialize MySQL backend for IdP 
user tests", e);
+    }
+  }
+
+  private void initializePostgreSQLBackend(Config backendConfig) throws 
IOException {
+    ContainerSuite containerSuite = ContainerSuite.getInstance();
+    containerSuite.startPostgreSQLContainer(POSTGRESQL_TEST_DATABASE);
+    PostgreSQLContainer postgreSQLContainer = 
containerSuite.getPostgreSQLContainer();
+    String schemaName = "idp_user_" + 
UUID.randomUUID().toString().replace("-", "");
+    String jdbcUrl = postgreSQLContainer.getJdbcUrl(POSTGRESQL_TEST_DATABASE);
+
+    backendConfig.set(
+        Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL, jdbcUrl + 
"?currentSchema=" + schemaName);
+    backendConfig.set(
+        Configs.ENTITY_RELATIONAL_JDBC_BACKEND_USER, 
postgreSQLContainer.getUsername());
+    backendConfig.set(
+        Configs.ENTITY_RELATIONAL_JDBC_BACKEND_PASSWORD, 
postgreSQLContainer.getPassword());
+    backendConfig.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER, 
"org.postgresql.Driver");
+
+    try (Connection connection =
+            DriverManager.getConnection(
+                jdbcUrl, postgreSQLContainer.getUsername(), 
postgreSQLContainer.getPassword());
+        Statement statement = connection.createStatement()) {
+      statement.execute("DROP SCHEMA IF EXISTS " + schemaName + " CASCADE");
+      statement.execute("CREATE SCHEMA " + schemaName);
+      statement.execute("SET search_path TO " + schemaName);
+      executeSqlStatements(statement, 
loadSchemaStatements(POSTGRESQL_BACKEND));
+    } catch (SQLException e) {
+      throw new RuntimeException("Failed to initialize PostgreSQL backend for 
IdP user tests", e);
+    }
+  }
+
+  private void initializeH2Backend(Config backendConfig) throws IOException {
+    h2Path = Files.createTempDirectory("gravitino_idp_basic_h2_");
+    backendConfig.set(
+        Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL,
+        String.format("jdbc:h2:file:%s;DB_CLOSE_DELAY=-1;MODE=MYSQL", h2Path));
+    backendConfig.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_USER, "root");
+    backendConfig.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_PASSWORD, 
"123456");
+    backendConfig.set(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER, 
"org.h2.Driver");
+  }
+
+  private String[] loadSchemaStatements(String databaseType) throws 
IOException {
+    Path scriptPath =
+        resolveProjectRoot()
+            .resolve("scripts")
+            .resolve(databaseType)
+            .resolve(
+                String.format(
+                    "schema-%s-%s.sql", 
ConfigConstants.CURRENT_SCRIPT_VERSION, databaseType));
+    return Arrays.stream(Files.readString(scriptPath).split(";"))
+        .map(String::trim)
+        .filter(sql -> !sql.isEmpty())
+        .toArray(String[]::new);
+  }
+
+  private Path resolveProjectRoot() {
+    String rootDir = System.getenv("GRAVITINO_ROOT_DIR");
+    if (StringUtils.isBlank(rootDir)) {
+      rootDir = System.getenv("GRAVITINO_HOME");
+    }
+
+    if (StringUtils.isBlank(rootDir)) {
+      throw new IllegalStateException(
+          "GRAVITINO_ROOT_DIR or GRAVITINO_HOME must be set for IdP user 
storage tests");
+    }
+
+    return Path.of(rootDir);
+  }
+
+  private void executeSqlStatements(Statement statement, String[] 
sqlStatements)
+      throws SQLException {
+    for (String sql : sqlStatements) {
+      statement.execute(sql);
+    }
+  }
+
+  private void deleteDirectory(Path directory) throws IOException {
+    try (Stream<Path> paths = Files.walk(directory)) {
+      paths.sorted(Comparator.reverseOrder()).forEach(this::deletePath);
+    }
+  }
+
+  private void deletePath(Path path) {
+    try {
+      Files.deleteIfExists(path);
+    } catch (IOException e) {
+      throw new RuntimeException("Delete path failed: " + path, e);
+    }
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserMetaStorage.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserMetaStorage.java
new file mode 100644
index 0000000000..025b3c5fb0
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/TestIdpUserMetaStorage.java
@@ -0,0 +1,169 @@
+/*
+ * 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.idp.storage.mapper;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.Comparator;
+import java.util.List;
+import org.apache.gravitino.idp.storage.po.IdpUserPO;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.exceptions.PersistenceException;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+@Tag("gravitino-docker-test")
+class TestIdpUserMetaStorage extends AbstractIdpMetaStorageTest {
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testInsertIdpUserAndSelectIdpUser(String type) throws IOException {
+    init(type);
+    IdpUserPO firstUser = insertUser(1L, "alice", "hash-a", 1L, 0L, 0L);
+
+    assertEquals(firstUser, idpUserMetaMapper.selectIdpUser("alice"));
+    assertNull(idpUserMetaMapper.selectIdpUser("unknown"));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testSelectIdpUsers(String type) throws IOException {
+    init(type);
+    IdpUserPO firstUser = insertUser(1L, "alice", "hash-a", 1L, 0L, 0L);
+    IdpUserPO secondUser = insertUser(2L, "bob", "hash-b", 1L, 0L, 0L);
+
+    List<IdpUserPO> users = idpUserMetaMapper.selectIdpUsers(List.of("bob", 
"alice"));
+    users.sort(Comparator.comparing(IdpUserPO::getUserId));
+    assertIterableEquals(List.of(firstUser, secondUser), users);
+    List<IdpUserPO> usersWithEmptyFilter = 
idpUserMetaMapper.selectIdpUsers(List.of());
+    usersWithEmptyFilter.sort(Comparator.comparing(IdpUserPO::getUserId));
+    assertIterableEquals(List.of(firstUser, secondUser), usersWithEmptyFilter);
+    assertThrows(PersistenceException.class, () -> 
idpUserMetaMapper.selectIdpUsers(null));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testSelectIdpUsersIgnoresDeletedUsers(String type) throws IOException {
+    init(type);
+    IdpUserPO activeUser = insertUser(1L, "alice", "hash-a", 1L, 0L, 0L);
+    insertUser(2L, "bob", "hash-b", 1L, 0L, 10L);
+
+    assertIterableEquals(
+        List.of(activeUser), idpUserMetaMapper.selectIdpUsers(List.of("alice", 
"bob")));
+    assertNull(idpUserMetaMapper.selectIdpUser("bob"));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testUpdateIdpUserPassword(String type) throws IOException {
+    init(type);
+    insertUser(1L, "alice", "hash-a", 1L, 0L, 0L);
+
+    assertEquals(1, idpUserMetaMapper.updateIdpUserPassword(1L, "hash-a-2"));
+    assertEquals("hash-a-2", 
idpUserMetaMapper.selectIdpUser("alice").getPasswordHash());
+    assertEquals(1L, 
idpUserMetaMapper.selectIdpUser("alice").getCurrentVersion());
+    assertEquals(0L, 
idpUserMetaMapper.selectIdpUser("alice").getLastVersion());
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testUpdateIdpUserPasswordKeepsVersionsUnchanged(String type) throws 
IOException {
+    init(type);
+    insertUser(1L, "alice", "hash-a", 3L, 2L, 0L);
+
+    assertEquals(1, idpUserMetaMapper.updateIdpUserPassword(1L, "hash-a-2"));
+    assertEquals("hash-a-2", 
idpUserMetaMapper.selectIdpUser("alice").getPasswordHash());
+    assertEquals(3L, 
idpUserMetaMapper.selectIdpUser("alice").getCurrentVersion());
+    assertEquals(2L, 
idpUserMetaMapper.selectIdpUser("alice").getLastVersion());
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testSoftDeleteIdpUser(String type) throws IOException {
+    init(type);
+    insertUser(1L, "alice", "hash-a", 1L, 0L, 0L);
+
+    assertEquals(1, idpUserMetaMapper.softDeleteIdpUser(1L));
+    assertNull(idpUserMetaMapper.selectIdpUser("alice"));
+    assertTrue(queryLongValue("idp_user_meta", "deleted_at", "user_id", 1L) > 
0L);
+    assertEquals(1L, queryLongValue("idp_user_meta", "current_version", 
"user_id", 1L));
+    assertEquals(0L, queryLongValue("idp_user_meta", "last_version", 
"user_id", 1L));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testDeleteIdpUserMetasByLegacyTimeline(String type) throws IOException {
+    init(type);
+    insertUser(1L, "legacy-user", "hash", 1L, 0L, 10L);
+    insertUser(2L, "new-user", "hash", 1L, 0L, 30L);
+    insertUser(3L, "active-user", "hash", 1L, 0L, 0L);
+
+    assertEquals(1, idpUserMetaMapper.deleteIdpUserMetasByLegacyTimeline(20L, 
10));
+    assertEquals(0, countRows("idp_user_meta", "user_id", 1L));
+    assertEquals(1, countRows("idp_user_meta", "user_id", 2L));
+    assertEquals(1, countRows("idp_user_meta", "user_id", 3L));
+  }
+
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testRestart(String type) throws IOException {
+    init(type);
+    IdpUserPO expectedActiveUser = insertUser(1L, "alice", "hash-a", 3L, 2L, 
0L);
+    insertUser(2L, "bob", "hash-b", 1L, 0L, 10L);
+    assertEquals(1, idpUserMetaMapper.updateIdpUserPassword(1L, "hash-a-2"));
+
+    expectedActiveUser =
+        IdpUserPO.builder()
+            .withUserId(expectedActiveUser.getUserId())
+            .withUserName(expectedActiveUser.getUserName())
+            .withPasswordHash("hash-a-2")
+            .withCurrentVersion(expectedActiveUser.getCurrentVersion())
+            .withLastVersion(expectedActiveUser.getLastVersion())
+            .withDeletedAt(expectedActiveUser.getDeletedAt())
+            .build();
+
+    assertPersistedUsers(expectedActiveUser);
+
+    restartBackend();
+
+    assertPersistedUsers(expectedActiveUser);
+  }
+
+  private void assertPersistedUsers(IdpUserPO expectedActiveUser) {
+    assertTrue(
+        SqlSessionFactoryHelper.getInstance()
+            .getSqlSessionFactory()
+            .getConfiguration()
+            .hasMapper(IdpUserMetaMapper.class));
+
+    assertEquals(expectedActiveUser, idpUserMetaMapper.selectIdpUser("alice"));
+    assertNull(idpUserMetaMapper.selectIdpUser("bob"));
+
+    List<IdpUserPO> users = idpUserMetaMapper.selectIdpUsers(List.of("bob", 
"alice"));
+    users.sort(Comparator.comparing(IdpUserPO::getUserId));
+    assertIterableEquals(List.of(expectedActiveUser), users);
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/base/TestIdpUserMetaBaseSQLProvider.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/base/TestIdpUserMetaBaseSQLProvider.java
new file mode 100644
index 0000000000..b14e015428
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/base/TestIdpUserMetaBaseSQLProvider.java
@@ -0,0 +1,169 @@
+/*
+ * 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.idp.storage.mapper.provider.base;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.gravitino.idp.storage.po.IdpUserPO;
+import org.apache.ibatis.builder.BuilderException;
+import org.apache.ibatis.mapping.BoundSql;
+import org.apache.ibatis.mapping.SqlSource;
+import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestIdpUserMetaBaseSQLProvider {
+
+  @Test
+  void testSelectIdpUser() {
+    String normalizedSql = 
createProvider().selectIdpUser("tom").replaceAll("\\s+", " ").trim();
+
+    Assertions.assertTrue(normalizedSql.contains("SELECT user_id as userId"));
+    Assertions.assertTrue(normalizedSql.contains("FROM idp_user_meta"));
+    Assertions.assertTrue(
+        normalizedSql.contains("WHERE user_name = #{username} AND deleted_at = 
0"));
+  }
+
+  @Test
+  void testSelectIdpUsers() {
+    String script = createProvider().selectIdpUsers(Arrays.asList("tom", 
"jerry"));
+    Map<String, Object> params = new HashMap<>();
+    params.put("usernames", Arrays.asList("tom", "jerry"));
+
+    String normalizedSql = renderScript(script, params);
+
+    Assertions.assertTrue(normalizedSql.contains("SELECT user_id as userId"));
+    Assertions.assertTrue(normalizedSql.contains("FROM idp_user_meta"));
+    Assertions.assertTrue(normalizedSql.matches(".*user_name IN \\( \\? , \\? 
\\).*"));
+    Assertions.assertFalse(normalizedSql.matches(".*\\b1\\s*=\\s*0\\b.*"));
+  }
+
+  @Test
+  void testSelectIdpUsersWithEmptyUserNames() {
+    String script = createProvider().selectIdpUsers(Collections.emptyList());
+    Map<String, Object> params = new HashMap<>();
+    params.put("usernames", Collections.emptyList());
+
+    String normalizedSql = renderScript(script, params);
+
+    Assertions.assertFalse(
+        normalizedSql.matches(".*\\bIN\\s*\\(\\s*\\).*"),
+        "Empty userNames should not generate invalid SQL IN (...) with no 
values");
+    Assertions.assertFalse(normalizedSql.matches(".*\\b1\\s*=\\s*0\\b.*"));
+    Assertions.assertEquals(
+        "SELECT user_id as userId, user_name as userName, password_hash as 
passwordHash,"
+            + " current_version as currentVersion, last_version as 
lastVersion,"
+            + " deleted_at as deletedAt FROM idp_user_meta WHERE deleted_at = 
0",
+        normalizedSql);
+  }
+
+  @Test
+  void testSelectIdpUsersWithNullUserNames() {
+    String script = createProvider().selectIdpUsers(null);
+    Map<String, Object> params = new HashMap<>();
+    params.put("usernames", null);
+
+    Assertions.assertThrows(BuilderException.class, () -> renderScript(script, 
params));
+  }
+
+  @Test
+  void testInsertIdpUser() {
+    String normalizedSql =
+        createProvider().insertIdpUser(newUserPO()).replaceAll("\\s+", " 
").trim();
+
+    Assertions.assertTrue(normalizedSql.contains("INSERT INTO idp_user_meta"));
+    Assertions.assertTrue(
+        normalizedSql.contains(
+            "(user_id, user_name, password_hash, current_version, 
last_version, deleted_at)"));
+    Assertions.assertTrue(
+        normalizedSql.contains(
+            "VALUES ( #{userMeta.userId}, #{userMeta.userName}, 
#{userMeta.passwordHash},"
+                + " #{userMeta.currentVersion}, #{userMeta.lastVersion},"
+                + " #{userMeta.deletedAt} )"));
+  }
+
+  @Test
+  void testUpdateIdpUserPassword() {
+    String normalizedSql =
+        createProvider().updateIdpUserPassword(1L, "hash").replaceAll("\\s+", 
" ").trim();
+
+    Assertions.assertTrue(normalizedSql.contains("UPDATE idp_user_meta"));
+    Assertions.assertTrue(normalizedSql.contains("SET password_hash = 
#{passwordHash}"));
+    Assertions.assertTrue(normalizedSql.contains("WHERE user_id = #{userId}"));
+    Assertions.assertTrue(normalizedSql.contains("AND deleted_at = 0"));
+  }
+
+  @Test
+  void testSoftDeleteIdpUser() {
+    String normalizedSql = 
createProvider().softDeleteIdpUser(1L).replaceAll("\\s+", " ").trim();
+
+    Assertions.assertTrue(normalizedSql.contains("UPDATE idp_user_meta"));
+    Assertions.assertTrue(normalizedSql.contains("CURRENT_TIME_MILLIS()"));
+    Assertions.assertTrue(normalizedSql.contains("WHERE user_id = #{userId} 
AND deleted_at = 0"));
+  }
+
+  @Test
+  void testDeleteIdpUserMetasByLegacyTimeline() {
+    String normalizedSql =
+        createProvider().deleteIdpUserMetasByLegacyTimeline(1L, 
2).replaceAll("\\s+", " ").trim();
+
+    Assertions.assertEquals(
+        "DELETE FROM idp_user_meta WHERE deleted_at > 0 AND deleted_at < 
#{legacyTimeline}"
+            + " LIMIT #{limit}",
+        normalizedSql);
+  }
+
+  @Test
+  void testCurrentTimeMillisExpression() {
+    Assertions.assertEquals(
+        "(UNIX_TIMESTAMP() * 1000.0)",
+        new IdpUserMetaBaseSQLProvider().currentTimeMillisExpression());
+  }
+
+  private IdpUserMetaBaseSQLProvider createProvider() {
+    return new IdpUserMetaBaseSQLProvider() {
+      @Override
+      protected String currentTimeMillisExpression() {
+        return "CURRENT_TIME_MILLIS()";
+      }
+    };
+  }
+
+  private String renderScript(String script, Map<String, Object> params) {
+    SqlSource sqlSource =
+        new XMLLanguageDriver().createSqlSource(new Configuration(), script, 
Map.class);
+    BoundSql boundSql = sqlSource.getBoundSql(params);
+    return boundSql.getSql().replaceAll("\\s+", " ").trim();
+  }
+
+  private IdpUserPO newUserPO() {
+    return IdpUserPO.builder()
+        .withUserId(1L)
+        .withUserName("tom")
+        .withPasswordHash("hash")
+        .withCurrentVersion(1L)
+        .withLastVersion(1L)
+        .withDeletedAt(0L)
+        .build();
+  }
+}
diff --git a/plugins/idp-basic/build.gradle.kts 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/h2/TestIdpUserMetaH2Provider.java
similarity index 64%
copy from plugins/idp-basic/build.gradle.kts
copy to 
plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/h2/TestIdpUserMetaH2Provider.java
index e10209214c..ab2c5f2ea7 100644
--- a/plugins/idp-basic/build.gradle.kts
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/h2/TestIdpUserMetaH2Provider.java
@@ -17,23 +17,19 @@
  * under the License.
  */
 
-plugins {
-  `maven-publish`
-  id("java")
-  id("idea")
-}
+package org.apache.gravitino.idp.storage.mapper.provider.h2;
 
-dependencies {
-  implementation(libs.bcprov.jdk18on)
-  implementation(libs.commons.lang3)
-  implementation(libs.guava)
-  testImplementation(libs.junit.jupiter.api)
-  testRuntimeOnly(libs.junit.jupiter.engine)
-}
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestIdpUserMetaH2Provider {
+
+  @Test
+  void testCurrentTimeMillisExpression() {
+    IdpUserMetaH2Provider provider = new IdpUserMetaH2Provider();
 
-tasks {
-  test {
-    environment("GRAVITINO_HOME", rootDir.path)
-    environment("GRAVITINO_TEST", "true")
+    Assertions.assertEquals(
+        "DATEDIFF('MILLISECOND', TIMESTAMP '1970-01-01 00:00:00', 
CURRENT_TIMESTAMP())",
+        provider.currentTimeMillisExpression());
   }
 }
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/TestIdpUserMetaPostgreSQLProvider.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/TestIdpUserMetaPostgreSQLProvider.java
new file mode 100644
index 0000000000..21a16b074d
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/mapper/provider/postgresql/TestIdpUserMetaPostgreSQLProvider.java
@@ -0,0 +1,45 @@
+/*
+ * 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.idp.storage.mapper.provider.postgresql;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestIdpUserMetaPostgreSQLProvider {
+
+  @Test
+  void testCurrentTimeMillisExpression() {
+    IdpUserMetaPostgreSQLProvider provider = new 
IdpUserMetaPostgreSQLProvider();
+
+    Assertions.assertEquals(
+        "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)",
+        provider.currentTimeMillisExpression());
+  }
+
+  @Test
+  void testDeleteIdpUserMetasByLegacyTimeline() {
+    IdpUserMetaPostgreSQLProvider provider = new 
IdpUserMetaPostgreSQLProvider();
+
+    Assertions.assertEquals(
+        "DELETE FROM idp_user_meta WHERE user_id IN (SELECT user_id FROM 
idp_user_meta"
+            + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit})",
+        provider.deleteIdpUserMetasByLegacyTimeline(1L, 2));
+  }
+}
diff --git 
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/po/TestIdpUserPO.java
 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/po/TestIdpUserPO.java
new file mode 100644
index 0000000000..8bdfa0f16c
--- /dev/null
+++ 
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/storage/po/TestIdpUserPO.java
@@ -0,0 +1,105 @@
+/*
+ * 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.idp.storage.po;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestIdpUserPO {
+
+  @Test
+  public void testIdpUserPOBuilder() {
+    IdpUserPO userPO =
+        IdpUserPO.builder()
+            .withUserId(1L)
+            .withUserName("alice")
+            .withPasswordHash("hash")
+            .withCurrentVersion(1L)
+            .withLastVersion(1L)
+            .withDeletedAt(0L)
+            .build();
+
+    Assertions.assertEquals(1L, userPO.getUserId());
+    Assertions.assertEquals("alice", userPO.getUserName());
+    Assertions.assertEquals("hash", userPO.getPasswordHash());
+    Assertions.assertEquals(1L, userPO.getCurrentVersion());
+    Assertions.assertEquals(1L, userPO.getLastVersion());
+    Assertions.assertEquals(0L, userPO.getDeletedAt());
+  }
+
+  @Test
+  public void testIdpUserPOBuilderAllowsPartialFields() {
+    IdpUserPO userPO =
+        IdpUserPO.builder()
+            .withUserId(1L)
+            .withUserName("alice")
+            .withCurrentVersion(1L)
+            .withLastVersion(1L)
+            .withDeletedAt(0L)
+            .build();
+
+    Assertions.assertEquals(1L, userPO.getUserId());
+    Assertions.assertEquals("alice", userPO.getUserName());
+    Assertions.assertNull(userPO.getPasswordHash());
+  }
+
+  @Test
+  public void testEqualsAndHashCode() {
+    IdpUserPO userPO1 =
+        IdpUserPO.builder()
+            .withUserId(1L)
+            .withUserName("alice")
+            .withPasswordHash("hash")
+            .withCurrentVersion(1L)
+            .withLastVersion(1L)
+            .withDeletedAt(0L)
+            .build();
+
+    IdpUserPO userPO2 =
+        IdpUserPO.builder()
+            .withUserId(1L)
+            .withUserName("alice")
+            .withPasswordHash("hash")
+            .withCurrentVersion(1L)
+            .withLastVersion(1L)
+            .withDeletedAt(0L)
+            .build();
+
+    Assertions.assertEquals(userPO1, userPO2);
+    Assertions.assertEquals(userPO1.hashCode(), userPO2.hashCode());
+  }
+
+  @Test
+  public void testBuilderReuseDoesNotMutateBuiltObject() {
+    var builder =
+        IdpUserPO.builder()
+            .withUserId(1L)
+            .withUserName("alice")
+            .withPasswordHash("hash")
+            .withCurrentVersion(1L)
+            .withLastVersion(1L)
+            .withDeletedAt(0L);
+
+    IdpUserPO firstUser = builder.build();
+    IdpUserPO secondUser = builder.withUserName("bob").build();
+
+    Assertions.assertEquals("alice", firstUser.getUserName());
+    Assertions.assertEquals("bob", secondUser.getUserName());
+  }
+}

Reply via email to