ActiveModel: продвинутые возможности
Здесь собраны примеры для возможностей API, которые не вошли в основной пример (active-model-with-decorators.md): валидаторы, хуки жизненного цикла, разница между способами создания модели, сериализация и маппинг в другие структуры.
Валидаторы
import { ActiveModel, ActiveField } from '@alt-point/active-models'
class Product extends ActiveModel {
@ActiveField({
validator (model, prop, value) {
if (typeof value !== 'number' || value < 0) {
throw new TypeError(`"${prop}" must be a non-negative number, got ${value}`)
}
}
})
price: number = 0
}
const product = Product.create({ price: 100 })
try {
product.price = -50
} catch (e) {
console.error((e as Error).message) // "price" must be a non-negative number, got -50
}
console.log(product.price) // 100 — значение не изменилосьВажно: validator — это не предикат. Его возвращаемое значение (true/false) никак не влияет на результат — оно просто отбрасывается. Единственный способ отклонить значение — выбросить исключение внутри валидатора.
Ещё важнее: валидатор (как и setter, и события beforeSetValue/afterSetValue) срабатывает, только когда новое значение отличается от текущего (сравнение через Object.is). Он не сработает, если поле вообще отсутствует в объекте, переданном в create()/fill(), и не сработает, если явно передать значение, совпадающее с текущим значением поля (например, с его дефолтом из инициализатора). Так что валидатором нельзя "поймать" отсутствие обязательного поля — только некорректное значение при попытке его действительно изменить. Для обязательности поля проверяйте наличие ключа во входных данных до вызова create() (см. пример в node-example.md).
class Task extends ActiveModel {
@ActiveField({
validator (model, prop, value) {
if (typeof value !== 'string' || !value.trim()) {
throw new TypeError(`"${prop}" must be a non-empty string`)
}
}
})
title: string = ''
}
Task.create({}) // НЕ бросает — title остаётся дефолтным '', валидатор не вызывался
Task.create({ title: '' }) // тоже НЕ бросает — новое значение равно текущему, снова пропуск
Task.create({ title: 123 as any }) // бросает — значение реально отличается от '', валидатор сработалХуки on / once
Есть два независимых уровня подписки на события полей (beforeSetValue, afterSetValue, beforeDeletingAttribute, nulling):
- Через декоратор
@ActiveField({ on: {...} })(илиonce) — регистрируется один раз при определении класса и общий для всех инстансов этого класса. Срабатывает только для того свойства, к которому применён декоратор. - Через
model.emitter.on(...)/.once(...)— регистрируется на конкретном инстансе. Срабатывает для любого активного поля этого инстанса — фильтрацию по имени свойства нужно делать в колбэке самостоятельно черезpayload.prop.
import { ActiveModel, ActiveField, EventType } from '@alt-point/active-models'
class Order extends ActiveModel {
@ActiveField({
on: {
beforeSetValue ({ prop, value, oldValue }) {
console.log(`[${prop}] будет изменено: ${oldValue} → ${value}`)
},
afterSetValue ({ prop, value }) {
console.log(`[${prop}] изменено на ${value}`)
},
// сработает, только когда значение, которое НЕ было null, становится именно null
// (переход в undefined это событие не вызывает)
nulling ({ prop, oldValue }) {
console.log(`[${prop}] обнулено (было: ${oldValue})`)
}
}
})
status: string = 'new'
@ActiveField({ protected: true })
total: number = 0
}
const order = Order.create({})
order.status = 'paid' // сработают beforeSetValue и afterSetValue выше
try {
delete (order as any).total // total помечено protected
} catch (e) {
console.error((e as Error).message) // Property "total" is protected!
}Подписка на уровне инстанса, для сравнения:
const off = order.emitter.on(EventType.afterSetValue, ({ prop, value }: any) => {
console.log(`инстанс-обработчик: [${prop}] → ${value}`)
})
order.status = 'shipped' // сработают и декораторный хук, и обработчик выше
off() // отписались
const unsubscribeOnce = order.emitter.once(EventType.afterSetValue, () => {
console.log('сработает только один раз')
})
order.status = 'delivered' // сработает
order.status = 'cancelled' // уже не сработаетnew Model(data) vs Model.create(data) vs .fill(data)
Ловушка: new Model(data) может "потерять" переданные данные
class User extends ActiveModel {
@ActiveField() name: string = 'Гость'
}
const viaConstructor = new User({ name: 'Алиса' })
console.log(viaConstructor.name) // "Гость" — данные потеряны!
const viaCreate = User.create({ name: 'Алиса' })
console.log(viaCreate.name) // "Алиса" — как ожидалосьПричина в порядке инициализации полей класса в JS/TS: инициализатор поля (name: string = 'Гость') выполняется после возврата из super(data) и затирает то, что базовый конструктор ActiveModel уже успел заполнить из data. Model.create(data) обходит эту проблему — сначала создаёт инстанс (инициализаторы полей отрабатывают), и только потом заполняет его данными.
Рекомендация: используйте Model.create(data) (и createLazy/asyncCreate*/asyncCreateLazy) как основной способ создания моделей с данными. Если необходимо переопределить конструктор, задокументированный обходной путь — вызвать this.fill(data) явно после super(data):
class User extends ActiveModel {
@ActiveField() name: string = 'Гость'
constructor (data?: any) {
super(data)
if (data) {
this.fill(data)
}
}
}
new User({ name: 'Алиса' }).name // "Алиса" — обходной путь восстанавливает данныеfill()
.fill(data, force?) — точечно заполняет поля уже созданного инстанса.
- Без
force— заполняет только "свои" ключи: уже объявленные (own) поля модели или поля, зарегистрированные какfillable. Незнакомые ключи изdataтихо игнорируются. - С
force: true— заполняет любой ключ изdata, включая незадекларированные — используйте с осторожностью, это может добавить в модель произвольные поля мимо схемы. - Поле, помеченное
fillable: false, всё равно не будет перезаписано черезfill(), даже сforce: true—forceснимает только проверку "это известное поле", но не проверкуfillableв самом сеттере прокси. Дляreadonlyдействует более тонкое правило — см. разделreadonlyниже.
const user = User.create({ name: 'Алиса' })
user.fill({ unknownField: '...' } as any) // проигнорировано — unknownField не объявлен и не fillable
user.fill({ unknownField: '...' } as any, true) // добавлено принудительноfillable
По умолчанию (@ActiveField() без опций) поле получает fillable: true, protected: true — то есть его можно менять, но нельзя удалить (delete). fillable: false — поле нельзя изменить ни через create()/fill(), ни через прямое присваивание.
class Invoice extends ActiveModel {
@ActiveField({ fillable: false })
number: string = 'INV-0001'
}
const invoice = Invoice.create({ number: 'HACKED' })
console.log(invoice.number) // "INV-0001" — переданное значение молча проигнорировано при create()
try {
invoice.number = 'HACKED-2'
} catch (e) {
console.error((e as Error).message) // 'set' on proxy: trap returned falsish for property 'number'
}Обратите внимание на асимметрию: create()/fill() молча игнорируют заблокированную запись (они вызывают Reflect.set как функцию — она просто возвращает false, без исключения), а вот прямое присваивание (model.prop = x) в этом случае бросает исключение — так требует строгий режим Proxy-инвариантов в ES-классах.
readonly
Поле, помеченное readonly: true, можно установить один раз — через Model.create(data) (фабрику), через new Model(data) (конструктор) или значением из инициализатора поля класса, смотря что сработает раньше. После этого любые дальнейшие попытки изменить поле — через fill() (даже с force: true) или прямое присваивание — тихо игнорируются, без исключения: значение просто не меняется.
class Order extends ActiveModel {
@ActiveField({ readonly: true })
id: string = ''
}
const a = Order.create({ id: 'A1' })
console.log(a.id) // 'A1' — значение взято из data
a.id = 'A2'
console.log(a.id) // всё ещё 'A1' — запись молча проигнорирована
const b = new Order({ id: 'B1' })
console.log(b.id) // 'B1' — точно так же работает и через конструкторПочему это молча игнорируется, а не бросает исключение (в отличие от fillable: false выше): если бы заблокированная запись бросала исключение, стандартный сценарий "readonly-поле + собственный инициализатор" ломал бы конструктор. При new Model(data) инициализатор подкласса (id: string = randomUUID()) выполняется после super(data) — то есть он попадает в прокси уже вторым присваиванием того же поля (первое произошло при заполнении из data внутри super()), и если бы это бросало исключение, само создание объекта завершалось бы ошибкой.
Если нужно, чтобы клиентские данные вообще не могли повлиять на поле — например, id/createdAt, которые обязаны генерироваться только сервером (см. node-example.md) — readonly сам по себе для этого недостаточен: раз он допускает установку значения из data при первой попытке, злоумышленник может просто передать нужное поле в data, и оно "займёт" эту единственную попытку записи. Для по-настоящему server-only поля комбинируйте readonly с fillable: false — эта комбинация ведёт себя как обычный fillable: false (блокирует запись всегда, включая первую попытку из data), и единственный источник значения — инициализатор поля класса:
class Task extends ActiveModel {
@ActiveField({ readonly: true, fillable: false })
id: string = randomUUID() // единственный источник значения — этот инициализатор
}
Task.create({ id: 'spoofed' }).id // сгенерированный UUID, а не 'spoofed' — data полностью проигнорированаСериализация и toJSON()
class User extends ActiveModel {
@ActiveField() id: string = ''
@ActiveField({ hidden: true }) passwordHash: string = ''
@ActiveField({ protected: true }) role: string = 'user'
@ActiveField({ readonly: true }) createdAt: string = new Date().toISOString()
}
const user = User.create({ id: '1', passwordHash: 'a1b2c3', role: 'admin' })
console.log(Object.keys(user)) // ['id', 'role', 'createdAt'] — passwordHash отсутствует
console.log(JSON.stringify(user)) // {"id":"1","role":"admin","createdAt":"..."}
console.log(user.passwordHash) // "a1b2c3" — hidden скрывает только от перечисления/сериализацииhidden— поле не появляется вObject.keys(),for...in,{...spread}иJSON.stringify(), но остаётся доступным напрямую (user.passwordHash). Хорошо подходит для паролей/токенов, которые модель должна хранить, но никогда не должна случайно сериализовать наружу.protected— поле видно и сериализуется как обычно, ноdelete user.roleбросит исключение.readonly— поле видно и сериализуется, но присвоить новое значение после создания нельзя (см. также раздел проfillable/readonlyвыше).
mapTo() / hasMapping()
Позволяет один раз задать правило преобразования модели в другую структуру (DTO для бэкенда, view-модель и т.п.) и переиспользовать его на любом инстансе.
import { ActiveModel, ActiveField } from '@alt-point/active-models'
class Order extends ActiveModel {
@ActiveField() id: string = ''
@ActiveField() total: number = 0
}
// Целевая структура не обязана быть ActiveModel
class OrderPayload {
constructor (public orderId: string, public amountCents: number) {}
}
// Регистрируем маппинг один раз — например, в точке инициализации приложения
Order.mapTo(OrderPayload, (order) => new OrderPayload(order.id, Math.round(order.total * 100)))
const order = Order.create({ id: '42', total: 19.99 })
order.hasMapping(OrderPayload) // true
const payload = order.mapTo(OrderPayload)
// payload instanceof OrderPayload → { orderId: '42', amountCents: 1999 }По умолчанию mapTo(target, lazy = true, ...args) работает "лениво": если маппинг для target не зарегистрирован, метод молча возвращает order.clone() вместо ошибки. Передайте lazy: false, чтобы явно упасть с ошибкой при отсутствии маппинга:
class Unmapped {}
order.mapTo(Unmapped) // маппинг не зарегистрирован → тихо вернёт order.clone()
order.mapTo(Unmapped, false) // бросит: "Mapping for target not found"