From 510e2964d1ecbdc076a59b3eda6081a6a42dc863 Mon Sep 17 00:00:00 2001 From: kiddyu <58631254@qq.com> Date: Fri, 8 May 2026 15:57:46 +0800 Subject: [PATCH] fix: Attribute accessor handling in Model --- jest.config.js | 3 +- package.json | 2 +- src/concerns/has-attributes.js | 103 +++++++++++++++++++-------------- test/index.test.js | 44 ++++++++++++-- test/repro_accessor.test.js | 61 +++++++++++++++++++ 5 files changed, 161 insertions(+), 52 deletions(-) create mode 100644 test/repro_accessor.test.js diff --git a/jest.config.js b/jest.config.js index 6220a75..6520802 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,7 +4,8 @@ module.exports = { // Node environment test configuration displayName: 'node', testEnvironment: 'node', - testMatch: ['/test/**/index.test.js'], + testMatch: ['/test/**/*.test.js'], + testPathIgnorePatterns: ['/node_modules/', 'browser.test.js'] }, { // Browser environment test configuration diff --git a/package.json b/package.json index c51ba25..b752451 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sutando", - "version": "1.7.4", + "version": "1.8.0-dev.3", "packageManager": "pnpm@7.33.7", "description": "A modern Node.js ORM. Makes it enjoyable to interact with your database. Support Mysql, MSSql, MariaDB, Sqlite.", "homepage": "https://sutando.org/", diff --git a/src/concerns/has-attributes.js b/src/concerns/has-attributes.js index fd614a1..bdfd42f 100644 --- a/src/concerns/has-attributes.js +++ b/src/concerns/has-attributes.js @@ -18,12 +18,12 @@ const HasAttributes = (Model) => { casts = {}; changes = {}; appends = []; - + setAppends(appends) { this.appends = appends; return this; } - + append(...keys) { const appends = flattenDeep(keys); this.appends = [...this.appends, ...appends]; @@ -80,10 +80,10 @@ const HasAttributes = (Model) => { return false; } - + getDirty() { const dirty = {}; - + const attributes = this.getAttributes(); for (const key in attributes) { const value = attributes[key]; @@ -91,7 +91,7 @@ const HasAttributes = (Model) => { dirty[key] = value; } } - + return dirty; } @@ -109,7 +109,7 @@ const HasAttributes = (Model) => { return false; } } - + setAttributes(attributes) { this.attributes = { ...attributes }; } @@ -123,11 +123,11 @@ const HasAttributes = (Model) => { return this; } - + getAttributes() { return { ...this.attributes }; } - + setAttribute(key, value) { const setterMethod = getSetterMethod(key); if (typeof this[setterMethod] === 'function') { @@ -150,14 +150,14 @@ const HasAttributes = (Model) => { return this; } - + const casts = this.getCasts(); const castType = casts[key]; - + if (this.isCustomCast(castType)) { value = castType.set(this, key, value, this.attributes); } - + if (castType === 'json') { value = JSON.stringify(value); } @@ -169,12 +169,12 @@ const HasAttributes = (Model) => { if (value !== null && this.isDateAttribute(key)) { value = this.fromDateTime(value); } - + this.attributes[key] = value; - + return this; } - + getAttribute(key) { if (!key) { return; @@ -190,37 +190,37 @@ const HasAttributes = (Model) => { const caster = this[attrMethod](); return caster.get(this.attributes[key], this.attributes); } - + if (key in this.attributes) { if (this.hasCast(key)) { return this.castAttribute(key, this.attributes[key]); } - + if (this.getDates().includes(key)) { return this.asDateTime(this.attributes[key]); } return this.attributes[key]; } - + if (key in this.relations) { return this.relations[key]; } - + return; } - + castAttribute(key, value) { const castType = this.getCastType(key); - + if (!castType) { return value; } - + if (value === null) { return value; } - + switch (castType) { case 'int': case 'integer': @@ -257,25 +257,36 @@ const HasAttributes = (Model) => { case 'timestamp': return this.asTimestamp(value); } - + if (this.isCustomCast(castType)) { return castType.get(this, key, value, this.attributes); } - + return value; } - + attributesToData() { const attributes = { ...this.attributes }; - + const appends = new Set(this.appends); + const mutated = new Set(); + for (const key in attributes) { if (this.hidden.includes(key)) { unset(attributes, key); } - + if (this.visible.length > 0 && this.visible.includes(key) === false) { unset(attributes, key); } + + if (typeof this[getAttrMethod(key)] === 'function' || typeof this[getGetterMethod(key)] === 'function') { + const value = this.mutateAttribute(key, attributes[key]); + if (value !== undefined && value !== null) { + attributes[key] = value; + appends.delete(key); + mutated.add(key); + } + } } for (const key of this.getDates()) { @@ -290,6 +301,10 @@ const HasAttributes = (Model) => { const casts = this.getCasts(); for (const key in casts) { + if (mutated.has(key)) { + continue; + } + const value = casts[key]; if ((key in attributes) === false) { @@ -308,29 +323,29 @@ const HasAttributes = (Model) => { attributes[key] = dayjs(attributes[key]).format(value.split(':')[1]); } } - - for (const key of this.appends) { + + for (const key of Array.from(appends)) { attributes[key] = this.mutateAttribute(key, null); } - + return attributes; } - + mutateAttribute(key, value) { if (typeof this[getGetterMethod(key)] === 'function') { return this[getGetterMethod(key)](value); } else if (typeof this[getAttrMethod(key)] === 'function') { const caster = this[getAttrMethod(key)](); - return caster.get(key, this.attributes); + return caster.get(value, this.attributes); } else if (key in this) { return this[key]; } return value; } - + mutateAttributeForArray(key, value) { - + } isDateAttribute(key) { @@ -347,11 +362,11 @@ const HasAttributes = (Model) => { this.getUpdatedAtColumn(), ] : []; } - + getCasts() { if (this.getIncrementing()) { return { - [this.getKeyName()]: this.getKeyType(), + [this.getKeyName()]: this.getKeyType(), ...this.casts }; } @@ -387,7 +402,7 @@ const HasAttributes = (Model) => { return this.constructor.castTypeCache[castTypeCacheKey] = convertedCastType; } - + hasCast(key, types = []) { if (key in this.casts) { types = flatten(types); @@ -417,7 +432,7 @@ const HasAttributes = (Model) => { if (typeof cast !== 'string') { return false; } - + return cast.startsWith('decimal:'); } @@ -434,27 +449,27 @@ const HasAttributes = (Model) => { getDateFormat() { return this.dateFormat || 'YYYY-MM-DD HH:mm:ss'; } - + asDecimal(value, decimals) { return parseFloat(value).toFixed(decimals); } - + asDateTime(value) { if (value === null) { return null; } - + if (value instanceof Date) { return value; } - + if (typeof value === 'number') { return new Date(value * 1000); } - + return new Date(value); } - + asDate(value) { const date = this.asDateTime(value); return dayjs(date).startOf('day').toDate(); diff --git a/test/index.test.js b/test/index.test.js index 4c6d7cd..dfe26bb 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -273,14 +273,46 @@ describe('Model', () => { }); }); - it('model getter settings', () => { - expect(testModel.full_name).toBe('Joe Shmoe'); + it('accessor and mutator same as field name should work without appends', () => { + class User extends Model { + attributeFirstName() { + return Attribute.make({ + get: (value) => value.toUpperCase(), + set: (value) => value.toLowerCase() + }); + } + } + + const user = new User({ + firstName: 'Joe' + }); + + expect(user.attributes.firstName).toBe('joe'); + expect(user.firstName).toBe('JOE'); + + const data = user.toData(); + expect(data.firstName).toBe('JOE'); }); - it('model setter settings', () => { - testModel.full_name = 'Bill Gates'; - expect(testModel.firstName).toBe('Bill'); - expect(testModel.lastName).toBe('Gates'); + it('getter and setter same as field name should work without appends', () => { + class User extends Model { + getFirstNameAttribute(value) { + return value.toUpperCase(); + } + setFirstNameAttribute(value) { + this.attributes.firstName = value.toLowerCase(); + } + } + + const user = new User({ + firstName: 'Joe' + }); + + expect(user.attributes.firstName).toBe('joe'); + expect(user.firstName).toBe('JOE'); + + const data = user.toData(); + expect(data.firstName).toBe('JOE'); }); }) diff --git a/test/repro_accessor.test.js b/test/repro_accessor.test.js new file mode 100644 index 0000000..8ef95af --- /dev/null +++ b/test/repro_accessor.test.js @@ -0,0 +1,61 @@ +const { sutando, Model, Attribute } = require('../src'); + +describe('Accessor and Mutator same as field name', () => { + beforeAll(async () => { + sutando.addConnection({ + client: 'sqlite3', + connection: { + filename: ':memory:' + }, + useNullAsDefault: true + }); + + await sutando.connection().schema.createTable('users', (table) => { + table.increments('id'); + table.string('name'); + table.string('email'); + }); + }); + + afterAll(async () => { + await sutando.connection().destroy(); + }); + + it('should work without appends when accessor name is same as field name', async () => { + class User extends Model { + attributeName() { + return Attribute.make({ + get: (value) => value.toUpperCase(), + set: (value) => value.toLowerCase() + }); + } + } + + const user = new User(); + user.name = 'Joe'; + expect(user.attributes.name).toBe('joe'); // Mutator should work + expect(user.name).toBe('JOE'); // Accessor should work + + const data = user.toData(); + expect(data.name).toBe('JOE'); // toData should use accessor + }); + + it('should work without appends when getter name is same as field name', async () => { + class User extends Model { + getNameAttribute(value) { + return value.toUpperCase(); + } + setNameAttribute(value) { + this.attributes.name = value.toLowerCase(); + } + } + + const user = new User(); + user.name = 'Joe'; + expect(user.attributes.name).toBe('joe'); // Mutator should work + expect(user.name).toBe('JOE'); // Accessor should work + + const data = user.toData(); + expect(data.name).toBe('JOE'); // toData should use accessor + }); +});