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
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@

def upgrade():
op.execute(
"CREATE TYPE sparkjoinstrategy AS ENUM "
"('broadcast', 'merge', 'shuffle_hash', 'shuffle_replicate_nl')",
"""
DO $$ BEGIN
CREATE TYPE sparkjoinstrategy AS ENUM
('broadcast', 'merge', 'shuffle_hash', 'shuffle_replicate_nl');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
""",
)
op.add_column(
"dimensionlink",
Expand Down
17 changes: 17 additions & 0 deletions datajunction-server/datajunction_server/api/namespaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
)
from datajunction_server.internal.namespaces import (
create_namespace,
get_git_info_for_namespace,
get_nodes_in_namespace,
get_nodes_in_namespace_detailed,
get_project_config,
Expand Down Expand Up @@ -264,6 +265,14 @@ async def deactivate_a_namespace(
message=f"Namespace `{namespace}` is already deactivated.",
)

git_info = await get_git_info_for_namespace(session, namespace)
if git_info and git_info.get("is_default_branch") and git_info.get("repo"):
raise DJInvalidInputException(
message=f"Cannot delete namespace `{namespace}`: it is the default branch "
f"of a git-backed namespace ({git_info['repo']}). "
"Only non-default branch namespaces can be deleted.",
)

# If there are no active nodes in the namespace, we can safely deactivate this namespace
node_list = await NodeNamespace.list_nodes(session, namespace)
node_names = [node.name for node in node_list]
Expand Down Expand Up @@ -422,6 +431,14 @@ async def hard_delete_node_namespace(
access_checker.add_namespace(namespace, ResourceAction.DELETE)
await access_checker.check(on_denied=AccessDenialMode.RAISE)

git_info = await get_git_info_for_namespace(session, namespace)
if git_info and git_info.get("is_default_branch") and git_info.get("repo"):
raise DJInvalidInputException(
message=f"Cannot delete namespace `{namespace}`: it is the default branch "
f"of a git-backed namespace ({git_info['repo']}). "
"Only non-default branch namespaces can be deleted.",
)

impacts = await hard_delete_namespace(
session=session,
namespace=namespace,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ def resolve_git_info_from_map(
"default_branch": default_branch,
"path": config_ns.git_path,
"is_default_branch": (
branch == default_branch if branch and default_branch else True
branch is None # root namespace — no branch means it IS the default
or (default_branch is not None and branch == default_branch)
),
"parent_namespace": branch_ns.parent_namespace if branch_ns else None,
"git_only": config_ns.git_only,
Expand Down
110 changes: 110 additions & 0 deletions datajunction-server/tests/api/namespaces_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2557,3 +2557,113 @@ async def test_list_namespace_branches_excludes_deactivated(
namespaces = [b["namespace"] for b in response.json()]
assert active_ns in namespaces
assert deact_ns not in namespaces


@pytest.mark.asyncio
async def test_deactivate_git_default_branch_namespace_blocked(
module__client_with_all_examples: AsyncClient,
) -> None:
"""
Deleting (soft or hard) a git-backed namespace whose git_branch matches
its default_branch must be rejected, regardless of cascade setting.
"""
root = "protected.root.deact"
branch_ns = "protected.root.deact.main"

# Set up a git-root namespace with a default branch
await module__client_with_all_examples.post(f"/namespaces/{root}/")
await module__client_with_all_examples.patch(
f"/namespaces/{root}/git",
json={"github_repo_path": "corp/protected-repo", "default_branch": "main"},
)

# Create the default branch namespace
await module__client_with_all_examples.post(f"/namespaces/{branch_ns}/")
await module__client_with_all_examples.patch(
f"/namespaces/{branch_ns}/git",
json={"parent_namespace": root, "git_branch": "main"},
)

# Soft delete should be blocked
response = await module__client_with_all_examples.delete(
f"/namespaces/{branch_ns}/",
)
assert response.status_code == 422
assert "default branch" in response.json()["message"]
assert "corp/protected-repo" in response.json()["message"]

# Soft delete with cascade should also be blocked
response = await module__client_with_all_examples.delete(
f"/namespaces/{branch_ns}/?cascade=true",
)
assert response.status_code == 422
assert "default branch" in response.json()["message"]


@pytest.mark.asyncio
async def test_hard_delete_git_default_branch_namespace_blocked(
module__client_with_all_examples: AsyncClient,
) -> None:
"""
Hard deleting a git-backed namespace whose git_branch matches its
default_branch must be rejected.
"""
root = "protected.root.hard"
branch_ns = "protected.root.hard.main"

await module__client_with_all_examples.post(f"/namespaces/{root}/")
await module__client_with_all_examples.patch(
f"/namespaces/{root}/git",
json={"github_repo_path": "corp/protected-hard-repo", "default_branch": "main"},
)

await module__client_with_all_examples.post(f"/namespaces/{branch_ns}/")
await module__client_with_all_examples.patch(
f"/namespaces/{branch_ns}/git",
json={"parent_namespace": root, "git_branch": "main"},
)

# Hard delete should be blocked
response = await module__client_with_all_examples.delete(
f"/namespaces/{branch_ns}/hard/",
)
assert response.status_code == 422
assert "default branch" in response.json()["message"]
assert "corp/protected-hard-repo" in response.json()["message"]

# Hard delete with cascade should also be blocked
response = await module__client_with_all_examples.delete(
f"/namespaces/{branch_ns}/hard/?cascade=true",
)
assert response.status_code == 422
assert "default branch" in response.json()["message"]


@pytest.mark.asyncio
async def test_delete_non_default_git_branch_namespace_allowed(
module__client_with_all_examples: AsyncClient,
) -> None:
"""
Deleting a non-default branch namespace should still work normally.
"""
root = "protected.root.feature"
branch_ns = "protected.root.feature.my_feature"

await module__client_with_all_examples.post(f"/namespaces/{root}/")
await module__client_with_all_examples.patch(
f"/namespaces/{root}/git",
json={"github_repo_path": "corp/feature-repo", "default_branch": "main"},
)

await module__client_with_all_examples.post(f"/namespaces/{branch_ns}/")
await module__client_with_all_examples.patch(
f"/namespaces/{branch_ns}/git",
json={"parent_namespace": root, "git_branch": "my-feature"},
)

# Soft delete of a non-default branch should succeed (no nodes → deactivates cleanly)
response = await module__client_with_all_examples.delete(
f"/namespaces/{branch_ns}/",
)
assert response.status_code == 200
assert "deactivated" in response.json()["message"]
6 changes: 3 additions & 3 deletions datajunction-server/tests/internal/git/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -802,11 +802,11 @@ async def test_is_default_branch_true_when_no_git_branch(
assert result["is_default_branch"] is True

@pytest.mark.asyncio
async def test_is_default_branch_true_when_no_default_branch(
async def test_is_default_branch_false_when_no_default_branch(
self,
session: AsyncSession,
):
"""git_branch set but default_branch is None → defaults to True."""
"""git_branch set but default_branch is None → is_default_branch is False (unknown)."""
session.add(
NodeNamespace(
namespace="proj",
Expand All @@ -826,7 +826,7 @@ async def test_is_default_branch_true_when_no_default_branch(
result = await get_git_info_for_namespace(session, "proj.feature_x")

assert result is not None
assert result["is_default_branch"] is True
assert result["is_default_branch"] is False

# ------------------------------------------------------------------
# parent_namespace
Expand Down
Loading