User Tools

Site Tools


start

This is an old revision of the document!


Title: Consistency Manager (Edges + Policy-Aware Deletion) Version: 1.2 Updated: 2025-11-11

1) Purpose


Maintain application-level referential integrity across PHP models (Invoice, InvoiceLine, Account, Partner, Contract, User, …) using: - An edges registry table (`consistency_edges`) that stores directed links like `invoice → user` with a relation label (e.g., `invoice:owner`). - A centralized policy map that decides what to do when deleting a node: RESTRICT | NULLIFY | CASCADE | DETACH. - A single, policy-aware deletion method: `_destroyMySelf()` on every model.

What changed today - Edges can reliably reference string identifiers (e.g., `partners.uniqueid`) as `dst_id`. - Per-edge resolve_by support: pass `['resolve_by' ⇒ 'uniqueid']` to resolve a string key to the destination’s numeric `id` before storing. - Optional edge UID templating saved in `meta_json.uid`. - A registry-driven reindex CLI builds all edges from a single PHP registry file. - Safer table-name resolution for `\Lib\GlobalOptions::*` across runtime and CLI. - Inbound/outbound lookups accept string `dst_id` and numeric `src_id` consistently.

2) One deletion method


Only call `_destroyMySelf()` from application code. It is policy-aware by default. It: - Inspects inbound edges into the node being deleted. - Applies the policy for that node’s table. - Performs NULLIFY/DETACH/CASCADE where configured. - Physically deletes the row and removes its edges.

No separate `delete()` or wrapper is required.

3) GlobalOptions table names


In model edge maps and in the policy map, table names are configured as `\Lib\GlobalOptions::*` identifiers. At runtime, these identifiers are resolved to physical table names by calling the corresponding `GlobalOptions` method.

Examples: - `\Lib\GlobalOptions::CPTDB_TBL_USERS()` → actual users table - `\Lib\GlobalOptions::CPTDB_TBL_ACCOUNTING_INVOICES()` → actual invoices table

Models set `$this→table = \Lib\GlobalOptions::XYZ();` so they always use the physical table when persisting/loading.

4) Edges map (per model)


Each model defines `$edgesMap` to declare outbound relations. Example (Invoice):

``` protected array $edgesMap = [

'owner_user_id' => [
  'relation'  => 'invoice:owner',
  'dst_table' => 'GlobalOptions::CPTDB_TBL_USERS',
],
'address_id' => [
  'relation'  => 'invoice:address',
  'dst_table' => 'GlobalOptions::CPTDB_TBL_ADDRESSES',
],
'contact_id' => [
  'relation'  => 'invoice:contact',
  'dst_table' => 'GlobalOptions::CPTDB_TBL_CONTACTS',
],
'rawfile_id' => [
  'relation'  => 'invoice:rawfile',
  'dst_table' => 'GlobalOptions::CPTDB_TBL_RAWFILES',
],
// SPECIAL: invoice.partner_id stores the Partner’s UNIQUE string key (partners.uniqueid)
'partner_id' => [
  'relation'   => 'invoice:partner',
  'dst_table'  => 'GlobalOptions::CPTDB_TBL_PARTNERS',
  // choose ONE of the following, depending on how you want to store:
  // A) Keep edges.dst_id as the UNIQUE string (no lookup):
  //    (no resolve_by set)
  // B) Resolve the unique string to partner.id before storing:
  'resolve_by' => 'uniqueid',
  // Optional audit UID template (goes to meta_json.uid):
  // 'uid' => 'inv:partner:{src_id}:{dst_id}',
],

]; ```

On every `_save()`, the base model: - Deletes old outgoing edges for this record. - Inserts new edges derived from current field values (using the rules above).

5) Policy map


Defined in `Lib/Model/ConsistencyPolicy.php`. Keys and `nullify.table` entries can use `GlobalOptions::*` identifiers.

``` $T_USER = 'GlobalOptions::CPTDB_TBL_USERS'; $T_INVOICES = 'GlobalOptions::CPTDB_TBL_ACCOUNTING_INVOICES'; $T_INVOICE_LINES = 'GlobalOptions::CPTDB_TBL_ACCOUNTING_INVOICE_LINES';

return [

$T_USER => [
  'inbound' => [
    'invoice:owner' => [
      'action'  => 'NULLIFY',
      'nullify' => [
        ['table' => 'GlobalOptions::CPTDB_TBL_ACCOUNTING_INVOICES', 'field' => 'owner_user_id'],
      ],
    ],
    '*' => ['action' => 'RESTRICT'],
  ],
],
$T_INVOICES => [
  'inbound' => [
    'invoice_line:invoice' => ['action' => 'CASCADE'],
    '*' => ['action' => 'RESTRICT'],
  ],
],
$T_INVOICE_LINES => [
  'inbound' => [
    '*' => ['action' => 'RESTRICT'],
  ],
],

]; ```

