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 21be7acce7 test(amber): cover the dataset search query builder's entry 
construction (#7855)
21be7acce7 is described below

commit 21be7acce7b529bedefda1d008caa4501b8456b7
Author: Xinyuan Lin <[email protected]>
AuthorDate: Sun Aug 23 04:36:03 2026 +0000

    test(amber): cover the dataset search query builder's entry construction 
(#7855)
    
    ### What changes were proposed in this PR?
    
    `DatasetSearchQueryBuilderSpec` goes from 9 tests to 18. The existing
    spec asserted on rendered SQL only, so `toEntryImpl` — the half that
    builds what the dashboard actually displays — was entirely unexercised.
    
    Measured with one fresh sbt JVM per run, `rm -rf` on the jacoco dir
    between them, and the same suite-name filter for before and after. Two
    metrics, because they differ: Codecov's (fully-covered lines, so a line
    with any missed branch arm counts against you) and JaCoCo's line-hit.
    
    | File | Codecov | JaCoCo line-hit | Branches |
    |---|---|---|---|
    | `DatasetSearchQueryBuilder.scala` | 34/58 = 58.6% → **53/58 = 91.4%**
    | 35/58 → **56/58** | 6/18 → **13/18** |
    
    Incidental, same module and same measurement, all reproduced from a HEAD
    baseline:
    
    | File | Codecov |
    |---|---|
    | `FulltextSearchQueryUtils.scala` | 15/33 = 45.5% → **20/33 = 60.6%** |
    | `UnifiedResourceSchema.scala` | 61/76 = 80.3% → **65/76 = 85.5%** |
    | `SearchQueryBuilder.scala` | 14/20 = 70.0% → **16/20 = 80.0%** |
    | `DashboardResource.scala` | 14/89 = 15.7% → **18/89 = 20.2%** |
    
    What the new tests pin: the size that reaches the dashboard, `isOwner`,
    the `NONE` privilege fallback, the resource-type literal that
    `DashboardResource` `MatchError`s on, the null that becomes the
    user-visible `hasMismatch` flag, every dataset column under the alias
    its schema slot names, the owner join predicate, the AND-composition of
    the date/id/keyword filters, and one keyword per reserved character.
    
    ### Two mechanical notes a reviewer will want
    
    **`toEntryImpl` is `override protected` here**, while the `Project` and
    `Workflow` siblings widen theirs to `override def`. So the
    `ProjectSearchQueryBuilderSpec` pattern does not transfer, and widening
    the modifier would be a production edit. The route in is the trait's
    public `toEntry`, which runs `UnifiedResourceSchema.translateRecord`
    first — so the record must carry the aliased select fields. The spec
    fetches the builder's own query to get them.
    
    **The LakeFS loopback stub binds at the host and port the config already
    names**, rather than repointing `StorageConfig.lakefsEndpoint`. That is
    deliberate and load-bearing: `LakeFSStorageClient`'s
    `apiClient`/`refsApi`/`objectsApi` are private `lazy val`s that capture
    the endpoint once per JVM, and amber has `Tags.limit(Tags.Test, 1)` but
    no `Test / fork`, so the module shares one JVM. `workflow-core` gets
    away with mutating that global only because it forks its
    `@NonParallelTest` suites; in amber the same move is order-dependent and
    would leave the client pointed at a dead port for every later suite. The
    stub itself is ~90 duplicated lines, because `amber/build.sbt` declares
    only `DAO % "test->test"` and `Auth % "test->test"`, so
    `workflow-core`'s stub is not on amber's test classpath.
    
    ### Verification
    
    29 mutations. Every survivor is stated below rather than dropped.
    
    A selection, weighted toward the ones that pin something a reader would
    otherwise assume:
    
    | Mutation | Killed by |
    |---|---|
    | **exchange** the `name` and `description` schema slots | projects
    every dataset column under the alias its schema slot names |
    | the same for `is_public`/`is_downloadable`, `did`/`ownerId`,
    `repository`/… | same test, one assertion per pair |
    | drop the `creation_time` slot | the projection test, plus a non-null
    check on the column |
    | alter the owner join predicate | joins the owner row on the dataset's
    owner |
    | drop any one of the three where-clause connectives | ANDs the date, id
    and keyword filters together |
    | render only one half of the full-text field list | searches the
    dataset's name *and* its description |
    | drop either date bound | the same where-clause test, which pins the
    rendered range in order |
    | break the split for any one of `+ - ( ) < > ~ * @ "` | one assertion
    per reserved character, each in a `withClue` naming it |
    | construct the entry with the wrong resource type | tags the entry as a
    dataset and fills the dataset payload slot |
    
    **Survivors, stated plainly:**
    
    - Replacing `record.into(USER).into(classOf[User])` with a fresh `User`
    survives. It is an equivalent mutant *given* a production defect —
    `ownerEmail` is always null — so killing it would need either a
    production change or an assertion that cements the bug. Neither was
    done.
    - Deleting `.filter(_.nonEmpty)` at line 110 survives:
    `getFullTextSearchFilter` re-filters downstream in
    `FulltextSearchQueryUtils`. Equivalent.
    - **Environmental, and worth knowing:** with port 8000 held by another
    process, the eight mutants behind the stub all become survivors, because
    their killer tests `cancel`. This was measured, not assumed. CI is
    deterministic here — the amber unit job that runs
    `WorkflowExecutionService/jacoco` has no LakeFS, and the
    `treeverse/lakefs` container at `build.yml:1011` belongs to a different
    job. But a local run with `bin/local-dev.sh up` will silently disarm
    those assertions and still go green, so the spec documents that a
    `canceled N` line is a disarmed suite rather than a skipped nicety.
    
    ### Five claims corrected during review
    
    Adversarial review found 11 defects in the first draft; three of its
    suggested fixes were refused as not actually discriminating the mutant
    they targeted, or as adding a permanently-dead test-only branch.
    Corrections worth recording because they were stated confidently and
    wrongly:
    
    - "The frontend renders `resourceName`/`resourceDescription` straight
    from the search response" — **false**. `searchAllResources` consumes
    every record into a `DashboardClickableFileEntry`; the only grep hit is
    unrelated.
    - "pgroonga is never reached, so one fewer unrestored global is needed"
    — **false**. Dumping the render of the exact call the keyword test makes
    shows the pgroonga operator present.
    - "Two of the six branch arms of `dataset.getOwnerUid == uid`" — the
    jacoco XML says three unreached arms, not two.
    - An in-test comment claimed an entry-level assertion would catch a
    schema field wired to the wrong column. It cannot, when the wrong column
    belongs to another slot — `translateRecord` hides it.
    - The first draft presented a name/description swap as a hole it had
    found and fixed. True but incomplete: it closed exactly one pair and
    left three others, which is why the projection test now carries ten
    alias assertions instead of two.
    
    ### Reported, not pinned
    
    `ownerEmail` on the constructed entry is always null. Pinning current
    behaviour would cement it, so the spec asserts nothing about it.
    
    No production file is touched.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7854
    
    ### How was this PR tested?
    
    ```
    sbt "WorkflowExecutionService/testOnly 
org.apache.texera.web.resource.dashboard.DatasetSearchQueryBuilderSpec"
    ```
    
    ```
    [info] Total number of tests run: 18
    [info] Suites: completed 1, aborted 0
    [info] Tests: succeeded 18, failed 0, canceled 0, ignored 0, pending 0
    ```
    
    `canceled 0` means port 8000 was free and the suite ran fully armed.
    `Test/scalafmtCheck` and `Test/scalafix --check` both pass.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../dashboard/DatasetSearchQueryBuilderSpec.scala  | 471 ++++++++++++++++++++-
 1 file changed, 449 insertions(+), 22 deletions(-)

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 6e760e119c..338a2b3651 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
@@ -19,24 +19,37 @@
 
 package org.apache.texera.web.resource.dashboard
 
+import com.sun.net.httpserver.{HttpExchange, HttpServer}
+import org.apache.texera.common.config.StorageConfig
 import org.apache.texera.dao.MockTexeraDB
-import 
org.apache.texera.web.resource.dashboard.DashboardResource.SearchQueryParams
-import org.scalatest.BeforeAndAfterAll
+import org.apache.texera.dao.jooq.generated.enums.{PrivilegeEnum, UserRoleEnum}
+import org.apache.texera.dao.jooq.generated.tables.daos.{DatasetDao, 
DatasetUserAccessDao, UserDao}
+import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, 
DatasetUserAccess, User}
+import org.apache.texera.web.resource.dashboard.DashboardResource.{
+  DashboardClickableFileEntry,
+  SearchQueryParams
+}
+import org.jooq.Record
+import org.scalatest.{BeforeAndAfterAll, OptionValues}
 import org.scalatest.flatspec.AnyFlatSpec
 import org.scalatest.matchers.should.Matchers
 
