Skip to content

Commit 14280d4

Browse files
committed
Support postgres omitted interval span unit
1 parent fc5624e commit 14280d4

File tree

5 files changed

+86
-6
lines changed

5 files changed

+86
-6
lines changed

sqlglot/dialects/postgres.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,8 @@ class Tokenizer(tokens.Tokenizer):
375375
VAR_SINGLE_TOKENS = {"$"}
376376

377377
class Parser(parser.Parser):
378+
SUPPORTS_OMITTED_INTERVAL_SPAN_UNIT = True
379+
378380
PROPERTY_PARSERS = {
379381
**parser.Parser.PROPERTY_PARSERS,
380382
"SET": lambda self: self.expression(exp.SetConfigProperty, this=self._parse_set()),

sqlglot/expressions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8561,6 +8561,10 @@ def parse_identifier(name: str | Identifier, dialect: DialectType = None) -> Ide
85618561

85628562
INTERVAL_STRING_RE = re.compile(r"\s*(-?[0-9]+(?:\.[0-9]+)?)\s*([a-zA-Z]+)\s*")
85638563

8564+
# Matches day-time interval strings that contain a number of days, a space,
8565+
# and a time component in the format hh:[mm:[ss[.ff]]]
8566+
INTERVAL_DAY_TIME_RE = re.compile(r"^\s*(-?[0-9]+)\s+(\d{1,2}:\d{0,2}(?::\d{0,2}(?:\.\d+)?)?)\s*$")
8567+
85648568

85658569
def to_interval(interval: str | Literal) -> Interval:
85668570
"""Builds an interval expression from a string like '1 day' or '5 months'."""

sqlglot/generator.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3275,14 +3275,19 @@ def in_unnest_op(self, unnest: exp.Unnest) -> str:
32753275
return f"(SELECT {self.sql(unnest)})"
32763276

32773277
def interval_sql(self, expression: exp.Interval) -> str:
3278-
unit = self.sql(expression, "unit")
3278+
unit_expression = expression.args.get("unit")
3279+
unit = self.sql(unit_expression) if unit_expression else ""
32793280
if not self.INTERVAL_ALLOWS_PLURAL_FORM:
32803281
unit = self.TIME_PART_SINGULARS.get(unit, unit)
32813282
unit = f" {unit}" if unit else ""
32823283

32833284
if self.SINGLE_STRING_INTERVAL:
32843285
this = expression.this.name if expression.this else ""
3285-
return f"INTERVAL '{this}{unit}'" if this else f"INTERVAL{unit}"
3286+
if this:
3287+
if unit and isinstance(unit_expression, exp.IntervalSpan):
3288+
return f"INTERVAL '{this}'{unit}"
3289+
return f"INTERVAL '{this}{unit}'"
3290+
return f"INTERVAL{unit}"
32863291

32873292
this = self.sql(expression, "this")
32883293
if this:

sqlglot/parser.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1561,6 +1561,10 @@ def _parse_partitioned_by_bucket_or_truncate(self) -> t.Optional[exp.Expression]
15611561
# Adding an ON TRUE, makes transpilation semantically correct for other dialects
15621562
ADD_JOIN_ON_TRUE = False
15631563

1564+
# Whether INTERVAL spans with literal format '\d+ hh:[mm:[ss[.ff]]]'
1565+
# can omit the span unit `DAY TO MINUTE` or `DAY TO SECOND`
1566+
SUPPORTS_OMITTED_INTERVAL_SPAN_UNIT = False
1567+
15641568
__slots__ = (
15651569
"error_level",
15661570
"error_message_context",
@@ -5105,9 +5109,38 @@ def _parse_interval(self, match_interval: bool = True) -> t.Optional[exp.Add | e
51055109
self._retreat(index)
51065110
return None
51075111

5108-
unit = self._parse_function() or (
5109-
not self._match(TokenType.ALIAS, advance=False)
5110-
and self._parse_var(any_token=True, upper=True)
5112+
# detect day-time interval span with omitted units:
5113+
# INTERVAL '<days> <time with colon>' [maybe explicit span `unit TO unit`]
5114+
infer_interval_span_units = False
5115+
if (
5116+
this
5117+
and this.is_string
5118+
and self.INTERVAL_SPANS
5119+
and self.SUPPORTS_OMITTED_INTERVAL_SPAN_UNIT
5120+
):
5121+
day_time_format_literal = exp.INTERVAL_DAY_TIME_RE.match(this.name)
5122+
if day_time_format_literal:
5123+
index = self._index
5124+
5125+
# Var "TO" Var
5126+
first_unit = self._parse_var(any_token=True, upper=True)
5127+
second_unit = None
5128+
if first_unit and self._match_text_seq("TO"):
5129+
second_unit = self._parse_var(any_token=True, upper=True)
5130+
5131+
self._retreat(index)
5132+
infer_interval_span_units = not (first_unit and second_unit)
5133+
5134+
unit = (
5135+
None
5136+
if infer_interval_span_units
5137+
else (
5138+
self._parse_function()
5139+
or (
5140+
not self._match(TokenType.ALIAS, advance=False)
5141+
and self._parse_var(any_token=True, upper=True)
5142+
)
5143+
)
51115144
)
51125145

51135146
# Most dialects support, e.g., the form INTERVAL '5' day, thus we try to parse
@@ -5124,7 +5157,27 @@ def _parse_interval(self, match_interval: bool = True) -> t.Optional[exp.Add | e
51245157
if len(parts) == 1:
51255158
this = exp.Literal.string(parts[0][0])
51265159
unit = self.expression(exp.Var, this=parts[0][1].upper())
5127-
if self.INTERVAL_SPANS and self._match_text_seq("TO"):
5160+
5161+
# infer DAY TO MINUTE/SECOND omitted span units
5162+
if (
5163+
self.INTERVAL_SPANS
5164+
and self.SUPPORTS_OMITTED_INTERVAL_SPAN_UNIT
5165+
and day_time_format_literal
5166+
):
5167+
time_part = day_time_format_literal.group(2)
5168+
5169+
if infer_interval_span_units:
5170+
seconds_present = time_part.count(":") >= 2 or "." in time_part
5171+
unit = self.expression(
5172+
exp.IntervalSpan,
5173+
this=exp.var("DAY"),
5174+
expression=exp.var("SECOND" if seconds_present else "MINUTE"),
5175+
)
5176+
if (
5177+
self.INTERVAL_SPANS
5178+
and self._match_text_seq("TO")
5179+
and not isinstance(unit, exp.IntervalSpan)
5180+
):
51285181
unit = self.expression(
51295182
exp.IntervalSpan, this=unit, expression=self._parse_var(any_token=True, upper=True)
51305183
)

tests/dialects/test_postgres.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,3 +1637,19 @@ def test_begin_transaction(self):
16371637
self.validate_identity(
16381638
f"BEGIN {keyword} {level}, {level}", f"BEGIN {level}, {level}"
16391639
).assert_is(exp.Transaction)
1640+
1641+
def test_interval_span(self):
1642+
for time_str in ["1 01:", "1 01:", "1 01:00", "1 01:00"]:
1643+
self.validate_identity(f"INTERVAL '{time_str}'", f"INTERVAL '{time_str}' DAY TO MINUTE")
1644+
# explicit SECOND overrides inference to MINUTE
1645+
self.validate_identity(f"INTERVAL '{time_str}' DAY TO SECOND")
1646+
1647+
for time_str in ["1 01:01:", "1 01:01:", "1 01:01:01", "1 01:01:01.01"]:
1648+
self.validate_identity(f"INTERVAL '{time_str}'", f"INTERVAL '{time_str}' DAY TO SECOND")
1649+
# explicit MINUTE overrides inference to SECOND
1650+
self.validate_identity(f"INTERVAL '{time_str}' DAY TO MINUTE")
1651+
1652+
# Ensure AND is not consumed as a unit following an omitted-span interval
1653+
day_time_str = "a > INTERVAL '1 00:00' AND TRUE"
1654+
self.validate_identity(day_time_str, "a > INTERVAL '1 00:00' DAY TO MINUTE AND TRUE")
1655+
self.assertIsInstance(self.parse_one(day_time_str), exp.And)

0 commit comments

Comments
 (0)