Skip to content

Commit dbc83df

Browse files
authored
fix(parser)!: JSON path key quote granularity (#7758)
1 parent 17300b2 commit dbc83df

6 files changed

Lines changed: 33 additions & 28 deletions

File tree

sqlglot/dialects/dialect.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2134,10 +2134,9 @@ def _json_extract_segments(self: Generator, expression: JSON_EXTRACT_TYPE) -> st
21342134
if not isinstance(path, exp.JSONPath):
21352135
return rename_func(name)(self, expression)
21362136

2137-
escape = path.args.get("escape")
2138-
21392137
segments = []
21402138
for segment in path.expressions:
2139+
escape = segment.args.get("quoted")
21412140
path = self.sql(segment)
21422141
if path:
21432142
if isinstance(segment, exp.JSONPathPart) and (

sqlglot/expressions/query.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1967,7 +1967,7 @@ class JSON(Expression):
19671967

19681968

19691969
class JSONPath(Expression):
1970-
arg_types = {"expressions": True, "escape": False}
1970+
arg_types = {"expressions": True}
19711971

19721972
@property
19731973
def output_name(self) -> str:
@@ -1984,7 +1984,7 @@ class JSONPathFilter(JSONPathPart):
19841984

19851985

19861986
class JSONPathKey(JSONPathPart):
1987-
arg_types = {"this": True}
1987+
arg_types = {"this": True, "quoted": False}
19881988

19891989

19901990
class JSONPathRecursive(JSONPathPart):

sqlglot/generator.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,12 @@ class Generator:
489489
# Whether to escape keys using single quotes in JSON paths
490490
JSON_PATH_SINGLE_QUOTE_ESCAPE = False
491491

492+
# Whether a quoted JSON path key (e.g. from a quoted identifier or ['key'] bracket) must be
493+
# rendered in bracket form to preserve its case-sensitivity, even if it would otherwise match
494+
# SAFE_JSON_PATH_KEY_RE and render as a bare dotted key. Needed for dialects like Databricks
495+
# where a bare colon key is case-insensitive but a bracketed key is case-sensitive.
496+
JSON_PATH_KEY_QUOTED_FORCES_BRACKETS = False
497+
492498
# The JSONPathPart expressions supported by this dialect
493499
SUPPORTED_JSON_PATH_PARTS: t.ClassVar = ALL_JSON_PATH_PARTS.copy()
494500

@@ -3721,9 +3727,6 @@ def jsonkeyvalue_sql(self, expression: exp.JSONKeyValue) -> str:
37213727
def jsonpath_sql(self, expression: exp.JSONPath) -> str:
37223728
path = self.expressions(expression, sep="", flat=True).lstrip(".")
37233729

3724-
if expression.args.get("escape"):
3725-
path = self.escape_str(path)
3726-
37273730
if self.QUOTE_JSON_PATH:
37283731
path = f"{self.dialect.QUOTE_START}{path}{self.dialect.QUOTE_END}"
37293732

@@ -5238,10 +5241,20 @@ def _jsonpathkey_sql(self, expression: exp.JSONPathKey) -> str:
52385241
this = self.json_path_part(this)
52395242
return f".{this}" if this else ""
52405243

5241-
if self.SAFE_JSON_PATH_KEY_RE.match(this):
5244+
quoted = expression.args.get("quoted")
5245+
if not (
5246+
quoted and self.JSON_PATH_KEY_QUOTED_FORCES_BRACKETS
5247+
) and self.SAFE_JSON_PATH_KEY_RE.match(this):
52425248
return f".{this}"
52435249

52445250
this = self.json_path_part(this)
5251+
5252+
if quoted and self.QUOTE_JSON_PATH:
5253+
# The whole path is rendered as a single quoted string literal, so the bracketed key
5254+
# (which may itself contain backslash-escaped quotes, e.g. ["x \"y\"z"]) must be
5255+
# escaped again for the outer string literal (-> ["x \\"y\\"z"]).
5256+
this = self.escape_str(this)
5257+
52455258
return (
52465259
f"[{this}]"
52475260
if self._quote_json_path_key_using_brackets and self.JSON_PATH_BRACKETED_KEY_SUPPORTED

sqlglot/generators/databricks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class DatabricksGenerator(SparkGenerator):
1515
COPY_PARAMS_ARE_WRAPPED = False
1616
COPY_PARAMS_EQ_REQUIRED = True
1717
JSON_PATH_SINGLE_QUOTE_ESCAPE = False
18+
JSON_PATH_KEY_QUOTED_FORCES_BRACKETS = True
1819
SAFE_JSON_PATH_KEY_RE = exp.SAFE_IDENTIFIER_RE
1920
QUOTE_JSON_PATH = False
2021
PARSE_JSON_NAME: str | None = "PARSE_JSON"

sqlglot/parser.py

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6669,13 +6669,12 @@ def _build_json_extract(
66696669
self,
66706670
this: exp.Expr | None,
66716671
path_parts: list[exp.JSONPathPart],
6672-
escape: bool | None,
66736672
) -> tuple[exp.Expr | None, list[exp.JSONPathPart]]:
66746673
if len(path_parts) > 1:
66756674
this = self.expression(
66766675
exp.JSONExtract(
66776676
this=this,
6678-
expression=exp.JSONPath(expressions=path_parts, escape=escape),
6677+
expression=exp.JSONPath(expressions=path_parts),
66796678
variant_extract=True,
66806679
requires_json=self.JSON_EXTRACT_REQUIRES_JSON_EXPRESSION,
66816680
)
@@ -6686,28 +6685,24 @@ def _build_json_extract(
66866685

66876686
def _parse_colon_as_variant_extract(self, this: exp.Expr | None) -> exp.Expr | None:
66886687
path_parts: list[exp.JSONPathPart] = [exp.JSONPathRoot()]
6689-
escape = None
66906688

66916689
while self._match(TokenType.COLON):
66926690
if not self.COLON_CHAIN_IS_SINGLE_EXTRACT:
6693-
this, path_parts = self._build_json_extract(this, path_parts, escape)
6694-
escape = None
6691+
this, path_parts = self._build_json_extract(this, path_parts)
66956692

66966693
key = self._parse_id_var(any_token=True, tokens=(TokenType.SELECT,))
66976694

66986695
if key:
6699-
if isinstance(key, exp.Identifier) and key.quoted:
6700-
escape = True
6701-
path_parts.append(exp.JSONPathKey(this=key.name))
6696+
quoted = isinstance(key, exp.Identifier) and key.quoted
6697+
path_parts.append(exp.JSONPathKey(this=key.name, quoted=quoted))
67026698

67036699
while True:
67046700
if self._match(TokenType.DOT):
67056701
next_key = self._parse_id_var(any_token=True, tokens=(TokenType.SELECT,))
67066702

67076703
if next_key:
6708-
if isinstance(next_key, exp.Identifier) and next_key.quoted:
6709-
escape = True
6710-
path_parts.append(exp.JSONPathKey(this=next_key.name))
6704+
quoted = isinstance(next_key, exp.Identifier) and next_key.quoted
6705+
path_parts.append(exp.JSONPathKey(this=next_key.name, quoted=quoted))
67116706
elif self._match(TokenType.L_BRACKET):
67126707
bracket_expr = self._parse_bracket_key_value()
67136708

@@ -6716,15 +6711,13 @@ def _parse_colon_as_variant_extract(self, this: exp.Expr | None) -> exp.Expr | N
67166711

67176712
if bracket_expr:
67186713
if bracket_expr.is_string:
6719-
path_parts.append(exp.JSONPathKey(this=bracket_expr.name))
6720-
escape = True
6714+
path_parts.append(exp.JSONPathKey(this=bracket_expr.name, quoted=True))
67216715
elif bracket_expr.is_star:
67226716
path_parts.append(exp.JSONPathSubscript(this=exp.JSONPathWildcard()))
67236717
elif bracket_expr.is_number:
67246718
path_parts.append(exp.JSONPathSubscript(this=bracket_expr.to_py()))
67256719
else:
6726-
this, path_parts = self._build_json_extract(this, path_parts, escape)
6727-
escape = None
6720+
this, path_parts = self._build_json_extract(this, path_parts)
67286721

67296722
this = self.expression(
67306723
exp.Bracket(
@@ -6733,8 +6726,7 @@ def _parse_colon_as_variant_extract(self, this: exp.Expr | None) -> exp.Expr | N
67336726
)
67346727

67356728
elif self._match(TokenType.DCOLON):
6736-
this, path_parts = self._build_json_extract(this, path_parts, escape)
6737-
escape = None
6729+
this, path_parts = self._build_json_extract(this, path_parts)
67386730

67396731
cast_type = self._parse_types()
67406732
if cast_type:
@@ -6744,7 +6736,7 @@ def _parse_colon_as_variant_extract(self, this: exp.Expr | None) -> exp.Expr | N
67446736
else:
67456737
break
67466738

6747-
this, _ = self._build_json_extract(this, path_parts, escape)
6739+
this, _ = self._build_json_extract(this, path_parts)
67486740

67496741
return this
67506742

tests/dialects/test_databricks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ def test_json(self):
294294
)
295295
self.validate_identity(
296296
"""SELECT c1:['price'] FROM VALUES ('{ "price": 5 }') AS T(c1)""",
297-
"""SELECT c1:price FROM VALUES ('{ "price": 5 }') AS T(c1)""",
297+
"""SELECT c1:["price"] FROM VALUES ('{ "price": 5 }') AS T(c1)""",
298298
)
299299
self.validate_identity(
300300
"""SELECT GET_JSON_OBJECT(c1, '$.price') FROM VALUES ('{ "price": 5 }') AS T(c1)"""
@@ -304,7 +304,7 @@ def test_json(self):
304304
self.validate_identity("SELECT GET_JSON_OBJECT(GET_JSON_OBJECT(col, '$[0]'), '$.a')")
305305
self.validate_identity(
306306
"""SELECT raw:`zip code`, raw:`fb:testid`, raw:store['bicycle'], raw:store["zip code"]""",
307-
"""SELECT raw:["zip code"], raw:["fb:testid"], raw:store.bicycle, raw:store["zip code"]""",
307+
"""SELECT raw:["zip code"], raw:["fb:testid"], raw:store["bicycle"], raw:store["zip code"]""",
308308
)
309309
self.validate_all(
310310
"SELECT col:`fr'uit`",

0 commit comments

Comments
 (0)