Actions - RESTRICT: block deletion if any inbound edges match. - NULLIFY : set referencing field(s) to `NULL` based on `nullify` list. - CASCADE : recursively delete referencing sources (policy-aware). - DETACH : remove edges only; no data changes.

6) Storage model & SQL schema


What the runtime stores - `src_table` / `dst_table`: raw physical table names (GlobalOptions are resolved before write). - `src_id`: integer (the source record’s `id`). - `dst_id`: string (can be a numeric id or a unique string such as `partners.uniqueid`). - `meta_json`: free JSON; the manager can write `{“_resolved”:{“by”:“uniqueid”,“from”:“NVG-2025-…”,“to”:“55090”}}` and/or a `uid`.

Recommended schema (`sql/consistency_edges.sql`) ``` CREATE TABLE `consistency_edges` (

`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`src_table` VARCHAR(128) NOT NULL,
`src_id` BIGINT UNSIGNED NOT NULL,
`relation` VARCHAR(128) NOT NULL,
`dst_table` VARCHAR(128) NOT NULL,
`dst_id` VARCHAR(128) NOT NULL,              -- CHANGED: allow string identifiers
`weight` INT NOT NULL DEFAULT 1,
`meta_json` JSON NULL,
`created` DATETIME NOT NULL,
`updated` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `u_edge` (`src_table`,`src_id`,`relation`,`dst_table`,`dst_id`),
KEY `i_dst` (`dst_table`,`dst_id`),
KEY `i_src` (`src_table`,`src_id`),
KEY `i_rel` (`relation`)

); ```

Migration note - If you previously had `dst_id BIGINT`, migrate to `VARCHAR(128)` if you want to store string keys. - If you prefer to keep `dst_id` numeric, use `resolve_by` (see §7) so the manager resolves the string to the destination’s numeric `id` before insert.

7) How destination identifiers are chosen (numeric vs string)


There are two supported modes per edge:

A) Store the string key as `dst_id` (no resolution) - Do not set `resolve_by`. - The edge stores whatever the model field contains (e.g., `partner_id = 'NVG-2025-1181A'`). - Schema must allow strings for `dst_id`.

B) Resolve the string key → destination numeric `id` - In the edge definition (model or registry), set: `'resolve_by' ⇒ 'uniqueid'`. - The manager runs: `SELECT id FROM partners WHERE uniqueid = :val LIMIT 1`. - If found, it stores that numeric `id` as `dst_id`, and writes a hint into `meta_json._resolved`. - If not found, it keeps the original value and logs a warning.

Which is correct? Both are supported. Pick a single convention per table/relation and keep it consistent. - Prefer resolution to numeric `id` when you control the destination table and want faster joins. - Prefer string `dst_id` when referencing external identifiers you don’t want to dereference now.

8) CLI (with registry-based reindex)


Usage: ``` php bin/consistency show inbound <TABLE|GlobalOptionsId> <ID> php bin/consistency show outbound <TABLE|GlobalOptionsId> <ID> php bin/consistency delete <TABLE|GlobalOptionsId> <ID> php bin/consistency sync <TABLE|GlobalOptionsId> <ID>

# NEW php bin/consistency reindex table <TABLE|GlobalOptionsId> [–truncate-first] php bin/consistency reindex all [–truncate-first] ```

- Registry file: `Lib/Model/edges.registry.php`

Central place to declare source tables, their PKs, and edges (including `resolve_by` and optional `uid` template).

Registry example (Invoice) ``` return [

'nvg_cpt_accounting_invoices' => [
  'pk'    => 'id',
  'chunk' => 1000,
  'edges' => [
    'owner_user_id' => [
      'relation'  => 'invoice:owner',
      'dst_table' => 'GlobalOptions::CPTDB_TBL_USERS',
      'uid'       => '{src_table}:{relation}:{src_id}->{dst_table}:{dst_id}',
    ],
    'address_id' => [
      'relation'  => 'invoice:address',
      'dst_table' => 'GlobalOptions::CPTDB_TBL_ADDRESSES',
      'uid'       => 'inv:addr:{src_id}:{dst_id}',
    ],
    'contact_id' => [
      'relation'  => 'invoice:contact',
      'dst_table' => 'GlobalOptions::CPTDB_TBL_CONTACTS',
    ],
    'rawfile_id' => [
      'relation'  => 'invoice:rawfile',
      'dst_table' => 'GlobalOptions::CPTDB_TBL_RAWFILES',
    ],
    // SPECIAL
    'partner_id' => [
      'relation'   => 'invoice:partner',
      'dst_table'  => 'GlobalOptions::CPTDB_TBL_PARTNERS',
      'resolve_by' => 'uniqueid',  // resolve 'NVG-2025-…' → partners.id
    ],
  ],
],

]; ```

