Skip to content

Commit e6374b5

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(objectql): master_detail cascade delete + autonumber; app-showcase master-detail page (#1601)
* feat(app-showcase): master-detail showcase page (New Project + Tasks) Adds showcase_project_workspace page rendering object-master-detail-form for showcase_project + showcase_task (master_detail), plus a nav entry. Verifies ObjectUI ADR-0001 master-detail subform end-to-end in the showcase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(objectql): master_detail cascade delete + autonumber generation - delete now applies referential delete behavior for incoming relations: master_detail cascades (parent owns children; only explicit 'restrict' deviates), lookup honors deleteBehavior (default set_null). Recurses for grandchildren; depth-guarded. - insert now generates values for empty autonumber fields BEFORE required validation (max+1, seeded per object.field, honors autonumberFormat), so a required autonumber is never rejected as 'missing'. Verified end-to-end on app-showcase: showcase_project delete cascades to showcase_task; showcase_field_zoo.f_autonumber auto-populates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: changeset for objectql cascade-delete + autonumber Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 60fc501 commit e6374b5

7 files changed

Lines changed: 258 additions & 6 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): master_detail cascade delete + autonumber generation
6+
7+
- `delete` now applies referential delete behavior for incoming relations: `master_detail` cascades to children (the parent owns the child lifecycle; only an explicit `restrict` deviates), `lookup` honors its `deleteBehavior` (default `set_null`). Recurses for grandchildren, depth-guarded, single-id deletes. Previously deleting a parent left its children orphaned.
8+
- `insert` now generates values for empty `autonumber` fields before required-validation (`max+1`, seeded per `object.field`, honors `autonumberFormat`). Previously a required autonumber was rejected as "missing" and autonumber fields were never populated.

