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 |
|---|---|
|
|
|
|
|
|
|
|
a list of vertices or edges, such as |
a |
a property, or anything else |
the JSON value: |
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, '-')
|
the vertex’s identity, a |
|
the label it was created with |
|
its property map, a |
|
one property |
|
the property map as JSON |
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]))
|
the path’s vertices and edges, in order, as tuples |
|
the first and last vertex, or |
|
the number of hops, that is, of edges |
|
the number of elements: vertices and edges together |
|
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 as1e-400, into0.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'}}
|
JSON |
|
one graph value as |
|
a |
>>> 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.