Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions doc/api/model/static-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions doc/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions lib/model/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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))
Expand Down
7 changes: 7 additions & 0 deletions lib/queryBuilder/graph/GraphOptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
22 changes: 22 additions & 0 deletions tests/ts/graph-strict-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// old options and the strict ones can't be mixed.

import {
GraphSettings,
InsertGraphOptions,
InsertGraphSettings,
StrictInsertGraphOptions,
StrictUpsertGraphOptions,
UpsertGraphOptions,
Expand Down Expand Up @@ -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();
74 changes: 74 additions & 0 deletions tests/unit/model/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
42 changes: 42 additions & 0 deletions typings/objection/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Omit<GraphRelationSettings, 'removed'>> {
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<InsertGraphRelationSettings> {
removed: 'preserve';
kept: 'preserve';
}

export interface InsertGraphMethod<M extends Model> {
<QB extends AnyQueryBuilder>(
this: QB,
Expand Down Expand Up @@ -3241,6 +3259,18 @@ export interface ModelClass<M extends Model> extends ModelConstructor<M> {
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<Model>,
Expand Down Expand Up @@ -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,
Expand Down
Loading