@yunsoft/yuncms-core 0.1.5 → 0.1.7

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.
@@ -3,7 +3,10 @@ import { randomUUID } from 'node:crypto';
3
3
  import { assertIdentifier, quoteIdentifier } from '../identifier.js';
4
4
  import { enforcePermissionValidation } from '../permission-validation.js';
5
5
  import {
6
+ assertQueryCost,
7
+ compileAggregate,
6
8
  compileFilter,
9
+ compileSearch,
7
10
  compileSelectFields,
8
11
  compileSort,
9
12
  parseItemsQuery,
@@ -176,10 +179,37 @@ export class ItemsService extends BaseService {
176
179
  return entries;
177
180
  }
178
181
 
179
- compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema) {
182
+ compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema, search = null) {
180
183
  const permissionSql = compileFilter(permissionFilter, fullSchema);
181
184
  const userSql = compileFilter(userFilter, userSchema);
182
- return combineCompiledFilters(permissionSql, userSql);
185
+ const searchSql = compileSearch(search, userSchema);
186
+ return combineCompiledFilters(permissionSql, userSql, searchSql);
187
+ }
188
+
189
+ async normalizeReadQuery(rawQuery) {
190
+ const parsed = parseItemsQuery(rawQuery);
191
+ const filtered = this.emitter
192
+ ? await this.emitter.filter('items.query', parsed, this.hookContext({ operation: 'read' }))
193
+ : parsed;
194
+ const query = parseItemsQuery(filtered);
195
+ assertQueryCost(query);
196
+ return query;
197
+ }
198
+
199
+ async emitReadAction(query, rows, schema, { single = false } = {}) {
200
+ if (!this.emitter) return;
201
+ const primaryKey = schema.primary_key;
202
+ const keys = rows
203
+ .filter((row) => row && Object.hasOwn(row, primaryKey))
204
+ .map((row) => row[primaryKey])
205
+ .slice(0, QUERY_LIMITS.maxLimit);
206
+ await this.emitter.action('items.read', {
207
+ collection: this.collection,
208
+ query,
209
+ keys,
210
+ count: rows.length,
211
+ single,
212
+ }, this.hookContext({ operation: 'read' }));
183
213
  }
184
214
 
