Skip to content

Commit 2a2fcd7

Browse files
ymc9claude
andcommitted
refactor: extract shared filter schemas in RPC OpenAPI spec generation
Unify filter schema creation into a single `buildFilterSchema` method with optional slicing context to eliminate duplication. Shared filter schemas (_StringFilter, _IntFilter, etc.) are now generated once and referenced via $ref. Also adds update-baseline npm script and regenerates baselines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6ab9b8a commit 2a2fcd7

7 files changed

Lines changed: 1445 additions & 559 deletions

File tree

packages/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"watch": "tsup-node --watch",
99
"lint": "eslint src --ext ts",
1010
"test": "vitest run",
11+
"update-baseline": "UPDATE_BASELINE=1 vitest run test/openapi",
1112
"pack": "pnpm pack"
1213
},
1314
"keywords": [

packages/server/src/api/rest/openapi.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
457457
}
458458

459459
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
460-
if (fieldDef.relation || fieldDef.omit || fieldDef.foreignKeyFor) continue;
460+
if (fieldDef.relation) continue;
461461
if (idFieldNames.has(fieldName)) continue;
462462

463463
const type = fieldDef.type;
@@ -749,7 +749,6 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
749749
const relationships: Record<string, SchemaObject | ReferenceObject> = {};
750750

751751
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
752-
if (fieldDef.omit) continue;
753752
if (fieldDef.updatedAt) continue;
754753
if (fieldDef.foreignKeyFor) continue;
755754
// Skip auto-generated id fields
@@ -813,7 +812,6 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
813812
const relationships: Record<string, SchemaObject | ReferenceObject> = {};
814813

815814
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
816-
if (fieldDef.omit) continue;
817815
if (fieldDef.updatedAt) continue;
818816
if (fieldDef.foreignKeyFor) continue;
819817
if (fieldDef.relation && !isModelIncluded(fieldDef.type, this.queryOptions)) continue;

packages/server/src/api/rpc/openapi.ts

Lines changed: 112 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,9 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
410410
}
411411
}
412412

413+
// Shared filter schemas for where inputs
414+
this.generateFilterSchemas(schemas);
415+
413416
// Per-model schemas
414417
for (const modelName of getIncludedModels(this.schema, this.queryOptions)) {
415418
const modelDef = this.schema.models[modelName]!;
@@ -440,12 +443,21 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
440443
schemas[`${modelName}GroupByArgs`] = this.buildGroupByArgsSchema(modelName);
441444
schemas[`${modelName}ExistsArgs`] = this.buildExistsArgsSchema(modelName);
442445
schemas[`${modelName}Response`] = this.buildResponseSchema(modelName);
443-
schemas[`${modelName}CountAggregateOutputType`] = this.buildCountAggregateOutputTypeSchema(modelName, modelDef);
446+
schemas[`${modelName}CountAggregateOutputType`] = this.buildCountAggregateOutputTypeSchema(
447+
modelName,
448+
modelDef,
449+
);
444450
schemas[`${modelName}MinAggregateOutputType`] = this.buildMinAggregateOutputTypeSchema(modelName, modelDef);
445451
schemas[`${modelName}MaxAggregateOutputType`] = this.buildMaxAggregateOutputTypeSchema(modelName, modelDef);
446452
if (this.modelHasNumericFields(modelDef)) {
447-
schemas[`${modelName}AvgAggregateOutputType`] = this.buildAvgAggregateOutputTypeSchema(modelName, modelDef);
448-
schemas[`${modelName}SumAggregateOutputType`] = this.buildSumAggregateOutputTypeSchema(modelName, modelDef);
453+
schemas[`${modelName}AvgAggregateOutputType`] = this.buildAvgAggregateOutputTypeSchema(
454+
modelName,
455+
modelDef,
456+
);
457+
schemas[`${modelName}SumAggregateOutputType`] = this.buildSumAggregateOutputTypeSchema(
458+
modelName,
459+
modelDef,
460+
);
449461
}
450462
schemas[`Aggregate${modelName}`] = this.buildAggregateSchema(modelName, modelDef);
451463
schemas[`${modelName}GroupByOutputType`] = this.buildGroupByOutputTypeSchema(modelName, modelDef);
@@ -572,18 +584,11 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
572584
}
573585

