@byline/db-postgres 3.13.2 → 3.14.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.
|
@@ -109,6 +109,22 @@ function* flattenFieldDataGen(field, data, locale, field_path) {
|
|
|
109
109
|
yield* flattenFieldSetDataGen(block.fields, item_data, locale, item_path);
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
// Ordered multi-target relation: emit one relation row per item at an
|
|
113
|
+
// indexed path (`<field>.0`, `<field>.1`, …), mirroring the array path
|
|
114
|
+
// convention. `restoreFieldSetData` reassembles the ordered array.
|
|
115
|
+
// Relations are non-localized, so every row carries locale 'all'. Identity
|
|
116
|
+
// is the `targetDocumentId` (the widget dedups), so no `store_meta` rows are
|
|
117
|
+
// needed. Must intercept before the generic value branch, which would
|
|
118
|
+
// otherwise read the whole array as a single relation value.
|
|
119
|
+
else if (field.type === 'relation' && field.hasMany) {
|
|
120
|
+
const items = data;
|
|
121
|
+
for (let i = 0; i < items.length; i++) {
|
|
122
|
+
const item = items[i];
|
|
123
|
+
if (item == null)
|
|
124
|
+
continue;
|
|
125
|
+
yield flattenValueFieldData(field, [...field_path, `${i}`], item, 'all');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
112
128
|
// Handle localized fields separately.
|
|
113
129
|
else if (field.localized) {
|
|
114
130
|
// If locale is 'all', data is expected to be an object that maps locales to
|
|
@@ -39,9 +39,13 @@ export const restoreFieldSetData = (fields, flattenedData, resolveLocale) => {
|
|
|
39
39
|
result[fieldName] = restoreFieldData(field, result[fieldName], item, 1, warnings, resolveLocale);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
-
// Array/blocks/group fixup
|
|
42
|
+
// Array/blocks/group/hasMany-relation fixup — a required list-shaped field
|
|
43
|
+
// with no stored rows reconstructs as an empty array rather than absent.
|
|
43
44
|
for (const field of fields) {
|
|
44
|
-
if ((field.type === 'array' ||
|
|
45
|
+
if ((field.type === 'array' ||
|
|
46
|
+
field.type === 'blocks' ||
|
|
47
|
+
(field.type === 'relation' && field.hasMany)) &&
|
|
48
|
+
!field.optional) {
|
|
45
49
|
result[field.name] = result[field.name] || [];
|
|
46
50
|
}
|
|
47
51
|
else if (field.type === 'group' && !field.optional) {
|
|
@@ -60,6 +64,9 @@ const restoreFieldData = (field, target, data, pathIndex, warnings, resolveLocal
|
|
|
60
64
|
else if (field.type === 'blocks') {
|
|
61
65
|
return restoreBlocksFieldData(field, target, data, pathIndex, warnings, resolveLocale);
|
|
62
66
|
}
|
|
67
|
+
else if (field.type === 'relation' && field.hasMany) {
|
|
68
|
+
return restoreMultiRelationFieldData(target, data, pathIndex, warnings);
|
|
69
|
+
}
|
|
63
70
|
if (field.localized) {
|
|
64
71
|
if (data.locale === 'all') {
|
|
65
72
|
warnings.push(`Received non-localized data for localized field at path ${data.field_path.join('.')}`);
|
|
@@ -172,6 +179,22 @@ const restoreBlocksFieldData = (field, target, data, pathIndex, warnings, resolv
|
|
|
172
179
|
target[arrayIndex][fieldName] = restoreFieldData(subField, target[arrayIndex][fieldName], data, pathIndex + 3, warnings, resolveLocale);
|
|
173
180
|
return target;
|
|
174
181
|
};
|
|
182
|
+
const restoreMultiRelationFieldData = (target, data, pathIndex, warnings) => {
|
|
183
|
+
// Path shape: `[...prefix, <field>, <index>]`. At `pathIndex` we have the
|
|
184
|
+
// ordered position; the row itself is a single relation value.
|
|
185
|
+
const arrayIndex = Number.parseInt(data.field_path[pathIndex] ?? '', 10);
|
|
186
|
+
if (Number.isNaN(arrayIndex) || arrayIndex < 0) {
|
|
187
|
+
warnings.push(`Invalid relation index '${data.field_path[pathIndex]}' in path ${data.field_path.join('.')}`);
|
|
188
|
+
return target;
|
|
189
|
+
}
|
|
190
|
+
if (data.field_type !== 'relation') {
|
|
191
|
+
warnings.push(`Expected relation row for hasMany relation but got ${data.field_type} at path ${data.field_path.join('.')}`);
|
|
192
|
+
return target;
|
|
193
|
+
}
|
|
194
|
+
target = target || [];
|
|
195
|
+
target[arrayIndex] = extractValueFieldData(data);
|
|
196
|
+
return target;
|
|
197
|
+
};
|
|
175
198
|
const extractValueFieldData = (data) => {
|
|
176
199
|
switch (data.field_type) {
|
|
177
200
|
case 'text':
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Storage round-trip for `hasMany` relations — an ordered list of relation
|
|
10
|
+
* values flattens to indexed `store_relation` rows (`<field>.0`, `<field>.1`,
|
|
11
|
+
* …) and reconstructs back into the same ordered array. Pure functions; no
|
|
12
|
+
* Postgres needed.
|
|
13
|
+
*/
|
|
14
|
+
import { defineCollection } from '@byline/core';
|
|
15
|
+
import { describe, expect, it } from 'vitest';
|
|
16
|
+
import { flattenFieldSetData } from '../storage-flatten.js';
|
|
17
|
+
import { restoreFieldSetData } from '../storage-restore.js';
|
|
18
|
+
const Articles = defineCollection({
|
|
19
|
+
path: 'articles',
|
|
20
|
+
labels: { singular: 'Article', plural: 'Articles' },
|
|
21
|
+
fields: [
|
|
22
|
+
{ name: 'title', type: 'text' },
|
|
23
|
+
{
|
|
24
|
+
name: 'authors',
|
|
25
|
+
type: 'relation',
|
|
26
|
+
targetCollection: 'people',
|
|
27
|
+
hasMany: true,
|
|
28
|
+
optional: true,
|
|
29
|
+
},
|
|
30
|
+
// A required hasMany to exercise the empty-list fixup.
|
|
31
|
+
{
|
|
32
|
+
name: 'tags',
|
|
33
|
+
type: 'relation',
|
|
34
|
+
targetCollection: 'tags',
|
|
35
|
+
hasMany: true,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
const Single = defineCollection({
|
|
40
|
+
path: 'single',
|
|
41
|
+
labels: { singular: 'Single', plural: 'Singles' },
|
|
42
|
+
fields: [{ name: 'lead', type: 'relation', targetCollection: 'people', optional: true }],
|
|
43
|
+
});
|
|
44
|
+
describe('hasMany relation storage round-trip', () => {
|
|
45
|
+
it('flattens an ordered list to indexed relation rows', () => {
|
|
46
|
+
const flat = flattenFieldSetData(Articles.fields, {
|
|
47
|
+
title: 'Hello',
|
|
48
|
+
authors: [
|
|
49
|
+
{ targetDocumentId: 'a1', targetCollectionId: 'people' },
|
|
50
|
+
{ targetDocumentId: 'a2', targetCollectionId: 'people' },
|
|
51
|
+
{ targetDocumentId: 'a3', targetCollectionId: 'people' },
|
|
52
|
+
],
|
|
53
|
+
tags: [],
|
|
54
|
+
}, 'all');
|
|
55
|
+
const relRows = flat.filter((r) => r.field_type === 'relation');
|
|
56
|
+
expect(relRows.map((r) => r.field_path.join('.'))).toEqual([
|
|
57
|
+
'authors.0',
|
|
58
|
+
'authors.1',
|
|
59
|
+
'authors.2',
|
|
60
|
+
]);
|
|
61
|
+
// Relations are non-localized — every row carries locale 'all'.
|
|
62
|
+
expect(relRows.every((r) => r.locale === 'all')).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
it('reconstructs the ordered array (and preserves order)', () => {
|
|
65
|
+
const data = {
|
|
66
|
+
title: 'Hello',
|
|
67
|
+
authors: [
|
|
68
|
+
{ targetDocumentId: 'a3', targetCollectionId: 'people' },
|
|
69
|
+
{ targetDocumentId: 'a1', targetCollectionId: 'people' },
|
|
70
|
+
{ targetDocumentId: 'a2', targetCollectionId: 'people' },
|
|
71
|
+
],
|
|
72
|
+
tags: [{ targetDocumentId: 't1', targetCollectionId: 'tags' }],
|
|
73
|
+
};
|
|
74
|
+
const { data: restored, warnings } = restoreFieldSetData(Articles.fields, flattenFieldSetData(Articles.fields, data, 'all'));
|
|
75
|
+
expect(warnings).toEqual([]);
|
|
76
|
+
expect(restored.authors.map((a) => a.targetDocumentId)).toEqual(['a3', 'a1', 'a2']);
|
|
77
|
+
expect(restored.authors[0]).toMatchObject({
|
|
78
|
+
targetDocumentId: 'a3',
|
|
79
|
+
targetCollectionId: 'people',
|
|
80
|
+
});
|
|
81
|
+
expect(restored.tags.map((t) => t.targetDocumentId)).toEqual(['t1']);
|
|
82
|
+
});
|
|
83
|
+
it('reconstructs a required empty hasMany as []', () => {
|
|
84
|
+
const { data: restored } = restoreFieldSetData(Articles.fields, flattenFieldSetData(Articles.fields, { title: 'x', authors: [], tags: [] }, 'all'));
|
|
85
|
+
expect(restored.tags).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
it('leaves single (non-hasMany) relations as scalar values', () => {
|
|
88
|
+
const { data: restored } = restoreFieldSetData(Single.fields, flattenFieldSetData(Single.fields, { lead: { targetDocumentId: 'p1', targetCollectionId: 'people' } }, 'all'));
|
|
89
|
+
expect(Array.isArray(restored.lead)).toBe(false);
|
|
90
|
+
expect(restored.lead).toMatchObject({ targetDocumentId: 'p1', targetCollectionId: 'people' });
|
|
91
|
+
});
|
|
92
|
+
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/db-postgres",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.14.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"pg": "^8.21.0",
|
|
58
58
|
"uuid": "^14.0.0",
|
|
59
59
|
"zod": "^4.4.3",
|
|
60
|
-
"@byline/
|
|
61
|
-
"@byline/
|
|
62
|
-
"@byline/core": "3.
|
|
60
|
+
"@byline/admin": "3.14.0",
|
|
61
|
+
"@byline/auth": "3.14.0",
|
|
62
|
+
"@byline/core": "3.14.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@biomejs/biome": "2.4.15",
|