Skip to content
Merged
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
2 changes: 2 additions & 0 deletions sqlglot/dialects/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,8 @@ class Tokenizer(tokens.Tokenizer):
VAR_SINGLE_TOKENS = {"$"}

class Parser(parser.Parser):
SUPPORTS_OMITTED_INTERVAL_SPAN_UNIT = True

PROPERTY_PARSERS = {
**parser.Parser.PROPERTY_PARSERS,
"SET": lambda self: self.expression(exp.SetConfigProperty, this=self._parse_set()),
Expand Down
13 changes: 13 additions & 0 deletions sqlglot/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8581,6 +8581,19 @@ def parse_identifier(name: str | Identifier, dialect: DialectType = None) -> Ide

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

# Matches day-time interval strings that contain
# - A number of days (possibly negative or with decimals)
# - At least one space
# - Portions of a time-like signature, potentially negative
# - Standard format [-]h+:m+:s+[.f+]
# - Just minutes/seconds/frac seconds [-]m+:s+.f+
# - Just hours, minutes, maybe colon [-]h+:m+[:]
# - Just hours, maybe colon [-]h+[:]
# - Just colon :
INTERVAL_DAY_TIME_RE = re.compile(
r"\s*-?\s*\d+(?:\.\d+)?\s+(?:-?(?:\d+:)?\d+:\d+(?:\.\d+)?|-?(?:\d+:){1,2}|:)\s*"
)


def to_interval(interval: str | Literal) -> Interval:
"""Builds an interval expression from a string like '1 day' or '5 months'."""
Expand Down
9 changes: 7 additions & 2 deletions sqlglot/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3275,14 +3275,19 @@ def in_unnest_op(self, unnest: exp.Unnest) -> str:
return f"(SELECT {self.sql(unnest)})"

def interval_sql(self, expression: exp.Interval) -> str:
unit = self.sql(expression, "unit")
unit_expression = expression.args.get("unit")
unit = self.sql(unit_expression) if unit_expression else ""
if not self.INTERVAL_ALLOWS_PLURAL_FORM:
unit = self.TIME_PART_SINGULARS.get(unit, unit)
unit = f" {unit}" if unit else ""

if self.SINGLE_STRING_INTERVAL:
this = expression.this.name if expression.this else ""
return f"INTERVAL '{this}{unit}'" if this else f"INTERVAL{unit}"
if this:
if unit_expression and isinstance(unit_expression, exp.IntervalSpan):
return f"INTERVAL '{this}'{unit}"
return f"INTERVAL '{this}{unit}'"
return f"INTERVAL{unit}"

this = self.sql(expression, "this")
if this:
Expand Down
39 changes: 36 additions & 3 deletions sqlglot/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,10 @@ def _parse_partitioned_by_bucket_or_truncate(self) -> t.Optional[exp.Expression]
# Adding an ON TRUE, makes transpilation semantically correct for other dialects
ADD_JOIN_ON_TRUE = False

# Whether INTERVAL spans with literal format '\d+ hh:[mm:[ss[.ff]]]'
# can omit the span unit `DAY TO MINUTE` or `DAY TO SECOND`
SUPPORTS_OMITTED_INTERVAL_SPAN_UNIT = False

__slots__ = (
"error_level",
"error_message_context",
Expand Down Expand Up @@ -5105,9 +5109,37 @@ def _parse_interval(self, match_interval: bool = True) -> t.Optional[exp.Add | e
self._retreat(index)
return None

unit = self._parse_function() or (
not self._match(TokenType.ALIAS, advance=False)
and self._parse_var(any_token=True, upper=True)
# handle day-time format interval span with omitted units:
# INTERVAL '<number days> hh[:][mm[:ss[.ff]]]' <maybe `unit TO unit`>
interval_span_units_omitted = None
if (
this
and this.is_string
and self.SUPPORTS_OMITTED_INTERVAL_SPAN_UNIT
and exp.INTERVAL_DAY_TIME_RE.match(this.name)
):
index = self._index

# Var "TO" Var
first_unit = self._parse_var(any_token=True, upper=True)
second_unit = None
if first_unit and self._match_text_seq("TO"):
second_unit = self._parse_var(any_token=True, upper=True)

interval_span_units_omitted = not (first_unit and second_unit)

self._retreat(index)

unit = (
None
if interval_span_units_omitted
else (
self._parse_function()
or (
not self._match(TokenType.ALIAS, advance=False)
and self._parse_var(any_token=True, upper=True)
)
)
)

# Most dialects support, e.g., the form INTERVAL '5' day, thus we try to parse
Expand All @@ -5124,6 +5156,7 @@ def _parse_interval(self, match_interval: bool = True) -> t.Optional[exp.Add | e
if len(parts) == 1:
this = exp.Literal.string(parts[0][0])
unit = self.expression(exp.Var, this=parts[0][1].upper())

if self.INTERVAL_SPANS and self._match_text_seq("TO"):
unit = self.expression(
exp.IntervalSpan, this=unit, expression=self._parse_var(any_token=True, upper=True)
Expand Down
22 changes: 22 additions & 0 deletions tests/dialects/test_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -1641,3 +1641,25 @@ def test_begin_transaction(self):
self.validate_identity(
f"BEGIN {keyword} {level}, {level}", f"BEGIN {level}, {level}"
).assert_is(exp.Transaction)

def test_interval_span(self):
for time_str in ["1 01:", "1 01:00", "1.5 01:", "-0.25 01:"]:
with self.subTest(f"Postgres INTERVAL span, omitted DAY TO MINUTE unit: {time_str}"):
self.validate_identity(f"INTERVAL '{time_str}'")

for time_str in [
"1 01:01:",
"1 01:01:",
"1 01:01:01",
"1 01:01:01.01",
"1.5 01:01:",
"-0.25 01:01:",
]:
with self.subTest(f"Postgres INTERVAL span, omitted DAY TO SECOND unit: {time_str}"):
self.validate_identity(f"INTERVAL '{time_str}'")

# Ensure AND is not consumed as a unit following an omitted-span interval
with self.subTest("Postgres INTERVAL span, omitted unit with following AND"):
day_time_str = "a > INTERVAL '1 00:00' AND TRUE"
self.validate_identity(day_time_str, "a > INTERVAL '1 00:00' AND TRUE")
self.assertIsInstance(self.parse_one(day_time_str), exp.And)