├── .editorconfig ├── .github ├── CODEOWNERS └── workflows │ └── main.yml ├── .gitignore ├── .phpcs.xml.dist ├── .phpunit-watcher.yml ├── .vscode ├── launch.json └── settings.json ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bin ├── install-wp-tests.sh ├── setup-wp └── wait-for-it.sh ├── cloud-build └── pull-requests.yaml ├── composer.json ├── composer.lock ├── docker-compose.yml ├── docs └── img │ ├── filtered-simple-clothing.png │ ├── query-1.png │ ├── query-2.png │ ├── results-of-query-or-example.png │ ├── sandals-results.png │ └── simple-clothing-store.png ├── phpunit.xml.dist ├── readme.txt ├── sonar-project.properties ├── src ├── aggregate-query.php ├── filter-exception.php └── filter-query.php ├── tests ├── bootstrap.php ├── src │ ├── aggregate-query.test.php │ └── filter-query.test.php └── wp-graphql-filter-query.test.php └── wp-graphql-filter-query.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # https://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = tab 15 | indent_size = 4 16 | 17 | [{.jshintrc,*.json,*.yml}] 18 | indent_style = space 19 | indent_size = 2 -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @wpengine/orion -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | types: [opened, synchronize, reopened] 7 | 8 | name: Main Workflow 9 | jobs: 10 | sonarqube: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | # Disabling shallow clone is recommended for improving relevancy of reporting 16 | fetch-depth: 0 17 | - name: SonarQube Scan 18 | uses: sonarsource/sonarqube-scan-action@master 19 | env: 20 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 21 | SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} 22 | - name: SonarQube Quality Gate check 23 | uses: sonarsource/sonarqube-quality-gate-action@master 24 | # Force to fail step after specific time 25 | timeout-minutes: 5 26 | env: 27 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | 3 | .idea/ 4 | wordpress 5 | .wordpress/ 6 | .vscode/ 7 | -------------------------------------------------------------------------------- /.phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | Generally-applicable sniffs for WordPress plugins. 4 | 5 | . 6 | 7 | 8 | /vendor/ 9 | /.wordpress/ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | /tests/ 62 | 63 | 64 | 65 | 66 | /tests/ 67 | 68 | 69 | 70 | 71 | /tests/ 72 | 73 | 74 | 75 | 76 | /tests/ 77 | 78 | 79 | 80 | 81 | /tests/ 82 | 83 | 84 | 85 | 86 | /tests/ 87 | 88 | 89 | 90 | 91 | /tests/ 92 | 93 | 94 | 95 | 96 | /tests/ 97 | 98 | 99 | 100 | 101 | /tests/ 102 | 103 | 104 | 105 | 106 | /tests/ 107 | 108 | 109 | 110 | 111 | /tests/ 112 | 113 | 114 | 115 | 116 | /tests/ 117 | 118 | 119 | -------------------------------------------------------------------------------- /.phpunit-watcher.yml: -------------------------------------------------------------------------------- 1 | watch: 2 | directories: 3 | - src 4 | - tests 5 | exclude: 6 | - lib 7 | fileMask: '*.php' 8 | ignoreDotFiles: true 9 | ignoreVCS: true 10 | ignoreVCSIgnored: false 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Listen for Xdebug", 6 | "type": "php", 7 | "request": "launch", 8 | "port": 9003, 9 | "xdebugSettings": { 10 | "max_children": 128, 11 | "max_data": 1024, 12 | "max_depth": 3, 13 | "show_hidden": 1 14 | }, 15 | "pathMappings": { 16 | "/var/www/html/wp-content/plugins/wp-graphql-filter-query": "${workspaceFolder}", 17 | "/var/www/html": "${workspaceFolder}/.wordpress/wordpress", 18 | } 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "phpcs.standard": "./.phpcs.xml.dist", 3 | "phpcs.ignorePatterns": ["vendor"], 4 | "phpcs.executablePath": "./vendor/bin/phpcs", 5 | "phpcbf.onsave": true, 6 | "phpcbf.executablePath": "vendor/bin/phpcbf", 7 | "phpcbf.configSearch": true, 8 | "phpSniffer.autoDetect": true, 9 | "editor.formatOnSaveTimeout": 5000, 10 | "phpcbf.debug": true, 11 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to WPGraphQL Filter Query 2 | 3 | Thank you for your interest in contributing. We welcome bug reports, feature suggestions and pull requests. 4 | 5 | ## 1. First, Search For Duplicates 6 | 7 | [Search the existing issues](https://github.com/wpengine/wp-graphql-filter-query/search?type=Issues) before logging a new one. 8 | 9 | Some search tips: 10 | 11 | - _Don't_ restrict your search to only open issues. An issue with a title similar to yours may have been closed as a duplicate of one with a less-findable title. 12 | - Search for the title of the issue you're about to log. This sounds obvious but 80% of the time this is sufficient to find a duplicate when one exists. 13 | - Read more than the first page of results. Many bugs here use the same words so relevancy sorting is not particularly strong. 14 | - If you have a crash, search for the first few topmost function names shown in the call stack. 15 | 16 | ## 2. Did You Find A Bug? 17 | 18 | When logging a bug, please be sure to include the following: 19 | 20 | - What version of the package/plugin are you using 21 | - If at all possible, an _isolated_ way to reproduce the behavior 22 | - The behavior you expect to see, and the actual behavior 23 | 24 | ## 3. Do You Have A Suggestion? 25 | 26 | We also accept suggestions in the issue tracker. Be sure to [search](https://github.com/wpengine/wp-graphql-filter-query/search?type=issues) first. 27 | 28 | In general, things we find useful when reviewing suggestions are: 29 | 30 | - A description of the problem you're trying to solve 31 | - An overview of the suggested solution 32 | - Examples of how the suggestion would work in various places 33 | - Code examples showing e.g. "this would be an error, this wouldn't" 34 | - Code examples showing usage (if possible) 35 | - If relevant, precedent in other frameworks or libraries can be useful for establishing context and expected behavior 36 | 37 | # Instructions For Contributing Code 38 | 39 | ## What You'll Need 40 | 41 | 0. [A bug or feature you want to work on](https://github.com/wpengine/wp-graphql-filter-query/labels/help%20wanted)! 42 | 1. [A GitHub account](https://github.com/join). 43 | 2. A working copy of the code. 44 | 45 | ## Housekeeping 46 | 47 | Your pull request should: 48 | 49 | - Include a description of what your change intends to do 50 | - Be based on reasonably recent commit in the **main** branch 51 | - Include adequate tests 52 | - At least one test should fail in the absence of your non-test code changes. If your PR does not match this criteria, please specify why 53 | - Tests should include reasonable permutations of the target fix/change 54 | - Include baseline changes with your change 55 | - Contain proper [semantic commit messages](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716#gistcomment-3711094) as follows: 56 | 57 | ``` 58 | []: () 59 | │ │ | │ 60 | | | | └─> Summary in present tense. Not capitalized. No period at the end. 61 | | | | 62 | │ │ └─> Issue # (optional): Issue number if related to bug database. 63 | │ │ 64 | │ └─> Scope (optional): eg. common, compiler, authentication, core 65 | │ 66 | └─> Type: chore, docs, feat, fix, refactor, style, or test. 67 | ``` 68 | 69 | - To avoid line ending issues, set `autocrlf = input` and `whitespace = cr-at-eol` in your git configuration 70 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PHP_VERSION=7.4 2 | ARG WORDPRESS_VERSION=5.9.3 3 | 4 | FROM wordpress:${WORDPRESS_VERSION}-php${PHP_VERSION}-apache 5 | 6 | ARG PLUGIN_NAME=wp-graphql-filter-query 7 | ARG php_ini_file_path="/usr/local/etc/php/php.ini" 8 | 9 | # Setup the OS 10 | RUN apt-get -qq update ; apt-get -y install unzip curl sudo subversion mariadb-client less \ 11 | && apt-get autoclean \ 12 | && chsh -s /bin/bash www-data 13 | 14 | # Install wp-cli 15 | RUN curl https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar > /usr/local/bin/wp-cli.phar \ 16 | && echo "#!/bin/bash" > /usr/local/bin/wp-cli \ 17 | && echo "su www-data -c \"/usr/local/bin/wp-cli.phar --path=/var/www/html \$*\"" >> /usr/local/bin/wp \ 18 | && chmod 755 /usr/local/bin/wp* \ 19 | && echo "*** wp command installed" 20 | 21 | # Install composer 22 | RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \ 23 | && php composer-setup.php \ 24 | && php -r "unlink('composer-setup.php');" \ 25 | && mv composer.phar /usr/local/bin/ \ 26 | && echo "#!/bin/bash" > /usr/local/bin/composer \ 27 | && echo "su www-data -c \"/usr/local/bin/composer.phar --working-dir=/var/www/html/wp-content/plugins/${PLUGIN_NAME} \$*\"" >> /usr/local/bin/composer \ 28 | && chmod ugo+x /usr/local/bin/composer \ 29 | && echo "*** composer command installed" 30 | 31 | # Create testing environment 32 | COPY bin/install-wp-tests.sh /usr/local/bin/ 33 | RUN echo "#!/bin/bash" > /usr/local/bin/install-wp-tests \ 34 | && echo "su www-data -c \"install-wp-tests.sh \${WORDPRESS_DB_NAME}_test root root \${WORDPRESS_DB_HOST} latest\"" >> /usr/local/bin/install-wp-tests \ 35 | && chmod ugo+x /usr/local/bin/install-wp-test* \ 36 | && su www-data -c "/usr/local/bin/install-wp-tests.sh ${WORDPRESS_DB_NAME}_test root root '' latest true" \ 37 | && echo "*** install-wp-tests installed" 38 | 39 | # enable xdebug for local env 40 | RUN pecl install xdebug \ 41 | && cp /usr/local/etc/php/php.ini-development $php_ini_file_path \ 42 | && printf ' \n\ 43 | [xdebug] \n\ 44 | zend_extension='$(find / -iname *xdebug.so)'\n\ 45 | xdebug.idekey = PHPSTORM \n\ 46 | xdebug.mode=debug \n\ 47 | xdebug.discover_client_host = 1 \n\ 48 | xdebug.log="/var/www/html/xdebug.log" \n\ 49 | xdebug.client_host = "host.docker.internal" \n\ 50 | max_execution_time = 0 \n\ 51 | ' >> $php_ini_file_path 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | 61 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 62 | 63 | 0. This License applies to any program or other work which contains 64 | a notice placed by the copyright holder saying it may be distributed 65 | under the terms of this General Public License. The "Program", below, 66 | refers to any such program or work, and a "work based on the Program" 67 | means either the Program or any derivative work under copyright law: 68 | that is to say, a work containing the Program or a portion of it, 69 | either verbatim or with modifications and/or translated into another 70 | language. (Hereinafter, translation is included without limitation in 71 | the term "modification".) Each licensee is addressed as "you". 72 | 73 | Activities other than copying, distribution and modification are not 74 | covered by this License; they are outside its scope. The act of 75 | running the Program is not restricted, and the output from the Program 76 | is covered only if its contents constitute a work based on the 77 | Program (independent of having been made by running the Program). 78 | Whether that is true depends on what the Program does. 79 | 80 | 1. You may copy and distribute verbatim copies of the Program's 81 | source code as you receive it, in any medium, provided that you 82 | conspicuously and appropriately publish on each copy an appropriate 83 | copyright notice and disclaimer of warranty; keep intact all the 84 | notices that refer to this License and to the absence of any warranty; 85 | and give any other recipients of the Program a copy of this License 86 | along with the Program. 87 | 88 | You may charge a fee for the physical act of transferring a copy, and 89 | you may at your option offer warranty protection in exchange for a fee. 90 | 91 | 2. You may modify your copy or copies of the Program or any portion 92 | of it, thus forming a work based on the Program, and copy and 93 | distribute such modifications or work under the terms of Section 1 94 | above, provided that you also meet all of these conditions: 95 | 96 | 97 | a) You must cause the modified files to carry prominent notices 98 | stating that you changed the files and the date of any change. 99 | 100 | b) You must cause any work that you distribute or publish, that in 101 | whole or in part contains or is derived from the Program or any 102 | part thereof, to be licensed as a whole at no charge to all third 103 | parties under the terms of this License. 104 | 105 | c) If the modified program normally reads commands interactively 106 | when run, you must cause it, when started running for such 107 | interactive use in the most ordinary way, to print or display an 108 | announcement including an appropriate copyright notice and a 109 | notice that there is no warranty (or else, saying that you provide 110 | a warranty) and that users may redistribute the program under 111 | these conditions, and telling the user how to view a copy of this 112 | License. (Exception: if the Program itself is interactive but 113 | does not normally print such an announcement, your work based on 114 | the Program is not required to print an announcement.) 115 | 116 | These requirements apply to the modified work as a whole. If 117 | identifiable sections of that work are not derived from the Program, 118 | and can be reasonably considered independent and separate works in 119 | themselves, then this License, and its terms, do not apply to those 120 | sections when you distribute them as separate works. But when you 121 | distribute the same sections as part of a whole which is a work based 122 | on the Program, the distribution of the whole must be on the terms of 123 | this License, whose permissions for other licensees extend to the 124 | entire whole, and thus to each and every part regardless of who wrote it. 125 | 126 | Thus, it is not the intent of this section to claim rights or contest 127 | your rights to work written entirely by you; rather, the intent is to 128 | exercise the right to control the distribution of derivative or 129 | collective works based on the Program. 130 | 131 | In addition, mere aggregation of another work not based on the Program 132 | with the Program (or with a work based on the Program) on a volume of 133 | a storage or distribution medium does not bring the other work under 134 | the scope of this License. 135 | 136 | 3. You may copy and distribute the Program (or a work based on it, 137 | under Section 2) in object code or executable form under the terms of 138 | Sections 1 and 2 above provided that you also do one of the following: 139 | 140 | 141 | a) Accompany it with the complete corresponding machine-readable 142 | source code, which must be distributed under the terms of Sections 143 | 1 and 2 above on a medium customarily used for software interchange; or, 144 | 145 | b) Accompany it with a written offer, valid for at least three 146 | years, to give any third party, for a charge no more than your 147 | cost of physically performing source distribution, a complete 148 | machine-readable copy of the corresponding source code, to be 149 | distributed under the terms of Sections 1 and 2 above on a medium 150 | customarily used for software interchange; or, 151 | 152 | c) Accompany it with the information you received as to the offer 153 | to distribute corresponding source code. (This alternative is 154 | allowed only for noncommercial distribution and only if you 155 | received the program in object code or executable form with such 156 | an offer, in accord with Subsection b above.) 157 | 158 | The source code for a work means the preferred form of the work for 159 | making modifications to it. For an executable work, complete source 160 | code means all the source code for all modules it contains, plus any 161 | associated interface definition files, plus the scripts used to 162 | control compilation and installation of the executable. However, as a 163 | special exception, the source code distributed need not include 164 | anything that is normally distributed (in either source or binary 165 | form) with the major components (compiler, kernel, and so on) of the 166 | operating system on which the executable runs, unless that component 167 | itself accompanies the executable. 168 | 169 | If distribution of executable or object code is made by offering 170 | access to copy from a designated place, then offering equivalent 171 | access to copy the source code from the same place counts as 172 | distribution of the source code, even though third parties are not 173 | compelled to copy the source along with the object code. 174 | 175 | 4. You may not copy, modify, sublicense, or distribute the Program 176 | except as expressly provided under this License. Any attempt 177 | otherwise to copy, modify, sublicense or distribute the Program is 178 | void, and will automatically terminate your rights under this License. 179 | However, parties who have received copies, or rights, from you under 180 | this License will not have their licenses terminated so long as such 181 | parties remain in full compliance. 182 | 183 | 5. You are not required to accept this License, since you have not 184 | signed it. However, nothing else grants you permission to modify or 185 | distribute the Program or its derivative works. These actions are 186 | prohibited by law if you do not accept this License. Therefore, by 187 | modifying or distributing the Program (or any work based on the 188 | Program), you indicate your acceptance of this License to do so, and 189 | all its terms and conditions for copying, distributing or modifying 190 | the Program or works based on it. 191 | 192 | 6. Each time you redistribute the Program (or any work based on the 193 | Program), the recipient automatically receives a license from the 194 | original licensor to copy, distribute or modify the Program subject to 195 | these terms and conditions. You may not impose any further 196 | restrictions on the recipients' exercise of the rights granted herein. 197 | You are not responsible for enforcing compliance by third parties to 198 | this License. 199 | 200 | 7. If, as a consequence of a court judgment or allegation of patent 201 | infringement or for any other reason (not limited to patent issues), 202 | conditions are imposed on you (whether by court order, agreement or 203 | otherwise) that contradict the conditions of this License, they do not 204 | excuse you from the conditions of this License. If you cannot 205 | distribute so as to satisfy simultaneously your obligations under this 206 | License and any other pertinent obligations, then as a consequence you 207 | may not distribute the Program at all. For example, if a patent 208 | license would not permit royalty-free redistribution of the Program by 209 | all those who receive copies directly or indirectly through you, then 210 | the only way you could satisfy both it and this License would be to 211 | refrain entirely from distribution of the Program. 212 | 213 | If any portion of this section is held invalid or unenforceable under 214 | any particular circumstance, the balance of the section is intended to 215 | apply and the section as a whole is intended to apply in other 216 | circumstances. 217 | 218 | It is not the purpose of this section to induce you to infringe any 219 | patents or other property right claims or to contest validity of any 220 | such claims; this section has the sole purpose of protecting the 221 | integrity of the free software distribution system, which is 222 | implemented by public license practices. Many people have made 223 | generous contributions to the wide range of software distributed 224 | through that system in reliance on consistent application of that 225 | system; it is up to the author/donor to decide if he or she is willing 226 | to distribute software through any other system and a licensee cannot 227 | impose that choice. 228 | 229 | This section is intended to make thoroughly clear what is believed to 230 | be a consequence of the rest of this License. 231 | 232 | 8. If the distribution and/or use of the Program is restricted in 233 | certain countries either by patents or by copyrighted interfaces, the 234 | original copyright holder who places the Program under this License 235 | may add an explicit geographical distribution limitation excluding 236 | those countries, so that distribution is permitted only in or among 237 | countries not thus excluded. In such case, this License incorporates 238 | the limitation as if written in the body of this License. 239 | 240 | 9. The Free Software Foundation may publish revised and/or new versions 241 | of the General Public License from time to time. Such new versions will 242 | be similar in spirit to the present version, but may differ in detail to 243 | address new problems or concerns. 244 | 245 | Each version is given a distinguishing version number. If the Program 246 | specifies a version number of this License which applies to it and "any 247 | later version", you have the option of following the terms and conditions 248 | either of that version or of any later version published by the Free 249 | Software Foundation. If the Program does not specify a version number of 250 | this License, you may choose any version ever published by the Free Software 251 | Foundation. 252 | 253 | 10. If you wish to incorporate parts of the Program into other free 254 | programs whose distribution conditions are different, write to the author 255 | to ask for permission. For software which is copyrighted by the Free 256 | Software Foundation, write to the Free Software Foundation; we sometimes 257 | make exceptions for this. Our decision will be guided by the two goals 258 | of preserving the free status of all derivatives of our free software and 259 | of promoting the sharing and reuse of software generally. 260 | 261 | NO WARRANTY 262 | 263 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 264 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 265 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 266 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 267 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 268 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 269 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 270 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 271 | REPAIR OR CORRECTION. 272 | 273 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 274 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 275 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 276 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 277 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 278 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 279 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 280 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 281 | POSSIBILITY OF SUCH DAMAGES. 282 | 283 | END OF TERMS AND CONDITIONS 284 | 285 | How to Apply These Terms to Your New Programs 286 | 287 | If you develop a new program, and you want it to be of the greatest 288 | possible use to the public, the best way to achieve this is to make it 289 | free software which everyone can redistribute and change under these terms. 290 | 291 | To do so, attach the following notices to the program. It is safest 292 | to attach them to the start of each source file to most effectively 293 | convey the exclusion of warranty; and each file should have at least 294 | the "copyright" line and a pointer to where the full notice is found. 295 | 296 | 297 | Copyright (C) 298 | 299 | This program is free software; you can redistribute it and/or modify 300 | it under the terms of the GNU General Public License as published by 301 | the Free Software Foundation; either version 2 of the License, or 302 | (at your option) any later version. 303 | 304 | This program is distributed in the hope that it will be useful, 305 | but WITHOUT ANY WARRANTY; without even the implied warranty of 306 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 307 | GNU General Public License for more details. 308 | 309 | You should have received a copy of the GNU General Public License along 310 | with this program; if not, write to the Free Software Foundation, Inc., 311 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 312 | 313 | Also add information on how to contact you by electronic and paper mail. 314 | 315 | If the program is interactive, make it output a short notice like this 316 | when it starts in an interactive mode: 317 | 318 | Gnomovision version 69, Copyright (C) year name of author 319 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 320 | This is free software, and you are welcome to redistribute it 321 | under certain conditions; type `show c' for details. 322 | 323 | The hypothetical commands `show w' and `show c' should show the appropriate 324 | parts of the General Public License. Of course, the commands you use may 325 | be called something other than `show w' and `show c'; they could even be 326 | mouse-clicks or menu items--whatever suits your program. 327 | 328 | You should also get your employer (if you work as a programmer) or your 329 | school, if any, to sign a "copyright disclaimer" for the program, if 330 | necessary. Here is a sample; alter the names: 331 | 332 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 333 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 334 | 335 | , 1 April 1989 336 | Ty Coon, President of Vice 337 | 338 | This General Public License does not permit incorporating your program into 339 | proprietary programs. If your program is a subroutine library, you may 340 | consider it more useful to permit linking proprietary applications with the 341 | library. If this is what you want to do, use the GNU Lesser General 342 | Public License instead of this License. 343 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PLUGIN_DIR=/var/www/html/wp-content/plugins/wp-graphql-filter-query 2 | BIN_DIR=$(PLUGIN_DIR)/bin 3 | COMPOSER=docker run --rm -it -v `pwd`:/app -w /app composer 4 | DC=docker compose 5 | 6 | all: composer-install composer-dump-autoload build run setup lint test 7 | 8 | build: 9 | $(DC) build 10 | 11 | composer-install: 12 | $(COMPOSER) install 13 | 14 | composer-update: 15 | $(COMPOSER) update 16 | 17 | composer-dump-autoload: 18 | $(COMPOSER) dump-autoload 19 | 20 | clean: 21 | rm -rf .wordpress 22 | 23 | run: 24 | $(DC) up -d 25 | $(DC) exec wp $(BIN_DIR)/wait-for-it.sh db:3306 26 | $(DC) exec wp install-wp-tests 27 | $(DC) cp wp:/tmp/wordpress-tests-lib `pwd`/.wordpress/wordpress-tests-lib 28 | 29 | down: 30 | $(DC) down --volumes 31 | 32 | setup: 33 | $(DC) exec wp $(BIN_DIR)/setup-wp 34 | 35 | shell: 36 | $(DC) exec wp bash 37 | 38 | lint: 39 | $(DC) exec wp composer phpcs 40 | 41 | lint-fix: 42 | $(DC) exec wp composer phpcs:fix 43 | 44 | test: 45 | $(DC) exec wp composer phpunit 46 | 47 | test-watch: 48 | $(DC) exec -w $(PLUGIN_DIR) wp ./vendor/bin/phpunit-watcher watch 49 | 50 | reset: down clean all 51 | 52 | gbuild-pull-requests: 53 | gcloud builds submit \ 54 | --config="cloud-build/pull-requests.yaml" \ 55 | --project="wp-engine-headless-build" 56 | 57 | tail-debug-log: 58 | $(DC) run -T --rm wp tail -f /var/www/html/wp-content/debug.log 59 | 60 | wp-version: 61 | $(DC) exec wp bash -c 'cat /var/www/html/wp-includes/version.php | grep wp_version' 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WPGraphQL Filter Query 2 | 3 | Adds taxonomy filtering and aggregation support to [WPGraphQL](https://www.wpgraphql.com). 4 | 5 | - [WPGraphQL Filter Query](#wpgraphql-filter-query) 6 | - [WPGraphQL limitation](#wpgraphql-limitation) 7 | - [Solution](#solution) 8 | - [What are Filters (and Aggregates)](#what-are-filters-and-aggregates) 9 | - [Use Case Example](#use-case-example) 10 | - [Querying with Filters](#querying-with-filters) 11 | - [Advanced Queries](#advanced-queries) 12 | - [Multiple Comparison Operators on one Taxonomy](#multiple-comparison-operators-on-one-taxonomy) 13 | - [Multiple Relation Operators on Multiple Taxonomies](#multiple-relation-operators-on-multiple-taxonomies) 14 | - [Nesting Relation Operators on Multiple Taxonomies](#nesting-relation-operators-on-multiple-taxonomies) 15 | - [Readable Filter Queries](#readable-filter-queries) 16 | - [Dependencies](#dependencies) 17 | - [Install and Activate](#install-and-activate) 18 | - [Contributing](#contributing) 19 | - [Requirements](#requirements) 20 | - [Getting Started](#getting-started) 21 | - [Testing](#testing) 22 | - [Linting](#linting) 23 | - [VSCode](#vscode) 24 | 25 | ## WPGraphQL limitation 26 | 27 | The introduction of WPGraphQL helped make access to WordPress headless data even easier than via REST, but there are still some areas and use-cases that are outside that repository’s existing focus or scope. From interfacing with the WPGraphQL team it was noted that there was a community desire for a query filter argument implementation to extend the existing plugin, which could support scenarios such as the **taxQuery** candidate [here](https://github.com/wp-graphql/wp-graphql/pull/1387). 28 | 29 | ### Solution 30 | 31 | In collaboration with the WPGraphQL team we built this plugin to: 32 | 33 | - Add a `filter` WPGraphQL input argument connection, for all Post types, which supports the passing of multiple Taxonomies for refinement. 34 | - Add an `aggregation` field connection, available in the body of all Post types, which can return a sum of the names of each Taxonomy that occurs, and a count of the number of occurrences of each, in each query response. 35 | 36 | ## What are Filters (and Aggregates) 37 | 38 | Filters allow the limiting of Post results by **Taxonomy** fields & values. Aggregates give a summary of such Taxonomies available to a given query - based on a sum of the resulting Posts associated Taxonomies. So, if one queried all Posts on _food_ (this would need to be the name of a related Taxonomy here) from a Lifestyle blog, one might expect to receive all food-related Posts, plus the sum of all Taxonomies that occurred within the Posts' results - such as _sweet_ or _savory_, but probably not _home decor_ or _yoga_ (unless a topic related to both _food_ and _yoga_ perhaps). 39 | 40 | Broadly speaking, WordPress Taxonomies have two default types: **Categories & Tags**. **Categories** are more of a grouping mechanism, which supports hierarchy and siblings (_animal-> feline-> tiger, lion, cat_), whereas **Tags** are more for additional shared information (_green, heavy, out-of-stock_). This plugin supports both of these Taxomonies within both Filters and Aggregates. 41 | 42 | ### Use Case Example 43 | 44 | Using an example of a fictional _Sample Clothing Store_ WordPress site we can further illustrate the use of filters. Here, the products are separated into 4, sometimes overlapping, Categories: _Sport_, _Formal_, _Footwear_ and _Accessories_ - there will be no inheritance Categories in this simple example, but some sibling Categories. There are 6 possible Tags also available, for further information on and grouping of the stock, and for shared traits: _Keep Warm_, _Keep Cool_, _Waterproof_, _On Sale_, _Cushioning_, and _Gym_. The 12 products are shown in the image below - 4 in each Category, but one can see _Leather Shoes_ shares both Formal and Footwear Categories, while _Gym Trainers_ shares both Sport and Footwear Categories. The blue numbers below each product show their associated Tags. 45 | 46 | ![Simple Clothing Store](docs/img/simple-clothing-store.png) 47 | 48 | Now, if we pictured this sample store having its own website frontend (reading from our WordPress Headless WPGraphQL) which could display all of the products, and allow filtering by the Categories and Tags e.g. **query #1** image. 49 | 50 | In this query the customer has visited perhaps a _Featured Sportswear & Footwear_ section, from one of the website’s homepage links, which automatically filter-queried the products by the given Categories of _Sport & Footwear_, while also returning the Aggregations of still-applicable Taxonomies (by name & occurrence count). 51 | 52 | From this filter-by-category view the customer could then further refine what products they are interested in, by selecting only certain Tags from the Aggregated Tags returned (which was all 6 in this example). Next, in the **query #1** image below, one can see some of the Tag boxes being checked so that, by **query #2**, we can see the products returned from the filter-by-category-and-tag query have gone from 6 (Gym T-shirt, Hoodie, Sweat Pants, Leather Shoes, Sandals, Gym Trainers) to 4 (Gym T-shirt, Sweat Pants, Sandals, Gym Trainers) in number. 53 | 54 | We can see in both query results that: 55 | 56 | - Of the 6 Products returned in **query #1**, there were occurrences of all 6 Tags with a sum of 15 instances (again, the aggregate/ sum of Tags, by name & counts). 57 | - By **query #2**, even though we specified 2 tags to filter by, the 4 Products returned contained a total of 4 associated Tags (each had both specified & unspecified Tags) and 12 instances. 58 | 59 | ![Filtered Simple Clothing](docs/img/filtered-simple-clothing.png) 60 | 61 | This above example uses, by default, an `and` relationship between the specified Taxonomies, but this plugin supports both `AND` plus `OR` relationships within its filters - these are the **relation** operators. In this example a multiple-equality ‘in’ ‘comparison’ operator is used as the comparison for filtering, but this plugin supports all of the following comparison operators: `eq`, `notEq`, `like`, `notLike`, `in`, `notIn`. 62 | 63 | ## Querying with Filters 64 | 65 | Given the **Simple Clothing Store** website above, we can start seeing how one would go about benefiting from filter queries in **query #1 and #2**. To replicate the backend needed to supply such frontend functionality we must first create a WP instance with **WPGraphQL** & our **WPGraphQL Filter Query** plugins installed and activated. Then we need to clear any default data and add the 4 Categories, 6 Tags, and 12 Products (as Posts) from our earlier data, and establish the specified relationships between our Products, Tags and Categories. Once this is done one can begin querying the data via WPGraphQL’s inbuilt GraphiQL IDE (a familiarity with this is assumed). 66 | 67 | The **query #1** should look roughly like this, with Filter by Category: 68 | 69 | ```graphql 70 | query Query1 { 71 | posts(filter: { category: { name: { in: ["Sport", "Footwear"] } } }) { 72 | nodes { 73 | title 74 | } 75 | 76 | aggregations { 77 | tags { 78 | key 79 | count 80 | } 81 | 82 | categories { 83 | key 84 | count 85 | } 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | ![Query #1](docs/img/query-1.png) 92 | 93 | **Query #2** builds on 1, with the addition of selected (UI-checked) Tags to Filter. 94 | 95 | When we add Tags to the filter an implicit Relation Operator of `and` is applied between the Category and the Tag objects - _if (Category-match && Tag-match) return Post(s)_. 96 | 97 | ```graphql 98 | query Query2 { 99 | posts( 100 | filter: { 101 | category: { name: { in: ["Sport", "Footwear"] } } 102 | tag: { name: { in: ["On Sale", "Gym"] } } 103 | } 104 | ) { 105 | nodes { 106 | title 107 | } 108 | 109 | aggregations { 110 | tags { 111 | key 112 | count 113 | } 114 | 115 | categories { 116 | key 117 | count 118 | } 119 | } 120 | } 121 | } 122 | ``` 123 | 124 | ![Query #2](docs/img/query-2.png) 125 | 126 | ## Advanced Queries 127 | 128 | ### Multiple Comparison Operators on one Taxonomy 129 | 130 | If the **query #2** was taken a step further to perhaps exclude the _Gym Tag_, we could see only _Sandals_ would make a return as _On Sale_. This is the lowest level of chaining operators, and the relation operator for these will always be `and`. We also see a new Comparison Operator, coupled with the previous one: `notEq`: 131 | 132 | ```graphql 133 | query ProductsByFilterNotEqGym { 134 | posts( 135 | filter: { 136 | category: { name: { in: ["Sport", "Footwear"] } } 137 | tag: { name: { in: ["On Sale", "Gym"], notEq: "Gym" } } 138 | } 139 | ) { 140 | nodes { 141 | title 142 | } 143 | 144 | aggregations { 145 | tags { 146 | key 147 | count 148 | } 149 | 150 | categories { 151 | key 152 | count 153 | } 154 | } 155 | } 156 | } 157 | ``` 158 | 159 | ![Results of notEq example](docs/img/sandals-results.png) 160 | 161 | ### Multiple Relation Operators on Multiple Taxonomies 162 | 163 | This plugin supports 4 root filter query arguments presently: 164 | 165 | - 2 Taxonomies: ‘Tag’ & ‘Category’ (Covered already) 166 | - 2 Relation Operators: `or` & `and` 167 | - These cannot appear as siblings, so use one or neither, per nested object 168 | - These accept an array of root filter query objects (so, each object of array itself can have Taxonomies, or/and Relation Operators, recursively) 169 | - Nested filter query objects can be nested up to 10 times presently 170 | 171 | Given **query #2** as a starting point, we could separate the two Tags to be searched into their own filter query object, inside an `or` Relation Operator and get the same result (Also switched the Comparison Operator here to `eq`, vs `in` as there was only one Tag comparison made in each object now): 172 | 173 | ```graphql 174 | query ProductsByFilterOROperator { 175 | posts( 176 | filter: { 177 | category: { name: { in: ["Sport", "Footwear"] } } 178 | or: [ 179 | { tag: { name: { eq: "On Sale" } } } 180 | { tag: { name: { eq: "Gym" } } } 181 | ] 182 | } 183 | ) { 184 | nodes { 185 | title 186 | } 187 | 188 | aggregations { 189 | tags { 190 | key 191 | count 192 | } 193 | 194 | categories { 195 | key 196 | count 197 | } 198 | } 199 | } 200 | } 201 | ``` 202 | 203 | ![Results of OR example](docs/img/results-of-query-or-example.png) 204 | 205 | ### Nesting Relation Operators on Multiple Taxonomies 206 | 207 | If we simply swap the Relation Operator to `and` now on this previous query our results now must contain both Tag Comparison Operator matches, rather than either or. This eliminates _Sandals_, which only had the _On Sale_ Tag, but not the _Gym_ one. 208 | 209 | ```graphql 210 | query ProductsByFilterANDOperator { 211 | posts( 212 | filter: { 213 | category: { name: { in: ["Sport", "Footwear"] } } 214 | and: [ 215 | { tag: { name: { eq: "On Sale" } } } 216 | { tag: { name: { eq: "Gym" } } } 217 | ] 218 | } 219 | ) { 220 | nodes { 221 | title 222 | } 223 | 224 | aggregations { 225 | tags { 226 | key 227 | count 228 | } 229 | 230 | categories { 231 | key 232 | count 233 | } 234 | } 235 | } 236 | } 237 | ``` 238 | 239 | ![Results of OR example](docs/img/results-of-query-or-example.png) 240 | 241 | ## Readable Filter Queries 242 | 243 | Using the relation operators are powerful, and were made to be siblings of Taxonomies, but, for greatest clarity do not reply on the implicit `and` relation of siblings at filter query level. As mentioned, nesting of queries is available via relation operators, up to a depth of 10, but this may be somewhat unreadable, like nested callbacks. 244 | 245 | ## Dependencies 246 | 247 | In order to use WPGraphQL Filter Query, you must have [WPGraphQL](https://www.wpgraphql.com) installed and activated. 248 | 249 | ## Install and Activate 250 | 251 | WPGraphQL Filter Query is not currently available on the WordPress.org repository, so you must [download it from Github](https://github.com/wpengine/wp-graphql-filter-query/archive/refs/heads/main.zip). 252 | 253 | [Learn more](https://wordpress.org/support/article/managing-plugins/) about installing WordPress plugins from a Zip file. 254 | 255 | ## Contributing 256 | 257 | ### Requirements 258 | 259 | - Docker 260 | 261 | ### Getting Started 262 | 263 | To get started with the dev environment run 264 | 265 | ``` 266 | make 267 | ``` 268 | 269 | ### Testing 270 | 271 | ``` 272 | make test 273 | ``` 274 | 275 | ### Linting 276 | 277 | ``` 278 | make lint 279 | ``` 280 | 281 | ### VSCode 282 | 283 | Install the following plugins to gain formatting and lint errors showing up in the editor 284 | 285 | ![image](https://user-images.githubusercontent.com/24898309/174314127-6238f618-0355-4187-b43c-c7a81f451c5f.png) 286 | -------------------------------------------------------------------------------- /bin/install-wp-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -lt 3 ]; then 4 | echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" 5 | exit 1 6 | fi 7 | 8 | DB_NAME=$1 9 | DB_USER=$2 10 | DB_PASS=$3 11 | DB_HOST=${4-localhost} 12 | WP_VERSION=${5-latest} 13 | SKIP_DB_CREATE=${6-false} 14 | 15 | TMPDIR=${TMPDIR-/tmp} 16 | TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") 17 | WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} 18 | WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress} 19 | 20 | download() { 21 | if [ `which curl` ]; then 22 | curl -s "$1" > "$2"; 23 | elif [ `which wget` ]; then 24 | wget -nv -O "$2" "$1" 25 | fi 26 | } 27 | 28 | if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then 29 | WP_BRANCH=${WP_VERSION%\-*} 30 | WP_TESTS_TAG="branches/$WP_BRANCH" 31 | 32 | elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then 33 | WP_TESTS_TAG="branches/$WP_VERSION" 34 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then 35 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 36 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 37 | WP_TESTS_TAG="tags/${WP_VERSION%??}" 38 | else 39 | WP_TESTS_TAG="tags/$WP_VERSION" 40 | fi 41 | elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 42 | WP_TESTS_TAG="trunk" 43 | else 44 | # http serves a single offer, whereas https serves multiple. we only want one 45 | download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json 46 | grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json 47 | LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') 48 | if [[ -z "$LATEST_VERSION" ]]; then 49 | echo "Latest WordPress version could not be found" 50 | exit 1 51 | fi 52 | WP_TESTS_TAG="tags/$LATEST_VERSION" 53 | fi 54 | set -ex 55 | 56 | install_wp() { 57 | 58 | if [ -d $WP_CORE_DIR ]; then 59 | return; 60 | fi 61 | 62 | mkdir -p $WP_CORE_DIR 63 | 64 | if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 65 | mkdir -p $TMPDIR/wordpress-trunk 66 | rm -rf $TMPDIR/wordpress-trunk/* 67 | svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress 68 | mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR 69 | else 70 | if [ $WP_VERSION == 'latest' ]; then 71 | local ARCHIVE_NAME='latest' 72 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then 73 | # https serves multiple offers, whereas http serves single. 74 | download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json 75 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 76 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 77 | LATEST_VERSION=${WP_VERSION%??} 78 | else 79 | # otherwise, scan the releases and get the most up to date minor version of the major release 80 | local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'` 81 | LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1) 82 | fi 83 | if [[ -z "$LATEST_VERSION" ]]; then 84 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 85 | else 86 | local ARCHIVE_NAME="wordpress-$LATEST_VERSION" 87 | fi 88 | else 89 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 90 | fi 91 | download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz 92 | tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR 93 | fi 94 | 95 | download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php 96 | } 97 | 98 | install_test_suite() { 99 | # portable in-place argument for both GNU sed and Mac OSX sed 100 | if [[ $(uname -s) == 'Darwin' ]]; then 101 | local ioption='-i.bak' 102 | else 103 | local ioption='-i' 104 | fi 105 | 106 | # set up testing suite if it doesn't yet exist 107 | if [ ! -d $WP_TESTS_DIR ]; then 108 | # set up testing suite 109 | mkdir -p $WP_TESTS_DIR 110 | rm -rf $WP_TESTS_DIR/{includes,data} 111 | svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes 112 | svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data 113 | fi 114 | 115 | if [ ! -f wp-tests-config.php ]; then 116 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php 117 | # remove all forward slashes in the end 118 | WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") 119 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 120 | sed $ioption "s:__DIR__ . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 121 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php 122 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php 123 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php 124 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php 125 | fi 126 | 127 | } 128 | 129 | recreate_db() { 130 | shopt -s nocasematch 131 | if [[ $1 =~ ^(y|yes)$ ]] 132 | then 133 | mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA 134 | create_db 135 | echo "Recreated the database ($DB_NAME)." 136 | else 137 | echo "Leaving the existing database ($DB_NAME) in place." 138 | fi 139 | shopt -u nocasematch 140 | } 141 | 142 | create_db() { 143 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA 144 | } 145 | 146 | install_db() { 147 | 148 | if [ ${SKIP_DB_CREATE} = "true" ]; then 149 | return 0 150 | fi 151 | 152 | # parse DB_HOST for port or socket references 153 | local PARTS=(${DB_HOST//\:/ }) 154 | local DB_HOSTNAME=${PARTS[0]}; 155 | local DB_SOCK_OR_PORT=${PARTS[1]}; 156 | local EXTRA="" 157 | 158 | if ! [ -z $DB_HOSTNAME ] ; then 159 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then 160 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" 161 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then 162 | EXTRA=" --socket=$DB_SOCK_OR_PORT" 163 | elif ! [ -z $DB_HOSTNAME ] ; then 164 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" 165 | fi 166 | fi 167 | 168 | # create database 169 | if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ] 170 | then 171 | echo "Reinstalling will delete the existing test database ($DB_NAME)" 172 | # read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB 173 | recreate_db $DELETE_EXISTING_DB 174 | else 175 | create_db 176 | fi 177 | } 178 | 179 | install_wp 180 | install_test_suite 181 | install_db 182 | -------------------------------------------------------------------------------- /bin/setup-wp: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | WP_REQUIRED_PLUGINS="wp-graphql query-monitor" 4 | 5 | echo "Downloading Wordpress core ..." 6 | wp core download 7 | 8 | if ! $(wp core is-installed); then 9 | echo "Waiting for MySQL..." 10 | wp core install --url=http://localhost:8080 --title=Example --admin_user=admin --admin_password=admin --admin_email=admin@example.com --path=/var/www/html 11 | wp plugin install ${WP_REQUIRED_PLUGINS} --activate 12 | wp plugin activate wp-graphql-filter-query 13 | 14 | # Settings 15 | wp config set GRAPHQL_DEBUG true 16 | 17 | # Set post permalink structure to post name to allow wp graphql to work 18 | wp option update permalink_structure '/%postname%' 19 | 20 | wp post create --post_title='cat' --post_status='publish' --post_type='post' 21 | wp post create --post_title='dog' --post_status='publish' --post_type='post' 22 | 23 | else 24 | echo "WordPress is ready." 25 | fi 26 | -------------------------------------------------------------------------------- /bin/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # Check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | 148 | WAITFORIT_BUSYTIMEFLAG="" 149 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 150 | WAITFORIT_ISBUSY=1 151 | # Check if busybox timeout uses -t flag 152 | # (recent Alpine versions don't support -t anymore) 153 | if timeout &>/dev/stdout | grep -q -e '-t '; then 154 | WAITFORIT_BUSYTIMEFLAG="-t" 155 | fi 156 | else 157 | WAITFORIT_ISBUSY=0 158 | fi 159 | 160 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 161 | wait_for 162 | WAITFORIT_RESULT=$? 163 | exit $WAITFORIT_RESULT 164 | else 165 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 166 | wait_for_wrapper 167 | WAITFORIT_RESULT=$? 168 | else 169 | wait_for 170 | WAITFORIT_RESULT=$? 171 | fi 172 | fi 173 | 174 | if [[ $WAITFORIT_CLI != "" ]]; then 175 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 176 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 177 | exit $WAITFORIT_RESULT 178 | fi 179 | exec "${WAITFORIT_CLI[@]}" 180 | else 181 | exit $WAITFORIT_RESULT 182 | fi -------------------------------------------------------------------------------- /cloud-build/pull-requests.yaml: -------------------------------------------------------------------------------- 1 | 2 | steps: 3 | - name: 'composer' 4 | id: composer-install 5 | args: ["install"] 6 | 7 | - id: docker-compose-up 8 | name: 'docker/compose' 9 | entrypoint: 'sh' 10 | args: 11 | - '-c' 12 | - | 13 | DOCKER_BUILDKIT=1 docker-compose up -d 14 | docker-compose exec -T wp /var/www/html/wp-content/plugins/wp-graphql-filter-query/bin/wait-for-it.sh db:3306 15 | docker-compose exec -T wp install-wp-tests 16 | docker-compose exec -T wp bash -c "wp core install --url=http://localhost:8080 --title=Example --admin_user=admin --admin_password=admin --admin_email=admin@example.com --path=/var/www/html" 17 | docker-compose exec -T wp bash -c "wp plugin install wp-graphql" 18 | 19 | - id: lint 20 | name: 'docker/compose' 21 | entrypoint: 'sh' 22 | args: 23 | - '-c' 24 | - | 25 | docker-compose exec -T wp composer phpcs 26 | 27 | - id: test 28 | name: 'docker/compose' 29 | entrypoint: 'sh' 30 | args: 31 | - '-c' 32 | - | 33 | docker-compose exec -T wp composer phpunit 34 | 35 | - id: clean 36 | name: 'docker/compose' 37 | entrypoint: 'sh' 38 | args: 39 | - '-c' 40 | - | 41 | docker-compose down -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wpengine/wp-graphql-filter-query", 3 | "description": "Filters and aggregates for wp-graphql", 4 | "config": { 5 | "platform": { 6 | "php": "7.4.23" 7 | }, 8 | "allow-plugins": { 9 | "dealerdirect/phpcodesniffer-composer-installer": true 10 | } 11 | }, 12 | "authors": [ 13 | { 14 | "name": "WP Engine", 15 | "email": "atlas@wpengine.com" 16 | } 17 | ], 18 | "require-dev": { 19 | "phpunit/phpunit": "^7", 20 | "yoast/phpunit-polyfills": "^1.0", 21 | "php-parallel-lint/php-parallel-lint": "^1.3.2", 22 | "phpcompatibility/phpcompatibility-wp": "^2.1.3", 23 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7.2", 24 | "squizlabs/php_codesniffer": "^3.6.0", 25 | "wp-coding-standards/wpcs": "^2.3.0", 26 | "wp-graphql/wp-graphql": "^1.8", 27 | "spatie/phpunit-watcher": "^1.23" 28 | }, 29 | "scripts": { 30 | "phpunit": "vendor/bin/phpunit", 31 | "lint": "vendor/bin/parallel-lint --exclude .git --exclude app --exclude vendor .", 32 | "phpcs": "phpcs", 33 | "phpcs:fix": "phpcbf", 34 | "suite": [ 35 | "@lint", 36 | "@phpcs", 37 | "@test" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | db: 5 | image: mariadb 6 | restart: unless-stopped 7 | environment: 8 | MYSQL_ROOT_PASSWORD: root 9 | MYSQL_DATABASE: wordpress 10 | MYSQL_USER: wordpress 11 | MYSQL_PASSWORD: wordpress 12 | volumes: 13 | - db-data:/var/lib/mysql 14 | ports: 15 | - 3307:3306 16 | 17 | wp: 18 | build: 19 | context: . 20 | dockerfile: Dockerfile 21 | restart: unless-stopped 22 | environment: 23 | XDEBUG_CONFIG: ${XDEBUG_CONFIG} 24 | WORDPRESS_DB_HOST: db 25 | WORDPRESS_DB_USER: root 26 | WORDPRESS_DB_PASSWORD: root 27 | WORDPRESS_DB_NAME: wordpress 28 | WORDPRESS_DEBUG: 1 29 | WORDPRESS_CONFIG_EXTRA: | 30 | define('WP_SITEURL', 'http://' . $$_SERVER['HTTP_HOST'] ); 31 | define('WP_HOME', 'http://' . $$_SERVER['HTTP_HOST'] ); 32 | define('WP_DEBUG_LOG', true); 33 | volumes: 34 | - ./.wordpress/wordpress:/var/www/html 35 | - ./:/var/www/html/wp-content/plugins/wp-graphql-filter-query 36 | ports: 37 | - 8080:80 38 | depends_on: 39 | - db 40 | 41 | # Make network name pretty 42 | 43 | # Persist DB and WordPress data across containers 44 | volumes: 45 | db-data: 46 | # wp-data: 47 | -------------------------------------------------------------------------------- /docs/img/filtered-simple-clothing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpengine/wp-graphql-filter-query/134c61209ecab38957e0c9cb8cc3394d174a4554/docs/img/filtered-simple-clothing.png -------------------------------------------------------------------------------- /docs/img/query-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpengine/wp-graphql-filter-query/134c61209ecab38957e0c9cb8cc3394d174a4554/docs/img/query-1.png -------------------------------------------------------------------------------- /docs/img/query-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpengine/wp-graphql-filter-query/134c61209ecab38957e0c9cb8cc3394d174a4554/docs/img/query-2.png -------------------------------------------------------------------------------- /docs/img/results-of-query-or-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpengine/wp-graphql-filter-query/134c61209ecab38957e0c9cb8cc3394d174a4554/docs/img/results-of-query-or-example.png -------------------------------------------------------------------------------- /docs/img/sandals-results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpengine/wp-graphql-filter-query/134c61209ecab38957e0c9cb8cc3394d174a4554/docs/img/sandals-results.png -------------------------------------------------------------------------------- /docs/img/simple-clothing-store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpengine/wp-graphql-filter-query/134c61209ecab38957e0c9cb8cc3394d174a4554/docs/img/simple-clothing-store.png -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./tests/ 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Wp Graphql Filter Query === 2 | Contributors: (this should be a list of wordpress.org userid's) 3 | Donate link: https://example.com/ 4 | Tags: comments, spam 5 | Requires at least: 4.5 6 | Tested up to: 6.0 7 | Requires PHP: 5.6 8 | Stable tag: 0.1.0 9 | License: GPLv2 or later 10 | License URI: https://www.gnu.org/licenses/gpl-2.0.html 11 | 12 | Here is a short description of the plugin. This should be no more than 150 characters. No markup here. 13 | 14 | == Description == 15 | 16 | This is the long description. No limit, and you can use Markdown (as well as in the following sections). 17 | 18 | For backwards compatibility, if this section is missing, the full length of the short description will be used, and 19 | Markdown parsed. 20 | 21 | A few notes about the sections above: 22 | 23 | * "Contributors" is a comma separated list of wp.org/wp-plugins.org usernames 24 | * "Tags" is a comma separated list of tags that apply to the plugin 25 | * "Requires at least" is the lowest version that the plugin will work on 26 | * "Tested up to" is the highest version that you've *successfully used to test the plugin*. Note that it might work on 27 | higher versions... this is just the highest one you've verified. 28 | * Stable tag should indicate the Subversion "tag" of the latest stable version, or "trunk," if you use `/trunk/` for 29 | stable. 30 | 31 | Note that the `readme.txt` of the stable tag is the one that is considered the defining one for the plugin, so 32 | if the `/trunk/readme.txt` file says that the stable tag is `4.3`, then it is `/tags/4.3/readme.txt` that'll be used 33 | for displaying information about the plugin. In this situation, the only thing considered from the trunk `readme.txt` 34 | is the stable tag pointer. Thus, if you develop in trunk, you can update the trunk `readme.txt` to reflect changes in 35 | your in-development version, without having that information incorrectly disclosed about the current stable version 36 | that lacks those changes -- as long as the trunk's `readme.txt` points to the correct stable tag. 37 | 38 | If no stable tag is provided, it is assumed that trunk is stable, but you should specify "trunk" if that's where 39 | you put the stable version, in order to eliminate any doubt. 40 | 41 | == Installation == 42 | 43 | This section describes how to install the plugin and get it working. 44 | 45 | e.g. 46 | 47 | 1. Upload `plugin-name.php` to the `/wp-content/plugins/` directory 48 | 1. Activate the plugin through the 'Plugins' menu in WordPress 49 | 1. Place `` in your templates 50 | 51 | == Frequently Asked Questions == 52 | 53 | = A question that someone might have = 54 | 55 | An answer to that question. 56 | 57 | = What about foo bar? = 58 | 59 | Answer to foo bar dilemma. 60 | 61 | == Screenshots == 62 | 63 | 1. This screen shot description corresponds to screenshot-1.(png|jpg|jpeg|gif). Note that the screenshot is taken from 64 | the /assets directory or the directory that contains the stable readme.txt (tags or trunk). Screenshots in the /assets 65 | directory take precedence. For example, `/assets/screenshot-1.png` would win over `/tags/4.3/screenshot-1.png` 66 | (or jpg, jpeg, gif). 67 | 2. This is the second screen shot 68 | 69 | == Changelog == 70 | 71 | = 1.0 = 72 | * A change since the previous version. 73 | * Another change. 74 | 75 | = 0.5 = 76 | * List versions from most recent at top to oldest at bottom. 77 | 78 | == Upgrade Notice == 79 | 80 | = 1.0 = 81 | Upgrade notices describe the reason a user should upgrade. No more than 300 characters. 82 | 83 | = 0.5 = 84 | This version fixes a security related bug. Upgrade immediately. 85 | 86 | == Arbitrary section == 87 | 88 | You may provide arbitrary sections, in the same format as the ones above. This may be of use for extremely complicated 89 | plugins where more information needs to be conveyed that doesn't fit into the categories of "description" or 90 | "installation." Arbitrary sections will be shown below the built-in sections outlined above. 91 | 92 | == A brief Markdown Example == 93 | 94 | Ordered list: 95 | 96 | 1. Some feature 97 | 1. Another feature 98 | 1. Something else about the plugin 99 | 100 | Unordered list: 101 | 102 | * something 103 | * something else 104 | * third thing 105 | 106 | Here's a link to [WordPress](https://wordpress.org/ "Your favorite software") and one to [Markdown's Syntax Documentation][markdown syntax]. 107 | Titles are optional, naturally. 108 | 109 | [markdown syntax]: https://daringfireball.net/projects/markdown/syntax 110 | "Markdown is what the parser uses to process much of the readme file" 111 | 112 | Markdown uses email style notation for blockquotes and I've been told: 113 | > Asterisks for *emphasis*. Double it up for **strong**. 114 | 115 | `` 116 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=wp-graphql-filter-query 2 | sonar.sources=. -------------------------------------------------------------------------------- /src/aggregate-query.php: -------------------------------------------------------------------------------- 1 | 'aggregate', 34 | 'fields' => [ 35 | 'key' => [ 36 | 'type' => 'String', 37 | ], 38 | 'count' => [ 39 | 'type' => 'Integer', 40 | ], 41 | ], 42 | ] 43 | ); 44 | 45 | $post_types = filter_query_get_supported_post_types(); 46 | 47 | // iterate through all the supported models. 48 | foreach ( $post_types as $post_type ) { 49 | // pick out the aggregate fields this model. 50 | $fields = [ 'categories', 'tags' ]; 51 | 52 | // if none continue. 53 | if ( count( $fields ) < 1 ) { 54 | continue; 55 | } 56 | 57 | // next we are generating the aggregates block for each model. 58 | $aggregate_graphql = []; 59 | foreach ( $fields as $field ) { 60 | $aggregate_graphql[ $field ] = [ 61 | 'type' => array( 'list_of' => 'BucketItem' ), 62 | 'resolve' => function ( $root, $args, $context, $info ) { 63 | global $wpdb; 64 | $taxonomy = $info->fieldName; 65 | if ( $info->fieldName === 'tags' ) { 66 | $taxonomy = 'post_tag'; 67 | } elseif ( $info->fieldName === 'categories' ) { 68 | $taxonomy = 'category'; 69 | } 70 | 71 | if ( empty( FilterQuery::get_query_args() ) ) { 72 | $sql = "SELECT terms.name as 'key' ,taxonomy.count as count 73 | FROM {$wpdb->prefix}terms AS terms 74 | INNER JOIN {$wpdb->prefix}term_taxonomy 75 | AS taxonomy 76 | ON (terms.term_id = taxonomy.term_id) 77 | WHERE taxonomy = %s AND taxonomy.count > 0;"; 78 | } else { 79 | $query_results = new \WP_Query( FilterQuery::get_query_args() ); 80 | $sub_sql = $this->remove_sql_group_by( $query_results->request ); 81 | $sub_sql = $this->remove_sql_order_by( $sub_sql ); 82 | $sub_sql = $this->remove_sql_limit( $sub_sql ); 83 | 84 | $sql = "SELECT wt.name as 'key', count({$wpdb->prefix}posts.ID) as count 85 | FROM {$wpdb->prefix}posts 86 | LEFT JOIN {$wpdb->prefix}term_relationships ON ({$wpdb->prefix}posts.ID = {$wpdb->prefix}term_relationships.object_id) 87 | LEFT JOIN {$wpdb->prefix}term_taxonomy wtt ON ({$wpdb->prefix}term_relationships.term_taxonomy_id = wtt.term_taxonomy_id AND wtt.taxonomy = %s ) 88 | LEFT JOIN {$wpdb->prefix}terms wt ON wtt.term_id = wt.term_id 89 | WHERE wt.name IS NOT NULL AND {$wpdb->prefix}posts.ID = ANY ( {$sub_sql} ) 90 | GROUP BY wt.name 91 | LIMIT 0, 40"; 92 | } 93 | 94 | return $wpdb->get_results( $wpdb->prepare( $sql, $taxonomy ), 'ARRAY_A' ); //phpcs:disable 95 | }, 96 | ]; 97 | } 98 | 99 | // store object name in a variable to DRY up code. 100 | $aggregate_for_type_name = 'AggregatesFor' . $post_type['capitalize_name']; 101 | 102 | // finally, register the type. 103 | register_graphql_object_type( 104 | $aggregate_for_type_name, 105 | [ 106 | 'description' => 'aggregate', 107 | 'fields' => $aggregate_graphql, 108 | ] 109 | ); 110 | 111 | // here we are registering the root `aggregates` field onto each model 112 | // that has aggregate fields defined. 113 | register_graphql_field( 114 | 'RootQueryTo' . $post_type['capitalize_name'] . 'Connection', 115 | 'aggregations', 116 | [ 117 | 'type' => $aggregate_for_type_name, 118 | 'resolve' => function( $root, $args, $context, $info ) { 119 | return []; 120 | }, 121 | ] 122 | ); 123 | } 124 | } 125 | 126 | /** 127 | * Remove GROUP BY SQL clause from a WP_Query formatted SQL. 128 | * 129 | * @param string $sql Sql string to have order by removed. 130 | * 131 | * @return string 132 | */ 133 | private function remove_sql_group_by( string $sql ): string { 134 | $sql_order_by = $this->clause_to_be_modified( $sql, 'GROUP BY', 'ORDER BY' ); 135 | 136 | return str_replace( $sql_order_by, '', $sql ); 137 | } 138 | 139 | /** 140 | * Remove LIMIT SQL clause from a WP_Query formatted SQL. 141 | * 142 | * @param string $sql Sql string to have order by removed. 143 | * 144 | * @return string 145 | */ 146 | private function remove_sql_limit( string $sql ): string { 147 | preg_match( "#LIMIT(.*?)$#s", $sql, $matches ); 148 | $limit = $matches[0] ?? ''; 149 | 150 | return str_replace( $limit , '', $sql ); 151 | } 152 | 153 | /** 154 | * Remove ORDER BY SQL clause from a WP_Query formatted SQL. 155 | * 156 | * @param string $sql Sql string to have order by removed. 157 | * 158 | * @return string 159 | */ 160 | private function remove_sql_order_by( string $sql ): string { 161 | $sql_order_by = $this->clause_to_be_modified( $sql, 'ORDER BY', 'LIMIT' ); 162 | 163 | return str_replace( $sql_order_by, '', $sql ); 164 | } 165 | 166 | /** 167 | * Returns the clause to be modified at a WP_Query formatted SQL. 168 | * Examples: 169 | * For $sql = "SELECT id FROM wp_posts WHERE 1=1;" 170 | * if : $from = 'SELECT', $to = 'FROM', method will return 'SELECT id' 171 | * if : $from = 'SELECT', $to = 'WHERE', method will return 'SELECT id FROM wp_posts' 172 | * 173 | * @param string $sql Sql string to have select replaced. 174 | * @param string $from Start SQL clause from a WP_Query formatted SQL. 175 | * @param string $to End SQL clause from a WP_Query formatted SQL. 176 | * 177 | * @return string Sql with new select clause. 178 | */ 179 | private function clause_to_be_modified( string $sql, string $from = '', string $to = '' ): string { 180 | $sql_select_with_select_from = $this->extract_substring( $sql, $from, $to, true ); 181 | 182 | return str_replace( $to, '', $sql_select_with_select_from ); 183 | } 184 | 185 | /** 186 | * Extracts a substring between two strings. 187 | * 188 | * @param string $subject String to searched. 189 | * @param string $from From string. 190 | * @param string $to To string. 191 | * @param bool $include_from_to Substring includes $from, $to or not. 192 | * 193 | * @return string 194 | */ 195 | private function extract_substring( string $subject, string $from = '', string $to = '', bool $include_from_to = false ): string { 196 | preg_match( "#{$from}(.*?){$to}#s", $subject, $matches ); 197 | 198 | return $include_from_to ? ( $matches[0] ?? '' ) : $matches[1] ?? ''; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/filter-exception.php: -------------------------------------------------------------------------------- 1 | 'TaxonomyFilter', 60 | 'description' => __( 'Filtering Queried Results By Taxonomy Objects', 'wp-graphql-filter-query' ), 61 | ]; 62 | 63 | $fields[ $post_type['plural_name'] ]['args'] = $args; 64 | } 65 | } 66 | 67 | return $fields; 68 | } 69 | 70 | /** 71 | * $operator_mappings. 72 | * 73 | * @var array 74 | */ 75 | public $operator_mappings = array( 76 | 'in' => 'IN', 77 | 'notIn' => 'NOT IN', 78 | 'eq' => 'IN', 79 | 'notEq' => 'NOT IN', 80 | 'like' => 'IN', 81 | 'notLike' => 'NOT IN', 82 | ); 83 | 84 | /** 85 | * $taxonomy_keys. 86 | * 87 | * @var array 88 | */ 89 | public $taxonomy_keys = [ 'tag', 'category' ]; 90 | 91 | /** 92 | * $relation_keys. 93 | * 94 | * @var array 95 | */ 96 | public $relation_keys = [ 'and', 'or' ]; 97 | 98 | /** 99 | * Check if operator is like or notLike 100 | * 101 | * @param array $filter_obj A Filter object, for wpQuery access, to build upon within each recursive call. 102 | * @param int $depth A depth-counter to track recusrive call depth. 103 | * 104 | * @throws FilterException Throws max nested filter depth exception, caught by wpgraphql response. 105 | * @throws FilterException Throws and/or not allowed as siblings exception, caught by wpgraphql response. 106 | * @throws FilterException Throws empty relation (and/or) exception, caught by wpgraphql response. 107 | * @return array 108 | */ 109 | private function resolve_taxonomy( array $filter_obj, int $depth ): array { 110 | if ( $depth > $this->max_nesting_depth ) { 111 | throw new FilterException( 'The Filter\'s relation allowable depth nesting has been exceeded. Please reduce to allowable (' . $this->max_nesting_depth . ') depth to proceed' ); 112 | } elseif ( array_key_exists( 'and', $filter_obj ) && array_key_exists( 'or', $filter_obj ) ) { 113 | throw new FilterException( 'A Filter can only accept one of an \'and\' or \'or\' child relation as an immediate child.' ); 114 | } 115 | 116 | $temp_query = []; 117 | foreach ( $filter_obj as $root_obj_key => $value ) { 118 | if ( in_array( $root_obj_key, $this->taxonomy_keys, true ) ) { 119 | $attribute_array = $value; 120 | foreach ( $attribute_array as $field_key => $field_kvp ) { 121 | foreach ( $field_kvp as $operator => $terms ) { 122 | $mapped_operator = $this->operator_mappings[ $operator ] ?? 'IN'; 123 | $is_like_operator = $this->is_like_operator( $operator ); 124 | $taxonomy = $root_obj_key === 'tag' ? 'post_tag' : 'category'; 125 | 126 | $terms = ! $is_like_operator ? $terms : get_terms( 127 | [ 128 | 'taxonomy' => $taxonomy, 129 | 'fields' => 'ids', 130 | 'name__like' => esc_attr( $terms ), 131 | ] 132 | ); 133 | 134 | $result = [ 135 | 'taxonomy' => $taxonomy, 136 | 'field' => ( $field_key === 'id' ) || $is_like_operator ? 'term_id' : 'name', 137 | 'terms' => $terms, 138 | 'operator' => $mapped_operator, 139 | ]; 140 | 141 | $temp_query[] = $result; 142 | } 143 | } 144 | } elseif ( in_array( $root_obj_key, $this->relation_keys, true ) ) { 145 | $nested_obj_array = $value; 146 | $wp_query_array = []; 147 | 148 | if ( count( $nested_obj_array ) === 0 ) { 149 | throw new FilterException( 'The Filter relation array specified has no children. Please remove the relation key or add one or more appropriate objects to proceed.' ); 150 | } 151 | foreach ( $nested_obj_array as $nested_obj_index => $nested_obj_value ) { 152 | $wp_query_array[ $nested_obj_index ] = $this->resolve_taxonomy( $nested_obj_value, ++$depth ); 153 | $wp_query_array[ $nested_obj_index ]['relation'] = 'AND'; 154 | } 155 | $wp_query_array['relation'] = strtoupper( $root_obj_key ); 156 | $temp_query[] = $wp_query_array; 157 | } 158 | } 159 | return $temp_query; 160 | } 161 | 162 | /** 163 | * Apply facet filters using graphql_connection_query_args filter hook. 164 | * 165 | * @param array $query_args Arguments that come from previous filter and will be passed to WP_Query. 166 | * @param AbstractConnectionResolver $connection_resolver Connection resolver. 167 | * 168 | * @return array 169 | */ 170 | public function apply_recursive_filter_resolver( array $query_args, AbstractConnectionResolver $connection_resolver ): array { 171 | $args = $connection_resolver->getArgs(); 172 | $this->max_nesting_depth = $this->get_query_depth(); 173 | 174 | if ( empty( $args['filter'] ) ) { 175 | return $query_args; 176 | } 177 | 178 | $filter_args_root = $args['filter']; 179 | 180 | $query_args['tax_query'][] = $this->resolve_taxonomy( $filter_args_root, 0, [] ); 181 | 182 | self::$query_args = $query_args; 183 | return $query_args; 184 | } 185 | /** 186 | * Return query args. 187 | * 188 | * @return array|null 189 | */ 190 | public static function get_query_args(): ?array { 191 | return self::$query_args; 192 | } 193 | 194 | /** 195 | * Register Nested Input objs. 196 | * 197 | * @return void 198 | */ 199 | public function extend_wp_graphql_fields() { 200 | register_graphql_input_type( 201 | 'FilterFieldsString', 202 | [ 203 | 'description' => __( 'String Field Match Arguments', 'wp-graphql-filter-query' ), 204 | 'fields' => [ 205 | 'in' => [ 206 | 'type' => [ 'list_of' => 'String' ], 207 | 'description' => __( 'For This To Be Truthy, At Least One Item Of The String Array Arg Passed Here Must Be Contained Within The Calling Taxonomy Field, By Way Of Predefined Aggregates', 'wp-graphql-filter-query' ), 208 | ], 209 | 'notIn' => [ 210 | 'type' => [ 'list_of' => 'String' ], 211 | 'description' => __( 'For This To Be Truthy, Not One Item Of The String Array Arg Passed Here Can Be Contained Within The Calling Taxonomy Field, By Way Of Predefined Aggregates', 'wp-graphql-filter-query' ), 212 | ], 213 | 'like' => [ 214 | 'type' => 'String', 215 | 'description' => __( 'For This To Be Truthy, The Arg Passed Here Must Relate To The Calling Taxonomy Field, By Way Of Predefined Aggregates', 'wp-graphql-filter-query' ), 216 | ], 217 | 'notLike' => [ 218 | 'type' => 'String', 219 | 'description' => __( 'For This To Be Truthy, The Arg Passed Here Must Not Relate To The Calling Taxonomy Field, By Way Of Predefined Aggregates', 'wp-graphql-filter-query' ), 220 | ], 221 | 'eq' => [ 222 | 'type' => 'String', 223 | 'description' => __( 'For This To Be Truthy, The Arg Passed Here Must Be An Exact Match To The Calling Taxonomy Field, By Way Of Predefined Aggregates', 'wp-graphql-filter-query' ), 224 | ], 225 | 'notEq' => [ 226 | 'type' => 'String', 227 | 'description' => __( 'For This To Be Truthy, The Arg Passed Here Must Not Match To The Calling Taxonomy Field, By Way Of Predefined Aggregates', 'wp-graphql-filter-query' ), 228 | ], 229 | ], 230 | ] 231 | ); 232 | 233 | register_graphql_input_type( 234 | 'FilterFieldsInteger', 235 | [ 236 | 'description' => __( 'Integer Field Match Arguments', 'wp-graphql-filter-query' ), 237 | 'fields' => [ 238 | 'in' => [ 239 | 'type' => [ 'list_of' => 'Integer' ], 240 | 'description' => __( 'For This To Be Truthy, At Least One Item Of The String Array Arg Passed Here Must Be Contained Within The Calling Taxonomy Field, By Way Of Predefined Aggregates', 'wp-graphql-filter-query' ), 241 | ], 242 | 'notIn' => [ 243 | 'type' => [ 'list_of' => 'Integer' ], 244 | 'description' => __( 'For This To Be Truthy, Not One Item Of The String Array Arg Passed Here Can Be Contained Within The Calling Taxonomy Field, By Way Of Predefined Aggregates', 'wp-graphql-filter-query' ), 245 | ], 246 | 'eq' => [ 247 | 'type' => 'Integer', 248 | 'description' => __( 'For This To Be Truthy, The Arg Passed Here Must Be An Exact Match To The Calling Taxonomy Field, By Way Of Predefined Aggregates', 'wp-graphql-filter-query' ), 249 | ], 250 | 'notEq' => [ 251 | 'type' => 'Integer', 252 | 'description' => __( 'For This To Be Truthy, The Arg Passed Here Must Not Match To The Calling Taxonomy Field, By Way Of Predefined Aggregates', 'wp-graphql-filter-query' ), 253 | ], 254 | ], 255 | ] 256 | ); 257 | 258 | register_graphql_input_type( 259 | 'TaxonomyFilterFields', 260 | [ 261 | 'description' => __( 'Taxonomy fields For Filtering', 'wp-graphql-filter-query' ), 262 | 'fields' => [ 263 | 'name' => [ 264 | 'type' => 'FilterFieldsString', 265 | 'description' => __( 'ID field For Filtering, Of Type String', 'wp-graphql-filter-query' ), 266 | ], 267 | 'id' => [ 268 | 'type' => 'FilterFieldsInteger', 269 | 'description' => __( 'ID field For Filtering, Of Type Integer', 'wp-graphql-filter-query' ), 270 | ], 271 | ], 272 | ] 273 | ); 274 | 275 | register_graphql_input_type( 276 | 'TaxonomyFilter', 277 | [ 278 | 'description' => __( 'Taxonomies Where Filtering Supported', 'wp-graphql-filter-query' ), 279 | 'fields' => [ 280 | 'tag' => [ 281 | 'type' => 'TaxonomyFilterFields', 282 | 'description' => __( 'Tags Object Fields Allowable For Filtering', 'wp-graphql-filter-query' ), 283 | ], 284 | 'category' => [ 285 | 'type' => 'TaxonomyFilterFields', 286 | 'description' => __( 'Category Object Fields Allowable For Filtering', 'wp-graphql-filter-query' ), 287 | ], 288 | 'and' => [ 289 | 'type' => [ 'list_of' => 'TaxonomyFilter' ], 290 | 'description' => __( '\'AND\' Array of Taxonomy Objects Allowable For Filtering', 'wp-graphql-filter-query' ), 291 | ], 292 | 'or' => [ 293 | 'type' => [ 'list_of' => 'TaxonomyFilter' ], 294 | 'description' => __( '\'OR\' Array of Taxonomy Objects Allowable For Filterin', 'wp-graphql-filter-query' ), 295 | ], 296 | ], 297 | ] 298 | ); 299 | } 300 | 301 | /** 302 | * Check if custom wpgraphql depth is set, and, if so, what it is - else 10 303 | * 304 | * @return int 305 | */ 306 | private function get_query_depth(): int { 307 | $opt = get_option( 'graphql_general_settings' ); 308 | if ( ! empty( $opt ) && $opt !== false && $opt['query_depth_enabled'] === 'on' ) { 309 | return $opt['query_depth_max']; 310 | } 311 | 312 | return 10; 313 | } 314 | 315 | /** 316 | * Check if operator like or notLike 317 | * 318 | * @param string $operator Received operator - not mapped. 319 | * 320 | * @return bool 321 | */ 322 | private function is_like_operator( string $operator ): bool { 323 | return in_array( $operator, [ 'like', 'notLike' ], true ); 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 'cat', 24 | 'post_content' => 'this is a cat', 25 | 'post_status' => 'publish', 26 | ) 27 | ); 28 | 29 | self::$category_animal_id = wp_create_category( 'animal' ); 30 | self::$category_feline_id = wp_create_category( 'feline' ); 31 | self::$category_canine_id = wp_create_category( 'canine' ); 32 | wp_set_post_categories( $cat_post_id, array( self::$category_animal_id, self::$category_feline_id ) ); 33 | $ids = wp_add_post_tags( $cat_post_id, array( 'black', 'small' ) ); 34 | self::$tag_black_id = $ids[0]; 35 | self::$tag_small_id = $ids[1]; 36 | 37 | $dog_post_id = wp_insert_post( 38 | array( 39 | 'post_title' => 'dog', 40 | 'post_content' => 'this is a dog', 41 | 'post_status' => 'publish', 42 | ) 43 | ); 44 | wp_set_post_categories( $dog_post_id, array( self::$category_animal_id, self::$category_canine_id ) ); 45 | $ids = wp_add_post_tags( $dog_post_id, array( 'black', 'big' ) ); 46 | self::$tag_big_id = $ids[1]; 47 | 48 | $twenty_posts_category_id = wp_create_category( 'twenty-posts' ); 49 | 50 | foreach ( range( 1, 20 ) as $i ) { 51 | $id = wp_insert_post( 52 | array( 53 | 'post_title' => 'post ' . $i, 54 | 'post_content' => 'this is post ' . $i, 55 | 'post_status' => 'publish', 56 | ) 57 | ); 58 | wp_set_post_categories( $id, array( $twenty_posts_category_id ) ); 59 | } 60 | } 61 | 62 | protected function setUp(): void { 63 | register_post_type( 64 | 'zombie', 65 | array( 66 | 'labels' => array( 67 | 'name' => 'Zombies', 68 | ), 69 | 'public' => true, 70 | 'capability_type' => 'post', 71 | 'map_meta_cap' => false, 72 | /** WP GRAPHQL */ 73 | 'show_in_graphql' => true, 74 | 'hierarchical' => true, 75 | 'graphql_single_name' => 'zombie', 76 | 'graphql_plural_name' => 'zombies', 77 | ) 78 | ); 79 | } 80 | 81 | public function data_for_schema_exists_for_aggregations(): array { 82 | return [ 83 | 'posts_have_aggregations' => [ 84 | 'query { 85 | posts { 86 | nodes { 87 | title 88 | } 89 | aggregations { 90 | tags { 91 | key 92 | count 93 | } 94 | } 95 | } 96 | }', 97 | 'data', 98 | ], 99 | 'pages_have_aggregations' => [ 100 | 'query { 101 | pages { 102 | nodes { 103 | title 104 | } 105 | aggregations { 106 | tags { 107 | key 108 | count 109 | } 110 | } 111 | } 112 | }', 113 | 'data', 114 | ], 115 | 'zombies_have_aggregations' => [ 116 | 'query { 117 | zombies { 118 | nodes { 119 | title 120 | } 121 | aggregations { 122 | tags { 123 | key 124 | count 125 | } 126 | } 127 | } 128 | }', 129 | 'data', 130 | ], 131 | 'non_existing_type_should_not_have_aggregations' => [ 132 | 'query { 133 | doesNotExist { 134 | nodes { 135 | title 136 | } 137 | aggregations { 138 | tags { 139 | key 140 | count 141 | } 142 | } 143 | } 144 | }', 145 | 'errors', 146 | ], 147 | ]; 148 | } 149 | 150 | /** 151 | * 152 | * @dataProvider data_for_schema_exists_for_aggregations 153 | * @return void 154 | * @throws Exception 155 | */ 156 | public function test_schema_exists_for_aggregations( $query, $expected ) { 157 | $result = graphql( array( 'query' => $query ) ); 158 | $this->assertArrayHasKey( $expected, $result, json_encode( $result ) ); 159 | $this->assertNotEmpty( $result ); 160 | } 161 | 162 | /** 163 | * We need this function because dataproviders are called BEFORE setUp and setUpBeforeClass. 164 | * For more info please see here https://phpunit.readthedocs.io/en/9.5/writing-tests-for-phpunit.html#testing-exceptions 165 | * 166 | * @param string $query Query with placeholders that need to be replaced. 167 | * 168 | * @return string Query with info replaced. 169 | */ 170 | private function replace_ids( string $query ): string { 171 | $search = array( 172 | self::CATEGORY_ANIMAL_ID_TO_BE_REPLACED, 173 | self::CATEGORY_FELINE_ID_TO_BE_REPLACED, 174 | self::CATEGORY_CANINE_ID_TO_BE_REPLACED, 175 | self::TAG_BLACK_ID_TO_BE_REPLACED, 176 | self::TAG_BIG_ID_TO_BE_REPLACED, 177 | self::TAG_SMALL_ID_TO_BE_REPLACED, 178 | ); 179 | $replace = array( 180 | self::$category_animal_id, 181 | self::$category_feline_id, 182 | self::$category_canine_id, 183 | self::$tag_black_id, 184 | self::$tag_big_id, 185 | self::$tag_small_id, 186 | ); 187 | 188 | return str_replace( $search, $replace, $query ); 189 | } 190 | 191 | 192 | public function test_get_tags_aggregations() { 193 | $query = 'query { 194 | posts { 195 | nodes { 196 | title 197 | } 198 | aggregations { 199 | tags { 200 | key 201 | count 202 | } 203 | categories { 204 | key 205 | count 206 | } 207 | } 208 | } 209 | }'; 210 | 211 | $expected_tags = [ 212 | [ 213 | 'key' => 'black', 214 | 'count' => 2, 215 | ], 216 | [ 217 | 'key' => 'small', 218 | 'count' => 1, 219 | ], 220 | [ 221 | 'key' => 'big', 222 | 'count' => 1, 223 | ], 224 | ]; 225 | 226 | $expected_categories = [ 227 | [ 228 | 'key' => 'animal', 229 | 'count' => 2, 230 | ], 231 | [ 232 | 'key' => 'feline', 233 | 'count' => 1, 234 | ], 235 | [ 236 | 'key' => 'canine', 237 | 'count' => 1, 238 | ], 239 | [ 240 | 'key' => 'twenty-posts', 241 | 'count' => 20, 242 | ], 243 | ]; 244 | 245 | $result = do_graphql_request( $query ); 246 | $this->assertArrayHasKey( 'data', $result, json_encode( $result ) ); 247 | $this->assertNotEmpty( $result['data']['posts']['aggregations'] ); 248 | $this->assertEquals( $expected_tags, $result['data']['posts']['aggregations']['tags'] ); 249 | $this->assertEquals( $expected_categories, $result['data']['posts']['aggregations']['categories'] ); 250 | } 251 | 252 | /** 253 | * @dataProvider filter_aggregations_data_provider 254 | * 255 | * @param string $query GraphQL query to test. 256 | * @param string $expected_result What the root object of query return should be. 257 | * @throws Exception 258 | */ 259 | public function test_schema_exists_for_aggregations_with_filters( string $query, string $expected_result ) { 260 | $query = $this->replace_ids( $query ); 261 | $result = graphql( array( 'query' => $query ) ); 262 | $expected_result_arr = json_decode( $expected_result, true ); 263 | $this->assertNotEmpty( $result ); 264 | $expected_result_key = array_key_first( $expected_result_arr ); 265 | $this->assertArrayHasKey( $expected_result_key, $result, json_encode( $result ) ); 266 | 267 | if ( $expected_result_key !== 'errors' ) { 268 | $this->assertEquals( $expected_result_arr[ $expected_result_key ], $result[ $expected_result_key ] ); 269 | } 270 | } 271 | 272 | public function filter_aggregations_data_provider(): array { 273 | return [ 274 | 'posts_valid_filter_category_name_eq' => [ 275 | 'query { 276 | posts( 277 | filter: { 278 | category: { 279 | name: { 280 | eq: "animal" 281 | } 282 | } 283 | } 284 | ) { 285 | aggregations { 286 | categories { 287 | key 288 | count 289 | } 290 | } 291 | } 292 | }', 293 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "2"}, { "key" : "canine", "count" : "1"}, { "key" : "feline", "count" : "1"} ]}}}}', 294 | ], 295 | 'posts_valid_filter_category_name_in' => [ 296 | 'query { 297 | posts( 298 | filter: { 299 | category: { 300 | name: { 301 | in: ["animal", "feline"] 302 | } 303 | } 304 | } 305 | ) { 306 | aggregations { 307 | categories { 308 | key 309 | count 310 | } 311 | } 312 | } 313 | }', 314 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "2"}, { "key" : "canine", "count" : "1"}, { "key" : "feline", "count" : "1"} ]}}}}', 315 | ], 316 | 'posts_valid_filter_category_name_notEq' => [ 317 | 'query { 318 | posts( 319 | filter: { 320 | category: { 321 | name: { 322 | notEq: "animal" 323 | } 324 | } 325 | } 326 | ) { 327 | aggregations { 328 | categories { 329 | key 330 | count 331 | } 332 | } 333 | } 334 | }', 335 | '{"data": { "posts": {"aggregations" : { "categories" : [{ "key" : "twenty-posts", "count" : "20"} ]}}}}', 336 | ], 337 | 'posts_valid_filter_category_name_notIn' => [ 338 | 'query { 339 | posts( 340 | filter: { 341 | category: { 342 | name: { 343 | notIn: ["feline"] 344 | } 345 | } 346 | } 347 | ) { 348 | aggregations { 349 | categories { 350 | key 351 | count 352 | } 353 | } 354 | } 355 | }', 356 | '{"data": { "posts": {"aggregations" : { "categories" : [{ "key" : "animal", "count" : "1"}, { "key" : "canine", "count" : "1"}, { "key" : "twenty-posts", "count" : "20"}]}}}}', 357 | ], 358 | 'posts_valid_filter_category_name_eq_and_in' => [ 359 | 'query { 360 | posts( 361 | filter: { 362 | category: { 363 | name: { 364 | eq: "canine" 365 | in: ["animal"] 366 | } 367 | } 368 | } 369 | ) { 370 | aggregations { 371 | categories { 372 | key 373 | count 374 | } 375 | } 376 | } 377 | }', 378 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "canine", "count" : "1"}]}}}}', 379 | ], 380 | 'posts_valid_filter_category_name_notEq_and_in' => [ 381 | 'query { 382 | posts( 383 | filter: { 384 | category: { 385 | name: { 386 | notEq: "canine" 387 | in: ["animal"] 388 | } 389 | } 390 | } 391 | ) { 392 | aggregations { 393 | categories { 394 | key 395 | count 396 | } 397 | } 398 | } 399 | }', 400 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "feline", "count" : "1"}]}}}}', 401 | ], 402 | 'posts_valid_filter_category_name_eq_and_notIn' => [ 403 | 'query { 404 | posts( 405 | filter: { 406 | category: { 407 | name: { 408 | eq: "feline" 409 | notIn: ["red"] 410 | } 411 | } 412 | } 413 | ) { 414 | aggregations { 415 | categories { 416 | key 417 | count 418 | } 419 | } 420 | } 421 | }', 422 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "feline", "count" : "1"}]}}}}', 423 | ], 424 | 'posts_valid_filter_category_name_eq_and_notIn_multiple' => [ 425 | 'query { 426 | posts( 427 | filter: { 428 | category: { 429 | name: { 430 | eq: "feline" 431 | notIn: ["red", "animal"] 432 | } 433 | } 434 | } 435 | ) { 436 | aggregations { 437 | categories { 438 | key 439 | count 440 | } 441 | } 442 | } 443 | }', 444 | '{"data": { "posts": {"aggregations" : { "categories" : []}}}}', 445 | ], 446 | 'posts_valid_filter_category_name_like' => [ 447 | 'query { 448 | posts( 449 | filter: { 450 | category: { 451 | name: { 452 | like: "nima" 453 | } 454 | } 455 | } 456 | ) { 457 | aggregations { 458 | categories { 459 | key 460 | count 461 | } 462 | } 463 | } 464 | }', 465 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "2"}, { "key" : "canine", "count" : "1"}, { "key" : "feline", "count" : "1"} ] }}}}', 466 | ], 467 | 'posts_valid_filter_category_name_not_like' => [ 468 | 'query { 469 | posts( 470 | filter: { 471 | category: { 472 | name: { 473 | like: "nima" 474 | } 475 | } 476 | } 477 | ) { 478 | aggregations { 479 | categories { 480 | key 481 | count 482 | } 483 | } 484 | } 485 | }', 486 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "2"}, { "key" : "canine", "count" : "1"}, { "key" : "feline", "count" : "1"} ] }}}}', 487 | ], 488 | 'posts_valid_filter_category_name_like_eq' => [ 489 | 'query { 490 | posts( 491 | filter: { 492 | category: { 493 | name: { 494 | like: "anim" 495 | eq: "canine" 496 | } 497 | } 498 | } 499 | ) { 500 | aggregations { 501 | categories { 502 | key 503 | count 504 | } 505 | } 506 | } 507 | }', 508 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "canine", "count" : "1"} ] }}}}', 509 | ], 510 | 'posts_valid_filter_category_name_like_notEq' => [ 511 | 'query { 512 | posts( 513 | filter: { 514 | category: { 515 | name: { 516 | like: "anim" 517 | notEq: "canine" 518 | } 519 | } 520 | } 521 | ) { 522 | aggregations { 523 | categories { 524 | key 525 | count 526 | } 527 | } 528 | } 529 | }', 530 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "feline", "count" : "1"} ] }}}}', 531 | ], 532 | 'posts_valid_filter_category_name_notLike_in_multiple' => [ 533 | 'query { 534 | posts( 535 | filter: { 536 | category: { 537 | name: { 538 | notLike: "ani" 539 | in: ["canine", "feline"] 540 | } 541 | } 542 | } 543 | ) { 544 | aggregations { 545 | categories { 546 | key 547 | count 548 | } 549 | } 550 | } 551 | }', 552 | '{"data": { "posts": {"aggregations" : { "categories" : [] }}}}', 553 | ], 554 | 'posts_valid_filter_category_name_like_notIn' => [ 555 | 'query { 556 | posts( 557 | filter: { 558 | category: { 559 | name: { 560 | like: "ani" 561 | notIn: ["feline"] 562 | } 563 | } 564 | } 565 | ) { 566 | aggregations { 567 | categories { 568 | key 569 | count 570 | } 571 | } 572 | } 573 | }', 574 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "canine", "count" : "1"} ] }}}}', 575 | ], 576 | 'posts_valid_filter_category_id_eq' => [ 577 | 'query { 578 | posts( 579 | filter: { 580 | category: { 581 | id: { 582 | eq: ' . self::CATEGORY_CANINE_ID_TO_BE_REPLACED . ' 583 | } 584 | } 585 | } 586 | ) { 587 | aggregations { 588 | categories { 589 | key 590 | count 591 | } 592 | } 593 | } 594 | }', 595 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "canine", "count" : "1"} ] }}}}', 596 | ], 597 | 598 | // ======================================================================================================================================= 599 | 'posts_valid_filter_category_id_notEq' => [ 600 | 'query { 601 | posts( 602 | filter: { 603 | category: { 604 | id: { 605 | notEq: ' . self::CATEGORY_FELINE_ID_TO_BE_REPLACED . ' 606 | } 607 | } 608 | } 609 | ) { 610 | aggregations { 611 | categories { 612 | key 613 | count 614 | } 615 | } 616 | } 617 | }', 618 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "canine", "count" : "1"}, { "key" : "twenty-posts", "count" : "20"} ] }}}}', 619 | ], 620 | 'posts_valid_filter_category_id_in' => [ 621 | 'query { 622 | posts( 623 | filter: { 624 | category: { 625 | id: { 626 | in: [' . self::CATEGORY_FELINE_ID_TO_BE_REPLACED . '] 627 | } 628 | } 629 | } 630 | ) { 631 | aggregations { 632 | categories { 633 | key 634 | count 635 | } 636 | } 637 | } 638 | }', 639 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "feline", "count" : "1"} ] }}}}', 640 | ], 641 | 'posts_valid_filter_category_id_notIn' => [ 642 | 'query { 643 | posts( 644 | filter: { 645 | category: { 646 | id: { 647 | notIn: [' . self::CATEGORY_CANINE_ID_TO_BE_REPLACED . '] 648 | } 649 | } 650 | } 651 | ) { 652 | aggregations { 653 | categories { 654 | key 655 | count 656 | } 657 | } 658 | } 659 | }', 660 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "feline", "count" : "1"}, { "key" : "twenty-posts", "count" : "20"} ] }}}}', 661 | ], 662 | 'posts_valid_filter_category_name_eq_id_notEq' => [ 663 | 'query { 664 | posts( 665 | filter: { 666 | category: { 667 | name: { 668 | eq: "animal" 669 | }, 670 | id: { 671 | notEq: ' . self::CATEGORY_FELINE_ID_TO_BE_REPLACED . ' 672 | } 673 | } 674 | } 675 | ) { 676 | aggregations { 677 | categories { 678 | key 679 | count 680 | } 681 | } 682 | } 683 | }', 684 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "canine", "count" : "1"} ] }}}}', 685 | ], 686 | 'posts_valid_filter_category_name_eq_id_Eq' => [ 687 | 'query { 688 | posts( 689 | filter: { 690 | category: { 691 | name: { 692 | eq: "animal" 693 | }, 694 | id: { 695 | eq: ' . self::CATEGORY_FELINE_ID_TO_BE_REPLACED . ' 696 | } 697 | } 698 | } 699 | ) { 700 | aggregations { 701 | categories { 702 | key 703 | count 704 | } 705 | } 706 | } 707 | }', 708 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "feline", "count" : "1"} ] }}}}', 709 | ], 710 | 'posts_valid_filter_tag_name_eq' => [ 711 | 'query { 712 | posts( 713 | filter: { 714 | tag: { 715 | name: { 716 | eq: "black" 717 | } 718 | } 719 | } 720 | ) { 721 | aggregations { 722 | tags { 723 | key 724 | count 725 | } 726 | } 727 | } 728 | }', 729 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "2"}, { "key" : "small", "count" : "1"} ] }}}}', 730 | ], 731 | 'posts_valid_filter_tag_name_in' => [ 732 | 'query { 733 | posts( 734 | filter: { 735 | tag: { 736 | name: { 737 | in: ["black", "small"] 738 | } 739 | } 740 | } 741 | ) { 742 | aggregations { 743 | tags { 744 | key 745 | count 746 | } 747 | } 748 | } 749 | }', 750 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "2"}, { "key" : "small", "count" : "1"} ] }}}}', 751 | ], 752 | 'posts_valid_filter_tag_name_notEq' => [ 753 | 'query { 754 | posts( 755 | filter: { 756 | tag: { 757 | name: { 758 | notEq: "black" 759 | } 760 | } 761 | } 762 | ) { 763 | aggregations { 764 | tags { 765 | key 766 | count 767 | } 768 | } 769 | } 770 | }', 771 | '{"data": { "posts": {"aggregations" : { "tags" : [] }}}}', 772 | ], 773 | 'posts_valid_filter_tag_name_notIn' => [ 774 | 'query { 775 | posts( 776 | filter: { 777 | tag: { 778 | name: { 779 | notIn: ["small"] 780 | } 781 | } 782 | } 783 | ) { 784 | aggregations { 785 | tags { 786 | key 787 | count 788 | } 789 | } 790 | } 791 | }', 792 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "1"} ] }}}}', 793 | ], 794 | 'posts_valid_filter_tag_name_eq_and_in' => [ 795 | 'query { 796 | posts( 797 | filter: { 798 | tag: { 799 | name: { 800 | eq: "big", 801 | in: ["black"] 802 | } 803 | } 804 | } 805 | ) { 806 | aggregations { 807 | tags { 808 | key 809 | count 810 | } 811 | } 812 | } 813 | }', 814 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "1"} ] }}}}', 815 | ], 816 | 'posts_valid_filter_tag_name_notEq_and_in' => [ 817 | 'query { 818 | posts( 819 | filter: { 820 | tag: { 821 | name: { 822 | notEq: "big", 823 | in: ["black"] 824 | } 825 | } 826 | } 827 | ) { 828 | aggregations { 829 | tags { 830 | key 831 | count 832 | } 833 | } 834 | } 835 | }', 836 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "black", "count" : "1"}, { "key" : "small", "count" : "1"} ] }}}}', 837 | ], 838 | 'posts_valid_filter_tag_name_neq_and_notIn' => [ 839 | 'query { 840 | posts( 841 | filter: { 842 | tag: { 843 | name: { 844 | eq: "small", 845 | notIn: ["red"] 846 | } 847 | } 848 | } 849 | ) { 850 | aggregations { 851 | tags { 852 | key 853 | count 854 | } 855 | } 856 | } 857 | }', 858 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "black", "count" : "1"}, { "key" : "small", "count" : "1"} ] }}}}', 859 | ], 860 | 'posts_valid_filter_tag_name_neq_and_notIn_multiple' => [ 861 | 'query { 862 | posts( 863 | filter: { 864 | tag: { 865 | name: { 866 | eq: "small", 867 | notIn: ["red", "black"] 868 | } 869 | } 870 | } 871 | ) { 872 | aggregations { 873 | tags { 874 | key 875 | count 876 | } 877 | } 878 | } 879 | }', 880 | '{"data": { "posts": {"aggregations" : { "tags" : [] }}}}', 881 | ], 882 | 'posts_valid_filter_tag_name_like' => [ 883 | 'query { 884 | posts( 885 | filter: { 886 | tag: { 887 | name: { 888 | like: "bl", 889 | } 890 | } 891 | } 892 | ) { 893 | aggregations { 894 | tags { 895 | key 896 | count 897 | } 898 | } 899 | } 900 | }', 901 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "2"}, { "key" : "small", "count" : "1"} ] }}}}', 902 | ], 903 | 'posts_valid_filter_tag_name_notLike' => [ 904 | 'query { 905 | posts( 906 | filter: { 907 | tag: { 908 | name: { 909 | notLike: "sm", 910 | } 911 | } 912 | } 913 | ) { 914 | aggregations { 915 | tags { 916 | key 917 | count 918 | } 919 | } 920 | } 921 | }', 922 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "1"} ] }}}}', 923 | ], 924 | 'posts_valid_filter_tag_name_like_eq' => [ 925 | 'query { 926 | posts( 927 | filter: { 928 | tag: { 929 | name: { 930 | like: "bl", 931 | eq: "big" 932 | } 933 | } 934 | } 935 | ) { 936 | aggregations { 937 | tags { 938 | key 939 | count 940 | } 941 | } 942 | } 943 | }', 944 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "1"} ] }}}}', 945 | ], 946 | 'posts_valid_filter_tag_name_like_notEq' => [ 947 | 'query { 948 | posts( 949 | filter: { 950 | tag: { 951 | name: { 952 | like: "lac", 953 | notEq: "small" 954 | } 955 | } 956 | } 957 | ) { 958 | aggregations { 959 | tags { 960 | key 961 | count 962 | } 963 | } 964 | } 965 | }', 966 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "1"} ] }}}}', 967 | ], 968 | 'posts_valid_filter_tag_name_like_in_multiple' => [ 969 | 'query { 970 | posts( 971 | filter: { 972 | tag: { 973 | name: { 974 | like: "bl", 975 | in: ["big", "small"] 976 | } 977 | } 978 | } 979 | ) { 980 | aggregations { 981 | tags { 982 | key 983 | count 984 | } 985 | } 986 | } 987 | }', 988 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "2"}, { "key" : "small", "count" : "1"} ] }}}}', 989 | ], 990 | 'posts_valid_filter_tag_name_notLike_in_multiple' => [ 991 | 'query { 992 | posts( 993 | filter: { 994 | tag: { 995 | name: { 996 | notLike: "bl", 997 | in: ["big", "small"] 998 | } 999 | } 1000 | } 1001 | ) { 1002 | aggregations { 1003 | tags { 1004 | key 1005 | count 1006 | } 1007 | } 1008 | } 1009 | }', 1010 | '{"data": { "posts": {"aggregations" : { "tags" : [] }}}}', 1011 | ], 1012 | 'posts_valid_filter_tag_name_like_notIn' => [ 1013 | 'query { 1014 | posts( 1015 | filter: { 1016 | tag: { 1017 | name: { 1018 | like: "bl", 1019 | notIn: ["small"] 1020 | } 1021 | } 1022 | } 1023 | ) { 1024 | aggregations { 1025 | tags { 1026 | key 1027 | count 1028 | } 1029 | } 1030 | } 1031 | }', 1032 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "1"} ] }}}}', 1033 | ], 1034 | 'posts_valid_filter_tag_id_eq' => [ 1035 | 'query { 1036 | posts( 1037 | filter: { 1038 | tag: { 1039 | id: { 1040 | eq: ' . self::TAG_BIG_ID_TO_BE_REPLACED . ' 1041 | } 1042 | } 1043 | } 1044 | ) { 1045 | aggregations { 1046 | tags { 1047 | key 1048 | count 1049 | } 1050 | } 1051 | } 1052 | }', 1053 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "1"} ] }}}}', 1054 | ], 1055 | 'posts_valid_filter_tag_id_notEq' => [ 1056 | 'query { 1057 | posts( 1058 | filter: { 1059 | tag: { 1060 | id: { 1061 | notEq: ' . self::TAG_SMALL_ID_TO_BE_REPLACED . ' 1062 | } 1063 | } 1064 | } 1065 | ) { 1066 | aggregations { 1067 | tags { 1068 | key 1069 | count 1070 | } 1071 | } 1072 | } 1073 | }', 1074 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "1"} ] }}}}', 1075 | ], 1076 | 'posts_valid_filter_tag_id_in' => [ 1077 | 'query { 1078 | posts( 1079 | filter: { 1080 | tag: { 1081 | id: { 1082 | in: [' . self::TAG_SMALL_ID_TO_BE_REPLACED . '] 1083 | } 1084 | } 1085 | } 1086 | ) { 1087 | aggregations { 1088 | tags { 1089 | key 1090 | count 1091 | } 1092 | } 1093 | } 1094 | }', 1095 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "black", "count" : "1"}, { "key" : "small", "count" : "1"} ] }}}}', 1096 | ], 1097 | 'posts_valid_filter_tag_id_notIn' => [ 1098 | 'query { 1099 | posts( 1100 | filter: { 1101 | tag: { 1102 | id: { 1103 | notIn: [' . self::TAG_BIG_ID_TO_BE_REPLACED . '] 1104 | } 1105 | } 1106 | } 1107 | ) { 1108 | aggregations { 1109 | tags { 1110 | key 1111 | count 1112 | } 1113 | } 1114 | } 1115 | }', 1116 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "black", "count" : "1"}, { "key" : "small", "count" : "1"} ] }}}}', 1117 | ], 1118 | 'posts_valid_filter_tag_name_eq_id_notEq' => [ 1119 | 'query { 1120 | posts( 1121 | filter: { 1122 | tag: { 1123 | name: { 1124 | eq: "black" 1125 | }, 1126 | id: { 1127 | notEq: ' . self::TAG_SMALL_ID_TO_BE_REPLACED . ' 1128 | } 1129 | } 1130 | } 1131 | ) { 1132 | aggregations { 1133 | tags { 1134 | key 1135 | count 1136 | } 1137 | } 1138 | } 1139 | }', 1140 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "big", "count" : "1"}, { "key" : "black", "count" : "1"} ] }}}}', 1141 | ], 1142 | 'posts_valid_filter_tag_name_eq_id_Eq' => [ 1143 | 'query { 1144 | posts( 1145 | filter: { 1146 | tag: { 1147 | name: { 1148 | eq: "black" 1149 | }, 1150 | id: { 1151 | eq: ' . self::TAG_SMALL_ID_TO_BE_REPLACED . ' 1152 | } 1153 | } 1154 | } 1155 | ) { 1156 | aggregations { 1157 | tags { 1158 | key 1159 | count 1160 | } 1161 | } 1162 | } 1163 | }', 1164 | '{"data": { "posts": {"aggregations" : { "tags" : [ { "key" : "black", "count" : "1"}, { "key" : "small", "count" : "1"} ] }}}}', 1165 | ], 1166 | 'posts_accept_valid_tax_filter_args' => [ 1167 | 'query { 1168 | posts( 1169 | filter: { 1170 | category: { 1171 | id: { 1172 | eq: 10 1173 | }, 1174 | name: { 1175 | eq: "foo" 1176 | } 1177 | }, 1178 | tag: { 1179 | name: { 1180 | in: ["foo", "bar"], 1181 | like: "tst" 1182 | } 1183 | } 1184 | } 1185 | ) { 1186 | aggregations { 1187 | tags { 1188 | key 1189 | count 1190 | } 1191 | } 1192 | } 1193 | }', 1194 | '{"data": { "posts": {"aggregations" : { "tags" : [] }}}}', 1195 | ], 1196 | 'pages_accept_valid_tax_filter_args' => [ 1197 | 'query { 1198 | pages( 1199 | filter: { 1200 | category: { 1201 | id: { 1202 | eq: 10 1203 | }, 1204 | name: { 1205 | eq: "foo" 1206 | } 1207 | }, 1208 | tag: { 1209 | name: { 1210 | in: ["foo", "bar"], 1211 | like: "tst" 1212 | } 1213 | } 1214 | } 1215 | ) { 1216 | aggregations { 1217 | tags { 1218 | key 1219 | count 1220 | }, 1221 | categories { 1222 | key 1223 | count 1224 | } 1225 | } 1226 | } 1227 | }', 1228 | '{"data": { "pages": {"aggregations" : { "tags" : [], "categories" : [] }}}}', 1229 | ], 1230 | 'zombies_accept_valid_tax_filter_args' => [ 1231 | 'query { 1232 | zombies( 1233 | filter: { 1234 | category: { 1235 | id: { 1236 | eq: 10 1237 | }, 1238 | name: { 1239 | eq: "foo" 1240 | } 1241 | }, 1242 | tag: { 1243 | name: { 1244 | in: ["foo", "bar"], 1245 | like: "tst" 1246 | } 1247 | } 1248 | } 1249 | ) { 1250 | aggregations { 1251 | tags { 1252 | key 1253 | count 1254 | }, 1255 | categories { 1256 | key 1257 | count 1258 | } 1259 | } 1260 | } 1261 | }', 1262 | '{"data": { "zombies": {"aggregations" : { "tags" : [], "categories" : [] }}}}', 1263 | ], 1264 | ]; 1265 | } 1266 | } 1267 | -------------------------------------------------------------------------------- /tests/src/filter-query.test.php: -------------------------------------------------------------------------------- 1 | 'graphql', 14 | 'restrict_endpoint_to_logged_in_users' => 'off', 15 | 'batch_queries_enabled' => 'on', 16 | 'batch_limit' => '5', 17 | 'query_depth_enabled' => 'off', 18 | 'query_depth_max' => '10', 19 | 'graphiql_enabled' => 'on', 20 | 'show_graphiql_link_in_admin_bar' => 'on', 21 | 'delete_data_on_deactivate' => 'on', 22 | 'debug_mode_enabled' => 'off', 23 | 'tracing_enabled' => 'off', 24 | 'tracing_user_role' => 'administrator', 25 | 'query_logs_enabled' => 'off', 26 | 'query_log_user_role' => 'administrator', 27 | 'public_introspection_enabled' => 'off', 28 | ); 29 | 30 | private const CATEGORY_ANIMAL_ID_TO_BE_REPLACED = '{!#%_CATEGORY_ANIMAL_%#!}'; 31 | private const CATEGORY_FELINE_ID_TO_BE_REPLACED = '{!#%_CATEGORY_FELINE_%#!}'; 32 | private const CATEGORY_CANINE_ID_TO_BE_REPLACED = '{!#%_CATEGORY_CANINE_%#!}'; 33 | private const TAG_BLACK_ID_TO_BE_REPLACED = '{!#%_TAG_BLACK_%#!}'; 34 | private const TAG_BIG_ID_TO_BE_REPLACED = '{!#%_TAG_BIG_%#!}'; 35 | private const TAG_SMALL_ID_TO_BE_REPLACED = '{!#%_TAG_SMALL_%#!}'; 36 | private const QUERY_DEPTH_DEFAULT = 10; 37 | private const QUERY_DEPTH_CUSTOM = 11; 38 | 39 | public static function setUpBeforeClass(): void { 40 | add_option( 'graphql_general_settings', self::$mock_opt ); 41 | $cat_post_id = wp_insert_post( 42 | array( 43 | 'post_title' => 'cat', 44 | 'post_content' => 'this is a cat', 45 | 'post_status' => 'publish', 46 | ) 47 | ); 48 | 49 | self::$category_animal_id = wp_create_category( 'animal' ); 50 | self::$category_feline_id = wp_create_category( 'feline' ); 51 | self::$category_canine_id = wp_create_category( 'canine' ); 52 | wp_set_post_categories( $cat_post_id, array( self::$category_animal_id, self::$category_feline_id ) ); 53 | $ids = wp_add_post_tags( $cat_post_id, array( 'black', 'small' ) ); 54 | self::$tag_black_id = $ids[0]; 55 | self::$tag_small_id = $ids[1]; 56 | 57 | $dog_post_id = wp_insert_post( 58 | array( 59 | 'post_title' => 'dog', 60 | 'post_content' => 'this is a dog', 61 | 'post_status' => 'publish', 62 | ) 63 | ); 64 | wp_set_post_categories( $dog_post_id, array( self::$category_animal_id, self::$category_canine_id ) ); 65 | $ids = wp_add_post_tags( $dog_post_id, array( 'black', 'big' ) ); 66 | self::$tag_big_id = $ids[1]; 67 | } 68 | 69 | protected function setUp(): void { 70 | register_post_type( 71 | 'zombie', 72 | array( 73 | 'labels' => array( 74 | 'name' => 'Zombies', 75 | ), 76 | 'public' => true, 77 | 'capability_type' => 'post', 78 | 'map_meta_cap' => false, 79 | /** WP GRAPHQL */ 80 | 'show_in_graphql' => true, 81 | 'hierarchical' => true, 82 | 'graphql_single_name' => 'zombie', 83 | 'graphql_plural_name' => 'zombies', 84 | ) 85 | ); 86 | } 87 | 88 | /** 89 | * @dataProvider filter_errors_data_provider 90 | * 91 | * @param string $query GraphQL query to test. 92 | * @param string $expected_error What the error object of query return should be. 93 | * @throws Exception 94 | */ 95 | public function test_schema_errors_for_filters( string $query, string $expected_error ) { 96 | $this->update_wpgraphql_query_depth( 'on', self::QUERY_DEPTH_CUSTOM ); 97 | $query = $this->replace_ids( $query ); 98 | $result = graphql( array( 'query' => $query ) ); 99 | $this->assertEquals( $expected_error, $result['errors'][0]['message'] ); 100 | } 101 | 102 | public function filter_errors_data_provider(): array { 103 | return array( 104 | 'and_plus_or_as_siblings_returns_error' => [ 105 | 'query { 106 | posts( 107 | filter: { 108 | or: [], 109 | and: [], 110 | } 111 | ) { 112 | nodes { 113 | title 114 | content 115 | } 116 | } 117 | }', 118 | 'A Filter can only accept one of an \'and\' or \'or\' child relation as an immediate child.', 119 | ], 120 | 'empty_filter_or_returns_error' => [ 121 | 'query { 122 | posts( 123 | filter: { 124 | or: [], 125 | } 126 | ) { 127 | nodes { 128 | title 129 | content 130 | } 131 | } 132 | }', 133 | 'The Filter relation array specified has no children. Please remove the relation key or add one or more appropriate objects to proceed.', 134 | ], 135 | 'empty_filter_and_returns_error' => [ 136 | 'query { 137 | posts( 138 | filter: { 139 | and: [], 140 | } 141 | ) { 142 | nodes { 143 | title 144 | content 145 | } 146 | } 147 | }', 148 | 'The Filter relation array specified has no children. Please remove the relation key or add one or more appropriate objects to proceed.', 149 | ], 150 | 'relation_nesting_gt_11_should_return_error' => [ 151 | 'query { 152 | posts( 153 | filter: { 154 | or: [ 155 | { 156 | or: [ 157 | { 158 | or: [ 159 | { 160 | or: [ 161 | { 162 | or: [ 163 | { 164 | or: [ 165 | { 166 | or: [ 167 | { 168 | or: [ 169 | { 170 | or: [ 171 | { 172 | or: [ 173 | { 174 | or: [ 175 | { 176 | tag: { 177 | name: {eq: "small"} 178 | } 179 | }, 180 | { 181 | category: { 182 | name: {eq: "feline"} 183 | } 184 | } 185 | ] 186 | } 187 | ] 188 | } 189 | ] 190 | } 191 | ] 192 | } 193 | ] 194 | } 195 | ] 196 | } 197 | ] 198 | } 199 | ] 200 | } 201 | ] 202 | } 203 | ] 204 | }, 205 | ] 206 | } 207 | ) { 208 | nodes { 209 | title 210 | } 211 | } 212 | }', 213 | 'The Filter\'s relation allowable depth nesting has been exceeded. Please reduce to allowable (' . self::QUERY_DEPTH_CUSTOM . ') depth to proceed', 214 | ], 215 | ); 216 | } 217 | 218 | /** 219 | * @dataProvider filters_data_provider 220 | * 221 | * @param string $query GraphQL query to test. 222 | * @param string $expected_result What the root object of query return should be. 223 | * @throws Exception 224 | */ 225 | public function test_schema_exists_for_filters( string $query, string $expected_result ) { 226 | $this->update_wpgraphql_query_depth( 'off', self::QUERY_DEPTH_DEFAULT ); 227 | $query = $this->replace_ids( $query ); 228 | $result = graphql( array( 'query' => $query ) ); 229 | $expected_result_arr = json_decode( $expected_result, true ); 230 | $this->assertNotEmpty( $result ); 231 | $expected_result_key = array_key_first( $expected_result_arr ); 232 | $this->assertArrayHasKey( $expected_result_key, $result, json_encode( $result ) ); 233 | 234 | if ( $expected_result_key !== 'errors' ) { 235 | $this->assertEquals( $expected_result_arr[ $expected_result_key ], $result[ $expected_result_key ] ); 236 | } 237 | } 238 | 239 | /** 240 | * We need this function because dataproviders are called BEFORE setUp and setUpBeforeClass. 241 | * For more info please see here https://phpunit.readthedocs.io/en/9.5/writing-tests-for-phpunit.html#testing-exceptions 242 | * 243 | * @param string $query Query with placeholders that need to be replaced. 244 | * 245 | * @return string Query with info replaced. 246 | */ 247 | private function replace_ids( string $query ): string { 248 | $search = array( 249 | self::CATEGORY_ANIMAL_ID_TO_BE_REPLACED, 250 | self::CATEGORY_FELINE_ID_TO_BE_REPLACED, 251 | self::CATEGORY_CANINE_ID_TO_BE_REPLACED, 252 | self::TAG_BLACK_ID_TO_BE_REPLACED, 253 | self::TAG_BIG_ID_TO_BE_REPLACED, 254 | self::TAG_SMALL_ID_TO_BE_REPLACED, 255 | ); 256 | $replace = array( 257 | self::$category_animal_id, 258 | self::$category_feline_id, 259 | self::$category_canine_id, 260 | self::$tag_black_id, 261 | self::$tag_big_id, 262 | self::$tag_small_id, 263 | ); 264 | 265 | return str_replace( $search, $replace, $query ); 266 | } 267 | 268 | /** 269 | * Short function to update the WpGraphQL settings for custom query depth 270 | * 271 | * @param string $toggle_state on/off toggle for custom setting. 272 | * @param int $depth_limit depth limit to set, when toggleState value is 'on'. 273 | */ 274 | private function update_wpgraphql_query_depth( string $toggle_state, int $depth_limit ): void { 275 | $wpgraphql_options = get_option( 'graphql_general_settings' ); 276 | $wpgraphql_options['query_depth_enabled'] = $toggle_state; 277 | if ( $toggle_state === 'on' ) { 278 | $wpgraphql_options['query_depth_max'] = '' . $depth_limit; 279 | } else { 280 | $wpgraphql_options['query_depth_max'] = '10'; 281 | } 282 | update_option( 'graphql_general_settings', $wpgraphql_options ); 283 | } 284 | 285 | public function filters_data_provider(): array { 286 | return array( 287 | 'posts_valid_filter_category_name_eq' => [ 288 | 'query { 289 | posts( 290 | filter: { 291 | category: { 292 | name: { 293 | eq: "animal" 294 | } 295 | } 296 | } 297 | ) { 298 | nodes { 299 | title 300 | content 301 | } 302 | } 303 | }', 304 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}, {"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 305 | ], 306 | 307 | 'posts_valid_filter_category_name_in' => [ 308 | 'query { 309 | posts( 310 | filter: { 311 | category: { 312 | name: { 313 | in: ["animal", "feline"] 314 | } 315 | } 316 | } 317 | ) { 318 | nodes { 319 | title 320 | content 321 | } 322 | } 323 | }', 324 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}, {"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 325 | ], 326 | 'posts_valid_filter_category_name_notEq' => [ 327 | 'query { 328 | posts( 329 | filter: { 330 | category: { 331 | name: { 332 | notEq: "animal" 333 | } 334 | } 335 | } 336 | ) { 337 | nodes { 338 | title 339 | content 340 | } 341 | } 342 | }', 343 | '{"data": { "posts": {"nodes" : []}}}', 344 | ], 345 | 'posts_valid_filter_category_name_notIn' => [ 346 | 'query { 347 | posts( 348 | filter: { 349 | category: { 350 | name: { 351 | notIn: ["feline"] 352 | } 353 | } 354 | } 355 | ) { 356 | nodes { 357 | title 358 | content 359 | } 360 | } 361 | }', 362 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"} ]}}}', 363 | ], 364 | 'posts_valid_filter_category_name_eq_and_in' => [ 365 | 'query { 366 | posts( 367 | filter: { 368 | category: { 369 | name: { 370 | eq: "canine", 371 | in: ["animal"] 372 | } 373 | } 374 | } 375 | ) { 376 | nodes { 377 | title 378 | content 379 | } 380 | } 381 | }', 382 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"} ]}}}', 383 | ], 384 | 'posts_valid_filter_category_name_notEq_and_in' => [ 385 | 'query { 386 | posts( 387 | filter: { 388 | category: { 389 | name: { 390 | notEq: "canine", 391 | in: ["animal"] 392 | } 393 | } 394 | } 395 | ) { 396 | nodes { 397 | title 398 | content 399 | } 400 | } 401 | }', 402 | '{"data": { "posts": {"nodes" : [{"title": "cat" , "content" : "

