From 0dc1abdac45f00639c4847bc19c34a2e5e0c70fa Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Tue, 16 Mar 2021 14:54:53 -0700 Subject: [PATCH] Initial DataSource setup Summary: For context see https://fb.workplace.com/notes/470523670998369 This diff introduces the DataSource abstraction, that can store records. If a key is set a key -> record mapping is stored, to make it easy to update existing records using `upsert`, without knowing their exact index. Internal storage will be slightly altered in upcoming diffs, so don't pay to much attention to that part. Reviewed By: nikoant Differential Revision: D25953337 fbshipit-source-id: 1c3b53a2fcf61abaf061946be4af21d2aecc6c6d --- desktop/.eslintrc.js | 1 + .../flipper-plugin/src/__tests__/api.node.tsx | 1 + desktop/flipper-plugin/src/index.ts | 2 + .../src/state/datasource/DataSource.tsx | 187 ++++++++++++++++++ .../__tests__/datasource-basics.node.tsx | 96 +++++++++ docs/extending/flipper-plugin.mdx | 4 + 6 files changed, 291 insertions(+) create mode 100644 desktop/flipper-plugin/src/state/datasource/DataSource.tsx create mode 100644 desktop/flipper-plugin/src/state/datasource/__tests__/datasource-basics.node.tsx diff --git a/desktop/.eslintrc.js b/desktop/.eslintrc.js index efc6b734f..2a4717341 100644 --- a/desktop/.eslintrc.js +++ b/desktop/.eslintrc.js @@ -109,6 +109,7 @@ module.exports = { // for reference: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/README.md#extension-rules 'no-unused-vars': 0, 'no-redeclare': 0, + 'no-dupe-class-members': 0, '@typescript-eslint/no-redeclare': 1, '@typescript-eslint/no-unused-vars': [ 1, diff --git a/desktop/flipper-plugin/src/__tests__/api.node.tsx b/desktop/flipper-plugin/src/__tests__/api.node.tsx index f926e3fea..1ed280d75 100644 --- a/desktop/flipper-plugin/src/__tests__/api.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/api.node.tsx @@ -34,6 +34,7 @@ test('Correct top level API exposed', () => { "Tracked", "TrackingScope", "batch", + "createDataSource", "createState", "produce", "renderReactRoot", diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index 1d0ebdb47..35da44b4e 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -71,6 +71,8 @@ export { } from './utils/Logger'; export {Idler} from './utils/Idler'; +export {createDataSource} from './state/datasource/DataSource'; + // It's not ideal that this exists in flipper-plugin sources directly, // but is the least pain for plugin authors. // Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin) diff --git a/desktop/flipper-plugin/src/state/datasource/DataSource.tsx b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx new file mode 100644 index 000000000..b91cfae94 --- /dev/null +++ b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx @@ -0,0 +1,187 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +// TODO: support better minification +// TODO: separate views from datasource to be able to support multiple transformation simultanously + +type ExtractKeyType< + T extends object, + KEY extends keyof T +> = T[KEY] extends string ? string : T[KEY] extends number ? number : never; + +type AppendEvent = { + type: 'append'; + value: T; +}; +type UpdateEvent = { + type: 'update'; + value: T; + index: number; +}; + +type DataEvent = AppendEvent | UpdateEvent; + +class DataSource< + T extends object, + KEY extends keyof T, + KEY_TYPE extends string | number | never = ExtractKeyType +> { + private _records: T[] = []; + private _recordsById: Map = new Map(); + private keyAttribute: undefined | keyof T; + private idToIndex: Map = new Map(); + dataUpdateQueue: DataEvent[] = []; + // viewUpdateQueue; + + viewRecords: T[] = []; + nextViewRecords: T[] = []; // for double buffering + + /** + * Returns a direct reference to the stored records. + * The collection should be treated as readonly and mutable; + * the collection might be directly written to by the datasource, + * so for an immutable state create a defensive copy: + * `datasource.records.slice()` + */ + get records(): readonly T[] { + return this._records; + } + + /** + * returns a direct reference to the stored records as lookup map, + * based on the key attribute set. + * The colletion should be treated as readonly and mutable (it might change over time). + * Create a defensive copy if needed. + */ + get recordsById(): ReadonlyMap { + this.assertKeySet(); + return this._recordsById; + } + + constructor(keyAttribute: KEY | undefined) { + this.keyAttribute = keyAttribute; + } + + private assertKeySet() { + if (!this.keyAttribute) { + throw new Error( + 'No key has been set. Records cannot be looked up by key', + ); + } + } + + private getKey(value: T): KEY_TYPE; + private getKey(value: any): any { + this.assertKeySet(); + const key = value[this.keyAttribute!]; + if ((typeof key === 'string' || typeof key === 'number') && key !== '') { + return key; + } + throw new Error(`Invalid key value: '${key}'`); + } + + /** + * Returns the index of a specific key in the *source* set + */ + indexOfKey(key: KEY_TYPE): number { + this.assertKeySet(); + return this.idToIndex.get(key) ?? -1; + } + + append(value: T) { + if (this.keyAttribute) { + const key = this.getKey(value); + if (this._recordsById.has(key)) { + throw new Error(`Duplicate key: '${key}'`); + } + this._recordsById.set(key, value); + this.idToIndex.set(key, this._records.length); + } + this._records.push(value); + this.emitDataEvent({ + type: 'append', + value, + }); + } + + /** + * Updates or adds a record. Returns `true` if the record already existed. + * Can only be used if a key is used. + */ + upsert(value: T): boolean { + this.assertKeySet(); + const key = this.getKey(value); + if (this.idToIndex.has(key)) { + const idx = this.idToIndex.get(key)!; + this.update(idx, value); + return true; + } else { + this.append(value); + return false; + } + } + + update(index: number, value: T) { + if (this.keyAttribute) { + const key = this.getKey(value); + const currentKey = this.getKey(this._records[index]); + if (currentKey !== key) { + this._recordsById.delete(currentKey); + this.idToIndex.delete(currentKey); + } + this._recordsById.set(key, value); + this.idToIndex.set(key, index); + } + this._records[index] = value; + this.emitDataEvent({ + type: 'update', + value, + index, + }); + } + + /** + * Removes the first N entries. + * @param amount + */ + shift(_amount: number) { + // increase an offset variable with amount, and correct idToIndex reads / writes with that + // removes the affected records for _records, _recordsById and idToIndex + throw new Error('Not Implemented'); + } + + emitDataEvent(event: DataEvent) { + this.dataUpdateQueue.push(event); + // TODO: schedule + this.processEvents(); + } + + processEvents() { + const events = this.dataUpdateQueue.splice(0); + events.forEach((_event) => { + // TODO: + }); + } +} + +export function createDataSource( + initialSet: T[], + keyAttribute: KEY, +): DataSource>; +export function createDataSource( + initialSet?: T[], +): DataSource; +export function createDataSource( + initialSet: T[] = [], + keyAttribute?: KEY | undefined, +): DataSource { + const ds = new DataSource(keyAttribute); + initialSet.forEach((value) => ds.append(value)); + return ds; +} diff --git a/desktop/flipper-plugin/src/state/datasource/__tests__/datasource-basics.node.tsx b/desktop/flipper-plugin/src/state/datasource/__tests__/datasource-basics.node.tsx new file mode 100644 index 000000000..f0d75bdea --- /dev/null +++ b/desktop/flipper-plugin/src/state/datasource/__tests__/datasource-basics.node.tsx @@ -0,0 +1,96 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {createDataSource} from '../DataSource'; + +type Todo = { + id: string; + title: string; + done?: boolean; +}; + +const drinkCoffee: Todo = { + id: 'coffee', + title: 'drink coffee', +}; +const eatCookie: Todo = { + id: 'cookie', + title: 'eat a cookie', + done: true, +}; +const submitBug: Todo = { + id: 'bug', + title: 'submit a bug', + done: false, +}; + +test('can create a datasource', () => { + const ds = createDataSource([eatCookie]); + expect(ds.records).toEqual([eatCookie]); + + ds.append(drinkCoffee); + expect(ds.records).toEqual([eatCookie, drinkCoffee]); + + expect(() => ds.recordsById).toThrow(/Records cannot be looked up by key/); + + ds.update(1, submitBug); + expect(ds.records[1]).toBe(submitBug); +}); + +test('can create a keyed datasource', () => { + const ds = createDataSource([eatCookie], 'id'); + expect(ds.records).toEqual([eatCookie]); + + ds.append(drinkCoffee); + expect(ds.records).toEqual([eatCookie, drinkCoffee]); + + expect(ds.recordsById.get('bug')).toBe(undefined); + expect(ds.recordsById.get('cookie')).toBe(eatCookie); + expect(ds.recordsById.get('coffee')).toBe(drinkCoffee); + expect(ds.indexOfKey('bug')).toBe(-1); + expect(ds.indexOfKey('cookie')).toBe(0); + expect(ds.indexOfKey('coffee')).toBe(1); + + ds.update(1, submitBug); + expect(ds.records[1]).toBe(submitBug); + expect(ds.recordsById.get('coffee')).toBe(undefined); + expect(ds.recordsById.get('bug')).toBe(submitBug); + expect(ds.indexOfKey('bug')).toBe(1); + expect(ds.indexOfKey('cookie')).toBe(0); + expect(ds.indexOfKey('coffee')).toBe(-1); + + // upsert existing + const newBug = { + id: 'bug', + title: 'file a bug', + done: true, + }; + ds.upsert(newBug); + expect(ds.records[1]).toBe(newBug); + expect(ds.recordsById.get('bug')).toBe(newBug); + + // upsert new + const trash = { + id: 'trash', + title: 'take trash out', + }; + ds.upsert(trash); + expect(ds.records[2]).toBe(trash); + expect(ds.recordsById.get('trash')).toBe(trash); +}); + +test('throws on invalid keys', () => { + const ds = createDataSource([eatCookie], 'id'); + expect(() => { + ds.append({id: '', title: 'test'}); + }).toThrow(`Invalid key value: ''`); + expect(() => { + ds.append({id: 'cookie', title: 'test'}); + }).toThrow(`Duplicate key: 'cookie'`); +}); diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index 5556bcc65..a76810d41 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -438,6 +438,10 @@ rows.update(draft => { console.log(rows.get().length) // 2 ``` +### createDataSource + +Coming soon. + ## React Hooks ### usePlugin