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
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,9 @@ export class MariaDBBrokerChangesTable extends AbstractTable {
OR([
creation || deletion
? `${this.escapeColumnIdentifier('kind', alias)} IN (${[
creation && utils.MutationType.CREATION,
deletion && utils.MutationType.DELETION,
...(creation ? [utils.MutationType.CREATION] : []),
...(deletion ? [utils.MutationType.DELETION] : []),
]
.filter(utils.isNonNil)
.map((kind) => this.serializeColumnValue('kind', kind))
.join(',')})`
: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,58 @@ describe('Subscription', () => {
);
});
});

// Regression for the companion of PR #20: `EdgeDependency.flattened` surfaces a
// head-node deletion-only dependency (e.g. `Tag` with `{ deletion: true }` and
// no `creation`) whenever the edge has a re-read-synthesizing child. The
// changes-table `filterDependencies` must build a valid `kind IN (...)` clause
// for it instead of crashing while serializing the `false` boolean as a `kind`
// enum value.
describe('Subscription diagnosis with a deletion-only dependency', () => {
const gp = createMyGP('connector_mariadb_subscription_deletion_only');

const Article = gp.getNodeByName('Article');

let subscription: ChangesSubscriptionStream<MyContext>;

beforeEach(async () => {
await gp.connector.setup();

// Article -> tags -> tag (edge to Tag) -> articles (reverse-edge): the
// `tag` edge gains a re-read-synthesizing child, so its head `Tag` is
// surfaced as deletion-only (no mutable `Tag` field is selected).
subscription = await Article.api.subscribeToChanges(myAdminContext, {
where: { status: ArticleStatus.PUBLISHED },
selection: {
onUpsert: `{
id
tags(first: 10) {
tag {
id
articles(first: 10) {
article { id }
}
}
}
}`,
},
});
});

afterEach(async () => {
await subscription.dispose();
await gp.connector.teardown();
});

it('surfaces "Tag" as a deletion-only dependency', () => {
assert.deepEqual(subscription.dependencyTree.flattened.toJSON().Tag, {
deletion: true,
});
});

it('diagnoses without crashing on the deletion-only dependency', async () => {
// Before the fix, this rejected with an AssertionError (boolean vs string)
// while building the `kind IN (...)` clause in `filterDependencies`.
await assert.doesNotReject(() => gp.broker.diagnose());
});
});
137 changes: 136 additions & 1 deletion packages/graphql-platform/src/node/dependency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as utils from '@prismamedia/graphql-platform-utils';
import assert from 'node:assert';
import { before, describe, it } from 'node:test';
import { ArticleStatus, nodes, type MyContext } from '../__tests__/config.js';
import { GraphQLPlatform } from '../index.js';
import { GraphQLPlatform, OnEdgeHeadDeletion } from '../index.js';
import {
NodeCreation,
NodeDeletion,
Expand Down Expand Up @@ -867,4 +867,139 @@ describe('Dependency', () => {
});
});
});

