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
102 changes: 97 additions & 5 deletions src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,79 @@ export namespace Query {
return Array.from(groups.values());
}

interface JoinedFieldSplit {
filter: Set<string>;
sort: Set<string>;
}

function addAll(target: Set<string>, names: string[]) {
for (const name of names) {
target.add(name);
}
}

/**
* Splits the joined fields of `meta` between those {@link List} must
* materialize before the filter and count (`filter`) and those it only
* needs after the count (`sort`). Joined fields referenced by neither are
* left out so the list route can defer their lookups to the returned page.
*
* Computed expressions may read joined fields, so materializing a computed
* field pulls every remaining joined group in with it. Groups whose local
* key is also a foreign field must resolve before `Foreign` replaces the
* key with the looked-up record.
*/
function splitJoinedFields(
meta: DataAPIMeta,
sorting?: [string, "asc" | "desc" | undefined],
filters?: Record<string, FilterValue>,
): JoinedFieldSplit {
const split: JoinedFieldSplit = { filter: new Set(), sort: new Set() };
const filterNames = Object.keys(meta.filters).filter(
(name) => filters && name in filters,
);
const hasComputedFilter = filterNames.some(
(name) => meta.fields[name]?.computed,
);
const sortField = sorting?.[0];
const hasComputedSort = !!sortField && !!meta.fields[sortField]?.computed;
for (const group of collectJoinedGroups(meta)) {
const names = group.fields.map((field) => field.name);
if (
hasComputedFilter ||
names.some((name) => filterNames.includes(name))
) {
addAll(split.filter, names);
} else if (
hasComputedSort ||
(sortField && names.includes(sortField)) ||
meta.fields[group.localKey]?.foreign
) {
addAll(split.sort, names);
}
}
return split;
}

/**
* Joined field names that {@link List} does not materialize for the given
* split. The default list route resolves these after pagination so their
* lookups only run on the returned page.
*/
function deferredJoinedFields(
meta: DataAPIMeta,
split: JoinedFieldSplit,
): Set<string> {
return new Set(
Object.entries(meta.fields)
.filter(
([name, field]) =>
field.joined && !split.filter.has(name) && !split.sort.has(name),
)
.map(([name]) => name),
);
}

