diff --git a/datajunction-server/datajunction_server/alembic/versions/2026_08_10_0000-dl_default_value_json.py b/datajunction-server/datajunction_server/alembic/versions/2026_08_10_0000-dl_default_value_json.py new file mode 100644 index 000000000..1a31977d5 --- /dev/null +++ b/datajunction-server/datajunction_server/alembic/versions/2026_08_10_0000-dl_default_value_json.py @@ -0,0 +1,54 @@ +"""store dimension link default values as json + +Revision ID: dl_default_value_json +Revises: dw0001warnings +Create Date: 2026-08-10 00:00:00.000000+00:00 + +""" +# pylint: disable=no-member, invalid-name, missing-function-docstring + +import sqlalchemy as sa +from alembic import op + +revision = "dl_default_value_json" +down_revision = "dw0001warnings" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + if bind.dialect.name == "postgresql": + op.execute( + "ALTER TABLE dimensionlink " + "ALTER COLUMN default_value TYPE jsonb " + "USING to_jsonb(default_value)", + ) + return + + with op.batch_alter_table("dimensionlink", schema=None) as batch_op: + batch_op.alter_column( + "default_value", + existing_type=sa.String(), + type_=sa.JSON(), + existing_nullable=True, + ) + + +def downgrade(): + bind = op.get_bind() + if bind.dialect.name == "postgresql": + op.execute( + "ALTER TABLE dimensionlink " + "ALTER COLUMN default_value TYPE varchar " + "USING default_value #>> '{}'", + ) + return + + with op.batch_alter_table("dimensionlink", schema=None) as batch_op: + batch_op.alter_column( + "default_value", + existing_type=sa.JSON(), + type_=sa.String(), + existing_nullable=True, + ) diff --git a/datajunction-server/datajunction_server/api/graphql/scalars/node.py b/datajunction-server/datajunction_server/api/graphql/scalars/node.py index e92b7ffd3..653bf20dc 100644 --- a/datajunction-server/datajunction_server/api/graphql/scalars/node.py +++ b/datajunction-server/datajunction_server/api/graphql/scalars/node.py @@ -211,7 +211,7 @@ class DimensionLink: join_cardinality: JoinCardinality | None # type: ignore role: str | None foreign_keys: JSON - default_value: str | None + default_value: JSON | None @strawberry.type diff --git a/datajunction-server/datajunction_server/api/graphql/schema.graphql b/datajunction-server/datajunction_server/api/graphql/schema.graphql index 28ccab691..47a924d86 100644 --- a/datajunction-server/datajunction_server/api/graphql/schema.graphql +++ b/datajunction-server/datajunction_server/api/graphql/schema.graphql @@ -129,7 +129,7 @@ type DimensionLink { joinCardinality: JoinCardinality role: String foreignKeys: JSON! - defaultValue: String + defaultValue: JSON } type Engine { diff --git a/datajunction-server/datajunction_server/api/nodes.py b/datajunction-server/datajunction_server/api/nodes.py index 69a5dde0d..213ddb70c 100644 --- a/datajunction-server/datajunction_server/api/nodes.py +++ b/datajunction-server/datajunction_server/api/nodes.py @@ -1200,10 +1200,13 @@ async def update_node( session, name, options=NodeOutput.load_options(), + populate_existing=True, ) + assert node is not None + await session.refresh(node.current, attribute_names=["columns", "parents"]) # Update views if this is a cube (non-blocking) - if node and node.type == NodeType.CUBE: # type: ignore + if node.type == NodeType.CUBE: background_tasks.add_task( create_cube_views, cube_name=name, diff --git a/datajunction-server/datajunction_server/construction/build_v2.py b/datajunction-server/datajunction_server/construction/build_v2.py index 99749b625..370907819 100644 --- a/datajunction-server/datajunction_server/construction/build_v2.py +++ b/datajunction-server/datajunction_server/construction/build_v2.py @@ -14,7 +14,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, noload, selectinload -from datajunction_server.construction.utils import to_namespaced_name +from datajunction_server.construction.utils import ( + dimension_link_default_literal, + to_namespaced_name, +) from datajunction_server.database import Engine from datajunction_server.database.attributetype import ColumnAttribute from datajunction_server.database.column import Column @@ -1931,7 +1934,13 @@ def build_dimension_attribute( if link.default_value is not None: # pragma: no cover coalesce_expr = ast.Function( ast.Name("COALESCE"), - args=[column, ast.String(f"'{link.default_value}'")], + args=[ + column, + dimension_link_default_literal( + link.default_value, + str(col.type) if col.type else None, # type: ignore + ), + ], ) if alias: aliased = coalesce_expr.set_alias(ast.Name(alias)) diff --git a/datajunction-server/datajunction_server/construction/build_v3/measures.py b/datajunction-server/datajunction_server/construction/build_v3/measures.py index abaf1ec7c..9707329b2 100644 --- a/datajunction-server/datajunction_server/construction/build_v3/measures.py +++ b/datajunction-server/datajunction_server/construction/build_v3/measures.py @@ -70,6 +70,7 @@ make_column_ref, make_name, ) +from datajunction_server.construction.utils import dimension_link_default_literal from datajunction_server.database.node import Node from datajunction_server.internal.scan_estimation import calculate_scan_estimate from datajunction_server.models.decompose import Aggregability, MetricComponent @@ -1042,14 +1043,22 @@ def build_dimension_col_expr( col_ref = make_column_ref(resolved_dim.column_name, table_alias) default_value = None + column_type = None if resolved_dim.join_path and resolved_dim.join_path.links: last_link = resolved_dim.join_path.links[-1] default_value = last_link.default_value + if ctx: + node = ctx.nodes.get(resolved_dim.node_name) + if node: + column_type = get_column_type(node, resolved_dim.column_name) if default_value is not None: coalesce_func = ast.Function( ast.Name("COALESCE"), - args=[col_ref, ast.String(f"'{default_value}'")], + args=[ + col_ref, + dimension_link_default_literal(default_value, column_type), + ], ) col_expr = coalesce_func.set_alias(ast.Name(clean_alias)) col_expr.set_as(True) diff --git a/datajunction-server/datajunction_server/construction/utils.py b/datajunction-server/datajunction_server/construction/utils.py index 0cb135cba..5ec2eff5c 100755 --- a/datajunction-server/datajunction_server/construction/utils.py +++ b/datajunction-server/datajunction_server/construction/utils.py @@ -2,8 +2,9 @@ Utilities used around construction """ +import decimal import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +16,91 @@ from datajunction_server.models.node_type import NodeType if TYPE_CHECKING: - from datajunction_server.sql.parsing.ast import Name + from datajunction_server.sql.parsing.ast import Name, Value + + +NUMERIC_COLUMN_TYPES = { + "bigint", + "decimal", + "double", + "float", + "int", + "integer", + "long", + "number", + "numeric", + "smallint", + "tinyint", +} +BOOLEAN_COLUMN_TYPES = {"bool", "boolean"} +STRING_COLUMN_TYPES = {"char", "string", "text", "varchar"} + + +def _base_column_type(column_type: str | None) -> str | None: + """Return a simple comparable type name from a DJ column type string.""" + if not column_type: + return None + return column_type.lower().split("(", maxsplit=1)[0].strip() + + +def _string_literal(value: Any) -> "Value": + """Build a quoted SQL string literal.""" + from datajunction_server.sql.parsing import ast + + escaped = str(value).replace("'", "''") + return ast.String(f"'{escaped}'") + + +def _number_literal(value: Any) -> "Value": + """Build a numeric SQL literal.""" + from datajunction_server.sql.parsing import ast + + if isinstance(value, bool): + raise ValueError("Boolean values cannot be used as numeric defaults") + return ast.Number(value) + + +def _boolean_literal(value: Any) -> "Value": + """Build a boolean SQL literal.""" + from datajunction_server.sql.parsing import ast + + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "false"}: + return ast.Boolean(normalized == "true") + raise ValueError(f"Invalid boolean default value: {value}") + return ast.Boolean(bool(value)) + + +def dimension_link_default_literal( + default_value: Any, + column_type: str | None = None, +) -> "Value": + """ + Build an AST literal for a dimension link default value. + + When the target dimension column type is known, it wins over the Python + value type so legacy string-authored numeric defaults like ``"0"`` render + as numeric SQL for numeric columns. + """ + from datajunction_server.sql.parsing import ast + + base_type = _base_column_type(column_type) + if default_value is None: + return ast.Null() + + if base_type in STRING_COLUMN_TYPES: + return _string_literal(default_value) + if base_type in NUMERIC_COLUMN_TYPES: + return _number_literal(default_value) + if base_type in BOOLEAN_COLUMN_TYPES: + return _boolean_literal(default_value) + + if isinstance(default_value, bool): + return ast.Boolean(default_value) + if isinstance(default_value, (int, float, decimal.Decimal)): + return ast.Number(default_value) + return _string_literal(default_value) async def get_dj_node( diff --git a/datajunction-server/datajunction_server/database/dimensionlink.py b/datajunction-server/datajunction_server/database/dimensionlink.py index ca5a981bc..784d1e5fd 100644 --- a/datajunction-server/datajunction_server/database/dimensionlink.py +++ b/datajunction-server/datajunction_server/database/dimensionlink.py @@ -4,12 +4,14 @@ from typing import TYPE_CHECKING from sqlalchemy import JSON, BigInteger, Enum, ForeignKey, Index, Integer +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship from datajunction_server.database.base import Base from datajunction_server.database.node import Node, NodeRevision from datajunction_server.models.dimensionlink import ( + DimensionLinkDefault, JoinCardinality, JoinType, SparkJoinStrategy, @@ -84,7 +86,10 @@ class DimensionLink(Base): # Optional default value to use when LEFT JOIN produces NULL # (e.g., "Unknown" for a dimension column that may not have a match) - default_value: Mapped[str | None] = mapped_column(default=None) + default_value: Mapped[DimensionLinkDefault | None] = mapped_column( + JSON().with_variant(JSONB(), "postgresql"), + default=None, + ) # Optional Spark join strategy hint (e.g., broadcast, merge) spark_hints: Mapped[SparkJoinStrategy | None] = mapped_column( @@ -100,6 +105,7 @@ def to_spec(self): dimension_node=self.dimension.name, join_on=self.join_sql, join_type=self.join_type if self.join_type else JoinType.LEFT, + default_value=self.default_value, spark_hints=self.spark_hints, ) diff --git a/datajunction-server/datajunction_server/database/node.py b/datajunction-server/datajunction_server/database/node.py index edcb8b701..b1926b6c2 100644 --- a/datajunction-server/datajunction_server/database/node.py +++ b/datajunction-server/datajunction_server/database/node.py @@ -751,6 +751,7 @@ async def get_by_name( raise_if_not_exists: bool = False, include_inactive: bool = False, for_update: bool = False, + populate_existing: bool = False, ) -> Node | None: """ Get a node by name @@ -768,6 +769,8 @@ async def get_by_name( statement = statement.with_for_update().execution_options( populate_existing=True, ) + elif populate_existing: + statement = statement.execution_options(populate_existing=True) result = await session.execute(statement) node = result.unique().scalar_one_or_none() if not node and raise_if_not_exists: diff --git a/datajunction-server/datajunction_server/internal/nodes.py b/datajunction-server/datajunction_server/internal/nodes.py index 0a4d4e5ab..239bb973f 100644 --- a/datajunction-server/datajunction_server/internal/nodes.py +++ b/datajunction-server/datajunction_server/internal/nodes.py @@ -1304,7 +1304,7 @@ async def update_any_node( if node.type == NodeType.CUBE: # type: ignore node = cast(Node, await Node.get_cube_by_name(session, name)) - node_revision = await update_cube_node( + await update_cube_node( session, node.current, # type: ignore data, @@ -1316,7 +1316,7 @@ async def update_any_node( save_history=save_history, refresh_materialization=refresh_materialization, ) - return node_revision.node if node_revision else node + return node return await update_node_with_query( name, data, @@ -1805,8 +1805,8 @@ async def update_cube_node( ) new_cube_revision.version = bump_version(node_revision.version, change_tier) - new_cube_revision.node = node_revision.node - new_cube_revision.node.current_version = new_cube_revision.version # type: ignore + new_cube_revision.node = node + node.current_version = new_cube_revision.version # type: ignore await save_history( event=History( @@ -1896,7 +1896,7 @@ async def update_cube_node( ) session.add(new_cube_revision) - session.add(new_cube_revision.node) + session.add(node) await session.commit() # Stop the remote workflows only once DJ's own state is committed, so a slow or @@ -1916,8 +1916,6 @@ async def update_cube_node( ) await session.refresh(new_cube_revision) - await session.refresh(new_cube_revision.node) - await session.refresh(new_cube_revision.node.current) return new_cube_revision diff --git a/datajunction-server/datajunction_server/models/deployment.py b/datajunction-server/datajunction_server/models/deployment.py index 0392b2081..2d7cb788e 100644 --- a/datajunction-server/datajunction_server/models/deployment.py +++ b/datajunction-server/datajunction_server/models/deployment.py @@ -19,6 +19,7 @@ ) from datajunction_server.models.base import labelize from datajunction_server.models.dimensionlink import ( + DimensionLinkDefault, JoinType, LinkType, SparkJoinStrategy, @@ -317,7 +318,7 @@ class DimensionJoinLinkSpec(DimensionLinkSpec): node_column: str | None = None join_type: JoinType = JoinType.LEFT join_on: str | None = None - default_value: str | None = None + default_value: DimensionLinkDefault | None = None spark_hints: SparkJoinStrategy | None = None @property diff --git a/datajunction-server/datajunction_server/models/dimensionlink.py b/datajunction-server/datajunction_server/models/dimensionlink.py index 76d5657e2..f6fe464c4 100644 --- a/datajunction-server/datajunction_server/models/dimensionlink.py +++ b/datajunction-server/datajunction_server/models/dimensionlink.py @@ -5,6 +5,8 @@ from datajunction_server.enum import StrEnum from datajunction_server.models.node_type import NodeNameOutput +DimensionLinkDefault = str | int | float | bool + class JoinCardinality(StrEnum): """ @@ -68,7 +70,7 @@ class JoinLinkInput(BaseModel): join_on: str | None = None join_cardinality: JoinCardinality | None = JoinCardinality.MANY_TO_ONE role: str | None = None - default_value: str | None = None + default_value: DimensionLinkDefault | None = None spark_hints: SparkJoinStrategy | None = None @@ -83,7 +85,7 @@ class LinkDimensionOutput(BaseModel): join_cardinality: JoinCardinality | None = None role: str | None = None foreign_keys: dict[str, str | None] - default_value: str | None = None + default_value: DimensionLinkDefault | None = None spark_hints: SparkJoinStrategy | None = None model_config = ConfigDict(from_attributes=True) diff --git a/datajunction-server/datajunction_server/sql/dag.py b/datajunction-server/datajunction_server/sql/dag.py index e31e5e727..c7811c719 100644 --- a/datajunction-server/datajunction_server/sql/dag.py +++ b/datajunction-server/datajunction_server/sql/dag.py @@ -389,7 +389,17 @@ async def get_dimension_attributes( reference_links = [] await refresh_if_needed(session, node.current, ["columns"]) for col in node.current.columns: - await refresh_if_needed(session, col, ["dimension_id", "dimension_column"]) + await refresh_if_needed( + session, + col, + [ + "dimension_id", + "dimension_column", + "display_name", + "type", + "attributes", + ], + ) if col.dimension_id and col.dimension_column: await session.refresh(col, ["dimension"]) if ref_link := await build_reference_link( # pragma: no cover @@ -401,7 +411,17 @@ async def get_dimension_attributes( for dimension_node, path, role in dimension_nodes_and_paths: await refresh_if_needed(session, dimension_node.current, ["columns"]) for col in dimension_node.current.columns: - await refresh_if_needed(session, col, ["dimension_id", "dimension_column"]) + await refresh_if_needed( + session, + col, + [ + "dimension_id", + "dimension_column", + "display_name", + "type", + "attributes", + ], + ) if col.dimension_id and col.dimension_column: join_path = ( [node.name] if dimension_node.name != node.name else [] diff --git a/datajunction-server/tests/api/dimension_links_test.py b/datajunction-server/tests/api/dimension_links_test.py index 700612ddf..e5e1da34a 100644 --- a/datajunction-server/tests/api/dimension_links_test.py +++ b/datajunction-server/tests/api/dimension_links_test.py @@ -1402,6 +1402,92 @@ async def test_dimension_link_with_default_value( assert_sql_equal(v3_metrics_sql, expected_v3_metrics_sql) +@pytest.mark.asyncio +async def test_dimension_link_numeric_default_value( + dimensions_link_client: AsyncClient, +): + """ + Numeric default_value should validate and render as an unquoted SQL literal + for numeric dimension columns. + """ + response = await dimensions_link_client.post( + "/nodes/default.events/link", + json={ + "dimension_node": "default.countries", + "join_type": "left", + "join_on": ( + "default.events.user_registration_country = " + "default.countries.country_code" + ), + "join_cardinality": "many_to_one", + "default_value": 0, + }, + ) + assert response.status_code == 201 + + response = await dimensions_link_client.get("/nodes/default.events") + link = response.json()["dimension_links"][0] + assert link["default_value"] == 0 + + response = await dimensions_link_client.get( + "/sql/default.events?dimensions=default.countries.population", + ) + query = response.json()["sql"] + expected_sql = """ + WITH default_countries AS ( + SELECT country_code, + name, + population + FROM default.examples.countries + ), + default_events AS ( + SELECT user_id, + event_start_date, + event_end_date, + elapsed_secs, + user_registration_country + FROM default.examples.events + ) + SELECT t1.user_id, + t1.event_start_date, + t1.event_end_date, + t1.elapsed_secs, + t1.user_registration_country, + COALESCE(t2.population, 0) AS population + FROM default_events t1 + LEFT OUTER JOIN default_countries t2 ON t1.user_registration_country = t2.country_code + """ + assert_sql_equal(query, expected_sql) + + response = await dimensions_link_client.get( + "/sql/measures/v3/", + params={ + "metrics": ["default.elapsed_secs"], + "dimensions": ["default.countries.population"], + }, + ) + assert response.status_code == 200 + v3_measures_sql = response.json()["grain_groups"][0]["sql"] + expected_v3_measures_sql = """ + WITH default_countries AS ( + SELECT country_code, population + FROM default.examples.countries + ), + default_events AS ( + SELECT elapsed_secs, user_registration_country + FROM default.examples.events + ) + SELECT + COALESCE(t2.population, 0) AS population, + SUM(t1.elapsed_secs) elapsed_secs_sum_88a2603f + FROM default_events t1 + LEFT OUTER JOIN default_countries t2 + ON t1.user_registration_country = t2.country_code + GROUP BY t2.population + """ + assert_sql_equal(v3_measures_sql, expected_v3_measures_sql) + + @pytest.mark.asyncio async def test_dimension_link_default_value_graphql( dimensions_link_client: AsyncClient, diff --git a/datajunction-server/tests/construction/build_v3/helpers_test.py b/datajunction-server/tests/construction/build_v3/helpers_test.py index bc9f86bd2..4bd1054e1 100644 --- a/datajunction-server/tests/construction/build_v3/helpers_test.py +++ b/datajunction-server/tests/construction/build_v3/helpers_test.py @@ -1,5 +1,6 @@ """Tests for build_v3 helper functions.""" +from types import SimpleNamespace from typing import cast from unittest.mock import MagicMock @@ -36,6 +37,7 @@ from datajunction_server.construction.build_v3.measures import ( _add_table_prefixes_to_filter, _resolve_dim_namespace_refs, + build_dimension_col_expr, collect_cte_nodes_and_needed_columns, ) from datajunction_server.construction.build_v3.types import ( @@ -64,6 +66,7 @@ from datajunction_server.models.node import NodeType from datajunction_server.naming import amenable_col_names from datajunction_server.sql.parsing import ast +from datajunction_server.sql.parsing import types as ct from datajunction_server.sql.parsing.backends.antlr4 import ast, parse @@ -106,6 +109,75 @@ def test_dimension_ref_just_column_name_raises(self): parse_dimension_ref("status") +def test_build_dimension_col_expr_uses_numeric_default_for_numeric_column(): + """ + v3 dimension projections should coerce legacy string defaults by column type. + """ + resolved_dim = ResolvedDimension( + original_ref="default.countries.population", + node_name="default.countries", + column_name="population", + role=None, + join_path=JoinPath( + links=[SimpleNamespace(default_value="0", role=None)], + target_dimension=SimpleNamespace(name="default.countries"), + ), + is_local=False, + ) + ctx = SimpleNamespace( + nodes={ + "default.countries": SimpleNamespace( + current=SimpleNamespace( + columns=[ + SimpleNamespace( + name="population", + type=ct.IntegerType(), + ), + ], + ), + ), + }, + ) + + expr = build_dimension_col_expr( + resolved_dim, + main_alias="t1", + dim_aliases={("default.countries", ""): "t2"}, + clean_alias="population", + ctx=ctx, # type: ignore[arg-type] + ) + + assert str(expr) == "COALESCE(t2.population, 0) AS population" + + +def test_build_dimension_col_expr_handles_missing_context_node(): + """ + v3 dimension projections should still render defaults if ctx lacks the dim node. + """ + resolved_dim = ResolvedDimension( + original_ref="default.countries.population", + node_name="default.countries", + column_name="population", + role=None, + join_path=JoinPath( + links=[SimpleNamespace(default_value=0, role=None)], + target_dimension=SimpleNamespace(name="default.countries"), + ), + is_local=False, + ) + ctx = SimpleNamespace(nodes={}) + + expr = build_dimension_col_expr( + resolved_dim, + main_alias="t1", + dim_aliases={("default.countries", ""): "t2"}, + clean_alias="population", + ctx=ctx, # type: ignore[arg-type] + ) + + assert str(expr) == "COALESCE(t2.population, 0) AS population" + + def _make_ref_column( name: str, dimension_id: int | None, diff --git a/datajunction-server/tests/construction/utils_test.py b/datajunction-server/tests/construction/utils_test.py index a4cd5a3dc..d42ece84c 100644 --- a/datajunction-server/tests/construction/utils_test.py +++ b/datajunction-server/tests/construction/utils_test.py @@ -5,7 +5,10 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession -from datajunction_server.construction.utils import get_dj_node +from datajunction_server.construction.utils import ( + dimension_link_default_literal, + get_dj_node, +) from datajunction_server.errors import DJErrorException from datajunction_server.models.node_type import NodeType @@ -45,3 +48,35 @@ async def test_get_dj_node_raise_unknown_node_exception(session: AsyncSession): assert "No node `event_type` exists of kind transform" in str( exc_info.value, ) + + +def test_dimension_link_default_literal_uses_column_type(): + """ + Dimension link defaults should render according to target column type when known. + """ + assert str(dimension_link_default_literal("0", "int")) == "0" + assert str(dimension_link_default_literal(0, "string")) == "'0'" + assert str(dimension_link_default_literal("false", "boolean")) == "False" + assert str(dimension_link_default_literal("Bob's Team", "string")) == ( + "'Bob''s Team'" + ) + + +def test_dimension_link_default_literal_validation_edges(): + """ + Dimension link defaults should reject invalid typed literals. + """ + with pytest.raises(ValueError, match="Boolean values cannot be used"): + dimension_link_default_literal(True, "int") + + with pytest.raises(ValueError, match="Invalid boolean default value"): + dimension_link_default_literal("maybe", "boolean") + + +def test_dimension_link_default_literal_fallbacks(): + """ + Dimension link defaults should render fallback literals from Python values. + """ + assert str(dimension_link_default_literal(None)) == "NULL" + assert str(dimension_link_default_literal(True)) == "True" + assert str(dimension_link_default_literal(0, "boolean")) == "False" diff --git a/datajunction-server/tests/models/deployment_test.py b/datajunction-server/tests/models/deployment_test.py index 55b8b011b..78f112708 100644 --- a/datajunction-server/tests/models/deployment_test.py +++ b/datajunction-server/tests/models/deployment_test.py @@ -443,6 +443,18 @@ def test_dimension_join_link_spec_with_default_value(): assert hash(link_spec) != hash(different_default) +def test_dimension_join_link_spec_with_numeric_default_value(): + """Test DimensionJoinLinkSpec accepts numeric default_value.""" + link_spec = DimensionJoinLinkSpec( + dimension_node="some.dimension.tiers", + join_type="left", + join_on="events.tier_id = some.dimension.tiers.id", + default_value=0, + ) + + assert link_spec.default_value == 0 + + def test_source_spec_with_dimension_link_default_value(): """Test SourceSpec with dimension_links including default_value.""" source_spec = SourceSpec( diff --git a/docs/content/0.1.0/docs/data-modeling/dimension-links.md b/docs/content/0.1.0/docs/data-modeling/dimension-links.md index cc8b4805c..a2be57ee8 100644 --- a/docs/content/0.1.0/docs/data-modeling/dimension-links.md +++ b/docs/content/0.1.0/docs/data-modeling/dimension-links.md @@ -294,7 +294,8 @@ defined for that dimension. When using `LEFT` or `RIGHT` join types for dimension links, unmatched rows will produce NULL values for dimension columns. You can configure a `default_value` on the dimension link to provide a fallback value in these cases. -When `default_value` is set, DJ will wrap dimension columns in a `COALESCE` function in the generated SQL: +When `default_value` is set, DJ will wrap dimension columns in a `COALESCE` function in the generated SQL. +The fallback can be a string, number, or boolean value: ```sql -- Without default_value @@ -306,6 +307,11 @@ LEFT JOIN user ON events.user_id = user.id SELECT COALESCE(user.name, 'Unknown') AS user_name FROM events LEFT JOIN user ON events.user_id = user.id + +-- With default_value = 0 +SELECT COALESCE(user.account_tier, 0) AS account_tier +FROM events +LEFT JOIN user ON events.user_id = user.id ``` This is useful when: diff --git a/docs/content/0.1.0/docs/data-modeling/yaml.md b/docs/content/0.1.0/docs/data-modeling/yaml.md index 351ec9f15..0c702673f 100644 --- a/docs/content/0.1.0/docs/data-modeling/yaml.md +++ b/docs/content/0.1.0/docs/data-modeling/yaml.md @@ -150,7 +150,7 @@ dimension_links: - type: join node_column: state_id dimension_node: ${prefix}roads.us_state - default_value: Unknown # Optional: fallback for NULL values from LEFT JOIN + default_value: Unknown # Optional: string/number/boolean fallback for NULL values from LEFT JOIN - type: reference node_column: birth_date dimension: ${prefix}roads.date_dim.dateint @@ -165,7 +165,7 @@ dimension_links: | `join_on` | No | A custom join on SQL clause | | `join_type` | No | The type of join (one of `left`, `right`, `inner`, `full`, `cross`). Defaults to `left`. | | `role` | No | The role this dimension represents | -| `default_value` | No | A fallback value for NULL results from LEFT/RIGHT joins. When set, dimension columns are wrapped in `COALESCE(column, 'default_value')`. | +| `default_value` | No | A string, number, or boolean fallback value for NULL results from LEFT/RIGHT joins. When set, dimension columns are wrapped in `COALESCE(column, default_value)`. | | `spark_hints` | No | Spark join strategy hint for this dimension join. One of `broadcast`, `merge`, `shuffle_hash`, `shuffle_replicate_nl`. Emitted as a hint comment in the generated SELECT. Has no effect on non-Spark engines. | | Reference Link Fields | Required? | Description |