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

Core Data: Implement _fields data reuse for entities #19498

Merged
merged 5 commits into from
Aug 24, 2020
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
23 changes: 21 additions & 2 deletions docs/designers-developers/developers/data/data-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,18 +152,21 @@ _Returns_

<a name="getEntityRecord" href="#getEntityRecord">#</a> **getEntityRecord**

Returns the Entity's record object by key.
Returns the Entity's record object by key. Returns `null` if the value is not
yet received, undefined if the value entity is known to not exist, or the
entity object if it exists and is received.

_Parameters_

- _state_ `Object`: State tree
- _kind_ `string`: Entity kind.
- _name_ `string`: Entity name.
- _key_ `number`: Record's key
- _query_ `?Object`: Optional query.

_Returns_

- `?Object`: Record.
- `(?Object|null)`: Record.

<a name="getEntityRecordEdits" href="#getEntityRecordEdits">#</a> **getEntityRecordEdits**

Expand Down Expand Up @@ -348,6 +351,22 @@ _Returns_

- `boolean`: Whether the entity record has edits or not.

<a name="hasEntityRecords" href="#hasEntityRecords">#</a> **hasEntityRecords**

Returns true if records have been received for the given set of parameters,
or false otherwise.

_Parameters_

- _state_ `Object`: State tree
- _kind_ `string`: Entity kind.
- _name_ `string`: Entity name.
- _query_ `?Object`: Optional terms query.

_Returns_

- `boolean`: Whether entity records have been received.

<a name="hasFetchedAutosaves" href="#hasFetchedAutosaves">#</a> **hasFetchedAutosaves**

Returns true if the REST request for autosaves has completed.
Expand Down
23 changes: 21 additions & 2 deletions packages/core-data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,18 +389,21 @@ _Returns_

<a name="getEntityRecord" href="#getEntityRecord">#</a> **getEntityRecord**

Returns the Entity's record object by key.
Returns the Entity's record object by key. Returns `null` if the value is not
yet received, undefined if the value entity is known to not exist, or the
entity object if it exists and is received.

_Parameters_

- _state_ `Object`: State tree
- _kind_ `string`: Entity kind.
- _name_ `string`: Entity name.
- _key_ `number`: Record's key
- _query_ `?Object`: Optional query.

_Returns_

- `?Object`: Record.
- `(?Object|null)`: Record.

<a name="getEntityRecordEdits" href="#getEntityRecordEdits">#</a> **getEntityRecordEdits**

Expand Down Expand Up @@ -585,6 +588,22 @@ _Returns_

- `boolean`: Whether the entity record has edits or not.

<a name="hasEntityRecords" href="#hasEntityRecords">#</a> **hasEntityRecords**

Returns true if records have been received for the given set of parameters,
or false otherwise.

_Parameters_

- _state_ `Object`: State tree
- _kind_ `string`: Entity kind.
- _name_ `string`: Entity name.
- _query_ `?Object`: Optional terms query.

_Returns_

- `boolean`: Whether entity records have been received.

<a name="hasFetchedAutosaves" href="#hasFetchedAutosaves">#</a> **hasFetchedAutosaves**

Returns true if the REST request for autosaves has completed.
Expand Down
26 changes: 21 additions & 5 deletions packages/core-data/src/queried-data/get-query-parts.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@ import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import { withWeakMapCache } from '../utils';
import { withWeakMapCache, getNormalizedCommaSeparable } from '../utils';

/**
* An object of properties describing a specific query.
*
* @typedef {Object} 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.
* @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, non-fields query parameters.
* @property {?(string[])} fields Target subset of fields to derive from
* item objects.
* @property {?(number[])} include Specific item IDs to include.
*/

/**
Expand All @@ -36,6 +39,8 @@ export function getQueryParts( query ) {
stableKey: '',
page: 1,
perPage: 10,
fields: null,
include: null,
};

// Ensure stable key by sorting keys. Also more efficient for iterating.
Expand All @@ -49,10 +54,21 @@ export function getQueryParts( query ) {
case 'page':
parts[ key ] = Number( value );
break;

case 'per_page':
parts.perPage = Number( value );
break;

case 'include':
parts.include = getNormalizedCommaSeparable( value ).map(
Number
);
break;

case '_fields':
parts.fields = getNormalizedCommaSeparable( value );
break;

default:
// While it could be any deterministic string, for simplicity's
// sake mimic querystring encoding for stable key.
Expand Down
42 changes: 42 additions & 0 deletions packages/core-data/src/queried-data/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,47 @@ function items( state = {}, action ) {
return state;
}

/**
* Reducer tracking item completeness, keyed by ID. A complete item is one for
* which all fields are known. This is used in supporting `_fields` queries,
* where not all properties associated with an entity are necessarily returned.
* In such cases, completeness is used as an indication of whether it would be
* safe to use queried data for a non-`_fields`-limited request.
*
* @param {Object<string,boolean>} state Current state.
* @param {Object} action Dispatched action.
*
* @return {Object<string,boolean>} Next state.
*/
export function itemIsComplete( state = {}, action ) {
const { type, query, key = DEFAULT_ENTITY_KEY } = action;
if ( type !== 'RECEIVE_ITEMS' ) {
return state;
}

// An item is considered complete if it is received without an associated
// fields query. Ideally, this would be implemented in such a way where the
// complete aggregate of all fields would satisfy completeness. Since the
// fields are not consistent across all entity types, this would require
// introspection on the REST schema for each entity to know which fields
// compose a complete item for that entity.
const isCompleteQuery =
! query || ! Array.isArray( getQueryParts( query ).fields );

return {
...state,
...action.items.reduce( ( result, item ) => {
const itemId = item[ key ];

// Defer to completeness if already assigned. Technically the
// data may be outdated if receiving items for a field subset.
result[ itemId ] = state[ itemId ] || isCompleteQuery;

return result;
}, {} ),
};
}

