Skip to content

Commit 6a6c7df

Browse files
os-zhuangclaude
andcommitted
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>
1 parent 54703ad commit 6a6c7df

2 files changed

Lines changed: 156 additions & 4 deletions

File tree

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)