Skip to content

fix(services): reject/ignore explicit null in partial updates for NOT NULL fields - #14040

Draft
stilla[bot] wants to merge 1 commit into
mainfrom
stilla/fix-explicit-null-partial-updates
Draft

fix(services): reject/ignore explicit null in partial updates for NOT NULL fields#14040
stilla[bot] wants to merge 1 commit into
mainfrom
stilla/fix-explicit-null-partial-updates

Conversation

@stilla

@stilla stilla Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Context

Triggered by production Sentry issue SERVER-4ZZ: an IntegrityError (NotNullViolationError on benefits.description) surfaced deep inside an unrelated SELECT, because SQLAlchemy's autoflush tried to persist a pending UPDATE benefits SET description=NULL from a prior PATCH /v1/benefits/{id} call.

Root cause: BenefitUpdateBase.description is typed str | None (so it can be omitted from a partial update), but benefits.description is NOT NULL. Pydantic v2 marks an explicitly-sent JSON null as "set" in model_fields_set, so it survives exclude_unset=True filtering and gets setattr'd straight onto the ORM object with no guard.

This exact bug class was already fixed once for discount.duration in #10491 (guard changed from is not None to "duration" in model_fields_set). This PR applies the same defensive pattern everywhere else it was found unfixed across the codebase.

Fields fixed

Reject explicit null (raises PolarRequestValidationError, matching each file's existing idiom for invalid input):

  • discount.type (reused the existing type-mismatch validation path)
  • discount.name
  • benefit.description (the original incident field)

Silently ignore explicit null (preserves the existing value, matching sibling fields in the same method that already do this):

  • product.name, product.visibility, product.is_archived
  • organization.name, socials, embed_hosts, default_presentment_currency, default_tax_behavior, sso_enforced, customer_email_settings, customer_portal_settings
  • checkout.is_business_customer, allow_discount_codes, require_billing_address, customer_metadata
  • meter.name, meter.unit
  • custom_field.name, slug, properties
  • license_key.status

Testing

  • Added service-layer tests mirroring fix(discount): reject explicit null duration in update guard #10491's coverage for each fix (null-rejection and null-ignored cases).
  • ruff check and mypy pass on all changed files.
  • Could not run the DB-backed test suite in the sandbox (no Postgres/Redis/Minio/network available); tests follow existing passing patterns in each file but haven't been executed green in this environment. Please run the full test suite in CI before merging.

No migrations or schemas were changed — service-layer guards only.

Sent by @pieterbeulque from Stilla investigation.

Review in cubic

…domains

Batch fix for the "explicit null bypasses exclude_unset partial update" bug
class: an update schema types a field Optional so it can be omitted, but the
field maps to a NOT NULL column. Pydantic v2 marks an explicitly-sent JSON
`null` as set, so `model_dump(exclude_unset=True)` keeps it and the None reaches
the ORM object, causing an IntegrityError (sometimes surfacing later via an
unrelated autoflush).

Mirrors the fix pattern established for `discount.duration` in PR #10491 —
checking `model_fields_set` / guarding the raw dict application — and follows
each file's existing sibling-field behavior (reject vs. silently ignore):

- discount: reject explicit null for `type` (via the existing "cannot be
  changed" path) and `name`.
- benefit: reject explicit null for `description` (the Sentry incident field).
- product: exclude `name`/`visibility`/`is_archived` from the trailing raw-dict
  loop so it can't overwrite the earlier `is not None` handling; add the missing
  `visibility` guard (silently ignore null).
- organization: drop explicit-null `name`, `socials`, `embed_hosts`,
  `default_presentment_currency`, `default_tax_behavior`, `sso_enforced`,
  `customer_email_settings`, `customer_portal_settings` from update_dict,
  mirroring feature_settings/subscription_settings/dispute_settings (ignore).
- checkout: skip explicit-null `is_business_customer`, `allow_discount_codes`,
  `require_billing_address`, `customer_metadata` in the raw-dict loop (ignore).
- meter: exclude+conditionally re-add `name`/`unit`, mirroring filter/aggregation
  (ignore).
- custom_field: skip explicit null in the raw setattr loop (ignore).
- license_key: skip explicit-null `status` in the raw setattr loop (ignore).

Adds service tests mirroring the discount.duration null-rejection coverage.

Co-Authored-By: Stilla <stilla@stilla.ai>
@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
orbit Ready Ready Preview Aug 30, 2026 11:32am
polar-test Ready Ready Preview Aug 30, 2026 11:32am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

OpenAPI Changes

No changes detected in the OpenAPI schema.

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.

0 participants