From f7f75be8778408a310ff182891f1df90d9e90ec7 Mon Sep 17 00:00:00 2001 From: William Wong Date: Thu, 13 Jun 2024 02:07:29 -0700 Subject: [PATCH] Add generatorWithLastValue --- CHANGELOG.md | 1 + README.md | 38 ++++ .../integration-test/importDefault.test.ts | 34 ++++ packages/integration-test/importNamed.test.ts | 34 ++++ .../integration-test/requireNamed.test.cjs | 34 ++++ .../integration-test/requiredDefault.test.cjs | 34 ++++ packages/iter-fest/package.json | 20 +++ .../src/asyncGeneratorWithLastValue.spec.ts | 162 ++++++++++++++++++ .../src/asyncGeneratorWithLastValue.ts | 47 +++++ .../src/generatorWithLastValue.spec.ts | 138 +++++++++++++++ .../iter-fest/src/generatorWithLastValue.ts | 43 +++++ packages/iter-fest/src/index.ts | 2 + packages/iter-fest/tsup.config.ts | 2 + 13 files changed, 589 insertions(+) create mode 100644 packages/iter-fest/src/asyncGeneratorWithLastValue.spec.ts create mode 100644 packages/iter-fest/src/asyncGeneratorWithLastValue.ts create mode 100644 packages/iter-fest/src/generatorWithLastValue.spec.ts create mode 100644 packages/iter-fest/src/generatorWithLastValue.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b98a0..a1174d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `readerValues` in PR [#12](https://github.com/compulim/iter-fest/pull/12) and [#14](https://github.com/compulim/iter-fest/pull/14) - Added `observableSubscribeAsReadable` in PR [#13](https://github.com/compulim/iter-fest/pull/13) - Added `iterableGetReadable` in PR [#15](https://github.com/compulim/iter-fest/pull/15) +- Added `generatorWithLastValue`/`asyncGeneratorWithLastValue` in PR [#XX](https://github.com/compulim/iter-fest/pull/XX) ### Changed diff --git a/README.md b/README.md index bac57d0..b860fac 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,44 @@ const iterable = new PushAsyncIterableIterator(); // Prints "1", "2", "3", "Done". ``` +### Using for-loop with generator + +Compare to `Iterator`, `Generator` offers advanced capability. + +When using for-loop with generator, the last value return from the generator is lost. + +The `generatorWithLastValue()` and `asyncGeneratorWithLastValue()` helps bridge the for-loop usage by storing the last value and make it accessible via `lastValue()`. + +```ts +const generator = generatorWithLastValue( + (function* () { + yield 1; + yield 2; + yield 3; + + return 'end'; + })() +); + +for (const value of generator) { + console.log(value); // Prints "1", "2", "3". +} + +console.log(generator.lastValue()); // Prints "end". +``` + +Note: `lastValue()` will throw if it is being called before end of iteration. + +The value returned from `generatorWithLastValue()`/`asyncGeneratorWithLastValue()` will passthrough all function calls to original `Generator` with a minor difference. Calling `[Symbol.iterator]()`/`[Symbol.asyncIterator]()` on the returned generator will not start a fresh iteration. If a fresh iteration is required, create a new one before passing it to `generatorWithLastValue()`/`asyncGeneratorWithLastValue()`. + +```ts +const generator = generatorWithLastValue( + (function* () { + // ... + })()[Symbol.iterator]() // Creates a fresh iteration. +); +``` + ## Behaviors ### How this compares to the TC39 proposals? diff --git a/packages/integration-test/importDefault.test.ts b/packages/integration-test/importDefault.test.ts index 0547f5e..a7d71b5 100644 --- a/packages/integration-test/importDefault.test.ts +++ b/packages/integration-test/importDefault.test.ts @@ -3,6 +3,8 @@ import { Observable, PushAsyncIterableIterator, SymbolObservable, + asyncGeneratorWithLastValue, + generatorWithLastValue, iterableAt, iterableConcat, iterableEntries, @@ -30,6 +32,38 @@ import { readerValues } from 'iter-fest'; +test('asyncGeneratorWithLastValue should work', async () => { + const asyncGenerator = asyncGeneratorWithLastValue( + (async function* () { + yield 1; + + return 'end' as const; + })() + ); + + for await (const value of asyncGenerator) { + expect(value).toBe(1); + } + + expect(asyncGenerator.lastValue()).toEqual('end'); +}); + +test('generatorWithLastValue should work', () => { + const generator = generatorWithLastValue( + (function* () { + yield 1; + + return 'end' as const; + })() + ); + + for (const value of generator) { + expect(value).toBe(1); + } + + expect(generator.lastValue()).toEqual('end'); +}); + test('iterableAt should work', () => expect(iterableAt([1, 2, 3].values(), 1)).toBe(2)); test('iterableConcat should work', () => diff --git a/packages/integration-test/importNamed.test.ts b/packages/integration-test/importNamed.test.ts index 7ae1654..daa640a 100644 --- a/packages/integration-test/importNamed.test.ts +++ b/packages/integration-test/importNamed.test.ts @@ -1,4 +1,6 @@ import withResolvers from 'core-js-pure/full/promise/with-resolvers'; +import { asyncGeneratorWithLastValue } from 'iter-fest/asyncGeneratorWithLastValue'; +import { generatorWithLastValue } from 'iter-fest/generatorWithLastValue'; import { iterableAt } from 'iter-fest/iterableAt'; import { iterableConcat } from 'iter-fest/iterableConcat'; import { iterableEntries } from 'iter-fest/iterableEntries'; @@ -28,6 +30,38 @@ import { PushAsyncIterableIterator } from 'iter-fest/pushAsyncIterableIterator'; import { readerValues } from 'iter-fest/readerValues'; import { SymbolObservable } from 'iter-fest/symbolObservable'; +test('asyncGeneratorWithLastValue should work', async () => { + const asyncGenerator = asyncGeneratorWithLastValue( + (async function* () { + yield 1; + + return 'end' as const; + })() + ); + + for await (const value of asyncGenerator) { + expect(value).toBe(1); + } + + expect(asyncGenerator.lastValue()).toEqual('end'); +}); + +test('generatorWithLastValue should work', () => { + const generator = generatorWithLastValue( + (function* () { + yield 1; + + return 'end' as const; + })() + ); + + for (const value of generator) { + expect(value).toBe(1); + } + + expect(generator.lastValue()).toEqual('end'); +}); + test('iterableAt should work', () => expect(iterableAt([1, 2, 3].values(), 1)).toBe(2)); test('iterableConcat should work', () => diff --git a/packages/integration-test/requireNamed.test.cjs b/packages/integration-test/requireNamed.test.cjs index 7242edd..317262e 100644 --- a/packages/integration-test/requireNamed.test.cjs +++ b/packages/integration-test/requireNamed.test.cjs @@ -1,5 +1,7 @@ const withResolvers = require('core-js-pure/full/promise/with-resolvers'); +const { asyncGeneratorWithLastValue } = require('iter-fest/asyncGeneratorWithLastValue'); +const { generatorWithLastValue } = require('iter-fest/generatorWithLastValue'); const { iterableAt } = require('iter-fest/iterableAt'); const { iterableConcat } = require('iter-fest/iterableConcat'); const { iterableEntries } = require('iter-fest/iterableEntries'); @@ -29,6 +31,38 @@ const { PushAsyncIterableIterator } = require('iter-fest/pushAsyncIterableIterat const { readerValues } = require('iter-fest/readerValues'); const { SymbolObservable } = require('iter-fest/symbolObservable'); +test('asyncGeneratorWithLastValue should work', async () => { + const asyncGenerator = asyncGeneratorWithLastValue( + (async function* () { + yield 1; + + return 'end'; + })() + ); + + for await (const value of asyncGenerator) { + expect(value).toBe(1); + } + + expect(asyncGenerator.lastValue()).toEqual('end'); +}); + +test('generatorWithLastValue should work', () => { + const generator = generatorWithLastValue( + (function* () { + yield 1; + + return 'end'; + })() + ); + + for (const value of generator) { + expect(value).toBe(1); + } + + expect(generator.lastValue()).toEqual('end'); +}); + test('iterableAt should work', () => expect(iterableAt([1, 2, 3].values(), 1)).toBe(2)); test('iterableConcat should work', () => diff --git a/packages/integration-test/requiredDefault.test.cjs b/packages/integration-test/requiredDefault.test.cjs index 82a88ca..56acb62 100644 --- a/packages/integration-test/requiredDefault.test.cjs +++ b/packages/integration-test/requiredDefault.test.cjs @@ -1,6 +1,8 @@ const withResolvers = require('core-js-pure/full/promise/with-resolvers'); const { + asyncGeneratorWithLastValue, + generatorWithLastValue, iterableAt, iterableConcat, iterableEntries, @@ -31,6 +33,38 @@ const { SymbolObservable } = require('iter-fest'); +test('asyncGeneratorWithLastValue should work', async () => { + const asyncGenerator = asyncGeneratorWithLastValue( + (async function* () { + yield 1; + + return 'end'; + })() + ); + + for await (const value of asyncGenerator) { + expect(value).toBe(1); + } + + expect(asyncGenerator.lastValue()).toEqual('end'); +}); + +test('generatorWithLastValue should work', () => { + const generator = generatorWithLastValue( + (function* () { + yield 1; + + return 'end'; + })() + ); + + for (const value of generator) { + expect(value).toBe(1); + } + + expect(generator.lastValue()).toEqual('end'); +}); + test('iterableAt should work', () => expect(iterableAt([1, 2, 3].values(), 1)).toBe(2)); test('iterableConcat should work', () => diff --git a/packages/iter-fest/package.json b/packages/iter-fest/package.json index 1399f91..a39e8f0 100644 --- a/packages/iter-fest/package.json +++ b/packages/iter-fest/package.json @@ -6,6 +6,26 @@ "./dist/" ], "exports": { + "./asyncGeneratorWithLastValue": { + "import": { + "types": "./dist/iter-fest.asyncGeneratorWithLastValue.d.mts", + "default": "./dist/iter-fest.asyncGeneratorWithLastValue.mjs" + }, + "require": { + "types": "./dist/iter-fest.asyncGeneratorWithLastValue.d.ts", + "default": "./dist/iter-fest.asyncGeneratorWithLastValue.js" + } + }, + "./generatorWithLastValue": { + "import": { + "types": "./dist/iter-fest.generatorWithLastValue.d.mts", + "default": "./dist/iter-fest.generatorWithLastValue.mjs" + }, + "require": { + "types": "./dist/iter-fest.generatorWithLastValue.d.ts", + "default": "./dist/iter-fest.generatorWithLastValue.js" + } + }, "./iterableAt": { "import": { "types": "./dist/iter-fest.iterableAt.d.mts", diff --git a/packages/iter-fest/src/asyncGeneratorWithLastValue.spec.ts b/packages/iter-fest/src/asyncGeneratorWithLastValue.spec.ts new file mode 100644 index 0000000..88bfb84 --- /dev/null +++ b/packages/iter-fest/src/asyncGeneratorWithLastValue.spec.ts @@ -0,0 +1,162 @@ +import { asyncGeneratorWithLastValue, type AsyncGeneratorWithLastValue } from './asyncGeneratorWithLastValue'; + +test('usage', async () => { + const generator = asyncGeneratorWithLastValue( + (async function* () { + await undefined; + + yield 1; + await undefined; + + return 'end' as const; + })() + ); + + for await (const value of generator) { + expect(value).toBe(1); + } + + expect(generator.lastValue()).toBe('end'); +}); + +describe('comprehensive', () => { + let generator: AsyncGeneratorWithLastValue; + let numTimesGeneratorCalled: number; + + beforeEach(() => { + numTimesGeneratorCalled = 0; + + generator = asyncGeneratorWithLastValue( + (async function* () { + numTimesGeneratorCalled++; + + await undefined; + yield 1; + await undefined; + yield 2; + await undefined; + + return 'end' as const; + })() + ); + }); + + test('generator() should not have been called', () => expect(numTimesGeneratorCalled).toBe(0)); + + describe('when next() is called', () => { + let result: IteratorResult; + + beforeEach(async () => { + result = await generator.next(); + }); + + test('generator() should have been called once', () => expect(numTimesGeneratorCalled).toBe(1)); + test('should return 1', () => expect(result).toEqual({ done: false, value: 1 })); + test('call lastValue() should throw', () => + expect(() => generator.lastValue()).toThrow('Iteration has not complete yet, cannot get last value.')); + + describe('when next() is called again', () => { + let result: IteratorResult; + + beforeEach(async () => { + result = await generator.next(); + }); + + test('generator() should have been called once', () => expect(numTimesGeneratorCalled).toBe(1)); + test('should return 2', () => expect(result).toEqual({ done: false, value: 2 })); + test('call lastValue() should throw', () => + expect(() => generator.lastValue()).toThrow('Iteration has not complete yet, cannot get last value.')); + + describe('when next() is called again', () => { + let result: IteratorResult; + + beforeEach(async () => { + result = await generator.next(); + }); + + test('generator() should have been called once', () => expect(numTimesGeneratorCalled).toBe(1)); + test('should return done', () => expect(result).toEqual({ done: true, value: 'end' })); + test('lastValue() should return "end"', () => expect(generator.lastValue()).toBe('end')); + }); + }); + }); +}); + +test('passthrough next', async () => { + const next = jest.fn(); + + const generator = asyncGeneratorWithLastValue( + (async function* () { + await undefined; + + const value = yield 1; + + await undefined; + + next(value); + })() + ); + + await expect(generator.next()).resolves.toEqual({ done: false, value: 1 }); + await expect(generator.next(true)).resolves.toEqual({ done: true, value: undefined }); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(true); +}); + +test('passthrough return', async () => { + const shouldNotCall = jest.fn(); + + const generator = asyncGeneratorWithLastValue( + (async function* () { + await undefined; + + yield 1; + await undefined; + + shouldNotCall(); + await undefined; + + yield 2; + await undefined; + + return false; + })() + ); + + await expect(generator.next()).resolves.toEqual({ done: false, value: 1 }); + await expect(generator.return(true)).resolves.toEqual({ done: true, value: true }); + + expect(shouldNotCall).not.toHaveBeenCalled(); +}); + +test('passthrough throw', async () => { + const throw_ = jest.fn(); + const shouldNotCall = jest.fn(); + + const generator = asyncGeneratorWithLastValue( + (async function* () { + try { + await undefined; + + yield 1; + await undefined; + + shouldNotCall(); + await undefined; + + yield 2; + await undefined; + } catch (error) { + throw_(error); + } + })() + ); + + await expect(generator.next()).resolves.toEqual({ done: false, value: 1 }); + await expect(generator.throw('artificial')).resolves.toEqual({ done: true, value: undefined }); + + expect(throw_).toHaveBeenCalledTimes(1); + expect(throw_).toHaveBeenNthCalledWith(1, 'artificial'); + expect(shouldNotCall).not.toHaveBeenCalled(); +}); diff --git a/packages/iter-fest/src/asyncGeneratorWithLastValue.ts b/packages/iter-fest/src/asyncGeneratorWithLastValue.ts new file mode 100644 index 0000000..2475e0c --- /dev/null +++ b/packages/iter-fest/src/asyncGeneratorWithLastValue.ts @@ -0,0 +1,47 @@ +const STILL_ITERATING = Symbol(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AsyncGeneratorWithLastValue = AsyncGenerator< + T, + TReturn, + TNext +> & { + lastValue(): TReturn; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function asyncGeneratorWithLastValue( + generator: AsyncGenerator +): AsyncGeneratorWithLastValue { + let lastValue: typeof STILL_ITERATING | TReturn = STILL_ITERATING; + + const asyncGeneratorWithLastValue = { + [Symbol.asyncIterator]() { + return asyncGeneratorWithLastValue; + }, + lastValue(): TReturn { + if (lastValue === STILL_ITERATING) { + throw new Error('Iteration has not complete yet, cannot get last value.'); + } + + return lastValue; + }, + async next(next: TNext) { + const result = await generator.next(next); + + if (result.done) { + lastValue = result.value; + } + + return result; + }, + return(value: TReturn) { + return generator.return(value); + }, + throw(error: unknown) { + return generator.throw(error); + } + }; + + return asyncGeneratorWithLastValue; +} diff --git a/packages/iter-fest/src/generatorWithLastValue.spec.ts b/packages/iter-fest/src/generatorWithLastValue.spec.ts new file mode 100644 index 0000000..ac0943f --- /dev/null +++ b/packages/iter-fest/src/generatorWithLastValue.spec.ts @@ -0,0 +1,138 @@ +import { generatorWithLastValue, type GeneratorWithLastValue } from './generatorWithLastValue'; + +test('usage', () => { + const generator = generatorWithLastValue( + (function* () { + yield 1; + + return 'end' as const; + })() + ); + + for (const value of generator) { + expect(value).toBe(1); + } + + expect(generator.lastValue()).toBe('end'); +}); + +describe('comprehensive', () => { + let generator: GeneratorWithLastValue; + let numTimesGeneratorCalled: number; + + beforeEach(() => { + numTimesGeneratorCalled = 0; + + generator = generatorWithLastValue( + (function* () { + numTimesGeneratorCalled++; + + yield 1; + yield 2; + + return 'end' as const; + })() + ); + }); + + test('generator() should not have been called', () => expect(numTimesGeneratorCalled).toBe(0)); + + describe('when next() is called', () => { + let result: IteratorResult; + + beforeEach(() => { + result = generator.next(); + }); + + test('generator() should have been called once', () => expect(numTimesGeneratorCalled).toBe(1)); + test('should return 1', () => expect(result).toEqual({ done: false, value: 1 })); + test('call lastValue() should throw', () => + expect(() => generator.lastValue()).toThrow('Iteration has not complete yet, cannot get last value.')); + + describe('when next() is called again', () => { + let result: IteratorResult; + + beforeEach(() => { + result = generator.next(); + }); + + test('generator() should have been called once', () => expect(numTimesGeneratorCalled).toBe(1)); + test('should return 2', () => expect(result).toEqual({ done: false, value: 2 })); + test('call lastValue() should throw', () => + expect(() => generator.lastValue()).toThrow('Iteration has not complete yet, cannot get last value.')); + + describe('when next() is called again', () => { + let result: IteratorResult; + + beforeEach(() => { + result = generator.next(); + }); + + test('generator() should have been called once', () => expect(numTimesGeneratorCalled).toBe(1)); + test('should return done', () => expect(result).toEqual({ done: true, value: 'end' })); + test('lastValue() should return "end"', () => expect(generator.lastValue()).toBe('end')); + }); + }); + }); +}); + +test('passthrough next', () => { + const next = jest.fn(); + + const generator = generatorWithLastValue( + (function* () { + const value = yield 1; + + next(value); + })() + ); + + expect(generator.next()).toEqual({ done: false, value: 1 }); + expect(generator.next(true)).toEqual({ done: true, value: undefined }); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(true); +}); + +test('passthrough return', () => { + const shouldNotCall = jest.fn(); + + const generator = generatorWithLastValue( + (function* () { + yield 1; + shouldNotCall(); + yield 2; + + return false; + })() + ); + + expect(generator.next()).toEqual({ done: false, value: 1 }); + expect(generator.return(true)).toEqual({ done: true, value: true }); + + expect(shouldNotCall).not.toHaveBeenCalled(); +}); + +test('passthrough throw', () => { + const throw_ = jest.fn(); + const shouldNotCall = jest.fn(); + + const generator = generatorWithLastValue( + (function* () { + try { + yield 1; + shouldNotCall(); + yield 2; + } catch (error) { + throw_(error); + } + })() + ); + + expect(generator.next()).toEqual({ done: false, value: 1 }); + expect(generator.throw('artificial')).toEqual({ done: true, value: undefined }); + + expect(throw_).toHaveBeenCalledTimes(1); + expect(throw_).toHaveBeenNthCalledWith(1, 'artificial'); + expect(shouldNotCall).not.toHaveBeenCalled(); +}); diff --git a/packages/iter-fest/src/generatorWithLastValue.ts b/packages/iter-fest/src/generatorWithLastValue.ts new file mode 100644 index 0000000..ff9b53c --- /dev/null +++ b/packages/iter-fest/src/generatorWithLastValue.ts @@ -0,0 +1,43 @@ +const STILL_ITERATING = Symbol(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type GeneratorWithLastValue = Generator & { + lastValue(): TReturn; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function generatorWithLastValue( + generator: Generator +): GeneratorWithLastValue { + let lastValue: typeof STILL_ITERATING | TReturn = STILL_ITERATING; + + const generatorWithLastValue = { + [Symbol.iterator]() { + return generatorWithLastValue; + }, + lastValue(): TReturn { + if (lastValue === STILL_ITERATING) { + throw new Error('Iteration has not complete yet, cannot get last value.'); + } + + return lastValue; + }, + next(next: TNext) { + const result = generator.next(next); + + if (result.done) { + lastValue = result.value; + } + + return result; + }, + return(value: TReturn) { + return generator.return(value); + }, + throw(error: unknown) { + return generator.throw(error); + } + }; + + return generatorWithLastValue; +} diff --git a/packages/iter-fest/src/index.ts b/packages/iter-fest/src/index.ts index cef4dfb..8a02c3a 100644 --- a/packages/iter-fest/src/index.ts +++ b/packages/iter-fest/src/index.ts @@ -1,6 +1,8 @@ export * from './Observable'; export * from './PushAsyncIterableIterator'; export * from './SymbolObservable'; +export * from './asyncGeneratorWithLastValue'; +export * from './generatorWithLastValue'; export * from './iterableAt'; export * from './iterableConcat'; export * from './iterableEntries'; diff --git a/packages/iter-fest/tsup.config.ts b/packages/iter-fest/tsup.config.ts index 536629d..f169bcd 100644 --- a/packages/iter-fest/tsup.config.ts +++ b/packages/iter-fest/tsup.config.ts @@ -5,6 +5,8 @@ export default defineConfig([ dts: true, entry: { 'iter-fest': './src/index.ts', + 'iter-fest.asyncGeneratorWithLastValue': './src/asyncGeneratorWithLastValue.ts', + 'iter-fest.generatorWithLastValue': './src/generatorWithLastValue.ts', 'iter-fest.iterableAt': './src/iterableAt.ts', 'iter-fest.iterableConcat': './src/iterableConcat.ts', 'iter-fest.iterableEntries': './src/iterableEntries.ts',