feat: v5 relations — M:N ORM methods, orm-test package, filter type-safety + composite PK support#880
Conversation
… add/remove junction methods - Destructure tablesMeta in runCodegenPipeline() alongside introspection - Create enrich-relations.ts to populate ManyToManyRelation junction key fields from _meta - Add buildJunctionRemoveDocument helper to query-builder template - Generate add<Relation>/remove<Relation> methods on ORM models for M:N relations - Methods delegate to junction table's existing create/delete CRUD mutations
…s to match CRUD pattern
… for M:N methods - Use getCreateInputTypeName/getDeleteInputTypeName/getCreateMutationName/getDeleteMutationName instead of manual string construction - Resolve actual PK types via getPrimaryKeyInfo() instead of hardcoding string - Only import buildJunctionRemoveDocument when remove methods are actually generated - Remove redundant leftTable lookup (table IS the left table)
…reSQL New graphql/orm-test package that exercises ORM M:N patterns against a live PostGraphile schema via graphile-test: - SQL fixtures: posts, tags, post_tags junction with @behavior +manyToMany - ManyToManyOptInPreset enabled for M:N connection fields - Tests: addTag (createPostTag), removeTag (deletePostTagByPostIdAndTagId, deletePostTagByRowId), CRUD, reverse M:N direction, unique constraint - 10/10 tests pass locally
Exercises tsvector, BM25 (pg_textsearch), pg_trgm, pgvector, PostGIS, relation filters, scalar filters, and M:N junctions in one test suite. Crown jewel: a single GraphQL request combining all 7 plugin types with multi-signal ORDER BY (BM25 score + trgm similarity). 36 mega-query tests + 10 M:N tests = 46 total, all passing.
- Add codegen-helper.ts: orchestrates introspection → inferTables → generateOrm → compile → load at test runtime
- Add graphile-adapter.ts: wraps graphile-test query() as GraphQLAdapter for ORM client
- Rewrite orm-m2n.test.ts: uses generated ORM for findMany/create, raw GraphQL for delete (junction PK not inferred)
- Rewrite mega-query.test.ts: uses generated ORM for all 7 ConstructivePreset plugins
- Fix vectorEmbedding filter shape: VectorNearbyInput is flat {vector, distance}, not nested {nearby: {embedding}}
- Fix BM25 assertions: BM25 returns all rows with scores (0 for non-matches), doesn't filter
- Each test suite gets isolated __generated__/<name> directory to avoid parallel collisions
- Add @constructive-io/graphql-codegen dependency to orm-test package.json
- 48 tests pass (12 M:N + 36 mega query)
…er seed data
- Richer seed body texts for dramatic BM25 score separation
- Semantic vector embeddings clustered by category (restaurant/park/museum)
- Concrete assertions: BM25 ranking order, trgm similarity thresholds, tsvRank ranges
- Fix vectorEmbedding filter shape: use flat {vector, distance} (not nested nearby/embedding)
- 3 new pgvector cluster tests (restaurant, park, museum directions)
- searchScore composite signal test combining tsvector + BM25 + trgm
- 52 tests total, all passing
…f truth Issue 1: buildTableFilterProperties now uses the schema's filter input type as the sole source of truth (via TypeRegistry), capturing plugin-injected filter fields (bm25Body, tsvTsv, trgmName, vectorEmbedding, geom, etc.) that are not present on the entity type. Same pattern as buildOrderByValues. Adds collectFilterExtraInputTypes to discover custom filter types referenced by plugin fields. Issue 2: inferPrimaryKeyFromInputObject now returns all candidate key fields instead of null when multiple exist, enabling composite PK detection for junction tables like PostTag(postId, tagId). Prefers DeleteInput over UpdateInput as signal source (fewer non-PK fields to filter).
…' into devin/1774088288-v5-relations-unified
…devin/1774088288-v5-relations-unified
…safety-composite-pk' into devin/1774088288-v5-relations-unified
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
…C (graphile-test handles roles/extensions)
… (pgsql-test manages extensions)
…_id) PK, ORM delete with composite keys, derive input types from actual mutation names
- Update getDeleteInputTypeName/getUpdateInputTypeName in utils.ts to derive from actual mutation name (table.query?.delete/update) when available - Add inputTypeFromMutation() helper in infer-tables.ts for the 3 local sites - Remove 5 ad-hoc ucFirst(mutationName) + 'Input' scattered across files - One function controls each name, one place to make a mistake
| @@ -181,7 +189,6 @@ export function generateModelFile( | |||
| const orderByTypeName = getOrderByTypeName(table); | |||
| const createInputTypeName = `Create${typeName}Input`; | |||
| const updateInputTypeName = `Update${typeName}Input`; | |||
There was a problem hiding this comment.
🔴 updateInputTypeName is hardcoded, inconsistent with deleteInputTypeName fix
The PR correctly changed deleteInputTypeName to use getDeleteInputTypeName(table) (line 203), which derives the input type name from the actual mutation name to support composite PKs and custom mutation names. However, updateInputTypeName on line 191 is still hardcoded as `Update${typeName}Input` instead of using the analogous getUpdateInputTypeName(table). For tables with custom update mutation names (e.g., modifyOrganization), the generated code will reference UpdateOrganizationInput (which doesn't exist in the schema) instead of ModifyOrganizationInput. The snapshot at model-generator.test.ts.snap:373 confirms the bug: the Organization model's update method emits "UpdateOrganizationInput" even though the mutation is "modifyOrganization". The same issue affects composite PK tables where the mutation might be updatePostTagByPostIdAndTagId — the generated code would reference UpdatePostTagInput instead of UpdatePostTagByPostIdAndTagIdInput.
| const updateInputTypeName = `Update${typeName}Input`; | |
| const updateInputTypeName = getUpdateInputTypeName(table); |
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Unified PR combining four feature branches for v5 M:N relation support. PR #863 (_meta plumbing + Clean* rename) was already merged to main; this combines the three remaining open PRs (#867, #869, #879).
1. M:N ORM add/remove methods (
model-generator.ts,query-builder.ts,enrich-relations.ts,pipeline/index.ts)tablesMetafromsource.fetch()through a newenrichManyToManyRelationsstep that populates junction key fields onManyToManyRelationadd<Singular>/remove<Singular>class methods on ORM models for each enriched M:N relationgetPrimaryKeyInfo()+tsTypeFromPrimitive()for correct PK typing,inflekt.singularize()for method names, existinggetCreateMutationName/getDeleteMutationNameutilities for namingbuildJunctionRemoveDocumentadded to query-builder for composite-key junction deletion2. Composite PK detection & delete support (
infer-tables.ts,model-generator.ts,query-builder.ts)inferPrimaryKeyFromInputObjectnow returnsIntrospectionInputValue[]instead of| nullwhen multiple candidates existPostTag(postId, tagId)DeleteInputoverUpdateInputas signal source (fewer non-PK fields)matchMutationOperationsbroadened to matchdeleteXxxBy*andupdateXxxBy*patterns (PostGraphile generatesdeletePostTagByPostIdAndTagIdfor composite PK tables)buildDeleteByPkDocumenttakes a keys object ({ id: args.where.id }or{ postId, tagId }) instead of separateid/idFieldNameparams — supports both single and composite PK deletes3. Consolidated input type name derivation (
utils.ts,infer-tables.ts,model-generator.ts)getDeleteInputTypeName(table)andgetUpdateInputTypeName(table)inutils.tsnow derive from actual mutation names (table.query?.delete/table.query?.update) when available, handling composite PK naming likeDeletePostTagByPostIdAndTagIdInputinputTypeFromMutation()helper ininfer-tables.tsreplaces 3 ad-hocucFirst(mutationName) + 'Input'callsmodel-generator.tsnow callsgetDeleteInputTypeName(table)instead of inline derivation (2 sites)4. Filter type-safety (
input-types-generator.ts)buildTableFilterPropertiesuses the schema's{Entity}Filterinput type as sole source of truth via TypeRegistry when available (same pattern asbuildOrderByValues)collectFilterExtraInputTypesdiscovers custom filter types referenced by plugin fields for generationtable.fields-based generation when TypeRegistry is absent5. orm-test package (
graphql/orm-test/)graphile-testConstructivePresetin both test suites — same preset as production, ensuring tests exercise the full plugin stack including InflektPlugin's clean M:N field namesUpdates since last revision
getDeleteInputTypeNameandgetUpdateInputTypeNameinutils.tsnow derive from actual mutation names whentable.query?.delete/table.query?.updateis set.inputTypeFromMutation()helper added toinfer-tables.ts. Eliminated 5 ad-hocucFirst(mutationName) + 'Input'scattered acrossinfer-tables.ts(3 sites) andmodel-generator.ts(2 sites).removeOrganization→RemoveOrganizationInputinstead ofDeleteOrganizationInput).Review & Testing Checklist for Human
getDeleteInputTypeName/getUpdateInputTypeNamebehavioral change for existing callers — These helpers inutils.tsnow consulttable.query?.delete/table.query?.update. Other callers (mutations.ts,cli-generator.ts) previously always gotDelete${table.name}Input. If those code paths receive tables withtable.query?.deleteset (e.g. composite PK tables from introspection), they'll now get the mutation-derived name. Verify this is correct or that those callers don't encounter composite-PK tables.buildDeleteByPkDocumentAPI changed from(entityName, mutation, field, id, inputType, idFieldName, select, map)to(entityName, mutation, field, keysObject, inputType, select, map). The snapshot shows single-PK tables pass{ id: args.where.id }. Confirm no callers outside codegen use the old signature directly.startsWithmatching inmatchMutationOperations—field.name.startsWith(${expectedDelete}By)could theoretically match unintended mutations if entity names overlap. TheBysuffix prevents false positives between e.g.deletePostanddeletePostTag(checksdeletePostBy*vsdeletePostTagBy*), but verify this holds for your schema.clientMutationId/non-patch fields inDeleteInputare PK fields. Confirm no plugins inject extra non-PK fields into Delete input types.cd graphql/codegen && pnpm test(317 tests + 121 snapshots),cd graphql/query && pnpm test(18 tests),cd graphql/orm-test && pnpm test(52 tests against postgres-plus:18). Also runpnpm generateagainst a real schema with M:N junction tables using composite PKs and verify the generated delete methods compile and work correctly.Notes
pnpm-lock.yamldiff is mostly formatting changes (multi-line → single-lineresolution:fields) from a pnpm version difference, plus minor version bumps (semver7.7.3→7.7.4,pg-protocol1.12.0→1.13.0)tablesMetais absent (endpoint/file sources), so all changes are backward compatibleLink to Devin session: https://app.devin.ai/sessions/745d2d10b699452091e24131ba5edef2
Requested by: @pyramation