Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions graphile/graphile-connection-filter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# graphile-connection-filter

A PostGraphile v5 native connection filter plugin for the Constructive monorepo.

Adds advanced filtering capabilities to connection and list fields, including:

- Per-table filter types (e.g. `UserFilter`)
- Per-scalar operator types (e.g. `StringFilter`, `IntFilter`)
- Standard operators: `equalTo`, `notEqualTo`, `isNull`, `in`, `notIn`, etc.
- Sort operators: `lessThan`, `greaterThan`, etc.
- Pattern matching: `includes`, `startsWith`, `endsWith`, `like` + case-insensitive variants
- Type-specific operators: JSONB, hstore, inet, array, range
- Logical operators: `and`, `or`, `not`
- Custom operator API: `addConnectionFilterOperator` for satellite plugins

## Usage

```typescript
import { ConnectionFilterPreset } from 'graphile-connection-filter';

const preset: GraphileConfig.Preset = {
extends: [
ConnectionFilterPreset(),
],
};
```

## Custom Operators

Satellite plugins can register custom operators during the `init` hook:

```typescript
const addConnectionFilterOperator = (build as any).addConnectionFilterOperator;
if (typeof addConnectionFilterOperator === 'function') {
addConnectionFilterOperator('MyType', 'myOperator', {
description: 'My custom operator',
resolve: (sqlIdentifier, sqlValue) => sql`${sqlIdentifier} OP ${sqlValue}`,
});
}
```
55 changes: 55 additions & 0 deletions graphile/graphile-connection-filter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"name": "graphile-connection-filter",
"version": "1.0.0",
"description": "PostGraphile v5 native connection filter plugin - adds advanced filtering to connections",
"author": "Constructive <developers@constructive.io>",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"scripts": {
"clean": "makage clean",
"prepack": "npm run build",
"build": "makage build",
"build:dev": "makage build --dev",
"lint": "eslint . --fix",
"test": "jest",
"test:watch": "jest --watch"
},
"publishConfig": {
"access": "public",
"directory": "dist"
},
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/constructive"
},
"keywords": [
"postgraphile",
"graphile",
"constructive",
"plugin",
"postgres",
"graphql",
"filter",
"connection-filter",
"v5"
],
"bugs": {
"url": "https://github.com/constructive-io/constructive/issues"
},
"devDependencies": {
"@types/node": "^22.19.11",
"makage": "^0.1.10"
},
"peerDependencies": {
"@dataplan/pg": "1.0.0-rc.5",
"graphile-build": "5.0.0-rc.4",
"graphile-build-pg": "5.0.0-rc.5",
"graphile-config": "1.0.0-rc.5",
"graphql": "^16.9.0",
"pg-sql2": "5.0.0-rc.4",
"postgraphile": "5.0.0-rc.7"
}
}
88 changes: 88 additions & 0 deletions graphile/graphile-connection-filter/src/augmentations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* TypeScript namespace augmentations for graphile-connection-filter.
*
* These extend the Graphile type system so that our custom inflection methods,
* build properties, scope properties, schema options, and behaviors are
* recognized by the TypeScript compiler.
*/

import 'graphile-build';
import 'graphile-build-pg';
import type { ConnectionFilterOperatorSpec, ConnectionFilterOperatorsDigest, PgConnectionFilterOperatorsScope } from './types';

