jerryshao commented on code in PR #13880:
URL: https://github.com/apache/gravitino/pull/13880#discussion_r4291871375
##########
core/src/main/java/org/apache/gravitino/storage/relational/po/RolePO.java:
##########
@@ -31,6 +33,17 @@ public class RolePO {
private Long lastVersion;
private Long deletedAt;
+ private List<SecurableObjectPO> securableObjectPOs = new ArrayList<>();
+
+ /**
+ * Returns privileges read in the same statement as this role.
+ *
+ * @return the privilege snapshot, or an empty list for metadata-only reads
+ */
+ public List<SecurableObjectPO> getSecurableObjectPOs() {
Review Comment:
[Important] `getSecurableObjectPOs()` returns an empty list both for a role
that genuinely has no privileges and for a `RolePO` that came from a
metadata-only read (`selectRoleMetaByMetalakeIdAndName`), and nothing records
which of the two it is. The consumers turn that ambiguity into silent
authorization-data rewrites rather than an error:
- `RoleMetaService.listSecurableObjects` (RoleMetaService.java:521) would
yield an empty privilege list, so `updateRole` computes `oldObjects = {}`
(RoleMetaService.java:253-256) and re-inserts every privilege while deleting
none.
- `UserPO.getRolePOs()` / `GroupPO.getRolePOs()` have the same shape:
`updateUser` diffs role IDs as sets (UserMetaService.java:250, :261-268), so an
unloaded list leaves `deleteRoleIds` empty and re-grants every membership.
Every caller in this PR is correct — I checked all of them, and the three
`listSecurableObjects` sites (RoleMetaService.java:146, :243, :398) plus the
four principal sites all pass the `withPrivileges` / `withRoles` flag — so this
is latent, not a live bug. But the dual-mode read is new here, the two modes
differ only by a boolean, and the compiler gives no help. Suggestion: have the
aggregate mappers mark the PO as loaded and add a `Preconditions.checkState` at
the top of `listSecurableObjects` and the two membership diffs, so a future
metadata-only read fails loudly instead of quietly rewriting privileges or
memberships.
Verified by: grepped every reader of the four new accessors across
core/src/main, then read `updateRole`, `updateUser` and `updateGroup` to
confirm an empty old list produces insert-all / delete-none.
##########
core/src/main/java/org/apache/gravitino/storage/relational/mapper/RoleMetaMapper.java:
##########
@@ -122,4 +128,88 @@ Integer deleteRoleMetasByLegacyTimeline(
@SelectProvider(type = RoleMetaSQLProviderFactory.class, method =
"batchGetRoleUpdatedAt")
List<RoleUpdatedAt> batchGetRoleUpdatedAt(@Param("roleIds") List<Long>
roleIds);
+ /**
+ * Defines the nested result mapping for single-statement role reads.
+ *
+ * @return unused; this declaration is never executed
+ */
+ @Results(
+ id = "rolePrivilegeSnapshot",
+ value = {
+ @Result(property = "id", column = "relationId", id = true),
+ @Result(property = "roleId", column = "roleId"),
+ @Result(property = "metadataObjectId", column = "metadataObjectId"),
+ @Result(property = "type", column = "type"),
+ @Result(property = "privilegeNames", column = "privilegeNames"),
+ @Result(property = "privilegeConditions", column =
"privilegeConditions"),
+ @Result(property = "currentVersion", column = "currentVersion"),
+ @Result(property = "lastVersion", column = "lastVersion"),
+ @Result(property = "deletedAt", column = "deletedAt")
+ })
+ @Select("SELECT 1")
+ SecurableObjectPO rolePrivilegeSnapshot();
Review Comment:
[Nit] `rolePrivilegeSnapshot()`, `membershipRoleSnapshot()`
(RoleMetaMapper.java:214) and `tableColumnSnapshot()` (TableMetaMapper.java:74)
exist only to register a `@Results` id, and the Javadoc says so — but they are
still public mapper methods, so calling one compiles and then fails at runtime
inside the result mapping. Where a shared child map can hang off a real
statement you already avoid this (`tableWithColumns` at TableMetaMapper.java:84
plus `@ResultMap`); for a map that really is shared across mappers, an XML
`<resultMap>` fragment would keep the placeholder off the interface. If the
annotation form stays, a name that reads as unmistakably internal would at
least discourage accidental calls. Note a `default` method will not work as a
substitute: MyBatis skips default methods when parsing, so the result map would
never be registered.
Verified by: read all three placeholder declarations and the
`tableWithColumns` / `@ResultMap` pair in TableMetaMapper.
##########
core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TableMetaBaseSQLProvider.java:
##########
@@ -426,4 +428,78 @@ public String batchSelectTableByIdentifier(
"""
.formatted(TABLE_NAME, TableVersionMapper.TABLE_NAME);
}
+ /**
+ * Builds a single-statement table and column snapshot by schema ID and name.
+ *
+ * @param schemaId the schema ID
+ * @param tableName the table name
+ * @return the aggregate select SQL
+ */
+ public String selectTableWithColumnsBySchemaIdAndName(
+ @Param("schemaId") Long schemaId, @Param("tableName") String tableName) {
+ return withColumns(selectTableMetaBySchemaIdAndName(schemaId, tableName));
+ }
+
+ /**
+ * Builds a single-statement table and column snapshot by full name.
+ *
+ * @param metalakeName the metalake name
+ * @param catalogName the catalog name
+ * @param schemaName the physical schema name
+ * @param tableName the table name
+ * @return the aggregate select SQL
+ */
+ public String selectTableWithColumnsByFullQualifiedName(
+ @Param("metalakeName") String metalakeName,
+ @Param("catalogName") String catalogName,
+ @Param("schemaName") String schemaName,
+ @Param("tableName") String tableName) {
+ return withColumns(
+ selectTableByFullQualifiedName(metalakeName, catalogName, schemaName,
tableName));
+ }
+
+ private String withColumns(String tableSql) {
+ // One statement gives the root, version and columns a common snapshot.
Overwrite retires old
+ // columns, so a second SELECT cannot reconstruct them. Aggregate history
once and join by the
+ // existing (table_id, table_version, column_id, deleted_at) unique key.
Correlated scans for
+ // each column would be quadratic for long histories. Root subqueries have
independent bound
+ // parameters on all supported backends. Exclude DELETE markers only after
resolving history,
+ // otherwise a dropped column would reappear through its earlier
CREATE/UPDATE row.
+ String rootSql = tableSql.strip().replaceFirst(";\\s*$", "");
+ return "SELECT t.*, "
+ + "c.column_id AS col_columnId, "
+ + "c.column_name AS col_columnName, "
+ + "c.column_position AS col_columnPosition, "
+ + "c.metalake_id AS col_metalakeId, "
+ + "c.catalog_id AS col_catalogId, "
+ + "c.schema_id AS col_schemaId, "
+ + "c.table_id AS col_tableId, "
+ + "c.table_version AS col_tableVersion, "
+ + "c.column_type AS col_columnType, "
+ + "c.column_comment AS col_columnComment, "
+ + "c.column_nullable AS col_nullable, "
+ + "c.column_auto_increment AS col_autoIncrement, "
+ + "c.column_default_value AS col_defaultValue, "
+ + "c.column_op_type AS col_columnOpType, "
+ + "c.deleted_at AS col_deletedAt, "
+ + "c.audit_info AS col_auditInfo "
+ + "FROM ("
+ + rootSql
+ + ") t LEFT JOIN ("
+ + " SELECT history.table_id, history.column_id,
MAX(history.table_version) AS max_version"
+ + " FROM "
+ + TableColumnMapper.COLUMN_TABLE_NAME
+ + " history JOIN ("
+ + rootSql
+ + ") observed"
+ + " ON history.table_id = observed.tableId AND history.table_version
<= observed.currentVersion"
Review Comment:
[Question] The root select is inlined twice, so
`selectTableWithColumnsByFullQualifiedName` evaluates the
metalake-catalog-schema-table-version chain once as `t` and again as
`observed`. `POStorageReadRouting.getPO` falls back to the full-name route
whenever the entity-id cache is off (POStorageReadRouting.java:68, :75), so in
that configuration every table get pays the five-table join twice.
Two things I could not settle from the PR text: (a) was the EXPLAIN ANALYZE
with 45,000 history rows run against this full-name variant, or only the
schema-id one? (b) was a form without the duplicate considered — `LATERAL` is
not portable to all targets, but bounding history with a scalar subquery on
`table_meta.current_version`, or feeding `latest` from
`selectTableIdBySchemaIdAndName`, would pin the history scan without re-running
the parent chain.
Not blocking: the correctness argument for the duplicate holds, since both
occurrences read one statement snapshot at READ_COMMITTED. I would just like
the full-name cost on the record.
Verified by: rendered both provider methods by hand from this file, then
read `POStorageReadRouting.getPO` for the routing and SqlSessions.java:65 for
the isolation level.
##########
core/src/main/java/org/apache/gravitino/storage/relational/service/RoleMetaService.java:
##########
@@ -514,7 +517,8 @@ public int deleteRoleMetasByLegacyTimeline(long
legacyTimeline, int limit) {
}
private static List<SecurableObject> listSecurableObjects(RolePO po) {
- List<SecurableObjectPO> securableObjectPOs =
listSecurableObjectsByRoleId(po.getRoleId());
+ // Full role conversions must use privileges from the same statement as
the role metadata.
+ List<SecurableObjectPO> securableObjectPOs = po.getSecurableObjectPOs();
Review Comment:
[Nit] Now that all three callers read privileges from the role statement,
`listSecurableObjectsByRoleId` (RoleMetaService.java:456) has no production
caller left; the only reference in the tree is the assertion at
core/src/test/java/org/apache/gravitino/storage/relational/service/TestRoleMetaService.java:191.
Its `@Monitored` metric `listSecurableObjectsByRoleId` will therefore go to
zero, which is worth a line in the PR description for anyone alerting on it.
Either drop the method and have the test assert through
`SecurableObjectMapper`, or keep it with a comment saying it is test-only.
Verified by: grep for `listSecurableObjectsByRoleId` across the repository —
the only hits outside its own definition, the mapper and the provider are that
one test assertion.
##########
core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TableMetaBaseSQLProvider.java:
##########
@@ -426,4 +428,78 @@ public String batchSelectTableByIdentifier(
"""
.formatted(TABLE_NAME, TableVersionMapper.TABLE_NAME);
}
+ /**
+ * Builds a single-statement table and column snapshot by schema ID and name.
+ *
+ * @param schemaId the schema ID
+ * @param tableName the table name
+ * @return the aggregate select SQL
+ */
+ public String selectTableWithColumnsBySchemaIdAndName(
+ @Param("schemaId") Long schemaId, @Param("tableName") String tableName) {
+ return withColumns(selectTableMetaBySchemaIdAndName(schemaId, tableName));
+ }
+
+ /**
+ * Builds a single-statement table and column snapshot by full name.
+ *
+ * @param metalakeName the metalake name
+ * @param catalogName the catalog name
+ * @param schemaName the physical schema name
+ * @param tableName the table name
+ * @return the aggregate select SQL
+ */
+ public String selectTableWithColumnsByFullQualifiedName(
+ @Param("metalakeName") String metalakeName,
+ @Param("catalogName") String catalogName,
+ @Param("schemaName") String schemaName,
+ @Param("tableName") String tableName) {
+ return withColumns(
+ selectTableByFullQualifiedName(metalakeName, catalogName, schemaName,
tableName));
+ }
+
+ private String withColumns(String tableSql) {
+ // One statement gives the root, version and columns a common snapshot.
Overwrite retires old
+ // columns, so a second SELECT cannot reconstruct them. Aggregate history
once and join by the
+ // existing (table_id, table_version, column_id, deleted_at) unique key.
Correlated scans for
+ // each column would be quadratic for long histories. Root subqueries have
independent bound
+ // parameters on all supported backends. Exclude DELETE markers only after
resolving history,
+ // otherwise a dropped column would reappear through its earlier
CREATE/UPDATE row.
+ String rootSql = tableSql.strip().replaceFirst(";\\s*$", "");
Review Comment:
[Nit] `tableSql.strip().replaceFirst(";\\s*$", "")` makes `withColumns`
depend on two undocumented properties of whatever the root provider returns.
The trailing `;` only exists because `selectTableByFullQualifiedName` (line
344) is a text block that happens to end with one, and the wrapping is valid
only while the root is static SQL. A sibling in this same class,
`batchSelectTableByIdentifier` (line 397), is `<script>`-wrapped; if a backend
override ever returns dynamic SQL for either root, the `<script>` tag lands
inside `FROM (...)`, is no longer at position 0, and MyBatis ships the XML to
the database as literal text. A `Preconditions.checkArgument` that the root
carries no `<` (or just a sentence stating the static-SQL contract) would turn
that into a fast, obvious failure.
Verified by: read both root providers and `batchSelectTableByIdentifier` in
this file; MyBatis's XML language driver treats a provider string as dynamic
only when it starts with `<script>`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]