Repository navigation
feat: hooks for derived and checked data (generated, unique, check, aggregate, denormalize, …), query utils and testing helpers - #67
Open
fratzinger wants to merge 2 commits into
Conversation
…ted` and testing helpers New: - hooks: `generated` (fields computed from other fields of the item) and `unique` (checks like a unique index, with `where` and `nullsNotDistinct`) - utils: `getMergedData`, `findAll`, `getIdField` - predicates: `isSelected` - testing: `runHook`, `expectNoSideEffects`, `deepFreeze` Fixes: - `checkContext` only reports the criteria that did not match - `findOrCreate` finds the record of a service that paginates despite `paginate: false` - `changeMany`, `zipDataResult` and `trigger` respect `service.options.id` - `isEqual` tells `new Array(2)` from `[]` Internal: - the fetches of a call are shared via `call-fetches.ts`, also between `defineStash` and `getMergedData`, and reuse fetches with a broader `$select` - path and query helpers in `common`, `isDeepEqual` next to `isEqual` - the "does not mutate" tests of hooks and resolvers run through `runHook` - CLAUDE.md: a performance section Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deploying feathers-utils with
|
| Latest commit: |
50c6f76
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4c5f2314.feathers-utils.pages.dev |
| Branch Preview URL: | https://feat-generated-unique-testin.feathers-utils.pages.dev |
…normalize` and `getChanges` New: - hooks: `check` (a CHECK constraint across fields), `restrictQuery` (fields and operators external callers may query by), `skipUnchanged` (drops unchanged patch fields, skips no-op writes), `aggregate` (values on parent items computed from their children) and `denormalize` (fields copied from a related item, kept in sync) - utils: `getChanges` - `getMergedData`, `generated`, `unique` and `check` take `select: false` to fetch whole items, which later hooks of the call reuse Changed: - `preventChanges` compares with the stored item on `update` - and restores a changed field instead of deleting it - and on `patch` with `compare` - `check`, `unique` and `generated` compare items by content (`isUnchanged`), so an unchanged bson `ObjectId` counts as unchanged Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generated, unique, getMergedData, findAll, isSelected and testing helpersgenerated, unique, check, aggregate, denormalize, …), query utils and testing helpers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hooks for derived and checked data, built on a shared way of reading the items of a call (
getMergedData), plus query utils and testing helpers.New
Hooks
generated: fields computed from other fields of the same item, like a generated column.generate(items, context)computes all items of a call at once and may be async. Apatchrecomputes only when it changes afromfield; missing fields are read withgetMergedData. If a multi patch gives the items different values, they are written after the call with one_patchper group of equal values (patchBatch). Use asaround, or asbefore+after.unique: checks like a unique index. Severalfields,whereas a partial index, andnullsNotDistinct. It checks the items of the call among themselves and against the stored items with a single query. By default it uses_find, so hooks offindcan't hide conflicts (underscore: falseswitches tofind). Items whose values don't change are skipped.errorreplaces theConflict.check: aCHECKconstraint across fields, e.g.startDate <= endDate. Apatchof onlyendDateis checked against the storedstartDate, which a validator ofdatacan't do.restrictQuery({ fields }): the fields external callers may filter, sort and select by, and the operators allowed on each. Covers$and/$or/$nor,$sort,$selectand nested paths. Without it, a client can filter by{ password: { $regex: '^a' } }.skipUnchanged: drops the fields of a patch that keep their stored value. If nothing changes, the write is skipped: the result is the stored items and nopatchedevent is sent.ignore: ['updatedAt']keeps timestamps from counting as a change.aggregate: values on parent items computed from their children, likeposts.commentCount.compute(parentIds, context)gets every parent the call touched, before and after it, so a move updates both. Parents with equal values share a write.denormalize: copies fields from a related item and keeps them in sync, likeposts.authorNamefromusers.name. It returns atargethook (ageneratedhook, with one query per call) and asourcehook (onepatchManyper changed source item, through the target's hooks and with events). Several definitions work side by side on one target.Utils
getMergedData(context, fields, { select })returns the given fields of every item a call writes, with the values the item has once the call is done, plusstoredwhen they were fetched. Only the needed fields are selected (select: falsefetches whole items), and the fetch is shared withstash,triggerand the hooks above.findAll(app, path, params, { underscore })returns all items as an array, whether or not the service paginates.getChanges(before, after, fields?)returns the fields that differ, as{ [path]: { before, after } }.getIdField(service)Predicates
isSelected(fields): whether$selectcovers the fields. The id field always counts as selected;findwith$limit: 0selects nothing.Testing
runHook(hook, options)runs a single hook through Feathers on a stub service (or a givenservice/app) and returns the context.expectNoSideEffectsanddeepFreeze, moved fromtest/utilsand made independent of the test framework.Changed
preventChangescompares with the stored item:updateit always compares. A changed field gets its stored value back (or the hook throws witherror). Before, it deleted the field, becauseupdatereplaces the whole item, and witherrorit always threw.patch, the comparison is opt-in withcompare: true.Fixes
checkContextlisted every given criterion in its error, not just those that did not match.findOrCreatetreated a page from a service that paginates despitepaginate: falseas empty and created a duplicate.changeMany(patchMany/removeMany),zipDataResultandtriggernow also respectservice.options.id.isEqualcomparednew Array(2)and[]as equal.Internal
call-fetches.tsholds the fetches of a call, shared bydefineStash,getMergedDataand the hooks above. A fetch is reused when its params differ only by a broader (or no)$select.defineStashbehaves as before.common: path helpers inpaths.ts,selectCovers,hasAllOwnProperties,findAllOf, andisDeepEqual/isUnchangednext toisEqual. Items count as unchanged by content, so an unchanged bsonObjectIdcounts as unchanged too.runHook.Review notes
$-prefixed keys in patch data ($inc, …) are treated as plain keys, because their meaning depends on the adapter.getMergedDatathrows for a dotted patch key that overlaps a requested field.generated, plusaggregateanddenormalize) are not atomic with the call unless a transaction is passed inparams.uniquecan't rule out races between concurrent calls; only a database index can.preventChangeswithoutcomparestill turns{ role: { type } }into{ role: {} }whenrole.typeis protected. This is existing behavior with an existing test, so I left it unchanged; it's worth a follow-up.Testing
pnpm test(lint, typecheck, coverage) passes: 173 test files, 2100 tests, coverage 97.8%.pnpm buildpasses.🤖 Generated with Claude Code