+import java.io.IOException
+import java.net.{InetSocketAddress, URI}
+import java.nio.charset.StandardCharsets.UTF_8
+import java.util.concurrent.{ExecutorService, Executors}
 import scala.jdk.CollectionConverters._
 
 /**
-  * Covers the access-control shape of the SQL `DatasetSearchQueryBuilder` 
produces.
+  * Covers `DatasetSearchQueryBuilder` from both ends: the shape of the SQL it 
builds, and the
+  * `DashboardClickableFileEntry` it turns a fetched row into.
   *
-  * Every member of the builder is `override protected`, so unlike the 
workflow and project arms
-  * nothing here is directly callable. The one public route in is the trait's 
`final constructQuery`,
-  * and what it returns can be rendered to SQL and inspected without ever 
executing it — so these are
-  * assertions about the query's *shape*, not about rows.
+  * == Part 1: which datasets a caller may see ==
   *
-  * What that buys is the piece of this file with real consequences: which 
datasets a caller is
-  * allowed to see. Three branches decide it and none is otherwise tested —
+  * Every member of the builder is `override protected`, so the query side is 
reachable only through
+  * the trait's `final constructQuery`, and what it returns can be rendered to 
SQL and inspected
+  * without executing it — those are assertions about the query's *shape*. 
Three branches decide
+  * visibility and nothing else tests them:
   *
   *   - the join predicate `.and(if (uid == null) DSL.falseCondition() else 
DATASET_USER_ACCESS.UID.eq(uid))`,
   *     which scopes the grant rows to the caller. Without the `eq(uid)` the 
`UID.isNotNull` check
@@ -45,27 +58,251 @@ import scala.jdk.CollectionConverters._
   *   - `includePublic == false`, which must return only explicitly-granted 
datasets and must not
   *     leak public ones in.
   *
-  * `initializeDBAndReplaceDSLContext` is needed only because 
`SearchQueryBuilder.context` reads
-  * `SqlServer.getInstance()`; no query is run against the database.
+  * == Part 2: what a row becomes ==
+  *
+  * `toEntryImpl` is `override protected` here (the workflow and project 
siblings widen theirs to
+  * `override def`), so it is not directly callable. The route in is the 
trait's public `toEntry`,
+  * which first runs `UnifiedResourceSchema.translateRecord` — so the record 
handed to it must carry
+  * the *aliased* select fields, not raw `DATASET.*` fields. Rather than 
hand-build that record,
+  * these tests fetch the builder's own query and pass a real row, which is 
exactly what
+  * `DashboardResource.searchAllResources` does.
+  *
+  * A record permutation is invisible from this end. `translateRecord` writes 
each aliased value back
+  * to the very column the schema paired it with 
(`UnifiedResourceSchema:174`), so ANY swap of two
+  * same-typed schema assignments — `name`/`description`, 
`is_public`/`is_downloadable`,
+  * `did`/`ownerId` — cancels out exactly and produces a byte-identical entry. 
What the entry-level
+  * column assertions below really pin is that each slot still references 
*some* `DATASET` column
+  * (wire a slot to a literal and `translateRecord` drops the field, so the 
value reads back null).
+  * The projections are pinned where a permutation is actually visible: the 
SQL-shape test
+  * "project every dataset column under the alias its schema slot names".
+  *
+  * `toEntryImpl` also calls `LakeFSStorageClient.retrieveRepositorySize`, 
which is a live HTTP call
+  * with no injectable seam. Two non-obvious things make it testable anyway:
+  *
+  *   - **The stub binds AT the configured endpoint** 
(`StorageConfig.lakefsEndpoint`, i.e.
+  *     `localhost:8000`) instead of repointing that endpoint at an ephemeral 
port. This is
+  *     deliberate. `LakeFSStorageClient.apiClient` is a `lazy val` that 
captures the endpoint once
+  *     per JVM, and `amber/build.sbt` runs every suite in ONE unforked JVM
+  *     (`Tags.limit(Tags.Test, 1)` serializes them but does not isolate 
them). Mutating the global
+  *     `StorageConfig.lakefsEndpoint` here would therefore be order-dependent 
— any earlier suite
+  *     that forced `apiClient` wins — and would leave the client bound to a 
dead port for every
+  *     later suite. Binding at the address the config already names is 
order-independent and
+  *     memoizes nothing wrongly. The price is that the port is 
machine-global: if something else
+  *     holds it (a local `bin/local-dev.sh up` lakeFS, or a sibling worktree 
running these tests),
+  *     the four tests that need a *successful* size `cancel` rather than 
fail. CI has no lakeFS in
+  *     this job, so it is deterministic there.
+  *
+  *     A cancel is louder than it looks, and the sbt log shows it only as 
`canceled 4` while the
+  *     build stays green. It disarms the whole `toEntry` half of this suite: 
not just its coverage
+  *     (which falls back toward the ~59% this file had before), but every 
assertion protecting
+  *     `toEntryImpl`. Measured: with the port held, mutating `size` to `0L` 
in the entry leaves
+  *     `succeeded 14, failed 0, canceled 4` and sbt printing "All tests 
passed" at exit 0. The
+  *     assertions in the SQL-shape tests are unaffected, because they render 
rather than fetch and
+  *     never call lakeFS — that is the half that stays armed everywhere 
(verified: with the port
+  *     held, breaking a projection or a where-clause connective still fails 
the build).
+  *   - **The `null`-return test needs no stub at all.** Connection-refused, 
404 and 501 all surface
+  *     as `io.lakefs.clients.sdk.ApiException` (`ApiClient` wraps 
`IOException` in one, and non-2xx
+  *     throws one directly), so that test behaves identically whether the 
stub bound or not.
+  *
+  * The stub is ~50 lines duplicated in spirit from
+  * `common/workflow-core/.../LakeFSStorageClientSpec`. It cannot be shared: 
`build.sbt` gives
+  * `WorkflowExecutionService` a test->test dependency on `DAO` and `Auth` 
only, so workflow-core's
+  * test tree is not on this module's test classpath.
+  *
+  * The keyword tests RENDER a full-text predicate (they do not fetch one), 
which does read the
+  * JVM-global `FulltextSearchQueryUtils.usePgroonga` and emits whichever arm 
it currently selects:
+  * `pgroonga_condition(...)` when this suite runs alone, the 
`to_tsvector`/`to_tsquery` arm if
+  * `DatasetResourceSpec` or `WorkflowResourceSpec` ran earlier in this JVM 
and left the global
+  * `false` (both set it and neither restores it; amber has no `Test / fork`). 
This suite therefore
+  * neither touches nor restores that global, and every keyword assertion here 
is deliberately
+  * branch-independent: the tokens themselves and the `coalesce(...) || ' ' || 
coalesce(...)`
+  * expression are built at `FulltextSearchQueryUtils:49-51`, *before* the `if 
(usePgroonga)`.
+  * Anything added here must keep that property — an assertion on 
`pgroonga_condition` would pass
+  * solo and fail in a full-module run.
+  *
+  * Two lines are executed but unobservable, on purpose. `val owner = 
record.into(USER)...` and
+  * `owner.getEmail` run on every entry, yet replacing them with a bare `new 
User` changes nothing:
+  * the dataset schema leaves `UnifiedResourceSchema`'s `userEmail` at its 
`DSL.inline("")` default,
+  * so the translated record carries no `USER` column and 
`DashboardDataset.ownerEmail` is ALWAYS
+  * null for dataset search results — which also makes the `leftJoin(USER)` a 
join that is selected
+  * from and never read. That is a production defect, reported separately; 
asserting the null here
+  * would cement it, so these tests pin the join's shape and leave the value 
alone.
   *
-  * Deliberately not covered: `toEntryImpl` (lines 128-164) is ~80% of this 
file's uncovered lines
-  * and sits behind a live LakeFS `retrieveRepositorySize` call with no 
mockable seam. Reaching it
-  * would need a source change (injecting a repository-size provider), not a 
test.
+  * Not covered, and not coverable from a test:
+  *   - `constructFromClause`'s `includePublic: Boolean = false` default. 
`scalac` emits
+  *     `constructFromClause$default$3`, but `constructQuery` always passes 
all three arguments, so
+  *     it has zero call sites and the method is `protected`.
+  *   - the `LazyLogging` `logger` lazy-val bitmap on the `object` line, and 
scala-logging's
+  *     `if (underlying.isErrorEnabled)` guard around `logger.error`. Only the 
enabled arm ever runs.
+  *   - three of the six branch arms of `dataset.getOwnerUid == uid` (JaCoCo 
reports cb=3, mb=3).
+  *     They are the null-safe boxed-`Integer` equality's null checks, and 
they describe a null
+  *     `owner_uid` — a state `INT NOT NULL` plus an FK to `"user"(uid)` 
forbids.
+  *   - `class DatasetSearchQueryBuilder {}` at the bottom of the file: a 
vestigial empty class with
+  *     zero references repo-wide. It should be deleted, not instantiated by a 
test.
+  *
+  * Covered but unconstrainable, so no reviewer should count it as pinned 
behaviour:
+  *   - the `.filter(_.nonEmpty)` after the keyword split. 
`getFullTextSearchFilter` re-applies
+  *     `keywords.filter(_.nonEmpty)` itself (`FulltextSearchQueryUtils:43`), 
so dropping it here is
+  *     an equivalent mutation — no observable differs.
   */
 class DatasetSearchQueryBuilderSpec
     extends AnyFlatSpec
     with Matchers
