Skip to content

Commit 8376b0d

Browse files
committed
Fixing CRUD cascading operations to work on raw data rows not visible rows.
Also revising menu management to regenerate menus for tabs and their descendents based on update and delete events.
1 parent 9c8534b commit 8376b0d

4 files changed

Lines changed: 225 additions & 112 deletions

File tree

docs/README_developer.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,74 @@ After each merge step, if the resulting entry lacks a `name` field, the slot/att
8585

8686
---
8787

88+
## CRUD Operations (`AppContext.js`)
89+
90+
DataHarmonizer's 1-to-many mode links multiple Handsontable tabs in a parent–child hierarchy driven by foreign-key annotations in the schema. `AppContext.js` implements all CRUD coordination across those tabs.
91+
92+
### Relation map (`crudGetRelations`)
93+
94+
At load time, `crudGetRelations(schema)` walks every class's `attributes` and looks for slots annotated with `foreign_key: OtherClass.slot_name`. For each such slot it builds a symmetric entry in `this.relations`:
95+
96+
```
97+
relations[childClass].parent[parentClass][childSlot] = parentSlot
98+
relations[childClass].dependent_slots[childSlot] = { foreign_class, foreign_slot }
99+
relations[parentClass].child[childClass][parentSlot] = childSlot
100+
relations[parentClass].target_slots[parentSlot][childClass] = childSlot
101+
```
102+
103+
`relations[cls].dependents` is then populated by `crudGetDependents`, which walks the child graph breadth-first to collect every transitive descendant of a class.
104+
105+
### Dependent-row state (`dependent_rows`)
106+
107+
`this.dependent_rows` is a `Map` keyed by class name. Each entry records the current cascade context for that class:
108+
109+
| Field | Meaning |
110+
|-------|---------|
111+
| `fkey_vals` | Foreign-key slot values used to filter this class's rows |
112+
| `fkey_status` | 0 = unsatisfied, 1 = partial, 2 = no FKs, 3 = fully satisfied |
113+
| `key_vals` | The class's own key slot values (used by children as their FK values) |
114+
| `key_changed_vals` | Which key slots are changing and to what new value |
115+
116+
### Refreshing tab display (`refreshTabDisplay`)
117+
118+
Called whenever the user selects a row in any tab. It calls `crudGetDependentRows` to recompute `dependent_rows` for the selected class and all its descendants, then:
119+
120+
- Calls `setDHTabStatus` on each descendant — enabling or disabling the tab based on `fkey_status`.
121+
- Calls `tabFilter` on each descendant — hiding rows whose FK values do not match the cascade context.
122+
123+
`tabFilter` uses Handsontable's HiddenRows plugin so that hidden rows remain in the source data and are not deleted.
124+
125+
### Cascade update (`getChangeReport` + `beforeChange` hook)
126+
127+
When a user edits a key slot in a tab's row, `DataHarmonizer.js`'s `beforeChange` hook fires. It calls `getChangeReport` which in turn calls `crudGetDependentRows` with the pending change. If any descendant rows would be affected, a confirmation dialog lists them. On confirmation, each affected row in each descendant tab has its FK slot updated via `setSourceDataAtCell` (physical row index, reaches hidden rows).
128+
129+
#### How cascade FK values are built for dependents
130+
131+
`crudGetDependentRows` iterates the `dependents` map of the changed class in dependency order. For each dependent class it derives `fkey_vals` **purely from the cascade chain already recorded in `dependent_rows`** — not from the user's current tab selection. This ensures the cascade scope is determined only by the record being changed:
132+
133+
- If `start_name = 'Schema'` and Schema.name is being renamed, every descendant (Class, Slot, UniqueKey, Enum, etc.) is searched using only `{schema_id: oldName}`. A currently selected Class row does **not** narrow the search to that class's rows.
134+
- If `start_name = 'Class'` and Class.name is being renamed, descendants are searched using `{class_id: oldName, schema_id: <value from Schema's cascade entry>}`.
135+
136+
Null FK values (parents not in the cascade chain) are excluded from the row search, so only the keys that actually flow from the changed record are applied as filters.
137+
138+
`key_vals` stored for intermediate classes in `dependent_rows` contains only FK-chain values (no user-selection values), preventing user selections from leaking into downstream FK lookups.
139+
140+
### Cascade delete (`getChangeReport` + right-click Delete)
141+
142+
The same `getChangeReport` machinery is used. On confirmation, dependent rows are removed via `crudDeleteRowsByPhysical`, which filters the source data array and calls `hot.loadData()` — removing all matching rows (visible or hidden) without needing visual-row index conversion.
143+
144+
### Row search (`crudFindRowByKeyVals` / `crudFindAllRowsByKeyVals`)
145+
146+
Searches are done on physical (source) row indices using `getSourceDataAtCell`, so hidden rows are always included. Null values in the search filter are treated as "match any" by being excluded from the filter before the search.
147+
148+
### Current scope and future directions
149+
150+
The cascade system currently operates in **single-hierarchy** mode: a rename or delete on a given class propagates to all descendants of that class using only the changed record's key values as the filter. The user's selections in other tabs do not influence the cascade scope.
151+
152+
A future option could support **multi-focus cascading**, where the user first narrows one or more intermediate tabs (e.g., selects a specific Class row) and the cascade is then scoped to that focused subset — for example, renaming only the UniqueKey rows that belong to the selected class, rather than all UniqueKey rows for the schema. This would require extending `crudGetDependentRows` to optionally incorporate user-selection values from non-start-class tabs into the `fkey_vals` chain.
153+
154+
---
155+
88156
## `tabular_to_schema.py` — Legacy Schema Build Tool
89157

