Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ type DimensionLink {
joinCardinality: JoinCardinality
role: String
foreignKeys: JSON!
defaultValue: String
defaultValue: JSON
}

type Engine {
Expand Down
5 changes: 4 additions & 1 deletion datajunction-server/datajunction_server/api/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions datajunction-server/datajunction_server/construction/build_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
89 changes: 87 additions & 2 deletions datajunction-server/datajunction_server/construction/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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,
)

Expand Down
3 changes: 3 additions & 0 deletions datajunction-server/datajunction_server/database/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
12 changes: 5 additions & 7 deletions datajunction-server/datajunction_server/internal/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand Down
3 changes: 2 additions & 1 deletion datajunction-server/datajunction_server/models/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
)
from datajunction_server.models.base import labelize
from datajunction_server.models.dimensionlink import (
DimensionLinkDefault,
JoinType,
LinkType,
SparkJoinStrategy,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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


Expand All @@ -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)
Loading
Loading