Merge pull request #87 from bknd-io/fix/many-to-many

fix persisting of many to many entity
This commit is contained in:
dswbx
2025-02-18 19:03:56 +01:00
committed by GitHub
12 changed files with 86 additions and 70 deletions

View File

@@ -165,6 +165,13 @@ export class Entity<
return this.getField(name); return this.getField(name);
} }
hasField(name: string): boolean;
hasField(field: Field): boolean;
hasField(nameOrField: string | Field): boolean {
const name = typeof nameOrField === "string" ? nameOrField : nameOrField.name;
return this.fields.findIndex((field) => field.name === name) !== -1;
}
getFields(include_virtual: boolean = false): Field[] { getFields(include_virtual: boolean = false): Field[] {
if (include_virtual) return this.fields; if (include_virtual) return this.fields;
return this.fields.filter((f) => !f.isVirtual()); return this.fields.filter((f) => !f.isVirtual());

View File

@@ -73,7 +73,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
sort: entity.getDefaultSort(), sort: entity.getDefaultSort(),
select: entity.getSelect() select: entity.getSelect()
}; };
//console.log("validated", validated);
if (!options) return validated; if (!options) return validated;
@@ -144,7 +143,9 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
}); });
if (invalid.length > 0) { if (invalid.length > 0) {
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`); throw new InvalidSearchParamsException(
`Invalid where field(s): ${invalid.join(", ")}`
).context({ aliases, entity: entity.name });
} }
validated.where = options.where; validated.where = options.where;
@@ -334,7 +335,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> { async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
const { qb, options } = this.buildQuery(_options); const { qb, options } = this.buildQuery(_options);
//console.log("findMany:options", options);
await this.emgr.emit( await this.emgr.emit(
new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options }) new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options })
@@ -386,7 +386,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
} }
}; };
//console.log("findManyOptions", newEntity.name, findManyOptions);
return this.cloneFor(newEntity).findMany(findManyOptions); return this.cloneFor(newEntity).findMany(findManyOptions);
} }

View File

@@ -37,7 +37,7 @@ export class PrimaryField<Required extends true | false = false> extends Field<
} }
override async transformPersist(value: any): Promise<number> { override async transformPersist(value: any): Promise<number> {
throw new Error("This function should not be called"); throw new Error("PrimaryField: This function should not be called");
} }
override toJsonSchema() { override toJsonSchema() {

View File

@@ -105,13 +105,13 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
} }
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> { override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
const conn = this.connectionEntity; const { other, otherRef } = this.getQueryInfo(entity);
return { return {
where: { where: {
[`${conn.name}.${entity.name}_${entity.getPrimaryField().name}`]: id [otherRef]: id
}, },
join: [this.target.reference] join: [other.reference]
}; };
} }
@@ -160,47 +160,27 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
.whereRef(entityRef, "=", otherRef) .whereRef(entityRef, "=", otherRef)
.innerJoin(...join) .innerJoin(...join)
.limit(limit); .limit(limit);
/*return qb.select((eb) => {
const select: any[] = other.entity.getSelect(other.entity.name);
// @todo: also add to find by references
if (additionalFields.length > 0) {
const conn = this.connectionEntity.name;
select.push(
jsonBuildObject(
Object.fromEntries(
additionalFields.map((f) => [f.name, eb.ref(`${conn}.${f.name}`)])
)
).as(this.connectionTableMappedName)
);
}
return jsonFrom(
eb
.selectFrom(other.entity.name)
.select(select)
.whereRef(entityRef, "=", otherRef)
.innerJoin(...join)
.limit(limit)
).as(other.reference);
});*/
} }
initialize(em: EntityManager<any>) { initialize(em: EntityManager<any>) {
this.em = em; this.em = em;
//this.connectionEntity.addField(new RelationField(this.source.entity)); const sourceField = RelationField.create(this, this.source);
//this.connectionEntity.addField(new RelationField(this.target.entity)); const targetField = RelationField.create(this, this.target);
this.connectionEntity.addField(RelationField.create(this, this.source));
this.connectionEntity.addField(RelationField.create(this, this.target));
// @todo: check this if (em.hasEntity(this.connectionEntity)) {
for (const field of this.additionalFields) { // @todo: also check for correct signatures of field
this.source.entity.addField(new VirtualField(this.connectionTableMappedName)); if (!this.connectionEntity.hasField(sourceField)) {
this.target.entity.addField(new VirtualField(this.connectionTableMappedName)); this.connectionEntity.addField(sourceField);
}
if (!this.connectionEntity.hasField(targetField)) {
this.connectionEntity.addField(targetField);
}
} else {
this.connectionEntity.addField(sourceField);
this.connectionEntity.addField(targetField);
em.addEntity(this.connectionEntity);
} }
em.addEntity(this.connectionEntity);
} }
override getName(): string { override getName(): string {

View File

@@ -88,7 +88,7 @@ export class RelationField extends Field<RelationFieldConfig> {
} }
override async transformPersist(value: any, em: EntityManager<any>): Promise<any> { override async transformPersist(value: any, em: EntityManager<any>): Promise<any> {
throw new Error("This function should not be called"); throw new Error("RelationField: This function should not be called");
} }
override toJsonSchema() { override toJsonSchema() {

View File

@@ -2,6 +2,7 @@ import type { PrimaryFieldType } from "core";
import type { Entity, EntityManager } from "../entities"; import type { Entity, EntityManager } from "../entities";
import { import {
type EntityRelation, type EntityRelation,
ManyToManyRelation,
type MutationOperation, type MutationOperation,
MutationOperations, MutationOperations,
RelationField RelationField
@@ -26,11 +27,26 @@ export class RelationMutator {
*/ */
getRelationalKeys(): string[] { getRelationalKeys(): string[] {
const references: string[] = []; const references: string[] = [];
// if persisting a manytomany connection table
// @todo: improve later
if (this.entity.type === "generated") {
const relation = this.em.relations.all.find(
(r) => r instanceof ManyToManyRelation && r.connectionEntity.name === this.entity.name
);
if (relation instanceof ManyToManyRelation) {
references.push(
...this.entity.fields.filter((f) => f.type === "relation").map((f) => f.name)
);
}
}
this.em.relationsOf(this.entity.name).map((r) => { this.em.relationsOf(this.entity.name).map((r) => {
const info = r.helper(this.entity.name).getMutationInfo(); const info = r.helper(this.entity.name).getMutationInfo();
references.push(info.reference); references.push(info.reference);
info.local_field && references.push(info.local_field); info.local_field && references.push(info.local_field);
}); });
return references; return references;
} }

View File

@@ -72,8 +72,9 @@ export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field
}) => { }) => {
const desc = field.getDescription(); const desc = field.getDescription();
return ( return (
<Label {...props} title={desc} className="flex flex-row gap-2 items-center"> <Label {...props} title={desc} className="flex flex-row gap-1 items-center">
{field.getLabel()} {field.getLabel()}
{field.isRequired() && <span className="font-medium opacity-30">*</span>}
{desc && <TbInfoCircle className="opacity-50" />} {desc && <TbInfoCircle className="opacity-50" />}
</Label> </Label>
); );

View File

@@ -25,7 +25,7 @@ export const Check = () => {
); );
}; };
type TableProps = { export type EntityTableProps = {
data: EntityData[]; data: EntityData[];
entity: Entity; entity: Entity;
select?: string[]; select?: string[];
@@ -44,7 +44,7 @@ type TableProps = {
}; };
}; };
export const EntityTable: React.FC<TableProps> = ({ export const EntityTable: React.FC<EntityTableProps> = ({
data = [], data = [],
entity, entity,
select, select,
@@ -184,7 +184,7 @@ const SortIndicator = ({
sort, sort,
field field
}: { }: {
sort: Pick<TableProps, "sort">["sort"]; sort: Pick<EntityTableProps, "sort">["sort"];
field: string; field: string;
}) => { }) => {
if (!sort || sort.by !== field) return <TbSelector size={18} className="mt-[1px]" />; if (!sort || sort.by !== field) return <TbSelector size={18} className="mt-[1px]" />;

View File

@@ -12,7 +12,8 @@ import { Popover } from "ui/components/overlay/Popover";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
import { routes } from "ui/lib/routes"; import { routes } from "ui/lib/routes";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { EntityTable } from "../EntityTable"; import { EntityTable, type EntityTableProps } from "../EntityTable";
import type { ResponseObject } from "modules/ModuleApi";
// @todo: allow clear if not required // @todo: allow clear if not required
export function EntityRelationalFormField({ export function EntityRelationalFormField({
@@ -20,7 +21,7 @@ export function EntityRelationalFormField({
field, field,
data, data,
disabled, disabled,
tabIndex tabIndex,
}: { }: {
fieldApi: FieldApi<any, any>; fieldApi: FieldApi<any, any>;
field: RelationField; field: RelationField;
@@ -30,12 +31,18 @@ export function EntityRelationalFormField({
}) { }) {
const { app } = useBknd(); const { app } = useBknd();
const entity = app.entity(field.target())!; const entity = app.entity(field.target())!;
const [query, setQuery] = useState<any>({ limit: 10, page: 1, perPage: 10 }); const [query, setQuery] = useState<any>({
limit: 10,
page: 1,
perPage: 10,
select: entity.getSelect(undefined, "table"),
});
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const ref = useRef<any>(null); const ref = useRef<any>(null);
const $q = useEntityQuery(field.target(), undefined, { const $q = useEntityQuery(field.target(), undefined, {
select: query.select,
limit: query.limit, limit: query.limit,
offset: (query.page - 1) * query.limit offset: (query.page - 1) * query.limit,
}); });
const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>(); const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>();
@@ -120,8 +127,8 @@ export function EntityRelationalFormField({
"Enter", "Enter",
() => { () => {
ref.current?.click(); ref.current?.click();
} },
] ],
])} ])}
> >
{_value ? ( {_value ? (
@@ -179,31 +186,37 @@ export function EntityRelationalFormField({
); );
} }
const PopoverTable = ({ container, entity, query, toggle, onClickRow, onClickPage }) => { type PropoverTableProps = Omit<EntityTableProps, "data"> & {
container: ResponseObject;
query: any;
toggle: () => void;
}
const PopoverTable = ({ container, entity, query, toggle, onClickRow, onClickPage }: PropoverTableProps) => {
function handleNext() { function handleNext() {
if (query.limit * query.page < container.meta?.count) { if (query.limit * query.page < container.meta?.count) {
onClickPage(query.page + 1); onClickPage?.(query.page + 1);
} }
} }
function handlePrev() { function handlePrev() {
if (query.page > 1) { if (query.page > 1) {
onClickPage(query.page - 1); onClickPage?.(query.page - 1);
} }
} }
useHotkeys([ useHotkeys([
["ArrowRight", handleNext], ["ArrowRight", handleNext],
["ArrowLeft", handlePrev], ["ArrowLeft", handlePrev],
["Escape", toggle] ["Escape", toggle],
]); ]);
return ( return (
<div> <div>
<EntityTable <EntityTable
classNames={{ value: "line-clamp-1 truncate max-w-52 text-nowrap" }} classNames={{ value: "line-clamp-1 truncate max-w-52 text-nowrap" }}
data={container.data ?? []} data={container ?? []}
entity={entity} entity={entity}
select={query.select}
total={container.meta?.count} total={container.meta?.count}
page={query.page} page={query.page}
onClickRow={onClickRow} onClickRow={onClickRow}

View File

@@ -262,18 +262,18 @@ function EntityDetailInner({
navigate(routes.data.entity.edit(other.entity.name, row.id)); navigate(routes.data.entity.edit(other.entity.name, row.id));
} }
function handleClickNew() { let handleClickNew: any;
try { try {
if (other.entity.type !== "system") {
const ref = relation.getReferenceQuery(other.entity, id, other.reference); const ref = relation.getReferenceQuery(other.entity, id, other.reference);
handleClickNew = () => {
navigate(routes.data.entity.create(other.entity.name), { navigate(routes.data.entity.create(other.entity.name), {
query: ref.where query: ref.where
}); });
//navigate(routes.data.entity.create(other.entity.name) + `?${query}`); //navigate(routes.data.entity.create(other.entity.name) + `?${query}`);
} catch (e) { };
console.error("handleClickNew", e);
} }
} } catch (e) {}
if (!$q.data) { if (!$q.data) {
return null; return null;

View File

@@ -18,7 +18,7 @@ export function DataEntityCreate({ params }) {
const entity = $data.entity(params.entity as string); const entity = $data.entity(params.entity as string);
if (!entity) { if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />; return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
} else if (entity.type !== "regular") { } else if (entity.type === "system") {
return <Message.NotAllowed description={`Entity "${params.entity}" cannot be created.`} />; return <Message.NotAllowed description={`Entity "${params.entity}" cannot be created.`} />;
} }

View File

@@ -155,7 +155,7 @@ function EntityCreateButton({ entity }: { entity: Entity }) {
const [navigate] = useNavigate(); const [navigate] = useNavigate();
if (!entity) return null; if (!entity) return null;
if (entity.type !== "regular") { if (entity.type === "system") {
const system = { const system = {
users: b.app.config.auth.entity_name, users: b.app.config.auth.entity_name,
media: b.app.config.media.entity_name media: b.app.config.media.entity_name