+    with OptionValues
     with BeforeAndAfterAll
     with MockTexeraDB {
 
   private val uid: Integer = Integer.valueOf(42)
 
+  // Owner and a second signed-in caller. Both are outside java.lang.Integer's 
cache (-128..127) so
+  // that `dataset.getOwnerUid == uid` cannot pass by reference identity.
+  private val ownerUid: Integer = Integer.valueOf(9101)
+  private val otherUid: Integer = Integer.valueOf(9102)
+
+  private val sizedDid: Integer = Integer.valueOf(9001)
+  private val goneDid: Integer = Integer.valueOf(9002)
+
+  private val sizedRepo = "texera-ds-sized"
+  private val goneRepo = "texera-ds-gone"
+
+  // 
---------------------------------------------------------------------------------------------
+  // lakeFS loopback stub, bound at the endpoint the config already names (see 
the class comment).
+  // 
---------------------------------------------------------------------------------------------
+
+  private val lakefsUri = new URI(StorageConfig.lakefsEndpoint)
+
+  // Normalised, because the handler paths below are matched by exact equality 
and
+  // StorageConfig.lakefsEndpoint is a `var` that tests are expected to 
override. A configured
+  // ".../api/v1/" would otherwise yield "/api/v1//repositories/..." here, 
match nothing, and make
+  // the requireStub()-guarded tests FAIL on a 404 rather than cancel -- the 
one failure mode the
+  // class comment promises they will not have.
+  private val apiPrefix = lakefsUri.getPath.stripSuffix("/")
+
+  private val commitId = "c0ffee"
+
+  private val commitListJson =
+    
s"""{"pagination":{"has_more":false,"next_offset":"","results":1,"max_per_page":1000},
+       
|"results":[{"id":"$commitId","parents":[],"committer":"tester","message":"m",
+       |"creation_date":1700000000,"meta_range_id":"mr"}]}""".stripMargin
+
+  private def objectStats(path: String, sizeBytes: Long): String =
+    
s"""{"path":"$path","path_type":"object","physical_address":"s3://bucket/$path",
+       
|"checksum":"chk","mtime":1700000000,"size_bytes":$sizeBytes}""".stripMargin
+
+  /** Two objects, 10 + 32 bytes, so the size the entry carries is a value 
nothing else produces. */
+  private val objectPageJson = {
+    val objects = Seq(objectStats("small.csv", 10L), objectStats("large.csv", 
32L)).mkString(",")
+    
s"""{"pagination":{"has_more":false,"next_offset":"","results":2,"max_per_page":1000},
+       |"results":[$objects]}""".stripMargin
+  }
+
+  private var server: HttpServer = _
+  private var serverPool: ExecutorService = _
+  private var stubBound: Boolean = false
+
+  private def respond(exchange: HttpExchange, status: Int, body: String): Unit 
= {
+    val bytes = body.getBytes(UTF_8)
+    exchange.getResponseHeaders.set("Content-Type", "application/json")
+    exchange.sendResponseHeaders(status, bytes.length.toLong)
+    exchange.getResponseBody.write(bytes)
+  }
+
+  private def handle(exchange: HttpExchange): Unit =
+    try {
+      exchange.getRequestBody.readAllBytes()
+      exchange.getRequestURI.getPath match {
+        case p if p == s"$apiPrefix/repositories/$sizedRepo/refs/main/commits" 
=>
+          respond(exchange, 200, commitListJson)
+        case p if p == 
s"$apiPrefix/repositories/$sizedRepo/refs/$commitId/objects/ls" =>
+          respond(exchange, 200, objectPageJson)
+        case _ =>
+          // Everything else — including `goneRepo` — is a repository lakeFS 
does not have.
+          respond(exchange, 404, """{"message":"repository not found"}""")
+      }
+    } finally exchange.close()
+
+  /**
+    * Tests that need `retrieveRepositorySize` to *succeed* can only run if 
the stub owns the
+    * configured port. Cancelling is the documented environmental skip; it is 
not a failure.
+    */
+  private def requireStub(): Unit =
+    if (!stubBound) {
+      cancel(
+        s"another process holds ${lakefsUri.getHost}:${lakefsUri.getPort}, " +
+          s"the endpoint StorageConfig.lakefsEndpoint names; cannot serve a 
lakeFS size here"
+      )
+    }
+
+  // 
---------------------------------------------------------------------------------------------
+  // Fixture
+  // 
---------------------------------------------------------------------------------------------
+
+  private def user(u: Integer, name: String): User = {
+    val user = new User
+    user.setUid(u)
+    user.setName(name)
+    user.setEmail(s"[email protected]")
+    user.setRole(UserRoleEnum.REGULAR)
+    user
+  }
+
+  private def dataset(
+      did: Integer,
+      name: String,
+      repositoryName: String,
+      isDownloadable: Boolean
+  ): Dataset = {
+    val d = new Dataset
+    d.setDid(did)
+    d.setOwnerUid(ownerUid)
+    d.setName(name)
+    d.setRepositoryName(repositoryName)
+    d.setIsPublic(true)
+    d.setIsDownloadable(java.lang.Boolean.valueOf(isDownloadable))
+    d.setDescription(s"description of $name")
+    d.setCoverImage(s"$name.png")
+    d
+  }
+
+  private def grant(did: Integer, u: Integer, privilege: PrivilegeEnum): 
DatasetUserAccess = {
+    val access = new DatasetUserAccess
+    access.setDid(did)
+    access.setUid(u)
+    access.setPrivilege(privilege)
+    access
+  }
+
   override protected def beforeAll(): Unit = {
     initializeDBAndReplaceDSLContext()
+
+    val cfg = getDSLContext.configuration()
+    new UserDao(cfg)
+      .insert(user(ownerUid, "dataset_search_owner"), user(otherUid, 
"dataset_search_other"))
+    new DatasetDao(cfg).insert(
+      dataset(sizedDid, "sized_dataset", sizedRepo, isDownloadable = false),
+      dataset(goneDid, "gone_dataset", goneRepo, isDownloadable = true)
+    )
+    // Only the owner holds an explicit grant; `otherUid` sees the dataset 
purely because it is
+    // public, which is what drives the PrivilegeEnum.NONE fallback.
+    new DatasetUserAccessDao(cfg).insert(grant(sizedDid, ownerUid, 
PrivilegeEnum.WRITE))
+
+    try {
+      server = HttpServer.create(new InetSocketAddress(lakefsUri.getHost, 
lakefsUri.getPort), 0)
+      server.createContext("/", (exchange: HttpExchange) => handle(exchange))
+      serverPool = Executors.newFixedThreadPool(2)
+      server.setExecutor(serverPool)
+      server.start()
+      stubBound = true
+    } catch {
+      case _: IOException => stubBound = false
+    }
   }
 
   override protected def afterAll(): Unit = {
-    shutdownDB()
+    try {
+      if (server != null) server.stop(0)
+      if (serverPool != null) serverPool.shutdownNow()
+    } finally shutdownDB()
   }
 
   private def params(keywords: String*): SearchQueryParams =
