Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion BREAKINGCHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
1. `@omit` and `@password` attributes have been removed
1. SWR plugin is removed
1. `makeModelSchema()` no longer includes relation fields by default — use `include` or `select` options to opt in, mirroring ORM behaviour
1. ORM entity mutation catch-all hooks now need to be registered under `onEntityMutation.$all`
1. ORM entity mutation catch-all hooks should now be registered under `onEntityMutation.$all` (the previous shape still works but emits a deprecation warning); entities passed to per-model hooks are typed as raw database rows (same as `$qb` results)
106 changes: 106 additions & 0 deletions packages/orm/src/client/client-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ import { createZenStackPromise, type ZenStackPromise } from './promise';
import { fieldHasDefaultValue, getField, isUnsupportedField, requireModel } from './query-utils';
import { ResultProcessor } from './result-processor';

// tracks `onEntityMutation` objects that have already been validated/warned about,
// so that derived clients (e.g., from `$use` or transactions) don't warn repeatedly
const warnedPlugins = new WeakSet<object>();

type ExtResultFieldDef = {
needs: Record<string, true>;
compute: (data: Record<string, any>) => unknown;
Expand Down Expand Up @@ -92,6 +96,10 @@ export class ClientImpl {
...this.$options.functions,
};

if (this.$options.plugins) {
this.$options.plugins = this.normalizePlugins(this.$options.plugins);
}

this.validateOptions(baseClient, options);

// here we use kysely's props constructor so we can pass a custom query executor
Expand Down Expand Up @@ -163,6 +171,104 @@ export class ClientImpl {
return createClientProxy(this);
}

/**
* Normalizes plugin definitions for backward compatibility and validates
* `onEntityMutation` keys. Legacy plugins that define hooks directly under
* `onEntityMutation` (instead of under `$all`) are converted to the new shape
* with a one-time warning.
*/
private normalizePlugins(plugins: AnyPlugin[]): AnyPlugin[] {
let changed = false;
const result = plugins.map((plugin) => {
const normalized = this.normalizeEntityMutationHooks(plugin);
if (normalized !== plugin) {
changed = true;
}
return normalized;
});
return changed ? result : plugins;
}

private normalizeEntityMutationHooks(plugin: AnyPlugin): AnyPlugin {
const hooks = plugin.onEntityMutation as Record<string, unknown> | undefined;
if (!hooks || typeof hooks !== 'object') {
return plugin;
}

const legacyKeys = ['beforeEntityMutation', 'afterEntityMutation', 'runAfterMutationWithinTransaction'];
const presentLegacyKeys = legacyKeys.filter((key) => key in hooks);

const unknownKeys = Object.keys(hooks).filter(
(key) => key !== '$all' && !legacyKeys.includes(key) && !(key in this.$schema.models),
);

const warnOnce = (message: string) => {
if (!warnedPlugins.has(hooks)) {
console.warn(message);
}
};

if (unknownKeys.length > 0) {
warnOnce(
`Plugin "${plugin.id}" defines "onEntityMutation" hooks for unknown keys: ${unknownKeys
.map((k) => `"${k}"`)
.join(', ')}. Valid keys are "$all" and model names. These hooks will never be invoked.`,
);
}

if (presentLegacyKeys.length === 0) {
warnedPlugins.add(hooks);
return plugin;
}

warnOnce(
`Plugin "${plugin.id}" defines "onEntityMutation" hooks in a deprecated shape. ` +
`Hooks that apply to all models should be nested under "$all": ` +
`{ onEntityMutation: { $all: { beforeEntityMutation, afterEntityMutation, runAfterMutationWithinTransaction } } }. ` +
`The legacy shape is still supported but will be removed in a future release.`,
);
warnedPlugins.add(hooks);

const legacy: Record<string, unknown> = {};
const rest: Record<string, unknown> = {};
for (const [key, value] of Object.entries(hooks)) {
if (legacyKeys.includes(key)) {
legacy[key] = value;
} else {
rest[key] = value;
}
}

// bind legacy callbacks to the original plugin object to preserve `this`
for (const key of ['beforeEntityMutation', 'afterEntityMutation']) {
if (typeof legacy[key] === 'function') {
legacy[key] = (legacy[key] as Function).bind(plugin);
}
}

const normalizedHooks = {
...rest,
$all: {
// explicitly nested `$all` (if any) takes precedence over legacy keys
...legacy,
...(rest['$all'] as object | undefined),
},
};
// mark the normalized hooks as validated so derived clients don't warn again
warnedPlugins.add(normalizedHooks);

// create a copy that preserves the plugin's prototype chain (plugins may be class
// instances with hooks defined on the prototype), overriding only `onEntityMutation`
const copy = Object.create(Object.getPrototypeOf(plugin), Object.getOwnPropertyDescriptors(plugin));
Object.defineProperty(copy, 'onEntityMutation', {
value: normalizedHooks,
enumerable: true,
configurable: true,
writable: true,
});
return copy as AnyPlugin;
}

private validateOptions(baseClient: ClientImpl | undefined, options: ClientOptions<SchemaDef>) {
if (!baseClient && !options.skipValidationForComputedFields) {
// validate computed fields configuration once for the root client
Expand Down
7 changes: 5 additions & 2 deletions packages/orm/src/client/executor/zenstack-query-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,8 +450,11 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor {
}

if (this.dialect.supportsReturning) {
// entities are returned in the query result
return queryResult.rows as Record<string, unknown>[];
// entities are returned in the query result; shallow-copy the rows so that
// hooks (which may run after the ORM has post-processed the query result
// in place, e.g., when deferred to after transaction commit) always see
// raw database rows
return (queryResult.rows as Record<string, unknown>[]).map((row) => ({ ...row }));
} else {
const mutatedIds = InsertQueryNode.is(queryNode)
? this.getInsertIds(mutationInfo.model, queryNode, queryResult)
Expand Down
30 changes: 25 additions & 5 deletions packages/orm/src/client/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { OperationNode, QueryId, QueryResult, RootOperationNode, UnknownRow } from 'kysely';
import type { OperationNode, QueryId, QueryResult, RootOperationNode, Selectable, UnknownRow } from 'kysely';
import type { ZodType } from 'zod';
import type { ClientContract, ZModelFunction } from '.';
import type { GetModelFields, GetModels, NonRelationFields, SchemaDef } from '@zenstackhq/schema';
import type { MaybePromise } from '../utils/type-utils';
import type { MapModelFieldType } from './crud-types';
import type { AllCrudOperations, CoreCrudOperations } from './crud/operations/base';
import type { DefaultModelResult } from './crud-types';
import type { ToKyselySchema } from './query-builder';

type AllowedExtQueryArgKeys = CoreCrudOperations | '$create' | '$read' | '$update' | '$delete' | '$all';

Expand Down Expand Up @@ -388,12 +388,32 @@ export type PluginAfterEntityMutationArgs<Schema extends SchemaDef> = MutationHo
client: ClientContract<Schema>;
};

/**
* The shape of an entity passed to per-model entity mutation hooks. Entities are
* loaded directly from the database table (the same way as `$qb` query results),
* so they contain only physical columns (no relations, no computed fields, and no
* fields inherited from a delegate base model), and values are in the form returned
* by the database driver, not post-processed by the ORM.
*
* For models using delegate inheritance (`@@delegate`), a mutation fires one event
* per physical table written, keyed by that table's model. E.g., creating a sub-model
* entity fires a base-model event followed by a sub-model event; updating only
* inherited fields fires only a base-model event; deleting a sub-model entity fires
* only a base-model event (the sub-model row is removed by cascade). Base-model
* entities carry the discriminator field, which can be used to identify the concrete
* sub-model. To observe all mutations of a sub-model, register hooks on its base model
* (or use `$all`).
*/
export type MutationHookEntity<Schema extends SchemaDef, Model extends GetModels<Schema>> = Selectable<
ToKyselySchema<Schema>[Model]
>;

export type PluginModelBeforeEntityMutationArgs<Schema extends SchemaDef, Model extends GetModels<Schema>> = Omit<
MutationHooksArgs<Schema>,
'model'
> & {
model: Model;
loadBeforeMutationEntities(): Promise<DefaultModelResult<Schema, Model>[] | undefined>;
loadBeforeMutationEntities(): Promise<MutationHookEntity<Schema, Model>[] | undefined>;
client: ClientContract<Schema>;
};

Expand All @@ -402,8 +422,8 @@ export type PluginModelAfterEntityMutationArgs<Schema extends SchemaDef, Model e
'model'
> & {
model: Model;
loadAfterMutationEntities(): Promise<DefaultModelResult<Schema, Model>[] | undefined>;
beforeMutationEntities?: DefaultModelResult<Schema, Model>[];
loadAfterMutationEntities(): Promise<MutationHookEntity<Schema, Model>[] | undefined>;
beforeMutationEntities?: MutationHookEntity<Schema, Model>[];
client: ClientContract<Schema>;
};

Expand Down
Loading
Loading