Skip to content

Commit db5c4e4

Browse files
committed
feat: _meta plumbing + CleanManyToManyRelation junction key fields
- Extend CleanManyToManyRelation with junctionLeftKeyFields, junctionRightKeyFields, leftKeyFields, rightKeyFields (both codegen and query packages) - Add MetaTableInfo type to SchemaSourceResult for _meta data flow - Add fetchGraphqlQuery() helper for arbitrary GraphQL queries - Endpoint source now fetches _meta in parallel with introspection (graceful fallback if endpoint lacks MetaSchemaPlugin) - Add buildSchemaWithMeta() to graphile-schema that returns SDL + cached tablesMeta from MetaSchemaPlugin - Database source now returns tablesMeta via buildSchemaWithMeta() Sets up the plumbing for PR 3 (ORM add/remove junction methods).
1 parent f67b6f3 commit db5c4e4

9 files changed

Lines changed: 215 additions & 21 deletions

File tree

graphile/graphile-schema/src/build-schema.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import deepmerge from 'deepmerge'
22
import { printSchema } from 'graphql'
3-
import { ConstructivePreset, makePgService } from 'graphile-settings'
3+
import { ConstructivePreset, makePgService, _cachedTablesMeta } from 'graphile-settings'
44
import { makeSchema } from 'graphile-build'
55
import { getPgPool } from 'pg-cache'
66
import { getPgEnvOptions } from 'pg-env'
@@ -12,7 +12,13 @@ export type BuildSchemaOptions = {
1212
graphile?: Partial<GraphileConfig.Preset>;
1313
};
1414