@@ -85,6 +322,21 @@ class DatasetSearchQueryBuilderSpec
       .toLowerCase
       .replace("\"", "")
 
+  /** One row of the builder's own query, narrowed to a single dataset id. */
+  private def rowFor(callerUid: Integer, did: Integer): Record = {
+    val p = SearchQueryParams(datasetIds = List(did).asJava)
+    val rows = DatasetSearchQueryBuilder.constructQuery(callerUid, p, 
includePublic = true).fetch()
+    rows.size() shouldBe 1
+    rows.get(0)
+  }
+
+  private def entryFor(callerUid: Integer, did: Integer): 
DashboardClickableFileEntry =
+    DatasetSearchQueryBuilder.toEntry(callerUid, rowFor(callerUid, did))
+
+  // 
---------------------------------------------------------------------------------------------
+  // constructQuery: access control
+  // 
---------------------------------------------------------------------------------------------
+
   "an anonymous search" should "be restricted to public datasets" in {
     val sql = sqlFor(null, includePublic = true)
 
@@ -128,14 +380,72 @@ class DatasetSearchQueryBuilderSpec
     sql should include(" or ")
   }
 
-  "the keyword filter" should "split on the punctuation the full-text engine 
reserves" in {
-    // A user pasting `a+b` means two terms, not a literal. The split set is 
this file's own and is
-    // not shared with the other builders.
-    val sql = sqlFor(uid, includePublic = true, params("alpha+beta"))
+  "the keyword filter" should "split on every character the full-text engine 
reserves" in {
+    // A user pasting `a+b` means two terms, not a literal, and the split set 
is this file's own —
+    // not shared with the other builders. Every character in it needs its own 
keyword: whatever
+    // survives the split is interpolated straight into a plain-SQL condition 
string
+    // (FulltextSearchQueryUtils:56), so a character dropped from the class 
reaches the rendered
+    // predicate intact. `"` is the one that matters most, being a quote 
inside a raw SQL literal.
+    val reserved = Seq('+', '-', '(', ')', '<', '>', '~', '*', '@', '"')
+
+    reserved.zipWithIndex.foreach {
+      case (reservedChar, i) =>
+        val left = s"lhs$i"
+        val right = s"rhs$i"
+        val keyword = s"$left$reservedChar$right"
+        // Rendered WITHOUT `sqlFor`'s quote-stripping: for `"` that helper 
would erase the very
+        // character under test. Both arms of `usePgroonga` carry the 
surviving tokens verbatim.
+        val sql = getDSLContext
+          .renderInlined(
+            DatasetSearchQueryBuilder.constructQuery(uid, params(keyword), 
includePublic = true)
+          )
+          .toLowerCase
+
+        withClue(s"reserved character '$reservedChar' -> ") {
+          sql should include(left)
+          sql should include(right)
+          sql should not include keyword
+        }
+    }
+  }
+
+  it should "search the dataset's name and its description, not just one of 
them" in {
+    // The keyword tokens land in the predicate's search argument whichever 
columns are searched, so
+    // only this concatenated expression can see the field list shrink to one 
column — dataset search
+    // silently stopping to match descriptions, or names. The expression is 
built at
+    // FulltextSearchQueryUtils:49-51, before the `if (usePgroonga)`, so both 
arms render it.
+    val sql = sqlFor(uid, includePublic = true, params("alpha"))
 
