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

Initial entities implementation #6462

Merged
merged 13 commits into from
May 2, 2018
Merged

Initial entities implementation #6462

merged 13 commits into from
May 2, 2018

Conversation

youknowriad
Copy link
Contributor

@youknowriad youknowriad commented Apr 27, 2018

This PR tries to implement the idea of an entities abstraction. An entity is an abstraction of a WP entity (post, taxonomy, postType, category...). It allows to automatically generate selectors, resolvers for the said entity in the core data module.

In this PR, it's a basic implementation:

  • Entities are static (could be enhanced to be made dynamic)
  • It supports only fetching by PK (could be easily extended to add more selectors)
  • I'm using it for the postType and media entities at the moment.

This PR is blocking #6384 and is related to #6395

@youknowriad youknowriad added the Framework Issues related to broader framework topics, especially as it relates to javascript label Apr 27, 2018
@youknowriad youknowriad self-assigned this Apr 27, 2018
@youknowriad youknowriad requested review from aduth and a team April 27, 2018 07:36
yield receivePostTypes( postType );
export async function* getModelRecord( state, kind, name, primaryKey ) {
const modelConfig = getModel( kind, name );
const record = await apiRequest( { path: `${ modelConfig.baseUrl }/${ primaryKey }?context=edit` } );
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we should allow model to configure the request as some models probably don't require context=edit.

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 tend to think we should always use the edit context (or remove the context concept entirely) but yeah let's keep this way and see if we need to make it configurable as we add models.

Copy link
Member

Choose a reason for hiding this comment

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

I'd also like to remove it entirely. At least from this abstraction.

@jorgefilipecosta
Copy link
Member

jorgefilipecosta commented Apr 28, 2018

Regarding the functional point of view, these changes test well, awesome work 👍
I like this introduction of the model's concept as it could allow us to reduce duplicate code in actions, resolvers, and selectors.
I'm curious about the future plans. Is the idea to involve this model concept? E.g: allow us to mutate and change models besides just retrieving requests? Should we allow the automatic generation of the general request that requests all records e.g: getPostTypes() that requests '/wp/v2/types/', (with opt-in or opt-out flag in the model to enable or disable this). This would allow us to not repeat requests if the "general request" was executed before and now we want a specific record that was already retrieved in the "general request".

@youknowriad
Copy link
Contributor Author

I'm curious about the future plans. Is the idea to involve this model concept? E.g: allow us to mutate and change models besides just retrieving requests?

Yes exactly, mutation, pagination, query fetching...

This would allow us to not repeat requests if the "general request" was executed before and now we want a specific record that was already retrieved in the "general request".

General request => yes, this just sets the baseline but I don't think we should use it to retrieve a specific record because of pagination.

};
}
function model( modelConfig ) {
return ( state = { byPK: {} }, action ) => {
Copy link
Member

@gziolo gziolo Apr 30, 2018

Choose a reason for hiding this comment

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

Can we avoid using PK abbreviation and use consistently PrimaryKey? :D

import { find } from 'lodash';

const models = [
{ name: 'postType', kind: 'root', pk: 'slug', baseUrl: '/wp/v2/types' },
Copy link
Member

Choose a reason for hiding this comment

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

It should be type rather than kind for consistency. I guess this is because of the fact that type is a reserved word in actions, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes it's one of the reasons. Also, type is overused :) so we could have a postype of type type :)

Copy link
Member

Choose a reason for hiding this comment

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

Can we have a post type of kind type? :D

@gziolo
Copy link
Member

gziolo commented Apr 30, 2018

This is cool 👍

@youknowriad
Copy link
Contributor Author

How can we move forward with this, I'd love to merge it to unblock #6384

@youknowriad
Copy link
Contributor Author

@aduth I'd appreciate your thoughts on this one

@aduth
Copy link
Member

aduth commented Apr 30, 2018

and is related to #6395

Can you elaborate on in what way this relates to #6395 ? Do they complement or conflict with one another?

Am planning to take a closer look this evening.

@aduth
Copy link
Member

aduth commented Apr 30, 2018

What exactly is kind ?

}

const modelsByKind = groupBy( modelsConfig, 'kind' );
export const models = combineReducers( Object.entries( modelsByKind ).reduce( ( memo, [ kind, subModels ] ) => {
Copy link
Member

Choose a reason for hiding this comment

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

This whole sequence of logic could do for some explaining variables, newlines, comments. Quite difficult to interpret.

*/
export function getPostType( state, slug ) {
Copy link
Member

Choose a reason for hiding this comment

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

I assume we have some using the existing selector to be updated (work-in-progress) ?

Copy link
Member

Choose a reason for hiding this comment

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

This PR now generates dynamically the selector in core-data/model-selectors.js. So everything keeps working as before :)

{ name: 'postType', kind: 'root', primaryKey: 'slug', baseUrl: '/wp/v2/types' },
];

export function getModel( kind, name ) {
Copy link
Member

Choose a reason for hiding this comment

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

I think you'd at one point floated the idea to me that this configuration could live in state, which would enable others to more easily extend with their own models, and might make it align better with existing concepts like getting via selector, not the standalone utility function here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes true, I didn't want to build a huge PR directly on the first iteration, I do think we need dynamic models, I think it's needed in #6384 but was thinking of adding those in another iteration.

import { find } from 'lodash';

const models = [
{ name: 'postType', kind: 'root', primaryKey: 'slug', baseUrl: '/wp/v2/types' },
Copy link
Member

Choose a reason for hiding this comment

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

I don't know that it's an issue, but I could foresee some confusion on primaryKey naming with how it might be confused or misinterpreted as the database concept, where in the case of post types there is no database entity, and for others we might choose a key which isn't actually the primary key for the database entity. Maybe just me?

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 don't know, I've always used primaryKey personally but I can update to identifier if you think it's better.

Copy link
Member

@aduth aduth May 1, 2018

Choose a reason for hiding this comment

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

Maybe just key ? Or keyBy (#) ?

@@ -0,0 +1,19 @@
/**
Copy link
Member

Choose a reason for hiding this comment

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

Should we organize models-related files into their own folder if there's several related ones which stand apart from the rest of the files?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Move those to the index.js. It's not easy to find a good location without creating cycle dependencies.

import modelsConfig from './models';
import { getModelRecord } from './resolvers';

export default modelsConfig.reduce( ( memo, { kind, name } ) => {
Copy link
Member

Choose a reason for hiding this comment

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

Minor: Just noting that this is a runtime evaluation which could theoretically be pre-evaluated (the sort of thing prepack would love to optimize for).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Haha, are you suggesting we try prepack :) I haven't played with it yet

Copy link
Member

@aduth aduth May 1, 2018

Choose a reason for hiding this comment

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

Haha, are you suggesting we try prepack :)

Nah, not necessarily. Just remarking that it's wasted work that could be pregenerated.

There's also this, which might be more of a viable option if it's really a concern: https://github.com/kentcdodds/babel-plugin-preval

};
}
function model( modelConfig ) {
return ( state = { byPrimaryKey: {} }, action ) => {
Copy link
Member

Choose a reason for hiding this comment

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

What other properties do you foresee living in this state object?

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 thinking queries maybe, but it could be added later, I can update this PR to remove this key at the moment.

@youknowriad
Copy link
Contributor Author

What exactly is kind ?

Kind is just a way to group models to avoid duplication, I see these as possible values: root, postType, taxonomy.

This will avoid ambiguity in the reducers and at the selectors level.

How it relates to #6395

I believe there a conflict in the implementation details but they share similar goes. Instead of having helpers to create reducers/selectors/actions... the models abstraction generate them. #6395 adds the queries data though, and I think this could be refactored to be included in the models abstraction. (each model will generate its queries sub state and its selectors).

@youknowriad youknowriad changed the title Initial models implementation Initial entities implementation May 2, 2018
const postType = await apiRequest( { path: `/wp/v2/types/${ slug }?context=edit` } );
yield receivePostTypes( postType );
export async function* getEntityRecord( state, kind, name, key ) {
const entity = getEntity( kind, name );
Copy link
Member

@gziolo gziolo May 2, 2018

Choose a reason for hiding this comment

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

What does entity contain, is it config? It looks like it. Should we rename to:

const entityConfig = getEntityConfig( kind, name );

to make it easier to follow?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we use entityConfig it means we need an entity too, but we don't have such a thing.

I think it's just entity. I've used entityConfig in the reducer to avoid variable shadowing.

@youknowriad
Copy link
Contributor Author

In the last commit 9609b27 I've updated the media to use the abstraction.

@youknowriad
Copy link
Contributor Author

I feel it's pretty solid for a V1, let me know how to move this forward.

import entities from './entities';

const entityResolvers = entities.reduce( ( memo, { kind, name } ) => {
const kindPrefix = kind === 'root' ? '' : upperFirst( camelCase( kind ) );
Copy link
Member

@gziolo gziolo May 2, 2018

Choose a reason for hiding this comment

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

I would love to see the following method to avoid code duplication and ensure that both selector and resolver names always match:

const getMethodName = ( kind, name ) => {
    const kindPrefix = kind === 'root' ? '' : upperFirst( camelCase( kind ) );
    const nameSuffix = upperFirst( camelCase( name ) );
    return `get${ kindPrefix }${ nameSuffix }`;
};

This logic is quite complicated and essential to have it working properly for all selectors so it would be great to have a simple unit test which ensures it never regresses.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good suggestion, updating.

Copy link
Member

Choose a reason for hiding this comment

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

or:

const generateEntityMethods = ( source) => {
	const entityResolvers = entities.reduce( ( memo, { kind, name } ) => {
		const kindPrefix = kind === 'root' ? '' : upperFirst( camelCase( kind ) );
		const nameSuffix = upperFirst( camelCase( name ) );
		return {
			...memo,
			[ `get${ kindPrefix }${ nameSuffix }` ]: ( state, key ) => source.getEntityRecord( state, kind, name, key ),
		};
	}, {} );

const entityResolvesr = generateEntityMethods( resolvers );
const entitySelectors = generateEntityMethods( selectors );

Copy link
Member

@gziolo gziolo left a comment

Choose a reason for hiding this comment

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

I'm giving 👍 to the implementation. I recommend waiting for another opinion before proceeding given the impact this PR is going to have. I personally love the direction and flexibility this PR introduces. In particular, this will make it very easy to have those entities extensible because of how those definitions are stored in one file: https://github.com/WordPress/gutenberg/blob/9609b27b885598deb495369f28bd19c11c7023a2/core-data/entities.js.

import { find, upperFirst, camelCase } from 'lodash';

const entities = [
{ name: 'postType', kind: 'root', key: 'slug', baseUrl: '/wp/v2/types' },
Copy link
Member

Choose a reason for hiding this comment

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

Could kind just be optional rather than having an explicit 'root' value? Or does that make the implementation more complex to handle the fact it's optional?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In several methods, the kind is the first parameter before the name and other optional parameters (think query, for instance). I'd love to get rid of the kind but I fear these methods arguments won't be that easy to handle in that case. getEntityRecords( null, 'postType', query )

* @return {Object} Entity
*/
export function getEntity( kind, name ) {
return find( entities, ( entity ) => entity.kind === kind && entity.name === name );
Copy link
Member

Choose a reason for hiding this comment

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

Nit: Object predicate could make this nice:

return find( entities, { kind, name } );

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 though these shortcuts were getting deprecated from lodash?

Copy link
Member

@aduth aduth May 2, 2018

Choose a reason for hiding this comment

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

As I understand it, they are on the roadmap to be excluded by default in 5.0.0 .

Remove shorthand support by default

https://github.com/lodash/lodash/wiki/Roadmap

Doesn't mean they won't exist as an option. I'm personally a fan, and would opt for it.

* @return {string} Method name
*/
export const getMethodName = ( kind, name ) => {
const kindPrefix = kind === 'root' ? '' : upperFirst( camelCase( kind ) );
Copy link
Member

Choose a reason for hiding this comment

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

root optional might actually make this simpler.

@@ -10,12 +10,29 @@ import reducer from './reducer';
import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';
import { default as entities, getMethodName } from './entities';

const entityResolvers = entities.reduce( ( memo, { kind, name } ) => {
Copy link
Member

Choose a reason for hiding this comment

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

Personally I've stopped using the name memo because it seems to be common for it to be unfamiliar. Not sure if you feel strongly about it one way or the other.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What's your current way of doing this? do you use a specific name, like resolvers here for instance?

Copy link
Member

Choose a reason for hiding this comment

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

I tend toward result, though I'm not strongly committed to it.

const entityResolvers = entities.reduce( ( memo, { kind, name } ) => {
const methodName = getMethodName( kind, name );
return {
...memo,
Copy link
Member

Choose a reason for hiding this comment

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

Minor (Perf): As implemented, we're creating a shallow clone on each iteration, when we could be mutating directly.

};
}, {} );

const entitySelectors = entities.reduce( ( memo, { kind, name } ) => {
Copy link
Member

@aduth aduth May 2, 2018

Choose a reason for hiding this comment

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

My DRY senses are tingling 😄

Maybe:

const createEntityRecordGetter = ( source ) => entities.reduce( ( result, entity ) => {
	const { kind, name } = entity;
	const methodName = getMethodName( kind, name );
	result[ methodName ] = ( state, key ) => source.getEntityRecord( state, kind, name, key );
	return result;
} );

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 know me too, but at some point, I thought they could diverge (have more selectors than resolvers ). Of course, we can update later when they diverge.

Copy link
Member

Choose a reason for hiding this comment

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

I think I saw a similar comment earlier today 😄

Copy link
Member

Choose a reason for hiding this comment

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

Oof, the "Show outdated" toggles in GitHub are really easy to miss. 😬

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since you both asked for it, I executed :)


return state;
const key = entityConfig.key || 'id';
Copy link
Member

Choose a reason for hiding this comment

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

Minor (Perf): We don't need to derive this on every dispatch. It can be in the surrounding function scope.

@youknowriad
Copy link
Contributor Author

Thanks all for your help on this PR

@youknowriad youknowriad merged commit 239de2e into master May 2, 2018
@youknowriad youknowriad deleted the try/models branch May 2, 2018 14:50
@youknowriad youknowriad added this to the 2.8 milestone May 2, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Framework Issues related to broader framework topics, especially as it relates to javascript
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants