Repository: dominikbraun/graph
Pull request #51 (2022-10-09 16:31:01)

allow vertices to have weights and attributes
This adds the possibility for vertices to have weights and attributes - just like edges.
It solves #32 and is based on the idea from the issue.
Graphs store the vertices' properties in a map based on the hash value.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7313d1..8ef3ccc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 ## [0.13.0] - 2022-10-15
 
 ### Added
+* Added the `VertexProperties` type for storing vertex-related properties.
+* Added the `VertexWithProperties` method for retrieving a vertex and its properties.
+* Added the `VertexWeight` functional option that can be used for `AddVertex`.
+* Added the `VertexAttribute` functional option that can be used for `AddVertex`.
+* Added support for rendering vertices with attributes using `draw.DOT`.
+
+### Changed
+* Changed `AddVertex` to accept functional options.
 
 ### Fixed
 * Fixed the behavior of `ShortestPath` when the target vertex is not reachable from one of the visited vertices.
diff --git a/README.md b/README.md
index 46d0afe..18ab57f 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ A library for creating generic graph data structures and modifying, analyzing, a
 * Algorithms for finding paths or components, such as shortest paths or strongly connected components.
 * Algorithms for transformations and representations, such as transitive reduction or topological order.
 * Algorithms for non-recursive graph traversal, such as DFS or BFS.
-* Edges with optional metadata, such as weights or custom attributes.
+* Vertices and edges with optional metadata, such as weights or custom attributes.
 * Visualization of graphs using the DOT language and Graphviz.
 * Extensive tests with ~90% coverage, and zero dependencies.
 
