Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/datajoint/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,7 +1138,12 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None)
path : str
JSON path (e.g., 'field' or 'nested.field').
return_type : str, optional
Return type specification (MySQL-specific).
Type to cast the extracted value to. Every backend accepts MySQL's
``JSON_VALUE ... RETURNING`` spellings (``signed``, ``unsigned``,
``decimal(p,s)``, ``char(n)``, ``double``, ``datetime``, ...) and the
common PostgreSQL spellings (``int``, ``integer``, ``bigint``,
``numeric(p,s)``, ``varchar(n)``, ``text``, ``boolean``,
``timestamp``, ...).

Returns
-------
Expand All @@ -1152,6 +1157,36 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None)
"""
...

@abstractmethod
def json_path_equals(self, column: str, path: str, value: str) -> str:
"""
Generate a condition comparing the JSON value at a path with a JSON literal.

The comparison is between JSON values, not text: numbers compare
numerically (``64`` equals ``64.0``), booleans match only JSON booleans,
and a value of another JSON type (e.g. a string) is simply unequal.

Parameters
----------
column : str
Column name containing JSON data.
path : str
JSON path (e.g., 'field' or 'nested.field').
value : str
JSON literal (e.g., '64', '2.5', 'true').

Returns
-------
str
Database-specific SQL condition.

Examples
--------
MySQL: json_extract(`column`, _utf8mb4'$.path') = cast('64' as json)
PostgreSQL: jsonb_extract_path("column", 'path') = '64'::jsonb
"""
...

def translate_expression(self, expr: str) -> str:
"""
Translate SQL expression for backend compatibility.
Expand Down
68 changes: 66 additions & 2 deletions src/datajoint/adapters/mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import re
from typing import Any

import pymysql as client
Expand Down Expand Up @@ -44,6 +45,25 @@
"date": "date",
}

# JSON path type annotations (``attr.path:type``) that PostgreSQL accepts but
# MySQL's ``JSON_VALUE ... RETURNING`` does not, mapped onto the RETURNING
# vocabulary so the same annotation works on both backends. Annotations not
# listed here are passed through unchanged.
JSON_RETURN_TYPE_MAP = {
"int": "signed",
"integer": "signed",
"smallint": "signed",
"bigint": "signed",
"bool": "signed",
"boolean": "signed",
"numeric": "decimal",
"double precision": "double",
"varchar": "char",
"text": "char",
"timestamp": "datetime",
}
_JSON_RETURN_TYPE_PATTERN = re.compile(r"\s*(?P<name>[a-z]+(?:\s+[a-z]+)?)\s*(?P<args>\(.*\))?\s*")


class MySQLAdapter(DatabaseAdapter):
"""MySQL database adapter implementation."""
Expand Down Expand Up @@ -856,7 +876,9 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None)
path : str
JSON path (e.g., 'field' or 'nested.field').
return_type : str, optional
Return type specification (e.g., 'decimal(10,2)').
Return type specification (e.g., 'decimal(10,2)'). PostgreSQL
spellings such as 'int' or 'numeric(10,2)' are mapped onto the
RETURNING vocabulary (see ``JSON_RETURN_TYPE_MAP``).

Returns
-------
Expand All @@ -869,11 +891,53 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None)
"json_value(`data`, _utf8mb4'$.field')"
>>> adapter.json_path_expr('data', 'value', 'decimal(10,2)')
"json_value(`data`, _utf8mb4'$.value' returning decimal(10,2))"
>>> adapter.json_path_expr('data', 'n', 'int')
"json_value(`data`, _utf8mb4'$.n' returning signed)"
"""
quoted_col = self.quote_identifier(column)
return_clause = f" returning {return_type}" if return_type else ""
return_clause = f" returning {self._json_return_type(return_type)}" if return_type else ""
return f"json_value({quoted_col}, _utf8mb4'$.{path}'{return_clause})"

@staticmethod
def _json_return_type(return_type: str) -> str:
"""Map a JSON path type annotation onto MySQL's RETURNING vocabulary."""
match = _JSON_RETURN_TYPE_PATTERN.fullmatch(return_type.lower())
if match is None:
return return_type
name = " ".join(match["name"].split())
if name not in JSON_RETURN_TYPE_MAP:
return return_type
mysql_type = JSON_RETURN_TYPE_MAP[name]
# Integer display widths (e.g. int(11)) have no RETURNING equivalent; other
# arguments (precision, scale, length) carry over.
return mysql_type if mysql_type == "signed" else mysql_type + (match["args"] or "")

def json_path_equals(self, column: str, path: str, value: str) -> str:
"""
Generate a condition comparing the JSON value at a path with a JSON literal.

Parameters
----------
column : str
Column name containing JSON data.
path : str
JSON path (e.g., 'field' or 'nested.field').
value : str
JSON literal (e.g., '64', '2.5', 'true').

Returns
-------
str
MySQL condition comparing two JSON values.

Examples
--------
>>> adapter.json_path_equals('data', 'n', '64')
"json_extract(`data`, _utf8mb4'$.n') = cast('64' as json)"
"""
quoted_col = self.quote_identifier(column)
return f"json_extract({quoted_col}, _utf8mb4'$.{path}') = cast('{value}' as json)"

