From 9f3c12264cc3b8089ba326c04b42bb35eb5d4d75 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 27 Mar 2026 20:47:01 +0000 Subject: [PATCH 1/4] feat(codegen): optional pgTypesFile enrichment for pgvector/tsvector/PostGIS detection - Add pgTypesFile config option to GraphQLSDKConfigTarget - Create enrichPgTypes() to load JSON and merge pgType into Table fields - Add enrichment step to pipeline after inferTablesFromIntrospection() - Add FullText GQL scalar fallback to isTsvectorField() detection - Add --dump-pg-types CLI command to generate pg-types.json from introspection - Add buildPgTypesMap() and writePgTypesFile() utilities - Add 17 tests covering enrichment, loading, and dump functions --- .../introspect/dump-pg-types.test.ts | 96 +++++++++ .../introspect/enrich-pg-types.test.ts | 200 ++++++++++++++++++ graphql/codegen/src/cli/handler.ts | 63 ++++++ graphql/codegen/src/cli/index.ts | 9 +- .../codegen/cli/table-command-generator.ts | 2 +- .../codegen/src/core/codegen/docs-utils.ts | 5 +- graphql/codegen/src/core/dump-pg-types.ts | 63 ++++++ .../src/core/introspect/enrich-pg-types.ts | 118 +++++++++++ graphql/codegen/src/core/pipeline/index.ts | 11 + graphql/codegen/src/types/config.ts | 14 ++ 10 files changed, 578 insertions(+), 3 deletions(-) create mode 100644 graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts create mode 100644 graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts create mode 100644 graphql/codegen/src/core/dump-pg-types.ts create mode 100644 graphql/codegen/src/core/introspect/enrich-pg-types.ts diff --git a/graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts b/graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts new file mode 100644 index 000000000..c6eccd1dd --- /dev/null +++ b/graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts @@ -0,0 +1,96 @@ +import { buildPgTypesMap } from '../../core/dump-pg-types'; +import type { Table } from '../../types/schema'; + +function makeField(name: string, gqlType: string, isArray = false, pgType?: string | null) { + return { + name, + type: { + gqlType, + isArray, + ...(pgType !== undefined ? { pgType } : {}), + }, + }; +} + +function makeTable(name: string, fields: ReturnType[]): Table { + return { + name, + fields, + relations: { belongsTo: [], hasOne: [], hasMany: [], manyToMany: [] }, + }; +} + +describe('buildPgTypesMap', () => { + it('should build map from tables with pgType', () => { + const tables = [ + makeTable('Document', [ + makeField('id', 'UUID', false, 'uuid'), + makeField('vectorEmbedding', 'Float', true, 'vector'), + makeField('tsvContent', 'FullText', false, 'tsvector'), + ]), + ]; + + const result = buildPgTypesMap(tables); + expect(result).toEqual({ + Document: { + id: { pgType: 'uuid' }, + vectorEmbedding: { pgType: 'vector' }, + tsvContent: { pgType: 'tsvector' }, + }, + }); + }); + + it('should set pgType to null when not available (template mode)', () => { + const tables = [ + makeTable('User', [ + makeField('id', 'UUID'), + makeField('name', 'String'), + ]), + ]; + + const result = buildPgTypesMap(tables); + expect(result).toEqual({ + User: { + id: { pgType: null }, + name: { pgType: null }, + }, + }); + }); + + it('should include pgAlias and typmod when present', () => { + const tables: Table[] = [ + { + name: 'Article', + fields: [ + { + name: 'embedding', + type: { + gqlType: 'Float', + isArray: true, + pgType: 'vector', + pgAlias: 'vector', + typmod: 768, + }, + }, + ], + relations: { belongsTo: [], hasOne: [], hasMany: [], manyToMany: [] }, + }, + ]; + + const result = buildPgTypesMap(tables); + expect(result.Article.embedding).toEqual({ + pgType: 'vector', + pgAlias: 'vector', + typmod: 768, + }); + }); + + it('should handle empty tables array', () => { + expect(buildPgTypesMap([])).toEqual({}); + }); + + it('should handle tables with no fields', () => { + const tables = [makeTable('Empty', [])]; + expect(buildPgTypesMap(tables)).toEqual({}); + }); +}); diff --git a/graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts b/graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts new file mode 100644 index 000000000..5d6393773 --- /dev/null +++ b/graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts @@ -0,0 +1,200 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + enrichPgTypes, + enrichPgTypesFromFile, + loadPgTypesFile, +} from '../../core/introspect/enrich-pg-types'; +import type { Table } from '../../types/schema'; + +function makeField(name: string, gqlType: string, isArray = false, pgType?: string) { + return { + name, + type: { + gqlType, + isArray, + ...(pgType !== undefined ? { pgType } : {}), + }, + }; +} + +function makeTable(name: string, fields: ReturnType[]): Table { + return { + name, + fields, + relations: { belongsTo: [], hasOne: [], hasMany: [], manyToMany: [] }, + }; +} + +describe('enrichPgTypes', () => { + it('should enrich fields with pgType from map', () => { + const tables = [ + makeTable('Document', [ + makeField('id', 'UUID'), + makeField('vectorEmbedding', 'Float', true), + makeField('tsvContent', 'FullText'), + ]), + ]; + + const count = enrichPgTypes(tables, { + Document: { + vectorEmbedding: { pgType: 'vector' }, + tsvContent: { pgType: 'tsvector' }, + }, + }); + + expect(count).toBe(2); + expect(tables[0].fields[1].type.pgType).toBe('vector'); + expect(tables[0].fields[2].type.pgType).toBe('tsvector'); + // Should NOT touch fields not in the map + expect(tables[0].fields[0].type.pgType).toBeUndefined(); + }); + + it('should enrich pgAlias and typmod', () => { + const tables = [ + makeTable('Article', [ + makeField('bodyEmbedding', 'Float', true), + ]), + ]; + + const count = enrichPgTypes(tables, { + Article: { + bodyEmbedding: { pgType: 'vector', pgAlias: 'vector', typmod: 768 }, + }, + }); + + expect(count).toBe(1); + expect(tables[0].fields[0].type.pgType).toBe('vector'); + expect(tables[0].fields[0].type.pgAlias).toBe('vector'); + expect(tables[0].fields[0].type.typmod).toBe(768); + }); + + it('should skip tables not in the map', () => { + const tables = [ + makeTable('User', [makeField('id', 'UUID')]), + ]; + + const count = enrichPgTypes(tables, { + Document: { vectorEmbedding: { pgType: 'vector' } }, + }); + + expect(count).toBe(0); + }); + + it('should skip fields not in the map', () => { + const tables = [ + makeTable('Document', [ + makeField('id', 'UUID'), + makeField('title', 'String'), + ]), + ]; + + const count = enrichPgTypes(tables, { + Document: { vectorEmbedding: { pgType: 'vector' } }, + }); + + expect(count).toBe(0); + }); + + it('should not overwrite gqlType or isArray', () => { + const tables = [ + makeTable('Document', [ + makeField('vectorEmbedding', 'Float', true), + ]), + ]; + + enrichPgTypes(tables, { + Document: { vectorEmbedding: { pgType: 'vector' } }, + }); + + expect(tables[0].fields[0].type.gqlType).toBe('Float'); + expect(tables[0].fields[0].type.isArray).toBe(true); + expect(tables[0].fields[0].type.pgType).toBe('vector'); + }); + + it('should handle empty map gracefully', () => { + const tables = [makeTable('User', [makeField('id', 'UUID')])]; + const count = enrichPgTypes(tables, {}); + expect(count).toBe(0); + }); +}); + +describe('loadPgTypesFile', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pg-types-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return null for non-existent file', () => { + const result = loadPgTypesFile(path.join(tmpDir, 'nonexistent.json')); + expect(result).toBeNull(); + }); + + it('should load valid JSON file', () => { + const filePath = path.join(tmpDir, 'pg-types.json'); + fs.writeFileSync(filePath, JSON.stringify({ + Document: { vectorEmbedding: { pgType: 'vector' } }, + })); + + const result = loadPgTypesFile(filePath); + expect(result).toEqual({ + Document: { vectorEmbedding: { pgType: 'vector' } }, + }); + }); + + it('should throw on invalid JSON', () => { + const filePath = path.join(tmpDir, 'bad.json'); + fs.writeFileSync(filePath, 'not json'); + + expect(() => loadPgTypesFile(filePath)).toThrow(); + }); + + it('should throw on non-object JSON', () => { + const filePath = path.join(tmpDir, 'array.json'); + fs.writeFileSync(filePath, '["not", "an", "object"]'); + + expect(() => loadPgTypesFile(filePath)).toThrow(/expected a JSON object/); + }); +}); + +describe('enrichPgTypesFromFile', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pg-types-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return 0 for non-existent file', () => { + const tables = [makeTable('User', [makeField('id', 'UUID')])]; + const count = enrichPgTypesFromFile(tables, path.join(tmpDir, 'nope.json')); + expect(count).toBe(0); + }); + + it('should load file and enrich tables', () => { + const filePath = path.join(tmpDir, 'pg-types.json'); + fs.writeFileSync(filePath, JSON.stringify({ + Document: { tsvContent: { pgType: 'tsvector' } }, + })); + + const tables = [ + makeTable('Document', [ + makeField('tsvContent', 'FullText'), + ]), + ]; + + const count = enrichPgTypesFromFile(tables, filePath); + expect(count).toBe(1); + expect(tables[0].fields[0].type.pgType).toBe('tsvector'); + }); +}); diff --git a/graphql/codegen/src/cli/handler.ts b/graphql/codegen/src/cli/handler.ts index 3a8d3573f..fe568ad3a 100644 --- a/graphql/codegen/src/cli/handler.ts +++ b/graphql/codegen/src/cli/handler.ts @@ -8,6 +8,7 @@ import type { Question } from 'inquirerer'; import { findConfigFile, loadConfigFile } from '../core/config'; +import { buildPgTypesMap, writePgTypesFile } from '../core/dump-pg-types'; import { expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, generate, generateMulti } from '../core/generate'; import type { GraphQLSDKConfigTarget } from '../types/config'; import { @@ -31,6 +32,12 @@ export async function runCodegenHandler( ): Promise { const args = camelizeArgv(argv as Record); + // Handle --dump-pg-types: run pipeline, extract pg type metadata, write JSON + if (args.dumpPgTypes) { + await handleDumpPgTypes(args, prompter); + return; + } + const schemaConfig = args.schemaEnabled ? { enabled: true, @@ -129,3 +136,59 @@ export async function runCodegenHandler( }); printResult(result); } + +async function handleDumpPgTypes( + args: Record, + prompter: Prompter, +): Promise { + const hasSourceFlags = Boolean( + args.endpoint || args.schemaFile || args.schemaDir || args.schemas || args.apiNames + ); + const configPath = + (args.config as string | undefined) || + (!hasSourceFlags ? findConfigFile() : undefined); + + let fileConfig: GraphQLSDKConfigTarget = {}; + if (configPath) { + const loaded = await loadConfigFile(configPath); + if (!loaded.success) { + console.error('x', loaded.error); + process.exit(1); + } + fileConfig = loaded.config as GraphQLSDKConfigTarget; + } + + const seeded = seedArgvFromConfig(args, fileConfig); + const answers = hasResolvedCodegenSource(seeded) + ? seeded + : await prompter.prompt(seeded, codegenQuestions); + const options = buildGenerateOptions(answers, fileConfig); + + // Run the pipeline to get tables (we need the full codegen pipeline to infer tables) + const result = await generate({ + ...options, + dryRun: true, // Don't write codegen files, just get pipeline data + }); + + if (!result.success || !result.pipelineData?.tables.length) { + console.error('x', result.message || 'No tables found'); + process.exit(1); + } + + const pgTypes = buildPgTypesMap(result.pipelineData.tables); + const outputPath = typeof args.dumpPgTypes === 'string' + ? args.dumpPgTypes + : 'pg-types.json'; + const written = await writePgTypesFile(pgTypes, outputPath); + + const tableCount = Object.keys(pgTypes).length; + const fieldCount = Object.values(pgTypes).reduce( + (sum, fields) => sum + Object.keys(fields).length, + 0, + ); + console.log(`[ok] Wrote pg-types.json: ${tableCount} tables, ${fieldCount} fields`); + console.log(` ${written}`); + console.log(''); + console.log('Usage: add pgTypesFile to your codegen config:'); + console.log(` { pgTypesFile: '${outputPath}' }`); +} diff --git a/graphql/codegen/src/cli/index.ts b/graphql/codegen/src/cli/index.ts index c0889131f..aeda8e92d 100644 --- a/graphql/codegen/src/cli/index.ts +++ b/graphql/codegen/src/cli/index.ts @@ -42,6 +42,13 @@ Schema Export: --schema-output Output directory for the exported schema file --schema-filename Filename for the exported schema (default: schema.graphql) +PostgreSQL Type Metadata: + --dump-pg-types [path] Generate pg-types.json from introspection. + Captures pgType, pgAlias, typmod for each field. + Use with pgTypesFile config option for precise detection + of pgvector, tsvector, PostGIS columns. + Default output: ./pg-types.json + -h, --help Show this help message --version Show version number `; @@ -79,7 +86,7 @@ export const options: Partial = { a: 'authorization', v: 'verbose', }, - boolean: ['schema-enabled'], + boolean: ['schema-enabled', 'dump-pg-types'], string: [ 'config', 'endpoint', diff --git a/graphql/codegen/src/core/codegen/cli/table-command-generator.ts b/graphql/codegen/src/core/codegen/cli/table-command-generator.ts index c44af1578..3977983b4 100644 --- a/graphql/codegen/src/core/codegen/cli/table-command-generator.ts +++ b/graphql/codegen/src/core/codegen/cli/table-command-generator.ts @@ -547,7 +547,7 @@ function buildSearchHandler( const whereProps: t.ObjectProperty[] = []; for (const group of specialGroups) { for (const field of group.fields) { - if (field.type.pgType?.toLowerCase() === 'tsvector') { + if (field.type.pgType?.toLowerCase() === 'tsvector' || (field.type.gqlType === 'FullText' && !field.type.isArray)) { // tsvector field: { query } whereProps.push( t.objectProperty( diff --git a/graphql/codegen/src/core/codegen/docs-utils.ts b/graphql/codegen/src/core/codegen/docs-utils.ts index 33c126328..87c78f3ae 100644 --- a/graphql/codegen/src/core/codegen/docs-utils.ts +++ b/graphql/codegen/src/core/codegen/docs-utils.ts @@ -161,7 +161,10 @@ function isEmbeddingField(f: Field): boolean { function isTsvectorField(f: Field): boolean { const pgType = f.type.pgType?.toLowerCase(); - return pgType === 'tsvector'; + if (pgType === 'tsvector') return true; + // Fallback: PostGraphile maps tsvector columns to the FullText GQL scalar + if (f.type.gqlType === 'FullText' && !f.type.isArray) return true; + return false; } function isSearchComputedField(f: Field): boolean { diff --git a/graphql/codegen/src/core/dump-pg-types.ts b/graphql/codegen/src/core/dump-pg-types.ts new file mode 100644 index 000000000..d20df5965 --- /dev/null +++ b/graphql/codegen/src/core/dump-pg-types.ts @@ -0,0 +1,63 @@ +/** + * Dump PostgreSQL Type Metadata + * + * Generates a pg-types.json file by running the codegen pipeline and + * extracting the Table/Field metadata. When used with a database source + * (which has access to PostGraphile's pg catalog), this captures pgType + * information that isn't available from standard GraphQL introspection. + * + * For sources that don't provide pgType (endpoint, file), the output + * serves as a template: fields are listed with `pgType: null` so the + * user can fill in the correct values manually. + */ +import * as fs from 'node:fs'; +import path from 'node:path'; + +import type { Table } from '../types/schema'; +import type { PgTypesMap } from './introspect/enrich-pg-types'; + +/** + * Build a PgTypesMap from an array of Table objects. + * Includes all fields, even those without pgType, so the output + * can serve as a template for manual editing. + */ +export function buildPgTypesMap(tables: Table[]): PgTypesMap { + const result: PgTypesMap = {}; + + for (const table of tables) { + const fieldMap: Record = {}; + + for (const field of table.fields) { + const entry: { pgType?: string | null; pgAlias?: string | null; typmod?: number | null } = {}; + + // Always include pgType (even if null — serves as template placeholder) + entry.pgType = field.type.pgType ?? null; + + // Only include pgAlias and typmod if they have values + if (field.type.pgAlias) entry.pgAlias = field.type.pgAlias; + if (field.type.typmod != null) entry.typmod = field.type.typmod; + + fieldMap[field.name] = entry; + } + + if (Object.keys(fieldMap).length > 0) { + result[table.name] = fieldMap; + } + } + + return result; +} + +/** + * Write a PgTypesMap to a JSON file. + */ +export async function writePgTypesFile( + pgTypes: PgTypesMap, + outputPath: string, +): Promise { + const resolved = path.resolve(outputPath); + const dir = path.dirname(resolved); + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(resolved, JSON.stringify(pgTypes, null, 2) + '\n', 'utf-8'); + return resolved; +} diff --git a/graphql/codegen/src/core/introspect/enrich-pg-types.ts b/graphql/codegen/src/core/introspect/enrich-pg-types.ts new file mode 100644 index 000000000..c3822b940 --- /dev/null +++ b/graphql/codegen/src/core/introspect/enrich-pg-types.ts @@ -0,0 +1,118 @@ +/** + * PostgreSQL Type Enrichment + * + * Enriches Table objects with PostgreSQL-specific type metadata (pgType, pgAlias, etc.) + * from an optional introspection JSON file. + * + * The JSON file format maps table names to field-level pg type overrides: + * ```json + * { + * "Document": { + * "vectorEmbedding": { "pgType": "vector" }, + * "tsvContent": { "pgType": "tsvector" }, + * "geom": { "pgType": "geometry" } + * }, + * "Article": { + * "bodyEmbedding": { "pgType": "vector", "typmod": 768 } + * } + * } + * ``` + * + * When the file is absent, codegen continues with heuristic-based detection + * (name patterns, GQL type inference). When present, fields get exact pgType + * metadata, enabling precise detection of pgvector, tsvector, PostGIS, etc. + */ +import * as fs from 'node:fs'; +import path from 'node:path'; + +import type { FieldType, Table } from '../../types/schema'; + +/** + * Per-field PostgreSQL type overrides. + * Only the fields present in FieldType that are pg-specific. + */ +export interface PgTypeOverride { + /** PostgreSQL native type (e.g., "vector", "tsvector", "geometry", "text") */ + pgType?: string; + /** PostgreSQL type alias / domain name */ + pgAlias?: string; + /** Type modifier from PostgreSQL (e.g., vector dimension) */ + typmod?: number; +} + +/** + * Top-level structure: table name → field name → pg type overrides + */ +export type PgTypesMap = Record>; + +/** + * Load a pg-types JSON file from disk. + * Returns null if the file does not exist (optional file). + * Throws on parse errors (malformed JSON should be reported). + */ +export function loadPgTypesFile(filePath: string): PgTypesMap | null { + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) { + return null; + } + + const raw = fs.readFileSync(resolved, 'utf-8'); + const parsed = JSON.parse(raw) as PgTypesMap; + + // Basic validation: must be an object + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `Invalid pg-types file at ${resolved}: expected a JSON object mapping table names to field overrides.`, + ); + } + + return parsed; +} + +/** + * Enrich Table objects with PostgreSQL type metadata from a PgTypesMap. + * Mutates the tables array in-place (same pattern as enrichManyToManyRelations). + * + * Only merges pg-specific fields (pgType, pgAlias, typmod) — does not overwrite + * gqlType, isArray, or other introspection-derived data. + * + * @returns Number of fields enriched (for logging) + */ +export function enrichPgTypes(tables: Table[], pgTypes: PgTypesMap): number { + let enriched = 0; + + for (const table of tables) { + const fieldOverrides = pgTypes[table.name]; + if (!fieldOverrides) continue; + + for (const field of table.fields) { + const override = fieldOverrides[field.name]; + if (!override) continue; + + const updates: Partial = {}; + if (override.pgType !== undefined) updates.pgType = override.pgType; + if (override.pgAlias !== undefined) updates.pgAlias = override.pgAlias; + if (override.typmod !== undefined) updates.typmod = override.typmod; + + if (Object.keys(updates).length > 0) { + field.type = { ...field.type, ...updates }; + enriched++; + } + } + } + + return enriched; +} + +/** + * Convenience: load a pg-types file and enrich tables in one step. + * Returns the number of fields enriched, or 0 if the file was not found. + */ +export function enrichPgTypesFromFile( + tables: Table[], + filePath: string, +): number { + const pgTypes = loadPgTypesFile(filePath); + if (!pgTypes) return 0; + return enrichPgTypes(tables, pgTypes); +} diff --git a/graphql/codegen/src/core/pipeline/index.ts b/graphql/codegen/src/core/pipeline/index.ts index 16be011b6..fadfa8e4c 100644 --- a/graphql/codegen/src/core/pipeline/index.ts +++ b/graphql/codegen/src/core/pipeline/index.ts @@ -14,6 +14,7 @@ import type { Table, TypeRegistry, } from '../../types/schema'; +import { enrichPgTypesFromFile } from '../introspect/enrich-pg-types'; import { enrichManyToManyRelations } from '../introspect/enrich-relations'; import { inferTablesFromIntrospection } from '../introspect/infer-tables'; import type { SchemaSource } from '../introspect/source'; @@ -128,6 +129,16 @@ export async function runCodegenPipeline( log(` Enriched M:N relations from _meta (${tablesMeta.length} tables)`); } + // 2b. Enrich fields with PostgreSQL type metadata from optional JSON file + if (config.pgTypesFile) { + const enrichedCount = enrichPgTypesFromFile(tables, config.pgTypesFile); + if (enrichedCount > 0) { + log(` Enriched ${enrichedCount} fields with pg type metadata from ${config.pgTypesFile}`); + } else { + log(` No pg type enrichments applied (file: ${config.pgTypesFile})`); + } + } + // 3. Filter tables by config (combine exclude and systemExclude) tables = filterTables(tables, config.tables.include, [ ...config.tables.exclude, diff --git a/graphql/codegen/src/types/config.ts b/graphql/codegen/src/types/config.ts index 790b11be7..f18c93fa1 100644 --- a/graphql/codegen/src/types/config.ts +++ b/graphql/codegen/src/types/config.ts @@ -262,6 +262,20 @@ export interface GraphQLSDKConfigTarget { */ db?: DbConfig; + /** + * Path to a JSON file providing PostgreSQL-specific type metadata. + * Enriches inferred Table objects with pgType, pgAlias, and typmod + * that are not available from standard GraphQL introspection. + * + * When present, enables precise detection of pgvector, tsvector, PostGIS, etc. + * When absent, codegen falls back to heuristic detection (name patterns, GQL types). + * + * Generate this file with: `graphql-codegen --dump-pg-types` + * + * Format: `{ "TableName": { "fieldName": { "pgType": "vector", ... } } }` + */ + pgTypesFile?: string; + /** * Headers to include in introspection requests */ From 70ba291ca8470cbc6db78929feddfb7a44e1b0f8 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 27 Mar 2026 21:15:33 +0000 Subject: [PATCH 2/4] refactor(codegen): replace pgTypesFile with metaFile / --dump-meta approach - Expand MetaTableInfo to include fields[] with type.pgType from _meta - Replace enrichPgTypes(PgTypesMap) with enrichPgTypesFromMeta(MetaTableInfo[]) - Rename pgTypesFile -> metaFile in config (full _meta dump, not pgType-specific) - Rename --dump-pg-types -> --dump-meta in CLI (dumps MetaTableInfo[]) - Pipeline now auto-enriches pgType from _meta in database mode - metaFile serves as sidecar for file/schemaDir/endpoint modes - Heuristic fallbacks still work when no _meta is available - Update all tests to match new API (19 tests passing) --- .../introspect/dump-pg-types.test.ts | 82 +++++---- .../introspect/enrich-pg-types.test.ts | 173 +++++++++++------- graphql/codegen/src/cli/handler.ts | 33 ++-- graphql/codegen/src/cli/index.ts | 14 +- graphql/codegen/src/core/dump-pg-types.ts | 84 ++++----- .../src/core/introspect/enrich-pg-types.ts | 133 ++++++-------- .../src/core/introspect/source/types.ts | 30 ++- graphql/codegen/src/core/pipeline/index.ts | 20 +- graphql/codegen/src/types/config.ts | 15 +- 9 files changed, 317 insertions(+), 267 deletions(-) diff --git a/graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts b/graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts index c6eccd1dd..b64e8a232 100644 --- a/graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts +++ b/graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts @@ -1,4 +1,4 @@ -import { buildPgTypesMap } from '../../core/dump-pg-types'; +import { buildMetaFromTables } from '../../core/dump-pg-types'; import type { Table } from '../../types/schema'; function makeField(name: string, gqlType: string, isArray = false, pgType?: string | null) { @@ -20,8 +20,8 @@ function makeTable(name: string, fields: ReturnType[]): Table }; } -describe('buildPgTypesMap', () => { - it('should build map from tables with pgType', () => { +describe('buildMetaFromTables', () => { + it('should build MetaTableInfo[] from tables with pgType', () => { const tables = [ makeTable('Document', [ makeField('id', 'UUID', false, 'uuid'), @@ -30,17 +30,17 @@ describe('buildPgTypesMap', () => { ]), ]; - const result = buildPgTypesMap(tables); - expect(result).toEqual({ - Document: { - id: { pgType: 'uuid' }, - vectorEmbedding: { pgType: 'vector' }, - tsvContent: { pgType: 'tsvector' }, - }, - }); + const result = buildMetaFromTables(tables); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Document'); + expect(result[0].fields).toEqual([ + { name: 'id', type: { pgType: 'uuid', gqlType: 'UUID', isArray: false } }, + { name: 'vectorEmbedding', type: { pgType: 'vector', gqlType: 'Float', isArray: true } }, + { name: 'tsvContent', type: { pgType: 'tsvector', gqlType: 'FullText', isArray: false } }, + ]); }); - it('should set pgType to null when not available (template mode)', () => { + it('should set pgType to "unknown" when not available', () => { const tables = [ makeTable('User', [ makeField('id', 'UUID'), @@ -48,49 +48,53 @@ describe('buildPgTypesMap', () => { ]), ]; - const result = buildPgTypesMap(tables); - expect(result).toEqual({ - User: { - id: { pgType: null }, - name: { pgType: null }, - }, - }); + const result = buildMetaFromTables(tables); + expect(result).toHaveLength(1); + expect(result[0].fields).toEqual([ + { name: 'id', type: { pgType: 'unknown', gqlType: 'UUID', isArray: false } }, + { name: 'name', type: { pgType: 'unknown', gqlType: 'String', isArray: false } }, + ]); }); - it('should include pgAlias and typmod when present', () => { + it('should preserve relations', () => { const tables: Table[] = [ { name: 'Article', fields: [ - { - name: 'embedding', - type: { - gqlType: 'Float', - isArray: true, - pgType: 'vector', - pgAlias: 'vector', - typmod: 768, - }, - }, + { name: 'id', type: { gqlType: 'UUID', isArray: false, pgType: 'uuid' } }, ], - relations: { belongsTo: [], hasOne: [], hasMany: [], manyToMany: [] }, + relations: { + belongsTo: [], + hasOne: [], + hasMany: [], + manyToMany: [{ + fieldName: 'tags', + rightTable: 'Tag', + junctionTable: 'ArticleTag', + type: 'Tag', + junctionLeftKeyFields: ['articleId'], + junctionRightKeyFields: ['tagId'], + leftKeyFields: ['id'], + rightKeyFields: ['id'], + }], + }, }, ]; - const result = buildPgTypesMap(tables); - expect(result.Article.embedding).toEqual({ - pgType: 'vector', - pgAlias: 'vector', - typmod: 768, - }); + const result = buildMetaFromTables(tables); + expect(result[0].relations.manyToMany).toHaveLength(1); + expect(result[0].relations.manyToMany[0].junctionTable.name).toBe('ArticleTag'); + expect(result[0].relations.manyToMany[0].rightTable.name).toBe('Tag'); }); it('should handle empty tables array', () => { - expect(buildPgTypesMap([])).toEqual({}); + expect(buildMetaFromTables([])).toEqual([]); }); it('should handle tables with no fields', () => { const tables = [makeTable('Empty', [])]; - expect(buildPgTypesMap(tables)).toEqual({}); + const result = buildMetaFromTables(tables); + expect(result).toHaveLength(1); + expect(result[0].fields).toEqual([]); }); }); diff --git a/graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts b/graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts index 5d6393773..baf937c54 100644 --- a/graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts +++ b/graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts @@ -3,10 +3,11 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { - enrichPgTypes, - enrichPgTypesFromFile, - loadPgTypesFile, + enrichPgTypesFromMeta, + enrichPgTypesFromMetaFile, + loadMetaFile, } from '../../core/introspect/enrich-pg-types'; +import type { MetaTableInfo } from '../../core/introspect/source/types'; import type { Table } from '../../types/schema'; function makeField(name: string, gqlType: string, isArray = false, pgType?: string) { @@ -28,8 +29,23 @@ function makeTable(name: string, fields: ReturnType[]): Table }; } -describe('enrichPgTypes', () => { - it('should enrich fields with pgType from map', () => { +function makeMetaTable( + name: string, + fields: Array<{ name: string; pgType: string; gqlType?: string }>, +): MetaTableInfo { + return { + name, + schemaName: 'public', + fields: fields.map((f) => ({ + name: f.name, + type: { pgType: f.pgType, gqlType: f.gqlType ?? 'String', isArray: false }, + })), + relations: { manyToMany: [] }, + }; +} + +describe('enrichPgTypesFromMeta', () => { + it('should enrich fields with pgType from _meta', () => { const tables = [ makeTable('Document', [ makeField('id', 'UUID'), @@ -38,64 +54,65 @@ describe('enrichPgTypes', () => { ]), ]; - const count = enrichPgTypes(tables, { - Document: { - vectorEmbedding: { pgType: 'vector' }, - tsvContent: { pgType: 'tsvector' }, - }, - }); + const meta = [ + makeMetaTable('Document', [ + { name: 'id', pgType: 'uuid' }, + { name: 'vectorEmbedding', pgType: 'vector' }, + { name: 'tsvContent', pgType: 'tsvector' }, + ]), + ]; + + const count = enrichPgTypesFromMeta(tables, meta); - expect(count).toBe(2); + expect(count).toBe(3); + expect(tables[0].fields[0].type.pgType).toBe('uuid'); expect(tables[0].fields[1].type.pgType).toBe('vector'); expect(tables[0].fields[2].type.pgType).toBe('tsvector'); - // Should NOT touch fields not in the map - expect(tables[0].fields[0].type.pgType).toBeUndefined(); }); - it('should enrich pgAlias and typmod', () => { + it('should skip tables not in meta', () => { const tables = [ - makeTable('Article', [ - makeField('bodyEmbedding', 'Float', true), - ]), + makeTable('User', [makeField('id', 'UUID')]), ]; - const count = enrichPgTypes(tables, { - Article: { - bodyEmbedding: { pgType: 'vector', pgAlias: 'vector', typmod: 768 }, - }, - }); + const meta = [ + makeMetaTable('Document', [{ name: 'vectorEmbedding', pgType: 'vector' }]), + ]; - expect(count).toBe(1); - expect(tables[0].fields[0].type.pgType).toBe('vector'); - expect(tables[0].fields[0].type.pgAlias).toBe('vector'); - expect(tables[0].fields[0].type.typmod).toBe(768); + const count = enrichPgTypesFromMeta(tables, meta); + expect(count).toBe(0); }); - it('should skip tables not in the map', () => { + it('should skip fields not in meta', () => { const tables = [ - makeTable('User', [makeField('id', 'UUID')]), + makeTable('Document', [ + makeField('id', 'UUID'), + makeField('title', 'String'), + ]), ]; - const count = enrichPgTypes(tables, { - Document: { vectorEmbedding: { pgType: 'vector' } }, - }); + const meta = [ + makeMetaTable('Document', [{ name: 'vectorEmbedding', pgType: 'vector' }]), + ]; + const count = enrichPgTypesFromMeta(tables, meta); expect(count).toBe(0); }); - it('should skip fields not in the map', () => { + it('should not overwrite existing pgType', () => { const tables = [ makeTable('Document', [ - makeField('id', 'UUID'), - makeField('title', 'String'), + makeField('vectorEmbedding', 'Float', true, 'existing_type'), ]), ]; - const count = enrichPgTypes(tables, { - Document: { vectorEmbedding: { pgType: 'vector' } }, - }); + const meta = [ + makeMetaTable('Document', [{ name: 'vectorEmbedding', pgType: 'vector' }]), + ]; + const count = enrichPgTypesFromMeta(tables, meta); expect(count).toBe(0); + expect(tables[0].fields[0].type.pgType).toBe('existing_type'); }); it('should not overwrite gqlType or isArray', () => { @@ -105,27 +122,53 @@ describe('enrichPgTypes', () => { ]), ]; - enrichPgTypes(tables, { - Document: { vectorEmbedding: { pgType: 'vector' } }, - }); + const meta = [ + makeMetaTable('Document', [{ name: 'vectorEmbedding', pgType: 'vector', gqlType: 'Int' }]), + ]; + + enrichPgTypesFromMeta(tables, meta); expect(tables[0].fields[0].type.gqlType).toBe('Float'); expect(tables[0].fields[0].type.isArray).toBe(true); expect(tables[0].fields[0].type.pgType).toBe('vector'); }); - it('should handle empty map gracefully', () => { + it('should skip fields with pgType "unknown"', () => { + const tables = [ + makeTable('User', [makeField('id', 'UUID')]), + ]; + + const meta = [ + makeMetaTable('User', [{ name: 'id', pgType: 'unknown' }]), + ]; + + const count = enrichPgTypesFromMeta(tables, meta); + expect(count).toBe(0); + }); + + it('should handle empty meta gracefully', () => { + const tables = [makeTable('User', [makeField('id', 'UUID')])]; + const count = enrichPgTypesFromMeta(tables, []); + expect(count).toBe(0); + }); + + it('should handle meta without fields', () => { const tables = [makeTable('User', [makeField('id', 'UUID')])]; - const count = enrichPgTypes(tables, {}); + const meta: MetaTableInfo[] = [{ + name: 'User', + schemaName: 'public', + relations: { manyToMany: [] }, + }]; + const count = enrichPgTypesFromMeta(tables, meta); expect(count).toBe(0); }); }); -describe('loadPgTypesFile', () => { +describe('loadMetaFile', () => { let tmpDir: string; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pg-types-test-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meta-test-')); }); afterEach(() => { @@ -133,42 +176,39 @@ describe('loadPgTypesFile', () => { }); it('should return null for non-existent file', () => { - const result = loadPgTypesFile(path.join(tmpDir, 'nonexistent.json')); + const result = loadMetaFile(path.join(tmpDir, 'nonexistent.json')); expect(result).toBeNull(); }); it('should load valid JSON file', () => { - const filePath = path.join(tmpDir, 'pg-types.json'); - fs.writeFileSync(filePath, JSON.stringify({ - Document: { vectorEmbedding: { pgType: 'vector' } }, - })); + const filePath = path.join(tmpDir, '_meta.json'); + const meta = [makeMetaTable('Document', [{ name: 'vectorEmbedding', pgType: 'vector' }])]; + fs.writeFileSync(filePath, JSON.stringify(meta)); - const result = loadPgTypesFile(filePath); - expect(result).toEqual({ - Document: { vectorEmbedding: { pgType: 'vector' } }, - }); + const result = loadMetaFile(filePath); + expect(result).toEqual(meta); }); it('should throw on invalid JSON', () => { const filePath = path.join(tmpDir, 'bad.json'); fs.writeFileSync(filePath, 'not json'); - expect(() => loadPgTypesFile(filePath)).toThrow(); + expect(() => loadMetaFile(filePath)).toThrow(); }); - it('should throw on non-object JSON', () => { - const filePath = path.join(tmpDir, 'array.json'); - fs.writeFileSync(filePath, '["not", "an", "object"]'); + it('should throw on non-array JSON', () => { + const filePath = path.join(tmpDir, 'object.json'); + fs.writeFileSync(filePath, '{"not": "an array"}'); - expect(() => loadPgTypesFile(filePath)).toThrow(/expected a JSON object/); + expect(() => loadMetaFile(filePath)).toThrow(/expected a JSON array/); }); }); -describe('enrichPgTypesFromFile', () => { +describe('enrichPgTypesFromMetaFile', () => { let tmpDir: string; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pg-types-test-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meta-test-')); }); afterEach(() => { @@ -177,15 +217,14 @@ describe('enrichPgTypesFromFile', () => { it('should return 0 for non-existent file', () => { const tables = [makeTable('User', [makeField('id', 'UUID')])]; - const count = enrichPgTypesFromFile(tables, path.join(tmpDir, 'nope.json')); + const count = enrichPgTypesFromMetaFile(tables, path.join(tmpDir, 'nope.json')); expect(count).toBe(0); }); it('should load file and enrich tables', () => { - const filePath = path.join(tmpDir, 'pg-types.json'); - fs.writeFileSync(filePath, JSON.stringify({ - Document: { tsvContent: { pgType: 'tsvector' } }, - })); + const filePath = path.join(tmpDir, '_meta.json'); + const meta = [makeMetaTable('Document', [{ name: 'tsvContent', pgType: 'tsvector' }])]; + fs.writeFileSync(filePath, JSON.stringify(meta)); const tables = [ makeTable('Document', [ @@ -193,7 +232,7 @@ describe('enrichPgTypesFromFile', () => { ]), ]; - const count = enrichPgTypesFromFile(tables, filePath); + const count = enrichPgTypesFromMetaFile(tables, filePath); expect(count).toBe(1); expect(tables[0].fields[0].type.pgType).toBe('tsvector'); }); diff --git a/graphql/codegen/src/cli/handler.ts b/graphql/codegen/src/cli/handler.ts index fe568ad3a..222aa3d19 100644 --- a/graphql/codegen/src/cli/handler.ts +++ b/graphql/codegen/src/cli/handler.ts @@ -8,7 +8,7 @@ import type { Question } from 'inquirerer'; import { findConfigFile, loadConfigFile } from '../core/config'; -import { buildPgTypesMap, writePgTypesFile } from '../core/dump-pg-types'; +import { buildMetaFromTables, writeMetaFile } from '../core/dump-pg-types'; import { expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, generate, generateMulti } from '../core/generate'; import type { GraphQLSDKConfigTarget } from '../types/config'; import { @@ -32,9 +32,9 @@ export async function runCodegenHandler( ): Promise { const args = camelizeArgv(argv as Record); - // Handle --dump-pg-types: run pipeline, extract pg type metadata, write JSON - if (args.dumpPgTypes) { - await handleDumpPgTypes(args, prompter); + // Handle --dump-meta: run pipeline, extract _meta table metadata, write JSON + if (args.dumpMeta) { + await handleDumpMeta(args, prompter); return; } @@ -137,7 +137,7 @@ export async function runCodegenHandler( printResult(result); } -async function handleDumpPgTypes( +async function handleDumpMeta( args: Record, prompter: Prompter, ): Promise { @@ -175,20 +175,17 @@ async function handleDumpPgTypes( process.exit(1); } - const pgTypes = buildPgTypesMap(result.pipelineData.tables); - const outputPath = typeof args.dumpPgTypes === 'string' - ? args.dumpPgTypes - : 'pg-types.json'; - const written = await writePgTypesFile(pgTypes, outputPath); + const meta = buildMetaFromTables(result.pipelineData.tables); + const outputPath = typeof args.dumpMeta === 'string' + ? args.dumpMeta + : '_meta.json'; + const written = await writeMetaFile(meta, outputPath); - const tableCount = Object.keys(pgTypes).length; - const fieldCount = Object.values(pgTypes).reduce( - (sum, fields) => sum + Object.keys(fields).length, - 0, - ); - console.log(`[ok] Wrote pg-types.json: ${tableCount} tables, ${fieldCount} fields`); + const tableCount = meta.length; + const fieldCount = meta.reduce((sum, t) => sum + (t.fields?.length ?? 0), 0); + console.log(`[ok] Wrote _meta.json: ${tableCount} tables, ${fieldCount} fields`); console.log(` ${written}`); console.log(''); - console.log('Usage: add pgTypesFile to your codegen config:'); - console.log(` { pgTypesFile: '${outputPath}' }`); + console.log('Usage: add metaFile to your codegen config:'); + console.log(` { metaFile: '${outputPath}' }`); } diff --git a/graphql/codegen/src/cli/index.ts b/graphql/codegen/src/cli/index.ts index aeda8e92d..29dc337f7 100644 --- a/graphql/codegen/src/cli/index.ts +++ b/graphql/codegen/src/cli/index.ts @@ -42,12 +42,12 @@ Schema Export: --schema-output Output directory for the exported schema file --schema-filename Filename for the exported schema (default: schema.graphql) -PostgreSQL Type Metadata: - --dump-pg-types [path] Generate pg-types.json from introspection. - Captures pgType, pgAlias, typmod for each field. - Use with pgTypesFile config option for precise detection - of pgvector, tsvector, PostGIS columns. - Default output: ./pg-types.json +Metadata Export: + --dump-meta [path] Generate _meta.json from introspection. + Captures field-level pgType, relations, and other + metadata from the MetaSchemaPlugin. + Use with metaFile config option for file/schemaDir modes. + Default output: ./_meta.json -h, --help Show this help message --version Show version number @@ -86,7 +86,7 @@ export const options: Partial = { a: 'authorization', v: 'verbose', }, - boolean: ['schema-enabled', 'dump-pg-types'], + boolean: ['schema-enabled', 'dump-meta'], string: [ 'config', 'endpoint', diff --git a/graphql/codegen/src/core/dump-pg-types.ts b/graphql/codegen/src/core/dump-pg-types.ts index d20df5965..16f0a9339 100644 --- a/graphql/codegen/src/core/dump-pg-types.ts +++ b/graphql/codegen/src/core/dump-pg-types.ts @@ -1,63 +1,65 @@ /** - * Dump PostgreSQL Type Metadata + * Dump _meta Table Metadata * - * Generates a pg-types.json file by running the codegen pipeline and - * extracting the Table/Field metadata. When used with a database source - * (which has access to PostGraphile's pg catalog), this captures pgType - * information that isn't available from standard GraphQL introspection. + * Generates a _meta.json file by running the codegen pipeline and + * extracting table/field metadata in MetaTableInfo[] format. * - * For sources that don't provide pgType (endpoint, file), the output - * serves as a template: fields are listed with `pgType: null` so the - * user can fill in the correct values manually. + * When used with a database source (which has MetaSchemaPlugin), + * this captures the full _meta data including pgType for every field. + * + * For sources without _meta (endpoint, file), the output captures + * whatever metadata is available from introspection alone. + * + * The output file can be used as a metaFile sidecar for file/schemaDir + * modes, giving them the same metadata that database mode gets automatically. */ import * as fs from 'node:fs'; import path from 'node:path'; import type { Table } from '../types/schema'; -import type { PgTypesMap } from './introspect/enrich-pg-types'; +import type { MetaTableInfo } from './introspect/source/types'; /** - * Build a PgTypesMap from an array of Table objects. - * Includes all fields, even those without pgType, so the output - * can serve as a template for manual editing. + * Build a MetaTableInfo[] from an array of Table objects. + * Captures field-level pgType info in the same shape as _cachedTablesMeta. */ -export function buildPgTypesMap(tables: Table[]): PgTypesMap { - const result: PgTypesMap = {}; - - for (const table of tables) { - const fieldMap: Record = {}; - - for (const field of table.fields) { - const entry: { pgType?: string | null; pgAlias?: string | null; typmod?: number | null } = {}; - - // Always include pgType (even if null — serves as template placeholder) - entry.pgType = field.type.pgType ?? null; - - // Only include pgAlias and typmod if they have values - if (field.type.pgAlias) entry.pgAlias = field.type.pgAlias; - if (field.type.typmod != null) entry.typmod = field.type.typmod; - - fieldMap[field.name] = entry; - } - - if (Object.keys(fieldMap).length > 0) { - result[table.name] = fieldMap; - } - } - - return result; +export function buildMetaFromTables(tables: Table[]): MetaTableInfo[] { + return tables.map((table) => ({ + name: table.name, + schemaName: 'public', // Default; overridden if _meta provides actual schema + fields: table.fields.map((field) => ({ + name: field.name, + type: { + pgType: field.type.pgType ?? 'unknown', + gqlType: field.type.gqlType, + isArray: field.type.isArray, + }, + })), + relations: { + manyToMany: table.relations.manyToMany.map((rel) => ({ + fieldName: rel.fieldName ?? null, + type: rel.type ?? null, + junctionTable: { name: rel.junctionTable ?? '' }, + junctionLeftKeyAttributes: (rel.junctionLeftKeyFields ?? []).map((k) => ({ name: k })), + junctionRightKeyAttributes: (rel.junctionRightKeyFields ?? []).map((k) => ({ name: k })), + leftKeyAttributes: (rel.leftKeyFields ?? []).map((k) => ({ name: k })), + rightKeyAttributes: (rel.rightKeyFields ?? []).map((k) => ({ name: k })), + rightTable: { name: rel.rightTable ?? '' }, + })), + }, + })); } /** - * Write a PgTypesMap to a JSON file. + * Write MetaTableInfo[] to a JSON file. */ -export async function writePgTypesFile( - pgTypes: PgTypesMap, +export async function writeMetaFile( + meta: MetaTableInfo[], outputPath: string, ): Promise { const resolved = path.resolve(outputPath); const dir = path.dirname(resolved); await fs.promises.mkdir(dir, { recursive: true }); - await fs.promises.writeFile(resolved, JSON.stringify(pgTypes, null, 2) + '\n', 'utf-8'); + await fs.promises.writeFile(resolved, JSON.stringify(meta, null, 2) + '\n', 'utf-8'); return resolved; } diff --git a/graphql/codegen/src/core/introspect/enrich-pg-types.ts b/graphql/codegen/src/core/introspect/enrich-pg-types.ts index c3822b940..49a22ef50 100644 --- a/graphql/codegen/src/core/introspect/enrich-pg-types.ts +++ b/graphql/codegen/src/core/introspect/enrich-pg-types.ts @@ -1,68 +1,78 @@ /** * PostgreSQL Type Enrichment * - * Enriches Table objects with PostgreSQL-specific type metadata (pgType, pgAlias, etc.) - * from an optional introspection JSON file. + * Enriches Table objects with PostgreSQL-specific type metadata (pgType) + * from _meta table metadata. * - * The JSON file format maps table names to field-level pg type overrides: - * ```json - * { - * "Document": { - * "vectorEmbedding": { "pgType": "vector" }, - * "tsvContent": { "pgType": "tsvector" }, - * "geom": { "pgType": "geometry" } - * }, - * "Article": { - * "bodyEmbedding": { "pgType": "vector", "typmod": 768 } - * } - * } - * ``` + * Two sources of _meta data: + * 1. Live from database mode — _cachedTablesMeta populated by MetaSchemaPlugin + * 2. Static from metaFile — _meta.json sidecar loaded from disk (for file/schemaDir mode) * - * When the file is absent, codegen continues with heuristic-based detection - * (name patterns, GQL type inference). When present, fields get exact pgType - * metadata, enabling precise detection of pgvector, tsvector, PostGIS, etc. + * When neither is available, codegen falls back to heuristic detection + * (FullText → tsvector, name patterns, GQL type patterns). */ import * as fs from 'node:fs'; import path from 'node:path'; -import type { FieldType, Table } from '../../types/schema'; +import type { Table } from '../../types/schema'; +import type { MetaTableInfo } from './source/types'; /** - * Per-field PostgreSQL type overrides. - * Only the fields present in FieldType that are pg-specific. + * Enrich Table objects with pgType from _meta field metadata. + * Mutates the tables array in-place (same pattern as enrichManyToManyRelations). + * + * Matches _meta fields to Table fields by name and copies pgType. + * Does not overwrite gqlType, isArray, or other introspection-derived data. + * + * @returns Number of fields enriched (for logging) */ -export interface PgTypeOverride { - /** PostgreSQL native type (e.g., "vector", "tsvector", "geometry", "text") */ - pgType?: string; - /** PostgreSQL type alias / domain name */ - pgAlias?: string; - /** Type modifier from PostgreSQL (e.g., vector dimension) */ - typmod?: number; -} +export function enrichPgTypesFromMeta( + tables: Table[], + tablesMeta: MetaTableInfo[], +): number { + let enriched = 0; -/** - * Top-level structure: table name → field name → pg type overrides - */ -export type PgTypesMap = Record>; + const metaByName = new Map(tablesMeta.map((m) => [m.name, m])); + + for (const table of tables) { + const meta = metaByName.get(table.name); + if (!meta?.fields?.length) continue; + + // Build a lookup of meta field name → pgType + const pgTypeByField = new Map( + meta.fields.map((f) => [f.name, f.type.pgType]), + ); + + for (const field of table.fields) { + const pgType = pgTypeByField.get(field.name); + if (pgType && pgType !== 'unknown' && !field.type.pgType) { + field.type = { ...field.type, pgType }; + enriched++; + } + } + } + + return enriched; +} /** - * Load a pg-types JSON file from disk. + * Load a _meta.json sidecar file from disk. * Returns null if the file does not exist (optional file). * Throws on parse errors (malformed JSON should be reported). */ -export function loadPgTypesFile(filePath: string): PgTypesMap | null { +export function loadMetaFile(filePath: string): MetaTableInfo[] | null { const resolved = path.resolve(filePath); if (!fs.existsSync(resolved)) { return null; } const raw = fs.readFileSync(resolved, 'utf-8'); - const parsed = JSON.parse(raw) as PgTypesMap; + const parsed = JSON.parse(raw) as MetaTableInfo[]; - // Basic validation: must be an object - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + // Basic validation: must be an array + if (!Array.isArray(parsed)) { throw new Error( - `Invalid pg-types file at ${resolved}: expected a JSON object mapping table names to field overrides.`, + `Invalid meta file at ${resolved}: expected a JSON array of table metadata.`, ); } @@ -70,49 +80,14 @@ export function loadPgTypesFile(filePath: string): PgTypesMap | null { } /** - * Enrich Table objects with PostgreSQL type metadata from a PgTypesMap. - * Mutates the tables array in-place (same pattern as enrichManyToManyRelations). - * - * Only merges pg-specific fields (pgType, pgAlias, typmod) — does not overwrite - * gqlType, isArray, or other introspection-derived data. - * - * @returns Number of fields enriched (for logging) - */ -export function enrichPgTypes(tables: Table[], pgTypes: PgTypesMap): number { - let enriched = 0; - - for (const table of tables) { - const fieldOverrides = pgTypes[table.name]; - if (!fieldOverrides) continue; - - for (const field of table.fields) { - const override = fieldOverrides[field.name]; - if (!override) continue; - - const updates: Partial = {}; - if (override.pgType !== undefined) updates.pgType = override.pgType; - if (override.pgAlias !== undefined) updates.pgAlias = override.pgAlias; - if (override.typmod !== undefined) updates.typmod = override.typmod; - - if (Object.keys(updates).length > 0) { - field.type = { ...field.type, ...updates }; - enriched++; - } - } - } - - return enriched; -} - -/** - * Convenience: load a pg-types file and enrich tables in one step. + * Convenience: load a metaFile and enrich tables in one step. * Returns the number of fields enriched, or 0 if the file was not found. */ -export function enrichPgTypesFromFile( +export function enrichPgTypesFromMetaFile( tables: Table[], filePath: string, ): number { - const pgTypes = loadPgTypesFile(filePath); - if (!pgTypes) return 0; - return enrichPgTypes(tables, pgTypes); + const meta = loadMetaFile(filePath); + if (!meta) return 0; + return enrichPgTypesFromMeta(tables, meta); } diff --git a/graphql/codegen/src/core/introspect/source/types.ts b/graphql/codegen/src/core/introspect/source/types.ts index 949d5e70a..14b390f22 100644 --- a/graphql/codegen/src/core/introspect/source/types.ts +++ b/graphql/codegen/src/core/introspect/source/types.ts @@ -7,12 +7,32 @@ import type { IntrospectionQueryResponse } from '../../../types/introspection'; /** - * Minimal table metadata from the _meta query, used to enrich M:N relations - * with junction key field information that isn't available from introspection alone. + * Field-level type metadata from _meta. + * Contains PostgreSQL-specific type info not available from GraphQL introspection. + */ +export interface MetaFieldType { + pgType: string; + gqlType: string; + isArray: boolean; +} + +/** + * Field metadata from _meta. + */ +export interface MetaFieldInfo { + name: string; + type: MetaFieldType; +} + +/** + * Table metadata from the _meta query. + * Provides field-level pgType info and M:N relation junction key details + * that aren't available from standard GraphQL introspection alone. */ export interface MetaTableInfo { name: string; schemaName: string; + fields?: MetaFieldInfo[]; relations: { manyToMany: Array<{ fieldName: string | null; @@ -37,8 +57,10 @@ export interface SchemaSourceResult { introspection: IntrospectionQueryResponse; /** - * Optional table metadata from _meta query (provides M:N junction key details). - * Present when the source supports _meta (database mode or endpoints with MetaSchemaPlugin). + * Optional table metadata from _meta query. + * Provides field-level pgType info and M:N junction key details. + * Present when the source supports _meta (database mode or endpoints with MetaSchemaPlugin), + * or loaded from a metaFile JSON sidecar in file/schemaDir mode. */ tablesMeta?: MetaTableInfo[]; } diff --git a/graphql/codegen/src/core/pipeline/index.ts b/graphql/codegen/src/core/pipeline/index.ts index fadfa8e4c..3d79b7e24 100644 --- a/graphql/codegen/src/core/pipeline/index.ts +++ b/graphql/codegen/src/core/pipeline/index.ts @@ -14,7 +14,7 @@ import type { Table, TypeRegistry, } from '../../types/schema'; -import { enrichPgTypesFromFile } from '../introspect/enrich-pg-types'; +import { enrichPgTypesFromMeta, enrichPgTypesFromMetaFile } from '../introspect/enrich-pg-types'; import { enrichManyToManyRelations } from '../introspect/enrich-relations'; import { inferTablesFromIntrospection } from '../introspect/infer-tables'; import type { SchemaSource } from '../introspect/source'; @@ -129,13 +129,21 @@ export async function runCodegenPipeline( log(` Enriched M:N relations from _meta (${tablesMeta.length} tables)`); } - // 2b. Enrich fields with PostgreSQL type metadata from optional JSON file - if (config.pgTypesFile) { - const enrichedCount = enrichPgTypesFromFile(tables, config.pgTypesFile); + // 2b. Enrich fields with pgType from _meta (database mode provides this automatically) + if (tablesMeta?.length) { + const enrichedCount = enrichPgTypesFromMeta(tables, tablesMeta); + if (enrichedCount > 0) { + log(` Enriched ${enrichedCount} fields with pgType from _meta`); + } + } + + // 2c. Enrich fields with pgType from metaFile sidecar (file/schemaDir/endpoint mode) + if (config.metaFile) { + const enrichedCount = enrichPgTypesFromMetaFile(tables, config.metaFile); if (enrichedCount > 0) { - log(` Enriched ${enrichedCount} fields with pg type metadata from ${config.pgTypesFile}`); + log(` Enriched ${enrichedCount} fields with pgType from metaFile: ${config.metaFile}`); } else { - log(` No pg type enrichments applied (file: ${config.pgTypesFile})`); + log(` No pgType enrichments from metaFile: ${config.metaFile}`); } } diff --git a/graphql/codegen/src/types/config.ts b/graphql/codegen/src/types/config.ts index f18c93fa1..3d7a6c6be 100644 --- a/graphql/codegen/src/types/config.ts +++ b/graphql/codegen/src/types/config.ts @@ -263,18 +263,21 @@ export interface GraphQLSDKConfigTarget { db?: DbConfig; /** - * Path to a JSON file providing PostgreSQL-specific type metadata. - * Enriches inferred Table objects with pgType, pgAlias, and typmod - * that are not available from standard GraphQL introspection. + * Path to a _meta.json sidecar file providing table metadata. + * Enriches inferred Table objects with field-level pgType info + * that is not available from standard GraphQL introspection. + * + * In database mode, _meta data is fetched automatically from the MetaSchemaPlugin. + * For file/schemaDir/endpoint modes, provide this file to get the same metadata. * * When present, enables precise detection of pgvector, tsvector, PostGIS, etc. * When absent, codegen falls back to heuristic detection (name patterns, GQL types). * - * Generate this file with: `graphql-codegen --dump-pg-types` + * Generate this file with: `graphql-codegen --dump-meta` * - * Format: `{ "TableName": { "fieldName": { "pgType": "vector", ... } } }` + * Format: MetaTableInfo[] (same shape as _cachedTablesMeta from MetaSchemaPlugin) */ - pgTypesFile?: string; + metaFile?: string; /** * Headers to include in introspection requests From 69531c97dafc38e6ca6b4817b041e2618fa5b413 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 27 Mar 2026 21:36:58 +0000 Subject: [PATCH 3/4] refactor(codegen): remove pgType enrichment machinery, detect via GQL type names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove metaFile config, --dump-meta CLI, enrichPgTypesFromMeta/MetaFile, buildMetaFromTables/writeMetaFile, and all associated tests. pgvector/tsvector/PostGIS detection now relies entirely on GraphQL type names from introspection (Vector, FullText, GeoJSON, etc.) — the codec plugins already translate pg types to distinct GQL scalars, so no _meta or sidecar file is needed for type detection. M:N junction key enrichment from _meta remains (genuinely needs it). --- .../introspect/dump-pg-types.test.ts | 100 -------- .../introspect/enrich-pg-types.test.ts | 239 ------------------ graphql/codegen/src/cli/handler.ts | 60 ----- graphql/codegen/src/cli/index.ts | 9 +- .../codegen/cli/table-command-generator.ts | 2 +- .../codegen/src/core/codegen/docs-utils.ts | 6 +- graphql/codegen/src/core/dump-pg-types.ts | 65 ----- .../src/core/introspect/enrich-pg-types.ts | 93 ------- .../src/core/introspect/source/types.ts | 30 +-- graphql/codegen/src/core/pipeline/index.ts | 19 -- graphql/codegen/src/types/config.ts | 17 -- 11 files changed, 10 insertions(+), 630 deletions(-) delete mode 100644 graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts delete mode 100644 graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts delete mode 100644 graphql/codegen/src/core/dump-pg-types.ts delete mode 100644 graphql/codegen/src/core/introspect/enrich-pg-types.ts diff --git a/graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts b/graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts deleted file mode 100644 index b64e8a232..000000000 --- a/graphql/codegen/src/__tests__/introspect/dump-pg-types.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { buildMetaFromTables } from '../../core/dump-pg-types'; -import type { Table } from '../../types/schema'; - -function makeField(name: string, gqlType: string, isArray = false, pgType?: string | null) { - return { - name, - type: { - gqlType, - isArray, - ...(pgType !== undefined ? { pgType } : {}), - }, - }; -} - -function makeTable(name: string, fields: ReturnType[]): Table { - return { - name, - fields, - relations: { belongsTo: [], hasOne: [], hasMany: [], manyToMany: [] }, - }; -} - -describe('buildMetaFromTables', () => { - it('should build MetaTableInfo[] from tables with pgType', () => { - const tables = [ - makeTable('Document', [ - makeField('id', 'UUID', false, 'uuid'), - makeField('vectorEmbedding', 'Float', true, 'vector'), - makeField('tsvContent', 'FullText', false, 'tsvector'), - ]), - ]; - - const result = buildMetaFromTables(tables); - expect(result).toHaveLength(1); - expect(result[0].name).toBe('Document'); - expect(result[0].fields).toEqual([ - { name: 'id', type: { pgType: 'uuid', gqlType: 'UUID', isArray: false } }, - { name: 'vectorEmbedding', type: { pgType: 'vector', gqlType: 'Float', isArray: true } }, - { name: 'tsvContent', type: { pgType: 'tsvector', gqlType: 'FullText', isArray: false } }, - ]); - }); - - it('should set pgType to "unknown" when not available', () => { - const tables = [ - makeTable('User', [ - makeField('id', 'UUID'), - makeField('name', 'String'), - ]), - ]; - - const result = buildMetaFromTables(tables); - expect(result).toHaveLength(1); - expect(result[0].fields).toEqual([ - { name: 'id', type: { pgType: 'unknown', gqlType: 'UUID', isArray: false } }, - { name: 'name', type: { pgType: 'unknown', gqlType: 'String', isArray: false } }, - ]); - }); - - it('should preserve relations', () => { - const tables: Table[] = [ - { - name: 'Article', - fields: [ - { name: 'id', type: { gqlType: 'UUID', isArray: false, pgType: 'uuid' } }, - ], - relations: { - belongsTo: [], - hasOne: [], - hasMany: [], - manyToMany: [{ - fieldName: 'tags', - rightTable: 'Tag', - junctionTable: 'ArticleTag', - type: 'Tag', - junctionLeftKeyFields: ['articleId'], - junctionRightKeyFields: ['tagId'], - leftKeyFields: ['id'], - rightKeyFields: ['id'], - }], - }, - }, - ]; - - const result = buildMetaFromTables(tables); - expect(result[0].relations.manyToMany).toHaveLength(1); - expect(result[0].relations.manyToMany[0].junctionTable.name).toBe('ArticleTag'); - expect(result[0].relations.manyToMany[0].rightTable.name).toBe('Tag'); - }); - - it('should handle empty tables array', () => { - expect(buildMetaFromTables([])).toEqual([]); - }); - - it('should handle tables with no fields', () => { - const tables = [makeTable('Empty', [])]; - const result = buildMetaFromTables(tables); - expect(result).toHaveLength(1); - expect(result[0].fields).toEqual([]); - }); -}); diff --git a/graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts b/graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts deleted file mode 100644 index baf937c54..000000000 --- a/graphql/codegen/src/__tests__/introspect/enrich-pg-types.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; - -import { - enrichPgTypesFromMeta, - enrichPgTypesFromMetaFile, - loadMetaFile, -} from '../../core/introspect/enrich-pg-types'; -import type { MetaTableInfo } from '../../core/introspect/source/types'; -import type { Table } from '../../types/schema'; - -function makeField(name: string, gqlType: string, isArray = false, pgType?: string) { - return { - name, - type: { - gqlType, - isArray, - ...(pgType !== undefined ? { pgType } : {}), - }, - }; -} - -function makeTable(name: string, fields: ReturnType[]): Table { - return { - name, - fields, - relations: { belongsTo: [], hasOne: [], hasMany: [], manyToMany: [] }, - }; -} - -function makeMetaTable( - name: string, - fields: Array<{ name: string; pgType: string; gqlType?: string }>, -): MetaTableInfo { - return { - name, - schemaName: 'public', - fields: fields.map((f) => ({ - name: f.name, - type: { pgType: f.pgType, gqlType: f.gqlType ?? 'String', isArray: false }, - })), - relations: { manyToMany: [] }, - }; -} - -describe('enrichPgTypesFromMeta', () => { - it('should enrich fields with pgType from _meta', () => { - const tables = [ - makeTable('Document', [ - makeField('id', 'UUID'), - makeField('vectorEmbedding', 'Float', true), - makeField('tsvContent', 'FullText'), - ]), - ]; - - const meta = [ - makeMetaTable('Document', [ - { name: 'id', pgType: 'uuid' }, - { name: 'vectorEmbedding', pgType: 'vector' }, - { name: 'tsvContent', pgType: 'tsvector' }, - ]), - ]; - - const count = enrichPgTypesFromMeta(tables, meta); - - expect(count).toBe(3); - expect(tables[0].fields[0].type.pgType).toBe('uuid'); - expect(tables[0].fields[1].type.pgType).toBe('vector'); - expect(tables[0].fields[2].type.pgType).toBe('tsvector'); - }); - - it('should skip tables not in meta', () => { - const tables = [ - makeTable('User', [makeField('id', 'UUID')]), - ]; - - const meta = [ - makeMetaTable('Document', [{ name: 'vectorEmbedding', pgType: 'vector' }]), - ]; - - const count = enrichPgTypesFromMeta(tables, meta); - expect(count).toBe(0); - }); - - it('should skip fields not in meta', () => { - const tables = [ - makeTable('Document', [ - makeField('id', 'UUID'), - makeField('title', 'String'), - ]), - ]; - - const meta = [ - makeMetaTable('Document', [{ name: 'vectorEmbedding', pgType: 'vector' }]), - ]; - - const count = enrichPgTypesFromMeta(tables, meta); - expect(count).toBe(0); - }); - - it('should not overwrite existing pgType', () => { - const tables = [ - makeTable('Document', [ - makeField('vectorEmbedding', 'Float', true, 'existing_type'), - ]), - ]; - - const meta = [ - makeMetaTable('Document', [{ name: 'vectorEmbedding', pgType: 'vector' }]), - ]; - - const count = enrichPgTypesFromMeta(tables, meta); - expect(count).toBe(0); - expect(tables[0].fields[0].type.pgType).toBe('existing_type'); - }); - - it('should not overwrite gqlType or isArray', () => { - const tables = [ - makeTable('Document', [ - makeField('vectorEmbedding', 'Float', true), - ]), - ]; - - const meta = [ - makeMetaTable('Document', [{ name: 'vectorEmbedding', pgType: 'vector', gqlType: 'Int' }]), - ]; - - enrichPgTypesFromMeta(tables, meta); - - expect(tables[0].fields[0].type.gqlType).toBe('Float'); - expect(tables[0].fields[0].type.isArray).toBe(true); - expect(tables[0].fields[0].type.pgType).toBe('vector'); - }); - - it('should skip fields with pgType "unknown"', () => { - const tables = [ - makeTable('User', [makeField('id', 'UUID')]), - ]; - - const meta = [ - makeMetaTable('User', [{ name: 'id', pgType: 'unknown' }]), - ]; - - const count = enrichPgTypesFromMeta(tables, meta); - expect(count).toBe(0); - }); - - it('should handle empty meta gracefully', () => { - const tables = [makeTable('User', [makeField('id', 'UUID')])]; - const count = enrichPgTypesFromMeta(tables, []); - expect(count).toBe(0); - }); - - it('should handle meta without fields', () => { - const tables = [makeTable('User', [makeField('id', 'UUID')])]; - const meta: MetaTableInfo[] = [{ - name: 'User', - schemaName: 'public', - relations: { manyToMany: [] }, - }]; - const count = enrichPgTypesFromMeta(tables, meta); - expect(count).toBe(0); - }); -}); - -describe('loadMetaFile', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meta-test-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('should return null for non-existent file', () => { - const result = loadMetaFile(path.join(tmpDir, 'nonexistent.json')); - expect(result).toBeNull(); - }); - - it('should load valid JSON file', () => { - const filePath = path.join(tmpDir, '_meta.json'); - const meta = [makeMetaTable('Document', [{ name: 'vectorEmbedding', pgType: 'vector' }])]; - fs.writeFileSync(filePath, JSON.stringify(meta)); - - const result = loadMetaFile(filePath); - expect(result).toEqual(meta); - }); - - it('should throw on invalid JSON', () => { - const filePath = path.join(tmpDir, 'bad.json'); - fs.writeFileSync(filePath, 'not json'); - - expect(() => loadMetaFile(filePath)).toThrow(); - }); - - it('should throw on non-array JSON', () => { - const filePath = path.join(tmpDir, 'object.json'); - fs.writeFileSync(filePath, '{"not": "an array"}'); - - expect(() => loadMetaFile(filePath)).toThrow(/expected a JSON array/); - }); -}); - -describe('enrichPgTypesFromMetaFile', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meta-test-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('should return 0 for non-existent file', () => { - const tables = [makeTable('User', [makeField('id', 'UUID')])]; - const count = enrichPgTypesFromMetaFile(tables, path.join(tmpDir, 'nope.json')); - expect(count).toBe(0); - }); - - it('should load file and enrich tables', () => { - const filePath = path.join(tmpDir, '_meta.json'); - const meta = [makeMetaTable('Document', [{ name: 'tsvContent', pgType: 'tsvector' }])]; - fs.writeFileSync(filePath, JSON.stringify(meta)); - - const tables = [ - makeTable('Document', [ - makeField('tsvContent', 'FullText'), - ]), - ]; - - const count = enrichPgTypesFromMetaFile(tables, filePath); - expect(count).toBe(1); - expect(tables[0].fields[0].type.pgType).toBe('tsvector'); - }); -}); diff --git a/graphql/codegen/src/cli/handler.ts b/graphql/codegen/src/cli/handler.ts index 222aa3d19..3a8d3573f 100644 --- a/graphql/codegen/src/cli/handler.ts +++ b/graphql/codegen/src/cli/handler.ts @@ -8,7 +8,6 @@ import type { Question } from 'inquirerer'; import { findConfigFile, loadConfigFile } from '../core/config'; -import { buildMetaFromTables, writeMetaFile } from '../core/dump-pg-types'; import { expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, generate, generateMulti } from '../core/generate'; import type { GraphQLSDKConfigTarget } from '../types/config'; import { @@ -32,12 +31,6 @@ export async function runCodegenHandler( ): Promise { const args = camelizeArgv(argv as Record); - // Handle --dump-meta: run pipeline, extract _meta table metadata, write JSON - if (args.dumpMeta) { - await handleDumpMeta(args, prompter); - return; - } - const schemaConfig = args.schemaEnabled ? { enabled: true, @@ -136,56 +129,3 @@ export async function runCodegenHandler( }); printResult(result); } - -async function handleDumpMeta( - args: Record, - prompter: Prompter, -): Promise { - const hasSourceFlags = Boolean( - args.endpoint || args.schemaFile || args.schemaDir || args.schemas || args.apiNames - ); - const configPath = - (args.config as string | undefined) || - (!hasSourceFlags ? findConfigFile() : undefined); - - let fileConfig: GraphQLSDKConfigTarget = {}; - if (configPath) { - const loaded = await loadConfigFile(configPath); - if (!loaded.success) { - console.error('x', loaded.error); - process.exit(1); - } - fileConfig = loaded.config as GraphQLSDKConfigTarget; - } - - const seeded = seedArgvFromConfig(args, fileConfig); - const answers = hasResolvedCodegenSource(seeded) - ? seeded - : await prompter.prompt(seeded, codegenQuestions); - const options = buildGenerateOptions(answers, fileConfig); - - // Run the pipeline to get tables (we need the full codegen pipeline to infer tables) - const result = await generate({ - ...options, - dryRun: true, // Don't write codegen files, just get pipeline data - }); - - if (!result.success || !result.pipelineData?.tables.length) { - console.error('x', result.message || 'No tables found'); - process.exit(1); - } - - const meta = buildMetaFromTables(result.pipelineData.tables); - const outputPath = typeof args.dumpMeta === 'string' - ? args.dumpMeta - : '_meta.json'; - const written = await writeMetaFile(meta, outputPath); - - const tableCount = meta.length; - const fieldCount = meta.reduce((sum, t) => sum + (t.fields?.length ?? 0), 0); - console.log(`[ok] Wrote _meta.json: ${tableCount} tables, ${fieldCount} fields`); - console.log(` ${written}`); - console.log(''); - console.log('Usage: add metaFile to your codegen config:'); - console.log(` { metaFile: '${outputPath}' }`); -} diff --git a/graphql/codegen/src/cli/index.ts b/graphql/codegen/src/cli/index.ts index 29dc337f7..c0889131f 100644 --- a/graphql/codegen/src/cli/index.ts +++ b/graphql/codegen/src/cli/index.ts @@ -42,13 +42,6 @@ Schema Export: --schema-output Output directory for the exported schema file --schema-filename Filename for the exported schema (default: schema.graphql) -Metadata Export: - --dump-meta [path] Generate _meta.json from introspection. - Captures field-level pgType, relations, and other - metadata from the MetaSchemaPlugin. - Use with metaFile config option for file/schemaDir modes. - Default output: ./_meta.json - -h, --help Show this help message --version Show version number `; @@ -86,7 +79,7 @@ export const options: Partial = { a: 'authorization', v: 'verbose', }, - boolean: ['schema-enabled', 'dump-meta'], + boolean: ['schema-enabled'], string: [ 'config', 'endpoint', diff --git a/graphql/codegen/src/core/codegen/cli/table-command-generator.ts b/graphql/codegen/src/core/codegen/cli/table-command-generator.ts index 3977983b4..a121a5595 100644 --- a/graphql/codegen/src/core/codegen/cli/table-command-generator.ts +++ b/graphql/codegen/src/core/codegen/cli/table-command-generator.ts @@ -547,7 +547,7 @@ function buildSearchHandler( const whereProps: t.ObjectProperty[] = []; for (const group of specialGroups) { for (const field of group.fields) { - if (field.type.pgType?.toLowerCase() === 'tsvector' || (field.type.gqlType === 'FullText' && !field.type.isArray)) { + if (field.type.gqlType === 'FullText' && !field.type.isArray) { // tsvector field: { query } whereProps.push( t.objectProperty( diff --git a/graphql/codegen/src/core/codegen/docs-utils.ts b/graphql/codegen/src/core/codegen/docs-utils.ts index 87c78f3ae..46ccdd1fa 100644 --- a/graphql/codegen/src/core/codegen/docs-utils.ts +++ b/graphql/codegen/src/core/codegen/docs-utils.ts @@ -153,8 +153,10 @@ function isPostGISField(f: Field): boolean { } function isEmbeddingField(f: Field): boolean { - const pgType = f.type.pgType?.toLowerCase(); - if (pgType === 'vector') return true; + // VectorCodecPlugin maps pgvector `vector` columns to the `Vector` GQL scalar. + // This is the primary detection path — no _meta or pgType enrichment needed. + if (f.type.gqlType === 'Vector') return true; + // Legacy fallback: name-based heuristic for schemas without VectorCodecPlugin if (/embedding$/i.test(f.name) && f.type.isArray && f.type.gqlType === 'Float') return true; return false; } diff --git a/graphql/codegen/src/core/dump-pg-types.ts b/graphql/codegen/src/core/dump-pg-types.ts deleted file mode 100644 index 16f0a9339..000000000 --- a/graphql/codegen/src/core/dump-pg-types.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Dump _meta Table Metadata - * - * Generates a _meta.json file by running the codegen pipeline and - * extracting table/field metadata in MetaTableInfo[] format. - * - * When used with a database source (which has MetaSchemaPlugin), - * this captures the full _meta data including pgType for every field. - * - * For sources without _meta (endpoint, file), the output captures - * whatever metadata is available from introspection alone. - * - * The output file can be used as a metaFile sidecar for file/schemaDir - * modes, giving them the same metadata that database mode gets automatically. - */ -import * as fs from 'node:fs'; -import path from 'node:path'; - -import type { Table } from '../types/schema'; -import type { MetaTableInfo } from './introspect/source/types'; - -/** - * Build a MetaTableInfo[] from an array of Table objects. - * Captures field-level pgType info in the same shape as _cachedTablesMeta. - */ -export function buildMetaFromTables(tables: Table[]): MetaTableInfo[] { - return tables.map((table) => ({ - name: table.name, - schemaName: 'public', // Default; overridden if _meta provides actual schema - fields: table.fields.map((field) => ({ - name: field.name, - type: { - pgType: field.type.pgType ?? 'unknown', - gqlType: field.type.gqlType, - isArray: field.type.isArray, - }, - })), - relations: { - manyToMany: table.relations.manyToMany.map((rel) => ({ - fieldName: rel.fieldName ?? null, - type: rel.type ?? null, - junctionTable: { name: rel.junctionTable ?? '' }, - junctionLeftKeyAttributes: (rel.junctionLeftKeyFields ?? []).map((k) => ({ name: k })), - junctionRightKeyAttributes: (rel.junctionRightKeyFields ?? []).map((k) => ({ name: k })), - leftKeyAttributes: (rel.leftKeyFields ?? []).map((k) => ({ name: k })), - rightKeyAttributes: (rel.rightKeyFields ?? []).map((k) => ({ name: k })), - rightTable: { name: rel.rightTable ?? '' }, - })), - }, - })); -} - -/** - * Write MetaTableInfo[] to a JSON file. - */ -export async function writeMetaFile( - meta: MetaTableInfo[], - outputPath: string, -): Promise { - const resolved = path.resolve(outputPath); - const dir = path.dirname(resolved); - await fs.promises.mkdir(dir, { recursive: true }); - await fs.promises.writeFile(resolved, JSON.stringify(meta, null, 2) + '\n', 'utf-8'); - return resolved; -} diff --git a/graphql/codegen/src/core/introspect/enrich-pg-types.ts b/graphql/codegen/src/core/introspect/enrich-pg-types.ts deleted file mode 100644 index 49a22ef50..000000000 --- a/graphql/codegen/src/core/introspect/enrich-pg-types.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * PostgreSQL Type Enrichment - * - * Enriches Table objects with PostgreSQL-specific type metadata (pgType) - * from _meta table metadata. - * - * Two sources of _meta data: - * 1. Live from database mode — _cachedTablesMeta populated by MetaSchemaPlugin - * 2. Static from metaFile — _meta.json sidecar loaded from disk (for file/schemaDir mode) - * - * When neither is available, codegen falls back to heuristic detection - * (FullText → tsvector, name patterns, GQL type patterns). - */ -import * as fs from 'node:fs'; -import path from 'node:path'; - -import type { Table } from '../../types/schema'; -import type { MetaTableInfo } from './source/types'; - -/** - * Enrich Table objects with pgType from _meta field metadata. - * Mutates the tables array in-place (same pattern as enrichManyToManyRelations). - * - * Matches _meta fields to Table fields by name and copies pgType. - * Does not overwrite gqlType, isArray, or other introspection-derived data. - * - * @returns Number of fields enriched (for logging) - */ -export function enrichPgTypesFromMeta( - tables: Table[], - tablesMeta: MetaTableInfo[], -): number { - let enriched = 0; - - const metaByName = new Map(tablesMeta.map((m) => [m.name, m])); - - for (const table of tables) { - const meta = metaByName.get(table.name); - if (!meta?.fields?.length) continue; - - // Build a lookup of meta field name → pgType - const pgTypeByField = new Map( - meta.fields.map((f) => [f.name, f.type.pgType]), - ); - - for (const field of table.fields) { - const pgType = pgTypeByField.get(field.name); - if (pgType && pgType !== 'unknown' && !field.type.pgType) { - field.type = { ...field.type, pgType }; - enriched++; - } - } - } - - return enriched; -} - -/** - * Load a _meta.json sidecar file from disk. - * Returns null if the file does not exist (optional file). - * Throws on parse errors (malformed JSON should be reported). - */ -export function loadMetaFile(filePath: string): MetaTableInfo[] | null { - const resolved = path.resolve(filePath); - if (!fs.existsSync(resolved)) { - return null; - } - - const raw = fs.readFileSync(resolved, 'utf-8'); - const parsed = JSON.parse(raw) as MetaTableInfo[]; - - // Basic validation: must be an array - if (!Array.isArray(parsed)) { - throw new Error( - `Invalid meta file at ${resolved}: expected a JSON array of table metadata.`, - ); - } - - return parsed; -} - -/** - * Convenience: load a metaFile and enrich tables in one step. - * Returns the number of fields enriched, or 0 if the file was not found. - */ -export function enrichPgTypesFromMetaFile( - tables: Table[], - filePath: string, -): number { - const meta = loadMetaFile(filePath); - if (!meta) return 0; - return enrichPgTypesFromMeta(tables, meta); -} diff --git a/graphql/codegen/src/core/introspect/source/types.ts b/graphql/codegen/src/core/introspect/source/types.ts index 14b390f22..949d5e70a 100644 --- a/graphql/codegen/src/core/introspect/source/types.ts +++ b/graphql/codegen/src/core/introspect/source/types.ts @@ -7,32 +7,12 @@ import type { IntrospectionQueryResponse } from '../../../types/introspection'; /** - * Field-level type metadata from _meta. - * Contains PostgreSQL-specific type info not available from GraphQL introspection. - */ -export interface MetaFieldType { - pgType: string; - gqlType: string; - isArray: boolean; -} - -/** - * Field metadata from _meta. - */ -export interface MetaFieldInfo { - name: string; - type: MetaFieldType; -} - -/** - * Table metadata from the _meta query. - * Provides field-level pgType info and M:N relation junction key details - * that aren't available from standard GraphQL introspection alone. + * Minimal table metadata from the _meta query, used to enrich M:N relations + * with junction key field information that isn't available from introspection alone. */ export interface MetaTableInfo { name: string; schemaName: string; - fields?: MetaFieldInfo[]; relations: { manyToMany: Array<{ fieldName: string | null; @@ -57,10 +37,8 @@ export interface SchemaSourceResult { introspection: IntrospectionQueryResponse; /** - * Optional table metadata from _meta query. - * Provides field-level pgType info and M:N junction key details. - * Present when the source supports _meta (database mode or endpoints with MetaSchemaPlugin), - * or loaded from a metaFile JSON sidecar in file/schemaDir mode. + * Optional table metadata from _meta query (provides M:N junction key details). + * Present when the source supports _meta (database mode or endpoints with MetaSchemaPlugin). */ tablesMeta?: MetaTableInfo[]; } diff --git a/graphql/codegen/src/core/pipeline/index.ts b/graphql/codegen/src/core/pipeline/index.ts index 3d79b7e24..16be011b6 100644 --- a/graphql/codegen/src/core/pipeline/index.ts +++ b/graphql/codegen/src/core/pipeline/index.ts @@ -14,7 +14,6 @@ import type { Table, TypeRegistry, } from '../../types/schema'; -import { enrichPgTypesFromMeta, enrichPgTypesFromMetaFile } from '../introspect/enrich-pg-types'; import { enrichManyToManyRelations } from '../introspect/enrich-relations'; import { inferTablesFromIntrospection } from '../introspect/infer-tables'; import type { SchemaSource } from '../introspect/source'; @@ -129,24 +128,6 @@ export async function runCodegenPipeline( log(` Enriched M:N relations from _meta (${tablesMeta.length} tables)`); } - // 2b. Enrich fields with pgType from _meta (database mode provides this automatically) - if (tablesMeta?.length) { - const enrichedCount = enrichPgTypesFromMeta(tables, tablesMeta); - if (enrichedCount > 0) { - log(` Enriched ${enrichedCount} fields with pgType from _meta`); - } - } - - // 2c. Enrich fields with pgType from metaFile sidecar (file/schemaDir/endpoint mode) - if (config.metaFile) { - const enrichedCount = enrichPgTypesFromMetaFile(tables, config.metaFile); - if (enrichedCount > 0) { - log(` Enriched ${enrichedCount} fields with pgType from metaFile: ${config.metaFile}`); - } else { - log(` No pgType enrichments from metaFile: ${config.metaFile}`); - } - } - // 3. Filter tables by config (combine exclude and systemExclude) tables = filterTables(tables, config.tables.include, [ ...config.tables.exclude, diff --git a/graphql/codegen/src/types/config.ts b/graphql/codegen/src/types/config.ts index 3d7a6c6be..790b11be7 100644 --- a/graphql/codegen/src/types/config.ts +++ b/graphql/codegen/src/types/config.ts @@ -262,23 +262,6 @@ export interface GraphQLSDKConfigTarget { */ db?: DbConfig; - /** - * Path to a _meta.json sidecar file providing table metadata. - * Enriches inferred Table objects with field-level pgType info - * that is not available from standard GraphQL introspection. - * - * In database mode, _meta data is fetched automatically from the MetaSchemaPlugin. - * For file/schemaDir/endpoint modes, provide this file to get the same metadata. - * - * When present, enables precise detection of pgvector, tsvector, PostGIS, etc. - * When absent, codegen falls back to heuristic detection (name patterns, GQL types). - * - * Generate this file with: `graphql-codegen --dump-meta` - * - * Format: MetaTableInfo[] (same shape as _cachedTablesMeta from MetaSchemaPlugin) - */ - metaFile?: string; - /** * Headers to include in introspection requests */ From a65b33da5810787045f254d10a36a66f3bd41615 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 27 Mar 2026 22:08:15 +0000 Subject: [PATCH 4/4] refactor(node-type-registry): replace --introspection with --meta for blueprint type generation Customers without direct Postgres access can now use the _meta GraphQL query output to generate typed blueprint interfaces. The --meta flag accepts _meta.json in any format: raw TableMeta[], { tables: [...] }, or { data: { _meta: { tables: [...] } } }. When --meta is not provided, static fallback types are used (no breakage). Renames IntrospectionFieldMeta/IntrospectionTableMeta to MetaFieldInfo/ MetaTableInfo to align with MetaSchemaPlugin naming. --- .../src/codegen/generate-types.ts | 78 ++++++++++--------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/graphile/node-type-registry/src/codegen/generate-types.ts b/graphile/node-type-registry/src/codegen/generate-types.ts index 88d6082c8..f9aa480b3 100644 --- a/graphile/node-type-registry/src/codegen/generate-types.ts +++ b/graphile/node-type-registry/src/codegen/generate-types.ts @@ -14,7 +14,7 @@ * when building blueprint JSON. The API itself accepts plain JSONB. * * Usage: - * npx ts-node src/codegen/generate-types.ts [--outdir ] [--introspection ] + * npx ts-node src/codegen/generate-types.ts [--outdir ] [--meta ] */ // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -29,10 +29,10 @@ import { allNodeTypes } from '../index'; import type { NodeTypeDefinition } from '../types'; // --------------------------------------------------------------------------- -// Introspection types (subset of TableMeta from graphile-settings) +// _meta types (matches TableMeta / FieldMeta from MetaSchemaPlugin) // --------------------------------------------------------------------------- -interface IntrospectionFieldMeta { +interface MetaFieldInfo { name: string; type: { pgType: string; gqlType: string; isArray: boolean }; isNotNull: boolean; @@ -42,10 +42,10 @@ interface IntrospectionFieldMeta { description: string | null; } -interface IntrospectionTableMeta { +interface MetaTableInfo { name: string; schemaName: string; - fields: IntrospectionFieldMeta[]; + fields: MetaFieldInfo[]; } // --------------------------------------------------------------------------- @@ -262,7 +262,7 @@ function pgTypeToTSType(pgType: string, isArray: boolean): t.TSType { * `typeOverrides` for columns that need a non-default TS type. */ function deriveInterfaceFromTable( - table: IntrospectionTableMeta, + table: MetaTableInfo, interfaceName: string, description: string, typeOverrides?: Record @@ -291,22 +291,22 @@ function deriveInterfaceFromTable( // --------------------------------------------------------------------------- function findTable( - tables: IntrospectionTableMeta[], + tables: MetaTableInfo[], schemaName: string, tableName: string -): IntrospectionTableMeta | undefined { +): MetaTableInfo | undefined { return tables.find((tbl) => tbl.schemaName === schemaName && tbl.name === tableName); } function buildBlueprintField( - introspection?: IntrospectionTableMeta[] + meta?: MetaTableInfo[] ): t.ExportNamedDeclaration { - const table = introspection && findTable(introspection, 'metaschema_public', 'field'); + const table = meta && findTable(meta, 'metaschema_public', 'field'); if (table) { return deriveInterfaceFromTable( table, 'BlueprintField', - 'A custom field (column) to add to a blueprint table. Derived from metaschema_public.field.', + 'A custom field (column) to add to a blueprint table. Derived from _meta.', ); } // Static fallback @@ -324,19 +324,19 @@ function buildBlueprintField( function buildBlueprintPolicy( authzNodes: NodeTypeDefinition[], - introspection?: IntrospectionTableMeta[] + meta?: MetaTableInfo[] ): t.ExportNamedDeclaration { const policyTypeAnnotation = authzNodes.length > 0 ? strUnion(authzNodes.map((nt) => nt.name)) : t.tsStringKeyword(); - const table = introspection && findTable(introspection, 'metaschema_public', 'policy'); + const table = meta && findTable(meta, 'metaschema_public', 'policy'); if (table) { return deriveInterfaceFromTable( table, 'BlueprintPolicy', - 'An RLS policy entry for a blueprint table. Derived from metaschema_public.policy.', + 'An RLS policy entry for a blueprint table. Derived from _meta.', { // policy_type gets a typed union of known Authz* node names policy_type: policyTypeAnnotation, @@ -403,14 +403,14 @@ function buildBlueprintFullTextSearch(): t.ExportNamedDeclaration { } function buildBlueprintIndex( - introspection?: IntrospectionTableMeta[] + meta?: MetaTableInfo[] ): t.ExportNamedDeclaration { - const table = introspection && findTable(introspection, 'metaschema_public', 'index'); + const table = meta && findTable(meta, 'metaschema_public', 'index'); if (table) { return deriveInterfaceFromTable( table, 'BlueprintIndex', - 'An index definition within a blueprint. Derived from metaschema_public.index.', + 'An index definition within a blueprint. Derived from _meta.', { // JSONB columns get Record instead of the default index_params: recordType(t.tsStringKeyword(), t.tsUnknownKeyword()), @@ -634,7 +634,7 @@ function sectionComment(title: string): t.Statement { // Main generator // --------------------------------------------------------------------------- -function buildProgram(introspection?: IntrospectionTableMeta[]): string { +function buildProgram(meta?: MetaTableInfo[]): string { const statements: t.Statement[] = []; // Group node types by category @@ -667,16 +667,16 @@ function buildProgram(introspection?: IntrospectionTableMeta[]): string { statements.push(...generateParamsInterfaces(nts)); } - // -- Structural types (introspection-driven when available) -- - const introspectionSource = introspection - ? 'Derived from introspection JSON' - : 'Static fallback (no introspection provided)'; - statements.push(sectionComment(`Structural types — ${introspectionSource}`)); - statements.push(buildBlueprintField(introspection)); - statements.push(buildBlueprintPolicy(authzNodes, introspection)); + // -- Structural types (_meta-driven when available, static fallback otherwise) -- + const metaSource = meta + ? 'Derived from _meta' + : 'Static fallback (no _meta provided)'; + statements.push(sectionComment(`Structural types — ${metaSource}`)); + statements.push(buildBlueprintField(meta)); + statements.push(buildBlueprintPolicy(authzNodes, meta)); statements.push(buildBlueprintFtsSource()); statements.push(buildBlueprintFullTextSearch()); - statements.push(buildBlueprintIndex(introspection)); + statements.push(buildBlueprintIndex(meta)); // -- Node types discriminated union -- statements.push( @@ -725,19 +725,25 @@ function main() { const outdir = outdirIdx !== -1 ? args[outdirIdx + 1] : join(__dirname, '..'); - const introspectionIdx = args.indexOf('--introspection'); - let introspection: IntrospectionTableMeta[] | undefined; - if (introspectionIdx !== -1 && args[introspectionIdx + 1]) { - const introspectionPath = args[introspectionIdx + 1]; - console.log(`Reading introspection from ${introspectionPath}`); - const raw = readFileSync(introspectionPath, 'utf-8'); - introspection = JSON.parse(raw) as IntrospectionTableMeta[]; - console.log(`Loaded ${introspection.length} tables from introspection`); + const metaIdx = args.indexOf('--meta'); + let meta: MetaTableInfo[] | undefined; + if (metaIdx !== -1 && args[metaIdx + 1]) { + const metaPath = args[metaIdx + 1]; + console.log(`Reading _meta from ${metaPath}`); + const raw = readFileSync(metaPath, 'utf-8'); + const parsed = JSON.parse(raw); + // Accept both { tables: [...] } (GQL query result) and raw [...] (array) + meta = (Array.isArray(parsed) ? parsed : parsed?.tables ?? parsed?.data?._meta?.tables) as MetaTableInfo[] | undefined; + if (meta) { + console.log(`Loaded ${meta.length} tables from _meta`); + } else { + console.log('Could not find tables in _meta JSON; using static fallback types'); + } } else { - console.log('No --introspection flag; using static fallback types'); + console.log('No --meta flag; using static fallback types'); } - const content = buildProgram(introspection); + const content = buildProgram(meta); const filename = 'blueprint-types.generated.ts'; const filepath = join(outdir, filename);