|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * THE blank-as-null rule — which columns of an object store a blank string as |
| 5 | + * `null`, and which blanks — read by the write door that stores it and by the |
| 6 | + * seed loader's replay, which must compare a seed value in the form the door |
| 7 | + * stores it. |
| 8 | + * |
| 9 | + * ## Two faces, one rule |
| 10 | + * |
| 11 | + * - `@objectstack/objectql`'s write door (`normalizeBlankTypedValues`) applies |
| 12 | + * it to every insert and update payload before anything else reads it. |
| 13 | + * - `@objectstack/metadata-protocol`'s seed loader asks it in `isNoOpReplay` |
| 14 | + * (the `blankAsNull` replay comparand): a seed writing `''` on such a column |
| 15 | + * lands as `null`, and compared as written it read the row as changed and |
| 16 | + * rewrote it on every boot (#29277; the #20308 and #29176 arms churned the |
| 17 | + * same way before it). |
| 18 | + * |
| 19 | + * The two packages do not depend on each other, and a copy each is how a |
| 20 | + * replay comes to disagree with the store. So the rule lives here, beside |
| 21 | + * `multiValueStorageForm` and `temporalStorageForm`, which are shared for the |
| 22 | + * same reason. The type classes and the `$empty` table it reads are the |
| 23 | + * spec's, never re-listed. |
| 24 | + * |
| 25 | + * ## The rule — three arms |
| 26 | + * |
| 27 | + * - [#20308] `'blank'`: a column whose type is non-string-typed |
| 28 | + * (`NON_TEXT_STORED_VALUE_TYPES`: numeric, boolean, `date`, `datetime`, |
| 29 | + * `time`) stores any blank (`isMissing`: `''` or whitespace) as `null` — |
| 30 | + * required or not, lifecycle or not: a blank is never a value of its type. |
| 31 | + * - [#29176] `'empty'`: exactly `''` on an optional text-like column |
| 32 | + * (`STRING_VALUE_TYPES` minus `password` / `secret`) a unique constraint |
| 33 | + * covers — its own `unique`, or a unique `indexes[]` entry, both read through |
| 34 | + * `isUniqueDeclared`, the drivers' own predicate. |
| 35 | + * - [#29277] `'empty'`: exactly `''` on an optional column on the `$empty` |
| 36 | + * table's `null_only` row (`expandEmptyOperator`) whose value is ONE string |
| 37 | + * ({@link ONE_STRING_STORED_TYPES}), unique or not (maintainer ruling B on |
| 38 | + * #29346, record 6579813296). |
| 39 | + * |
| 40 | + * The two `'empty'` arms never reach a `required: true` column (there `''` is |
| 41 | + * refused `required`, and must not first become a `null` a `defaultValue` |
| 42 | + * would fill) nor a lifecycle column the engine owns |
| 43 | + * ({@link ENGINE_LIFECYCLE_COLUMNS}), and never whitespace (a value on both |
| 44 | + * rows of the `$empty` table they touch). |
| 45 | + */ |
| 46 | + |
| 47 | +import { |
| 48 | + NON_TEXT_STORED_VALUE_TYPES, |
| 49 | + STRING_VALUE_TYPES, |
| 50 | + SINGLE_OPTION_TYPES, |
| 51 | + REFERENCE_VALUE_TYPES, |
| 52 | + FILE_REFERENCE_TYPES, |
| 53 | + expandEmptyOperator, |
| 54 | + isUniqueDeclared, |
| 55 | + type ValueShapeFieldDef, |
| 56 | +} from '@objectstack/spec/data'; |
| 57 | +import { isMissing } from './required-emptiness.js'; |
| 58 | + |
| 59 | +/** |
| 60 | + * Lifecycle columns the engine always owns and the client never supplies, |
| 61 | + * skipped by NAME because they are not author-declared business fields. The |
| 62 | + * write door's validator skips the same names (its `SKIP_FIELDS` is this list). |
| 63 | + */ |
| 64 | +export const ENGINE_LIFECYCLE_COLUMNS: readonly string[] = Object.freeze([ |
| 65 | + 'id', 'created_at', 'created_by', 'updated_at', 'updated_by', |
| 66 | +]); |
| 67 | +const LIFECYCLE: ReadonlySet<string> = new Set(ENGINE_LIFECYCLE_COLUMNS); |
| 68 | + |
| 69 | +/** |
| 70 | + * [#29176] The text-like types of the unique arm: the spec's |
| 71 | + * `STRING_VALUE_TYPES` — exactly the "text-like" row of the `$empty` table, |
| 72 | + * where `''` and `null` already answer as one — minus the two credential |
| 73 | + * types. ⛔ `password` and `secret` stay out: a maintainer ruling refuses `''` |
| 74 | + * on them (`EmptyCredentialWriteError`) while `null` means "clear", so |
| 75 | + * rewriting the blank would turn that refusal into a silent clear. |
| 76 | + */ |
| 77 | +const UNIQUE_BLANK_TEXT_TYPES: ReadonlySet<string> = new Set( |
| 78 | + [...STRING_VALUE_TYPES].filter((t) => t !== 'password' && t !== 'secret'), |
| 79 | +); |
| 80 | + |
| 81 | +/** |
| 82 | + * [#29277] The storage-shape half of the `null_only` arm: the spec's value |
| 83 | + * classes whose single value IS one string — an option code |
| 84 | + * (`SINGLE_OPTION_TYPES`), a record id (`REFERENCE_VALUE_TYPES`), an opaque |
| 85 | + * `sys_file` id (`FILE_REFERENCE_TYPES`; SQL stores it JSON-encoded, still one |
| 86 | + * string to every reader). Read as the spec's constants, never re-listed. The |
| 87 | + * spec has no predicate for "stores one string", so this set is stated here, |
| 88 | + * and `record-validator.single-value-blank.test.ts` (`@objectstack/objectql`) |
| 89 | + * ties it to the table: every type on the `null_only` row that is not typed |
| 90 | + * must be in this set or in one of the two classes left out below, so a type |
| 91 | + * that joins the row unclassified turns that pin red. |
| 92 | + * |
| 93 | + * Left out, each measured on SQLite through the engine: |
| 94 | + * |
| 95 | + * - `STRUCTURED_JSON_TYPES` (`json`, `composite`, `record`, `location`, |
| 96 | + * `address`, `repeater`, `vector`): the value is an object or an array, so |
| 97 | + * `''` is not a blank of its shape. What empty means for them is a |
| 98 | + * separate question. |
| 99 | + * - `COMPUTED_VALUE_TYPES` (`formula`, `autonumber`; `summary` is typed): the |
| 100 | + * engine owns the value — a formula is never stored, an autonumber is |
| 101 | + * issued on insert whatever the caller sent, `''` included. |
| 102 | + */ |
| 103 | +const ONE_STRING_STORED_TYPES: ReadonlySet<string> = new Set([ |
| 104 | + ...SINGLE_OPTION_TYPES, |
| 105 | + ...REFERENCE_VALUE_TYPES, |
| 106 | + ...FILE_REFERENCE_TYPES, |
| 107 | +]); |
| 108 | + |
| 109 | +/** |
| 110 | + * Which blanks a column stores as `null`: `'blank'` — any blank string |
| 111 | + * (`isMissing`); `'empty'` — exactly `''`. |
| 112 | + */ |
| 113 | +export type BlankAsNullReach = 'blank' | 'empty'; |
| 114 | + |
| 115 | +/** The field shape the rule reads. */ |
| 116 | +export interface BlankAsNullFieldDef extends ValueShapeFieldDef { |
| 117 | + required?: boolean; |
| 118 | + unique?: unknown; |
| 119 | +} |
| 120 | + |
| 121 | +/** |
| 122 | + * The columns of one object whose blank the write door stores as `null`, each |
| 123 | + * with its reach. A column not in the map keeps every blank as written. |
| 124 | + */ |
| 125 | +export function blankAsNullColumns( |
| 126 | + objectSchema: { fields?: Record<string, BlankAsNullFieldDef | undefined>; indexes?: unknown } | undefined | null, |
| 127 | +): ReadonlyMap<string, BlankAsNullReach> { |
| 128 | + const out = new Map<string, BlankAsNullReach>(); |
| 129 | + const fields = objectSchema?.fields; |
| 130 | + if (!fields || typeof fields !== 'object') return out; |
| 131 | + let covered: Set<string> | undefined; |
| 132 | + const isCovered = (name: string) => (covered ??= uniqueCoveredColumns(fields, objectSchema?.indexes)).has(name); |
| 133 | + for (const [name, def] of Object.entries(fields)) { |
| 134 | + if (!def || typeof def.type !== 'string') continue; |
| 135 | + if (NON_TEXT_STORED_VALUE_TYPES.has(def.type)) { |
| 136 | + out.set(name, 'blank'); |
| 137 | + continue; |
| 138 | + } |
| 139 | + if (LIFECYCLE.has(name) || def.required === true) continue; |
| 140 | + const empty = UNIQUE_BLANK_TEXT_TYPES.has(def.type) |
| 141 | + ? isCovered(name) |
| 142 | + : expandEmptyOperator(def).arm === 'null_only' && ONE_STRING_STORED_TYPES.has(def.type); |
| 143 | + if (empty) out.set(name, 'empty'); |
| 144 | + } |
| 145 | + return out; |
| 146 | +} |
| 147 | + |
| 148 | +/** |
| 149 | + * The form a value written to a column of the given reach is stored in: |
| 150 | + * `null` when the value is a blank that reach takes, else the value itself |
| 151 | + * (the same reference). `reach` `undefined` — a column not in |
| 152 | + * {@link blankAsNullColumns} — keeps every value. |
| 153 | + */ |
| 154 | +export function blankStorageForm(value: unknown, reach: BlankAsNullReach | undefined): unknown { |
| 155 | + if (reach === undefined || typeof value !== 'string') return value; |
| 156 | + if (reach === 'empty') return value === '' ? null : value; |
| 157 | + return isMissing(value) ? null : value; |
| 158 | +} |
| 159 | + |
| 160 | +function uniqueCoveredColumns(fields: Record<string, BlankAsNullFieldDef | undefined>, indexes: unknown): Set<string> { |
| 161 | + const covered = new Set<string>(); |
| 162 | + for (const [name, def] of Object.entries(fields)) { |
| 163 | + if (def && isUniqueDeclared(def.unique)) covered.add(name); |
| 164 | + } |
| 165 | + if (Array.isArray(indexes)) { |
| 166 | + for (const idx of indexes) { |
| 167 | + if (!idx || typeof idx !== 'object' || Array.isArray(idx)) continue; |
| 168 | + const { unique, fields: members } = idx as { unique?: unknown; fields?: unknown }; |
| 169 | + if (!isUniqueDeclared(unique) || !Array.isArray(members)) continue; |
| 170 | + for (const name of members) if (typeof name === 'string') covered.add(name); |
| 171 | + } |
| 172 | + } |
| 173 | + return covered; |
| 174 | +} |
0 commit comments