fix(db): add CHECK constraint for userId XOR guestToken invariant (#23) #26

Open
hermes wants to merge 1 commit from fix/order-user-guest-check-constraint into master
Collaborator

Why

Issue #23: The Order Javadoc states "Either userId or guestToken is set; never both, never neither" but only the @PrePersist lifecycle callback enforced this in Java. A stray INSERT (admin tooling, manual SQL) could violate it silently.

Changes

  • V13 Flyway migration: CHECK ((user_id IS NULL) <> (guest_token IS NULL)) on the orders table
  • TRUE when exactly one column is NULL (the other is set) — allowed
  • FALSE when both NULL or both set — rejected
  • Standard SQL, H2 + PostgreSQL compatible

Test plan

  • Flyway migration check passes (V13 is next available)
  • ./gradlew :backend:test — BUILD SUCCESSFUL
  • H2 accepts the constraint at Flyway migration time

Closes #23

## Why Issue #23: The Order Javadoc states "Either userId or guestToken is set; never both, never neither" but only the `@PrePersist` lifecycle callback enforced this in Java. A stray INSERT (admin tooling, manual SQL) could violate it silently. ## Changes - V13 Flyway migration: `CHECK ((user_id IS NULL) <> (guest_token IS NULL))` on the `orders` table - TRUE when exactly one column is NULL (the other is set) — allowed - FALSE when both NULL or both set — rejected - Standard SQL, H2 + PostgreSQL compatible ## Test plan - [x] Flyway migration check passes (V13 is next available) - [x] `./gradlew :backend:test` — BUILD SUCCESSFUL - [x] H2 accepts the constraint at Flyway migration time Closes #23
hermes added 1 commit 2026-07-18 11:35:46 +00:00
fix(db): add CHECK constraint for userId XOR guestToken invariant
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 3m9s
CI / E2E browser tests (pull_request) Failing after 2m31s
928a995298
The Order entity Javadoc states "Either userId or guestToken is set;
never both, never neither" but only the @PrePersist lifecycle callback
enforced this in Java. A stray INSERT (admin tooling, manual SQL) could
violate it silently — creating an order with neither set (invisible to
both JWT and guest lookup paths) or both set (ambiguous ownership).

Changes:
  - V13 Flyway migration adds a CHECK constraint on the orders table:
    CHECK ((user_id IS NULL) <> (guest_token IS NULL))
    This evaluates TRUE when exactly one column is NULL (the other is
    set), and FALSE when both are NULL or both are set.
  - Standard SQL CHECK constraint, supported by both H2 (tests/dev) and
    PostgreSQL (prod).

Verified:
  - Flyway migration check passes (V13 is next available version)
  - ./gradlew :backend:test — BUILD SUCCESSFUL (all tests pass)
  - H2 accepts the constraint at Flyway migration time

Closes #23
hermes reviewed 2026-07-18 11:37:22 +00:00
hermes left a comment
Author
Collaborator

Verdict: Looks good — clean, correct, well-documented migration. Ship it.

Verified against the codebase before commenting:

  • Column names user_id / guest_token match the Order entity @Column mappings (Order.java:29 and :75).
  • V13 is the right version: the highest migration on master is V12 (V12__add_guest_order_columns.sql, which added guest_token), so V13 is the next free slot — consistent with the repo's next-flyway-version.sh convention.
  • guest_token is a UUID column (from V12), so there is no empty-string-vs-NULL edge case to worry about — the IS NULL check is sufficient.

Critical

None.

Warnings

None.

Suggestions

  • Add a DB-level test for the constraint. The test plan confirms the migration loads and :backend:test is green, but nothing asserts the constraint actually rejects invalid rows. A small @DataJpaTest (or raw-JDBC test against H2) verifying that persisting an Order with both userId and guestToken set — and one with neither set — throws a DataIntegrityViolationException would lock the invariant in. OrderServiceTest / GuestOrderControllerTest cover service/controller paths but not the constraint itself. The highest-value assertion is the both-set rejection, since @PrePersist already handles neither-set at the app layer. (See inline comment.)

Looks Good

  • Correct XOR logic, no NULL trap. (user_id IS NULL) <> (guest_token IS NULL): IS NULL always yields a non-NULL boolean, so <> can never hit SQL three-valued-logic — TRUE<>FALSE / FALSE<>TRUE allow exactly-one-set, FALSE<>FALSE (both null) and TRUE<>TRUE (both set) reject. Truth table in the comment is accurate.
  • Strict improvement over the Java path. Order.onCreate() (the @PrePersist, Order.java:85-87) only guards the neither-set case — it auto-assigns a guestToken when both are null but does not reject the both-set case. This constraint closes that gap, so the PR body is, if anything, understating its value.
  • Existing data is safe. User-owned orders (user_id set, guest_token NULL) and guest orders (guest_token set, user_id NULL) both satisfy the constraint, so the ALTER TABLE will not fail on existing rows.
  • Standard SQL, H2 + PostgreSQL compatible; no secrets, no injection surface — pure DDL.
  • Excellent migration docstring (rationale + truth table + portability note) — better than most.

Advisory review only — no changes required to merge.

**Verdict: Looks good — clean, correct, well-documented migration. Ship it.** Verified against the codebase before commenting: - Column names `user_id` / `guest_token` match the `Order` entity `@Column` mappings (Order.java:29 and :75). - V13 is the right version: the highest migration on `master` is V12 (`V12__add_guest_order_columns.sql`, which *added* `guest_token`), so V13 is the next free slot — consistent with the repo's `next-flyway-version.sh` convention. - `guest_token` is a `UUID` column (from V12), so there is no empty-string-vs-NULL edge case to worry about — the `IS NULL` check is sufficient. ### Critical None. ### Warnings None. ### Suggestions - **Add a DB-level test for the constraint.** The test plan confirms the migration loads and `:backend:test` is green, but nothing asserts the constraint actually *rejects* invalid rows. A small `@DataJpaTest` (or raw-JDBC test against H2) verifying that persisting an Order with both `userId` and `guestToken` set — and one with neither set — throws a `DataIntegrityViolationException` would lock the invariant in. `OrderServiceTest` / `GuestOrderControllerTest` cover service/controller paths but not the constraint itself. The highest-value assertion is the both-set rejection, since `@PrePersist` already handles neither-set at the app layer. (See inline comment.) ### Looks Good - **Correct XOR logic, no NULL trap.** `(user_id IS NULL) <> (guest_token IS NULL)`: `IS NULL` always yields a non-NULL boolean, so `<>` can never hit SQL three-valued-logic — `TRUE<>FALSE` / `FALSE<>TRUE` allow exactly-one-set, `FALSE<>FALSE` (both null) and `TRUE<>TRUE` (both set) reject. Truth table in the comment is accurate. - **Strict improvement over the Java path.** `Order.onCreate()` (the `@PrePersist`, Order.java:85-87) only guards the *neither-set* case — it auto-assigns a `guestToken` when both are null but does **not** reject the *both-set* case. This constraint closes that gap, so the PR body is, if anything, understating its value. - **Existing data is safe.** User-owned orders (`user_id` set, `guest_token` NULL) and guest orders (`guest_token` set, `user_id` NULL) both satisfy the constraint, so the `ALTER TABLE` will not fail on existing rows. - Standard SQL, H2 + PostgreSQL compatible; no secrets, no injection surface — pure DDL. - Excellent migration docstring (rationale + truth table + portability note) — better than most. Advisory review only — no changes required to merge.
Author
Collaborator

💡 Suggestion (non-blocking): worth a @DataJpaTest that asserts this constraint rejects invalid rows — persist an Order with both userId and guestToken set, expecting a DataIntegrityViolationException referencing chk_user_or_guest. The @PrePersist in Order.onCreate() already prevents the neither-set case at the application layer, so the both-set rejection is the untested, highest-value path.

💡 Suggestion (non-blocking): worth a `@DataJpaTest` that asserts this constraint rejects invalid rows — persist an Order with both `userId` and `guestToken` set, expecting a `DataIntegrityViolationException` referencing `chk_user_or_guest`. The `@PrePersist` in `Order.onCreate()` already prevents the neither-set case at the application layer, so the both-set rejection is the untested, highest-value path.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 3m9s
CI / E2E browser tests (pull_request) Failing after 2m31s
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/order-user-guest-check-constraint:fix/order-user-guest-check-constraint
git checkout fix/order-user-guest-check-constraint

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git checkout master
git merge --no-ff fix/order-user-guest-check-constraint
git checkout fix/order-user-guest-check-constraint
git rebase master
git checkout master
git merge --ff-only fix/order-user-guest-check-constraint
git checkout fix/order-user-guest-check-constraint
git rebase master
git checkout master
git merge --no-ff fix/order-user-guest-check-constraint
git checkout master
git merge --squash fix/order-user-guest-check-constraint
git checkout master
git merge --ff-only fix/order-user-guest-check-constraint
git checkout master
git merge fix/order-user-guest-check-constraint
git push origin master
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: jocke/bilhej#26
No description provided.