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.
86 lines
4 KiB
TypeScript
86 lines
4 KiB
TypeScript
/**
|
|
* @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/)
|
|
})
|
|
})
|