runic-py 0.3.4__tar.gz → 0.3.6__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.
- {runic_py-0.3.4 → runic_py-0.3.6}/.gitignore +5 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/PKG-INFO +3 -3
- {runic_py-0.3.4 → runic_py-0.3.6}/README.md +2 -2
- {runic_py-0.3.4 → runic_py-0.3.6}/pyproject.toml +4 -23
- runic_py-0.3.6/runic/cypher.py +76 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/_base.py +1 -1
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/age.py +13 -4
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/falkordb.py +47 -17
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/memgraph.py +7 -3
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/neo4j.py +7 -3
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/autogen.py +15 -15
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/cli.py +21 -1
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/context.py +4 -1
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/introspect.py +14 -7
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/script.py +11 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/templates/script.py.mako +1 -1
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/core/descriptors.py +3 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/core/metadata.py +5 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/core/types.py +10 -2
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/age.py +24 -2
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/bolt.py +14 -2
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/mapper/mapper.py +24 -43
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/operations.py +6 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/_compiler.py +1 -1
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/builder.py +56 -38
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/specialised.py +13 -61
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/schema/index_manager.py +1 -1
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/session/async_session.py +26 -9
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/session/session.py +18 -6
- {runic_py-0.3.4 → runic_py-0.3.6}/LICENSE.md +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/__init__.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/__init__.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/__init__.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/arcadedb.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/checksum.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/config.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/exceptions.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/manifest.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/operations.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/schema.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/service.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/templates/env.py.mako +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/testing.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/version.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/__init__.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/core/__init__.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/core/models.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/__init__.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/arcadedb.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/factory.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/falkordb.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/memgraph.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/neo4j.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/exceptions.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/mapper/__init__.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/mapper/relationship_loader.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/mapper/relationship_writer.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/__init__.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/_decoder.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/expressions.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/traversal.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/__init__.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/_base.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/async_repository.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/cypher.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/protocol.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/repository.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/schema/__init__.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/session/__init__.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/session/_base.py +0 -0
- {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/session/connection_pool.py +0 -0
|
@@ -86,6 +86,9 @@ develop-eggs/
|
|
|
86
86
|
dist/
|
|
87
87
|
dmypy.json
|
|
88
88
|
docs/_build/
|
|
89
|
+
docs/.vitepress/cache/
|
|
90
|
+
docs/.vitepress/dist/
|
|
91
|
+
docs/node_modules/
|
|
89
92
|
Documents/
|
|
90
93
|
downloads/
|
|
91
94
|
eggs/
|
|
@@ -123,3 +126,5 @@ var/
|
|
|
123
126
|
venv.bak/
|
|
124
127
|
venv/
|
|
125
128
|
wheels/
|
|
129
|
+
.playwright-mcp/
|
|
130
|
+
.playwright/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runic-py
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
4
4
|
Summary: Graph schema migrations and OGM for Cypher-based graph databases.
|
|
5
5
|
Project-URL: Homepage, https://runic-py.readthedocs.io/en/latest/
|
|
6
6
|
Project-URL: Repository, https://github.com/jenreh/runic
|
|
@@ -36,13 +36,13 @@ Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'neo4j'
|
|
|
36
36
|
Description-Content-Type: text/markdown
|
|
37
37
|
|
|
38
38
|
<div align="center">
|
|
39
|
-
<img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/
|
|
39
|
+
<img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/public/runic.svg" width="240" alt="Runic logo">
|
|
40
40
|
|
|
41
41
|
# Runic
|
|
42
42
|
|
|
43
43
|
**Graph schema migrations and OGM for Cypher-based graph databases.**
|
|
44
44
|
|
|
45
|
-

|
|
46
46
|
[](https://pypi.org/project/runic-py/)
|
|
47
47
|
[](https://www.python.org)
|
|
48
48
|
[](LICENSE.md)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
<img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/
|
|
2
|
+
<img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/public/runic.svg" width="240" alt="Runic logo">
|
|
3
3
|
|
|
4
4
|
# Runic
|
|
5
5
|
|
|
6
6
|
**Graph schema migrations and OGM for Cypher-based graph databases.**
|
|
7
7
|
|
|
8
|
-

|
|
9
9
|
[](https://pypi.org/project/runic-py/)
|
|
10
10
|
[](https://www.python.org)
|
|
11
11
|
[](LICENSE.md)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "runic-py"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.6"
|
|
4
4
|
description = "Graph schema migrations and OGM for Cypher-based graph databases."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -37,7 +37,7 @@ all = [
|
|
|
37
37
|
[dependency-groups]
|
|
38
38
|
dev = [
|
|
39
39
|
"bump-my-version==1.3.0",
|
|
40
|
-
"deepeval==4.0.
|
|
40
|
+
"deepeval==4.0.6",
|
|
41
41
|
"detect-secrets==1.5.0",
|
|
42
42
|
"falkordb>=1.6.1",
|
|
43
43
|
"falkordblite==0.10.0",
|
|
@@ -54,7 +54,7 @@ dev = [
|
|
|
54
54
|
"sphinx-copybutton==0.5.2",
|
|
55
55
|
"sphinx-design==0.7.0",
|
|
56
56
|
"sphinx==9.1.0",
|
|
57
|
-
"ty==0.0.
|
|
57
|
+
"ty==0.0.47",
|
|
58
58
|
]
|
|
59
59
|
|
|
60
60
|
[project.scripts]
|
|
@@ -93,7 +93,7 @@ exclude = [
|
|
|
93
93
|
|
|
94
94
|
[tool.bumpversion]
|
|
95
95
|
commit = false
|
|
96
|
-
current_version = "0.3.
|
|
96
|
+
current_version = "0.3.6"
|
|
97
97
|
tag = false
|
|
98
98
|
allow-dirty = false
|
|
99
99
|
|
|
@@ -107,25 +107,6 @@ filename = "README.md"
|
|
|
107
107
|
search = ''
|
|
108
108
|
replace = ''
|
|
109
109
|
|
|
110
|
-
[[tool.bumpversion.files]]
|
|
111
|
-
filename = "docs/source/index.rst"
|
|
112
|
-
search = '.. |Github| image:: https://img.shields.io/badge/release-{current_version}-yellow?logo=github'
|
|
113
|
-
replace = '.. |Github| image:: https://img.shields.io/badge/release-{new_version}-yellow?logo=github'
|
|
114
|
-
|
|
115
|
-
[[tool.bumpversion.files]]
|
|
116
|
-
filename = "docs/source/index.rst"
|
|
117
|
-
search = ' :target: https://github.com/jenreh/runic/tree/v{current_version}'
|
|
118
|
-
replace = ' :target: https://github.com/jenreh/runic/tree/v{new_version}'
|
|
119
|
-
|
|
120
|
-
[[tool.bumpversion.files]]
|
|
121
|
-
filename = "docs/source/conf.py"
|
|
122
|
-
search = 'version = "{current_version}"'
|
|
123
|
-
replace = 'version = "{new_version}"'
|
|
124
|
-
|
|
125
|
-
[[tool.bumpversion.files]]
|
|
126
|
-
filename = "docs/source/conf.py"
|
|
127
|
-
search = 'release = "{current_version}"'
|
|
128
|
-
replace = 'release = "{new_version}"'
|
|
129
110
|
|
|
130
111
|
[tool.ruff]
|
|
131
112
|
cache-dir = ".cache/ruff"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Cypher escaping helpers shared by the migrate and OGM subsystems.
|
|
2
|
+
|
|
3
|
+
Cypher (and the FalkorDB/Neo4j/Memgraph dialects) cannot bind *identifiers*
|
|
4
|
+
(labels, relationship types, property keys) or DDL-internal string literals as
|
|
5
|
+
query parameters. These helpers make such interpolation safe:
|
|
6
|
+
|
|
7
|
+
* :func:`escape_identifier` backtick-quotes an identifier, doubling embedded
|
|
8
|
+
backticks. Use it for pattern positions such as ``(n:{label})`` or
|
|
9
|
+
``[r:{rel_type}]``.
|
|
10
|
+
* :func:`escape_string` produces a single-quoted Cypher string literal with
|
|
11
|
+
backslashes and single quotes escaped. Use it for procedure string arguments
|
|
12
|
+
and DDL option maps (for example ``CALL db.idx.fulltext.createNodeIndex``).
|
|
13
|
+
|
|
14
|
+
Both functions reject control characters, which can never appear in a legal
|
|
15
|
+
identifier or option value and are a strong signal of an injection attempt.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
_CONTROL_CHARS = frozenset(chr(c) for c in range(0x20)) | {"\x7f"}
|
|
23
|
+
|
|
24
|
+
# Labels, relationship types, and similar schema identifiers that come from
|
|
25
|
+
# model *definitions* are interpolated directly into Cypher patterns
|
|
26
|
+
# (``(n:{label})``, ``[r:{rel_type}]``). They are validated once at definition
|
|
27
|
+
# time against this pattern so every downstream interpolation is safe by
|
|
28
|
+
# construction — see :func:`validate_identifier`.
|
|
29
|
+
_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_identifier(name: str, kind: str = "identifier") -> str:
|
|
33
|
+
"""Return ``name`` if it is a safe Cypher schema identifier, else raise.
|
|
34
|
+
|
|
35
|
+
Used at model-definition chokepoints (node labels, edge types, relationship
|
|
36
|
+
types) so the value can be interpolated into Cypher patterns without risk of
|
|
37
|
+
injection. The accepted form is a simple identifier: a letter or underscore
|
|
38
|
+
followed by letters, digits, or underscores.
|
|
39
|
+
"""
|
|
40
|
+
if not isinstance(name, str) or not _IDENTIFIER_RE.fullmatch(name):
|
|
41
|
+
msg = (
|
|
42
|
+
f"invalid Cypher {kind} {name!r}; must match "
|
|
43
|
+
f"{_IDENTIFIER_RE.pattern} (a letter/underscore followed by "
|
|
44
|
+
"letters, digits, or underscores)"
|
|
45
|
+
)
|
|
46
|
+
raise ValueError(msg)
|
|
47
|
+
return name
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _reject_control_chars(value: str, kind: str) -> None:
|
|
51
|
+
if any(ch in _CONTROL_CHARS for ch in value):
|
|
52
|
+
msg = f"illegal control character in Cypher {kind}: {value!r}"
|
|
53
|
+
raise ValueError(msg)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def escape_identifier(name: str) -> str:
|
|
57
|
+
"""Return ``name`` as a backtick-quoted Cypher identifier.
|
|
58
|
+
|
|
59
|
+
Embedded backticks are doubled so the value cannot break out of the quoting.
|
|
60
|
+
Safe for use in pattern positions, e.g. ``f"(n:{escape_identifier(label)})"``.
|
|
61
|
+
"""
|
|
62
|
+
_reject_control_chars(name, "identifier")
|
|
63
|
+
escaped = name.replace("`", "``")
|
|
64
|
+
return f"`{escaped}`"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def escape_string(value: str) -> str:
|
|
68
|
+
"""Return ``value`` as a single-quoted, escaped Cypher string literal.
|
|
69
|
+
|
|
70
|
+
Backslashes and single quotes are escaped so the value cannot break out of
|
|
71
|
+
the literal. Safe for procedure arguments and DDL option maps, e.g.
|
|
72
|
+
``f"language: {escape_string(language)}"``.
|
|
73
|
+
"""
|
|
74
|
+
_reject_control_chars(value, "string literal")
|
|
75
|
+
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
|
|
76
|
+
return f"'{escaped}'"
|
|
@@ -230,7 +230,7 @@ class GraphAdapterBase(ABC):
|
|
|
230
230
|
current = self.get_checksums()
|
|
231
231
|
current[rev_id] = checksum
|
|
232
232
|
current_by = self.get_installed_by()
|
|
233
|
-
if installed_by:
|
|
233
|
+
if installed_by is not None:
|
|
234
234
|
current_by[rev_id] = installed_by
|
|
235
235
|
self.run_query(
|
|
236
236
|
_SET_TRACKING_QUERY,
|
|
@@ -57,15 +57,24 @@ class AGEAdapter(GraphAdapterBase, GraphAdapter):
|
|
|
57
57
|
return self._graph_name
|
|
58
58
|
|
|
59
59
|
def execute(self, cypher: str, params: dict[str, Any]) -> Any:
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
try:
|
|
61
|
+
result = self._driver.execute(cypher, params)
|
|
62
|
+
self._driver.commit()
|
|
63
|
+
except Exception:
|
|
64
|
+
# Clear the aborted transaction so the adapter remains usable.
|
|
65
|
+
self._driver.rollback()
|
|
66
|
+
raise
|
|
62
67
|
return result
|
|
63
68
|
|
|
64
69
|
def run_query(self, query: str, params: dict | None = None) -> Any:
|
|
65
|
-
result = self._driver.execute(query, params or {})
|
|
66
70
|
# AGEDriver no longer auto-commits; the adapter owns the transaction
|
|
67
71
|
# lifecycle for write operations (not managed by an OGM Session here).
|
|
68
|
-
|
|
72
|
+
try:
|
|
73
|
+
result = self._driver.execute(query, params or {})
|
|
74
|
+
self._driver.commit()
|
|
75
|
+
except Exception:
|
|
76
|
+
self._driver.rollback()
|
|
77
|
+
raise
|
|
69
78
|
return result
|
|
70
79
|
|
|
71
80
|
def run_ro_query(self, query: str) -> Any:
|
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
import time
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
+
from runic.cypher import escape_identifier, escape_string
|
|
7
8
|
from runic.migrate import introspect
|
|
8
9
|
from runic.migrate.adapters import GraphAdapter
|
|
9
10
|
from runic.migrate.adapters._base import _encode_kv_list, _parse_kv_list
|
|
@@ -18,6 +19,10 @@ log = logging.getLogger(__name__)
|
|
|
18
19
|
_POLL_RETRIES = 30
|
|
19
20
|
_POLL_INTERVAL = 0.5
|
|
20
21
|
|
|
22
|
+
# FalkorDB vector index similarity functions are a closed set; reject anything
|
|
23
|
+
# else early rather than interpolate an unknown value into the OPTIONS map.
|
|
24
|
+
_VECTOR_SIMILARITY_FUNCTIONS = frozenset({"euclidean", "cosine"})
|
|
25
|
+
|
|
21
26
|
_VERSION_LABEL = "_FalkorMigrateVersion"
|
|
22
27
|
_GET_VERSION_QUERY = f"MATCH (v:{_VERSION_LABEL}) RETURN v.revisions, v.revision"
|
|
23
28
|
_SET_VERSION_QUERY = (
|
|
@@ -197,15 +202,17 @@ class FalkorDBAdapter(GraphAdapter):
|
|
|
197
202
|
# ------------------------------------------------------------------
|
|
198
203
|
|
|
199
204
|
def create_range_index(self, label: str, prop: str, *, rel: bool = False) -> None:
|
|
205
|
+
label_id = escape_identifier(label)
|
|
206
|
+
prop_id = escape_identifier(prop)
|
|
200
207
|
if rel:
|
|
201
|
-
query = f"CREATE INDEX FOR ()-[r:{
|
|
208
|
+
query = f"CREATE INDEX FOR ()-[r:{label_id}]->() ON (r.{prop_id})"
|
|
202
209
|
else:
|
|
203
|
-
query = f"CREATE INDEX FOR (n:{
|
|
210
|
+
query = f"CREATE INDEX FOR (n:{label_id}) ON (n.{prop_id})"
|
|
204
211
|
log.info("creating range index on %s.%s", label, prop)
|
|
205
212
|
self._driver.execute(query, {})
|
|
206
213
|
|
|
207
214
|
def drop_range_index(self, label: str, prop: str, *, rel: bool = False) -> None: # noqa: ARG002
|
|
208
|
-
query = f"DROP INDEX ON :{label}({prop})"
|
|
215
|
+
query = f"DROP INDEX ON :{escape_identifier(label)}({escape_identifier(prop)})"
|
|
209
216
|
log.info("dropping range index on %s.%s", label, prop)
|
|
210
217
|
self._driver.execute(query, {})
|
|
211
218
|
|
|
@@ -220,26 +227,29 @@ class FalkorDBAdapter(GraphAdapter):
|
|
|
220
227
|
language: str | None = None,
|
|
221
228
|
stopwords: list[str] | None = None,
|
|
222
229
|
) -> None:
|
|
230
|
+
props_str = ", ".join(escape_string(p) for p in props)
|
|
223
231
|
if language or stopwords:
|
|
224
|
-
map_parts = [f"label:
|
|
232
|
+
map_parts = [f"label: {escape_string(label)}"]
|
|
225
233
|
if language:
|
|
226
|
-
map_parts.append(f"language:
|
|
234
|
+
map_parts.append(f"language: {escape_string(language)}")
|
|
227
235
|
if stopwords:
|
|
228
|
-
sw = "[" + ", ".join(
|
|
236
|
+
sw = "[" + ", ".join(escape_string(w) for w in stopwords) + "]"
|
|
229
237
|
map_parts.append(f"stopwords: {sw}")
|
|
230
238
|
map_literal = "{" + ", ".join(map_parts) + "}"
|
|
231
|
-
props_str = ", ".join(f"'{p}'" for p in props)
|
|
232
239
|
query = f"CALL db.idx.fulltext.createNodeIndex({map_literal}, {props_str})"
|
|
233
240
|
else:
|
|
234
|
-
|
|
235
|
-
|
|
241
|
+
query = (
|
|
242
|
+
"CALL db.idx.fulltext.createNodeIndex("
|
|
243
|
+
f"{escape_string(label)}, {props_str})"
|
|
244
|
+
)
|
|
236
245
|
log.info("creating fulltext index on %s %s", label, list(props))
|
|
237
246
|
self._driver.execute(query, {})
|
|
238
247
|
|
|
239
248
|
def drop_fulltext_index(self, label: str, *props: str) -> None:
|
|
240
249
|
log.info("dropping fulltext index on %s %s", label, list(props))
|
|
250
|
+
label_id = escape_identifier(label)
|
|
241
251
|
for prop in props:
|
|
242
|
-
query = f"DROP FULLTEXT INDEX FOR (n:{
|
|
252
|
+
query = f"DROP FULLTEXT INDEX FOR (n:{label_id}) ON (n.{escape_identifier(prop)})"
|
|
243
253
|
self._driver.execute(query, {})
|
|
244
254
|
|
|
245
255
|
# ------------------------------------------------------------------
|
|
@@ -257,16 +267,29 @@ class FalkorDBAdapter(GraphAdapter):
|
|
|
257
267
|
ef_construction: int = 200,
|
|
258
268
|
ef_runtime: int = 10,
|
|
259
269
|
) -> None:
|
|
270
|
+
if similarity not in _VECTOR_SIMILARITY_FUNCTIONS:
|
|
271
|
+
msg = (
|
|
272
|
+
f"unsupported vector similarity function {similarity!r}; "
|
|
273
|
+
f"expected one of {sorted(_VECTOR_SIMILARITY_FUNCTIONS)}"
|
|
274
|
+
)
|
|
275
|
+
raise ValueError(msg)
|
|
276
|
+
label_id = escape_identifier(label)
|
|
277
|
+
prop_id = escape_identifier(prop)
|
|
260
278
|
options = (
|
|
261
|
-
f"{{dimension: {dimension}, similarityFunction:
|
|
279
|
+
f"{{dimension: {dimension}, similarityFunction: {escape_string(similarity)}, "
|
|
262
280
|
f"M: {m}, efConstruction: {ef_construction}, efRuntime: {ef_runtime}}}"
|
|
263
281
|
)
|
|
264
|
-
query =
|
|
282
|
+
query = (
|
|
283
|
+
f"CREATE VECTOR INDEX FOR (n:{label_id}) ON (n.{prop_id}) OPTIONS {options}"
|
|
284
|
+
)
|
|
265
285
|
log.info("creating vector index on %s.%s", label, prop)
|
|
266
286
|
self._driver.execute(query, {})
|
|
267
287
|
|
|
268
288
|
def drop_vector_index(self, label: str, prop: str) -> None:
|
|
269
|
-
query =
|
|
289
|
+
query = (
|
|
290
|
+
f"DROP VECTOR INDEX FOR (n:{escape_identifier(label)}) "
|
|
291
|
+
f"(n.{escape_identifier(prop)})"
|
|
292
|
+
)
|
|
270
293
|
log.info("dropping vector index on %s.%s", label, prop)
|
|
271
294
|
self._driver.execute(query, {})
|
|
272
295
|
|
|
@@ -285,7 +308,7 @@ class FalkorDBAdapter(GraphAdapter):
|
|
|
285
308
|
self._db.execute_command(
|
|
286
309
|
"GRAPH.CONSTRAINT",
|
|
287
310
|
"CREATE",
|
|
288
|
-
|
|
311
|
+
self._graph.name,
|
|
289
312
|
kind,
|
|
290
313
|
entity,
|
|
291
314
|
label,
|
|
@@ -296,11 +319,18 @@ class FalkorDBAdapter(GraphAdapter):
|
|
|
296
319
|
self._poll_constraint(label, props)
|
|
297
320
|
|
|
298
321
|
def _poll_constraint(self, label: str, props: list[str]) -> None:
|
|
322
|
+
target_props = list(props)
|
|
299
323
|
for _ in range(_POLL_RETRIES):
|
|
300
324
|
result = self._driver.execute("CALL db.constraints()", {})
|
|
301
325
|
for row in result.rows:
|
|
302
|
-
|
|
303
|
-
|
|
326
|
+
# Each row is a flat list of columns, consistent with
|
|
327
|
+
# introspect.read_live_schema():
|
|
328
|
+
# [type, label, properties, entity_type, status]
|
|
329
|
+
if not isinstance(row, (list, tuple)) or len(row) < 5:
|
|
330
|
+
continue
|
|
331
|
+
if row[1] != label or list(row[2]) != target_props:
|
|
332
|
+
continue
|
|
333
|
+
status = row[4]
|
|
304
334
|
if status == "FAILED":
|
|
305
335
|
raise ConstraintFailedError(f"constraint on {label}.{props} failed")
|
|
306
336
|
if status == "OPERATIONAL":
|
|
@@ -318,7 +348,7 @@ class FalkorDBAdapter(GraphAdapter):
|
|
|
318
348
|
self._db.execute_command(
|
|
319
349
|
"GRAPH.CONSTRAINT",
|
|
320
350
|
"DROP",
|
|
321
|
-
|
|
351
|
+
self._graph.name,
|
|
322
352
|
kind,
|
|
323
353
|
entity,
|
|
324
354
|
label,
|
|
@@ -76,7 +76,7 @@ class MemgraphAdapter(GraphAdapterBase, GraphAdapter):
|
|
|
76
76
|
auth=self._driver.auth,
|
|
77
77
|
database=graph_name,
|
|
78
78
|
dialect=_MEMGRAPH_DIALECT,
|
|
79
|
-
encrypted=
|
|
79
|
+
encrypted=self._driver.encrypted,
|
|
80
80
|
)
|
|
81
81
|
return MemgraphAdapter(new_driver, graph_name)
|
|
82
82
|
|
|
@@ -196,7 +196,10 @@ class MemgraphAdapter(GraphAdapterBase, GraphAdapter):
|
|
|
196
196
|
if idx_type == "label+property" and prop:
|
|
197
197
|
specs.add(IndexSpec(label=label, property=prop, index_type="RANGE"))
|
|
198
198
|
except Exception as exc:
|
|
199
|
-
|
|
199
|
+
# Returning empty specs here would make autogenerate treat every
|
|
200
|
+
# index as missing; abort loudly instead of silently degrading.
|
|
201
|
+
log.error("Memgraph SHOW INDEX INFO failed: %s", exc)
|
|
202
|
+
raise
|
|
200
203
|
try:
|
|
201
204
|
result = self.run_ro_query("SHOW CONSTRAINT INFO")
|
|
202
205
|
for row in result.rows:
|
|
@@ -214,5 +217,6 @@ class MemgraphAdapter(GraphAdapterBase, GraphAdapter):
|
|
|
214
217
|
for prop in prop_list:
|
|
215
218
|
specs.add(IndexSpec(label=label, property=prop, index_type=kind))
|
|
216
219
|
except Exception as exc:
|
|
217
|
-
log.
|
|
220
|
+
log.error("Memgraph SHOW CONSTRAINT INFO failed: %s", exc)
|
|
221
|
+
raise
|
|
218
222
|
return specs
|
|
@@ -76,7 +76,7 @@ class Neo4jAdapter(GraphAdapterBase, GraphAdapter):
|
|
|
76
76
|
auth=self._driver.auth,
|
|
77
77
|
database=graph_name,
|
|
78
78
|
dialect=_NEO4J_DIALECT,
|
|
79
|
-
encrypted=
|
|
79
|
+
encrypted=self._driver.encrypted,
|
|
80
80
|
)
|
|
81
81
|
return Neo4jAdapter(new_driver, graph_name)
|
|
82
82
|
|
|
@@ -213,7 +213,10 @@ class Neo4jAdapter(GraphAdapterBase, GraphAdapter):
|
|
|
213
213
|
IndexSpec(label=label, property=prop, index_type=idx_type)
|
|
214
214
|
)
|
|
215
215
|
except Exception as exc:
|
|
216
|
-
|
|
216
|
+
# Returning empty specs here would make autogenerate treat every
|
|
217
|
+
# index as missing; abort loudly instead of silently degrading.
|
|
218
|
+
log.error("Neo4j SHOW INDEXES failed: %s", exc)
|
|
219
|
+
raise
|
|
217
220
|
try:
|
|
218
221
|
result = self.run_ro_query(
|
|
219
222
|
"SHOW CONSTRAINTS YIELD type, entityType, labelsOrTypes, properties"
|
|
@@ -230,5 +233,6 @@ class Neo4jAdapter(GraphAdapterBase, GraphAdapter):
|
|
|
230
233
|
IndexSpec(label=label, property=prop, index_type="UNIQUE")
|
|
231
234
|
)
|
|
232
235
|
except Exception as exc:
|
|
233
|
-
log.
|
|
236
|
+
log.error("Neo4j SHOW CONSTRAINTS failed: %s", exc)
|
|
237
|
+
raise
|
|
234
238
|
return specs
|
|
@@ -56,53 +56,53 @@ def _constraint_key(c: UniqueConstraint | MandatoryConstraint) -> tuple:
|
|
|
56
56
|
|
|
57
57
|
def _range_create(idx: RangeIndex) -> str:
|
|
58
58
|
rel_arg = ", rel=True" if idx.rel else ""
|
|
59
|
-
return f
|
|
59
|
+
return f"op.create_range_index({idx.label!r}, {idx.prop!r}{rel_arg})"
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
def _range_drop(idx: RangeIndex) -> str:
|
|
63
63
|
rel_arg = ", rel=True" if idx.rel else ""
|
|
64
|
-
return f
|
|
64
|
+
return f"op.drop_range_index({idx.label!r}, {idx.prop!r}{rel_arg})"
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
def _fulltext_create(idx: FulltextIndex) -> str:
|
|
68
|
-
props_args = ", ".join(
|
|
68
|
+
props_args = ", ".join(repr(p) for p in idx.props)
|
|
69
69
|
extra: list[str] = []
|
|
70
70
|
if idx.language:
|
|
71
|
-
extra.append(f
|
|
71
|
+
extra.append(f"language={idx.language!r}")
|
|
72
72
|
if idx.stopwords:
|
|
73
|
-
sw = "[" + ", ".join(
|
|
73
|
+
sw = "[" + ", ".join(repr(w) for w in idx.stopwords) + "]"
|
|
74
74
|
extra.append(f"stopwords={sw}")
|
|
75
75
|
extra_str = (", " + ", ".join(extra)) if extra else ""
|
|
76
|
-
return f
|
|
76
|
+
return f"op.create_fulltext_index({idx.label!r}, {props_args}{extra_str})"
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
def _fulltext_drop(idx: FulltextIndex) -> str:
|
|
80
|
-
props_args = ", ".join(
|
|
81
|
-
return f
|
|
80
|
+
props_args = ", ".join(repr(p) for p in idx.props)
|
|
81
|
+
return f"op.drop_fulltext_index({idx.label!r}, {props_args})"
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
def _vector_create(idx: VectorIndex) -> str:
|
|
85
85
|
return (
|
|
86
|
-
f
|
|
87
|
-
f
|
|
86
|
+
f"op.create_vector_index({idx.label!r}, {idx.prop!r}, "
|
|
87
|
+
f"{idx.dimension}, {idx.similarity!r}, "
|
|
88
88
|
f"m={idx.m}, ef_construction={idx.ef_construction}, ef_runtime={idx.ef_runtime})"
|
|
89
89
|
)
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
def _vector_drop(idx: VectorIndex) -> str:
|
|
93
|
-
return f
|
|
93
|
+
return f"op.drop_vector_index({idx.label!r}, {idx.prop!r})"
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
def _constraint_create(c: UniqueConstraint | MandatoryConstraint) -> str:
|
|
97
97
|
kind = "UNIQUE" if isinstance(c, UniqueConstraint) else "MANDATORY"
|
|
98
|
-
props_repr = "[" + ", ".join(
|
|
99
|
-
return f
|
|
98
|
+
props_repr = "[" + ", ".join(repr(p) for p in c.props) + "]"
|
|
99
|
+
return f"op.create_constraint({kind!r}, {c.entity!r}, {c.label!r}, {props_repr})"
|
|
100
100
|
|
|
101
101
|
|
|
102
102
|
def _constraint_drop(c: UniqueConstraint | MandatoryConstraint) -> str:
|
|
103
103
|
kind = "UNIQUE" if isinstance(c, UniqueConstraint) else "MANDATORY"
|
|
104
|
-
props_repr = "[" + ", ".join(
|
|
105
|
-
return f
|
|
104
|
+
props_repr = "[" + ", ".join(repr(p) for p in c.props) + "]"
|
|
105
|
+
return f"op.drop_constraint({kind!r}, {c.entity!r}, {c.label!r}, {props_repr})"
|
|
106
106
|
|
|
107
107
|
|
|
108
108
|
# ---------------------------------------------------------------------------
|
|
@@ -35,12 +35,32 @@ def _resolve_config(config: Path) -> Path:
|
|
|
35
35
|
return config
|
|
36
36
|
if config == _DEFAULT_CONFIG and _MARKER_FILE.exists():
|
|
37
37
|
candidate = Path(_MARKER_FILE.read_text().strip())
|
|
38
|
-
if candidate
|
|
38
|
+
if _is_safe_marker_target(candidate):
|
|
39
39
|
log.debug("resolved config from .runic: %s", candidate)
|
|
40
40
|
return candidate
|
|
41
41
|
return config
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def _is_safe_marker_target(candidate: Path) -> bool:
|
|
45
|
+
"""Return True if *candidate* is a regular file inside the project root.
|
|
46
|
+
|
|
47
|
+
The .runic marker is treated as a path that is later ``exec``-ed, so a
|
|
48
|
+
candidate escaping the project root (e.g. ``/etc/passwd`` or a symlink) is
|
|
49
|
+
rejected to prevent arbitrary-file execution via a tampered marker.
|
|
50
|
+
"""
|
|
51
|
+
project_root = Path.cwd().resolve()
|
|
52
|
+
try:
|
|
53
|
+
resolved = candidate.resolve()
|
|
54
|
+
resolved.relative_to(project_root)
|
|
55
|
+
except OSError, ValueError:
|
|
56
|
+
log.warning("ignoring .runic target outside project root: %s", candidate)
|
|
57
|
+
return False
|
|
58
|
+
if not resolved.is_file():
|
|
59
|
+
log.warning("ignoring .runic target that is not a regular file: %s", candidate)
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
|
|
44
64
|
_DB_CONNECTION_ERROR_NAMES = frozenset(
|
|
45
65
|
{
|
|
46
66
|
"AuthenticationError",
|
|
@@ -9,6 +9,7 @@ from runic.migrate.adapters import GraphAdapter
|
|
|
9
9
|
from runic.migrate.manifest import SchemaManifest
|
|
10
10
|
from runic.migrate.operations import GraphOperations
|
|
11
11
|
from runic.migrate.script import (
|
|
12
|
+
AmbiguousRevision,
|
|
12
13
|
Revision,
|
|
13
14
|
RevisionInfo,
|
|
14
15
|
RevisionNotFound,
|
|
@@ -113,7 +114,9 @@ class Runic:
|
|
|
113
114
|
def get_revision_message(self, rev_id: str) -> str | None:
|
|
114
115
|
try:
|
|
115
116
|
return self._script_dir.get_revision(rev_id).message
|
|
116
|
-
except
|
|
117
|
+
except RevisionNotFound, AmbiguousRevision:
|
|
118
|
+
# Expected when the revision is absent or the prefix is ambiguous;
|
|
119
|
+
# other errors (corrupt scripts, I/O) propagate rather than hide.
|
|
117
120
|
return None
|
|
118
121
|
|
|
119
122
|
def _resolve_upgrade_target(self, target: str) -> str:
|
|
@@ -465,6 +465,7 @@ def read_live_schema(graph: Any) -> LiveSchema:
|
|
|
465
465
|
entity_type: str = row[_IDX_ENTITYTYPE]
|
|
466
466
|
rel = entity_type == "RELATIONSHIP"
|
|
467
467
|
|
|
468
|
+
fulltext_props: list[str] = []
|
|
468
469
|
for prop in props:
|
|
469
470
|
prop_types: list[str] = types_dict.get(prop, [])
|
|
470
471
|
if not prop_types:
|
|
@@ -473,13 +474,10 @@ def read_live_schema(graph: Any) -> LiveSchema:
|
|
|
473
474
|
if idx_type == "RANGE":
|
|
474
475
|
range_indexes.append(RangeIndex(label=label, prop=prop, rel=rel))
|
|
475
476
|
elif idx_type == "FULLTEXT":
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
label=label, props=[prop], language=lang, stopwords=sw
|
|
481
|
-
)
|
|
482
|
-
)
|
|
477
|
+
# Accumulate all fulltext props of this label into a single
|
|
478
|
+
# multi-property index so it matches the grouped manifest spec
|
|
479
|
+
# (one FulltextIndex per label, not one per property).
|
|
480
|
+
fulltext_props.append(prop)
|
|
483
481
|
elif idx_type == "VECTOR":
|
|
484
482
|
prop_opts: dict = options_dict.get(prop, {})
|
|
485
483
|
dimension: int = int(prop_opts.get("dimension", 0))
|
|
@@ -503,6 +501,15 @@ def read_live_schema(graph: Any) -> LiveSchema:
|
|
|
503
501
|
"unknown index type %r for %s.%s — skipping", idx_type, label, prop
|
|
504
502
|
)
|
|
505
503
|
|
|
504
|
+
if fulltext_props:
|
|
505
|
+
sw = tuple(stopwords) if stopwords else None
|
|
506
|
+
lang = language if language and language != "english" else None
|
|
507
|
+
fulltext_indexes.append(
|
|
508
|
+
FulltextIndex(
|
|
509
|
+
label=label, props=fulltext_props, language=lang, stopwords=sw
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
|
|
506
513
|
con_result = graph.ro_query("CALL db.constraints()")
|
|
507
514
|
for row in con_result.result_set:
|
|
508
515
|
if len(row) < _CON_MIN_COLS:
|
|
@@ -22,6 +22,16 @@ if TYPE_CHECKING:
|
|
|
22
22
|
log = logging.getLogger(__name__)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def _docstring_safe(text: str) -> str:
|
|
26
|
+
"""Neutralise sequences that would break out of a ``\"\"\"`` docstring.
|
|
27
|
+
|
|
28
|
+
The migration ``message`` is interpolated raw into the generated module's
|
|
29
|
+
docstring. Escaping every double-quote run prevents a crafted message from
|
|
30
|
+
terminating the docstring early and injecting executable code.
|
|
31
|
+
"""
|
|
32
|
+
return text.replace('"', '\\"')
|
|
33
|
+
|
|
34
|
+
|
|
25
35
|
class RevisionNotFound(Exception):
|
|
26
36
|
pass
|
|
27
37
|
|
|
@@ -439,6 +449,7 @@ class ScriptDirectory:
|
|
|
439
449
|
branch_labels=branch_labels or [],
|
|
440
450
|
depends_on=depends_on or [],
|
|
441
451
|
message=message,
|
|
452
|
+
doc_message=_docstring_safe(message),
|
|
442
453
|
create_date=datetime.now(UTC),
|
|
443
454
|
upgrade_body=upgrade_body,
|
|
444
455
|
downgrade_body=downgrade_body,
|
|
@@ -396,6 +396,9 @@ def Relation( # noqa: N802
|
|
|
396
396
|
target="Person",
|
|
397
397
|
)
|
|
398
398
|
"""
|
|
399
|
+
from runic.cypher import validate_identifier
|
|
400
|
+
|
|
401
|
+
validate_identifier(relationship, "relationship type")
|
|
399
402
|
return FieldDescriptor(
|
|
400
403
|
relationship=relationship,
|
|
401
404
|
direction=direction,
|