Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(collections): ensure pick doesn't generate missing properties as undefined #5926

Merged
merged 6 commits into from
Sep 12, 2024

Conversation

yuhr
Copy link
Contributor

@yuhr yuhr commented Sep 9, 2024

Fixes #5922.

@yuhr yuhr requested a review from kt3k as a code owner September 9, 2024 13:50
@CLAassistant
Copy link

CLAassistant commented Sep 9, 2024

CLA assistant check
All committers have signed the CLA.

Copy link

codecov bot commented Sep 9, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 96.32%. Comparing base (b3e1ebb) to head (5d2fd7e).
Report is 31 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5926      +/-   ##
==========================================
+ Coverage   96.25%   96.32%   +0.06%     
==========================================
  Files         489      483       -6     
  Lines       39457    39405      -52     
  Branches     5824     5839      +15     
==========================================
- Hits        37980    37955      -25     
+ Misses       1433     1408      -25     
+ Partials       44       42       -2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@yuhr
Copy link
Contributor Author

yuhr commented Sep 9, 2024

Oops, for now it should respect non-own properties as it used to.

collections/pick.ts Outdated Show resolved Hide resolved
"toString",
]);

assertEquals(picked, { toString: Object.prototype.toString });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally don't think this is intentionally in this way.. (The check with Object.hasOwn meets the users' expectation better in my view)

@lambdalisue What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering the original purpose, I hadn't envisioned a use case where toString would be picked. Therefore, I think @kt3k's suggestion is a good one. However, since the implementation currently references obj[k], changing it to Object.hasOwn would be a breaking change. Additionally, in terms of type expression, it's written as K in keyof T, which means it allows something like toString (the following code does not result in a type error).

import { pick } from "jsr:@std/collections/pick";
import { omit } from "jsr:@std/collections/omit";

class A {
  constructor(public a: string, public b: string, public c: string) {}

  foo(): string {
    return "hello";
  }
}

const obj = new A("a", "b", "c");

const p = pick(obj, ["a", "c", "foo"]);
const o = omit(obj, ["b"]);

console.log(obj.foo());
console.log(p.foo());
console.log(o.foo());

This might be beyond the scope of this PR, but I realized that o.foo() does not work in the code above. Thus, currently there is an inconsistency between pick and omit. Given this situation, we need to choose between the following options:

  1. Restrict the targets of pick and omit to Object.hasOwn (and adjust the type so it expresses this correctly).
  2. Fix the implementation of omit so that o.foo() does not result in a runtime error.

Personally, I prefer option 1, but I’m not sure how to express this with types.

Copy link
Contributor Author

@yuhr yuhr Sep 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually agree with @kt3k, the test case is just to avoid introducing accidental breaking change in the meantime.

I appreciate all the consideration given by @lambdalisue. Especially, the inconsistency between pick and omit is one of the reasons I proposed #5927; the most beautiful solution I think is to only operate on own properties and copy the prototype to the result, which is kind of a merge of option 1 and 2. Try replacing the imports with the following code and rerun your example code:

const pick = <T extends object, K extends keyof T>(
	obj: Readonly<T>,
	keys: readonly K[],
): Pick<T, K> => {
	const result = Object.create(Object.getPrototypeOf(obj))
	for (const key of keys) {
		const descriptor = Object.getOwnPropertyDescriptor(obj, key)
		if (descriptor) Object.defineProperty(result, key, descriptor)
	}
	if (!Object.isExtensible(obj)) Object.preventExtensions(result)
	return result
}

const omit = <T extends object, K extends keyof T>(
	obj: Readonly<T>,
	keys: readonly K[],
): Omit<T, K> => {
	const result = Object.create(Object.getPrototypeOf(obj))
	const excludes = new Set(keys)
	for (const key of Reflect.ownKeys(obj)) {
		if (!excludes.has(key as K)) {
			const descriptor = Object.getOwnPropertyDescriptor(obj, key)!
			Object.defineProperty(result, key, descriptor)
		}
	}
	if (!Object.isExtensible(obj)) Object.preventExtensions(result)
	return result
}

But yes, the parameter types will be looser and the return types will be stricter than desired this way, though still sound.

Anyways it's beyond the scope, so I suggest moving to another issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok. The current check (key in obj) works good for the case of the above class A example (foo method should be picked in that case).

I'm now in favor of landing this fix.

However, I'm still not sure we should include this particular example (pick(obj, ["toString"])) as a test case, as this causes type error if we don't type cast obj to any. I think we can consider this particular behavior something like undefined/not decided behavior.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about just removing this test case and revisit later when someone comes up with something better?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not opposed to it, but just curious, is the concept of “undefined behavior” common in the standard library? If we decide to make this UB, there has to be an explicit notice in the JSDoc comment.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not common to make something explicitly undefined behavior in the standard library, but I think we are not ready to decide about this behavior, and also this looks out of scope of this PR.

In my view, in the below example, picking foo looks fine, but picking toString looks strange as it doesn't match its type (it's actually causes a type error).

import { pick } from "@std/collections";
class A {
  foo() {
  }
  bar() {
  }
}
const a = new A();
pick(a, ["foo"]);
pick(a, ["toString"]);

@yuhr yuhr requested a review from kt3k September 11, 2024 14:46
Copy link
Member

@kt3k kt3k left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks!

@kt3k kt3k merged commit 1a3a515 into denoland:main Sep 12, 2024
16 checks passed
@lambdalisue
Copy link
Contributor

lambdalisue commented Sep 12, 2024

I've created an issue for o.foo() things
#5966

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

pick shouldn't pick missing keys
4 participants