Graph values#

A query that returns a whole vertex, edge or path gets back a Vertex, an Edge or a Path. A query that returns properties gets back ordinary Python values. This page describes the first kind, and how either kind is read.

The server sends

You get

vertex

agensgraph.Vertex

edge

agensgraph.Edge

graphpath

agensgraph.Path

graphid

agensgraph.GraphId

a list of vertices or edges, such as collect(n) or nodes(p)

a list of them

a property, or anything else jsonb

the JSON value: str, int, float, bool, None, list, dict

any other SQL type

what psycopg reads it as

Vertices#

>>> (keanu,) = conn.execute_query(
...     "MATCH (p:person {name: 'Keanu Reeves'}) RETURN p"
... ).records[0]
>>> keanu
Vertex(person[4.1])
>>> keanu.id
GraphId(labid=4, locid=1)
>>> keanu.label
'person'
>>> keanu.properties
{'born': 1964, 'name': 'Keanu Reeves'}
>>> keanu.get("born"), keanu.get("died", "-")
(1964, '-')

id

the vertex’s identity, a GraphId

label

the label it was created with

properties

its property map, a dict

get(key, default=None)

one property

properties_json()

the property map as JSON bytes

label is the vertex’s own label, not its ancestry. A vertex of a label that inherits from another has the child’s name here; Cypher’s labels(n) returns the whole list, own label first.

The order of the keys in properties is the order jsonb stores them in, which is by key length and then by bytes, not the order they were written in.

A property map is decoded when you first read it#

The property map arrives as JSON bytes and stays that way until something reads properties or get(). A query that sorts, counts or passes vertices on without looking inside them never pays for decoding their maps, and what that saves grows with the map: it matters most for a map that holds an embedding.

properties_json() hands the bytes over as they arrived, without decoding them, if nothing has read the map yet.

Edges#

>>> (neo,) = conn.execute_query(
...     "MATCH (:person {name: 'Keanu Reeves'})-[r:acted_in]->(:movie {title: 'The Matrix'})"
...     " RETURN r"
... ).records[0]
>>> neo
Edge(acted_in[5.1] 4.1->3.1)
>>> neo.start, neo.end
(GraphId(labid=4, locid=1), GraphId(labid=3, locid=1))
>>> neo.properties
{'role': 'Neo'}

An Edge has everything a Vertex has, plus start and end: the identities of the vertex it leaves and the vertex it enters. They are GraphIds, not vertices; return the vertices too if you need them.

Paths#

>>> (path,) = conn.execute_query(
...     "MATCH p = (:movie {title: 'The Matrix'})-[:related_to*2]->"
...     "(:movie {title: 'The Matrix Revolutions'}) RETURN p"
... ).records[0]
>>> path
Path(3 vertices, 2 edges)
>>> path.length
2
>>> len(path)
5
>>> list(path)
[Vertex(movie[3.1]), Edge(related_to[7.1] 3.1->3.2), Vertex(movie[3.2]), Edge(related_to[7.2] 3.2->3.3), Vertex(movie[3.3])]
>>> path.start, path.end
(Vertex(movie[3.1]), Vertex(movie[3.3]))

vertices, edges

the path’s vertices and edges, in order, as tuples

start, end

the first and last vertex, or None for an empty path

length

the number of hops, that is, of edges

len(path)

the number of elements: vertices and edges together

path[i]

an element: even positions are vertices, odd ones edges; negative indexes and slices work

iterating

vertex, edge, vertex, …

A path of one vertex has length 0 and len 1, and is true in a boolean context. An empty path is legal, has neither, and is false.

Lists of elements#

collect(n), nodes(p) and relationships(p) return lists, and arrive as Python lists of Vertex or Edge:

>>> conn.execute_query(
...     "MATCH (:person {name: 'Keanu Reeves'})-[:acted_in]->(m) RETURN collect(m)"
... ).records[0][0][:2]
[Vertex(movie[3.1]), Vertex(movie[3.2])]

A NULL inside such a list, which the server produces for an OPTIONAL MATCH that matched nothing, is None.

Identities#

A GraphId is a pair of numbers: the label’s id and the element’s number within that label. It prints the way the server prints it:

>>> keanu.id.labid, keanu.id.locid, str(keanu.id)
(4, 1, '4.1')

Build one from its two numbers, GraphId(4, 1). packed is the single 64-bit integer the server stores, label id in the top 16 bits, and GraphId.from_packed() reverses it; identities sort in the same order either way. To pass an identity you hold as text, write %s::graphid in the statement.

Equality, hashing, immutability#

A vertex or an edge compares and hashes on its identity alone, as the server compares elements. The same element read twice, even with different properties in between, is equal to itself, and works as a dictionary key or in a set:

