feat(v11)!: restrict size/first/last magic keys to arrays and strings

Replace ownPropertyOnly guards with simpler v11 semantics: magic
property resolution applies only to arrays and strings. Plain objects
no longer get Object.keys size or first/last fallbacks.

Related to #916

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-06-20 00:57:03 +08:00
co-authored by Cursor
parent c173505e78
commit 952e3546bb
2 changed files with 31 additions and 24 deletions
+22 -9
View File
@@ -32,7 +32,7 @@ describe('Context', function () {
it('should read nested property', async function () {
expect(ctx.get(['obj', 'first'])).toEqual('f')
expect(ctx.get(['obj', 'last'])).toEqual('l')
expect(ctx.get(['obj', 'size'])).toEqual(2)
expect(ctx.get(['obj', 'size'])).toBeUndefined()
})
it('undefined property should yield undefined', async function () {
expect(ctx.get(['notdefined'])).toEqual(undefined)
@@ -55,7 +55,9 @@ describe('Context', function () {
it('should return array length as size', async function () {
expect(ctx.get(['bar', 'arr', 'size'])).toEqual(2)
})
it('should return map size as size', async function () {
it('should return map size only via own property access', async function () {
expect(ctx.get(['map', 'size'])).toBeUndefined()
ctx = new Context(scope, { ownPropertyOnly: false } as any)
expect(ctx.get(['map', 'size'])).toEqual(1)
})
it('should return undefined if not have a size', async function () {
@@ -68,6 +70,14 @@ describe('Context', function () {
it('should read .last of array', async function () {
expect(ctx.get(['bar', 'arr', 'last'])).toEqual('b')
})
it('should read own size property on objects', async function () {
expect(ctx.get(['zoo', 'size'])).toEqual(4)
})
it('should read string first/last as magic keys', async function () {
ctx.push({ str: 'abc' })
expect(ctx.getSync(['str', 'first'])).toEqual('a')
expect(ctx.getSync(['str', 'last'])).toEqual('c')
})
it('should read element of array', async function () {
expect(ctx.get(['arr', 1])).toEqual('b')
})
@@ -167,13 +177,16 @@ describe('Context', function () {
ctx.push({ foo: [1, 2] })
return expect(ctx.getSync(['foo', 'size'])).toEqual(2)
})
it('should allow size to access Set.prototype.size', function () {
it('should allow size to access Set.prototype.size via property access', function () {
ctx.push({ foo: new Set([1, 2]) })
expect(ctx.getSync(['foo', 'size'])).toEqual(undefined)
ctx = new Context({}, { ownPropertyOnly: false } as any)
ctx.push({ foo: new Set([1, 2]) })
return expect(ctx.getSync(['foo', 'size'])).toEqual(2)
})
it('should allow size to access Object key count', function () {
it('should not apply size magic to plain objects', function () {
ctx.push({ foo: { bar: 'BAR', coo: 'COO' } })
return expect(ctx.getSync(['foo', 'size'])).toEqual(2)
return expect(ctx.getSync(['foo', 'size'])).toEqual(undefined)
})
it('should throw when property is hidden and strictVariables is true', function () {
ctx = new Context(ctx, {
@@ -183,11 +196,11 @@ describe('Context', function () {
ctx.push({ foo: Object.create({ bar: 'BAR' }) })
return expect(() => ctx.getSync(['foo', 'bar'])).toThrow(/undefined variable: foo.bar/)
})
it('should block prototype size/first/last magic keys', function () {
it('should not apply size/first/last magic keys to plain objects', function () {
Object.assign(Object.prototype, { size: 123, first: 'FIRST_PROTO', last: 'LAST_PROTO' })
try {
ctx.push({ foo: {} })
expect(ctx.getSync(['foo', 'size'])).toEqual(0)
expect(ctx.getSync(['foo', 'size'])).toEqual(undefined)
expect(ctx.getSync(['foo', 'first'])).toEqual(undefined)
expect(ctx.getSync(['foo', 'last'])).toEqual(undefined)
} finally {
@@ -196,13 +209,13 @@ describe('Context', function () {
delete (Object.prototype as any).last
}
})
it('should allow array first/last/size magic keys with ownPropertyOnly', function () {
it('should allow array first/last/size magic keys', function () {
ctx.push({ foo: [1, 2, 3] })
expect(ctx.getSync(['foo', 'first'])).toEqual(1)
expect(ctx.getSync(['foo', 'last'])).toEqual(3)
expect(ctx.getSync(['foo', 'size'])).toEqual(3)
})
it('should use prototype magic keys when ownPropertyOnly=false', function () {
it('should read prototype size/first/last via property access when ownPropertyOnly=false', function () {
Object.assign(Object.prototype, { size: 123, first: 'FIRST_PROTO', last: 'LAST_PROTO' })
try {
ctx = new Context({}, { ownPropertyOnly: false } as any)
+9 -15
View File
@@ -129,9 +129,9 @@ export class Context {
const value = readJSProperty(obj, key, this.ownPropertyOnly)
if (value === undefined && obj instanceof Drop) return obj.liquidMethodMissing(key, this)
if (isFunction(value)) return value.call(obj)
if (key === 'size') return readSize(obj, this.ownPropertyOnly)
else if (key === 'first') return readFirst(obj, this.ownPropertyOnly)
else if (key === 'last') return readLast(obj, this.ownPropertyOnly)
if (key === 'size' && (isArray(obj) || isString(obj))) return readSize(obj)
else if (key === 'first' && (isArray(obj) || isString(obj))) return readFirst(obj)
else if (key === 'last' && (isArray(obj) || isString(obj))) return readLast(obj)
return value
}
}
@@ -141,22 +141,16 @@ export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: b
return obj[key]
}
function readFirst (obj: Scope, ownPropertyOnly: boolean) {
function readFirst (obj: Scope) {
if (isArray(obj)) return obj[0]
if (ownPropertyOnly && !hasOwnProperty.call(obj, 'first') && !(obj instanceof Drop)) return undefined
return obj['first']
return obj[0]
}
function readLast (obj: Scope, ownPropertyOnly: boolean) {
function readLast (obj: Scope) {
if (isArray(obj)) return obj[obj.length - 1]
if (ownPropertyOnly && !hasOwnProperty.call(obj, 'last') && !(obj instanceof Drop)) return undefined
return obj['last']
return obj[obj.length - 1]
}
function readSize (obj: Scope, ownPropertyOnly: boolean) {
if (hasOwnProperty.call(obj, 'size')) return obj['size']
if (!ownPropertyOnly && obj['size'] !== undefined) return obj['size']
if (isArray(obj) || isString(obj)) return obj.length
if (obj instanceof Set || obj instanceof Map) return obj.size
if (typeof obj === 'object') return Object.keys(obj).length
function readSize (obj: Scope) {
return obj.length
}