// @see https://github.com/prismamedia/graphql-platform - reverse-edge towards a deleted head
describe('Reverse-edge towards a cascade-deleted head', () => {
// A dedicated platform adding a "TagBrandedContent" node, cascade-deleted
// with its "Tag", and reachable from the "Article" selection through
// Article -> tags -> tag -> brandedContents.
const cascadingGP = new GraphQLPlatform({
nodes: {
...nodes,
Tag: {
...nodes.Tag,
reverseEdges: {
...nodes.Tag.reverseEdges,
brandedContents: { originalEdge: 'TagBrandedContent.tag' },
},
},
TagBrandedContent: {
components: {
tag: {
kind: 'Edge',
head: 'Tag',
onHeadDeletion: OnEdgeHeadDeletion.CASCADE,
nullable: false,
mutable: false,
},
brandKey: {
kind: 'Leaf',
type: 'NonEmptyTrimmedString',
nullable: false,
mutable: false,
},
isActive: { kind: 'Leaf', type: 'Boolean' },
},
uniques: [['tag', 'brandKey']],
},
},
} as any);

const CascadingArticle = cascadingGP.getNodeByName('Article');
const Tag = cascadingGP.getNodeByName('Tag');
const ArticleTag = cascadingGP.getNodeByName('ArticleTag');
const TagBrandedContent = cascadingGP.getNodeByName('TagBrandedContent');

const tagId = '00000000-0000-4000-8000-000000000900';

const dependency = new DocumentSetDependency(CascadingArticle, {
selection: CascadingArticle.outputType.select(`{
_id
tags(first: 100) {
tag {
id
brandedContents(where: { isActive: true }, first: 100) { brandKey }
}
}
}`),
});

const tagDeletion = new NodeDeletion(Tag, myRequestContext, {
id: tagId,
title: 'Old Tag',
slug: 'old-tag',
deprecated: false,
createdAt: new Date(),
updatedAt: new Date(),
});

const articleTagDeletions = [11, 22, 33].map(
(_id) =>
new NodeDeletion(ArticleTag, myRequestContext, {
article: { _id },
order: 1,
tag: { id: tagId },
}),
);

const brandedContentDeletion = new NodeDeletion(
TagBrandedContent,
myRequestContext,
{ tag: { id: tagId }, brandKey: 'CAP', isActive: true },
);

it('surfaces the edge-head deletion to the flattened dependencies (broker delivery)', () => {
// The fold can only collapse the redundant existence if the broker
// actually delivers the Tag deletion. The Article subscription reads only
// immutable Tag fields, so Tag would otherwise be invisible: an edge with
// re-read-synthesizing children (here `tag`, via `brandedContents`) must
// therefore surface its head deletion so the broker wakes on - and
// delivers - it.
assert.deepEqual(dependency.flattened.dependencies.get(Tag)?.toJSON(), {
deletion: true,
});
});

it('does not synthesize a reverse-edge existence towards the deleted tag', () => {
const dependentGraph = dependency.createDependentGraph(
MutationContextChanges.createFromChanges([
tagDeletion,
...articleTagDeletions,
brandedContentDeletion,
]),
);

assert(dependentGraph);

const upsert = dependentGraph.upsertFilter.inputValue;

// The impacted articles are already captured through the cascade-deleted
// junction rows: the re-read is a plain primary-key lookup.
assert.deepEqual(upsert, { _id_in: [11, 22, 33] });

// Explicit guard: no existence towards the deleted tag.
assert(
!JSON.stringify(upsert).includes('tags_some'),
`upsertFilter must not contain an existence towards the deleted tag, got: ${JSON.stringify(upsert)}`,
);
});

it('still synthesizes the existence when the tag is NOT deleted', () => {
// Same branded-content deletion, but the tag survives (no Tag deletion,
// no ArticleTag deletion): the existence towards the affected tag must
// remain, so the affected articles can be re-read.
const dependentGraph = dependency.createDependentGraph(
MutationContextChanges.createFromChanges([brandedContentDeletion]),
);

assert(dependentGraph);

assert(
JSON.stringify(dependentGraph.upsertFilter.inputValue).includes(
'tags_some',
),
`upsertFilter must keep the existence towards the surviving tag, got: ${JSON.stringify(dependentGraph.upsertFilter.inputValue)}`,
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ exports[`Dependency > Complex filter & selection > has a consistent dependency-t
]
},
"User": {
"deletion": true,
"update": [
"lastLoggedInAt"
]
Expand Down
39 changes: 39 additions & 0 deletions packages/graphql-platform/src/node/dependency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,45 @@ export class EdgeDependency extends NodeDependencyTree {
return [...this.parent.path, this.edge];
}

/**
* An edge surfaces its head-node deletion to the flattened dependencies (and
* therefore to the broker) whenever it has children synthesizing a re-read of
* that head.
*
* When the head is deleted, the existence-filters those children generate
* towards it become unsatisfiable (the referencing rows have been
* cascade-deleted / set-null). Making the head deletion visible lets the
* dependent-graph fold those existences away instead of emitting a
* full-scanning existence towards a row that no longer exists.
*
* Note: this is intentionally added to `flattened` only, not to
* `currentLevel`: the head deletion must be *delivered* so the fold can see
* it, but it must NOT become a hit at this edge level (which would re-emit the
* very existence we want to fold).
*/
@MGetter
public override get flattened(): FlattenedNodeDependencyTree {
const flattened = super.flattened;

if (!this.children.size || !this.node.isDeletable()) {
return flattened;
}

const dependencies = new Map(flattened.dependencies);

const headDeletion = new NodeDependency(this.node, {
[utils.MutationType.DELETION]: true,
});

const current = dependencies.get(this.node);
dependencies.set(
this.node,
current ? current.mergeWith(headDeletion) : headDeletion,
);

return new FlattenedNodeDependencyTree(dependencies);
}

public attachTo(parent: NodeDependencyTree): this | EdgeDependency {
return new EdgeDependency(parent, this.edge, this.config);
}
Expand Down
Loading
Loading