Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ jobs:
- batch: pg-postgres
packages: 'postgres/pgsql-test postgres/drizzle-orm-test postgres/introspectron graphile/graphile-test graphile/graphile-connection-filter graphile/graphile-postgis'
- batch: pg-graphql
packages: 'graphile/graphile-search graphile/graphile-ltree graphile/graphile-bulk-mutations graphile/graphile-function-bindings graphile/graphile-history graphql/orm-test graphql/test graphql/playwright-test'
packages: 'graphile/graphile-search graphile/graphile-ltree graphile/graphile-bulk-mutations graphile/graphile-function-bindings graphile/graphile-history graphile/graphile-meta graphile/graphile-schema graphql/orm-test graphql/test graphql/playwright-test'

env:
PGHOST: localhost
Expand Down
31 changes: 12 additions & 19 deletions graphile/graphile-meta/__tests__/meta-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import {

import {
_buildFieldMeta,
_cachedTablesMeta,
_pgTypeToGqlType,
getTablesMetaForSchema,
MetaSchemaPlugin
} from '../src';
import { collectTablesMeta } from '../src/table-meta-builder';
Expand Down Expand Up @@ -136,14 +136,6 @@ 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 @@ -2024,9 +2016,6 @@ describe('MetaSchemaPlugin', () => {
}),
types: [userType]
});
callFinalizeHook(schema, build);
const seededTables = _cachedTablesMeta as any[];

