Skip to content

Latest commit

 

History

History
317 lines (243 loc) · 13.8 KB

File metadata and controls

317 lines (243 loc) · 13.8 KB


Attributes and Configuration

Note: To use attribute-based configuration, you only need to install the Neo4j provider package:

dotnet add package Cvoya.Graph.Neo4j

The analyzers package is optional but recommended for extra compile-time validation:

dotnet add package Cvoya.Graph.Analyzers

CVOYA Graph uses attributes to provide declarative configuration for how your domain classes map to graph elements. This approach offers clean, type-safe configuration with support for indexing, custom labeling, and property control.

Node Configuration

NodeAttribute

The [Node] attribute specifies how a class maps to a graph node with custom labeling support.

[Node("Person")]
public record Person : Node
{
    public string Name { get; set; } = string.Empty;
}

Default Behavior: Without the attribute, the class name is used as the node label:

// This will create nodes with label "Employee"
public record Employee : Node
{
}

One label per type: a node type maps to exactly one label, and a relationship type to exactly one type name. This label is the correlation key between the stored node and the .NET type used to materialize it (see Type resolution below).

Uniqueness: the label must be unique across every node type loaded in the process, compared case-insensitively (Person and person are treated as the same label). Two loaded node types that resolve to the same label — whether both declare it explicitly, or one falls back to a class name that matches another's label — are rejected at registration with a GraphException. The same rule applies independently to relationship type names. This mirrors the compile-time analyzers (CG008/CG009), which flag the collision before you run.

Multiple labels on a single typed node are not supported. If you need arbitrary, runtime-defined label sets (for example cross-cutting tags), use DynamicNode, whose Labels collection you manage yourself.

Property Configuration

PropertyAttribute

The [Property] attribute provides fine-grained control over property mapping, including custom names, indexing, and serialization behavior:

[Node("Person")]
public record Person : Node
{
    [Property(Label = "full_name")]
    public string FullName { get; set; } = string.Empty;

    public string Email { get; set; } = string.Empty;

    [Property(Label = "birth_date")]
    public DateTime DateOfBirth { get; set; }

    [Property(Ignore = true)]
    public string TemporaryCalculation { get; set; } = string.Empty;

    // This property uses its own name in the graph
    public int Age { get; set; }
}

Property Configuration Options

Option Description Example
Label Custom property name in graph storage [Property(Label = "full_name")]
Ignore Exclude from graph persistence [Property(Ignore = true)]
IsKey Include the property in the entity's optional domain-key tuple [Property(IsKey = true)]
IsUnique Require this individual property's value to be unique within the mapped label/type [Property(IsUnique = true)]
IsRequired Require a stored non-null value [Property(IsRequired = true)]
IsIndexed Request a provider index for the property [Property(IsIndexed = true)]

Note: Providers decide how to apply requested indexes and constraints for their storage engine.

Optional domain keys

A node or relationship may be keyless. A property named Id is not inferred as a key; only an explicit IsKey = true declaration participates in the domain key.

[Node("Person")]
public record Person : Node
{
    // Keyless: Name is ordinary data and no domain key is declared.
    public string Name { get; init; } = string.Empty;
}

[Node("Customer")]
public record Customer : Node
{
    [Property(IsKey = true)]
    public string Tenant { get; init; } = string.Empty;

    [Property(IsKey = true)]
    public string CustomerNumber { get; init; } = string.Empty;
}

All IsKey properties on one entity form one ordered tuple. The Customer example therefore has one composite key (CustomerNumber, Tenant) (the runtime schema orders mapped property names ordinally), not two individually unique properties. Set IsUnique = true on a component only when that component must also be independently unique.

Keys are scoped to the mapped node label or relationship type in one configured graph store. They must be non-nullable, graph-storable scalar values; collections, complex values, ignored properties, and unsupported property types are rejected at runtime even when the analyzer package is not installed. With the analyzer installed, invalid key declarations and ignored-property schema conflicts are also reported at build time as CG018 (a key on a property type that is unsupported regardless of key configuration keeps its existing property-type diagnostic). IsKey implies IsRequired and IsIndexed, but does not set the explicit IsUnique schema flag.

A domain key is not provider-native graph element identity, is not an implicit endpoint or mutation target, and is not automatically immutable. Key values may change through an operation that can preserve the applicable constraints. An ignored property cannot also request key, unique, indexed, or required behavior.

In v1.0, a simple collection cannot declare either IsKey = true or IsUnique = true, regardless of whether its element type is nullable. Collection equality and uniqueness do not have a portable cross-provider graph contract, so schema registration rejects these declarations before any provider creates a constraint or performs mutation uniqueness preflight. Ordinary non-constrained collections remain supported.

Property Types

Supported property types:

  • Primitive types: int, long, float, double, decimal, bool
  • string
  • DateTime, DateTimeOffset, DateOnly, TimeOnly
  • Guid
  • Uri
  • Enums
  • Collections of simple or complex element types, declared as a one-dimensional array, List<T>, or HashSet<T>, or as an interface they satisfy: IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, ISet<T>, or IReadOnlySet<T>. A set-typed declaration round-trips as a HashSet<T>; every other shape as a List<T> (or an array). Other concrete collection types (for example Queue<T>, SortedSet<T>, ObservableCollection<T>) are not supported and are reported by CG004/CG005.
  • Relationship collections may contain only simple element types; complex values and collections of complex values are supported on nodes only.
  • Spatial types (with provider support): Cvoya.Graph.Point
  • "Complex" (as defined by CVOYA Graph): a user-defined class or struct composed of supported properties. A struct is valid only as a nested complex value; graph entities themselves (INode/IRelationship) must be reference types (CG014).

