Copilot commented on code in PR #7853:
URL: https://github.com/apache/texera/pull/7853#discussion_r3936808013


##########
amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowPublishService.scala:
##########
@@ -0,0 +1,193 @@
+/*
+ * 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.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")
+    }
+    logger.info(s"Workflow $wid published, following latest")
+    statusOf(wid)

Review Comment:
   The log message claims the workflow is now 'following latest', but `publish` 
intentionally does not drop an existing pin (and can be called on an 
already-public, pinned workflow). Consider logging the actual resulting state 
(e.g., include whether it is pinned via `statusOf`) or use a neutral message 
like 'published' to avoid misleading operational diagnostics.



##########
amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowPublishSpec.scala:
##########
@@ -0,0 +1,564 @@
+/*
+ * 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 org.apache.texera.auth.SessionUser
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.Tables.{WORKFLOW, 
WORKFLOW_USER_ACCESS}
+import org.apache.texera.dao.jooq.generated.enums.{DefaultViewEnum, 
PrivilegeEnum, UserRoleEnum}
+import org.apache.texera.dao.jooq.generated.tables.daos.{
+  UserDao,
+  WorkflowDao,
+  WorkflowUserAccessDao
+}
+import org.apache.texera.dao.jooq.generated.tables.pojos.{User, Workflow, 
WorkflowUserAccess}
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import org.jooq.{ExecuteContext, ExecuteListener}
+import org.jooq.impl.{DefaultConfiguration, DefaultExecuteListenerProvider}
+
+import java.time.OffsetDateTime
+import javax.ws.rs.{BadRequestException, ForbiddenException, NotFoundException}
+
+/**
+  * Covers the publish state a workflow can be in: following the author's 
latest content, as
+  * publishing has always done, or holding a pinned copy of the version the 
author froze.
+  *
+  * Only the state itself is covered here: nothing serves the pinned copy to a 
reader yet, so the
+  * assertions are about which copy each operation leaves stored.
+  */
+class WorkflowPublishSpec
+    extends AnyFlatSpec
+    with BeforeAndAfterAll
+    with Matchers
+    with MockTexeraDB {
+
+  private val exampleCreationTime = 
OffsetDateTime.parse("2025-01-01T00:00:00Z")
+
+  private def makeUser(uid: Int, name: String): User = {
+    val user = new User
+    user.setUid(Integer.valueOf(uid))
+    user.setName(name)
+    user.setEmail(s"[email protected]")
+    user.setRole(UserRoleEnum.ADMIN)
+    user.setComment("test")
+    user.setAccountCreationTime(exampleCreationTime)
+    user
+  }
+
+  /** The author. */
+  private val owner = makeUser(1, "publish_owner")
+
+  /** A stranger: no access of their own, so nothing about this workflow is 
theirs to change. */
+  private val stranger = makeUser(2, "publish_stranger")
+
+  private val ownerSession = new SessionUser(owner)
+  private val strangerSession = new SessionUser(stranger)
+
+  private val workflowResource = new WorkflowResource()
+
+  private val publishedContent = 
"""{"operators":[],"note":"content_as_published"}"""
+  private val editedContent = 
"""{"operators":[],"note":"content_only_a_draft"}"""
+
+  private def workflowDao = new WorkflowDao(getDSLContext.configuration())
+
+  override protected def beforeAll(): Unit = {
+    initializeDBAndReplaceDSLContext()
+    val userDao = new UserDao(getDSLContext.configuration())
+    userDao.insert(owner)
+    userDao.insert(stranger)
+  }
+
+  override protected def afterAll(): Unit = shutdownDB()
+
+  /** Creates a workflow owned by `owner` holding [[publishedContent]]. */
+  private def createWorkflow(name: String): Integer = {
+    val workflow = new Workflow()
+    workflow.setName(name)
+    workflow.setDescription("a workflow")
+    workflow.setContent(publishedContent)
+    workflowResource.createWorkflow(workflow, ownerSession).workflow.getWid
+  }
+
+  /**
+    * Publishes and pins in one step, which is the state most of these tests 
are about. Publishing on
+    * its own leaves the workflow following the author's latest; pinning is 
what freezes a copy.
+    */
+  private def publishPinned(wid: Integer): 
WorkflowPublishService.PublishStatus = {
+    workflowResource.makePublic(wid, ownerSession)
+    workflowResource.pinLatest(wid, ownerSession)
+  }
+
+  /** Saves `content` as the author's working copy, the way an autosave would. 
*/
+  private def edit(wid: Integer, content: String): Unit = {
+    val workflow = workflowDao.fetchOneByWid(wid)
+    workflow.setContent(content)
+    workflowResource.persistWorkflow(workflow, ownerSession)
+  }
+
+  /** Renames and re-describes the author's working copy, the way the 
dashboard does. */
+  private def relabel(wid: Integer, name: String, description: String): Unit = 
{
+    val workflow = workflowDao.fetchOneByWid(wid)
+    workflow.setName(name)
+    workflow.setDescription(description)
+    workflowResource.persistWorkflow(workflow, ownerSession)
+  }
+
+  /**
+    * Runs `interleaved` in the last moment before `act` sends its own write, 
which is where a second
+    * request slips in unnoticed. Driven off the statement itself rather than 
off a thread, so the
+    * ordering is the same on every run.
+    */
+  private def interleaving(interleaved: () => Unit)(act: => Unit): Unit = {
+    var pending = true
+    val configuration = 
getDSLContext.configuration().asInstanceOf[DefaultConfiguration]
+    val previousListeners = configuration.executeListenerProviders()
+    configuration.set(new DefaultExecuteListenerProvider(new ExecuteListener {
+      override def executeStart(ctx: ExecuteContext): Unit = {
+        // The workflow table itself, not workflow_version or the access 
tables: matching those too
+        // would let a later change to one of these paths interleave at the 
wrong moment and leave
+        // the test passing for the wrong reason.
+        val sql = Option(ctx.sql()).getOrElse("").toLowerCase
+        if (pending && sql.startsWith("update") && sql.contains("\"workflow\" 
set")) {
+          pending = false
+          interleaved()
+        }

Review Comment:
   This interleaving detector relies on the exact SQL string shape (quoting and 
formatting) produced by jOOQ, which can change across dialects/jOOQ versions 
and make these tests brittle. To make the trigger more robust, consider 
matching more flexibly (e.g., tolerate different quoting/backticks and 
whitespace via a regex) or use `ctx.query()`/query metadata where available 
rather than the rendered SQL string.



-- 
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]

Reply via email to