UID templating You can add a stable edge identifier (for audits) via `uid`: - Example: `{src_table}:{relation}:{src_id}→{dst_table}:{dst_id}` - Example: `inv:addr:{src_id}:{dst_id}` The CLI and runtime write this into `meta_json.uid`.

9) Runtime hooks (where edges are written)


In the base model, the simplified hook:

``` protected function registerConsistencyEdges(): void {

if (!class_exists('\Lib\Model\_ConsistencyManager')) return;
$mgr = \Lib\Model\_ConsistencyManager::get();
$tbl = $this->fetchTableName();
if (!$mgr || !$tbl || $this->id <= 0) return;
// Use raw table name (resolved already in fetchTableName)
$srcSpec = $tbl;
// Remove old outbound edges
$mgr->removeLinksFor(['table' => $srcSpec, 'id' => $this->id], 'src');
// Recreate outbound edges
foreach ($this->edgesFromSelf() as $edge) {
  $mgr->ensureLink(
    ['table' => $srcSpec, 'id' => $this->id],
    $edge['relation'],
    [
      'table'      => $edge['dst_table'],
      'id'         => $edge['dst_id'],
      // Pass-through: allow special resolution per edge
      'resolve_by' => $edge['resolve_by'] ?? null,
    ],
    $edge['meta'] ?? [],
    1
  );
}

} ```

And the minimal `edgesFromSelf()` update:

``` public function edgesFromSelf(): array {

$edges = [];
foreach ($this->edgesMap as $field => $map) {
  $raw = $this->record[$field] ?? null;
  if ($raw === null || $raw === '' || $raw === 0 || $raw === '0') continue;
  $edges[] = [
    'relation'   => (string)($map['relation'] ?? (static::class . ':' . $field)),
    'dst_table'  => $this->canonicalizeSpec((string)($map['dst_table'] ?? '')),
    'dst_id'     => (string)$raw,                       // keep as-is (string or number)
    'resolve_by' => isset($map['resolve_by']) ? (string)$map['resolve_by'] : null,
    'meta'       => (array)($map['meta'] ?? []),
  ];
}
return $edges;

} ```

10) Worked examples


1) Delete a User who owns invoices (owner_user_id) Policy: `invoice:owner → NULLIFY invoice.owner_user_id`

 Result: Sets `owner_user_id = NULL` on referencing invoices; edges removed; user deleted.

2) Delete a Partner referenced by invoices Policy: `invoice:partner → RESTRICT`

 Result: Blocked. Reassign or change policy to NULLIFY/CASCADE.

3) Delete an Invoice that has lines Policy: `invoice_line:invoice → CASCADE`

 Result: Deletes lines first (policy-aware), then the invoice.

4) Delete an Account referenced by invoice lines Policy: `invoice_line:account → RESTRICT`

 Result: Blocked. Migrate lines or change policy to NULLIFY.

5) Delete an InvoiceLine With default `'*' ⇒ RESTRICT` on line destinations, deletions are constrained by inbound relations (if any).

6) DETACH-only scenario If an inbound relation is DETACH, deletion removes the edge row, data stays untouched.

7) Partner unique key on Invoice

  1. If `edgesMap['partner_id']['resolve_by']='uniqueid'`: `partner_id='NVG-2025-…'` resolves to `partners.id` and stores that numeric id in `dst_id`.
  2. If omitted: the edge stores the string `NVG-2025-…` directly as `dst_id` (schema must allow strings).

11) Tips


- Always call `_destroyMySelf()` to delete. It’s policy-aware. - Keep `edgesMap` minimal and meaningful. - Prefer NULLIFY for optional FKs that can be cleared. - Use CASCADE sparingly and deliberately (e.g., Invoice → InvoiceLine). - RESTRICT is a safe default when in doubt. - Decide one convention per relation for `dst_id` (numeric vs string) and stick to it; if you need numeric, add `resolve_by`.

12) Quick migration checklist


- If you will store string `dst_id` values:

`ALTER TABLE consistency_edges MODIFY dst_id VARCHAR(128) NOT NULL;`

- (Optional) Add an edge UID to meta via registry `uid` templates (no schema change). - Run CLI reindex to rebuild edges from the new registry:

  1. One table: `php bin/consistency reindex table GlobalOptions::CPTDB_TBL_ACCOUNTING_INVOICES –truncate-first`
  2. All: `php bin/consistency reindex all –truncate-first`

- Verify deletion flows through `_destroyMySelf()` against your policy map.

start.1762875916.txt.gz · Last modified: by admin