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

Pagination idea: type-specific refetching plugins #26

Closed
stubailo opened this issue Mar 24, 2016 · 19 comments
Closed

Pagination idea: type-specific refetching plugins #26

stubailo opened this issue Mar 24, 2016 · 19 comments

Comments

@stubailo
Copy link
Contributor

stubailo commented Mar 24, 2016

In some sense, handling pagination in a smart way is just a special case of "when you ask for data, treat certain fields in a special way because we know that the arguments are meaningful." So if we're looking at a Relay spec paginated list:

// first fetch
{
  user {
    id
    name
    friends(first: 10) {
      edges {
        cursor
        node {
          id
          name
        }
      }
      pageInfo {
        hasNextPage
      }
    }
  }
}

// new query
... same stuff
friends(first: 20)

// second fetch, doesn't fetch all 20 but uses the existing information
... same stuff
friends(first: 10, after: "lastCursor")

Notice how easy it is for us as humans to imagine what data needs to be fetched to satisfy the new query.

Here's a set of hypotheses:

  1. The initial fetch doesn't need any up-front information about types or pagination - it can look at the query result to know what to do, as long as we inject __typename fields where necessary.
  2. The re-fetch can determine the new query by the information in the store, which now has type annotations and the arguments from the new query
  3. The transformation from (2) can be written as a pure function that can be injected into the apollo client and associated with certain type names

Basically, you could write a function and tell the apollo client:

"When refetching any field which refers to an object with a type matching this regular expression, give me the query you were going to fetch, and the contents of the store, and I'll give you a new query that says how to fetch the missing data."

So, for Relay pagination, you'd say:

client.registerPaginationPlugin(/.+Connection/, ({ state, id, selectionSet }) => {
  ... do work ...

  return {
    // as much of the result as can be found
    result,

    // if empty, then the cache is sufficient and result contains the data.
    // otherwise, an array of queries that need to be fetched
    missingSelectionSets, 
  }
});

Ideally this will allow plugging in to different pagination systems, as long as the paginated fields have predictable type names, for example *PaginatedList or Paginated*.

If we can do this, it will achieve some really nice effects:

  1. You don't necessarily need to use the Relay pagination spec, which can be hard to translate to some REST APIs
  2. The store can avoid being concerned with pagination if the API above is indeed sufficient

This is just a very ambitious idea, and I'm eager to determine if this very simple model can actually handle all cases of pagination and Relay connections in particular. More analysis to come soon.

@jbaxleyiii @helfer curious what you think about this.

@stubailo stubailo changed the title Pagination approach: type refetching plugins Pagination idea: type-specific refetching plugins Mar 24, 2016
@stubailo stubailo added the idea label Mar 24, 2016
@jbaxleyiii
Copy link
Contributor

@stubailo I'm a big fan of the flexibility of this method. We will be using what we have of apollo-client in an app this week that has pagination. I'll report back what usage on a non relay spec server is like.

cc @johnthepink

@stubailo
Copy link
Contributor Author

What do you mean by this week? :P that sounds pretty soon!

@helfer
Copy link
Contributor

helfer commented Mar 25, 2016

I think these kinds of plugins are a great idea! The thing to keep in mind is that we might have to be more careful with garbage collection in the case where newer queries rely on the data from no longer active queries still being in the store. Somehow we'd have to know what data the plugins are extracting from the store.
It might not be necessary for pagination, but you could imagine other plugins that know for example that the result of { base_amount, accrued_interest(after_years: 5) } is enough to calculate { base_amount, accrued_interest(after_years: 6) }.
If the first query is no longer active and the garbage collection doesn't know that the second query is now using that data, we'd be fetching extra data. Probably not a huge deal in this case, but something to keep in mind.
Of course this is a completely fictional example, but stuff like this should remain a possibility.

@stubailo
Copy link
Contributor Author

BTW this issue blocks on #42