90158
`script/tabular_to_schema.py` was the previous way of assembling a complete DataHarmonizer `schema.yaml` from spreadsheet-based source files. It combines three inputs:

lib/AppContext.js

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,10 +1463,44 @@ export default class AppContext {
14631463

14641464
// For each dependent
14651465
this.relations[start_name]?.dependents?.keys().forEach((class_name) => {
1466-
// 1) Assemble the needed key_vals for this start class and its
1467-
// dependents (via foreign keys).
1468-
let [key_vals, fkey_vals, fkey_status] =
1469-
this.crudGetDependentKeyVals(class_name);
1466+
// Build fkey_vals purely from the cascade chain already recorded in
1467+
// dependent_rows. Do NOT call crudGetDependentKeyVals() here, because
1468+
// that function reads values from the user's currently selected rows in
1469+
// each tab — which would over-narrow the cascade search filter.
1470+
// E.g. if the user has "GRDISample" selected in the Class tab, calling
1471+
// crudGetDependentKeyVals for UniqueKey would inject class_id="GRDISample"
1472+
// into the filter, missing every other class's unique keys.
1473+
let fkey_vals = {};
1474+
let fkey_status;
1475+
let found_fkey = false;
1476+
1477+
if (this.relations[class_name].foreign_key_count === 0) {
1478+
fkey_status = 2; // no FKs — satisfied
1479+
} else {
1480+
fkey_status = 3; // assume satisfied until a null FK is found
1481+
for (let [slot_name, slot_link] of Object.entries(this.relations[class_name].dependent_slots)) {
1482+
let parent_dep = this.dependent_rows.get(slot_link.foreign_class);
1483+
let foreign_value = null;
1484+
if (parent_dep) {
1485+
foreign_value = parent_dep.key_vals[slot_link.foreign_slot] ?? null;
1486+
if (foreign_value === '') foreign_value = null;
1487+
}
1488+
fkey_vals[slot_name] = foreign_value;
1489+
if (foreign_value !== null) {
1490+
found_fkey = true;
1491+
} else {
1492+
fkey_status = 0;
1493+
}
1494+
}
1495+
if (fkey_status === 0 && found_fkey) fkey_status = 1; // partial
1496+
}
1497+
1498+
// key_vals for cascade: only FK-chain values, no user-selection values.
1499+
// When a downstream dependent reads this class's key_vals to get its own
1500+
// FK value, it will see null (not the user's selected row value), so it
1501+
// won't over-narrow its own search filter.
1502+
let key_vals = {...fkey_vals};
1503+
14701504
let dependent_rows_obj = {
14711505
fkey_vals: fkey_vals,
14721506
fkey_status: fkey_status,
@@ -1509,10 +1543,15 @@ export default class AppContext {
15091543
*/
15101544

15111545
if (fkey_status > 0) {
1512-
// Issue: special case: Field / slot table has slots with no class name,
1513-
// as well as class name = one in fkey_vals. Allow empty key fields -
1514-
// leave that to validation to catch.
1515-
let rows = this.crudFindAllRowsByKeyVals(class_name, fkey_vals);
1546+
// Exclude null FK values from the search: a null value means the
1547+
// corresponding parent has no selection, so treat it as "match any".
1548+
// This ensures multi-FK tables (Slot, SlotGroup, UniqueKey, EnumValue)
1549+
// are included in the cascade report even when only the top-level
1550+
// ancestor (e.g. Schema) is selected.
1551+
let search_vals = Object.fromEntries(
1552+
Object.entries(fkey_vals).filter(([k, v]) => v !== null)
1553+
);
1554+
let rows = this.crudFindAllRowsByKeyVals(class_name, search_vals);
15161555
if (rows.length) {
15171556

15181557
dependent_rows_obj['count'] = rows.length;
@@ -1705,6 +1744,24 @@ export default class AppContext {
17051744
}
17061745
*/
17071746

1747+
/**
1748+
* Delete rows from a DH tab by physical (source) row index.
1749+
* Copies the source data, removes the specified rows, and bulk-reloads the
1750+
* table via hot.loadData(). This approach works on all rows regardless of
1751+
* whether they are currently hidden by the tab filter.
1752+
* Note: loadData() resets hidden-row plugin state; re-apply tabFilter if needed.
1753+
*
1754+
* @param {String} class_name DH tab name
1755+
* @param {Array} physical_rows Array of physical (source) row indices to delete
1756+
*/
1757+
crudDeleteRowsByPhysical(class_name, physical_rows) {
1758+
const dh = this.dhs[class_name];
1759+
if (!dh || !physical_rows.length) return;
1760+
const rowSet = new Set(physical_rows);
1761+
const newData = dh.hot.getSourceData().filter((_, idx) => !rowSet.has(idx));
1762+
dh.hot.loadData(newData);
1763+
}
1764+
17081765
/**
17091766
* Search for key_vals combo in given dh handsontable.
17101767
* @param {Object} dh DataHarmonizer instance
@@ -1734,11 +1791,12 @@ export default class AppContext {
17341791
crudFindRowByKeyVals(dh, key_vals, row = 0) {
17351792
const total_rows = dh.hot.countSourceRows();
17361793
while (row < total_rows) {
1737-
if ( // .every() causes return on first break with row
1794+
if ( // .every() causes return on first break with row
17381795
Object.entries(key_vals).every(([slot_name, value]) => {
1739-
const col_value = dh.hot.getDataAtCell(row, dh.slot_name_to_column[slot_name]);
1740-
// Allow empty values by HandsonTable? But adding "col_value === null" triggers infinite loop?
1741-
return value == col_value; //|| (slot_name === 'class_id' && col_value === '')
1796+
// Use getSourceDataAtCell (physical row index) to search all rows,
1797+
// including those hidden by the tab filter.
1798+
const col_value = dh.hot.getSourceDataAtCell(row, dh.slot_name_to_column[slot_name]);
1799+
return value == col_value;
17421800
})
17431801
) {
17441802
break;

lib/DataHarmonizer.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -278,10 +278,11 @@ class DataHarmonizer {
278278
)
279279
) {
280280
// User has seen the warning and has confirmed ok to proceed.
281-
for (let [dependent_name, dependent_obj] of Object.entries(change_report)) {
281+
for (let [dependent_name, dependent_obj] of Object.entries(change_report)) {
282282
if (dependent_obj.rows?.length) {
283-
let dh_changes = dependent_obj.rows.map(x => [x,1]);
284-
self.context.dhs[dependent_name].hot.alter('remove_row', dh_changes);
283+
// rows are physical (source) indices. Delete via source-data reload
284+
// so hidden rows are also removed without needing toVisualRow().
285+
self.context.crudDeleteRowsByPhysical(dependent_name, dependent_obj.rows);
285286
}
286287
};
287288
self.hot.alter('remove_row', selection);
@@ -579,13 +580,14 @@ class DataHarmonizer {
579580
let dependent_dh = self.context.dhs[dependent_name];
580581
self.hot.suspendExecution(); // Advantageous to do this?
581582
dependent_dh.hot.batchRender(() => {
582-
for (let dep_row in dependent_obj.rows) {
583+
for (let dep_row of dependent_obj.rows) {
583584
Object.entries(dependent_obj.key_changed_vals).forEach(([dep_slot, dep_value]) => {
584585
let dep_col = dependent_dh.slot_name_to_column[dep_slot];
585-
// While setDataAtCell triggers this beforeUpdate() again, 'batch_updates' event is trapped without further ado above.
586-
dependent_dh.hot.setDataAtCell(dep_row, dep_col, dep_value, 'batch_updates');
586+
// dep_row is a physical (source) row index from crudFindRowByKeyVals.
587+
// setSourceDataAtCell works on physical rows and reaches hidden rows.
588+
dependent_dh.hot.setSourceDataAtCell(dep_row, dep_col, dep_value);
587589
})
588-
}
590+
}
589591
}); // End of hot batch render
590592
self.hot.resumeExecution();
591593
}

0 commit comments

Comments
 (0)