fquery 0.3__tar.gz → 0.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {fquery-0.3 → fquery-0.5}/LICENSE +2 -1
  2. {fquery-0.3 → fquery-0.5}/PKG-INFO +34 -5
  3. {fquery-0.3 → fquery-0.5}/README.md +0 -1
  4. fquery-0.5/fquery/arrow.py +121 -0
  5. {fquery-0.3 → fquery-0.5}/fquery/async_utils.py +5 -1
  6. fquery-0.5/fquery/cypher_builder.py +194 -0
  7. fquery-0.5/fquery/ladybug.py +394 -0
  8. {fquery-0.3 → fquery-0.5}/fquery/malloy_builder.py +2 -8
  9. fquery-0.5/fquery/naming.py +5 -0
  10. fquery-0.5/fquery/polars_builder.py +53 -0
  11. fquery-0.5/fquery/pydantic.py +124 -0
  12. {fquery-0.3 → fquery-0.5}/fquery/query.py +56 -6
  13. {fquery-0.3 → fquery-0.5}/fquery/sql_builder.py +2 -4
  14. {fquery-0.3 → fquery-0.5}/fquery/sqlmodel.py +163 -24
  15. {fquery-0.3 → fquery-0.5}/fquery/view_model.py +11 -1
  16. {fquery-0.3 → fquery-0.5}/fquery.egg-info/PKG-INFO +34 -5
  17. {fquery-0.3 → fquery-0.5}/fquery.egg-info/SOURCES.txt +13 -0
  18. fquery-0.5/fquery.egg-info/requires.txt +43 -0
  19. fquery-0.5/pyproject.toml +115 -0
  20. {fquery-0.3 → fquery-0.5}/setup.py +18 -6
  21. {fquery-0.3 → fquery-0.5}/tests/__init__.py +0 -3
  22. {fquery-0.3 → fquery-0.5}/tests/async_test.py +5 -1
  23. {fquery-0.3 → fquery-0.5}/tests/mock_user.py +6 -9
  24. fquery-0.5/tests/test_arrow.py +175 -0
  25. fquery-0.5/tests/test_cypher.py +81 -0
  26. fquery-0.5/tests/test_ladybug.py +234 -0
  27. fquery-0.5/tests/test_ladybug_demo.py +16 -0
  28. {fquery-0.3 → fquery-0.5}/tests/test_malloy.py +1 -1
  29. fquery-0.5/tests/test_polars.py +36 -0
  30. fquery-0.5/tests/test_pydantic.py +68 -0
  31. fquery-0.5/tests/test_self_reference.py +30 -0
  32. {fquery-0.3 → fquery-0.5}/tests/test_sqlmodel.py +52 -1
  33. fquery-0.3/fquery.egg-info/requires.txt +0 -10
  34. fquery-0.3/pyproject.toml +0 -8
  35. {fquery-0.3 → fquery-0.5}/fquery/__init__.py +0 -0
  36. {fquery-0.3 → fquery-0.5}/fquery/aitertools.py +0 -0
  37. {fquery-0.3 → fquery-0.5}/fquery/django.py +0 -0
  38. {fquery-0.3 → fquery-0.5}/fquery/execute.py +0 -0
  39. {fquery-0.3 → fquery-0.5}/fquery/fgraphql.py +0 -0
  40. {fquery-0.3 → fquery-0.5}/fquery/resolve.py +0 -0
  41. {fquery-0.3 → fquery-0.5}/fquery/visitor.py +0 -0
  42. {fquery-0.3 → fquery-0.5}/fquery/walk.py +0 -0
  43. {fquery-0.3 → fquery-0.5}/fquery.egg-info/dependency_links.txt +0 -0
  44. {fquery-0.3 → fquery-0.5}/fquery.egg-info/top_level.txt +0 -0
  45. {fquery-0.3 → fquery-0.5}/setup.cfg +0 -0
  46. {fquery-0.3 → fquery-0.5}/tests/benchmark.py +0 -0
  47. {fquery-0.3 → fquery-0.5}/tests/django_example_model.py +0 -0
  48. {fquery-0.3 → fquery-0.5}/tests/graphql_mock_user.py +0 -0
  49. {fquery-0.3 → fquery-0.5}/tests/test_operators.py +0 -0
  50. {fquery-0.3 → fquery-0.5}/tests/test_sql.py +0 -0
  51. {fquery-0.3 → fquery-0.5}/tests/test_walk.py +0 -0
  52. {fquery-0.3 → fquery-0.5}/tests/test_walk_obj.py +0 -0
