diff --git a/.changeset/bright-turkeys-study.md b/.changeset/bright-turkeys-study.md new file mode 100644 index 0000000..46d6cfd --- /dev/null +++ b/.changeset/bright-turkeys-study.md @@ -0,0 +1,9 @@ +--- +"@codemix/graph": minor +--- + +Expand traversal parity across value and edge pipelines. + +`ValueTraversal` now supports `dedup()`, `skip()`, `limit()`, `range()`, `count()`, `property()`, and `properties()` so extracted values can keep flowing through the same shaping and projection steps as other traversal results. + +`EdgeTraversal` now supports direct `skip()`, `limit()`, `range()`, `count()`, `map()`, `property()`, `properties()`, and `order()` operations, making it possible to paginate, transform, project, and sort edges without first converting them to vertices or raw values. diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..a246baa --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +staged_files=() +while IFS= read -r -d '' file; do + case "$file" in + *.cjs|*.css|*.cts|*.html|*.js|*.json|*.jsonc|*.jsx|*.md|*.mdx|*.mjs|*.mts|*.scss|*.ts|*.tsx|*.yaml|*.yml) + if [[ -f "$file" ]]; then + staged_files+=("$file") + fi + ;; + esac +done < <(git diff --cached --name-only --diff-filter=ACMR -z) + +if (( ${#staged_files[@]} == 0 )); then + exit 0 +fi + +echo "Formatting staged files with oxfmt" +pnpm exec oxfmt --write -- "${staged_files[@]}" +git add -- "${staged_files[@]}" diff --git a/README.md b/README.md index e2c25cc..6dbff14 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ A fully typed, in-memory graph database. Vertices and edges are strongly typed a pnpm add @codemix/graph ``` +### Development + +Running `pnpm install` in the monorepo configures Git to use the tracked hooks in `.githooks`. + +The pre-commit hook formats staged supported source files with `oxfmt` and restages them automatically. You can re-run the hook setup at any time with `pnpm run setup:hooks`. + ### Quick Start ```typescript diff --git a/package.json b/package.json index 9fe8b83..6a0c0e6 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "scripts": { "build": "pnpm run -r build", "changeset": "changeset", + "prepare": "sh ./scripts/setup-git-hooks.sh", "version-packages": "changeset version", "release": "pnpm build && changeset publish", + "setup:hooks": "sh ./scripts/setup-git-hooks.sh", "typecheck": "pnpm run -r typecheck", "test": "vitest", "test:coverage": "vitest run --coverage", diff --git a/packages/graph/src/Traversals.ts b/packages/graph/src/Traversals.ts index 2fc7846..69e4665 100644 --- a/packages/graph/src/Traversals.ts +++ b/packages/graph/src/Traversals.ts @@ -1017,7 +1017,7 @@ export class VertexTraversal ext [ ...this.steps, new MapElementsStep({ - mapper: (path) => path.value.properties[propertyName], + mapper: (path) => path.value.get(propertyName), }), ], ); @@ -1106,6 +1106,53 @@ export class ValueTraversal ext TSchema, TValue > { + /** + * Deduplicate the values in the traversal. + */ + public dedup() { + return new ValueTraversal(this.graph, [...this.steps, new DedupStep({})]); + } + + /** + * Skip the first n values in the traversal. + */ + public skip(n: number) { + return new ValueTraversal(this.graph, [ + ...this.steps, + new RangeStep({ start: n, end: Infinity }), + ]); + } + + /** + * Take the first n values in the traversal. + * @param n The number of values to take. + */ + public limit(n: number) { + return new ValueTraversal(this.graph, [ + ...this.steps, + new RangeStep({ start: 0, end: n }), + ]); + } + + /** + * Slice the values in the traversal. + * @param start The index to start slicing + * @param end The index to end slicing + */ + public range(start: number, end: number) { + return new ValueTraversal(this.graph, [ + ...this.steps, + new RangeStep({ start, end }), + ]); + } + + /** + * Count the number of values in the traversal. + */ + public count() { + return new ValueTraversal(this.graph, [...this.steps, new CountStep({})]); + } + /** * Map each value in the traversal to a new value. * @param mapper A function that maps the current value to a new value. @@ -1149,6 +1196,93 @@ export class ValueTraversal ext ]); } + /** + * Select a single property of the values in the traversal. + * Supports both graph elements and plain object values. + * @param propertyName The name of the property to select. + */ + public property>( + propertyName: TPropertyName, + ) { + return new ValueTraversal[TPropertyName]>( + this.graph, + [ + ...this.steps, + new MapElementsStep({ + mapper: (value) => { + if (value instanceof Element) { + return value.get(propertyName as never); + } + if (typeof value === "object" && value !== null) { + return value[propertyName as keyof typeof value]; + } + return undefined as GetValueTraversalProperties[TPropertyName]; + }, + }), + ], + ); + } + + /** + * Select specific properties of the values in the traversal. + * Supports both graph elements and plain object values. + * @param propertyNames The names of the properties to select. + */ + public properties(): ValueTraversal>; + public properties< + const TPropertyNames extends readonly (keyof GetValueTraversalProperties)[], + >( + ...propertyNames: TPropertyNames + ): ValueTraversal, TPropertyNames[number]>>; + public properties< + const TPropertyNames extends readonly (keyof GetValueTraversalProperties)[], + >(...propertyNames: TPropertyNames): ValueTraversal { + return new ValueTraversal< + TSchema, + Pick, TPropertyNames[number]> + >(this.graph, [ + ...this.steps, + new MapElementsStep({ + mapper: (value) => { + if (value instanceof Element) { + const storedProps = value[$StoredElement].properties; + if (propertyNames.length === 0) { + return storedProps as Pick< + GetValueTraversalProperties, + TPropertyNames[number] + >; + } + const properties = {} as Pick< + GetValueTraversalProperties, + TPropertyNames[number] + >; + for (const propertyName of propertyNames) { + properties[propertyName] = value.get(propertyName as never); + } + return properties; + } + if (typeof value === "object" && value !== null) { + if (propertyNames.length === 0) { + return value as Pick, TPropertyNames[number]>; + } + const properties = {} as Pick< + GetValueTraversalProperties, + TPropertyNames[number] + >; + for (const propertyName of propertyNames) { + properties[propertyName] = value[propertyName as keyof typeof value] as Pick< + GetValueTraversalProperties, + TPropertyNames[number] + >[typeof propertyName]; + } + return properties; + } + return {} as Pick, TPropertyNames[number]>; + }, + }), + ]); + } + /** * Order the values in the traversal. */ @@ -1900,6 +2034,46 @@ export class EdgeTraversal exten return new EdgeTraversal(this.graph, [...this.steps, new DedupStep({})]); } + /** + * Skip the first n edges in the traversal. + */ + public skip(n: number) { + return new EdgeTraversal(this.graph, [ + ...this.steps, + new RangeStep({ start: n, end: Infinity }), + ]); + } + + /** + * Take the first n edges in the traversal. + * @param n The number of edges to take. + */ + public limit(n: number) { + return new EdgeTraversal(this.graph, [ + ...this.steps, + new RangeStep({ start: 0, end: n }), + ]); + } + + /** + * Slice the edges in the traversal. + * @param start The index to start slicing + * @param end The index to end slicing + */ + public range(start: number, end: number) { + return new EdgeTraversal(this.graph, [ + ...this.steps, + new RangeStep({ start, end }), + ]); + } + + /** + * Count the number of edges in the traversal. + */ + public count() { + return new ValueTraversal(this.graph, [...this.steps, new CountStep({})]); + } + /** * Select the labeled elements in the path * @param pathLabels The labels to select. @@ -1927,4 +2101,100 @@ export class EdgeTraversal exten new ValuesStep({}), ]); } + + /** + * Select a single property of the edges in the traversal. + * @param propertyName The name of the property to select. + */ + public property>( + propertyName: TPropertyName, + ) { + return new ValueTraversal[TPropertyName]>( + this.graph, + [ + ...this.steps, + new MapElementsStep({ + mapper: (path) => path.value.get(propertyName), + }), + ], + ); + } + + /** + * Select specific properties of the edges in the traversal. + * @param propertyNames The names of the properties to select. + */ + public properties(): ValueTraversal>; + public properties< + const TPropertyNames extends readonly (keyof GetTraversalPathProperties)[], + >( + ...propertyNames: TPropertyNames + ): ValueTraversal, TPropertyNames[number]>>; + public properties< + const TPropertyNames extends readonly (keyof GetTraversalPathProperties)[], + >(...propertyNames: TPropertyNames) { + return new ValueTraversal< + TSchema, + Pick, TPropertyNames[number]> + >(this.graph, [ + ...this.steps, + new MapElementsStep({ + mapper: (path) => { + const value = path.value; + const storedProps = value[$StoredElement].properties; + if (propertyNames.length === 0) { + return storedProps; + } + const properties = {} as any; + for (const propertyName of propertyNames) { + properties[propertyName] = storedProps[propertyName as keyof typeof storedProps]; + } + return properties; + }, + }), + ]); + } + + /** + * Map each edge in the traversal to a new value. + * @param mapper A function that maps the path to a new value. + */ + public map(mapper: (path: TPath) => TValue) { + return new ValueTraversal(this.graph, [ + ...this.steps, + new MapElementsStep({ + mapper, + }), + ]); + } + + /** + * Order the edges in the traversal. + */ + public order() { + return new OrderEdgeTraversal(this.graph, [ + ...this.steps, + new OrderStep({ directions: [] }), + ]); + } +} + +export class OrderEdgeTraversal< + const TSchema extends GraphSchema, + const TPath, +> extends EdgeTraversal { + /** + * Order the edges in the traversal by a property. + * @param propertyName The name of the property to order by. + * @param direction The direction to order by. + */ + public by & string>( + propertyName: TPropertyName, + direction: OrderDirection = "asc", + ) { + return new OrderEdgeTraversal( + this.graph, + appendOrderDirection(this.steps, { key: propertyName, direction }), + ); + } } diff --git a/packages/graph/src/test/EdgeTraversal.test.ts b/packages/graph/src/test/EdgeTraversal.test.ts index feb3e63..53a0485 100644 --- a/packages/graph/src/test/EdgeTraversal.test.ts +++ b/packages/graph/src/test/EdgeTraversal.test.ts @@ -1,7 +1,60 @@ +import { StandardSchemaV1 } from "@standard-schema/spec"; import { expect, test } from "vitest"; import { createDemoGraph, DemoSchema } from "../getDemoGraph.js"; import { GraphTraversal } from "../Traversals.js"; import { Edge, Vertex, Graph } from "../Graph.js"; +import { InMemoryGraphStorage } from "../GraphStorage.js"; +import type { GraphSchema } from "../GraphSchema.js"; + +function makeType(_defaultValue: T): StandardSchemaV1 { + return { + "~standard": { + version: 1, + vendor: "codemix", + validate: (value) => { + return { value: value as T }; + }, + }, + }; +} + +const weightedEdgeSchema = { + vertices: { + Person: { + properties: { + name: { type: makeType("") }, + }, + }, + }, + edges: { + knows: { + properties: { + strength: { type: makeType(0) }, + note: { type: makeType("") }, + }, + }, + }, +} as const satisfies GraphSchema; + +type WeightedEdgeSchema = typeof weightedEdgeSchema; + +function createWeightedEdgeGraph() { + const graph = new Graph({ + schema: weightedEdgeSchema, + storage: new InMemoryGraphStorage(), + validateProperties: false, + }); + + const ann = graph.addVertex("Person", { name: "Ann" }); + const ben = graph.addVertex("Person", { name: "Ben" }); + const cam = graph.addVertex("Person", { name: "Cam" }); + + graph.addEdge(ann, "knows", ben, { strength: 2, note: "peer" }); + graph.addEdge(ann, "knows", cam, { strength: 1, note: "mentor" }); + graph.addEdge(ben, "knows", cam, { strength: 3, note: "teammate" }); + + return new GraphTraversal(graph); +} const { graph, alice, bob } = createDemoGraph(); const g = new GraphTraversal(graph); @@ -183,6 +236,13 @@ test("Edge ordering and pagination - outV() converts edges to vertices for pagin expect(vertices.every((v) => v instanceof Vertex)).toBe(true); }); +test("Edge ordering and pagination - limit() truncates direct edge traversals", () => { + const edges = Array.from(g.E().limit(3).values()); + + expect(edges.length).toBeLessThanOrEqual(3); + expect(edges.every((edge) => edge instanceof Edge)).toBe(true); +}); + test("Edge ordering and pagination - Edge to vertex with skip", () => { const allVertices = Array.from(g.E().outV().values()); const skippedVertices = Array.from(g.E().outV().skip(2).values()); @@ -190,6 +250,14 @@ test("Edge ordering and pagination - Edge to vertex with skip", () => { expect(skippedVertices.length).toBe(allVertices.length - 2); }); +test("Edge ordering and pagination - skip() skips leading edges", () => { + const allEdges = Array.from(g.E().values()); + const skippedEdges = Array.from(g.E().skip(2).values()); + + expect(skippedEdges.length).toBe(allEdges.length - 2); + expect(skippedEdges.every((edge) => edge instanceof Edge)).toBe(true); +}); + test("Edge ordering and pagination - Edge to vertex with range", () => { const vertices = Array.from(g.E().outV().range(1, 4).values()); @@ -197,6 +265,13 @@ test("Edge ordering and pagination - Edge to vertex with range", () => { expect(vertices.every((v) => v instanceof Vertex)).toBe(true); }); +test("Edge ordering and pagination - range() slices direct edge traversals", () => { + const edges = Array.from(g.E().range(1, 4).values()); + + expect(edges.length).toBeLessThanOrEqual(3); + expect(edges.every((edge) => edge instanceof Edge)).toBe(true); +}); + test("Edge traversal with select - select() retrieves labeled edges and vertices", () => { const results = Array.from( g.V().as("v1").outE("knows").as("e").inV().as("v2").select("v1", "e", "v2").values(), @@ -252,6 +327,13 @@ test("Edge counting and aggregation - count() counts edges via outV", () => { expect(counts[0]!).toBeGreaterThan(0); }); +test("Edge counting and aggregation - count() counts edges directly", () => { + const edges = Array.from(g.E().values()); + const counts = Array.from(g.E().count()); + + expect(counts).toEqual([edges.length]); +}); + test("Edge counting and aggregation - count() on filtered edges", () => { const knowsCount = Array.from(g.E().hasLabel("knows").outV().count())[0]!; const likesCount = Array.from(g.E().hasLabel("likes").outV().count())[0]!; @@ -262,6 +344,54 @@ test("Edge counting and aggregation - count() on filtered edges", () => { expect(typeof likesCount).toBe("number"); }); +test("Edge value extraction - map() transforms edges into derived values", () => { + const targetNames = Array.from( + g + .V(alice.id) + .outE("knows") + .map((path) => path.value.inV.get("name")) + .order() + .by() + .values(), + ); + + expect(targetNames).toEqual(["Bob", "Charlie"]); +}); + +test("Edge value extraction - property() extracts edge properties", () => { + const weighted = createWeightedEdgeGraph(); + const strengths = Array.from(weighted.E().order().by("strength").property("strength")); + + expect(strengths).toEqual([1, 2, 3]); +}); + +test("Edge value extraction - properties() extracts selected edge properties", () => { + const weighted = createWeightedEdgeGraph(); + const properties = Array.from( + weighted.E().order().by("strength").properties("strength", "note").values(), + ); + + expect(properties).toEqual([ + { strength: 1, note: "mentor" }, + { strength: 2, note: "peer" }, + { strength: 3, note: "teammate" }, + ]); +}); + +test("Edge ordering and pagination - order().by(property) sorts edges", () => { + const weighted = createWeightedEdgeGraph(); + const strengths = Array.from( + weighted + .E() + .order() + .by("strength", "desc") + .map((path) => path.value.get("strength")) + .values(), + ); + + expect(strengths).toEqual([3, 2, 1]); +}); + test("Complex edge patterns - Chain multiple edge traversals", () => { const paths = Array.from( g diff --git a/packages/graph/src/test/ValueTraversal.test.ts b/packages/graph/src/test/ValueTraversal.test.ts index 398ba3e..0abf404 100644 --- a/packages/graph/src/test/ValueTraversal.test.ts +++ b/packages/graph/src/test/ValueTraversal.test.ts @@ -85,6 +85,65 @@ test("ValueTraversal Operations - filter() operation - filter() after values() k expect(names).toEqual(["Fiona", "George"]); }); +test("ValueTraversal Operations - limit() operation - limit() after values() truncates extracted values", () => { + const names = Array.from( + g + .V() + .hasLabel("Person") + .order() + .by("name") + .values() + .limit(3) + .map((vertex) => vertex.get("name")), + ); + + expect(names).toEqual(["Alice", "Bob", "Charlie"]); +}); + +test("ValueTraversal Operations - skip() operation - skip() after values() drops leading values", () => { + const names = Array.from( + g + .V() + .hasLabel("Person") + .order() + .by("name") + .values() + .skip(2) + .map((vertex) => vertex.get("name")), + ); + + expect(names).toEqual(["Charlie", "Dave", "Erin", "Fiona", "George"]); +}); + +test("ValueTraversal Operations - range() operation - range() after values() slices extracted values", () => { + const names = Array.from( + g + .V() + .hasLabel("Person") + .order() + .by("name") + .values() + .range(1, 4) + .map((vertex) => vertex.get("name")), + ); + + expect(names).toEqual(["Bob", "Charlie", "Dave"]); +}); + +test("ValueTraversal Operations - dedup() operation - dedup() after values() removes duplicate extracted values", () => { + const vertices = Array.from(g.union(g.V(alice.id), g.V(alice.id)).values().dedup()); + + expect(vertices).toHaveLength(1); + expect(vertices[0]!.id).toBe(alice.id); +}); + +test("ValueTraversal Operations - count() operation - count() after values() counts extracted values", () => { + const people = Array.from(g.V().hasLabel("Person").values()); + const counts = Array.from(g.V().hasLabel("Person").values().count()); + + expect(counts).toEqual([people.length]); +}); + test("ValueTraversal Operations - order() operation - order().by() sorts primitive values", () => { const names = Array.from( g @@ -135,6 +194,53 @@ test("ValueTraversal Operations - order() operation - order().by(property) sorts expect(keys).toEqual([...keys].sort()); }); +test("ValueTraversal Operations - property() operation - property() after values() extracts element properties", () => { + const names = Array.from(g.V().hasLabel("Person").order().by("name").values().property("name")); + + expect(names).toEqual(["Alice", "Bob", "Charlie", "Dave", "Erin", "Fiona", "George"]); +}); + +test("ValueTraversal Operations - property() operation - property() extracts object properties", () => { + const names = Array.from( + g + .V(alice.id) + .map((path) => ({ + name: path.value.get("name"), + age: path.value.get("age"), + })) + .property("name"), + ); + + expect(names).toEqual(["Alice"]); +}); + +test("ValueTraversal Operations - properties() operation - properties() after values() extracts all element properties", () => { + const results = Array.from(g.V(alice.id).values().properties()); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ name: "Alice", age: 30 }); +}); + +test("ValueTraversal Operations - properties() operation - properties() extracts selected object properties", () => { + const results = Array.from( + g + .V() + .hasLabel("Person") + .limit(2) + .map((path) => ({ + bucket: path.value.get("age") >= 30 ? "older" : "younger", + name: path.value.get("name"), + age: path.value.get("age"), + })) + .properties("bucket", "name"), + ); + + expect(results).toEqual([ + { bucket: "older", name: "Alice" }, + { bucket: "younger", name: "Bob" }, + ]); +}); + test("ValueTraversal Operations - values() extraction - values() on edge traversal", () => { const edges = Array.from(g.E().values()); diff --git a/scripts/setup-git-hooks.sh b/scripts/setup-git-hooks.sh new file mode 100755 index 0000000..0d031de --- /dev/null +++ b/scripts/setup-git-hooks.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh + +set -eu + +if ! command -v git >/dev/null 2>&1; then + exit 0 +fi + +if ! git rev-parse --show-toplevel >/dev/null 2>&1; then + exit 0 +fi + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +current_hooks_path="$(git config --get core.hooksPath || true)" +if [ "$current_hooks_path" = ".githooks" ]; then + exit 0 +fi + +git config core.hooksPath .githooks +echo "Configured Git hooks to use .githooks"