diff --git a/src/components.ts b/src/components.ts index 6c97d2c..e9b33d8 100644 --- a/src/components.ts +++ b/src/components.ts @@ -463,6 +463,79 @@ export namespace Query { return Array.from(groups.values()); } + interface JoinedFieldSplit { + filter: Set; + sort: Set; + } + + function addAll(target: Set, 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, + ): 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 { + 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, schemaName: string | undefined, @@ -477,18 +550,23 @@ export namespace Query { db: SchemaInstance, meta: DataAPIMeta, query: Stream, + only?: Set, ): Stream; export function Joined( db: SchemaInstance, meta: DataAPIMeta, query: Datum, + only?: Set, ): Datum; export function Joined( db: SchemaInstance, meta: DataAPIMeta, query: Stream | Datum, + only?: Set, ): Stream | Datum { - 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; } @@ -714,7 +792,7 @@ export namespace Query { sorting?: [string, "asc" | "desc" | undefined], filters?: Record, db?: SchemaInstance, - ): [sorted: Stream, total: Datum] { + ): [sorted: Stream, total: Datum, deferredJoined: Set] { const filterList = Object.entries(meta.filters).filter( ([name]) => filters && name in filters, ); @@ -730,8 +808,14 @@ export namespace Query { ? request.getAll(indexedFilter?.[0] ?? "", index) : request; - if (db) { - tmpRequest = Joined(db, meta, tmpRequest as Stream) as Stream; + const joinedSplit = splitJoinedFields(meta, sorting, filters); + if (db && joinedSplit.filter.size > 0) { + tmpRequest = Joined( + db, + meta, + tmpRequest as Stream, + joinedSplit.filter, + ) as Stream; } const filteredComputed = new Set( @@ -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, + joinedSplit.sort, + ) as Stream; + } if ( db && sortField && @@ -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, id: string | string[]) { diff --git a/src/index.ts b/src/index.ts index c6cc1f1..0c6f469 100644 --- a/src/index.ts +++ b/src/index.ts @@ -162,6 +162,21 @@ function displayOnlyComputedFields( return new Set(names); } +function displayOnlyJoinedFields( + deferred: Set, + params: Parameters.ListParameters, + pluck: Set | undefined, + materializeAll: boolean, +): Set { + 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) { @@ -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, @@ -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, diff --git a/src/tests/components/joined.test.ts b/src/tests/components/joined.test.ts index cb7a5d8..9235212 100644 --- a/src/tests/components/joined.test.ts +++ b/src/tests/components/joined.test.ts @@ -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, @@ -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 () => @@ -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).concat(" (author)"), + ) + declare display: string; + } + + return _seedTables(); +} + +async function _seedTables() { await RegisterSchema(schemaName); await _dropTables(); @@ -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());