@@ -1,6 +1,7 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) Facebook, Inc. and its affiliates.
3
+ Copyright (c) Facebook, Inc. and its affiliates, 2015-2020
4
+ Copyright (c) Arun Sharma, 2021-2025
4
5
 
5
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
7
  of this software and associated documentation files (the "Software"), to deal
@@ -1,16 +1,46 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: fquery
3
- Version: 0.3
3
+ Version: 0.5
4
4
  Summary: A graph query engine
5
5
  Home-page: https://github.com/adsharma/fquery
6
+ License: MIT
6
7
  Classifier: Programming Language :: Python :: 3
7
8
  Classifier: License :: OSI Approved :: MIT License
8
9
  Classifier: Operating System :: OS Independent
9
10
  Requires-Python: >=3.6
10
11
  Description-Content-Type: text/markdown
11
- Provides-Extra: SQL
12
- Provides-Extra: graphql
13
12
  License-File: LICENSE
13
+ Requires-Dist: aioitertools
14
+ Provides-Extra: fquery
15
+ Requires-Dist: aioitertools; extra == "fquery"
16
+ Provides-Extra: pydantic
17
+ Requires-Dist: pydantic; extra == "pydantic"
18
+ Provides-Extra: sql
19
+ Requires-Dist: pypika>=0.36.5; extra == "sql"
20
+ Provides-Extra: sqlmodel
21
+ Requires-Dist: sqlmodel>=0.0.42; extra == "sqlmodel"
22
+ Requires-Dist: duckdb_engine>=0.14.0; extra == "sqlmodel"
23
+ Requires-Dist: inflection>=0.5.1; extra == "sqlmodel"
24
+ Requires-Dist: sqlalchemy>=2.0.36; extra == "sqlmodel"
25
+ Provides-Extra: graphql
26
+ Requires-Dist: strawberry-graphql>=0.37.1; extra == "graphql"
27
+ Provides-Extra: polars
28
+ Requires-Dist: polars>=0.12.0; extra == "polars"
29
+ Provides-Extra: pyarrow
30
+ Requires-Dist: pyarrow; extra == "pyarrow"
31
+ Provides-Extra: django
32
+ Requires-Dist: django; extra == "django"
33
+ Provides-Extra: malloy
34
+ Provides-Extra: cypher
35
+ Provides-Extra: ladybug
36
+ Requires-Dist: ladybug>=0.17.0; python_version >= "3.10" and extra == "ladybug"
37
+ Provides-Extra: test
38
+ Requires-Dist: pytest; extra == "test"
39
+ Provides-Extra: df
40
+ Requires-Dist: polars>=0.12.0; extra == "df"
41
+ Dynamic: home-page
42
+ Dynamic: license-file
43
+ Dynamic: requires-python
14
44
 
15
45
  # Overview
16
46
 
@@ -58,4 +88,3 @@ fquery with Django and get easy access to graphql functionality
58
88
  This project is made available under the Apache License, version 2.0.
59
89
 
60
90
  See [LICENSE.txt](license.txt) for details.
61
-
@@ -44,4 +44,3 @@ fquery with Django and get easy access to graphql functionality
44
44
  This project is made available under the Apache License, version 2.0.
45
45
 
46
46
  See [LICENSE.txt](license.txt) for details.