15-
export async function buildSchemaSDL(opts: BuildSchemaOptions): Promise<string> {
15+
export interface BuildSchemaWithMetaResult {
16+
sdl: string;
17+
/** Table metadata collected by MetaSchemaPlugin during schema build */
18+
tablesMeta: unknown[];
19+
}
20+
21+
async function buildSchemaInternal(opts: BuildSchemaOptions) {
1622
const database = opts.database ?? 'constructive'
1723
const schemas = Array.isArray(opts.schemas) ? opts.schemas : []
1824

@@ -39,5 +45,23 @@ export async function buildSchemaSDL(opts: BuildSchemaOptions): Promise<string>
3945
: basePreset
4046

4147
const { schema } = await makeSchema(preset)
48+
return { schema }
49+
}
50+
51+
export async function buildSchemaSDL(opts: BuildSchemaOptions): Promise<string> {
52+
const { schema } = await buildSchemaInternal(opts)
4253
return printSchema(schema)
4354
}
55+
56+
/**
57+
* Build schema SDL and also return _meta table metadata.
58+
* The MetaSchemaPlugin populates a module-level cache during makeSchema();
59+
* we snapshot it immediately after the build completes.
60+
*/
61+
export async function buildSchemaWithMeta(opts: BuildSchemaOptions): Promise<BuildSchemaWithMetaResult> {
62+
const { schema } = await buildSchemaInternal(opts)
63+
return {
64+
sdl: printSchema(schema),
65+
tablesMeta: [..._cachedTablesMeta],
66+
}
67+
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { buildSchemaSDL } from './build-schema';
2-
export type { BuildSchemaOptions } from './build-schema';
1+
export { buildSchemaSDL, buildSchemaWithMeta } from './build-schema';
2+
export type { BuildSchemaOptions, BuildSchemaWithMetaResult } from './build-schema';
33
export { fetchEndpointSchemaSDL } from './fetch-endpoint-schema';
44
export type { FetchEndpointSchemaOptions } from './fetch-endpoint-schema';

graphql/codegen/src/core/introspect/fetch-schema.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,79 @@ export interface FetchSchemaResult {
7171
statusCode?: number;
7272
}
7373

74+
export interface FetchGraphqlQueryResult<T = unknown> {
75+
success: boolean;
76+
data?: T;
77+
error?: string;
78+
statusCode?: number;
79+
}
80+
81+
/**
82+
* Execute an arbitrary GraphQL query against an endpoint.
83+
* Reuses the same HTTP plumbing as fetchSchema but accepts any query string.
84+
*/
85+
export async function fetchGraphqlQuery<T = unknown>(
86+
options: FetchSchemaOptions & { query: string },
87+
): Promise<FetchGraphqlQueryResult<T>> {
88+
const { endpoint, authorization, headers = {}, timeout = 30000, query } = options;
89+
90+
const url = new URL(endpoint);
91+
92+
const requestHeaders: Record<string, string> = {
93+
'Content-Type': 'application/json',
94+
Accept: 'application/json',
95+
...headers,
96+
};
97+
98+
if (authorization) {
99+
requestHeaders['Authorization'] = authorization;
100+
}
101+
102+
const body = JSON.stringify({ query, variables: {} });
103+
104+
const requestOptions: http.RequestOptions = {
105+
method: 'POST',
106+
headers: requestHeaders,
107+
};
108+
109+
try {
110+
const response = await makeRequest(url, requestOptions, body, timeout);
111+
112+
if (response.statusCode < 200 || response.statusCode >= 300) {
113+
return {
114+
success: false,
115+
error: `HTTP ${response.statusCode}: ${response.statusMessage}`,
116+
statusCode: response.statusCode,
117+
};
118+
}
119+
120+
const json = JSON.parse(response.data) as {
121+
data?: T;
122+
errors?: Array<{ message: string }>;
123+
};
124+
125+
if (json.errors && json.errors.length > 0) {
126+
const errorMessages = json.errors.map((e) => e.message).join('; ');
127+
return {
128+
success: false,
129+
error: `GraphQL errors: ${errorMessages}`,
130+
statusCode: response.statusCode,
131+
};
132+
}
133+
134+
return {
135+
success: true,
136+
data: json.data,
137+
statusCode: response.statusCode,
138+
};
139+
} catch (err) {
140+
if (err instanceof Error) {
141+
return { success: false, error: err.message };
142+
}
143+
return { success: false, error: 'Unknown error occurred' };
144+
}
145+
}
146+
74147
/**
75148
* Fetch the full schema introspection from a GraphQL endpoint
76149
*/

graphql/codegen/src/core/introspect/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export { pluralize, singularize } from 'inflekt';
1212
// Schema sources
1313
export type {
1414
CreateSchemaSourceOptions,
15+
MetaTableInfo,
1516
SchemaSource,
1617
SchemaSourceResult,
1718
} from './source';
@@ -24,8 +25,8 @@ export {
2425
} from './source';
2526

2627
// Schema fetching (still used by watch mode)
27-
export type { FetchSchemaOptions, FetchSchemaResult } from './fetch-schema';
28-
export { fetchSchema } from './fetch-schema';
28+
export type { FetchSchemaOptions, FetchSchemaResult, FetchGraphqlQueryResult } from './fetch-schema';
29+
export { fetchSchema, fetchGraphqlQuery } from './fetch-schema';
2930

3031
// Transform utilities (only filterTables, getTableNames, findTable are still useful)
3132
export { filterTables, findTable, getTableNames } from './transform';

graphql/codegen/src/core/introspect/source/database.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@
33
*
44
* Loads GraphQL schema directly from a PostgreSQL database using PostGraphile
55
* introspection and converts it to introspection format.
6+
* Also returns _meta table metadata when available (via MetaSchemaPlugin cache).
67
*/
78
import { buildSchema, introspectionFromSchema } from 'graphql';
89

9-
import { buildSchemaSDL } from 'graphile-schema';
10+
import { buildSchemaWithMeta } from 'graphile-schema';
1011

1112
import type { IntrospectionQueryResponse } from '../../../types/introspection';
1213
import {
1314
createDatabasePool,
1415
resolveApiSchemas,
1516
validateServicesSchemas,
1617
} from './api-schemas';
17-
import type { SchemaSource, SchemaSourceResult } from './types';
18+
import type { MetaTableInfo, SchemaSource, SchemaSourceResult } from './types';
1819
import { SchemaSourceError } from './types';
1920

2021
export interface DatabaseSchemaSourceOptions {
@@ -78,13 +79,16 @@ export class DatabaseSchemaSource implements SchemaSource {
7879
schemas = this.options.schemas ?? ['public'];
7980
}
8081

81-
// Build SDL from database
82+
// Build SDL + _meta from database
8283
let sdl: string;
84+
let tablesMeta: unknown[] = [];
8385
try {
84-
sdl = await buildSchemaSDL({
86+
const result = await buildSchemaWithMeta({
8587
database,
8688
schemas,
8789
});
90+
sdl = result.sdl;
91+
tablesMeta = result.tablesMeta;
8892
} catch (err) {
8993
throw new SchemaSourceError(
9094
`Failed to introspect database: ${err instanceof Error ? err.message : 'Unknown error'}`,
@@ -130,7 +134,10 @@ export class DatabaseSchemaSource implements SchemaSource {
130134
JSON.stringify(introspectionResult),
131135
) as IntrospectionQueryResponse;
132136

133-
return { introspection };
137+
return {
138+
introspection,
139+
tablesMeta: tablesMeta as MetaTableInfo[],
140+
};
134141
}
135142

136143
describe(): string {

graphql/codegen/src/core/introspect/source/endpoint.ts

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,43 @@
22
* Endpoint Schema Source
33
*
44
* Fetches GraphQL schema via introspection from a live endpoint.
5-
* Wraps the existing fetchSchema() function with the SchemaSource interface.
5+
* Optionally fetches _meta query in parallel for M:N junction key metadata.
66
*/
7-
import { fetchSchema } from '../fetch-schema';
8-
import type { SchemaSource, SchemaSourceResult } from './types';
7+
import { fetchSchema, fetchGraphqlQuery } from '../fetch-schema';
8+
import type { MetaTableInfo, SchemaSource, SchemaSourceResult } from './types';
99
import { SchemaSourceError } from './types';
1010

11+
/**
12+
* _meta GraphQL query — fetches M:N junction key metadata.
13+
* Only the fields needed for enriching CleanManyToManyRelation are selected.
14+
*/
15+
const META_QUERY = `{
16+
_meta {
17+
tables {
18+
name
19+
schemaName
20+
relations {
21+
manyToMany {
22+
fieldName
23+
type
24+
junctionTable { name }
25+
junctionLeftKeyAttributes { name }
26+
junctionRightKeyAttributes { name }
27+
leftKeyAttributes { name }
28+
rightKeyAttributes { name }
29+
rightTable { name }
30+
}
31+
}
32+
}
33+
}
34+
}`;
35+
36+
interface MetaQueryResponse {
37+
_meta: {
38+
tables: MetaTableInfo[];
39+
};
40+
}
41+
1142
export interface EndpointSchemaSourceOptions {
1243
/**
1344
* GraphQL endpoint URL
@@ -41,30 +72,45 @@ export class EndpointSchemaSource implements SchemaSource {
4172
}
4273

4374
async fetch(): Promise<SchemaSourceResult> {
44-
const result = await fetchSchema({
75+
const fetchOpts = {
4576
endpoint: this.options.endpoint,
4677
authorization: this.options.authorization,
4778
headers: this.options.headers,
4879
timeout: this.options.timeout,
49-
});
80+
};
81+
82+
// Run introspection and _meta query in parallel.
83+
// _meta is best-effort: if the endpoint doesn't expose it, we proceed without.
84+
const [introspectionResult, metaResult] = await Promise.all([
85+
fetchSchema(fetchOpts),
86+
fetchGraphqlQuery<MetaQueryResponse>({ ...fetchOpts, query: META_QUERY })
87+
.catch((): null => null),
88+
]);
5089

51-
if (!result.success) {
90+
if (!introspectionResult.success) {
5291
throw new SchemaSourceError(
53-
result.error ?? 'Unknown error fetching schema',
92+
introspectionResult.error ?? 'Unknown error fetching schema',
5493
this.describe(),
5594
);
5695
}
5796

58-
if (!result.data) {
97+
if (!introspectionResult.data) {
5998
throw new SchemaSourceError(
6099
'No introspection data returned',
61100
this.describe(),
62101
);
63102
}
64103

65-
return {
66-
introspection: result.data,
104+
const result: SchemaSourceResult = {
105+
introspection: introspectionResult.data,
67106
};
107+
108+
// Attach _meta data if available
109+
if (metaResult?.success && metaResult.data?._meta?.tables) {
110+
result.tablesMeta = metaResult.data._meta.tables;
111+
}
112+
113+
return result;
68114
}
69115

70116
describe(): string {

graphql/codegen/src/core/introspect/source/types.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@
66
*/
77
import type { IntrospectionQueryResponse } from '../../../types/introspection';
88

9+
/**
10+
* Minimal table metadata from the _meta query, used to enrich M:N relations
11+
* with junction key field information that isn't available from introspection alone.
12+
*/
13+
export interface MetaTableInfo {
14+
name: string;
15+
schemaName: string;
16+
relations: {
17+
manyToMany: Array<{
18+
fieldName: string | null;
19+
type: string | null;
20+
junctionTable: { name: string };
21+
junctionLeftKeyAttributes: Array<{ name: string }>;
22+
junctionRightKeyAttributes: Array<{ name: string }>;
23+
leftKeyAttributes: Array<{ name: string }>;
24+
rightKeyAttributes: Array<{ name: string }>;
25+
rightTable: { name: string };
26+
}>;
27+
};
28+
}
29+
930
/**
1031
* Result from fetching a schema source
1132
*/
@@ -14,6 +35,12 @@ export interface SchemaSourceResult {
1435
* The GraphQL introspection data
1536
*/
1637
introspection: IntrospectionQueryResponse;
38+
39+
/**
40+
* Optional table metadata from _meta query (provides M:N junction key details).
41+
* Present when the source supports _meta (database mode or endpoints with MetaSchemaPlugin).
42+
*/
43+
tablesMeta?: MetaTableInfo[];
1744
}
1845

1946
/**

graphql/codegen/src/types/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,14 @@ export interface CleanManyToManyRelation {
192192
rightTable: string;
193193
junctionTable: string;
194194
type: string | null;
195+
/** Junction FK field names pointing to the left table */
196+
junctionLeftKeyFields?: string[];
197+
/** Junction FK field names pointing to the right table */
198+
junctionRightKeyFields?: string[];
199+
/** Left table key fields (usually just 'id') */
200+
leftKeyFields?: string[];
201+
/** Right table key fields (usually just 'id') */
202+
rightKeyFields?: string[];
195203
}
196204

197205
// ============================================================================

graphql/query/src/types/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,14 @@ export interface CleanManyToManyRelation {
192192
rightTable: string;
193193
junctionTable: string;
194194
type: string | null;
195+
/** Junction FK field names pointing to the left table */
196+
junctionLeftKeyFields?: string[];
197+
/** Junction FK field names pointing to the right table */
198+
junctionRightKeyFields?: string[];
199+
/** Left table key fields (usually just 'id') */
200+
leftKeyFields?: string[];
201+
/** Right table key fields (usually just 'id') */
202+
rightKeyFields?: string[];
195203
}
196204

197205
// ============================================================================

0 commit comments

Comments
 (0)