function resolveSchemaDb(
sourceDb: SchemaInstance<any>,
schemaName: string | undefined,
Expand All @@ -477,18 +550,23 @@ export namespace Query {
db: SchemaInstance<any>,
meta: DataAPIMeta,
query: Stream<any>,
only?: Set<string>,
): Stream<any>;
export function Joined(
db: SchemaInstance<any>,
meta: DataAPIMeta,
query: Datum<any>,
only?: Set<string>,
): Datum<any>;
export function Joined(
db: SchemaInstance<any>,
meta: DataAPIMeta,
query: Stream<any> | Datum<any>,
only?: Set<string>,
): Stream<any> | Datum<any> {
const groups = collectJoinedGroups(meta);
const groups = collectJoinedGroups(meta).filter(
(group) => !only || group.fields.some((field) => only.has(field.name)),
);
if (groups.length === 0) {
return query as any;
}
Expand Down Expand Up @@ -714,7 +792,7 @@ export namespace Query {
sorting?: [string, "asc" | "desc" | undefined],
filters?: Record<string, FilterValue>,
db?: SchemaInstance<any>,
): [sorted: Stream<T>, total: Datum<number>] {
): [sorted: Stream<T>, total: Datum<number>, deferredJoined: Set<string>] {
const filterList = Object.entries(meta.filters).filter(
([name]) => filters && name in filters,
);
Expand All @@ -730,8 +808,14 @@ export namespace Query {
? request.getAll(indexedFilter?.[0] ?? "", index)
: request;

if (db) {
tmpRequest = Joined(db, meta, tmpRequest as Stream<any>) as Stream<T>;
const joinedSplit = splitJoinedFields(meta, sorting, filters);
if (db && joinedSplit.filter.size > 0) {
tmpRequest = Joined(
db,
meta,
tmpRequest as Stream<any>,
joinedSplit.filter,
) as Stream<T>;
Comment thread
MrSociety404 marked this conversation as resolved.
}

const filteredComputed = new Set(
Expand Down Expand Up @@ -773,6 +857,14 @@ export namespace Query {
}, tmpRequest);
}
const total = tmpRequest.count();
if (db && joinedSplit.sort.size > 0) {
tmpRequest = Joined(
db,
meta,
tmpRequest as Stream<any>,
joinedSplit.sort,
) as Stream<T>;
}
if (
db &&
sortField &&
Expand All @@ -789,7 +881,7 @@ export namespace Query {
if (shouldSort && !shouldSort.indexed && sortField) {
tmpRequest = tmpRequest.orderBy(sortField, sorting?.[1] ?? "asc");
}
return [tmpRequest, total];
return [tmpRequest, total, deferredJoinedFields(meta, joinedSplit)];
}

export function Delete(table: Table<any>, id: string | string[]) {
Expand Down
37 changes: 36 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,21 @@ function displayOnlyComputedFields(
return new Set(names);
}

function displayOnlyJoinedFields(
deferred: Set<string>,
params: Parameters.ListParameters,
pluck: Set<string> | undefined,
materializeAll: boolean,
): Set<string> {
if (materializeAll) {
return deferred;
}
const names = Array.from(deferred).filter(
(name) => params.noPluck || pluck?.has(name),
);
return new Set(names);
}

export namespace DefaultRoutes {
class Methods {
async get(_reqCtx: RequestContext, params: Parameters.GetParameters) {
Expand Down Expand Up @@ -198,7 +213,7 @@ export namespace DefaultRoutes {
"asc" | "desc" | undefined,
])
: undefined;
let [query, queryTotal] = Query.List(
let [query, queryTotal, deferredJoined] = Query.List(
this,
meta,
model.table,
Expand Down Expand Up @@ -230,6 +245,26 @@ export namespace DefaultRoutes {
let queryPaged = query.slice(offset, limit);

const displayComputed = displayOnlyComputedFields(meta, params, pluck);
// A computed expression may read any joined field, so when a computed
// field is materialized after the slice, every deferred joined group
// must be merged first regardless of pluck (mirroring the conservative
// rule Query.List applies pre-count). The extra lookups only run on the
// page and the final pluck() strips fields the response did not ask for.
const displayJoined = displayOnlyJoinedFields(
deferredJoined,
params,
pluck,
displayComputed.size > 0,
);
if (displayJoined.size > 0) {
queryPaged = Query.Joined(
model.database,
meta,
queryPaged,
displayJoined,
);
}

if (displayComputed.size > 0) {
queryPaged = Query.Computed(
model.database,
Expand Down
101 changes: 100 additions & 1 deletion src/tests/components/joined.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import {
import {
Access,
AccessMode,
Computed,
Filter,
Joined,
Listable,
ModelReference,
Sortable,
} from "@antelopejs/interface-data-api/metadata";
import { Schema } from "@antelopejs/interface-database";
import { Schema, type ValueProxy } from "@antelopejs/interface-database";
import {
BasicDataModel,
Field,
Expand Down Expand Up @@ -95,6 +96,10 @@ describe("Field Joined", () => {
it("sorts by joined field descending", async () =>
await sortsByJoinedFieldDescending());
it("filters by joined field", async () => await filtersByJoinedField());
it("keeps joined fields on paginated rows when unused by sort and filter", async () =>
await keepsJoinedFieldsOnPaginatedRows());
it("resolves computed fields reading a non-plucked joined field", async () =>
await resolvesComputedFieldReadingNonPluckedJoinedField());
it("returns null for orphan foreign key", async () =>
await returnsNullForOrphanForeignKey());
it("ignores joined field on edit body", async () =>
Expand Down Expand Up @@ -153,6 +158,52 @@ async function _createDataController(testName: string) {
declare email: string;
}

return _seedTables();
}

// Regression fixture for the lazy joined-field split: `name` is a joined
// field that is neither listable, filtered, nor sorted, while `display` is a
// listable computed field whose expression reads it. The deferred joined
// lookup must still run before the computed expression on the paginated rows.
async function _createComputedJoinedController(testName: string) {
@RegisterDataController()
class _JoinedComputedTestAPI extends DataController(
Book,
{
list: DefaultRoutes.List,
},
Controller(`/${testName}`),
) {
@ModelReference()
@Model(BookModel)
declare bookModel: BookModel;

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

@Listable()
@Access(AccessMode.ReadWrite)
declare title: string;

@Joined({
table: authorTableName,
localKey: "authorId",
remoteField: "name",
})
declare name: string;

@Listable()
@Computed((row) =>
(row.key("name") as ValueProxy<string>).concat(" (author)"),
)
declare display: string;
}

return _seedTables();
}

async function _seedTables() {
await RegisterSchema(schemaName);
await _dropTables();

Expand Down Expand Up @@ -268,6 +319,54 @@ async function filtersByJoinedField() {
}
}

async function keepsJoinedFieldsOnPaginatedRows() {
await _createDataController(getFunctionName());

const response = await listRequest(getFunctionName(), {
sortKey: "title",
sortDirection: "asc",
offset: "1",
limit: "2",
});
expect(response.status).to.equal(200);
const data = (await response.json()) as {
results: BookListed[];
total: number;
};
expect(data.total).to.equal(5);
expect(data.results.map((b) => b.title)).to.deep.equal([
"Alpha Rising",
"Beta Stories",
]);
expect(data.results.map((b) => b.name)).to.deep.equal([
"Alice Carter",
"Bob Stone",
]);
expect(data.results.map((b) => b.email)).to.deep.equal([
"alice@example.com",
"bob@example.com",
]);
}

async function resolvesComputedFieldReadingNonPluckedJoinedField() {
await _createComputedJoinedController(getFunctionName());

const response = await listRequest(getFunctionName(), {});
expect(response.status).to.equal(200);
const data = (await response.json()) as {
results: { title: string; display: string | null; name?: string }[];
total: number;
};
expect(data.total).to.equal(5);
expect(data.results).to.have.lengthOf(5);

const alphaRising = data.results.find((b) => b.title === "Alpha Rising");
expect(alphaRising).to.not.equal(undefined);
expect(alphaRising?.display).to.equal("Alice Carter (author)");
// the joined field itself is not listable, so it must stay out of the response
expect(alphaRising?.name).to.equal(undefined);
}

async function returnsNullForOrphanForeignKey() {
await _createDataController(getFunctionName());

Expand Down
Loading