From b06968c25baef2d08d1a7fcadf07bfc5fbff5462 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 00:35:55 +0000 Subject: [PATCH] feat: refactor ConstructivePreset into createConstructivePreset() factory Convert static ConstructivePreset into a factory function that accepts ConstructivePresetOptions (mirrors database_settings/api_settings flags). Each feature flag controls whether its corresponding plugin preset is included in the generated Graphile config: enableConnectionFilter -> ConnectionFilterPreset, EnableAllFilterColumnsPreset enableManyToMany -> ManyToManyOptInPreset enableSearch -> UnifiedSearchPreset enablePostgis -> GraphilePostgisPreset enableLtree -> GraphileLtreePreset enableDirectUploads -> UploadPreset enablePresignedUploads -> PresignedUrlPreset, BucketProvisionerPreset enableAggregates -> PgAggregatesPreset enableLlm -> reserved (no plugin yet) connectionFilterOperatorFactories are dynamically built based on which satellite plugins (search, postgis, ltree) are enabled. The static ConstructivePreset export is preserved for backwards compat as createConstructivePreset() with no args (all defaults). Server's buildPreset() now passes databaseSettings directly to the factory, wiring the full feature flag cascade end-to-end. --- graphile/graphile-settings/src/index.ts | 8 +- .../src/presets/constructive-preset.ts | 336 ++++++++++-------- .../graphile-settings/src/presets/index.ts | 3 +- graphql/server/src/middleware/graphile.ts | 19 +- 4 files changed, 208 insertions(+), 158 deletions(-) diff --git a/graphile/graphile-settings/src/index.ts b/graphile/graphile-settings/src/index.ts index 5ff8ed26c..8d41a1e9d 100644 --- a/graphile/graphile-settings/src/index.ts +++ b/graphile/graphile-settings/src/index.ts @@ -41,11 +41,9 @@ import 'graphile-build'; // Re-export all plugins and presets // ============================================================================ -// Main preset -export { ConstructivePreset } from './presets/constructive-preset'; - -// Optional presets (not included in ConstructivePreset by default) -export { PgAggregatesPreset } from 'graphile-pg-aggregates'; +// Main preset + factory +export { ConstructivePreset, createConstructivePreset } from './presets/constructive-preset'; +export type { ConstructivePresetOptions } from './presets/constructive-preset'; // Re-export all plugins for convenience export * from './plugins/index'; diff --git a/graphile/graphile-settings/src/presets/constructive-preset.ts b/graphile/graphile-settings/src/presets/constructive-preset.ts index 6aa91f398..6e87d6c07 100644 --- a/graphile/graphile-settings/src/presets/constructive-preset.ts +++ b/graphile/graphile-settings/src/presets/constructive-preset.ts @@ -3,6 +3,7 @@ import type { GraphileConfig } from 'graphile-config'; import { ConnectionFilterPreset } from 'graphile-connection-filter'; import { createFolderOperatorFactory, GraphileLtreePreset } from 'graphile-ltree'; import { createPostgisOperatorFactory,GraphilePostgisPreset } from 'graphile-postgis'; +import { PgAggregatesPreset } from 'graphile-pg-aggregates'; import { PresignedUrlPreset } from 'graphile-presigned-url-plugin'; import { createMatchesOperatorFactory, createTrgmOperatorFactories,UnifiedSearchPreset } from 'graphile-search'; import { SqlExpressionValidatorPreset } from 'graphile-sql-expression-validator'; @@ -25,163 +26,220 @@ import { createBucketNameResolver, createEnsureBucketProvisioned, getAllowedOrig import { constructiveUploadFieldDefinitions } from '../upload-resolver'; /** - * Constructive PostGraphile v5 Preset + * Feature flags that control which optional Graphile plugins are included + * in the preset. Mirrors the `database_settings` / `api_settings` cascade + * from the services DB. * - * This is the main preset that combines all our custom plugins and configurations. - * It provides a clean, opinionated GraphQL API built from PostgreSQL. + * Every flag defaults to the value that matches the current production + * behavior so that `createConstructivePreset()` (no args) is identical to + * the previous static `ConstructivePreset`. + */ +export interface ConstructivePresetOptions { + enableAggregates?: boolean; + enablePostgis?: boolean; + enableSearch?: boolean; + enableDirectUploads?: boolean; + enablePresignedUploads?: boolean; + enableManyToMany?: boolean; + enableConnectionFilter?: boolean; + enableLtree?: boolean; + enableLlm?: boolean; +} + +const DEFAULTS: Required = { + enableAggregates: false, + enablePostgis: true, + enableSearch: true, + enableDirectUploads: true, + enablePresignedUploads: true, + enableManyToMany: true, + enableConnectionFilter: true, + enableLtree: true, + enableLlm: false, +}; + +/** + * Create a Constructive PostGraphile v5 Preset. + * + * Accepts optional feature flags (`ConstructivePresetOptions`) that map 1-to-1 + * with the `database_settings` / `api_settings` tables. When a flag is `true` + * its corresponding plugin preset is included; when `false` it is omitted. * - * FEATURES: - * - No Node/Relay features (keeps `id` as `id`, no global object identification) - * - Custom inflection using inflekt library - * - Conflict detection for multi-schema setups - * - Inflector logging for debugging (enable with INFLECTOR_LOG=1) - * - Primary key only lookups (no *ByEmail, *ByUsername, etc.) - * - Connection filter plugin with all columns filterable - * - Many-to-many relationships (opt-in via @behavior +manyToMany) - * - Meta schema plugin (_meta query for introspection of tables, fields, indexes) - * - PostGIS support (geometry/geography types, GeoJSON scalar — auto-detects PostGIS extension) - * - PostGIS connection filter operators (spatial filtering on geometry/geography columns) - * - Upload plugin (file upload to S3/MinIO for image, upload, attachment domain columns) - * - Presigned URL plugin (requestUploadUrl mutation + downloadUrl computed field) - * - Bucket provisioner plugin (auto-provisions S3 buckets on @storageBuckets table mutations, - * CORS management, provisionBucket mutation for manual/retry) - * - SQL expression validator (validates @sqlExpression columns in mutations) - * - PG type mappings (maps custom types like email, url to GraphQL scalars) - * - pgvector search (auto-discovers vector columns: filter fields, distance computed fields, - * orderBy distance — zero config) - * - pg_textsearch BM25 search (auto-discovers BM25 indexes: filter fields, score computed fields, - * orderBy score — zero config) - * - pg_trgm fuzzy matching (similarTo/wordSimilarTo on text columns, similarity score fields, - * orderBy similarity — zero config, typo-tolerant) - * - ltree support (auto-detects ltree columns, LTree scalar with file-path syntax, - * containment/glob filters — within, ancestorOf, glob) - * - Aggregates (OPTIONAL — not included by default; add PgAggregatesPreset to extends to enable. - * Provides sum, avg, min, max, stddev, variance, distinctCount on connections, - * groupedAggregates with groupBy + having, orderBy relational aggregates, - * filter by relational aggregates — per-table opt-out via @behavior -aggregates) + * Calling with no arguments produces the same preset as the previous static + * `ConstructivePreset` (everything on except aggregates and LLM). * - * RELATION FILTERS: - * - Enabled via connectionFilterRelations: true - * - Forward: filter child by parent (e.g. allOrders(where: { clientByClientId: { name: { startsWith: "Acme" } } })) - * - Backward: filter parent by children (e.g. allClients(where: { ordersByClientId: { some: { total: { greaterThan: 1000 } } } })) + * CORE PRESETS (always included): + * - MinimalPreset (PostGraphile without Node/Relay) + * - ConflictDetectorPreset (multi-schema conflict detection) + * - InflektPreset (custom inflection) + * - InflectorLoggerPreset (debugging, INFLECTOR_LOG=1) + * - NoUniqueLookupPreset (primary-key-only lookups) + * - MetaSchemaPreset (_meta introspection) + * - SqlExpressionValidatorPreset (@sqlExpression validation) + * - PgTypeMappingsPreset (email, url, etc.) + * - RequiredInputPreset (@requiredInput support) + * + * FLAG-CONTROLLED PRESETS: + * - enableConnectionFilter -> ConnectionFilterPreset, EnableAllFilterColumnsPreset + * - enableManyToMany -> ManyToManyOptInPreset + * - enableSearch -> UnifiedSearchPreset (tsvector, BM25, pg_trgm, pgvector) + * - enablePostgis -> GraphilePostgisPreset + * - enableLtree -> GraphileLtreePreset + * - enableDirectUploads -> UploadPreset + * - enablePresignedUploads -> PresignedUrlPreset, BucketProvisionerPreset + * - enableAggregates -> PgAggregatesPreset (off by default) + * - enableLlm -> (no plugin yet, reserved for future use) + * + * RELATION FILTERS (when enableConnectionFilter is true): + * - Forward: filter child by parent + * - Backward: filter parent by children * * USAGE: * ```typescript - * import { ConstructivePreset } from 'graphile-settings/presets'; - * import { makePgService } from 'postgraphile/adaptors/pg'; + * import { createConstructivePreset, makePgService } from 'graphile-settings'; * - * const preset: GraphileConfig.Preset = { - * extends: [ConstructivePreset], - * pgServices: [ - * makePgService({ - * connectionString: DATABASE_URL, - * schemas: ['public'], - * }), - * ], + * // All defaults (same as previous static ConstructivePreset) + * const preset = { + * extends: [createConstructivePreset()], + * pgServices: [makePgService({ connectionString, schemas })], + * }; + * + * // With database_settings feature flags + * const preset = { + * extends: [createConstructivePreset({ enableAggregates: true, enablePostgis: false })], + * pgServices: [makePgService({ connectionString, schemas })], * }; * ``` */ -export const ConstructivePreset: GraphileConfig.Preset = { - extends: [ +export function createConstructivePreset( + options?: ConstructivePresetOptions, +): GraphileConfig.Preset { + const opts = { ...DEFAULTS, ...options }; + + // ----- extends array ----- + const presets: GraphileConfig.Preset[] = [ + // Core (always on) MinimalPreset, ConflictDetectorPreset, InflektPreset, InflectorLoggerPreset, NoUniqueLookupPreset, - ConnectionFilterPreset({ connectionFilterRelations: true }), - EnableAllFilterColumnsPreset, - ManyToManyOptInPreset, MetaSchemaPreset, - UnifiedSearchPreset({ fullTextScalarName: 'FullText', tsConfig: 'english' }), - GraphilePostgisPreset, - GraphileLtreePreset, - UploadPreset({ - uploadFieldDefinitions: constructiveUploadFieldDefinitions, - maxFileSize: 10 * 1024 * 1024 // 10MB - }), - PresignedUrlPreset({ - s3: getPresignedUrlS3Config, - resolveBucketName: createBucketNameResolver(), - ensureBucketProvisioned: createEnsureBucketProvisioned() - }), - BucketProvisionerPreset({ - connection: getBucketProvisionerConnection, - allowedOrigins: getAllowedOrigins() - }), SqlExpressionValidatorPreset(), PgTypeMappingsPreset, - RequiredInputPreset - ], - /** - * Disable PostGraphile core's condition argument entirely. - * All filtering now lives under the `where` argument via our v5-native - * graphile-connection-filter plugin (which renames the default `filter` - * argument to `where` via `connectionFilterArgumentName: 'where'`). - * Search, BM25, pgvector, and PostGIS filter fields all hook into - * `isPgConnectionFilter` instead of `isPgCondition`. - */ - disablePlugins: [ - 'PgConditionArgumentPlugin', - 'PgConditionCustomFieldsPlugin' - ], - /** - * Connection Filter Plugin Configuration - * - * These options control what fields appear in the `where` argument on connections. - * Our v5-native graphile-connection-filter plugin controls relation filters via the - * `connectionFilterRelations` option passed to ConnectionFilterPreset(). - * - * NOTE: By default, PostGraphile v5 only allows filtering on INDEXED columns. - * We override this with EnableAllFilterColumnsPreset to allow filtering on ALL columns. - * This gives developers flexibility but requires monitoring for slow queries on - * non-indexed columns. - */ - schema: { - /** - * connectionFilterComputedColumns: false - * Disables filtering on computed columns (functions that return a value for a row). - * Computed columns can be expensive to filter on since they may not be indexed. - * To selectively enable, use `@filterable` smart tag on specific functions. - */ - connectionFilterComputedColumns: false, - - /** - * connectionFilterSetofFunctions: false - * Disables filtering on functions that return `setof` (multiple rows). - * These can be expensive operations. To selectively enable, use `@filterable` smart tag. - */ - connectionFilterSetofFunctions: false, - - /** - * connectionFilterLogicalOperators: true (default) - * Keeps `and`, `or`, `not` operators for combining filter conditions. - * Example: where: { or: [{ name: { eq: "foo" } }, { name: { eq: "bar" } }] } - */ - connectionFilterLogicalOperators: true, - - /** - * connectionFilterArrays: true (default) - * Allows filtering on PostgreSQL array columns. - * Example: where: { tags: { contains: ["important"] } } - */ - connectionFilterArrays: true, - - /** - * connectionFilterOperatorFactories - * Aggregates all satellite plugin operator factories into a single array. - * graphile-config replaces (not concatenates) arrays when merging presets, - * so we must explicitly collect all factories here at the top level. - */ - connectionFilterOperatorFactories: [ - createMatchesOperatorFactory('FullText', 'english'), - createTrgmOperatorFactories(), - createPostgisOperatorFactory(), - createFolderOperatorFactory() - ] - // NOTE: The UnifiedSearchPreset also registers matches + trgm operator factories. - // graphile-config merges arrays from presets, so having them here as well is fine - // and ensures they're present even if the preset order changes. + RequiredInputPreset, + ]; + + if (opts.enableConnectionFilter) { + presets.push( + ConnectionFilterPreset({ connectionFilterRelations: true }), + EnableAllFilterColumnsPreset, + ); } -}; + + if (opts.enableManyToMany) { + presets.push(ManyToManyOptInPreset); + } + + if (opts.enableSearch) { + presets.push( + UnifiedSearchPreset({ fullTextScalarName: 'FullText', tsConfig: 'english' }), + ); + } + + if (opts.enablePostgis) { + presets.push(GraphilePostgisPreset); + } + + if (opts.enableLtree) { + presets.push(GraphileLtreePreset); + } + + if (opts.enableDirectUploads) { + presets.push( + UploadPreset({ + uploadFieldDefinitions: constructiveUploadFieldDefinitions, + maxFileSize: 10 * 1024 * 1024, // 10MB + }), + ); + } + + if (opts.enablePresignedUploads) { + presets.push( + PresignedUrlPreset({ + s3: getPresignedUrlS3Config, + resolveBucketName: createBucketNameResolver(), + ensureBucketProvisioned: createEnsureBucketProvisioned(), + }), + BucketProvisionerPreset({ + connection: getBucketProvisionerConnection, + allowedOrigins: getAllowedOrigins(), + }), + ); + } + + if (opts.enableAggregates) { + presets.push(PgAggregatesPreset); + } + + // ----- connectionFilterOperatorFactories ----- + // Only include operator factories for features that are actually enabled. + // graphile-config replaces (not concatenates) arrays when merging presets, + // so we collect all active factories into a single top-level array. + const operatorFactories: unknown[] = []; + if (opts.enableConnectionFilter) { + if (opts.enableSearch) { + operatorFactories.push( + createMatchesOperatorFactory('FullText', 'english'), + createTrgmOperatorFactories(), + ); + } + if (opts.enablePostgis) { + operatorFactories.push(createPostgisOperatorFactory()); + } + if (opts.enableLtree) { + operatorFactories.push(createFolderOperatorFactory()); + } + } + + // ----- disablePlugins ----- + // When connection filter is enabled it replaces the built-in condition arg. + const disablePlugins: string[] = []; + if (opts.enableConnectionFilter) { + disablePlugins.push('PgConditionArgumentPlugin', 'PgConditionCustomFieldsPlugin'); + } + + // ----- schema options ----- + const schema: Record = {}; + if (opts.enableConnectionFilter) { + schema.connectionFilterComputedColumns = false; + schema.connectionFilterSetofFunctions = false; + schema.connectionFilterLogicalOperators = true; + schema.connectionFilterArrays = true; + if (operatorFactories.length > 0) { + schema.connectionFilterOperatorFactories = operatorFactories; + } + } + + const preset: GraphileConfig.Preset = { + extends: presets, + }; + + if (disablePlugins.length > 0) { + preset.disablePlugins = disablePlugins; + } + + if (Object.keys(schema).length > 0) { + preset.schema = schema; + } + + return preset; +} + +/** + * Default Constructive preset -- everything enabled except aggregates and LLM. + * Backwards-compatible: identical to the previous static ConstructivePreset. + */ +export const ConstructivePreset: GraphileConfig.Preset = createConstructivePreset(); export default ConstructivePreset; diff --git a/graphile/graphile-settings/src/presets/index.ts b/graphile/graphile-settings/src/presets/index.ts index ef54ac339..b9b926116 100644 --- a/graphile/graphile-settings/src/presets/index.ts +++ b/graphile/graphile-settings/src/presets/index.ts @@ -5,4 +5,5 @@ * for common use cases. */ -export { ConstructivePreset } from './constructive-preset'; +export { ConstructivePreset, createConstructivePreset } from './constructive-preset'; +export type { ConstructivePresetOptions } from './constructive-preset'; diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index 6275d638a..134c03f4c 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -6,7 +6,7 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; import type { GraphQLError, GraphQLFormattedError } from 'grafast/graphql'; import { createGraphileInstance, type GraphileCacheEntry, graphileCache } from 'graphile-cache'; import type { GraphileConfig } from 'graphile-config'; -import { ConstructivePreset, makePgService, PgAggregatesPreset } from 'graphile-settings'; +import { createConstructivePreset, makePgService } from 'graphile-settings'; import { getPgPool } from 'pg-cache'; import { getPgEnvOptions } from 'pg-env'; import './types'; // for Request type @@ -196,11 +196,10 @@ const reqLabel = (req: Request): string => (req.requestId ? `[${req.requestId}]` /** * Build a PostGraphile v5 preset for a tenant. * - * When `databaseSettings` are available, feature flags control which - * optional plugins are included. Currently only `enable_aggregates` - * adds a preset (PgAggregatesPreset) — all other features are baked - * into ConstructivePreset and are always-on until per-plugin disable - * logic is implemented in a follow-up. + * When `databaseSettings` are available the flags are forwarded to + * `createConstructivePreset()` which conditionally includes each + * plugin preset. Without settings the default preset is used + * (everything on except aggregates and LLM). */ const buildPreset = ( pool: import('pg').Pool, @@ -209,14 +208,8 @@ const buildPreset = ( roleName: string, databaseSettings?: DatabaseSettings, ): GraphileConfig.Preset => { - const presets: GraphileConfig.Preset[] = [ConstructivePreset]; - - if (databaseSettings?.enableAggregates) { - presets.push(PgAggregatesPreset); - } - return { - extends: presets, + extends: [createConstructivePreset(databaseSettings)], pgServices: [ makePgService({ pool,