47
-
@@ -0,0 +1,121 @@
1
+ import types
2
+ from collections.abc import Mapping
3
+ from dataclasses import dataclass, fields, is_dataclass
4
+ from datetime import date, datetime, time
5
+ from decimal import Decimal
6
+ from typing import (
7
+ Any,
8
+ Dict,
9
+ List,
10
+ Tuple,
11
+ Type,
12
+ Union,
13
+ get_args,
14
+ get_origin,
15
+ get_type_hints,
16
+ )
17
+
18
+ import pyarrow as pa
19
+
20
+ UNION_TYPES = (Union,)
21
+ if hasattr(types, "UnionType"):
22
+ UNION_TYPES = UNION_TYPES + (types.UnionType,)
23
+
24
+
25
+ TYPEMAP = {
26
+ bool: pa.bool_(),
27
+ int: pa.int64(),
28
+ float: pa.float64(),
29
+ str: pa.string(),
30
+ bytes: pa.binary(),
31
+ datetime: pa.timestamp("us"),
32
+ date: pa.date32(),
33
+ time: pa.time64("us"),
34
+ Decimal: pa.decimal128(38, 9),
35
+ }
36
+
37
+
38
+ def arrow(cls):
39
+ return model(dataclass(kw_only=True)(cls))
40
+
41
+
42
+ def table(cls, rows) -> pa.Table:
43
+ return pa.Table.from_pylist(
44
+ [_record(cls, row) for row in rows], schema=cls.__arrow__
45
+ )
46
+
47
+
48
+ def _record(cls: Type, row) -> Dict[str, Any]:
49
+ field_names = [field.name for field in fields(cls)]
50
+ if is_dataclass(row):
51
+ return {name: getattr(row, name) for name in field_names}
52
+ if isinstance(row, Mapping):
53
+ return {name: row[name] for name in field_names}
54
+ raise TypeError(f"Expected {cls.__name__} instance or mapping, got {type(row)!r}")
55
+
56
+
57
+ def _is_optional(origin, args) -> bool:
58
+ return origin in UNION_TYPES and type(None) in args
59
+
60
+
61
+ def _arrow_type(annotation) -> Tuple[pa.DataType, bool]:
62
+ if isinstance(annotation, pa.DataType):
63
+ return annotation, False
64
+
65
+ origin = get_origin(annotation)
66
+ args = get_args(annotation)
67
+
68
+ if _is_optional(origin, args):
69
+ non_none = [arg for arg in args if arg is not type(None)]
70
+ if len(non_none) != 1:
71
+ raise TypeError(f"Unsupported union type {annotation!r}")
72
+ arrow_type, _ = _arrow_type(non_none[0])
73
+ return arrow_type, True
74
+
75
+ if origin in (list, List):
76
+ if len(args) != 1:
77
+ raise TypeError(f"List fields must specify one item type: {annotation!r}")
78
+ item_type, _ = _arrow_type(args[0])
79
+ return pa.list_(item_type), False
80
+
81
+ if origin in (dict, Dict):
82
+ if len(args) != 2:
83
+ raise TypeError(
84
+ f"Dict fields must specify key and value types: {annotation!r}"
85
+ )
86
+ key_type, _ = _arrow_type(args[0])
87
+ item_type, _ = _arrow_type(args[1])
88
+ return pa.map_(key_type, item_type), False
89
+
90
+ try:
91
+ return TYPEMAP[annotation], False
92
+ except KeyError:
93
+ raise TypeError(f"Unsupported Arrow field type {annotation!r}") from None
94
+
95
+
96
+ def _nullable(field, annotation_nullable: bool) -> bool:
97
+ if annotation_nullable:
98
+ return True
99
+ return field.default is None
100
+
101
+
102
+ def _schema_field(field, annotation) -> pa.Field:
103
+ arrow_type, annotation_nullable = _arrow_type(annotation)
104
+ return pa.field(
105
+ field.name, arrow_type, nullable=_nullable(field, annotation_nullable)
106
+ )
107
+
108
+
109
+ def model(cls: Type) -> Type:
110
+ """
111
+ Decorator to convert a dataclass to a PyArrow-backed row type.
112
+ """
113
+ type_hints = get_type_hints(cls)
114
+ cls.__arrow__ = pa.schema(
115
+ [
116
+ _schema_field(field, type_hints.get(field.name, field.type))
117
+ for field in fields(cls)
118
+ ]
119
+ )
120
+ cls.to_arrow = classmethod(table)
121
+ return cls
@@ -8,7 +8,11 @@ from contextlib import contextmanager
8
8
 
