Skip to content

templates: apply a field's defaultValue before the create-time calculations (#7104) - #7115

Merged
delchev merged 1 commit into
masterfrom
issue-7104-defaults-before-create-calc
Sep 7, 2026
Merged

templates: apply a field's defaultValue before the create-time calculations (#7104)#7115
delchev merged 1 commit into
masterfrom
issue-7104-defaults-before-create-calc

Conversation

@delchev

@delchev delchev commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

defaultValue: was the column's DB DEFAULT and nothing else on the server, so the database supplied it at INSERT — after the generated repository's create-time calculations had already run in Java and read a null.

On BusinessIntents STA, a purchase-order line posted without VatRate (vatRate: { defaultValue: 20 }, vat: calculatedOnCreate: "round(Net * VatRate / 100, 2)") stored VatRate 20 from the default and Vat 0 from the calculation that reads it; only a later no-op PUT — whose recalculation sees the stored default — put the two in agreement. The generated form hid it, because the item dialog seeds the same default and posts it explicitly. So it was every REST caller, import and server-side create that silently got a wrong total.

The fix

Repository.java.template gains an #applyDefaults() macro, emitted as the first statement of save(): a column the write left empty gets its authored default, in a literal of the property's own type, before the label, the roll-up/aggregate guards, the document checks and the calculatedOnCreate expressions read it.

  • save() only. An existing row is never re-defaulted, so a value the user deliberately cleared stays cleared through update().
  • The DB DEFAULT stays, for a row that reaches the table by any other route.
  • A numeric default is parsed from its authored text (new java.math.BigDecimal("20")) rather than inlined as a numeric literal, so an author's 8.0 on an integer column fails that one create instead of failing the whole generated build.
  • A string default is accepted in either authoring shape — bare (DRAFT, what the item dialog seeds) or SQL-quoted ('DRAFT', what a working DB DEFAULT needs, since the value reaches the DDL verbatim) — and both yield the string the column would hold.
  • A date/time/timestamp or binary column is excluded: its DEFAULT reaches the DDL verbatim and is typically a SQL expression (CURRENT_DATE, now()), which has no Java stand-in. There the DB DEFAULT remains the only leg.

A to-one relation's init: is the same mechanism (it lands as dataDefaultValue on the FK), so an initial status is now assigned by the repository too — the value the create-time postings and process triggers see, not the one the database was about to fill.

Verification

  • New IntentEngineIT#an_authored_default_is_applied_before_the_create_time_calculations — a decimal, a boolean and a string default; each guarded on null; the decimal assigned before the Calc.eval that reads it; and exactly once in the file, so update() does not re-default.
  • IntentEngineIT new test + a_required_value_is_refused_by_name_instead_of_by_the_database + calculated_field_action_emits_an_imports_backed_callout_in_the_repository — 3/3.
  • IntentEmissionCoverageIT — green. It exercises this at runtime through a real HTTP create: a document posted without its init:-defaulted status still answers 200 and echoes the applied default.
  • ModelGenerationIT — green (every template still renders its model).
  • Full mvn install — BUILD SUCCESS.

Fixes #7104

🤖 Generated with Claude Code