@@ -255,6 +255,19 @@ _ = g.AddEdge(1, 2, graph.EdgeAttribute("color", "red"))
 To get an overview of all supported attributes, take a look at the
 [DOT documentation](https://graphviz.org/doc/info/attrs.html).
 
+## Setting vertex attributes
+
+Vertices may have one or more attributes which can be used to store metadata. Attributes will be
+taken into account when [visualizing a graph](#visualize-a-graph-using-graphviz). For example, this
+vertex will be rendered in red color:
+
+```go
+_ = g.AddVertex(1, graph.VertexAttribute("style", "filled"), graph.VertexAttribute("fillcolor", "red"))
+```
+
+To get an overview of all supported attributes, take a look at the
+[DOT documentation](https://graphviz.org/doc/info/attrs.html).
+
 # Concepts
 
 ## Hashes
diff --git a/directed.go b/directed.go
index d2831ce..c737274 100644
--- a/directed.go
+++ b/directed.go
@@ -6,22 +6,24 @@ import (
 )
 
 type directed[K comparable, T any] struct {
-	hash     Hash[K, T]
-	traits   *Traits
-	vertices map[K]T
-	edges    map[K]map[K]Edge[T]
-	outEdges map[K]map[K]Edge[T]
-	inEdges  map[K]map[K]Edge[T]
+	hash             Hash[K, T]
+	traits           *Traits
+	vertices         map[K]T
+	vertexProperties map[K]*VertexProperties
+	edges            map[K]map[K]Edge[T]
+	outEdges         map[K]map[K]Edge[T]
+	inEdges          map[K]map[K]Edge[T]
 }
 
 func newDirected[K comparable, T any](hash Hash[K, T], traits *Traits) *directed[K, T] {
 	return &directed[K, T]{
-		hash:     hash,
-		traits:   traits,
-		vertices: make(map[K]T),
-		edges:    make(map[K]map[K]Edge[T]),
-		outEdges: make(map[K]map[K]Edge[T]),
-		inEdges:  make(map[K]map[K]Edge[T]),
+		hash:             hash,
+		traits:           traits,
+		vertices:         make(map[K]T),
+		vertexProperties: make(map[K]*VertexProperties),
+		edges:            make(map[K]map[K]Edge[T]),
+		outEdges:         make(map[K]map[K]Edge[T]),
+		inEdges:          make(map[K]map[K]Edge[T]),
 	}
 }
 
@@ -29,9 +31,17 @@ func (d *directed[K, T]) Traits() *Traits {
 	return d.traits
 }
 
-func (d *directed[K, T]) AddVertex(value T) error {
+func (d *directed[K, T]) AddVertex(value T, options ...func(*VertexProperties)) error {
 	hash := d.hash(value)
 	d.vertices[hash] = value
+	d.vertexProperties[hash] = &VertexProperties{
+		Weight:     0,
+		Attributes: make(map[string]string),
+	}
+
+	for _, option := range options {
+		option(d.vertexProperties[hash])
+	}
 
 	return nil
 }
@@ -45,6 +55,20 @@ func (d *directed[K, T]) Vertex(hash K) (T, error) {
 	return vertex, nil
 }
 
+func (d *directed[K, T]) VertexWithProperties(hash K) (T, VertexProperties, error) {
+	vertex, err := d.Vertex(hash)
+	if err != nil {
+		return vertex, VertexProperties{}, err
+	}
+
+	properties, ok := d.vertexProperties[hash]
+	if !ok {
+		return vertex, *properties, fmt.Errorf("vertex with hash %v doesn't exist", hash)
+	}
+
+	return vertex, *properties, nil
+}
+
 func (d *directed[K, T]) AddEdge(sourceHash, targetHash K, options ...func(*EdgeProperties)) error {
 	source, ok := d.vertices[sourceHash]
 	if !ok {
@@ -171,18 +195,24 @@ func (d *directed[K, T]) Clone() (Graph[K, T], error) {
 	}
 
 	vertices := make(map[K]T)
+	vertexProperties := make(map[K]*VertexProperties)
 
 	for hash, vertex := range d.vertices {
 		vertices[hash] = vertex
+		vertexProperties[hash] = &VertexProperties{
+			Weight:     d.vertexProperties[hash].Weight,
+			Attributes: d.vertexProperties[hash].Attributes,
+		}
 	}
 
 	return &directed[K, T]{
-		hash:     d.hash,
-		traits:   traits,
-		vertices: vertices,
-		edges:    cloneEdges(d.edges),
-		outEdges: cloneEdges(d.outEdges),
-		inEdges:  cloneEdges(d.inEdges),
+		hash:             d.hash,
+		traits:           traits,
+		vertices:         vertices,
+		vertexProperties: vertexProperties,
+		edges:            cloneEdges(d.edges),
+		outEdges:         cloneEdges(d.outEdges),
+		inEdges:          cloneEdges(d.inEdges),
 	}, nil
 }
 
diff --git a/draw/draw.go b/draw/draw.go
index 182e97a..56f90f7 100644
--- a/draw/draw.go
+++ b/draw/draw.go
@@ -10,9 +10,10 @@ import (
 	"github.com/dominikbraun/graph"
 )
 
+// ToDo: This template should be simplified and split into multiple templates.
 const dotTemplate = `strict {{.GraphType}} {
 {{range $s := .Statements}}
-	"{{.Source}}" {{if .Target}}{{$.EdgeOperator}} "{{.Target}}" [ {{range $k, $v := .Attributes}}{{$k}}="{{$v}}", {{end}} weight={{.Weight}} ]{{end}};
+	"{{.Source}}" {{if .Target}}{{$.EdgeOperator}} "{{.Target}}" [ {{range $k, $v := .EdgeAttributes}}{{$k}}="{{$v}}", {{end}} weight={{.EdgeWeight}} ]{{else}}[ {{range $k, $v := .SourceAttributes}}{{$k}}="{{$v}}", {{end}} weight={{.SourceWeight}} ]{{end}};
 {{end}}
 }
 `
@@ -24,10 +25,12 @@ type description struct {
 }
 
 type statement struct {
-	Source     interface{}
-	Target     interface{}
-	Weight     int
-	Attributes map[string]string
+	Source           interface{}
+	Target           interface{}
+	SourceWeight     int
+	SourceAttributes map[string]string
+	EdgeWeight       int
+	EdgeAttributes   map[string]string
 }
 
 // DOT renders the given graph structure in DOT language into an io.Writer, for example a file. The
@@ -39,13 +42,13 @@ type statement struct {
 //
 //	g.AddVertex(1)
 //	g.AddVertex(2)
-//	g.AddVertex(3)
+//	g.AddVertex(3, graph.VertexAttribute("style", "filled"), graph.VertexAttribute("fillcolor", "red"))
 //
-//	_ = g.Edge(1, 2)
-//	_ = g.Edge(1, 3)
+//	_ = g.AddEdge(1, 2, graph.EdgeWeight(10), graph.EdgeAttribute("color", "red"))
+//	_ = g.AddEdge(1, 3)
 //
 //	file, _ := os.Create("./my-graph.gv")
-//	_ = graph.Draw(g, file)
+//	_ = draw.DOT(g, file)
 //
 // To generate an SVG from the created file using Graphviz, use a command such as the following:
 //
@@ -82,20 +85,24 @@ func generateDOT[K comparable, T any](g graph.Graph[K, T]) (description, error)
 	}
 
 	for vertex, adjacencies := range adjacencyMap {
-		if len(adjacencies) == 0 {
-			stmt := statement{
-				Source: vertex,
-			}
-			desc.Statements = append(desc.Statements, stmt)
-			continue
+		_, sourceProperties, err := g.VertexWithProperties(vertex)
+		if err != nil {
+			return desc, err
+		}
+
+		stmt := statement{
+			Source:           vertex,
+			SourceWeight:     sourceProperties.Weight,
+			SourceAttributes: sourceProperties.Attributes,
 		}
+		desc.Statements = append(desc.Statements, stmt)
 
 		for adjacency, edge := range adjacencies {
 			stmt := statement{
-				Source:     vertex,
-				Target:     adjacency,
-				Weight:     edge.Properties.Weight,
-				Attributes: edge.Properties.Attributes,
+				Source:         vertex,
+				Target:         adjacency,
+				EdgeWeight:     edge.Properties.Weight,
+				EdgeAttributes: edge.Properties.Attributes,
 			}
 			desc.Statements = append(desc.Statements, stmt)
 		}
diff --git a/graph.go b/graph.go
index a871901..66e5a34 100644
--- a/graph.go
+++ b/graph.go
@@ -16,11 +16,21 @@ type Graph[K comparable, T any] interface {
 	// Whether AddVertex is idempotent depends on the underlying vertex store implementation. By
 	// default, when using the in-memory store, an existing vertex will be overwritten, whereas
 	// other stores might return an error.
-	AddVertex(value T) error
+	//
+	// AddVertex accepts a variety of functional options to set further edge details such as the
+	// weight or an attribute:
+	//
+	//	_ = graph.AddVertex("A", "B", graph.VertexWeight(4), graph.VertexAttribute("label", "my-label"))
+	//
+	AddVertex(value T, options ...func(*VertexProperties)) error
 
 	// Vertex returns the vertex with the given hash or an error if the vertex doesn't exist.
 	Vertex(hash K) (T, error)
 
+	// VertexWithProperties returns the vertex with the given hash as well as its properties or an
+	// error if the vertex doesn't exist.
+	VertexWithProperties(hash K) (T, VertexProperties, error)
+
 	// AddEdge creates an edge between the source and the target vertex. If the Directed option has
 	// been called on the graph, this is a directed edge. Returns an error if either vertex doesn't
 	// exist or the edge already exists.
@@ -28,7 +38,7 @@ type Graph[K comparable, T any] interface {
 	// AddEdge accepts a variety of functional options to set further edge details such as the
 	// weight or an attribute:
 	//
-	//	_ = graph.Edge("A", "B", graph.EdgeWeight(4), graph.EdgeAttribute("label", "my-label"))
+	//	_ = graph.AddEdge("A", "B", graph.EdgeWeight(4), graph.EdgeAttribute("label", "my-label"))
 	//
 	AddEdge(sourceHash, targetHash K, options ...func(*EdgeProperties)) error
 
@@ -90,7 +100,7 @@ type Edge[T any] struct {
 // EdgeProperties represents a set of properties that each edge possesses. They can be set when
 // adding a new edge using the functional options provided by this library:
 //
-//	g.Edge("A", "B", graph.EdgeWeight(2), graph.EdgeAttribute("color", "red"))
+//	g.AddEdge("A", "B", graph.EdgeWeight(2), graph.EdgeAttribute("color", "red"))
 //
 // The example above will create an edge with weight 2 and a "color" attribute with value "red".
 type EdgeProperties struct {
@@ -187,3 +197,30 @@ func EdgeAttribute(key, value string) func(*EdgeProperties) {
 		e.Attributes[key] = value
 	}
 }
+
+// VertexProperties represents a set of properties that each vertex possesses. They can be set when
+// adding a new vertex using the functional options provided by this library:
+//
+//	g.AddVertex("A", "B", graph.VertexWeight(2), graph.VertexAttribute("color", "red"))
+//
+// The example above will create an vertex with weight 2 and a "color" attribute with value "red".
+type VertexProperties struct {
+	Attributes map[string]string
+	Weight     int
+}
+
+// VertexWeight returns a function that sets the weight of an vertex to the given weight. This is a
+// functional option for the Vertex and AddVertex methods.
+func VertexWeight(weight int) func(*VertexProperties) {
+	return func(e *VertexProperties) {
+		e.Weight = weight
+	}
+}
+
+// VertexAttribute returns a function that adds the given key-value pair to the attributes of an
+// vertex. This is a functional option for the Vertex and AddVertex methods.
+func VertexAttribute(key, value string) func(*VertexProperties) {
+	return func(e *VertexProperties) {
+		e.Attributes[key] = value
+	}
+}
diff --git a/undirected.go b/undirected.go
index 6441c7d..d7c3e82 100644
--- a/undirected.go
+++ b/undirected.go
@@ -6,20 +6,22 @@ import (
 )
 
 type undirected[K comparable, T any] struct {
-	hash     Hash[K, T]
-	traits   *Traits
-	vertices map[K]T
-	outEdges map[K]map[K]Edge[T]
-	inEdges  map[K]map[K]Edge[T]
+	hash             Hash[K, T]
+	traits           *Traits
+	vertices         map[K]T
+	vertexProperties map[K]*VertexProperties
+	outEdges         map[K]map[K]Edge[T]
+	inEdges          map[K]map[K]Edge[T]
 }
 
 func newUndirected[K comparable, T any](hash Hash[K, T], traits *Traits) *undirected[K, T] {
 	return &undirected[K, T]{
-		hash:     hash,
-		traits:   traits,
-		vertices: make(map[K]T),
-		outEdges: make(map[K]map[K]Edge[T]),
-		inEdges:  make(map[K]map[K]Edge[T]),
+		hash:             hash,
+		traits:           traits,
+		vertices:         make(map[K]T),
+		vertexProperties: make(map[K]*VertexProperties),
+		outEdges:         make(map[K]map[K]Edge[T]),
+		inEdges:          make(map[K]map[K]Edge[T]),
 	}
 }
 
@@ -27,9 +29,17 @@ func (u *undirected[K, T]) Traits() *Traits {
 	return u.traits
 }
 
-func (u *undirected[K, T]) AddVertex(value T) error {
+func (u *undirected[K, T]) AddVertex(value T, options ...func(*VertexProperties)) error {
 	hash := u.hash(value)
 	u.vertices[hash] = value
+	u.vertexProperties[hash] = &VertexProperties{
+		Weight:     0,
+		Attributes: make(map[string]string),
+	}
+
+	for _, option := range options {
+		option(u.vertexProperties[hash])
+	}
 
 	return nil
 }
@@ -43,6 +53,20 @@ func (u *undirected[K, T]) Vertex(hash K) (T, error) {
 	return vertex, nil
 }
 
+func (u *undirected[K, T]) VertexWithProperties(hash K) (T, VertexProperties, error) {
+	vertex, err := u.Vertex(hash)
+	if err != nil {
+		return vertex, VertexProperties{}, err
+	}
+
+	properties, ok := u.vertexProperties[hash]
+	if !ok {
+		return vertex, *properties, fmt.Errorf("vertex with hash %v doesn't exist", hash)
+	}
+
+	return vertex, *properties, nil
+}
+
 func (u *undirected[K, T]) AddEdge(sourceHash, targetHash K, options ...func(*EdgeProperties)) error {
 	source, ok := u.vertices[sourceHash]
 	if !ok {
@@ -156,17 +180,23 @@ func (u *undirected[K, T]) Clone() (Graph[K, T], error) {
 	}
 
 	vertices := make(map[K]T)
+	vertexProperties := make(map[K]*VertexProperties)
 
 	for hash, vertex := range u.vertices {
 		vertices[hash] = vertex
+		vertexProperties[hash] = &VertexProperties{
+			Weight:     u.vertexProperties[hash].Weight,
+			Attributes: u.vertexProperties[hash].Attributes,
+		}
 	}
 
 	return &undirected[K, T]{
-		hash:     u.hash,
-		traits:   traits,
-		vertices: vertices,
-		outEdges: cloneEdges(u.outEdges),
-		inEdges:  cloneEdges(u.inEdges),
+		hash:             u.hash,
+		traits:           traits,
+		vertices:         vertices,
+		vertexProperties: vertexProperties,
+		outEdges:         cloneEdges(u.outEdges),
+		inEdges:          cloneEdges(u.inEdges),
 	}, nil
 }
 


Test patch:
diff --git a/directed_test.go b/directed_test.go
index a5b6bbf..5168f1f 100644
--- a/directed_test.go
+++ b/directed_test.go
@@ -31,12 +31,22 @@ func TestDirected_Traits(t *testing.T) {
 
 func TestDirected_AddVertex(t *testing.T) {
 	tests := map[string]struct {
-		vertices         []int
-		expectedVertices []int
+		vertices           []int
+		properties         *VertexProperties
+		expectedVertices   []int
+		expectedProperties *VertexProperties
 	}{
 		"graph with 3 vertices": {
-			vertices:         []int{1, 2, 3},
+			vertices: []int{1, 2, 3},
+			properties: &VertexProperties{
+				Attributes: map[string]string{"color": "red"},
+				Weight:     10,
+			},
 			expectedVertices: []int{1, 2, 3},
+			expectedProperties: &VertexProperties{
+				Attributes: map[string]string{"color": "red"},
+				Weight:     10,
+			},
 		},
 		"graph with duplicated vertex": {
 			vertices:         []int{1, 2, 2},
@@ -48,14 +58,48 @@ func TestDirected_AddVertex(t *testing.T) {
 		graph := newDirected(IntHash, &Traits{})
 
 		for _, vertex := range test.vertices {
-			_ = graph.AddVertex(vertex)
+			if test.properties == nil {
+				_ = graph.AddVertex(vertex)
+				continue
+			}
+			// If there are vertex attributes, iterate over them and call VertexAttribute for each
+			// entry. A vertex should only have one attribute so that AddVertex is invoked once.
+			for key, value := range test.properties.Attributes {
+				_ = graph.AddVertex(vertex, VertexWeight(test.properties.Weight), VertexAttribute(key, value))
+			}
 		}
 
 		for _, vertex := range test.vertices {
+			if len(graph.vertices) != len(test.expectedVertices) {
+				t.Errorf("%s: vertex count doesn't match: expected %v, got %v", name, len(test.expectedVertices), len(graph.vertices))
+			}
+
 			hash := graph.hash(vertex)
 			if _, ok := graph.vertices[hash]; !ok {
 				t.Errorf("%s: vertex %v not found in graph: %v", name, vertex, graph.vertices)
 			}
+
+			if test.properties == nil {
+				continue
+			}
+
+			if graph.vertexProperties[hash].Weight != test.expectedProperties.Weight {
+				t.Errorf("%s: edge weights don't match: expected weight %v, got %v", name, test.expectedProperties.Weight, graph.vertexProperties[hash].Weight)
+			}
+
+			if len(graph.vertexProperties[hash].Attributes) != len(test.expectedProperties.Attributes) {
+				t.Fatalf("%s: attributes lengths don't match: expcted %v, got %v", name, len(test.expectedProperties.Attributes), len(graph.vertexProperties[hash].Attributes))
+			}
+
+			for expectedKey, expectedValue := range test.expectedProperties.Attributes {
+				value, ok := graph.vertexProperties[hash].Attributes[expectedKey]
+				if !ok {
+					t.Errorf("%s: attribute keys don't match: expected key %v not found", name, expectedKey)
+				}
+				if value != expectedValue {
+					t.Errorf("%s: attribute values don't match: expected value %v for key %v, got %v", name, expectedValue, expectedKey, value)
+				}
+			}
 		}
 	}
 }
@@ -480,7 +524,7 @@ func TestDirected_Clone(t *testing.T) {
 		graph := New(IntHash, Directed())
 
 		for _, vertex := range test.vertices {
-			_ = graph.AddVertex(vertex)
+			_ = graph.AddVertex(vertex, VertexWeight(vertex), VertexAttribute("color", "red"))
 		}
 
 		for _, edge := range test.edges {
@@ -517,6 +561,12 @@ func TestDirected_Clone(t *testing.T) {
 			if actualVertex != expectedVertex {
 				t.Errorf("%s: vertex expectancy doesn't match: expected %v, got %v", name, expectedVertex, actualVertex)
 			}
+			if actual.vertexProperties[expectedHash].Weight != expected.vertexProperties[expectedHash].Weight {
+				t.Errorf("%s: vertex properties expectancy doesn't match: expected %v, got %v", name, expected.vertexProperties[expectedHash], actual.vertexProperties[expectedHash])
+			}
+			if actual.vertexProperties[expectedHash].Attributes["color"] != expected.vertexProperties[expectedHash].Attributes["color"] {
+				t.Errorf("%s: vertex properties expectancy doesn't match: expected %v, got %v", name, expected.vertexProperties[expectedHash], actual.vertexProperties[expectedHash])
+			}
 		}
 
 		if len(actual.edges) != len(expected.edges) {
diff --git a/draw/draw_test.go b/draw/draw_test.go
index 0bd14d9..d1e7437 100644
--- a/draw/draw_test.go
+++ b/draw/draw_test.go
@@ -10,10 +10,11 @@ import (
 
 func TestGenerateDOT(t *testing.T) {
 	tests := map[string]struct {
-		graph    graph.Graph[string, string]
-		vertices []string
-		edges    []graph.Edge[string]
-		expected description
+		graph            graph.Graph[string, string]
+		vertices         []string
+		vertexProperties map[string]graph.VertexProperties
+		edges            []graph.Edge[string]
+		expected         description
 	}{
 		"3-vertex directed graph": {
 			graph:    graph.New(graph.StringHash, graph.Directed()),
@@ -28,6 +29,7 @@ func TestGenerateDOT(t *testing.T) {
 				Statements: []statement{
 					{Source: "1", Target: "2"},
 					{Source: "1", Target: "3"},
+					{Source: "1"},
 					{Source: "2"},
 					{Source: "3"},
 				},
@@ -54,24 +56,80 @@ func TestGenerateDOT(t *testing.T) {
 				EdgeOperator: "->",
 				Statements: []statement{
 					{
-						Source: "1",
-						Target: "2",
-						Weight: 10,
-						Attributes: map[string]string{
+						Source:     "1",
+						Target:     "2",
+						EdgeWeight: 10,
+						EdgeAttributes: map[string]string{
 							"color": "red",
 						},
 					},
 					{Source: "1", Target: "3"},
+					{Source: "1"},
 					{Source: "2"},
 					{Source: "3"},
 				},
 			},
 		},
+		"vertices with attributes": {
+			graph:    graph.New(graph.StringHash, graph.Directed(), graph.Weighted()),
+			vertices: []string{"1", "2"},
+			vertexProperties: map[string]graph.VertexProperties{
+				"1": {
+					Attributes: map[string]string{
+						"color": "red",
+					},
+					Weight: 10,
+				},
+				"2": {
+					Attributes: map[string]string{
+						"color": "blue",
+					},
+					Weight: 20,
+				},
+			},
+			edges: []graph.Edge[string]{
+				{Source: "1", Target: "2"},
+			},
+			expected: description{
+				GraphType:    "digraph",
+				EdgeOperator: "->",
+				Statements: []statement{
+					{
+						Source: "1",
+						SourceAttributes: map[string]string{
+							"color": "red",
+						},
+						SourceWeight: 10,
+					},
+					{
+						Source: "2",
+						SourceAttributes: map[string]string{
+							"color": "blue",
+						},
+						SourceWeight: 20,
+					},
+					{
+						Source: "1",
+						Target: "2",
+					},
+				},
+			},
+		},
 	}
 
 	for name, test := range tests {
 		for _, vertex := range test.vertices {
-			_ = test.graph.AddVertex(vertex)
+			if test.vertexProperties == nil {
+				_ = test.graph.AddVertex(vertex)
+				continue
+			}
+			// If there are vertex attributes, iterate over them and call VertexAttribute for each
+			// entry. A vertex should only have one attribute so that AddVertex is invoked once.
+			for key, value := range test.vertexProperties[vertex].Attributes {
+				weight := test.vertexProperties[vertex].Weight
+				// ToDo: Clarify how multiple functional options and attributes can be tested.
+				_ = test.graph.AddVertex(vertex, graph.VertexWeight(weight), graph.VertexAttribute(key, value))
+			}
 		}
 
 		for _, edge := range test.edges {
@@ -117,6 +175,7 @@ func TestRenderDOT(t *testing.T) {
 				Statements: []statement{
 					{Source: 1, Target: 2},
 					{Source: 1, Target: 3},
+					{Source: 1},
 					{Source: 2},
 					{Source: 3},
 				},
@@ -124,8 +183,9 @@ func TestRenderDOT(t *testing.T) {
 			expected: `strict digraph {
 				"1" -> "2" [ weight=0 ];
 				"1" -> "3" [ weight=0 ];
-				"2" ;
-				"3" ;
+				"1" [ weight=0 ];
+				"2" [ weight=0 ];
+				"3" [ weight=0 ];
 			}`,
 		},
 		"custom edge attributes": {
@@ -136,17 +196,18 @@ func TestRenderDOT(t *testing.T) {
 					{
 						Source: 1,
 						Target: 2,
-						Attributes: map[string]string{
+						EdgeAttributes: map[string]string{
 							"color": "red",
 						},
 					},
 					{
 						Source: 1,
 						Target: 3,
-						Attributes: map[string]string{
+						EdgeAttributes: map[string]string{
 							"color": "blue",
 						},
 					},
+					{Source: 1},
 					{Source: 2},
 					{Source: 3},
 				},
@@ -154,8 +215,9 @@ func TestRenderDOT(t *testing.T) {
 			expected: `strict digraph {
 				"1" -> "2" [ color="red", weight=0 ];
 				"1" -> "3" [ color="blue", weight=0 ];
-				"2" ;
-				"3" ;
+				"1" [ weight=0 ];
+				"2" [ weight=0 ];
+				"3" [ weight=0 ];
 			}`,
 		},
 		"vertices containing special characters": {
@@ -174,6 +236,37 @@ func TestRenderDOT(t *testing.T) {
 				".config" -> "my file.txt" [ weight=0 ];
 			}`,
 		},
+		"vertices with attributes": {
+			description: description{
+				GraphType:    "digraph",
+				EdgeOperator: "->",
+				Statements: []statement{
+					{
+						Source: "1",
+						SourceAttributes: map[string]string{
+							"color": "red",
+						},
+						SourceWeight: 10,
+					},
+					{
+						Source: "2",
+						SourceAttributes: map[string]string{
+							"color": "blue",
+						},
+						SourceWeight: 20,
+					},
+					{
+						Source: "1",
+						Target: "2",
+					},
+				},
+			},
+			expected: `strict digraph {
+				"1" [ color="red", weight=10 ];
+				"2" [ color="blue", weight=20 ];
+				"1" -> "2" [ weight=0 ];
+			}`,
+		},
 	}
 
 	for name, test := range tests {
@@ -216,12 +309,26 @@ func normalizeOutput(output string) string {
 }
 
 func statementsAreEqual(a, b statement) bool {
-	if len(a.Attributes) != len(b.Attributes) {
+	if len(a.EdgeAttributes) != len(b.EdgeAttributes) {
+		return false
+	}
+
+	for aKey, aValue := range a.EdgeAttributes {
+		bValue, ok := b.EdgeAttributes[aKey]
+		if !ok {
+			return false
+		}
+		if aValue != bValue {
+			return false
+		}
+	}
+
+	if len(a.SourceAttributes) != len(b.SourceAttributes) {
 		return false
 	}
 
-	for aKey, aValue := range a.Attributes {
-		bValue, ok := b.Attributes[aKey]
+	for aKey, aValue := range a.SourceAttributes {
+		bValue, ok := b.SourceAttributes[aKey]
 		if !ok {
 			return false
 		}
@@ -232,5 +339,6 @@ func statementsAreEqual(a, b statement) bool {
 
 	return a.Source == b.Source &&
 		a.Target == b.Target &&
-		a.Weight == b.Weight
+		a.EdgeWeight == b.EdgeWeight &&
+		a.SourceWeight == b.SourceWeight
 }
diff --git a/undirected_test.go b/undirected_test.go
index 2e9ebd9..c98d942 100644
--- a/undirected_test.go
+++ b/undirected_test.go
@@ -31,12 +31,22 @@ func TestUndirected_Traits(t *testing.T) {
 
 func TestUndirected_AddVertex(t *testing.T) {
 	tests := map[string]struct {
-		vertices         []int
-		expectedVertices []int
+		vertices           []int
+		properties         *VertexProperties
+		expectedVertices   []int
+		expectedProperties *VertexProperties
 	}{
 		"graph with 3 vertices": {
-			vertices:         []int{1, 2, 3},
+			vertices: []int{1, 2, 3},
+			properties: &VertexProperties{
+				Attributes: map[string]string{"color": "red"},
+				Weight:     10,
+			},
 			expectedVertices: []int{1, 2, 3},
+			expectedProperties: &VertexProperties{
+				Attributes: map[string]string{"color": "red"},
+				Weight:     10,
+			},
 		},
 		"graph with duplicated vertex": {
 			vertices:         []int{1, 2, 2},
@@ -48,14 +58,48 @@ func TestUndirected_AddVertex(t *testing.T) {
 		graph := newUndirected(IntHash, &Traits{})
 
 		for _, vertex := range test.vertices {
-			_ = graph.AddVertex(vertex)
+			if test.properties == nil {
+				_ = graph.AddVertex(vertex)
+				continue
+			}
+			// If there are vertex attributes, iterate over them and call VertexAttribute for each
+			// entry. A vertex should only have one attribute so that AddVertex is invoked once.
+			for key, value := range test.properties.Attributes {
+				_ = graph.AddVertex(vertex, VertexWeight(test.properties.Weight), VertexAttribute(key, value))
+			}
 		}
 
 		for _, vertex := range test.vertices {
+			if len(graph.vertices) != len(test.expectedVertices) {
+				t.Errorf("%s: vertex count doesn't match: expected %v, got %v", name, len(test.expectedVertices), len(graph.vertices))
+			}
+
 			hash := graph.hash(vertex)
 			if _, ok := graph.vertices[hash]; !ok {
 				t.Errorf("%s: vertex %v not found in graph: %v", name, vertex, graph.vertices)
 			}
+
+			if test.properties == nil {
+				continue
+			}
+
+			if graph.vertexProperties[hash].Weight != test.expectedProperties.Weight {
+				t.Errorf("%s: edge weights don't match: expected weight %v, got %v", name, test.expectedProperties.Weight, graph.vertexProperties[hash].Weight)
+			}
+
+			if len(graph.vertexProperties[hash].Attributes) != len(test.expectedProperties.Attributes) {
+				t.Fatalf("%s: attributes lengths don't match: expcted %v, got %v", name, len(test.expectedProperties.Attributes), len(graph.vertexProperties[hash].Attributes))
+			}
+
+			for expectedKey, expectedValue := range test.expectedProperties.Attributes {
+				value, ok := graph.vertexProperties[hash].Attributes[expectedKey]
+				if !ok {
+					t.Errorf("%s: attribute keys don't match: expected key %v not found", name, expectedKey)
+				}
+				if value != expectedValue {
+					t.Errorf("%s: attribute values don't match: expected value %v for key %v, got %v", name, expectedValue, expectedKey, value)
+				}
+			}
 		}
 	}
 }
@@ -444,7 +488,7 @@ func TestUndirected_Clone(t *testing.T) {
 		graph := New(IntHash)
 
 		for _, vertex := range test.vertices {
-			_ = graph.AddVertex(vertex)
+			_ = graph.AddVertex(vertex, VertexWeight(vertex), VertexAttribute("color", "red"))
 		}
 
 		for _, edge := range test.edges {
@@ -481,6 +525,12 @@ func TestUndirected_Clone(t *testing.T) {
 			if actualVertex != expectedVertex {
 				t.Errorf("%s: vertex expectancy doesn't match: expected %v, got %v", name, expectedVertex, actualVertex)
 			}
+			if actual.vertexProperties[expectedHash].Weight != expected.vertexProperties[expectedHash].Weight {
+				t.Errorf("%s: vertex properties expectancy doesn't match: expected %v, got %v", name, expected.vertexProperties[expectedHash], actual.vertexProperties[expectedHash])
+			}
+			if actual.vertexProperties[expectedHash].Attributes["color"] != expected.vertexProperties[expectedHash].Attributes["color"] {
+				t.Errorf("%s: vertex properties expectancy doesn't match: expected %v, got %v", name, expected.vertexProperties[expectedHash], actual.vertexProperties[expectedHash])
+			}
 		}
 
 		if len(actual.inEdges) != len(expected.inEdges) {