This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 444fc58284 refactor(amber): make the hub and unified search
resource-agnostic (#7928)
444fc58284 is described below
commit 444fc582840cbac7a6fe3479fd3890dd6efe9549
Author: Tanishq Gandhi <[email protected]>
AuthorDate: Wed Aug 26 17:39:02 2026 +0000
refactor(amber): make the hub and unified search resource-agnostic (#7928)
### What changes were proposed in this PR?
The hub dispatched on `EntityType` in four separate places, and
`DatasetSearchQueryBuilder`
carried its own copy of the access-scoped join, the keyword/date/id
filter and the
LakeFS-sized hydration. Adding a second resource type meant four
dispatch edits plus a
clone of the query logic, and two of those sites compiled cleanly but
failed at runtime.
- `EntityTables` now exposes one `EntityTableSet` per entity type, with
`cloneTable` as an
`Option` and a new `AccessTable` that removes the non-exhaustive match
in
`HubResource.userAccess`.
- `VersionedResourceTables` names the columns of a LakeFS-backed
resource;
`VersionedResourceSearchQueryBuilder` holds the single copy of FROM /
WHERE / hydration.
`DatasetSearchQueryBuilder` keeps only its projection.
- The five dataset-only slots in `UnifiedResourceSchema` become shared
versioned-resource
slots, so the next resource type widens the union by two columns instead
of seven.
Two existing bugs are fixed as a result: `getTops?entityType=dataset`
with no `actionTypes`
no longer 500s on a throwing `CloneTable(Dataset)`, and `getCounts`'
clone guard now asks
whether the entity type has a clone table instead of whether it is not a
dataset.
No user-facing behavior changes.
### Any related issues, documentation, discussions?
Prepares the amber side of #6501 (part of #6494): adding models becomes
one descriptor plus
one registry entry. Contains #6872 and #7922, so their commits show in
the diff until they
merge.
### How was this PR tested?
Existing suites, unmodified except where they pin the renamed projection
aliases:
`WorkflowExecutionService/testOnly *dashboard*` — 467/467, and
`FileService/test` — 376/376.
New `VersionedResourceTablesSpec` covers the descriptor contract and the
access-join
predicate; `EntityTablesSpec` covers the registry; regression tests
added for the two bugs
above (`getTops` with no `actionTypes`, and the clone bucket for a
cloneless entity type).
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5)
---
.../dashboard/DatasetSearchQueryBuilder.scala | 139 +-------------
.../resource/dashboard/UnifiedResourceSchema.scala | 41 +++--
.../VersionedResourceSearchQueryBuilder.scala | 96 ++++++++++
.../dashboard/VersionedResourceTables.scala | 147 +++++++++++++++
.../web/resource/dashboard/hub/EntityTables.scala | 120 +++++++++---
.../web/resource/dashboard/hub/HubResource.scala | 204 +++++++++------------
.../dashboard/DatasetSearchQueryBuilderSpec.scala | 12 +-
.../dashboard/UnifiedResourceSchemaSpec.scala | 30 +--
.../dashboard/VersionedResourceTablesSpec.scala | 140 ++++++++++++++
.../resource/dashboard/hub/EntityTablesSpec.scala | 47 ++++-
.../dashboard/hub/HubEntityModelSpec.scala | 6 +-
.../resource/dashboard/hub/HubResourceSpec.scala | 32 +++-
12 files changed, 686 insertions(+), 328 deletions(-)
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala
index 0cda3eecdc..ae8b89c371 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala
@@ -19,149 +19,26 @@
package org.apache.texera.web.resource.dashboard
-import com.typesafe.scalalogging.LazyLogging
-import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
import org.apache.texera.dao.jooq.generated.Tables.{DATASET,
DATASET_USER_ACCESS}
-import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
-import org.apache.texera.dao.jooq.generated.tables.User.USER
-import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, User}
-import
org.apache.texera.web.resource.dashboard.DashboardResource.DashboardClickableFileEntry
-import org.apache.texera.web.resource.dashboard.FulltextSearchQueryUtils.{
- getContainsFilter,
- getDateFilter,
- getFullTextSearchFilter
-}
-import
org.apache.texera.web.resource.dashboard.user.dataset.DatasetResource.DashboardDataset
import org.jooq.impl.DSL
-import org.jooq.{Condition, GroupField, Record, TableLike}
-import scala.jdk.CollectionConverters.CollectionHasAsScala
+/** Query logic lives in [[VersionedResourceSearchQueryBuilder]]; only the
projection is here. */
+object DatasetSearchQueryBuilder
+ extends
VersionedResourceSearchQueryBuilder(VersionedResourceTables.DatasetTables) {
-object DatasetSearchQueryBuilder extends SearchQueryBuilder with LazyLogging {
override protected val mappedResourceSchema: UnifiedResourceSchema =
UnifiedResourceSchema(
resourceType = DSL.inline(SearchQueryBuilder.DATASET_RESOURCE_TYPE),
name = DATASET.NAME,
description = DATASET.DESCRIPTION,
creationTime = DATASET.CREATION_TIME,
ownerId = DATASET.OWNER_UID,
- did = DATASET.DID,
+ versionedResourceId = DATASET.DID,
repositoryName = DATASET.REPOSITORY_NAME,
- isDatasetPublic = DATASET.IS_PUBLIC,
- isDatasetDownloadable = DATASET.IS_DOWNLOADABLE,
- datasetUserAccess = DATASET_USER_ACCESS.PRIVILEGE,
- datasetCoverImage = DATASET.COVER_IMAGE
+ isVersionedResourcePublic = DATASET.IS_PUBLIC,
+ isVersionedResourceDownloadable = DATASET.IS_DOWNLOADABLE,
+ versionedResourceUserAccess = DATASET_USER_ACCESS.PRIVILEGE,
+ versionedResourceCoverImage = DATASET.COVER_IMAGE
)
-
- /*
- * constructs the FROM clause for querying datasets with specific access
controls.
- *
- * Parameter:
- * - uid: Integer - Represents the unique identifier of the current user.
- * - uid is 'null' if the user is not logged in or performing a public
search.
- * - Otherwise, `uid` holds the identifier for the logged-in user.
- * - includePublic - Boolean - Specifies whether to include public datasets
in the result.
- */
- override protected def constructFromClause(
- uid: Integer,
- params: DashboardResource.SearchQueryParams,
- includePublic: Boolean = false
- ): TableLike[_] = {
- // Case 1: if `uid` is (set) and `includePublic` is false
- // -> return ONLY datasets that given `uid` has explicit access to.
- // Case 2: if `uid` is (null) and `includePublic` is true
- // -> return ONLY datasets that are public
- // Case 3: if `uid` is (set) and `includePublic` is true
- // -> Union of datasets that are public and explicitly shared with user is
returned
- // Case 4: if `uid` is (null) and `includePublic` is false
- // -> return public datasets by default as user might not be logged in
- val baseJoin = DATASET
- .leftJoin(DATASET_USER_ACCESS)
- .on(DATASET_USER_ACCESS.DID.eq(DATASET.DID))
- .and(if (uid == null) DSL.falseCondition() else
DATASET_USER_ACCESS.UID.eq(uid))
- .leftJoin(USER)
- .on(USER.UID.eq(DATASET.OWNER_UID))
-
- // Set the `condition` where clause here
- val condition: Condition =
- if (uid == null) {
- // Case 2 and 4
- // Get all the public datasets by default
- DATASET.IS_PUBLIC.eq(true)
- } else {
- if (includePublic) {
- // Case 3
- // Get all the datasets that `uid` has access to and the public
datasets
- DATASET.IS_PUBLIC.eq(true).or(DATASET_USER_ACCESS.UID.isNotNull)
- } else {
- // Case 1
- // If `includePublic` is false get only user accessible datasets
- DATASET_USER_ACCESS.UID.isNotNull
- }
- }
- baseJoin.where(condition)
- }
-
- override protected def constructWhereClause(
- uid: Integer,
- params: DashboardResource.SearchQueryParams
- ): Condition = {
- val splitKeywords = params.keywords.asScala
- .flatMap(_.split("[+\\-()<>~*@\"]"))
- .filter(_.nonEmpty)
- .toSeq
-
- getDateFilter(
- params.creationStartDate,
- params.creationEndDate,
- DATASET.CREATION_TIME
- )
- .and(getContainsFilter(params.datasetIds, DATASET.DID))
- .and(
- getFullTextSearchFilter(splitKeywords, List(DATASET.NAME,
DATASET.DESCRIPTION))
- )
- }
-
- override protected def getGroupByFields: Seq[GroupField] = {
- Seq.empty
- }
-
- override protected def toEntryImpl(
- uid: Integer,
- record: Record
- ): DashboardResource.DashboardClickableFileEntry = {
- val dataset = record.into(DATASET).into(classOf[Dataset])
- val owner = record.into(USER).into(classOf[User])
- var size = 0L
-
- try {
- size =
LakeFSStorageClient.retrieveRepositorySize(dataset.getRepositoryName)
- } catch {
- case e: io.lakefs.clients.sdk.ApiException =>
- // Treat all LakeFS ApiException as mismatch (repository not found,
being deleted, or any fatal error)
- logger.error(
- s"LakeFS ApiException for dataset repository
'${dataset.getRepositoryName}': ${e.getMessage}",
- e
- )
- return null
- }
-
- val dd = DashboardDataset(
- dataset,
- owner.getEmail,
- Option(
- record.get(
- DATASET_USER_ACCESS.PRIVILEGE,
- classOf[PrivilegeEnum]
- )
- ).getOrElse(PrivilegeEnum.NONE),
- dataset.getOwnerUid == uid,
- size
- )
- DashboardClickableFileEntry(
- resourceType = SearchQueryBuilder.DATASET_RESOURCE_TYPE,
- dataset = Some(dd)
- )
- }
}
class DatasetSearchQueryBuilder {}
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
index aa3681ae1f..cfe3f381a4 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
@@ -70,13 +70,15 @@ object UnifiedResourceSchema {
pid: Field[Integer] = DSL.cast(null, classOf[Integer]),
projectOwnerId: Field[Integer] = DSL.cast(null, classOf[Integer]),
projectColor: Field[String] = DSL.inline(""),
- did: Field[Integer] = DSL.cast(null, classOf[Integer]),
+ versionedResourceId: Field[Integer] = DSL.cast(null, classOf[Integer]),
datasetStoragePath: Field[String] = DSL.cast(null, classOf[String]),
repositoryName: Field[String] = DSL.inline(""),
- isDatasetPublic: Field[java.lang.Boolean] = DSL.cast(null,
classOf[java.lang.Boolean]),
- isDatasetDownloadable: Field[java.lang.Boolean] = DSL.cast(null,
classOf[java.lang.Boolean]),
- datasetUserAccess: Field[PrivilegeEnum] =
DSL.castNull(classOf[PrivilegeEnum]),
- datasetCoverImage: Field[String] = DSL.cast(null, classOf[String]),
+ isVersionedResourcePublic: Field[java.lang.Boolean] =
+ DSL.cast(null, classOf[java.lang.Boolean]),
+ isVersionedResourceDownloadable: Field[java.lang.Boolean] =
+ DSL.cast(null, classOf[java.lang.Boolean]),
+ versionedResourceUserAccess: Field[PrivilegeEnum] =
DSL.castNull(classOf[PrivilegeEnum]),
+ versionedResourceCoverImage: Field[String] = DSL.cast(null,
classOf[String]),
workflowCoverImage: Field[String] = DSL.cast(null, classOf[String])
): UnifiedResourceSchema = {
new UnifiedResourceSchema(
@@ -97,13 +99,17 @@ object UnifiedResourceSchema {
pid -> pid.as("pid"),
projectOwnerId -> projectOwnerId.as("owner_uid"),
projectColor -> projectColor.as("color"),
- did -> did.as("did"),
+ versionedResourceId -> versionedResourceId.as("versioned_resource_id"),
datasetStoragePath -> datasetStoragePath.as("dataset_storage_path"),
repositoryName -> repositoryName.as("repository_name"),
- isDatasetPublic -> isDatasetPublic.as("is_dataset_public"),
- isDatasetDownloadable ->
isDatasetDownloadable.as("is_dataset_downloadable"),
- datasetUserAccess -> datasetUserAccess.as("user_dataset_access"),
- datasetCoverImage -> datasetCoverImage.as("cover_image"),
+ isVersionedResourcePublic -> isVersionedResourcePublic
+ .as("is_versioned_resource_public"),
+ isVersionedResourceDownloadable -> isVersionedResourceDownloadable
+ .as("is_versioned_resource_downloadable"),
+ versionedResourceUserAccess -> versionedResourceUserAccess
+ .as("user_versioned_resource_access"),
+ versionedResourceCoverImage -> versionedResourceCoverImage
+ .as("versioned_resource_cover_image"),
workflowCoverImage -> workflowCoverImage.as("workflow_cover_image")
)
)
@@ -141,13 +147,16 @@ object UnifiedResourceSchema {
* - `fileSize`: Size of the file, as an `Integer`.
* - `fileUserAccess`: Access privileges for the file, as a
`UserFileAccessPrivilege`.
*
- * Attributes specific to datasets:
- * - `did`: Dataset ID, as an `Integer`.
+ * Attributes shared by the LakeFS-backed resources (datasets, models). One
row is one
+ * resource type, so each builder feeds these slots its own columns instead
of duplicating
+ * them per resource; the aliases only line up positionally across the UNION
ALL.
+ * - `versionedResourceId`: Dataset ID / model ID, as an `Integer`.
* - `datasetStoragePath`: The storage path of the dataset, as a `String`.
- * - `repositoryName`: The name of the repository where the dataset is
stored, as a `String`.
- * - `isDatasetPublic`: Indicates if the dataset is public, as a `Boolean`.
- * - `isDatasetDownloadable`: Indicates if the dataset is downloadable, as a
`Boolean`.
- * - `datasetUserAccess`: Access privileges for the dataset, as a
`PrivilegeEnum`
+ * - `repositoryName`: The name of the repository where the resource is
stored, as a `String`.
+ * - `isVersionedResourcePublic`: Indicates if the resource is public, as a
`Boolean`.
+ * - `isVersionedResourceDownloadable`: Indicates if the resource is
downloadable, as a `Boolean`.
+ * - `versionedResourceUserAccess`: Access privileges for the resource, as a
`PrivilegeEnum`
+ * - `versionedResourceCoverImage`: Cover image path of the resource, as a
`String`.
*/
class UnifiedResourceSchema private (
fieldMappingSeq: Seq[(Field[_], Field[_])]
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceSearchQueryBuilder.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceSearchQueryBuilder.scala
new file mode 100644
index 0000000000..a1e00ec45a
--- /dev/null
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceSearchQueryBuilder.scala
@@ -0,0 +1,96 @@
+/*
+ * 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.texera.web.resource.dashboard
+
+import org.apache.texera.web.resource.dashboard.FulltextSearchQueryUtils.{
+ getContainsFilter,
+ getDateFilter,
+ getFullTextSearchFilter
+}
+import org.jooq.impl.DSL
+import org.jooq.{Condition, GroupField, Record, TableLike}
+
+import scala.jdk.CollectionConverters.CollectionHasAsScala
+
+/**
+ * The one copy of FROM / WHERE / hydration for every LakeFS-backed resource.
A concrete
+ * builder supplies only its [[VersionedResourceTables]] descriptor and its
projection.
+ */
+abstract class VersionedResourceSearchQueryBuilder[Rec <: Record, P](
+ tables: VersionedResourceTables[Rec, P]
+) extends SearchQueryBuilder {
+
+ /**
+ * `uid` is null for anonymous callers. Visibility: public only when `uid`
is null;
+ * explicitly-granted only when `includePublic` is false; both when it is
true.
+ */
+ override protected def constructFromClause(
+ uid: Integer,
+ params: DashboardResource.SearchQueryParams,
+ includePublic: Boolean = false
+ ): TableLike[_] = {
+ val baseJoin = tables.joinWithAccessAndOwner(
+ Some(if (uid == null) DSL.falseCondition() else
tables.access.uidColumn.eq(uid))
+ )
+
+ val condition: Condition =
+ if (uid == null) {
+ tables.isPublicColumn.eq(true)
+ } else {
+ if (includePublic) {
+ tables.isPublicColumn.eq(true).or(tables.access.uidColumn.isNotNull)
+ } else {
+ tables.access.uidColumn.isNotNull
+ }
+ }
+ baseJoin.where(condition)
+ }
+
+ override protected def constructWhereClause(
+ uid: Integer,
+ params: DashboardResource.SearchQueryParams
+ ): Condition = {
+ val splitKeywords = params.keywords.asScala
+ .flatMap(_.split("[+\\-()<>~*@\"]"))
+ .filter(_.nonEmpty)
+ .toSeq
+
+ getDateFilter(
+ params.creationStartDate,
+ params.creationEndDate,
+ tables.creationTimeColumn
+ )
+ .and(getContainsFilter(tables.searchIds(params), tables.idColumn))
+ .and(
+ getFullTextSearchFilter(splitKeywords, List(tables.nameColumn,
tables.descriptionColumn))
+ )
+ }
+
+ override protected def getGroupByFields: Seq[GroupField] = {
+ Seq.empty
+ }
+
+ override protected def toEntryImpl(
+ uid: Integer,
+ record: Record
+ ): DashboardResource.DashboardClickableFileEntry =
+ // null = mismatch; searchAllResources drops it and flips hasMismatch.
+ tables.hydrate(record, uid).map(_._2).orNull
+}
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTables.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTables.scala
new file mode 100644
index 0000000000..27287db7cc
--- /dev/null
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTables.scala
@@ -0,0 +1,147 @@
+/*
+ * 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.texera.web.resource.dashboard
+
+import com.typesafe.scalalogging.LazyLogging
+import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
+import org.apache.texera.dao.jooq.generated.Tables.DATASET
+import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
+import org.apache.texera.dao.jooq.generated.tables.User.USER
+import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, User}
+import org.apache.texera.dao.jooq.generated.tables.records.DatasetRecord
+import org.apache.texera.web.resource.dashboard.DashboardResource.{
+ DashboardClickableFileEntry,
+ SearchQueryParams
+}
+import org.apache.texera.web.resource.dashboard.hub.EntityTables.{AccessTable,
BaseEntityTable}
+import
org.apache.texera.web.resource.dashboard.user.dataset.DatasetResource.DashboardDataset
+import org.jooq._
+
+import java.sql.Timestamp
+
+/**
+ * Names the columns of one LakeFS-backed resource (dataset, model) so
unified search and the
+ * hub are written once. A new resource type is one descriptor plus one
`EntityTables` entry.
+ */
+trait VersionedResourceTables[Rec <: Record, P] extends BaseEntityTable with
LazyLogging {
+ override type R = Rec
+ override val table: Table[Rec]
+ override val idColumn: TableField[Rec, Integer]
+ override val isPublicColumn: TableField[Rec, java.lang.Boolean]
+
+ val resourceType: String
+ val nameColumn: TableField[Rec, String]
+ val descriptionColumn: TableField[Rec, String]
+ val creationTimeColumn: TableField[Rec, Timestamp]
+ val ownerUidColumn: TableField[Rec, Integer]
+ val access: AccessTable
+
+ def searchIds(params: SearchQueryParams): java.util.List[Integer]
+
+ def pojo(record: Record): P
+ def idOf(resource: P): Integer
+ def ownerUidOf(resource: P): Integer
+ def repositoryNameOf(resource: P): String
+
+ def entry(
+ resource: P,
+ ownerEmail: String,
+ accessPrivilege: PrivilegeEnum,
+ isOwner: Boolean,
+ size: Long
+ ): DashboardClickableFileEntry
+
+ /** `accessCondition` narrows the access join: search to the caller, the hub
to nothing. */
+ final def joinWithAccessAndOwner(accessCondition: Option[Condition]):
Table[Record] = {
+ val accessJoin = table
+ .leftJoin(access.table)
+ .on(access.idColumn.eq(idColumn))
+ val filtered = accessCondition.map(condition =>
accessJoin.and(condition)).getOrElse(accessJoin)
+ filtered
+ .leftJoin(USER)
+ .on(USER.UID.eq(ownerUidColumn))
+ }
+
+ /** `None` when LakeFS cannot size the resource; callers drop it and report
a mismatch. */
+ final def hydrate(
+ record: Record,
+ uid: Integer
+ ): Option[(Integer, DashboardClickableFileEntry)] = {
+ val resource = pojo(record)
+ repositorySize(resource).map { size =>
+ idOf(resource) -> entry(
+ resource,
+ record.into(USER).into(classOf[User]).getEmail,
+ Option(record.get(access.privilegeColumn, classOf[PrivilegeEnum]))
+ .getOrElse(PrivilegeEnum.NONE),
+ ownerUidOf(resource) == uid,
+ size
+ )
+ }
+ }
+
+ final def repositorySize(resource: P): Option[Long] = {
+ val repositoryName = repositoryNameOf(resource)
+ try {
+ Some(LakeFSStorageClient.retrieveRepositorySize(repositoryName))
+ } catch {
+ case e: io.lakefs.clients.sdk.ApiException =>
+ logger.error(
+ s"LakeFS ApiException for $resourceType repository
'$repositoryName': ${e.getMessage}",
+ e
+ )
+ None
+ }
+ }
+}
+
+object VersionedResourceTables {
+
+ case object DatasetTables extends VersionedResourceTables[DatasetRecord,
Dataset] {
+ override val resourceType: String =
SearchQueryBuilder.DATASET_RESOURCE_TYPE
+ override val table: Table[DatasetRecord] = DATASET
+ override val idColumn: TableField[DatasetRecord, Integer] = DATASET.DID
+ override val isPublicColumn: TableField[DatasetRecord, java.lang.Boolean]
= DATASET.IS_PUBLIC
+ override val nameColumn: TableField[DatasetRecord, String] = DATASET.NAME
+ override val descriptionColumn: TableField[DatasetRecord, String] =
DATASET.DESCRIPTION
+ override val creationTimeColumn: TableField[DatasetRecord, Timestamp] =
DATASET.CREATION_TIME
+ override val ownerUidColumn: TableField[DatasetRecord, Integer] =
DATASET.OWNER_UID
+ override val access: AccessTable = AccessTable.DatasetAccessTable
+
+ override def searchIds(params: SearchQueryParams): java.util.List[Integer]
= params.datasetIds
+
+ override def pojo(record: Record): Dataset =
record.into(DATASET).into(classOf[Dataset])
+ override def idOf(resource: Dataset): Integer = resource.getDid
+ override def ownerUidOf(resource: Dataset): Integer = resource.getOwnerUid
+ override def repositoryNameOf(resource: Dataset): String =
resource.getRepositoryName
+
+ override def entry(
+ resource: Dataset,
+ ownerEmail: String,
+ accessPrivilege: PrivilegeEnum,
+ isOwner: Boolean,
+ size: Long
+ ): DashboardClickableFileEntry =
+ DashboardClickableFileEntry(
+ resourceType = resourceType,
+ dataset = Some(DashboardDataset(resource, ownerEmail, accessPrivilege,
isOwner, size))
+ )
+ }
+}
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala
index b152086d4f..4a2d749fc1 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala
@@ -20,12 +20,62 @@
package org.apache.texera.web.resource.dashboard.hub
import org.apache.texera.dao.jooq.generated.Tables._
+import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
import org.apache.texera.dao.jooq.generated.tables.records._
+import org.apache.texera.web.resource.dashboard.VersionedResourceTables
import org.jooq._
object EntityTables {
+
+ // ==================== THE REGISTRY ====================
+
+ /** Every table one hub entity type owns. A new entity type is one set plus
one line in `apply`. */
+ sealed trait EntityTableSet {
+ val base: BaseEntityTable
+ val like: LikeTable
+ val viewCount: ViewCountTable
+ val access: AccessTable
+
+ /**
+ * Empty for cloneless entities (datasets, models): callers that can skip
cloning read
+ * this and report zero, instead of `CloneTable.apply` throwing. Not
named `clone`
+ * because `Object.clone` owns that name.
+ */
+ val cloneTable: Option[CloneTable]
+
+ /** Set for LakeFS-backed resources, so the hub can hydrate without a
per-type branch. */
+ val versionedResource: Option[VersionedResourceTables[_ <: Record, _]]
+ }
+
+ case object WorkflowTableSet extends EntityTableSet {
+ override val base: BaseEntityTable = BaseEntityTable.WorkflowTable
+ override val like: LikeTable = LikeTable.WorkflowLikeTable
+ override val viewCount: ViewCountTable =
ViewCountTable.WorkflowViewCountTable
+ override val access: AccessTable = AccessTable.WorkflowAccessTable
+ override val cloneTable: Option[CloneTable] =
Some(CloneTable.WorkflowCloneTable)
+ override val versionedResource: Option[VersionedResourceTables[_ <:
Record, _]] = None
+ }
+
+ case object DatasetTableSet extends EntityTableSet {
+ override val base: BaseEntityTable = VersionedResourceTables.DatasetTables
+ override val like: LikeTable = LikeTable.DatasetLikeTable
+ override val viewCount: ViewCountTable =
ViewCountTable.DatasetViewCountTable
+ override val access: AccessTable = AccessTable.DatasetAccessTable
+ override val cloneTable: Option[CloneTable] = None
+ override val versionedResource: Option[VersionedResourceTables[_ <:
Record, _]] = Some(
+ VersionedResourceTables.DatasetTables
+ )
+ }
+
+ def apply(entityType: EntityType): EntityTableSet =
+ entityType match {
+ case EntityType.Workflow => WorkflowTableSet
+ case EntityType.Dataset => DatasetTableSet
+ }
+
// ==================== BASE TABLE ====================
- sealed trait BaseEntityTable {
+ // Not sealed: VersionedResourceTables implements it, so id/is_public are
named once.
+ trait BaseEntityTable {
type R <: Record
val table: Table[R]
val isPublicColumn: TableField[R, java.lang.Boolean]
@@ -41,18 +91,7 @@ object EntityTables {
override val idColumn: TableField[WorkflowRecord, Integer] = WORKFLOW.WID
}
- case object DatasetTable extends BaseEntityTable {
- override type R = DatasetRecord
- override val table: Table[DatasetRecord] = DATASET
- override val isPublicColumn: TableField[DatasetRecord,
java.lang.Boolean] = DATASET.IS_PUBLIC
- override val idColumn: TableField[DatasetRecord, Integer] = DATASET.DID
- }
-
- def apply(entityType: EntityType): BaseEntityTable =
- entityType match {
- case EntityType.Workflow => WorkflowTable
- case EntityType.Dataset => DatasetTable
- }
+ def apply(entityType: EntityType): BaseEntityTable =
EntityTables(entityType).base
}
// ==================== BASE LC (like & clone) TABLE ====================
@@ -83,11 +122,7 @@ object EntityTables {
override val idColumn: TableField[DatasetUserLikesRecord, Integer] =
DATASET_USER_LIKES.DID
}
- def apply(entityType: EntityType): LikeTable =
- entityType match {
- case EntityType.Workflow => WorkflowLikeTable
- case EntityType.Dataset => DatasetLikeTable
- }
+ def apply(entityType: EntityType): LikeTable =
EntityTables(entityType).like
}
// ==================== CLONE TABLE ====================
@@ -103,12 +138,11 @@ object EntityTables {
WORKFLOW_USER_CLONES.WID
}
+ /** For callers that cannot proceed without one; see
`EntityTableSet.cloneTable`. */
def apply(entityType: EntityType): CloneTable =
- entityType match {
- case EntityType.Workflow => WorkflowCloneTable
- case _ =>
- throw new IllegalArgumentException(s"Unsupported entity type:
$entityType for clone")
- }
+ EntityTables(entityType).cloneTable.getOrElse(
+ throw new IllegalArgumentException(s"Unsupported entity type:
$entityType for clone")
+ )
}
// ==================== VIEW COUNT TABLE ====================
@@ -136,10 +170,40 @@ object EntityTables {
DATASET_VIEW_COUNT.VIEW_COUNT
}
- def apply(entityType: EntityType): ViewCountTable =
- entityType match {
- case EntityType.Workflow => WorkflowViewCountTable
- case EntityType.Dataset => DatasetViewCountTable
- }
+ def apply(entityType: EntityType): ViewCountTable =
EntityTables(entityType).viewCount
+ }
+
+ // ==================== ACCESS TABLE ====================
+ /** The `<entity>_user_access` sibling table, replacing the inline match in
HubResource. */
+ sealed trait AccessTable {
+ type R <: Record
+ val table: Table[R]
+ val idColumn: TableField[R, Integer]
+ val uidColumn: TableField[R, Integer]
+ val privilegeColumn: TableField[R, PrivilegeEnum]
+ }
+
+ object AccessTable {
+ case object WorkflowAccessTable extends AccessTable {
+ override type R = WorkflowUserAccessRecord
+ override val table: Table[WorkflowUserAccessRecord] =
WORKFLOW_USER_ACCESS
+ override val idColumn: TableField[WorkflowUserAccessRecord, Integer] =
+ WORKFLOW_USER_ACCESS.WID
+ override val uidColumn: TableField[WorkflowUserAccessRecord, Integer] =
+ WORKFLOW_USER_ACCESS.UID
+ override val privilegeColumn: TableField[WorkflowUserAccessRecord,
PrivilegeEnum] =
+ WORKFLOW_USER_ACCESS.PRIVILEGE
+ }
+
+ case object DatasetAccessTable extends AccessTable {
+ override type R = DatasetUserAccessRecord
+ override val table: Table[DatasetUserAccessRecord] = DATASET_USER_ACCESS
+ override val idColumn: TableField[DatasetUserAccessRecord, Integer] =
DATASET_USER_ACCESS.DID
+ override val uidColumn: TableField[DatasetUserAccessRecord, Integer] =
DATASET_USER_ACCESS.UID
+ override val privilegeColumn: TableField[DatasetUserAccessRecord,
PrivilegeEnum] =
+ DATASET_USER_ACCESS.PRIVILEGE
+ }
+
+ def apply(entityType: EntityType): AccessTable =
EntityTables(entityType).access
}
}
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
index 33ba16c6e1..0dd3cfc1b6 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
@@ -19,30 +19,24 @@
package org.apache.texera.web.resource.dashboard.hub
-import com.typesafe.scalalogging.Logger
import io.dropwizard.auth.Auth
-import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
import org.apache.texera.auth.SessionUser
import org.apache.texera.dao.SqlServer
import org.apache.texera.dao.jooq.generated.Tables._
import org.apache.texera.dao.jooq.generated.enums.ActionEnum
-import org.apache.texera.dao.jooq.generated.tables.Dataset.DATASET
-import
org.apache.texera.dao.jooq.generated.tables.DatasetUserAccess.DATASET_USER_ACCESS
import org.apache.texera.dao.jooq.generated.tables.User.USER
-import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset,
DatasetUserAccess}
import
org.apache.texera.web.resource.dashboard.DashboardResource.DashboardClickableFileEntry
+import org.apache.texera.web.resource.dashboard.VersionedResourceTables
import org.apache.texera.web.resource.dashboard.hub.ActionType.{Clone, Like,
Unlike, View}
import org.apache.texera.web.resource.dashboard.hub.EntityTables._
import org.apache.texera.web.resource.dashboard.hub.HubResource._
-import
org.apache.texera.web.resource.dashboard.user.dataset.DatasetResource.DashboardDataset
import
org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.{
DashboardWorkflow,
baseWorkflowSelect,
mapWorkflowEntries
}
-import org.jooq.Table
import org.jooq.impl.DSL
-import org.slf4j.LoggerFactory
+import org.jooq.{Record, Table, TableField}
import java.util.regex.Pattern
import javax.servlet.http.HttpServletRequest
@@ -50,10 +44,8 @@ import javax.ws.rs._
import javax.ws.rs.core.{Context, MediaType}
import scala.collection.mutable.ListBuffer
import scala.jdk.CollectionConverters._
-import scala.language.existentials
object HubResource {
- private lazy val logger: Logger =
Logger(LoggerFactory.getLogger(getClass.getName))
// Represents an entity reference for general-purpose batch APIs.
// Used by: isLikedHelper, recordLikeAction, getCounts, userAccess
@@ -120,7 +112,7 @@ object HubResource {
.groupBy(_.entityType)
.foreach {
case (etype, groupReqs) =>
- val tbl = LikeTable(etype)
+ val tbl = EntityTables(etype).like
val ids = groupReqs.map(_.entityId)
val likedSet: Set[Int] = context
@@ -191,7 +183,7 @@ object HubResource {
): Boolean = {
val (entityId, entityType) =
(userRequest.entityId, userRequest.entityType)
- val entityTables = LikeTable(entityType)
+ val entityTables = EntityTables(entityType).like
val (table, uidColumn, idColumn) =
(entityTables.table, entityTables.uidColumn, entityTables.idColumn)
@@ -282,59 +274,40 @@ object HubResource {
mapWorkflowEntries(records, uid)
}
- def fetchDashboardDatasetsByDids(dids: Seq[Integer], uid: Integer):
List[DashboardDataset] = {
- if (dids.isEmpty) {
- return List.empty[DashboardDataset]
+ /**
+ * Hydrates ids of one LakeFS-backed resource into hub entries. Unsizable
resources are
+ * dropped; ids are de-duplicated because the access join can match one
twice.
+ */
+ def fetchDashboardVersionedResourcesByIds(
+ tables: VersionedResourceTables[_ <: Record, _],
+ ids: Seq[Integer],
+ uid: Integer
+ ): List[DashboardClickableFileEntry] = {
+ if (ids.isEmpty) {
+ return List.empty[DashboardClickableFileEntry]
}
val records = context
.select()
- .from(
- DATASET
- .leftJoin(DATASET_USER_ACCESS)
- .on(DATASET_USER_ACCESS.DID.eq(DATASET.DID))
- .leftJoin(USER)
- .on(USER.UID.eq(DATASET.OWNER_UID))
- )
- .where(DATASET.DID.in(dids: _*))
+ .from(tables.joinWithAccessAndOwner(None))
+ .where(tables.idColumn.in(ids: _*))
.groupBy(
- DATASET.DID,
- DATASET.NAME,
- DATASET.DESCRIPTION,
- DATASET.OWNER_UID,
+ tables.idColumn,
+ tables.nameColumn,
+ tables.descriptionColumn,
+ tables.ownerUidColumn,
USER.NAME,
- DATASET_USER_ACCESS.DID,
- DATASET_USER_ACCESS.UID,
+ tables.access.idColumn,
+ tables.access.uidColumn,
USER.UID
)
.fetch()
records.asScala
- .flatMap { record =>
- val dataset = record.into(DATASET).into(classOf[Dataset])
- val datasetAccess =
record.into(DATASET_USER_ACCESS).into(classOf[DatasetUserAccess])
- val ownerEmail = record.into(USER).getEmail
- try {
- Some(
- DashboardDataset(
- isOwner = if (uid == null) false else dataset.getOwnerUid == uid,
- dataset = dataset,
- accessPrivilege = datasetAccess.getPrivilege,
- ownerEmail = ownerEmail,
- size =
LakeFSStorageClient.retrieveRepositorySize(dataset.getRepositoryName)
- )
- )
- } catch {
- case e: io.lakefs.clients.sdk.ApiException =>
- logger.error(
- s"LakeFS ApiException for dataset repository
'${dataset.getRepositoryName}': ${e.getMessage}",
- e
- )
- None
- }
- }
+ .flatMap(record => tables.hydrate(record, uid))
.toList
- .distinctBy(_.dataset.getDid)
+ .distinctBy(_._1)
+ .map(_._2)
}
}
@@ -349,7 +322,7 @@ class HubResource {
@GET
@Path("/count")
def getCount(@QueryParam("entityType") entityType: EntityType): Integer = {
- val entityTables = BaseEntityTable(entityType)
+ val entityTables = EntityTables(entityType).base
val (table, isPublicColumn) = (entityTables.table,
entityTables.isPublicColumn)
context
@@ -425,7 +398,7 @@ class HubResource {
* Unified endpoint to fetch the top N (here N = 8) public entities for a
given entity type,
* grouped by specified action types, with optional user context.
*
- * @param entityType The EntityType enum value (Workflow or Dataset) to
query.
+ * @param entityType The EntityType enum value (Workflow, Dataset) to
query.
* @param actionTypes Optional list of ActionType enums to include (Like,
Clone).
* If omitted or empty, defaults to [Like, Clone].
* @param uid Optional user ID (Integer) for user-specific context.
@@ -445,7 +418,8 @@ class HubResource {
@QueryParam("uid") uid: Integer,
@QueryParam("limit") limit: Integer
): java.util.Map[String, java.util.List[DashboardClickableFileEntry]] = {
- val baseTable = BaseEntityTable(entityType)
+ val tableSet = EntityTables(entityType)
+ val baseTable = tableSet.base
val isPublicColumn = baseTable.isPublicColumn
val baseIdColumn = baseTable.idColumn
val topN: Int = Option(limit).filter(_ > 0).map(_.intValue).getOrElse(8)
@@ -462,53 +436,50 @@ class HubResource {
val result: Map[String, java.util.List[DashboardClickableFileEntry]] =
types.map { act =>
- val (table, idColumn) = act match {
+ val rankedBy: Option[(Table[_], TableField[_, Integer])] = act match {
case ActionType.Like =>
- val lt = LikeTable(entityType)
- (lt.table, lt.idColumn)
+ val lt = tableSet.like
+ Some((lt.table, lt.idColumn))
case ActionType.Clone =>
- val ct = CloneTable(entityType)
- (ct.table, ct.idColumn)
+ tableSet.cloneTable.map(ct => (ct.table, ct.idColumn))
case other =>
throw new BadRequestException(
s"Unsupported actionType: '$other'. Supported: [like, clone]"
)
}
- val topIds: Seq[Integer] = context
- .select(idColumn)
- .from(table)
- .join(baseTable.table)
- .on(idColumn.eq(baseIdColumn))
- .where(isPublicColumn.eq(true))
- .groupBy(idColumn)
- .orderBy(DSL.count(idColumn).desc())
- .limit(topN)
- .fetchInto(classOf[Integer])
- .asScala
- .toSeq
+ val topIds: Seq[Integer] = rankedBy.toSeq.flatMap {
+ case (table, idColumn) =>
+ context
+ .select(idColumn)
+ .from(table)
+ .join(baseTable.table)
+ .on(idColumn.eq(baseIdColumn))
+ .where(isPublicColumn.eq(true))
+ .groupBy(idColumn)
+ .orderBy(DSL.count(idColumn).desc())
+ .limit(topN)
+ .fetchInto(classOf[Integer])
+ .asScala
+ .toSeq
+ }
val entries: Seq[DashboardClickableFileEntry] =
- if (entityType == EntityType.Workflow) {
- fetchDashboardWorkflowsByWids(topIds, currentUid).map { w =>
- DashboardClickableFileEntry(
- resourceType = entityType.value,
- workflow = Some(w),
- project = None,
- dataset = None
- )
- }
- } else if (entityType == EntityType.Dataset) {
- fetchDashboardDatasetsByDids(topIds, currentUid).map { d =>
- DashboardClickableFileEntry(
- resourceType = entityType.value,
- workflow = None,
- project = None,
- dataset = Some(d)
- )
- }
- } else {
- Seq.empty
+ tableSet.versionedResource match {
+ case Some(versionedResource) =>
+ fetchDashboardVersionedResourcesByIds(versionedResource, topIds,
currentUid)
+ case None =>
+ entityType match {
+ case EntityType.Workflow =>
+ fetchDashboardWorkflowsByWids(topIds, currentUid).map { w =>
+ DashboardClickableFileEntry(
+ resourceType = entityType.value,
+ workflow = Some(w)
+ )
+ }
+ case other =>
+ throw new BadRequestException(s"getTops is not supported for
'$other'")
+ }
}
act.value -> entries.toList.asJava
@@ -577,7 +548,8 @@ class HubResource {
grouped.foreach {
case (etype, ids) =>
- val viewTbl = ViewCountTable(etype)
+ val tableSet = EntityTables(etype)
+ val viewTbl = tableSet.viewCount
val viewMap: Map[Int, Int] =
if (requestedActions.contains(ActionType.View)) {
val raw = context
@@ -603,7 +575,7 @@ class HubResource {
raw ++ missing.map(id => id.intValue() -> 0).toMap
} else Map.empty
- val likeTbl = LikeTable(etype)
+ val likeTbl = tableSet.like
val likeMap: Map[Int, Int] =
if (requestedActions.contains(ActionType.Like)) {
context
@@ -621,21 +593,23 @@ class HubResource {
} else Map.empty
val cloneMap: Map[Int, Int] =
- if (requestedActions.contains(ActionType.Clone) && etype !=
EntityType.Dataset) {
- val cloneTbl = CloneTable(etype)
- context
- .select(cloneTbl.idColumn, DSL.count().`as`("cnt"))
- .from(cloneTbl.table)
- .where(cloneTbl.idColumn.in(ids: _*))
- .groupBy(cloneTbl.idColumn)
- .fetch()
- .asScala
- .map { r =>
- r.get(cloneTbl.idColumn).intValue() ->
- r.get("cnt", classOf[Integer]).intValue()
- }
- .toMap
- } else Map.empty
+ tableSet.cloneTable
+ .filter(_ => requestedActions.contains(ActionType.Clone))
+ .map { cloneTbl =>
+ context
+ .select(cloneTbl.idColumn, DSL.count().`as`("cnt"))
+ .from(cloneTbl.table)
+ .where(cloneTbl.idColumn.in(ids: _*))
+ .groupBy(cloneTbl.idColumn)
+ .fetch()
+ .asScala
+ .map { r =>
+ r.get(cloneTbl.idColumn).intValue() ->
+ r.get("cnt", classOf[Integer]).intValue()
+ }
+ .toMap
+ }
+ .getOrElse(Map.empty)
reqs.filter(_.entityType == etype).foreach { req =>
val key = req.entityId.intValue()
@@ -675,18 +649,14 @@ class HubResource {
val reqs =
entityIds.asScala
.zip(entityTypes.asScala)
- .map { case (et, id) => UserRequest(et, id) }
+ .map { case (id, etype) => UserRequest(id, etype) }
.toList
val responses = ListBuffer[AccessResponse]()
reqs.groupBy(_.entityType).foreach {
case (etype, groupReqs) =>
- val (tbl, idCol, uidCol) = etype match {
- case EntityType.Workflow =>
- (WORKFLOW_USER_ACCESS: Table[_], WORKFLOW_USER_ACCESS.WID,
WORKFLOW_USER_ACCESS.UID)
- case EntityType.Dataset =>
- (DATASET_USER_ACCESS: Table[_], DATASET_USER_ACCESS.DID,
DATASET_USER_ACCESS.UID)
- }
+ val access = EntityTables(etype).access
+ val (tbl, idCol, uidCol) = (access.table, access.idColumn,
access.uidColumn)
val records = context
.select(idCol, uidCol)
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilderSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilderSpec.scala
index 338a2b3651..cc60e1853e 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilderSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilderSpec.scala
@@ -485,12 +485,14 @@ class DatasetSearchQueryBuilderSpec
sql should include("dataset.description as resourcedescription")
sql should include("dataset.creation_time as resourcecreationtime")
sql should include("dataset.owner_uid as resourceownerid")
- sql should include("dataset.did as did")
sql should include("dataset.repository_name as repository_name")
- sql should include("dataset.is_public as is_dataset_public")
- sql should include("dataset.is_downloadable as is_dataset_downloadable")
- sql should include("dataset_user_access.privilege as user_dataset_access")
- sql should include("dataset.cover_image as cover_image")
+ // The id/publicity/privilege/cover slots are shared with the other
versioned resources,
+ // so their aliases are resource-neutral while the columns feeding them
are the dataset's.
+ sql should include("dataset.did as versioned_resource_id")
+ sql should include("dataset.is_public as is_versioned_resource_public")
+ sql should include("dataset.is_downloadable as
is_versioned_resource_downloadable")
+ sql should include("dataset_user_access.privilege as
user_versioned_resource_access")
+ sql should include("dataset.cover_image as versioned_resource_cover_image")
}
it should "join the owner row on the dataset's owner" in {
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
index 9d5f1e0b0b..52890b4bf0 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
@@ -101,13 +101,13 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with
Matchers {
pid = PROJECT.PID,
projectOwnerId = PROJECT.OWNER_ID,
projectColor = PROJECT.COLOR,
- did = DATASET.DID,
+ versionedResourceId = DATASET.DID,
datasetStoragePath = sentinelStoragePath,
repositoryName = DATASET.REPOSITORY_NAME,
- isDatasetPublic = DATASET.IS_PUBLIC,
- isDatasetDownloadable = DATASET.IS_DOWNLOADABLE,
- datasetUserAccess = DATASET_USER_ACCESS.PRIVILEGE,
- datasetCoverImage = DATASET.COVER_IMAGE,
+ isVersionedResourcePublic = DATASET.IS_PUBLIC,
+ isVersionedResourceDownloadable = DATASET.IS_DOWNLOADABLE,
+ versionedResourceUserAccess = DATASET_USER_ACCESS.PRIVILEGE,
+ versionedResourceCoverImage = DATASET.COVER_IMAGE,
workflowCoverImage = WORKFLOW_COVER_IMAGE.IMAGE
)
@@ -129,13 +129,13 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with
Matchers {
"pid" -> PROJECT.PID,
"owner_uid" -> PROJECT.OWNER_ID,
"color" -> PROJECT.COLOR,
- "did" -> DATASET.DID,
+ "versioned_resource_id" -> DATASET.DID,
"dataset_storage_path" -> sentinelStoragePath,
"repository_name" -> DATASET.REPOSITORY_NAME,
- "is_dataset_public" -> DATASET.IS_PUBLIC,
- "is_dataset_downloadable" -> DATASET.IS_DOWNLOADABLE,
- "user_dataset_access" -> DATASET_USER_ACCESS.PRIVILEGE,
- "cover_image" -> DATASET.COVER_IMAGE,
+ "is_versioned_resource_public" -> DATASET.IS_PUBLIC,
+ "is_versioned_resource_downloadable" -> DATASET.IS_DOWNLOADABLE,
+ "user_versioned_resource_access" -> DATASET_USER_ACCESS.PRIVILEGE,
+ "versioned_resource_cover_image" -> DATASET.COVER_IMAGE,
"workflow_cover_image" -> WORKFLOW_COVER_IMAGE.IMAGE
)
@@ -167,7 +167,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with
Matchers {
rendered should include("'' as \"resourceType\"")
rendered should include("cast(null as timestamp) as
\"resourceCreationTime\"")
rendered should include("cast(null as int) as \"resourceOwnerId\"")
- rendered should include("cast(null as boolean) as \"is_dataset_public\"")
+ rendered should include("cast(null as boolean) as
\"is_versioned_resource_public\"")
}
// -- the de-dup behind translatedFieldSet
-----------------------------------
@@ -209,7 +209,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with
Matchers {
"resourceOwnerId", // cast(null as int)
"workflow_privilege", // cast(null as privilege_enum)
"dataset_storage_path", // cast(null as varchar)
- "is_dataset_public" // cast(null as boolean)
+ "is_versioned_resource_public" // cast(null as boolean)
)
}
@@ -229,10 +229,10 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with
Matchers {
"uid",
"owner_uid",
"color",
- "did",
+ "versioned_resource_id",
"repository_name",
- "is_dataset_downloadable",
- "cover_image"
+ "is_versioned_resource_downloadable",
+ "versioned_resource_cover_image"
)
aliases should contain("resourceOwnerId")
}
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTablesSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTablesSpec.scala
new file mode 100644
index 0000000000..a509f03352
--- /dev/null
+++
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTablesSpec.scala
@@ -0,0 +1,140 @@
+/*
+ * 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.texera.web.resource.dashboard
+
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
+import org.apache.texera.dao.jooq.generated.tables.pojos.Dataset
+import
org.apache.texera.web.resource.dashboard.DashboardResource.SearchQueryParams
+import org.apache.texera.web.resource.dashboard.hub.EntityTables
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import scala.jdk.CollectionConverters._
+
+/**
+ * The descriptor is the only place a resource's columns are named, so a slot
wired to the
+ * wrong column still compiles and runs — it just queries the wrong field.
SQL is rendered,
+ * never executed. `repositorySize` is uncovered: live LakeFS call, no
mockable seam.
+ */
+class VersionedResourceTablesSpec
+ extends AnyFlatSpec
+ with Matchers
+ with BeforeAndAfterAll
+ with MockTexeraDB {
+
+ private val tables = VersionedResourceTables.DatasetTables
+
+ override protected def beforeAll(): Unit = {
+ initializeDBAndReplaceDSLContext()
+ }
+
+ override protected def afterAll(): Unit = {
+ shutdownDB()
+ }
+
+ private def rendered(sql: String): String = sql.toLowerCase.replace("\"", "")
+
+ private def dataset(did: Int, ownerUid: Int, repositoryName: String):
Dataset = {
+ val d = new Dataset
+ d.setDid(Integer.valueOf(did))
+ d.setOwnerUid(Integer.valueOf(ownerUid))
+ d.setRepositoryName(repositoryName)
+ d
+ }
+
+ "DatasetTables" should "name every column the shared query logic reads" in {
+ tables.table.getName shouldBe "dataset"
+ tables.idColumn.getName shouldBe "did"
+ tables.isPublicColumn.getName shouldBe "is_public"
+ tables.nameColumn.getName shouldBe "name"
+ tables.descriptionColumn.getName shouldBe "description"
+ tables.creationTimeColumn.getName shouldBe "creation_time"
+ tables.ownerUidColumn.getName shouldBe "owner_uid"
+ }
+
+ it should "share the hub's access table rather than naming it twice" in {
+ tables.access shouldBe EntityTables.AccessTable.DatasetAccessTable
+ }
+
+ it should "tag its rows with the resource type DashboardResource dispatches
on" in {
+ // DashboardResource matches this literal with no default branch.
+ tables.resourceType shouldBe SearchQueryBuilder.DATASET_RESOURCE_TYPE
+ tables.resourceType shouldBe "dataset"
+ }
+
+ it should "read its id filter out of the shared search params" in {
+ // A sibling's param list here would silently apply the wrong filter.
+ val ids = List(Integer.valueOf(7), Integer.valueOf(9)).asJava
+ tables.searchIds(SearchQueryParams(datasetIds = ids)) shouldBe ids
+ tables.searchIds(SearchQueryParams()) shouldBe empty
+ }
+
+ it should "expose the POJO fields the hub de-dupes and sizes by" in {
+ val d = dataset(11, 22, "dataset-11")
+ tables.idOf(d) shouldBe Integer.valueOf(11)
+ tables.ownerUidOf(d) shouldBe Integer.valueOf(22)
+ tables.repositoryNameOf(d) shouldBe "dataset-11"
+ }
+
+ "entry" should "wrap the resource in the slot the frontend reads it from" in
{
+ val d = dataset(11, 22, "dataset-11")
+ val entry = tables.entry(d, "[email protected]", PrivilegeEnum.READ, isOwner
= true, size = 512L)
+
+ entry.resourceType shouldBe "dataset"
+ entry.workflow shouldBe None
+ entry.project shouldBe None
+ val dashboardDataset = entry.dataset.getOrElse(fail("expected a dataset
entry"))
+ dashboardDataset.dataset shouldBe d
+ dashboardDataset.ownerEmail shouldBe "[email protected]"
+ dashboardDataset.accessPrivilege shouldBe PrivilegeEnum.READ
+ dashboardDataset.isOwner shouldBe true
+ dashboardDataset.size shouldBe 512L
+ }
+
+ "joinWithAccessAndOwner" should "join the access rows and the owner" in {
+ // Rendered names are schema-qualified (texera_db.dataset.did), hence the
regex.
+ val sql =
rendered(getDSLContext.render(tables.joinWithAccessAndOwner(None)))
+
+ sql should include regex "dataset_user_access\\.did = [\\w.]*dataset\\.did"
+ sql should include regex "user\\.uid = [\\w.]*dataset\\.owner_uid"
+ // Both joins are outer: a resource with no grant row must still appear.
+ sql.split("left outer join").length shouldBe 3
+ }
+
+ it should "leave the access rows unfiltered when no condition is given" in {
+ // The hub's call: every grant row.
+ val sql =
rendered(getDSLContext.render(tables.joinWithAccessAndOwner(None)))
+
+ sql should not include "dataset_user_access.uid ="
+ }
+
+ it should "narrow the access rows when a condition is given" in {
+ // The search's call. Without the predicate, `uid is not null` matches
anybody's grant.
+ val sql = rendered(
+ getDSLContext.renderInlined(
+
tables.joinWithAccessAndOwner(Some(tables.access.uidColumn.eq(Integer.valueOf(42))))
+ )
+ )
+
+ sql should include("dataset_user_access.uid = 42")
+ }
+}
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala
index 5be87fdbd0..f60f92ebd9 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala
@@ -19,6 +19,7 @@
package org.apache.texera.web.resource.dashboard.hub
+import org.apache.texera.web.resource.dashboard.VersionedResourceTables
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
@@ -31,9 +32,10 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
EntityTables.BaseEntityTable.WorkflowTable
}
- it should "dispatch Dataset → DatasetTable" in {
+ it should "dispatch Dataset → the dataset's search descriptor" in {
+ // Datasets have no BaseEntityTable object: VersionedResourceTables
implements it.
EntityTables.BaseEntityTable(EntityType.Dataset) shouldBe
- EntityTables.BaseEntityTable.DatasetTable
+ VersionedResourceTables.DatasetTables
}
"BaseEntityTable.WorkflowTable" should "wire up id and isPublic columns from
WORKFLOW" in {
@@ -42,8 +44,8 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
t.isPublicColumn.getName shouldBe "is_public"
}
- "BaseEntityTable.DatasetTable" should "wire up id and isPublic columns from
DATASET" in {
- val t = EntityTables.BaseEntityTable.DatasetTable
+ "VersionedResourceTables.DatasetTables" should "wire up id and isPublic
columns from DATASET" in {
+ val t = VersionedResourceTables.DatasetTables
t.idColumn.getName shouldBe "did"
t.isPublicColumn.getName shouldBe "is_public"
}
@@ -79,8 +81,7 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
it should "throw IllegalArgumentException for Dataset because there is no
DatasetClone table" in {
// The asymmetry is intentional today: dataset clones aren't a modelled
- // entity. Pinning the exception so a future addition of DatasetCloneTable
- // forces this spec to be updated alongside the new dispatch branch.
+ // entity. CloneTable.apply stays for recordCloneAction.
val ex = intercept[IllegalArgumentException] {
EntityTables.CloneTable(EntityType.Dataset)
}
@@ -88,6 +89,40 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
ex.getMessage should include("clone")
}
+ // -- the registry
-----------------------------------------------------------
+
+ "EntityTables.apply" should "expose every table an entity type owns" in {
+ val workflow = EntityTables(EntityType.Workflow)
+ workflow.base shouldBe EntityTables.BaseEntityTable.WorkflowTable
+ workflow.like shouldBe EntityTables.LikeTable.WorkflowLikeTable
+ workflow.viewCount shouldBe
EntityTables.ViewCountTable.WorkflowViewCountTable
+ workflow.access shouldBe EntityTables.AccessTable.WorkflowAccessTable
+ workflow.cloneTable shouldBe
Some(EntityTables.CloneTable.WorkflowCloneTable)
+ workflow.versionedResource shouldBe None
+
+ val dataset = EntityTables(EntityType.Dataset)
+ dataset.base shouldBe VersionedResourceTables.DatasetTables
+ dataset.like shouldBe EntityTables.LikeTable.DatasetLikeTable
+ dataset.viewCount shouldBe
EntityTables.ViewCountTable.DatasetViewCountTable
+ dataset.access shouldBe EntityTables.AccessTable.DatasetAccessTable
+ dataset.cloneTable shouldBe None
+ dataset.versionedResource shouldBe
Some(VersionedResourceTables.DatasetTables)
+ }
+
+ // -- AccessTable
------------------------------------------------------------
+
+ "EntityTables.AccessTable" should "expose id, uid and privilege per entity"
in {
+ val w = EntityTables.AccessTable(EntityType.Workflow)
+ w.idColumn.getName shouldBe "wid"
+ w.uidColumn.getName shouldBe "uid"
+ w.privilegeColumn.getName shouldBe "privilege"
+
+ val d = EntityTables.AccessTable(EntityType.Dataset)
+ d.idColumn.getName shouldBe "did"
+ d.uidColumn.getName shouldBe "uid"
+ d.privilegeColumn.getName shouldBe "privilege"
+ }
+
// -- ViewCountTable
---------------------------------------------------------
"EntityTables.ViewCountTable.apply" should "dispatch Workflow →
WorkflowViewCountTable" in {
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubEntityModelSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubEntityModelSpec.scala
index 2924cf54b4..03a0908532 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubEntityModelSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubEntityModelSpec.scala
@@ -21,6 +21,7 @@ package org.apache.texera.web.resource.dashboard.hub
import org.apache.texera.amber.util.JSONUtils.objectMapper
import org.apache.texera.dao.jooq.generated.Tables._
+import org.apache.texera.web.resource.dashboard.VersionedResourceTables
import org.scalatest.flatspec.AnyFlatSpec
class HubEntityModelSpec extends AnyFlatSpec {
@@ -164,9 +165,10 @@ class HubEntityModelSpec extends AnyFlatSpec {
assert(t.idColumn == WORKFLOW.WID)
}
- it should "dispatch Dataset → DatasetTable" in {
+ it should "dispatch Dataset → the dataset's search descriptor" in {
+ // Datasets have no BaseEntityTable object: VersionedResourceTables
implements it.
val t = EntityTables.BaseEntityTable(EntityType.Dataset)
- assert(t == EntityTables.BaseEntityTable.DatasetTable)
+ assert(t == VersionedResourceTables.DatasetTables)
assert(t.table == DATASET)
assert(t.isPublicColumn == DATASET.IS_PUBLIC)
assert(t.idColumn == DATASET.DID)
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala
index ffd860541a..c7f7c772a4 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala
@@ -35,6 +35,7 @@ import org.apache.texera.dao.jooq.generated.tables.pojos.{
WorkflowOfUser,
WorkflowUserAccess
}
+import org.apache.texera.web.resource.dashboard.VersionedResourceTables
import org.apache.texera.web.resource.dashboard.hub.HubResource._
import org.scalamock.scalatest.MockFactory
import org.scalatest.BeforeAndAfterAll
@@ -715,8 +716,6 @@ class HubResourceSpec
seedDatasetViewCount(820801, 7)
Seq(ownerUid, likerUid, thirdUid).foreach(seedDatasetLike(820801, _))
- // CloneTable(Dataset) throws; the `etype != Dataset` guard is what keeps
this
- // request from turning into a 500.
val counts = hub.getCounts(types(Ds), ids(820801),
null).asScala.head.counts
counts.asScala.toMap shouldBe Map(
@@ -760,6 +759,22 @@ class HubResourceSpec
hub.getTops(Wf, null, null, null).asScala.keySet shouldBe Set("like",
"clone")
}
+ // Regression: the default bucket list ([like, clone]) used to drive getTops
into a
+ // throwing CloneTable(Dataset), making this plain request a 500.
+ it should "answer for a cloneless entity type instead of failing on its
absent clone table" in {
+ val tops = hub.getTops(Ds, null, null, null).asScala
+
+ tops.keySet shouldBe Set("like", "clone")
+ tops("clone").asScala shouldBe empty
+ }
+
+ it should "return an empty clone bucket when clone is asked for explicitly"
in {
+ seedDataset(820850, "ds_no_clones")
+ seedDatasetLike(820850, likerUid)
+
+ hub.getTops(Ds, actions(ActionType.Clone), null,
null).get("clone").asScala shouldBe empty
+ }
+
it should "select the most-liked public workflows and honour the limit" in {
val hot = seedWorkflow(810701, "wf_hot")
val warm = seedWorkflow(810702, "wf_warm")
@@ -914,11 +929,12 @@ class HubResourceSpec
fetchDashboardWorkflowsByWids(Seq(id), null).head.isOwner shouldBe false
}
- // fetchDashboardDatasetsByDids calls LakeFSStorageClient for every did it
resolves,
- // so an empty id list is the only input exercisable without a LakeFS
server. This
- // pins the contract (empty in, empty out), not the early-return branch
itself: the
- // query would also come back empty if the guard were removed.
- "fetchDashboardDatasetsByDids" should "return an empty list for an empty did
list" in {
- fetchDashboardDatasetsByDids(Seq.empty, Integer.valueOf(ownerUid))
shouldBe empty
+ // Every resolved id hits LakeFS, so an empty list is the only input
testable without one.
+ "fetchDashboardVersionedResourcesByIds" should "return an empty list for an
empty id list" in {
+ fetchDashboardVersionedResourcesByIds(
+ VersionedResourceTables.DatasetTables,
+ Seq.empty,
+ Integer.valueOf(ownerUid)
+ ) shouldBe empty
}
}