574586
private buildCreateInputSchema(_modelName: string, modelDef: ModelDef): SchemaObject {
575-
const idFieldNames = new Set(modelDef.idFields);
576587
const properties: Record<string, SchemaObject | ReferenceObject> = {};
577588
const required: string[] = [];
578589

579590
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
580591
if (fieldDef.relation) continue;
581-
if (fieldDef.foreignKeyFor) continue;
582-
if (fieldDef.omit) continue;
583-
if (fieldDef.updatedAt) continue;
584-
// Skip auto-generated id fields
585-
if (idFieldNames.has(fieldName) && fieldDef.default !== undefined) continue;
586-
587592
properties[fieldName] = this.typeToSchema(fieldDef.type);
588593
if (!fieldDef.optional && fieldDef.default === undefined && !fieldDef.array) {
589594
required.push(fieldName);
@@ -602,10 +607,6 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
602607

603608
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
604609
if (fieldDef.relation) continue;
605-
if (fieldDef.foreignKeyFor) continue;
606-
if (fieldDef.omit) continue;
607-
if (fieldDef.updatedAt) continue;
608-
609610
properties[fieldName] = this.typeToSchema(fieldDef.type);
610611
}
611612

@@ -645,7 +646,6 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
645646

646647
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
647648
if (fieldDef.relation) continue;
648-
if (fieldDef.omit) continue;
649649
const filterSchema = this.buildFieldFilterSchema(modelName, fieldName, fieldDef);
650650
if (filterSchema) {
651651
properties[fieldName] = filterSchema;
@@ -840,7 +840,8 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
840840

841841
private modelHasNumericFields(modelDef: ModelDef): boolean {
842842
return Object.values(modelDef.fields).some(
843-
(f) => !f.relation && (f.type === 'Int' || f.type === 'Float' || f.type === 'BigInt' || f.type === 'Decimal')
843+
(f) =>
844+
!f.relation && (f.type === 'Int' || f.type === 'Float' || f.type === 'BigInt' || f.type === 'Decimal'),
844845
);
845846
}
846847

@@ -879,7 +880,12 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
879880
const properties: Record<string, SchemaObject> = {};
880881
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
881882
if (fieldDef.relation) continue;
882-
if (fieldDef.type !== 'Int' && fieldDef.type !== 'Float' && fieldDef.type !== 'BigInt' && fieldDef.type !== 'Decimal')
883+
if (
884+
fieldDef.type !== 'Int' &&
885+
fieldDef.type !== 'Float' &&
886+
fieldDef.type !== 'BigInt' &&
887+
fieldDef.type !== 'Decimal'
888+
)
883889
continue;
884890
// avg always returns a float
885891
properties[fieldName] = { oneOf: [{ type: 'null' as const }, { type: 'number' }] };
@@ -891,7 +897,12 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
891897
const properties: Record<string, SchemaObject | ReferenceObject> = {};
892898
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
893899
if (fieldDef.relation) continue;
894-
if (fieldDef.type !== 'Int' && fieldDef.type !== 'Float' && fieldDef.type !== 'BigInt' && fieldDef.type !== 'Decimal')
900+
if (
901+
fieldDef.type !== 'Int' &&
902+
fieldDef.type !== 'Float' &&
903+
fieldDef.type !== 'BigInt' &&
904+
fieldDef.type !== 'Decimal'
905+
)
895906
continue;
896907
// sum preserves the original type
897908
properties[fieldName] = { oneOf: [{ type: 'null' as const }, this.typeToSchema(fieldDef.type)] };
@@ -902,7 +913,10 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
902913
private buildAggregateSchema(modelName: string, modelDef: ModelDef): SchemaObject {
903914
const properties: Record<string, SchemaObject> = {
904915
_count: {
905-
oneOf: [{ type: 'null' as const }, { $ref: `#/components/schemas/${modelName}CountAggregateOutputType` }],
916+
oneOf: [
917+
{ type: 'null' as const },
918+
{ $ref: `#/components/schemas/${modelName}CountAggregateOutputType` },
919+
],
906920
},
907921
_min: {
908922
oneOf: [{ type: 'null' as const }, { $ref: `#/components/schemas/${modelName}MinAggregateOutputType` }],
@@ -1017,13 +1031,54 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
10171031
return baseSchema;
10181032
}
10191033

1020-
private buildFieldFilterSchema(modelName: string, fieldName: string, fieldDef: FieldDef): SchemaObject | undefined {
1021-
const baseSchema = this.typeToSchema(fieldDef.type);
1034+
/**
1035+
* Generates shared filter schemas for all field types used across models.
1036+
*/
1037+
private generateFilterSchemas(schemas: Record<string, SchemaObject | ReferenceObject>): void {
1038+
const filters = new Map<string, { type: string; array: boolean }>();
1039+
1040+
for (const modelName of getIncludedModels(this.schema, this.queryOptions)) {
1041+
const modelDef = this.schema.models[modelName]!;
1042+
for (const [, fieldDef] of Object.entries(modelDef.fields)) {
1043+
if (fieldDef.relation) continue;
1044+
const name = this.filterSchemaName(fieldDef.type, !!fieldDef.array);
1045+
if (!filters.has(name)) {
1046+
filters.set(name, { type: fieldDef.type, array: !!fieldDef.array });
1047+
}
1048+
}
1049+
}
1050+
1051+
for (const [name, { type, array }] of filters) {
1052+
schemas[name] = this.buildFilterSchema(type, array)!;
1053+
}
1054+
}
1055+
1056+
/**
1057+
* Returns the schema name for a shared filter (e.g. "_StringFilter", "_IntListFilter").
1058+
*/
1059+
private filterSchemaName(type: string, array: boolean): string {
1060+
return array ? `_${type}ListFilter` : `_${type}Filter`;
1061+
}
1062+
1063+
/**
1064+
* Builds a filter schema for a given field type. When `fieldContext` is provided,
1065+
* only filter kinds that pass `isFilterKindIncluded` are included; otherwise all
1066+
* applicable filter kinds are included (used for shared filter schemas).
1067+
*/
1068+
private buildFilterSchema(
1069+
type: string,
1070+
array: boolean,
1071+
fieldContext?: { modelName: string; fieldName: string },
1072+
): SchemaObject | undefined {
1073+
const includeKind = (kind: string) =>
1074+
!fieldContext ||
1075+
isFilterKindIncluded(fieldContext.modelName, fieldContext.fieldName, kind, this.queryOptions);
1076+
1077+
const baseSchema = this.typeToSchema(type);
10221078
const filterProps: Record<string, SchemaObject | ReferenceObject> = {};
1023-
const type = fieldDef.type;
10241079

10251080
// Equality operators
1026-
if (isFilterKindIncluded(modelName, fieldName, 'Equality', this.queryOptions)) {
1081+
if (includeKind('Equality')) {
10271082
filterProps['equals'] = baseSchema;
10281083
filterProps['not'] = baseSchema;
10291084
filterProps['in'] = { type: 'array', items: baseSchema };
@@ -1033,7 +1088,7 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
10331088
// Range operators (numeric/datetime types)
10341089
if (
10351090
(type === 'Int' || type === 'Float' || type === 'BigInt' || type === 'Decimal' || type === 'DateTime') &&
1036-
isFilterKindIncluded(modelName, fieldName, 'Range', this.queryOptions)
1091+
includeKind('Range')
10371092
) {
10381093
filterProps['lt'] = baseSchema;
10391094
filterProps['lte'] = baseSchema;
@@ -1042,15 +1097,15 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
10421097
}
10431098

10441099
// Like operators (String type)
1045-
if (type === 'String' && isFilterKindIncluded(modelName, fieldName, 'Like', this.queryOptions)) {
1100+
if (type === 'String' && includeKind('Like')) {
10461101
filterProps['contains'] = { type: 'string' };
10471102
filterProps['startsWith'] = { type: 'string' };
10481103
filterProps['endsWith'] = { type: 'string' };
10491104
filterProps['mode'] = { type: 'string', enum: ['default', 'insensitive'] };
10501105
}
10511106

10521107
// List operators (array fields)
1053-
if (fieldDef.array && isFilterKindIncluded(modelName, fieldName, 'List', this.queryOptions)) {
1108+
if (array && includeKind('List')) {
10541109
filterProps['has'] = baseSchema;
10551110
filterProps['hasEvery'] = { type: 'array', items: baseSchema };
10561111
filterProps['hasSome'] = { type: 'array', items: baseSchema };
@@ -1062,13 +1117,43 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
10621117
const filterObject: SchemaObject = { type: 'object', properties: filterProps };
10631118

10641119
// If Equality is included, allow shorthand (direct value) via oneOf
1065-
if (isFilterKindIncluded(modelName, fieldName, 'Equality', this.queryOptions)) {
1120+
if (includeKind('Equality')) {
10661121
return { oneOf: [baseSchema, filterObject] };
10671122
}
10681123

10691124
return filterObject;
10701125
}
10711126

1127+
/**
1128+
* Returns true if no field-level filter slicing is configured for this model/field.
1129+
*/
1130+
private hasDefaultFilters(modelName: string, fieldName: string): boolean {
1131+
const slicing = this.queryOptions?.slicing;
1132+
if (!slicing?.models) return true;
1133+
1134+
const modelKey = lowerCaseFirst(modelName);
1135+
const modelSlicing = (slicing.models as Record<string, any>)[modelKey] ?? (slicing.models as any).$all;
1136+
if (!modelSlicing?.fields) return true;
1137+
1138+
const fieldSlicing = modelSlicing.fields[fieldName] ?? modelSlicing.fields.$all;
1139+
return !fieldSlicing;
1140+
}
1141+
1142+
private buildFieldFilterSchema(
1143+
modelName: string,
1144+
fieldName: string,
1145+
fieldDef: FieldDef,
1146+
): SchemaObject | ReferenceObject | undefined {
1147+
// If no slicing customization, reference the shared filter schema
1148+
if (this.hasDefaultFilters(modelName, fieldName)) {
1149+
const name = this.filterSchemaName(fieldDef.type, !!fieldDef.array);
1150+
return { $ref: `#/components/schemas/${name}` };
1151+
}
1152+
1153+
// Slicing is active — build inline filter with only included filter kinds
1154+
return this.buildFilterSchema(fieldDef.type, !!fieldDef.array, { modelName, fieldName });
1155+
}
1156+
10721157
private typeToSchema(type: string): SchemaObject | ReferenceObject {
10731158
switch (type) {
10741159
case 'String':

0 commit comments

Comments
 (0)