├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------