declare global {
namespace GraphileBuild {
interface Inflection {
/** Filter type name for a table, e.g. "UserFilter" */
filterType(this: Inflection, typeName: string): string;
/** Filter field type name for a scalar, e.g. "StringFilter" */
filterFieldType(this: Inflection, typeName: string): string;
/** Filter field list type name for an array scalar, e.g. "StringListFilter" */
filterFieldListType(this: Inflection, typeName: string): string;
}

interface Build {
/** Returns the operator digest for a given codec, or null if not filterable */
connectionFilterOperatorsDigest(codec: any): ConnectionFilterOperatorsDigest | null;
/** Escapes LIKE wildcard characters (% and _) */
escapeLikeWildcards(input: unknown): string;
/** Registers a custom filter operator (used by satellite plugins) */
addConnectionFilterOperator(
typeNameOrNames: string | string[],
filterName: string,
spec: ConnectionFilterOperatorSpec
): void;
/** Internal filter operator registry keyed by filter type name */
[key: symbol]: any;
}

interface ScopeInputObject {
/** True if this is a table-level connection filter type (e.g. UserFilter) */
isPgConnectionFilter?: boolean;
/** Operator type scope data (present on scalar filter types like StringFilter) */
pgConnectionFilterOperators?: PgConnectionFilterOperatorsScope;
}

interface ScopeInputObjectFieldsField {
/** True if this field is an attribute-based filter field */
isPgConnectionFilterField?: boolean;
/** True if this field is a filter operator (e.g. equalTo, lessThan) */
isPgConnectionFilterOperator?: boolean;
/** True if this field is a logical operator (and/or/not) */
isPgConnectionFilterOperatorLogical?: boolean;
/** True if this is a many-relation filter field */
isPgConnectionFilterManyField?: boolean;
}

interface BehaviorStrings {
filter: true;
filterProc: true;
'attribute:filterBy': true;
}

interface SchemaOptions {
connectionFilterArrays?: boolean;
connectionFilterLogicalOperators?: boolean;
connectionFilterAllowNullInput?: boolean;
connectionFilterAllowEmptyObjectInput?: boolean;
connectionFilterAllowedFieldTypes?: string[];
connectionFilterAllowedOperators?: string[];
connectionFilterOperatorNames?: Record<string, string>;
connectionFilterSetofFunctions?: boolean;
connectionFilterComputedColumns?: boolean;
connectionFilterRelations?: boolean;
}
}

namespace GraphileConfig {
interface Plugins {
ConnectionFilterInflectionPlugin: true;
ConnectionFilterTypesPlugin: true;
ConnectionFilterArgPlugin: true;
ConnectionFilterAttributesPlugin: true;
ConnectionFilterOperatorsPlugin: true;
ConnectionFilterCustomOperatorsPlugin: true;
ConnectionFilterLogicalOperatorsPlugin: true;
}
}
}
60 changes: 60 additions & 0 deletions graphile/graphile-connection-filter/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* graphile-connection-filter
*
* A PostGraphile v5 native connection filter plugin.
* Adds advanced filtering capabilities to connection and list fields.
*
* @example
* ```typescript
* import { ConnectionFilterPreset } from 'graphile-connection-filter';
*
* const preset = {
* extends: [
* ConnectionFilterPreset(),
* ],
* };
* ```
*
* For satellite plugins that need to register custom operators:
* ```typescript
* // In your plugin's init hook:
* const addConnectionFilterOperator = (build as any).addConnectionFilterOperator;
* if (typeof addConnectionFilterOperator === 'function') {
* addConnectionFilterOperator('MyType', 'myOperator', {
* description: 'My custom operator',
* resolve: (sqlIdentifier, sqlValue) => sql`${sqlIdentifier} OP ${sqlValue}`,
* });
* }
* ```
*/

export { ConnectionFilterPreset } from './preset';

// Re-export all plugins for granular use
export {
ConnectionFilterInflectionPlugin,
ConnectionFilterTypesPlugin,
ConnectionFilterArgPlugin,
ConnectionFilterAttributesPlugin,
ConnectionFilterOperatorsPlugin,
ConnectionFilterCustomOperatorsPlugin,
ConnectionFilterLogicalOperatorsPlugin,
makeApplyFromOperatorSpec,
} from './plugins';

// Re-export types
export type {
ConnectionFilterOperatorSpec,
ConnectionFilterOptions,
ConnectionFilterOperatorsDigest,
PgConnectionFilterOperatorsScope,
} from './types';
export { $$filters } from './types';