>>> again = conn.execute_query(
...     "MATCH (p:person) WHERE p.born = 1964 AND p.name STARTS WITH 'K' RETURN p"
... ).records[0][0]
>>> again == keanu, len({again, keanu})
(True, 1)

A vertex never equals an edge, even one with the same identity, and a Path compares on its vertices and edges.

The values are read-only: none of their fields can be assigned. Read a value, then write the change with a statement. Vertices, edges and identities are also not tracked by Python’s cycle collector, which keeps a large result cheap to hold.

Two renderings#

The server can send a graph value in two forms. The default is its text rendering, the same one psql prints, which carries everything the driver needs. The other is its composite rendering, asked for per statement with binary_=True:

>>> text = conn.execute_query("MATCH ()-[r:acted_in]->() RETURN r ORDER BY id(r) LIMIT 3").records
>>> composite = conn.execute_query(
...     "MATCH ()-[r:acted_in]->() RETURN r ORDER BY id(r) LIMIT 3", binary_=True
... ).records
>>> text == composite
True

Both produce the same values. The composite form trades bandwidth for parsing: each value carries its field lengths, so the driver reads them instead of searching for where a value ends, but it also carries type information the text form leaves out. What that is worth depends on the shape of the result: edges, which are mostly fixed-width fields, gain the most, while on whole vertices and on paths, which are mostly property maps decoded the same way either way, the difference is small. It is the clear choice for embedding vectors, whose text is long decimal and whose composite form is the numbers themselves. Otherwise leave it off, and measure before turning it on for one slow query.

The composite form names a label by its id only, so the driver looks the name up in the connection’s label table, which conn.graph() fills. execute_query(binary_=True) checks that the table is there and raises agensgraph.errors.StaleLabelCache if it is not. psycopg’s own binary=True, on execute() or a cursor, skips that check, so use binary_=True on execute_query() or stream() instead.

Numbers#

jsonb stores a number exactly, at any precision; a Python float does not. By default:

  • an integer of any size reads as an exact int;

  • a non-integer reads as a float, which keeps about seventeen significant digits and turns a value too small to represent, such as 1e-400, into 0.0.

For data where those digits matter, read every non-integer as a Decimal instead:

import agensgraph

agensgraph.read_numbers_exactly()       # once, when the application starts
>>> conn.execute_query("RETURN 3.14159265358979323846264338327950288").records
[(Decimal('3.14159265358979323846264338327950288'),)]
>>> agensgraph.read_numbers_exactly(False)

It applies to property maps and to jsonb columns alike, so RETURN n and RETURN n.price agree about the same stored value. It costs nothing on integers; a map of non-integers takes about four times as long to decode, and a large embedding about six. reading_numbers_exactly() says whether it is on.

The setting is for the whole process. Set it once, before reading anything: a property map is decoded when it is first read, not when it arrives, so changing the setting while a result is in hand can give two rows of it different types.

Handing values to something that wants JSON#

A graph value is not something json.dumps knows. Three functions convert it, in the same shape:

>>> agensgraph.to_builtins(keanu)
{'id': '4.1', 'label': 'person', 'properties': {'born': 1964, 'name': 'Keanu Reeves'}}
>>> agensgraph.to_builtins(neo)
{'id': '5.1', 'label': 'acted_in', 'start': '4.1', 'end': '3.1', 'properties': {'role': 'Neo'}}

to_json(value)

JSON bytes for a value, a row or a whole result

to_builtins(value)

one graph value as dicts and lists

json_default

a default= hook for json.dumps

>>> rows = conn.execute_query(
...     "MATCH (p:person {name: 'Keanu Reeves'})-[r:acted_in]->(:movie {title: 'The Matrix'})"
...     " RETURN p, r"
... ).records
>>> agensgraph.to_json(rows)
b'[[{"id":"4.1","label":"person","properties":{"born": 1964, "name": "Keanu Reeves"}},{"id":"5.1","label":"acted_in","start":"4.1","end":"3.1","properties":{"role": "Neo"}}]]'
>>> import json
>>> json.dumps(rows[0][1], default=agensgraph.json_default)
'{"id": "5.1", "label": "acted_in", "start": "4.1", "end": "3.1", "properties": {"role": "Neo"}}'

A path becomes {"vertices": [...], "edges": [...]}. An identity becomes the text the server prints, "4.1", which goes straight back into a statement as %s::graphid.

to_json is the fast one. A property map nobody has read is copied into the output as the bytes it arrived in, never decoded, so on results with large maps, such as embeddings, it is far faster than going through json.dumps, which decodes every number and writes it out again.

to_builtins takes one graph value, not a list. Do not hand a graph value to msgspec.json.encode directly: it would write the value’s private fields, and msgspec gives no hook to prevent it.