@stubailo stubailo modified the milestone: alpha Mar 28, 2016
@stubailo stubailo added feature and removed idea labels Apr 7, 2016
@stubailo stubailo removed this from the alpha milestone Apr 7, 2016
@stubailo
Copy link
Contributor Author

Assigning to Martijn to get his feedback!

@stubailo
Copy link
Contributor Author

Rather than having regex on type, we could do something similar to ESLint rules, which specify which AST nodes they are interested in: https://github.com/apollostack/eslint-plugin-graphql/blob/545fcecc8476a13c8d12291cd7fc8924a366178a/src/index.js#L25

@deoqc
Copy link

deoqc commented May 16, 2016

Any updates in pagination? I'm using a custom solution, without a very good result.

Really hoping to have an Apollo solution to this.


Pagination (see bellow) is as high-order element that initialize/fetch data on mount, delete on unmount, and expose 2 props to its children:

  • data with the query result
  • addFetch to call when needing to fetch more data (I use it in react native ListView in onEndReached).

Problems:

  • Duplicated data: I actually have a reducer pagination in my redux store and aggregate the results of pagination fetching. This is only temporary since I destroy on unmount and trust the apollo client query to memoize results.
  • Performance: I'm feeling its pain and believe its because of the duplicate the data fetching redux logic (first apollo query, then I use the result and aggregate in pagination.[config.name] branch in store. I'm using immutablejs in the store.

Here is the complete gist, and bellow the main parts and some explanation.

const query = `
  query doSomeQuery(..., $first: Int!, after: String) { # --> must accept these two parameters (after needs to allow null)
    viewer {
      nest {
         myPaginated($first: Int!, after: String) {
           edges { # --> data array must be in edges
             node {
               ...
             }
           }
           pageInfo {  #  --> must have a pageInfo with at least these two fields
             hasNextPage
             endCursor
           }
         }
       }
    }
  }
`

Now I can abstract the pagination to a custom connector:

const variables = (state, ownProps) => ({
  // custom variables: will fixed after first call
});

const config = {
  name: 'myUniquePaginationName',
  query,
  path: ['viewer', 'nest', 'myPagination'], // need to now where the pagination part is so I aggregate data
  initCount: 10, // $first parameter in initial fetching
  addCount: 10, // $first parameter in adicional fetching
  variables,
};

const ComponentWithPagination = Pagination(config)(Component);

@stubailo
Copy link
Contributor Author

We're going to start working on pagination full-time soon, I'll take a deeper look at this then!

@jbaxleyiii
Copy link
Contributor

@stubailo is there any progress / recommendations for pagination?

@stubailo
Copy link
Contributor Author

Ah, sorry, not yet - we needed to do batching first because it was necessary for Galaxy. I'm not sure that the query diffing approach for pagination is the right one, I think I actually prefer something more like addFetch. @jbaxleyiii do you guys have a big paginated view in your app? It would be good to take a look at the needs there and see how it would work with different designs.

@jbaxleyiii
Copy link
Contributor

@stubailo we do! can we schedule a call?

@abhiaiyer91
Copy link
Contributor

Let's write a spec for this? Should we rely on the view frameworks to power this experience for us? If so, let's write specs for the view layers we support now

@dbx834
Copy link

dbx834 commented Jul 15, 2016

Hello,

After having recently decided to jump on the Apollo ship, I've been teaching myself Apollo. Please see http://sandbox.kāla.com/data-emulation.

Was looking the best way to go about pagination and stumbled across this. I've tried out something similar to what @deoqc has done. Do you think is it a good idea to make some sort of a wrapper for this? So that pagination (and other repetitive and often used functions like sort, filter, etc) can be quickly implemented for any data.

How are the Apollo developers going to implement pagination?

Also, what is the status for stuff like filtering, sorting and searching? Will Apollo have some support for that, or will we have to write our own stuff?

See graphql/graphql-relay-js#20 in this context.