// Re-export utilities
export {
isEmpty,
makeAssertAllowed,
isComputedScalarAttributeResource,
getComputedAttributeResources,
} from './utils';
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import '../augmentations';
import type { GraphileConfig } from 'graphile-config';
import { makeAssertAllowed } from '../utils';

const version = '1.0.0';

/**
* ConnectionFilterArgPlugin
*
* Adds the `filter` argument to connection and simple collection fields.
* Uses `applyPlan` to create a PgCondition that child filter fields can
* add WHERE clauses to.
*
* This runs before PgConnectionArgOrderByPlugin so that filters are applied
* before ordering (important for e.g. full-text search rank ordering).
*/
export const ConnectionFilterArgPlugin: GraphileConfig.Plugin = {
name: 'ConnectionFilterArgPlugin',
version,
description: 'Adds the filter argument to connection and list fields',
before: ['PgConnectionArgOrderByPlugin'],

schema: {
hooks: {
GraphQLObjectType_fields_field_args(args, build, context) {
const {
extend,
inflection,
EXPORTABLE,
dataplanPg: { PgCondition },
} = build;
const {
scope: {
isPgFieldConnection,
isPgFieldSimpleCollection,
pgFieldResource: resource,
pgFieldCodec,
fieldName,
},
Self,
} = context;

const shouldAddFilter =
isPgFieldConnection || isPgFieldSimpleCollection;
if (!shouldAddFilter) return args;

const codec = pgFieldCodec ?? resource?.codec;
if (!codec) return args;

// Check behavior: procedures use "filterProc", tables use "filter"
const desiredBehavior = resource?.parameters
? 'filterProc'
: 'filter';
if (
resource
? !build.behavior.pgResourceMatches(resource, desiredBehavior)
: !build.behavior.pgCodecMatches(codec, desiredBehavior)
) {
return args;
}

const returnCodec = codec;
const nodeType = build.getGraphQLTypeByPgCodec(
returnCodec,
'output'
);
if (!nodeType) return args;

const nodeTypeName = nodeType.name;
const filterTypeName = inflection.filterType(nodeTypeName);
const FilterType = build.getTypeByName(filterTypeName);
if (!FilterType) return args;

const assertAllowed = makeAssertAllowed(build);

// For setof functions returning scalars, track the codec
const attributeCodec =
resource?.parameters && !resource?.codec.attributes
? resource.codec
: null;

return extend(
args,
{
filter: {
description:
'A filter to be used in determining which values should be returned by the collection.',
type: FilterType,
...(isPgFieldConnection
? {
applyPlan: EXPORTABLE(
(
PgCondition: any,
assertAllowed: any,
attributeCodec: any
) =>
function (_: any, $connection: any, fieldArg: any) {
const $pgSelect = $connection.getSubplan();
fieldArg.apply(
$pgSelect,
(queryBuilder: any, value: any) => {
assertAllowed(value, 'object');
if (value == null) return;
const condition = new PgCondition(queryBuilder);
if (attributeCodec) {
condition.extensions.pgFilterAttribute = {
codec: attributeCodec,
};
}
return condition;
}
);
},
[PgCondition, assertAllowed, attributeCodec]
),
}
: {
applyPlan: EXPORTABLE(
(
PgCondition: any,
assertAllowed: any,
attributeCodec: any
) =>
function (_: any, $pgSelect: any, fieldArg: any) {
fieldArg.apply(
$pgSelect,
(queryBuilder: any, value: any) => {
assertAllowed(value, 'object');
if (value == null) return;
const condition = new PgCondition(queryBuilder);
if (attributeCodec) {
condition.extensions.pgFilterAttribute = {
codec: attributeCodec,
};
}
return condition;
}
);
},
[PgCondition, assertAllowed, attributeCodec]
),
}),
},
},
`Adding connection filter arg to field '${fieldName}' of '${Self.name}'`
);
},
},
},
};
Loading
Loading