Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .agents/skills/verify-interface/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ One file per feature. `covers:` lists every source file that computes something

## Known issues, driven on purpose

Three journeys fail on head and cite their issue: `issue-90-edit-index` ([#90](https://github.com/AntelopeJS/interface-data-api/issues/90)), `issue-92-getter-mode` ([#92](https://github.com/AntelopeJS/interface-data-api/issues/92)) and `issue-93-multi-foreign` ([#93](https://github.com/AntelopeJS/interface-data-api/issues/93)). A verdict that runs their features is FAIL until the issue is fixed; the fix turns the journey green.
Two journeys fail on head and cite their issue: `issue-90-edit-index` ([#90](https://github.com/AntelopeJS/interface-data-api/issues/90)) and `issue-93-multi-foreign` ([#93](https://github.com/AntelopeJS/interface-data-api/issues/93)). A verdict that runs their features is FAIL until the issue is fixed; the fix turns the journey green.

## Not driven

Expand Down
6 changes: 1 addition & 5 deletions .agents/skills/verify-interface/features/listing.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ A client pages, sorts and narrows the rows of a table through `GET /resource/lis
- `pluck-modes`: the default list mode answers the listable fields with read access, a `detailed` mode answers its own fields and does not inherit the default ones, a field can be left out of a mode, a field that is not listable is still read by `get`, and a getter lists the database fields it reads.
- `pagination`: a list answers `results`, `total`, `offset` and `limit`; `limit` and `offset` page the rows, the last page is short, the default is 10 and a route without `maxPage` caps `limit` at 100.
- `sorting`: `sortKey` and `sortDirection` sort on indexed and non-indexed `@Sortable` fields, any other direction sorts ascending, and a field without `@Sortable` answers `Field is not sortable.`.
- `issue-92-getter-mode`: fails on purpose until [#92](https://github.com/AntelopeJS/interface-data-api/issues/92) is fixed: a getter that a list mode does not declare is left out of that mode.
- `issue-92-getter-mode`: a getter that a list mode does not declare is left out of that mode ([#92](https://github.com/AntelopeJS/interface-data-api/issues/92)).

## How to get to it (user POV)

Expand All @@ -48,7 +48,3 @@ pnpm exec antelopejs-verify journey "$RUN" listing
```

Each journey compiles the consumer in `fixture/shop-1.ts.txt` and `fixture/shop-2.ts.txt`, joined in that order, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`.

## Gotchas

- `/people/brief` lists a getter that only the default mode declares: [#92](https://github.com/AntelopeJS/interface-data-api/issues/92), driven by `issue-92-getter-mode`.
2 changes: 1 addition & 1 deletion .agents/skills/verify-interface/journeys-probe-1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export const issue90 = shopJourney("fails on purpose until #90 is fixed: edit wi
context.expect(row.folder, [equal("#90: the row found through the index holds the new title", pick(row.rows[0] ?? null, "title"), "New")]);
});

export const issue92 = shopJourney("fails on purpose until #92 is fixed: a getter that a list mode does not declare is left out of it", async (shop) => {
export const issue92 = shopJourney("#92: a getter that a list mode does not declare is left out of it", async (shop) => {
const { context } = shop;
await seed(shop, USERS);
const brief = await shop.request("people in the brief mode", "GET", "/people/brief");
Expand Down
2 changes: 1 addition & 1 deletion src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ export namespace Query {
action?: string,
onlyList?: boolean,
) {
return readProperties(obj, meta, dbData, action, onlyList);
return readProperties(obj, meta, dbData, action, { onlyList });
}

/**
Expand Down
11 changes: 7 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { WriteDocument } from "@antelopejs/interface-database/common";
import { Parameters, Query, Validation } from "./components";
import { isWriteDocument, isWriteValue } from "./internal/guards";
import { prototypeOf } from "./internal/prototypes";
import { ReadProperties as readProperties } from "./internal/read-properties";
import {
DataAPIMeta,
type DataControllerCallback,
Expand Down Expand Up @@ -221,12 +222,12 @@ export namespace DefaultRoutes {
{ exposeDeferredJoined: true },
);

const pluck: Set<string> | undefined =
meta.pluck[params.pluckMode ?? "list"];
const pluckMode = params.pluckMode ?? "list";
const pluck: Set<string> | undefined = meta.pluck[pluckMode];
assert(
params.noPluck || pluck,
400,
`No fields found for pluckMode '${params.pluckMode ?? "list"}'`,
`No fields found for pluckMode '${pluckMode}'`,
);

const offset = params.offset || 0;
Expand All @@ -248,7 +249,9 @@ export namespace DefaultRoutes {
const entryInstance = model.constructor.fromDatabase(entry);
assert(entryInstance, 500, "The database answered an empty row.");
Validation.Unlock(this, meta, entryInstance);
return Query.ReadProperties(this, meta, entryInstance, "list");
return readProperties(this, meta, entryInstance, "list", {
listMode: params.noPluck ? undefined : pluckMode,
});
}),
);

Expand Down
21 changes: 20 additions & 1 deletion src/internal/read-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,31 @@ import type {
DataControllerInstance,
RowFields,
} from "../metadata";
import type { FieldData } from "./field-types";
import { isFunction } from "./guards";
import { ownEntry } from "./meta-records";
import { fieldsOf, prototypeOf } from "./prototypes";

/** Which fields a read leaves out. */
interface ReadFilter {
/** Leaves out the fields that no list mode lists. */
onlyList?: boolean;
/** Leaves out the getters that this list mode does not list. */
listMode?: string;
}

function listedIn(field: FieldData, mode: string) {
return (
field.listable !== undefined && ownEntry(field.listable, mode) !== undefined
);
}

export async function ReadProperties(
obj: DataControllerInstance,
meta: DataAPIMeta,
dbData: RowFields,
action?: string,
onlyList?: boolean,
{ onlyList, listMode }: ReadFilter = {},
) {
const readable =
meta.readable[action ?? "_default"] ?? meta.readable._default;
Expand All @@ -32,6 +48,9 @@ export async function ReadProperties(
if (onlyList && !field.listable) {
continue;
}
if (listMode !== undefined && !listedIn(field, listMode)) {
continue;
}
const val: unknown = field.desc?.get?.apply(instance);
res[key] = await (isFunction(val) ? val() : val);
}
Expand Down
130 changes: 130 additions & 0 deletions src/tests/index/list_getter_modes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { expect } from "chai";
import { z } from "zod";
import { Controller } from "@antelopejs/interface-api";
import { Access, AccessMode, Listable, ModelReference } from "../../metadata";
import {
DataController,
DefaultRoutes,
RegisterDataController,
} from "../../index";
import {
BasicDataModel,
Field,
Model,
RegisterSchema,
RegisterTable,
Table,
} from "@antelopejs/interface-database-decorators";

import { listResponseSchema, readJson } from "../internal/http";
import { getSchemaInstance, request } from "../utils";

const TABLE = "list-getter-modes-people";
const SCHEMA = "default";
const LOCATION = "list-getter-modes";
let fullNameReads = 0;

@RegisterTable(TABLE, SCHEMA)
class Person extends Table {
@Field("string")
declare name: string;
@Field("string")
declare email: string;
}
class PersonModel extends BasicDataModel(Person, TABLE) {}

@RegisterDataController()
class _PersonAPI extends DataController(
Person,
{
list: DefaultRoutes.List,
detailed: DefaultRoutes.WithOptions(DefaultRoutes.List, {
pluckMode: "detailed",
}),
everything: DefaultRoutes.WithOptions(DefaultRoutes.List, {
pluckMode: "detailed",
noPluck: true,
}),
},
Controller(`/${LOCATION}`),
) {
@ModelReference()
@Model(PersonModel)
declare model: PersonModel;

@Listable()
@Listable(true, "detailed")
@Access(AccessMode.ReadOnly)
declare name: string;

@Listable()
@Access(AccessMode.ReadOnly)
declare email: string;

@Listable(["name", "email"])
@Access(AccessMode.ReadOnly)
get fullName(): string {
fullNameReads++;
return `${this.name} <${this.email}>`;
}

@Listable([])
@Access(AccessMode.ReadOnly)
get kind(): string {
return "person";
}

@Listable(["name"], "detailed")
@Access(AccessMode.ReadOnly)
get initial(): string {
return this.name.charAt(0);
}
}

async function listRows(uri: string) {
const response = await request(LOCATION, uri, "GET");
expect(response.status).to.equal(200);
const body = await readJson(listResponseSchema(z.looseObject({})), response);
return body.results;
}

describe("List getters per pluck mode", () => {
before(async () => {
await RegisterSchema(SCHEMA);
await new PersonModel(getSchemaInstance(SCHEMA)).insert({
name: "Alice",
email: "alice@example.com",
});
});
beforeEach(() => {
fullNameReads = 0;
});

it("evaluates a getter in the mode that lists it", async () => {
const rows = await listRows("list");
expect(rows).to.deep.equal([
{
name: "Alice",
email: "alice@example.com",
fullName: "Alice <alice@example.com>",
kind: "person",
},
]);
expect(fullNameReads).to.equal(1);
});

it("leaves out a getter the route's mode does not list", async () => {
const rows = await listRows("detailed");
expect(rows).to.deep.equal([{ name: "Alice", initial: "A" }]);
expect(fullNameReads).to.equal(0);
});

it("evaluates every getter on a route that does not pluck", async () => {
const [row] = await listRows("everything");
expect(row).to.include({
fullName: "Alice <alice@example.com>",
kind: "person",
initial: "A",
});
});
});
Loading