Thanks!

@dbx834
Copy link

dbx834 commented Jul 25, 2016

Hello again,

Here's another solution that works fairly well with RESTful APIs,

I have been able to do all sorts of things - sort, filter, sort on particular columns, text-search using this method.

Server,

import { createApolloServer } from 'meteor/apollo';
import { HTTP } from 'meteor/http';
import _ from 'lodash';
import randomstring from 'randomstring';
import numeral from 'numeral';
import cache from 'memory-cache';

// ----------------------------------------------------------------------- API Adapter

/**
* @summary Adapter, connects to the API
*/
class Adapter {

  /**
  * @summary constructor, define default stuff
  */
  constructor() {
    this.configuration = { // Declare default values here or in the resolver
      currentPage: '1', // The first query will always show the first page
    };
  }

  /**
  * @summary callApi, calls the API
  * @param {string} url, the URL
  * @returns {object}, the response
  */
  callApi(url) {
    try {
      const apiResponse = HTTP.get(url);
      const returnObject = {
        count: null,
        data: [],
      };
      _.each(apiResponse.data.results, function (row) {
        returnObject.data.push({
          id: randomstring.generate(),
          name: row.name,
          diameter: row.diameter,
          rotationPeriod: row.rotation_period,
          orbitalPeriod: row.orbital_period,
          gravity: row.gravity.replace('standard', '').trim(),
          population: (row.population === 'unknown' ? row.population : numeral(parseInt(row.population, 10)).format('0a')),
          climate: row.climate,
          terrain: row.terrain,
          surfaceWater: row.surface_water,
        });
      });
      returnObject.count = apiResponse.data.count;
      return returnObject;
    } catch (error) {
      console.log(error);
      return null;
    }
  }

  /**
  * @summary configure the API
  * @param {object} args, arguments
  * @returns {object} this.configuration, returns the configuration
  */
  configure(args) {
    this.configuration.currentPage = args.currentPage;

    // Just an example. Anything can be returned to the client in the conf object
    let metaPlanetsTotalRecords = cache.get('metaPlanetsTotalRecords');
    if (!metaPlanetsTotalRecords) {
      metaPlanetsTotalRecords = this.callApi('http://swapi.co/api/planets/?page=1').count; // Get counts anyhow, this is used client-side to determine how many pages there will be
      cache.put('metaPlanetsTotalRecords', metaPlanetsTotalRecords, (60 * 60 * 1000) /* Keep this in memory of one hour */);
    }
    this.configuration.totalRecords = metaPlanetsTotalRecords;

    return this.configuration;
  }

  /**
  * @summary fetch from remote
  * @returns {object} data, returns the data from remote API call, or returns null in case of error
  */
  fetch() {
    return this.callApi(`http://swapi.co/api/planets/?page=${this.configuration.currentPage}`).data;
  }
}

const API = new Adapter();

// ----------------------------------------------------------------------- Schema

const schema = [`
type Planet {
  id: String
  name: String
  diameter: String
  gravity: String
  climate: String
  terrain: String
  rotationPeriod: String
  population: String
  orbitalPeriod: String
  surfaceWater: String
}
type MetaPlanets {
  planets: [Planet]
  totalRecords: String
  currentPage: String
}
type Query {
  planets: [Planet]
  metaPlanets(currentPage: String): MetaPlanets
}
schema {
  query: Query
}
`];

// ----------------------------------------------------------------------- Resolvers

const resolvers = {
  Query: {
    planets() {
      return API.fetch();
    },
    metaPlanets(root, { currentPage = '1' } = {}) { // Declare default values here or in the Adapter
      return API.configure({ currentPage });
    },
  },
  MetaPlanets: {
    planets() {
      return API.fetch();
    },
  },
};

createApolloServer({
  graphiql: true,
  pretty: true,
  schema,
  resolvers,
});

And, in client,

