├── .env.docker ├── .env.github ├── .github ├── run-tests └── workflows │ └── test.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── CONTRIBUTING.md ├── README.md ├── codeception.dist.yml ├── composer.json ├── composer.wp-install.json ├── docker ├── Dockerfile ├── docker-compose.yml ├── init.sh └── run ├── docs └── tutorial.md ├── package-lock.json ├── package.json ├── plugin.php ├── src └── Loader.php ├── tests ├── _data │ ├── .gitkeep │ └── config.php ├── _support │ ├── AcceptanceTester.php │ ├── FunctionalTester.php │ ├── Helper │ │ ├── Acceptance.php │ │ ├── Functional.php │ │ ├── Unit.php │ │ └── Wpunit.php │ ├── UnitTester.php │ ├── WpunitTester.php │ └── _generated │ │ ├── FunctionalTesterActions.php │ │ └── WpunitTesterActions.php ├── functional.suite.dist.yml ├── functional │ └── OffsetPaginationCest.php ├── wpunit.suite.dist.yml └── wpunit │ ├── PageInfoTest.php │ ├── PostObjectCursorTest.php │ ├── PostPaginationTest.php │ └── UserPaginationTest.php ├── tools └── release └── vendor ├── autoload.php └── composer ├── ClassLoader.php ├── LICENSE ├── autoload_classmap.php ├── autoload_namespaces.php ├── autoload_psr4.php ├── autoload_real.php └── autoload_static.php /.env.docker: -------------------------------------------------------------------------------- 1 | # Assign unique name for the container 2 | WPTT_CONTAINER_NAME=wp-graphql-offset-pagination 3 | 4 | # Where the testing installation will be made 5 | WPTT_INSTALL_DIR=.wp-install 6 | 7 | WPTT_DB_NAME=example 8 | WPTT_DB_HOST=db 9 | WPTT_DB_USER=root 10 | WPTT_DB_PASSWORD=root 11 | 12 | WPTT_SITE_HOST=localhost:8080 13 | WPTT_SITE_ADMIN_EMAIL=admin@wp.test 14 | WPTT_SITE_ADMIN_USERNAME=admin 15 | WPTT_SITE_ADMIN_PASSWORD=password 16 | 17 | # For the mariadb container 18 | MYSQL_ROOT_PASSWORD=root 19 | 20 | -------------------------------------------------------------------------------- /.env.github: -------------------------------------------------------------------------------- 1 | WPTT_INSTALL_DIR=".wp-install" 2 | WPTT_DB_NAME="wpgraphql_offset_pagination_test" 3 | 4 | # In Github actions there is a build in mysql running on localhost with these 5 | # credentials 6 | WPTT_DB_HOST="127.0.0.1" 7 | WPTT_DB_USER="root" 8 | WPTT_DB_PASSWORD="root" 9 | 10 | WPTT_SITE_HOST="localhost:8080" 11 | WPTT_SITE_ADMIN_EMAIL="admin@wp.test" 12 | WPTT_SITE_ADMIN_USERNAME="admin" 13 | WPTT_SITE_ADMIN_PASSWORD="password" -------------------------------------------------------------------------------- /.github/run-tests: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | cp .env.github .env 6 | 7 | export PATH="$(pwd)/vendor/bin:$PATH" 8 | 9 | 10 | composer install 11 | composer wp-install 12 | 13 | if [ "$(wp-install --status)" = "full" ]; then 14 | wp-install --serve & 15 | fi 16 | 17 | composer test 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | services: 8 | mariadb: 9 | image: mariadb 10 | ports: 11 | - 3306:3306 12 | env: 13 | MYSQL_ROOT_PASSWORD: root 14 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v1 18 | 19 | - uses: actions/cache@v1 20 | id: cache-composer 21 | with: 22 | path: ~/.composer/cache 23 | key: ${{ runner.os }}-composer 24 | 25 | - uses: actions/cache@v1 26 | id: cache-wp-cli 27 | with: 28 | path: ~/.wp-cli/cache 29 | key: ${{ runner.os }}-wp-cli 30 | 31 | - name: Run tests 32 | run: .github/run-tests 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .wp-install 3 | _output 4 | composer.lock 5 | node_modules 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4, 4 | "overrides": [ 5 | { 6 | "files": "*.php", 7 | "options": { 8 | "singleQuote": true, 9 | "trailingCommaPHP": true 10 | } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Docker: PHP Listen for XDebug", 6 | "type": "php", 7 | "request": "launch", 8 | "port": 9000, 9 | "pathMappings": { 10 | "/app": "${workspaceFolder}" 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.wp-install": true, 4 | "**/vendor": true 5 | } 6 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project uses [WP Testing Tools][] to run tests with Codeception and 4 | wp-browser. 5 | 6 | To run the tests all you need is git and Docker. On Windows you should run 7 | the commands from git-bash. 8 | 9 | In the git repository root run 10 | 11 | ./docker/run compose 12 | 13 | This will build and start up the Docker environment. It will take awhile but 14 | only on the first time. 15 | 16 | Once it's running, on a second terminal start the testing shell 17 | 18 | ./docker/run shell 19 | 20 | and in the shell run 21 | 22 | composer test 23 | 24 | For more information checkout the [WP Testing Tools][] README. 25 | 26 | [wp testing tools]: https://github.com/valu-digital/wp-testing-tools 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wp-graphql-offset-pagination 2 | 3 | Adds traditional offset pagination support to WPGraphQL. This is useful only 4 | when you need to implement: 5 | 6 | - Numbered links to the "pages" 7 | - Ordering with custom SQL 8 | - Read the [tutorial](docs/tutorial.md) 9 | - You should read it even if don't plan to use this plugin as it teaches 10 | you a lot about WPGraphQL internals! 11 | 12 | **You should not use this plugin if you can avoid it.** The cursors in the 13 | wp-graphql core are faster and more efficient although this plugin should perform 14 | comparatively to a traditional WordPress pagination implementation. 15 | 16 | This plugin implements offset pagination for post object (build-in and custom 17 | ones), content nodes and user connections. This means there's no WooCommerce for example 18 | but checkout [this issue](https://github.com/valu-digital/wp-graphql-offset-pagination/issues/1) if you are interested in one. 19 | 20 | PRs welcome for term connections. See [CONTRIBUTING.md](CONTRIBUTING.md). 21 | 22 | 23 | 24 | ## Usage 25 | 26 | ```graphql 27 | query Posts { 28 | posts(where: { offsetPagination: { size: 10, offset: 10 } }) { 29 | pageInfo { 30 | offsetPagination { 31 | # Boolean whether there are more nodes in this connection. 32 | # Eg. you can increment offset to get more nodes. 33 | # Use this to implement "fetch more" buttons etc. 34 | hasMore 35 | 36 | # True when there are previous nodes 37 | # Eg. you can decrement offset to get previous nodes. 38 | hasPrevious 39 | 40 | # Get the total node count in the connection. Using this 41 | # field activates total calculations which will make your 42 | # queries slower. Use with caution. 43 | total 44 | } 45 | } 46 | nodes { 47 | title 48 | } 49 | } 50 | } 51 | ``` 52 | 53 | The where argument is the same for `contentNodes` and `users`. 54 | 55 | ## Installation 56 | 57 | Use must have WPGraphQL v0.8.4 or later installed. 58 | 59 | If you use composer you can install it from Packagist 60 | 61 | composer require valu/wp-graphql-offset-pagination 62 | 63 | Otherwise you can clone it from Github to your plugins using the stable branch 64 | 65 | cd wp-content/plugins 66 | git clone --branch stable https://github.com/valu-digital/wp-graphql-offset-pagination.git 67 | 68 | ## Prior Art 69 | 70 | This a reimplementation of [darylldoyle/wp-graphql-offset-pagination][] by 71 | Daryll Doyle. The API is bit different but this one has unit&integration 72 | tests and support for latest WPGraphQL. 73 | 74 | [darylldoyle/wp-graphql-offset-pagination]: https://github.com/darylldoyle/wp-graphql-offset-pagination 75 | -------------------------------------------------------------------------------- /codeception.dist.yml: -------------------------------------------------------------------------------- 1 | paths: 2 | tests: tests 3 | output: tests/_output 4 | data: tests/_data 5 | support: tests/_support 6 | envs: tests/_envs 7 | actor_suffix: Tester 8 | settings: 9 | colors: true 10 | memory_limit: 1024M 11 | extensions: 12 | enabled: 13 | - Codeception\Extension\RunFailed 14 | commands: 15 | - Codeception\Command\GenerateWPUnit 16 | - Codeception\Command\GenerateWPRestApi 17 | - Codeception\Command\GenerateWPRestController 18 | - Codeception\Command\GenerateWPRestPostTypeController 19 | - Codeception\Command\GenerateWPAjax 20 | - Codeception\Command\GenerateWPCanonical 21 | - Codeception\Command\GenerateWPXMLRPC 22 | params: 23 | - .env 24 | modules: 25 | config: 26 | WPLoader: 27 | wpRootFolder: "%WPTT_INSTALL_DIR%/web" 28 | dbName: "%WPTT_DB_NAME%" 29 | dbHost: "%WPTT_DB_HOST%" 30 | dbUser: "%WPTT_DB_USER%" 31 | dbPassword: "%WPTT_DB_PASSWORD%" 32 | tablePrefix: "_wp" 33 | domain: "%WPTT_SITE_HOST%" 34 | adminEmail: "%WPTT_SITE_ADMIN_EMAIL%" 35 | title: "Test" 36 | plugins: 37 | - "wp-graphql/wp-graphql.php" 38 | - "wp-graphql-offset-pagination/plugin.php" 39 | activatePlugins: 40 | - "wp-graphql/wp-graphql.php" 41 | - "wp-graphql-offset-pagination/plugin.php" 42 | configFile: "tests/_data/config.php" 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "valu/wp-graphql-offset-pagination", 3 | "description": "Add offset pagination to wp-graphql", 4 | "type": "wordpress-plugin", 5 | "license": "GPL-2.0-or-later", 6 | "authors": [ 7 | { 8 | "name": "Esa-Matti Suuronen", 9 | "email": "esa-matti.suuronen@valu.fi", 10 | "role": "developer" 11 | } 12 | ], 13 | "require-dev": { 14 | "valu/wp-testing-tools": "^0.4.0", 15 | "lucatume/wp-browser": "^2.2", 16 | "phpunit/phpunit": "^8.0", 17 | "codeception/module-asserts": "^1.0", 18 | "codeception/module-phpbrowser": "^1.0", 19 | "codeception/module-webdriver": "^1.0", 20 | "codeception/module-db": "^1.0", 21 | "codeception/module-filesystem": "^1.0", 22 | "codeception/module-rest": "^1.0", 23 | "codeception/module-cli": "^1.0", 24 | "codeception/util-universalframework": "^1.0" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "WPGraphQL\\Extensions\\OffsetPagination\\": "src/" 29 | } 30 | }, 31 | "scripts": { 32 | "wp-install": "wp-install --full --env-file .env --wp-composer-file composer.wp-install.json", 33 | "wpunit": "codecept run wpunit", 34 | "functional": "codecept run functional", 35 | "test": [ 36 | "@wpunit", 37 | "@functional" 38 | ] 39 | }, 40 | "config": { 41 | "optimize-autoloader": true 42 | }, 43 | "support": { 44 | "issues": "https://github.com/valu-digital/wp-graphql-offset-pagination/issues", 45 | "source": "https://github.com/valu-digital/wp-graphql-offset-pagination" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /composer.wp-install.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wp-testing-tools/wp", 3 | "type": "project", 4 | "license": "GPL-2.0-or-later", 5 | "repositories": [ 6 | { 7 | "type": "composer", 8 | "url": "https://wpackagist.org" 9 | } 10 | ], 11 | "require": { 12 | "composer/installers": "^1.0", 13 | "wp-graphql/wp-graphql": "^0.8.4" 14 | }, 15 | "extra": { 16 | "installer-paths": { 17 | "web/wp-content/plugins/{$name}": ["type:wordpress-plugin"], 18 | "web/wp-content/mu-plugins/{$name}": ["type:wordpress-muplugin"], 19 | "web/wp-content/themes/{$name}": ["type:wordpress-theme"] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:bionic 2 | 3 | ARG DEBIAN_FRONTEND=noninteractive 4 | RUN apt-get update \ 5 | && apt-get install --no-install-recommends -y \ 6 | php-cli \ 7 | curl \ 8 | php-cli \ 9 | php-curl \ 10 | php-gd \ 11 | php-mbstring \ 12 | php-zip \ 13 | php-dom \ 14 | php-mysql \ 15 | php-xdebug \ 16 | git \ 17 | zip \ 18 | unzip \ 19 | jq \ 20 | less \ 21 | ca-certificates \ 22 | mariadb-client 23 | 24 | RUN curl -L https://github.com/composer/composer/releases/download/1.9.1/composer.phar -o /usr/local/bin/composer && chmod +x /usr/local/bin/composer 25 | 26 | ARG XDEBUG=/etc/php/7.2/cli/conf.d/20-xdebug.ini 27 | RUN echo "[XDebug]" >> ${XDEBUG} \ 28 | && echo "xdebug.remote_enable = 1" >> ${XDEBUG} \ 29 | && echo "xdebug.remote_autostart = 1" >> ${XDEBUG} \ 30 | && echo "xdebug.remote_host = host.docker.internal" >> ${XDEBUG} 31 | 32 | # Put composer stuff to path so it is easy to run codecept 33 | ENV PATH="/app/vendor/bin:${PATH}" 34 | ENV WP_DOCKER=1 35 | 36 | # wp-install can be sometimes too slow for composer 37 | ENV COMPOSER_PROCESS_TIMEOUT=3600 38 | 39 | RUN mkdir -p /app 40 | WORKDIR /app 41 | 42 | RUN adduser --disabled-password --gecos "" wp 43 | USER wp 44 | 45 | EXPOSE 8080 46 | 47 | CMD ["/app/docker/init.sh"] -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | wp: 4 | init: true 5 | env_file: 6 | - ../.env 7 | container_name: "${WPTT_CONTAINER_NAME}-wp" 8 | ports: 9 | - "8080:8080" 10 | depends_on: 11 | - db 12 | build: 13 | context: .. 14 | dockerfile: docker/Dockerfile 15 | volumes: 16 | - ..:/app 17 | db: 18 | container_name: "${WPTT_CONTAINER_NAME}-db" 19 | image: mariadb 20 | env_file: 21 | - ../.env 22 | -------------------------------------------------------------------------------- /docker/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | if [ "${WP_DOCKER:-}" != "1" ]; then 6 | >&2 echo "This script is for the Docker container init." 7 | exit 1 8 | fi 9 | 10 | # Keep the container running so it can be accessed with the shell 11 | keepalive() { 12 | exec tail -f /dev/null 13 | } 14 | 15 | if [ "${WPTT_NO_INIT:-}" = "1" ]; then 16 | >&2 echo "Container running ok. Enter it with 'docker/run shell'" 17 | keepalive 18 | fi 19 | 20 | composer install 21 | 22 | composer wp-install 23 | 24 | 25 | if [ "$(wp-install --status)" = "full" ]; then 26 | >&2 echo "WP is running at http://localhost:8080/ and run tests against it using 'docker/run shell'" 27 | exec wp-install --serve 28 | else 29 | # Otherwise just keep the container running so it can be accessed with docker/shell.sh 30 | >&2 echo "Container running ok. Enter it with 'docker/run shell'" 31 | keepalive 32 | fi 33 | -------------------------------------------------------------------------------- /docker/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | cmd=docker/run 6 | help() { 7 | echo " 8 | Docker shell wrapper for WP Testing Tools 9 | 10 | usage: 11 | 12 | 13 | $cmd compose [custom options] 14 | Start the Docker enviroment with docker-compose 15 | 16 | $cmd shell 17 | Enter the testing shell 18 | 19 | $cmd update 20 | Update these Docker scripts 21 | 22 | $cmd compose-no-init 23 | Start the container without installing anything. Usefull for 24 | debugging or developing install steps 25 | 26 | " 27 | } 28 | 29 | if [ ! -f docker/run ]; then 30 | >&2 echo 31 | >&2 echo "Oops! The docker script is supposed to be run from the parent directory" 32 | >&2 echo "Ex. ./docker/run or more simply just docker/run" 33 | >&2 echo 34 | exit 1 35 | fi 36 | 37 | echo "# Generated file. Do not edit. Edit .env.docker instead" > .env 38 | echo "" >> .env 39 | cat .env.docker >> .env 40 | eval $(grep -v '^#' .env) 41 | 42 | 43 | shell() { 44 | command=$@ 45 | 46 | if [ "$command" = "" ]; then 47 | command="bash -l" 48 | >&2 echo 49 | >&2 echo "Welcome to WordPress testing shell!" 50 | >&2 echo "Your plugin is mounted to /app and all composer dependencies are put to PATH." 51 | >&2 echo 52 | >&2 echo "Try: codecept run wpunit" 53 | >&2 echo " or: codecept run functional" 54 | >&2 echo 55 | fi 56 | 57 | exec docker exec -it "${WPTT_CONTAINER_NAME}-wp" $command 58 | } 59 | 60 | compose() { 61 | command=$@ 62 | 63 | if [ "$command" = "" ]; then 64 | command="up --build --abort-on-container-exit" 65 | fi 66 | 67 | exec docker-compose -f docker/docker-compose.yml $command 68 | } 69 | 70 | update() { 71 | if [ -x ./vendor/bin/wptt-configure ]; then 72 | exec ./vendor/bin/wptt-configure --docker 73 | fi 74 | 75 | >&2 echo 76 | >&2 echo "valu/wp-testing-tools not installed with composer?" 77 | >&2 echo 78 | exit 5 79 | } 80 | 81 | case "${1:-}" in 82 | -h|--help) 83 | help 84 | exit 85 | ;; 86 | "") 87 | >&2 help 88 | exit 89 | ;; 90 | compose) 91 | shift 92 | compose $@ 93 | ;; 94 | compose-no-init) 95 | shift 96 | echo "WPTT_NO_INIT=1" >> .env 97 | compose $@ 98 | ;; 99 | shell) 100 | shift 101 | shell $@ 102 | ;; 103 | update) 104 | shift 105 | update 106 | ;; 107 | *) 108 | >&2 echo "Bad action ${1:-}" 109 | exit 1 110 | ;; 111 | esac -------------------------------------------------------------------------------- /docs/tutorial.md: -------------------------------------------------------------------------------- 1 | # Pushing WPGraphQL Cursor Limits 2 | 3 | Although this tutorial is in the `wp-graphql-offset-pagination` repository 4 | this tutorial contains valuable information for any developers extending 5 | WPGraphQL filtering / ordering with just plain WPGraphQL cursors. We'll 6 | discuss what the limits are and how they fall short. We also only use PHP 7 | APIs exposed by WPGraphQL and WP itself. 8 | 9 | Here's a limit pushing use case: 10 | 11 | You have a custom post type for events and you have the event start time 12 | (timestamp) as a meta field and you want to display the events in this order: 13 | 14 | 1. First display the events that start today or have started today 15 | 2. Then display the events that are the closest to starting 16 | 3. Do not show past events at all 17 | 18 | The tricky bit is the handling of the events that have been already started 19 | today because they they match to both 1 and 3. In pure MySQL terms this can 20 | be implemented with a clever use of `CASE`, `DATE` and `NOW()`. 21 | 22 | This tutorial is on very advanced level. If you get through it and understand 23 | everything I bet you can safely call yourself a "senior WPGraphQL 24 | developer". I assume you known basics of WP development, WP-CLI, SQL and 25 | WPGraphQL. 26 | 27 | For purposes of this tutorial we simplify the example a bit so we don't have 28 | to deal with changing time. 29 | 30 | ## Test data 31 | 32 | Lets create some testing data. 33 | 34 | Run this with `wp eval-file create-data.php` 35 | 36 | ```php 37 | foreach (range('A', 'Z') as $num => $char) { 38 | $num++; // start from 1 39 | $post_id = wp_insert_post([ 40 | 'post_title' => "$char post $num", 41 | 'post_type' => 'post', 42 | 'post_status' => 'publish' 43 | ]); 44 | 45 | if ($num % 2 === 0) { 46 | update_post_meta($post_id, 'example', 'Even ' . $char); 47 | } else { 48 | update_post_meta($post_id, 'example', 'Odd ' . $char); 49 | } 50 | 51 | echo "Created $post_id\n"; 52 | } 53 | ``` 54 | 55 | This will create a post for each character in the alphabet and saves whether 56 | it's in a even or odd position in the alphabet to `example` meta. 57 | 58 | **We will be creating a custom GraphQL Input Field that prioritizes ordering 59 | based on the `example` meta.** 60 | 61 | ## GraphQL Field for Custom Meta 62 | 63 | But first we'll want to expose the `example` meta to the GraphQL schema for 64 | debugging purposes. 65 | 66 | ```php 67 | add_action( 68 | 'graphql_register_types', 69 | function () { 70 | register_graphql_field('Post', 'example', [ 71 | 'type' => 'String', 72 | 'resolve' => function (\WPGraphQL\Model\Post $post) { 73 | return get_post_meta($post->ID, 'example', true); 74 | } 75 | ]); 76 | }, 77 | 10, 78 | 0 79 | ); 80 | ``` 81 | 82 | We should be now able to query the posts with 83 | 84 | ```graphql 85 | { 86 | posts(where: { orderby: { field: TITLE, order: ASC } }) { 87 | nodes { 88 | title 89 | example 90 | } 91 | } 92 | } 93 | ``` 94 | 95 | We'll get the posts in the alphabetical order as we asked 96 | 97 | ```json 98 | { "title": "A post 1", "example": "Odd A" }, 99 | { "title": "B post 2", "example": "Even B" }, 100 | { "title": "C post 3", "example": "Odd C" }, 101 | { "title": "D post 4", "example": "Even D" }, 102 | ... 103 | ``` 104 | 105 | ## GraphQL Input Field for the Prioritization 106 | 107 | Next we'll need to add the input field which can be used to prioritize the 108 | posts. WPGraphQL allows developers to extend the `where` input field. So in 109 | the `graphql_register_types` action we can extend the 110 | `RootQueryToPostConnectionWhereArgs` type. You can find out this type name by 111 | looking it up using [wp-graphiql][]. 112 | 113 | [wp-graphiql]: https://github.com/wp-graphql/wp-graphiql 114 | 115 | ```php 116 | add_action( 117 | 'graphql_register_types', 118 | function () { 119 | register_graphql_field( 120 | 'RootQueryToPostConnectionWhereArgs', 121 | 'prioritize', 122 | [ 123 | 'type' => 'String' 124 | ] 125 | ); 126 | }, 127 | 10, 128 | 0 129 | ); 130 | ``` 131 | 132 | It's now legal to write 133 | 134 | ```graphql 135 | { 136 | posts(where: { prioritize: "Odd" }) { 137 | nodes { 138 | title 139 | example 140 | } 141 | } 142 | } 143 | ``` 144 | 145 | ## Mapping GraphQL Input field to WP Query 146 | 147 | But we must use it for it have any effect. We will use the 148 | `graphql_map_input_fields_to_wp_query` filter to map it into to the query 149 | args of the `\WP_Query` instance WPGraphQL is internally using. 150 | 151 | ```php 152 | add_filter( 153 | 'graphql_map_input_fields_to_wp_query', 154 | function (array $query_args, array $where_args) { 155 | if (!isset($where_args['prioritize'])) { 156 | // If the "prioritize where is argument is not used, bail out. 157 | return $query_args; 158 | } 159 | 160 | // The $query_args is passed to the \WP_Query instance so just copy the 161 | // value from graphql where args 162 | $query_args['prioritize'] = $where_args['prioritize']; 163 | 164 | return $query_args; 165 | }, 166 | 10, 167 | 2 168 | ); 169 | ``` 170 | 171 | If we were doing something simpler that can be done with straight WP Query we 172 | could just add it to the `$query_args` in a form regonized by it and we would 173 | be done. 174 | 175 | For example if we're to just filter out old events we could do this: 176 | 177 | ```php 178 | $query_args['meta_key'] = 'start_date'; 179 | $query_args['meta_query'] = [ 180 | [ 181 | 'key' => 'start_date', 182 | 'compare' => '<', 183 | 'value' => time(), // Compare with the current timestamp. 184 | 'type' => 'NUMERIC' 185 | ] 186 | ]; 187 | ``` 188 | 189 | WPGraphQL should support all features supported by WP Query. Including 190 | `meta_query` and `tax_query`. 191 | 192 | But that's only an "Advanced Level" WPGraphQL usage and this article is about 193 | the "Very Advanced Level" so we'll continue to write some custom SQL 😱 194 | 195 | ## Generating SQL in the WP Query 196 | 197 | Since we only moved the `prioritize` field to a query var that is not 198 | understood by WP Query we must actually teach WP Query how to handle it. We 199 | can do that by hooking in the low level `post_clauses` filter that allow us 200 | to manipulate the SQL query generation inside the WP Query instance. 201 | 202 | This were we get into the territory that Cursors cannot handle. Specifically 203 | **because we mess with the `orderby` clause**. 204 | 205 | ```php 206 | add_filter( 207 | 'posts_clauses', 208 | function (array $clauses, \WP_Query $query) { 209 | global $wpdb; 210 | 211 | if (!isset($query->query_vars['prioritize'])) { 212 | // Bail out if not using the 'prioritize' query var passed from the 213 | // WPGraphQL filter. NOTE: You should probably use more unique query 214 | // var name since this hook is called on every \WP_Query usage in 215 | // WP. 216 | return $clauses; 217 | } 218 | 219 | $meta_key = 'example'; 220 | // 🛑 Do not forget to escape user input data! 221 | $prioritize = esc_sql($query->query_vars['prioritize']); 222 | 223 | // Create join for the meta field. We use a custom alias for the join so 224 | // we can reference it from the 'fields' clause 225 | $join_name = 'CUSTOM_META_JOIN'; 226 | $join = " LEFT JOIN $wpdb->postmeta AS $join_name 227 | ON $wpdb->posts.ID = $join_name.post_id 228 | AND $join_name.meta_key = '$meta_key' "; 229 | 230 | // Append it to the existing joins 231 | $clauses['join'] .= $join; 232 | 233 | // Let's add a custom field with alias to the query which can be 234 | // referenced in ordering. This is the magic. More on this later. 235 | $field_name = 'PRIORITIZE_ORDER'; 236 | $field = " CASE 237 | WHEN $join_name.meta_key = '$meta_key' 238 | AND $join_name.meta_value LIKE '${prioritize}%' 239 | THEN 1 240 | ELSE 2 241 | END AS $field_name"; 242 | 243 | // Append it to the fields 244 | $clauses['fields'] .= ", $field"; 245 | 246 | // Make this field the first ordering directive by prepending it 247 | $clauses['orderby'] = "${field_name}, " . $clauses['orderby']; 248 | 249 | return $clauses; 250 | }, 251 | 10, 252 | 2 253 | ); 254 | ``` 255 | 256 | Whoa! That's a lot! But if you got this far you can congratulate youself! You can now write: 257 | 258 | ```graphql 259 | { 260 | posts( 261 | where: { prioritize: "Even", orderby: { field: TITLE, order: ASC } } 262 | ) { 263 | nodes { 264 | title 265 | example 266 | } 267 | } 268 | } 269 | ``` 270 | 271 | and you'll get the "Even" posts first in alphabetical order (BDFHJ...) 272 | 273 | ```json 274 | { "title": "B post 2", "example": "Even B" }, 275 | { "title": "D post 4", "example": "Even D" }, 276 | { "title": "F post 6", "example": "Even F" }, 277 | ... 278 | ``` 279 | 280 | With `wp-graphql-offset-pagination` you can paginate to the "Odd" posts 281 | 282 | ```graphql 283 | { 284 | posts( 285 | where: { 286 | prioritize: "Even" 287 | orderby: { field: TITLE, order: ASC } 288 | offsetPagination: { size: 10, offset: 12 } 289 | } 290 | ) { 291 | nodes { 292 | title 293 | example 294 | } 295 | } 296 | } 297 | ``` 298 | 299 | and you'll get 300 | 301 | ```json 302 | { "title": "Z post 26", "example": "Even Z" }, 303 | { "title": "A post 1", "example": "Odd A" }, 304 | { "title": "C post 3", "example": "Odd C" }, 305 | ... 306 | ``` 307 | 308 | ## Ordering using CASE in SQL 309 | 310 | But let's go back to the SQL we just created. Specifically the `CASE` statement: 311 | 312 | ```sql 313 | CASE 314 | WHEN $join_name.meta_key = '$meta_key' AND $join_name.meta_value LIKE '${prioritize}%' 315 | THEN 1 316 | ELSE 2 317 | END 318 | ``` 319 | 320 | This is the magic that allows us to modify the ordering in SQL almost 321 | arbitrarily. With the `CASE` statement we can turn any SQL expression to a 322 | number which can be used in the ORDER statement. 323 | 324 | If you still remember the use case I mentioned in the begining, this method 325 | can be used to detect the "current day" and prioritize that. 326 | 327 | The `WHEN` statement for it would be something like this 328 | 329 | ```sql 330 | WHEN DATE( FROM_UNIXTIME( $join_name.meta_value ) ) = DATE( NOW() ) 331 | ``` 332 | 333 | This works because the `DATE` type in SQL does not contain the time part and 334 | casting to it just drops it so if it equals to current date it's today! 335 | 336 | I'll leave the complete implementation as an exercise to you. 337 | 338 | ## Cursors? 339 | 340 | We're done coding-wise but since I have your attention we'll dive a bit 341 | deeper into the Cursors in WPGraphQL. 342 | 343 | You might want to try what happens when you try to paginate the example with 344 | the WPGraphQL cursors (`first`, `after`, `pageInfo.endCursor`). The first 345 | page looks good, maybe the second one too but at some point it goes of the 346 | rails and misses some data. 347 | 348 | If you are interested why cursor pagination is a good idea despite of its 349 | limitiations I'd recommend you to read this article from Slack Engineering 350 | 351 | 352 | 353 | tl;dr it's faster on big data sets because with a cursor the database does 354 | not have to read the rows before the cursor at all. Just offseting the query 355 | is a lot more work. 356 | 357 | The cursor is implemented as a `WHERE` clause using the auto incremented row 358 | id. So technically the cursor is a post id in the `wp_posts` table. But 359 | **when a `ORDER` clause is added it must be implemented as a cursor too!** 360 | 361 | Here's an example of a SQL query with cursors for order by `post_title`, 362 | `modified_date`, `created_date` and `id`: 363 | 364 | ```sql 365 | WHERE post_title >= $post_title_cursor 366 | AND ( post_title > $post_title_cursor OR ( post_modified >= $post_modified_cursor 367 | AND ( post_modified > $post_modified_cursor OR ( post_created >= $post_created_cursor 368 | AND ( post_created > $post_created_cursor OR id > :$post_id_cursor ) ) 369 | ) 370 | ) 371 | ) 372 | ORDER BY post_title, post_modified, post_created, id 373 | ``` 374 | 375 | As you can see it is a recursive problem. You cannot modify this by just 376 | stuffing some extra SQL in the `post_clauses` filter. Also even if you could 377 | you would have to replicate the `CASE` statement in the `WHERE` clause which 378 | would probably destroy the performance gains because `CASE` statement would 379 | need to be evaluated on each row (not 100% sure on this!). 380 | 381 | Luckily the cursor builder in WPGraphQL handles this recursive SQL generation 382 | for you for the standard WP Query uses but when you modify the SQL you must 383 | be very careful. But not all modifications are bad. For example **just adding 384 | extra filtering the to the `$fields['where']` should be ok**. For the rest 385 | there is this `wp-graphql-offset-pagination` which enables all the crazy use 386 | cases like this. Albeit beign bit slower. 387 | 388 | If you have questions or something to add/correct feel free to ping me on 389 | Twitter [@esamatti][] or open an issue on this repository. 390 | 391 | [@esamatti]: https://twitter.com/esamatti 392 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wp-graphql-offset-pagination", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@prettier/plugin-php": { 8 | "version": "0.14.0", 9 | "resolved": "https://registry.npmjs.org/@prettier/plugin-php/-/plugin-php-0.14.0.tgz", 10 | "integrity": "sha512-aUHD08hly/mBFMZlQbXZoA+k4DixiWZACLzT4Mz90qK6nlkw9TbPaby11m8OIwXcyUQRW6BeZ6hk209TbPxraQ==", 11 | "requires": { 12 | "linguist-languages": "^7.5.1", 13 | "mem": "^6.0.1", 14 | "php-parser": "3.0.0" 15 | } 16 | }, 17 | "linguist-languages": { 18 | "version": "7.9.0", 19 | "resolved": "https://registry.npmjs.org/linguist-languages/-/linguist-languages-7.9.0.tgz", 20 | "integrity": "sha512-saKTpS7BH8vOOwzrZNTkFL/DuT2JN7cg6oHWY8nAjt89+pV1qFcpbjEEcZdAv9ogc4DcxVFHkXmjeyU/DiFHQw==" 21 | }, 22 | "map-age-cleaner": { 23 | "version": "0.1.3", 24 | "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", 25 | "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", 26 | "requires": { 27 | "p-defer": "^1.0.0" 28 | } 29 | }, 30 | "mem": { 31 | "version": "6.1.0", 32 | "resolved": "https://registry.npmjs.org/mem/-/mem-6.1.0.tgz", 33 | "integrity": "sha512-RlbnLQgRHk5lwqTtpEkBTQ2ll/CG/iB+J4Hy2Wh97PjgZgXgWJWrFF+XXujh3UUVLvR4OOTgZzcWMMwnehlEUg==", 34 | "requires": { 35 | "map-age-cleaner": "^0.1.3", 36 | "mimic-fn": "^3.0.0" 37 | } 38 | }, 39 | "mimic-fn": { 40 | "version": "3.0.0", 41 | "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.0.0.tgz", 42 | "integrity": "sha512-PiVO95TKvhiwgSwg1IdLYlCTdul38yZxZMIcnDSFIBUm4BNZha2qpQ4GpJ++15bHoKDtrW2D69lMfFwdFYtNZQ==" 43 | }, 44 | "p-defer": { 45 | "version": "1.0.0", 46 | "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", 47 | "integrity": "sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=" 48 | }, 49 | "php-parser": { 50 | "version": "3.0.0", 51 | "resolved": "https://registry.npmjs.org/php-parser/-/php-parser-3.0.0.tgz", 52 | "integrity": "sha512-WqGNf8Y5LBOXKJz9eKMwxjqfJ7KnwVU7DH3ebPTaOLlPtVrig++zG8KoOXywY9V9jQxY+wOY0EtC/e+wamaxsQ==" 53 | }, 54 | "prettier": { 55 | "version": "2.0.4", 56 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.0.4.tgz", 57 | "integrity": "sha512-SVJIQ51spzFDvh4fIbCLvciiDMCrRhlN3mbZvv/+ycjvmF5E73bKdGfU8QDLNmjYJf+lsGnDBC4UUnvTe5OO0w==" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wp-graphql-offset-pagination", 3 | "version": "1.0.0", 4 | "description": "Adds traditional offset pagination support to WPGraphQL. This useful only when you need to implement", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "dependencies": { 10 | "@prettier/plugin-php": "^0.14.0", 11 | "prettier": "^2.0.4" 12 | }, 13 | "devDependencies": {}, 14 | "scripts": { 15 | "prettier": "prettier --write 'src/*.php' 'tests/*/*.php'" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/valu-digital/wp-graphql-offset-pagination.git" 20 | }, 21 | "author": "", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/valu-digital/wp-graphql-offset-pagination/issues" 25 | }, 26 | "homepage": "https://github.com/valu-digital/wp-graphql-offset-pagination#readme" 27 | } 28 | -------------------------------------------------------------------------------- /plugin.php: -------------------------------------------------------------------------------- 1 | bind_hooks(); 14 | } 15 | 16 | function bind_hooks() 17 | { 18 | add_action( 19 | 'graphql_register_types', 20 | [$this, 'op_action_register_types'], 21 | 9, 22 | 0 23 | ); 24 | 25 | add_filter( 26 | 'graphql_map_input_fields_to_wp_query', 27 | [$this, 'op_filter_map_offset_to_wp_query_args'], 28 | 10, 29 | 2 30 | ); 31 | 32 | add_filter( 33 | 'graphql_map_input_fields_to_wp_user_query', 34 | [$this, 'op_filter_map_offset_to_wp_user_query_args'], 35 | 10, 36 | 2 37 | ); 38 | 39 | add_filter( 40 | 'graphql_connection_page_info', 41 | [$this, 'op_filter_graphql_connection_page_info'], 42 | 10, 43 | 2 44 | ); 45 | 46 | add_filter( 47 | 'graphql_connection_query_args', 48 | [$this, 'op_filter_graphql_connection_query_args'], 49 | 10, 50 | 5 51 | ); 52 | 53 | add_filter( 54 | 'graphql_connection_amount_requested', 55 | [$this, 'op_filter_graphql_connection_amount_requested'], 56 | 10, 57 | 2 58 | ); 59 | } 60 | 61 | function op_filter_graphql_connection_amount_requested($amount, $resolver) 62 | { 63 | if (self::is_offset_resolver($resolver)) { 64 | return self::get_page_size($resolver); 65 | } 66 | 67 | return $amount; 68 | } 69 | 70 | /** 71 | * Returns true when the resolver is resolving offset pagination 72 | */ 73 | static function get_page_size(AbstractConnectionResolver $resolver) 74 | { 75 | $args = $resolver->get_args(); 76 | return intval($args['where']['offsetPagination']['size'] ?? 0); 77 | } 78 | 79 | static function is_offset_resolver(AbstractConnectionResolver $resolver) 80 | { 81 | $args = $resolver->get_args(); 82 | return isset($args['where']['offsetPagination']); 83 | } 84 | 85 | /** 86 | * Lazily enable total calculations only when they are asked in the 87 | * selection set. 88 | */ 89 | function op_filter_graphql_connection_query_args( 90 | $query_args, 91 | AbstractConnectionResolver $resolver 92 | ) { 93 | $info = $resolver->get_info(); 94 | $selection_set = $info->getFieldSelection(2); 95 | 96 | if (!isset($selection_set['pageInfo']['offsetPagination']['total'])) { 97 | // get out if not requesting total counting 98 | return $query_args; 99 | } 100 | 101 | if ($resolver instanceof UserConnectionResolver) { 102 | // Enable slow total counting for user connections 103 | $query_args['count_total'] = true; 104 | } else { 105 | // Enable slow total counting for posts connections 106 | $query_args['no_found_rows'] = false; 107 | } 108 | 109 | return $query_args; 110 | } 111 | 112 | static function add_post_type_fields(\WP_Post_Type $post_type_object) 113 | { 114 | $type = ucfirst($post_type_object->graphql_single_name); 115 | register_graphql_fields("RootQueryTo{$type}ConnectionWhereArgs", [ 116 | 'offsetPagination' => [ 117 | 'type' => 'OffsetPagination', 118 | 'description' => "Paginate {$type}s with offsets", 119 | ], 120 | ]); 121 | } 122 | 123 | function op_filter_graphql_connection_page_info( 124 | $page_info, 125 | AbstractConnectionResolver $resolver 126 | ) { 127 | $size = self::get_page_size($resolver); 128 | $query = $resolver->get_query(); 129 | $args = $resolver->get_args(); 130 | $offset = $args['where']['offsetPagination']['offset'] ?? 0; 131 | 132 | $total = null; 133 | 134 | if ($query instanceof \WP_Query) { 135 | $total = $query->found_posts; 136 | } elseif ($query instanceof \WP_User_Query) { 137 | $total = $query->total_users; 138 | } 139 | 140 | $page_info['offsetPagination'] = [ 141 | 'total' => $total, 142 | 'hasMore' => count($resolver->get_ids()) > $size, 143 | 'hasPrevious' => $offset > 0, 144 | ]; 145 | return $page_info; 146 | } 147 | 148 | function op_filter_map_offset_to_wp_query_args( 149 | array $query_args, 150 | array $where_args 151 | ) { 152 | if (isset($where_args['offsetPagination']['offset'])) { 153 | $query_args['offset'] = $where_args['offsetPagination']['offset']; 154 | } 155 | 156 | if (isset($where_args['offsetPagination']['size'])) { 157 | // Fetch size+1 to be able calculate "hasMore" field without 158 | // slowly counting full totals. 159 | $query_args['posts_per_page'] = 160 | intval($where_args['offsetPagination']['size']) + 1; 161 | } 162 | 163 | return $query_args; 164 | } 165 | 166 | function op_filter_map_offset_to_wp_user_query_args( 167 | array $query_args, 168 | array $where_args 169 | ) { 170 | if (isset($where_args['offsetPagination']['offset'])) { 171 | $query_args['offset'] = $where_args['offsetPagination']['offset']; 172 | } 173 | 174 | if (isset($where_args['offsetPagination']['size'])) { 175 | $query_args['number'] = 176 | intval($where_args['offsetPagination']['size']) + 1; 177 | } 178 | 179 | return $query_args; 180 | } 181 | 182 | function op_action_register_types() 183 | { 184 | foreach (\WPGraphQL::get_allowed_post_types() as $post_type) { 185 | self::add_post_type_fields(get_post_type_object($post_type)); 186 | } 187 | 188 | register_graphql_object_type('OffsetPaginationPageInfo', [ 189 | 'description' => __( 190 | 'Get information about the offset pagination state', 191 | 'wp-graphql-offset-pagination' 192 | ), 193 | 'fields' => [ 194 | 'total' => [ 195 | 'type' => 'Int', 196 | 'description' => __( 197 | 'Total amount of nodes in this connection', 198 | 'wp-graphql-offset-pagination' 199 | ), 200 | ], 201 | 'hasMore' => [ 202 | 'type' => 'Boolean', 203 | 'description' => __( 204 | 'True if there is one or more nodes available in this connection. Eg. you can increase the offset at least by one.', 205 | 'wp-graphql-offset-pagination' 206 | ), 207 | ], 208 | 'hasPrevious' => [ 209 | 'type' => 'Boolean', 210 | 'description' => __( 211 | 'True when offset can be decresed eg. offset is 0<', 212 | 'wp-graphql-offset-pagination' 213 | ), 214 | ], 215 | ], 216 | ]); 217 | 218 | register_graphql_field('WPPageInfo', 'offsetPagination', [ 219 | 'type' => 'OffsetPaginationPageInfo', 220 | 'description' => __( 221 | 'Get information about the offset pagination state in the current connection', 222 | 'wp-graphql-offset-pagination' 223 | ), 224 | ]); 225 | 226 | register_graphql_input_type('OffsetPagination', [ 227 | 'description' => __( 228 | 'Offset pagination input type', 229 | 'wp-graphql-offet-pagination' 230 | ), 231 | 'fields' => [ 232 | 'size' => [ 233 | 'type' => 'Int', 234 | 'description' => __( 235 | 'Number of post to show per page. Passed to posts_per_page of WP_Query.', 236 | 'wp-graphql-offset-pagination' 237 | ), 238 | ], 239 | 'offset' => [ 240 | 'type' => 'Int', 241 | 'description' => __( 242 | 'Number of post to show per page. Passed to posts_per_page of WP_Query.', 243 | 'wp-graphql-offset-pagination' 244 | ), 245 | ], 246 | ], 247 | ]); 248 | 249 | register_graphql_field( 250 | 'RootQueryToContentNodeConnectionWhereArgs', 251 | 'offsetPagination', 252 | [ 253 | 'type' => 'OffsetPagination', 254 | 'description' => 'Paginate content nodes with offsets', 255 | ] 256 | ); 257 | 258 | register_graphql_field( 259 | 'RootQueryToUserConnectionWhereArgs', 260 | 'offsetPagination', 261 | [ 262 | 'type' => 'OffsetPagination', 263 | 'description' => 'Paginate users with offsets', 264 | ] 265 | ); 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /tests/_data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valu-digital/wp-graphql-offset-pagination/db85d9b676377f4ddda96859f56e14c1e32dd0ce/tests/_data/.gitkeep -------------------------------------------------------------------------------- /tests/_data/config.php: -------------------------------------------------------------------------------- 1 | factory()->post->create(); 26 | * $userId = $I->factory()->user->create(['role' => 'administrator']); 27 | * ``` 28 | * 29 | * @return FactoryStore A factory store, proxy to get hold of the Core suite object factories. 30 | * 31 | * @link https://make.wordpress.org/core/handbook/testing/automated-testing/writing-phpunit-tests/ 32 | * @see \Codeception\Module\WPLoader::factory() 33 | */ 34 | public function factory() { 35 | return $this->getScenario()->runStep(new \Codeception\Step\Action('factory', func_get_args())); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/functional.suite.dist.yml: -------------------------------------------------------------------------------- 1 | actor: FunctionalTester 2 | modules: 3 | enabled: 4 | - REST 5 | - WPDb 6 | - WPBrowser 7 | - Asserts 8 | - \Helper\Functional 9 | disabled: 10 | - WPLoader 11 | 12 | config: 13 | REST: 14 | depends: WPBrowser 15 | url: "http://%WPTT_SITE_HOST%" 16 | WPBrowser: 17 | url: "http://%WPTT_SITE_HOST%" 18 | adminUsername: "%WPTT_SITE_ADMIN_USERNAME%" 19 | adminPassword: "%WPTT_SITE_ADMIN_PASSWORD%" 20 | adminPath: "/wp-admin" 21 | WPDb: 22 | dsn: "mysql:host=%WPTT_DB_HOST%;dbname=%WPTT_DB_NAME%" 23 | user: %WPTT_DB_USER% 24 | password: %WPTT_DB_PASSWORD% 25 | dump: %WPTT_INSTALL_DIR%/dump.sql 26 | populate: true 27 | cleanup: true 28 | url: "http://%WPTT_SITE_HOST%" 29 | tablePrefix: wp_ 30 | -------------------------------------------------------------------------------- /tests/functional/OffsetPaginationCest.php: -------------------------------------------------------------------------------- 1 | havePageInDatabase(['post_title' => "Post $number"]); 11 | } 12 | 13 | $query = ' 14 | query Posts { 15 | pages(where: { 16 | orderby: {field: TITLE, order: ASC}, 17 | offsetPagination: {size: 5, offset: 2} 18 | }) { 19 | nodes { 20 | title 21 | } 22 | } 23 | } 24 | '; 25 | 26 | $I->haveHttpHeader('Content-Type', 'application/json'); 27 | $I->sendPOST('/graphql', [ 28 | 'query' => $query, 29 | ]); 30 | $I->seeResponseCodeIs(200); 31 | $I->seeResponseIsJson(); 32 | // $res = $I->grabResponse(); 33 | $I->seeResponseContainsJson([ 34 | 'data' => [ 35 | 'pages' => [ 36 | 'nodes' => [ 37 | ['title' => 'Post 02'], 38 | ['title' => 'Post 03'], 39 | ['title' => 'Post 04'], 40 | ['title' => 'Post 05'], 41 | ['title' => 'Post 06'], 42 | ], 43 | ], 44 | ], 45 | ]); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/wpunit.suite.dist.yml: -------------------------------------------------------------------------------- 1 | # Codeception Test Suite Configuration 2 | # 3 | # Suite for unit or integration tests that require WordPress functions and classes. 4 | 5 | actor: WpunitTester 6 | modules: 7 | enabled: 8 | - WPLoader 9 | - \Helper\Wpunit 10 | -------------------------------------------------------------------------------- /tests/wpunit/PageInfoTest.php: -------------------------------------------------------------------------------- 1 | true, 11 | 'labels' => [ 12 | 'menu_name' => __('Docs', 'your-textdomain'), 13 | ], 14 | 'supports' => ['title'], 15 | 'show_in_graphql' => true, 16 | 'hierarchical' => true, 17 | 'graphql_single_name' => 'testCpt', 18 | 'graphql_plural_name' => 'testCpts', 19 | ]); 20 | } 21 | 22 | public function tearDown(): void 23 | { 24 | parent::tearDown(); 25 | \WPGraphQL::clear_schema(); 26 | } 27 | 28 | function createPosts($count, $args = []) 29 | { 30 | $title_prefix = 'Post'; 31 | 32 | if (isset($args['title_prefix'])) { 33 | $title_prefix = $args['title_prefix']; 34 | unset($args['title_prefix']); 35 | } 36 | 37 | foreach (range(1, $count) as $number) { 38 | $number = str_pad($number, 2, '0', STR_PAD_LEFT); 39 | self::factory()->post->create( 40 | array_merge( 41 | [ 42 | 'post_title' => "$title_prefix $number", 43 | ], 44 | $args 45 | ) 46 | ); 47 | } 48 | } 49 | 50 | public function testHasMoreTrueWithoutOffset() 51 | { 52 | $this->createPosts(10); 53 | 54 | $res = graphql([ 55 | 'query' => ' 56 | query Posts { 57 | posts(where: { 58 | orderby: {field: TITLE, order: ASC}, 59 | offsetPagination: {size: 5} 60 | }) { 61 | pageInfo { 62 | offsetPagination { 63 | hasMore 64 | } 65 | } 66 | nodes { 67 | title 68 | } 69 | } 70 | } 71 | ', 72 | ]); 73 | 74 | $has_more = 75 | $res['data']['posts']['pageInfo']['offsetPagination']['hasMore']; 76 | 77 | $this->assertEquals(true, $has_more); 78 | 79 | $nodes = $res['data']['posts']['nodes']; 80 | $titles = \wp_list_pluck($nodes, 'title'); 81 | $this->assertEquals($titles, [ 82 | 'Post 01', 83 | 'Post 02', 84 | 'Post 03', 85 | 'Post 04', 86 | 'Post 05', 87 | ]); 88 | } 89 | 90 | public function testHasMoreTrueOneBeforeEnd() 91 | { 92 | $this->createPosts(10); 93 | 94 | $res = graphql([ 95 | 'query' => ' 96 | query Posts { 97 | posts(where: { 98 | orderby: {field: TITLE, order: ASC}, 99 | offsetPagination: {size: 5, offset: 4} 100 | }) { 101 | pageInfo { 102 | offsetPagination { 103 | hasMore 104 | } 105 | } 106 | nodes { 107 | title 108 | } 109 | } 110 | } 111 | ', 112 | ]); 113 | 114 | $nodes = $res['data']['posts']['nodes']; 115 | $titles = \wp_list_pluck($nodes, 'title'); 116 | $this->assertEquals($titles, [ 117 | 'Post 05', 118 | 'Post 06', 119 | 'Post 07', 120 | 'Post 08', 121 | 'Post 09', 122 | ]); 123 | 124 | $has_more = 125 | $res['data']['posts']['pageInfo']['offsetPagination']['hasMore']; 126 | $this->assertEquals(true, $has_more); 127 | } 128 | 129 | public function testHasMoreFalseOnEnd() 130 | { 131 | $this->createPosts(10); 132 | 133 | $res = graphql([ 134 | 'query' => ' 135 | query Posts { 136 | posts(where: { 137 | orderby: {field: TITLE, order: ASC}, 138 | offsetPagination: {size: 5, offset: 5} 139 | }) { 140 | pageInfo { 141 | offsetPagination { 142 | hasMore 143 | } 144 | } 145 | nodes { 146 | title 147 | } 148 | } 149 | } 150 | ', 151 | ]); 152 | 153 | $nodes = $res['data']['posts']['nodes']; 154 | $titles = \wp_list_pluck($nodes, 'title'); 155 | $this->assertEquals($titles, [ 156 | 'Post 06', 157 | 'Post 07', 158 | 'Post 08', 159 | 'Post 09', 160 | 'Post 10', 161 | ]); 162 | 163 | $has_more = 164 | $res['data']['posts']['pageInfo']['offsetPagination']['hasMore']; 165 | $this->assertEquals(false, $has_more); 166 | } 167 | 168 | public function testHasMoreFalsePastEnd() 169 | { 170 | $this->createPosts(10); 171 | 172 | $res = graphql([ 173 | 'query' => ' 174 | query Posts { 175 | posts(where: { 176 | orderby: {field: TITLE, order: ASC}, 177 | offsetPagination: {size: 5, offset: 7} 178 | }) { 179 | pageInfo { 180 | offsetPagination { 181 | hasMore 182 | } 183 | } 184 | nodes { 185 | title 186 | } 187 | } 188 | } 189 | ', 190 | ]); 191 | 192 | $nodes = $res['data']['posts']['nodes']; 193 | $titles = \wp_list_pluck($nodes, 'title'); 194 | $this->assertEquals($titles, ['Post 08', 'Post 09', 'Post 10']); 195 | 196 | $has_more = 197 | $res['data']['posts']['pageInfo']['offsetPagination']['hasMore']; 198 | $this->assertEquals(false, $has_more); 199 | } 200 | 201 | public function testHasPreviousFalseWithoutOffset() 202 | { 203 | $this->createPosts(10); 204 | 205 | $res = graphql([ 206 | 'query' => ' 207 | query Posts { 208 | posts(where: { 209 | orderby: {field: TITLE, order: ASC}, 210 | offsetPagination: {size: 5} 211 | }) { 212 | pageInfo { 213 | offsetPagination { 214 | hasPrevious 215 | } 216 | } 217 | nodes { 218 | title 219 | } 220 | } 221 | } 222 | ', 223 | ]); 224 | 225 | $has_previous = 226 | $res['data']['posts']['pageInfo']['offsetPagination'][ 227 | 'hasPrevious' 228 | ]; 229 | $this->assertEquals(false, $has_previous); 230 | 231 | $nodes = $res['data']['posts']['nodes']; 232 | $titles = \wp_list_pluck($nodes, 'title'); 233 | $this->assertEquals($titles, [ 234 | 'Post 01', 235 | 'Post 02', 236 | 'Post 03', 237 | 'Post 04', 238 | 'Post 05', 239 | ]); 240 | } 241 | 242 | public function testHasPreviousFalseWithZeroOffset() 243 | { 244 | $this->createPosts(10); 245 | 246 | $res = graphql([ 247 | 'query' => ' 248 | query Posts { 249 | posts(where: { 250 | orderby: {field: TITLE, order: ASC}, 251 | offsetPagination: {size: 5, offset: 0} 252 | }) { 253 | pageInfo { 254 | offsetPagination { 255 | hasPrevious 256 | } 257 | } 258 | nodes { 259 | title 260 | } 261 | } 262 | } 263 | ', 264 | ]); 265 | 266 | $has_previous = 267 | $res['data']['posts']['pageInfo']['offsetPagination'][ 268 | 'hasPrevious' 269 | ]; 270 | $this->assertEquals(false, $has_previous); 271 | 272 | $nodes = $res['data']['posts']['nodes']; 273 | $titles = \wp_list_pluck($nodes, 'title'); 274 | $this->assertEquals($titles, [ 275 | 'Post 01', 276 | 'Post 02', 277 | 'Post 03', 278 | 'Post 04', 279 | 'Post 05', 280 | ]); 281 | } 282 | 283 | public function testHasPreviousTrueWithOffsetOne() 284 | { 285 | $this->createPosts(10); 286 | 287 | $res = graphql([ 288 | 'query' => ' 289 | query Posts { 290 | posts(where: { 291 | orderby: {field: TITLE, order: ASC}, 292 | offsetPagination: {size: 5, offset: 1} 293 | }) { 294 | pageInfo { 295 | offsetPagination { 296 | hasPrevious 297 | } 298 | } 299 | nodes { 300 | title 301 | } 302 | } 303 | } 304 | ', 305 | ]); 306 | 307 | $has_previous = 308 | $res['data']['posts']['pageInfo']['offsetPagination'][ 309 | 'hasPrevious' 310 | ]; 311 | $this->assertEquals(true, $has_previous); 312 | 313 | $nodes = $res['data']['posts']['nodes']; 314 | $titles = \wp_list_pluck($nodes, 'title'); 315 | $this->assertEquals($titles, [ 316 | 'Post 02', 317 | 'Post 03', 318 | 'Post 04', 319 | 'Post 05', 320 | 'Post 06', 321 | ]); 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /tests/wpunit/PostObjectCursorTest.php: -------------------------------------------------------------------------------- 1 | current_time = strtotime('- 1 day'); 18 | $this->current_date = date('Y-m-d H:i:s', $this->current_time); 19 | $this->current_date_gmt = gmdate('Y-m-d H:i:s', $this->current_time); 20 | $this->admin = $this->factory()->user->create([ 21 | 'role' => 'administrator', 22 | ]); 23 | $this->created_post_ids = $this->create_posts(); 24 | } 25 | 26 | public function tearDown(): void 27 | { 28 | parent::tearDown(); 29 | } 30 | 31 | public function createPostObject($args) 32 | { 33 | /** 34 | * Set up the $defaults 35 | */ 36 | $defaults = [ 37 | 'post_author' => $this->admin, 38 | 'post_content' => 'Test page content', 39 | 'post_excerpt' => 'Test excerpt', 40 | 'post_status' => 'publish', 41 | 'post_title' => 'Test Title', 42 | 'post_type' => 'post', 43 | 'post_date' => $this->current_date, 44 | 'has_password' => false, 45 | 'post_password' => null, 46 | ]; 47 | 48 | /** 49 | * Combine the defaults with the $args that were 50 | * passed through 51 | */ 52 | $args = array_merge($defaults, $args); 53 | 54 | /** 55 | * Create the page 56 | */ 57 | $post_id = $this->factory->post->create($args); 58 | 59 | /** 60 | * Update the _edit_last and _edit_lock fields to simulate a user editing the page to 61 | * test retrieving the fields 62 | * 63 | * @since 0.0.5 64 | */ 65 | update_post_meta( 66 | $post_id, 67 | '_edit_lock', 68 | $this->current_time . ':' . $this->admin 69 | ); 70 | update_post_meta($post_id, '_edit_last', $this->admin); 71 | 72 | /** 73 | * Return the $id of the post_object that was created 74 | */ 75 | return $post_id; 76 | } 77 | 78 | /** 79 | * Creates several posts (with different timestamps) for use in cursor query tests 80 | * 81 | * @param int $count Number of posts to create. 82 | * @return array 83 | */ 84 | public function create_posts($count = 20) 85 | { 86 | // Ensure that ordering by titles is different from ordering by ids 87 | $titles = 'qwertyuiopasdfghjklzxcvbnm'; 88 | 89 | // Create posts 90 | $created_posts = []; 91 | for ($i = 1; $i <= $count; $i++) { 92 | // Set the date 1 minute apart for each post 93 | $date = date('Y-m-d H:i:s', strtotime("-1 day +{$i} minutes")); 94 | $created_posts[$i] = $this->createPostObject([ 95 | 'post_type' => 'post', 96 | 'post_date' => $date, 97 | 'post_status' => 'publish', 98 | 'post_title' => $titles[$i % strlen($titles)], 99 | ]); 100 | } 101 | 102 | return $created_posts; 103 | } 104 | 105 | private function formatNumber($num) 106 | { 107 | return sprintf('%08d', $num); 108 | } 109 | 110 | private function numberToMysqlDate($num) 111 | { 112 | return sprintf('2019-03-%02d', $num); 113 | } 114 | 115 | private function deleteByMetaKey($key, $value) 116 | { 117 | $args = [ 118 | 'meta_query' => [ 119 | [ 120 | 'key' => $key, 121 | 'value' => $value, 122 | 'compare' => '=', 123 | ], 124 | ], 125 | ]; 126 | 127 | $query = new WP_Query($args); 128 | 129 | foreach ($query->posts as $post) { 130 | wp_delete_post($post->ID, true); 131 | } 132 | } 133 | 134 | /** 135 | * Assert given query fields in a GraphQL post cursor against a plain WP Query 136 | */ 137 | public function assertQueryInCursor($meta_fields, $posts_per_page = 5) 138 | { 139 | add_filter( 140 | 'graphql_map_input_fields_to_wp_query', 141 | function ($query_args) use ($meta_fields) { 142 | return array_merge($query_args, $meta_fields); 143 | }, 144 | 10, 145 | 1 146 | ); 147 | 148 | // Must use dummy where args here to force 149 | // graphql_map_input_fields_to_wp_query to be executes 150 | $query = " 151 | query getPosts(\$cursor: String) { 152 | posts(after: \$cursor, first: $posts_per_page, where: {author: {$this->admin}}) { 153 | pageInfo { 154 | endCursor 155 | } 156 | edges { 157 | node { 158 | title 159 | postId 160 | } 161 | } 162 | } 163 | } 164 | "; 165 | 166 | $first = do_graphql_request($query, 'getPosts', ['cursor' => '']); 167 | $this->assertArrayNotHasKey('errors', $first, print_r($first, true)); 168 | 169 | $first_page_actual = array_map(function ($edge) { 170 | return $edge['node']['postId']; 171 | }, $first['data']['posts']['edges']); 172 | 173 | $cursor = $first['data']['posts']['pageInfo']['endCursor']; 174 | $second = do_graphql_request($query, 'getPosts', ['cursor' => $cursor]); 175 | $this->assertArrayNotHasKey('errors', $second, print_r($second, true)); 176 | 177 | $second_page_actual = array_map(function ($edge) { 178 | return $edge['node']['postId']; 179 | }, $second['data']['posts']['edges']); 180 | 181 | // Make correspondig WP_Query 182 | WPGraphQL::set_is_graphql_request(true); 183 | $first_page = new WP_Query( 184 | array_merge($meta_fields, [ 185 | 'post_status' => 'publish', 186 | 'post_type' => 'post', 187 | 'post_author' => $this->admin, 188 | 'posts_per_page' => $posts_per_page, 189 | 'paged' => 1, 190 | ]) 191 | ); 192 | 193 | $second_page = new WP_Query( 194 | array_merge($meta_fields, [ 195 | 'post_status' => 'publish', 196 | 'post_type' => 'post', 197 | 'post_author' => $this->admin, 198 | 'posts_per_page' => $posts_per_page, 199 | 'paged' => 2, 200 | ]) 201 | ); 202 | WPGraphQL::set_is_graphql_request(true); 203 | 204 | $first_page_expected = wp_list_pluck($first_page->posts, 'ID'); 205 | $second_page_expected = wp_list_pluck($second_page->posts, 'ID'); 206 | 207 | // Aserting like this we get more readable assertion fail message 208 | $this->assertEquals( 209 | implode(',', $first_page_expected), 210 | implode(',', $first_page_actual), 211 | 'First page' 212 | ); 213 | $this->assertEquals( 214 | implode(',', $second_page_expected), 215 | implode(',', $second_page_actual), 216 | 'Second page' 217 | ); 218 | } 219 | 220 | /** 221 | * Test default order 222 | */ 223 | public function testDefaultPostOrdering() 224 | { 225 | $this->assertQueryInCursor([]); 226 | } 227 | 228 | /** 229 | * Simple title ordering test 230 | */ 231 | public function testPostOrderingByPostTitleDefault() 232 | { 233 | $this->assertQueryInCursor([ 234 | 'orderby' => 'post_title', 235 | ]); 236 | } 237 | 238 | /** 239 | * Simple title ordering test by ASC 240 | */ 241 | public function testPostOrderingByPostTitleASC() 242 | { 243 | $this->assertQueryInCursor([ 244 | 'orderby' => 'post_title', 245 | 'order' => 'ASC', 246 | ]); 247 | } 248 | 249 | /** 250 | * Simple title ordering test by ASC 251 | */ 252 | public function testPostOrderingByPostTitleDESC() 253 | { 254 | $this->assertQueryInCursor([ 255 | 'orderby' => 'post_title', 256 | 'order' => 'DESC', 257 | ]); 258 | } 259 | 260 | public function testPostOrderingByDuplicatePostTitles() 261 | { 262 | foreach ($this->created_post_ids as $index => $post_id) { 263 | wp_update_post([ 264 | 'ID' => $post_id, 265 | 'post_title' => 'duptitle', 266 | ]); 267 | } 268 | 269 | $this->assertQueryInCursor([ 270 | 'orderby' => 'post_title', 271 | 'order' => 'DESC', 272 | ]); 273 | } 274 | 275 | public function testPostOrderingByMetaString() 276 | { 277 | // Add post meta to created posts 278 | foreach ($this->created_post_ids as $index => $post_id) { 279 | update_post_meta( 280 | $post_id, 281 | 'test_meta', 282 | $this->formatNumber($index) 283 | ); 284 | } 285 | 286 | // Move number 19 to the second page when ordering by test_meta 287 | $this->deleteByMetaKey('test_meta', $this->formatNumber(6)); 288 | update_post_meta( 289 | $this->created_post_ids[19], 290 | 'test_meta', 291 | $this->formatNumber(6) 292 | ); 293 | 294 | $this->assertQueryInCursor([ 295 | 'orderby' => ['meta_value' => 'ASC'], 296 | 'meta_key' => 'test_meta', 297 | ]); 298 | } 299 | 300 | public function testPostOrderingByMetaDate() 301 | { 302 | // Add post meta to created posts 303 | foreach ($this->created_post_ids as $index => $post_id) { 304 | update_post_meta( 305 | $post_id, 306 | 'test_meta', 307 | $this->numberToMysqlDate($index) 308 | ); 309 | } 310 | 311 | // Move number 19 to the second page when ordering by test_meta 312 | $this->deleteByMetaKey('test_meta', $this->numberToMysqlDate(6)); 313 | update_post_meta( 314 | $this->created_post_ids[19], 315 | 'test_meta', 316 | $this->numberToMysqlDate(6) 317 | ); 318 | 319 | $this->assertQueryInCursor([ 320 | 'orderby' => ['meta_value' => 'ASC'], 321 | 'meta_key' => 'test_meta', 322 | 'meta_type' => 'DATE', 323 | ]); 324 | } 325 | 326 | public function testPostOrderingByMetaDateDESC() 327 | { 328 | // Add post meta to created posts 329 | foreach ($this->created_post_ids as $index => $post_id) { 330 | update_post_meta( 331 | $post_id, 332 | 'test_meta', 333 | $this->numberToMysqlDate($index) 334 | ); 335 | } 336 | 337 | $this->deleteByMetaKey('test_meta', $this->numberToMysqlDate(14)); 338 | update_post_meta( 339 | $this->created_post_ids[2], 340 | 'test_meta', 341 | $this->numberToMysqlDate(14) 342 | ); 343 | 344 | $this->assertQueryInCursor([ 345 | 'orderby' => ['meta_value' => 'DESC'], 346 | 'meta_key' => 'test_meta', 347 | 'meta_type' => 'DATE', 348 | ]); 349 | } 350 | 351 | public function testPostOrderingByMetaNumber() 352 | { 353 | // Add post meta to created posts 354 | foreach ($this->created_post_ids as $index => $post_id) { 355 | update_post_meta($post_id, 'test_meta', $index); 356 | } 357 | 358 | // Move number 19 to the second page when ordering by test_meta 359 | $this->deleteByMetaKey('test_meta', 6); 360 | update_post_meta($this->created_post_ids[19], 'test_meta', 6); 361 | 362 | $this->assertQueryInCursor([ 363 | 'orderby' => ['meta_value' => 'ASC'], 364 | 'meta_key' => 'test_meta', 365 | 'meta_type' => 'UNSIGNED', 366 | ]); 367 | } 368 | 369 | public function testPostOrderingByMetaNumberDESC() 370 | { 371 | // Add post meta to created posts 372 | foreach ($this->created_post_ids as $index => $post_id) { 373 | update_post_meta($post_id, 'test_meta', $index); 374 | } 375 | 376 | $this->deleteByMetaKey('test_meta', 14); 377 | update_post_meta($this->created_post_ids[2], 'test_meta', 14); 378 | 379 | $this->assertQueryInCursor([ 380 | 'orderby' => ['meta_value' => 'DESC'], 381 | 'meta_key' => 'test_meta', 382 | 'meta_type' => 'UNSIGNED', 383 | ]); 384 | } 385 | 386 | public function testPostOrderingWithMetaFiltering() 387 | { 388 | // Add post meta to created posts 389 | foreach ($this->created_post_ids as $index => $post_id) { 390 | update_post_meta($post_id, 'test_meta', $index); 391 | } 392 | 393 | // Move number 2 to the second page when ordering by test_meta 394 | $this->deleteByMetaKey('test_meta', 15); 395 | update_post_meta($this->created_post_ids[2], 'test_meta', 15); 396 | 397 | $this->assertQueryInCursor( 398 | [ 399 | 'orderby' => ['meta_value' => 'ASC'], 400 | 'meta_key' => 'test_meta', 401 | 'meta_type' => 'UNSIGNED', 402 | 'meta_query' => [ 403 | [ 404 | 'key' => 'test_meta', 405 | 'compare' => '>', 406 | 'value' => 10, 407 | 'type' => 'UNSIGNED', 408 | ], 409 | ], 410 | ], 411 | 3 412 | ); 413 | } 414 | 415 | public function testPostOrderingByMetaQueryClause() 416 | { 417 | foreach ($this->created_post_ids as $index => $post_id) { 418 | update_post_meta( 419 | $post_id, 420 | 'test_meta', 421 | $this->formatNumber($index) 422 | ); 423 | } 424 | 425 | // Move number 19 to the second page when ordering by test_meta 426 | $this->deleteByMetaKey('test_meta', $this->formatNumber(6)); 427 | update_post_meta( 428 | $this->created_post_ids[19], 429 | 'test_meta', 430 | $this->formatNumber(6) 431 | ); 432 | 433 | $this->assertQueryInCursor([ 434 | 'orderby' => ['test_clause' => 'ASC'], 435 | 'meta_query' => [ 436 | 'test_clause' => [ 437 | 'key' => 'test_meta', 438 | 'compare' => 'EXISTS', 439 | ], 440 | ], 441 | ]); 442 | } 443 | 444 | public function testPostOrderingByMetaQueryClauseString() 445 | { 446 | foreach ($this->created_post_ids as $index => $post_id) { 447 | update_post_meta( 448 | $post_id, 449 | 'test_meta', 450 | $this->formatNumber($index) 451 | ); 452 | } 453 | 454 | // Move number 19 to the second page when ordering by test_meta 455 | $this->deleteByMetaKey('test_meta', $this->formatNumber(6)); 456 | update_post_meta( 457 | $this->created_post_ids[19], 458 | 'test_meta', 459 | $this->formatNumber(6) 460 | ); 461 | 462 | $this->assertQueryInCursor([ 463 | 'orderby' => 'test_clause', 464 | 'order' => 'ASC', 465 | 'meta_query' => [ 466 | 'test_clause' => [ 467 | 'key' => 'test_meta', 468 | 'compare' => 'EXISTS', 469 | ], 470 | ], 471 | ]); 472 | } 473 | 474 | /** 475 | * When ordering posts with the same meta value the returned order can vary if 476 | * there isn't a second ordering field. This test does not fail every time 477 | * so it tries to execute the assertion multiple times to make happen more often 478 | */ 479 | public function testPostOrderingStability() 480 | { 481 | add_filter('is_graphql_request', '__return_true'); 482 | 483 | foreach ($this->created_post_ids as $index => $post_id) { 484 | update_post_meta( 485 | $post_id, 486 | 'test_meta', 487 | $this->numberToMysqlDate($index) 488 | ); 489 | } 490 | 491 | update_post_meta( 492 | $this->created_post_ids[19], 493 | 'test_meta', 494 | $this->numberToMysqlDate(6) 495 | ); 496 | 497 | $this->assertQueryInCursor([ 498 | 'orderby' => ['meta_value' => 'ASC'], 499 | 'meta_key' => 'test_meta', 500 | 'meta_type' => 'DATE', 501 | ]); 502 | 503 | update_post_meta( 504 | $this->created_post_ids[17], 505 | 'test_meta', 506 | $this->numberToMysqlDate(6) 507 | ); 508 | 509 | $this->assertQueryInCursor([ 510 | 'orderby' => ['meta_value' => 'ASC'], 511 | 'meta_key' => 'test_meta', 512 | 'meta_type' => 'DATE', 513 | ]); 514 | 515 | update_post_meta( 516 | $this->created_post_ids[18], 517 | 'test_meta', 518 | $this->numberToMysqlDate(6) 519 | ); 520 | 521 | $this->assertQueryInCursor([ 522 | 'orderby' => ['meta_value' => 'ASC'], 523 | 'meta_key' => 'test_meta', 524 | 'meta_type' => 'DATE', 525 | ]); 526 | } 527 | 528 | /** 529 | * Test support for meta_value_num 530 | */ 531 | public function testPostOrderingByMetaValueNum() 532 | { 533 | // Add post meta to created posts 534 | foreach ($this->created_post_ids as $index => $post_id) { 535 | update_post_meta($post_id, 'test_meta', $index); 536 | } 537 | 538 | // Move number 19 to the second page when ordering by test_meta 539 | $this->deleteByMetaKey('test_meta', 6); 540 | update_post_meta($this->created_post_ids[19], 'test_meta', 6); 541 | 542 | $this->deleteByMetaKey('test_meta', 16); 543 | update_post_meta($this->created_post_ids[2], 'test_meta', 16); 544 | 545 | $this->assertQueryInCursor([ 546 | 'orderby' => 'meta_value_num', 547 | 'order' => 'ASC', 548 | 'meta_key' => 'test_meta', 549 | ]); 550 | } 551 | } 552 | -------------------------------------------------------------------------------- /tests/wpunit/PostPaginationTest.php: -------------------------------------------------------------------------------- 1 | true, 11 | 'labels' => [ 12 | 'menu_name' => __('Docs', 'your-textdomain'), 13 | ], 14 | 'supports' => ['title'], 15 | 'show_in_graphql' => true, 16 | 'hierarchical' => true, 17 | 'graphql_single_name' => 'testCpt', 18 | 'graphql_plural_name' => 'testCpts', 19 | ]); 20 | } 21 | 22 | public function tearDown(): void 23 | { 24 | parent::tearDown(); 25 | \WPGraphQL::clear_schema(); 26 | } 27 | 28 | function createPosts($count, $args = []) 29 | { 30 | $title_prefix = 'Post'; 31 | 32 | if (isset($args['title_prefix'])) { 33 | $title_prefix = $args['title_prefix']; 34 | unset($args['title_prefix']); 35 | } 36 | 37 | foreach (range(1, $count) as $number) { 38 | $number = str_pad($number, 2, '0', STR_PAD_LEFT); 39 | self::factory()->post->create( 40 | array_merge( 41 | [ 42 | 'post_title' => "$title_prefix $number", 43 | ], 44 | $args 45 | ) 46 | ); 47 | } 48 | } 49 | 50 | public function testContentNodesCanLimitPostsPerPAge() 51 | { 52 | $this->createPosts(10); 53 | 54 | $res = graphql([ 55 | 'query' => ' 56 | query Posts { 57 | contentNodes(where: { 58 | orderby: {field: TITLE, order: ASC}, 59 | offsetPagination: {size: 5} 60 | }) { 61 | nodes { 62 | ... on Post { 63 | title 64 | } 65 | } 66 | } 67 | } 68 | ', 69 | ]); 70 | 71 | $nodes = $res['data']['contentNodes']['nodes']; 72 | $titles = \wp_list_pluck($nodes, 'title'); 73 | 74 | $this->assertEquals($titles, [ 75 | 'Post 01', 76 | 'Post 02', 77 | 'Post 03', 78 | 'Post 04', 79 | 'Post 05', 80 | ]); 81 | } 82 | 83 | public function testContentNodesCanReadTotalViaPageInfo() 84 | { 85 | $this->createPosts(10); 86 | 87 | $res = graphql([ 88 | 'query' => ' 89 | query Posts { 90 | contentNodes(where: { 91 | orderby: {field: TITLE, order: ASC}, 92 | offsetPagination: {size: 5} 93 | }) { 94 | pageInfo { 95 | offsetPagination { 96 | total 97 | } 98 | } 99 | nodes { 100 | ... on Post { 101 | title 102 | } 103 | } 104 | } 105 | } 106 | ', 107 | ]); 108 | 109 | $total = 110 | $res['data']['contentNodes']['pageInfo']['offsetPagination'][ 111 | 'total' 112 | ]; 113 | $this->assertEquals(10, $total); 114 | } 115 | 116 | public function testContentNodesCanMoveOffset() 117 | { 118 | $this->createPosts(10); 119 | 120 | $res = graphql([ 121 | 'query' => ' 122 | query Posts { 123 | contentNodes(where: { 124 | orderby: {field: TITLE, order: ASC}, 125 | offsetPagination: {size: 5, offset: 1} 126 | }) { 127 | nodes { 128 | ... on Post { 129 | title 130 | } 131 | } 132 | } 133 | } 134 | ', 135 | ]); 136 | 137 | $nodes = $res['data']['contentNodes']['nodes']; 138 | $titles = \wp_list_pluck($nodes, 'title'); 139 | 140 | $this->assertEquals(5, count($titles)); 141 | $this->assertEquals( 142 | ['Post 02', 'Post 03', 'Post 04', 'Post 05', 'Post 06'], 143 | $titles 144 | ); 145 | } 146 | 147 | public function testConentNodesCanHavePageBiggerThanDefaultCursor() 148 | { 149 | $this->createPosts(25); 150 | 151 | $res = graphql([ 152 | 'query' => ' 153 | query Posts { 154 | contentNodes(where: { 155 | orderby: {field: TITLE, order: ASC}, 156 | offsetPagination: {size: 15, offset: 5} 157 | }) { 158 | nodes { 159 | ... on Post { 160 | title 161 | } 162 | } 163 | } 164 | } 165 | ', 166 | ]); 167 | 168 | $nodes = $res['data']['contentNodes']['nodes']; 169 | $titles = \wp_list_pluck($nodes, 'title'); 170 | 171 | $this->assertEquals(15, count($titles)); 172 | $this->assertEquals( 173 | [ 174 | 'Post 06', 175 | 'Post 07', 176 | 'Post 08', 177 | 'Post 09', 178 | 'Post 10', 179 | 'Post 11', 180 | 'Post 12', 181 | 'Post 13', 182 | 'Post 14', 183 | 'Post 15', 184 | 'Post 16', 185 | 'Post 17', 186 | 'Post 18', 187 | 'Post 19', 188 | 'Post 20', 189 | ], 190 | $titles 191 | ); 192 | } 193 | 194 | public function testContentNodesCanMixPostTypes() 195 | { 196 | $this->createPosts(10); 197 | $this->createPosts(10, [ 198 | 'post_type' => 'page', 199 | 'title_prefix' => 'Page', 200 | ]); 201 | 202 | $res = graphql([ 203 | 'query' => ' 204 | query Posts { 205 | contentNodes(where: { 206 | orderby: {field: TITLE, order: ASC}, 207 | offsetPagination: {size: 15, offset: 5} 208 | }) { 209 | nodes { 210 | ... on Post { 211 | title 212 | } 213 | ... on Page { 214 | title 215 | } 216 | } 217 | } 218 | } 219 | ', 220 | ]); 221 | 222 | $nodes = $res['data']['contentNodes']['nodes']; 223 | $titles = \wp_list_pluck($nodes, 'title'); 224 | 225 | $this->assertEquals(15, count($titles)); 226 | $this->assertEquals( 227 | [ 228 | 'Page 06', 229 | 'Page 07', 230 | 'Page 08', 231 | 'Page 09', 232 | 'Page 10', 233 | 'Post 01', 234 | 'Post 02', 235 | 'Post 03', 236 | 'Post 04', 237 | 'Post 05', 238 | 'Post 06', 239 | 'Post 07', 240 | 'Post 08', 241 | 'Post 09', 242 | 'Post 10', 243 | ], 244 | $titles 245 | ); 246 | } 247 | 248 | public function testContentNodesCanFilterAndPaginate() 249 | { 250 | $this->createPosts(10); 251 | $this->createPosts(10, [ 252 | 'post_type' => 'page', 253 | 'title_prefix' => 'Page', 254 | ]); 255 | 256 | $res = graphql([ 257 | 'query' => ' 258 | query Posts { 259 | contentNodes(where: { 260 | orderby: {field: TITLE, order: ASC}, 261 | offsetPagination: {size: 5, offset: 2}, 262 | contentTypes: [POST] 263 | }) { 264 | nodes { 265 | ... on Post { 266 | title 267 | } 268 | ... on Page { 269 | title 270 | } 271 | } 272 | } 273 | } 274 | ', 275 | ]); 276 | 277 | $nodes = $res['data']['contentNodes']['nodes']; 278 | $titles = \wp_list_pluck($nodes, 'title'); 279 | 280 | $this->assertEquals(5, count($titles)); 281 | $this->assertEquals( 282 | ['Post 03', 'Post 04', 'Post 05', 'Post 06', 'Post 07'], 283 | $titles 284 | ); 285 | } 286 | 287 | public function testPostsCanLimitPostsPerPage() 288 | { 289 | $this->createPosts(10); 290 | 291 | $res = graphql([ 292 | 'query' => ' 293 | query Posts { 294 | posts(where: { 295 | orderby: {field: TITLE, order: ASC}, 296 | offsetPagination: {size: 5} 297 | }) { 298 | nodes { 299 | title 300 | } 301 | } 302 | } 303 | ', 304 | ]); 305 | 306 | $nodes = $res['data']['posts']['nodes']; 307 | $titles = \wp_list_pluck($nodes, 'title'); 308 | 309 | $this->assertEquals($titles, [ 310 | 'Post 01', 311 | 'Post 02', 312 | 'Post 03', 313 | 'Post 04', 314 | 'Post 05', 315 | ]); 316 | } 317 | 318 | public function testPostsMoveOffset() 319 | { 320 | $this->createPosts(10); 321 | 322 | $res = graphql([ 323 | 'query' => ' 324 | query Posts { 325 | posts(where: { 326 | orderby: {field: TITLE, order: ASC}, 327 | offsetPagination: {size: 5, offset: 2} 328 | }) { 329 | nodes { 330 | title 331 | } 332 | } 333 | } 334 | ', 335 | ]); 336 | 337 | $nodes = $res['data']['posts']['nodes']; 338 | $titles = \wp_list_pluck($nodes, 'title'); 339 | 340 | $this->assertEquals($titles, [ 341 | 'Post 03', 342 | 'Post 04', 343 | 'Post 05', 344 | 'Post 06', 345 | 'Post 07', 346 | ]); 347 | } 348 | 349 | public function testCPTCanLimitPostsPerPage() 350 | { 351 | $this->createPosts(10, [ 352 | 'post_type' => 'test_cpt', 353 | 'title_prefix' => 'Test CPT', 354 | ]); 355 | 356 | $res = graphql([ 357 | 'query' => ' 358 | query Posts { 359 | testCpts(where: { 360 | orderby: {field: TITLE, order: ASC}, 361 | offsetPagination: {size: 5} 362 | }) { 363 | nodes { 364 | title 365 | } 366 | } 367 | } 368 | ', 369 | ]); 370 | 371 | $this->assertEquals('', $res['errors'][0]['message'] ?? ''); 372 | $nodes = $res['data']['testCpts']['nodes']; 373 | $titles = \wp_list_pluck($nodes, 'title'); 374 | 375 | $this->assertEquals($titles, [ 376 | 'Test CPT 01', 377 | 'Test CPT 02', 378 | 'Test CPT 03', 379 | 'Test CPT 04', 380 | 'Test CPT 05', 381 | ]); 382 | } 383 | 384 | public function testCPTCanMoveOffset() 385 | { 386 | $this->createPosts(10, [ 387 | 'post_type' => 'test_cpt', 388 | 'title_prefix' => 'Test CPT', 389 | ]); 390 | 391 | $res = graphql([ 392 | 'query' => ' 393 | query Posts { 394 | testCpts(where: { 395 | orderby: {field: TITLE, order: ASC}, 396 | offsetPagination: {size: 5, offset: 2} 397 | }) { 398 | nodes { 399 | title 400 | } 401 | } 402 | } 403 | ', 404 | ]); 405 | 406 | $nodes = $res['data']['testCpts']['nodes']; 407 | $titles = \wp_list_pluck($nodes, 'title'); 408 | 409 | $this->assertEquals($titles, [ 410 | 'Test CPT 03', 411 | 'Test CPT 04', 412 | 'Test CPT 05', 413 | 'Test CPT 06', 414 | 'Test CPT 07', 415 | ]); 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /tests/wpunit/UserPaginationTest.php: -------------------------------------------------------------------------------- 1 | user->create( 28 | array_merge( 29 | [ 30 | 'user_login' => "${name_prefix}-${number}", 31 | ], 32 | $args 33 | ) 34 | ); 35 | } 36 | } 37 | 38 | public function testUsersCanLimit() 39 | { 40 | $this->createUsers(10); 41 | wp_set_current_user(get_user_by('login', 'admin')->ID); 42 | 43 | $res = graphql([ 44 | 'query' => ' 45 | query Users { 46 | users(where: { 47 | orderby: {field: DISPLAY_NAME, order: ASC}, 48 | offsetPagination: {size: 5} 49 | }) { 50 | nodes { 51 | name 52 | } 53 | } 54 | } 55 | ', 56 | ]); 57 | 58 | $this->assertEquals('', $res['errors'][0]['message'] ?? ''); 59 | $nodes = $res['data']['users']['nodes']; 60 | $names = \wp_list_pluck($nodes, 'name'); 61 | 62 | $this->assertEquals(5, count($names)); 63 | $this->assertEquals( 64 | [ 65 | 'admin', 66 | 'test-user-01', 67 | 'test-user-02', 68 | 'test-user-03', 69 | 'test-user-04', 70 | ], 71 | $names 72 | ); 73 | } 74 | 75 | public function testUsersCannotBeReadWithoutAuth() 76 | { 77 | $this->createUsers(2); 78 | 79 | $res = graphql([ 80 | 'query' => ' 81 | query Users { 82 | users(where: { 83 | orderby: {field: DISPLAY_NAME, order: ASC}, 84 | offsetPagination: {size: 5} 85 | }) { 86 | nodes { 87 | name 88 | } 89 | } 90 | } 91 | ', 92 | ]); 93 | 94 | $this->assertEquals('', $res['errors'][0]['message'] ?? ''); 95 | $nodes = $res['data']['users']['nodes']; 96 | $names = \wp_list_pluck($nodes, 'name'); 97 | 98 | $this->assertEquals(0, count($names)); 99 | } 100 | 101 | public function testUsersSetOffset() 102 | { 103 | $this->createUsers(10); 104 | wp_set_current_user(get_user_by('login', 'admin')->ID); 105 | 106 | $res = graphql([ 107 | 'query' => ' 108 | query Users { 109 | users(where: { 110 | orderby: {field: DISPLAY_NAME, order: ASC}, 111 | offsetPagination: {size: 5, offset: 3} 112 | }) { 113 | nodes { 114 | name 115 | } 116 | } 117 | } 118 | ', 119 | ]); 120 | 121 | $this->assertEquals('', $res['errors'][0]['message'] ?? ''); 122 | $nodes = $res['data']['users']['nodes']; 123 | $names = \wp_list_pluck($nodes, 'name'); 124 | 125 | $this->assertEquals(5, count($names)); 126 | $this->assertEquals( 127 | [ 128 | 'test-user-03', 129 | 'test-user-04', 130 | 'test-user-05', 131 | 'test-user-06', 132 | 'test-user-07', 133 | ], 134 | $names 135 | ); 136 | } 137 | 138 | public function testUsersLongOffset() 139 | { 140 | $this->createUsers(20); 141 | wp_set_current_user(get_user_by('login', 'admin')->ID); 142 | 143 | $res = graphql([ 144 | 'query' => ' 145 | query Users { 146 | users(where: { 147 | orderby: {field: DISPLAY_NAME, order: ASC}, 148 | offsetPagination: {size: 5, offset: 15} 149 | }) { 150 | nodes { 151 | name 152 | } 153 | } 154 | } 155 | ', 156 | ]); 157 | 158 | $this->assertEquals('', $res['errors'][0]['message'] ?? ''); 159 | $nodes = $res['data']['users']['nodes']; 160 | $names = \wp_list_pluck($nodes, 'name'); 161 | 162 | $this->assertEquals(5, count($names)); 163 | $this->assertEquals( 164 | [ 165 | 'test-user-15', 166 | 'test-user-16', 167 | 'test-user-17', 168 | 'test-user-18', 169 | 'test-user-19', 170 | ], 171 | $names 172 | ); 173 | } 174 | 175 | public function testHasMoreWithoutOffset() 176 | { 177 | $this->createUsers(10); 178 | wp_set_current_user(1); 179 | wp_set_current_user(get_user_by('login', 'admin')->ID); 180 | 181 | $res = graphql([ 182 | 'query' => ' 183 | query Users { 184 | users(where: { 185 | orderby: {field: DISPLAY_NAME, order: ASC}, 186 | offsetPagination: {size: 5} 187 | }) { 188 | pageInfo { 189 | offsetPagination { 190 | hasMore 191 | } 192 | } 193 | nodes { 194 | name 195 | } 196 | } 197 | } 198 | ', 199 | ]); 200 | 201 | $this->assertEquals('', $res['errors'][0]['message'] ?? ''); 202 | $nodes = $res['data']['users']['nodes']; 203 | $names = \wp_list_pluck($nodes, 'name'); 204 | 205 | $this->assertEquals(5, count($names)); 206 | $this->assertEquals( 207 | [ 208 | 'admin', 209 | 'test-user-01', 210 | 'test-user-02', 211 | 'test-user-03', 212 | 'test-user-04', 213 | ], 214 | $names 215 | ); 216 | 217 | $has_more = 218 | $res['data']['users']['pageInfo']['offsetPagination']['hasMore']; 219 | $this->assertEquals(true, $has_more); 220 | } 221 | 222 | public function testHasMoreWithOffsetOne() 223 | { 224 | $this->createUsers(10); 225 | wp_set_current_user(get_user_by('login', 'admin')->ID); 226 | 227 | $res = graphql([ 228 | 'query' => ' 229 | query Users { 230 | users(where: { 231 | orderby: {field: DISPLAY_NAME, order: ASC}, 232 | offsetPagination: {size: 5, offset: 1} 233 | }) { 234 | pageInfo { 235 | offsetPagination { 236 | hasMore 237 | } 238 | } 239 | nodes { 240 | name 241 | } 242 | } 243 | } 244 | ', 245 | ]); 246 | 247 | $this->assertEquals('', $res['errors'][0]['message'] ?? ''); 248 | $nodes = $res['data']['users']['nodes']; 249 | $names = \wp_list_pluck($nodes, 'name'); 250 | 251 | $this->assertEquals(5, count($names)); 252 | $this->assertEquals( 253 | [ 254 | 'test-user-01', 255 | 'test-user-02', 256 | 'test-user-03', 257 | 'test-user-04', 258 | 'test-user-05', 259 | ], 260 | $names 261 | ); 262 | 263 | $has_more = 264 | $res['data']['users']['pageInfo']['offsetPagination']['hasMore']; 265 | $this->assertEquals(true, $has_more); 266 | } 267 | 268 | public function testHasMoreFalseAtEnd() 269 | { 270 | $this->createUsers(10); 271 | wp_set_current_user(get_user_by('login', 'admin')->ID); 272 | 273 | $res = graphql([ 274 | 'query' => ' 275 | query Users { 276 | users(where: { 277 | orderby: {field: DISPLAY_NAME, order: ASC}, 278 | offsetPagination: {size: 5, offset: 6} 279 | }) { 280 | pageInfo { 281 | offsetPagination { 282 | hasMore 283 | } 284 | } 285 | nodes { 286 | name 287 | } 288 | } 289 | } 290 | ', 291 | ]); 292 | 293 | $this->assertEquals('', $res['errors'][0]['message'] ?? ''); 294 | $nodes = $res['data']['users']['nodes']; 295 | $names = \wp_list_pluck($nodes, 'name'); 296 | 297 | $this->assertEquals( 298 | [ 299 | 'test-user-06', 300 | 'test-user-07', 301 | 'test-user-08', 302 | 'test-user-09', 303 | 'test-user-10', 304 | ], 305 | $names 306 | ); 307 | 308 | $has_more = 309 | $res['data']['users']['pageInfo']['offsetPagination']['hasMore']; 310 | $this->assertEquals(false, $has_more); 311 | } 312 | 313 | public function testCanGetTotal() 314 | { 315 | $this->createUsers(10); 316 | wp_set_current_user(get_user_by('login', 'admin')->ID); 317 | 318 | $res = graphql([ 319 | 'query' => ' 320 | query Users { 321 | users(where: { 322 | orderby: {field: DISPLAY_NAME, order: ASC}, 323 | offsetPagination: {size: 5, offset: 2} 324 | }) { 325 | pageInfo { 326 | offsetPagination { 327 | total 328 | } 329 | } 330 | nodes { 331 | name 332 | } 333 | } 334 | } 335 | ', 336 | ]); 337 | 338 | $this->assertEquals('', $res['errors'][0]['message'] ?? ''); 339 | 340 | $total = $res['data']['users']['pageInfo']['offsetPagination']['total']; 341 | 342 | $this->assertEquals(11, $total); 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /tools/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | project="valu-digital/wp-graphql-offset-pagination" 4 | version_file="plugin.php" 5 | 6 | set -eu 7 | 8 | help() { 9 | >&2 echo " 10 | Create new release. Creates git tag and github release 11 | " 12 | } 13 | 14 | if [ "${1:-}" = "-h" -o "${1:-}" = "--help" ]; then 15 | help 16 | exit 1 17 | fi 18 | 19 | if [ "$(uname)" != "Darwin" ]; then 20 | echo "Sorry, this script only works on macOS for now." 21 | exit 1 22 | fi 23 | 24 | if [ "$(git status . --porcelain)" != "" ]; then 25 | echo "Dirty git. Commit changes" 26 | exit 1 27 | fi 28 | 29 | if [ "$(git rev-parse --abbrev-ref HEAD)" != "master" ]; then 30 | echo "Not on the master branch" 31 | exit 2 32 | fi 33 | 34 | git push origin master:master 35 | 36 | current_version="$(cat $version_file | sed -En 's/.*Version: ([^ ]*)/\1/p')" 37 | 38 | echo "Current version is: $current_version" 39 | 40 | read -p "New version> " new_version 41 | 42 | 43 | sed -E -i "" "s/(.*Version:) .*/\1 ${new_version}/" $version_file 44 | 45 | rm -rf composer.lock vendor/ 46 | composer dump-autoload 47 | 48 | git add "$version_file" vendor 49 | git commit -m "Release v${new_version}" 50 | git tag -a "v${new_version}" -m "Release v${new_version}" 51 | 52 | git push origin master:master 53 | git push origin --tags 54 | git push origin master:stable -f 55 | 56 | range="v${new_version}...v${current_version}" 57 | 58 | echo 59 | echo 60 | git log --format=' - %s %h' $range | sed 1,1d 61 | echo 62 | echo 63 | echo "All changes https://github.com/${project}/compare/${range}" 64 | echo 65 | echo 66 | 67 | set -x 68 | open "https://github.com/${project}/releases/new?title=v${new_version}&tag=v${new_version}" 69 | -------------------------------------------------------------------------------- /vendor/autoload.php: -------------------------------------------------------------------------------- 1 | 7 | * Jordi Boggiano 8 | * 9 | * For the full copyright and license information, please view the LICENSE 10 | * file that was distributed with this source code. 11 | */ 12 | 13 | namespace Composer\Autoload; 14 | 15 | /** 16 | * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. 17 | * 18 | * $loader = new \Composer\Autoload\ClassLoader(); 19 | * 20 | * // register classes with namespaces 21 | * $loader->add('Symfony\Component', __DIR__.'/component'); 22 | * $loader->add('Symfony', __DIR__.'/framework'); 23 | * 24 | * // activate the autoloader 25 | * $loader->register(); 26 | * 27 | * // to enable searching the include path (eg. for PEAR packages) 28 | * $loader->setUseIncludePath(true); 29 | * 30 | * In this example, if you try to use a class in the Symfony\Component 31 | * namespace or one of its children (Symfony\Component\Console for instance), 32 | * the autoloader will first look for the class under the component/ 33 | * directory, and it will then fallback to the framework/ directory if not 34 | * found before giving up. 35 | * 36 | * This class is loosely based on the Symfony UniversalClassLoader. 37 | * 38 | * @author Fabien Potencier 39 | * @author Jordi Boggiano 40 | * @see http://www.php-fig.org/psr/psr-0/ 41 | * @see http://www.php-fig.org/psr/psr-4/ 42 | */ 43 | class ClassLoader 44 | { 45 | // PSR-4 46 | private $prefixLengthsPsr4 = array(); 47 | private $prefixDirsPsr4 = array(); 48 | private $fallbackDirsPsr4 = array(); 49 | 50 | // PSR-0 51 | private $prefixesPsr0 = array(); 52 | private $fallbackDirsPsr0 = array(); 53 | 54 | private $useIncludePath = false; 55 | private $classMap = array(); 56 | private $classMapAuthoritative = false; 57 | private $missingClasses = array(); 58 | private $apcuPrefix; 59 | 60 | public function getPrefixes() 61 | { 62 | if (!empty($this->prefixesPsr0)) { 63 | return call_user_func_array('array_merge', $this->prefixesPsr0); 64 | } 65 | 66 | return array(); 67 | } 68 | 69 | public function getPrefixesPsr4() 70 | { 71 | return $this->prefixDirsPsr4; 72 | } 73 | 74 | public function getFallbackDirs() 75 | { 76 | return $this->fallbackDirsPsr0; 77 | } 78 | 79 | public function getFallbackDirsPsr4() 80 | { 81 | return $this->fallbackDirsPsr4; 82 | } 83 | 84 | public function getClassMap() 85 | { 86 | return $this->classMap; 87 | } 88 | 89 | /** 90 | * @param array $classMap Class to filename map 91 | */ 92 | public function addClassMap(array $classMap) 93 | { 94 | if ($this->classMap) { 95 | $this->classMap = array_merge($this->classMap, $classMap); 96 | } else { 97 | $this->classMap = $classMap; 98 | } 99 | } 100 | 101 | /** 102 | * Registers a set of PSR-0 directories for a given prefix, either 103 | * appending or prepending to the ones previously set for this prefix. 104 | * 105 | * @param string $prefix The prefix 106 | * @param array|string $paths The PSR-0 root directories 107 | * @param bool $prepend Whether to prepend the directories 108 | */ 109 | public function add($prefix, $paths, $prepend = false) 110 | { 111 | if (!$prefix) { 112 | if ($prepend) { 113 | $this->fallbackDirsPsr0 = array_merge( 114 | (array) $paths, 115 | $this->fallbackDirsPsr0 116 | ); 117 | } else { 118 | $this->fallbackDirsPsr0 = array_merge( 119 | $this->fallbackDirsPsr0, 120 | (array) $paths 121 | ); 122 | } 123 | 124 | return; 125 | } 126 | 127 | $first = $prefix[0]; 128 | if (!isset($this->prefixesPsr0[$first][$prefix])) { 129 | $this->prefixesPsr0[$first][$prefix] = (array) $paths; 130 | 131 | return; 132 | } 133 | if ($prepend) { 134 | $this->prefixesPsr0[$first][$prefix] = array_merge( 135 | (array) $paths, 136 | $this->prefixesPsr0[$first][$prefix] 137 | ); 138 | } else { 139 | $this->prefixesPsr0[$first][$prefix] = array_merge( 140 | $this->prefixesPsr0[$first][$prefix], 141 | (array) $paths 142 | ); 143 | } 144 | } 145 | 146 | /** 147 | * Registers a set of PSR-4 directories for a given namespace, either 148 | * appending or prepending to the ones previously set for this namespace. 149 | * 150 | * @param string $prefix The prefix/namespace, with trailing '\\' 151 | * @param array|string $paths The PSR-4 base directories 152 | * @param bool $prepend Whether to prepend the directories 153 | * 154 | * @throws \InvalidArgumentException 155 | */ 156 | public function addPsr4($prefix, $paths, $prepend = false) 157 | { 158 | if (!$prefix) { 159 | // Register directories for the root namespace. 160 | if ($prepend) { 161 | $this->fallbackDirsPsr4 = array_merge( 162 | (array) $paths, 163 | $this->fallbackDirsPsr4 164 | ); 165 | } else { 166 | $this->fallbackDirsPsr4 = array_merge( 167 | $this->fallbackDirsPsr4, 168 | (array) $paths 169 | ); 170 | } 171 | } elseif (!isset($this->prefixDirsPsr4[$prefix])) { 172 | // Register directories for a new namespace. 173 | $length = strlen($prefix); 174 | if ('\\' !== $prefix[$length - 1]) { 175 | throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); 176 | } 177 | $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; 178 | $this->prefixDirsPsr4[$prefix] = (array) $paths; 179 | } elseif ($prepend) { 180 | // Prepend directories for an already registered namespace. 181 | $this->prefixDirsPsr4[$prefix] = array_merge( 182 | (array) $paths, 183 | $this->prefixDirsPsr4[$prefix] 184 | ); 185 | } else { 186 | // Append directories for an already registered namespace. 187 | $this->prefixDirsPsr4[$prefix] = array_merge( 188 | $this->prefixDirsPsr4[$prefix], 189 | (array) $paths 190 | ); 191 | } 192 | } 193 | 194 | /** 195 | * Registers a set of PSR-0 directories for a given prefix, 196 | * replacing any others previously set for this prefix. 197 | * 198 | * @param string $prefix The prefix 199 | * @param array|string $paths The PSR-0 base directories 200 | */ 201 | public function set($prefix, $paths) 202 | { 203 | if (!$prefix) { 204 | $this->fallbackDirsPsr0 = (array) $paths; 205 | } else { 206 | $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; 207 | } 208 | } 209 | 210 | /** 211 | * Registers a set of PSR-4 directories for a given namespace, 212 | * replacing any others previously set for this namespace. 213 | * 214 | * @param string $prefix The prefix/namespace, with trailing '\\' 215 | * @param array|string $paths The PSR-4 base directories 216 | * 217 | * @throws \InvalidArgumentException 218 | */ 219 | public function setPsr4($prefix, $paths) 220 | { 221 | if (!$prefix) { 222 | $this->fallbackDirsPsr4 = (array) $paths; 223 | } else { 224 | $length = strlen($prefix); 225 | if ('\\' !== $prefix[$length - 1]) { 226 | throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); 227 | } 228 | $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; 229 | $this->prefixDirsPsr4[$prefix] = (array) $paths; 230 | } 231 | } 232 | 233 | /** 234 | * Turns on searching the include path for class files. 235 | * 236 | * @param bool $useIncludePath 237 | */ 238 | public function setUseIncludePath($useIncludePath) 239 | { 240 | $this->useIncludePath = $useIncludePath; 241 | } 242 | 243 | /** 244 | * Can be used to check if the autoloader uses the include path to check 245 | * for classes. 246 | * 247 | * @return bool 248 | */ 249 | public function getUseIncludePath() 250 | { 251 | return $this->useIncludePath; 252 | } 253 | 254 | /** 255 | * Turns off searching the prefix and fallback directories for classes 256 | * that have not been registered with the class map. 257 | * 258 | * @param bool $classMapAuthoritative 259 | */ 260 | public function setClassMapAuthoritative($classMapAuthoritative) 261 | { 262 | $this->classMapAuthoritative = $classMapAuthoritative; 263 | } 264 | 265 | /** 266 | * Should class lookup fail if not found in the current class map? 267 | * 268 | * @return bool 269 | */ 270 | public function isClassMapAuthoritative() 271 | { 272 | return $this->classMapAuthoritative; 273 | } 274 | 275 | /** 276 | * APCu prefix to use to cache found/not-found classes, if the extension is enabled. 277 | * 278 | * @param string|null $apcuPrefix 279 | */ 280 | public function setApcuPrefix($apcuPrefix) 281 | { 282 | $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; 283 | } 284 | 285 | /** 286 | * The APCu prefix in use, or null if APCu caching is not enabled. 287 | * 288 | * @return string|null 289 | */ 290 | public function getApcuPrefix() 291 | { 292 | return $this->apcuPrefix; 293 | } 294 | 295 | /** 296 | * Registers this instance as an autoloader. 297 | * 298 | * @param bool $prepend Whether to prepend the autoloader or not 299 | */ 300 | public function register($prepend = false) 301 | { 302 | spl_autoload_register(array($this, 'loadClass'), true, $prepend); 303 | } 304 | 305 | /** 306 | * Unregisters this instance as an autoloader. 307 | */ 308 | public function unregister() 309 | { 310 | spl_autoload_unregister(array($this, 'loadClass')); 311 | } 312 | 313 | /** 314 | * Loads the given class or interface. 315 | * 316 | * @param string $class The name of the class 317 | * @return bool|null True if loaded, null otherwise 318 | */ 319 | public function loadClass($class) 320 | { 321 | if ($file = $this->findFile($class)) { 322 | includeFile($file); 323 | 324 | return true; 325 | } 326 | } 327 | 328 | /** 329 | * Finds the path to the file where the class is defined. 330 | * 331 | * @param string $class The name of the class 332 | * 333 | * @return string|false The path if found, false otherwise 334 | */ 335 | public function findFile($class) 336 | { 337 | // class map lookup 338 | if (isset($this->classMap[$class])) { 339 | return $this->classMap[$class]; 340 | } 341 | if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { 342 | return false; 343 | } 344 | if (null !== $this->apcuPrefix) { 345 | $file = apcu_fetch($this->apcuPrefix.$class, $hit); 346 | if ($hit) { 347 | return $file; 348 | } 349 | } 350 | 351 | $file = $this->findFileWithExtension($class, '.php'); 352 | 353 | // Search for Hack files if we are running on HHVM 354 | if (false === $file && defined('HHVM_VERSION')) { 355 | $file = $this->findFileWithExtension($class, '.hh'); 356 | } 357 | 358 | if (null !== $this->apcuPrefix) { 359 | apcu_add($this->apcuPrefix.$class, $file); 360 | } 361 | 362 | if (false === $file) { 363 | // Remember that this class does not exist. 364 | $this->missingClasses[$class] = true; 365 | } 366 | 367 | return $file; 368 | } 369 | 370 | private function findFileWithExtension($class, $ext) 371 | { 372 | // PSR-4 lookup 373 | $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; 374 | 375 | $first = $class[0]; 376 | if (isset($this->prefixLengthsPsr4[$first])) { 377 | $subPath = $class; 378 | while (false !== $lastPos = strrpos($subPath, '\\')) { 379 | $subPath = substr($subPath, 0, $lastPos); 380 | $search = $subPath . '\\'; 381 | if (isset($this->prefixDirsPsr4[$search])) { 382 | $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); 383 | foreach ($this->prefixDirsPsr4[$search] as $dir) { 384 | if (file_exists($file = $dir . $pathEnd)) { 385 | return $file; 386 | } 387 | } 388 | } 389 | } 390 | } 391 | 392 | // PSR-4 fallback dirs 393 | foreach ($this->fallbackDirsPsr4 as $dir) { 394 | if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { 395 | return $file; 396 | } 397 | } 398 | 399 | // PSR-0 lookup 400 | if (false !== $pos = strrpos($class, '\\')) { 401 | // namespaced class name 402 | $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) 403 | . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); 404 | } else { 405 | // PEAR-like class name 406 | $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; 407 | } 408 | 409 | if (isset($this->prefixesPsr0[$first])) { 410 | foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { 411 | if (0 === strpos($class, $prefix)) { 412 | foreach ($dirs as $dir) { 413 | if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { 414 | return $file; 415 | } 416 | } 417 | } 418 | } 419 | } 420 | 421 | // PSR-0 fallback dirs 422 | foreach ($this->fallbackDirsPsr0 as $dir) { 423 | if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { 424 | return $file; 425 | } 426 | } 427 | 428 | // PSR-0 include paths. 429 | if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { 430 | return $file; 431 | } 432 | 433 | return false; 434 | } 435 | } 436 | 437 | /** 438 | * Scope isolated include. 439 | * 440 | * Prevents access to $this/self from included files. 441 | */ 442 | function includeFile($file) 443 | { 444 | include $file; 445 | } 446 | -------------------------------------------------------------------------------- /vendor/composer/LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright (c) Nils Adermann, Jordi Boggiano 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished 9 | to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /vendor/composer/autoload_classmap.php: -------------------------------------------------------------------------------- 1 | $baseDir . '/src/Loader.php', 10 | ); 11 | -------------------------------------------------------------------------------- /vendor/composer/autoload_namespaces.php: -------------------------------------------------------------------------------- 1 | array($baseDir . '/src'), 10 | ); 11 | -------------------------------------------------------------------------------- /vendor/composer/autoload_real.php: -------------------------------------------------------------------------------- 1 | = 50600 && !defined('HHVM_VERSION') && (!function_exists('zend_loader_file_encoded') || !zend_loader_file_encoded()); 27 | if ($useStaticLoader) { 28 | require_once __DIR__ . '/autoload_static.php'; 29 | 30 | call_user_func(\Composer\Autoload\ComposerStaticInite59a3a397b95a76817a0e6c0a56a9eec::getInitializer($loader)); 31 | } else { 32 | $map = require __DIR__ . '/autoload_namespaces.php'; 33 | foreach ($map as $namespace => $path) { 34 | $loader->set($namespace, $path); 35 | } 36 | 37 | $map = require __DIR__ . '/autoload_psr4.php'; 38 | foreach ($map as $namespace => $path) { 39 | $loader->setPsr4($namespace, $path); 40 | } 41 | 42 | $classMap = require __DIR__ . '/autoload_classmap.php'; 43 | if ($classMap) { 44 | $loader->addClassMap($classMap); 45 | } 46 | } 47 | 48 | $loader->register(true); 49 | 50 | return $loader; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /vendor/composer/autoload_static.php: -------------------------------------------------------------------------------- 1 | 11 | array ( 12 | 'WPGraphQL\\Extensions\\OffsetPagination\\' => 38, 13 | ), 14 | ); 15 | 16 | public static $prefixDirsPsr4 = array ( 17 | 'WPGraphQL\\Extensions\\OffsetPagination\\' => 18 | array ( 19 | 0 => __DIR__ . '/../..' . '/src', 20 | ), 21 | ); 22 | 23 | public static $classMap = array ( 24 | 'WPGraphQL\\Extensions\\OffsetPagination\\Loader' => __DIR__ . '/../..' . '/src/Loader.php', 25 | ); 26 | 27 | public static function getInitializer(ClassLoader $loader) 28 | { 29 | return \Closure::bind(function () use ($loader) { 30 | $loader->prefixLengthsPsr4 = ComposerStaticInite59a3a397b95a76817a0e6c0a56a9eec::$prefixLengthsPsr4; 31 | $loader->prefixDirsPsr4 = ComposerStaticInite59a3a397b95a76817a0e6c0a56a9eec::$prefixDirsPsr4; 32 | $loader->classMap = ComposerStaticInite59a3a397b95a76817a0e6c0a56a9eec::$classMap; 33 | 34 | }, null, ClassLoader::class); 35 | } 36 | } 37 | --------------------------------------------------------------------------------