You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/08/31 15:14:25 UTC
(texera) branch gh-readonly-queue/main/pr-7875-50321e403c82df299a13deb50a7f9849dd93bdba created (now 0298a3bbe4)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-7875-50321e403c82df299a13deb50a7f9849dd93bdba
in repository https://gitbox.apache.org/repos/asf/texera.git
at 0298a3bbe4 feat(amber): report the owner's email in dataset search (#7875)
This branch includes the following new commits:
new 0298a3bbe4 feat(amber): report the owner's email in dataset search (#7875)
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(texera) 01/01: feat(amber): report the owner's email in dataset search (#7875)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7875-50321e403c82df299a13deb50a7f9849dd93bdba
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 0298a3bbe49b644d062a5e3b3dd56dd0e399cc84
Author: Xinyuan Lin <xi...@uci.edu>
AuthorDate: Mon Aug 31 15:14:02 2026 +0000
feat(amber): report the owner's email in dataset search (#7875)
### What changes were proposed in this PR?
**Why it was never populated.** Every LakeFS-backed resource hydrates
its dashboard entry through `VersionedResourceTables.hydrate`, which
reads the owner out of the joined `USER` row:
```scala
record.into(USER).into(classOf[User]).getEmail
```
`joinWithAccessAndOwner` writes that join for every versioned resource.
But the select list comes entirely from each subclass's
`mappedResourceSchema`, and `UnifiedResourceSchema.apply` defaults
`userEmail` to `DSL.inline("")` — a slot `DatasetSearchQueryBuilder`
never named. So `hydrate` reads the owner out of a record that has no
owner in it.
Two steps turn the missing projection into a `null`:
```
schema slot userEmail = DSL.inline("") <- default, never overridden
|
SQL '' as email <- USER is joined, never read
|
translateRecord dedupes by ORIGINAL field, and jOOQ compares fields by
rendered SQL, so projectsOfWorkflow / userName / userEmail /
projectColor -- all DSL.inline("") -- collapse to ONE entry
keyed on the first of them
|
record no `email` column at all -> record.into(USER) = empty User
|
entry DashboardDataset.ownerEmail = null (not "")
```
**Change.** Name the slot — in the base class, so every versioned
resource gets it (see below). `hydrate` is untouched.
Before → after:
| | before | after |
|---|---|---|
| projection | `'' as email` | `texera_db.user.email as email` |
| `DashboardDataset.ownerEmail` | `null`, every row | the dataset
owner's address |
| owner `leftJoin(USER)` | joined, selected from, never read | read |
`WorkflowSearchQueryBuilder` is the sibling that shows the step that was
missed: it opts into the USER column it reads (`userName = USER.NAME`),
and `VersionedResourceSearchQueryBuilder` already filters on
`USER.EMAIL` for the `owners` query param — the email was reachable
through the join all along, only the projection was absent.
`HubResource` and file-service's `/dataset/list` both populate the same
field correctly; dataset search was the one producer that did not.
Scope of the impact, stated precisely because it is narrower than it
looks: `DashboardEntry.ownerEmail` (`dashboard-entry.ts:132`) receives
the null, and no frontend code reads that field for datasets today, so
no screen was visibly wrong. It was a trap rather than a broken page —
`dataset-selection-modal.component.ts:127` builds the storage logical
path `/${ResourceType.Dataset}/${ownerEmail}/${name}/${version}` out of
a `DashboardDataset`, and only escapes `/dataset/null/...` because it
lists via file-service.
**The slot lives in the base class, per @mengw15's review.** `hydrate`
is `final` on `VersionedResourceTables` and reads `USER.EMAIL` for
*every* versioned resource, so leaving the projection to each subclass
meant the next resource type would inherit the same trap.
`VersionedResourceSearchQueryBuilder` now builds the whole
`UnifiedResourceSchema` as a `final lazy val` a subclass cannot replace:
```scala
final override protected lazy val mappedResourceSchema: UnifiedResourceSchema =
UnifiedResourceSchema(
resourceType = DSL.inline(tables.resourceType),
name = tables.nameColumn,
...
userEmail = USER.EMAIL,
repositoryName = repositoryNameColumn,
...
)
```
Eight of the twelve fields come off the `tables` descriptor, `userEmail`
is fixed here, and a subclass supplies only the three columns the
descriptor does not name:
```scala
object DatasetSearchQueryBuilder
extends VersionedResourceSearchQueryBuilder(VersionedResourceTables.DatasetTables) {
override protected val repositoryNameColumn: Field[String] = DATASET.REPOSITORY_NAME
override protected val isDownloadableColumn: Field[java.lang.Boolean] = DATASET.IS_DOWNLOADABLE
override protected val coverImageColumn: Field[String] = DATASET.COVER_IMAGE
}
```
A new versioned resource now gets the owner email whether or not its
author thinks about it. It also removes the duplication where the
descriptor and the projection each spelled out `DATASET.NAME` /
`DATASET.DESCRIPTION` / `DATASET.DID`, so the FROM clause and the select
list can no longer disagree about which column they mean.
Two things worth a reviewer's eye:
- **`lazy` is load-bearing, not decoration.** The abstract projection
members are subclass `val`s, so a plain `val` here reads them before
they are initialised. Verified: it dies with `NullPointerException:
Cannot invoke "org.jooq.Field.as(String)" because "repositoryName" is
null`.
- **The rendered projection is otherwise unchanged.** Every pre-existing
per-alias assertion in the projection test passes untouched; only the
`email` column is added. That is what confirms `tables.nameColumn` and
friends are the same fields the subclass used to name by hand.
### Any related issues, documentation, discussions?
Closes #7874
### How was this PR tested?
`DatasetSearchQueryBuilderSpec` goes from 21 tests to 24, reusing the
fixture and lakeFS loopback stub #7855 built.
| test | what it pins |
|---|---|
| `carry the owner's email address` | the value on the entry — the
assertion that kills the mutant below |
| `take the email from the dataset's owner, not from the caller` |
fetched as `otherUid`, who reaches the dataset only because it is
public, so a lookup that echoed the signed-in caller back would fail |
| `project every dataset column under the alias its schema slot names`
(extended) | `user.email as email` in the SELECT. Now that the slot is
the base class's, this guards every versioned resource: dropping it from
`VersionedResourceSearchQueryBuilder` fails 3 tests here |
| `stay union-compatible with the workflow and project branches` | new —
see below |
The mutation #7855 recorded as surviving now dies. Replacing the
`record.into(USER).into(classOf[User])` in `hydrate` with a fresh
`User`:
| | before this PR | after |
|---|---|---|
| `new User` mutant | survives the whole suite (equivalent mutant, given
the defect) | `succeeded 22, failed 2` — both owner-email tests |
Verified in order, one sbt JVM each, all after merging main:
| run | result |
|---|---|
| with the change | `succeeded 24, failed 0, canceled 0` |
| `new User` mutant in `hydrate` | `succeeded 22, failed 2, canceled 0`
|
| `userEmail` slot dropped from the base class | `succeeded 21, failed
3, canceled 0` |
| base schema as a plain `val` instead of `lazy val` |
`NullPointerException` on `repositoryName` — why the `lazy` is there |
| the 7 dashboard suites (adds `UnifiedResourceSchemaSpec`,
`WorkflowSearchQueryBuilderSpec`, `ProjectSearchQueryBuilderSpec`) |
`Suites: completed 7, Tests: succeeded 113, failed 0, canceled 0` |
| `scalafmtCheckAll` + `Test/scalafixAll --check` | clean |
```bash
sbt "WorkflowExecutionService/testOnly org.apache.texera.web.resource.dashboard.DatasetSearchQueryBuilderSpec"
```
Two notes for a reviewer:
**What the union test is and is not for.** @mengw15 is right that the
hazard is not new: `userName` already has exactly this shape — only the
workflow branch projects a real column, dataset and project both project
`DSL.inline("")` — and that union runs in production today, so a
`varchar`-vs-`''` mix in one slot is not something this PR introduces. I
have corrected the test's comment and dropped the overstated claim that
was in this description. The test is still worth keeping:
`DashboardResource.searchAllResources` stacks the three builders with
`unionAll` for the dashboard's default view, nothing else in the suite
executes that union, and the contract is invisible from inside a single
builder.
**`canceled` is the failure mode to watch, and it moved.** The stub
binds the configured port (`localhost:8000`) rather than an ephemeral
one, because `LakeFSStorageClient.apiClient` is a `lazy val` capturing
`StorageConfig.lakefsEndpoint` once per JVM and amber runs every suite
in one unforked JVM. If something else holds that port — a local
`bin/local-dev.sh up` — the stub-dependent tests cancel, and that count
goes from 4 to 6 with the owner-email pair added. A cancel is quiet: sbt
prints `All tests passed` at exit 0. Measured on the merged suite, with
the port held and `size` mutated to `0L` in `hydrate`, the run reports
`succeeded 18, failed 0, canceled 6` and still exits green — so the
owner-email pair is disarmed alongside the rest of the `toEntry` half.
The class comment carries these numbers and I re-measured them after the
merge. CI has no lakeFS in this job, so it is deterministic there.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (claude-opus-5)
---
.../dashboard/DatasetSearchQueryBuilder.scala | 27 +++---
.../VersionedResourceSearchQueryBuilder.scala | 40 ++++++++-
.../dashboard/DatasetSearchQueryBuilderSpec.scala | 97 ++++++++++++++++++----
3 files changed, 129 insertions(+), 35 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 ae8b89c371..8fea491156 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,26 +19,21 @@
package org.apache.texera.web.resource.dashboard
-import org.apache.texera.dao.jooq.generated.Tables.{DATASET, DATASET_USER_ACCESS}
-import org.jooq.impl.DSL
+import org.apache.texera.dao.jooq.generated.Tables.DATASET
+import org.jooq.Field
-/** Query logic lives in [[VersionedResourceSearchQueryBuilder]]; only the projection is here. */
+/**
+ * Query logic and projection live in [[VersionedResourceSearchQueryBuilder]]; only the columns
+ * the [[VersionedResourceTables]] descriptor does not already name are here.
+ */
object DatasetSearchQueryBuilder
extends VersionedResourceSearchQueryBuilder(VersionedResourceTables.DatasetTables) {
- 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,
- versionedResourceId = DATASET.DID,
- repositoryName = DATASET.REPOSITORY_NAME,
- isVersionedResourcePublic = DATASET.IS_PUBLIC,
- isVersionedResourceDownloadable = DATASET.IS_DOWNLOADABLE,
- versionedResourceUserAccess = DATASET_USER_ACCESS.PRIVILEGE,
- versionedResourceCoverImage = DATASET.COVER_IMAGE
- )
+ override protected val repositoryNameColumn: Field[String] = DATASET.REPOSITORY_NAME
+
+ override protected val isDownloadableColumn: Field[java.lang.Boolean] = DATASET.IS_DOWNLOADABLE
+
+ override protected val coverImageColumn: Field[String] = DATASET.COVER_IMAGE
}
class DatasetSearchQueryBuilder {}
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
index 8893c20395..4dd17c9221 100644
--- 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
@@ -26,18 +26,52 @@ import org.apache.texera.web.resource.dashboard.FulltextSearchQueryUtils.{
}
import org.apache.texera.dao.jooq.generated.tables.User.USER
import org.jooq.impl.DSL
-import org.jooq.{Condition, GroupField, Record, TableLike}
+import org.jooq.{Condition, Field, 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.
+ * The one copy of FROM / WHERE / projection / hydration for every LakeFS-backed resource. A
+ * concrete builder supplies only its [[VersionedResourceTables]] descriptor and the three
+ * projected columns the descriptor does not already name.
*/
abstract class VersionedResourceSearchQueryBuilder[Rec <: Record, P](
tables: VersionedResourceTables[Rec, P]
) extends SearchQueryBuilder {
+ /** The resource's LakeFS repository name, which [[VersionedResourceTables.hydrate]] sizes. */
+ protected val repositoryNameColumn: Field[String]
+
+ protected val isDownloadableColumn: Field[java.lang.Boolean]
+
+ protected val coverImageColumn: Field[String]
+
+ /**
+ * Built here rather than per-subclass, and `final` so a subclass cannot replace it, because
+ * `hydrate` reads columns the subclass would otherwise have to remember to project. `userEmail`
+ * is the one that bit: it defaults to `DSL.inline("")`, so omitting it cost nothing at compile
+ * time and silently made `ownerEmail` null on every row. Everything else comes off the
+ * descriptor, so the projection and the FROM clause cannot disagree about which columns they mean.
+ *
+ * `lazy` matters: the abstract members above are subclass `val`s, still null while this class's
+ * constructor runs.
+ */
+ final override protected lazy val mappedResourceSchema: UnifiedResourceSchema =
+ UnifiedResourceSchema(
+ resourceType = DSL.inline(tables.resourceType),
+ name = tables.nameColumn,
+ description = tables.descriptionColumn,
+ creationTime = tables.creationTimeColumn,
+ ownerId = tables.ownerUidColumn,
+ userEmail = USER.EMAIL,
+ versionedResourceId = tables.idColumn,
+ repositoryName = repositoryNameColumn,
+ isVersionedResourcePublic = tables.isPublicColumn,
+ isVersionedResourceDownloadable = isDownloadableColumn,
+ versionedResourceUserAccess = tables.access.privilegeColumn,
+ versionedResourceCoverImage = coverImageColumn
+ )
+
/**
* `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.
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 e585848d3c..4594dd9b1a 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
@@ -89,14 +89,16 @@ import scala.jdk.CollectionConverters._
* 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
+ * the six 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
+ * A cancel is louder than it looks, and the sbt log shows it only as `canceled 6` 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
+ * `toEntryImpl` — the owner-email pair included, so the `new User` mutant this suite otherwise
+ * kills goes unnoticed too. Measured: with the port held, mutating `size` to `0L` in
+ * `VersionedResourceTables.hydrate` leaves `succeeded 18, failed 0, canceled 6` 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).
@@ -120,13 +122,22 @@ import scala.jdk.CollectionConverters._
* 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.
+ * The `record.into(USER).into(classOf[User]).getEmail` in `VersionedResourceTables.hydrate` used to
+ * be executed but unobservable: the dataset schema left `UnifiedResourceSchema`'s `userEmail` at its
+ * `DSL.inline("")` default, so the translated record carried no `USER` column,
+ * `DashboardDataset.ownerEmail` was ALWAYS null for dataset search results, and the owner
+ * `leftJoin(USER)` was a join that was selected from and never read. Replacing that read with a bare
+ * `new User` therefore changed nothing. The schema now names `userEmail = USER.EMAIL` and the value
+ * is asserted below, which kills that mutant; the owner-email tests are the ones that hold it dead,
+ * so a schema slot silently dropped again fails them.
+ *
+ * The slot now lives in the base rather than here, which is what stops the next versioned resource
+ * from repeating the bug: `hydrate` is `final` on `VersionedResourceTables` and reads `USER.EMAIL`
+ * for EVERY versioned resource, so `VersionedResourceSearchQueryBuilder` builds the whole projection
+ * — `userEmail` included — as a `final lazy val` a subclass cannot replace, and takes only the three
+ * columns its descriptor does not already name. A new resource type therefore gets the owner email
+ * whether or not its author thinks about it, and the projection assertion below guards the base for
+ * every subclass instead of just this one.
*
* Not covered, and not coverable from a test:
* - `constructFromClause`'s `includePublic: Boolean = false` default. `scalac` emits
@@ -162,6 +173,9 @@ class DatasetSearchQueryBuilderSpec
private val sizedDid: Integer = Integer.valueOf(9001)
private val goneDid: Integer = Integer.valueOf(9002)
+ /** Every dataset `beforeAll` seeds, so row-count assertions do not hard-code the fixture size. */
+ private val seededDids: Seq[Integer] = Seq(sizedDid, goneDid)
+
private val sizedRepo = "texera-ds-sized"
private val goneRepo = "texera-ds-gone"
@@ -520,14 +534,42 @@ class DatasetSearchQueryBuilderSpec
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")
+ // The one projected column that is not a DATASET column, and the one this schema used to leave
+ // at its `DSL.inline("")` default. It gets an assertion of its own rather than trusting the
+ // entry-level test because it is now the base class's slot, not this builder's: dropping it from
+ // `VersionedResourceSearchQueryBuilder` renders `'' as email` for every versioned resource, and
+ // this names the missing slot instead of surfacing as a null three layers downstream.
+ sql should include("user.email as email")
+ }
+
+ it should "stay union-compatible with the workflow and project branches" in {
+ // `DashboardResource.searchAllResources` stacks the three builders with `unionAll` for a
+ // resourceType of "" — the dashboard's default view — so every branch must project the same
+ // aliases in the same order with types Postgres will unify. A `varchar`-vs-`''` mix in one slot
+ // is not itself new: `userName` already has exactly this shape (only the workflow branch
+ // projects a real column; dataset and project both project `DSL.inline("")`) and that union
+ // runs in production today. What the test buys is that the contract is invisible from inside a
+ // single builder — nothing fails to compile, and a genuine mismatch would surface only as a
+ // failed query at runtime. This is the only test that executes the union; every other test here
+ // renders one branch, or fetches from one.
+ val union = WorkflowSearchQueryBuilder
+ .constructQuery(uid, params(), includePublic = true)
+ .unionAll(ProjectSearchQueryBuilder.constructQuery(uid, params(), includePublic = true))
+ .unionAll(DatasetSearchQueryBuilder.constructQuery(uid, params(), includePublic = true))
+
+ // Both seeded datasets are public, so both reach `uid`; no workflow or project rows are seeded.
+ // Derived from the fixture rather than hard-coded, since the count is incidental — that Postgres
+ // accepts the union at all is what is under test.
+ getDSLContext.fetch(union).size() shouldBe seededDids.size
}
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.
+ // `VersionedResourceTables.hydrate` reads the owner email out of this join. The owner-email
+ // tests below now see the
+ // value, so they would fail on a join dropped altogether — but not on a join RE-AIMED at a
+ // same-typed column, because every seeded dataset shares one owner. Pinning the predicate is
+ // what separates `USER.UID.eq(DATASET.OWNER_UID)` from `eq(DATASET_USER_ACCESS.UID)`, which
+ // would report the *caller's* email as the owner's on every shared dataset.
val sql = sqlFor(uid, includePublic = true)
sql should include("left outer join texera_db.user on texera_db.user.uid = ")
@@ -563,6 +605,29 @@ class DatasetSearchQueryBuilderSpec
dd.size shouldBe 42L
}
+ it should "carry the owner's email address" in {
+ requireStub()
+
+ // Was null for every dataset in the dashboard until the schema named `userEmail = USER.EMAIL`:
+ // the USER join was selected from and never read. This is also the assertion that kills the
+ // `record.into(USER).into(classOf[User])` -> `new User` mutant in
+ // `VersionedResourceTables.hydrate`, which survived the whole suite while the value was null.
+ entryFor(ownerUid, sizedDid).dataset.value.ownerEmail shouldBe "dataset_search_owner@mail.com"
+ }
+
+ it should "take the email from the dataset's owner, not from the caller" in {
+ requireStub()
+
+ // `otherUid` reaches this dataset only because it is public, and has its own address seeded. The
+ // entry must still name the owner — this is what the dashboard labels the dataset with, and it
+ // is the only assertion here that separates a real owner lookup from one that echoes the
+ // signed-in caller back.
+ val dd = entryFor(otherUid, sizedDid).dataset.value
+
+ dd.ownerEmail shouldBe "dataset_search_owner@mail.com"
+ dd.ownerEmail should not be "dataset_search_other@mail.com"
+ }
+
it should "set isOwner only for the dataset's own owner" in {
requireStub()