import React from 'react';
import { connect } from 'react-apollo';
import { createContainer } from 'meteor/react-meteor-data';
import gql from 'graphql-tag';

import { Table } from 'meteor/sandbox:lib-duplicate';

// ----------------------------------------------------------------------- Component JSS Stylesheet

... // Removed for brevity

// ----------------------------------------------------------------------- Table definitions

const columns = [{
  title: 'Name',
  key: 'name',
  render: (record) => {
    return (
      <span>{record.name}</span>
    );
  },
}, ... // Removed for brevity];

// ----------------------------------------------------------------------- Component

/**
 * @summary DataEmulationPaginate
 * @returns {object} Renders a DOM Object
 */
class DataEmulationPaginate extends React.Component {

  /**
   * @summary constructor function
   * @param {object} props, component properties
   * @returns {object} null
   */
  constructor(props) {
    super(props);

    // Link functions
    this.onPageChange = this.onPageChange.bind(this);
  }

  /**
   * @summary onPageChange function, what to do on page change?
   * @returns {object} null
   */
  onPageChange(page) {
    console.log(`On page: ${page}`);
  }

  /**
   * @summary render function
   * @returns {object} Returns DOM Object
   */
  render() {

    const classes = this.props.sheet.classes;
    console.log(this.props);
    console.log(this.props.data.metaPlanets);

    return (
      <section>
        <h1 className="m-b-1">Paginate</h1>
        <Table columns={columns} dataSource={this.props.data.loading === false ? this.props.data.metaPlanets.planets : []} loading={this.props.data.loading} pagination={{ total: 61, onChange: this.onPageChange }} />

      </section>
    );
  }
}

// ----------------------------------------------------------------------- GraphQL Adapter

const Adapter = connect({
  mapQueriesToProps() {
    return {
      data: {
        query: gql`
          query getMetaPlanets ($currentPage: String) {
            metaPlanets(currentPage: $currentPage) {
              currentPage
              totalRecords
              planets {
                id
                name
                diameter
                rotationPeriod
                orbitalPeriod
                gravity
                population
                climate
                terrain
                surfaceWater
              }
            }
          }
        `,
        variables: {
          currentPage: '2', // Connect this to state or props, or whatever
        },
      },
    };
  },
})(DataEmulationPaginate);

// ----------------------------------------------------------------------- Container Component

const Container = createContainer(() => {
  return {};
}, Adapter);

export default useSheet(Container, stylesheet);

@dbx834
Copy link

dbx834 commented Jul 25, 2016

Here's a working example, Apollo-Paginate

@sandervanhooft
Copy link

sandervanhooft commented Aug 2, 2016

Here's a working example, Apollo-Paginate

@dbx834 Seems to be offline.

@dbx834
Copy link

dbx834 commented Aug 2, 2016

Please try again. Just checked, it works for me.

@sandervanhooft
Copy link

Yes, seems to be up again, thanks! :)
On di 2 aug. 2016 at 15:19, Pranav notifications@github.com wrote:

Please try again. Just checked, it works for me.


You are receiving this because you commented.
Reply to this email directly, view it on GitHub
#26 (comment),
or mute the thread
https://github.com/notifications/unsubscribe-auth/AG7dpxzitWHoslo1TLY7ZLPtf8WAGnNlks5qb0PngaJpZM4H3ukx
.

@stubailo stubailo closed this as completed Aug 2, 2016
@stubailo
Copy link
Contributor Author

stubailo commented Aug 2, 2016

We just merged a new pagination approach: #472

Docs coming soon: https://github.com/apollostack/docs/pull/157

jbaxleyiii pushed a commit that referenced this issue Oct 17, 2017
* chore(docs): Angular2 docs

* fix(layout): Fix too many underlines...
jbaxleyiii pushed a commit that referenced this issue Oct 18, 2017
* Basic redux docs

* Added some notes about the dev tools extension
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 17, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants