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.
Files changed (71) hide show
  1. {runic_py-0.3.4 → runic_py-0.3.6}/.gitignore +5 -0
  2. {runic_py-0.3.4 → runic_py-0.3.6}/PKG-INFO +3 -3
  3. {runic_py-0.3.4 → runic_py-0.3.6}/README.md +2 -2
  4. {runic_py-0.3.4 → runic_py-0.3.6}/pyproject.toml +4 -23
  5. runic_py-0.3.6/runic/cypher.py +76 -0
  6. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/_base.py +1 -1
  7. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/age.py +13 -4
  8. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/falkordb.py +47 -17
  9. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/memgraph.py +7 -3
  10. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/neo4j.py +7 -3
  11. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/autogen.py +15 -15
  12. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/cli.py +21 -1
  13. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/context.py +4 -1
  14. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/introspect.py +14 -7
  15. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/script.py +11 -0
  16. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/templates/script.py.mako +1 -1
  17. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/core/descriptors.py +3 -0
  18. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/core/metadata.py +5 -0
  19. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/core/types.py +10 -2
  20. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/age.py +24 -2
  21. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/bolt.py +14 -2
  22. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/mapper/mapper.py +24 -43
  23. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/operations.py +6 -0
  24. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/_compiler.py +1 -1
  25. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/builder.py +56 -38
  26. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/specialised.py +13 -61
  27. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/schema/index_manager.py +1 -1
  28. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/session/async_session.py +26 -9
  29. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/session/session.py +18 -6
  30. {runic_py-0.3.4 → runic_py-0.3.6}/LICENSE.md +0 -0
  31. {runic_py-0.3.4 → runic_py-0.3.6}/runic/__init__.py +0 -0
  32. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/__init__.py +0 -0
  33. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/__init__.py +0 -0
  34. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/adapters/arcadedb.py +0 -0
  35. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/checksum.py +0 -0
  36. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/config.py +0 -0
  37. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/exceptions.py +0 -0
  38. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/manifest.py +0 -0
  39. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/operations.py +0 -0
  40. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/schema.py +0 -0
  41. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/service.py +0 -0
  42. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/templates/env.py.mako +0 -0
  43. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/testing.py +0 -0
  44. {runic_py-0.3.4 → runic_py-0.3.6}/runic/migrate/version.py +0 -0
  45. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/__init__.py +0 -0
  46. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/core/__init__.py +0 -0
  47. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/core/models.py +0 -0
  48. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/__init__.py +0 -0
  49. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/arcadedb.py +0 -0
  50. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/factory.py +0 -0
  51. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/falkordb.py +0 -0
  52. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/memgraph.py +0 -0
  53. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/driver/neo4j.py +0 -0
  54. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/exceptions.py +0 -0
  55. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/mapper/__init__.py +0 -0
  56. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/mapper/relationship_loader.py +0 -0
  57. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/mapper/relationship_writer.py +0 -0
  58. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/__init__.py +0 -0
  59. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/_decoder.py +0 -0
  60. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/expressions.py +0 -0
  61. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/query/traversal.py +0 -0
  62. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/__init__.py +0 -0
  63. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/_base.py +0 -0
  64. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/async_repository.py +0 -0
  65. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/cypher.py +0 -0
  66. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/protocol.py +0 -0
  67. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/repository/repository.py +0 -0
  68. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/schema/__init__.py +0 -0
  69. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/session/__init__.py +0 -0
  70. {runic_py-0.3.4 → runic_py-0.3.6}/runic/ogm/session/_base.py +0 -0
  71. {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.4
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/source/_static/runic.svg" width="240" alt="Runic logo">
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
- ![Version](https://img.shields.io/badge/version-0.3.4-blue)
45
+ ![Version](https://img.shields.io/badge/version-0.3.6-blue)
46
46
  [![PyPI](https://img.shields.io/pypi/v/runic-py.svg)](https://pypi.org/project/runic-py/)
47
47
  [![Python](https://img.shields.io/badge/python-3.14%2B-orange)](https://www.python.org)
48
48
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE.md)
@@ -1,11 +1,11 @@
1
1
  <div align="center">
2
- <img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/source/_static/runic.svg" width="240" alt="Runic logo">
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
- ![Version](https://img.shields.io/badge/version-0.3.4-blue)
8
+ ![Version](https://img.shields.io/badge/version-0.3.6-blue)
9
9
  [![PyPI](https://img.shields.io/pypi/v/runic-py.svg)](https://pypi.org/project/runic-py/)
10
10
  [![Python](https://img.shields.io/badge/python-3.14%2B-orange)](https://www.python.org)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE.md)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runic-py"
3
- version = "0.3.4"
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.5",
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.44",
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.4"
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 = '![Version](https://img.shields.io/badge/version-{current_version}-blue)'
108
108
  replace = '![Version](https://img.shields.io/badge/version-{new_version}-blue)'
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
- result = self._driver.execute(cypher, params)
61
- self._driver.commit()
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
- self._driver.commit()
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:{label}]->() ON (r.{prop})"
208
+ query = f"CREATE INDEX FOR ()-[r:{label_id}]->() ON (r.{prop_id})"
202
209
  else:
203
- query = f"CREATE INDEX FOR (n:{label}) ON (n.{prop})"
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: '{label}'"]
232
+ map_parts = [f"label: {escape_string(label)}"]
225
233
  if language:
226
- map_parts.append(f"language: '{language}'")
234
+ map_parts.append(f"language: {escape_string(language)}")
227
235
  if stopwords:
228
- sw = "[" + ", ".join(f"'{w}'" for w in stopwords) + "]"
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
- props_str = ", ".join(f"'{p}'" for p in props)
235
- query = f"CALL db.idx.fulltext.createNodeIndex('{label}', {props_str})"
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:{label}) ON (n.{prop})"
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: '{similarity}', "
279
+ f"{{dimension: {dimension}, similarityFunction: {escape_string(similarity)}, "
262
280
  f"M: {m}, efConstruction: {ef_construction}, efRuntime: {ef_runtime}}}"
263
281
  )
264
- query = f"CREATE VECTOR INDEX FOR (n:{label}) ON (n.{prop}) OPTIONS {options}"
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 = f"DROP VECTOR INDEX FOR (n:{label}) (n.{prop})"
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
- label,
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
- entry = row[0]
303
- status = entry[4] if isinstance(entry, (list, tuple)) else str(entry)
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
- label,
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=False,
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
- log.warning("Memgraph SHOW INDEX INFO failed: %s", exc)
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.warning("Memgraph SHOW CONSTRAINT INFO failed: %s", exc)
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=True,
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
- log.warning("Neo4j SHOW INDEXES failed: %s", exc)
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.warning("Neo4j SHOW CONSTRAINTS failed: %s", exc)
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'op.create_range_index("{idx.label}", "{idx.prop}"{rel_arg})'
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'op.drop_range_index("{idx.label}", "{idx.prop}"{rel_arg})'
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(f'"{p}"' for p in idx.props)
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'language="{idx.language}"')
71
+ extra.append(f"language={idx.language!r}")
72
72
  if idx.stopwords:
73
- sw = "[" + ", ".join(f'"{w}"' for w in idx.stopwords) + "]"
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'op.create_fulltext_index("{idx.label}", {props_args}{extra_str})'
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(f'"{p}"' for p in idx.props)
81
- return f'op.drop_fulltext_index("{idx.label}", {props_args})'
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'op.create_vector_index("{idx.label}", "{idx.prop}", '
87
- f'{idx.dimension}, "{idx.similarity}", '
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'op.drop_vector_index("{idx.label}", "{idx.prop}")'
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(f'"{p}"' for p in c.props) + "]"
99
- return f'op.create_constraint("{kind}", "{c.entity}", "{c.label}", {props_repr})'
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(f'"{p}"' for p in c.props) + "]"
105
- return f'op.drop_constraint("{kind}", "{c.entity}", "{c.label}", {props_repr})'
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.exists():
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 Exception:
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
- sw = tuple(stopwords) if stopwords else None
477
- lang = language if language and language != "english" else None
478
- fulltext_indexes.append(
479
- FulltextIndex(
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,
@@ -1,4 +1,4 @@
1
- """${message}
1
+ """${doc_message}
2
2
 
3
3
  Revision ID: ${up_revision}
4
4
  Revises: ${down_revision}
@@ -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,