def translate_expression(self, expr: str) -> str:
"""
Translate SQL expression for MySQL compatibility.
Expand Down
56 changes: 44 additions & 12 deletions src/datajoint/adapters/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,23 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None)
'jsonb_extract_path_text("data", \\'value\\')::float'
"""
quoted_col = self.quote_identifier(column)
expr = f"jsonb_extract_path_text({quoted_col}, {self._json_path_args(path)})"
# Add cast if return type specified
if return_type:
# Map DataJoint types to PostgreSQL types
pg_type = return_type.lower()
if pg_type in ("unsigned", "signed"):
pg_type = "integer"
elif pg_type == "double":
pg_type = "double precision"
elif pg_type.startswith("datetime"):
pg_type = "timestamp" + pg_type[len("datetime") :]
expr = f"({expr})::{pg_type}"
return expr

@staticmethod
def _json_path_args(path: str) -> str:
"""Split a JSON path into quoted jsonb_extract_path() arguments."""
# Split path by '.' for nested access, handling array notation
path_parts = []
for part in path.split("."):
Expand All @@ -1115,18 +1132,33 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None)
path_parts.extend(indices)
else:
path_parts.append(part)
path_args = ", ".join(f"'{part}'" for part in path_parts)
expr = f"jsonb_extract_path_text({quoted_col}, {path_args})"
# Add cast if return type specified
if return_type:
# Map DataJoint types to PostgreSQL types
pg_type = return_type.lower()
if pg_type in ("unsigned", "signed"):
pg_type = "integer"
elif pg_type == "double":
pg_type = "double precision"
expr = f"({expr})::{pg_type}"
return expr
return ", ".join(f"'{part}'" for part in path_parts)

def json_path_equals(self, column: str, path: str, value: str) -> str:
"""
Generate a condition comparing the JSON value at a path with a JSON literal.

Parameters
----------
column : str
Column name containing JSON data.
path : str
JSON path (e.g., 'field' or 'nested.field').
value : str
JSON literal (e.g., '64', '2.5', 'true').

Returns
-------
str
PostgreSQL condition comparing two jsonb values.

Examples
--------
>>> adapter.json_path_equals('data', 'n', '64')
'jsonb_extract_path("data", \\'n\\') = \\'64\\'::jsonb'
"""
quoted_col = self.quote_identifier(column)
return f"jsonb_extract_path({quoted_col}, {self._json_path_args(path)}) = '{value}'::jsonb"

def translate_expression(self, expr: str) -> str:
"""
Expand Down
40 changes: 40 additions & 0 deletions src/datajoint/condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import inspect
import json
import logging
import math
import re
import uuid
from dataclasses import dataclass
Expand Down Expand Up @@ -66,6 +67,40 @@ def translate_attribute(key: str, adapter=None) -> tuple[dict | None, str]:
)


# Python values that a JSON path restriction compares as JSON values, not as text
_JSON_SCALAR_TYPES = (bool, numpy.bool_, int, numpy.integer, float, numpy.floating, decimal.Decimal)


def _json_literal(value: Any) -> str:
"""
Render a bool or number as a JSON literal for a JSON path comparison.

Parameters
----------
value : bool, int, float, or decimal.Decimal
Value to render (numpy scalars included).

Returns
-------
str
JSON literal, e.g. ``'true'``, ``'64'``, ``'2.5'``.

Raises
------
DataJointError
If the value is NaN or infinite, which JSON cannot represent.
"""
if isinstance(value, (bool, numpy.bool_)):
return "true" if value else "false"
if isinstance(value, (int, numpy.integer)):
return str(int(value))
if isinstance(value, decimal.Decimal) and value.is_finite():
return str(value)
if isinstance(value, (float, numpy.floating)) and math.isfinite(value):
return json.dumps(float(value))
raise DataJointError(f"Cannot restrict a JSON path by the non-finite number {value}.")


class PromiscuousOperand:
"""
Wrapper to bypass join compatibility checking.
Expand Down Expand Up @@ -344,6 +379,11 @@ def prep_value(k, v):
return f"{k}='{json.dumps(v)}'"
if v is None:
return f"{k} IS NULL"
if is_json_path and not key_match["type"] and isinstance(v, _JSON_SCALAR_TYPES):
# The extracted value is text: PostgreSQL refuses to compare it with a
# bool or number, and MySQL matches no bool. Compare JSON values instead,
# which gives the same result on both (64 matches 64.0, true only true).
return adapter.json_path_equals(key_match["attr"], key_match["path"], _json_literal(v))
if key_match is not None and query_expression.heading[key_match["attr"]].uuid:
if not isinstance(v, uuid.UUID):
try:
Expand Down
Loading