Compare commits

...

5 Commits

Author SHA1 Message Date
Evan Rusackas
ac864cc94b Merge branch 'master' into fix/postgresql-interval-chart-rendering 2026-04-25 02:51:48 -04:00
Evan Rusackas
0a2b837c89 fix: address review feedback for INTERVAL type handling
- Extract lambda to named `_normalize_interval` method for testability
- Return None for NULL values to preserve NULL semantics (not 0)
- Exclude bool from numeric branch (bool is subclass of int in Python)
- Return None for unconvertible types to avoid mixed-type columns
- Add tests for zero duration, negative intervals, and bool handling
- Add INTERVAL to column spec test (NUMERIC type)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-22 08:53:15 -07:00
Evan Rusackas
d3c562657a feat(postgres): convert INTERVAL to milliseconds for DURATION formatter
Changed INTERVAL values to be converted to milliseconds instead of
seconds, enabling users to use Superset's built-in "DURATION" number
format for human-readable display (e.g., "1d 2h 30m 45s" instead of
raw numeric values like "95445000").

This addresses the review feedback about making interval values more
user-friendly in charts while maintaining numeric operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-22 08:53:14 -07:00
Evan Rusackas
be90e08f83 feat(postgres): improve INTERVAL type handling for robust chart rendering
- Enhanced mutator to handle multiple PostgreSQL INTERVAL formats
- Added support for timedelta, numeric, None, and string values
- Improved test coverage with comprehensive test cases
- Added documentation explaining the mutator's purpose

Addresses review comments from @korbit-ai and @giftig
2026-04-22 08:53:14 -07:00
Evan Rusackas
4e74dc0250 fix(charts): handle PostgreSQL INTERVAL type in bar and pie charts
PostgreSQL INTERVAL types were causing bar and pie charts to fail rendering when used as metrics. This fix converts INTERVAL values (timedelta objects) to numeric seconds so they can be properly displayed in charts.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-22 08:53:09 -07:00
2 changed files with 74 additions and 4 deletions

View File

@@ -21,10 +21,11 @@ import logging
import re
from datetime import datetime
from re import Pattern
from typing import Any, Optional, TYPE_CHECKING
from typing import Any, Callable, Optional, TYPE_CHECKING
from flask_babel import gettext as __
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, JSON
from sqlalchemy import types
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, INTERVAL, JSON
from sqlalchemy.dialects.postgresql.base import PGInspector
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
@@ -526,8 +527,37 @@ class PostgresEngineSpec(BasicParametersMixin, PostgresBaseEngineSpec):
ENUM(),
GenericDataType.STRING,
),
(
re.compile(r"^interval", re.IGNORECASE),
INTERVAL(),
GenericDataType.NUMERIC,
),
)
@staticmethod
def _normalize_interval(v: Any) -> Any:
"""Convert PostgreSQL INTERVAL values to milliseconds.
psycopg2 returns timedelta objects which we convert to milliseconds for
numeric operations in bar/pie charts. Using milliseconds allows users to
apply the built-in "DURATION" number format for human-readable display
(e.g., "1d 2h 30m 45s").
Returns None for values that cannot be converted to preserve NULL semantics
and avoid mixed-type columns.
"""
if v is None:
return None
if hasattr(v, "total_seconds"):
return v.total_seconds() * 1000
if isinstance(v, (int, float)) and not isinstance(v, bool):
return float(v) * 1000
return None # Can't convert to numeric — treat as missing
column_type_mutators: dict[types.TypeEngine, Callable[[Any], Any]] = {
INTERVAL: _normalize_interval.__func__, # type: ignore[attr-defined]
}
@classmethod
def get_schema_from_engine_params(
cls,

View File

@@ -15,14 +15,14 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from datetime import datetime, timedelta
from typing import Any, Optional
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from sqlalchemy import column, types
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, JSON
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, INTERVAL, JSON
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.engine.url import make_url
@@ -87,6 +87,8 @@ def test_convert_dttm(
("TIME", types.Time, None, GenericDataType.TEMPORAL, True),
# Boolean
("BOOLEAN", types.Boolean, None, GenericDataType.BOOLEAN, False),
# Interval (mapped to NUMERIC for chart rendering)
("INTERVAL", INTERVAL, None, GenericDataType.NUMERIC, False),
],
)
def test_get_column_spec(
@@ -363,3 +365,41 @@ class TestRedshiftDetection:
spec.update_params_from_encrypted_extra(database, params)
assert "pool_events" not in params
def test_interval_type_mutator() -> None:
"""
DB Eng Specs (postgres): Test INTERVAL type mutator
INTERVAL values are converted to milliseconds so users can apply
the built-in "DURATION" number format for human-readable display.
"""
mutator = spec.column_type_mutators[INTERVAL]
# Test timedelta conversion (most common case from psycopg2)
# Result is in milliseconds for compatibility with DURATION formatter
td = timedelta(days=1, hours=2, minutes=30, seconds=45)
assert mutator(td) == 95445000.0 # Total ms: (1*86400 + 2*3600 + 30*60 + 45) * 1000
# Test zero duration
assert mutator(timedelta(0)) == 0.0
# Test negative interval
assert mutator(timedelta(days=-1)) == -86400000.0
# Test numeric values (assumed to be seconds) are converted to milliseconds
assert mutator(12345) == 12345000.0
assert mutator(123.45) == 123450.0
# Test None preserves NULL semantics (not converted to 0)
assert mutator(None) is None
# Test bool is not treated as numeric (bool is subclass of int in Python)
assert mutator(True) is None
assert mutator(False) is None
# Test unconvertible types return None to avoid mixed-type columns
assert mutator("1 day 02:30:45") is None
assert mutator("P1DT2H30M45S") is None
assert mutator([1, 2, 3]) is None
assert mutator({"days": 1}) is None