Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ object UnifiedResourceSchema {
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])
workflowCoverImage: Field[String] = DSL.cast(null, classOf[String]),
// Workflow-only: whether the workflow also offers a Form View, so the listing can
// mark the row and route it accordingly.
workflowIsFormView: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean])
): UnifiedResourceSchema = {
new UnifiedResourceSchema(
Seq(
Expand Down Expand Up @@ -110,7 +113,8 @@ object UnifiedResourceSchema {
.as("user_versioned_resource_access"),
versionedResourceCoverImage -> versionedResourceCoverImage
.as("versioned_resource_cover_image"),
workflowCoverImage -> workflowCoverImage.as("workflow_cover_image")
workflowCoverImage -> workflowCoverImage.as("workflow_cover_image"),
workflowIsFormView -> workflowIsFormView.as("workflow_is_form_view")
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
ownerId = WORKFLOW_OF_USER.UID,
userName = USER.NAME,
projectsOfWorkflow = groupConcatDistinct(WORKFLOW_OF_PROJECT.PID),
workflowCoverImage = DSL.max(WORKFLOW_COVER_IMAGE.IMAGE).as("workflow_cover_image")
workflowCoverImage = DSL.max(WORKFLOW_COVER_IMAGE.IMAGE).as("workflow_cover_image"),
workflowIsFormView = WORKFLOW.IS_FORM_VIEW.as("workflow_is_form_view")
)
}

Expand Down Expand Up @@ -156,8 +157,13 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
Option(record.get(WORKFLOW_USER_ACCESS.PRIVILEGE, classOf[PrivilegeEnum]))
.map(_.toString)
.getOrElse(PrivilegeEnum.NONE.toString),
record.into(USER).getName,
record.into(WORKFLOW).into(classOf[Workflow]),
record.into(USER).getName, {
// The select lists specific columns, so the POJO built from the record does not carry
// this one. Without it the listing forgets the Form View marker on every refresh.
val w = record.into(WORKFLOW).into(classOf[Workflow])
w.setIsFormView(record.get("workflow_is_form_view", classOf[java.lang.Boolean]) == true)
w
},
if (record.get(pidField) == null) {
List[Integer]()
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ object WorkflowResource {
creationTime: Timestamp,
lastModifiedTime: Timestamp,
isPublished: Boolean,
readonly: Boolean
readonly: Boolean,
// Whether this workflow also offers a Form View; both views load through this endpoint.
isFormView: Boolean
)

case class WorkflowIDs(wids: List[Integer], pid: Option[Integer])
Expand Down Expand Up @@ -417,7 +419,8 @@ class WorkflowResource extends LazyLogging {
workflow.getCreationTime,
workflow.getLastModifiedTime,
workflow.getIsPublic,
!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)
!WorkflowAccessResource.hasWriteAccess(wid, user.getUid),
workflow.getIsFormView == true
)
} else {
throw new ForbiddenException("No sufficient access privilege.")
Expand All @@ -439,6 +442,14 @@ class WorkflowResource extends LazyLogging {
@Path("/persist")
def persistWorkflow(workflow: Workflow, @Auth sessionUser: SessionUser): Workflow = {
val user = sessionUser.getUser

// `is_form_view` is owned by /enable-form-view and /disable-form-view alone; a plain save
// sends the whole POJO to workflowDao.update, so without this its default clears the flag.
if (workflow.getWid != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking: this read-modify-write still has a small window — an enable-form-view landing between the fetch and workflowDao.update gets overwritten by the save. Since setFormView already updates by explicit column, persist could do the same (update only name/description/content/is_public), which would remove both the extra fetch and the window.

Option(workflowDao.fetchOneByWid(workflow.getWid))
.foreach(stored => workflow.setIsFormView(stored.getIsFormView))
Comment on lines +448 to +450
}

if (workflowOfUserExists(workflow.getWid, user.getUid)) {
WorkflowVersionResource.insertVersion(workflow, insertingNewWorkflow = false)
workflowDao.update(workflow)
Expand Down Expand Up @@ -507,7 +518,9 @@ class WorkflowResource extends LazyLogging {
assignNewOperatorIds(oldWorkflow.getContent),
null,
null,
false
false,
// the Form View is part of the workflow, so a copy keeps it
oldWorkflow.getIsFormView
),
sessionUser
)
Expand Down Expand Up @@ -557,7 +570,9 @@ class WorkflowResource extends LazyLogging {
assignNewOperatorIds(oldWorkflow.getContent),
null,
null,
false
false,
// a biologist's path is hub -> clone -> use, so the clone must stay usable
oldWorkflow.getIsFormView
),
sessionUser
)
Expand Down Expand Up @@ -726,6 +741,39 @@ class WorkflowResource extends LazyLogging {
workflowDao.update(workflow)
}

/**
* Turn the Form View on for a workflow. Only this on/off flag lives in a column; the
* form's definition travels in workflow.content under `formBinding`, so turning it
* off does not erase it -- toggling back on restores the author's setup.
*/
@PUT
@RolesAllowed(Array("REGULAR", "ADMIN"))
@Path("/enable-form-view/{wid}")
def enableFormView(@PathParam("wid") wid: Integer, @Auth user: SessionUser): Unit = {
setFormView(wid, user, enabled = true)
}

@PUT
@RolesAllowed(Array("REGULAR", "ADMIN"))
@Path("/disable-form-view/{wid}")
def disableFormView(@PathParam("wid") wid: Integer, @Auth user: SessionUser): Unit = {
setFormView(wid, user, enabled = false)
}

private def setFormView(wid: Integer, user: SessionUser, enabled: Boolean): Unit = {
if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) {
throw new ForbiddenException(s"You do not have permission to modify workflow $wid")
}
// Update only this column. The flag is deliberately independent of content, so a toggle
// must not rewrite the whole row -- doing so would touch content (and could clobber a
// concurrent save) and bump the last-modified time for a mere flag flip.
context
.update(WORKFLOW)
.set(WORKFLOW.IS_FORM_VIEW, java.lang.Boolean.valueOf(enabled))
.where(WORKFLOW.WID.eq(wid))
.execute()
Comment on lines +770 to +774
}

/** Returns the workflow's cover image; 404 if none set. */
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
Expand Down Expand Up @@ -848,7 +896,8 @@ class WorkflowResource extends LazyLogging {
workflow.getCreationTime,
workflow.getLastModifiedTime,
workflow.getIsPublic,
readonly = true
readonly = true,
isFormView = workflow.getIsFormView == true
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,9 @@ class WorkflowVersionResource {
assignNewOperatorIds(workflowVersion.getContent),
null,
null,
false
false,
// the version's content carries the Form View definition, so keep it usable
workflowVersion.getIsFormView
Comment on lines +439 to +440
),
sessionUser
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {

// Sentinels for the three slots that have no convenient distinct table
// column of the right type; every other slot uses a real generated column so
// that all 24 originals render differently from one another.
// that all 25 originals render differently from one another.
private val sentinelResourceType: Field[String] = JDSL.inline("s-resource-type")
private val sentinelProjects: Field[String] = JDSL.inline("s-projects")
private val sentinelStoragePath: Field[String] = JDSL.inline("s-storage-path")
Expand Down Expand Up @@ -108,7 +108,8 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
isVersionedResourceDownloadable = DATASET.IS_DOWNLOADABLE,
versionedResourceUserAccess = DATASET_USER_ACCESS.PRIVILEGE,
versionedResourceCoverImage = DATASET.COVER_IMAGE,
workflowCoverImage = WORKFLOW_COVER_IMAGE.IMAGE
workflowCoverImage = WORKFLOW_COVER_IMAGE.IMAGE,
workflowIsFormView = WORKFLOW.IS_FORM_VIEW
)

// Expected projection, in order: alias -> the original it must be built from.
Expand Down Expand Up @@ -136,13 +137,14 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
"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
"workflow_cover_image" -> WORKFLOW_COVER_IMAGE.IMAGE,
"workflow_is_form_view" -> WORKFLOW.IS_FORM_VIEW
)

// -- apply(): the projection ------------------------------------------------

"apply" should "expose all 24 slots as aliases, in the order the UNION ALL depends on" in {
sentinelSchema.allFields should have size 24
"apply" should "expose all 25 slots as aliases, in the order the UNION ALL depends on" in {
sentinelSchema.allFields should have size 25
sentinelSchema.allFields.map(_.getName) shouldBe expectedProjection.map(_._1)
}

Expand All @@ -162,7 +164,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
// about datasets still union with one that does: the column count and
// types have to line up.
val defaults = UnifiedResourceSchema()
defaults.allFields should have size 24
defaults.allFields should have size 25
val rendered = ctx.renderInlined(JDSL.select(defaults.allFields: _*))
rendered should include("'' as \"resourceType\"")
rendered should include("cast(null as timestamp) as \"resourceCreationTime\"")
Expand Down Expand Up @@ -197,12 +199,12 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
}

it should "collapse the all-defaults projection down to one alias per distinct default" in {
// 24 slots, but only six structurally distinct default expressions, so the
// 25 slots, but only six structurally distinct default expressions, so the
// de-dup collapses the map to six entries. Worth pinning because it is
// surprising, and because it is what makes the keep-first rule observable at
// all: allFields stays at 24 while the translation map does not.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this line still says 24.

val defaults = UnifiedResourceSchema()
defaults.allFields should have size 24
defaults.allFields should have size 25
translatedAliases(defaults) shouldBe Seq(
"resourceType", // DSL.inline("")
"resourceCreationTime", // cast(null as timestamp)
Expand All @@ -213,15 +215,15 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
)
}

it should "keep every distinct original when the caller supplies 24 distinct Fields" in {
it should "keep every distinct original when the caller supplies 25 distinct Fields" in {
// Nothing to collapse here, which is the control case for the two tests
// above: the shrinkage they observe comes from duplicate originals only.
translatedAliases(sentinelSchema) shouldBe expectedProjection.map(_._1)
}

it should "drop exactly the duplicated slots of the production workflow projection" in {
val workflowSchema = WorkflowSearchQueryBuilder.mappedResourceSchema
workflowSchema.allFields should have size 24
workflowSchema.allFields should have size 25
val aliases = translatedAliases(workflowSchema)
// `uid` duplicates ownerId (WORKFLOW_OF_USER.UID); the rest are slots the
// builder left at their default, and the defaults collide by type.
Expand All @@ -241,7 +243,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {

"jOOQ Field equality" should "be structural, which is what makes the de-dup collapse anything" in {
// If jOOQ ever switched to identity equality, translatedFieldSet would keep
// all 24 slots and translateRecord would start reading duplicated columns —
// all 25 slots and translateRecord would start reading duplicated columns —
// the tests above would flip, and this one says why.
JDSL.cast(null, classOf[Integer]) shouldBe JDSL.cast(null, classOf[Integer])
JDSL.inline("") shouldBe JDSL.inline("")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
// QueryParts structurally. The first test pins that assumption.
private val pidField = JDSL.groupConcatDistinct(WORKFLOW_OF_PROJECT.PID)
private val coverField = JDSL.max(WORKFLOW_COVER_IMAGE.IMAGE).as("workflow_cover_image")
// The select lists is_form_view under its own alias (not carried by the WORKFLOW POJO),
// and toEntryImpl reads it back by that alias — the record has to carry the column.
private val isFormViewField = WORKFLOW.IS_FORM_VIEW.as("workflow_is_form_view")

private val ownerUid: Integer = Integer.valueOf(42)
private val viewerUid: Integer = Integer.valueOf(43)
Expand All @@ -85,7 +88,8 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
uidValue: Integer = ownerUid,
privilege: PrivilegeEnum = PrivilegeEnum.WRITE,
projects: String = "3,1,2",
cover: String = "cover-b64"
cover: String = "cover-b64",
formView: Boolean = false
): Record = {
val record = ctx.newRecord(
WORKFLOW.WID,
Expand All @@ -95,7 +99,8 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
WORKFLOW_USER_ACCESS.PRIVILEGE,
USER.NAME,
pidField,
coverField
coverField,
isFormViewField
)
record.set(WORKFLOW.WID, wid)
record.set(WORKFLOW.NAME, "wf-name")
Expand All @@ -105,6 +110,7 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
record.set(USER.NAME, "owner-name")
record.set(pidField, projects)
record.set(coverField, cover)
record.set(isFormViewField, java.lang.Boolean.valueOf(formView))
record
}

Expand Down Expand Up @@ -201,6 +207,13 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
workflowOf(translatedRecord(cover = null), ownerUid).coverImage shouldBe None
}

it should "carry the Form View flag off its own aliased column" in {
// The listing's select projects is_form_view separately (the WORKFLOW POJO the
// record maps into does not carry it), so toEntryImpl must read it back by alias.
workflowOf(translatedRecord(formView = true), ownerUid).workflow.getIsFormView shouldBe true
workflowOf(translatedRecord(formView = false), ownerUid).workflow.getIsFormView shouldBe false
}

it should "tag the entry as a workflow and leave the other payload slots empty" in {
val entry = WorkflowSearchQueryBuilder.toEntryImpl(ownerUid, translatedRecord())
entry.resourceType shouldBe "workflow"
Expand Down
Loading
Loading