Skip to content

Commit 12aeb7b

Browse files
genuclaude
andauthored
feat(orm): add result plugin extension point (zenstackhq#2442)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a6ce140 commit 12aeb7b

11 files changed

Lines changed: 1595 additions & 87 deletions

File tree

packages/orm/src/client/client-impl.ts

Lines changed: 292 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ import * as BuiltinFunctions from './functions';
4141
import { SchemaDbPusher } from './helpers/schema-db-pusher';
4242
import type { ClientOptions, ProceduresOptions } from './options';
4343
import type { AnyPlugin } from './plugin';
44+
45+
type ExtResultFieldDef = {
46+
needs: Record<string, true>;
47+
compute: (data: Record<string, any>) => unknown;
48+
};
49+
import { getField } from './query-utils';
4450
import { createZenStackPromise, type ZenStackPromise } from './promise';
4551
import { ResultProcessor } from './result-processor';
4652

@@ -582,6 +588,11 @@ function createModelCrudHandler(
582588
inputValidator: InputValidator<any>,
583589
resultProcessor: ResultProcessor<any>,
584590
): ModelOperations<any, any> {
591+
// check if any plugin defines ext result fields
592+
const plugins = client.$options.plugins ?? [];
593+
const schema = client.$schema;
594+
const hasAnyExtResult = hasExtResultFieldDefs(plugins);
595+
585596
const createPromise = (
586597
operation: CoreCrudOperations,
587598
nominalOperation: AllCrudOperations,
@@ -592,17 +603,30 @@ function createModelCrudHandler(
592603
) => {
593604
return createZenStackPromise(async (txClient?: ClientContract<any>) => {
594605
let proceed = async (_args: unknown) => {
606+
// prepare args for ext result: strip ext result field names from select/omit,
607+
// inject needs fields into select (recursively handles nested relations)
608+
const shouldApplyExtResult = hasAnyExtResult && EXT_RESULT_OPERATIONS.has(operation);
609+
const processedArgs = shouldApplyExtResult
610+
? prepareArgsForExtResult(_args, model, schema, plugins)
611+
: _args;
612+
595613
const _handler = txClient ? handler.withClient(txClient) : handler;
596-
const r = await _handler.handle(operation, _args);
614+
const r = await _handler.handle(operation, processedArgs);
597615
if (!r && throwIfNoResult) {
598616
throw createNotFoundError(model);
599617
}
600618
let result: unknown;
601619
if (r && postProcess) {
602-
result = resultProcessor.processResult(r, model, args);
620+
result = resultProcessor.processResult(r, model, processedArgs);
603621
} else {
604622
result = r ?? null;
605623
}
624+
625+
// compute ext result fields (recursively handles nested relations)
626+
if (result && shouldApplyExtResult) {
627+
result = applyExtResult(result, model, _args, schema, plugins);
628+
}
629+
606630
return result;
607631
};
608632

@@ -858,3 +882,269 @@ function createModelCrudHandler(
858882

859883
return operations as ModelOperations<any, any>;
860884
}
885+
886+
// #region Extended result field helpers
887+
888+
// operations that return model rows and should have ext result fields applied
889+
const EXT_RESULT_OPERATIONS = new Set<CoreCrudOperations>([
890+
'findMany',
891+
'findUnique',
892+
'findFirst',
893+
'create',
894+
'createManyAndReturn',
895+
'update',
896+
'updateManyAndReturn',
897+
'upsert',
898+
'delete',
899+
]);
900+
901+
/**
902+
* Returns true if any plugin defines ext result fields for any model.
903+
*/
904+
function hasExtResultFieldDefs(plugins: AnyPlugin[]): boolean {
905+
return plugins.some((p) => p.result && Object.keys(p.result).length > 0);
906+
}
907+
908+
/**
909+
* Collects extended result field definitions from all plugins for a given model.
910+
*/
911+
function collectExtResultFieldDefs(
912+
model: string,
913+
schema: SchemaDef,
914+
plugins: AnyPlugin[],
915+
): Map<string, ExtResultFieldDef> {
916+
const defs = new Map<string, ExtResultFieldDef>();
917+
for (const plugin of plugins) {
918+
const resultConfig = plugin.result;
919+
if (resultConfig) {
920+
const modelConfig = resultConfig[lowerCaseFirst(model)];
921+
if (modelConfig) {
922+
for (const [fieldName, fieldDef] of Object.entries(modelConfig)) {
923+
if (getField(schema, model, fieldName)) {
924+
throw new Error(
925+
`Plugin "${plugin.id}" registers ext result field "${fieldName}" on model "${model}" which conflicts with an existing model field`,
926+
);
927+
}
928+
for (const needField of Object.keys((fieldDef as ExtResultFieldDef).needs ?? {})) {
929+
const needDef = getField(schema, model, needField);
930+
if (!needDef || needDef.relation) {
931+
throw new Error(
932+
`Plugin "${plugin.id}" registers ext result field "${fieldName}" on model "${model}" with invalid need "${needField}"`,
933+
);
934+
}
935+
}
936+
defs.set(fieldName, fieldDef as ExtResultFieldDef);
937+
}
938+
}
939+
}
940+
}
941+
return defs;
942+
}
943+
944+
/**
945+
* Prepares query args for extended result fields (recursive):
946+
* - Strips ext result field names from `select` and `omit`
947+
* - Injects `needs` fields into `select` when ext result fields are explicitly selected
948+
* - Recurses into `include` and `select` for nested relation fields
949+
*/
950+
function prepareArgsForExtResult(
951+
args: unknown,
952+
model: string,
953+
schema: SchemaDef,
954+
plugins: AnyPlugin[],
955+
): unknown {
956+
if (!args || typeof args !== 'object') {
957+
return args;
958+
}
959+
960+
const extResultDefs = collectExtResultFieldDefs(model, schema, plugins);
961+
const typedArgs = args as Record<string, unknown>;
962+
let result = typedArgs;
963+
let changed = false;
964+
965+
const select = typedArgs['select'] as Record<string, unknown> | undefined;
966+
const omit = typedArgs['omit'] as Record<string, unknown> | undefined;
967+
const include = typedArgs['include'] as Record<string, unknown> | undefined;
968+
969+
if (select && extResultDefs.size > 0) {
970+
const newSelect = { ...select };
971+
for (const [fieldName, fieldDef] of extResultDefs) {
972+
if (newSelect[fieldName]) {
973+
delete newSelect[fieldName];
974+
// inject needs fields
975+
for (const needField of Object.keys(fieldDef.needs)) {
976+
if (!newSelect[needField]) {
977+
newSelect[needField] = true;
978+
}
979+
}
980+
}
981+
}
982+
result = { ...result, select: newSelect };
983+
changed = true;
984+
}
985+
986+
if (omit && extResultDefs.size > 0) {
987+
const newOmit = { ...omit };
988+
for (const [fieldName, fieldDef] of extResultDefs) {
989+
if (newOmit[fieldName]) {
990+
// strip ext result field names from omit (they don't exist in the DB)
991+
delete newOmit[fieldName];
992+
} else {
993+
// this ext result field is active — ensure its needs are not omitted
994+
for (const needField of Object.keys(fieldDef.needs)) {
995+
if (newOmit[needField]) {
996+
delete newOmit[needField];
997+
}
998+
}
999+
}
1000+
}
1001+
result = { ...result, omit: newOmit };
1002+
changed = true;
1003+
}
1004+
1005+
// Recurse into nested relations in `include`
1006+
if (include) {
1007+
const newInclude = { ...include };
1008+
let includeChanged = false;
1009+
for (const [field, value] of Object.entries(newInclude)) {
1010+
if (value && typeof value === 'object') {
1011+
const fieldDef = getField(schema, model, field);
1012+
if (fieldDef?.relation) {
1013+
const targetModel = fieldDef.type;
1014+
const processed = prepareArgsForExtResult(value, targetModel, schema, plugins);
1015+
if (processed !== value) {
1016+
newInclude[field] = processed;
1017+
includeChanged = true;
1018+
}
1019+
}
1020+
}
1021+
}
1022+
if (includeChanged) {
1023+
result = changed ? { ...result, include: newInclude } : { ...typedArgs, include: newInclude };
1024+
changed = true;
1025+
}
1026+
}
1027+
1028+
// Recurse into nested relations in `select` (relation fields can have nested args)
1029+
if (select) {
1030+
const currentSelect = (changed ? (result as Record<string, unknown>)['select'] : select) as
1031+
| Record<string, unknown>
1032+
| undefined;
1033+
if (currentSelect) {
1034+
const newSelect = { ...currentSelect };
1035+
let selectChanged = false;
1036+
for (const [field, value] of Object.entries(newSelect)) {
1037+
if (value && typeof value === 'object') {
1038+
const fieldDef = getField(schema, model, field);
1039+
if (fieldDef?.relation) {
1040+
const targetModel = fieldDef.type;
1041+
const processed = prepareArgsForExtResult(value, targetModel, schema, plugins);
1042+
if (processed !== value) {
1043+
newSelect[field] = processed;
1044+
selectChanged = true;
1045+
}
1046+
}
1047+
}
1048+
}
1049+
if (selectChanged) {
1050+
result = { ...result, select: newSelect };
1051+
changed = true;
1052+
}
1053+
}
1054+
}
1055+
1056+
return changed ? result : args;
1057+
}
1058+
1059+
/**
1060+
* Applies extended result field computation to query results (recursive).
1061+
* Processes the current model's ext result fields, then recurses into nested relation data.
1062+
*/
1063+
function applyExtResult(
1064+
result: unknown,
1065+
model: string,
1066+
originalArgs: unknown,
1067+
schema: SchemaDef,
1068+
plugins: AnyPlugin[],
1069+
): unknown {
1070+
const extResultDefs = collectExtResultFieldDefs(model, schema, plugins);
1071+
if (Array.isArray(result)) {
1072+
for (let i = 0; i < result.length; i++) {
1073+
result[i] = applyExtResultToRow(result[i], model, originalArgs, schema, plugins, extResultDefs);
1074+
}
1075+
return result;
1076+
} else {
1077+
return applyExtResultToRow(result, model, originalArgs, schema, plugins, extResultDefs);
1078+
}
1079+
}
1080+
1081+
function applyExtResultToRow(
1082+
row: unknown,
1083+
model: string,
1084+
originalArgs: unknown,
1085+
schema: SchemaDef,
1086+
plugins: AnyPlugin[],
1087+
extResultDefs: Map<string, ExtResultFieldDef>,
1088+
): unknown {
1089+
if (!row || typeof row !== 'object') {
1090+
return row;
1091+
}
1092+
1093+
const data = row as Record<string, unknown>;
1094+
const typedArgs = (originalArgs && typeof originalArgs === 'object' ? originalArgs : {}) as Record<string, unknown>;
1095+
const select = typedArgs['select'] as Record<string, unknown> | undefined;
1096+
const omit = typedArgs['omit'] as Record<string, unknown> | undefined;
1097+
const include = typedArgs['include'] as Record<string, unknown> | undefined;
1098+
1099+
// Compute ext result fields for the current model
1100+
for (const [fieldName, fieldDef] of extResultDefs) {
1101+
if (select && !select[fieldName]) {
1102+
continue;
1103+
}
1104+
if (omit?.[fieldName]) {
1105+
continue;
1106+
}
1107+
const needsSatisfied = Object.keys(fieldDef.needs).every((needField) => needField in data);
1108+
if (needsSatisfied) {
1109+
data[fieldName] = fieldDef.compute(data);
1110+
}
1111+
}
1112+
1113+
// Strip fields that shouldn't be in the result: when `select` was used,
1114+
// drop any field not in the original select and not a computed ext result field;
1115+
// when `omit` was used, re-delete any field the user originally omitted.
1116+
if (select) {
1117+
for (const key of Object.keys(data)) {
1118+
if (!select[key] && !extResultDefs.has(key)) {
1119+
delete data[key];
1120+
}
1121+
}
1122+
} else if (omit) {
1123+
for (const key of Object.keys(omit)) {
1124+
if (omit[key] && !extResultDefs.has(key)) {
1125+
delete data[key];
1126+
}
1127+
}
1128+
}
1129+
1130+
// Recurse into nested relation data
1131+
const relationSource = include ?? select;
1132+
if (relationSource) {
1133+
for (const [field, value] of Object.entries(relationSource)) {
1134+
if (data[field] == null) {
1135+
continue;
1136+
}
1137+
const fieldDef = getField(schema, model, field);
1138+
if (!fieldDef?.relation) {
1139+
continue;
1140+
}
1141+
const targetModel = fieldDef.type;
1142+
const nestedArgs = value && typeof value === 'object' ? value : undefined;
1143+
data[field] = applyExtResult(data[field], targetModel, nestedArgs, schema, plugins);
1144+
}
1145+
}
1146+
1147+
return data;
1148+
}
1149+
1150+
// #endregion

0 commit comments

Comments
 (0)