@rebasepro/server-postgresql 0.6.0 → 0.6.1
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/dist/index.es.js +46 -7
- package/dist/index.es.js.map +1 -1
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +15 -18
- package/src/data-transformer.ts +9 -1
- package/src/services/EntityPersistService.ts +8 -8
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/test/array-null-safety.test.ts +335 -0
- package/test/data-transformer.test.ts +53 -2
- package/test/pg-array-null-patch.test.ts +65 -0
- package/vite.config.ts +8 -6
- package/dist/index.umd.js +0 -11055
- package/dist/index.umd.js.map +0 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patches all PgArray columns on the given tables to handle NULL values safely.
|
|
3
|
+
*
|
|
4
|
+
* Drizzle ORM's `PgArray.mapFromDriverValue` calls `value.map(...)` without
|
|
5
|
+
* guarding against `null`. When a PostgreSQL native array column (`text[]`,
|
|
6
|
+
* `integer[]`, etc.) contains NULL, the pg driver returns `null` in JavaScript,
|
|
7
|
+
* and `null.map(...)` throws `TypeError: value.map is not a function`.
|
|
8
|
+
*
|
|
9
|
+
* This function walks every column of every registered table and, for any
|
|
10
|
+
* `PgArray` column, wraps its `mapFromDriverValue` to return `null` when the
|
|
11
|
+
* database value is nullish.
|
|
12
|
+
*
|
|
13
|
+
* This is a workaround for a known Drizzle ORM issue. Should be removed once
|
|
14
|
+
* Drizzle handles nullable arrays natively.
|
|
15
|
+
*/
|
|
16
|
+
export declare function patchPgArrayNullSafety(tables: Record<string, unknown>): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgresql",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.1",
|
|
5
5
|
"description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -71,11 +71,11 @@
|
|
|
71
71
|
"hono": "^4.12.25",
|
|
72
72
|
"pg": "^8.21.0",
|
|
73
73
|
"ws": "^8.21.0",
|
|
74
|
-
"@rebasepro/
|
|
75
|
-
"@rebasepro/
|
|
76
|
-
"@rebasepro/
|
|
77
|
-
"@rebasepro/
|
|
78
|
-
"@rebasepro/utils": "0.6.
|
|
74
|
+
"@rebasepro/types": "0.6.1",
|
|
75
|
+
"@rebasepro/server-core": "0.6.1",
|
|
76
|
+
"@rebasepro/sdk-generator": "0.6.1",
|
|
77
|
+
"@rebasepro/common": "0.6.1",
|
|
78
|
+
"@rebasepro/utils": "0.6.1"
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
|
81
81
|
"@types/jest": "^30.0.0",
|
|
@@ -4,41 +4,31 @@
|
|
|
4
4
|
* Implements the `BackendBootstrapper` interface for PostgreSQL.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { getTableName, isTable, Relations, sql
|
|
7
|
+
import { getTableName, isTable, Relations, sql } from "drizzle-orm";
|
|
8
8
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
|
-
import { PgEnum, PgTable
|
|
9
|
+
import { PgEnum, PgTable } from "drizzle-orm/pg-core";
|
|
10
10
|
import type { RebasePgTable } from "./types";
|
|
11
11
|
import {
|
|
12
|
+
type AuthAdapter,
|
|
12
13
|
BackendBootstrapper,
|
|
13
|
-
InitializedDriver,
|
|
14
14
|
BootstrappedAuth,
|
|
15
15
|
DatabaseAdmin,
|
|
16
|
-
RealtimeProvider,
|
|
17
16
|
type DataDriver,
|
|
18
|
-
type AuthAdapter,
|
|
19
17
|
EntityCollection,
|
|
20
|
-
|
|
18
|
+
InitializedDriver,
|
|
19
|
+
RealtimeProvider
|
|
21
20
|
} from "@rebasepro/types";
|
|
22
21
|
import { PostgresBackendDriver } from "./PostgresBackendDriver";
|
|
23
22
|
import { RealtimeService } from "./services/realtimeService";
|
|
24
23
|
import { DatabasePoolManager } from "./databasePoolManager";
|
|
25
24
|
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
26
|
-
import {
|
|
27
|
-
createAuthRoutes,
|
|
28
|
-
requireAuth,
|
|
29
|
-
requireAdmin,
|
|
30
|
-
logger
|
|
31
|
-
} from "@rebasepro/server-core";
|
|
25
|
+
import { createEmailService, type EmailConfig, type EmailService, logger } from "@rebasepro/server-core";
|
|
32
26
|
import { ensureAuthTablesExist } from "./auth/ensure-tables";
|
|
33
|
-
import {
|
|
27
|
+
import { AuthSchemaTables, PostgresAuthRepository, UserService } from "./auth/services";
|
|
34
28
|
import { createAuthSchema } from "./schema/auth-schema";
|
|
35
|
-
|
|
36
|
-
import { createEmailService, type EmailConfig, type EmailService } from "@rebasepro/server-core";
|
|
37
|
-
import { createHistoryRoutes } from "@rebasepro/server-core";
|
|
38
29
|
import { HistoryService } from "./history/HistoryService";
|
|
39
30
|
import { ensureHistoryTableExists } from "./history/ensure-history-table";
|
|
40
|
-
import
|
|
41
|
-
import type { HonoEnv } from "@rebasepro/server-core";
|
|
31
|
+
import { patchPgArrayNullSafety } from "./utils/pg-array-null-patch";
|
|
42
32
|
|
|
43
33
|
export interface PostgresDriverConfig {
|
|
44
34
|
connectionString?: string;
|
|
@@ -108,6 +98,13 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
108
98
|
if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums as Record<string, PgEnum<[string, ...string[]]>>);
|
|
109
99
|
if (pgConfig.schema?.relations) registry.registerRelations(pgConfig.schema.relations as Record<string, Relations>);
|
|
110
100
|
|
|
101
|
+
// Patch Drizzle's PgArray columns to handle NULL values safely.
|
|
102
|
+
// Drizzle's mapFromDriverValue crashes with "value.map is not a function"
|
|
103
|
+
// when a native array column (text[], integer[], etc.) contains NULL.
|
|
104
|
+
if (pgConfig.schema?.tables) {
|
|
105
|
+
patchPgArrayNullSafety(pgConfig.schema.tables as Record<string, unknown>);
|
|
106
|
+
}
|
|
107
|
+
|
|
111
108
|
// Build schema-aware Drizzle connection
|
|
112
109
|
const mergedSchema: Record<string, unknown> = {
|
|
113
110
|
...pgConfig.schema?.tables,
|
package/src/data-transformer.ts
CHANGED
|
@@ -244,8 +244,12 @@ export function serializePropertyToServer(value: unknown, property: Property): u
|
|
|
244
244
|
};
|
|
245
245
|
});
|
|
246
246
|
}
|
|
247
|
+
return value;
|
|
247
248
|
}
|
|
248
|
-
|
|
249
|
+
// Non-array value for an array property — coerce to avoid .map() crashes downstream
|
|
250
|
+
logger.warn(`Expected array value for array property, got ${typeof value}. Coercing to empty array.`);
|
|
251
|
+
return [];
|
|
252
|
+
|
|
249
253
|
|
|
250
254
|
case "map":
|
|
251
255
|
if (typeof value === "object" && property.properties) {
|
|
@@ -585,6 +589,10 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
585
589
|
};
|
|
586
590
|
});
|
|
587
591
|
}
|
|
592
|
+
} else {
|
|
593
|
+
// Non-array value from DB for an array property — coerce to avoid .map() crashes
|
|
594
|
+
logger.warn(`Expected array value from DB for array property, got ${typeof value}. Coercing to array.`);
|
|
595
|
+
return typeof value === "string" ? [value] : [];
|
|
588
596
|
}
|
|
589
597
|
return value;
|
|
590
598
|
|
|
@@ -192,17 +192,17 @@ export class EntityPersistService {
|
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
let savedId: string | number;
|
|
196
|
+
try {
|
|
197
|
+
// Transform relations to IDs, then sanitize
|
|
198
|
+
const serializedResult = serializeDataToServer(otherValues as M, collection.properties as Properties, collection, this.registry);
|
|
197
199
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
// Extract relation updates from the typed result
|
|
201
|
+
const inverseRelationUpdates = serializedResult.inverseRelationUpdates;
|
|
202
|
+
const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
|
|
201
203
|
|
|
202
|
-
|
|
204
|
+
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
203
205
|
|
|
204
|
-
let savedId: string | number;
|
|
205
|
-
try {
|
|
206
206
|
savedId = await this.db.transaction(async (tx) => {
|
|
207
207
|
let currentId: string | number;
|
|
208
208
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getTableColumns } from "drizzle-orm";
|
|
2
|
+
import { PgArray, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import { logger } from "@rebasepro/server-core";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Patches all PgArray columns on the given tables to handle NULL values safely.
|
|
7
|
+
*
|
|
8
|
+
* Drizzle ORM's `PgArray.mapFromDriverValue` calls `value.map(...)` without
|
|
9
|
+
* guarding against `null`. When a PostgreSQL native array column (`text[]`,
|
|
10
|
+
* `integer[]`, etc.) contains NULL, the pg driver returns `null` in JavaScript,
|
|
11
|
+
* and `null.map(...)` throws `TypeError: value.map is not a function`.
|
|
12
|
+
*
|
|
13
|
+
* This function walks every column of every registered table and, for any
|
|
14
|
+
* `PgArray` column, wraps its `mapFromDriverValue` to return `null` when the
|
|
15
|
+
* database value is nullish.
|
|
16
|
+
*
|
|
17
|
+
* This is a workaround for a known Drizzle ORM issue. Should be removed once
|
|
18
|
+
* Drizzle handles nullable arrays natively.
|
|
19
|
+
*/
|
|
20
|
+
export function patchPgArrayNullSafety(tables: Record<string, unknown>): void {
|
|
21
|
+
let patchedCount = 0;
|
|
22
|
+
|
|
23
|
+
for (const tableOrRelation of Object.values(tables)) {
|
|
24
|
+
if (!(tableOrRelation instanceof PgTable)) continue;
|
|
25
|
+
|
|
26
|
+
const columns = getTableColumns(tableOrRelation);
|
|
27
|
+
for (const column of Object.values(columns)) {
|
|
28
|
+
if (column instanceof PgArray) {
|
|
29
|
+
const original = column.mapFromDriverValue.bind(column);
|
|
30
|
+
column.mapFromDriverValue = function (value: unknown) {
|
|
31
|
+
if (value == null) return null;
|
|
32
|
+
return original(value as string | unknown[]);
|
|
33
|
+
};
|
|
34
|
+
patchedCount++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (patchedCount > 0) {
|
|
40
|
+
logger.debug(`[PgArray] Patched ${patchedCount} array column(s) for null-safety`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, it, expect } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
serializePropertyToServer,
|
|
4
|
+
parsePropertyFromServer,
|
|
5
|
+
serializeDataToServer
|
|
6
|
+
} from "../src/data-transformer";
|
|
7
|
+
import type { Property, Properties, EntityCollection } from "@rebasepro/types";
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────────────────────
|
|
10
|
+
// Fixture helpers
|
|
11
|
+
// ─────────────────────────────────────────────────────────────
|
|
12
|
+
function makeCollection(slug: string, properties: Properties): EntityCollection {
|
|
13
|
+
return {
|
|
14
|
+
name: slug,
|
|
15
|
+
slug,
|
|
16
|
+
path: slug,
|
|
17
|
+
collectionType: "postgres",
|
|
18
|
+
tableName: slug,
|
|
19
|
+
properties
|
|
20
|
+
} as unknown as EntityCollection;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────────────────────
|
|
24
|
+
// Property fixtures
|
|
25
|
+
// ─────────────────────────────────────────────────────────────
|
|
26
|
+
const arrayOfStringsProp: Property = {
|
|
27
|
+
type: "array",
|
|
28
|
+
name: "Tags",
|
|
29
|
+
of: { type: "string", name: "Tag" }
|
|
30
|
+
} as Property;
|
|
31
|
+
|
|
32
|
+
const arrayOfNumbersProp: Property = {
|
|
33
|
+
type: "array",
|
|
34
|
+
name: "Scores",
|
|
35
|
+
of: { type: "number", name: "Score" }
|
|
36
|
+
} as Property;
|
|
37
|
+
|
|
38
|
+
const arrayOfMapsProp: Property = {
|
|
39
|
+
type: "array",
|
|
40
|
+
name: "Addresses",
|
|
41
|
+
of: {
|
|
42
|
+
type: "map",
|
|
43
|
+
name: "Address",
|
|
44
|
+
properties: {
|
|
45
|
+
street: { type: "string", name: "Street" },
|
|
46
|
+
city: { type: "string", name: "City" }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} as Property;
|
|
50
|
+
|
|
51
|
+
const arrayOneOfProp: Property = {
|
|
52
|
+
type: "array",
|
|
53
|
+
name: "Blocks",
|
|
54
|
+
oneOf: {
|
|
55
|
+
typeField: "type",
|
|
56
|
+
valueField: "value",
|
|
57
|
+
properties: {
|
|
58
|
+
text: { type: "string", name: "Text" } as Property,
|
|
59
|
+
number: { type: "number", name: "Number" } as Property
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} as Property;
|
|
63
|
+
|
|
64
|
+
const arrayOfRelationsProp: Property = {
|
|
65
|
+
type: "array",
|
|
66
|
+
name: "Authors",
|
|
67
|
+
of: { type: "relation", name: "Author" }
|
|
68
|
+
} as Property;
|
|
69
|
+
|
|
70
|
+
// A bare array property without `of` or `oneOf`
|
|
71
|
+
const bareArrayProp: Property = {
|
|
72
|
+
type: "array",
|
|
73
|
+
name: "RawArray"
|
|
74
|
+
} as Property;
|
|
75
|
+
|
|
76
|
+
// ─────────────────────────────────────────────────────────────
|
|
77
|
+
// 1. serializePropertyToServer — array null safety
|
|
78
|
+
// ─────────────────────────────────────────────────────────────
|
|
79
|
+
describe("serializePropertyToServer — array null safety", () => {
|
|
80
|
+
|
|
81
|
+
// ── Null / undefined early return ──
|
|
82
|
+
it("returns null when value is null with array-of-strings property", () => {
|
|
83
|
+
expect(serializePropertyToServer(null, arrayOfStringsProp)).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns undefined when value is undefined with array-of-strings property", () => {
|
|
87
|
+
expect(serializePropertyToServer(undefined, arrayOfStringsProp)).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── Empty array ──
|
|
91
|
+
it("returns [] when value is an empty array", () => {
|
|
92
|
+
expect(serializePropertyToServer([], arrayOfStringsProp)).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Null with different sub-types ──
|
|
96
|
+
it("returns null when value is null with array-of-numbers property", () => {
|
|
97
|
+
expect(serializePropertyToServer(null, arrayOfNumbersProp)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns null when value is null with array-of-maps property", () => {
|
|
101
|
+
expect(serializePropertyToServer(null, arrayOfMapsProp)).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns null when value is null with array oneOf property", () => {
|
|
105
|
+
expect(serializePropertyToServer(null, arrayOneOfProp)).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── Non-array values (coerced to empty array) ──
|
|
109
|
+
it("coerces a non-array string to empty array for array property", () => {
|
|
110
|
+
expect(serializePropertyToServer("not-an-array", arrayOfStringsProp)).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("coerces a non-array number to empty array for array property", () => {
|
|
114
|
+
expect(serializePropertyToServer(42, arrayOfStringsProp)).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("coerces a non-array object to empty array for bare array property", () => {
|
|
118
|
+
const obj = { a: 1 };
|
|
119
|
+
expect(serializePropertyToServer(obj, bareArrayProp)).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("coerces a boolean to empty array for array property", () => {
|
|
123
|
+
expect(serializePropertyToServer(true, arrayOfStringsProp)).toEqual([]);
|
|
124
|
+
expect(serializePropertyToServer(false, arrayOfStringsProp)).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── Arrays with null / undefined elements ──
|
|
128
|
+
it("handles array with null elements by passing each through sub-serialization", () => {
|
|
129
|
+
const result = serializePropertyToServer([null, "a", null], arrayOfStringsProp);
|
|
130
|
+
expect(result).toEqual([null, "a", null]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("handles array with undefined elements by passing each through sub-serialization", () => {
|
|
134
|
+
const result = serializePropertyToServer([undefined, "a"], arrayOfStringsProp);
|
|
135
|
+
expect(result).toEqual([undefined, "a"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ── Array of relation objects with null entries ──
|
|
139
|
+
it("serializes array of relation objects with null entries correctly", () => {
|
|
140
|
+
const value = [{ id: "1" }, null, { id: "2" }];
|
|
141
|
+
const result = serializePropertyToServer(value, arrayOfRelationsProp);
|
|
142
|
+
// null entries pass through serializePropertyToServer for the sub-property
|
|
143
|
+
// which returns null for null values
|
|
144
|
+
expect(result).toEqual(["1", null, "2"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── Array of maps where some entries are null ──
|
|
148
|
+
it("handles array of maps where some entries are null gracefully", () => {
|
|
149
|
+
const value = [
|
|
150
|
+
{ street: "123 Main", city: "NYC" },
|
|
151
|
+
null,
|
|
152
|
+
{ street: "456 Oak", city: "LA" }
|
|
153
|
+
];
|
|
154
|
+
const result = serializePropertyToServer(value, arrayOfMapsProp);
|
|
155
|
+
expect(result).toEqual([
|
|
156
|
+
{ street: "123 Main", city: "NYC" },
|
|
157
|
+
null,
|
|
158
|
+
{ street: "456 Oak", city: "LA" }
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── Empty array with oneOf property ──
|
|
163
|
+
it("returns [] when value is an empty array with oneOf property", () => {
|
|
164
|
+
expect(serializePropertyToServer([], arrayOneOfProp)).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── Nested array of arrays (unusual) ──
|
|
168
|
+
it("returns nested array of arrays as-is for bare array property", () => {
|
|
169
|
+
const nested = [["a", "b"], ["c"]];
|
|
170
|
+
// Without `of` or `oneOf`, the array case falls through to `return value`
|
|
171
|
+
expect(serializePropertyToServer(nested, bareArrayProp)).toEqual([["a", "b"], ["c"]]);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─────────────────────────────────────────────────────────────
|
|
176
|
+
// 2. parsePropertyFromServer — array null safety
|
|
177
|
+
// ─────────────────────────────────────────────────────────────
|
|
178
|
+
describe("parsePropertyFromServer — array null safety", () => {
|
|
179
|
+
const dummyCollection = makeCollection("items", {
|
|
180
|
+
tags: arrayOfStringsProp
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── Null / undefined early return ──
|
|
184
|
+
it("returns null when value is null with array property", () => {
|
|
185
|
+
expect(parsePropertyFromServer(null, arrayOfStringsProp, dummyCollection)).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns undefined when value is undefined with array property", () => {
|
|
189
|
+
expect(parsePropertyFromServer(undefined, arrayOfStringsProp, dummyCollection)).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Empty array ──
|
|
193
|
+
it("returns [] when value is an empty array with array-of-strings", () => {
|
|
194
|
+
expect(parsePropertyFromServer([], arrayOfStringsProp, dummyCollection)).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── Non-array values (coerced to array) ──
|
|
198
|
+
it("coerces a non-array string to single-element array for array property", () => {
|
|
199
|
+
expect(parsePropertyFromServer("not-an-array", arrayOfStringsProp, dummyCollection)).toEqual(["not-an-array"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("coerces a non-array number to empty array for array property", () => {
|
|
203
|
+
expect(parsePropertyFromServer(99, arrayOfStringsProp, dummyCollection)).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ── Null with oneOf ──
|
|
207
|
+
it("returns null when value is null with array oneOf property", () => {
|
|
208
|
+
expect(parsePropertyFromServer(null, arrayOneOfProp, dummyCollection)).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── Array with null elements ──
|
|
212
|
+
it("handles array with null elements for parsing gracefully", () => {
|
|
213
|
+
const result = parsePropertyFromServer([null, "a", null], arrayOfStringsProp, dummyCollection);
|
|
214
|
+
expect(result).toEqual([null, "a", null]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── Falsy non-null values (coerced to array) ──
|
|
218
|
+
it("coerces falsy number 0 to empty array for array property", () => {
|
|
219
|
+
expect(parsePropertyFromServer(0, arrayOfStringsProp, dummyCollection)).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("coerces false to empty array for array property", () => {
|
|
223
|
+
expect(parsePropertyFromServer(false, arrayOfStringsProp, dummyCollection)).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("coerces empty string to single-element array for array property", () => {
|
|
227
|
+
expect(parsePropertyFromServer("", arrayOfStringsProp, dummyCollection)).toEqual([""]);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ─────────────────────────────────────────────────────────────
|
|
232
|
+
// 3. serializeDataToServer — entity with null array fields
|
|
233
|
+
// ─────────────────────────────────────────────────────────────
|
|
234
|
+
describe("serializeDataToServer — entity with null array fields", () => {
|
|
235
|
+
const tagsProperty: Property = {
|
|
236
|
+
type: "array",
|
|
237
|
+
name: "Tags",
|
|
238
|
+
of: { type: "string", name: "Tag" }
|
|
239
|
+
} as Property;
|
|
240
|
+
|
|
241
|
+
const categoriesProperty: Property = {
|
|
242
|
+
type: "array",
|
|
243
|
+
name: "Categories",
|
|
244
|
+
of: { type: "string", name: "Category" }
|
|
245
|
+
} as Property;
|
|
246
|
+
|
|
247
|
+
const metadataProperty: Property = {
|
|
248
|
+
type: "map",
|
|
249
|
+
name: "Metadata",
|
|
250
|
+
properties: {
|
|
251
|
+
labels: {
|
|
252
|
+
type: "array",
|
|
253
|
+
name: "Labels",
|
|
254
|
+
of: { type: "string", name: "Label" }
|
|
255
|
+
} as Property
|
|
256
|
+
}
|
|
257
|
+
} as Property;
|
|
258
|
+
|
|
259
|
+
const properties: Properties = {
|
|
260
|
+
tags: tagsProperty
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// ── Null array field ──
|
|
264
|
+
it("serializes entity with tags: null — scalarData.tags should be null", () => {
|
|
265
|
+
const result = serializeDataToServer(
|
|
266
|
+
{ tags: null },
|
|
267
|
+
properties
|
|
268
|
+
);
|
|
269
|
+
expect(result.scalarData.tags).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ── Undefined array field ──
|
|
273
|
+
it("serializes entity with tags: undefined — tags value should be undefined in scalarData", () => {
|
|
274
|
+
const result = serializeDataToServer(
|
|
275
|
+
{ tags: undefined },
|
|
276
|
+
properties
|
|
277
|
+
);
|
|
278
|
+
// Object.entries iterates keys even when value is undefined,
|
|
279
|
+
// and serializePropertyToServer returns undefined for undefined input
|
|
280
|
+
expect(result.scalarData.tags).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── Empty array field ──
|
|
284
|
+
it("serializes entity with tags: [] — scalarData.tags should be []", () => {
|
|
285
|
+
const result = serializeDataToServer(
|
|
286
|
+
{ tags: [] },
|
|
287
|
+
properties
|
|
288
|
+
);
|
|
289
|
+
expect(result.scalarData.tags).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ── Multiple array fields, mixed null / populated ──
|
|
293
|
+
it("handles entity with multiple array fields, some null, some populated", () => {
|
|
294
|
+
const multiProperties: Properties = {
|
|
295
|
+
tags: tagsProperty,
|
|
296
|
+
categories: categoriesProperty
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const result = serializeDataToServer(
|
|
300
|
+
{ tags: null, categories: ["cat-a", "cat-b"] },
|
|
301
|
+
multiProperties
|
|
302
|
+
);
|
|
303
|
+
expect(result.scalarData.tags).toBeNull();
|
|
304
|
+
expect(result.scalarData.categories).toEqual(["cat-a", "cat-b"]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ── Nested map containing null array ──
|
|
308
|
+
it("handles entity with nested map containing array field that is null", () => {
|
|
309
|
+
const nestedProperties: Properties = {
|
|
310
|
+
metadata: metadataProperty
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const result = serializeDataToServer(
|
|
314
|
+
{ metadata: { labels: null } },
|
|
315
|
+
nestedProperties
|
|
316
|
+
);
|
|
317
|
+
// The map serializer recurses into sub-properties; the inner array gets null-checked
|
|
318
|
+
expect(result.scalarData.metadata).toEqual({ labels: null });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ── All array fields null ──
|
|
322
|
+
it("handles entity with only array fields, all null — scalarData should have all null values", () => {
|
|
323
|
+
const allArrayProperties: Properties = {
|
|
324
|
+
tags: tagsProperty,
|
|
325
|
+
categories: categoriesProperty
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const result = serializeDataToServer(
|
|
329
|
+
{ tags: null, categories: null },
|
|
330
|
+
allArrayProperties
|
|
331
|
+
);
|
|
332
|
+
expect(result.scalarData.tags).toBeNull();
|
|
333
|
+
expect(result.scalarData.categories).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -149,14 +149,65 @@ path: "authors" }
|
|
|
149
149
|
of: { type: "string" } as Property
|
|
150
150
|
} as unknown as Property;
|
|
151
151
|
|
|
152
|
+
const arrayOfNumbersProp: Property = {
|
|
153
|
+
type: "array",
|
|
154
|
+
of: { type: "number" } as Property
|
|
155
|
+
} as unknown as Property;
|
|
156
|
+
|
|
157
|
+
const arrayOfRelationsProp: Property = {
|
|
158
|
+
type: "array",
|
|
159
|
+
of: { type: "relation" } as Property
|
|
160
|
+
} as unknown as Property;
|
|
161
|
+
|
|
152
162
|
it("should serialize array elements through their sub-property type", () => {
|
|
153
163
|
expect(serializePropertyToServer(["a", "b", "c"], arrayOfStringsProp))
|
|
154
164
|
.toEqual(["a", "b", "c"]);
|
|
155
165
|
});
|
|
156
166
|
|
|
157
|
-
it("should
|
|
167
|
+
it("should coerce non-array values to empty array", () => {
|
|
158
168
|
expect(serializePropertyToServer("not-an-array", arrayOfStringsProp))
|
|
159
|
-
.
|
|
169
|
+
.toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// --- Null safety ---
|
|
173
|
+
it("should return null for null array value", () => {
|
|
174
|
+
expect(serializePropertyToServer(null, arrayOfStringsProp)).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("should return undefined for undefined array value", () => {
|
|
178
|
+
expect(serializePropertyToServer(undefined, arrayOfStringsProp)).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should return empty array for empty array value", () => {
|
|
182
|
+
expect(serializePropertyToServer([], arrayOfStringsProp)).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should return null for null value with array-of-numbers property", () => {
|
|
186
|
+
expect(serializePropertyToServer(null, arrayOfNumbersProp)).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("should handle array with null elements", () => {
|
|
190
|
+
const result = serializePropertyToServer([null, "a", null, "b"], arrayOfStringsProp);
|
|
191
|
+
expect(result).toEqual([null, "a", null, "b"]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("should handle array of relation objects with null entries", () => {
|
|
195
|
+
const value = [{ id: "1", path: "tags" }, null, { id: "2", path: "tags" }];
|
|
196
|
+
const result = serializePropertyToServer(value, arrayOfRelationsProp);
|
|
197
|
+
expect(result).toEqual(["1", null, "2"]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("should coerce number value to empty array for array property", () => {
|
|
201
|
+
expect(serializePropertyToServer(42, arrayOfStringsProp)).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should coerce boolean value to empty array for array property", () => {
|
|
205
|
+
expect(serializePropertyToServer(true, arrayOfStringsProp)).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should coerce object value to empty array for array property", () => {
|
|
209
|
+
const obj = { a: 1 };
|
|
210
|
+
expect(serializePropertyToServer(obj, arrayOfStringsProp)).toEqual([]);
|
|
160
211
|
});
|
|
161
212
|
});
|
|
162
213
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, jest } from "@jest/globals";
|
|
2
|
+
import { pgTable, text, integer, boolean } from "drizzle-orm/pg-core";
|
|
3
|
+
import { patchPgArrayNullSafety } from "../src/utils/pg-array-null-patch";
|
|
4
|
+
import { getTableColumns } from "drizzle-orm";
|
|
5
|
+
import { PgArray } from "drizzle-orm/pg-core";
|
|
6
|
+
|
|
7
|
+
// Build a table that has native array columns
|
|
8
|
+
const testTable = pgTable("test_items", {
|
|
9
|
+
id: text("id").primaryKey(),
|
|
10
|
+
name: text("name"),
|
|
11
|
+
tags: text("tags").array(),
|
|
12
|
+
scores: integer("scores").array(),
|
|
13
|
+
flags: boolean("flags").array(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("patchPgArrayNullSafety", () => {
|
|
17
|
+
it("should patch PgArray columns to handle null", () => {
|
|
18
|
+
patchPgArrayNullSafety({ test_items: testTable });
|
|
19
|
+
|
|
20
|
+
const columns = getTableColumns(testTable);
|
|
21
|
+
const tagsColumn = columns.tags;
|
|
22
|
+
|
|
23
|
+
// Before patch, calling mapFromDriverValue(null) would throw TypeError
|
|
24
|
+
// After patch, it should return null
|
|
25
|
+
expect(tagsColumn.mapFromDriverValue(null as any)).toBeNull();
|
|
26
|
+
expect(tagsColumn.mapFromDriverValue(undefined as any)).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should still process valid array values correctly", () => {
|
|
30
|
+
patchPgArrayNullSafety({ test_items: testTable });
|
|
31
|
+
|
|
32
|
+
const columns = getTableColumns(testTable);
|
|
33
|
+
const tagsColumn = columns.tags;
|
|
34
|
+
const scoresColumn = columns.scores;
|
|
35
|
+
|
|
36
|
+
// Valid arrays should still work
|
|
37
|
+
expect(tagsColumn.mapFromDriverValue(["hello", "world"])).toEqual(["hello", "world"]);
|
|
38
|
+
expect(tagsColumn.mapFromDriverValue([])).toEqual([]);
|
|
39
|
+
expect(scoresColumn.mapFromDriverValue([1, 2, 3])).toEqual([1, 2, 3]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should not patch non-array columns", () => {
|
|
43
|
+
patchPgArrayNullSafety({ test_items: testTable });
|
|
44
|
+
|
|
45
|
+
const columns = getTableColumns(testTable);
|
|
46
|
+
const nameColumn = columns.name;
|
|
47
|
+
|
|
48
|
+
// Non-array columns should not be PgArray instances
|
|
49
|
+
expect(nameColumn instanceof PgArray).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should handle tables with no array columns", () => {
|
|
53
|
+
const simpleTable = pgTable("simple", {
|
|
54
|
+
id: text("id").primaryKey(),
|
|
55
|
+
name: text("name"),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Should not throw
|
|
59
|
+
expect(() => patchPgArrayNullSafety({ simple: simpleTable })).not.toThrow();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should handle empty input", () => {
|
|
63
|
+
expect(() => patchPgArrayNullSafety({})).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
});
|