…ations (#7104)

`defaultValue:` was the column's DB DEFAULT and nothing else on the server, so the
database supplied it at INSERT - after the generated repository's create-time
calculations had already run in Java and read a null. A purchase-order line posted
without VatRate stored VatRate 20 from the default and Vat 0 from the calculation
that reads it, and only a later no-op update (whose recalculation sees the stored
default) put the two in agreement. The generated form hid it, because the item
dialog seeds the same default and posts it explicitly - so it was every REST caller,
import and server-side create that silently got a wrong total.

The generated repository now assigns each authored default itself, as the first
statement of save(): a column the write left empty gets the default in a literal of
the property's own type, BEFORE the label, the guards, the checks and the
calculatedOnCreate expressions read it. Only save() defaults - an existing row is
never re-defaulted, so a value the user deliberately cleared stays cleared - and the
DB DEFAULT remains for a row that reaches the table by any other route.

A numeric default is parsed from its authored text rather than inlined as a numeric
literal, so an author's "8.0" on an integer column fails that one create instead of
failing the whole generated build. A string default is accepted in either authoring
shape, bare or SQL-quoted, since the value reaches the DDL verbatim. A date/time or
binary column is excluded: its DEFAULT is typically a SQL expression (CURRENT_DATE)
with no Java stand-in, so there the DB DEFAULT stays the only leg.

Fixes #7104

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@delchev
delchev merged commit 8000802 into master Sep 7, 2026
10 checks passed
@delchev
delchev deleted the issue-7104-defaults-before-create-calc branch September 7, 2026 11:11
delchev added a commit that referenced this pull request Sep 8, 2026
…7131) (#7163)

The amend detection (#7071) compared the stored item rows with the freshly
derived ones as they stand. Since #7104/#7115 the derived ones do not stand
that way for long: `#applyDefaults()` runs first in every generated `save()`,
so a compared column carrying an authored `defaultValue` is FILLED on the
stored row and still null on the not-yet-saved derived one. In the canonical
journal shape - a debit row assigning `Debit`, a credit row assigning `Credit`,
both `default: 0` - the stored credit row reads `Debit = 0` back while the
derived one holds null, `unchanged` could never be true, and every redelivery
of the posting event was classified as an amendment: all item rows deleted and
re-inserted with new ids, firing `-deleted`/`-created` on each and churning
every roll-up over them, silently and indefinitely.

The comparison is now over the values as they will be stored. Each compared
property (and each `map:` header assignment) carries the default its own
derived side will end up with, and a stored row reading that default back is
not a change. A default only the DATABASE can apply - a `date`/`timestamp`
column whose DEFAULT reaches the DDL verbatim as a SQL expression, which
`#applyDefaults()` skips for exactly the same reason - has no Java stand-in, so
what the stored row holds cannot be derived at all: that column is compared
only for the rows that do assign it.

Two adjacent weaknesses in the same comparison:

- every header assignment expression was evaluated TWICE, once inside
  `same(target.X, <expr>)` and again when assigning, so a non-deterministic
  expression made any later redelivery read as an amendment. Each is now
  evaluated once, into a numbered local both the comparison and the assignment
  read;
- `amendableGuard` fell back to `target.<Status> == null` for an `init:` that is
  not a digit string - false for every document the posting creates, so it
  refused every legitimate amendment with a log saying someone had acted on a
  document nobody had touched. That fallback is unreachable through the parser
  (`StatusSymbolResolver` rewrites a status named by its seeded name to the seed
  id and refuses one that resolves to none), but it is now a warned fail-open
  rather than a silent refusal: no guard is emitted, the target stays rewritable
  as one with no lifecycle is, and the model is named at generation time.

The authored `type:` to SQL type mapping the branch needs is the EDM's own, so
it moves to `IntentEntities.sqlType` and `EdmIntentGenerator.mapDataType`
delegates to it - two generators deciding independently what a default means is
how this defect arrived.

A model whose posting columns carry no defaults generates the same handler as
before.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
delchev added a commit that referenced this pull request Sep 9, 2026
…#7200)

#7101's create-path allocator resolved a null `per:` FK to its own copy of the
relation's `init:` (`numberPerDefault`), on the premise that "the database
applies that on insert". Since #7104/#7115 the applyDefaults macro is the FIRST
statement of `save()` and assigns exactly that FK from exactly that value, so
by the time the numbering block runs the FK is already set and the `== null`
arm is unreachable. Both arms resolved the same partition, so nothing was
wrong at run time - but the comment documented a premise that no longer holds,
and a reader would take a dead fallback for the load-bearing guarantee.

The arm and the marker are gone: the allocator reads the FK the write has
already defaulted, and a null FK now means what it says - a relation that
declares no `init:` at all, i.e. the tenant-wide base row. `numberPerDefault`
had exactly one consumer, so `EdmIntentGenerator.putNumberPartitionDefaults`
goes with it (which also reunites the `ProcessIds` javadoc it had been
inserted in front of with `processIdsProperty`).

The issue's second half asked for the partition default to be unquoted like
`#defaultLiteral`, so an `init: 'ACME'` could not allocate in partition
`'ACME'` beside `ACME`. It cannot: unlike a field `default:`, a relation's
`init:` is resolved by `StatusSymbolResolver` against the target's own seeds
and anything that is neither a seeded name nor a numeric id is refused at
parse - so the quoted shape never reaches a generator. Adding the unquoting
would have been a second piece of dead code beside the one being removed, so
the invariant is stated where a reader would otherwise wonder and pinned by a
test instead.

The `stampOn: issue` stamp keeps its own `perDefault` fallback: it reads a row
loaded from the database, where the column can genuinely hold null (an
explicit null write into a nullable column with a DEFAULT stores null).

Verified: the rendered repository defaults the FK before the allocation and
carries no second copy of the init value (IntentEngineIT's numbering case,
rewritten to assert that ORDER rather than the old ternary constant, green
end to end); engine-intent's 1182 unit tests green.

Fixes #7147

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

templates: defaultValue is applied after calculatedOnCreate on a server-side create - a line without vatRate computes vat 0

1 participant