diff --git a/src/components.ts b/src/components.ts index 96c65d8..24d8951 100644 --- a/src/components.ts +++ b/src/components.ts @@ -487,7 +487,8 @@ export namespace Validation { return Array.isArray(value) && value.length === 0; } - const isAbsentFromBody = (body: RequestBody, name: string) => !(name in body); + const isAbsentFromBody = (body: RequestBody, name: string) => + !Object.hasOwn(body, name); const isClearedByBody = (body: RequestBody, name: string) => Object.hasOwn(body, name) && isClearingValue(body[name]); @@ -563,7 +564,7 @@ export namespace Validation { const parsed = { ...obj }; const invalid: string[] = []; for (const [name, field] of Object.entries(meta.fields)) { - if (!field.validator || !(name in obj)) continue; + if (!field.validator || !Object.hasOwn(obj, name)) continue; const result = toParseResult(await field.validator(obj[name]), obj[name]); if (result.success) { parsed[name] = result.data; diff --git a/src/internal/list.ts b/src/internal/list.ts index 50f85d9..9ecd2d6 100644 --- a/src/internal/list.ts +++ b/src/internal/list.ts @@ -21,6 +21,7 @@ import { import { Computed } from "./computed"; import { wrapDeferredJoined } from "./deferred-joined-stream"; import { Foreign } from "./foreign"; +import { ownEntry } from "./meta-records"; import { deferredJoinedFields, Joined, @@ -77,9 +78,10 @@ function isHeldByKeyedModifier(meta: DataAPIMeta, field: string): boolean { Array.from(meta.modifierKeys.keys(), (modifier) => modifier.name), ); return ( - getMetadata(meta.tableClass, ModifiersStaticMetadata).fields[field]?.some( - ({ id }) => keyed.has(id), - ) ?? false + ownEntry( + getMetadata(meta.tableClass, ModifiersStaticMetadata).fields, + field, + )?.some(({ id }) => keyed.has(id)) ?? false ); } diff --git a/src/internal/properties.ts b/src/internal/properties.ts index c4a526e..5c21186 100644 --- a/src/internal/properties.ts +++ b/src/internal/properties.ts @@ -7,6 +7,7 @@ import type { RowFields, WritableAccessFields, } from "../metadata"; +import { ownEntry } from "./meta-records"; import { fieldsOf, prototypeOf } from "./prototypes"; function writableInBody( @@ -28,8 +29,9 @@ function writeTargetDefaults( for (const [key, value] of defaults) { if (value !== undefined) { instance[key] = value; - if (key in meta.fields) { - dbData[meta.fields[key].dbName || key] = value; + const field = ownEntry(meta.fields, key); + if (field) { + dbData[field.dbName || key] = value; } } } @@ -54,13 +56,14 @@ export function WriteProperties( writeTargetDefaults(instance, meta, dbData); } for (const [key, field] of writable.props) { - instance[key] = bodyData[key]; - dbData[field.dbName || key] = bodyData[key]; + const value = ownEntry(bodyData, key); + instance[key] = value; + dbData[field.dbName || key] = value; } instance.table = dbData; Object.setPrototypeOf(instance, prototypeOf(meta.target)); for (const [key, field] of writable.setters) { - field.desc?.set?.apply(instance, [bodyData[key]]); + field.desc?.set?.apply(instance, [ownEntry(bodyData, key)]); } return dbData; } diff --git a/src/tests/components/prototype_named_field.test.ts b/src/tests/components/prototype_named_field.test.ts new file mode 100644 index 0000000..dfa42d5 --- /dev/null +++ b/src/tests/components/prototype_named_field.test.ts @@ -0,0 +1,108 @@ +import { expect } from "chai"; +import { Controller } from "@antelopejs/interface-api"; +import { Schema } from "@antelopejs/interface-database"; +import { + DataController, + DefaultRoutes, + RegisterDataController, +} from "../../index"; +import { Query, Validation } from "../../components"; +import { + Access, + AccessMode, + DataAPIMeta, + Mandatory, + ModelReference, + Validator, +} from "../../metadata"; +import { + BasicDataModel, + Field, + Model, + RegisterSchema, + RegisterTable, + Table, +} from "@antelopejs/interface-database-decorators"; + +import { createdRowSchema, readJson } from "../internal/http"; +import { newRequest } from "../utils"; +import { isString } from "../../internal/guards"; + +const tableName = "prototype-named-field-rows"; +const schemaName = "default"; + +@RegisterTable(tableName, schemaName) +class Row extends Table { + declare _id: string; + + @Field("string") + declare title: string; +} +Field("string")(Row.prototype, "toString"); +class RowModel extends BasicDataModel(Row, tableName) {} + +async function createController( + testName: string, + toStringDecorators: PropertyDecorator[], +) { + @RegisterDataController() + class _PrototypeNamedFieldAPI extends DataController( + Row, + { new: DefaultRoutes.New }, + Controller(`/${testName}`), + ) { + @ModelReference() + @Model(RowModel) + declare rowModel: RowModel; + + @Access(AccessMode.ReadWrite) + declare title: string; + } + Access(AccessMode.ReadWrite)(_PrototypeNamedFieldAPI.prototype, "toString"); + for (const decorate of toStringDecorators) { + decorate(_PrototypeNamedFieldAPI.prototype, "toString"); + } + + await RegisterSchema(schemaName); + await Schema.get(schemaName)?.instance().table(tableName).delete(); +} + +describe("New route - a field named like an Object.prototype member", () => { + it("reports the field missing when it is mandatory and the body omits it", async () => { + await createController("tostring-mandatory", [Mandatory("new")]); + + const response = await newRequest("tostring-mandatory", { title: "a" }); + + expect(response.status, await response.clone().text()).to.equal(400); + expect(await response.json()).to.deep.equal({ + code: Validation.MISSING_MANDATORY_FIELDS, + fields: ["toString"], + }); + }); + + it("skips the field's validator when the body omits it", async () => { + await createController("tostring-validated", [Validator(isString)]); + + const response = await newRequest("tostring-validated", { title: "a" }); + + expect(response.status, await response.clone().text()).to.equal(200); + expect((await readJson(createdRowSchema, response))._id).to.be.a("string"); + }); +}); + +describe("Query.WriteProperties - a target default named like an Object.prototype member", () => { + it("writes only declared fields", async () => { + class Defaults { + valueOf = "default"; + other = "x"; + title = "t"; + } + const meta = new DataAPIMeta(Defaults); + meta.tableClass = class TableRow {}; + meta.setMode("title", AccessMode.ReadWrite); + + const dbData = await Query.WriteProperties({}, meta, {}); + + expect(Object.keys(dbData)).to.eql(["title"]); + }); +}); diff --git a/src/tests/components/prototype_named_filter.test.ts b/src/tests/components/prototype_named_filter.test.ts index 6f76925..c82db41 100644 --- a/src/tests/components/prototype_named_filter.test.ts +++ b/src/tests/components/prototype_named_filter.test.ts @@ -13,6 +13,7 @@ import { Filter, Listable, ModelReference, + Sortable, } from "../../metadata"; import { BasicDataModel, @@ -60,6 +61,7 @@ async function createController(testName: string) { Listable()(_PrototypeNamedFilterAPI.prototype, "valueOf"); Access(AccessMode.ReadOnly)(_PrototypeNamedFilterAPI.prototype, "valueOf"); Filter(undefined, false)(_PrototypeNamedFilterAPI.prototype, "valueOf"); + Sortable()(_PrototypeNamedFilterAPI.prototype, "valueOf"); await RegisterSchema(schemaName); const schema = Schema.get(schemaName); @@ -97,4 +99,19 @@ describe("List route - a field named like an Object.prototype member", () => { const data = await readJson(listSchema, response); expect(data.results.map(({ title }) => title)).to.eql(["alpha"]); }); + + it("sorts on the field", async () => { + await createController("valueof-sorted"); + + const response = await listRequest("valueof-sorted", { + sortKey: "valueOf", + }); + + expect(response.status, await response.clone().text()).to.equal(200); + const data = await readJson(listSchema, response); + expect(data.results.map(({ title }) => title)).to.have.members([ + "alpha", + "beta", + ]); + }); });