this is a cat

\n"} ]}}}', 403 | ], 404 | 'posts_valid_filter_category_name_neq_and_notIn' => [ 405 | 'query { 406 | posts( 407 | filter: { 408 | category: { 409 | name: { 410 | eq: "feline", 411 | notIn: ["red"] 412 | } 413 | } 414 | } 415 | ) { 416 | nodes { 417 | title 418 | content 419 | } 420 | } 421 | }', 422 | '{"data": { "posts": {"nodes" : [{"title": "cat" , "content" : "

this is a cat

\n"} ]}}}', 423 | ], 424 | 'posts_valid_filter_category_name_neq_and_notIn_multiple' => [ 425 | 'query { 426 | posts( 427 | filter: { 428 | category: { 429 | name: { 430 | eq: "feline", 431 | notIn: ["car", "animal"] 432 | } 433 | } 434 | } 435 | ) { 436 | nodes { 437 | title 438 | content 439 | } 440 | } 441 | }', 442 | '{"data": { "posts": {"nodes" : []}}}', 443 | ], 444 | 'posts_valid_filter_category_name_like' => [ 445 | 'query { 446 | posts( 447 | filter: { 448 | category: { 449 | name: { 450 | like: "nima", 451 | } 452 | } 453 | } 454 | ) { 455 | nodes { 456 | title 457 | content 458 | } 459 | } 460 | }', 461 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}, {"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 462 | ], 463 | 'posts_valid_filter_category_name_notLike' => [ 464 | 'query { 465 | posts( 466 | filter: { 467 | category: { 468 | name: { 469 | notLike: "fel", 470 | } 471 | } 472 | } 473 | ) { 474 | nodes { 475 | title 476 | content 477 | } 478 | } 479 | }', 480 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"} ]}}}', 481 | ], 482 | 'posts_valid_filter_category_name_like_eq' => [ 483 | 'query { 484 | posts( 485 | filter: { 486 | category: { 487 | name: { 488 | like: "anim", 489 | eq: "canine" 490 | } 491 | } 492 | } 493 | ) { 494 | nodes { 495 | title 496 | content 497 | } 498 | } 499 | }', 500 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"} ]}}}', 501 | ], 502 | 'posts_valid_filter_category_name_like_notEq' => [ 503 | 'query { 504 | posts( 505 | filter: { 506 | category: { 507 | name: { 508 | like: "nim", 509 | notEq: "feline" 510 | } 511 | } 512 | } 513 | ) { 514 | nodes { 515 | title 516 | content 517 | } 518 | } 519 | }', 520 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"} ]}}}', 521 | ], 522 | 'posts_valid_filter_category_name_like_in_multiple' => [ 523 | 'query { 524 | posts( 525 | filter: { 526 | category: { 527 | name: { 528 | like: "ani", 529 | in: ["canine", "feline"] 530 | } 531 | } 532 | } 533 | ) { 534 | nodes { 535 | title 536 | content 537 | } 538 | } 539 | }', 540 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}, {"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 541 | ], 542 | 'posts_valid_filter_category_name_notLike_in_multiple' => [ 543 | 'query { 544 | posts( 545 | filter: { 546 | category: { 547 | name: { 548 | notLike: "ani", 549 | in: ["canine", "feline"] 550 | } 551 | } 552 | } 553 | ) { 554 | nodes { 555 | title 556 | content 557 | } 558 | } 559 | }', 560 | '{"data": { "posts": {"nodes" : []}}}', 561 | ], 562 | 'posts_valid_filter_category_name_like_notIn' => [ 563 | 'query { 564 | posts( 565 | filter: { 566 | category: { 567 | name: { 568 | like: "ani", 569 | notIn: ["feline"] 570 | } 571 | } 572 | } 573 | ) { 574 | nodes { 575 | title 576 | content 577 | } 578 | } 579 | }', 580 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}]}}}', 581 | ], 582 | 'posts_valid_filter_category_id_eq' => [ 583 | 'query { 584 | posts( 585 | filter: { 586 | category: { 587 | id: { 588 | eq: ' . self::CATEGORY_CANINE_ID_TO_BE_REPLACED . ' 589 | } 590 | } 591 | } 592 | ) { 593 | nodes { 594 | title 595 | content 596 | } 597 | } 598 | }', 599 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}]}}}', 600 | ], 601 | 'posts_valid_filter_category_id_notEq' => [ 602 | 'query { 603 | posts( 604 | filter: { 605 | category: { 606 | id: { 607 | notEq: ' . self::CATEGORY_FELINE_ID_TO_BE_REPLACED . ' 608 | } 609 | } 610 | } 611 | ) { 612 | nodes { 613 | title 614 | content 615 | } 616 | } 617 | }', 618 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}]}}}', 619 | ], 620 | 'posts_valid_filter_category_id_in' => [ 621 | 'query { 622 | posts( 623 | filter: { 624 | category: { 625 | id: { 626 | in: [' . self::CATEGORY_FELINE_ID_TO_BE_REPLACED . '] 627 | } 628 | } 629 | } 630 | ) { 631 | nodes { 632 | title 633 | content 634 | } 635 | } 636 | }', 637 | '{"data": { "posts": {"nodes" : [{"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 638 | ], 639 | 'posts_valid_filter_category_id_notIn' => [ 640 | 'query { 641 | posts( 642 | filter: { 643 | category: { 644 | id: { 645 | notIn: [' . self::CATEGORY_CANINE_ID_TO_BE_REPLACED . '] 646 | } 647 | } 648 | } 649 | ) { 650 | nodes { 651 | title 652 | content 653 | } 654 | } 655 | }', 656 | '{"data": { "posts": {"nodes" : [{"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 657 | ], 658 | 'posts_valid_filter_category_name_eq_id_notEq' => [ 659 | 'query { 660 | posts( 661 | filter: { 662 | category: { 663 | name: { 664 | eq: "animal" 665 | }, 666 | id: { 667 | notEq: ' . self::CATEGORY_FELINE_ID_TO_BE_REPLACED . ' 668 | } 669 | } 670 | } 671 | ) { 672 | nodes { 673 | title 674 | content 675 | } 676 | } 677 | }', 678 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}]}}}', 679 | ], 680 | 'posts_valid_filter_category_name_eq_id_Eq' => [ 681 | 'query { 682 | posts( 683 | filter: { 684 | category: { 685 | name: { 686 | eq: "animal" 687 | }, 688 | id: { 689 | eq: ' . self::CATEGORY_FELINE_ID_TO_BE_REPLACED . ' 690 | } 691 | } 692 | } 693 | ) { 694 | nodes { 695 | title 696 | content 697 | } 698 | } 699 | }', 700 | '{"data": { "posts": {"nodes" : [{"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 701 | ], 702 | 'posts_valid_filter_tag_name_eq' => [ 703 | 'query { 704 | posts( 705 | filter: { 706 | tag: { 707 | name: { 708 | eq: "black" 709 | } 710 | } 711 | } 712 | ) { 713 | nodes { 714 | title 715 | content 716 | } 717 | } 718 | }', 719 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}, {"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 720 | ], 721 | 'posts_valid_filter_tag_name_in' => [ 722 | 'query { 723 | posts( 724 | filter: { 725 | tag: { 726 | name: { 727 | in: ["black", "small"] 728 | } 729 | } 730 | } 731 | ) { 732 | nodes { 733 | title 734 | content 735 | } 736 | } 737 | }', 738 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}, {"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 739 | ], 740 | 'posts_valid_filter_tag_name_notEq' => [ 741 | 'query { 742 | posts( 743 | filter: { 744 | tag: { 745 | name: { 746 | notEq: "black" 747 | } 748 | } 749 | } 750 | ) { 751 | nodes { 752 | title 753 | content 754 | } 755 | } 756 | }', 757 | '{"data": { "posts": {"nodes" : []}}}', 758 | ], 759 | 'posts_valid_filter_tag_name_notIn' => [ 760 | 'query { 761 | posts( 762 | filter: { 763 | tag: { 764 | name: { 765 | notIn: ["small"] 766 | } 767 | } 768 | } 769 | ) { 770 | nodes { 771 | title 772 | content 773 | } 774 | } 775 | }', 776 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"} ]}}}', 777 | ], 778 | 'posts_valid_filter_tag_name_eq_and_in' => [ 779 | 'query { 780 | posts( 781 | filter: { 782 | tag: { 783 | name: { 784 | eq: "big", 785 | in: ["black"] 786 | } 787 | } 788 | } 789 | ) { 790 | nodes { 791 | title 792 | content 793 | } 794 | } 795 | }', 796 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"} ]}}}', 797 | ], 798 | 'posts_valid_filter_tag_name_notEq_and_in' => [ 799 | 'query { 800 | posts( 801 | filter: { 802 | tag: { 803 | name: { 804 | notEq: "big", 805 | in: ["black"] 806 | } 807 | } 808 | } 809 | ) { 810 | nodes { 811 | title 812 | content 813 | } 814 | } 815 | }', 816 | '{"data": { "posts": {"nodes" : [{"title": "cat" , "content" : "

this is a cat

\n"} ]}}}', 817 | ], 818 | 'posts_valid_filter_tag_name_neq_and_notIn' => [ 819 | 'query { 820 | posts( 821 | filter: { 822 | tag: { 823 | name: { 824 | eq: "small", 825 | notIn: ["red"] 826 | } 827 | } 828 | } 829 | ) { 830 | nodes { 831 | title 832 | content 833 | } 834 | } 835 | }', 836 | '{"data": { "posts": {"nodes" : [{"title": "cat" , "content" : "

this is a cat

\n"} ]}}}', 837 | ], 838 | 'posts_valid_filter_tag_name_neq_and_notIn_multiple' => [ 839 | 'query { 840 | posts( 841 | filter: { 842 | tag: { 843 | name: { 844 | eq: "small", 845 | notIn: ["red", "black"] 846 | } 847 | } 848 | } 849 | ) { 850 | nodes { 851 | title 852 | content 853 | } 854 | } 855 | }', 856 | '{"data": { "posts": {"nodes" : []}}}', 857 | ], 858 | 'posts_valid_filter_tag_name_like' => [ 859 | 'query { 860 | posts( 861 | filter: { 862 | tag: { 863 | name: { 864 | like: "bl", 865 | } 866 | } 867 | } 868 | ) { 869 | nodes { 870 | title 871 | content 872 | } 873 | } 874 | }', 875 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}, {"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 876 | ], 877 | 'posts_valid_filter_tag_name_notLike' => [ 878 | 'query { 879 | posts( 880 | filter: { 881 | tag: { 882 | name: { 883 | notLike: "sm", 884 | } 885 | } 886 | } 887 | ) { 888 | nodes { 889 | title 890 | content 891 | } 892 | } 893 | }', 894 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"} ]}}}', 895 | ], 896 | 'posts_valid_filter_tag_name_like_eq' => [ 897 | 'query { 898 | posts( 899 | filter: { 900 | tag: { 901 | name: { 902 | like: "bl", 903 | eq: "big" 904 | } 905 | } 906 | } 907 | ) { 908 | nodes { 909 | title 910 | content 911 | } 912 | } 913 | }', 914 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"} ]}}}', 915 | ], 916 | 'posts_valid_filter_tag_name_like_notEq' => [ 917 | 'query { 918 | posts( 919 | filter: { 920 | tag: { 921 | name: { 922 | like: "lac", 923 | notEq: "small" 924 | } 925 | } 926 | } 927 | ) { 928 | nodes { 929 | title 930 | content 931 | } 932 | } 933 | }', 934 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"} ]}}}', 935 | ], 936 | 'posts_valid_filter_tag_name_like_in_multiple' => [ 937 | 'query { 938 | posts( 939 | filter: { 940 | tag: { 941 | name: { 942 | like: "bl", 943 | in: ["big", "small"] 944 | } 945 | } 946 | } 947 | ) { 948 | nodes { 949 | title 950 | content 951 | } 952 | } 953 | }', 954 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}, {"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 955 | ], 956 | 'posts_valid_filter_tag_name_notLike_in_multiple' => [ 957 | 'query { 958 | posts( 959 | filter: { 960 | tag: { 961 | name: { 962 | notLike: "bl", 963 | in: ["big", "small"] 964 | } 965 | } 966 | } 967 | ) { 968 | nodes { 969 | title 970 | content 971 | } 972 | } 973 | }', 974 | '{"data": { "posts": {"nodes" : []}}}', 975 | ], 976 | 'posts_valid_filter_tag_name_like_notIn' => [ 977 | 'query { 978 | posts( 979 | filter: { 980 | tag: { 981 | name: { 982 | like: "bl", 983 | notIn: ["small"] 984 | } 985 | } 986 | } 987 | ) { 988 | nodes { 989 | title 990 | content 991 | } 992 | } 993 | }', 994 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}]}}}', 995 | ], 996 | 'posts_valid_filter_tag_id_eq' => [ 997 | 'query { 998 | posts( 999 | filter: { 1000 | tag: { 1001 | id: { 1002 | eq: ' . self::TAG_BIG_ID_TO_BE_REPLACED . ' 1003 | } 1004 | } 1005 | } 1006 | ) { 1007 | nodes { 1008 | title 1009 | content 1010 | } 1011 | } 1012 | }', 1013 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}]}}}', 1014 | ], 1015 | 'posts_valid_filter_tag_id_notEq' => [ 1016 | 'query { 1017 | posts( 1018 | filter: { 1019 | tag: { 1020 | id: { 1021 | notEq: ' . self::TAG_SMALL_ID_TO_BE_REPLACED . ' 1022 | } 1023 | } 1024 | } 1025 | ) { 1026 | nodes { 1027 | title 1028 | content 1029 | } 1030 | } 1031 | }', 1032 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}]}}}', 1033 | ], 1034 | 'posts_valid_filter_tag_id_in' => [ 1035 | 'query { 1036 | posts( 1037 | filter: { 1038 | tag: { 1039 | id: { 1040 | in: [' . self::TAG_SMALL_ID_TO_BE_REPLACED . '] 1041 | } 1042 | } 1043 | } 1044 | ) { 1045 | nodes { 1046 | title 1047 | content 1048 | } 1049 | } 1050 | }', 1051 | '{"data": { "posts": {"nodes" : [{"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 1052 | ], 1053 | 'posts_valid_filter_tag_id_notIn' => [ 1054 | 'query { 1055 | posts( 1056 | filter: { 1057 | tag: { 1058 | id: { 1059 | notIn: [' . self::TAG_BIG_ID_TO_BE_REPLACED . '] 1060 | } 1061 | } 1062 | } 1063 | ) { 1064 | nodes { 1065 | title 1066 | content 1067 | } 1068 | } 1069 | }', 1070 | '{"data": { "posts": {"nodes" : [{"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 1071 | ], 1072 | 'posts_valid_filter_tag_name_eq_id_notEq' => [ 1073 | 'query { 1074 | posts( 1075 | filter: { 1076 | tag: { 1077 | name: { 1078 | eq: "black" 1079 | }, 1080 | id: { 1081 | notEq: ' . self::TAG_SMALL_ID_TO_BE_REPLACED . ' 1082 | } 1083 | } 1084 | } 1085 | ) { 1086 | nodes { 1087 | title 1088 | content 1089 | } 1090 | } 1091 | }', 1092 | '{"data": { "posts": {"nodes" : [{"title": "dog" , "content" : "

this is a dog

\n"}]}}}', 1093 | ], 1094 | 'posts_valid_filter_tag_name_eq_id_Eq' => [ 1095 | 'query { 1096 | posts( 1097 | filter: { 1098 | tag: { 1099 | name: { 1100 | eq: "black" 1101 | }, 1102 | id: { 1103 | eq: ' . self::TAG_SMALL_ID_TO_BE_REPLACED . ' 1104 | } 1105 | } 1106 | } 1107 | ) { 1108 | nodes { 1109 | title 1110 | content 1111 | } 1112 | } 1113 | }', 1114 | '{"data": { "posts": {"nodes" : [{"title": "cat" , "content" : "

this is a cat

\n"}]}}}', 1115 | ], 1116 | 'posts_accept_valid_tax_filter_args' => [ 1117 | 'query { 1118 | posts( 1119 | filter: { 1120 | category: { 1121 | id: { 1122 | eq: 10 1123 | }, 1124 | name: { 1125 | eq: "foo" 1126 | } 1127 | }, 1128 | tag: { 1129 | name: { 1130 | in: ["foo", "bar"], 1131 | like: "tst" 1132 | } 1133 | } 1134 | } 1135 | ) { 1136 | nodes { 1137 | title 1138 | content 1139 | } 1140 | } 1141 | }', 1142 | '{"data": { "posts": {"nodes" : []}}}', 1143 | ], 1144 | 'pages_accept_valid_tax_filter_args' => [ 1145 | 'query { 1146 | pages( 1147 | filter: { 1148 | category: { 1149 | id: { 1150 | eq: 10 1151 | }, 1152 | name: { 1153 | eq: "foo" 1154 | } 1155 | }, 1156 | tag: { 1157 | name: { 1158 | in: ["foo", "bar"], 1159 | like: "tst" 1160 | } 1161 | } 1162 | } 1163 | ) { 1164 | nodes { 1165 | title 1166 | content 1167 | } 1168 | } 1169 | }', 1170 | '{"data": { "pages": {"nodes" : []}}}', 1171 | ], 1172 | 'zombies_accept_valid_tax_filter_args' => [ 1173 | 'query { 1174 | zombies( 1175 | filter: { 1176 | category: { 1177 | id: { 1178 | eq: 10 1179 | }, 1180 | name: { 1181 | eq: "foo" 1182 | } 1183 | }, 1184 | tag: { 1185 | name: { 1186 | in: ["foo", "bar"], 1187 | like: "tst" 1188 | } 1189 | } 1190 | } 1191 | ) { 1192 | nodes { 1193 | title 1194 | content 1195 | } 1196 | } 1197 | }', 1198 | '{"data": { "zombies": {"nodes" : []}}}', 1199 | ], 1200 | 'posts_reject_invalid_tax_filter_args' => [ 1201 | 'query { 1202 | posts( 1203 | filter: { 1204 | category: { 1205 | id: { 1206 | eq: "10" 1207 | }, 1208 | name: { 1209 | eq: "foo" 1210 | } 1211 | }, 1212 | tag: { 1213 | name: { 1214 | in: ["foo", "bar"], 1215 | like: "tst" 1216 | } 1217 | } 1218 | } 1219 | ) { 1220 | nodes { 1221 | title 1222 | content 1223 | } 1224 | } 1225 | }', 1226 | '{"errors": null}', 1227 | ], 1228 | 'pages_reject_invalid_tax_filter_args' => [ 1229 | 'query { 1230 | pages( 1231 | filter: { 1232 | category: { 1233 | id: { 1234 | eq: "10" 1235 | }, 1236 | name: { 1237 | eq: "foo" 1238 | } 1239 | }, 1240 | tag: { 1241 | name: { 1242 | in: ["foo", "bar"], 1243 | like: "tst" 1244 | } 1245 | } 1246 | } 1247 | ) { 1248 | nodes { 1249 | title 1250 | content 1251 | } 1252 | } 1253 | }', 1254 | '{"errors": null}', 1255 | ], 1256 | 'non_filterable_types_reject_all_filter_args' => [ 1257 | 'query { 1258 | tags( 1259 | where: { 1260 | filter: { 1261 | category: { 1262 | id: { 1263 | eq: 10 1264 | }, 1265 | name: { 1266 | eq: "foo" 1267 | } 1268 | }, 1269 | tag: { 1270 | name: { 1271 | in: ["foo", "bar"], 1272 | like: "tst" 1273 | } 1274 | } 1275 | } 1276 | } 1277 | ) { 1278 | nodes { 1279 | slug 1280 | } 1281 | } 1282 | }', 1283 | '{"errors": null}', 1284 | ], 1285 | 'OR_with_one_condition_should_return_cat' => [ 1286 | 'query { 1287 | posts( 1288 | filter: { 1289 | or: [ 1290 | { tag: { name: { eq: "small" } } } 1291 | ] 1292 | } 1293 | ) { 1294 | nodes { 1295 | title 1296 | } 1297 | } 1298 | }', 1299 | '{"data": { "posts": {"nodes" : [{"title": "cat"}]}}}', 1300 | ], 1301 | 'OR_with_two_conditions_should_return_cat_and_dog' => [ 1302 | 'query { 1303 | posts( 1304 | filter: { 1305 | or: [ 1306 | { category: { name: { eq: "feline" } } } 1307 | { category: { name: { eq: "canine" } } } 1308 | ] 1309 | } 1310 | ) { 1311 | nodes { 1312 | title 1313 | } 1314 | } 1315 | }', 1316 | '{"data": { "posts": {"nodes" : [{"title": "dog" },{"title": "cat"}]}}}', 1317 | ], 1318 | 'OR_with_two_nested_AND_one_root_condition_should_return_cat' => [ 1319 | 'query { 1320 | posts( 1321 | filter: { 1322 | or: [ 1323 | { category: { name: { eq: "feline" } } } 1324 | { category: { name: { eq: "canine" } } } 1325 | ] 1326 | tag: { name: { eq: "small" } } 1327 | } 1328 | ) { 1329 | nodes { 1330 | title 1331 | } 1332 | } 1333 | }', 1334 | '{"data": { "posts": {"nodes" : [{"title": "cat"}]}}}', 1335 | ], 1336 | 'AND_with_one_condition_should_return_cat' => [ 1337 | 'query { 1338 | posts( 1339 | filter: { 1340 | and: [ 1341 | { tag: { name: { eq: "small" } } } 1342 | ] 1343 | } 1344 | ) { 1345 | nodes { 1346 | title 1347 | } 1348 | } 1349 | }', 1350 | '{"data": { "posts": {"nodes" : [{"title": "cat"}]}}}', 1351 | ], 1352 | 'AND_with_two_separate_conditions_should_return_cat_and_dog' => [ 1353 | 'query { 1354 | posts( 1355 | filter: { 1356 | and: [ 1357 | { tag: { name: { eq: "black" } } } 1358 | { category: { name: { eq: "animal" } } } 1359 | ] 1360 | } 1361 | ) { 1362 | nodes { 1363 | title 1364 | } 1365 | aggregations { 1366 | categories { 1367 | key 1368 | count 1369 | } 1370 | } 1371 | } 1372 | }', 1373 | '{"data": { "posts": {"nodes" : [{"title": "dog" },{"title": "cat"}], "aggregations" : { "categories" : [ { "key" : "animal", "count" : "2"}, { "key" : "canine", "count" : "1"}, { "key" : "feline", "count" : "1"}]}}}}', 1374 | ], 1375 | 'AND_with_one_nested_AND_one_root_condition_should_return_cat' => [ 1376 | 'query { 1377 | posts( 1378 | filter: { 1379 | and: [ 1380 | { category: { name: { eq: "feline" } } } 1381 | ] 1382 | tag: { name: { eq: "small" } } 1383 | } 1384 | ) { 1385 | nodes { 1386 | title 1387 | } 1388 | aggregations { 1389 | categories { 1390 | key 1391 | count 1392 | } 1393 | } 1394 | } 1395 | }', 1396 | '{"data": { "posts": {"aggregations" : { "categories" : [ { "key" : "animal", "count" : "1"}, { "key" : "feline", "count" : "1"}]}, "nodes" : [{"title": "cat"}]}}}', 1397 | ], 1398 | 'AND_OR_with_both_relations_should_return_error' => [ 1399 | 'query { 1400 | posts( 1401 | filter: { 1402 | or: [ 1403 | { tag: { name: { eq: "small" } } } 1404 | ] 1405 | and: [ 1406 | { category: { name: { eq: "feline" } } } 1407 | ] 1408 | } 1409 | ) { 1410 | nodes { 1411 | title 1412 | } 1413 | } 1414 | }', 1415 | '{"errors": null}', 1416 | ], 1417 | 'AND_with_no_children_should_return_error' => [ 1418 | 'query { 1419 | posts( 1420 | filter: { 1421 | and: [] 1422 | } 1423 | ) { 1424 | nodes { 1425 | title 1426 | } 1427 | } 1428 | }', 1429 | '{"errors": null}', 1430 | ], 1431 | 'OR_with_nesting_gt_10_should_return_error' => [ 1432 | 'query { 1433 | posts( 1434 | filter: { 1435 | or: [ 1436 | { 1437 | or: [ 1438 | { 1439 | or: [ 1440 | { 1441 | or: [ 1442 | { 1443 | or: [ 1444 | { 1445 | or: [ 1446 | { 1447 | or: [ 1448 | { 1449 | or: [ 1450 | { 1451 | or: [ 1452 | { 1453 | or: [ 1454 | { 1455 | tag: { 1456 | name: {eq: "small"} 1457 | } 1458 | }, 1459 | { 1460 | category: { 1461 | name: {eq: "feline"} 1462 | } 1463 | } 1464 | ] 1465 | } 1466 | ] 1467 | } 1468 | ] 1469 | } 1470 | ] 1471 | } 1472 | ] 1473 | } 1474 | ] 1475 | } 1476 | ] 1477 | } 1478 | ] 1479 | } 1480 | ] 1481 | }, 1482 | ] 1483 | } 1484 | ) { 1485 | nodes { 1486 | title 1487 | } 1488 | } 1489 | }', 1490 | '{"errors": null}', 1491 | ], 1492 | 'OR_with_nesting_lt_10_should_return_cat' => [ 1493 | 'query { 1494 | posts( 1495 | filter: { 1496 | or: [ 1497 | { 1498 | or: [ 1499 | { 1500 | or: [ 1501 | { 1502 | or: [ 1503 | { 1504 | or: [ 1505 | { 1506 | or: [ 1507 | { 1508 | or: [ 1509 | { 1510 | or: [ 1511 | { 1512 | or: [ 1513 | { 1514 | tag: { 1515 | name: {eq: "small"} 1516 | } 1517 | }, 1518 | { 1519 | category: { 1520 | name: {eq: "feline"} 1521 | } 1522 | } 1523 | ] 1524 | } 1525 | ] 1526 | } 1527 | ] 1528 | } 1529 | ] 1530 | } 1531 | ] 1532 | } 1533 | ] 1534 | } 1535 | ] 1536 | } 1537 | ] 1538 | }, 1539 | ] 1540 | } 1541 | ) { 1542 | nodes { 1543 | title 1544 | } 1545 | } 1546 | }', 1547 | '{"data": { "posts": {"nodes" : [{"title": "cat"}]}}}', 1548 | ], 1549 | 'OR_nested_with_one_root_AND_condition_should_return_cat' => [ 1550 | 'query { 1551 | posts( 1552 | filter: { 1553 | tag: { name: { eq: "small" } } 1554 | or: [ 1555 | { 1556 | or: [ 1557 | { 1558 | or: [ 1559 | { 1560 | or: [ 1561 | { 1562 | or: [ 1563 | { 1564 | or: [ 1565 | { 1566 | or: [ 1567 | { 1568 | or: [ 1569 | { 1570 | or: [ 1571 | { 1572 | category: { 1573 | name: {eq: "feline"} 1574 | } 1575 | } 1576 | ] 1577 | } 1578 | ] 1579 | } 1580 | ] 1581 | } 1582 | ] 1583 | } 1584 | ] 1585 | } 1586 | ] 1587 | } 1588 | ] 1589 | } 1590 | ] 1591 | }, 1592 | ] 1593 | } 1594 | ) { 1595 | nodes { 1596 | title 1597 | } 1598 | } 1599 | }', 1600 | '{"data": { "posts": {"nodes" : [{"title": "cat"}]}}}', 1601 | ], 1602 | ); 1603 | } 1604 | } 1605 | -------------------------------------------------------------------------------- /tests/wp-graphql-filter-query.test.php: -------------------------------------------------------------------------------- 1 | array( 10 | 'name' => 'Zombies', 11 | ), 12 | 'public' => true, 13 | 'capability_type' => 'post', 14 | 'map_meta_cap' => false, 15 | /** WP GRAPHQL */ 16 | 'show_in_graphql' => true, 17 | 'hierarchical' => true, 18 | 'graphql_single_name' => 'zombie', 19 | 'graphql_plural_name' => 'zombies', 20 | ) 21 | ); 22 | 23 | register_post_type( 24 | 'vampire', 25 | array( 26 | 'labels' => array( 27 | 'name' => 'Vampires', 28 | ), 29 | 'public' => true, 30 | 'capability_type' => 'post', 31 | 'map_meta_cap' => false, 32 | /** WP GRAPHQL */ 33 | 'show_in_graphql' => true, 34 | 'hierarchical' => true, 35 | 'graphql_single_name' => 'vampire', 36 | 'graphql_plural_name' => 'vampires', 37 | ) 38 | ); 39 | 40 | register_post_type( 41 | 'rabbit', 42 | array( 43 | 'labels' => array( 44 | 'name' => 'Rabbits', 45 | ), 46 | 'public' => true, 47 | 'capability_type' => 'post', 48 | 'map_meta_cap' => false, 49 | /** WP GRAPHQL */ 50 | 'show_in_graphql' => false, 51 | 'hierarchical' => true, 52 | 'graphql_single_name' => 'rabbit', 53 | 'graphql_plural_name' => 'rabbits', 54 | ) 55 | ); 56 | } 57 | 58 | public function test_filter_query_get_supported_post_types() { 59 | $post_types = filter_query_get_supported_post_types(); 60 | $expected_types = [ 61 | 'post' => [ 62 | 'name' => 'post', 63 | 'capitalize_name' => 'Post', 64 | 'plural_name' => 'posts', 65 | ], 66 | 'page' => [ 67 | 'name' => 'page', 68 | 'capitalize_name' => 'Page', 69 | 'plural_name' => 'pages', 70 | ], 71 | 'zombie' => [ 72 | 'name' => 'zombie', 73 | 'capitalize_name' => 'Zombie', 74 | 'plural_name' => 'zombies', 75 | ], 76 | 'vampire' => [ 77 | 'name' => 'vampire', 78 | 'capitalize_name' => 'Vampire', 79 | 'plural_name' => 'vampires', 80 | ], 81 | ]; 82 | $this->assertEquals( $expected_types, $post_types ); 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /wp-graphql-filter-query.php: -------------------------------------------------------------------------------- 1 | true, 52 | '_builtin' => false, 53 | 'show_in_graphql' => true, 54 | ], 55 | 'names' 56 | ); 57 | 58 | $type_names = array_merge( $built_ins, $cpt_type_names ); 59 | 60 | foreach ( $type_names as $type_name ) { 61 | $type_objects[ $type_name ] = array( 62 | 'name' => $type_name, 63 | 'capitalize_name' => ucwords( $type_name ), 64 | 'plural_name' => strtolower( get_post_type_object( $type_name )->label ), 65 | ); 66 | } 67 | 68 | return $type_objects; 69 | } 70 | 71 | 72 | ( new FilterQuery() )->add_hooks(); 73 | ( new AggregateQuery() )->add_hooks(); 74 | --------------------------------------------------------------------------------