Skip to content
Open
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
133 changes: 131 additions & 2 deletions app/controllers/auto_crud_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
InternalControllerValidationError,
} from "#exceptions/base_controller_errors";
import {
BadRequestException,
ConflictException,
ForbiddenException,
NotFoundException,
Expand All @@ -45,7 +46,12 @@ import type { preloadRelations } from "#scopes/preload_helper";
import type { handleSearchQuery } from "#scopes/search_helper";
import type { handleSortQuery } from "#scopes/sort_helper";
import "#utils/maps";
import { AutogenCacheEntry, relationValidator } from "#utils/model_autogen";
import {
AutogenCacheEntry,
hasUpdatablePivotColumns,
relationValidator,
splitPivotUpdateBody,
} from "#utils/model_autogen";
import type {
AnyValidator,
PrimaryKeyFieldDescriptor,
Expand Down Expand Up @@ -140,7 +146,8 @@ export type ControllerAction =
| "oneToOneRelationStore"
| "oneToManyRelationStore"
| "manyToManyRelationAttach"
| "manyToManyRelationDetach";
| "manyToManyRelationDetach"
| "manyToManyRelationUpdatePivot";

// Use the same Constructor type as other controllers (e.g., mobile_config_controller)

Expand Down Expand Up @@ -188,6 +195,7 @@ export default abstract class AutoCrudController<
* - oneToManyRelationStore -> "create"
* - manyToManyRelationAttach -> "update"
* - manyToManyRelationDetach -> "update"
* - manyToManyRelationUpdatePivot -> "update"
* - index/show -> none (public by default)
*
* @param action The controller action being performed
Expand All @@ -210,6 +218,7 @@ export default abstract class AutoCrudController<
return "create";
case "manyToManyRelationAttach":
case "manyToManyRelationDetach":
case "manyToManyRelationUpdatePivot":
return "update";
case "index":
case "show":
Expand Down Expand Up @@ -434,6 +443,10 @@ export default abstract class AutoCrudController<
return this.modelCacheEntry.manyToManyIdsValidator(relationName);
}

protected updatePivotValidator(relationName: string): AnyValidator {
return this.modelCacheEntry.updatePivotValidator(relationName);
}

/**
* The actual self-validation function, does not cache!
*/
Expand Down Expand Up @@ -638,6 +651,14 @@ export default abstract class AutoCrudController<
"manyToManyRelationDetach",
])
.as(`relation.${relationName}.detach`);
if (hasUpdatablePivotColumns(relation)) {
router
.patch(`/:localId/${snakeCaseName}/:relatedId`, [
controller,
"manyToManyRelationUpdatePivot",
])
.as(`relation.${relationName}.updatePivot`);
}
}
}
} else {
Expand Down Expand Up @@ -1269,4 +1290,112 @@ export default abstract class AutoCrudController<

return { success: true, numDetached: result };
}

