graphile-connection-filter 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +107 -0
- package/augmentations.d.ts +104 -0
- package/augmentations.js +11 -0
- package/esm/augmentations.d.ts +104 -0
- package/esm/augmentations.js +9 -0
- package/esm/index.d.ts +55 -0
- package/esm/index.js +56 -0
- package/esm/plugins/ConnectionFilterArgPlugin.d.ts +13 -0
- package/esm/plugins/ConnectionFilterArgPlugin.js +96 -0
- package/esm/plugins/ConnectionFilterAttributesPlugin.d.ts +14 -0
- package/esm/plugins/ConnectionFilterAttributesPlugin.js +79 -0
- package/esm/plugins/ConnectionFilterBackwardRelationsPlugin.d.ts +33 -0
- package/esm/plugins/ConnectionFilterBackwardRelationsPlugin.js +398 -0
- package/esm/plugins/ConnectionFilterComputedAttributesPlugin.d.ts +19 -0
- package/esm/plugins/ConnectionFilterComputedAttributesPlugin.js +133 -0
- package/esm/plugins/ConnectionFilterCustomOperatorsPlugin.d.ts +35 -0
- package/esm/plugins/ConnectionFilterCustomOperatorsPlugin.js +129 -0
- package/esm/plugins/ConnectionFilterForwardRelationsPlugin.d.ts +28 -0
- package/esm/plugins/ConnectionFilterForwardRelationsPlugin.js +168 -0
- package/esm/plugins/ConnectionFilterInflectionPlugin.d.ts +11 -0
- package/esm/plugins/ConnectionFilterInflectionPlugin.js +27 -0
- package/esm/plugins/ConnectionFilterLogicalOperatorsPlugin.d.ts +15 -0
- package/esm/plugins/ConnectionFilterLogicalOperatorsPlugin.js +86 -0
- package/esm/plugins/ConnectionFilterOperatorsPlugin.d.ts +21 -0
- package/esm/plugins/ConnectionFilterOperatorsPlugin.js +677 -0
- package/esm/plugins/ConnectionFilterTypesPlugin.d.ts +12 -0
- package/esm/plugins/ConnectionFilterTypesPlugin.js +225 -0
- package/esm/plugins/index.d.ts +11 -0
- package/esm/plugins/index.js +11 -0
- package/esm/plugins/operatorApply.d.ts +11 -0
- package/esm/plugins/operatorApply.js +70 -0
- package/esm/preset.d.ts +35 -0
- package/esm/preset.js +72 -0
- package/esm/types.d.ts +146 -0
- package/esm/types.js +4 -0
- package/esm/utils.d.ts +44 -0
- package/esm/utils.js +112 -0
- package/index.d.ts +55 -0
- package/index.js +77 -0
- package/package.json +58 -0
- package/plugins/ConnectionFilterArgPlugin.d.ts +13 -0
- package/plugins/ConnectionFilterArgPlugin.js +99 -0
- package/plugins/ConnectionFilterAttributesPlugin.d.ts +14 -0
- package/plugins/ConnectionFilterAttributesPlugin.js +82 -0
- package/plugins/ConnectionFilterBackwardRelationsPlugin.d.ts +33 -0
- package/plugins/ConnectionFilterBackwardRelationsPlugin.js +401 -0
- package/plugins/ConnectionFilterComputedAttributesPlugin.d.ts +19 -0
- package/plugins/ConnectionFilterComputedAttributesPlugin.js +136 -0
- package/plugins/ConnectionFilterCustomOperatorsPlugin.d.ts +35 -0
- package/plugins/ConnectionFilterCustomOperatorsPlugin.js +132 -0
- package/plugins/ConnectionFilterForwardRelationsPlugin.d.ts +28 -0
- package/plugins/ConnectionFilterForwardRelationsPlugin.js +171 -0
- package/plugins/ConnectionFilterInflectionPlugin.d.ts +11 -0
- package/plugins/ConnectionFilterInflectionPlugin.js +30 -0
- package/plugins/ConnectionFilterLogicalOperatorsPlugin.d.ts +15 -0
- package/plugins/ConnectionFilterLogicalOperatorsPlugin.js +89 -0
- package/plugins/ConnectionFilterOperatorsPlugin.d.ts +21 -0
- package/plugins/ConnectionFilterOperatorsPlugin.js +680 -0
- package/plugins/ConnectionFilterTypesPlugin.d.ts +12 -0
- package/plugins/ConnectionFilterTypesPlugin.js +228 -0
- package/plugins/index.d.ts +11 -0
- package/plugins/index.js +25 -0
- package/plugins/operatorApply.d.ts +11 -0
- package/plugins/operatorApply.js +73 -0
- package/preset.d.ts +35 -0
- package/preset.js +75 -0
- package/types.d.ts +146 -0
- package/types.js +7 -0
- package/utils.d.ts +44 -0
- package/utils.js +119 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import { isEmpty } from '../utils';
|
|
3
|
+
const version = '1.0.0';
|
|
4
|
+
/**
|
|
5
|
+
* ConnectionFilterAttributesPlugin
|
|
6
|
+
*
|
|
7
|
+
* Adds per-column filter fields to the table filter types.
|
|
8
|
+
* For example, on `UserFilter`, adds fields like `name` (type: StringFilter),
|
|
9
|
+
* `age` (type: IntFilter), etc.
|
|
10
|
+
*
|
|
11
|
+
* Each field's `apply` function creates a new PgCondition with the
|
|
12
|
+
* `pgFilterAttribute` extension set, so downstream operator fields know
|
|
13
|
+
* which column they are operating on.
|
|
14
|
+
*/
|
|
15
|
+
export const ConnectionFilterAttributesPlugin = {
|
|
16
|
+
name: 'ConnectionFilterAttributesPlugin',
|
|
17
|
+
version,
|
|
18
|
+
description: 'Adds column-based filter fields to connection filter types',
|
|
19
|
+
schema: {
|
|
20
|
+
entityBehavior: {
|
|
21
|
+
pgCodecAttribute: 'attribute:filterBy',
|
|
22
|
+
},
|
|
23
|
+
hooks: {
|
|
24
|
+
GraphQLInputObjectType_fields(inFields, build, context) {
|
|
25
|
+
let fields = inFields;
|
|
26
|
+
const { inflection, connectionFilterOperatorsDigest, dataplanPg: { PgCondition }, EXPORTABLE, } = build;
|
|
27
|
+
const { fieldWithHooks, scope: { pgCodec: rawCodec, isPgConnectionFilter }, } = context;
|
|
28
|
+
if (!isPgConnectionFilter || !rawCodec || !rawCodec.attributes) {
|
|
29
|
+
return fields;
|
|
30
|
+
}
|
|
31
|
+
const codec = rawCodec;
|
|
32
|
+
for (const [attributeName, attribute] of Object.entries(codec.attributes)) {
|
|
33
|
+
if (!build.behavior.pgCodecAttributeMatches([codec, attributeName], 'attribute:filterBy')) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const fieldName = inflection.attribute({ codec: codec, attributeName });
|
|
37
|
+
const colSpec = { fieldName, attributeName, attribute };
|
|
38
|
+
const digest = connectionFilterOperatorsDigest(attribute.codec);
|
|
39
|
+
if (!digest)
|
|
40
|
+
continue;
|
|
41
|
+
const OperatorsType = build.getTypeByName(digest.operatorsTypeName);
|
|
42
|
+
if (!OperatorsType)
|
|
43
|
+
continue;
|
|
44
|
+
const { connectionFilterAllowNullInput, } = build.options;
|
|
45
|
+
fields = build.extend(fields, {
|
|
46
|
+
[fieldName]: fieldWithHooks({
|
|
47
|
+
fieldName,
|
|
48
|
+
isPgConnectionFilterField: true,
|
|
49
|
+
}, () => ({
|
|
50
|
+
description: `Filter by the object\u2019s \`${fieldName}\` field.`,
|
|
51
|
+
type: OperatorsType,
|
|
52
|
+
apply: EXPORTABLE((PgCondition, colSpec, connectionFilterAllowNullInput, isEmpty) => function (queryBuilder, value) {
|
|
53
|
+
if (value === undefined) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (isEmpty(value)) {
|
|
57
|
+
throw Object.assign(new Error('Empty objects are forbidden in filter argument input.'), {});
|
|
58
|
+
}
|
|
59
|
+
if (!connectionFilterAllowNullInput &&
|
|
60
|
+
value === null) {
|
|
61
|
+
throw Object.assign(new Error('Null literals are forbidden in filter argument input.'), {});
|
|
62
|
+
}
|
|
63
|
+
const condition = new PgCondition(queryBuilder);
|
|
64
|
+
condition.extensions.pgFilterAttribute = colSpec;
|
|
65
|
+
return condition;
|
|
66
|
+
}, [
|
|
67
|
+
PgCondition,
|
|
68
|
+
colSpec,
|
|
69
|
+
connectionFilterAllowNullInput,
|
|
70
|
+
isEmpty,
|
|
71
|
+
]),
|
|
72
|
+
})),
|
|
73
|
+
}, 'Adding attribute-based filtering');
|
|
74
|
+
}
|
|
75
|
+
return fields;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
3
|
+
/**
|
|
4
|
+
* ConnectionFilterBackwardRelationsPlugin
|
|
5
|
+
*
|
|
6
|
+
* Adds backward relation filter fields to table filter types.
|
|
7
|
+
* A "backward" relation is one where another table has a FK referencing the current table.
|
|
8
|
+
*
|
|
9
|
+
* For unique backward relations (one-to-one), a single filter field is added:
|
|
10
|
+
* ```graphql
|
|
11
|
+
* allClients(filter: {
|
|
12
|
+
* profileByClientId: { bio: { includes: "engineer" } }
|
|
13
|
+
* }) { ... }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* For non-unique backward relations (one-to-many), a "many" filter type is added
|
|
17
|
+
* with `some`, `every`, and `none` sub-fields:
|
|
18
|
+
* ```graphql
|
|
19
|
+
* allClients(filter: {
|
|
20
|
+
* ordersByClientId: { some: { total: { greaterThan: 1000 } } }
|
|
21
|
+
* }) { ... }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* The SQL generated uses EXISTS subqueries:
|
|
25
|
+
* ```sql
|
|
26
|
+
* WHERE EXISTS (
|
|
27
|
+
* SELECT 1 FROM orders
|
|
28
|
+
* WHERE orders.client_id = clients.id
|
|
29
|
+
* AND <nested filter conditions>
|
|
30
|
+
* )
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export declare const ConnectionFilterBackwardRelationsPlugin: GraphileConfig.Plugin;
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import { makeAssertAllowed } from '../utils';
|
|
3
|
+
const version = '1.0.0';
|
|
4
|
+
/**
|
|
5
|
+
* ConnectionFilterBackwardRelationsPlugin
|
|
6
|
+
*
|
|
7
|
+
* Adds backward relation filter fields to table filter types.
|
|
8
|
+
* A "backward" relation is one where another table has a FK referencing the current table.
|
|
9
|
+
*
|
|
10
|
+
* For unique backward relations (one-to-one), a single filter field is added:
|
|
11
|
+
* ```graphql
|
|
12
|
+
* allClients(filter: {
|
|
13
|
+
* profileByClientId: { bio: { includes: "engineer" } }
|
|
14
|
+
* }) { ... }
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* For non-unique backward relations (one-to-many), a "many" filter type is added
|
|
18
|
+
* with `some`, `every`, and `none` sub-fields:
|
|
19
|
+
* ```graphql
|
|
20
|
+
* allClients(filter: {
|
|
21
|
+
* ordersByClientId: { some: { total: { greaterThan: 1000 } } }
|
|
22
|
+
* }) { ... }
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* The SQL generated uses EXISTS subqueries:
|
|
26
|
+
* ```sql
|
|
27
|
+
* WHERE EXISTS (
|
|
28
|
+
* SELECT 1 FROM orders
|
|
29
|
+
* WHERE orders.client_id = clients.id
|
|
30
|
+
* AND <nested filter conditions>
|
|
31
|
+
* )
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export const ConnectionFilterBackwardRelationsPlugin = {
|
|
35
|
+
name: 'ConnectionFilterBackwardRelationsPlugin',
|
|
36
|
+
version,
|
|
37
|
+
description: 'Adds backward relation filter fields to connection filter types',
|
|
38
|
+
inflection: {
|
|
39
|
+
add: {
|
|
40
|
+
filterManyType(_preset, table, foreignTable) {
|
|
41
|
+
return this.upperCamelCase(`${this.tableType(table)}-to-many-${this.tableType(foreignTable.codec)}-filter`);
|
|
42
|
+
},
|
|
43
|
+
filterBackwardSingleRelationExistsFieldName(_preset, relationFieldName) {
|
|
44
|
+
return `${relationFieldName}Exists`;
|
|
45
|
+
},
|
|
46
|
+
filterBackwardManyRelationExistsFieldName(_preset, relationFieldName) {
|
|
47
|
+
return `${relationFieldName}Exist`;
|
|
48
|
+
},
|
|
49
|
+
filterSingleRelationByKeysBackwardsFieldName(_preset, fieldName) {
|
|
50
|
+
return fieldName;
|
|
51
|
+
},
|
|
52
|
+
filterManyRelationByKeysFieldName(_preset, fieldName) {
|
|
53
|
+
return fieldName;
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
schema: {
|
|
58
|
+
behaviorRegistry: {
|
|
59
|
+
add: {
|
|
60
|
+
filterBy: {
|
|
61
|
+
description: 'Whether a relation should be available as a filter field',
|
|
62
|
+
entities: ['pgCodecRelation'],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
entityBehavior: {
|
|
67
|
+
pgCodecRelation: 'filterBy',
|
|
68
|
+
},
|
|
69
|
+
hooks: {
|
|
70
|
+
init(_, build) {
|
|
71
|
+
// Runtime check: only proceed if relation filters are enabled
|
|
72
|
+
if (!build.options.connectionFilterRelations) {
|
|
73
|
+
return _;
|
|
74
|
+
}
|
|
75
|
+
const { inflection } = build;
|
|
76
|
+
// Register "many" filter types (e.g. ClientToManyOrderFilter)
|
|
77
|
+
// These contain `some`, `every`, `none` fields.
|
|
78
|
+
const requireIndex = build.options.connectionFilterRelationsRequireIndex !== false;
|
|
79
|
+
for (const source of Object.values(build.input.pgRegistry.pgResources)) {
|
|
80
|
+
if (source.parameters ||
|
|
81
|
+
!source.codec.attributes ||
|
|
82
|
+
source.isUnique) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const [_relationName, relation] of Object.entries(source.getRelations())) {
|
|
86
|
+
if (!relation.isReferencee)
|
|
87
|
+
continue;
|
|
88
|
+
if (relation.isUnique)
|
|
89
|
+
continue;
|
|
90
|
+
const foreignTable = relation.remoteResource;
|
|
91
|
+
if (typeof foreignTable.from === 'function')
|
|
92
|
+
continue;
|
|
93
|
+
if (!build.behavior.pgCodecRelationMatches(relation, 'filterBy')) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Skip unindexed relations when requireIndex is enabled.
|
|
97
|
+
// PgIndexBehaviorsPlugin sets relation.extensions.isIndexed = false
|
|
98
|
+
// on relations without supporting indexes on the FK columns.
|
|
99
|
+
if (requireIndex && relation.extensions?.isIndexed === false) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const filterManyTypeName = inflection.filterManyType(source.codec, foreignTable);
|
|
103
|
+
const foreignTableTypeName = inflection.tableType(foreignTable.codec);
|
|
104
|
+
if (!build.getTypeMetaByName(filterManyTypeName)) {
|
|
105
|
+
build.recoverable(null, () => {
|
|
106
|
+
build.registerInputObjectType(filterManyTypeName, {
|
|
107
|
+
foreignTable,
|
|
108
|
+
isPgConnectionFilterMany: true,
|
|
109
|
+
}, () => ({
|
|
110
|
+
name: filterManyTypeName,
|
|
111
|
+
description: `A filter to be used against many \`${foreignTableTypeName}\` object types. All fields are combined with a logical \u2018and.\u2019`,
|
|
112
|
+
}), `ConnectionFilterBackwardRelationsPlugin: Adding '${filterManyTypeName}' type for ${foreignTable.name}`);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return _;
|
|
118
|
+
},
|
|
119
|
+
GraphQLInputObjectType_fields(inFields, build, context) {
|
|
120
|
+
let fields = inFields;
|
|
121
|
+
// Runtime check: only proceed if relation filters are enabled
|
|
122
|
+
if (!build.options.connectionFilterRelations) {
|
|
123
|
+
return fields;
|
|
124
|
+
}
|
|
125
|
+
const { extend, inflection, sql, graphql: { GraphQLBoolean }, EXPORTABLE, } = build;
|
|
126
|
+
const { fieldWithHooks, scope: { pgCodec, isPgConnectionFilter, foreignTable: scopeForeignTable, isPgConnectionFilterMany, }, Self, } = context;
|
|
127
|
+
const assertAllowed = makeAssertAllowed(build);
|
|
128
|
+
// ─── Part 1: Add backward relation fields to table filter types ───
|
|
129
|
+
const source = pgCodec &&
|
|
130
|
+
Object.values(build.input.pgRegistry.pgResources).find((s) => s.codec === pgCodec && !s.parameters);
|
|
131
|
+
if (isPgConnectionFilter && pgCodec && pgCodec.attributes && source) {
|
|
132
|
+
const requireIndex = build.options.connectionFilterRelationsRequireIndex !== false;
|
|
133
|
+
const backwardRelations = Object.entries(source.getRelations()).filter(([_relationName, relation]) => relation.isReferencee);
|
|
134
|
+
for (const [relationName, relation] of backwardRelations) {
|
|
135
|
+
const foreignTable = relation.remoteResource;
|
|
136
|
+
if (!build.behavior.pgCodecRelationMatches(relation, 'filterBy')) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (typeof foreignTable.from === 'function') {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// Skip unindexed relations when requireIndex is enabled.
|
|
143
|
+
// PgIndexBehaviorsPlugin sets relation.extensions.isIndexed = false
|
|
144
|
+
// on backward relations without supporting indexes on the FK columns.
|
|
145
|
+
if (requireIndex && relation.extensions?.isIndexed === false) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const isForeignKeyUnique = relation.isUnique;
|
|
149
|
+
const foreignTableExpression = foreignTable.from;
|
|
150
|
+
const localAttributes = relation.localAttributes;
|
|
151
|
+
const remoteAttributes = relation.remoteAttributes;
|
|
152
|
+
if (isForeignKeyUnique) {
|
|
153
|
+
// One-to-one: add a single relation filter field
|
|
154
|
+
const fieldName = inflection.singleRelationBackwards({
|
|
155
|
+
registry: source.registry,
|
|
156
|
+
codec: source.codec,
|
|
157
|
+
relationName,
|
|
158
|
+
});
|
|
159
|
+
const filterFieldName = inflection.filterSingleRelationByKeysBackwardsFieldName(fieldName);
|
|
160
|
+
const foreignTableTypeName = inflection.tableType(foreignTable.codec);
|
|
161
|
+
const foreignTableFilterTypeName = inflection.filterType(foreignTableTypeName);
|
|
162
|
+
const ForeignTableFilterType = build.getTypeByName(foreignTableFilterTypeName);
|
|
163
|
+
if (!ForeignTableFilterType)
|
|
164
|
+
continue;
|
|
165
|
+
fields = extend(fields, {
|
|
166
|
+
[filterFieldName]: fieldWithHooks({
|
|
167
|
+
fieldName: filterFieldName,
|
|
168
|
+
isPgConnectionFilterField: true,
|
|
169
|
+
}, () => ({
|
|
170
|
+
description: `Filter by the object\u2019s \`${fieldName}\` relation.`,
|
|
171
|
+
type: ForeignTableFilterType,
|
|
172
|
+
apply: EXPORTABLE((assertAllowed, foreignTable, foreignTableExpression, localAttributes, remoteAttributes, sql) => function ($where, value) {
|
|
173
|
+
assertAllowed(value, 'object');
|
|
174
|
+
if (value == null)
|
|
175
|
+
return;
|
|
176
|
+
const $subQuery = $where.existsPlan({
|
|
177
|
+
tableExpression: foreignTableExpression,
|
|
178
|
+
alias: foreignTable.name,
|
|
179
|
+
});
|
|
180
|
+
localAttributes.forEach((localAttribute, i) => {
|
|
181
|
+
const remoteAttribute = remoteAttributes[i];
|
|
182
|
+
$subQuery.where(sql `${$where.alias}.${sql.identifier(localAttribute)} = ${$subQuery.alias}.${sql.identifier(remoteAttribute)}`);
|
|
183
|
+
});
|
|
184
|
+
return $subQuery;
|
|
185
|
+
}, [
|
|
186
|
+
assertAllowed,
|
|
187
|
+
foreignTable,
|
|
188
|
+
foreignTableExpression,
|
|
189
|
+
localAttributes,
|
|
190
|
+
remoteAttributes,
|
|
191
|
+
sql,
|
|
192
|
+
]),
|
|
193
|
+
})),
|
|
194
|
+
}, `Adding connection filter backward single relation field from ${source.name} to ${foreignTable.name}`);
|
|
195
|
+
// Add exists field
|
|
196
|
+
const existsFieldName = inflection.filterBackwardSingleRelationExistsFieldName(fieldName);
|
|
197
|
+
fields = extend(fields, {
|
|
198
|
+
[existsFieldName]: fieldWithHooks({
|
|
199
|
+
fieldName: existsFieldName,
|
|
200
|
+
isPgConnectionFilterField: true,
|
|
201
|
+
}, () => ({
|
|
202
|
+
description: `A related \`${fieldName}\` exists.`,
|
|
203
|
+
type: GraphQLBoolean,
|
|
204
|
+
apply: EXPORTABLE((assertAllowed, foreignTable, foreignTableExpression, localAttributes, remoteAttributes, sql) => function ($where, value) {
|
|
205
|
+
assertAllowed(value, 'scalar');
|
|
206
|
+
if (value == null)
|
|
207
|
+
return;
|
|
208
|
+
const $subQuery = $where.existsPlan({
|
|
209
|
+
tableExpression: foreignTableExpression,
|
|
210
|
+
alias: foreignTable.name,
|
|
211
|
+
equals: value,
|
|
212
|
+
});
|
|
213
|
+
localAttributes.forEach((localAttribute, i) => {
|
|
214
|
+
const remoteAttribute = remoteAttributes[i];
|
|
215
|
+
$subQuery.where(sql `${$where.alias}.${sql.identifier(localAttribute)} = ${$subQuery.alias}.${sql.identifier(remoteAttribute)}`);
|
|
216
|
+
});
|
|
217
|
+
}, [
|
|
218
|
+
assertAllowed,
|
|
219
|
+
foreignTable,
|
|
220
|
+
foreignTableExpression,
|
|
221
|
+
localAttributes,
|
|
222
|
+
remoteAttributes,
|
|
223
|
+
sql,
|
|
224
|
+
]),
|
|
225
|
+
})),
|
|
226
|
+
}, `Adding connection filter backward single relation exists field for ${fieldName}`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
// One-to-many: add a "many" filter field (e.g. ordersByClientId: ClientToManyOrderFilter)
|
|
230
|
+
const fieldName = inflection._manyRelation({
|
|
231
|
+
registry: source.registry,
|
|
232
|
+
codec: source.codec,
|
|
233
|
+
relationName,
|
|
234
|
+
});
|
|
235
|
+
const filterFieldName = inflection.filterManyRelationByKeysFieldName(fieldName);
|
|
236
|
+
const filterManyTypeName = inflection.filterManyType(source.codec, foreignTable);
|
|
237
|
+
const FilterManyType = build.getTypeByName(filterManyTypeName);
|
|
238
|
+
if (!FilterManyType)
|
|
239
|
+
continue;
|
|
240
|
+
// The many relation field tags $where with relation info so that
|
|
241
|
+
// some/every/none can create the appropriate EXISTS subquery.
|
|
242
|
+
fields = extend(fields, {
|
|
243
|
+
[filterFieldName]: fieldWithHooks({
|
|
244
|
+
fieldName: filterFieldName,
|
|
245
|
+
isPgConnectionFilterField: true,
|
|
246
|
+
isPgConnectionFilterManyField: true,
|
|
247
|
+
}, () => ({
|
|
248
|
+
description: `Filter by the object\u2019s \`${fieldName}\` relation.`,
|
|
249
|
+
type: FilterManyType,
|
|
250
|
+
apply: EXPORTABLE((assertAllowed, foreignTable, foreignTableExpression, localAttributes, remoteAttributes) => function ($where, value) {
|
|
251
|
+
assertAllowed(value, 'object');
|
|
252
|
+
if (value == null)
|
|
253
|
+
return;
|
|
254
|
+
// Tag $where with relation info for some/every/none
|
|
255
|
+
$where._manyRelation = {
|
|
256
|
+
foreignTable,
|
|
257
|
+
foreignTableExpression,
|
|
258
|
+
localAttributes,
|
|
259
|
+
remoteAttributes,
|
|
260
|
+
};
|
|
261
|
+
return $where;
|
|
262
|
+
}, [
|
|
263
|
+
assertAllowed,
|
|
264
|
+
foreignTable,
|
|
265
|
+
foreignTableExpression,
|
|
266
|
+
localAttributes,
|
|
267
|
+
remoteAttributes,
|
|
268
|
+
]),
|
|
269
|
+
})),
|
|
270
|
+
}, `Adding connection filter backward many relation field from ${source.name} to ${foreignTable.name}`);
|
|
271
|
+
// Add exists field for many relations
|
|
272
|
+
const existsFieldName = inflection.filterBackwardManyRelationExistsFieldName(fieldName);
|
|
273
|
+
fields = extend(fields, {
|
|
274
|
+
[existsFieldName]: fieldWithHooks({
|
|
275
|
+
fieldName: existsFieldName,
|
|
276
|
+
isPgConnectionFilterField: true,
|
|
277
|
+
}, () => ({
|
|
278
|
+
description: `\`${fieldName}\` exist.`,
|
|
279
|
+
type: GraphQLBoolean,
|
|
280
|
+
apply: EXPORTABLE((assertAllowed, foreignTable, foreignTableExpression, localAttributes, remoteAttributes, sql) => function ($where, value) {
|
|
281
|
+
assertAllowed(value, 'scalar');
|
|
282
|
+
if (value == null)
|
|
283
|
+
return;
|
|
284
|
+
const $subQuery = $where.existsPlan({
|
|
285
|
+
tableExpression: foreignTableExpression,
|
|
286
|
+
alias: foreignTable.name,
|
|
287
|
+
equals: value,
|
|
288
|
+
});
|
|
289
|
+
localAttributes.forEach((localAttribute, i) => {
|
|
290
|
+
const remoteAttribute = remoteAttributes[i];
|
|
291
|
+
$subQuery.where(sql `${$where.alias}.${sql.identifier(localAttribute)} = ${$subQuery.alias}.${sql.identifier(remoteAttribute)}`);
|
|
292
|
+
});
|
|
293
|
+
}, [
|
|
294
|
+
assertAllowed,
|
|
295
|
+
foreignTable,
|
|
296
|
+
foreignTableExpression,
|
|
297
|
+
localAttributes,
|
|
298
|
+
remoteAttributes,
|
|
299
|
+
sql,
|
|
300
|
+
]),
|
|
301
|
+
})),
|
|
302
|
+
}, `Adding connection filter backward many relation exists field for ${fieldName}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// ─── Part 2: Add some/every/none fields to "many" filter types ───
|
|
307
|
+
if (isPgConnectionFilterMany && scopeForeignTable) {
|
|
308
|
+
const foreignTableTypeName = inflection.tableType(scopeForeignTable.codec);
|
|
309
|
+
const foreignTableFilterTypeName = inflection.filterType(foreignTableTypeName);
|
|
310
|
+
const ForeignTableFilterType = build.getTypeByName(foreignTableFilterTypeName);
|
|
311
|
+
if (!ForeignTableFilterType)
|
|
312
|
+
return fields;
|
|
313
|
+
fields = extend(fields, {
|
|
314
|
+
// `some`: EXISTS (... WHERE join AND filter)
|
|
315
|
+
some: fieldWithHooks({
|
|
316
|
+
fieldName: 'some',
|
|
317
|
+
isPgConnectionFilterField: true,
|
|
318
|
+
}, () => ({
|
|
319
|
+
description: 'Filters to entities where at least one related entity matches.',
|
|
320
|
+
type: ForeignTableFilterType,
|
|
321
|
+
apply: EXPORTABLE((assertAllowed, sql) => function ($where, value) {
|
|
322
|
+
assertAllowed(value, 'object');
|
|
323
|
+
if (value == null)
|
|
324
|
+
return;
|
|
325
|
+
const rel = $where._manyRelation;
|
|
326
|
+
if (!rel)
|
|
327
|
+
return;
|
|
328
|
+
const $subQuery = $where.existsPlan({
|
|
329
|
+
tableExpression: rel.foreignTableExpression,
|
|
330
|
+
alias: rel.foreignTable.name,
|
|
331
|
+
});
|
|
332
|
+
rel.localAttributes.forEach((la, i) => {
|
|
333
|
+
$subQuery.where(sql `${$where.alias}.${sql.identifier(la)} = ${$subQuery.alias}.${sql.identifier(rel.remoteAttributes[i])}`);
|
|
334
|
+
});
|
|
335
|
+
return $subQuery;
|
|
336
|
+
}, [assertAllowed, sql]),
|
|
337
|
+
})),
|
|
338
|
+
// `every`: NOT EXISTS (... WHERE join AND NOT(filter))
|
|
339
|
+
every: fieldWithHooks({
|
|
340
|
+
fieldName: 'every',
|
|
341
|
+
isPgConnectionFilterField: true,
|
|
342
|
+
}, () => ({
|
|
343
|
+
description: 'Filters to entities where every related entity matches.',
|
|
344
|
+
type: ForeignTableFilterType,
|
|
345
|
+
apply: EXPORTABLE((assertAllowed, sql) => function ($where, value) {
|
|
346
|
+
assertAllowed(value, 'object');
|
|
347
|
+
if (value == null)
|
|
348
|
+
return;
|
|
349
|
+
const rel = $where._manyRelation;
|
|
350
|
+
if (!rel)
|
|
351
|
+
return;
|
|
352
|
+
// NOT EXISTS (... WHERE join AND NOT(filter))
|
|
353
|
+
const $subQuery = $where.existsPlan({
|
|
354
|
+
tableExpression: rel.foreignTableExpression,
|
|
355
|
+
alias: rel.foreignTable.name,
|
|
356
|
+
equals: false,
|
|
357
|
+
});
|
|
358
|
+
rel.localAttributes.forEach((la, i) => {
|
|
359
|
+
$subQuery.where(sql `${$where.alias}.${sql.identifier(la)} = ${$subQuery.alias}.${sql.identifier(rel.remoteAttributes[i])}`);
|
|
360
|
+
});
|
|
361
|
+
// Negate the inner filter conditions
|
|
362
|
+
const $not = $subQuery.notPlan();
|
|
363
|
+
return $not;
|
|
364
|
+
}, [assertAllowed, sql]),
|
|
365
|
+
})),
|
|
366
|
+
// `none`: NOT EXISTS (... WHERE join AND filter)
|
|
367
|
+
none: fieldWithHooks({
|
|
368
|
+
fieldName: 'none',
|
|
369
|
+
isPgConnectionFilterField: true,
|
|
370
|
+
}, () => ({
|
|
371
|
+
description: 'Filters to entities where no related entity matches.',
|
|
372
|
+
type: ForeignTableFilterType,
|
|
373
|
+
apply: EXPORTABLE((assertAllowed, sql) => function ($where, value) {
|
|
374
|
+
assertAllowed(value, 'object');
|
|
375
|
+
if (value == null)
|
|
376
|
+
return;
|
|
377
|
+
const rel = $where._manyRelation;
|
|
378
|
+
if (!rel)
|
|
379
|
+
return;
|
|
380
|
+
// NOT EXISTS (... WHERE join AND filter)
|
|
381
|
+
const $subQuery = $where.existsPlan({
|
|
382
|
+
tableExpression: rel.foreignTableExpression,
|
|
383
|
+
alias: rel.foreignTable.name,
|
|
384
|
+
equals: false,
|
|
385
|
+
});
|
|
386
|
+
rel.localAttributes.forEach((la, i) => {
|
|
387
|
+
$subQuery.where(sql `${$where.alias}.${sql.identifier(la)} = ${$subQuery.alias}.${sql.identifier(rel.remoteAttributes[i])}`);
|
|
388
|
+
});
|
|
389
|
+
return $subQuery;
|
|
390
|
+
}, [assertAllowed, sql]),
|
|
391
|
+
})),
|
|
392
|
+
}, 'Adding some/every/none fields to many filter type');
|
|
393
|
+
}
|
|
394
|
+
return fields;
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
3
|
+
/**
|
|
4
|
+
* ConnectionFilterComputedAttributesPlugin
|
|
5
|
+
*
|
|
6
|
+
* Adds filter fields for computed columns (PostgreSQL functions that take
|
|
7
|
+
* a table row as their first argument and return a scalar).
|
|
8
|
+
*
|
|
9
|
+
* For example, given:
|
|
10
|
+
* CREATE FUNCTION person_full_name(person) RETURNS text AS $$ ... $$;
|
|
11
|
+
*
|
|
12
|
+
* This plugin adds a `fullName` filter field to `PersonFilter`, typed as `StringFilter`,
|
|
13
|
+
* allowing queries like:
|
|
14
|
+
* { people(filter: { fullName: { startsWith: "John" } }) { ... } }
|
|
15
|
+
*
|
|
16
|
+
* Controlled by the `connectionFilterComputedColumns` schema option (default: true).
|
|
17
|
+
* Requires the `filterBy` behavior on the pgResource to be enabled.
|
|
18
|
+
*/
|
|
19
|
+
export declare const ConnectionFilterComputedAttributesPlugin: GraphileConfig.Plugin;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import '../augmentations';
|
|
2
|
+
import { isComputedScalarAttributeResource, getComputedAttributeResources, } from '../utils';
|
|
3
|
+
const version = '1.0.0';
|
|
4
|
+
/**
|
|
5
|
+
* ConnectionFilterComputedAttributesPlugin
|
|
6
|
+
*
|
|
7
|
+
* Adds filter fields for computed columns (PostgreSQL functions that take
|
|
8
|
+
* a table row as their first argument and return a scalar).
|
|
9
|
+
*
|
|
10
|
+
* For example, given:
|
|
11
|
+
* CREATE FUNCTION person_full_name(person) RETURNS text AS $$ ... $$;
|
|
12
|
+
*
|
|
13
|
+
* This plugin adds a `fullName` filter field to `PersonFilter`, typed as `StringFilter`,
|
|
14
|
+
* allowing queries like:
|
|
15
|
+
* { people(filter: { fullName: { startsWith: "John" } }) { ... } }
|
|
16
|
+
*
|
|
17
|
+
* Controlled by the `connectionFilterComputedColumns` schema option (default: true).
|
|
18
|
+
* Requires the `filterBy` behavior on the pgResource to be enabled.
|
|
19
|
+
*/
|
|
20
|
+
export const ConnectionFilterComputedAttributesPlugin = {
|
|
21
|
+
name: 'ConnectionFilterComputedAttributesPlugin',
|
|
22
|
+
version,
|
|
23
|
+
description: 'Adds filter fields for computed column functions (scalar-returning functions that take a table row)',
|
|
24
|
+
schema: {
|
|
25
|
+
behaviorRegistry: {
|
|
26
|
+
add: {
|
|
27
|
+
filterBy: {
|
|
28
|
+
description: 'Whether a computed attribute resource should be available as a filter field',
|
|
29
|
+
entities: ['pgResource'],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
entityBehavior: {
|
|
34
|
+
pgResource: {
|
|
35
|
+
inferred(behavior, entity, build) {
|
|
36
|
+
if (build.options.connectionFilterComputedColumns &&
|
|
37
|
+
isComputedScalarAttributeResource(entity)) {
|
|
38
|
+
return [behavior, 'filterBy'];
|
|
39
|
+
}
|
|
40
|
+
return behavior;
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
hooks: {
|
|
45
|
+
GraphQLInputObjectType_fields(inFields, build, context) {
|
|
46
|
+
let fields = inFields;
|
|
47
|
+
const { inflection, connectionFilterOperatorsDigest, dataplanPg: { TYPES, PgCondition }, EXPORTABLE, } = build;
|
|
48
|
+
const { fieldWithHooks, scope: { pgCodec: codec, isPgConnectionFilter }, } = context;
|
|
49
|
+
// Only apply to table-level filter types (e.g. UserFilter)
|
|
50
|
+
if (!isPgConnectionFilter ||
|
|
51
|
+
!codec ||
|
|
52
|
+
!codec.attributes ||
|
|
53
|
+
codec.isAnonymous) {
|
|
54
|
+
return fields;
|
|
55
|
+
}
|
|
56
|
+
// Find the source resource for this codec
|
|
57
|
+
const source = Object.values(build.input.pgRegistry.pgResources).find((s) => s.codec === codec && !s.parameters && !s.isUnique);
|
|
58
|
+
if (!source) {
|
|
59
|
+
return fields;
|
|
60
|
+
}
|
|
61
|
+
const computedAttributeResources = getComputedAttributeResources(build, source);
|
|
62
|
+
for (const computedAttributeResource of computedAttributeResources) {
|
|
63
|
+
// Must return a scalar or an array (not a composite)
|
|
64
|
+
if (!computedAttributeResource.isUnique) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (computedAttributeResource.codec.attributes) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (computedAttributeResource.codec === TYPES.void) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Get the operator type for this codec
|
|
74
|
+
const digest = connectionFilterOperatorsDigest(computedAttributeResource.codec);
|
|
75
|
+
if (!digest) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const OperatorsType = build.getTypeByName(digest.operatorsTypeName);
|
|
79
|
+
if (!OperatorsType) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Check behavior
|
|
83
|
+
if (!build.behavior.pgResourceMatches(computedAttributeResource, 'filterBy')) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// Must have no required arguments beyond the first (the table row)
|
|
87
|
+
const { argDetails } = build.pgGetArgDetailsFromParameters(computedAttributeResource, computedAttributeResource.parameters.slice(1));
|
|
88
|
+
if (argDetails.some((a) => a.required)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Derive the field name from the computed attribute function
|
|
92
|
+
const fieldName = inflection.computedAttributeField({
|
|
93
|
+
resource: computedAttributeResource,
|
|
94
|
+
});
|
|
95
|
+
const functionResultCodec = computedAttributeResource.codec;
|
|
96
|
+
fields = build.extend(fields, {
|
|
97
|
+
[fieldName]: fieldWithHooks({
|
|
98
|
+
fieldName,
|
|
99
|
+
isPgConnectionFilterField: true,
|
|
100
|
+
}, {
|
|
101
|
+
description: `Filter by the object\u2019s \`${fieldName}\` field.`,
|
|
102
|
+
type: OperatorsType,
|
|
103
|
+
apply: EXPORTABLE((PgCondition, computedAttributeResource, fieldName, functionResultCodec) => function ($where, value) {
|
|
104
|
+
if (typeof computedAttributeResource.from !==
|
|
105
|
+
'function') {
|
|
106
|
+
throw new Error(`Unexpected...`);
|
|
107
|
+
}
|
|
108
|
+
if (value == null)
|
|
109
|
+
return;
|
|
110
|
+
const expression = computedAttributeResource.from({
|
|
111
|
+
placeholder: $where.alias,
|
|
112
|
+
});
|
|
113
|
+
const $col = new PgCondition($where);
|
|
114
|
+
$col.extensions.pgFilterAttribute = {
|
|
115
|
+
fieldName,
|
|
116
|
+
codec: functionResultCodec,
|
|
117
|
+
expression,
|
|
118
|
+
};
|
|
119
|
+
return $col;
|
|
120
|
+
}, [
|
|
121
|
+
PgCondition,
|
|
122
|
+
computedAttributeResource,
|
|
123
|
+
fieldName,
|
|
124
|
+
functionResultCodec,
|
|
125
|
+
]),
|
|
126
|
+
}),
|
|
127
|
+
}, '');
|
|
128
|
+
}
|
|
129
|
+
return fields;
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|