yangzhang75 commented on code in PR #7853: URL: https://github.com/apache/texera/pull/7853#discussion_r4127090895
########## amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowPublishService.scala: ########## @@ -0,0 +1,204 @@ +/* + * 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.user.workflow + +import com.typesafe.scalalogging.LazyLogging +import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.apache.texera.dao.SqlServer +import org.apache.texera.dao.jooq.generated.Tables.WORKFLOW +import org.apache.texera.dao.jooq.generated.enums.DefaultViewEnum +import org.apache.texera.dao.jooq.generated.tables.daos.WorkflowDao +import org.apache.texera.dao.jooq.generated.tables.pojos.Workflow +import org.jooq.DSLContext + +import javax.ws.rs.{BadRequestException, NotFoundException} +import scala.util.Try + +/** + * Version pinning for public workflows. + * + * A public workflow follows the author's latest, as publishing has always done, until the author + * pins the version they have now: the public then keeps seeing that frozen copy while the author's + * later edits stay in the workflow's own columns until they pin again. + * + * `is_public` stays the on/off switch; `published_content` is the pin, NULL while following. A pin + * freezes everything on public show -- the graph, the title, the description and the view it opens + * in -- because a copy that froze only its graph would still advertise a title nobody published. + * + * Not to be confused with sharing: a user granted access always tracks the author's latest, pin or + * no pin. Only viewers who arrive because the workflow is public are held at the frozen copy. + */ +object WorkflowPublishService extends LazyLogging { + + private def context: DSLContext = SqlServer.getInstance().createDSLContext() + + /** + * What the share dialog asks about: whether the workflow is public, whether a version is pinned, + * and whether that pin is holding edits back -- the last is true when pinning again would publish + * something, and always false while following. + */ + case class PublishStatus( + isPublished: Boolean, + isPinned: Boolean, + hasUnpublishedChanges: Boolean + ) + + /** + * Whether two workflow contents describe the same graph. Compared as parsed trees, because the + * two blobs travel by different routes and the same graph can come back with its whitespace or + * key order rearranged -- reporting that as an edit the public cannot see would be an alarm the + * author cannot clear. + */ + private def sameContent(a: String, b: String): Boolean = + a == b || Try(objectMapper.readTree(a) == objectMapper.readTree(b)).getOrElse(false) + + /** The workflow, or a 404. */ + private def requireWorkflow(wid: Integer): Workflow = + Option(new WorkflowDao(context.configuration).fetchOneByWid(wid)) + .getOrElse(throw new NotFoundException(s"Workflow $wid not found")) + + /** + * Turns publishing on, and touches nothing else. A workflow coming back from private is + * following the author's latest, because unpublishing always drops the pin: coming back should + * not silently put old public content back on show. Called on a workflow that is already public + * it changes nothing, pin included. + */ + def publish(wid: Integer): PublishStatus = { + val updated = context + .update(WORKFLOW) + .set(WORKFLOW.IS_PUBLIC, java.lang.Boolean.TRUE) + .where(WORKFLOW.WID.eq(wid)) + .execute() + if (updated == 0) { + throw new NotFoundException(s"Workflow $wid not found") + } + // Deliberately not "following latest": this turns publishing on and touches nothing else, so a + // workflow that somehow arrives here already pinned stays pinned. + logger.info(s"Workflow $wid published") + statusOf(wid) + } + + /** + * Freezes the author's current copy as the public one. The title, the description and the + * default view freeze with the graph: they are as public as it is, and the database refuses a + * pinned copy that carries only part of itself. The view matters because a form's definition + * rides inside the content -- serving the live preference over a frozen graph would open a form + * on a copy that has none. + * + * Each column is copied from its own row rather than from a workflow read a moment earlier, so + * there is no window in which the author's next save lands and the pin freezes the version + * before it -- which would leave them looking at "you have unpublished changes" the instant + * after they pinned. + * + * Publishing is a condition, not something this writes: asking to be published and pinning are + * two statements, so a `/private` landing between them would otherwise be undone here, putting a + * workflow the author had just taken down back on show under a frozen copy. + * + * @return how many rows it matched -- zero for a workflow that is missing or not published. + */ + private def writePin(wid: Integer): Int = + context + .update(WORKFLOW) + .set(WORKFLOW.PUBLISHED_CONTENT, WORKFLOW.CONTENT) + .set(WORKFLOW.PUBLISHED_NAME, WORKFLOW.NAME) + .set(WORKFLOW.PUBLISHED_DESCRIPTION, WORKFLOW.DESCRIPTION) + .set(WORKFLOW.PUBLISHED_DEFAULT_VIEW, WORKFLOW.DEFAULT_VIEW) + .where(WORKFLOW.WID.eq(wid).and(WORKFLOW.IS_PUBLIC.isTrue)) + .execute() + + /** + * Clears the pinned copy in one statement, optionally unpublishing too: the constraint accepts a + * row only with every frozen column set on a public workflow, or with every one of them NULL, so + * clearing them one at a time -- or clearing them after `is_public` -- would be rejected. + * + * `published_version_id` is named by that constraint as well but is not touched here, for the + * same reason [[writePin]] does not set it: nothing writes it yet, so it is NULL on every row. + * + * @return how many rows it matched, so a missing workflow is distinguishable from a done one. + */ + private def clearPin(wid: Integer, alsoUnpublish: Boolean = false): Int = { + val cleared = context + .update(WORKFLOW) + .set(WORKFLOW.PUBLISHED_CONTENT, null.asInstanceOf[String]) + .set(WORKFLOW.PUBLISHED_NAME, null.asInstanceOf[String]) + .set(WORKFLOW.PUBLISHED_DESCRIPTION, null.asInstanceOf[String]) + .set(WORKFLOW.PUBLISHED_DEFAULT_VIEW, null.asInstanceOf[DefaultViewEnum]) + val statement = + if (alsoUnpublish) cleared.set(WORKFLOW.IS_PUBLIC, java.lang.Boolean.FALSE) else cleared + statement.where(WORKFLOW.WID.eq(wid)).execute() Review Comment: Also fixed, and it is the mirror of the previous one — thanks. `clearPin` now carries the condition in its own statement for the unpin path (`WHERE wid = ? AND is_public`), and `unpin` tells a missing workflow from a private one the same way `pinLatest` does. Unpublishing deliberately keeps no such condition: taking down what is already down is a no-op, not an error. Covered by an interleaving test that mirrors the pin one — a `/private` committed between the guard and the update now answers 400 and leaves the workflow down, instead of reporting "unpinned, following latest" about something no longer on show. -- 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]