async manyToManyRelationUpdatePivot(httpCtx: HttpContext): Promise<unknown> {
const { request, route, auth } = httpCtx;
if (!auth.isAuthenticated) {
await auth.authenticate();
}
await this.selfValidate();
const relationName = this.relationNameFromRoute(route);
await this.authenticate(
httpCtx,
"manyToManyRelationUpdatePivot",
relationName,
);

const {
params: { localId, relatedId },
} = (await request.validateUsing(
this.manyToManyIdsValidator(relationName),
)) as { params: { localId: string | number; relatedId: string | number } };
const body = (await request.validateUsing(
this.updatePivotValidator(relationName),
)) as Record<string, unknown>;

await this.authorizeById(httpCtx, "manyToManyRelationUpdatePivot", {
localId,
relatedId,
relationName,
});

const relation = this.model.$relationsDefinitions.get(relationName);
if (relation === undefined) {
throw new InternalControllerError(
`Relation '${relationName}' does not exist on model '${this.model.name}'`,
);
}
if (relation.type !== "manyToMany") {
throw new InternalControllerError(
`Relation '${relationName}' of model '${this.model.name}' was passed into the 'manyToManyRelationUpdatePivot' method, ` +
`which only supports 'manyToMany' relations, but this relation is of type '${relation.type}'!`,
);
}
if (!validateTypedManyToManyRelation(relation)) {
throw new InternalControllerError(
`Relation '${relationName}' isn't properly typed!`,
);
}
if (!relation.booted) {
relation.boot();
}

const { pivotKey, pivotUpdate } = splitPivotUpdateBody(relation, body);

if (Object.keys(pivotUpdate).length === 0) {
throw new BadRequestException(
"At least one updatable pivot field must be provided",
);
}

for (const [name, field] of Object.entries(
relation.options.meta.declaredColumnTypes,
)) {
if (field.detachFilter && !(name in pivotKey)) {
throw new BadRequestException(
`Missing required pivot key field: ${name}`,
);
}
}

if (this.authorizeRecord !== AutoCrudController.prototype.authorizeRecord) {
const mainInstance = await this.getFirstOrFail(localId);
await this.authorizeRecord(
httpCtx,
"manyToManyRelationUpdatePivot",
mainInstance,
);
}

let result;
try {
result = await db
.knexQuery()
.table(relation.pivotTable)
.where({
...pivotKey,
[relation.pivotForeignKey]: localId,
[relation.pivotRelatedForeignKey]: relatedId,
})
.update({
...pivotUpdate,
...("pivotTimestamps" in relation.options &&
relation.options.pivotTimestamps === true
? { updated_at: new Date() }
: {}),
});
} catch (err) {
throw new BaseError("Failed to update pivot row", {
cause: err,
code: "E_DB_ERROR",
status: 500,
});
}

if (result === 0) {
throw new NotFoundException("No relation attachments matched your query");
}

return { success: true };
}
}
2 changes: 2 additions & 0 deletions app/controllers/v1/drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export abstract class GenericDraftController<
case "oneToManyRelationStore":
case "manyToManyRelationAttach":
case "manyToManyRelationDetach":
case "manyToManyRelationUpdatePivot":
return "authOnly";
// other actions
case "index":
Expand Down Expand Up @@ -243,6 +244,7 @@ export abstract class GenericDraftController<
oneToManyRelationStore: "update",
manyToManyRelationAttach: "update",
manyToManyRelationDetach: "update",
manyToManyRelationUpdatePivot: "update",
};
const slug = slugMap[action];