examples/app-showcase/objectstack.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { ShowcaseApp } from './src/apps/index.js';
1010
import { ChartGalleryDashboard } from './src/dashboards/index.js';
1111
import { allReports } from './src/reports/index.js';
1212
import { allActions } from './src/actions/index.js';
13-
import { ComponentGalleryPage } from './src/pages/index.js';
13+
import { ComponentGalleryPage, ProjectWorkspacePage } from './src/pages/index.js';
1414
import { allFlows } from './src/flows/index.js';
1515
import { allWebhooks } from './src/webhooks/index.js';
1616
import { allJobs } from './src/jobs/index.js';
@@ -113,7 +113,7 @@ export default defineStack({
113113
apps: [ShowcaseApp],
114114
portals: allPortals,
115115
views: [TaskViews, ProjectViews],
116-
pages: [ComponentGalleryPage],
116+
pages: [ComponentGalleryPage, ProjectWorkspacePage],
117117
dashboards: [ChartGalleryDashboard],
118118
reports: allReports,
119119
actions: allActions,

examples/app-showcase/src/apps/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const ShowcaseApp = App.create({
4848
icon: 'layout',
4949
children: [
5050
{ id: 'nav_gallery', type: 'page', pageName: 'showcase_component_gallery', label: 'Component Gallery', icon: 'layout-template' },
51+
{ id: 'nav_project_workspace', type: 'page', pageName: 'showcase_project_workspace', label: 'New Project + Tasks', icon: 'folder-plus' },
5152
],
5253
},
5354
],

examples/app-showcase/src/pages/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import type { Page } from '@objectstack/spec/ui';
44

5+
export { ProjectWorkspacePage } from './project-workspace.page.js';
6+
57
/**
68
* Component Gallery — a custom page that places a spread of standard page
79
* components (header, card, tabs, text/number/image/divider/button elements,
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { Page } from '@objectstack/spec/ui';
4+
5+
/**
6+
* Project Workspace — a master-detail (header + line items) entry scenario.
7+
*
8+
* Demonstrates the `object-master-detail-form` renderer (ObjectUI ADR-0001):
9+
* create a Project (parent) together with its Tasks (children) in one screen.
10+
* `showcase_task.project` is a `master_detail` field, so the children are
11+
* created with the parent FK set in a single client-orchestrated transaction.
12+
*/
13+
export const ProjectWorkspacePage: Page = {
14+
name: 'showcase_project_workspace',
15+
label: 'New Project + Tasks',
16+
type: 'app',
17+
template: 'default',
18+
kind: 'full',
19+
regions: [
20+
{
21+
name: 'header',
22+
width: 'full',
23+
components: [
24+
{
25+
type: 'page:header',
26+
properties: {
27+
title: 'New Project + Tasks',
28+
subtitle:
29+
'Master-detail entry — fill the project, add its tasks inline, and save them together.',
30+
icon: 'folder-plus',
31+
},
32+
},
33+
],
34+
},
35+
{
36+
name: 'main',
37+
width: 'large',
38+
components: [
39+
{
40+
type: 'object-master-detail-form',
41+
properties: {
42+
objectName: 'showcase_project',
43+
mode: 'create',
44+
formType: 'simple',
45+
submitText: 'Create Project + Tasks',
46+
fields: ['name', 'account', 'status', 'health', 'budget', 'end_date'],
47+
details: [
48+
{
49+
title: 'Tasks',
50+
childObject: 'showcase_task',
51+
relationshipField: 'project',
52+
amountField: 'estimate_hours',
53+
addLabel: 'Add task',
54+
columns: [
55+
{ field: 'title', label: 'Title', type: 'text', required: true },
56+
{
57+
field: 'status',
58+
label: 'Status',
59+
type: 'select',
60+
options: [
61+
{ label: 'Backlog', value: 'backlog' },
62+
{ label: 'To Do', value: 'todo' },
63+
{ label: 'In Progress', value: 'in_progress' },
64+
{ label: 'In Review', value: 'in_review' },
65+
{ label: 'Done', value: 'done' },
66+
],
67+
},
68+
{
69+
field: 'priority',
70+
label: 'Priority',
71+
type: 'select',
72+
options: [
73+
{ label: 'Low', value: 'low' },
74+
{ label: 'Medium', value: 'medium' },
75+
{ label: 'High', value: 'high' },
76+
{ label: 'Urgent', value: 'urgent' },
77+
],
78+
},
79+
{ field: 'estimate_hours', label: 'Estimate (h)', type: 'number' },
80+
{ field: 'due_date', label: 'Due Date', type: 'date' },
81+
],
82+
},
83+
],
84+
},
85+
},
86+
],
87+
},
88+
],
89+
};

packages/objectql/src/engine.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,71 @@ export class ObjectQL implements IDataEngine {
701701
return out;
702702
}
703703

704+
/**
705+
* Generate values for empty `autonumber` fields on insert. Runs BEFORE
706+
* required-validation so a `required` autonumber (e.g. a record number) is
707+
* never rejected for "missing" — the runtime owns the value, not the client.
708+
*
709+
* The next value is `max(existing) + 1`, seeded once per `object.field` from
710+
* the store then incremented in memory (monotonic within the process,
711+
* resilient to deletions). `autonumberFormat` is honored, e.g.
712+
* `CASE-{0000}` → `CASE-0042`. NOTE: in-memory seeding is single-instance;
713+
* a persistent sequence store is a follow-up for multi-instance setups.
714+
*/
715+
private async applyAutonumbers(
716+
object: string,
717+
record: Record<string, unknown>,
718+
execCtx?: ExecutionContext,
719+
): Promise<void> {
720+
const fields = (this.getSchema(object) as any)?.fields;
721+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return;
722+
for (const [name, def] of Object.entries(fields)) {
723+
if ((def as any)?.type !== 'autonumber') continue;
724+
const current = record[name];
725+
if (current != null && current !== '') continue; // respect explicit value
726+
const key = `${object}.${name}`;
727+
let next = this.autonumberCounters.get(key);
728+
if (next == null) next = await this.seedAutonumber(object, name, execCtx);
729+
next += 1;
730+
this.autonumberCounters.set(key, next);
731+
record[name] = this.formatAutonumber((def as any).autonumberFormat, next);
732+
}
733+
}
734+
735+
/** Seed the autonumber counter from the current max numeric value in store. */
736+
private async seedAutonumber(
737+
object: string,
738+
field: string,
739+
execCtx?: ExecutionContext,
740+
): Promise<number> {
741+
try {
742+
const rows = await this.find(object, {
743+
select: ['id', field],
744+
limit: 5000,
745+
context: execCtx,
746+
} as any);
747+
let max = 0;
748+
for (const r of rows || []) {
749+
const v = r?.[field];
750+
if (v == null) continue;
751+
const m = String(v).match(/(\d+)(?!.*\d)/); // last run of digits
752+
if (m) max = Math.max(max, parseInt(m[1], 10) || 0);
753+
}
754+
return max;
755+
} catch {
756+
return 0;
757+
}
758+
}
759+
760+
/** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */
761+
private formatAutonumber(format: string | undefined, value: number): string {
762+
if (!format) return String(value);
763+
const m = format.match(/\{(0+)\}/);
764+
if (!m) return format.includes('{0}') ? format.replace('{0}', String(value)) : `${format}${value}`;
765+
const padded = String(value).padStart(m[1].length, '0');
766+
return format.replace(m[0], padded);
767+
}
768+
704769
/**
705770
* Register contribution (Manifest)
706771
*
@@ -1431,6 +1496,10 @@ export class ObjectQL implements IDataEngine {
14311496

14321497
/** Maximum depth for recursive expand to prevent infinite loops */
14331498
private static readonly MAX_EXPAND_DEPTH = 3;
1499+
private static readonly MAX_CASCADE_DEPTH = 10;
1500+
/** In-memory next-value cache per `object.field` for autonumber generation,
1501+
* lazily seeded from the current max in the store. */
1502+
private readonly autonumberCounters = new Map<string, number>();
14341503

14351504
/**
14361505
* Post-process expand: resolve lookup/master_detail fields by batch-loading related records.
@@ -1753,6 +1822,9 @@ export class ObjectQL implements IDataEngine {
17531822
const rows = (hookContext.input.data as any[]).map((row) =>
17541823
this.applyFieldDefaults(object, row as Record<string, unknown>, opCtx.context, nowSnap),
17551824
);
1825+
for (const r of rows) {
1826+
await this.applyAutonumbers(object, r as Record<string, unknown>, opCtx.context);
1827+
}
17561828
for (const r of rows) {
17571829
await this.encryptSecretFields(object, r, opCtx.context, hookContext.input.options);
17581830
}
@@ -1773,6 +1845,7 @@ export class ObjectQL implements IDataEngine {
17731845
opCtx.context,
17741846
nowSnap,
17751847
);
1848+
await this.applyAutonumbers(object, row, opCtx.context);
17761849
await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options);
17771850
validateRecord(schemaForValidation, row, 'insert');
17781851
evaluateValidationRules(schemaForValidation as any, row, 'insert', { logger: this.logger });
@@ -1946,6 +2019,82 @@ export class ObjectQL implements IDataEngine {
19462019
return opCtx.result;
19472020
}
19482021

2022+
/**
2023+
* Apply referential delete behavior for relations pointing AT this record,
2024+
* before it is removed. For every registered object with a `master_detail`
2025+
* or `lookup` field referencing `object`, honor the field's `deleteBehavior`:
2026+
* - `cascade` → delete the dependent rows (recursively, so grandchildren
2027+
* are handled by each child's own delete),
2028+
* - `set_null` → clear the foreign key,
2029+
* - `restrict` → refuse the delete when dependents exist.
2030+
* `master_detail` defaults to `cascade` (the parent owns the child
2031+
* lifecycle); `lookup` defaults to `set_null`. Only runs for single-id
2032+
* deletes — multi/predicate deletes skip cascade (logged).
2033+
*/
2034+
private async cascadeDeleteRelations(
2035+
object: string,
2036+
id: string | number,
2037+
context?: ExecutionContext,
2038+
depth = 0,
2039+
): Promise<void> {
2040+
if (id == null || depth >= ObjectQL.MAX_CASCADE_DEPTH) return;
2041+
let objects: ServiceObject[];
2042+
try {
2043+
objects = this._registry.getAllObjects();
2044+
} catch {
2045+
return;
2046+
}
2047+
for (const child of objects) {
2048+
const childName = (child as any)?.name as string | undefined;
2049+
const fields = (child as any)?.fields as Record<string, any> | undefined;
2050+
if (!childName || !fields) continue;
2051+
for (const [fieldName, fdef] of Object.entries(fields)) {
2052+
if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) continue;
2053+
const ref = fdef.reference;
2054+
if (!ref) continue;
2055+
// Match the target object by raw or resolved name.
2056+
let resolvedRef: string | undefined;
2057+
try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; }
2058+
if (ref !== object && resolvedRef !== object) continue;
2059+
2060+
// A master-detail parent owns its children: cascade by default (the
2061+
// child FK is typically required, so set_null would be invalid). Only
2062+
// an explicit `restrict` deviates. A plain lookup honors its
2063+
// configured deleteBehavior (default set_null).
2064+
const behavior: string =
2065+
fdef.type === 'master_detail'
2066+
? (fdef.deleteBehavior === 'restrict' ? 'restrict' : 'cascade')
2067+
: (fdef.deleteBehavior || 'set_null');
2068+
2069+
let dependents: any[];
2070+
try {
2071+
dependents = await this.find(childName, { where: { [fieldName]: id }, context } as any);
2072+
} catch {
2073+
continue;
2074+
}
2075+
if (!dependents || dependents.length === 0) continue;
2076+
2077+
if (behavior === 'restrict') {
2078+
throw new Error(
2079+
`Cannot delete ${object} (${id}): ${dependents.length} dependent ${childName} record(s) via ${fieldName}`,
2080+
);
2081+
}
2082+
2083+
for (const dep of dependents) {
2084+
const depId = dep?.id;
2085+
if (depId == null) continue;
2086+
if (behavior === 'cascade') {
2087+
// Recurse via the public delete so the child's own cascade,
2088+
// hooks and events fire.
2089+
await this.delete(childName, { where: { id: depId }, context } as any);
2090+
} else {
2091+
await this.update(childName, { id: depId, [fieldName]: null }, { context } as any);
2092+
}
2093+
}
2094+
}
2095+
}
2096+
}
2097+
19492098
async delete(object: string, options?: EngineDeleteOptions): Promise<any> {
19502099
object = this.resolveObjectName(object);
19512100
this.logger.debug('Delete operation starting', { object });
@@ -1981,6 +2130,9 @@ export class ObjectQL implements IDataEngine {
19812130
try {
19822131
let result;
19832132
if (hookContext.input.id) {
2133+
// Honor referential delete behavior (cascade/set_null/restrict)
2134+
// for relations pointing at this record before removing it.
2135+
await this.cascadeDeleteRelations(object, hookContext.input.id as string | number, opCtx.context);
19842136
result = await driver.delete(object, hookContext.input.id as string, hookContext.input.options as any);
19852137
} else if (options?.multi && driver.deleteMany) {
19862138
const ast: QueryAST = { object, where: options.where };

packages/spec/src/data/field.zod.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -608,10 +608,10 @@ export const Field = {
608608
...config
609609
} as const),
610610

611-
masterDetail: (reference: string, config: FieldInput = {}) => ({
612-
type: 'master_detail',
613-
reference,
614-
...config
611+
masterDetail: (reference: string, config: FieldInput = {}) => ({
612+
type: 'master_detail',
613+
reference,
614+
...config
615615
} as const),
616616

617617
// Enhanced Field Type Helpers

0 commit comments

Comments
 (0)