/**
* Reducer tracking queries state, keyed by stable query key. Each reducer
* query object includes `itemIds` and `requestingPageByPerPage`.
Expand Down Expand Up @@ -171,5 +212,6 @@ const queries = ( state = {}, action ) => {

export default combineReducers( {
items,
itemIsComplete,
queries,
} );
56 changes: 51 additions & 5 deletions packages/core-data/src/queried-data/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,23 @@ const queriedItemsCacheByState = new WeakMap();
* @return {?Array} Query items.
*/
function getQueriedItemsUncached( state, query ) {
const { stableKey, page, perPage } = getQueryParts( query );
const { stableKey, page, perPage, include, fields } = getQueryParts(
query
);

if ( ! state.queries[ stableKey ] ) {
return null;
let itemIds;
if ( Array.isArray( include ) && ! stableKey ) {
// If the parsed query yields a set of IDs, but otherwise no filtering,
// it's safe to consider targeted item IDs as the include set. This
// doesn't guarantee that those objects have been queried, which is
// accounted for below in the loop `null` return.
itemIds = include;
// TODO: Avoid storing the empty stable string in reducer, since it
// can be computed dynamically here always.
} else if ( state.queries[ stableKey ] ) {
itemIds = state.queries[ stableKey ];
}

const itemIds = state.queries[ stableKey ];
if ( ! itemIds ) {
return null;
}
Expand All @@ -47,7 +57,43 @@ function getQueriedItemsUncached( state, query ) {
const items = [];
for ( let i = startOffset; i < endOffset; i++ ) {
const itemId = itemIds[ i ];
items.push( state.items[ itemId ] );
if ( Array.isArray( include ) && ! include.includes( itemId ) ) {
continue;
}

if ( ! state.items.hasOwnProperty( itemId ) ) {
return null;
}

const item = state.items[ itemId ];

let filteredItem;
if ( Array.isArray( fields ) ) {
filteredItem = {};

for ( let f = 0; f < fields.length; f++ ) {
// Abort the entire request if a field is missing from the item.
// This accounts for the fact that queried items are stored by
// stable key without an associated fields query. Other requests
// may have included fewer fields properties.
const field = fields[ f ];
if ( ! item.hasOwnProperty( field ) ) {
return null;
}

filteredItem[ field ] = item[ field ];
}
} else {
// If expecting a complete item, validate that completeness, or
// otherwise abort.
if ( ! state.itemIsComplete[ itemId ] ) {
return null;
}

filteredItem = item;
}

items.push( filteredItem );
}

return items;
Expand Down
34 changes: 34 additions & 0 deletions packages/core-data/src/queried-data/test/get-query-parts.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,32 @@ describe( 'getQueryParts', () => {
page: 2,
perPage: 2,
stableKey: '',
fields: null,
include: null,
} );
} );

it( 'parses out `include` ID filtering', () => {
const parts = getQueryParts( { include: [ 1 ] } );

expect( parts ).toEqual( {
page: 1,
perPage: 10,
stableKey: '',
fields: null,
include: [ 1 ],
} );
} );

it( 'parses out `_fields` property filtering', () => {
const parts = getQueryParts( { _fields: 'content', a: 1 } );

expect( parts ).toEqual( {
page: 1,
perPage: 10,
stableKey: 'a=1',
fields: [ 'content' ],
include: null,
} );
} );

Expand All @@ -23,6 +49,8 @@ describe( 'getQueryParts', () => {
page: 1,
perPage: 10,
stableKey: '%3F=%26&b=2',
fields: null,
include: null,
} );
} );

Expand All @@ -33,6 +61,8 @@ describe( 'getQueryParts', () => {
page: 1,
perPage: 10,
stableKey: 'a%5B0%5D=1&a%5B1%5D=2',
fields: null,
include: null,
} );
} );

Expand All @@ -45,6 +75,8 @@ describe( 'getQueryParts', () => {
page: 1,
perPage: 10,
stableKey: 'b=2',
fields: null,
include: null,
} );
} );

Expand All @@ -55,6 +87,8 @@ describe( 'getQueryParts', () => {
page: 1,
perPage: -1,
stableKey: 'b=2',
fields: null,
include: null,
} );
} );
} );
Loading