Skip to content

Commit 6cdb3ac

Browse files
committed
feat: add new tests for SQL count on empty types and OpenCypher UNWIND behavior
1 parent 1df2535 commit 6cdb3ac

8 files changed

Lines changed: 132 additions & 4 deletions

File tree

bindings/python/docs/development/testing/test-core.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[View source code]({{ config.repo_url }}/blob/{{ config.extra.version_tag }}/bindings/python/tests/test_core.py){ .md-button }
44

5-
There are **15 tests** covering fundamental database operations.
5+
There are **16 tests** covering fundamental database operations.
66

77
## Overview
88

@@ -15,6 +15,7 @@ Tests validate:
1515
- Query result handling and iteration
1616
- Error handling
1717
- OpenCypher queries (when available)
18+
- SQL aggregate behavior on empty types
1819
- Vector search with HNSW indexes
1920
- Unicode/international character support
2021
- Schema introspection and metadata
@@ -48,6 +49,7 @@ Tests validate:
4849

4950
- **cypher_queries**: Tests OpenCypher CREATE statements, MATCH queries, property access (when OpenCypher available)
5051
- **sql_queries**: Tests SQL queries, aggregation, filtering, joins
52+
- **sql_count_on_empty_type_returns_zero**: Tests `count(*)` on an empty type returns a row with `0`
5153

5254
### Advanced Features
5355

bindings/python/docs/development/testing/test-opencypher.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
The tests validate OpenCypher query support and common graph patterns.
66

7+
They also include regression coverage for planner behavior that matters to the
8+
Python bindings, such as `UNWIND` variables being usable inside `WHERE`
9+
predicates.
10+
711
## OpenCypher
812

