@ultimat3/entity 30.7.0 → 30.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/entity-parse.ts +3 -2
- package/src/entity-patch.ts +3 -1
- package/src/json-null.ts +13 -0
- package/src/memory-match.ts +16 -12
- package/src/pg-row.ts +7 -1
- package/src/view.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "30.
|
|
3
|
+
"version": "30.9.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"test": "bun test"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@ultimat3/core": "30.
|
|
47
|
-
"@ultimat3/db": "30.
|
|
48
|
-
"@ultimat3/schema": "30.
|
|
49
|
-
"@ultimat3/time": "30.
|
|
46
|
+
"@ultimat3/core": "30.9.0",
|
|
47
|
+
"@ultimat3/db": "30.9.0",
|
|
48
|
+
"@ultimat3/schema": "30.9.0",
|
|
49
|
+
"@ultimat3/time": "30.9.0"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/entity-parse.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { describeValue } from '@ultimat3/schema';
|
|
|
6
6
|
import { entityNow } from './clock';
|
|
7
7
|
import { newId } from './columns';
|
|
8
8
|
import { invariantViolated } from './errors';
|
|
9
|
+
import { holdsJsonNull } from './json-null';
|
|
9
10
|
import { sealedMissing } from './sealed-errors';
|
|
10
11
|
import type { AnyColumn, ColumnMeta } from './types';
|
|
11
12
|
|
|
@@ -41,7 +42,7 @@ export const rowParsers = <Row, Wire>(
|
|
|
41
42
|
// spread of an omitted optional key produces and is the shape every existing caller has.
|
|
42
43
|
const raw = input[property];
|
|
43
44
|
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
44
|
-
if (given === undefined || given === null) {
|
|
45
|
+
if (given === undefined || (given === null && !holdsJsonNull(column.$meta))) {
|
|
45
46
|
if (column.$meta.notNull) {
|
|
46
47
|
// A required sealed column ABSENT from a row is nearly always a spread of a repository
|
|
47
48
|
// row, which does not enumerate it — so the refusal names that, and the rewrite.
|
|
@@ -72,7 +73,7 @@ export const rowParsers = <Row, Wire>(
|
|
|
72
73
|
for (const [property, column] of wireEntries) {
|
|
73
74
|
const raw = input[property];
|
|
74
75
|
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
75
|
-
if (given === undefined || given === null) {
|
|
76
|
+
if (given === undefined || (given === null && !holdsJsonNull(column.$meta))) {
|
|
76
77
|
if (column.$meta.notNull) {
|
|
77
78
|
throw invariantViolated(name, property, 'is required and has no default');
|
|
78
79
|
}
|
package/src/entity-patch.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// would rewrite every column it did not name.
|
|
4
4
|
|
|
5
5
|
import { invariantViolated } from './errors';
|
|
6
|
+
import { holdsJsonNull } from './json-null';
|
|
6
7
|
import { copyRow } from './sealed';
|
|
7
8
|
import type { AnyColumn } from './types';
|
|
8
9
|
|
|
@@ -24,7 +25,8 @@ export const parseEntityPatch = <Patch>(
|
|
|
24
25
|
const column = Object.hasOwn(columns, property) ? columns[property] : undefined;
|
|
25
26
|
const value: unknown = descriptor.get === undefined ? descriptor.value : descriptor.get();
|
|
26
27
|
if (column === undefined || value === undefined) continue;
|
|
27
|
-
|
|
28
|
+
// A NOT NULL `jsonb` column's `null` is the JSON value, judged by its schema below.
|
|
29
|
+
if (value === null && !holdsJsonNull(column.$meta)) {
|
|
28
30
|
if (column.$meta.notNull) {
|
|
29
31
|
throw invariantViolated(name, property, 'is required and has no default');
|
|
30
32
|
}
|
package/src/json-null.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Single responsibility: when a `null` handed to a column is the JSON VALUE `null` rather than SQL
|
|
2
|
+
// NULL. One rule, read by the parsers, the Postgres binder and decoder, and the memory matcher —
|
|
3
|
+
// a copy in any one of them is a row the two drivers spell differently.
|
|
4
|
+
|
|
5
|
+
import type { ColumnMeta } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A NOT NULL `jsonb` column: the table holds no SQL NULL, so a `null` reaching it can only be the
|
|
9
|
+
* JSON literal, judged by the column's schema like any other JSON value and stored as
|
|
10
|
+
* `'null'::jsonb`. A NULLABLE `jsonb` column keeps `null` as SQL NULL — the clear every other
|
|
11
|
+
* nullable column means by it, and what `is null` answers in both drivers.
|
|
12
|
+
*/
|
|
13
|
+
export const holdsJsonNull = (meta: ColumnMeta): boolean => meta.kind === 'jsonb' && meta.notNull;
|
package/src/memory-match.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// deciding by `typeof` is answering a different question.
|
|
6
6
|
|
|
7
7
|
import { keyOf } from './batch-read';
|
|
8
|
+
import { columnFor } from './column';
|
|
8
9
|
import { arrayContains, arrayOverlaps, jsonContains, jsonHasKey } from './containment';
|
|
9
10
|
import { kindOf, valueAt } from './cursor';
|
|
10
11
|
import type { EntityCore } from './entity';
|
|
@@ -12,6 +13,7 @@ import { EntityError } from './errors';
|
|
|
12
13
|
import { searchInMemory } from './feature-errors';
|
|
13
14
|
import { instantMicros } from './instant';
|
|
14
15
|
import { isNullish as isNull } from './is-null';
|
|
16
|
+
import { holdsJsonNull } from './json-null';
|
|
15
17
|
import { likeMatches } from './like';
|
|
16
18
|
import { DECIMAL_TEXT, numericOrder } from './numeric-compare';
|
|
17
19
|
import { operatorUnknown } from './operator';
|
|
@@ -111,6 +113,10 @@ export const matchesPredicate = <Row>(
|
|
|
111
113
|
// predicate and a money sort key both name.
|
|
112
114
|
const kind = kindOf(entity, predicate.column);
|
|
113
115
|
const actual = valueAt(row, predicate.column);
|
|
116
|
+
// The row side of every NULL test. A NOT NULL `jsonb` column holding `null` holds the JSON
|
|
117
|
+
// literal (`json-null.ts`), which Postgres' `is null` never matches and its `@>` compares.
|
|
118
|
+
const held = columnFor(entity.$columns, predicate.column);
|
|
119
|
+
const rowIsNull = isNull(actual) && !(held !== undefined && holdsJsonNull(held.$meta));
|
|
114
120
|
/**
|
|
115
121
|
* `col = <value>`, with SQL's three-valued logic on both sides: a NULL is never EQUAL to
|
|
116
122
|
* anything, the other NULL included, so `equals` answers false the moment either side is one
|
|
@@ -124,34 +130,32 @@ export const matchesPredicate = <Row>(
|
|
|
124
130
|
* `valueAt(row, 'price.minor')` has nothing to read.
|
|
125
131
|
*/
|
|
126
132
|
const equals = (candidate: unknown): boolean =>
|
|
127
|
-
!
|
|
133
|
+
!rowIsNull && !isNull(candidate) && sameValueOfKind(kind, actual, candidate);
|
|
128
134
|
// `col > NULL` is UNKNOWN in SQL and UNKNOWN is not a match, so a NULL on EITHER side matches no
|
|
129
135
|
// row here either — `predicateSql` emits a bare `"col" > $1` and Postgres returns nothing. Without
|
|
130
136
|
// this the fall-through compared `String(null)` as the text `"null"`, which sorts after `"5"` and
|
|
131
137
|
// before `"z"`: `gt(seats, 5)` answered the null row in memory and never in production, and
|
|
132
138
|
// `lt(seats, null)` answered every row. The guard is HERE and not in `compareByKind`, which also
|
|
133
139
|
// orders a page — a sort puts NULLs last (`asc nulls last`) rather than dropping them.
|
|
134
|
-
const unknown = (): boolean =>
|
|
140
|
+
const unknown = (): boolean => rowIsNull || isNull(predicate.value);
|
|
135
141
|
const order = (): number => compareByKind(kind, actual, predicate.value);
|
|
136
142
|
switch (predicate.op) {
|
|
137
143
|
// `predicateSql` compiles a null operand to `"col" is null` rather than binding it, so this
|
|
138
144
|
// is the same predicate, not a widening of it.
|
|
139
145
|
case 'eq':
|
|
140
|
-
return predicate.value === null ?
|
|
146
|
+
return predicate.value === null ? rowIsNull : equals(predicate.value);
|
|
141
147
|
// `is distinct from` reads a NULL as a value on BOTH sides: TRUE where one side is null and
|
|
142
148
|
// the other is not, FALSE where both are. A bound `undefined` is a NULL parameter there, so
|
|
143
149
|
// the operand side reads through `isNull` and the two spellings mean one thing.
|
|
144
150
|
case 'neq':
|
|
145
|
-
return isNull(predicate.value) ? !
|
|
151
|
+
return isNull(predicate.value) ? !rowIsNull : !equals(predicate.value);
|
|
146
152
|
// `in` reads a LIST or nothing: an operand that is not an array matches no row, which is what
|
|
147
153
|
// `predicateSql` now compiles it to and what `@ultimat3/query` answers for the same operand.
|
|
148
154
|
// A NULL inside the list is asked as `is null` beside the list there, for the same reason.
|
|
149
155
|
case 'in':
|
|
150
156
|
return (
|
|
151
157
|
Array.isArray(predicate.value) &&
|
|
152
|
-
predicate.value.some((candidate) =>
|
|
153
|
-
isNull(candidate) ? isNull(actual) : equals(candidate),
|
|
154
|
-
)
|
|
158
|
+
predicate.value.some((candidate) => (isNull(candidate) ? rowIsNull : equals(candidate)))
|
|
155
159
|
);
|
|
156
160
|
case 'gt':
|
|
157
161
|
return !unknown() && order() > 0;
|
|
@@ -166,22 +170,22 @@ export const matchesPredicate = <Row>(
|
|
|
166
170
|
case 'like':
|
|
167
171
|
return !unknown() && likeMatches(entity.$name, String(predicate.value), String(actual));
|
|
168
172
|
case 'is-null':
|
|
169
|
-
return
|
|
173
|
+
return rowIsNull;
|
|
170
174
|
case 'is-not-null':
|
|
171
|
-
return !
|
|
175
|
+
return !rowIsNull;
|
|
172
176
|
// The containment half, decided by the column's DECLARED kind exactly as everything above it
|
|
173
177
|
// is: `@>` on a `jsonb` is recursive structural containment and `@>` on an array is plain
|
|
174
178
|
// element containment, and those are two different operators that happen to share a symbol.
|
|
175
179
|
// A NULL column value matches nothing, which is what the SQL answers too.
|
|
176
180
|
case 'contains':
|
|
177
|
-
return !
|
|
181
|
+
return !rowIsNull && containsBy(kind, actual, predicate.value);
|
|
178
182
|
case 'contained-by':
|
|
179
|
-
return !
|
|
183
|
+
return !rowIsNull && containsBy(kind, predicate.value, actual);
|
|
180
184
|
// `&&` is arrays only. A `jsonb` column reaching it is refused rather than guessed at: there
|
|
181
185
|
// is no `jsonb && jsonb` in Postgres, so any answer here would be one no statement can make.
|
|
182
186
|
case 'overlaps':
|
|
183
187
|
return (
|
|
184
|
-
!
|
|
188
|
+
!rowIsNull &&
|
|
185
189
|
arrayOverlaps(
|
|
186
190
|
asArray(entity, predicate, actual),
|
|
187
191
|
asArray(entity, predicate, predicate.value),
|
package/src/pg-row.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { kindOf } from './cursor';
|
|
|
10
10
|
import type { EntityCore } from './entity';
|
|
11
11
|
import { invariantViolated } from './errors';
|
|
12
12
|
import { pgInstantMicros, seekAlias } from './instant';
|
|
13
|
+
import { holdsJsonNull } from './json-null';
|
|
13
14
|
import { arrayFromDriver, NOT_AN_ARRAY } from './pg-array-decode';
|
|
14
15
|
import type { SortKey } from './tenancy';
|
|
15
16
|
import type { AnyColumn, MoneyValue, RowPatch } from './types';
|
|
@@ -142,6 +143,9 @@ export const arrayLiteral = (value: unknown): string =>
|
|
|
142
143
|
`{${(Array.isArray(value) ? value : [value]).map(arrayElement).join(',')}}`;
|
|
143
144
|
|
|
144
145
|
const bindable = (column: AnyColumn, value: unknown): unknown => {
|
|
146
|
+
// A NOT NULL `jsonb` column's `null` is the JSON literal, `'null'` cast back by the cell — bound
|
|
147
|
+
// as SQL NULL it was refused `23502` by the very column whose schema had just accepted it.
|
|
148
|
+
if (value === null && holdsJsonNull(column.$meta)) return JSON.stringify(null);
|
|
145
149
|
if (value === null || value === undefined) return null;
|
|
146
150
|
// A plain object is not a bindable parameter (`X_SQL_UNSAFE`), so a `jsonb` value crosses as its
|
|
147
151
|
// TEXT and `pg-sql.ts`'s cell casts it back — see `cellCast` for why the cast is `::text::jsonb`.
|
|
@@ -232,7 +236,9 @@ export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Ro
|
|
|
232
236
|
column.$meta.kind === 'money' && currency !== undefined
|
|
233
237
|
? moneyOf(source, head, currency, scale)
|
|
234
238
|
: source[head];
|
|
235
|
-
|
|
239
|
+
// `holdsJsonNull`: what the driver hands back for a stored `'null'::jsonb` is a JS `null`, and
|
|
240
|
+
// in a NOT NULL `jsonb` column that is the JSON value — its schema judges it, as it judged it in.
|
|
241
|
+
if (value !== undefined && (value !== null || holdsJsonNull(column.$meta))) {
|
|
236
242
|
// A sealed column's stored value is not the author's to parse: it goes up as it is, and the
|
|
237
243
|
// sealing seam opens it — so a row somebody left in the clear is `X_SEAL_INVALID` there
|
|
238
244
|
// rather than whatever the plaintext parser thinks of its length here.
|
package/src/view.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '@ultimat3/schema';
|
|
12
12
|
import { viewKeyUnknown } from './entity-error';
|
|
13
13
|
import { invariantViolated } from './errors';
|
|
14
|
+
import { holdsJsonNull } from './json-null';
|
|
14
15
|
import type { AnyColumn, ColumnMap } from './types';
|
|
15
16
|
import { columnIssue, missingIssue } from './view-issue';
|
|
16
17
|
|
|
@@ -151,7 +152,7 @@ export const viewFor = <Row, K extends keyof Row & string>(
|
|
|
151
152
|
const issues: StandardIssue[] = [];
|
|
152
153
|
for (const [key, column] of picked) {
|
|
153
154
|
const given = input[key];
|
|
154
|
-
if (given === undefined || given === null) {
|
|
155
|
+
if (given === undefined || (given === null && !holdsJsonNull(column.$meta))) {
|
|
155
156
|
// No default is filled in: a view projects a row that already exists, so an absent
|
|
156
157
|
// required column is missing data, never a value the projection may invent.
|
|
157
158
|
if (column.$meta.notNull) issues.push(missingIssue(key));
|