Declare the plugin tables' foreign keys - #29
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
locationlStorageNodeIDnullable,0-> NULLouwindowskeyldapoidcWhy 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 athosts?" 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()andplanSweep()select on===, which is whatkeeps 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 anumber would be applied by a core schema step, against tables core had not
swept.
Sweep, then add
ADR 0031 decision 8.
ADD CONSTRAINTvalidates the rows already in the tableand answers 1452 if any of them point at a parent that is gone — and
applyConstraints()reports a refusal rather than returning it, so aninstall 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()skipsthe
pSchemasteps 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.lStorageNodeIDgets SET NULL, and its0becomes NULL. Thecolumn 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
0was a documented, load-bearing value. A foreign keyaccepts NULL for "no reference" and nothing else. Nothing reading it
changed:
FOGController::get()reads throughisset(), so NULL comes backas
''and is falsy exactly as0was.createSql()now builds the columnnullable so a fresh install is not created already needing the migration.
oidcIdentityis a satellite of both ends. It is the record that thisexternal 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.lugTargetIDandoidcUserGrant.ougTargetIDstay polymorphicand take no constraint — the parent table is chosen by a sibling
*TargetTypecolumn. Same shape as core'sscheduledTasks.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
LDAPDeleteMassItemsexisted held mappingspointing 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:
fk_nfsGroupMembers_ngmGroupID, a core relationshipThat 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 DELETEthe map names — nomismatches, and nothing in the database the map does not declare.
Three properties checked separately, because each can fail while the others
pass:
the same 88, with
lStorageNodeIDbuilt nullable rather than migratedafterward.
ALTERat all.reconcile()that follows every coreschema 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 groupsand 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.phppins, per plugin, that thesweep 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 the0-to-NULL conversion, and building the columnNOT NULLincreateSql()each turn a check red.It deliberately does not check that
SchemaReconcileris fully qualified—
core-references-are-qualified.test.phpalready does that generically, anda check written here would pass while one of the two calls kept its
namespace.
🤖 Generated with Claude Code
https://claude.ai/code/session_019Q7gwbNYDmYVFRBrXDXFz5