Skip to content

Declare the plugin tables' foreign keys - #29

Merged
mastacontrola merged 5 commits into
mainfrom
fk-constraints
Aug 29, 2026
Merged

Declare the plugin tables' foreign keys#29
mastacontrola merged 5 commits into
mainfrom
fk-constraints

Conversation

@mastacontrola

Copy link
Copy Markdown
Member

The plugin half of fogproject ADR 0031. Depends on
FOGProject/fogproject#1469, which is merged
— these steps call
SchemaReconciler::sweepOrphans(), which did not exist before it.

22 relationships across five plugins, one commit each.

Plugin Step Column change Constraints
location 4 and 5 lStorageNodeID nullable, 0 -> NULL 4
ou 2 none 2
windowskey 3 none 2
ldap 28 none 6
oidc 9 none 8

Why the relationships are declared in core, not here

Half of them point at core tables — hosts, images, users, roles,
userGroups, nfsGroups — and the map is meant to answer "what points at
hosts?" from one file. What lives here is the act of applying them.

The direction rule holds in the arrows themselves: every one runs plugin ->
core or plugin -> plugin, and nothing in core points back, so uninstalling a
plugin is still just dropping its tables.

Each plugin's group is its own name, a string, where core's groups are
ints. planConstraints() and planSweep() select on ===, which is what
keeps the two spaces apart — and it has to be strict, because PHP 7.4 is
FOG's floor and there 5 == 'ldap' is true. A plugin group written as a
number would be applied by a core schema step, against tables core had not
swept.

Sweep, then add

ADR 0031 decision 8. ADD CONSTRAINT validates the rows already in the table
and answers 1452 if any of them point at a parent that is gone — and
applyConstraints() reports a refusal rather than returning it, so an
install that skipped the sweep would succeed while silently not creating the
constraint. The sweep is the precondition for the statement, not a policy
choice.

Every step is appended, never folded into an earlier one: installdb() skips
the pSchema steps an install has already passed instead of replaying them,
so editing an earlier step is invisible to everyone past it. That is exactly
what LDAP steps 10-16 exist to repair.

Two that needed a decision

  • location.lStorageNodeID gets SET NULL, and its 0 becomes NULL. The
    column is a tri-state and always has been — Location::getStorageNode()
    returns the named node when truthy and falls through to the group's optimal
    node when not — so 0 was a documented, load-bearing value. A foreign key
    accepts NULL for "no reference" and nothing else. Nothing reading it
    changed: FOGController::get() reads through isset(), so NULL comes back
    as '' and is falsy exactly as 0 was. createSql() now builds the column
    nullable so a fresh install is not created already needing the migration.
  • oidcIdentity is a satellite of both ends. It is the record that this
    external subject is this FOG user, so deleting either end has to take it.
    Left behind, the next user created with a recycled id inherits someone
    else's identity binding.

ldapUserGrant.lugTargetID and oidcUserGrant.ougTargetID stay polymorphic
and take no constraint — the parent table is chosen by a sibling
*TargetType column. Same shape as core's scheduledTasks.stGroupHostID.

LDAP is where this was already visible

Its existing steps 19-21 are this same sweep, hand-written, added because
deleting a user, role or user group did not clear the plugin's rows: an
install upgraded from before LDAPDeleteMassItems existed held mappings
pointing at ids that were gone. Those steps stay as they are, but they only
ever ran once, against a backlog. These constraints stop the backlog forming
again — in the database, rather than in a hook that has to be remembered at
every delete site.

Verification

Against a lab clone of a live database at schema 390, all five applied in
sequence:

Constraints in the database 88
Declared and enabled in the map 89
Not applied 1 — fk_nfsGroupMembers_ngmGroupID, a core relationship

That one gap is the refusal already documented for core group 5: a storage
node sits at group 0 on that data and nothing guesses a group. Every other
declared relationship landed with the exact ON DELETE the map names — no
mismatches, and nothing in the database the map does not declare.

Three properties checked separately, because each can fail while the others
pass:

  • Fresh install — every plugin table dropped, all five run from step 0:
    the same 88, with lStorageNodeID built nullable rather than migrated
    afterward.
  • Idempotence — every plugin re-run planned no ALTER at all.
  • Convergence — the unfiltered reconcile() that follows every core
    schema update, run over the finished install, plans exactly the one known
    refusal and returns true with it collected.

Behavior was probed per plugin against real rows and rolled back, not
inferred from DELETE_RULE. Deleting one LDAP server removed its 2 groups
and the 2 role associations pointing at those groups; deleting an OIDC
provider removed both identities, its group, and the role association
pointing at that group. Two levels, no PHP.

The new gate

tests/foreign-keys-applied-per-plugin.test.php pins, per plugin, that the
sweep is called, that the add is called, that the sweep comes first, and
that neither is called unfiltered. Mutation-tested: dropping either call,
reversing their order, dropping the group argument, reverting the column to
NOT NULL, dropping the 0-to-NULL conversion, and building the column
NOT NULL in createSql() each turn a check red.

