"""ADR-001 position contract surface Rename Asset.mapleft -> Asset.mapx, Asset.maptop -> Asset.mapy. Add Location.mapx / Location.mapy. Add AssetRelationship.label, AssetRelationship.inheritsposition, RelationshipType.propagatesthroughid. Seed the three canonical relationship types (partof, controls, connectedto) with controls.propagatesthroughid -> partof. Revision ID: 7a01_adr001_position Revises: 68b3947ae14f Create Date: 2026-05-30 """ from alembic import op import sqlalchemy as sa revision = '7a01_adr001_position' down_revision = '68b3947ae14f' branch_labels = None depends_on = None def upgrade(): # ---- Asset.mapleft/maptop -> mapx/mapy -------------------------------- with op.batch_alter_table('assets') as batch_op: batch_op.alter_column('mapleft', new_column_name='mapx', existing_type=sa.Integer(), existing_nullable=True, comment='X coordinate on floor map (ADR-001)') batch_op.alter_column('maptop', new_column_name='mapy', existing_type=sa.Integer(), existing_nullable=True, comment='Y coordinate on floor map (ADR-001)') # ---- Location: add mapx/mapy for the fallback path -------------------- with op.batch_alter_table('locations') as batch_op: batch_op.add_column(sa.Column('mapx', sa.Integer(), nullable=True, comment='Default X coordinate for assets at this location')) batch_op.add_column(sa.Column('mapy', sa.Integer(), nullable=True, comment='Default Y coordinate for assets at this location')) # ---- RelationshipType: add propagatesthroughid self-FK ---------------- with op.batch_alter_table('relationshiptypes') as batch_op: batch_op.add_column(sa.Column('propagatesthroughid', sa.Integer(), nullable=True, comment='Sibling-propagation rail per ADR-001')) batch_op.create_foreign_key( 'fk_relationshiptype_propagation', 'relationshiptypes', ['propagatesthroughid'], ['relationshiptypeid'], ) # ---- AssetRelationship: add label + inheritsposition ------------------ with op.batch_alter_table('assetrelationships') as batch_op: batch_op.add_column(sa.Column('label', sa.String(length=200), nullable=True, comment='Free-text relationship description (ADR-001)')) batch_op.add_column(sa.Column('inheritsposition', sa.Boolean(), nullable=False, server_default='1', comment='If true, resolved-position walk follows this edge (ADR-001)')) # ---- Seed three canonical relationship types -------------------------- # Idempotent: insert only if name is not already present. Then update # controls.propagatesthroughid to point at partof. relationshiptypes = sa.table( 'relationshiptypes', sa.column('relationshiptype', sa.String), sa.column('description', sa.Text), sa.column('propagatesthroughid', sa.Integer), sa.column('isactive', sa.Boolean), ) conn = op.get_bind() dialect_name = conn.dialect.name # MySQL default collation is case-insensitive, which makes a plain # `WHERE relationshiptype = 'controls'` match a legacy `Controls` row # and silently skip the insert (then accidentally wire propagation onto # the wrong row). Force binary comparison on MySQL so the three ADR # types are always distinct from legacy capitalized names. SQLite + # PostgreSQL are case-sensitive by default; the plain comparison is # safe there. def _eq(col_expr, value): if dialect_name == 'mysql': return f"BINARY {col_expr} = '{value}'" return f"{col_expr} = '{value}'" for rt in ( ('partof', 'Composition / sub-assembly (ADR-001)'), ('controls', 'Operational authority over another asset (ADR-001)'), ('connectedto', 'Network or data link without authority (ADR-001)'), ): existing = conn.execute(sa.text( f"SELECT relationshiptypeid FROM relationshiptypes WHERE {_eq('relationshiptype', rt[0])}" )).first() if not existing: conn.execute(relationshiptypes.insert().values( relationshiptype=rt[0], description=rt[1], propagatesthroughid=None, isactive=True, )) partof_row = conn.execute(sa.text( f"SELECT relationshiptypeid FROM relationshiptypes WHERE {_eq('relationshiptype', 'partof')}" )).first() if partof_row: conn.execute(sa.text( f"UPDATE relationshiptypes SET propagatesthroughid = :p WHERE {_eq('relationshiptype', 'controls')}" ), {'p': partof_row[0]}) def downgrade(): # Revert in reverse order. Drops the seeded ADR types if no rows # reference them; otherwise leaves them in place to avoid FK violations. conn = op.get_bind() with op.batch_alter_table('assetrelationships') as batch_op: batch_op.drop_column('inheritsposition') batch_op.drop_column('label') with op.batch_alter_table('relationshiptypes') as batch_op: batch_op.drop_constraint('fk_relationshiptype_propagation', type_='foreignkey') batch_op.drop_column('propagatesthroughid') with op.batch_alter_table('locations') as batch_op: batch_op.drop_column('mapy') batch_op.drop_column('mapx') with op.batch_alter_table('assets') as batch_op: batch_op.alter_column('mapx', new_column_name='mapleft', existing_type=sa.Integer(), existing_nullable=True) batch_op.alter_column('mapy', new_column_name='maptop', existing_type=sa.Integer(), existing_nullable=True) # Best-effort: drop the seeded types if nothing references them. for name in ('connectedto', 'controls', 'partof'): try: conn.execute(sa.text( "DELETE FROM relationshiptypes " "WHERE relationshiptype = :n " "AND relationshiptypeid NOT IN (" " SELECT relationshiptypeid FROM assetrelationships " " UNION SELECT relationshiptypeid FROM machinerelationships" ")" ), {'n': name}) except Exception: pass