Expand Down
8 changes: 4 additions & 4 deletions app/models/contributor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ export default class Contributor extends BaseModel {
@typedColumn({ foreignKeyOf: () => FileEntry, optional: true })
declare photoKey: string | null;

@typedColumn({ type: "number", hasDefault: true })
declare order: number;

@typedColumn.dateTime({ autoCreate: true })
declare createdAt: DateTime;

Expand All @@ -55,7 +52,10 @@ export default class Contributor extends BaseModel {

@typedManyToMany(() => Milestone, {
pivotTable: "contributor_roles",
pivotColumns: { role_id: { type: "integer", detachFilter: true } },
pivotColumns: {
role_id: { type: "integer", detachFilter: true },
order: { type: "number", hasDefault: true },
},
pivotTimestamps: true,
})
declare milestones: ManyToMany<typeof Milestone>;
Expand Down
8 changes: 7 additions & 1 deletion app/models/milestone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,14 @@ export default class Milestone extends BaseModel {

@typedManyToMany(() => Contributor, {
pivotTable: "contributor_roles",
pivotColumns: { role_id: { type: "integer", detachFilter: true } },
pivotColumns: {
role_id: { type: "integer", detachFilter: true },
order: { type: "number", hasDefault: true },
},
pivotTimestamps: true,
onQuery: (query) => {
return query.orderBy("contributor_roles.order", "asc");
},
})
declare contributors: ManyToMany<typeof Contributor>;

Expand Down
97 changes: 97 additions & 0 deletions app/utils/model_autogen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
LucidModel,
ModelColumnOptions,
} from "@adonisjs/lucid/types/model";
import type { ManyToManyRelationContract } from "@adonisjs/lucid/types/relations";

import {
validateColumnDef,
Expand Down Expand Up @@ -43,6 +44,7 @@ export class AutogenCacheEntry {
#relationStoreValidators = new Map<string, AnyValidator>();
#relationAttachValidators = new Map<string, AnyValidator>();
#relationDetachValidators = new Map<string, AnyValidator>();
#relationUpdatePivotValidators = new Map<string, AnyValidator>();
#manyToManyIdValidators = new Map<string, AnyValidator>();

private constructor(model: LucidModel) {
Expand Down Expand Up @@ -303,6 +305,57 @@ export class AutogenCacheEntry {
});
}

public updatePivotValidator(relationName: string): AnyValidator {
return this.#relationUpdatePivotValidators.getOrInsertWith(
relationName,
() => {
const relation = this.model.$relationsDefinitions.get(relationName);
if (relation === undefined) {
throw new InvalidModelDefinition(
`Relation '${relationName}' does not exist on model '${this.model.name}'`,
);
}
if (relation.type !== "manyToMany") {
throw new InvalidModelDefinition(
`Relation '${relationName}' is not a manyToMany relation!`,
);
}
if (!validateTypedManyToManyRelation(relation)) {
throw new InvalidModelDefinition(
`Relation '${relationName}' isn't properly typed!`,
);
}
if (!relation.booted) {
relation.boot();
}
return vine.compile(
vine.object(
Object.fromEntries(
Object.entries(relation.options.meta.declaredColumnTypes)
.map(([name, field]) => {
if (field.autoGenerated) {
return undefined;
}
let validator = field.validator;
if (field.detachFilter) {
return [name, validator];
}
if (
"optional" in validator &&
typeof validator.optional === "function"
) {
validator = (validator.optional as () => SchemaTypes)();
}
return [name, validator];
})
.filter((e) => e !== undefined),
),
),
);
},
);
}

public manyToManyIdsValidator(relationName: string): AnyValidator {
return this.#manyToManyIdValidators.getOrInsertWith(relationName, () => {
const relation = this.model.$relationsDefinitions.get(relationName);
Expand Down Expand Up @@ -353,6 +406,50 @@ export class AutogenCacheEntry {
const relationValidatorCache = new Map<string, RelationValidator>();
const modelAutogenCache = new Map<LucidModel, AutogenCacheEntry>();

export function hasUpdatablePivotColumns(
relation: ManyToManyRelationContract<LucidModel, LucidModel>,
): boolean {
if (!validateTypedManyToManyRelation(relation)) {
return false;
}
if (!relation.booted) {
relation.boot();
}
return Object.values(relation.options.meta.declaredColumnTypes).some(
(field) => !field.autoGenerated && !field.detachFilter,
);
}

export function splitPivotUpdateBody(
relation: ManyToManyRelationContract<LucidModel, LucidModel>,
body: Record<string, unknown>,
): {
pivotKey: Record<string, unknown>;
pivotUpdate: Record<string, unknown>;
} {
if (!validateTypedManyToManyRelation(relation)) {
throw new InvalidModelDefinition("Relation isn't properly typed!");
}
if (!relation.booted) {
relation.boot();
}
const pivotKey: Record<string, unknown> = {};
const pivotUpdate: Record<string, unknown> = {};
for (const [name, field] of Object.entries(
relation.options.meta.declaredColumnTypes,
)) {
if (field.autoGenerated || !(name in body)) {
continue;
}
if (field.detachFilter) {
pivotKey[name] = body[name];
} else {
pivotUpdate[name] = body[name];
}
}
return { pivotKey, pivotUpdate };
}

export function relationValidator(relations: string[]): RelationValidator {
return relationValidatorCache.getOrInsertWith(JSON.stringify(relations), () =>
vine.compile(
Expand Down
Loading
Loading