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

Data Module: Adding The queried data handling into the entities abstraction #8357

Merged
merged 5 commits into from
Aug 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/client-assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ function gutenberg_register_scripts_and_styles() {
wp_register_script(
'wp-core-data',
gutenberg_url( 'build/core-data/index.js' ),
array( 'wp-data', 'wp-api-fetch', 'lodash' ),
array( 'wp-data', 'wp-api-fetch', 'wp-url', 'lodash' ),
filemtime( gutenberg_dir_path() . 'build/core-data/index.js' ),
true
);
Expand Down
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/core-data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"@babel/runtime": "^7.0.0-beta.52",
"@wordpress/api-fetch": "file:../api-fetch",
"@wordpress/data": "file:../data",
"@wordpress/url": "file:../url",
"equivalent-key-map": "^0.2.1",
"lodash": "^4.17.10",
"rememo": "^3.0.0"
},
Expand Down
21 changes: 18 additions & 3 deletions packages/core-data/src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
*/
import { castArray } from 'lodash';

/**
* Internal dependencies
*/
import {
receiveItems,
receiveQueriedItems,
} from './queried-data';

/**
* Returns an action object used in signalling that terms have been received
* for a given taxonomy.
Expand Down Expand Up @@ -56,13 +64,20 @@ export function addEntities( entities ) {
* @param {string} kind Kind of the received entity.
* @param {string} name Name of the received entity.
* @param {Array|Object} records Records received.
* @param {?Object} query Query Object.
*
* @return {Object} Action object.
*/
export function receiveEntityRecords( kind, name, records ) {
export function receiveEntityRecords( kind, name, records, query ) {
let action;
if ( query ) {
action = receiveQueriedItems( records, query );
} else {
action = receiveItems( records );
}

return {
type: 'RECEIVE_ENTITY_RECORDS',
records: castArray( records ),
...action,
kind,
name,
};
Expand Down
34 changes: 34 additions & 0 deletions packages/core-data/src/queried-data/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { castArray } from 'lodash';

/**
* Returns an action object used in signalling that items have been received.
*
* @param {Array} items Items received.
*
* @return {Object} Action object.
*/
export function receiveItems( items ) {
return {
type: 'RECEIVE_ITEMS',
items: castArray( items ),
};
}

/**
* Returns an action object used in signalling that queried data has been
* received.
*
* @param {Array} items Queried items received.
* @param {?Object} query Optional query object.
*
* @return {Object} Action object.
*/
export function receiveQueriedItems( items, query = {} ) {
return {
...receiveItems( items ),
query,
};
}
72 changes: 72 additions & 0 deletions packages/core-data/src/queried-data/get-query-parts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* WordPress dependencies
*/
import { addQueryArgs } from '@wordpress/url';

/**
* Internal dependencies
*/
import { withWeakMapCache } from '../utils';

/**
* An object of properties describing a specific query.
*
* @typedef {WPQueriedDataQueryParts}
*
* @property {number} page The query page (1-based index, default 1).
* @property {number} perPage Items per page for query (default 10).
* @property {string} stableKey An encoded stable string of all non-pagination
* query parameters.
*/

/**
* Given a query object, returns an object of parts, including pagination
* details (`page` and `perPage`, or default values). All other properties are
* encoded into a stable (idempotent) `stableKey` value.
*
* @param {Object} query Optional query object.
*
* @return {WPQueriedDataQueryParts} Query parts.
*/
export function getQueryParts( query ) {
/**
* @type {WPQueriedDataQueryParts}
*/
const parts = {
stableKey: '',
page: 1,
perPage: 10,
};

// Ensure stable key by sorting keys. Also more efficient for iterating.
const keys = Object.keys( query ).sort();

for ( let i = 0; i < keys.length; i++ ) {
const key = keys[ i ];
const value = query[ key ];

switch ( key ) {
case 'page':
case 'perPage':
parts[ key ] = Number( value );
break;

default:
// While it could be any deterministic string, for simplicity's
// sake mimic querystring encoding for stable key.
//
// TODO: For consistency with PHP implementation, addQueryArgs
// should accept a key value pair, which may optimize its
// implementation for our use here, vs. iterating an object
// with only a single key.
parts.stableKey += (
( parts.stableKey ? '&' : '' ) +
addQueryArgs( '', { [ key ]: value } ).slice( 1 )
);
}
}

return parts;
}

export default withWeakMapCache( getQueryParts );
3 changes: 3 additions & 0 deletions packages/core-data/src/queried-data/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './actions';
export * from './selectors';
export { default as reducer } from './reducer';
127 changes: 127 additions & 0 deletions packages/core-data/src/queried-data/reducer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* External dependencies
*/
import { combineReducers } from 'redux';
import { keyBy, map, flowRight } from 'lodash';

/**
* Internal dependencies
*/
import {
ifMatchingAction,
replaceAction,
onSubKey,
} from '../utils';
import getQueryParts from './get-query-parts';

/**
* Returns a merged array of item IDs, given details of the received paginated
* items. The array is sparse-like with `undefined` entries where holes exist.
*
* @param {?Array<number>} itemIds Original item IDs (default empty array).
* @param {number[]} nextItemIds Item IDs to merge.
* @param {number} page Page of items merged.
* @param {number} perPage Number of items per page.
*
* @return {number[]} Merged array of item IDs.
*/
export function getMergedItemIds( itemIds, nextItemIds, page, perPage ) {
const nextItemIdsStartIndex = ( page - 1 ) * perPage;

// If later page has already been received, default to the larger known
// size of the existing array, else calculate as extending the existing.
const size = Math.max(
itemIds.length,
nextItemIdsStartIndex + nextItemIds.length
);

// Preallocate array since size is known.
const mergedItemIds = new Array( size );

for ( let i = 0; i < size; i++ ) {
// Preserve existing item ID except for subset of range of next items.
const isInNextItemsRange = (
i >= nextItemIdsStartIndex &&
i < nextItemIdsStartIndex + nextItemIds.length
);

mergedItemIds[ i ] = isInNextItemsRange ?
nextItemIds[ i - nextItemIdsStartIndex ] :
itemIds[ i ];
}

return mergedItemIds;
}

/**
* Reducer tracking items state, keyed by ID. Items are assumed to be normal,
* where identifiers are common across all queries.
*
* @param {Object} state Current state.
* @param {Object} action Dispatched action.
*
* @return {Object} Next state.
*/
function items( state = {}, action ) {
switch ( action.type ) {
case 'RECEIVE_ITEMS':
return {
...state,
...keyBy( action.items, action.key || 'id' ),
};
}

return state;
}

/**
* Reducer tracking queries state, keyed by stable query key. Each reducer
* query object includes `itemIds` and `requestingPageByPerPage`.
*
* @param {Object} state Current state.
* @param {Object} action Dispatched action.
*
* @return {Object} Next state.
*/
const queries = flowRight( [
// Limit to matching action type so we don't attempt to replace action on
// an unhandled action.
ifMatchingAction( ( action ) => 'query' in action ),

// Inject query parts into action for use both in `onSubKey` and reducer.
replaceAction( ( action ) => {
// `ifMatchingAction` still passes on initialization, where state is
// undefined and a query is not assigned. Avoid attempting to parse
// parts. `onSubKey` will omit by lack of `stableKey`.
if ( action.query ) {
return {
...action,
...getQueryParts( action.query ),
};
}

return action;
} ),

// Queries shape is shared, but keyed by query `stableKey` part. Original
// reducer tracks only a single query object.
onSubKey( 'stableKey' ),
] )( ( state = null, action ) => {
const { type, page, perPage, key = 'id' } = action;

if ( type !== 'RECEIVE_ITEMS' ) {
return state;
}

return getMergedItemIds(
state || [],
map( action.items, key ),
page,
perPage
);
} );

export default combineReducers( {
items,
queries,
} );
83 changes: 83 additions & 0 deletions packages/core-data/src/queried-data/selectors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* External dependencies
*/
import createSelector from 'rememo';
import EquivalentKeyMap from 'equivalent-key-map';

/**
* Internal dependencies
*/
import getQueryParts from './get-query-parts';

/**
* Cache of state keys to EquivalentKeyMap where the inner map tracks queries
* to their resulting items set. WeakMap allows garbage collection on expired
* state references.
*
* @type {WeakMap<Object,EquivalentKeyMap>}
*/
const queriedItemsCacheByState = new WeakMap();

/**
* Returns items for a given query, or null if the items are not known.
*
* @param {Object} state State object.
* @param {?Object} query Optional query.
*
* @return {?Array} Query items.
*/
function getQueriedItemsUncached( state, query ) {
const { stableKey, page, perPage } = getQueryParts( query );
if ( ! state.queries[ stableKey ] ) {
return null;
}

const itemIds = state.queries[ stableKey ];
if ( ! itemIds ) {
return null;
}

const startOffset = ( page - 1 ) * perPage;
const endOffset = Math.min(
startOffset + perPage,
itemIds.length
);

const items = [];
for ( let i = startOffset; i < endOffset; i++ ) {
const itemId = itemIds[ i ];
items.push( state.items[ itemId ] );
}

return items;
}

/**
* Returns items for a given query, or null if the items are not known. Caches
* result both per state (by reference) and per query (by deep equality).
* The caching approach is intended to be durable to query objects which are
* deeply but not referentially equal, since otherwise:
*
* `getQueriedItems( state, {} ) !== getQueriedItems( state, {} )`
*
* @param {Object} state State object.
* @param {?Object} query Optional query.
*
* @return {?Array} Query items.
*/
export const getQueriedItems = createSelector( ( state, query = {} ) => {
let queriedItemsCache = queriedItemsCacheByState.get( state );
if ( queriedItemsCache ) {
const queriedItems = queriedItemsCache.get( query );
if ( queriedItems !== undefined ) {
return queriedItems;
}
} else {
queriedItemsCache = new EquivalentKeyMap();
queriedItemsCacheByState.set( state, queriedItemsCache );
}

const items = getQueriedItemsUncached( state, query );
queriedItemsCache.set( query, items );
return items;
} );
Loading