Native-sized integers (IntPtr/UIntPtr, including the nint/nuint aliases) are not supported anywhere in an entity's property graph: as ordinary properties, key values, nullable values, collection elements, or members of complex property values.

The same root-and-nested rule rejects dictionaries, delegates, and every type declared under System.Threading.Tasks, System.IO, System.Net, System.Reflection, or System.Runtime — including the enums and structs those namespaces declare. Inherited public instance properties participate in validation. Static properties, indexers, and properties marked [Property(Ignore = true)] are not serialized, and the rules above ignore them: they never trigger CG004/CG005, a runtime schema failure, or serializer suppression.

public record Person : Node
{
    public string Name { get; set; }
    public int Age { get; set; }
    public double Height { get; set; }
    public bool IsActive { get; set; }
    public DateTime CreatedAt { get; set; }
    public string[] Tags { get; set; }
    public List<string> Emails { get; set; }
    public HashSet<string> Interests { get; set; }
    public Point Location { get; set; } // Spatial data
}

Relationship Configuration

RelationshipAttribute

The [Relationship] attribute configures how relationship classes map to graph edges by assigning their stored relationship type:

[Relationship("KNOWS")]
public record Knows : Relationship
{
    [Property(Label = "since_date", IsIndexed = true)]
    public DateTime Since { get; set; }
}

Relationship Direction Options

Relationships do not own endpoints or direction. RelationshipDirection is command intent passed to relationship creation, relative to the source/target arguments:

public enum RelationshipDirection
{
    Outgoing, // Stored source -> target (default)
    Incoming  // Stored target -> source
}

Direction Examples

[Relationship("FOLLOWS")]
public record Follows : Relationship;

var follower = graph.Nodes<Person>().Where(person => person.Email == followerEmail);
var followed = graph.Nodes<Person>().Where(person => person.Email == followedEmail);

// Default physical orientation: follower -> followed.
await graph.CreateRelationshipAsync(follower, new Follows(), followed);

// Reverse the physical orientation without adding state to Follows.
await graph.CreateRelationshipAsync(
    follower,
    new Follows(),
    followed,
    RelationshipDirection.Incoming);

Use GraphTraversalDirection.Both on traversal queries when you want to traverse matching stored edges in either physical direction. Inspect IGraphPathSegment.Direction when a query result must report physical orientation relative to its returned start/end nodes.

ComplexPropertyAttribute

Complex CLR properties become first-class value nodes connected by a relationship whose type defaults to the property name. Override that graph name when the domain calls for a different semantic edge:

public record Person : Node
{
    [ComplexProperty(RelationshipType = "LIVES_AT")]
    public Address Home { get; init; } = new();
}

The resulting structure is (:Person)-[:LIVES_AT]->(:Address). The attribute changes the relationship mapping only; the CLR property name and serialized value-node label are unchanged.

Inheritance and Polymorphism

Base Classes

CVOYA Graph requires provider implementors to support, if possible, materializing object instances as the type used during serialization. Consider the following type hierarchy:

[Node("Asset")]
public record Asset : Node
{
    public string Name { get; set; } = string.Empty;
    public decimal Value { get; set; }
}

[Node("Vehicle")]
public record Vehicle : Asset
{
    public string VIN { get; set; } = string.Empty;
    public int Year { get; set; }
}

[Node("RealEstate")]
public record RealEstate : Asset
{
    public string Address { get; set; } = string.Empty;
    public double SquareFeet { get; set; }
}

We can store a RealEstate instance even though the variable that holds it is of type Asset.

Asset realEstate = new RealEstate
{
    Name = "Office",
    Value = 1_250_000m,
    Address = "123 Main St",
    SquareFeet = 2_400
};
await graph.CreateNodeAsync(realEstate);

The underlying provider serializes the instance of the actual instance, which in this case is RealEstate. It stores enough metadata to know the type to be used when retrieving the node from the graph. If a different process, with a completely different type hierarchy is used to retrieve the graph node, the node's label is used in an attempt to identify the right type. In the above case, a type which has been annotated with the attribute Node("RealEstate") will be discovered and the deserialization will be attempted. If the type isn't compatible or a type annotated with that specific label isn't discovered, that is considered a runtime exception.

Type resolution

When materializing a stored node, the provider resolves the .NET type in this order:

  1. Stored metadata (exact). Each entity is persisted with its concrete .NET type name. If that type is loadable in the reading process and is assignable to the requested type, it is used directly — an exact round-trip.
  2. Label (portable). If the metadata type is not loadable (a different application, or the type was renamed or moved) or is not assignable to the requested type, the node's label is used to find a compatible local type. Because a label maps to exactly one type per process, this is deterministic. The requested type scopes the search, so Nodes<T>() materializes the node as a compatible local type.
  3. Fallback. Otherwise the requested type itself is used. For untyped reads, DynamicNode always succeeds, exposing the raw labels and properties.

This is why the explicit label is a durable contract: the metadata pointer is a fast path that is allowed to miss, and the label recovers the type across processes and across refactors.

Polymorphic queries rely on the class hierarchy rather than on multiple labels. Nodes<Asset>() matches not only :Asset but also :Vehicle and :RealEstate — at query-construction time the hierarchy is expanded to the set of compatible labels (each concrete subtype contributes its own single label). Only subtypes the registry has discovered (their assembly is loaded) participate.

Best Practices

  1. Use Meaningful Names: Choose clear, descriptive names for nodes and relationships
  2. Be Consistent: Establish naming conventions and stick to them
  3. Document Relationships: Use XML comments to document complex relationships
  4. Avoid Over-Attribution: Only add attributes when the default behavior isn't sufficient
  5. Consider Performance: Indexes and constraints can significantly impact performance