Skip to content

Commit 5d83b85

Browse files
genuclaude
andcommitted
feat(orm): add result plugin extension point for computed query fields
Implement a new `result` extension point that allows plugins to declare computed fields on query results with automatic type safety and select/omit awareness. Changes: - Add ExtResultFieldDef and ExtResultBase types to plugin.ts - Add ExtResult generic parameter to RuntimePlugin, AnyPlugin, definePlugin - Thread ExtResult through ClientContract and all CRUD return types - Add ExtractExtResult, ExtResultSelectOmitFields, SelectAwareExtResult type helpers - Implement recursive runtime computation in client-impl.ts with needs injection/stripping - Add ext result fields to Zod validation for select/omit schemas - Support nested relation ext result computation (both runtime and types) - Add 26 comprehensive tests covering single-model and nested relation scenarios Features: - Type-safe computed fields on query results - Select/omit-aware types (only include selected fields in result type) - Automatic field dependency (needs) injection and stripping - Multi-plugin composition support - Full support for nested relations (include/select with ext results) - Works with $transaction, $setAuth, $setOptions, etc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a49c2da commit 5d83b85

8 files changed

Lines changed: 1304 additions & 82 deletions

File tree

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

Lines changed: 265 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import * as BuiltinFunctions from './functions';
3838
import { SchemaDbPusher } from './helpers/schema-db-pusher';
3939
import type { ClientOptions, ProceduresOptions } from './options';
4040
import type { AnyPlugin } from './plugin';
41+
import { getField } from './query-utils';
4142
import { createZenStackPromise, type ZenStackPromise } from './promise';
4243
import { ResultProcessor } from './result-processor';
4344

