primd/fluidgraph

A Doctrine-inspired OGM for Bolt/Cypher Graph Databases

dev-master 2025-06-15 05:17 UTC

This package is auto-updated.

Last update: 2025-06-15 05:17:46 UTC


README

FluidGraph is an Object Graph Manager (OGM) for memgraph (though in principle could also work with Neo4j). It borrows a lot of concepts from Doctrine (a PHP ORM for relational databases), but aims to "rethink" many of those concepts in the graph paradigm.

This project is part of the Primd application stack and is copyright Primd Cooperative, licensed MIT. Primd Cooperative is a worker-owned start-up aiming to revolutionize hiring, learning, and work itself. For more information, or to support our work:

Installation

composer require primd/fluidgraph

Basic Concepts

FluidGraph borrows a bit of a "spiritual ontology" in order to talk about its core concepts. The Entity world is effectively the natural world. These are your concrete models and the things you interact with.

The Element world is the spiritual world. Entities are fastened to an Element. A single Element can be expressed in one or more Entities. These are the underlying graph data and not generally meant to be interacted with (unless you're doing more advanced development).

Both Entities and Elements have different forms, namely Nodes and Edges, i.e. there is a "Node Element" as well as a "Node Entity."

NOTE: FluidGraph is still alpha and is subject to fairly rapid changes, though we'll try not to break documented APIs.

Basic Usage

Instantiating a graph:

use Bolt\Bolt;
use Bolt\connection\StreamSocket;

$graph = new FluidGraph\Graph(
    [
        'scheme'      => 'basic',
        'principal'   => 'memgraph',
        'credentials' => 'password'
    ],
    new Bolt(new StreamSocket())
);

Create a Node class:

use FluidGraph\Node;
use FluidGraph\Entity

class Person extends Node
{
	use Entity\Id\Uuid7;

	public function __construct(
		public ?string $firstName = NULL,
		public ?string $lastName = NULL,
	) {}
}

Traits are used to provide built-in common functionality and usually represent hooks. The example above uses the Uuid7 trait to identify the entity. This will provide a key of id and automatically generate the UUID onCreate.

Create an Edge class:

use FluidGraph\Edge;
use FluidGraph\Entity;

class FriendsWith extends Edge
{
	use Entity\DateCreated;
	use Entity\DateModified;

	public string $description;
}

Add a relationship between people:

use FluidGraph\Node;
use FluidGraph\Like;
use FluidGraph\Entity;

use FluidGraph\Relationship\Many;
use FluidGraph\Relationship\Link;

class Person extends Node
{
	use Entity\Id\Uuid7;

    public protected(set) Many $friends;

	public function __construct(
		public ?string $firstName = NULL,
		public ?string $lastName = NULL,
	) {
		$this->friendships = Many::having(
			$this,
			FriendsWith::class,
            Link::to,
            Like::any,
			[
				Person::class
			]
		);
	}
}

Note: All properties on your entities MUST be publicly readable. They can have protected(set) or private(set), however, note that you CANNOT use property hooks. FluidGraph avoids reflection where possible, but due to how it uses per-property references, hooks are not viable.

Instantiate nodes:

$matt = new Person(firstName: 'Matt');
$jill = new Person(firstName: 'Jill');

Set the relationship between them:

$matt->friendships->set($jill, [
	'description' => 'Best friends forever!'
]);

Attach, merge changes into the queue, and execute it:

$graph->attach($matt)->queue->merge()->run();

Note: There is no need to attach $jill or the FriendsWith edge, as these are cascaded from $matt being attached. Without the relationship, $jill would need to be attached separately to persist.

Find a person:

$matt = $graph->findOne(Person::class, ['firstName' => 'Matt']);

Get their friends:

$friends = $matt->friendships->get(Person::class);

Get the friendships (The actual FriendsWith edges):

$friendships = $matt->friendships->all();

Note: The available methods and return values depend on the relationship type. A ToMany has all() while a ToOne has any() for example.

Working with Entities and Elements

Status

To determine the status of an Entity or Element you can use the status() method which, with no arguments will return a FluidGraph\Status or NULL if somehow an Entity has not been fastened.

$entity_or_element->status()

Status types:

FluidGraph\Status::* Description
fastened The entity or element is bound to its other half, that's it.
inducted The entity or element is ready and waiting to be merged with the graph.
attached The entity or element has been merged with and is attached to the graph
released The entity or element is ready and waiting to be removed from the graph.
detached The entity or element has been merged with and is detached from the graph

You can easily check if the status is of one or more types by passing arguments, in which case status() will return TRUE if the status is any one of the types, FALSE otherwise:

$entity_or_element->status(FluidGraph\Status::attached, ...)

Is

Determine whether or not an Entity or Element is the same as another-ish:

$entity_or_element->is($entity_or_element_or_class);

This returns TRUE in given the following modes and outcomes:

Entities share the same Element

$entity->is($entity);

Entity expresses a given Element

$entity->is($element);

Element is the same as another Element

$element->is($element);

Entity's Element is Labeled as a Class

$entity->is(Person::class);

Element is Labeled as a Class

$element->is(Person::class);

Because Entities can express the same Element without the need for polymorphism you can, for example, have a totally different Node class, such as Author and check whether or not they are the same as a Person:

if ($person->is(Author::class)) {
	// Do things knowing the person is an author
}
Like

Available for Nodes only (as they can have more than one label), is the like() methods which will observe both classes as well as arbitrary labels that may be common. Like will also accept Nodes and Node Elements:

$entity->like(Archivable::archived);

It is strongly recommended that you use constants for labels. How or where you implement them depends on how they are shared across Nodes. In the example above we have a separate Archivable Trait which could be used by various classes.

As

As mentioned before, different Entities can express the same Element. Because Nodes can carry multiple distinct labels, this effectively means that you can transform one Node into another (adding properties and relationships) in a dynamic an horizontal fashion.

A person becomes an author:

$author = $person->as(Author::class, ['penName' => 'Hairy Poster']);

$author->is(Person::class); // TRUE
$person->is(Author::class); // TRUE

NOTE: The Person object is not changed, rather, in this example a new Author object is created and the person/author share the same graph Node, the same Element (in FluidGraph). When working with a Person you only have access to the properties and relationships of a Person. The as() method allows you to gracefully cast the Entity type to access other properties and relationships.

When using as() to create a new instance of an existing Node Element, you need to pass any required arguments for instantiation (required by it's __construct() method) as the second parameter. If no properties are required, this can be excluded. If the Author object is already fastened to the underlying Node Element, then you can simply switch between them.

A subsequent merge/run of the queue will persist the Author Label as well as the related properties to the database.

$graph->queue->merge()->run();

NOTE: At present as() exists on Edges as well, however, edges cannot have more than one Label, so the behavior is not particularly defined. One approach that may be taken is to allow an $edge->as() call to create a new type of Edge between the same source and target Nodes. Another would be to change the type/label entirely.

Working with Relationships

Relationships are collections of Edges. To understand these better, we'll give a bit more definition to our Author class:

<?php

use FluidGraph\Node;
use FluidGraph\Like;
use FluidGraph\Mode;

use FluidGraph\Relationship\Many;
use FluidGraph\Relationship\Link;

    
class Author extends Node
{
	public Many $writings;

	public function __construct(
		public string $penName
	) {
		$this->writings = Many::having(
			$this,
			Wrote::class,
            Link::to,
            Like::any,
			[
				Book::class
			],
            Mode::lazy
		);
	}
}

Relationships have a subject (the Node from which they originate, $this when defined), a kind (the class of their Edge Entities and the label for the Edge Element), and a list of concerns (the classes of their related Node Entities).

In order to add this relationship, we need to define our Edge Entity Wrote. Edges can have their own properties, but in this case we'll keep it simple:

<?php

use FluidGraph\Edge;

class Wrote extends FluidGraph\Edge {}

We can add our corresponding Book node, as well:

class Book extends FluidGraph\Node
{
	public function __construct(
		public string $name,
		public int $pages
	) { }
}

With these options in place, we can now define books on our Author:

$book = new Book(name: 'The Cave of Blunder', pages: 13527);

$author->writings->set($book);

If our Edge had properties, we could define those properties when we set the $book:

$author->writings->set($book, [
    'dateStarted'  => new DateTime('September 17th, 1537'),
    'dateFinished' => new DateTime('June 1st, 1804')
]);

Similar to using as(), when we set an Entity on a given relationship, any arguments required to __construct() the edge would need to be passed. You can update the existing edge using the same method.

To get Nodes out of a relationship you use the corresponding get() method. For example, to get every Book written by an Author:

foreach($author->writings->get(Book::class) as $book) {
    // Do things with Books
}

When you unset() on a relationship the corresponding Edge is Released (and if the relationship is an owning relationship, related Nodes can be Released automatically):

$author->writings->unset($book);

If the Relationship is a ToOne the Entity argument is excluded.

NOTE: It's possible to have multiple Edges to/from the same nodes. While not yet supported, there would be additional methods for releasing individual edges. Which leads us to our next subject...

Getting Edges

Because Edges can have their own properties and/or you may need to remove a specific Edge from a relationship without destroying all relationships between two Nodes you occasionally may need to be able to obtain the edges themselves. In our running example these would be the Wrote object(s).

If you need to get all Edge Entities from a ToMany or FromMany relationships you can use the all() method:

foreach($author->writings->all() as $wrote) {
    // Do things with $wrote
}

NOTE: the return value of all() is a FluidGraph\Result objects, which has additional filtering and other abilities, but generally speaking operates like an array by extending ArrayObject.

To get the Edge Entity from a ToOne or FromOne relationship you can use the any() method which will return either the Edge Entity or NULL if there is no relationship.

if ($edge = $entity->relationship->any()) {
	// Do things with the $edge
}

In order to discover the Edges associated only with specific Nodes, Node Types, and Labels, you can use the of() and for() method. Both take multiple arguments of either Node Entities, Node Elements, or strings and collected the Edges that correspond to Nodes like() the argument. The only distinctions are as follows:

  1. The of() method only returns Edges whose Node corresponds to all arguments.
  2. The for() method returns Edges whose Node corresponds to any arguments.

Accordingly, for a single argument, these methods are effectively equivalent.

Finding Edges for a specific $person:

foreach($person->friendships->of($person) as $friends_with) {
	// Working with an Edge to a specific friend
}

Finding Edges to all friends who are of type Author:

foreach($person->friendships->of(Author::class) as $friends_with) {
    // Working with an Edge to a friend who's like() an Author
}

Note: No validation is done against the relationships concerns, because even though it will allow setting Nodes of the supported types, different Nodes Entities can share a common Node Element.

Finding Edges to all friends who are of type Author and labeled as Archived using of():

foreach($person->friendships->of(Author::class, Archivable::archived) as $friends_with) {
    // Working with an edge to friend who's like() an Author AND like() 'Archived'
}

Using the same argument with for() would result in finding Edges to all friends who are of type Author or labeled as Archived:

foreach($person->friendships->for(Author::class, Archivable::archived) as $friends_with) {
    // Working with an edge to friend who's like() an Author OR like() 'Archived'
}

Generally speaking, of() is likely what you want most of the time.

Advanced Querying

Querying in FluidGraph ultimately uses the Where class to construct composite callbacks which resolve to the final query. An instance of a Where is generated for every Query and an instance of a query is generated whenever the query property on the Graph object is access. A similar query to the one we showed at the beginning would be as follows:

$id = '01976f54-66b3-7744-a593-44259dce9651';

$person = $graph->findOne(Person::class, function($eq) use ($id) {
	return $eq('id', $id);
});

The arguments to the callback are how you request the functions you intend to use and they correspond to the public instance methods available on the Where class. In the example above, we're testing for equality, so we add $eq to request the Where::eq() method as a callback.

This method has pitfalls as it relates to code completion and typing which may be resolved at a later date by doing something like the following:

use FLuidGraph\Where\Eq;

$person = $graph->findOne(Person::class, function(Eq $eq) use ($id) {
	return $eq('id', $id);
});

Furthermore, at present, we're adding methods as we need them. This includes methods that correspond to Memgraph MAGE functions:

return $eq($upper($md5('id')), md5($id));

To build AND and OR conditions you can use the $all and $any callbacks respectively:

$person = $graph->findOne(Person::class, function($all, $eq) {
	return $any(
        $eq('email', 'mattsah@example.com'),
    	$all(
        	$eq('firstName', 'Matthew'),
            $eq('lastName', 'Sahagian')
        ),
    );
})

The above conditions translate to:

WHERE i.email = 'mattsah' OR (i.firstName = 'Matthew' AND i.lastName = 'Sahagian')

Using findOne will automatically use no ordering, limit the results to 2, skip 0and throw an exception if more than one result is returned, hence, you should be ensuring that your queries when using it provide for uniqueness.

Matching Multiple Entities

If you want to find multiple nodes or edges you can simply use the find() method. In addition to the where conditions you can provide $order, $limit and $skip parameters:

$people = $graph->find(
    Person::class,
    function ($eq) {
    	return $eq('firstName', 'Matthew');
    },
    [
        'lastName' => FluidGraph\Direction::asc
    ],
    10,
    10
);

An alternative way of defining this would be as follows:

$people = $graph->query
    ->match(Person::class)
    ->where(
        function ($eq) {
            return $eq('firstName', 'Matthew');
        }    
    )
    ->order([
        'lastName' => FluidGraph\Direction::asc
    ])
    ->limit(10)
    ->skip(10)
	->get()
    ->as(Person::class)
;

Advanced Relationships

Now that we've introduced a bit of querying, let's talk about more advanced relationships. When creating a relationship you can specify a FluidGraph\Relationship\Mode of that relationship. The LAZY and EAGER members of this enum are largely handled for you, and the only critical difference is whether or not the Edges and Nodes of that relationship are loaded immediately after the subject Node is realize or when the relationship is accessed in some way. For finer control and large relationships, you will want to use the MANUAL mode.

This mode requires you to establish the various query parameters and manually load in the Edges/Nodes you're working with:

$friends_named_matt = $person
    ->friendships
    ->match(Person::class)
    ->where(function($scope) {
      return $scope(FluidGraph\Scope::concern, function($eq) {
          return $eq('firstName', 'Matthew');
      });  
    })
    ->load()
    ->get()
;