@yunsoft/yuncms-core 0.1.2 → 0.1.5
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 +1 -1
- package/src/bootstrap.js +10 -0
- package/src/cache.js +85 -0
- package/src/config.js +50 -0
- package/src/context.js +5 -2
- package/src/index.js +12 -1
- package/src/maintenance-state.js +89 -0
- package/src/migrations/0008-studio-logo-file.js +10 -0
- package/src/migrations/0009-schema-display-names.js +19 -0
- package/src/migrations/0010-studio-favicon-file.js +12 -0
- package/src/migrations/0011-role-permission-actions.js +15 -0
- package/src/migrations/0012-files-read-filters.js +14 -0
- package/src/migrations.js +113 -3
- package/src/query.js +54 -12
- package/src/relation-expansion.js +191 -57
- package/src/schema-key.js +54 -0
- package/src/schema-metadata-repository.js +20 -8
- package/src/services/collections-service.js +14 -2
- package/src/services/core-services.js +2 -0
- package/src/services/fields-service.js +11 -2
- package/src/services/files-service.js +85 -7
- package/src/services/items-service.js +54 -1
- package/src/services/permissions-service.js +35 -15
- package/src/services/roles-service.js +44 -23
- package/src/services/studio-settings-service.js +83 -11
- package/src/services/system-collection-fields-service.js +122 -0
- package/src/services/users-service.js +27 -3
- package/src/system-permissions.js +50 -7
package/src/query.js
CHANGED
|
@@ -7,6 +7,18 @@ const FILTER_OPERATORS = new Set([
|
|
|
7
7
|
'_contains', '_starts_with', '_ends_with',
|
|
8
8
|
]);
|
|
9
9
|
|
|
10
|
+
export const QUERY_LIMITS = Object.freeze({
|
|
11
|
+
defaultLimit: 100,
|
|
12
|
+
maxLimit: 500,
|
|
13
|
+
maxFields: 100,
|
|
14
|
+
maxRelationExpansions: 20,
|
|
15
|
+
maxSortFields: 20,
|
|
16
|
+
maxOffset: 1_000_000,
|
|
17
|
+
maxFilterDepth: 8,
|
|
18
|
+
maxFilterNodes: 100,
|
|
19
|
+
maxInValues: 100,
|
|
20
|
+
});
|
|
21
|
+
|
|
10
22
|
function queryError(message, path = null) {
|
|
11
23
|
const error = new Error(message);
|
|
12
24
|
error.code = 'INVALID_QUERY';
|
|
@@ -14,11 +26,14 @@ function queryError(message, path = null) {
|
|
|
14
26
|
return error;
|
|
15
27
|
}
|
|
16
28
|
|
|
17
|
-
function normalizeDelimited(value, label) {
|
|
29
|
+
function normalizeDelimited(value, label, { maxItems }) {
|
|
18
30
|
if (value == null || value === '') return null;
|
|
19
31
|
const values = Array.isArray(value) ? value : String(value).split(',');
|
|
20
32
|
const normalized = values.map((item) => String(item).trim()).filter(Boolean);
|
|
21
33
|
if (normalized.length === 0) throw queryError(`${label} cannot be empty`, label);
|
|
34
|
+
if (normalized.length > maxItems) {
|
|
35
|
+
throw queryError(`${label} cannot contain more than ${maxItems} entries`, label);
|
|
36
|
+
}
|
|
22
37
|
return normalized;
|
|
23
38
|
}
|
|
24
39
|
|
|
@@ -50,7 +65,8 @@ function normalizeFilter(value) {
|
|
|
50
65
|
return value;
|
|
51
66
|
}
|
|
52
67
|
|
|
53
|
-
export function parseItemsQuery(raw = {},
|
|
68
|
+
export function parseItemsQuery(raw = {}, options = {}) {
|
|
69
|
+
const limits = { ...QUERY_LIMITS, ...options };
|
|
54
70
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
55
71
|
throw queryError('Query must be an object');
|
|
56
72
|
}
|
|
@@ -60,11 +76,19 @@ export function parseItemsQuery(raw = {}, { defaultLimit = 100, maxLimit = 500 }
|
|
|
60
76
|
}
|
|
61
77
|
|
|
62
78
|
return {
|
|
63
|
-
fields: normalizeDelimited(raw.fields, 'fields'),
|
|
79
|
+
fields: normalizeDelimited(raw.fields, 'fields', { maxItems: limits.maxFields }),
|
|
64
80
|
filter: normalizeFilter(raw.filter),
|
|
65
|
-
sort: normalizeDelimited(raw.sort, 'sort'),
|
|
66
|
-
limit: normalizeInteger(raw.limit, defaultLimit, {
|
|
67
|
-
|
|
81
|
+
sort: normalizeDelimited(raw.sort, 'sort', { maxItems: limits.maxSortFields }),
|
|
82
|
+
limit: normalizeInteger(raw.limit, limits.defaultLimit, {
|
|
83
|
+
label: 'limit',
|
|
84
|
+
min: 1,
|
|
85
|
+
max: limits.maxLimit,
|
|
86
|
+
}),
|
|
87
|
+
offset: normalizeInteger(raw.offset, 0, {
|
|
88
|
+
label: 'offset',
|
|
89
|
+
min: 0,
|
|
90
|
+
max: limits.maxOffset,
|
|
91
|
+
}),
|
|
68
92
|
};
|
|
69
93
|
}
|
|
70
94
|
|
|
@@ -105,7 +129,7 @@ function escapeLike(value) {
|
|
|
105
129
|
return String(value).replace(/[\\%_]/g, '\\$&');
|
|
106
130
|
}
|
|
107
131
|
|
|
108
|
-
function compileOperator(fieldSql, operator, value, path) {
|
|
132
|
+
function compileOperator(fieldSql, operator, value, path, limits) {
|
|
109
133
|
if (!FILTER_OPERATORS.has(operator)) throw queryError(`Unknown filter operator: ${operator}`, path);
|
|
110
134
|
|
|
111
135
|
switch (operator) {
|
|
@@ -122,6 +146,9 @@ function compileOperator(fieldSql, operator, value, path) {
|
|
|
122
146
|
case '_in':
|
|
123
147
|
case '_nin': {
|
|
124
148
|
if (!Array.isArray(value)) throw queryError(`${operator} requires an array`, path);
|
|
149
|
+
if (value.length > limits.maxInValues) {
|
|
150
|
+
throw queryError(`${operator} accepts at most ${limits.maxInValues} values`, path);
|
|
151
|
+
}
|
|
125
152
|
if (value.length === 0) return { sql: operator === '_in' ? '0 = 1' : '1 = 1', params: [] };
|
|
126
153
|
const placeholders = value.map(() => '?').join(', ');
|
|
127
154
|
return {
|
|
@@ -146,10 +173,18 @@ function compileOperator(fieldSql, operator, value, path) {
|
|
|
146
173
|
}
|
|
147
174
|
}
|
|
148
175
|
|
|
149
|
-
function compileFilterObject(filter, schema, path
|
|
176
|
+
function compileFilterObject(filter, schema, path, limits, state, depth) {
|
|
150
177
|
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
151
178
|
throw queryError('Filter node must be an object', path);
|
|
152
179
|
}
|
|
180
|
+
if (depth > limits.maxFilterDepth) {
|
|
181
|
+
throw queryError(`Filter depth cannot exceed ${limits.maxFilterDepth}`, path);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
state.nodes += 1;
|
|
185
|
+
if (state.nodes > limits.maxFilterNodes) {
|
|
186
|
+
throw queryError(`Filter cannot contain more than ${limits.maxFilterNodes} nodes`, path);
|
|
187
|
+
}
|
|
153
188
|
|
|
154
189
|
const fragments = [];
|
|
155
190
|
const params = [];
|
|
@@ -160,7 +195,7 @@ function compileFilterObject(filter, schema, path = 'filter') {
|
|
|
160
195
|
throw queryError(`${key} requires a non-empty array`, `${path}.${key}`);
|
|
161
196
|
}
|
|
162
197
|
const children = value.map((child, index) =>
|
|
163
|
-
compileFilterObject(child, schema, `${path}.${key}.${index}
|
|
198
|
+
compileFilterObject(child, schema, `${path}.${key}.${index}`, limits, state, depth + 1));
|
|
164
199
|
fragments.push(`(${children.map((child) => child.sql).join(key === '_and' ? ' AND ' : ' OR ')})`);
|
|
165
200
|
for (const child of children) params.push(...child.params);
|
|
166
201
|
continue;
|
|
@@ -174,7 +209,13 @@ function compileFilterObject(filter, schema, path = 'filter') {
|
|
|
174
209
|
const fieldSql = quoteIdentifier(key, 'field name');
|
|
175
210
|
const fieldFragments = [];
|
|
176
211
|
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
177
|
-
const compiled = compileOperator(
|
|
212
|
+
const compiled = compileOperator(
|
|
213
|
+
fieldSql,
|
|
214
|
+
operator,
|
|
215
|
+
operatorValue,
|
|
216
|
+
`${path}.${key}.${operator}`,
|
|
217
|
+
limits,
|
|
218
|
+
);
|
|
178
219
|
fieldFragments.push(compiled.sql);
|
|
179
220
|
params.push(...compiled.params);
|
|
180
221
|
}
|
|
@@ -186,8 +227,9 @@ function compileFilterObject(filter, schema, path = 'filter') {
|
|
|
186
227
|
return { sql: fragments.join(' AND '), params };
|
|
187
228
|
}
|
|
188
229
|
|
|
189
|
-
export function compileFilter(filter, schema) {
|
|
230
|
+
export function compileFilter(filter, schema, options = {}) {
|
|
190
231
|
if (!filter) return { sql: '', params: [] };
|
|
191
|
-
const
|
|
232
|
+
const limits = { ...QUERY_LIMITS, ...options };
|
|
233
|
+
const compiled = compileFilterObject(filter, schema, 'filter', limits, { nodes: 0 }, 1);
|
|
192
234
|
return { sql: ` WHERE ${compiled.sql}`, params: compiled.params };
|
|
193
235
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { assertIdentifier } from './identifier.js';
|
|
2
|
+
import { QUERY_LIMITS } from './query.js';
|
|
2
3
|
import { SchemaCache } from './schema.js';
|
|
3
4
|
import { ItemsService } from './services/items-service.js';
|
|
4
5
|
|
|
5
|
-
const MAX_EXPAND_FIELDS =
|
|
6
|
+
export const MAX_EXPAND_FIELDS = QUERY_LIMITS.maxRelationExpansions;
|
|
6
7
|
const defaultSchemaCache = new SchemaCache();
|
|
7
8
|
|
|
8
9
|
function expansionError(code, message, path = null) {
|
|
@@ -18,16 +19,24 @@ function normalizeDelimited(value) {
|
|
|
18
19
|
return values.map((entry) => String(entry).trim()).filter(Boolean);
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
function assertFieldToken(field, path) {
|
|
23
|
+
try {
|
|
24
|
+
assertIdentifier(field, 'field');
|
|
25
|
+
} catch {
|
|
26
|
+
throw expansionError('INVALID_QUERY', `Invalid field: ${field}`, path);
|
|
27
|
+
}
|
|
28
|
+
return field;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
export function parseExpandInput(value) {
|
|
22
32
|
const fields = [...new Set(normalizeDelimited(value))];
|
|
23
33
|
if (fields.length > MAX_EXPAND_FIELDS) {
|
|
24
34
|
throw expansionError(
|
|
25
35
|
'INVALID_QUERY',
|
|
26
|
-
`expand
|
|
36
|
+
`expand cannot contain more than ${MAX_EXPAND_FIELDS} entries`,
|
|
27
37
|
'expand',
|
|
28
38
|
);
|
|
29
39
|
}
|
|
30
|
-
|
|
31
40
|
for (const field of fields) {
|
|
32
41
|
try {
|
|
33
42
|
assertIdentifier(field, 'expand field');
|
|
@@ -47,73 +56,199 @@ function parseRelationMetadata(value) {
|
|
|
47
56
|
}
|
|
48
57
|
}
|
|
49
58
|
|
|
50
|
-
function withExpansionFields(rawFields, expandFields) {
|
|
51
|
-
if (rawFields == null || rawFields === '') return rawFields;
|
|
52
|
-
const selected = normalizeDelimited(rawFields);
|
|
53
|
-
if (selected.includes('*')) return selected;
|
|
54
|
-
return [...new Set([...selected, ...expandFields])];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function withoutExpand(query = {}, expandFields = []) {
|
|
58
|
-
const base = { ...query };
|
|
59
|
-
delete base.expand;
|
|
60
|
-
if (expandFields.length > 0 && Object.hasOwn(base, 'fields')) {
|
|
61
|
-
base.fields = withExpansionFields(base.fields, expandFields);
|
|
62
|
-
}
|
|
63
|
-
return base;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
59
|
async function schemaSnapshot(options) {
|
|
67
60
|
if (options.schema) return options.schema;
|
|
68
61
|
const cache = options.schemaCache ?? defaultSchemaCache;
|
|
69
62
|
return cache.get(options.database);
|
|
70
63
|
}
|
|
71
64
|
|
|
72
|
-
function
|
|
73
|
-
|
|
65
|
+
function relationFromSnapshot(snapshot, collection, field) {
|
|
66
|
+
return snapshot.relationByManyField?.get(`${collection}.${field}`) ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isDirectRelation(relation) {
|
|
70
|
+
if (!relation) return false;
|
|
71
|
+
const metadata = parseRelationMetadata(relation.metadata);
|
|
72
|
+
return !relation.junction_collection && metadata.kind !== 'm2m';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function directRelation(snapshot, collection, field, path = `fields.${field}`) {
|
|
76
|
+
const relation = relationFromSnapshot(snapshot, collection, field);
|
|
74
77
|
if (!relation) {
|
|
75
78
|
throw expansionError(
|
|
76
79
|
'INVALID_QUERY',
|
|
77
80
|
`Field is not a direct relation and cannot be expanded: ${collection}.${field}`,
|
|
78
|
-
|
|
81
|
+
path,
|
|
79
82
|
);
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
if (relation.junction_collection || metadata.kind === 'm2m') {
|
|
85
|
+
if (!isDirectRelation(relation)) {
|
|
84
86
|
throw expansionError(
|
|
85
87
|
'UNSUPPORTED_RELATION_EXPANSION',
|
|
86
|
-
`Only direct M2O fields can be expanded
|
|
87
|
-
|
|
88
|
+
`Only direct M2O/O2O fields can be expanded: ${collection}.${field}`,
|
|
89
|
+
path,
|
|
88
90
|
);
|
|
89
91
|
}
|
|
90
92
|
return relation;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
|
|
95
|
+
function readableSourceField(sourceSchema, permission, field) {
|
|
96
|
+
return Boolean(sourceSchema.fields?.[field]
|
|
97
|
+
&& (!permission.fields || permission.fields.includes(field)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function directReadableRelationFields(snapshot, collection, sourceSchema, permission) {
|
|
101
|
+
const prefix = `${collection}.`;
|
|
102
|
+
const fields = [];
|
|
103
|
+
for (const [key, relation] of snapshot.relationByManyField?.entries?.() ?? []) {
|
|
104
|
+
if (!key.startsWith(prefix) || !isDirectRelation(relation)) continue;
|
|
105
|
+
const field = key.slice(prefix.length);
|
|
106
|
+
if (readableSourceField(sourceSchema, permission, field)) fields.push(field);
|
|
107
|
+
}
|
|
108
|
+
return [...new Set(fields)];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mergeExpansionSelection(expansions, relationField, targetField) {
|
|
112
|
+
const current = expansions.get(relationField) ?? [];
|
|
113
|
+
if (current.includes('*')) return;
|
|
114
|
+
if (targetField === '*') {
|
|
115
|
+
expansions.set(relationField, ['*']);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
expansions.set(relationField, [...new Set([...current, targetField])]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function assertExpansionLimit(expansions, path = 'fields') {
|
|
122
|
+
if (expansions.size > MAX_EXPAND_FIELDS) {
|
|
123
|
+
throw expansionError(
|
|
124
|
+
'INVALID_QUERY',
|
|
125
|
+
`Relation expansion cannot contain more than ${MAX_EXPAND_FIELDS} fields`,
|
|
126
|
+
path,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseFieldsPlan({ value, snapshot, collection, sourceSchema, permission }) {
|
|
132
|
+
const tokens = normalizeDelimited(value);
|
|
133
|
+
if (tokens.length === 0) {
|
|
134
|
+
return { sourceFields: null, expansions: new Map() };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let sourceAll = false;
|
|
138
|
+
const sourceFields = [];
|
|
139
|
+
const expansions = new Map();
|
|
140
|
+
|
|
141
|
+
for (const token of tokens) {
|
|
142
|
+
if (token === '*') {
|
|
143
|
+
sourceAll = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (token === '*.*') {
|
|
148
|
+
sourceAll = true;
|
|
149
|
+
for (const relationField of directReadableRelationFields(
|
|
150
|
+
snapshot,
|
|
151
|
+
collection,
|
|
152
|
+
sourceSchema,
|
|
153
|
+
permission,
|
|
154
|
+
)) {
|
|
155
|
+
mergeExpansionSelection(expansions, relationField, '*');
|
|
156
|
+
}
|
|
157
|
+
assertExpansionLimit(expansions);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!token.includes('.')) {
|
|
162
|
+
sourceFields.push(assertFieldToken(token, `fields.${token}`));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const parts = token.split('.');
|
|
167
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
168
|
+
throw expansionError(
|
|
169
|
+
'UNSUPPORTED_RELATION_EXPANSION',
|
|
170
|
+
`Only one relation depth is supported in fields: ${token}`,
|
|
171
|
+
`fields.${token}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const relationField = assertFieldToken(parts[0], `fields.${token}`);
|
|
176
|
+
const targetField = parts[1] === '*'
|
|
177
|
+
? '*'
|
|
178
|
+
: assertFieldToken(parts[1], `fields.${token}`);
|
|
179
|
+
|
|
180
|
+
if (!readableSourceField(sourceSchema, permission, relationField)) {
|
|
181
|
+
throw expansionError('INVALID_QUERY', `Unknown field: ${relationField}`, `fields.${token}`);
|
|
182
|
+
}
|
|
183
|
+
directRelation(snapshot, collection, relationField, `fields.${token}`);
|
|
184
|
+
sourceFields.push(relationField);
|
|
185
|
+
mergeExpansionSelection(expansions, relationField, targetField);
|
|
186
|
+
assertExpansionLimit(expansions);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
sourceFields: sourceAll ? ['*'] : [...new Set(sourceFields)],
|
|
191
|
+
expansions,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function addLegacyExpansions({ query, plan, snapshot, collection, sourceSchema, permission }) {
|
|
196
|
+
for (const field of parseExpandInput(query.expand)) {
|
|
197
|
+
if (!readableSourceField(sourceSchema, permission, field)) {
|
|
198
|
+
throw expansionError('INVALID_QUERY', `Unknown field: ${field}`, `expand.${field}`);
|
|
199
|
+
}
|
|
200
|
+
directRelation(snapshot, collection, field, `expand.${field}`);
|
|
201
|
+
mergeExpansionSelection(plan.expansions, field, '*');
|
|
202
|
+
assertExpansionLimit(plan.expansions, 'expand');
|
|
203
|
+
if (plan.sourceFields && !plan.sourceFields.includes('*') && !plan.sourceFields.includes(field)) {
|
|
204
|
+
plan.sourceFields.push(field);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return plan;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function buildSelectionPlan({ collection, query, options, service }) {
|
|
94
211
|
const snapshot = await schemaSnapshot(options);
|
|
95
212
|
const sourceSchema = snapshot.collections?.[collection];
|
|
96
213
|
if (!sourceSchema) {
|
|
97
214
|
throw expansionError('COLLECTION_NOT_FOUND', `Unknown collection: ${collection}`);
|
|
98
215
|
}
|
|
99
|
-
if (expandFields.length === 0) return snapshot;
|
|
100
216
|
|
|
101
217
|
const permission = await service.resolvePermission('read');
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
218
|
+
const plan = parseFieldsPlan({
|
|
219
|
+
value: query.fields,
|
|
220
|
+
snapshot,
|
|
221
|
+
collection,
|
|
222
|
+
sourceSchema,
|
|
223
|
+
permission,
|
|
224
|
+
});
|
|
225
|
+
addLegacyExpansions({ query, plan, snapshot, collection, sourceSchema, permission });
|
|
226
|
+
return { ...plan, snapshot };
|
|
109
227
|
}
|
|
110
228
|
|
|
111
|
-
|
|
112
|
-
|
|
229
|
+
function baseQuery(query, sourceFields) {
|
|
230
|
+
const base = { ...query };
|
|
231
|
+
delete base.expand;
|
|
232
|
+
if (sourceFields == null) delete base.fields;
|
|
233
|
+
else base.fields = sourceFields;
|
|
234
|
+
return base;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function projectTarget(target, selection, visibleFields) {
|
|
238
|
+
const projectedFields = selection.includes('*') ? visibleFields : selection;
|
|
239
|
+
return Object.fromEntries(
|
|
240
|
+
projectedFields
|
|
241
|
+
.filter((field) => Object.hasOwn(target, field))
|
|
242
|
+
.map((field) => [field, target[field]]),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function expandRows({ collection, rows, expansions, options, ItemsServiceClass, snapshot }) {
|
|
247
|
+
if (expansions.size === 0 || rows.length === 0) return rows;
|
|
113
248
|
const effectiveSnapshot = snapshot ?? await schemaSnapshot(options);
|
|
114
249
|
let expandedRows = rows.map((row) => ({ ...row }));
|
|
115
250
|
|
|
116
|
-
for (const field of
|
|
251
|
+
for (const [field, selection] of expansions) {
|
|
117
252
|
const relation = directRelation(effectiveSnapshot, collection, field);
|
|
118
253
|
const targetCollection = relation.one_collection;
|
|
119
254
|
const targetKey = relation.one_field
|
|
@@ -132,21 +267,25 @@ async function expandRows({ collection, rows, expandFields, options, ItemsServic
|
|
|
132
267
|
}
|
|
133
268
|
|
|
134
269
|
const targetService = new ItemsServiceClass(targetCollection, options);
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
270
|
+
const targetResult = await targetService.readManyForRelation({
|
|
271
|
+
fields: selection,
|
|
272
|
+
lookupField: targetKey,
|
|
273
|
+
values,
|
|
138
274
|
});
|
|
139
275
|
|
|
140
276
|
const byKey = new Map();
|
|
141
|
-
for (const target of
|
|
277
|
+
for (const target of targetResult.data) {
|
|
142
278
|
if (!Object.hasOwn(target, targetKey)) {
|
|
143
279
|
throw expansionError(
|
|
144
280
|
'FORBIDDEN_FIELD',
|
|
145
281
|
`Expanded relation key is not readable: ${targetCollection}.${targetKey}`,
|
|
146
|
-
`
|
|
282
|
+
`fields.${field}`,
|
|
147
283
|
);
|
|
148
284
|
}
|
|
149
|
-
byKey.set(
|
|
285
|
+
byKey.set(
|
|
286
|
+
String(target[targetKey]),
|
|
287
|
+
projectTarget(target, selection, targetResult.visibleFields),
|
|
288
|
+
);
|
|
150
289
|
}
|
|
151
290
|
|
|
152
291
|
expandedRows = expandedRows.map((row) => ({
|
|
@@ -167,17 +306,16 @@ export async function readManyWithRelations({
|
|
|
167
306
|
ItemsServiceClass = ItemsService,
|
|
168
307
|
} = {}) {
|
|
169
308
|
assertIdentifier(collection, 'collection name');
|
|
170
|
-
const expandFields = parseExpandInput(query.expand);
|
|
171
309
|
const service = new ItemsServiceClass(collection, options);
|
|
172
|
-
const
|
|
173
|
-
const result = await service.readManyWithMeta(
|
|
310
|
+
const plan = await buildSelectionPlan({ collection, query, options, service });
|
|
311
|
+
const result = await service.readManyWithMeta(baseQuery(query, plan.sourceFields));
|
|
174
312
|
const data = await expandRows({
|
|
175
313
|
collection,
|
|
176
314
|
rows: result.data,
|
|
177
|
-
|
|
315
|
+
expansions: plan.expansions,
|
|
178
316
|
options,
|
|
179
317
|
ItemsServiceClass,
|
|
180
|
-
snapshot,
|
|
318
|
+
snapshot: plan.snapshot,
|
|
181
319
|
});
|
|
182
320
|
return { ...result, data };
|
|
183
321
|
}
|
|
@@ -196,21 +334,17 @@ export async function readOneWithRelations({
|
|
|
196
334
|
}
|
|
197
335
|
}
|
|
198
336
|
|
|
199
|
-
const expandFields = parseExpandInput(query.expand);
|
|
200
337
|
const service = new ItemsServiceClass(collection, options);
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
const record = await service.readOne(id, { fields });
|
|
338
|
+
const plan = await buildSelectionPlan({ collection, query, options, service });
|
|
339
|
+
const record = await service.readOne(id, { fields: plan.sourceFields });
|
|
204
340
|
if (!record) return null;
|
|
205
341
|
const [expanded] = await expandRows({
|
|
206
342
|
collection,
|
|
207
343
|
rows: [record],
|
|
208
|
-
|
|
344
|
+
expansions: plan.expansions,
|
|
209
345
|
options,
|
|
210
346
|
ItemsServiceClass,
|
|
211
|
-
snapshot,
|
|
347
|
+
snapshot: plan.snapshot,
|
|
212
348
|
});
|
|
213
349
|
return expanded;
|
|
214
350
|
}
|
|
215
|
-
|
|
216
|
-
export { MAX_EXPAND_FIELDS };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const TURKISH_ASCII = Object.freeze({
|
|
2
|
+
ç: 'c',
|
|
3
|
+
Ç: 'c',
|
|
4
|
+
ğ: 'g',
|
|
5
|
+
Ğ: 'g',
|
|
6
|
+
ı: 'i',
|
|
7
|
+
İ: 'i',
|
|
8
|
+
ö: 'o',
|
|
9
|
+
Ö: 'o',
|
|
10
|
+
ş: 's',
|
|
11
|
+
Ş: 's',
|
|
12
|
+
ü: 'u',
|
|
13
|
+
Ü: 'u',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function schemaNameError(message) {
|
|
17
|
+
const error = new Error(message);
|
|
18
|
+
error.code = 'INVALID_SCHEMA_NAME';
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeDisplayName(value, { fallback = null, maxLength = 255 } = {}) {
|
|
23
|
+
const candidate = typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : '';
|
|
24
|
+
const normalized = candidate || (typeof fallback === 'string' ? fallback.trim().replace(/\s+/g, ' ') : '');
|
|
25
|
+
if (!normalized) throw schemaNameError('A display name is required');
|
|
26
|
+
if (normalized.length > maxLength) throw schemaNameError(`Display name cannot exceed ${maxLength} characters`);
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeSchemaKey(value, { prefix = 'field', maxLength = 64 } = {}) {
|
|
31
|
+
const source = normalizeDisplayName(value, { maxLength: 512 });
|
|
32
|
+
const transliterated = [...source].map((character) => TURKISH_ASCII[character] ?? character).join('');
|
|
33
|
+
let key = transliterated
|
|
34
|
+
.normalize('NFKD')
|
|
35
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
38
|
+
.replace(/^_+|_+$/g, '')
|
|
39
|
+
.replace(/_+/g, '_');
|
|
40
|
+
|
|
41
|
+
if (!key) throw schemaNameError('Name must contain at least one letter or number');
|
|
42
|
+
if (/^[0-9]/.test(key)) key = `${prefix}_${key}`;
|
|
43
|
+
if (key.length > maxLength) key = key.slice(0, maxLength).replace(/_+$/g, '');
|
|
44
|
+
if (!key) throw schemaNameError('Name cannot be normalized to a schema key');
|
|
45
|
+
return key;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveSchemaName({ displayName, key, prefix = 'field' } = {}) {
|
|
49
|
+
const name = normalizeDisplayName(displayName ?? key);
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
key: normalizeSchemaKey(key ?? name, { prefix }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -12,7 +12,7 @@ export class SchemaMetadataRepository {
|
|
|
12
12
|
|
|
13
13
|
async listCollections() {
|
|
14
14
|
const [rows] = await this.database.query(
|
|
15
|
-
`SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
|
|
15
|
+
`SELECT collection, name, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
|
|
16
16
|
FROM yuncms_collections
|
|
17
17
|
ORDER BY collection ASC`,
|
|
18
18
|
);
|
|
@@ -21,7 +21,7 @@ export class SchemaMetadataRepository {
|
|
|
21
21
|
|
|
22
22
|
async readCollection(collection) {
|
|
23
23
|
const [rows] = await this.database.query(
|
|
24
|
-
`SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
|
|
24
|
+
`SELECT collection, name, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
|
|
25
25
|
FROM yuncms_collections
|
|
26
26
|
WHERE collection = ?
|
|
27
27
|
LIMIT 1`,
|
|
@@ -32,6 +32,7 @@ export class SchemaMetadataRepository {
|
|
|
32
32
|
|
|
33
33
|
async createCollection({
|
|
34
34
|
collection,
|
|
35
|
+
name = collection,
|
|
35
36
|
primaryKey = 'id',
|
|
36
37
|
note = null,
|
|
37
38
|
singleton = false,
|
|
@@ -41,10 +42,11 @@ export class SchemaMetadataRepository {
|
|
|
41
42
|
}) {
|
|
42
43
|
await this.database.query(
|
|
43
44
|
`INSERT INTO yuncms_collections
|
|
44
|
-
(collection, primary_key, note, singleton, hidden, \`system\`, metadata)
|
|
45
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
45
|
+
(collection, name, primary_key, note, singleton, hidden, \`system\`, metadata)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
46
47
|
[
|
|
47
48
|
collection,
|
|
49
|
+
name,
|
|
48
50
|
primaryKey,
|
|
49
51
|
note,
|
|
50
52
|
singleton ? 1 : 0,
|
|
@@ -60,6 +62,10 @@ export class SchemaMetadataRepository {
|
|
|
60
62
|
const assignments = [];
|
|
61
63
|
const params = [];
|
|
62
64
|
|
|
65
|
+
if (Object.hasOwn(patch, 'name')) {
|
|
66
|
+
assignments.push('name = ?');
|
|
67
|
+
params.push(patch.name);
|
|
68
|
+
}
|
|
63
69
|
if (Object.hasOwn(patch, 'note')) {
|
|
64
70
|
assignments.push('note = ?');
|
|
65
71
|
params.push(patch.note ?? null);
|
|
@@ -96,7 +102,7 @@ export class SchemaMetadataRepository {
|
|
|
96
102
|
|
|
97
103
|
async listFields(collection) {
|
|
98
104
|
const [rows] = await this.database.query(
|
|
99
|
-
`SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
|
|
105
|
+
`SELECT id, collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata,
|
|
100
106
|
created_at, updated_at
|
|
101
107
|
FROM yuncms_fields
|
|
102
108
|
WHERE collection = ?
|
|
@@ -108,7 +114,7 @@ export class SchemaMetadataRepository {
|
|
|
108
114
|
|
|
109
115
|
async readField(collection, field) {
|
|
110
116
|
const [rows] = await this.database.query(
|
|
111
|
-
`SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
|
|
117
|
+
`SELECT id, collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata,
|
|
112
118
|
created_at, updated_at
|
|
113
119
|
FROM yuncms_fields
|
|
114
120
|
WHERE collection = ? AND field = ?
|
|
@@ -121,6 +127,7 @@ export class SchemaMetadataRepository {
|
|
|
121
127
|
async createField({
|
|
122
128
|
collection,
|
|
123
129
|
field,
|
|
130
|
+
name = field,
|
|
124
131
|
type,
|
|
125
132
|
required = false,
|
|
126
133
|
readonly = false,
|
|
@@ -137,11 +144,12 @@ export class SchemaMetadataRepository {
|
|
|
137
144
|
}
|
|
138
145
|
await this.database.query(
|
|
139
146
|
`INSERT INTO yuncms_fields
|
|
140
|
-
(collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata)
|
|
141
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
147
|
+
(collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata)
|
|
148
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
142
149
|
[
|
|
143
150
|
collection,
|
|
144
151
|
field,
|
|
152
|
+
name,
|
|
145
153
|
type,
|
|
146
154
|
required ? 1 : 0,
|
|
147
155
|
readonly ? 1 : 0,
|
|
@@ -169,6 +177,10 @@ export class SchemaMetadataRepository {
|
|
|
169
177
|
}
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
if (Object.hasOwn(patch, 'name')) {
|
|
181
|
+
assignments.push('name = ?');
|
|
182
|
+
params.push(patch.name);
|
|
183
|
+
}
|
|
172
184
|
if (Object.hasOwn(patch, 'readonly')) {
|
|
173
185
|
assignments.push('readonly = ?');
|
|
174
186
|
params.push(patch.readonly ? 1 : 0);
|