Skip to content

A number field's declared scale is never enforced — values with more decimals are accepted and stored verbatim (min/max on the same field are enforced) #7501

Description

@baozhoutao

Summary

A number field that declares scale is not validated against it at runtime — a value with more decimal places than scale allows is accepted and stored verbatim. There is no error, no rounding, no warning.

Measured on @objectstack/*@17.0.0-rc.6, driver-sql (better-sqlite3), via both the REST create endpoint and the CSV import endpoint.

Repro

Field declaration:

work_hours: {
  type: 'number',
  label: 'Max hours per shift',
  precision: 5,
  scale: 0,        // ← declares "integer, no decimals"
  defaultValue: 12,
  min: 1,
  max: 12,
}

POST /api/v1/data/<object> with { "work_hours": 11.5 }

  • Expected: rejected (400, an INVALID_* envelope), or at minimum rounded to the declared scale.
  • Actual: HTTP 201, and the stored value is 11.5 — verified by reading the row straight out of storage, not through the API.

Same result with scale: 1 and a two-decimal input, and same result through
POST /api/v1/data/<object>/import (format: "csv").

min / max on the same field are enforced (-1400, code: "min_value"), so the validator does run — it just has no branch for scale.

Where it comes from

record-validator's number branch only tests def.min and def.max. A full search of
objectql / runtime / cli for scale finds it only in @objectstack/spec as descriptive
metadata ("Decimal places") — there is no runtime consumer anywhere in the shipped packages.

So scale today is documentation, not a constraint. That is surprising given it sits next to
precision, min and max in the same field declaration, all of which read as constraints.

Why it matters

A downstream app declared scale: 0 to express "this must be a whole number" and shipped it,
reasonably assuming the declaration was enforced the way min/max are. It is not — decimals
flow through to storage. There is no way to express "integer" in the field contract at all today,
so every such rule has to be re-implemented in an application hook, one field at a time.

Ask

Either of these would close it:

  1. Enforce scale in the number validator (reject, or round to the declared scale — either is
    predictable, silence is not), consistent with how min/max already behave; or
  2. If scale is intentionally display-only, say so in the spec description (it currently reads
    just "Decimal places", which does not distinguish "stored with" from "rendered with"), and
    provide a real way to declare an integer constraint.

Secondary observation (same field, same request path)

The min/max rejection message is half-translated: the field label is localized but the
sentence template is not, producing e.g.

Max hours per shift must be ≥ 1

in a zh-CN environment. It reaches end users verbatim on the import path, so an app cannot present
a fully localized message without intercepting and rewriting it. Mentioning it here rather than
opening a second card since it is the same validator; happy to split it out if preferred.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions