From 130e11d533155384792ea02537d9236af649e76f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 18:44:17 +0800 Subject: [PATCH] Serialize work order, maintenance and schedule relationships without asserting, and send cleared ones as cleared The work order, maintenance and maintenance schedule serializers read their polymorphic type with snapshot.attr('_type'), but those models declare no such attribute, so saving one in a debug build failed with "Model has no attribute named 'target_type' defined". They now read it through snapshot.attributes(). Ember Data only calls serializePolymorphicType for a related record that is present, so removing a target or assignee sent null embedded data while the _uuid and _type columns kept the old record. Unset relationships are now sent with both cleared. --- addon/serializers/maintenance-schedule.js | 6 +++- addon/serializers/maintenance.js | 6 +++- addon/serializers/work-order.js | 6 +++- .../clear-unset-polymorphic-relationships.js | 23 ++++++++++++++ .../clear-unset-polymorphic-relationships.js | 1 + tests/helpers/polymorphic-contract.js | 3 +- .../serializers/maintenance-schedule-test.js | 10 +++++++ tests/unit/serializers/maintenance-test.js | 10 +++++++ tests/unit/serializers/work-order-test.js | 10 +++++++ ...ar-unset-polymorphic-relationships-test.js | 30 +++++++++++++++++++ 10 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 addon/utils/clear-unset-polymorphic-relationships.js create mode 100644 app/utils/clear-unset-polymorphic-relationships.js create mode 100644 tests/unit/utils/clear-unset-polymorphic-relationships-test.js diff --git a/addon/serializers/maintenance-schedule.js b/addon/serializers/maintenance-schedule.js index 7832ab1..339992f 100644 --- a/addon/serializers/maintenance-schedule.js +++ b/addon/serializers/maintenance-schedule.js @@ -1,6 +1,7 @@ import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; import { isBlank } from '@ember/utils'; +import clearUnsetPolymorphicRelationships from '../utils/clear-unset-polymorphic-relationships'; export default class MaintenanceScheduleSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { /** @@ -26,6 +27,7 @@ export default class MaintenanceScheduleSerializer extends ApplicationSerializer const json = super.serialize(snapshot, options); const readOnly = ['subject_name', 'default_assignee_name']; readOnly.forEach((attr) => delete json[attr]); + clearUnsetPolymorphicRelationships(snapshot, json, ['subject', 'default_assignee']); return json; } @@ -44,7 +46,9 @@ export default class MaintenanceScheduleSerializer extends ApplicationSerializer let key = relationship.key; let belongsTo = snapshot.belongsTo(key); - const isPolymorphicTypeBlank = isBlank(snapshot.attr(key + '_type')); + // Read the type through attributes(): these models declare no `_type` + // attribute, and snapshot.attr() asserts on an undeclared one. + const isPolymorphicTypeBlank = isBlank(snapshot.attributes()[key + '_type']); if (isPolymorphicTypeBlank) { key = this.keyForAttribute ? this.keyForAttribute(key, 'serialize') : key; if (!belongsTo) { diff --git a/addon/serializers/maintenance.js b/addon/serializers/maintenance.js index 8b2155d..5a0aee0 100644 --- a/addon/serializers/maintenance.js +++ b/addon/serializers/maintenance.js @@ -1,6 +1,7 @@ import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; import { isBlank } from '@ember/utils'; +import clearUnsetPolymorphicRelationships from '../utils/clear-unset-polymorphic-relationships'; export default class MaintenanceSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { /** @@ -28,6 +29,7 @@ export default class MaintenanceSerializer extends ApplicationSerializer.extend( const json = super.serialize(snapshot, options); const readOnly = ['maintainable_name', 'performed_by_name', 'work_order_subject', 'duration_hours', 'is_overdue', 'days_until_due', 'cost_breakdown']; readOnly.forEach((attr) => delete json[attr]); + clearUnsetPolymorphicRelationships(snapshot, json, ['maintainable', 'performed_by']); return json; } @@ -46,7 +48,9 @@ export default class MaintenanceSerializer extends ApplicationSerializer.extend( let key = relationship.key; let belongsTo = snapshot.belongsTo(key); - const isPolymorphicTypeBlank = isBlank(snapshot.attr(key + '_type')); + // Read the type through attributes(): these models declare no `_type` + // attribute, and snapshot.attr() asserts on an undeclared one. + const isPolymorphicTypeBlank = isBlank(snapshot.attributes()[key + '_type']); if (isPolymorphicTypeBlank) { key = this.keyForAttribute ? this.keyForAttribute(key, 'serialize') : key; if (!belongsTo) { diff --git a/addon/serializers/work-order.js b/addon/serializers/work-order.js index 292124c..842d729 100644 --- a/addon/serializers/work-order.js +++ b/addon/serializers/work-order.js @@ -1,6 +1,7 @@ import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; import { isBlank } from '@ember/utils'; +import clearUnsetPolymorphicRelationships from '../utils/clear-unset-polymorphic-relationships'; export default class WorkOrderSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { /** @@ -27,6 +28,7 @@ export default class WorkOrderSerializer extends ApplicationSerializer.extend(Em const json = super.serialize(snapshot, options); const readOnly = ['target_name', 'assignee_name', 'is_overdue', 'days_until_due', 'completion_percentage']; readOnly.forEach((attr) => delete json[attr]); + clearUnsetPolymorphicRelationships(snapshot, json, ['target', 'assignee']); return json; } @@ -45,7 +47,9 @@ export default class WorkOrderSerializer extends ApplicationSerializer.extend(Em let key = relationship.key; let belongsTo = snapshot.belongsTo(key); - const isPolymorphicTypeBlank = isBlank(snapshot.attr(key + '_type')); + // Read the type through attributes(): these models declare no `_type` + // attribute, and snapshot.attr() asserts on an undeclared one. + const isPolymorphicTypeBlank = isBlank(snapshot.attributes()[key + '_type']); if (isPolymorphicTypeBlank) { key = this.keyForAttribute ? this.keyForAttribute(key, 'serialize') : key; if (!belongsTo) { diff --git a/addon/utils/clear-unset-polymorphic-relationships.js b/addon/utils/clear-unset-polymorphic-relationships.js new file mode 100644 index 0000000..b94f8e7 --- /dev/null +++ b/addon/utils/clear-unset-polymorphic-relationships.js @@ -0,0 +1,23 @@ +/** + * Send each unset polymorphic relationship as cleared. + * + * Ember Data only calls `serializePolymorphicType` for a related record that is + * present, so a removed relationship otherwise reaches the server as `null` + * embedded data while its `_uuid` and `_type` columns keep pointing + * at the old record. + * + * @param {Snapshot} snapshot + * @param {Object} json the serialized payload, changed in place + * @param {Array} keys the polymorphic relationship names + * @return {Object} json + */ +export default function clearUnsetPolymorphicRelationships(snapshot, json, keys) { + for (const key of keys) { + if (!snapshot.belongsTo(key)) { + json[`${key}_uuid`] = null; + json[`${key}_type`] = null; + } + } + + return json; +} diff --git a/app/utils/clear-unset-polymorphic-relationships.js b/app/utils/clear-unset-polymorphic-relationships.js new file mode 100644 index 0000000..fcbe70a --- /dev/null +++ b/app/utils/clear-unset-polymorphic-relationships.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/utils/clear-unset-polymorphic-relationships'; diff --git a/tests/helpers/polymorphic-contract.js b/tests/helpers/polymorphic-contract.js index b745fff..23f9e75 100644 --- a/tests/helpers/polymorphic-contract.js +++ b/tests/helpers/polymorphic-contract.js @@ -17,13 +17,14 @@ * Build the smallest snapshot `serializePolymorphicType` actually reads. * * @param {Object} [options] - * @param {Object} [options.attrs] values `snapshot.attr(key)` should return + * @param {Object} [options.attrs] values `snapshot.attr(key)` and `snapshot.attributes()` should return * @param {Object|null} [options.belongsTo] value `snapshot.belongsTo(key)` should return * @return {Object} */ export function snapshotStub({ attrs = {}, belongsTo = null } = {}) { return { attr: (key) => attrs[key], + attributes: () => attrs, belongsTo: () => belongsTo, }; } diff --git a/tests/unit/serializers/maintenance-schedule-test.js b/tests/unit/serializers/maintenance-schedule-test.js index 1f2b240..8a9112e 100644 --- a/tests/unit/serializers/maintenance-schedule-test.js +++ b/tests/unit/serializers/maintenance-schedule-test.js @@ -76,4 +76,14 @@ module('Unit | Serializer | maintenance schedule', function (hooks) { assert.strictEqual(normalized.data.relationships.default_assignee.data.type, 'facilitator-driver'); assert.strictEqual(normalized.data.relationships.default_assignee.data.id, 'driver-1'); }); + + test('a record whose model declares no polymorphic type attributes serializes without asserting', function (assert) { + const record = this.store.createRecord('maintenance-schedule', { subject: this.store.createRecord('maintenance-subject-vehicle', { id: 'subject_1' }) }); + + const json = record.serialize(); + + assert.strictEqual(json.subject_type, 'fleet-ops:vehicle', 'the type is derived from the related record'); + assert.strictEqual(json.default_assignee_uuid, null, 'an unset default_assignee clears its uuid'); + assert.strictEqual(json.default_assignee_type, null, 'an unset default_assignee clears its type'); + }); }); diff --git a/tests/unit/serializers/maintenance-test.js b/tests/unit/serializers/maintenance-test.js index c5b29ea..fbb7420 100644 --- a/tests/unit/serializers/maintenance-test.js +++ b/tests/unit/serializers/maintenance-test.js @@ -72,4 +72,14 @@ module('Unit | Serializer | maintenance', function (hooks) { assert.strictEqual(json['maintainable_type'], 'fleet-ops:vendor', 'the raw key still produces a usable type field'); }); + + test('a record whose model declares no polymorphic type attributes serializes without asserting', function (assert) { + const record = this.store.createRecord('maintenance', { maintainable: this.store.createRecord('maintenance-subject-vehicle', { id: 'subject_1' }) }); + + const json = record.serialize(); + + assert.strictEqual(json.maintainable_type, 'fleet-ops:vehicle', 'the type is derived from the related record'); + assert.strictEqual(json.performed_by_uuid, null, 'an unset performed_by clears its uuid'); + assert.strictEqual(json.performed_by_type, null, 'an unset performed_by clears its type'); + }); }); diff --git a/tests/unit/serializers/work-order-test.js b/tests/unit/serializers/work-order-test.js index 998547b..6408144 100644 --- a/tests/unit/serializers/work-order-test.js +++ b/tests/unit/serializers/work-order-test.js @@ -69,4 +69,14 @@ module('Unit | Serializer | work order', function (hooks) { assert.strictEqual(json['target_type'], 'fleet-ops:vendor', 'the raw key still produces a usable type field'); }); + + test('a record whose model declares no polymorphic type attributes serializes without asserting', function (assert) { + const record = this.store.createRecord('work-order', { target: this.store.createRecord('maintenance-subject-vehicle', { id: 'subject_1' }) }); + + const json = record.serialize(); + + assert.strictEqual(json.target_type, 'fleet-ops:vehicle', 'the type is derived from the related record'); + assert.strictEqual(json.assignee_uuid, null, 'an unset assignee clears its uuid'); + assert.strictEqual(json.assignee_type, null, 'an unset assignee clears its type'); + }); }); diff --git a/tests/unit/utils/clear-unset-polymorphic-relationships-test.js b/tests/unit/utils/clear-unset-polymorphic-relationships-test.js new file mode 100644 index 0000000..d41f4e3 --- /dev/null +++ b/tests/unit/utils/clear-unset-polymorphic-relationships-test.js @@ -0,0 +1,30 @@ +import clearUnsetPolymorphicRelationships from '@fleetbase/fleetops-data/utils/clear-unset-polymorphic-relationships'; +import { module, test } from 'qunit'; + +/** + * A removed polymorphic relationship has to reach the server as cleared + * columns; a relationship that is still set must be left to the type hook. + */ +module('Unit | Utility | clear-unset-polymorphic-relationships', function () { + const snapshot = (related) => ({ belongsTo: (key) => related[key] ?? null }); + + test('an unset relationship is sent with both its uuid and type cleared', function (assert) { + const json = { target: null }; + + const result = clearUnsetPolymorphicRelationships(snapshot({}), json, ['target']); + + assert.strictEqual(result, json, 'the payload is changed in place and returned'); + assert.strictEqual(json.target_uuid, null); + assert.strictEqual(json.target_type, null); + }); + + test('a relationship that is set is left untouched', function (assert) { + const json = { assignee_uuid: 'vendor_1', assignee_type: 'fleet-ops:vendor' }; + + clearUnsetPolymorphicRelationships(snapshot({ assignee: { id: 'vendor_1' } }), json, ['assignee', 'target']); + + assert.strictEqual(json.assignee_uuid, 'vendor_1'); + assert.strictEqual(json.assignee_type, 'fleet-ops:vendor'); + assert.strictEqual(json.target_uuid, null, 'the unset sibling is still cleared'); + }); +});