feat(8.0): related({ node }) — one-call both-direction incident edges
related({ from }) / related({ to }) each return one direction; related({ node })
unions both (every edge where the node is source OR target), deduped, in a single
O(degree) call — the indexed equivalent of merging two related() calls by hand.
The 'all edges on a noun' query the graph-viz path needs.
- RelatedParams.node: mutually exclusive with from/to (throws if combined);
composes with type/subtype/visibility filters; natural-key ids resolved.
- Implemented as a parallel out-edge (sourceId) + in-edge (targetId) fetch through
the O(degree) graph-index fast paths, merged + deduped by id (a self-loop appears
once), sorted for a stable page. Full incidence is the intended O(degree) cost;
deep offset paging of a very-high-degree node is best-effort (use the native
edgesForNode / a graph cursor for exhaustive paging).
- db.related() parity: node resolved + honored in relationMatchesParams (matches an
edge incident in either direction) for the historical/overlay paths.
Test: related-node-bidirectional.test.ts — both directions, equals from∪to, self-loop
dedupe, type-filter composition, default-hides-internal, node+from/to throws.
This commit is contained in:
parent
a3d6fdb8b3
commit
d4de48d004
4 changed files with 161 additions and 19 deletions
|
|
@ -3058,31 +3058,32 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const params: RelatedParams = {
|
||||
...rawParams,
|
||||
...(rawParams.from !== undefined && { from: resolveEntityId(rawParams.from) }),
|
||||
...(rawParams.to !== undefined && { to: resolveEntityId(rawParams.to) })
|
||||
...(rawParams.to !== undefined && { to: resolveEntityId(rawParams.to) }),
|
||||
...(rawParams.node !== undefined && { node: resolveEntityId(rawParams.node) })
|
||||
}
|
||||
|
||||
const limit = params.limit || 100
|
||||
const offset = params.offset || 0
|
||||
|
||||
// Production safety: warn for large unfiltered queries
|
||||
if (!params.from && !params.to && !params.type && limit > 10000) {
|
||||
console.warn(
|
||||
`[Brainy] related(): Fetching ${limit} relationships without filters. ` +
|
||||
`Consider adding 'from', 'to', or 'type' filter for better performance.`
|
||||
// `node` is the both-direction shorthand; it cannot also constrain one direction.
|
||||
if (params.node !== undefined && (params.from !== undefined || params.to !== undefined)) {
|
||||
throw new Error(
|
||||
"related(): 'node' is mutually exclusive with 'from'/'to' — pass 'node' for both-direction incident edges, or 'from'/'to' for one direction."
|
||||
)
|
||||
}
|
||||
|
||||
// Build filter for storage query
|
||||
// Production safety: warn for large unfiltered queries
|
||||
if (!params.from && !params.to && !params.node && !params.type && limit > 10000) {
|
||||
console.warn(
|
||||
`[Brainy] related(): Fetching ${limit} relationships without filters. ` +
|
||||
`Consider adding 'from', 'to', 'node', or 'type' filter for better performance.`
|
||||
)
|
||||
}
|
||||
|
||||
// Shared filter (type / subtype / service / visibility). Endpoint ids
|
||||
// (`sourceId` / `targetId`) are added per-direction below.
|
||||
const filter: any = {}
|
||||
|
||||
if (params.from) {
|
||||
filter.sourceId = params.from
|
||||
}
|
||||
|
||||
if (params.to) {
|
||||
filter.targetId = params.to
|
||||
}
|
||||
|
||||
if (params.type) {
|
||||
filter.verbType = Array.isArray(params.type) ? params.type : [params.type]
|
||||
}
|
||||
|
|
@ -3095,9 +3096,8 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
filter.service = params.service
|
||||
}
|
||||
|
||||
// Visibility (8.0): exclude hidden tiers by default. The exclusion is applied in the
|
||||
// storage scan AFTER metadata load (where verb.visibility is known), so the storage
|
||||
// limit stays exact. Like subtype, it disqualifies the metadata-less fast paths.
|
||||
// Visibility (8.0): exclude hidden tiers by default. Applied on the O(degree)
|
||||
// candidate set in the graph-index fast path (see baseStorage.applyVerbMetadataFilters).
|
||||
const excludedTiers = this.excludedVisibilityTiers(params)
|
||||
if (excludedTiers) {
|
||||
filter.excludeVisibility = excludedTiers
|
||||
|
|
@ -3106,6 +3106,42 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// VFS relationships are no longer filtered
|
||||
// VFS is part of the knowledge graph - users can filter explicitly if needed
|
||||
|
||||
// Both-direction incident-edge query: union the node's out-edges (it is the
|
||||
// source) and in-edges (it is the target) — each an O(degree) indexed lookup —
|
||||
// then dedupe a self-loop that appears in both. Sorted by id for a stable page,
|
||||
// sliced from the merged set. Fetching the node's full incidence is the intended
|
||||
// O(degree) cost; for a very-high-degree node, deep `offset` paging is best-effort
|
||||
// (use the native edgesForNode / a graph cursor for exhaustive paging).
|
||||
if (params.node !== undefined) {
|
||||
const endLimit = offset + limit
|
||||
const [outEdges, inEdges] = await Promise.all([
|
||||
this.storage.getVerbs({
|
||||
pagination: { limit: endLimit },
|
||||
filter: { ...filter, sourceId: params.node }
|
||||
}),
|
||||
this.storage.getVerbs({
|
||||
pagination: { limit: endLimit },
|
||||
filter: { ...filter, targetId: params.node }
|
||||
})
|
||||
])
|
||||
const byId = new Map<string, (typeof outEdges)['items'][number]>()
|
||||
for (const verb of outEdges.items) byId.set(verb.id, verb)
|
||||
for (const verb of inEdges.items) byId.set(verb.id, verb)
|
||||
const merged = [...byId.values()].sort((a, b) =>
|
||||
a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
||||
)
|
||||
return this.verbsToRelations(merged.slice(offset, offset + limit))
|
||||
}
|
||||
|
||||
// Single-direction query (from / to).
|
||||
if (params.from) {
|
||||
filter.sourceId = params.from
|
||||
}
|
||||
|
||||
if (params.to) {
|
||||
filter.targetId = params.to
|
||||
}
|
||||
|
||||
// Fetch from storage with pagination at storage layer (efficient!)
|
||||
const result = await this.storage.getVerbs({
|
||||
pagination: {
|
||||
|
|
|
|||
|
|
@ -460,7 +460,8 @@ export class Db<T = any> {
|
|||
const params: RelatedParams = {
|
||||
...rawParams,
|
||||
...(rawParams.from !== undefined && { from: resolveEntityId(rawParams.from) }),
|
||||
...(rawParams.to !== undefined && { to: resolveEntityId(rawParams.to) })
|
||||
...(rawParams.to !== undefined && { to: resolveEntityId(rawParams.to) }),
|
||||
...(rawParams.node !== undefined && { node: resolveEntityId(rawParams.node) })
|
||||
}
|
||||
const historical = this.isHistorical()
|
||||
|
||||
|
|
@ -1024,6 +1025,10 @@ function sortResultsBy<T>(results: Result<T>[], orderBy: string, order: 'asc' |
|
|||
* `to` → targetId, type/subtype set membership, service equality).
|
||||
*/
|
||||
function relationMatchesParams<T>(relation: Relation<T>, params: RelatedParams): boolean {
|
||||
// `node` matches an edge incident in EITHER direction (the both-direction shorthand).
|
||||
if (params.node !== undefined && relation.from !== params.node && relation.to !== params.node) {
|
||||
return false
|
||||
}
|
||||
if (params.from !== undefined && relation.from !== params.from) return false
|
||||
if (params.to !== undefined && relation.to !== params.to) return false
|
||||
if (params.type !== undefined) {
|
||||
|
|
|
|||
|
|
@ -684,6 +684,21 @@ export interface RelatedParams {
|
|||
*/
|
||||
to?: string
|
||||
|
||||
/**
|
||||
* Filter by INCIDENT entity ID — every relationship touching this entity in
|
||||
* EITHER direction (where it is the source OR the target), deduped. The
|
||||
* one-call "all edges on a noun" query: equivalent to merging
|
||||
* `related({ from: id })` and `related({ to: id })` but in a single
|
||||
* O(degree) call. Mutually exclusive with `from` / `to` (pass `node` for
|
||||
* both directions, or `from` / `to` for one). Composes with `type` /
|
||||
* `subtype` / visibility filters.
|
||||
*
|
||||
* @example
|
||||
* // Everything connected to this person, in or out.
|
||||
* const edges = await brain.related({ node: personId })
|
||||
*/
|
||||
node?: string
|
||||
|
||||
/**
|
||||
* Filter by relationship type(s)
|
||||
*
|
||||
|
|
|
|||
86
tests/unit/brainy/related-node-bidirectional.test.ts
Normal file
86
tests/unit/brainy/related-node-bidirectional.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* @module tests/unit/brainy/related-node-bidirectional
|
||||
* @description Graph-perf #4 (8.0): `related({ node })` — the one-call,
|
||||
* both-direction "all edges on a noun" query.
|
||||
*
|
||||
* `related({ from })` / `related({ to })` each return one direction; `related({ node })`
|
||||
* unions both (edges where the node is source OR target), deduped, in a single
|
||||
* O(degree) call — the indexed counterpart of merging two `related()` calls by hand.
|
||||
* It composes with `type` / `subtype` / visibility filters and is mutually exclusive
|
||||
* with `from` / `to`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../../src/index.js'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
import { createTestConfig } from '../../helpers/test-factory.js'
|
||||
|
||||
describe('related({ node }) both-direction incident edges (graph-perf #4)', () => {
|
||||
let brain: Brainy
|
||||
let a: string, b: string, c: string, d: string
|
||||
|
||||
beforeEach(async () => {
|
||||
brain = new Brainy(createTestConfig())
|
||||
await brain.init()
|
||||
a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' })
|
||||
b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' })
|
||||
c = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'C' })
|
||||
d = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'D' })
|
||||
// a's incident edges: a→b (out), c→a (in), a→d (out, ReportsTo), c→a internal (in, hidden)
|
||||
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, subtype: 'colleague' })
|
||||
await brain.relate({ from: c, to: a, type: VerbType.RelatedTo, subtype: 'colleague' })
|
||||
await brain.relate({ from: a, to: d, type: VerbType.ReportsTo, subtype: 'direct' })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('returns edges incident in BOTH directions (source OR target)', async () => {
|
||||
const edges = await brain.related({ node: a })
|
||||
expect(edges).toHaveLength(3)
|
||||
// Both out-edges (a is `from`) and the in-edge (a is `to`) are present.
|
||||
const pairs = edges.map((e) => `${e.from}->${e.to}`).sort()
|
||||
expect(pairs).toEqual([`${a}->${b}`, `${a}->${d}`, `${c}->${a}`].sort())
|
||||
})
|
||||
|
||||
it('matches the union of related({from}) + related({to})', async () => {
|
||||
const out = await brain.related({ from: a })
|
||||
const inc = await brain.related({ to: a })
|
||||
const expected = new Set([...out, ...inc].map((e) => e.id))
|
||||
const node = await brain.related({ node: a })
|
||||
expect(new Set(node.map((e) => e.id))).toEqual(expected)
|
||||
})
|
||||
|
||||
it('dedupes a self-loop (an edge where the node is both source and target)', async () => {
|
||||
await brain.relate({ from: a, to: a, type: VerbType.RelatedTo, subtype: 'self' })
|
||||
const edges = await brain.related({ node: a })
|
||||
const selfLoops = edges.filter((e) => e.from === a && e.to === a)
|
||||
expect(selfLoops).toHaveLength(1) // appears once, not twice
|
||||
})
|
||||
|
||||
it('composes with a type filter (both directions)', async () => {
|
||||
const related = await brain.related({ node: a, type: VerbType.RelatedTo })
|
||||
// a→b and c→a are RelatedTo; a→d is ReportsTo (excluded).
|
||||
expect(related.map((e) => `${e.from}->${e.to}`).sort()).toEqual(
|
||||
[`${a}->${b}`, `${c}->${a}`].sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('excludes internal edges by default, includes them with includeInternal', async () => {
|
||||
const e = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'E' })
|
||||
await brain.relate({ from: e, to: a, type: VerbType.RelatedTo, subtype: 'colleague', visibility: 'internal' })
|
||||
|
||||
const visible = await brain.related({ node: a })
|
||||
expect(visible.some((r) => r.from === e)).toBe(false)
|
||||
|
||||
const all = await brain.related({ node: a, includeInternal: true })
|
||||
const internalEdge = all.find((r) => r.from === e)
|
||||
expect(internalEdge?.visibility).toBe('internal')
|
||||
})
|
||||
|
||||
it("throws when 'node' is combined with 'from' or 'to'", async () => {
|
||||
await expect(brain.related({ node: a, from: b })).rejects.toThrow(/mutually exclusive/)
|
||||
await expect(brain.related({ node: a, to: b })).rejects.toThrow(/mutually exclusive/)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue