import { DrizzleClient } from "../interfaces"; import { CollectionConfig, FilterValues, OrderByTuple, ResolvedRelation, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types"; import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry"; import type { NestedPathHop } from "./nested-path"; /** * Service for handling all relation-related operations. * Handles fetching, updating, and managing row relations. */ /** * A related record resolved by {@link RelationService}: the target row's * values plus the identity (`id`) and originating collection (`path`) * needed to build relation references. Internal to the postgres driver — * flattened to plain rows at the fetch-service boundary. */ export interface RelatedRow = Record> { id: string | number; path: string; values: M; } export declare class RelationService { private db; private registry; constructor(db: DrizzleClient, registry: PostgresCollectionRegistry); /** * One target row, as the {@link RelatedRow} everything here returns. * * Eight sites built this by hand, which is how the address came to be the * target's first key column in all eight — one edit, eight places to miss. * * `resolveNested` is the one thing they did not agree on, and the * disagreement was invisible: the single-parent fetches pass `db` and * `registry` to `parseDataFromServer`, so the target's *own* relations get * resolved too, while the batch paths deliberately do not — a query per * target row is the N+1 the batching exists to avoid. Naming the parameter * makes that a decision rather than a difference between two call sites * nobody was comparing. */ private toRelatedRow; /** * A WHERE matching any of `parentIds`, by the whole key. * * A single key is an `IN (…)`. A composite one cannot be: matching * `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that * share their first column each receive the other's relations. It becomes * an OR of ANDs — one exact address per parent — which Postgres indexes the * same way it would a multi-column key lookup. */ private parentKeyCondition; /** * Reject a relation that cannot express a composite-keyed parent. * * `localKey` and `foreignKeyOnTarget` are single column names: one column * cannot reference a two-column key, so such a relation has no correct * reading. Left alone it would silently match on the first key column and * hand a tenant's rows to its neighbour — say so instead. */ private assertSingleKeyAddressable; /** * What the target's foreign key holds, for each of these parent rows. * * Ordinarily the parent's id, and then this is free. When the relation * declares a `sourceKey` the two are different values, and the mapping * between them lives in the source table — so it costs one SELECT, issued * once for the whole batch rather than per parent. * * Both directions come back because both are needed and deriving one from * the other by hand is how a batch loader ends up attributing a child to the * wrong parent: reads translate id → key to build the WHERE, and then * translate key → id to attribute each row that comes back. */ /** * The value a related row's foreign key must hold to belong to this parent. * * `undefined` when the parent's source key is null — which is not an error * here, only in the callers that were about to write it. Exposed for * {@link PersistService}, which stamps this onto a child created under a * nested path and would otherwise write the id and lose the row. */ parentKeyValue(parentCollection: CollectionConfig, relation: ResolvedHasOne | ResolvedHasMany, parentId: string | number, db?: DrizzleClient): Promise; /** * Shared with {@link RelationWriteService}: a write needs the same source * key a read does, and resolving it twice is how the two would disagree. */ resolveSourceKeys(parentCollection: CollectionConfig, relation: ResolvedHasOne | ResolvedHasMany, parentIds: (string | number)[], db?: DrizzleClient): Promise<{ keyByParentId: Map; parentIdByKey: Map; }>; /** * Fetch rows related to a parent row through a specific relation */ fetchRelatedEntities>(parentCollectionPath: string, parentId: string | number, relationKey: string, options?: { filter?: FilterValues>; orderBy?: string | OrderByTuple[]; order?: "desc" | "asc"; limit?: number; startAfter?: Record; searchString?: string; databaseId?: string; }): Promise[]>; /** * Fetch rows using join paths for complex relations */ fetchEntitiesUsingJoins>(parentCollection: CollectionConfig, parentId: string | number, relation: ResolvedRelation, options?: { filter?: FilterValues>; orderBy?: string | OrderByTuple[]; order?: "desc" | "asc"; limit?: number; startAfter?: Record; searchString?: string; databaseId?: string; }): Promise[]>; /** * Count related rows for a parent row */ countRelatedEntities>(parentCollectionPath: string, parentId: string | number, relationKey: string, options?: { filter?: FilterValues>; databaseId?: string; }): Promise; /** * Count the target rows a parent reaches through `relation`, narrowed by * `additionalFilters` (conditions on the target table). * * Shared by the public count and by {@link isRelated}, so "how many children * does this parent have" and "is this row one of them" are answered by the * same join — a membership test that reconstructed the join separately would * be free to disagree with the listing it is supposed to gate. */ private countRelatedRows; /** * Whether `targetId` is actually reachable from the parent named in `hop`. * * A nested address like `authors/1/posts/43` used to resolve to the target * collection and then match on the primary key alone, so the parent segment * decided nothing: the row came back, and was updated or deleted, whoever it * belonged to. Reads, updates and deletes now all gate on this. */ isRelated(hop: NestedPathHop, targetId: string | number): Promise; /** * Batch fetch related rows for multiple parent rows to avoid N+1 queries */ batchFetchRelatedEntities(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation): Promise>>>; /** * Batch fetch many-cardinality related rows for multiple parent rows. * Returns a Map instead of Map. * Uses a single SQL query with IN clause to avoid N+1. */ batchFetchRelatedEntitiesMany(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation): Promise>[]>>; }