@@ -547,6 +548,11 @@ function createModelCrudHandler(
547548
inputValidator: InputValidator<any>,
548549
resultProcessor: ResultProcessor<any>,
549550
): ModelOperations<any, any> {
551+
// check if any plugin defines ext result fields
552+
const plugins = client.$options.plugins ?? [];
553+
const schema = client.$schema;
554+
const hasAnyExtResult = hasExtResultDefs(plugins);
555+
550556
const createPromise = (
551557
operation: CoreCrudOperations,
552558
nominalOperation: AllCrudOperations,
@@ -557,17 +563,30 @@ function createModelCrudHandler(
557563
) => {
558564
return createZenStackPromise(async (txClient?: ClientContract<any>) => {
559565
let proceed = async (_args: unknown) => {
566+
// prepare args for ext result: strip ext result field names from select/omit,
567+
// inject needs fields into select (recursively handles nested relations)
568+
const processedArgs =
569+
postProcess && hasAnyExtResult
570+
? prepareArgsForExtResult(_args, model, schema, plugins)
571+
: _args;
572+
560573
const _handler = txClient ? handler.withClient(txClient) : handler;
561-
const r = await _handler.handle(operation, _args);
574+
const r = await _handler.handle(operation, processedArgs);
562575
if (!r && throwIfNoResult) {
563576
throw createNotFoundError(model);
564577
}
565578
let result: unknown;
566579
if (r && postProcess) {
567-
result = resultProcessor.processResult(r, model, args);
580+
result = resultProcessor.processResult(r, model, processedArgs);
568581
} else {
569582
result = r ?? null;
570583
}
584+
585+
// compute ext result fields (recursively handles nested relations)
586+
if (result && postProcess && hasAnyExtResult) {
587+
result = applyExtResult(result, model, _args, schema, plugins);
588+
}
589+
571590
return result;
572591
};
573592

@@ -823,3 +842,247 @@ function createModelCrudHandler(
823842

824843
return operations as ModelOperations<any, any>;
825844
}
845+
846+
// #region Extended result field helpers
847+
848+
type ExtResultDef = { needs: Record<string, true>; compute: (data: any) => unknown };
849+
850+
/**
851+
* Returns true if any plugin defines ext result fields for any model.
852+
*/
853+
function hasExtResultDefs(plugins: AnyPlugin[]): boolean {
854+
return plugins.some((p) => p.result && Object.keys(p.result).length > 0);
855+
}
856+
857+
/**
858+
* Collects extended result field definitions from all plugins for a given model.
859+
*/
860+
function collectExtResultDefs(model: string, plugins: AnyPlugin[]): Map<string, ExtResultDef> {
861+
const defs = new Map<string, ExtResultDef>();
862+
for (const plugin of plugins) {
863+
const resultConfig = plugin.result;
864+
if (resultConfig) {
865+
const modelConfig = resultConfig[model];
866+
if (modelConfig) {
867+
for (const [fieldName, fieldDef] of Object.entries(modelConfig)) {
868+
defs.set(fieldName, fieldDef as ExtResultDef);
869+
}
870+
}
871+
}
872+
}
873+
return defs;
874+
}
875+
876+
/**
877+
* Prepares query args for extended result fields (recursive):
878+
* - Strips ext result field names from `select` and `omit`
879+
* - Injects `needs` fields into `select` when ext result fields are explicitly selected
880+
* - Recurses into `include` and `select` for nested relation fields
881+
*/
882+
function prepareArgsForExtResult(
883+
args: unknown,
884+
model: string,
885+
schema: SchemaDef,
886+
plugins: AnyPlugin[],
887+
): unknown {
888+
if (!args || typeof args !== 'object') {
889+
return args;
890+
}
891+
892+
const extResultDefs = collectExtResultDefs(model, plugins);
893+
const typedArgs = args as Record<string, unknown>;
894+
let result = typedArgs;
895+
let changed = false;
896+
897+
const select = typedArgs['select'] as Record<string, unknown> | undefined;
898+
const omit = typedArgs['omit'] as Record<string, unknown> | undefined;
899+
const include = typedArgs['include'] as Record<string, unknown> | undefined;
900+
901+
if (select && extResultDefs.size > 0) {
902+
const newSelect = { ...select };
903+
for (const [fieldName, fieldDef] of extResultDefs) {
904+
if (newSelect[fieldName]) {
905+
delete newSelect[fieldName];
906+
// inject needs fields
907+
for (const needField of Object.keys(fieldDef.needs)) {
908+
if (!newSelect[needField]) {
909+
newSelect[needField] = true;
910+
}
911+
}
912+
}
913+
}
914+
result = { ...result, select: newSelect };
915+
changed = true;
916+
}
917+
918+
if (omit && extResultDefs.size > 0) {
919+
const newOmit = { ...omit };
920+
for (const fieldName of extResultDefs.keys()) {
921+
if (newOmit[fieldName]) {
922+
delete newOmit[fieldName];
923+
}
924+
}
925+
result = { ...result, omit: newOmit };
926+
changed = true;
927+
}
928+
929+
// Recurse into nested relations in `include`
930+
if (include) {
931+
const newInclude = { ...include };
932+
let includeChanged = false;
933+
for (const [field, value] of Object.entries(newInclude)) {
934+
if (value && typeof value === 'object') {
935+
const fieldDef = getField(schema, model, field);
936+
if (fieldDef?.relation) {
937+
const targetModel = fieldDef.type;
938+
const processed = prepareArgsForExtResult(value, targetModel, schema, plugins);
939+
if (processed !== value) {
940+
newInclude[field] = processed;
941+
includeChanged = true;
942+
}
943+
}
944+
}
945+
}
946+
if (includeChanged) {
947+
result = changed ? { ...result, include: newInclude } : { ...typedArgs, include: newInclude };
948+
changed = true;
949+
}
950+
}
951+
952+
// Recurse into nested relations in `select` (relation fields can have nested args)
953+
if (select) {
954+
const currentSelect = (changed ? (result as Record<string, unknown>)['select'] : select) as
955+
| Record<string, unknown>
956+
| undefined;
957+
if (currentSelect) {
958+
const newSelect = { ...currentSelect };
959+
let selectChanged = false;
960+
for (const [field, value] of Object.entries(newSelect)) {
961+
if (value && typeof value === 'object') {
962+
const fieldDef = getField(schema, model, field);
963+
if (fieldDef?.relation) {
964+
const targetModel = fieldDef.type;
965+
const processed = prepareArgsForExtResult(value, targetModel, schema, plugins);
966+
if (processed !== value) {
967+
newSelect[field] = processed;
968+
selectChanged = true;
969+
}
970+
}
971+
}
972+
}
973+
if (selectChanged) {
974+
result = { ...result, select: newSelect };
975+
changed = true;
976+
}
977+
}
978+
}
979+
980+
return changed ? result : args;
981+
}
982+
983+
/**
984+
* Applies extended result field computation to query results (recursive).
985+
* Processes the current model's ext result fields, then recurses into nested relation data.
986+
*/
987+
function applyExtResult(
988+
result: unknown,
989+
model: string,
990+
originalArgs: unknown,
991+
schema: SchemaDef,
992+
plugins: AnyPlugin[],
993+
): unknown {
994+
if (Array.isArray(result)) {
995+
for (let i = 0; i < result.length; i++) {
996+
result[i] = applyExtResultToRow(result[i], model, originalArgs, schema, plugins);
997+
}
998+
return result;
999+
} else {
1000+
return applyExtResultToRow(result, model, originalArgs, schema, plugins);
1001+
}
1002+
}
1003+
1004+
function applyExtResultToRow(
1005+
row: unknown,
1006+
model: string,
1007+
originalArgs: unknown,
1008+
schema: SchemaDef,
1009+
plugins: AnyPlugin[],
1010+
): unknown {
1011+
if (!row || typeof row !== 'object') {
1012+
return row;
1013+
}
1014+
1015+
const data = row as Record<string, unknown>;
1016+
const extResultDefs = collectExtResultDefs(model, plugins);
1017+
const typedArgs = (originalArgs && typeof originalArgs === 'object' ? originalArgs : {}) as Record<string, unknown>;
1018+
const select = typedArgs['select'] as Record<string, unknown> | undefined;
1019+
const omit = typedArgs['omit'] as Record<string, unknown> | undefined;
1020+
const include = typedArgs['include'] as Record<string, unknown> | undefined;
1021+
1022+
// Determine which ext result fields were selected/omitted at this level
1023+
const selectedExtResultFields = select ? new Set<string>() : undefined;
1024+
const omittedExtResultFields = omit ? new Set<string>() : undefined;
1025+
const injectedNeedsFields = new Set<string>();
1026+
1027+
if (select && extResultDefs.size > 0) {
1028+
for (const [fieldName, fieldDef] of extResultDefs) {
1029+
if (select[fieldName]) {
1030+
selectedExtResultFields!.add(fieldName);
1031+
// Track injected needs: fields that were NOT in the original select
1032+
for (const needField of Object.keys(fieldDef.needs)) {
1033+
if (!select[needField]) {
1034+
injectedNeedsFields.add(needField);
1035+
}
1036+
}
1037+
}
1038+
}
1039+
}
1040+
1041+
if (omit && extResultDefs.size > 0) {
1042+
for (const fieldName of extResultDefs.keys()) {
1043+
if (omit[fieldName]) {
1044+
omittedExtResultFields!.add(fieldName);
1045+
}
1046+
}
1047+
}
1048+
1049+
// Compute ext result fields for the current model
1050+
for (const [fieldName, fieldDef] of extResultDefs) {
1051+
if (omittedExtResultFields?.has(fieldName)) {
1052+
continue;
1053+
}
1054+
if (selectedExtResultFields !== undefined && !selectedExtResultFields.has(fieldName)) {
1055+
continue;
1056+
}
1057+
const needsSatisfied = Object.keys(fieldDef.needs).every((needField) => needField in data);
1058+
if (needsSatisfied) {
1059+
data[fieldName] = fieldDef.compute(data);
1060+
}
1061+
}
1062+
1063+
// Strip injected needs fields that weren't originally requested
1064+
for (const field of injectedNeedsFields) {
1065+
delete data[field];
1066+
}
1067+
1068+
// Recurse into nested relation data
1069+
const relationSource = include ?? select;
1070+
if (relationSource) {
1071+
for (const [field, value] of Object.entries(relationSource)) {
1072+
if (data[field] == null) {
1073+
continue;
1074+
}
1075+
const fieldDef = getField(schema, model, field);
1076+
if (!fieldDef?.relation) {
1077+
continue;
1078+
}
1079+
const targetModel = fieldDef.type;
1080+
const nestedArgs = value && typeof value === 'object' ? value : undefined;
1081+
data[field] = applyExtResult(data[field], targetModel, nestedArgs, schema, plugins);
1082+
}
1083+
}
1084+
1085+
return data;
1086+
}
1087+
1088+
// #endregion

0 commit comments

Comments
 (0)