Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Add tests for previous reactive hint fix, with more fixes. #1080

Merged
merged 1 commit into from
May 9, 2024
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
5 changes: 5 additions & 0 deletions packages/orm/src/EntityMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ export function getBaseSelfAndSubMetas(meta: EntityMetadata): EntityMetadata[] {
return [...meta.baseTypes, meta, ...meta.subTypes];
}

export function getSubMetas(meta: EntityMetadata): EntityMetadata[] {
// We should do recursion at some point
return meta.subTypes;
}

export function getBaseMeta(meta: EntityMetadata): EntityMetadata {
if (!meta.baseType) {
return meta;
Expand Down
82 changes: 63 additions & 19 deletions packages/orm/src/reactiveHints.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { Entity } from "./Entity";
import { FieldsOf, MaybeAbstractEntityConstructor, RelationsOf, getEmInternalApi } from "./EntityManager";
import { EntityMetadata, getBaseAndSelfMetas, getMetadata } from "./EntityMetadata";
import {
EntityMetadata,
ManyToManyField,
ManyToOneField,
OneToManyField,
OneToOneField,
PolymorphicFieldComponent,
getBaseAndSelfMetas,
getMetadata,
getSubMetas,
} from "./EntityMetadata";
import { Changes, FieldStatus, ManyToOneFieldStatus } from "./changes";
import { isChangeableField } from "./fields";
import { getProperties } from "./getProperties";
Expand Down Expand Up @@ -146,9 +156,10 @@ export function reverseReactiveHint<T extends Entity>(
if (!isReadOnly) {
fields.push(field.fieldName);
}
const otherFieldName = maybeAddTypeFilterSuffix(meta, field);
return reverseReactiveHint(rootType, field.otherMetadata().cstr, subHint, undefined, false).map(
({ entity, fields, path }) => {
return { entity, fields, path: [...path, field.otherFieldName] };
return { entity, fields, path: [...path, otherFieldName] };
},
);
}
Expand All @@ -161,21 +172,18 @@ export function reverseReactiveHint<T extends Entity>(
return field.components.flatMap((comp) => {
return reverseReactiveHint(rootType, comp.otherMetadata().cstr, subHint, undefined, false).map(
({ entity, fields, path }) => {
return { entity, fields, path: [...path, comp.otherFieldName] };
const otherFieldName = maybeAddTypeFilterSuffix(meta, comp);
return { entity, fields, path: [...path, otherFieldName] };
},
);
});
}
case "m2m": {
const otherField =
field.otherMetadata().allFields[field.otherFieldName] ??
fail(`No field ${field.otherMetadata().type}.${field.otherFieldName}`);
const otherFieldName =
otherField.kind === "poly" ? `${field.otherFieldName}@${meta.type}` : field.otherFieldName;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I extract this to maybeAddTypeFilterSuffix so it could be used across the relation cases

// While o2m and o2o can watch for just FK changes by passing `reactForOtherSide` (the FK lives in the other
// table), for m2m reactivity we push the collection name into the reactive hint, because it's effectively
// "the other/reverse side", and JoinRows will trigger it explicitly instead of `setField` for non-m2m keys.
fields.push(field.fieldName);
const otherFieldName = maybeAddTypeFilterSuffix(meta, field);
return reverseReactiveHint(
rootType,
field.otherMetadata().cstr,
Expand All @@ -191,10 +199,7 @@ export function reverseReactiveHint<T extends Entity>(
case "o2m":
case "o2o": {
const isOtherReadOnly = field.otherMetadata().allFields[field.otherFieldName].immutable;
const otherFieldName =
field.otherMetadata().allFields[field.otherFieldName].kind === "poly" || meta.baseType
? `${field.otherFieldName}@${meta.type}`
: field.otherFieldName;
const otherFieldName = maybeAddTypeFilterSuffix(meta, field);
// This is not a field, but we want our reverse side to be reactive, so pass reactForOtherSide
return reverseReactiveHint(
rootType,
Expand Down Expand Up @@ -252,6 +257,25 @@ export function reverseReactiveHint<T extends Entity>(
];
}

function maybeAddTypeFilterSuffix(
meta: EntityMetadata,
field: ManyToOneField | OneToManyField | ManyToManyField | OneToOneField | PolymorphicFieldComponent,
): string {
const otherField =
field.otherMetadata().allFields[field.otherFieldName] ??
fail(`No field ${field.otherMetadata().type}.${field.otherFieldName}`);
// If we're Foo, and the other field (which we'll traverse back from at runtime) is actually
// a poly FK pointing back to multiple `owner=Foo | Bar | Zaz`, add a suffix of `@Foo` so
// that any runtime traversal that hit `owner` and see a `Bar | Zaz` will stop.
const nextFieldIsPoly = otherField.kind === "poly";
// If we're a SubType, and the other field points to our base type, assume there might be
// SubType-specific fields in the path so far, so tell the other side to only walk back through
// us if its value is actually a SubType.
const nextFieldPointsToBaseType =
!!meta.baseType && otherField.kind !== "poly" && (otherField as any).otherMetadata().cstr !== meta.cstr;
return nextFieldIsPoly || nextFieldPointsToBaseType ? `${field.otherFieldName}@${meta.type}` : field.otherFieldName;
}

/**
* Walks `reverseHint` for every entity in `entities`.
*
Expand All @@ -264,11 +288,11 @@ export async function followReverseHint(entities: Entity[], reverseHint: string[
// And "walk backwards" through the reverse hint
while (paths.length) {
const path = paths.shift()!;
const [fieldName, viaPolyType] = path.split("@");
const [fieldName, viaType] = path.split("@");
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to just viaType since it's used for both polys & CTE/inheritance now

// The path might touch either a reference or a collection
const entitiesOrLists = await Promise.all(
current.flatMap((c: any) => {
const currentValuePromise = maybeLoadedPoly(c[fieldName].load(), viaPolyType);
const currentValuePromise = maybeApplyTypeFilter(c[fieldName].load(), viaType);
// If we're going from Book.author back to Author to re-validate the Author.books collection,
// see if Book.author has changed, so we can re-validate both the old author's books and the
// new author's books.
Expand All @@ -279,7 +303,7 @@ export async function followReverseHint(entities: Entity[], reverseHint: string[
if (isReference && changed && changed.hasUpdated && changed.originalValue) {
return [
currentValuePromise,
maybeLoadedPoly((changed as ManyToOneFieldStatus<any>).originalEntity, viaPolyType),
maybeApplyTypeFilter((changed as ManyToOneFieldStatus<any>).originalEntity, viaType),
];
}
if (isManyToMany) {
Expand Down Expand Up @@ -374,14 +398,34 @@ export interface ReactiveTarget {
path: string[];
}

async function maybeLoadedPoly(loadPromise: Promise<Entity>, viaPolyType: string | undefined) {
if (viaPolyType) {
const loaded: Entity = await loadPromise;
return loaded && getMetadata(loaded).type === viaPolyType ? loaded : undefined;
function maybeApplyTypeFilter(loadPromise: Promise<Entity | Entity[]>, viaType: string | undefined) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took out the async/await

if (viaType) {
return loadPromise.then((loaded) => {
if (Array.isArray(loaded)) {
return loaded.filter((e) => isTypeOrSubType(e, viaType));
} else if (loaded && isTypeOrSubType(loaded, viaType)) {
return loaded;
} else {
return undefined;
}
});
}
return loadPromise;
}

/** Handle `viaType` filtering with subtype awareness. */
function isTypeOrSubType(entity: Entity, typeName: string): boolean {
const meta = getMetadata(entity);
// Easy check for the name is the same
if (meta.type === typeName) return true;
// Otherwise see if the entity is a subtype of the typeName, i.e. if our poly/type
// filter is `@Publisher`, and we're a `SmallPublisher`, that's valid to traverse.
for (const other of getSubMetas(meta)) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now we allow subtypes of the filter to pass

if (other.type === typeName) return true;
}
return false;
}

export function isPolyHint(key: string): boolean {
return key.includes("@");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
newBook,
newBookReview,
newPublisher,
newSmallPublisher,
newTag,
SmallPublisher,
Tag,
Expand Down Expand Up @@ -209,6 +210,14 @@ describe("EntityManager.reactiveRules", () => {
expect(b.reviewsRuleInvoked).toBe(3);
});

it.withCtx("skips traversing through subtype-only relations", async ({ em }) => {
// Given we trigger an Image -> publishers -> critics (only exists on LargePublishers)
newSmallPublisher(em, { images: [{}] });
// Then it does not blow up (and we can't assert against the Critic rule having executed
// b/c the scenario is that SmallPublishers don't have the `critics` relation)
await em.flush();
});

it.withCtx("creates the right reactive rules", async () => {
const fn = expect.any(Function);
expect(getReactiveRules(Author)).toMatchObject([
Expand Down
4 changes: 2 additions & 2 deletions packages/tests/integration/src/entities/Critic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import { CriticCodegen, criticConfig as config } from "./entities";

export class Critic extends CriticCodegen {}

// remove once you have actual rules/hooks
config.placeholder();
/** For testing walking through subtype relations. */
config.addRule({ favoriteLargePublisher: "images" }, () => {});
Loading
Loading