Skip to content

Commit d3c7afb

Browse files
fix: Backend-agnostic JSON path expressions
- Updated translate_attribute() to accept optional adapter parameter - PostgreSQL adapter's json_path_expr() now handles array notation and type casting - Pass adapter to translate_attribute() in condition.py, declare.py, expression.py This enables basic JSON operations to work on both MySQL and PostgreSQL. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 94fa4a8 commit d3c7afb

File tree

4 files changed

+49
-16
lines changed

4 files changed

+49
-16
lines changed

src/datajoint/adapters/postgres.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,26 +1004,47 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None)
10041004
path : str
10051005
JSON path (e.g., 'field' or 'nested.field').
10061006
return_type : str, optional
1007-
Return type specification (not used in PostgreSQL jsonb_extract_path_text).
1007+
Return type specification for casting (e.g., 'float', 'decimal(10,2)').
10081008
10091009
Returns
10101010
-------
10111011
str
1012-
PostgreSQL jsonb_extract_path_text() expression.
1012+
PostgreSQL jsonb_extract_path_text() expression, with optional cast.
10131013
10141014
Examples
10151015
--------
10161016
>>> adapter.json_path_expr('data', 'field')
10171017
'jsonb_extract_path_text("data", \\'field\\')'
10181018
>>> adapter.json_path_expr('data', 'nested.field')
10191019
'jsonb_extract_path_text("data", \\'nested\\', \\'field\\')'
1020+
>>> adapter.json_path_expr('data', 'value', 'float')
1021+
'jsonb_extract_path_text("data", \\'value\\')::float'
10201022
"""
10211023
quoted_col = self.quote_identifier(column)
1022-
# Split path by '.' for nested access
1023-
path_parts = path.split(".")
1024+
# Split path by '.' for nested access, handling array notation
1025+
path_parts = []
1026+
for part in path.split("."):
1027+
# Handle array access like field[0]
1028+
if "[" in part:
1029+
base, rest = part.split("[", 1)
1030+
path_parts.append(base)
1031+
# Extract array indices
1032+
indices = rest.rstrip("]").split("][")
1033+
path_parts.extend(indices)
1034+
else:
1035+
path_parts.append(part)
10241036
path_args = ", ".join(f"'{part}'" for part in path_parts)
1025-
# Note: PostgreSQL jsonb_extract_path_text doesn't use return type parameter
1026-
return f"jsonb_extract_path_text({quoted_col}, {path_args})"
1037+
expr = f"jsonb_extract_path_text({quoted_col}, {path_args})"
1038+
# Add cast if return type specified
1039+
if return_type:
1040+
# Map DataJoint types to PostgreSQL types
1041+
pg_type = return_type.lower()
1042+
if pg_type in ("unsigned", "signed"):
1043+
pg_type = "integer"
1044+
elif pg_type == "double":
1045+
pg_type = "double precision"
1046+
expr = f"({expr})::{pg_type}"
1047+
return expr
10271048

10281049
def translate_expression(self, expr: str) -> str:
10291050
"""

src/datajoint/condition.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,17 @@
3131
JSON_PATTERN = re.compile(r"^(?P<attr>\w+)(\.(?P<path>[\w.*\[\]]+))?(:(?P<type>[\w(,\s)]+))?$")
3232

3333

34-
def translate_attribute(key: str) -> tuple[dict | None, str]:
34+
def translate_attribute(key: str, adapter=None) -> tuple[dict | None, str]:
3535
"""
3636
Translate an attribute key, handling JSON path notation.
3737
3838
Parameters
3939
----------
4040
key : str
4141
Attribute name, optionally with JSON path (e.g., ``"attr.path.field"``).
42+
adapter : DatabaseAdapter, optional
43+
Database adapter for backend-specific SQL generation.
44+
If not provided, uses MySQL syntax for backward compatibility.
4245
4346
Returns
4447
-------
@@ -53,9 +56,14 @@ def translate_attribute(key: str) -> tuple[dict | None, str]:
5356
if match["path"] is None:
5457
return match, match["attr"]
5558
else:
56-
return match, "json_value(`{}`, _utf8mb4'$.{}'{})".format(
57-
*[((f" returning {v}" if k == "type" else v) if v else "") for k, v in match.items()]
58-
)
59+
# Use adapter's json_path_expr if available, otherwise fall back to MySQL syntax
60+
if adapter is not None:
61+
return match, adapter.json_path_expr(match["attr"], match["path"], match["type"])
62+
else:
63+
# Legacy MySQL syntax for backward compatibility
64+
return match, "json_value(`{}`, _utf8mb4'$.{}'{})".format(
65+
*[((f" returning {v}" if k == "type" else v) if v else "") for k, v in match.items()]
66+
)
5967

6068

6169
class PromiscuousOperand:
@@ -306,14 +314,17 @@ def make_condition(
306314

307315
def prep_value(k, v):
308316
"""prepare SQL condition"""
309-
key_match, k = translate_attribute(k)
310-
if key_match["path"] is None:
317+
key_match, k = translate_attribute(k, adapter)
318+
is_json_path = key_match is not None and key_match.get("path") is not None
319+
has_explicit_type = key_match is not None and key_match.get("type") is not None
320+
321+
if not is_json_path:
311322
k = adapter.quote_identifier(k)
312-
if query_expression.heading[key_match["attr"]].json and key_match["path"] is not None and isinstance(v, dict):
323+
if is_json_path and isinstance(v, dict):
313324
return f"{k}='{json.dumps(v)}'"
314325
if v is None:
315326
return f"{k} IS NULL"
316-
if query_expression.heading[key_match["attr"]].uuid:
327+
if key_match is not None and query_expression.heading[key_match["attr"]].uuid:
317328
if not isinstance(v, uuid.UUID):
318329
try:
319330
v = uuid.UUID(v)

src/datajoint/declare.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,7 @@ def compile_index(line: str, index_sql: list[str], adapter) -> None:
755755
"""
756756

757757
def format_attribute(attr):
758-
match, attr = translate_attribute(attr)
758+
match, attr = translate_attribute(attr, adapter)
759759
if match is None:
760760
return attr
761761
if match["path"] is None:

src/datajoint/expression.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,8 @@ def proj(self, *attributes, **named_attributes):
457457
from other attributes available before the projection.
458458
Each attribute name can only be used once.
459459
"""
460-
named_attributes = {k: translate_attribute(v)[1] for k, v in named_attributes.items()}
460+
adapter = self.connection.adapter if hasattr(self, 'connection') and self.connection else None
461+
named_attributes = {k: translate_attribute(v, adapter)[1] for k, v in named_attributes.items()}
461462
# new attributes in parentheses are included again with the new name without removing original
462463
duplication_pattern = re.compile(rf"^\s*\(\s*(?!{'|'.join(CONSTANT_LITERALS)})(?P<name>[a-zA-Z_]\w*)\s*\)\s*$")
463464
# attributes without parentheses renamed

0 commit comments

Comments
 (0)