913
OpenCypher is a declarative graph query language for pattern matching and traversal.
@@ -40,6 +44,12 @@ with arcadedb.create_database("./opencypher_test_db") as db:
4044
pytest tests/test_cypher.py -v
4145
```
4246

47+
## Notable Regression Coverage
48+
49+
- `UNWIND` variables can be referenced from `WHERE` clauses during `MATCH`
50+
- Aggregations on missing labels still return a single row with `0`
51+
- Result property order remains stable for projected OpenCypher values
52+
4353
## OpenCypher vs SQL MATCH
4454

4555
| Feature | SQL MATCH | OpenCypher |

bindings/python/docs/development/testing/test-schema.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Schema tests cover:
88

99
-**Type Creation** - Vertex, edge, and document types
1010
-**Type Queries** - Getting types and checking existence
11+
-**Type Query Stability** - Repeated `get_types()` calls stay duplicate-free
1112
-**Type Deletion** - Removing types from schema
1213
-**Property Creation** - Adding properties to types
1314
-**Property Deletion** - Removing properties
@@ -52,6 +53,7 @@ Tests querying schema for types.
5253
- `test_get_type()` - Get type by name
5354
- `test_exists_type()` - Check if type exists
5455
- `test_get_types()` - List all types
56+
- `test_get_types_has_no_duplicates_across_repeated_calls()` - Repeated calls stay stable and duplicate-free
5557
- `test_get_type_properties()` - List type properties
5658

5759
**Pattern:**

bindings/python/docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ access methods**:
6363
```python
6464
db.command("sql", "CREATE DOCUMENT TYPE Person")
6565
with db.transaction():
66-
db.command("sql", "INSERT INTO Person SET name = 'Alice'")
66+
db.command("sql", "INSERT INTO Person SET name = 'Alice'")
6767
```
6868

6969
### HTTP API (Server Mode)

bindings/python/fix_markdown.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
separates them (space between ':' and first bullet point).
99
3) List item indentation must be a multiple of 4 spaces (0,4,8,...). Normalize to the next
1010
multiple of 4 for indented lists.
11+
4) In Python fenced code blocks, clamp accidental over-indentation after block openers such as
12+
`with`, `if`, and `for` to a single additional indentation level.
1113
"""
1214
from __future__ import annotations
1315

@@ -51,6 +53,15 @@ def _parse_fence(line: str) -> Optional[Tuple[str, int]]:
5153
return fence[0], len(fence)
5254

5355

56+
def _is_python_fence(line: str) -> bool:
57+
stripped = line.lstrip()
58+
match = FENCE_RE.match(stripped)
59+
if not match:
60+
return False
61+
info = match.group(2).strip().lower()
62+
return info.startswith("python")
63+
64+
5465
def _is_fence_close(line: str, fence_char: str, fence_len: int) -> bool:
5566
stripped = line.lstrip()
5667
if not stripped.startswith(fence_char * fence_len):
@@ -67,25 +78,42 @@ def _is_fence_close(line: str, fence_char: str, fence_len: int) -> bool:
6778

6879
def process_file(
6980
path: Path,
70-
) -> Optional[Tuple[int, int, int, List[int], List[int], List[int]]]:
81+
) -> Optional[
82+
Tuple[
83+
int,
84+
int,
85+
int,
86+
int,
87+
List[int],
88+
List[int],
89+
List[int],
90+
List[int],
91+
List[int],
92+
]
93+
]:
7194
original = path.read_text(encoding="utf-8")
7295
lines = original.splitlines()
7396
new_lines: list[str] = []
7497
in_fence = False
98+
in_python_fence = False
7599
fence_char: Optional[str] = None
76100
fence_len: Optional[int] = None
77101
changed = False
78102
header_fixes = 0
79103
blank_lines = 0
80104
heading_blank_lines = 0
81105
list_indent_fixes = 0
106+
code_indent_fixes = 0
82107
header_lines: List[int] = []
83108
blank_lines_at: List[int] = []
84109
heading_blank_lines_at: List[int] = []
85110
list_indent_lines: List[int] = []
111+
code_indent_lines: List[int] = []
86112

87113
last_list_indent_len: Optional[int] = None
88114
last_list_fixed_len: Optional[int] = None
115+
last_code_indent_len: Optional[int] = None
116+
last_code_line: Optional[str] = None
89117

90118
i = 0
91119
while i < len(lines):
@@ -95,16 +123,45 @@ def process_file(
95123
if fence_char is not None and fence_len is not None:
96124
if _is_fence_close(line, fence_char, fence_len):
97125
in_fence = False
126+
in_python_fence = False
98127
fence_char = None
99128
fence_len = None
129+
last_code_indent_len = None
130+
last_code_line = None
131+
new_lines.append(line)
132+
i += 1
133+
continue
134+
135+
if in_python_fence and line.strip():
136+
indent_len = len(line) - len(line.lstrip(" "))
137+
if (
138+
last_code_indent_len is not None
139+
and last_code_line is not None
140+
and last_code_line.rstrip().endswith(":")
141+
and indent_len > last_code_indent_len + 4
142+
):
143+
line = " " * (last_code_indent_len + 4) + line.lstrip(" ")
144+
changed = True
145+
code_indent_fixes += 1
146+
code_indent_lines.append(i + 1)
147+
148+
last_code_indent_len = len(line) - len(line.lstrip(" "))
149+
last_code_line = line
150+
elif in_python_fence:
151+
last_code_indent_len = None
152+
last_code_line = None
153+
100154
new_lines.append(line)
101155
i += 1
102156
continue
103157

104158
fence = _parse_fence(line)
105159
if fence:
106160
in_fence = True
161+
in_python_fence = _is_python_fence(line)
107162
fence_char, fence_len = fence
163+
last_code_indent_len = None
164+
last_code_line = None
108165
new_lines.append(line)
109166
i += 1
110167
continue
@@ -187,9 +244,11 @@ def process_file(
187244
header_fixes,
188245
blank_lines,
189246
list_indent_fixes,
247+
code_indent_fixes,
190248
header_lines,
191249
blank_lines_at,
192250
list_indent_lines,
251+
code_indent_lines,
193252
heading_blank_lines,
194253
heading_blank_lines_at,
195254
)
@@ -215,16 +274,19 @@ def main() -> int:
215274
total_header = 0
216275
total_blank = 0
217276
total_indent = 0
277+
total_code_indent = 0
218278
for md_file in iter_md_files(docs_root):
219279
result = process_file(md_file)
220280
if result:
221281
(
222282
header_fixes,
223283
blank_lines,
224284
list_indent_fixes,
285+
code_indent_fixes,
225286
header_lines,
226287
blank_lines_at,
227288
list_indent_lines,
289+
code_indent_lines,
228290
heading_blank_lines,
229291
heading_blank_lines_at,
230292
) = result
@@ -234,34 +296,40 @@ def main() -> int:
234296
header_fixes,
235297
blank_lines,
236298
list_indent_fixes,
299+
code_indent_fixes,
237300
header_lines,
238301
blank_lines_at,
239302
list_indent_lines,
303+
code_indent_lines,
240304
heading_blank_lines,
241305
heading_blank_lines_at,
242306
)
243307
)
244308
total_header += header_fixes
245309
total_blank += blank_lines
246310
total_indent += list_indent_fixes
311+
total_code_indent += code_indent_fixes
247312
total_blank += heading_blank_lines
248313

249314
print(f"Updated {len(changed_files)} files")
250315
print(
251316
"Totals: "
252317
f"headers fixed={total_header}, "
253318
f"blank lines inserted={total_blank}, "
254-
f"list indents normalized={total_indent}"
319+
f"list indents normalized={total_indent}, "
320+
f"python code indents normalized={total_code_indent}"
255321
)
256322

257323
for (
258324
path,
259325
header_fixes,
260326
blank_lines,
261327
list_indent_fixes,
328+
code_indent_fixes,
262329
header_lines,
263330
blank_lines_at,
264331
list_indent_lines,
332+
code_indent_lines,
265333
heading_blank_lines,
266334
heading_blank_lines_at,
267335
) in changed_files:
@@ -278,6 +346,9 @@ def main() -> int:
278346
if list_indent_fixes:
279347
print(f" List indents normalized: {list_indent_fixes}")
280348
print(f" Lines: {_format_line_list(list_indent_lines)}")
349+
if code_indent_fixes:
350+
print(f" Python code indents normalized: {code_indent_fixes}")
351+
print(f" Lines: {_format_line_list(code_indent_lines)}")
281352

282353
return 0
283354

bindings/python/tests/test_core.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ def test_database_operations(temp_db_path):
3737
assert record.get("value") == 42
3838

3939

40+
def test_sql_count_on_empty_type_returns_zero(temp_db_path):
41+
"""Test SQL count(*) returns a zero row for an empty type."""
42+
with arcadedb.create_database(temp_db_path) as db:
43+
db.command("sql", "CREATE DOCUMENT TYPE EmptyDoc")
44+
45+
result = db.query("sql", "SELECT count(*) as total FROM EmptyDoc")
46+
row = result.one()
47+
48+
assert row.get("total") == 0
49+
50+
4051
def test_rich_data_types(temp_db_path):
4152
"""Test ArcadeDB's rich data type support with comprehensive CRUD operations.
4253

bindings/python/tests/test_cypher.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,24 @@ def test_opencypher_collect_and_unwind(temp_db_path):
212212
assert rows == [("Acme", "Alice"), ("Acme", "Bob")]
213213

214214

215+
def test_opencypher_unwind_where_uses_unwind_variable(temp_db_path):
216+
"""Test WHERE predicates can reference variables introduced by UNWIND."""
217+
with arcadedb.create_database(temp_db_path) as db:
218+
_ensure_opencypher(db)
219+
_seed_graph(db)
220+
221+
result = db.query(
222+
"opencypher",
223+
"UNWIND ['Alice', 'Bob', 'Nobody'] AS expected_name "
224+
"MATCH (p:Person) "
225+
"WHERE p.name = expected_name "
226+
"RETURN p.name AS name ORDER BY name",
227+
)
228+
names = [record.get("name") for record in result]
229+
230+
assert names == ["Alice", "Bob"]
231+
232+
215233
def test_opencypher_pattern_comprehension(temp_db_path):
216234
"""Test pattern comprehension to derive relationship values."""
217235
with arcadedb.create_database(temp_db_path) as db:

bindings/python/tests/test_schema.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,20 @@ def test_get_types(self, test_db):
173173
assert "Vertex1" in type_names
174174
assert "Edge1" in type_names
175175

176+
def test_get_types_has_no_duplicates_across_repeated_calls(self, test_db):
177+
"""Test get_types remains stable and duplicate-free across repeated calls."""
178+
schema = test_db.schema
179+
schema.create_document_type("Doc1")
180+
schema.create_vertex_type("Vertex1")
181+
schema.create_edge_type("Edge1")
182+
183+
first_names = [type_obj.getName() for type_obj in schema.get_types()]
184+
second_names = [type_obj.getName() for type_obj in schema.get_types()]
185+
186+
assert len(first_names) == len(set(first_names))
187+
assert len(second_names) == len(set(second_names))
188+
assert set(first_names) == set(second_names)
189+
176190

177191
class TestTypeDeletion:
178192
"""Test type deletion methods."""

0 commit comments

Comments
 (0)