It deliberately does not check that SchemaReconciler is fully qualified
core-references-are-qualified.test.php already does that generically, and
a check written here would pass while one of the two calls kept its
namespace.

🤖 Generated with Claude Code

https://claude.ai/code/session_019Q7gwbNYDmYVFRBrXDXFz5

mastacontrola and others added 5 commits August 29, 2026 12:18
…as 0

fogproject ADR 0031, the first of the five plugins. Two appended schema
steps and one correction to createSql().

Step 4 makes lStorageNodeID nullable and converts its 0s. The column is a
tri-state and always has been -- Location::getStorageNode() returns the named
node when it is truthy and falls through to the storage group's optimal node
when it is not -- but it spelled "no node" as 0, and a foreign key accepts
NULL for "no reference" and nothing else. A constraint over the column as it
stood would demand a storage node with ngmID = 0 and refuse every location
that had not pinned one.

Nothing reading the column changed, and that is the point:
FOGController::get() reads through isset(), so NULL comes back as '' and is
falsy exactly as 0 was. createSql() now builds the column nullable so a fresh
install is not created already needing the migration -- appended rather than
folded into step 0 for the reason step 3 already gives, that installdb()
skips the pSchema steps an install has passed instead of replaying them.

Step 5 sweeps, then adds. Decision 8: ADD CONSTRAINT validates the rows
already in the table and answers 1452 if any point at a parent that is gone,
and applyConstraints() reports a refusal rather than returning it -- so an
install that skips the sweep succeeds, silently, without the constraint.
Both calls are filtered to this plugin's own group, so neither can reach
another plugin's tables or core's.

The relationships themselves are declared in fogproject's
commons/schema-constraints.php, not here. Half of them point at core tables,
and that map is meant to answer "what points at hosts?" from one file.

Two of the four needed a decision rather than the obvious CASCADE:

- lStorageNodeID gets SET NULL. Deleting a storage node that a location names
  degrades that location to group-optimal selection -- a state the plugin
  already implements and already handles. RESTRICT would refuse the delete
  with an opaque foreign key error naming no location.
- lStorageGroupID gets RESTRICT and no sentinel: storagegroupID is in
  Location::$databaseFieldsRequired, so the column has no legitimate 0.

Verified against a lab clone of the live database at schema 390. Fresh
install: table built nullable, all four constraints applied. Upgrade over
dirty data: the sweep removed one orphaned association and cleared one
location naming a node that no longer existed, then all four applied. Re-run:
nothing planned, nothing issued. Behavior probed individually -- deleting a
host removed its association, deleting the location removed the rest,
deleting a referenced storage group was refused with 1451 naming the
constraint, and deleting a named storage node left the location with its
reference NULL rather than removing it.

The new gate is mutation-tested: dropping either call, reversing their order,
dropping the group argument, reverting the column to NOT NULL, dropping the
0-to-NULL conversion, and building the column NOT NULL in createSql() each
turn a check red. It does not check that SchemaReconciler is fully qualified
-- core-references-are-qualified.test.php already does that generically, and
a check written here would pass while one of the two calls kept its
namespace.

Co-Authored-By: Claude <noreply@anthropic.com>
fogproject ADR 0031. One appended schema step, no column changes: both
ouAssoc columns are already int(11) NOT NULL against int(11) parents, and
neither carries a sentinel -- an association row exists only to name both
ends, so there is no "no reference" state to spell.

Decision 8's order, sweep then add. ADD CONSTRAINT validates the rows already
in the table and answers 1452 if any of them point at a parent that is gone,
and applyConstraints() REPORTS a refusal rather than returning it -- so an
install that skipped the sweep would succeed while silently not creating the
constraint. Both calls are filtered to this plugin's own group.

The relationships are declared in fogproject's
commons/schema-constraints.php: oaOUID CASCADE to `ou`, oaHostID CASCADE to
`hosts`. Both arrows run plugin -> core or plugin -> plugin, so uninstalling
the plugin is still just dropping its tables.

Appended rather than folded into step 0 because installdb() skips the pSchema
steps an install has already passed instead of replaying them.

Verified against a lab clone at schema 390: both constraints applied, the
sweep found nothing to remove, a second run planned no ALTER at all. Behavior
probed and rolled back -- deleting a host removed its association (3 -> 2),
deleting the OU removed the rest (2 -> 0).

Co-Authored-By: Claude <noreply@anthropic.com>
fogproject ADR 0031. One appended schema step, no column changes: both
windowsKeysAssoc columns are already int(11) NOT NULL against int(11)
parents, and neither carries a sentinel.

Decision 8's order, sweep then add, both calls filtered to this plugin's own
group. The relationships are declared in fogproject's
commons/schema-constraints.php: wkaImageID CASCADE to `images`, wkaKeyID
CASCADE to `windowsKeys`. CASCADE on the image side is the behavior that
already existed -- deleting an image was never meant to leave a key assigned
to it, and the association row carries nothing of its own to preserve.

