Skip to content
Closed
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
@@ -0,0 +1,23 @@
import { ConnectionFilterCustomOperatorsPlugin } from '../src/plugins/ConnectionFilterCustomOperatorsPlugin';
import { $$filters } from '../src/types';

describe('connection-filter build-state ownership', () => {
it('clears the custom operator registry through the build lifecycle', () => {
let dispose: (() => void) | undefined;
const build: any = {
registerBuildStateDisposer(callback: () => void) {
dispose = callback;
},
};
const buildHook = ConnectionFilterCustomOperatorsPlugin.schema!.hooks!
.build as (build: any) => any;
buildHook(build);

const operators = new Map([['equalTo', { resolve: jest.fn() }]]);
build[$$filters].set('StringFilter', operators);
dispose!();

expect(operators.size).toBe(0);
expect(build[$$filters].size).toBe(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,17 @@ export const ConnectionFilterCustomOperatorsPlugin: GraphileConfig.Plugin = {
hooks: {
build(build) {
// Initialize the filter registry
build[$$filters] = new Map<
const filters = new Map<
string,
Map<string, ConnectionFilterOperatorSpec>
>();
build[$$filters] = filters;
build.registerBuildStateDisposer(() => {
for (const operators of filters.values()) {
operators.clear();
}
filters.clear();
});

return build;
},
Expand Down
19 changes: 14 additions & 5 deletions graphile/graphile-meta/__tests__/meta-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ function callGraphQLObjectTypeFieldsHook(
});
}

function callFinalizeHook(schema: GraphQLSchema, build: any): GraphQLSchema {
const finalizeHook = MetaSchemaPlugin.schema!.hooks!.finalize as (
schema: GraphQLSchema,
build: any
) => GraphQLSchema;
return finalizeHook(schema, build);
}

function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
Expand Down Expand Up @@ -2016,6 +2024,7 @@ describe('MetaSchemaPlugin', () => {
}),
types: [userType]
});
callFinalizeHook(schema, build);
const result = await graphql({
schema,
source: `
Expand Down Expand Up @@ -2086,6 +2095,7 @@ describe('MetaSchemaPlugin', () => {
})
]
});
callFinalizeHook(schema, build);
return schema;
};

Expand All @@ -2112,7 +2122,7 @@ describe('MetaSchemaPlugin', () => {
).toEqual(['Project']);
});

it('validates metadata against schema changes made by later finalizers', async () => {
it('snapshots metadata from the finalized executable schema', async () => {
const codec = createMockCodec('user', {
id: createMockAttribute('text')
});
Expand Down Expand Up @@ -2143,12 +2153,11 @@ describe('MetaSchemaPlugin', () => {
fields: queryFields
});
const schema = new GraphQLSchema({ query: queryType });

// Before later finalizers mutate the schema, the metadata resolves the
// list entry-point; the resolver must recompute from the final schema.
// Simulate an earlier finalizer removing an entry-point before the meta
// plugin snapshots the executable schema.
expect((collectTablesMeta(build, schema) as any[])[0].query.all).toBe('users');

delete queryType.getFields().users;
callFinalizeHook(schema, build);
const result = await graphql({
schema,
source: '{ _meta { tables { query { all } } } }'
Expand Down
23 changes: 12 additions & 11 deletions graphile/graphile-meta/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,19 @@ import type { MetaBuild, TableMeta } from './types';

const runtimeTablesBySchema = new WeakMap<GraphQLSchema, TableMeta[]>();

function getRuntimeTablesMeta(
build: MetaBuild,
schema: GraphQLSchema
): TableMeta[] {
let tables = runtimeTablesBySchema.get(schema);
function getRuntimeTablesMeta(schema: GraphQLSchema): TableMeta[] {
const tables = runtimeTablesBySchema.get(schema);
if (!tables) {
tables = collectTablesMeta(build, schema);
runtimeTablesBySchema.set(schema, tables);
throw new Error(
'Meta schema runtime state was not finalized for this GraphQL schema'
);
}
return tables;
}

/**
* Returns the table metadata memoized for the given executable schema, or
* `undefined` if `_meta` has not been resolved against that schema (e.g. the
* meta plugin is disabled or `_meta` was never executed).
* `undefined` when the meta plugin was not installed for that schema.
*/
export function getTablesMetaForSchema(
schema: GraphQLSchema
Expand All @@ -41,12 +38,16 @@ export const MetaSchemaPlugin: GraphileConfig.Plugin = {
hooks: {
GraphQLObjectType_fields(rawFields, rawBuild, rawContext) {
if (!rawContext.scope.isRootQuery) return rawFields;
const build = rawBuild as unknown as MetaBuild;
return extendQueryWithMetaField(
rawFields as unknown as Record<string, unknown>,
(schema) => getRuntimeTablesMeta(build, schema),
getRuntimeTablesMeta
) as typeof rawFields;
},
finalize(schema, rawBuild) {
const build = rawBuild as unknown as MetaBuild;
runtimeTablesBySchema.set(schema, collectTablesMeta(build, schema));
return schema;
},
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createUnifiedSearchPlugin } from '../plugin';

function getBuildHook(plugin: GraphileConfig.Plugin): (build: any) => any {
return plugin.schema!.hooks!.build as (build: any) => any;
}

describe('graphile-search build-state ownership', () => {
it('clears the unified-search codec cache through the build lifecycle', () => {
const detectColumns = jest.fn((): never[] => []);
const plugin = createUnifiedSearchPlugin({
adapters: [
{
name: 'test',
detectColumns,
registerTypes: jest.fn(),
scoreSemantics: { metric: 'score', lowerIsBetter: false },
} as never,
],
});
let dispose: (() => void) | undefined;
const build = {
registerBuildStateDisposer(callback: () => void) {
dispose = callback;
},
};
getBuildHook(plugin)(build);

const inferred = (plugin.schema!.entityBehavior!.pgCodecAttribute as any)
.inferred.callback;
const codec = { name: 'document', attributes: { body: {} } };
inferred([], [codec, 'body'], build);
inferred([], [codec, 'body'], build);
expect(detectColumns).toHaveBeenCalledTimes(1);

dispose!();
inferred([], [codec, 'body'], build);
expect(detectColumns).toHaveBeenCalledTimes(2);
});
});
5 changes: 5 additions & 0 deletions graphile/graphile-search/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,11 @@ export function createUnifiedSearchPlugin(
},

hooks: {
build(build) {
build.registerBuildStateDisposer(() => codecCache.clear());
return build;
},

/**
* Register all adapter-specific GraphQL types during init.
*/
Expand Down
181 changes: 181 additions & 0 deletions graphile/graphile-settings/__tests__/build-state-retirement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {
buildSchema,
defaultPreset,
QueryPlugin,
QueryQueryPlugin,
} from 'graphile-build';
import { resolvePreset } from 'graphile-config';
import { GraphQLSchema, graphqlSync } from 'graphql';

import { BuildStateRetirementPlugin } from '../src/plugins/build-state-retirement';
import { ConstructivePreset } from '../src/presets/constructive-preset';

declare global {
namespace GraphileConfig {
interface Plugins {
BuildStateRetirementTestOwnerPlugin: true;
BuildStateRetirementInvalidSchemaPlugin: true;
}
}
}

function makeOwnerPlugin(
owned: string[],
capture: (build: GraphileBuild.BuildBase) => void
): GraphileConfig.Plugin {
return {
name: 'BuildStateRetirementTestOwnerPlugin',
schema: {
hooks: {
build(build) {
capture(build);
build.registerBuildStateDisposer(() => owned.push('first'));
build.registerBuildStateDisposer(() => owned.push('second'));
return build;
},
},
},
};
}

describe('Constructive build-state retirement', () => {
it('opts Constructive in without changing the Graphile default preset', () => {
expect(resolvePreset(ConstructivePreset).plugins).toContain(
BuildStateRetirementPlugin
);
expect(resolvePreset(defaultPreset).plugins).not.toContain(
BuildStateRetirementPlugin
);
});

it('retains build state when the CNC plugin is not installed', () => {
const disposed: string[] = [];
let capturedBuild: GraphileBuild.BuildBase | undefined;

buildSchema(
{
plugins: [
QueryPlugin,
QueryQueryPlugin,
makeOwnerPlugin(disposed, (build) => {
capturedBuild = build;
}),
],
},
Object.create(null)
);

expect(disposed).toEqual([]);
expect(capturedBuild!.input).toEqual(Object.create(null));
});

it('retires after validation, in reverse disposer order, and preserves execution', () => {
const disposed: string[] = [];
let capturedBuild: GraphileBuild.BuildBase | undefined;
const schema = buildSchema(
{
plugins: [
QueryPlugin,
QueryQueryPlugin,
makeOwnerPlugin(disposed, (build) => {
capturedBuild = build;
}),
BuildStateRetirementPlugin,
],
},
Object.create(null)
);

expect(disposed).toEqual(['second', 'first']);
expect(() => capturedBuild!.input).toThrow(
expect.objectContaining({ code: 'GRAPHILE_BUILD_STATE_RELEASED' })
);
expect(graphqlSync({ schema, source: '{ __typename }' })).toEqual({
data: { __typename: 'Query' },
});
});

it('does not retire when schema validation fails', () => {
const disposed: string[] = [];
let capturedBuild: GraphileBuild.BuildBase | undefined;
const InvalidSchemaPlugin: GraphileConfig.Plugin = {
name: 'BuildStateRetirementInvalidSchemaPlugin',
schema: {
hooks: {
finalize() {
return new GraphQLSchema({});
},
},
},
};

expect(() =>
buildSchema(
{
plugins: [
QueryPlugin,
QueryQueryPlugin,
makeOwnerPlugin(disposed, (build) => {
capturedBuild = build;
}),
BuildStateRetirementPlugin,
InvalidSchemaPlugin,
],
},
Object.create(null)
)
).toThrow(/validation failure/);
expect(disposed).toEqual([]);
expect(capturedBuild!.input).toEqual(Object.create(null));
});

it('attempts every disposer and aggregates failures before failing closed', () => {
const calls: string[] = [];
let capturedBuild: GraphileBuild.BuildBase | undefined;
const FailingOwnerPlugin: GraphileConfig.Plugin = {
name: 'BuildStateRetirementTestOwnerPlugin',
schema: {
hooks: {
build(build) {
capturedBuild = build;
build.registerBuildStateDisposer(() => {
calls.push('first');
throw new Error('first failed');
});
build.registerBuildStateDisposer(() => {
calls.push('second');
throw new Error('second failed');
});
return build;
},
},
},
};

expect(() =>
buildSchema(
{
plugins: [
QueryPlugin,
QueryQueryPlugin,
FailingOwnerPlugin,
BuildStateRetirementPlugin,
],
},
Object.create(null)
)
).toThrow(
expect.objectContaining({
message: expect.stringContaining('2 disposal errors'),
errors: [
expect.objectContaining({ message: 'second failed' }),
expect.objectContaining({ message: 'first failed' }),
],
})
);
expect(calls).toEqual(['second', 'first']);
expect(() => capturedBuild!.input).toThrow(
expect.objectContaining({ code: 'GRAPHILE_BUILD_STATE_RELEASED' })
);
});
});
Loading