diff --git a/doc/api/model/static-methods.md b/doc/api/model/static-methods.md index 10bb89eb9..249c51727 100644 --- a/doc/api/model/static-methods.md +++ b/doc/api/model/static-methods.md @@ -1040,6 +1040,46 @@ This method is mainly useful for plugin developers and for other generic usages. | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Object<string, [Relation](/api/types/#class-relation)> | Object whose keys are relation names and values are [Relation](/api/types/#class-relation) instances. | +## `static` getGraphSettings() + +```js +const settings = Person.getGraphSettings(relationPath, options, operation); +``` + +Resolves the [strict mode](/guide/query-examples.html#strict-mode) settings of [insertGraph](/api/query-builder/mutate-methods.html#insertgraph) or [upsertGraph](/api/query-builder/mutate-methods.html#upsertgraph) for the rows at a relation path, from the `root` and `relations` options of a call, with the same rules and defaults as the call itself. This is useful for code that prepares graphs before writing them and needs to know what the call does with each relation. + +Relations that the model doesn't have throw, so a misspelled path doesn't get the defaults. + +##### Arguments + +| Argument | Type | Description | +| ------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| relationPath | string \| string[] | The relation path, like `'movies.actors'` or `['movies', 'actors']`. An empty path gives the settings of `root`. | +| options | Object | The options of the strict `insertGraph` or `upsertGraph` call. | +| operation | string | `'insert'` or `'upsert'`: the method that the options are for, as their defaults differ. | + +##### Return value + +| Type | Description | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Object | The settings `removed`, `added`, `kept` and `created`. In `upsertGraph`, `removed` is `undefined` where the options don't set it. In `insertGraph`, `removed` and `kept` are `'preserve'`. | + +##### Examples + +```js +const options = { + strict: true, + relations: { + '*': { removed: 'preserve' }, + 'movies.actors': { added: 'relate' }, + }, +}; + +const settings = Person.getGraphSettings('movies.actors', options, 'upsert'); +console.log(settings); +// --> { removed: 'preserve', added: 'relate', kept: 'patch', created: 'insert' } +``` + ## `static` columnNameToPropertyName() ```js diff --git a/doc/release-notes/changelog.md b/doc/release-notes/changelog.md index fe52f3145..90ff95974 100644 --- a/doc/release-notes/changelog.md +++ b/doc/release-notes/changelog.md @@ -2,6 +2,10 @@ ## 4.3.1 +### Changes + +- Add `Model.getGraphSettings()`, which resolves the strict mode settings of `insertGraph()` or `upsertGraph()` for a relation path from the options of a call, with the same rules and defaults as the call itself [#537](https://github.com/ditojs/objection/issues/537) + ### Fixes - Strict `insertGraph()` with `added: 'relate'` throws a `NotFoundError` for related models whose ids don't exist, before anything is written, like `upsertGraph()` does. Since 4.3.0, it related them unchecked, writing keys that point to no row unless a foreign key constraint rejected them [#538](https://github.com/ditojs/objection/issues/538) diff --git a/lib/model/Model.js b/lib/model/Model.js index 7bf4177bd..ae071f6e9 100644 --- a/lib/model/Model.js +++ b/lib/model/Model.js @@ -33,6 +33,7 @@ import { ModifierNotFoundError } from './ModifierNotFoundError.js'; import { RelationProperty } from '../relations/RelationProperty.js'; import { RelationOwner } from '../relations/RelationOwner.js'; import { RelationWriteResultOperation } from '../relations/RelationWriteResultOperation.js'; +import { GraphOptions } from '../queryBuilder/graph/GraphOptions.js'; import { HasOneRelation } from '../relations/hasOne/HasOneRelation.js'; import { HasManyRelation } from '../relations/hasMany/HasManyRelation.js'; @@ -640,6 +641,32 @@ export class Model { return relation; } + // The settings of strict `insertGraph()` or `upsertGraph()` for the rows at + // `relationPath`, resolved from `options` like the graph writes them. + static getGraphSettings(relationPath, options, operation) { + if (!['insert', 'upsert'].includes(operation)) { + throw new Error(`expected operation to be 'insert' or 'upsert', got "${operation}"`); + } else if (!options.strict) { + throw new Error('getGraphSettings() only resolves the settings of strict mode'); + } + + const graphOptions = new GraphOptions(options, operation === 'insert'); + const path = isString(relationPath) + ? relationPath + ? relationPath.split('.') + : [] + : relationPath; + let modelClass = this; + + // Unknown relations throw, so that a misspelled path doesn't get the + // defaults: + for (const relationName of path) { + modelClass = modelClass.getRelation(relationName).relatedModelClass; + } + + return graphOptions.getSettings(path); + } + static fetchGraph($models, expression, options = {}) { return this.query(options.transaction) .resolve(this.ensureModelArray($models)) diff --git a/lib/queryBuilder/graph/GraphOptions.js b/lib/queryBuilder/graph/GraphOptions.js index 5ae8ae48d..a68e403cd 100644 --- a/lib/queryBuilder/graph/GraphOptions.js +++ b/lib/queryBuilder/graph/GraphOptions.js @@ -169,6 +169,13 @@ export class GraphOptions { return value ?? (this.insertOnly ? INSERT_DEFAULT_SETTINGS : DEFAULT_SETTINGS)[key]; } + + // All strict mode settings for the rows at `relationPath`: + getSettings(relationPath) { + return Object.fromEntries( + Object.keys(SETTING_VALUES).map((key) => [key, this.getSetting(relationPath, key)]), + ); + } } // Reject misspelled options, options of `upsertGraph` passed to `insertGraph` diff --git a/tests/ts/graph-strict-options.ts b/tests/ts/graph-strict-options.ts index b05bd563c..e81a5cb4d 100644 --- a/tests/ts/graph-strict-options.ts +++ b/tests/ts/graph-strict-options.ts @@ -2,7 +2,9 @@ // old options and the strict ones can't be mixed. import { + GraphSettings, InsertGraphOptions, + InsertGraphSettings, StrictInsertGraphOptions, StrictUpsertGraphOptions, UpsertGraphOptions, @@ -71,3 +73,23 @@ async function strictOptions() { } strictOptions(); + +function graphSettings() { + const upsert: GraphSettings = Person.getGraphSettings( + 'pets', + { strict: true, relations: { pets: { removed: 'delete' } } }, + 'upsert', + ); + const removed: 'preserve' | 'unrelate' | 'delete' | undefined = upsert.removed; + const added: 'relate' | 'insert' | 'relateOrInsert' | 'throw' = upsert.added; + const insert: InsertGraphSettings = Person.getGraphSettings(['pets'], { strict: true }, 'insert'); + const kept: 'preserve' = insert.kept; + // @ts-expect-error insertGraph() doesn't take the options of upsertGraph(). + Person.getGraphSettings('pets', { strict: true, fetchStrategy: 'OnlyNeeded' }, 'insert'); + // @ts-expect-error The settings are only resolved for strict mode. + Person.getGraphSettings('pets', { relate: true }, 'upsert'); + + return [removed, added, kept]; +} + +graphSettings(); diff --git a/tests/unit/model/Model.js b/tests/unit/model/Model.js index d76e48737..83facdd40 100644 --- a/tests/unit/model/Model.js +++ b/tests/unit/model/Model.js @@ -3057,6 +3057,80 @@ describe('Model', () => { JSON.stringify([p1, p2]); }); + describe('getGraphSettings()', () => { + let Person; + + beforeEach(() => { + Person = modelClass('Person'); + Person.relationMappings = { + parent: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'Person.parentId', to: 'Person.id' }, + }, + pets: { + relation: Model.HasManyRelation, + modelClass: modelClass('Animal'), + join: { from: 'Person.id', to: 'Animal.ownerId' }, + }, + }; + }); + + it('should resolve the settings of a relation path like upsertGraph()', () => { + const options = { + strict: true, + root: { kept: 'preserve' }, + relations: { '*': { removed: 'preserve' }, 'parent.^': { added: 'relate' } }, + }; + + expect(Person.getGraphSettings('parent.parent', options, 'upsert')).toEqual({ + removed: 'preserve', + added: 'relate', + kept: 'patch', + created: 'insert', + }); + expect(Person.getGraphSettings(['pets'], options, 'upsert')).toEqual({ + removed: 'preserve', + added: 'throw', + kept: 'patch', + created: 'insert', + }); + expect(Person.getGraphSettings('', options, 'upsert')).toEqual({ + removed: undefined, + added: 'throw', + kept: 'preserve', + created: 'insert', + }); + }); + + it('should resolve the settings of a relation path like insertGraph()', () => { + const options = { strict: true, relations: { parent: { added: 'relate' } } }; + + expect(Person.getGraphSettings('parent', options, 'insert')).toEqual({ + removed: 'preserve', + added: 'relate', + kept: 'preserve', + created: 'insert', + }); + expect(Person.getGraphSettings([], options, 'insert').added).toBe('insert'); + }); + + it('should throw for unknown relations, invalid options and operations', () => { + expect(() => Person.getGraphSettings('parent.toys', { strict: true }, 'upsert')).toThrow( + "doesn't have relation toys", + ); + expect(() => Person.getGraphSettings('parent', { strict: true }, 'patch')).toThrow( + `expected operation to be 'insert' or 'upsert', got "patch"`, + ); + expect(() => Person.getGraphSettings('parent', { relate: true }, 'upsert')).toThrow( + 'getGraphSettings() only resolves the settings of strict mode', + ); + expect(() => + Person.getGraphSettings('parent', { strict: true, fetchStrategy: 'Everything' }, 'insert'), + ).toThrow('"fetchStrategy" is not an option of insertGraph() in strict mode'); + }); + }); + function modelClass(tableName) { return class TestModel extends Model { static get tableName() { diff --git a/typings/objection/index.d.ts b/typings/objection/index.d.ts index 1e313c321..0eb2a3c6d 100644 --- a/typings/objection/index.d.ts +++ b/typings/objection/index.d.ts @@ -2261,6 +2261,24 @@ export interface GraphRootSettings { created?: GraphRelationSettings['created']; } +/** + * The settings of strict `upsertGraph()` for the rows at a relation path, as + * `Model.getGraphSettings()` resolves them. `removed` is only set where the + * options set it. + */ +export interface GraphSettings extends Required> { + removed: GraphRelationSettings['removed']; +} + +/** + * The settings of strict `insertGraph()` for the rows at a relation path, as + * `Model.getGraphSettings()` resolves them. + */ +export interface InsertGraphSettings extends Required { + removed: 'preserve'; + kept: 'preserve'; +} + export interface InsertGraphMethod { ( this: QB, @@ -3241,6 +3259,18 @@ export interface ModelClass extends ModelConstructor { getRelations(): Relations; getRelation(name: string): Relation; + getGraphSettings( + relationPath: string | readonly string[], + options: StrictInsertGraphOptions, + operation: 'insert', + ): InsertGraphSettings; + + getGraphSettings( + relationPath: string | readonly string[], + options: StrictUpsertGraphOptions, + operation: 'upsert', + ): GraphSettings; + traverse(models: Model | Model[], traverser: TraverserFunction): void; traverse( filterConstructor: ModelConstructor, @@ -3378,6 +3408,18 @@ export class Model { static getRelations(): Relations; static getRelation(name: string): Relation; + static getGraphSettings( + relationPath: string | readonly string[], + options: StrictInsertGraphOptions, + operation: 'insert', + ): InsertGraphSettings; + + static getGraphSettings( + relationPath: string | readonly string[], + options: StrictUpsertGraphOptions, + operation: 'upsert', + ): GraphSettings; + static traverse(models: Model | Model[], traverser: TraverserFunction): void; static traverse( filterConstructor: typeof Model,