9
9
  @contextmanager
10
10
  def _get_event_loop():
11
- loop = asyncio.get_event_loop()
11
+ try:
12
+ loop = asyncio.get_running_loop()
13
+ except RuntimeError:
14
+ loop = asyncio.new_event_loop()
15
+ asyncio.set_event_loop(loop)
12
16
  if not loop.is_running():
13
17
  yield loop
14
18
  else:
@@ -0,0 +1,194 @@
1
+ # Copyright (c) Arun Sharma, 2025
2
+ #
3
+ # This source code is licensed under the MIT license found in the
4
+ # LICENSE file in the root directory of this source tree.
5
+ import ast
6
+ import operator
7
+ from typing import Any
8
+
9
+ from .naming import query_type_name
10
+ from .visitor import Visitor
11
+
12
+ # inspired from pandas.core.computation.ops
13
+ _cmp_ops_syms = (">", "<", ">=", "<=", "==", "!=")
14
+ _cmp_ops_funcs = (
15
+ operator.gt,
16
+ operator.lt,
17
+ operator.ge,
18
+ operator.le,
19
+ operator.eq,
20
+ operator.ne,
21
+ )
22
+ _cmp_ops_dict = dict(zip(_cmp_ops_syms, _cmp_ops_funcs))
23
+
24
+
25
+ class CypherBuilderVisitor(Visitor):
26
+ def __init__(self, id1s):
27
+ self.cypher = None
28
+ self.match_parts = []
29
+ self.current_node = "u"
30
+ self.where_clauses = []
31
+ self.return_clause = ""
32
+ self.order_by_clause = ""
33
+ self.limit_clause = ""
34
+ self.visited = set()
35
+ self.node_counter = 0
36
+ self.root_label = None
37
+
38
+ @staticmethod
39
+ def table_from_query(query):
40
+ return query_type_name(query.__class__)
41
+
42
+ @staticmethod
43
+ def _cypher_literal(value: Any) -> str:
44
+ if value is None:
45
+ return "null"
46
+ if isinstance(value, bool):
47
+ return "true" if value else "false"
48
+ if isinstance(value, (int, float)):
49
+ return str(value)
50
+ if isinstance(value, str):
51
+ escaped = value.replace("\\", "\\\\").replace("'", "\\'")
52
+ return f"'{escaped}'"
53
+ raise TypeError(f"Unsupported Cypher property value {value!r}")
54
+
55
+ def _node_pattern(self, var: str, label: str, props=None) -> str:
56
+ if not props:
57
+ return f"({var}:{label})"
58
+ prop_list = ", ".join(
59
+ f"{name}: {self._cypher_literal(value)}" for name, value in props.items()
60
+ )
61
+ return f"({var}:{label} {{{prop_list}}})"
62
+
63
+ def _get_next_node_var(self):
64
+ self.node_counter += 1
65
+ return f"n{self.node_counter}"
66
+
67
+ async def visit_leaf(self, query):
68
+ if not self.root_label:
69
+ self.root_label = self.table_from_query(query)
70
+ self.match_parts = [
71
+ self._node_pattern(
72
+ self.current_node,
73
+ self.root_label,
74
+ getattr(query, "_match_props", None),
75
+ )
76
+ ]
77
+
78
+ if query in self.visited:
79
+ # Prevent infinite recursion
80
+ return
81
+ else:
82
+ self.visited.add(query)
83
+ for q in query.edges:
84
+ await self.visit(q)
85
+
86
+ async def visit_project(self, query):
87
+ await self.visit(query.child)
88
+ proj = ", ".join(
89
+ [
90
+ f"{self.current_node}.{x}" if x != ":id" else f"{self.current_node}.id"
91
+ for x in query.projector
92
+ ]
93
+ )
94
+ self.return_clause = f"RETURN {proj}"
95
+
96
+ async def visit_take(self, query):
97
+ await self.visit(query.child)
98
+ self.limit_clause = f"LIMIT {query._count}"
99
+
100
+ async def visit_where(self, query):
101
+ await self.visit(query.child)
102
+ # TODO: more general lazy expression evaluator
103
+ left, op, right = query._expr.value.split()
104
+ right = ast.literal_eval(right)
105
+ table, field = left.split(".") if "." in left else (self.cypher, left)
106
+ self.where_clauses.append(f"{self.current_node}.{field} {op} {right}")
107
+
108
+ async def visit_order_by(self, query):
109
+ await self.visit(query.child)
110
+ key = query._expr.value
111
+ table, field = key.split(".") if "." in key else (self.cypher, key)
112
+ self.order_by_clause = f"ORDER BY {self.current_node}.{field}"
113
+
114
+ async def visit_edge(self, query):
115
+ # Ensure we have the root label and initial match part
116
+ if not self.root_label:
117
+ # Find the root query
118
+ root_query = query
119
+ while hasattr(root_query, "child") and root_query.child:
120
+ root_query = root_query.child
121
+ if hasattr(root_query, "__class__"):
122
+ self.root_label = self.table_from_query(root_query)
123
+ self.match_parts = [
124
+ self._node_pattern(
125
+ self.current_node,
126
+ self.root_label,
127
+ getattr(root_query, "_match_props", None),
128
+ )
129
+ ]
130
+
131
+ edge_name = query.edge_name
132
+
133
+ # Check if this is part of a multi-hop pattern of the same edge type
134
+ # Look ahead to see if the child has another edge of the same type
135
+ has_same_edge_child = (
136
+ hasattr(query, "child")
137
+ and hasattr(query.child, "OP")
138
+ and query.child.OP.name == "EDGE"
139
+ and query.child.edge_name == edge_name
140
+ )
141
+
142
+ if has_same_edge_child:
143
+ root_query = query
144
+ while hasattr(root_query, "child") and root_query.child:
145
+ root_query = root_query.child
146
+ # Count the total number of consecutive edges of the same type
147
+ hops = 1 # current edge
148
+ current_query = query.child
149
+ while (
150
+ hasattr(current_query, "OP")
151
+ and current_query.OP.name == "EDGE"
152
+ and current_query.edge_name == edge_name
153
+ ):
154
+ hops += 1
155
+ current_query = current_query.child
156
+
157
+ # This is the start of a multi-hop pattern (e.g., friend-of-friend, etc.)
158
+ self.match_parts = [
159
+ self._node_pattern(
160
+ "a",
161
+ self.root_label,
162
+ getattr(root_query, "_match_props", None),
163
+ ),
164
+ f"[e:{edge_name.upper()}*{hops}..{hops}]",
165
+ f"(b:{self.root_label})",
166
+ ]
167
+ self.current_node = "b"
168
+ # Skip visiting the intermediate edges and visit the query after the chain
169
+ await self.visit(current_query)
170
+ else:
171
+ # Regular edge traversal
172
+ child_label = self.table_from_query(query._unbound)
173
+ next_node = self._get_next_node_var()
174
+ relationship = f"[:{edge_name.upper()}]"
175
+ self.match_parts.append(f"{relationship}->({next_node}:{child_label})")
176
+ self.current_node = next_node
177
+ await self.visit(query.child)
178
+
179
+ async def visit_union(self, query):
180
+ # UNION in Cypher - this is complex, would need to handle multiple queries
181
+ # For now, just visit the child
182
+ await self.visit(query.child)
183
+
184
+ async def visit_count(self, query):
185
+ await self.visit(query.child)
186
+ self.return_clause = "RETURN count(*)"
187
+
188
+ async def visit_nest(self, query):
189
+ # Nesting - not directly supported in Cypher
190
+ await self.visit(query.child)
191
+
192
+ async def visit_let(self, query):
193
+ # LET - for renaming, could be handled with AS in RETURN
194
+ await self.visit(query.child)