const result = await graphql({
schema,
source: `
Expand All @@ -2047,6 +2036,7 @@ describe('MetaSchemaPlugin', () => {

expect(result.errors).toBeUndefined();
expect(result.data?.ping).toBe('pong');
const seededTables = getTablesMetaForSchema(schema) as any[];
expect((result.data as any)?._meta?.tables).toHaveLength(seededTables.length);
expect((result.data as any)?._meta?.tables?.[0]).toMatchObject({
name: 'User',
Expand All @@ -2068,7 +2058,7 @@ describe('MetaSchemaPlugin', () => {
]);
});

it('keeps resolver metadata scoped while replacing the legacy cache per build', async () => {
it('keeps resolver metadata scoped per executable schema', async () => {
const buildSchema = (resourceName: string, typeName: string) => {
const build = createMockBuild({
[resourceName]: {
Expand Down Expand Up @@ -2096,15 +2086,11 @@ describe('MetaSchemaPlugin', () => {
})
]
});
callFinalizeHook(schema, build);
return schema;
};

const userSchema = buildSchema('user', 'User');
expect(_cachedTablesMeta.map((table) => table.name)).toEqual(['User']);

const projectSchema = buildSchema('project', 'Project');
expect(_cachedTablesMeta.map((table) => table.name)).toEqual(['Project']);
const source = '{ _meta { tables { name } } }';

const [userResult, projectResult] = await Promise.all([
Expand All @@ -2118,6 +2104,12 @@ describe('MetaSchemaPlugin', () => {
expect((projectResult.data as any)._meta.tables).toEqual([
{ name: 'Project' }
]);
expect(
getTablesMetaForSchema(userSchema)!.map((table) => table.name)
).toEqual(['User']);
expect(
getTablesMetaForSchema(projectSchema)!.map((table) => table.name)
).toEqual(['Project']);
});

it('validates metadata against schema changes made by later finalizers', async () => {
Expand Down Expand Up @@ -2152,8 +2144,9 @@ describe('MetaSchemaPlugin', () => {
});
const schema = new GraphQLSchema({ query: queryType });

callFinalizeHook(schema, build);
expect(_cachedTablesMeta[0].query.all).toBe('users');
// Before later finalizers mutate the schema, the metadata resolves the
// list entry-point; the resolver must recompute from the final schema.
expect((collectTablesMeta(build, schema) as any[])[0].query.all).toBe('users');

delete queryType.getFields().users;
const result = await graphql({
Expand Down
11 changes: 0 additions & 11 deletions graphile/graphile-meta/src/cache.ts

This file was deleted.

7 changes: 2 additions & 5 deletions graphile/graphile-meta/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@

import type { GraphileConfig } from 'graphile-config';

import { cachedTablesMeta } from './cache';
import { buildScalarEncoding } from './encoding-meta-builders';
import { MetaSchemaPlugin } from './plugin';
import { getTablesMetaForSchema, MetaSchemaPlugin } from './plugin';
import { buildFieldMeta, pgTypeToGqlType } from './type-mappings';

export { MetaSchemaPlugin };
export { getTablesMetaForSchema, MetaSchemaPlugin };

export const MetaSchemaPreset: GraphileConfig.Preset = {
plugins: [MetaSchemaPlugin],
Expand Down Expand Up @@ -40,8 +39,6 @@ export { pgTypeToGqlType as _pgTypeToGqlType };
/** @internal Exported for testing only */
export { buildFieldMeta as _buildFieldMeta };
/** @internal Exported for testing only */
export { cachedTablesMeta as _cachedTablesMeta };
/** @internal Exported for testing only */
export { buildScalarEncoding as _buildScalarEncoding };

export default MetaSchemaPlugin;
23 changes: 11 additions & 12 deletions graphile/graphile-meta/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import 'graphile-build';
import type { GraphileConfig } from 'graphile-config';
import type { GraphQLSchema } from 'graphql';

import { setCachedTablesMeta } from './cache';
import { extendQueryWithMetaField } from './graphql-meta-field';
import { collectTablesMeta } from './table-meta-builder';
import type { MetaBuild, TableMeta } from './types';
Expand All @@ -19,11 +18,21 @@ function getRuntimeTablesMeta(
if (!tables) {
tables = collectTablesMeta(build, schema);
runtimeTablesBySchema.set(schema, tables);
setCachedTablesMeta(tables);
}
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).
*/
export function getTablesMetaForSchema(
schema: GraphQLSchema
): TableMeta[] | undefined {
return runtimeTablesBySchema.get(schema);
}

export const MetaSchemaPlugin: GraphileConfig.Plugin = {
name: 'MetaSchemaPlugin',
version: '1.0.0',
Expand All @@ -38,16 +47,6 @@ export const MetaSchemaPlugin: GraphileConfig.Plugin = {
(schema) => getRuntimeTablesMeta(build, schema),
) as typeof rawFields;
},

finalize(schema, rawBuild) {
// Populate the legacy module-level cache for consumers that read
// `_cachedTablesMeta` without executing `_meta`. Deliberately does NOT
// pre-warm the per-schema memo: later finalizers may still mutate the
// schema, and the resolver must recompute from its final info.schema.
const build = rawBuild as unknown as MetaBuild;
setCachedTablesMeta(collectTablesMeta(build, schema));
return schema;
},
},
},
};
144 changes: 144 additions & 0 deletions graphile/graphile-schema/__tests__/build-schema-artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { pgCache } from 'pg-cache';
import { getConnections, PgTestClient } from 'pgsql-test';

import { buildIntrospectionJSON } from '../src/build-introspection';
import { buildSchemaArtifacts } from '../src/build-schema';

jest.setTimeout(60000);

let pg: PgTestClient;
let teardown: () => Promise<void>;
let database: string;

beforeAll(async () => {
({ pg, teardown } = await getConnections());
database = pg.config.database;

await pg.query(`
CREATE SCHEMA schema_a;
CREATE TABLE schema_a.alpha_items (
id serial PRIMARY KEY,
title text NOT NULL
);

CREATE SCHEMA schema_b;
CREATE TABLE schema_b.beta_widgets (
id serial PRIMARY KEY,
label text NOT NULL
);
`);
});

afterAll(async () => {
// Release the pg-cache pool created by buildSchemaArtifacts before the
// ephemeral database is dropped.
pgCache.delete(database);
await pgCache.waitForDisposals();
await teardown();
});

function tableNames(tables: { tableName: string }[]): string[] {
return tables.map((t) => t.tableName).sort();
}

describe('buildSchemaArtifacts', () => {
it('returns SDL and tablesMeta from the same schema build', async () => {
const a = await buildSchemaArtifacts({ database, schemas: ['schema_a'] });
expect(a.sdl).toContain('AlphaItem');
expect(a.sdl).not.toContain('BetaWidget');
expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']);

const b = await buildSchemaArtifacts({ database, schemas: ['schema_b'] });
expect(b.sdl).toContain('BetaWidget');
expect(tableNames(b.tablesMeta)).toEqual(['beta_widgets']);

// The earlier result must be unaffected by the later build.
expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']);
});

it('keeps results correlated under a forced A write -> B write -> A read schedule', async () => {
// Deterministically reproduce the unsafe ordering from the legacy split
// contract: caller A finishes collecting metadata (legacy global written),
// caller B builds fully (legacy global overwritten), then caller A resumes
// and returns. The correlated artifact API must still return A's metadata.
let releaseA!: () => void;
const gateA = new Promise<void>((resolve) => {
releaseA = resolve;
});
let signalACollected!: () => void;
const aCollected = new Promise<void>((resolve) => {
signalACollected = resolve;
});

const pendingA = buildSchemaArtifacts({
database,
schemas: ['schema_a'],
_onMetaCollected: async () => {
signalACollected();
await gateA;
}
});

await aCollected;
const b = await buildSchemaArtifacts({ database, schemas: ['schema_b'] });
releaseA();
const a = await pendingA;

expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']);
expect(a.sdl).toContain('AlphaItem');
expect(tableNames(b.tablesMeta)).toEqual(['beta_widgets']);
expect(b.sdl).toContain('BetaWidget');
});

it('keeps concurrent uncoordinated builds correlated', async () => {
const [a, b] = await Promise.all([
buildSchemaArtifacts({ database, schemas: ['schema_a'] }),
buildSchemaArtifacts({ database, schemas: ['schema_b'] })
]);

expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']);
expect(tableNames(b.tablesMeta)).toEqual(['beta_widgets']);
});

it('returns explicit empty metadata when the meta plugin is disabled', async () => {
const artifacts = await buildSchemaArtifacts({
database,
schemas: ['schema_a'],
graphile: { disablePlugins: ['MetaSchemaPlugin'] }
});

expect(artifacts.sdl).toContain('AlphaItem');
expect(artifacts.sdl).not.toContain('_meta');
expect(artifacts.tablesMeta).toEqual([]);
});
});

describe('buildIntrospectionJSON', () => {
it('returns metadata correlated to its own build', async () => {
let releaseA!: () => void;
const gateA = new Promise<void>((resolve) => {
releaseA = resolve;
});
let signalACollected!: () => void;
const aCollected = new Promise<void>((resolve) => {
signalACollected = resolve;
});

const pendingA = buildIntrospectionJSON({
database,
schemas: ['schema_a'],
_onMetaCollected: async () => {
signalACollected();
await gateA;
}
});

await aCollected;
const b = await buildIntrospectionJSON({ database, schemas: ['schema_b'] });
releaseA();
const a = await pendingA;

expect(tableNames(a)).toEqual(['alpha_items']);
expect(tableNames(b)).toEqual(['beta_widgets']);
});
});
1 change: 1 addition & 0 deletions graphile/graphile-schema/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
},
"devDependencies": {
"makage": "^0.3.0",
"pgsql-test": "workspace:^",
"ts-node": "^10.9.2"
},
"keywords": [
Expand Down
13 changes: 6 additions & 7 deletions graphile/graphile-schema/src/build-introspection.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import type { TableMeta } from 'graphile-settings'
import { _cachedTablesMeta } from 'graphile-settings'
import { buildSchemaSDL } from './build-schema'
import { buildSchemaArtifacts } from './build-schema'
import type { BuildSchemaOptions } from './build-schema'

export type { BuildSchemaOptions as BuildIntrospectionOptions }

/**
* Build introspection metadata for all tables visible in the given schemas.
*
* Internally calls `buildSchemaSDL()` which triggers the MetaSchemaPlugin
* finalization hook, populating `_cachedTablesMeta` as a side-effect. The cached
* metadata is then returned as a plain array of `TableMeta` objects.
* Internally calls `buildSchemaArtifacts()`, which returns SDL and `_meta`
* metadata from one correlated build boundary — both derived from the same
* final executable `GraphQLSchema` — so concurrent builds in one process
* cannot return each other's metadata.
*
* The result includes every table's fields, types, constraints, indexes,
* relations, inflection names, and query entry-points — the same data
Expand All @@ -31,6 +31,5 @@ export type { BuildSchemaOptions as BuildIntrospectionOptions }
export async function buildIntrospectionJSON(
opts: BuildSchemaOptions
): Promise<TableMeta[]> {
await buildSchemaSDL(opts)
return [..._cachedTablesMeta]
return (await buildSchemaArtifacts(opts)).tablesMeta
}
Loading
Loading