-    sql should include("alpha")
-    sql should include("beta")
-    sql should not include "alpha+beta"
+    sql should include("coalesce(texera_db.dataset.name, '')")
+    sql should include("coalesce(texera_db.dataset.description, '')")
+  }
+
+  "the where clause" should "AND its date, id and keyword filters together" in 
{
+    // The only test that renders more than one non-empty filter at once, and 
therefore the only one
+    // that can see the connectives: jOOQ drops `noCondition()` out of an AND 
chain AND out of an OR
+    // chain, so with a single filter present `.and` and `.or` render 
identically. Swapping either
+    // link turns a dashboard search into a UNION of the filters and hands 
back rows the caller's
+    // filter excluded.
+    val p = SearchQueryParams(
+      keywords = List("alpha").asJava,
+      datasetIds = List(sizedDid).asJava,
+      creationStartDate = "2020-01-01",
+      creationEndDate = "2020-12-31"
+    )
+    val sql = sqlFor(uid, includePublic = true, p)
+
+    // The create-date range, on the create-date column, with the bounds in 
that order. Nothing else
+    // in the suite passes a date, so without this the whole `getDateFilter` 
call is inert: it is
+    // satisfied by the modified-date params, or by the two arguments swapped.
+    sql should include(
+      "texera_db.dataset.creation_time between timestamp '2020-01-01 
00:00:00.0' " +
+        "and timestamp '2020-12-31 23:59:59.999'"
+    )
+    // ... AND the dataset-id filter ...
+    sql should include(s"and texera_db.dataset.did = $sizedDid")
+    // ... AND the full-text filter. Matching only the opening paren keeps 
this independent of which
+    // `usePgroonga` arm rendered the predicate itself.
+    sql should include(s"texera_db.dataset.did = $sizedDid and (")
   }
 
   "the query" should "dedupe with selectDistinct rather than a group by" in {
@@ -152,4 +462,121 @@ class DatasetSearchQueryBuilderSpec
     // MatchError at fetch time rather than a compile error.
     sqlFor(uid, includePublic = true) should include("'dataset'")
   }
