@byline/db-postgres 3.13.3 → 3.15.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/dist/modules/storage/storage-flatten.js +16 -0
- package/dist/modules/storage/storage-queries.js +7 -1
- package/dist/modules/storage/storage-restore.js +25 -2
- package/dist/modules/storage/tests/multi-relation.test.d.ts +8 -0
- package/dist/modules/storage/tests/multi-relation.test.js +92 -0
- package/package.json +4 -4
|
@@ -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
|
|
@@ -1233,7 +1233,13 @@ export class DocumentQueries {
|
|
|
1233
1233
|
// Text search across configured search fields via EXISTS on store_text.
|
|
1234
1234
|
if (query) {
|
|
1235
1235
|
const definition = await this.getDefinitionForCollection(collection_id);
|
|
1236
|
-
|
|
1236
|
+
// The admin list-view box matches store_text rows by field name. Derive
|
|
1237
|
+
// the names from the role-based `search.body` declaration (a field path
|
|
1238
|
+
// or `{ field, boost }`); fall back to the identity-ish `title`.
|
|
1239
|
+
const bodyDecls = definition.search?.body ?? [];
|
|
1240
|
+
const searchFields = bodyDecls.length > 0
|
|
1241
|
+
? bodyDecls.map((decl) => (typeof decl === 'string' ? decl : decl.field))
|
|
1242
|
+
: ['title'];
|
|
1237
1243
|
const searchConditions = searchFields.map((fieldName) => sql `(field_name = ${fieldName} AND value ILIKE ${`%${query}%`})`);
|
|
1238
1244
|
conditions.push(sql `EXISTS (
|
|
1239
1245
|
SELECT 1 FROM byline_store_text
|
|
@@ -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.15.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/
|
|
60
|
+
"@byline/core": "3.15.0",
|
|
61
|
+
"@byline/admin": "3.15.0",
|
|
62
|
+
"@byline/auth": "3.15.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@biomejs/biome": "2.4.15",
|