FlowQuery (Python)
A pure Python implementation of FlowQuery, a declarative OpenCypher-based query language for virtual graphs and data processing pipelines. This package has full functional fidelity with the TypeScript version.
Installation
pip install flowquery
Quick Start
Command Line Interface
Start the interactive REPL:
flowquery
Programmatic Usage
import asyncio
from flowquery import Runner
runner = Runner("WITH 1 as x RETURN x + 1 as result")
asyncio.run(runner.run())
print(runner.results) # [{'result': 2}]
In Jupyter notebooks, you can use await directly:
from flowquery import Runner
runner = Runner("WITH 1 as x RETURN x + 1 as result")
await runner.run()
print(runner.results) # [{'result': 2}]
Statement Info: Labels, Properties, and Source Lineage
The Runner exposes a metadata property that mirrors the TypeScript
implementation. It reports counts of virtual nodes and relationships
created/deleted plus an optional info: StatementInfo describing the
structure the query touches - independent of execution.
StatementInfo captures:
- The node labels and relationship types referenced.
- The data sources backing the underlying virtual definitions.
- The node/relationship properties consumed by the query -
alias.propaccesses anywhere inMATCH,WHERE,WITH,RETURN,ORDER BY, or function arguments, plus inline pattern properties like(u:User {id: 'rick.o'}). - The properties declared by each virtual's
RETURNclause viainfo.declared, so you can validate that a query references only declared properties. - Literal values supplied for properties at the call site via
info.nodes[label].literal_values- collected from inline pattern properties and from equality /INpredicates such asWHERE u.id = 'rick.o'orWHERE u.id IN ['a', 'b'].
The per-entity nodes and relationships maps give end-to-end lineage
from a property to its data source:
from flowquery import Runner
runner = Runner("""
CREATE VIRTUAL (:City) AS {
LOAD JSON FROM "https://example.com/cities" AS c
RETURN c.id AS id, c.name AS name, c.country AS country
};
CREATE VIRTUAL (:City)-[:FLIGHT]-(:City) AS {
LOAD JSON FROM "https://example.com/flights" AS f
RETURN f.left_id AS left_id, f.right_id AS right_id, f.airline AS airline
};
MATCH (a:City {name: 'NYC'})-[r:FLIGHT]->(b:City)
WHERE b.country IN ['US', 'CA']
RETURN a.name AS origin, b.name AS destination, r.airline AS airline
""")
info = runner.metadata.info
print(info.nodes)
# {'City': NodeInfo(
# properties=['country', 'name'],
# sources=['https://example.com/cities'],
# literal_values={'country': ['US', 'CA'], 'name': ['NYC']},
# )}
print(info.relationships)
# {'FLIGHT': RelationshipInfo(
# properties=['airline'],
# sources=['https://example.com/flights'],
# literal_values={},
# )}
print(info.declared.nodes['City'])
# DeclaredEntityInfo(
# properties=['country', 'id', 'name'],
# sources=['https://example.com/cities'],
# )
print(info.sources)
# ['https://example.com/cities', 'https://example.com/flights']
StatementInfo resolves sources and declared schemas for any virtual
the query touches - both inline CREATE VIRTUAL clauses and
previously-registered virtuals reached via MATCH or DELETE. The flat
node_labels, relationship_types, sources, node_properties, and
relationship_properties fields stay in sync with the per-entity nodes
and relationships maps. Only purely literal AST subtrees end up in
literal_values - values that depend on parameters, references,
f-strings, or subqueries are skipped.
The same StatementInfoCrawler can be used directly on any parsed AST
without going through a Runner:
from flowquery import StatementInfoCrawler
crawler = StatementInfoCrawler()
info = crawler.crawl(parsed_ast)
Lineage and Provenance
FlowQuery exposes two complementary lineage APIs that combine to trace every result cell back to the records, properties, and data sources that produced it.
Statement Info (always on)
Runner.metadata.info returns a StatementInfo snapshot derived from
the parsed AST. It lists the labels and types touched by the
statement, the properties read off each, the upstream data sources
(URLs, file URIs, async function names, let:// references), any
literal property filters present in WHERE, and per-output-column
lineage in info.returns:
from flowquery import Runner
runner = Runner("""
MATCH (c:City)
WHERE c.country = 'US'
RETURN c.name AS origin, c.country AS region
""")
info = runner.metadata.info
print(info.node_labels) # ['City']
print(info.node_properties) # {'City': ['country', 'name']}
print(info.nodes['City'].literal_values) # {'country': ['US']}
print(info.returns['origin'].kind) # 'property'
print(info.returns['origin'].references[0].alias) # 'c'
print(info.returns['origin'].references[0].property) # 'name'
info.returns is keyed by output column name and describes how each
column was computed (literal, property, expression, or
aggregate) plus every alias.property access that fed it.
Row-Level Provenance (opt in)
Pass RunnerOptions(provenance=True) to capture per-row lineage in
runner.provenance, aligned by index with runner.results:
from flowquery import Runner, RunnerOptions
runner = Runner(
"MATCH (a:City {name: 'New York'})-[r:FLIGHT]->(b:City) "
"RETURN a.name AS origin, b.name AS destination",
options=RunnerOptions(provenance=True),
)
await runner.run()
for row, prov in zip(runner.results, runner.provenance):
print(row, "<-", [n.id for n in prov.nodes])
for rel in prov.relationships:
print(" ", rel.type, rel.path) # full node-id path
Each RowProvenance carries the NodeBindings and
RelationshipBindings active when the row was projected. Hops carry
the matched relationship properties; nodes carry the matched node
properties. For aggregate rows, prov.rows lists one
RowSegment per contributing input row so collect()-style outputs
align positionally with their lineage.
trace_row(i) and lineage()
Runner.trace_row(i) and Runner.lineage() bundle the structural
column lineage with the runtime row provenance:
runner = Runner(
"MATCH (c:City) WHERE c.country = 'US' RETURN c.name AS origin",
options=RunnerOptions(provenance=True),
)
await runner.run()
trace = runner.trace_row(0)
print(trace['origin'].value) # 'New York'
print(trace['origin'].lineage.kind) # 'property'
print(trace['origin'].bindings[0].node.id) # 'nyc'
print(trace['origin'].bindings[0].value) # 'New York'
report = runner.lineage()
# report.columns -> column lineage map (deep copy of info.returns)
# report.rows -> per-row {column: CellTrace} maps
For aggregate columns (e.g. collect(c.id)), bindings lists one
entry per contributing input row, paired with the matched value at
that row.
Threading Lineage Through Virtual Sub-Queries
When a MATCH reads from a CREATE VIRTUAL definition, the inner
sub-query is itself executed with provenance, and the resulting
RowProvenance is threaded onto the outer binding's source field:
await Runner("""
CREATE VIRTUAL (:DerivedCity) AS {
MATCH (s:SrcCity) WHERE s.country = 'US' RETURN s.id AS id
}
""").run()
runner = Runner(
"MATCH (d:DerivedCity) RETURN d.id AS id",
options=RunnerOptions(provenance=True),
)
await runner.run()
d = runner.provenance[0].nodes[0] # outer NodeBinding
inner = d.source # inner RowProvenance
print(inner.nodes[0].alias, inner.nodes[0].id) # 's', 'nyc'
Recursion is unbounded: a virtual built atop another virtual produces
nested source chains all the way down.
Data Sources and LET Chaining
LOAD JSON FROM <url> operations add a DataSourceBinding to the
row's provenance segment. When the LOAD reads from a LET-bound
sub-query, the binding's source is let://<name> and
source_provenance carries the inner LET provenance, which in
turn lists its own data_sources:
await Runner('LET cities = { LOAD JSON FROM "file://..." AS c '
'RETURN c.id AS id, c.name AS name }').run()
await Runner("""
CREATE VIRTUAL (:Mirror) AS {
LOAD JSON FROM cities AS c RETURN c.id AS id, c.name AS name
}
""").run()
runner = Runner(
"MATCH (m:Mirror) RETURN m.name AS name",
options=RunnerOptions(provenance=True),
)
await runner.run()
inner = runner.provenance[0].nodes[0].source
let_ds = next(d for d in inner.data_sources
if d.source == 'let://cities')
file_ds = next(d for d in let_ds.source_provenance.data_sources
if d.source.startswith('file://'))
The same chain is reflected statically in info.sources so callers
can introspect the upstream dependency graph without running the
query.
Backend-Aware Virtual Sources
By default a virtual definition is loaded eagerly: its sub-query runs
once per query and all rows are held in memory. For large or remote
backends, a source provider lets FlowQuery read only what a pattern
needs. This is opt-in: existing CREATE VIRTUAL definitions behave
exactly as before.
The simplest way to write a provider is to subclass NodeSource or
RelationshipSource and implement scan(request, context). Implement
lookup (nodes by id) or expand (relationships by left_id/right_id)
when the backend can fetch selectively; the base class then declares the
matching capability for you. version defaults to the current contract
version, and capability keys may be written in snake_case
(max_batch_size, string_ordering). A method may return a list of
rows, an awaitable list, or an (async) iterable of pages.
The request is a SourceRequest: a dict with the same camelCase keys
as the TypeScript implementation (request["pageSize"]) that also
accepts snake_case keys (request["page_size"]) and attribute access
(request.ids, request.side, request.predicate, request.page_size).
from flowquery import FlowQuery, NodeSource, RelationshipSource, cursor_pages
class People(NodeSource):
capabilities = {
"cacheable": True, # identical reads within one query may be cached
"max_batch_size": 50, # ids per lookup/expand request
"predicates": {"operators": ["eq", "in", "gt", "gte", "lt", "lte"]},
}
def scan(self, request, context):
async def fetch(cursor):
page = await api.people(filter=request.predicate, cursor=cursor)
return {"rows": page.rows, "cursor": page.next_cursor}
return cursor_pages(fetch)
async def lookup(self, request, context): # declares "lookup": True
return await api.people_by_ids(request.ids)
class Knows(RelationshipSource):
async def scan(self, request, context):
return await api.all_knows()
async def expand(self, request, context): # declares both expand sides
key = "from" if request.side == "left" else "to"
return await api.knows(**{key: request.ids})
FlowQuery.register_node_source("Person", People())
FlowQuery.register_relationship_source("KNOWS", "Person", "Person", Knows())
fq = FlowQuery("MATCH (a:Person {id: 7})-[:KNOWS]->(b:Person) RETURN b.name LIMIT 10")
await fq.run()
print(fq.source_stats) # calls, pages, rows, cacheHits, batches, plans...
Subclassing is optional: any object with a capabilities dict and a
scan method works, and SourceProvider is a typing.Protocol
describing that shape.
The engine then loads correlated rows in batches instead of reading every person and relationship:
- Correlated loading and batching. It issues id lookups, expands per page of anchor nodes, and makes batched lookups for target nodes.
- Selective start points. A chain may start from its more selective
end. The decision is recorded in
source_stats["plans"], and it can change result order when there is noORDER BY. - Predicate push-down. Pattern properties and
AND-edWHEREcomparisons (=,<,<=,>,>=,IN) against literals or parameters are sent asrequest["predicate"]. - Streaming with early stop under
LIMIT. Reading stops once the limit is reached and the provider's iterator is closed. - Query-scoped caching.
- Cancellation. Call
runner.cancel()or passSourceOptions(signal=...)(anasyncio.EventorSourceSignal). The query raisesQueryCancelledError. - Read contexts. Optional
open_read_context()andclose_read_context(snapshot)methods are called once per query.
Providers may return a superset, because FlowQuery re-checks every
row. matches_predicate(predicate, row) is a reference implementation
of the predicate semantics.
Errors are reported through typed exceptions:
SourceCapabilityError: an invalid declaration, raised at registration.SourceResponseError: malformed pages or a repeated cursor.SourceError: any other provider failure.
A provider may raise SourceUnsupportedError from lookup/expand to
fall back to a scan.
Query-level tuning goes in RunnerOptions(sources=SourceOptions(...)):
page_size (256), max_batch_size (100), cache_capacity (10000),
max_prefetch_rows (1024) and reorder (True).
In-memory sources. in_memory_node_source(rows) and
in_memory_relationship_source(rows) are ready-made providers over
lists. They support lookup, expansion from both sides, every predicate
operator and paging, which makes them handy for tests, examples and
small reference data. Keyword options are cacheable (default True)
and max_batch_size.
from flowquery import in_memory_node_source, in_memory_relationship_source
FlowQuery.register_node_source(
"Person", in_memory_node_source([{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bob"}])
)
FlowQuery.register_relationship_source(
"KNOWS", "Person", "Person", in_memory_relationship_source([{"left_id": 1, "right_id": 2}])
)
Statement-backed sources. A CREATE VIRTUAL definition can opt into
the same contract declaratively with a WITH CAPABILITIES { ... } clause
between the pattern and AS. Each request runs its sub-query with
$request bound to the request (operation, label/type, ids,
side, predicate, pageSize):
await FlowQuery("""
CREATE VIRTUAL (:Ticket) WITH CAPABILITIES {lookup: true, streaming: true} AS {
LOAD JSON FROM f"https://api.example.com/tickets?ids={join(coalesce($request.ids, []), ',')}" AS t
RETURN t.id AS id, t.title AS title
}
""").run()
The map takes the same (camelCase) keys as the capabilities dict;
version defaults to the current contract version. Invalid capabilities
reject the whole definition. Redefining the label without
WITH CAPABILITIES, or deleting it, restores eager loading. The same
opt-in is available programmatically with
FlowQuery.set_node_source_capabilities(label, capabilities) and
FlowQuery.set_relationship_source_capabilities(...); pass None to
opt out.
Documentation
- Language Reference (clauses, expressions, functions, graph operations, and more)
- Quick Cheat Sheet
- Full Documentation
- Contributing Guide
- Virtual Graph Demo Notebook - a demo of virtual graph capabilities and custom function extensibility
Extending FlowQuery with Custom Functions
The query language itself is identical between the TypeScript and Python versions. The only difference is that custom functions are written in Python here instead of TypeScript.
Creating a Custom Scalar Function
Scalar functions operate on individual values and return a result:
from flowquery.extensibility import Function, FunctionDef
@FunctionDef({
"description": "Doubles a number",
"category": "scalar",
"parameters": [{"name": "value", "description": "Number to double", "type": "number"}],
"output": {"description": "Doubled value", "type": "number"},
})
class Double(Function):
def __init__(self):
super().__init__("double")
self._expected_parameter_count = 1
def value(self):
return self.get_children()[0].value() * 2
Once defined, use it in your queries:
WITH 5 AS num RETURN double(num) AS result
// Returns: [{"result": 10}]
Creating a Custom String Function
from flowquery.extensibility import Function, FunctionDef
@FunctionDef({
"description": "Reverses a string",
"category": "scalar",
"parameters": [{"name": "text", "description": "String to reverse", "type": "string"}],
"output": {"description": "Reversed string", "type": "string"},
})
class StrReverse(Function):
def __init__(self):
super().__init__("strreverse")
self._expected_parameter_count = 1
def value(self) -> str:
return str(self.get_children()[0].value())[::-1]
Usage:
WITH 'hello' AS s RETURN strreverse(s) AS reversed
// Returns: [{"reversed": "olleh"}]
Creating a Custom Aggregate Function
Aggregate functions process multiple values and return a single result. They require a ReducerElement to track state:
from flowquery.extensibility import AggregateFunction, FunctionDef, ReducerElement
class MinReducerElement(ReducerElement):
def __init__(self):
self._value = None
@property
def value(self):
return self._value
@value.setter
def value(self, val):
self._value = val
@FunctionDef({
"description": "Collects the minimum value",
"category": "aggregate",
"parameters": [{"name": "value", "description": "Value to compare", "type": "number"}],
"output": {"description": "Minimum value", "type": "number"},
})
class MinValue(AggregateFunction):
def __init__(self):
super().__init__("minvalue")
self._expected_parameter_count = 1
def reduce(self, element):
current = self.first_child().value()
if element.value is None or current < element.value:
element.value = current
def element(self):
return MinReducerElement()
Usage:
UNWIND [5, 2, 8, 1, 9] AS num RETURN minvalue(num) AS min
// Returns: [{"min": 1}]
Creating a Custom Async Data Provider
Async providers allow you to create custom data sources that can be used with LOAD JSON FROM:
from flowquery.extensibility import AsyncFunction, FunctionDef
@FunctionDef({
"description": "Provides example data for testing",
"category": "async",
"parameters": [],
"output": {"description": "Example data object", "type": "object"},
})
class GetExampleData(AsyncFunction):
def __init__(self):
super().__init__("getexampledata")
self._expected_parameter_count = 0
async def generate(self):
yield {"id": 1, "name": "Alice"}
yield {"id": 2, "name": "Bob"}
Usage:
LOAD JSON FROM getexampledata() AS data RETURN data.id AS id, data.name AS name
// Returns: [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
Using Custom Functions with Expressions
Custom functions integrate seamlessly with FlowQuery expressions and can be combined with other functions:
// Using custom function with expressions
WITH 5 * 3 AS num RETURN addhundred(num) + 1 AS result
// Using multiple custom functions together
WITH 2 AS num RETURN triple(num) AS tripled, square(num) AS squared
Introspecting Registered Functions
You can use the built-in functions() function to discover registered functions including your custom ones:
WITH functions() AS funcs
UNWIND funcs AS f
WITH f WHERE f.name = 'double'
RETURN f.name AS name, f.description AS description, f.category AS category
License
MIT License - see LICENSE for details.
Links
Metadata
Release files for flowquery 1.0.66
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| flowquery-1.0.66.tar.gz | 158.9 kB | Without provenance | Details for flowquery-1.0.66.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| flowquery-1.0.66-py3-none-any.whl | Python 3 | none | any | Details for flowquery-1.0.66-py3-none-any.whl |
Total release size: 390.0 kB
Release files / flowquery-1.0.66.tar.gz
| Download URL | flowquery-1.0.66.tar.gz |
|---|---|
| Size | 158.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2706535ea008a3c7ce66e6923d05b7abfdff416a205c1c2a1cd7cf23c545f92e
|
|
BLAKE2b-256 checksum How to use checksums |
4f82dd5b7e5788e4d3f9978dbd3c6cfeb31b0cfd3b8f0ef0312c091c97b61b96
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / flowquery-1.0.66-py3-none-any.whl
| Download URL | flowquery-1.0.66-py3-none-any.whl |
|---|---|
| Size | 231.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c6e844249ea127215025f87f3cffe259cdc616930374fc45867e43ce9c11fa7a
|
|
BLAKE2b-256 checksum How to use checksums |
404f4aa5656982375612cc993e701459182d68f3f51aa84406cd45deeb8e1a5e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Security for flowquery 1.0.66
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.