Skip to content

Commit 683e3f7

Browse files
xaviergonzCopilot
andauthored
feat(idProp): add per-model ID generator and inheritance checks (#576)
* feat(idProp): add per-model ID generation with inheritance support * Update packages/lib/src/modelShared/prop.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 3efc171 commit 683e3f7

File tree

6 files changed

+318
-163
lines changed

6 files changed

+318
-163
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Added `idProp.withGenerator(...)` to define per-model ID generation for `idProp` fields, including inheritance-aware behavior in `ExtendedModel`.
6+
57
## 1.18.0
68

79
- Added `defineModelMixin` and `composeMixins` helpers for type-safe class-model mixin composition without per-factory cast boilerplate.

apps/site/docs/classModels.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,22 @@ setGlobalConfig({
253253
})
254254
```
255255

256+
You can also define a generator per model ID field using `idProp.withGenerator(...)`:
257+
258+
```ts
259+
@model("myApp/Todo")
260+
class Todo extends Model({
261+
id: idProp.withGenerator(() => `todo-${uuid()}`),
262+
text: prop<string>(),
263+
}) {}
264+
```
265+
266+
For `ExtendedModel`, the generator follows inheritance:
267+
268+
- If base has one and extended does not override the id prop, extended uses base generator.
269+
- If extended overrides a base id prop, `withGenerator(...)` must match the base one exactly (including `undefined`).
270+
- If base has no id prop and extended defines one, extended uses its own generator.
271+
256272
## Getting the TypeScript types for model data and model creation data
257273

258274
- `ModelData<Model>` is the type of the model props without transformations (as accessible via `model.$`).

apps/site/docs/mstMigrationGuide.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ getRefId() {
105105
}
106106
```
107107

108+
If your MST model used a generated default identifier, for example:
109+
110+
```ts
111+
id: types.optional(types.identifier, () => `todo-${nanoid()}`)
112+
```
113+
114+
the equivalent in `mobx-keystone` is:
115+
116+
```ts
117+
id: idProp.withGenerator(() => `todo-${nanoid()}`)
118+
```
119+
108120
### 7) Identifier mutability differs
109121

110122
MST identifiers are effectively immutable in typical usage.
@@ -406,6 +418,7 @@ Note: `ExtendedModel` extends a single base class. If you need to merge more tha
406418
| `types.maybe(T)` | `prop<T \| undefined>()` or `tProp(types.maybe(T))` | Optional value; `T` must include `undefined` explicitly. |
407419
| `types.maybeNull(T)` | `prop<T \| null>()` or `tProp(types.maybeNull(T))` | Nullable value; `T` must include `null` explicitly. |
408420
| `types.identifier` | `idProp` | Preferred model ID field. |
421+
| `types.optional(types.identifier, () => id)` | `idProp.withGenerator(() => id)` | Per-model ID generator. |
409422
| `types.identifierNumber` | Prefer string IDs (`idProp`) or keep numeric field + override `getRefId()` to return `String(id)` | `mobx-keystone` refs require string IDs. |
410423
| `types.late(() => T)` | Usually not needed with class references | Circular/lazy types are class references; use `types.late(() => T)` only in runtime type-checking declarations if needed. |
411424

packages/lib/src/modelShared/prop.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface ModelProp<
4242
_setter: SetterMode
4343
_setterValueTransform: ((value: unknown) => unknown) | undefined
4444
_isId: boolean
45+
_idGenerator?: (() => string) | undefined
4546
_transform:
4647
| {
4748
transform: (
@@ -56,6 +57,10 @@ export interface ModelProp<
5657
_fromSnapshotProcessor?: (sn: unknown) => unknown
5758
_toSnapshotProcessor?: (sn: unknown) => unknown
5859

60+
/**
61+
* Adds a setter to the property. The setter will be named `set${CapitalizedPropName}`
62+
* and will be available in the model instance.
63+
*/
5964
withSetter(): ModelProp<
6065
TPropValue,
6166
TPropCreationValue,
@@ -67,6 +72,10 @@ export interface ModelProp<
6772
TFromSnapshotOverride,
6873
TToSnapshotOverride
6974
>
75+
/**
76+
* Adds a setter with a transform to the property. The setter will be named `set${CapitalizedPropName}`
77+
* and will be available in the model instance.
78+
*/
7079
withSetter(
7180
valueTransform: ModelPropSetterValueTransform<TTransformedValue>
7281
): ModelProp<
@@ -118,6 +127,12 @@ export interface ModelProp<
118127
TToSnapshotOverride
119128
>
120129

130+
/**
131+
* Sets snapshot processors for this property.
132+
*
133+
* `fromSnapshot` runs before assigning snapshot data into the model prop.
134+
* `toSnapshot` runs when exporting model data back to snapshot form.
135+
*/
121136
withSnapshotProcessor<
122137
FS = TFromSnapshotOverride,
123138
TS = TToSnapshotOverride,
@@ -160,12 +175,18 @@ export type ModelPropToSnapshot<MP extends AnyModelProp> = IsNeverType<
160175
* A model prop transform.
161176
*/
162177
export interface ModelPropTransform<TOriginal, TTransformed> {
178+
/**
179+
* Converts the stored/original value into the transformed value exposed by the model prop.
180+
*/
163181
transform(params: {
164182
originalValue: TOriginal
165183
cachedTransformedValue: TTransformed | undefined
166184
setOriginalValue(value: TOriginal): void
167185
}): TTransformed
168186

187+
/**
188+
* Converts the transformed model value back into the original stored value.
189+
*/
169190
untransform(params: {
170191
transformedValue: TTransformed
171192
cacheTransformedValue: () => void
@@ -250,6 +271,35 @@ export type ModelIdProp<T extends string = string> = ModelProp<
250271
true
251272
>
252273

274+
type TypedModelIdProp<T extends string = string, THasSetter = never> = Omit<
275+
ModelProp<T, T | undefined, T, T | undefined, never, true, THasSetter>,
276+
"withSetter"
277+
> & {
278+
/**
279+
* Enables generation of a setter method for the ID prop (`setId` for `id`, `setCustomId` for `customId`).
280+
*/
281+
withSetter(): TypedModelIdProp<T, string>
282+
/**
283+
* Enables generation of a setter method for the ID prop and applies a value transform to setter input.
284+
*/
285+
withSetter(valueTransform: ModelPropSetterValueTransform<T>): TypedModelIdProp<T, string>
286+
/**
287+
* @deprecated Setter methods are preferred.
288+
*/
289+
withSetter(mode: "assign"): TypedModelIdProp<T, string>
290+
291+
/**
292+
* Sets a custom generator for missing model IDs in this `idProp`.
293+
*/
294+
withGenerator(generator: () => T): TypedModelIdProp<T, THasSetter>
295+
296+
/**
297+
* Same as `idProp`, except that it might have a specific TypeScript string template as type.
298+
* E.g. `idProp.typedAs<`custom-${string}`>()`
299+
*/
300+
typedAs<U extends string>(): TypedModelIdProp<U, THasSetter>
301+
}
302+
253303
/**
254304
* A property that will be used as model id, accessible through $modelId.
255305
* Can only be used in models and there can be only one per model.
@@ -258,6 +308,7 @@ export const idProp = {
258308
_setter: false,
259309
_setterValueTransform: undefined,
260310
_isId: true,
311+
_idGenerator: undefined,
261312

262313
withSetter(modeOrValueTransform?: SetterMode | ModelPropSetterValueTransform<unknown>) {
263314
const obj: AnyModelProp = Object.create(this)
@@ -267,16 +318,16 @@ export const idProp = {
267318
return obj
268319
},
269320

321+
withGenerator(generator: () => string) {
322+
const obj: AnyModelProp = Object.create(this)
323+
obj._idGenerator = generator
324+
return obj
325+
},
326+
270327
typedAs() {
271-
return idProp
328+
return this
272329
},
273-
} as any as ModelIdProp & {
274-
/**
275-
* Same as `idProp`, except that it might have an specific TypeScript string template as type.
276-
* E.g. `typedIdProp<`custom-${string}`>()`
277-
*/
278-
typedAs<T extends string>(): ModelIdProp<T>
279-
}
330+
} as unknown as TypedModelIdProp
280331

281332
/**
282333
* @ignore

packages/lib/src/modelShared/sharedInternalModel.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,11 @@ const setModelInstanceDataFieldWithPrecheck: SetModelInstanceDataFieldFn = (
109109
return true
110110
}
111111

112-
const idGenerator = () => getGlobalConfig().modelIdGenerator()
113-
const tPropForId = tProp(typesString, idGenerator)
114-
tPropForId._isId = true
115-
const propForId = prop(idGenerator)
116-
propForId._isId = true
112+
const defaultModelIdGenerator = () => getGlobalConfig().modelIdGenerator()
113+
const tPropForDefaultId = tProp(typesString, defaultModelIdGenerator)
114+
tPropForDefaultId._isId = true
115+
const propForDefaultId = prop(defaultModelIdGenerator)
116+
propForDefaultId._isId = true
117117

118118
type FromSnapshotProcessorFn = (sn: any) => any
119119
type ToSnapshotProcessorFn = (sn: any, instance: any) => any
@@ -153,11 +153,12 @@ export function sharedInternalModel<
153153
}
154154

155155
const composedModelProps: ModelProps = modelProps
156+
let baseModelProps: ModelProps | undefined
156157
if (baseModel) {
157-
const oldModelProps = getInternalModelClassPropsInfo(baseModel)
158-
for (const oldModelPropKey of Object.keys(oldModelProps)) {
158+
baseModelProps = getInternalModelClassPropsInfo(baseModel)
159+
for (const oldModelPropKey of Object.keys(baseModelProps)) {
159160
if (!modelProps[oldModelPropKey]) {
160-
composedModelProps[oldModelPropKey] = oldModelProps[oldModelPropKey]
161+
composedModelProps[oldModelPropKey] = baseModelProps[oldModelPropKey]
161162
}
162163
}
163164
}
@@ -182,17 +183,41 @@ export function sharedInternalModel<
182183
if (idKeys.length > 0) {
183184
idKey = idKeys[0]
184185
const idProp = composedModelProps[idKey]
185-
let baseProp: AnyModelProp = needsTypeChecker ? tPropForId : propForId
186-
switch (idProp._setter) {
187-
case true:
188-
baseProp = baseProp.withSetter()
189-
break
190-
case "assign":
191-
baseProp = baseProp.withSetter("assign")
192-
break
193-
default:
194-
break
186+
const idPropGenerator = idProp._idGenerator
187+
const baseIdProp = baseModelProps?.[idKey]
188+
const baseIdPropGenerator = baseIdProp?._idGenerator
189+
const isOverridingBaseIdProp = !!baseIdProp?._isId && Object.hasOwn(modelProps, idKey)
190+
if (isOverridingBaseIdProp && baseIdPropGenerator !== idPropGenerator) {
191+
throw failure(
192+
`expected same idProp.withGenerator function when overriding a base idProp, but got different references`
193+
)
194+
}
195+
const resolvedIdGenerator = idPropGenerator ?? baseIdPropGenerator
196+
const effectiveModelIdGenerator = resolvedIdGenerator ?? defaultModelIdGenerator
197+
let baseProp: AnyModelProp
198+
if (effectiveModelIdGenerator === defaultModelIdGenerator) {
199+
baseProp = needsTypeChecker ? tPropForDefaultId : propForDefaultId
200+
} else {
201+
baseProp = needsTypeChecker
202+
? tProp(typesString, effectiveModelIdGenerator)
203+
: prop(effectiveModelIdGenerator)
204+
baseProp._isId = true
205+
}
206+
if (idProp._setterValueTransform) {
207+
baseProp = baseProp.withSetter(idProp._setterValueTransform)
208+
} else {
209+
switch (idProp._setter) {
210+
case true:
211+
baseProp = baseProp.withSetter()
212+
break
213+
case "assign":
214+
baseProp = baseProp.withSetter("assign")
215+
break
216+
default:
217+
break
218+
}
195219
}
220+
baseProp._idGenerator = resolvedIdGenerator
196221
composedModelProps[idKey] = baseProp
197222
}
198223

0 commit comments

Comments
 (0)