+
+  it should "project every dataset column under the alias its schema slot 
names" in {
+    // `toEntry` cannot see a mistake here at all. 
`UnifiedResourceSchema.translateRecord` writes
+    // every alias back to the column the schema paired it with, so swapping 
ANY two same-typed
+    // assignments — name/description, is_public/is_downloadable, did/ownerId 
— cancels out by the
+    // time `toEntryImpl` reads the record and yields a byte-identical entry. 
The rendered SELECT is
+    // the only place a permutation is visible.
+    //
+    // A permutation is not harmless downstream. 
`DashboardResource.getOrderFields` sorts on
+    // `resourceName` (its "Name" case, DashboardResource:168) and on 
`resourceCreationTime` (its
+    // "CreateTime" case), both by alias, so a swap silently re-points 
dashboard sorting. It is NOT
+    // the frontend that reads these aliases: `searchAllResources` consumes 
each record into a
+    // `DashboardClickableFileEntry` and returns those, so no alias survives 
into the HTTP response.
+    // `resourceDescription` in fact has no production reader at all today —
+    // `UnifiedResourceSchema.resourceDescriptionField` is referenced nowhere 
and `getColumnField`
+    // has no "Description" case — which is worth an issue of its own; it is 
pinned here anyway
+    // because the schema slot is what a copy-paste between builders gets 
wrong.
+    val sql = sqlFor(uid, includePublic = true)
+
+    sql should include("dataset.name as resourcename")
+    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")
+  }
+
+  it should "join the owner row on the dataset's owner" in {
+    // `toEntryImpl` reads `owner.getEmail` out of this join, so the predicate 
is load-bearing on
+    // paper; in practice the value is always null (see the class comment) and 
asserting it would
+    // cement that bug. The join's shape is safe to pin and is otherwise 
unconstrained: nothing else
+    // in the suite can tell `USER.UID.eq(DATASET.OWNER_UID)` from any other 
predicate, or from the
+    // join being absent altogether.
+    val sql = sqlFor(uid, includePublic = true)
+
+    sql should include("left outer join texera_db.user on texera_db.user.uid = 
")
+    sql should include("texera_db.user.uid = texera_db.dataset.owner_uid")
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // toEntry
+  // 
---------------------------------------------------------------------------------------------
+
+  "toEntry" should "carry every dataset column and the lakeFS repository size 
into the entry" in {
+    requireStub()
+
+    val entry = entryFor(ownerUid, sizedDid)
+    entry should not be null
+
+    val dd = entry.dataset.value
+    dd.dataset.getDid shouldBe sizedDid
+    dd.dataset.getOwnerUid shouldBe ownerUid
+    dd.dataset.getName shouldBe "sized_dataset"
+    dd.dataset.getDescription shouldBe "description of sized_dataset"
+    dd.dataset.getRepositoryName shouldBe sizedRepo
+    dd.dataset.getIsPublic shouldBe true
+    // The fixture flips this away from the column default, so a slot wired to 
a literal (rather
+    // than to a DATASET column) reads back as null here rather than 
accidentally matching. What it
+    // cannot see is a swap with `is_public`: see the projection test for why, 
and for where that is
+    // pinned instead.
+    dd.dataset.getIsDownloadable shouldBe false
+    dd.dataset.getCoverImage shouldBe "sized_dataset.png"
+    // DDL-defaulted, so this is null only if the schema stops projecting the 
column at all.
+    dd.dataset.getCreationTime should not be null
+    // 10 + 32 across the newest commit's two objects. This is the number the 
dashboard shows.
+    dd.size shouldBe 42L
+  }
+
+  it should "set isOwner only for the dataset's own owner" in {
+    requireStub()
+
+    entryFor(ownerUid, sizedDid).dataset.value.isOwner shouldBe true
+    entryFor(otherUid, sizedDid).dataset.value.isOwner shouldBe false
+    // An anonymous caller reaches the dataset because it is public, and owns 
nothing.
+    entryFor(null, sizedDid).dataset.value.isOwner shouldBe false
+  }
+
+  it should "report the granted privilege, and fall back to NONE with no grant 
row" in {
+    requireStub()
+
+    entryFor(ownerUid, sizedDid).dataset.value.accessPrivilege shouldBe 
PrivilegeEnum.WRITE
+    // `otherUid` has no dataset_user_access row: the left join yields null, 
and null must read as
+    // NONE rather than propagate to the client as a missing privilege.
+    entryFor(otherUid, sizedDid).dataset.value.accessPrivilege shouldBe 
PrivilegeEnum.NONE
+  }
+
+  it should "tag the entry as a dataset and fill the dataset payload slot" in {
+    // `entry.workflow shouldBe None` / `entry.project shouldBe None` used to 
sit here and were
+    // vacuous: `DashboardClickableFileEntry` declares both `= None` 
(DashboardResource:40-41) and
+    // this file passes neither, so no mutation of `toEntryImpl` can falsify 
them — they assert
+    // another file's case-class defaults. Only `resourceType` and `dataset` 
are this file's to set.
+    //
+    // `resourceType` here has the production constant on the right-hand side, 
so retargeting the
+    // constant's *value* moves both sides together; the inlined-literal 
assertion in
+    // "tag its rows as datasets" is what actually pins the value. The 
`getDid` check below is an
+    // identity check on the payload — that the entry carries the row it was 
built from — and is
+    // redundant with the column test rather than newly discriminating.
+    requireStub()
+
+    val entry = entryFor(ownerUid, sizedDid)
+    entry.resourceType shouldBe SearchQueryBuilder.DATASET_RESOURCE_TYPE
+    entry.dataset should be(defined)
+    entry.dataset.value.dataset.getDid shouldBe sizedDid
+  }
+
+  it should "drop the row entirely when lakeFS rejects the repository" in {
+    // No `requireStub()`: a 404 from the stub, a 404 from a real lakeFS and a 
refused connection all
+    // arrive as io.lakefs.clients.sdk.ApiException, so this holds however the 
port is occupied.
+    //
+    // The null is load-bearing. DashboardResource filters these rows out and 
raises `hasMismatch`
+    // from them; without it a dataset whose repository is gone is shown to 
the user as 0 bytes.
+    DatasetSearchQueryBuilder.toEntry(ownerUid, rowFor(ownerUid, goneDid)) 
shouldBe null
+  }
 }

Reply via email to