185
215
  async readMany(rawQuery = {}) {
@@ -188,15 +218,34 @@ export class ItemsService extends BaseService {
188
218
 
189
219
  async readManyWithMeta(rawQuery = {}) {
190
220
  const schema = await this.getCollectionSchema();
221
+ const query = await this.normalizeReadQuery(rawQuery);
191
222
  const permission = await this.resolvePermission('read');
192
223
  const accessSchema = schemaForFields(schema, permission.fields);
193
- const query = parseItemsQuery(rawQuery);
224
+ const filter = this.compileActionFilters(query.filter, permission.filter, accessSchema, schema, query.search);
225
+ const table = quoteIdentifier(this.collection, 'collection name');
226
+ const aggregate = compileAggregate(query.aggregate, query.groupBy, accessSchema);
227
+
228
+ if (aggregate) {
229
+ const sortSql = compileSort(query.sort, accessSchema);
230
+ const [rows] = await this.database.query(
231
+ `SELECT ${aggregate.sql} FROM ${table}${filter.sql}${aggregate.groupSql}${sortSql} LIMIT ? OFFSET ?`,
232
+ [...filter.params, query.limit, query.offset],
233
+ );
234
+ await this.emitReadAction(query, rows, schema);
235
+ return {
236
+ data: rows,
237
+ meta: {
238
+ total_count: rows.length,
239
+ limit: query.limit,
240
+ offset: query.offset,
241
+ aggregate: true,
242
+ },
243
+ };
244
+ }
245
+
194
246
  const requestedFields = normalizeFields(query.fields);
195
247
  const selected = compileSelectFields(requestedFields, accessSchema);
196
- const filter = this.compileActionFilters(query.filter, permission.filter, accessSchema, schema);
197
248
  const sortSql = compileSort(query.sort, accessSchema);
198
- const table = quoteIdentifier(this.collection, 'collection name');
199
-
200
249
  const [rows] = await this.database.query(
201
250
  `SELECT ${selected.sql} FROM ${table}${filter.sql}${sortSql} LIMIT ? OFFSET ?`,
202
251
  [...filter.params, query.limit, query.offset],
@@ -205,6 +254,7 @@ export class ItemsService extends BaseService {
205
254
  `SELECT COUNT(*) AS total_count FROM ${table}${filter.sql}`,
206
255
  filter.params,
207
256
  );
257
+ await this.emitReadAction(query, rows, schema);
208
258
 
209
259
  return {
210
260
  data: rows,
@@ -220,11 +270,7 @@ export class ItemsService extends BaseService {
220
270
  const schema = await this.getCollectionSchema();
221
271
  const trustedLookupField = assertIdentifier(lookupField, 'relation lookup field');
222
272
  if (!schema.fields[trustedLookupField]) {
223
- throw serviceError(
224
- 'INVALID_QUERY',
225
- `Unknown relation lookup field: ${trustedLookupField}`,
226
- trustedLookupField,
227
- );
273
+ throw serviceError('INVALID_QUERY', `Unknown relation lookup field: ${trustedLookupField}`, trustedLookupField);
228
274
  }
229
275
  if (!Array.isArray(values) || values.length === 0 || values.length > QUERY_LIMITS.maxLimit) {
230
276
  throw serviceError(
@@ -239,15 +285,9 @@ export class ItemsService extends BaseService {
239
285
  const visibleSelection = compileSelectFields(normalizeFields(fields), accessSchema);
240
286
  const internalSchema = {
241
287
  ...accessSchema,
242
- fields: {
243
- ...accessSchema.fields,
244
- [trustedLookupField]: schema.fields[trustedLookupField],
245
- },
288
+ fields: { ...accessSchema.fields, [trustedLookupField]: schema.fields[trustedLookupField] },
246
289
  };
247
- const internalSelection = compileSelectFields(
248
- [...visibleSelection.fields, trustedLookupField],
249
- internalSchema,
250
- );
290
+ const internalSelection = compileSelectFields([...visibleSelection.fields, trustedLookupField], internalSchema);
251
291
  const permissionFilter = compileFilter(permission.filter, schema);
252
292
  const table = quoteIdentifier(this.collection, 'collection name');
253
293
  const data = [];
@@ -283,7 +323,9 @@ export class ItemsService extends BaseService {
283
323
  `SELECT ${selected.sql} FROM ${table}${filter.sql} LIMIT 1`,
284
324
  filter.params,
285
325
  );
286
- return rows[0] ?? null;
326
+ const record = rows[0] ?? null;
327
+ if (record) await this.emitReadAction({ fields: normalizeFields(fields) }, [record], schema, { single: true });
328
+ return record;
287
329
  }
288
330
 
289
331
  async returnCreatedOrUpdated(id, schema) {
@@ -308,10 +350,8 @@ export class ItemsService extends BaseService {
308
350
  const values = { [schema.primary_key]: id, ...Object.fromEntries(entries) };
309
351
  const fields = Object.keys(values);
310
352
  const table = quoteIdentifier(this.collection, 'collection name');
311
-
312
353
  await this.database.query(
313
- `INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})
314
- VALUES (${fields.map(() => '?').join(', ')})`,
354
+ `INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
315
355
  fields.map((field) => values[field]),
316
356
  );
317
357
 
@@ -330,29 +370,21 @@ export class ItemsService extends BaseService {
330
370
  const staged = [];
331
371
 
332
372
  for (const payload of payloads) {
333
- const filteredPayload = await this.filterMutation('items.create', payload, {
334
- operation: 'create',
335
- bulk: true,
336
- });
373
+ const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create', bulk: true });
337
374
  const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
338
375
  const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
339
376
  const id = randomUUID();
340
377
  const candidate = createCandidateRecord(schema, id, entries);
341
378
  enforcePermissionValidation(candidate, permission.validation, schema);
342
- staged.push({
343
- id,
344
- values: { [schema.primary_key]: id, ...Object.fromEntries(entries) },
345
- });
379
+ staged.push({ id, values: { [schema.primary_key]: id, ...Object.fromEntries(entries) } });
346
380
  }
347
381
 
348
382
  await withTransaction(this.database, async (connection) => {
349
383
  const table = quoteIdentifier(this.collection, 'collection name');
350
-
351
384
  for (const entry of staged) {
352
385
  const fields = Object.keys(entry.values);
353
386
  await connection.query(
354
- `INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})
355
- VALUES (${fields.map(() => '?').join(', ')})`,
387
+ `INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
356
388
  fields.map((field) => entry.values[field]),
357
389
  );
358
390
  }
@@ -362,10 +394,7 @@ export class ItemsService extends BaseService {
362
394
  for (const entry of staged) {
363
395
  const record = await this.returnCreatedOrUpdated(entry.id, schema);
364
396
  records.push(record);
365
- await this.actionMutation('items.create', { key: entry.id, item: record }, {
366
- operation: 'create',
367
- bulk: true,
368
- });
397
+ await this.actionMutation('items.create', { key: entry.id, item: record }, { operation: 'create', bulk: true });
369
398
  }
370
399
  return records;
371
400
  }
@@ -373,15 +402,11 @@ export class ItemsService extends BaseService {
373
402
  async updateOne(id, payload = {}) {
374
403
  const schema = await this.getCollectionSchema();
375
404
  const permission = await this.resolvePermission('update');
376
- const filteredPayload = await this.filterMutation('items.update', payload, {
377
- operation: 'update',
378
- key: id,
379
- });
405
+ const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', key: id });
380
406
  const callerEntries = this.validatePayload(filteredPayload, schema, permission);
381
407
  if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
382
408
  const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
383
409
  const effectiveChanges = Object.fromEntries(entries);
384
-
385
410
  const table = quoteIdentifier(this.collection, 'collection name');
386
411
  const filter = combineCompiledFilters(
387
412
  compileFilter(permission.filter, schema),
@@ -389,10 +414,7 @@ export class ItemsService extends BaseService {
389
414
  );
390
415
 
391
416
  if (permission.validation) {
392
- const [currentRows] = await this.database.query(
393
- `SELECT * FROM ${table}${filter.sql} LIMIT 1`,
394
- filter.params,
395
- );
417
+ const [currentRows] = await this.database.query(`SELECT * FROM ${table}${filter.sql} LIMIT 1`, filter.params);
396
418
  const current = currentRows[0];
397
419
  if (!current) return null;
398
420
  enforcePermissionValidation({ ...current, ...effectiveChanges }, permission.validation, schema);
@@ -403,14 +425,9 @@ export class ItemsService extends BaseService {
403
425
  `UPDATE ${table} SET ${setSql}${filter.sql}`,
404
426
  [...entries.map(([, value]) => value), ...filter.params],
405
427
  );
406
-
407
428
  if (result.affectedRows === 0) return null;
408
429
  const record = await this.returnCreatedOrUpdated(id, schema);
409
- await this.actionMutation('items.update', {
410
- key: id,
411
- item: record,
412
- changes: effectiveChanges,
413
- }, { operation: 'update' });
430
+ await this.actionMutation('items.update', { key: id, item: record, changes: effectiveChanges }, { operation: 'update' });
414
431
  return record;
415
432
  }
416
433
 
@@ -422,11 +439,7 @@ export class ItemsService extends BaseService {
422
439
  const schema = await this.getCollectionSchema();
423
440
  const permission = await this.resolvePermission('update');
424
441
  const accessSchema = schemaForFields(schema, permission.fields);
425
- const filteredPayload = await this.filterMutation('items.update', payload, {
426
- operation: 'update',
427
- bulk: true,
428
- filter: filterInput,
429
- });
442
+ const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', bulk: true, filter: filterInput });
430
443
  const callerEntries = this.validatePayload(filteredPayload, schema, permission);
431
444
  if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
432
445
  const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
@@ -440,14 +453,9 @@ export class ItemsService extends BaseService {
440
453
  [...filter.params, MAX_BULK_VALIDATION_ROWS + 1],
441
454
  );
442
455
  if (rows.length > MAX_BULK_VALIDATION_ROWS) {
443
- throw serviceError(
444
- 'VALIDATION_BULK_LIMIT',
445
- `Permission validation can inspect at most ${MAX_BULK_VALIDATION_ROWS} rows per bulk update`,
446
- );
447
- }
448
- for (const row of rows) {
449
- enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
456
+ throw serviceError('VALIDATION_BULK_LIMIT', `Permission validation can inspect at most ${MAX_BULK_VALIDATION_ROWS} rows per bulk update`);
450
457
  }
458
+ for (const row of rows) enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
451
459
  }
452
460
 
453
461
  const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
@@ -455,35 +463,23 @@ export class ItemsService extends BaseService {
455
463
  `UPDATE ${table} SET ${setSql}${filter.sql}`,
456
464
  [...entries.map(([, value]) => value), ...filter.params],
457
465
  );
458
- await this.actionMutation('items.update', {
459
- filter: filterInput,
460
- changes: effectiveChanges,
461
- affected: result.affectedRows,
462
- }, { operation: 'update', bulk: true });
466
+ await this.actionMutation('items.update', { filter: filterInput, changes: effectiveChanges, affected: result.affectedRows }, { operation: 'update', bulk: true });
463
467
  return result.affectedRows;
464
468
  }
465
469
 
466
470
  async deleteOne(id) {
467
471
  const schema = await this.getCollectionSchema();
468
472
  const permission = await this.resolvePermission('delete');
469
- const filtered = await this.filterMutation('items.delete', { key: id }, {
470
- operation: 'delete',
471
- key: id,
472
- });
473
+ const filtered = await this.filterMutation('items.delete', { key: id }, { operation: 'delete', key: id });
473
474
  const key = filtered?.key ?? id;
474
475
  const table = quoteIdentifier(this.collection, 'collection name');
475
476
  const filter = combineCompiledFilters(
476
477
  compileFilter(permission.filter, schema),
477
478
  compileFilter({ [schema.primary_key]: { _eq: key } }, schema),
478
479
  );
479
- const [result] = await this.database.query(
480
- `DELETE FROM ${table}${filter.sql}`,
481
- filter.params,
482
- );
480
+ const [result] = await this.database.query(`DELETE FROM ${table}${filter.sql}`, filter.params);
483
481
  const deleted = result.affectedRows > 0;
484
- if (deleted) {
485
- await this.actionMutation('items.delete', { key }, { operation: 'delete' });
486
- }
482
+ if (deleted) await this.actionMutation('items.delete', { key }, { operation: 'delete' });
487
483
  return deleted;
488
484
  }
489
485
 
@@ -495,24 +491,15 @@ export class ItemsService extends BaseService {
495
491
  const schema = await this.getCollectionSchema();
496
492
  const permission = await this.resolvePermission('delete');
497
493
  const accessSchema = schemaForFields(schema, permission.fields);
498
- const filtered = await this.filterMutation('items.delete', { filter: filterInput }, {
499
- operation: 'delete',
500
- bulk: true,
501
- });
494
+ const filtered = await this.filterMutation('items.delete', { filter: filterInput }, { operation: 'delete', bulk: true });
502
495
  const effectiveFilter = filtered?.filter ?? filterInput;
503
496
  if (!effectiveFilter || typeof effectiveFilter !== 'object' || Array.isArray(effectiveFilter) || Object.keys(effectiveFilter).length === 0) {
504
497
  throw serviceError('FILTER_REQUIRED', 'deleteMany hook result must preserve a non-empty filter');
505
498
  }
506
499
  const filter = this.compileActionFilters(effectiveFilter, permission.filter, accessSchema, schema);
507
500
  const table = quoteIdentifier(this.collection, 'collection name');
508
- const [result] = await this.database.query(
509
- `DELETE FROM ${table}${filter.sql}`,
510
- filter.params,
511
- );
512
- await this.actionMutation('items.delete', {
513
- filter: effectiveFilter,
514
- affected: result.affectedRows,
515
- }, { operation: 'delete', bulk: true });
501
+ const [result] = await this.database.query(`DELETE FROM ${table}${filter.sql}`, filter.params);
502
+ await this.actionMutation('items.delete', { filter: effectiveFilter, affected: result.affectedRows }, { operation: 'delete', bulk: true });
516
503
  return result.affectedRows;
517
504
  }
518
505
  }