The gate stopped matching on raw source and now strips whitespace as well as
comments. It failed this plugin's perfectly valid applyConstraints() purely
because the argument was wrapped to keep the line under 80 columns, which is
a test that fails on formatting rather than on behavior. Re-proved after the
change: dropping either call, reversing their order, dropping the group
argument, and each of the three location-specific checks still turn red.

Verified against a lab clone at schema 390: both constraints applied, the
sweep found nothing, a second run planned no ALTER. Behavior probed and
rolled back -- deleting an image removed its association (3 -> 2), deleting
the key removed the rest (2 -> 0).

Co-Authored-By: Claude <noreply@anthropic.com>
fogproject ADR 0031. One appended schema step, no column changes: all six are
int(11) NOT NULL against int(11) parents and none carries a sentinel.

  LDAPGroups.lgServerID                   -> LDAPServers.lsID
  ldapGroupRoleAssoc.lgraGroupID          -> LDAPGroups.lgID
  ldapGroupRoleAssoc.lgraRoleID           -> roles.rID
  ldapGroupUserGroupAssoc.lgugGroupID     -> LDAPGroups.lgID
  ldapGroupUserGroupAssoc.lgugUserGroupID -> userGroups.ugID
  ldapUserGrant.lugUserID                 -> users.uId

All CASCADE. LDAPGroups is a satellite of its server -- a group has no
meaning without the directory it was read from -- and the rest are junctions.

This plugin is where the problem was already visible. Steps 19-21 are the
same sweep hand-written, added because deleting a user, role or user group
did not clear this plugin's rows, so an install upgraded from before
LDAPDeleteMassItems existed held mappings pointing at ids that were gone.
Those steps stay as they are -- anyone past them has already run them -- but
they only ever ran once, against a backlog. These constraints are what stop
the backlog forming again, in the database rather than in a hook that has to
be remembered at every delete site.

lugTargetID is deliberately excluded and stays polymorphic: its parent table
is chosen by the sibling lugTargetType column, so there is no single table to
reference. Step 19 sweeps it by hand and the hook keeps it clean, the same
shape as core's scheduledTasks.stGroupHostID.

Verified against a lab clone at schema 390 holding 12 LDAP groups, 12 role
associations and 21 user grants. All six applied, the sweep found nothing, a
second run planned no ALTER. Behavior probed and rolled back: deleting one
LDAP server removed its 2 groups AND the 2 role associations pointing at
those groups -- a two-level cascade with no PHP involved, which is the whole
point -- and deleting a user removed its 2 grants.

Co-Authored-By: Claude <noreply@anthropic.com>
fogproject ADR 0031, the last of the five plugins. One appended schema step,
no column changes: all eight are int(11) NOT NULL against int(11) parents and
none carries a sentinel.

  OIDCGroups.ogProviderID                 -> OIDCProviders.opID
  oidcIdentity.oiProviderID               -> OIDCProviders.opID
  oidcIdentity.oiUserID                   -> users.uId
  oidcGroupRoleAssoc.ograGroupID          -> OIDCGroups.ogID
  oidcGroupRoleAssoc.ograRoleID           -> roles.rID
  oidcGroupUserGroupAssoc.ogugGroupID     -> OIDCGroups.ogID
  oidcGroupUserGroupAssoc.ogugUserGroupID -> userGroups.ugID
  oidcUserGrant.ougUserID                 -> users.uId

All CASCADE. OIDCGroups and oidcIdentity are satellites -- a group claim
mapping and a subject-to-user binding both mean nothing without the provider
they came from -- and the rest are junctions.

oidcIdentity is the one worth being deliberate about rather than defaulting.
It is the record that this external subject IS this FOG user, so deleting
either end has to take it; leaving it behind would let the next user created
with a recycled id inherit someone else's identity binding.

All eight land in OIDCManager even though five of the tables belong to other
managers, including oidcIdentity, whose own schema() runs separately at step
1. The calls are driven by the table names in the map rather than by whose
manager is executing, so the plugin needs exactly one constraint step and it
belongs in the orchestrator -- by which point every one of its tables exists.

ougTargetID is excluded and stays polymorphic: its parent table is chosen by
the sibling ougTargetType column, the same shape as core's
scheduledTasks.stGroupHostID.

Verified against a lab clone at schema 390 holding 1 provider, 1 group, 2
identities and 2 user grants. All eight applied, the sweep found nothing, a
second run planned no ALTER. Behavior probed and rolled back: deleting the
provider removed both identities, its group, and the role association
pointing at that group -- two levels, no PHP -- and deleting the user that
held the grants removed both.

Co-Authored-By: Claude <noreply@anthropic.com>
@mastacontrola
mastacontrola merged commit 85745b7 into main Aug 29, 2026
2 checks passed
@mastacontrola
mastacontrola deleted the fk-constraints branch August 29, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant