Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
45ad401
initial
Dec 15, 2025
45396d4
added jsonb col and updated tests
Dec 16, 2025
c83780f
Merge branch 'main' into ENG-2185-add-new-jsob-tree-col
JadeCara Dec 16, 2025
a89bbce
update db yml
Dec 16, 2025
f5cf36a
test updates
Dec 16, 2025
fdef91d
clean up alembic file
Dec 17, 2025
2c3bc15
remove extra definition
Dec 17, 2025
9a788df
remove extra definition
Dec 17, 2025
fccc79b
fix return
Dec 17, 2025
ae56487
some clean ups
Dec 17, 2025
57599c9
Merge branch 'main' into ENG-2185-add-new-jsob-tree-col
JadeCara Dec 17, 2025
10c24be
Merge branch 'main' into ENG-2185-add-new-jsob-tree-col
Dec 18, 2025
744aa5e
missed one
Dec 18, 2025
0400766
Merge branch 'main' into ENG-2185-add-new-jsob-tree-col
JadeCara Dec 18, 2025
e025468
fix tests
Dec 18, 2025
b9f38c5
Merge branch 'ENG-2185-add-new-jsob-tree-col' of github.com:ethyca/fi…
Dec 18, 2025
3891837
clean up
Dec 29, 2025
f889e04
clean up
Dec 29, 2025
bf574bd
Merge branch 'main' into ENG-2185-add-new-jsob-tree-col
JadeCara Dec 29, 2025
68d1c4e
fix
Dec 29, 2025
a774f76
.
Dec 30, 2025
eb5d0aa
Apply suggestion from @JadeCara
JadeCara Dec 30, 2025
43a06fc
small udpates
Jan 5, 2026
39037fe
small docstring clean ups
Jan 5, 2026
3e07cce
Merge branch 'main' into ENG-2185-add-new-jsob-tree-col
JadeCara Jan 5, 2026
ee09dbd
changed get_root_condition to get_condition_tree
Jan 6, 2026
e952c1b
move from raw string to constant
Jan 6, 2026
ba6556f
Merge branch 'main' into ENG-2185-add-new-jsob-tree-col
JadeCara Jan 6, 2026
d49d3da
Merge branch 'main' into ENG-2185-add-new-jsob-tree-col
JadeCara Jan 12, 2026
b6cf8a8
changelog
Jan 12, 2026
8d17149
Apply suggestion from @JadeCara
JadeCara Jan 12, 2026
40265f0
Apply suggestion from @JadeCara
JadeCara Jan 12, 2026
b2273a4
remove redundant test
Jan 12, 2026
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
4 changes: 4 additions & 0 deletions .fides/db_dataset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,8 @@ dataset:
data_categories: [system.operations]
- name: digest_config_id
data_categories: [system.operations]
- name: condition_tree
data_categories: [system.operations]
- name: parent_id
data_categories: [system.operations]
- name: digest_condition_type
Expand Down Expand Up @@ -1277,6 +1279,8 @@ dataset:
data_categories: [system.operations]
- name: manual_task_id
data_categories: [system.operations]
- name: condition_tree
data_categories: [system.operations]
- name: parent_id
data_categories: [system.operations]
- name: condition_type
Expand Down
7 changes: 7 additions & 0 deletions changelog/7133.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Copy this file and rename it (e.g., pr-number.yaml or feature-name.yaml)
# Fill in the required fields and delete this comment block

type: Added # One of: Added, Changed, Developer Experience, Deprecated, Docs, Fixed, Removed, Security
description: Added a new jsonb column to the conditional dependency models.
pr: 7133 # PR number
labels: ["db-migration"] # Optional: ["high-risk", "db-migration"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""add jsonb tree column

Revision ID: 85ce2c1c9579
Revises: b9c8e7f6d5a4
Create Date: 2025-12-16 16:30:52.073758

"""

import json
from typing import Optional

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Session
from sqlalchemy.sql import quoted_name

# revision identifiers, used by Alembic.
revision = "85ce2c1c9579"
down_revision = "b9c8e7f6d5a4"
branch_labels = None
depends_on = None

# Whitelist of allowed table names for this migration
ALLOWED_TABLES = frozenset({"manual_task_conditional_dependency", "digest_condition"})
ALLOWED_ID_COLUMNS = frozenset({"id"})


def _validate_identifier(identifier: str, allowed: frozenset, identifier_type: str) -> str:
"""Validate that an identifier is in the allowed whitelist.

Args:
identifier: The table or column name to validate
allowed: Set of allowed identifier values
identifier_type: Description of the identifier type for error messages

Returns:
The validated identifier

Raises:
ValueError: If the identifier is not in the allowed set
"""
if identifier not in allowed:
raise ValueError(
f"Invalid {identifier_type}: {identifier!r}. "
f"Allowed values: {sorted(allowed)}"
)
return identifier


def _safe_identifier(name: str) -> quoted_name:
"""Return a properly quoted SQL identifier."""
return quoted_name(name, quote=True)


def build_condition_tree(
db: Session, table_name: str, row_id: str, id_column: str = "id"
) -> Optional[dict]:
"""Recursively build a condition tree from row-based storage.

Args:
db: Database session
table_name: Name of the table (must be in ALLOWED_TABLES whitelist)
row_id: ID of the row to build tree from
id_column: Name of the ID column (must be in ALLOWED_ID_COLUMNS whitelist)

Returns:
dict: Condition tree as a dictionary (ConditionLeaf or ConditionGroup format)

Raises:
ValueError: If table_name or id_column is not in the allowed whitelist
"""
# Validate identifiers against whitelist to prevent SQL injection
_validate_identifier(table_name, ALLOWED_TABLES, "table name")
_validate_identifier(id_column, ALLOWED_ID_COLUMNS, "id column")

# Use quoted identifiers for defense in depth
safe_table = _safe_identifier(table_name)
safe_id_col = _safe_identifier(id_column)

result = db.execute(
sa.text(
f"SELECT condition_type, field_address, operator, value, logical_operator "
f"FROM {safe_table} WHERE {safe_id_col} = :row_id"
),
{"row_id": row_id},
Comment thread
JadeCara marked this conversation as resolved.
).fetchone()

if not result:
return None

condition_type, field_address, operator, value, logical_operator = result

if condition_type == "leaf":
parsed_value = value

return {
"field_address": field_address,
"operator": operator,
"value": parsed_value,
}
Comment thread
JadeCara marked this conversation as resolved.

# It's a group - get children ordered by sort_order
children_rows = db.execute(
sa.text(
f"SELECT {safe_id_col} FROM {safe_table} "
f"WHERE parent_id = :parent_id ORDER BY sort_order"
),
{"parent_id": row_id},
).fetchall()

child_conditions = []
for (child_id,) in children_rows:
child_tree = build_condition_tree(db, table_name, child_id, id_column)
if child_tree:
child_conditions.append(child_tree)

if not child_conditions:
return None

return {
"logical_operator": logical_operator,
"conditions": child_conditions,
}


def migrate_conditions(db: Session, table_name: str) -> None:
"""Migrate existing row-based condition trees to JSONB format for the given table.

Args:
db: Database session
table_name: Name of the table (must be in ALLOWED_TABLES whitelist)

Raises:
ValueError: If table_name is not in the allowed whitelist
"""
# Validate table name against whitelist
_validate_identifier(table_name, ALLOWED_TABLES, "table name")
safe_table = _safe_identifier(table_name)

root_rows = db.execute(
sa.text(f"SELECT id FROM {safe_table} WHERE parent_id IS NULL")
).fetchall()

for (root_id,) in root_rows:
tree = build_condition_tree(db, table_name, root_id)

if tree:
db.execute(
sa.text(
f"UPDATE {safe_table} "
"SET condition_tree = :tree WHERE id = :root_id"
),
{"tree": json.dumps(tree), "root_id": root_id},
)


def upgrade():
# Step 1: Add condition_tree column to both tables
op.add_column(
"digest_condition",
sa.Column(
"condition_tree", postgresql.JSONB(astext_type=sa.Text()), nullable=True
),
)
op.add_column(
"manual_task_conditional_dependency",
sa.Column(
"condition_tree", postgresql.JSONB(astext_type=sa.Text()), nullable=True
),
)

# Step 2: Migrate existing row-based trees to JSONB
db = Session(op.get_bind())
migrate_conditions(db, "manual_task_conditional_dependency")
migrate_conditions(db, "digest_condition")
Comment thread
JadeCara marked this conversation as resolved.
Comment thread
JadeCara marked this conversation as resolved.
db.commit()


def downgrade():
op.drop_column("manual_task_conditional_dependency", "condition_tree")
op.drop_column("digest_condition", "condition_tree")
Loading
Loading